From b86335ddf134d24295c21acf3fd982bc89be74a7 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 5 Sep 2026 08:09:51 +0100 Subject: [PATCH 1/4] fix(maestro): guard structured web submits --- web/src/structured.test.ts | 125 +++++++++++++++++++++++++++++++++++++ web/src/structured.ts | 50 ++++++++++++--- 2 files changed, 165 insertions(+), 10 deletions(-) diff --git a/web/src/structured.test.ts b/web/src/structured.test.ts index e5faf7d..7e60ebd 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -147,6 +147,8 @@ describe("structured connection: turn/composer gating", () => { input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "do the thing"); expect(input.value).toBe(""); + ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); + ws.emit({ type: "reduced_state", state: idleState }); vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom")); input.value = "retry me"; @@ -158,6 +160,81 @@ describe("structured connection: turn/composer gating", () => { conn.dispose(); }); + it("disables immediately and dispatches only once before begin-turn is projected", () => { + vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {})); + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + ws.emit({ type: "reduced_state", state: idleState }); + const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; + const send = conn.el.querySelector(".structured-send") as HTMLButtonElement; + const form = conn.el.querySelector(".structured-composer") as HTMLFormElement; + input.value = "only once"; + + form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true })); + form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true })); + + expect(input.disabled).toBe(true); + expect(send.disabled).toBe(true); + expect(sendStructuredPrompt).toHaveBeenCalledTimes(1); + expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once"); + conn.dispose(); + }); + + it("does not overwrite a newer operator draft when the matching request fails", async () => { + let reject!: (error: unknown) => void; + vi.mocked(sendStructuredPrompt).mockImplementationOnce( + () => new Promise((_, rejectPromise) => { reject = rejectPromise; }), + ); + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + ws.emit({ type: "reduced_state", state: idleState }); + const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; + input.value = "first prompt"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); + ws.emit({ type: "reduced_state", state: idleState }); + input.value = "newer draft"; + + reject(new Error("late failure")); + await Promise.resolve().then(() => Promise.resolve()); + + expect(input.value).toBe("newer draft"); + expect(conn.el.textContent).toContain("late failure"); + conn.dispose(); + }); + + it("ignores an older failure after a newer submission generation starts", async () => { + let rejectFirst!: (error: unknown) => void; + vi.mocked(sendStructuredPrompt).mockImplementationOnce( + () => new Promise((_, rejectPromise) => { rejectFirst = rejectPromise; }), + ); + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + ws.emit({ type: "reduced_state", state: idleState }); + const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; + input.value = "first prompt"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); + ws.emit({ type: "reduced_state", state: idleState }); + input.value = "second prompt"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + + rejectFirst(new Error("stale failure")); + await Promise.resolve().then(() => Promise.resolve()); + + expect(sendStructuredPrompt).toHaveBeenCalledTimes(2); + expect(input.value).toBe(""); + expect(input.disabled).toBe(true); + expect(conn.el.textContent).not.toContain("stale failure"); + conn.dispose(); + }); + it("cancels the active turn via the cancel button", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); @@ -327,6 +404,54 @@ describe("structured connection: reconnection/error/loading", () => { conn.dispose(); }); + it("keeps a local submission gated across a reconnect until begin-turn arrives", () => { + vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {})); + const first = new FakeSocket(); + const second = new FakeSocket(); + const conn = mount({ sockets: [first, second], shouldReconnect: () => true }); + first.open(); + first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + first.emit({ type: "reduced_state", state: idleState }); + const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; + input.value = "survive reconnect"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + + first.drop(); + vi.advanceTimersByTime(1000); + second.open(); + second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + second.emit({ type: "reduced_state", state: idleState }); + expect(input.disabled).toBe(true); + + second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); + second.emit({ type: "reduced_state", state: idleState }); + expect(input.disabled).toBe(false); + conn.dispose(); + }); + + it("releases an accepted submission from an idle snapshot after reconnect", async () => { + vi.mocked(sendStructuredPrompt).mockResolvedValueOnce(undefined); + const first = new FakeSocket(); + const second = new FakeSocket(); + const conn = mount({ sockets: [first, second], shouldReconnect: () => true }); + first.open(); + first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + first.emit({ type: "reduced_state", state: idleState }); + const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; + input.value = "finishes while disconnected"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + await Promise.resolve().then(() => Promise.resolve()); + + first.drop(); + vi.advanceTimersByTime(1000); + second.open(); + second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + second.emit({ type: "reduced_state", state: idleState }); + + expect(input.disabled).toBe(false); + conn.dispose(); + }); + it("reports a daemon rejection reason and does not schedule a retry", () => { const only = new FakeSocket(); const conn = mount({ sockets: [only], shouldReconnect: () => true }); diff --git a/web/src/structured.ts b/web/src/structured.ts index 994c176..93f5736 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -109,6 +109,10 @@ export function createStructuredConnection( let state: ReducedState = { turn: idleTurn() }; let pendingApprovalRowId: string | null = null; let latestSession: Session | undefined = store?.snapshot().sessions.get(sessionId); + let submissionGeneration = 0; + let awaitingBeginGeneration: number | null = null; + let acceptedSubmission: { generation: number; socketGeneration: number } | null = null; + let socketGeneration = 0; function lastRowIdOfKind(kind: TranscriptRowKind): string | null { for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id; @@ -217,14 +221,23 @@ export function createStructuredConnection( } function renderComposerState(): void { - const active = state.turn.responding || state.turn.thinking; + const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null; input.disabled = active; sendBtn.disabled = active; - cancelBtn.hidden = !active; + cancelBtn.hidden = !state.turn.responding && !state.turn.thinking; } - function applyState(next: ReducedState): void { + function applyState(next: ReducedState, connectionGeneration: number): void { state = next; + if (state.turn.responding || state.turn.thinking) { + awaitingBeginGeneration = null; + } else if ( + awaitingBeginGeneration !== null + && acceptedSubmission?.generation === awaitingBeginGeneration + && connectionGeneration > acceptedSubmission.socketGeneration + ) { + awaitingBeginGeneration = null; + } const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null; if (newPendingId !== pendingApprovalRowId) { const prev = pendingApprovalRowId; @@ -268,14 +281,30 @@ export function createStructuredConnection( e.preventDefault(); const text = input.value; if (!text.trim() || input.disabled) return; + const generation = ++submissionGeneration; + awaitingBeginGeneration = generation; + acceptedSubmission = null; input.value = ""; autoResize(); clearError(); - sendStructuredPrompt(sessionId, text).catch((e: unknown) => { - input.value = text; - autoResize(); - showError(String(e)); - }); + renderComposerState(); + sendStructuredPrompt(sessionId, text).then( + () => { + if (generation === submissionGeneration) { + acceptedSubmission = { generation, socketGeneration }; + } + }, + (e: unknown) => { + if (generation !== submissionGeneration) return; + if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null; + if (input.value === "") { + input.value = text; + autoResize(); + } + showError(String(e)); + renderComposerState(); + }, + ); }); cancelBtn.addEventListener("click", () => { clearError(); @@ -303,6 +332,7 @@ export function createStructuredConnection( let disposed = false; function connect(): void { + const connectionGeneration = ++socketGeneration; ws = socketFactory(sessionId); ws.onopen = () => { reconnectMs = INITIAL_RECONNECT_MS; @@ -321,11 +351,11 @@ export function createStructuredConnection( applySnapshot(msg.snapshot); break; case "reduced_state": - applyState(msg.state); + applyState(msg.state, connectionGeneration); break; case "projection_update": applyDelta(msg.transcript); - applyState(msg.state); + applyState(msg.state, connectionGeneration); break; } }; From 8bd943a8cacc91230a920c1a0ce6a710b33b71b0 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 5 Sep 2026 08:16:14 +0100 Subject: [PATCH 2/4] fix(web): release accepted reconnect submit --- web/src/structured.test.ts | 29 +++++++++++++++++++++++++++++ web/src/structured.ts | 17 +++++++++++++---- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/web/src/structured.test.ts b/web/src/structured.test.ts index 7e60ebd..1ea3243 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -452,6 +452,35 @@ describe("structured connection: reconnection/error/loading", () => { conn.dispose(); }); + it("releases when reconnect idle arrives before submission acceptance", async () => { + let accept!: () => void; + vi.mocked(sendStructuredPrompt).mockImplementationOnce( + () => new Promise((resolve) => { accept = resolve; }), + ); + const first = new FakeSocket(); + const second = new FakeSocket(); + const conn = mount({ sockets: [first, second], shouldReconnect: () => true }); + first.open(); + first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + first.emit({ type: "reduced_state", state: idleState }); + const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; + input.value = "accepted after reconnect"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + + first.drop(); + vi.advanceTimersByTime(1000); + second.open(); + second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + second.emit({ type: "reduced_state", state: idleState }); + expect(input.disabled).toBe(true); + + accept(); + await Promise.resolve().then(() => Promise.resolve()); + + expect(input.disabled).toBe(false); + conn.dispose(); + }); + it("reports a daemon rejection reason and does not schedule a retry", () => { const only = new FakeSocket(); const conn = mount({ sockets: [only], shouldReconnect: () => true }); diff --git a/web/src/structured.ts b/web/src/structured.ts index 93f5736..0425dc6 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -113,6 +113,7 @@ export function createStructuredConnection( let awaitingBeginGeneration: number | null = null; let acceptedSubmission: { generation: number; socketGeneration: number } | null = null; let socketGeneration = 0; + let stateSocketGeneration = 0; function lastRowIdOfKind(kind: TranscriptRowKind): string | null { for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id; @@ -227,17 +228,22 @@ export function createStructuredConnection( cancelBtn.hidden = !state.turn.responding && !state.turn.thinking; } - function applyState(next: ReducedState, connectionGeneration: number): void { - state = next; + function reconcileAwaitingBegin(): void { if (state.turn.responding || state.turn.thinking) { awaitingBeginGeneration = null; } else if ( awaitingBeginGeneration !== null && acceptedSubmission?.generation === awaitingBeginGeneration - && connectionGeneration > acceptedSubmission.socketGeneration + && stateSocketGeneration > acceptedSubmission.socketGeneration ) { awaitingBeginGeneration = null; } + } + + function applyState(next: ReducedState, connectionGeneration: number): void { + state = next; + stateSocketGeneration = connectionGeneration; + reconcileAwaitingBegin(); const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null; if (newPendingId !== pendingApprovalRowId) { const prev = pendingApprovalRowId; @@ -282,6 +288,7 @@ export function createStructuredConnection( const text = input.value; if (!text.trim() || input.disabled) return; const generation = ++submissionGeneration; + const submissionSocketGeneration = socketGeneration; awaitingBeginGeneration = generation; acceptedSubmission = null; input.value = ""; @@ -291,7 +298,9 @@ export function createStructuredConnection( sendStructuredPrompt(sessionId, text).then( () => { if (generation === submissionGeneration) { - acceptedSubmission = { generation, socketGeneration }; + acceptedSubmission = { generation, socketGeneration: submissionSocketGeneration }; + reconcileAwaitingBegin(); + renderComposerState(); } }, (e: unknown) => { From b52a9085e9c6378b40ed0c9911ecf38236dfc316 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 5 Sep 2026 08:22:26 +0100 Subject: [PATCH 3/4] fix(web): require post-acceptance state --- web/src/structured.test.ts | 13 ++++++++++--- web/src/structured.ts | 26 +++++++++++++++++++++----- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/web/src/structured.test.ts b/web/src/structured.test.ts index 1ea3243..3a2adf9 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -429,7 +429,7 @@ describe("structured connection: reconnection/error/loading", () => { conn.dispose(); }); - it("releases an accepted submission from an idle snapshot after reconnect", async () => { + it("releases an accepted submission from a post-acceptance idle snapshot", async () => { vi.mocked(sendStructuredPrompt).mockResolvedValueOnce(undefined); const first = new FakeSocket(); const second = new FakeSocket(); @@ -452,14 +452,15 @@ describe("structured connection: reconnection/error/loading", () => { conn.dispose(); }); - it("releases when reconnect idle arrives before submission acceptance", async () => { + it("does not release from reconnect idle cached before submission acceptance", async () => { let accept!: () => void; vi.mocked(sendStructuredPrompt).mockImplementationOnce( () => new Promise((resolve) => { accept = resolve; }), ); const first = new FakeSocket(); const second = new FakeSocket(); - const conn = mount({ sockets: [first, second], shouldReconnect: () => true }); + const third = new FakeSocket(); + const conn = mount({ sockets: [first, second, third], shouldReconnect: () => true }); first.open(); first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); first.emit({ type: "reduced_state", state: idleState }); @@ -477,6 +478,12 @@ describe("structured connection: reconnection/error/loading", () => { accept(); await Promise.resolve().then(() => Promise.resolve()); + expect(input.disabled).toBe(true); + expect(second.closed).toBe(true); + + third.open(); + third.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + third.emit({ type: "reduced_state", state: idleState }); expect(input.disabled).toBe(false); conn.dispose(); }); diff --git a/web/src/structured.ts b/web/src/structured.ts index 0425dc6..6b8b021 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -297,11 +297,12 @@ export function createStructuredConnection( renderComposerState(); sendStructuredPrompt(sessionId, text).then( () => { - if (generation === submissionGeneration) { - acceptedSubmission = { generation, socketGeneration: submissionSocketGeneration }; - reconcileAwaitingBegin(); - renderComposerState(); - } + if (generation !== submissionGeneration || awaitingBeginGeneration !== generation) return; + const acceptanceSocketGeneration = socketGeneration; + acceptedSubmission = { generation, socketGeneration: acceptanceSocketGeneration }; + // A replacement socket may have captured idle before the POST claimed + // the prompt. Reconnect again so its snapshot cannot predate the 204. + if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation(); }, (e: unknown) => { if (generation !== submissionGeneration) return; @@ -340,6 +341,21 @@ export function createStructuredConnection( let reconnectTimer: number | null = null; let disposed = false; + function reconnectForReconciliation(): void { + const stale = ws; + stale.onopen = null; + stale.onmessage = null; + stale.onerror = null; + stale.onclose = null; + try { + stale.close(); + } catch { + // ignore + } + setConnState("reconnecting…", "connecting"); + connect(); + } + function connect(): void { const connectionGeneration = ++socketGeneration; ws = socketFactory(sessionId); From 25344abe4ff0e102a361c6cf91698deac9a52d26 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 5 Sep 2026 08:29:48 +0100 Subject: [PATCH 4/4] fix(web): close stale structured sockets --- web/src/structured.test.ts | 88 ++++++++++++++++++++++++++++++++++++++ web/src/structured.ts | 83 +++++++++++++++++++++++------------ 2 files changed, 144 insertions(+), 27 deletions(-) diff --git a/web/src/structured.test.ts b/web/src/structured.test.ts index 3a2adf9..7873a0b 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -235,6 +235,28 @@ describe("structured connection: turn/composer gating", () => { conn.dispose(); }); + it("ignores a submission failure after disposal", async () => { + let reject!: (error: unknown) => void; + vi.mocked(sendStructuredPrompt).mockImplementationOnce( + () => new Promise((_, rejectPromise) => { reject = rejectPromise; }), + ); + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ type: "reduced_state", state: idleState }); + const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; + input.value = "do not restore"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + + conn.dispose(); + input.value = "detached draft"; + reject(new Error("late failure")); + await Promise.resolve().then(() => Promise.resolve()); + + expect(input.value).toBe("detached draft"); + expect(conn.el.querySelector(".structured-error")).toBeNull(); + }); + it("cancels the active turn via the cancel button", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); @@ -378,6 +400,8 @@ describe("structured connection: reconnection/error/loading", () => { vi.useFakeTimers(); }); + afterEach(() => vi.useRealTimers()); + it("shows connecting, then connected, then reconnecting with backoff, and rebuilds from the fresh snapshot", () => { const first = new FakeSocket(); const second = new FakeSocket(); @@ -488,6 +512,70 @@ describe("structured connection: reconnection/error/loading", () => { conn.dispose(); }); + it("cancels pending backoff when acceptance forces reconciliation", async () => { + let accept!: () => void; + vi.mocked(sendStructuredPrompt).mockImplementationOnce( + () => new Promise((resolve) => { accept = resolve; }), + ); + const first = new FakeSocket(); + const second = new FakeSocket(); + const reconciliation = new FakeSocket(); + const duplicate = new FakeSocket(); + const conn = mount({ + sockets: [first, second, reconciliation, duplicate], + shouldReconnect: () => true, + }); + first.open(); + first.emit({ type: "reduced_state", state: idleState }); + const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; + input.value = "accepted during backoff"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + + first.drop(); + vi.advanceTimersByTime(1000); + second.drop(); + accept(); + await Promise.resolve().then(() => Promise.resolve()); + + expect(reconciliation.onopen).not.toBeNull(); + vi.advanceTimersByTime(60_000); + expect(duplicate.onopen).toBeNull(); + + reconciliation.open(); + reconciliation.emit({ type: "reduced_state", state: idleState }); + expect(input.disabled).toBe(false); + conn.dispose(); + expect(reconciliation.closed).toBe(true); + }); + + it("does not reconcile when submission acceptance arrives after disposal", async () => { + let accept!: () => void; + vi.mocked(sendStructuredPrompt).mockImplementationOnce( + () => new Promise((resolve) => { accept = resolve; }), + ); + const first = new FakeSocket(); + const second = new FakeSocket(); + const late = new FakeSocket(); + const conn = mount({ sockets: [first, second, late], shouldReconnect: () => true }); + first.open(); + first.emit({ type: "reduced_state", state: idleState }); + const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; + input.value = "accepted after disposal"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + + first.drop(); + vi.advanceTimersByTime(1000); + second.open(); + second.emit({ type: "reduced_state", state: idleState }); + conn.dispose(); + accept(); + await Promise.resolve().then(() => Promise.resolve()); + vi.advanceTimersByTime(60_000); + + expect(second.closed).toBe(true); + expect(late.onopen).toBeNull(); + }); + it("reports a daemon rejection reason and does not schedule a retry", () => { const only = new FakeSocket(); const conn = mount({ sockets: [only], shouldReconnect: () => true }); diff --git a/web/src/structured.ts b/web/src/structured.ts index 6b8b021..101af49 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -114,6 +114,7 @@ export function createStructuredConnection( let acceptedSubmission: { generation: number; socketGeneration: number } | null = null; let socketGeneration = 0; let stateSocketGeneration = 0; + let disposed = false; function lastRowIdOfKind(kind: TranscriptRowKind): string | null { for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id; @@ -143,7 +144,7 @@ export function createStructuredConnection( // request was in flight (superseded, cancelled, answered elsewhere), // the state-driven re-render already replaced the row; reviving // buttons on it would be reviving a decision that no longer applies. - if (rowId === pendingApprovalRowId) { + if (!disposed && rowId === pendingApprovalRowId) { showError(String(e)); throw e; } @@ -297,7 +298,11 @@ export function createStructuredConnection( renderComposerState(); sendStructuredPrompt(sessionId, text).then( () => { - if (generation !== submissionGeneration || awaitingBeginGeneration !== generation) return; + if ( + disposed + || generation !== submissionGeneration + || awaitingBeginGeneration !== generation + ) return; const acceptanceSocketGeneration = socketGeneration; acceptedSubmission = { generation, socketGeneration: acceptanceSocketGeneration }; // A replacement socket may have captured idle before the POST claimed @@ -305,7 +310,7 @@ export function createStructuredConnection( if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation(); }, (e: unknown) => { - if (generation !== submissionGeneration) return; + if (disposed || generation !== submissionGeneration) return; if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null; if (input.value === "") { input.value = text; @@ -318,7 +323,9 @@ export function createStructuredConnection( }); cancelBtn.addEventListener("click", () => { clearError(); - cancelStructuredTurn(sessionId).catch((e: unknown) => showError(String(e))); + cancelStructuredTurn(sessionId).catch((e: unknown) => { + if (!disposed) showError(String(e)); + }); }); // ---- narrow-pane rail collapse (a split pane, not just the viewport) ---- @@ -331,39 +338,59 @@ export function createStructuredConnection( // ---- live worktree/git-status rail data ---- const unsubscribeStore = store?.subscribe((catalog: CatalogState) => { + if (disposed) return; latestSession = catalog.sessions.get(sessionId); renderRailNow(); }); // ---- socket lifecycle ---- - let ws: WebSocket; + let ws: WebSocket | null = null; let reconnectMs = INITIAL_RECONNECT_MS; let reconnectTimer: number | null = null; - let disposed = false; - function reconnectForReconciliation(): void { - const stale = ws; - stale.onopen = null; - stale.onmessage = null; - stale.onerror = null; - stale.onclose = null; + function cancelReconnect(): void { + if (reconnectTimer === null) return; + window.clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + function closeCurrentSocket(): void { + const socket = ws; + ws = null; + if (!socket) return; + socket.onopen = null; + socket.onmessage = null; + socket.onerror = null; + socket.onclose = null; try { - stale.close(); + socket.close(); } catch { // ignore } + } + + function reconnectForReconciliation(): void { + if (disposed) return; + cancelReconnect(); + closeCurrentSocket(); setConnState("reconnecting…", "connecting"); connect(); } function connect(): void { + if (disposed) return; + cancelReconnect(); + closeCurrentSocket(); const connectionGeneration = ++socketGeneration; - ws = socketFactory(sessionId); - ws.onopen = () => { + const socket = socketFactory(sessionId); + ws = socket; + socket.onopen = () => { + if (disposed || ws !== socket) return; reconnectMs = INITIAL_RECONNECT_MS; setConnState("connected", "open"); }; - ws.onmessage = (ev) => { + socket.onmessage = (ev) => { + if (disposed || ws !== socket) return; if (typeof ev.data !== "string") return; let msg: StructuredEvent; try { @@ -384,20 +411,25 @@ export function createStructuredConnection( break; } }; - ws.onerror = () => setConnState("error", "error"); - ws.onclose = (ev) => { + socket.onerror = () => { + if (disposed || ws !== socket) return; + setConnState("error", "error"); + }; + socket.onclose = (ev) => { + if (disposed || ws !== socket) return; + ws = null; const reason = ev.reason?.trim(); - if (ev.code >= 4000 && reason && !disposed) { + if (ev.code >= 4000 && reason) { setConnState(reason, "error"); showError(`cannot attach: ${reason}`); return; } - if (disposed) return; if (opts.shouldReconnect?.(sessionId, ev.code)) { const delay = Math.min(reconnectMs, MAX_RECONNECT_MS); setConnState("reconnecting…", "connecting"); reconnectTimer = window.setTimeout(() => { reconnectTimer = null; + if (disposed) return; reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS); connect(); }, delay); @@ -423,7 +455,7 @@ export function createStructuredConnection( const focusOnMount = mountOpts?.focus !== false; const token = ++mountToken; requestAnimationFrame(() => { - if (token !== mountToken) return; // parked/remounted since this was scheduled + if (disposed || token !== mountToken) return; // parked/remounted since this was scheduled if (focusOnMount && !input.disabled) input.focus(); }); }, @@ -440,14 +472,11 @@ export function createStructuredConnection( dispose() { if (disposed) return; disposed = true; - if (reconnectTimer !== null) window.clearTimeout(reconnectTimer); + mountToken++; + cancelReconnect(); resizeObserver.disconnect(); unsubscribeStore?.(); - try { - ws.close(); - } catch { - // ignore - } + closeCurrentSocket(); el.remove(); }, };