From 90dd3147986973c8455ea63edc3acd3d1462acab Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 22 Aug 2026 08:44:41 +0900 Subject: [PATCH] fix(cursor): close HTTP/2 after turnEnded so a held-open response cannot stall the turn T03 (senpi #1062): after the server sends turnEnded, the application turn is complete. A server that keeps the HTTP/2 stream open past this point cannot hold the turn hostage until a 300s bridge idle timeout. Close our side after a 500ms grace so trailing frames (late usage, checkpoint) still land before we release the socket. The grace timer only checks expectedClose (client-tool suspend cancel); emittedTerminal is deliberately not checked because finalizeTurnEvents sets it synchronously during turnEnded mapping, before the timer fires. The client-tool suspend path (live-transport.ts:203-206) intentionally ends without waiting for turnEnded and is not affected by this change. --- src/adapters/cursor/live-transport.ts | 49 +++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index 83658837bf..f610e1a09e 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -91,6 +91,12 @@ const CURSOR_RUN_PATH = "/agent.v1.AgentService/Run"; const CURSOR_CLIENT_VERSION = "cli-2026.07.08-0c04a8a"; const HEARTBEAT_MS = 5_000; const CURSOR_FIRST_FRAME_TIMEOUT_MS = 30_000; +/** + * After `turnEnded` is decoded, the application turn is complete. A server that keeps + * HTTP/2 open past this point cannot hold the turn hostage (senpi #1062): we close our side + * after a short grace so any trailing frames (late usage, checkpoint) still land. + */ +const TURN_ENDED_CLOSE_GRACE_MS = 500; const CURSOR_TIMEOUT_DESTROY_GRACE_MS = 1_000; const CLIENT_TOOL_FINALIZE_GRACE_MS = 50; const GENERIC_TOOL_COUNT_MIN_FINALIZE_GRACE_MS = 750; @@ -414,6 +420,7 @@ class LiveCursorTransport implements CursorTransport { private http1Connection?: CursorHttp1BidiConnection; private heartbeat?: ReturnType; private firstFrameTimer?: ReturnType; + private turnEndedCloseTimer?: ReturnType; private committed = false; private expectedClose = false; /** @@ -759,6 +766,7 @@ class LiveCursorTransport implements CursorTransport { async close(): Promise { if (this.heartbeat) clearInterval(this.heartbeat); + if (this.turnEndedCloseTimer) clearTimeout(this.turnEndedCloseTimer); this.clearPendingFinalize(); this.clearFirstFrameTimer(); this.stream?.close(); @@ -793,6 +801,41 @@ class LiveCursorTransport implements CursorTransport { void this.startShellCleanup().catch(() => { /* close() observes the same cleanup promise */ }); } + /** + * T03 (#1062): after the server sends `turnEnded`, the application turn is complete. + * A server that keeps the HTTP/2 stream open past this point cannot hold the turn + * hostage until a 300s bridge idle timeout. Close our side after a short grace so any + * trailing frames (late usage, checkpoint) still land before we release the socket. + */ + private closeAfterTurnEnded(): void { + if (this.turnEndedCloseTimer) return; + this.turnEndedCloseTimer = setTimeout(() => { + this.turnEndedCloseTimer = undefined; + // Only expectedClose (client-tool suspend cancel) blocks the close. + // emittedTerminal is intentionally NOT checked here: finalizeTurnEvents sets it + // synchronously during turnEnded mapping, ~500ms before this timer fires, so + // checking it would make the close unreachable on every real path (the exact + // scenario this PR exists to fix — senpi #1062). + if (this.expectedClose) return; + debugProviderDiagnostic("cursor", "turn-ended-close", { + committed: this.committed, + framesReceived: this.framesReceived, + }); + this.expectedClose = true; + this.clearFirstFrameTimer(); + if (this.heartbeat) clearInterval(this.heartbeat); + if (this.http1Connection) { + this.http1Connection.close(); + } else { + try { + this.stream?.close(); + } catch { + this.stream?.destroy(); + } + } + }, TURN_ENDED_CLOSE_GRACE_MS); + } + private releaseBlobRequestScope(): void { const scope = this.blobRequestScope; if (!scope) return; @@ -1273,6 +1316,12 @@ class LiveCursorTransport implements CursorTransport { // A completion may carry only callId. Capture its ownership before mapping removes the open // call, because the embedded-tool classifier cannot identify that valid compact frame. const update = message.message.case === "interactionUpdate" ? message.message.value.message : undefined; + if (update?.case === "turnEnded") { + // T03: the application turn is complete. Close our side of HTTP/2 after a short + // grace so a held-open server response cannot pin the turn to the bridge's idle + // timeout (senpi #1062). finalizeTurnEvents already emitted done via the mapper. + this.closeAfterTurnEnded(); + } const completesOpenClientTool = update?.case === "toolCallCompleted" && state.openToolCalls.has(update.value.callId); const awaitedNativeArgsBeforeMapping = update?.case === "toolCallCompleted"