8a876fca45c7824a50cd74f01ea11e0bab7964a2

Author
célina <hanouticelina@gmail.com>
Committer
GitHub <noreply@github.com>
Date

Message

fix: stop using a stale ctx after session replacement (#25)

The fire-and-forget /props discovery captures the session ctx and keeps
using it after awaiting the fetch and SSE stream. When the session is
replaced mid-flight (e.g. new_session in RPC mode) the ctx is invalidated,
so every later access throws and crashes the extension.

Treat the stale-context error as "session gone" and stop quietly, and
abort the in-flight /props request and SSE stream on session_shutdown.

Fixes #23

Diff

 1diff --git a/index.ts b/index.ts
 2index 9ebc7f44f6b35b249077a0589199fe9f0536dc55..fd5e90c88f54b57f01e84db4b00fd864ac05311f 100644
 3--- a/index.ts
 4+++ b/index.ts
 5@@ -130,6 +130,13 @@ function applyTemplateThinkingSupport(model: MutableModelMetadata): void {
 6 	};
 7 }
 8 
 9+// Pi invalidates a captured ctx when the session is replaced (e.g. new_session in
10+// RPC mode). Any later ctx access then throws this error. Background work started
11+// before the replacement should treat it as "session gone" and stop quietly.
12+function isStaleContextError(error: unknown): boolean {
13+	return error instanceof Error && error.message.includes("stale after session replacement");
14+}
15+
16 export default async function (pi: ExtensionAPI) {
17 	let currentModels: LlamaModel[] = [];
18 
19@@ -365,9 +372,13 @@ export default async function (pi: ExtensionAPI) {
20 				}
21 			}
22 		} catch (error) {
23-			// Suppress errors from intentionally-aborted SSE connections.
24+			// Suppress errors from intentionally-aborted SSE connections and from a
25+			// stale ctx (session was replaced while streaming).
26 			const msg = (error as Error).message;
27-			if (signal.aborted && (error instanceof DOMException || msg === "terminated")) {
28+			if (
29+				isStaleContextError(error) ||
30+				(signal.aborted && (error instanceof DOMException || msg === "terminated"))
31+			) {
32 				return;
33 			}
34 			ctx?.ui.notify(`[llama-cpp] SSE error: ${msg}`, "warning");
35@@ -523,8 +534,9 @@ export default async function (pi: ExtensionAPI) {
36 			});
37 		} catch (error) {
38 			const err = error as Error;
39-			// Suppress notification for aborted requests (model was switched).
40-			if (err.name !== "AbortError") {
41+			// Suppress notification for aborted requests (model was switched) and for a
42+			// stale ctx (session was replaced while awaiting) — both are expected.
43+			if (err.name !== "AbortError" && !isStaleContextError(err)) {
44 				ctx?.ui.notify(`[llama-cpp] /props for ${modelId} failed: ${err.message}`, "error");
45 			}
46 		} finally {
47@@ -557,15 +569,25 @@ export default async function (pi: ExtensionAPI) {
48 
49 	// Discover /props for already-active models because re-selecting them does not emit model_select.
50 	pi.on("before_provider_request", (event, ctx) => {
51-		const modelId = (event.payload as { model?: unknown })?.model;
52-		if (typeof modelId === "string") {
53-			const activeModel =
54-				ctx.model?.provider === PROVIDER_ID && ctx.model.id === modelId ? ctx.model : undefined;
55-			void discoverModelMetadata(modelId, ctx, true, PROPS_TIMEOUT_MS, activeModel);
56+		try {
57+			const modelId = (event.payload as { model?: unknown })?.model;
58+			if (typeof modelId === "string") {
59+				const activeModel =
60+					ctx.model?.provider === PROVIDER_ID && ctx.model.id === modelId ? ctx.model : undefined;
61+				void discoverModelMetadata(modelId, ctx, true, PROPS_TIMEOUT_MS, activeModel);
62+			}
63+		} catch (error) {
64+			// Session was replaced as the request fired; nothing to discover.
65+			if (!isStaleContextError(error)) {
66+				throw error;
67+			}
68 		}
69 	});
70 
71 	pi.on("session_shutdown", () => {
72 		clearFooterStatusTimeout();
73+		// Stop in-flight /props and SSE so they don't resume against a stale ctx.
74+		propsAbortController?.abort();
75+		sseAbortController?.abort();
76 	});
77 }