fix(core): make "error" the canonical terminal status on a mid-session crash (#1490) (#1493)

* fix(core): make "error" the canonical terminal status on a mid-session crash (#1490)

On a real mid-session transport crash many SDK transports fire BOTH
`onclose` and `onerror` in a transport-dependent order. The `onclose`
guard (`if status !== "disconnected"`) let a trailing `onclose`
downgrade a crash's `"error"` status back to `"disconnected"`, so the
persistent terminal status differed by ordering:

- onerror-then-onclose → "disconnected"
- onclose-then-onerror → "error"

`onclose` now treats `"error"` as terminal (only `"connected"`/
`"connecting"` → `"disconnected"`), so `"error"` is the canonical resting
status in both orderings. It still emits the `disconnect` event exactly
once so session-teardown consumers fire identically either way.

Because the state managers gated list/message/subscription/task teardown
on `getStatus() === "disconnected"`, holding the status at `"error"`
would have left them stale in the onerror-first ordering — swapping one
ordering-dependence for another. Introduce `isTerminalStatus()`
(disconnected || error) and key every teardown guard off it so they
clear identically regardless of crash ordering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(core): single disconnect event when explicitly disconnecting from "error" (#1490 re-review)

Addresses the non-blocking observation from the PR review: explicitly
calling disconnect() from a held-"error" status could fire the
`disconnect` event twice. close() can trigger the transport's onclose
synchronously; onclose (holding status at "error") fired `disconnect`,
then disconnect()'s own guard saw status still != "disconnected" and
fired it again — a timing-dependence on whether the SDK closes sync.

Add a `disconnecting` ownership flag: onclose defers its status set +
`disconnect` event to disconnect() while it is set, so the
explicit-disconnect path emits exactly one `disconnect` regardless of
sync/async close. Crash-path onclose (flag unset) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(core): deterministically pin the sync-close disconnect double-fire (#1490 re-review)

The real-stdio test asserts the single-disconnect invariant but may not
exercise the sync-close branch (the one that double-fired) if the SDK
stdio transport closes asynchronously. Add a test that overrides the SDK
client's close() to invoke the transport's onclose synchronously,
forcing the exact timing the `disconnecting` flag guards. Verified it
asserts 2 (fails) without the flag and 1 (passes) with it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-06-15 10:15:29 -04:00 • committed by GitHub
commit dc31c28102
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 447 additions and 51 deletions

View file

@ -182,11 +182,24 @@ describe("ManagedPromptsState", () => {
expect(state.getPrompts()).toEqual([]);
});
it("statusChange to other (non-disconnected) values does not clear prompts", async () => {
it("statusChange to error clears prompts (error is terminal, #1490)", async () => {
client.setStatus("connected");
client.queuePromptPages({ prompts: [prompt("a")] });
await state.refresh();
expect(state.getPrompts()).toHaveLength(1);
const changePromise = waitForPromptsChange(state);
client.setStatus("error");
const next = await changePromise;
expect(next).toEqual([]);
expect(state.getPrompts()).toEqual([]);
});
it("statusChange to a non-terminal value (connecting) does not clear prompts", async () => {
client.setStatus("connected");
client.queuePromptPages({ prompts: [prompt("a")] });
await state.refresh();
client.setStatus("connecting");
expect(state.getPrompts().map((p) => p.name)).toEqual(["a"]);
});

View file

@ -136,11 +136,24 @@ describe("ManagedRequestorTasksState", () => {
expect(state.getTasks()).toEqual([]);
});
it("statusChange to other values does not clear tasks", async () => {
it("statusChange to error clears tasks (error is terminal, #1490)", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("t1")] });
await state.refresh();
expect(state.getTasks()).toHaveLength(1);
const changePromise = waitForChange(state);
client.setStatus("error");
const next = await changePromise;
expect(next).toEqual([]);
expect(state.getTasks()).toEqual([]);
});
it("statusChange to a non-terminal value (connecting) does not clear tasks", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("t1")] });
await state.refresh();
client.setStatus("connecting");
expect(state.getTasks().map((t) => t.taskId)).toEqual(["t1"]);
});

View file

@ -196,11 +196,24 @@ describe("ManagedResourceTemplatesState", () => {
expect(state.getResourceTemplates()).toEqual([]);
});
it("statusChange to other values does not clear templates", async () => {
it("statusChange to error clears templates (error is terminal, #1490)", async () => {
client.setStatus("connected");
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
await state.refresh();
expect(state.getResourceTemplates()).toHaveLength(1);
const changePromise = waitForChange(state);
client.setStatus("error");
const next = await changePromise;
expect(next).toEqual([]);
expect(state.getResourceTemplates()).toEqual([]);
});
it("statusChange to a non-terminal value (connecting) does not clear templates", async () => {
client.setStatus("connected");
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
await state.refresh();
client.setStatus("connecting");
expect(state.getResourceTemplates().map((t) => t.name)).toEqual(["a"]);
});

View file

@ -189,11 +189,24 @@ describe("ManagedResourcesState", () => {
expect(state.getResources()).toEqual([]);
});
it("statusChange to other (non-disconnected) values does not clear resources", async () => {
it("statusChange to error clears resources (error is terminal, #1490)", async () => {
client.setStatus("connected");
client.queueResourcePages({ resources: [resource("a://1")] });
await state.refresh();
expect(state.getResources()).toHaveLength(1);
const changePromise = waitForResourcesChange(state);
client.setStatus("error");
const next = await changePromise;
expect(next).toEqual([]);
expect(state.getResources()).toEqual([]);
});
it("statusChange to a non-terminal value (connecting) does not clear resources", async () => {
client.setStatus("connected");
client.queueResourcePages({ resources: [resource("a://1")] });
await state.refresh();
client.setStatus("connecting");
expect(state.getResources().map((r) => r.uri)).toEqual(["a://1"]);
});

View file

@ -242,11 +242,24 @@ describe("ManagedToolsState", () => {
expect(state.getTools()).toEqual([]);
});
it("statusChange to other (non-disconnected) values does not clear tools", async () => {
it("statusChange to error clears tools (error is terminal, #1490)", async () => {
client.setStatus("connected");
client.queueToolPages({ tools: [tool("a")] });
await state.refresh();
expect(state.getTools()).toHaveLength(1);
const changePromise = waitForToolsChange(state);
client.setStatus("error");
const next = await changePromise;
expect(next).toEqual([]);
expect(state.getTools()).toEqual([]);
});
it("statusChange to a non-terminal value (connecting) does not clear tools", async () => {
client.setStatus("connected");
client.queueToolPages({ tools: [tool("a")] });
await state.refresh();
client.setStatus("connecting");
expect(state.getTools().map((t) => t.name)).toEqual(["a"]);
});

View file

@ -177,9 +177,20 @@ describe("MessageLogState", () => {
expect(dispatched).toBe(true);
});
it("does not clear on non-disconnected status changes", () => {
it("clears on a mid-session crash (statusChange -> error, #1490)", () => {
client.setStatus("connected");
client.dispatchTypedEvent("message", notificationEntry());
expect(state.getMessages()).toHaveLength(1);
let dispatched = false;
state.addEventListener("messagesChange", () => (dispatched = true));
client.setStatus("error");
expect(state.getMessages()).toEqual([]);
expect(dispatched).toBe(true);
});
it("does not clear on a non-terminal status change (connecting)", () => {
client.dispatchTypedEvent("message", notificationEntry());
client.setStatus("connecting");
expect(state.getMessages()).toHaveLength(1);
});

View file

@ -81,11 +81,21 @@ describe("PagedPromptsState", () => {
expect(await changePromise).toEqual([]);
});
it("statusChange to non-disconnected values is a no-op", async () => {
it("statusChange to error clears prompts (error is terminal, #1490)", async () => {
client.setStatus("connected");
client.queuePromptPages({ prompts: [prompt("a")] });
await state.loadPage();
const changePromise = waitForChange(state);
client.setStatus("error");
expect(await changePromise).toEqual([]);
expect(state.getPrompts()).toEqual([]);
});
it("statusChange to a non-terminal value (connecting) is a no-op", async () => {
client.setStatus("connected");
client.queuePromptPages({ prompts: [prompt("a")] });
await state.loadPage();
client.setStatus("connecting");
expect(state.getPrompts().map((p) => p.name)).toEqual(["a"]);
});

View file

@ -148,6 +148,16 @@ describe("PagedRequestorTasksState", () => {
expect(state.getNextCursor()).toBeUndefined();
});
it("statusChange to error clears tasks and nextCursor (error is terminal, #1490)", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("t1")], nextCursor: "c1" });
await state.loadPage();
const changePromise = waitForChange(state);
client.setStatus("error");
expect(await changePromise).toEqual([]);
expect(state.getNextCursor()).toBeUndefined();
});
it("destroy stops listening and clears state", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("t1")] });

View file

@ -89,11 +89,21 @@ describe("PagedResourceTemplatesState", () => {
expect(await changePromise).toEqual([]);
});
it("statusChange to non-disconnected values is a no-op", async () => {
it("statusChange to error clears templates (error is terminal, #1490)", async () => {
client.setStatus("connected");
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
await state.loadPage();
const changePromise = waitForChange(state);
client.setStatus("error");
expect(await changePromise).toEqual([]);
expect(state.getResourceTemplates()).toEqual([]);
});
it("statusChange to a non-terminal value (connecting) is a no-op", async () => {
client.setStatus("connected");
client.queueResourceTemplatePages({ resourceTemplates: [template("a")] });
await state.loadPage();
client.setStatus("connecting");
expect(state.getResourceTemplates().map((t) => t.name)).toEqual(["a"]);
});

View file

@ -83,11 +83,21 @@ describe("PagedResourcesState", () => {
expect(await changePromise).toEqual([]);
});
it("statusChange to non-disconnected values is a no-op", async () => {
it("statusChange to error clears resources (error is terminal, #1490)", async () => {
client.setStatus("connected");
client.queueResourcePages({ resources: [resource("a://1")] });
await state.loadPage();
const changePromise = waitForChange(state);
client.setStatus("error");
expect(await changePromise).toEqual([]);
expect(state.getResources()).toEqual([]);
});
it("statusChange to a non-terminal value (connecting) is a no-op", async () => {
client.setStatus("connected");
client.queueResourcePages({ resources: [resource("a://1")] });
await state.loadPage();
client.setStatus("connecting");
expect(state.getResources().map((r) => r.uri)).toEqual(["a://1"]);
});

View file

@ -77,11 +77,21 @@ describe("PagedToolsState", () => {
expect(state.getTools()).toEqual([]);
});
it("statusChange to non-disconnected values is a no-op", async () => {
it("statusChange to error clears tools (error is terminal, #1490)", async () => {
client.setStatus("connected");
client.queueToolPages({ tools: [tool("a")] });
await state.loadPage();
const changePromise = waitForChange(state);
client.setStatus("error");
expect(await changePromise).toEqual([]);
expect(state.getTools()).toEqual([]);
});
it("statusChange to a non-terminal value (connecting) is a no-op", async () => {
client.setStatus("connected");
client.queueToolPages({ tools: [tool("a")] });
await state.loadPage();
client.setStatus("connecting");
expect(state.getTools().map((t) => t.name)).toEqual(["a"]);
});

View file

@ -191,10 +191,22 @@ describe("ResourceSubscriptionsState", () => {
expect(state.getSubscriptions()).toEqual([]);
});
it("does not clear subscriptions on non-disconnected status changes", () => {
it("clears subscriptions on a mid-session crash (statusChange to error, #1490)", async () => {
const state = new ResourceSubscriptionsState(client);
client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]);
expect(state.getSubscriptions()).toHaveLength(1);
const changePromise = waitForSubscriptionsChange(state);
client.setStatus("error");
const next = await changePromise;
expect(next).toEqual([]);
expect(state.getSubscriptions()).toEqual([]);
});
it("does not clear subscriptions on a non-terminal status change (connecting)", () => {
const state = new ResourceSubscriptionsState(client);
client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]);
client.setStatus("connecting");
expect(state.getSubscriptions()).toHaveLength(1);
});