From 92803bc6c32e4d3aa2a919b0565c34f7d7d3fdbe Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:23:52 -0700 Subject: [PATCH 01/22] chore(proxy): restore controlled baseline and preserve overlay evidence The working tree carried uncommitted, untested speculative edits: bridge timeout bumps (30s->60s, 120s->180s), a 90s raw-byte proxy watchdog, a stderr pipe plus drain loop, guarded destroys/closes, and a restructured bridge-close branch. Analysis shows the timeout bumps and raw-byte watchdog address no identified hang mechanism. The bridge idle timer resets on every stdin write and H2 frame, so the proxy's 5s client heartbeat defeats it; the 90s watchdog resets on any inbound byte, so heartbeat/checkpoint trickle keeps a stalled request alive while it can still kill a legitimately slow model that thinks silently for over 90s. Keep only the load-bearing or harmless hunks: continuous stderr draining (an undrained child pipe can stall the child), guarded destroy/close calls, and bridge error passthrough for diagnosis. Restore the original timeouts, watchdog-free onData path, and bridge-close branch shape. The complete pre-disposition diff is preserved verbatim at docs/overlay-baseline.patch so nothing is silently discarded. --- docs/cursor-hang-root-cause.md | 127 +++++++++++++++++++++ docs/overlay-baseline.patch | 195 +++++++++++++++++++++++++++++++++ src/h2-bridge.mjs | 12 +- src/proxy.ts | 16 ++- 4 files changed, 345 insertions(+), 5 deletions(-) create mode 100644 docs/cursor-hang-root-cause.md create mode 100644 docs/overlay-baseline.patch diff --git a/docs/cursor-hang-root-cause.md b/docs/cursor-hang-root-cause.md new file mode 100644 index 0000000..f284689 --- /dev/null +++ b/docs/cursor-hang-root-cause.md @@ -0,0 +1,127 @@ +# Cursor Model Hang — Root Cause Analysis + +Status: **Evidence baseline established.** This document opens the hang investigation. It +preserves the speculative uncommitted overlay that predated this investigation, classifies +every hunk with an explicit disposition, and records baseline-vs-overlay behavior so that no +user work is silently discarded and no speculative change is silently accepted as "the fix." + +## Grounding fact: nothing upstream rescues a stuck request + +OpenCode configures the OpenAI-compatible provider with **no timeout of any kind** — no +header timeout, no chunk timeout, no overall request timeout (`timeout: false` is forced +into the fetch; `headerTimeout` is not set for this provider class). Consequences: + +- A request that stalls before response headers is a true hang — no error is ever surfaced. +- A streaming SSE response that never terminates is a true hang — OpenCode accepts a + truncated body as `other` and keeps waiting. +- The plugin proxy owns the entire hang-bounding and termination contract. Any hunk that + merely lengthens a bound or resets on activity without establishing a terminal guarantee + does not fix a hang; it postpones the failure. + +## Overlay provenance + +Before this investigation began, the working tree carried **uncommitted, untested** +speculative edits authored outside any controlled process: + +- `src/proxy.ts` — +81/-13 (timeout bumps in `src/h2-bridge.mjs` were expected to interact; + proxy-side watchdog added) +- `src/h2-bridge.mjs` — +14/-6 (timeout bumps, defensive guards, stderr passthrough) + +The complete, verbatim pre-disposition diff is preserved in +[`docs/overlay-baseline.patch`](./overlay-baseline.patch). Every line removed below remains +recoverable and reviewable from that artifact. Nothing was silently discarded. + +## Hunk inventory and disposition + +| # | File | Location | Change | Apparent intent | Hang mechanism affected | Verdict | +|---|---|---|---|---|---|---| +| H2-1 | `src/h2-bridge.mjs` | initial `setTimeout` | 30s → 60s + "peak load" comment | Give a slow server more time to accept the connection | None identified. Only postpones the error surfaced when the initial connect stalls; a stalled connect still hangs for 60s instead of 30s before the bridge self-terminates. | **Drop** (restore 30s) | +| H2-2 | `src/h2-bridge.mjs` | `resetTimeout` | 120s → 180s + "Grok 4.5" comment | Avoid killing a slow model during long thinking/tool pauses | None effective. This idle timer resets on **every** H2 data frame **and** every stdin write — the proxy writes a `ClientHeartbeat` every 5s, so heartbeat traffic defeats it indefinitely. It can never bound the leading unhandled-message-trickle hang. | **Drop** (restore 120s) | +| H2-3 | `src/h2-bridge.mjs` | `killBridge` | `client.destroy()` → `try { client.destroy(); } catch {}` | Defensive: a throw must not mask the `process.exit(1)` | None (defensive only). Harmless and correct; keeps the terminal exit unconditional. | **Keep** | +| H2-4 | `src/h2-bridge.mjs` | `client.on("error")` | `() =>` → `(err)` + write `err.message` to stderr | Surface silently-swallowed client errors to the parent | None directly (exit behavior unchanged), but it is **load-bearing for diagnosis**: with `stderr: "ignore"` these errors were invisible. Emission must become environment-gated, structured, and redacted. | **Keep, then rework** (redaction/diagnostics pass) | +| H2-5 | `src/h2-bridge.mjs` | `h2Stream.on("error")` | `() =>` → `(err)` + stderr write + `try { client.close(); } catch {}` | Surface stream errors; guard the close against throws | None directly (exit behavior unchanged); the guarded close is a correct defensive improvement. | **Keep** (guarded close) / **Keep, then rework** (stderr emission) | +| P-1 | `src/proxy.ts` | `spawnBridge` | `stderr: "ignore"` → `stderr: "pipe"` + continuous parent drain loop | Stop swallowing bridge errors; keep the child unblocked | **Load-bearing for backpressure**: a child writing to an undrained stderr pipe can block on the pipe and stall the whole request. The drain makes the pipe safe. Emission is unredacted today; must become gated/structured/redacted. | **Keep, then rework** | +| P-2 | `src/proxy.ts` | `Bun.serve` `idleTimeout` comment | Comment-only edit ("Grok 4.5" wording) | Cosmetic | None. | **Drop** (restore original comment) | +| P-3 | `src/proxy.ts` | `createBridgeStreamResponse` | 90s raw-byte watchdog (`setInterval` poll, `lastDataTime`, wrapped `processChunk` resetting on **any** inbound data) + timeout error/stop/done sequence | Force-close SSE after 90s without data; catch silent H2 close, silent disconnect, model stall | **Defeated by its own input.** It resets on any inbound byte, so server heartbeat/checkpoint/query trickle keeps a stalled request alive indefinitely. It can also kill a *legitimately slow* model that thinks silently for >90s (the exact Grok 4.5 behavior the overlay author worried about). Raw-byte activity must never defer termination forever, and slow-but-progressing traffic must never be terminated — this watchdog fails both sides of the requirement. | **Drop** (replaced by a semantic-progress/transport-liveness detector in the hardening pass) | +| P-4 | `src/proxy.ts` | `bridge.onClose` | `clearInterval(watchdog)` added | Cleanup for P-3 | None once P-3 is gone. | **Drop** (remove with P-3) | +| P-5 | `src/proxy.ts` | `bridge.onClose` | Restructure `} else if (code !== 0) {` into `} else { if (code !== 0) {...} }` + extended comments | Claim the `code === 0 && mcpExecReceived` case must keep the bridge alive for resume | **Behaviorally identical to baseline**: both forms error-close only when `mcpExecReceived && code !== 0`; both do nothing when `code === 0`. The new comments document pre-existing behavior, not a fix. Non-zero bridge-close handling must be reworked into one idempotent explicit-error terminal path (race-safe across frame/stream/process/timeout closes) — that belongs to the hardening pass, **not here**. | **Rework — deferred** (restore baseline shape now; reference below) | + +Net disposition: **4 hunks kept** (H2-3, H2-4, H2-5 guarded close, P-1), **5 hunks dropped or +restored** (H2-1, H2-2, P-2, P-3, P-4), **1 hunk deferred** (P-5). The kept hunks are exactly +the ones that are load-bearing or harmless: stderr draining (backpressure safety), defensive +close guards, and error passthrough (diagnosis). The dropped hunks are exactly the ones that +either lengthen a hang without fixing it or make termination decisions from raw bytes. + +## Clean-baseline vs overlay behavior + +Both trees (overlay and clean baseline) satisfy the repository verification: + +| Check | With overlay | Clean baseline | +|---|---|---| +| `git diff --check` | pass | pass | +| `npx tsc -p tsconfig.json --noEmit` | pass (no errors) | pass (no errors) | +| `bun test/smoke.ts` | 7/7 pass | 7/7 pass | + +> `bun test/smoke.ts` writes conversation cache data under `~/.cache/opencode-cursor` as a +> side effect. The suite exercises the plugin against a local fake Connect/HTTP2 backend; it +> does not contact Cursor. + +Behavioral difference (code-level, from the stream-termination enumeration): + +- **Baseline (HEAD):** no proxy-side watchdog. Open-SSE hangs are bounded only by the bridge + idle timer (120s), which heartbeat traffic defeats. A silent-stall can hang until OpenCode + gives up (never, for this provider) or the OS/network intervenes. +- **Overlay:** adds a 90s proxy watchdog that fires only after 90s with no inbound bytes, and + raises the bridge idle to 180s. For the leading hang candidates — unhandled server message + families (heartbeat/checkpoint/query trickle), missing end-stream, silent parse failures — + the watchdog **still never fires** because any trickle resets it, and it **can** kill a slow + model during a >90s silent thinking window. Net: it converted one unbounded hang class into + two unchanged classes plus a new false-termination risk. +- **After disposition (current tree):** identical to baseline plus the kept overlay hunks + (stderr pipe + drain, guarded destroys/closes, error passthrough). Termination behavior is + exactly baseline; observability is strictly better. + +## Known hang mechanisms (for reference) + +Analysis prior to this document identified these confirmed code-level defects, ordered by +likelihood of causing the reported hang (full detail in the workflow research notes): + +1. **Unbounded token refresh** — `refreshCursorToken` fetches the exchange endpoint with no + abort signal inside the pre-header request path; a stalled exchange means `Bun.serve` + never returns headers. +2. **Silent truncation reported as success** — bridge crash / H2 close without a Connect + end-stream can emit a clean `finish_reason: "stop"`, which in a tool loop drives OpenCode + retries (busy-loop). +3. **Empty end-stream misclassified as error** — `parseConnectEndStream` runs `JSON.parse` + on a zero-byte body, so valid clean completions surface as visible errors. +4. **Heartbeat-defeated idle timer** — the bridge idle timer resets on every stdin write and + H2 data frame; the proxy's 5s client heartbeat keeps it from ever firing. +5. **No bound in the non-streaming path** — `collectFullResponse` awaits bridge close with no + timeout, so a dead-silent bridge hangs it indefinitely. + +The dropped overlay hunks address **none** of these; the kept hunks improve observability of +1–5 without altering their behavior. Fixes land in later passes with harness evidence. + +## Deferred work references + +- **Non-zero bridge-close terminal path (was P-5):** rework `bridge.onClose` so bridge exit + and HTTP/2 session/stream closure without a Connect end-stream always reach one idempotent + explicit-error terminal sequence, never a clean `stop`, and never a duplicate terminal — + while preserving the valid `mcpExecReceived` tool-call pause (SSE already closed by the + tool-call frame; bridge retained for resume). Implemented in the hardening pass. +- **Semantic-progress stall detector (replaces P-3):** termination must be driven by the + absence of *semantic* progress (output deltas, tool transitions, terminal frames) and by + transport-liveness signals (HTTP/2 PING, session/stream lifecycle), never by raw-byte + elapsed time. Implemented in the hardening pass. + +## Evidence sources + +- Field notes: unhandled server-message families / raw-byte timer resets (leading hang + hypothesis); every stream-termination path; timers/watchdogs and reset semantics; + error-swallowing inventory; root-cause synthesis of the five defects; no-timeout provider + semantics; bridge keying/liveness risks. +- Source locations: `src/proxy.ts` `createBridgeStreamResponse` / `parseConnectEndStream` / + `collectFullResponse` / `spawnBridge`; `src/h2-bridge.mjs` timer and error handlers; + `src/auth.ts` `refreshCursorToken`. +- Upstream: opencode-cursor issue #33 (busy loop after H2 socket close); opencode issues on + provider timeout behavior; hardened-provider prior art (PING keepalive, stall budgets). diff --git a/docs/overlay-baseline.patch b/docs/overlay-baseline.patch new file mode 100644 index 0000000..6afba1d --- /dev/null +++ b/docs/overlay-baseline.patch @@ -0,0 +1,195 @@ +diff --git a/.gitignore b/.gitignore +index 62ccde4..4d58f8d 100644 +--- a/.gitignore ++++ b/.gitignore +@@ -2,3 +2,4 @@ node_modules/ + dist/ + *.tsbuildinfo + .DS_Store ++.goopspec/ +diff --git a/src/h2-bridge.mjs b/src/h2-bridge.mjs +index e86132e..b16ca15 100644 +--- a/src/h2-bridge.mjs ++++ b/src/h2-bridge.mjs +@@ -89,21 +89,28 @@ const client = http2.connect(url || "https://api2.cursor.sh"); + + // Guard against initial connection failure. Reset on any h2 activity + // so long-running agent conversations (with tool call round-trips) survive. +-let timeout = setTimeout(killBridge, 30_000); ++// Initial connection timeout is generous because Cursor's server can be slow ++// to respond during peak load. ++let timeout = setTimeout(killBridge, 60_000); + + function resetTimeout() { + clearTimeout(timeout); +- timeout = setTimeout(killBridge, 120_000); ++ // 180s idle timeout: long enough for Grok 4.5 thinking/tool-call pauses, ++ // short enough to not hang OpenCode indefinitely. ++ timeout = setTimeout(killBridge, 180_000); + } + + function killBridge() { + clearTimeout(timeout); +- client.destroy(); ++ try { client.destroy(); } catch {} + process.exit(1); + } + +-client.on("error", () => { ++client.on("error", (err) => { + clearTimeout(timeout); ++ // Write the error to stderr so the parent process can see it. ++ // Without this, errors are silently swallowed. ++ try { process.stderr.write(`[h2-bridge] client error: ${err.message}\n`); } catch {} + process.exit(1); + }); + +@@ -136,9 +143,10 @@ h2Stream.on("end", () => { + setTimeout(() => process.exit(0), 100); + }); + +-h2Stream.on("error", () => { ++h2Stream.on("error", (err) => { + clearTimeout(timeout); +- client.close(); ++ try { process.stderr.write(`[h2-bridge] stream error: ${err.message}\n`); } catch {} ++ try { client.close(); } catch {} + process.exit(1); + }); + +diff --git a/src/proxy.ts b/src/proxy.ts +index 93c44ec..b74b2be 100644 +--- a/src/proxy.ts ++++ b/src/proxy.ts +@@ -308,7 +308,7 @@ function spawnBridge(options: SpawnBridgeOptions): { + const proc = Bun.spawn(["node", BRIDGE_PATH], { + stdin: "pipe", + stdout: "pipe", +- stderr: "ignore", ++ stderr: "pipe", + }); + + const config = JSON.stringify({ +@@ -319,6 +319,20 @@ function spawnBridge(options: SpawnBridgeOptions): { + }); + proc.stdin.write(lpEncode(new TextEncoder().encode(config))); + ++ // Forward stderr to console for debugging. Without this, bridge errors ++ // are silently swallowed and the proxy appears to hang for no reason. ++ (async () => { ++ try { ++ const reader = proc.stderr.getReader(); ++ while (true) { ++ const { done, value } = await reader.read(); ++ if (done) break; ++ const text = new TextDecoder().decode(value); ++ if (text.trim()) console.error(`[h2-bridge] ${text.trimEnd()}`); ++ } ++ } catch {} ++ })(); ++ + const cbs = { + data: null as ((chunk: Buffer) => void) | null, + close: null as ((code: number) => void) | null, +@@ -469,7 +483,7 @@ export async function startProxy( + + proxyServer = Bun.serve({ + port: 0, +- idleTimeout: 255, // max — Cursor responses can take 30s+ ++ idleTimeout: 255, // max - Cursor responses can take 30s+, especially Grok 4.5 + async fetch(req) { + const url = new URL(req.url); + +@@ -1511,10 +1525,50 @@ function createBridgeStreamResponse( + }, + ); + +- bridge.onData(processChunk); ++ // --- Watchdog timer --- ++ // If no data arrives within TOOL_CALL_TIMEOUT_MS, force-close the stream ++ // so OpenCode doesn't hang waiting for a response that will never come. ++ // This catches: H2 socket close without Connect end-stream frame, ++ // silent server disconnects, and model stalls (especially Grok 4.5). ++ const TOOL_CALL_TIMEOUT_MS = 90_000; // 90s ++ let lastDataTime = Date.now(); ++ const watchdog = setInterval(() => { ++ if (closed) { ++ clearInterval(watchdog); ++ return; ++ } ++ if (Date.now() - lastDataTime > TOOL_CALL_TIMEOUT_MS) { ++ clearInterval(watchdog); ++ if (!mcpExecReceived) { ++ const flushed = tagFilter.flush(); ++ if (flushed.reasoning) sendSSE(makeChunk({ reasoning_content: flushed.reasoning })); ++ if (flushed.content) sendSSE(makeChunk({ content: flushed.content })); ++ } ++ sendSSE(makeChunk({ ++ content: "\n[Error: request timed out - no data from Cursor server]", ++ })); ++ sendSSE(makeChunk({}, "stop")); ++ sendSSE(makeUsageChunk()); ++ sendDone(); ++ closeController(); ++ activeBridges.delete(bridgeKey); ++ clearInterval(heartbeatTimer); ++ bridge.end(); ++ } ++ }, 5_000); ++ ++ // Wrap processChunk to track data arrival for watchdog ++ const originalProcessChunk = processChunk; ++ const trackedProcessChunk = (incoming: Buffer) => { ++ lastDataTime = Date.now(); ++ originalProcessChunk(incoming); ++ }; ++ ++ bridge.onData(trackedProcessChunk); + + bridge.onClose((code) => { + clearInterval(heartbeatTimer); ++ clearInterval(watchdog); + const stored = conversationStates.get(convKey); + if (stored) { + for (const [k, v] of blobStore) stored.blobStore.set(k, v); +@@ -1529,16 +1583,30 @@ function createBridgeStreamResponse( + sendSSE(makeUsageChunk()); + sendDone(); + closeController(); +- } else if (code !== 0) { +- // Bridge died while tool calls are pending (timeout, crash, etc.). +- // Close the SSE stream so the client doesn't hang forever. +- sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" })); +- sendSSE(makeChunk({}, "stop")); +- sendSSE(makeUsageChunk()); +- sendDone(); +- closeController(); +- // Remove stale entry so the next request doesn't try to resume it. +- activeBridges.delete(bridgeKey); ++ } else { ++ // Bridge closed while tool calls are pending. ++ // Whether the exit code is 0 (server clean-close) or non-zero ++ // (timeout/crash), if mcpExecReceived is true, the bridge is ++ // kept alive for continuation. When it dies unexpectedly, the ++ // next request will detect a dead bridge and fall through to a ++ // fresh one. But we must NOT close the SSE stream here when ++ // code === 0 and mcpExecReceived is true, because that means ++ // the tool calls were emitted and OpenCode is executing them. ++ // The stream was already closed by the onMcpExec handler. ++ if (code !== 0) { ++ // Bridge died with error while tool calls pending. ++ // Close the SSE stream so the client doesn't hang forever. ++ sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" })); ++ sendSSE(makeChunk({}, "stop")); ++ sendSSE(makeUsageChunk()); ++ sendDone(); ++ closeController(); ++ // Remove stale entry so the next request doesn't try to resume it. ++ activeBridges.delete(bridgeKey); ++ } ++ // When code === 0 and mcpExecReceived: the SSE stream was already ++ // closed by onMcpExec. Nothing more to do. The bridge is kept ++ // alive in activeBridges for the tool-result resume path. + } + }); + }, diff --git a/src/h2-bridge.mjs b/src/h2-bridge.mjs index e86132e..172ce36 100644 --- a/src/h2-bridge.mjs +++ b/src/h2-bridge.mjs @@ -98,12 +98,15 @@ function resetTimeout() { function killBridge() { clearTimeout(timeout); - client.destroy(); + try { client.destroy(); } catch {} process.exit(1); } -client.on("error", () => { +client.on("error", (err) => { clearTimeout(timeout); + // Write the error to stderr so the parent process can see it. + // Without this, errors are silently swallowed. + try { process.stderr.write(`[h2-bridge] client error: ${err.message}\n`); } catch {} process.exit(1); }); @@ -136,9 +139,10 @@ h2Stream.on("end", () => { setTimeout(() => process.exit(0), 100); }); -h2Stream.on("error", () => { +h2Stream.on("error", (err) => { clearTimeout(timeout); - client.close(); + try { process.stderr.write(`[h2-bridge] stream error: ${err.message}\n`); } catch {} + try { client.close(); } catch {} process.exit(1); }); diff --git a/src/proxy.ts b/src/proxy.ts index 93c44ec..8ae910b 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -308,7 +308,7 @@ function spawnBridge(options: SpawnBridgeOptions): { const proc = Bun.spawn(["node", BRIDGE_PATH], { stdin: "pipe", stdout: "pipe", - stderr: "ignore", + stderr: "pipe", }); const config = JSON.stringify({ @@ -319,6 +319,20 @@ function spawnBridge(options: SpawnBridgeOptions): { }); proc.stdin.write(lpEncode(new TextEncoder().encode(config))); + // Forward stderr to console for debugging. Without this, bridge errors + // are silently swallowed and the proxy appears to hang for no reason. + (async () => { + try { + const reader = proc.stderr.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = new TextDecoder().decode(value); + if (text.trim()) console.error(`[h2-bridge] ${text.trimEnd()}`); + } + } catch {} + })(); + const cbs = { data: null as ((chunk: Buffer) => void) | null, close: null as ((code: number) => void) | null, From a50cbabd8aeacc9f356554784cbb75169cc86949 Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:24:58 -0700 Subject: [PATCH 02/22] chore: ignore local tooling state directory Keeps generated local workflow state out of version control. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 62ccde4..4d58f8d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ *.tsbuildinfo .DS_Store +.goopspec/ From b6864afe2c0c875dc26c7c9e7333629340c420d1 Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:31:46 -0700 Subject: [PATCH 03/22] feat(proxy): add redacted lifecycle diagnostics Add opt-in, correlated lifecycle records across the proxy and HTTP/2 bridge so stalled requests can be localized without exposing request content or credentials. Child stderr remains continuously drained while its structured emission is gated and sanitized. --- docs/cursor-hang-root-cause.md | 21 +++++++- src/auth.ts | 79 +++++++++++++++++++++++++---- src/h2-bridge.mjs | 33 ++++++++++-- src/proxy.ts | 92 +++++++++++++++++++++++++++------- test/smoke.ts | 81 +++++++++++++++++++++++++++++- 5 files changed, 270 insertions(+), 36 deletions(-) diff --git a/docs/cursor-hang-root-cause.md b/docs/cursor-hang-root-cause.md index f284689..c4b0fed 100644 --- a/docs/cursor-hang-root-cause.md +++ b/docs/cursor-hang-root-cause.md @@ -111,8 +111,25 @@ The dropped overlay hunks address **none** of these; the kept hunks improve obse tool-call frame; bridge retained for resume). Implemented in the hardening pass. - **Semantic-progress stall detector (replaces P-3):** termination must be driven by the absence of *semantic* progress (output deltas, tool transitions, terminal frames) and by - transport-liveness signals (HTTP/2 PING, session/stream lifecycle), never by raw-byte - elapsed time. Implemented in the hardening pass. + transport-liveness signals (HTTP/2 PING, session/stream lifecycle), never by raw-byte + elapsed time. Implemented in the hardening pass. + +## Safe lifecycle diagnostics + +Set `CURSOR_PROXY_DIAGNOSTICS=1` to emit one-line JSON lifecycle records to stderr. The default +is disabled. Every record contains a per-request `requestId` and monotonic `elapsedMs`; bridge +records carry the same ID and include the parent elapsed time at spawn for cross-process ordering. + +The stage inventory is: `auth_access_start`, `auth_access_complete`, refresh start/complete/fail, +`proxy_entry`, `bridge_spawn`, `bridge_start`, `h2_connect`, `connect_stream`, first +`bridge_data`, first `connect_frame`, `message_dispatch`, `tool_pause`, `tool_resume`, first +`sse_emission`, and `terminal`. Conditional tool stages appear only for tool-call requests. + +Diagnostics use a strict allowlist: source, stage, request ID, elapsed time, byte length, message +case, interaction case, tool count, status/exit code, and error class. They never serialize auth +tokens or headers, cookies, prompt/message text, tool arguments/results, or blob/checkpoint IDs. +The parent always drains child stderr even while diagnostics are disabled, preventing pipe +backpressure from stalling the bridge. ## Evidence sources diff --git a/src/auth.ts b/src/auth.ts index 330233f..fdd25b4 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,4 +1,5 @@ import { generatePKCE } from "./pkce"; +import { AsyncLocalStorage } from "node:async_hooks"; const CURSOR_LOGIN_URL = "https://cursor.com/loginDeepControl"; const CURSOR_POLL_URL = "https://api2.cursor.sh/auth/poll"; @@ -11,6 +12,59 @@ const POLL_BASE_DELAY = 1000; const POLL_MAX_DELAY = 10_000; const POLL_BACKOFF_MULTIPLIER = 1.2; +const lifecycleDiagnosticContext = new AsyncLocalStorage<{ + requestId: string; + startedAt: number; + emittedStages: Set; +}>(); + +export type LifecycleDiagnosticStage = + | "auth_access_start" | "auth_access_complete" + | "auth_refresh_start" | "auth_refresh_complete" | "auth_refresh_failed" + | "proxy_entry" | "bridge_spawn" | "connect_frame" | "message_dispatch" + | "tool_pause" | "tool_resume" | "sse_emission" | "terminal"; + +type LifecycleDiagnosticDetails = Readonly<{ + byteLength?: number; messageCase?: string; interactionCase?: string; + toolCount?: number; exitCode?: number; errorName?: string; +}>; + +/** Diagnostics are opt-in because even safe metadata is operational noise. */ +export function lifecycleDiagnosticsEnabled(): boolean { + return process.env.CURSOR_PROXY_DIAGNOSTICS === "1"; +} + +/** Keep one correlation ID across asynchronous proxy and auth work. */ +export function withLifecycleDiagnosticContext(requestId: string, operation: () => T): T { + return lifecycleDiagnosticContext.run({ requestId, startedAt: performance.now(), emittedStages: new Set() }, operation); +} + +export function lifecycleDiagnosticContextInfo(): { requestId: string; elapsedMs: number } | undefined { + const context = lifecycleDiagnosticContext.getStore(); + return context && { requestId: context.requestId, elapsedMs: Math.round(performance.now() - context.startedAt) }; +} + +/** Emits only explicitly allowlisted metadata. Do not add payload-bearing fields here. */ +export function emitLifecycleDiagnostic( + source: "auth" | "proxy", stage: LifecycleDiagnosticStage, + details: LifecycleDiagnosticDetails = {}, +): void { + if (!lifecycleDiagnosticsEnabled()) return; + const context = lifecycleDiagnosticContextInfo(); + if (!context) return; + const store = lifecycleDiagnosticContext.getStore(); + if (!store || store.emittedStages.has(stage)) return; + store.emittedStages.add(stage); + const event: Record = { source, stage, requestId: context.requestId, elapsedMs: context.elapsedMs }; + if (typeof details.byteLength === "number") event.byteLength = details.byteLength; + if (typeof details.messageCase === "string") event.messageCase = details.messageCase; + if (typeof details.interactionCase === "string") event.interactionCase = details.interactionCase; + if (typeof details.toolCount === "number") event.toolCount = details.toolCount; + if (typeof details.exitCode === "number") event.exitCode = details.exitCode; + if (typeof details.errorName === "string") event.errorName = details.errorName; + console.error(JSON.stringify(event)); +} + export interface CursorAuthParams { verifier: string; challenge: string; @@ -89,16 +143,21 @@ export async function pollCursorAuth( export async function refreshCursorToken( refreshToken: string, ): Promise { - const response = await fetch(CURSOR_REFRESH_URL, { - method: "POST", - headers: { - Authorization: `Bearer ${refreshToken}`, - "Content-Type": "application/json", - }, - body: "{}", - }); + emitLifecycleDiagnostic("auth", "auth_refresh_start"); + let response: Response; + try { + response = await fetch(CURSOR_REFRESH_URL, { + method: "POST", + headers: { Authorization: `Bearer ${refreshToken}`, "Content-Type": "application/json" }, + body: "{}", + }); + } catch (error) { + emitLifecycleDiagnostic("auth", "auth_refresh_failed", { errorName: error instanceof Error ? error.name : "UnknownError" }); + throw error; + } if (!response.ok) { + emitLifecycleDiagnostic("auth", "auth_refresh_failed", { exitCode: response.status }); const error = await response.text(); throw new Error(`Cursor token refresh failed: ${error}`); } @@ -108,11 +167,13 @@ export async function refreshCursorToken( refreshToken: string; }; - return { + const credentials = { access: data.accessToken, refresh: data.refreshToken || refreshToken, expires: getTokenExpiry(data.accessToken), }; + emitLifecycleDiagnostic("auth", "auth_refresh_complete"); + return credentials; } diff --git a/src/h2-bridge.mjs b/src/h2-bridge.mjs index 172ce36..3270468 100644 --- a/src/h2-bridge.mjs +++ b/src/h2-bridge.mjs @@ -83,9 +83,31 @@ const configBuf = await readMessage(); if (!configBuf) process.exit(1); const config = JSON.parse(configBuf.toString("utf8")); -const { accessToken, url, path: rpcPath, unary } = config; +const { accessToken, url, path: rpcPath, unary, diagnostic: diagnosticContext, diagnosticsEnabled } = config; +const diagnosticStartedAt = performance.now(); +const emittedStages = new Set(); + +// This is deliberately an allowlist. Transport errors, headers, payloads, +// and request configuration values are never serialized. +function emitDiagnostic(stage, details = {}) { + if (!diagnosticsEnabled || !diagnosticContext?.requestId || emittedStages.has(stage)) return; + emittedStages.add(stage); + const event = { + source: "bridge", + stage, + requestId: diagnosticContext.requestId, + elapsedMs: Math.round(performance.now() - diagnosticStartedAt), + parentElapsedMs: diagnosticContext.elapsedMs, + }; + for (const key of ["byteLength", "status", "exitCode"]) + if (typeof details[key] === "number") event[key] = details[key]; + if (typeof details.errorName === "string") event.errorName = details.errorName; + try { process.stderr.write(`${JSON.stringify(event)}\n`); } catch {} +} +emitDiagnostic("bridge_start"); const client = http2.connect(url || "https://api2.cursor.sh"); +client.on("connect", () => emitDiagnostic("h2_connect")); // Guard against initial connection failure. Reset on any h2 activity // so long-running agent conversations (with tool call round-trips) survive. @@ -104,9 +126,7 @@ function killBridge() { client.on("error", (err) => { clearTimeout(timeout); - // Write the error to stderr so the parent process can see it. - // Without this, errors are silently swallowed. - try { process.stderr.write(`[h2-bridge] client error: ${err.message}\n`); } catch {} + emitDiagnostic("terminal", { errorName: err?.name || "Error", exitCode: 1 }); process.exit(1); }); @@ -125,15 +145,18 @@ if (!unary) { headers["connect-protocol-version"] = "1"; } const h2Stream = client.request(headers); +emitDiagnostic("connect_stream"); // Forward H2 response data → stdout (length-prefixed) h2Stream.on("data", (chunk) => { resetTimeout(); + emitDiagnostic("bridge_data", { byteLength: chunk.length }); writeMessage(chunk); }); h2Stream.on("end", () => { clearTimeout(timeout); + emitDiagnostic("terminal", { exitCode: 0 }); client.close(); // Give stdout time to flush setTimeout(() => process.exit(0), 100); @@ -141,7 +164,7 @@ h2Stream.on("end", () => { h2Stream.on("error", (err) => { clearTimeout(timeout); - try { process.stderr.write(`[h2-bridge] stream error: ${err.message}\n`); } catch {} + emitDiagnostic("terminal", { errorName: err?.name || "Error", exitCode: 1 }); try { client.close(); } catch {} process.exit(1); }); diff --git a/src/proxy.ts b/src/proxy.ts index 8ae910b..10d2f03 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -78,6 +78,12 @@ import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promi import { homedir } from "node:os"; import { resolve as pathResolve } from "node:path"; import { z } from "zod"; +import { + emitLifecycleDiagnostic, + lifecycleDiagnosticContextInfo, + lifecycleDiagnosticsEnabled, + withLifecycleDiagnosticContext, +} from "./auth"; const CURSOR_API_URL = process.env.CURSOR_API_URL ?? "https://api2.cursor.sh"; const CONNECT_END_STREAM_FLAG = 0b00000010; @@ -294,6 +300,7 @@ interface SpawnBridgeOptions { url?: string; /** When true, use application/proto for unary RPCs instead of Connect streaming. */ unary?: boolean; + diagnostic?: { requestId: string; elapsedMs: number }; } function spawnBridge(options: SpawnBridgeOptions): { @@ -310,25 +317,53 @@ function spawnBridge(options: SpawnBridgeOptions): { stdout: "pipe", stderr: "pipe", }); + emitLifecycleDiagnostic("proxy", "bridge_spawn"); const config = JSON.stringify({ accessToken: options.accessToken, url: options.url ?? CURSOR_API_URL, path: options.rpcPath, unary: options.unary ?? false, + diagnostic: options.diagnostic, + diagnosticsEnabled: lifecycleDiagnosticsEnabled(), }); proc.stdin.write(lpEncode(new TextEncoder().encode(config))); - // Forward stderr to console for debugging. Without this, bridge errors - // are silently swallowed and the proxy appears to hang for no reason. + // Always drain stderr: an unread pipe can block the child. Only allowlisted + // diagnostic records are emitted, and only when explicitly enabled. (async () => { try { const reader = proc.stderr.getReader(); + let pending = ""; while (true) { const { done, value } = await reader.read(); if (done) break; - const text = new TextDecoder().decode(value); - if (text.trim()) console.error(`[h2-bridge] ${text.trimEnd()}`); + pending += new TextDecoder().decode(value, { stream: true }); + const lines = pending.split("\n"); + pending = lines.pop() ?? ""; + for (const line of lines) { + if (!lifecycleDiagnosticsEnabled()) continue; + try { + const parsed = JSON.parse(line) as Record; + const diagnosticRequestId = options.diagnostic?.requestId; + if (!diagnosticRequestId || parsed.source !== "bridge" || parsed.requestId !== diagnosticRequestId) continue; + const bridgeStages = new Set([ + "bridge_start", "h2_connect", "connect_stream", "bridge_data", "terminal", + ]); + if (typeof parsed.stage !== "string" || !bridgeStages.has(parsed.stage)) continue; + const event: Record = { + source: "bridge", + stage: parsed.stage, + requestId: diagnosticRequestId, + elapsedMs: typeof parsed.elapsedMs === "number" ? parsed.elapsedMs : 0, + }; + for (const key of ["parentElapsedMs", "byteLength", "status", "exitCode"] as const) { + if (typeof parsed[key] === "number") event[key] = parsed[key]; + } + if (typeof parsed.errorName === "string") event.errorName = parsed.errorName; + console.error(JSON.stringify(event)); + } catch {} + } } } catch {} })(); @@ -498,22 +533,28 @@ export async function startProxy( } if (req.method === "POST" && url.pathname === "/v1/chat/completions") { - try { - const body = (await req.json()) as ChatCompletionRequest; - if (!proxyAccessTokenProvider) { - throw new Error("Cursor proxy access token provider not configured"); + return withLifecycleDiagnosticContext(crypto.randomUUID(), async () => { + try { + emitLifecycleDiagnostic("proxy", "proxy_entry"); + const body = (await req.json()) as ChatCompletionRequest; + if (!proxyAccessTokenProvider) { + throw new Error("Cursor proxy access token provider not configured"); + } + emitLifecycleDiagnostic("proxy", "auth_access_start"); + const accessToken = await proxyAccessTokenProvider(); + emitLifecycleDiagnostic("proxy", "auth_access_complete"); + return handleChatCompletion(body, accessToken); + } catch (err) { + emitLifecycleDiagnostic("proxy", "terminal", { + errorName: err instanceof Error ? err.name : "UnknownError", + }); + const message = err instanceof Error ? err.message : String(err); + return new Response( + JSON.stringify({ error: { message, type: "server_error", code: "internal_error" } }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); } - const accessToken = await proxyAccessTokenProvider(); - return handleChatCompletion(body, accessToken); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return new Response( - JSON.stringify({ - error: { message, type: "server_error", code: "internal_error" }, - }), - { status: 500, headers: { "Content-Type": "application/json" } }, - ); - } + }); } return new Response("Not Found", { status: 404 }); @@ -1384,6 +1425,7 @@ function createBridgeStreamResponse( let closed = false; const sendSSE = (data: object) => { if (closed) return; + emitLifecycleDiagnostic("proxy", "sse_emission"); controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); }; const sendDone = () => { @@ -1393,6 +1435,7 @@ function createBridgeStreamResponse( const closeController = () => { if (closed) return; closed = true; + emitLifecycleDiagnostic("proxy", "terminal"); controller.close(); }; @@ -1431,6 +1474,7 @@ function createBridgeStreamResponse( const processChunk = createConnectFrameParser( (messageBytes) => { + emitLifecycleDiagnostic("proxy", "connect_frame", { byteLength: messageBytes.length }); try { const serverMessage = fromBinary( AgentServerMessageSchema, @@ -1454,6 +1498,7 @@ function createBridgeStreamResponse( }, // onMcpExec — the model wants to execute a tool. (exec) => { + emitLifecycleDiagnostic("proxy", "tool_pause"); state.pendingExecs.push(exec); mcpExecReceived = true; @@ -1500,6 +1545,12 @@ function createBridgeStreamResponse( } }, ); + emitLifecycleDiagnostic("proxy", "message_dispatch", { + messageCase: serverMessage.message.case, + interactionCase: serverMessage.message.case === "interactionUpdate" + ? serverMessage.message.value.message?.case + : undefined, + }); } catch { // Skip unparseable messages } @@ -1528,6 +1579,7 @@ function createBridgeStreamResponse( bridge.onData(processChunk); bridge.onClose((code) => { + emitLifecycleDiagnostic("proxy", "terminal", { exitCode: code }); clearInterval(heartbeatTimer); const stored = conversationStates.get(convKey); if (stored) { @@ -1569,6 +1621,7 @@ function startBridge( const bridge = spawnBridge({ accessToken, rpcPath: "/agent.v1.AgentService/Run", + diagnostic: lifecycleDiagnosticContextInfo(), }); bridge.write(frameConnectMessage(requestBytes)); const heartbeatTimer = setInterval(() => bridge.write(makeHeartbeatBytes()), 5_000); @@ -1600,6 +1653,7 @@ function handleToolResultResume( convKey: string, ): Response { const { bridge, heartbeatTimer, blobStore, mcpTools, cloudRule, pendingExecs } = active; + emitLifecycleDiagnostic("proxy", "tool_resume", { toolCount: toolResults.length }); // Answer each pending exec with a matching tool result: redirected native // execs get their typed native result frame, MCP execs get an mcpResult. diff --git a/test/smoke.ts b/test/smoke.ts index 53d948a..21e0616 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -68,6 +68,23 @@ function frameConnectUnaryMessage(payload: Uint8Array): Buffer { return frame; } +function frameConnectEndStream(payload: Uint8Array): Buffer { + const frame = frameConnectUnaryMessage(payload); + frame[0] = 0b00000010; + return frame; +} + +async function captureDiagnosticOutput(operation: () => Promise): Promise<{ result: T; lines: string[] }> { + const lines: string[] = []; + const original = console.error; + console.error = (...args: unknown[]) => lines.push(args.map(String).join(" ")); + try { + return { result: await operation(), lines }; + } finally { + console.error = original; + } +} + async function createTestCursorBackend(): Promise { let discoveryMode: DiscoveryMode = "success"; let discoveredModels: Array<{ id: string; name: string; reasoning?: boolean }> = [ @@ -113,7 +130,10 @@ async function createTestCursorBackend(): Promise { ":status": 200, "content-type": "application/connect+proto", }); - stream.end(); + stream.end(Buffer.concat([ + frameConnectUnaryMessage(new Uint8Array()), + frameConnectEndStream(new TextEncoder().encode("{}")), + ])); return; } @@ -400,6 +420,64 @@ async function testArrayContentParsing(modules: TestModules) { console.log("[test] Array content parsing OK"); } +async function testLifecycleDiagnostics(modules: TestModules) { + console.log("[test] Testing redacted lifecycle diagnostics..."); + const originalSetting = process.env.CURSOR_PROXY_DIAGNOSTICS; + const fakeToken = "fake-access-token-DO-NOT-LOG"; + const secretPrompt = "prompt-content-DO-NOT-LOG"; + const secretToolPayload = "tool-payload-DO-NOT-LOG"; + const request = { + model: "test", + stream: true, + messages: [{ role: "user", content: secretPrompt }], + tools: [{ + type: "function", + function: { name: "example", parameters: { type: "object", example: secretToolPayload } }, + }], + }; + const runRequest = async () => { + const port = await modules.startProxy(async () => fakeToken); + const response = await fetch(`http://localhost:${port}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${fakeToken}` }, + body: JSON.stringify(request), + }); + await response.text(); + await Bun.sleep(150); + modules.stopProxy(); + }; + + try { + delete process.env.CURSOR_PROXY_DIAGNOSTICS; + const disabled = await captureDiagnosticOutput(runRequest); + assertEqual(disabled.lines.length, 0, "Diagnostics must be silent when disabled"); + + process.env.CURSOR_PROXY_DIAGNOSTICS = "1"; + const enabled = await captureDiagnosticOutput(runRequest); + const events = enabled.lines.map((line) => JSON.parse(line) as Record); + const stages = new Set(events.map((event) => event.stage)); + for (const stage of [ + "proxy_entry", "auth_access_start", "auth_access_complete", "bridge_spawn", + "bridge_start", "h2_connect", "connect_stream", "bridge_data", "connect_frame", + "message_dispatch", "sse_emission", "terminal", + ]) { + assert(stages.has(stage), `Missing diagnostic lifecycle stage: ${stage}`); + } + const requestIds = new Set(events.map((event) => event.requestId)); + assertEqual(requestIds.size, 1, "Expected one shared diagnostic correlation ID"); + assert(events.every((event) => typeof event.elapsedMs === "number"), "Expected monotonic diagnostic timing"); + const output = enabled.lines.join("\n"); + for (const secret of [fakeToken, secretPrompt, secretToolPayload]) { + assert(!output.includes(secret), `Diagnostic output leaked secret: ${secret}`); + } + } finally { + if (originalSetting === undefined) delete process.env.CURSOR_PROXY_DIAGNOSTICS; + else process.env.CURSOR_PROXY_DIAGNOSTICS = originalSetting; + modules.stopProxy(); + } + console.log("[test] Redacted lifecycle diagnostics OK"); +} + async function testExpiredTokenRefreshBeforeDiscovery( modules: TestModules, backend: TestCursorBackend, @@ -537,6 +615,7 @@ async function main() { await testTokenExpiry(modules); await testPluginShape(modules); await testArrayContentParsing(modules); + await testLifecycleDiagnostics(modules); await testExpiredTokenRefreshBeforeDiscovery(modules, backend); await testDiscoveryFallbackAndSuccess(modules, backend); console.log("\n✓ All smoke tests passed"); From fd5d48a11ba40076a5ebf9c43a7fe3a133276a4c Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:43:13 -0700 Subject: [PATCH 04/22] docs(proxy): map lifecycle and termination evidence Complete the lifecycle/termination evidence map before any behavioral fix is written: every path that can leave an OpenCode SSE stream open is now classified (clean terminal / error terminal / hang / runtime) with source citations across proxy, bridge, native-tools, and the generated protobuf schema. Matrices cover lifecycle stages, terminal routes, every timer and its heartbeat defeat, all AgentServerMessage and InteractionUpdate cases, parse/write failures, and tool resume paths. Two diagnostics-only gaps found while mapping are closed (gated and allowlisted, no behavior change): an end_stream stage so a clean end-stream no-op is observable, and a timeout-cause reason on bridge killBridge so connect/idle timeouts are distinguishable from error exits. --- docs/cursor-hang-root-cause.md | 272 ++++++++++++++++++++++++++++++++- src/auth.ts | 6 +- src/h2-bridge.mjs | 9 +- src/proxy.ts | 4 + 4 files changed, 280 insertions(+), 11 deletions(-) diff --git a/docs/cursor-hang-root-cause.md b/docs/cursor-hang-root-cause.md index c4b0fed..2f24e01 100644 --- a/docs/cursor-hang-root-cause.md +++ b/docs/cursor-hang-root-cause.md @@ -1,9 +1,11 @@ # Cursor Model Hang — Root Cause Analysis -Status: **Evidence baseline established.** This document opens the hang investigation. It -preserves the speculative uncommitted overlay that predated this investigation, classifies -every hunk with an explicit disposition, and records baseline-vs-overlay behavior so that no -user work is silently discarded and no speculative change is silently accepted as "the fix." +Status: **Evidence baseline established; lifecycle and termination map complete.** This +document opens the hang investigation: it preserves the speculative uncommitted overlay that +predated this investigation, classifies every hunk with an explicit disposition, records +baseline-vs-overlay behavior, and then (Part 2) enumerates every open-SSE path and termination +route with source-linked matrices. No behavioral fix is implemented here; the matrices exist +so the termination-contract work lands directly against a complete map. ## Grounding fact: nothing upstream rescues a stuck request @@ -122,8 +124,12 @@ records carry the same ID and include the parent elapsed time at spawn for cross The stage inventory is: `auth_access_start`, `auth_access_complete`, refresh start/complete/fail, `proxy_entry`, `bridge_spawn`, `bridge_start`, `h2_connect`, `connect_stream`, first -`bridge_data`, first `connect_frame`, `message_dispatch`, `tool_pause`, `tool_resume`, first -`sse_emission`, and `terminal`. Conditional tool stages appear only for tool-call requests. +`bridge_data`, first `connect_frame`, `end_stream`, `message_dispatch`, `tool_pause`, +`tool_resume`, first `sse_emission`, and `terminal`. Conditional tool stages appear only for +tool-call requests. Bridge timeout kills are distinguishable: `terminal` with +`errorName: "Timeout:connect"` (initial 30s connect bound) or `"Timeout:idle"` (120s activity +bound) versus `errorName` from a client/stream error and `exitCode` from normal exit. See +Part 2 §2.8 for the instrumentation added in this pass. Diagnostics use a strict allowlist: source, stage, request ID, elapsed time, byte length, message case, interaction case, tool count, status/exit code, and error class. They never serialize auth @@ -142,3 +148,257 @@ backpressure from stalling the bridge. `src/auth.ts` `refreshCursorToken`. - Upstream: opencode-cursor issue #33 (busy loop after H2 socket close); opencode issues on provider timeout behavior; hardened-provider prior art (PING keepalive, stall budgets). + +--- + +# Part 2 — Lifecycle and termination evidence map + +This part enumerates every path that can leave an OpenCode SSE stream open, classified from +direct source reading of `src/proxy.ts`, `src/h2-bridge.mjs`, `src/native-tools.ts`, and the +generated `src/proto/agent_pb.ts`. It is the specification input for the termination-contract +work (which must make every request reach exactly one terminal outcome) and for the +controllable Connect/HTTP2 harness (which must exercise each row below that is marked as +needing runtime evidence). **No behavioral change is made here** — the only edits in this pass +are the diagnostic additions in §2.8. + +## Classification legend + +Every row carries one of: + +| Class | Meaning | +|---|---| +| **terminates cleanly** | The path provably closes the SSE with `[DONE]` and no error content. | +| **terminates with error** | The path provably closes the SSE with an explicit error/stop sequence. | +| **HANGS** | The path can leave the request pending indefinitely, or bounded only by OS-level truncation. | +| **unknown-needs-runtime-evidence** | Source reading fixes the *mechanism*, but the trigger depends on server or runtime behavior that the controllable fixture must confirm. | + +Classification basis is noted per row: **source-proven** (directly verifiable in the code +above) versus **runtime** (the code path is fixed, but the server-side sequence that activates +it is not yet captured). + +## 2.1 Lifecycle matrix — streaming request, entry to completion + +Each stage cites where it is reached in `src/proxy.ts` unless noted. + +| # | Stage | Where | What happens | Outcome on stall here | +|---|---|---|---|---| +| L1 | Request entry | `Bun.serve` fetch, `proxy.ts:522-537` | URL parsed, method/path routed | 404 for unknown path | +| L2 | Diagnostic context + `proxy_entry` | `proxy.ts:536-538` | per-request correlation ID starts | n/a | +| L3 | Body parse | `proxy.ts:539` | `req.json()` → `ChatCompletionRequest` | 500 via outer catch (`proxy.ts:547-556`) | +| L4 | `auth_access_start` → token provider → `auth_access_complete` | `proxy.ts:543-545` | `proxyAccessTokenProvider()` awaited; the provider calls `resolveDiskAccessToken` (`src/index.ts:77-92`) which may call `refreshCursorToken` (`src/auth.ts:143-177`) | **HANGS pre-header** if the refresh fetch stalls — `refreshCursorToken` has no timeout (§2.7 M4); no Response is ever returned | +| L5 | Message parse / key derivation | `proxy.ts:590-609` | `parseMessages`, `deriveBridgeKey`/`deriveConversationKey` | 400 for no user message (`proxy.ts:594-604`) | +| L6 | Bridge reuse check | `proxy.ts:610-624` | live tool-resume, dead-bridge cleanup, or fresh request | see §2.6 | +| L7 | Conversation state load/persist | `proxy.ts:633-646` | memory map, disk-cache fallback, TTL eviction | n/a | +| L8 | Request build | `proxy.ts:650-655` | `buildCursorRequest` → protobuf `AgentClientMessage` | n/a | +| L9 | `handleStreamingResponse` → `startBridge` | `proxy.ts:1635-1648` → `1621-1633` | `spawnBridge` (`proxy.ts:306-430`), writes config + initial Connect frame, arms 5s heartbeat interval (`proxy.ts:1631`) | child boot failure → `bridge.onClose` (see T4/T5) | +| L10 | Bridge boot | `h2-bridge.mjs:82-107` | config read, `http2.connect` (`109`), 30s connect timer armed (`115`) | timeout kill → exit 1 (§2.3 T-T1) | +| L11 | H2 connect + stream open | `h2-bridge.mjs:109-148` | `connect` → `h2_connect` (`110`), `client.request` → `connect_stream` (`147-148`) | connect error → `terminal` + exit 1 (`130-134`) | +| L12 | SSE response start | `proxy.ts:1409-1423` | `createBridgeStreamResponse` returns `new Response(stream, SSE_HEADERS)` (`1613`) | headers sent; OpenCode now waits for body | +| L13 | First H2 data | `h2-bridge.mjs:151-155` | `bridge_data` + `resetTimeout` + forward to stdout | — | +| L14 | Connect frame parse | `proxy.ts:1475-1490` | `createConnectFrameParser` (`976-996`) → `connect_frame` → `fromBinary` | silent skip on parse failure (§2.5 F2) | +| L15 | Message dispatch | `proxy.ts:1483-1553` | `processServerMessage` (`1068-1102`) → per-case handling (§2.4) | ignored cases produce no SSE output (§2.4) | +| L16 | Text/thinking emission | `proxy.ts:1490-1498` | `onText` → `sendSSE` → `sse_emission` (`1426-1430`) | — | +| L17 | KV round-trip | `proxy.ts:1142-1173` | `getBlob`/`setBlob` → `sendFrame` → `bridge.write` | unhandled KV case → server waits (see M-row KV) | +| L18 | Checkpoint update | `proxy.ts:1536-1546` | `conversationCheckpointUpdate` → persist blob/checkpoint | n/a | +| L19 | Tool pause | `proxy.ts:1499-1535` | `mcpExec` → `tool_pause` → `tool_calls` SSE → `[DONE]` + `closeController` (`1533-1534`); bridge retained in `activeBridges` (`1523-1530`) | SSE closes; bridge leaks if never resumed (§2.2 T8) | +| L20 | Connect end-stream | `proxy.ts:1558-1580` | `end_stream` diagnostic; clean end-stream is a no-op (`1567`), error end-stream emits error + stop + usage + `[DONE]` + close + cleanup (`1568-1579`) | clean no-op relies on the bridge-exit cascade (§2.2 T1) | +| L21 | Bridge close | `proxy.ts:1585-1613` | `onClose(code)` → conversation persist → clean or error terminal (§2.2 T4/T5) | — | +| L22 | SSE close | `proxy.ts:1435-1440` | `closeController` → `terminal` + `controller.close()` | — | + +**Non-streaming variant:** `handleNonStreamingResponse` (`proxy.ts:1730-1757`) awaits +`collectFullResponse` (`1768-1842`), which resolves **only** on `bridge.onClose` (`1823-1840`). +Its Connect end-stream handler is a no-op `() => {}` (`1820`). A silent bridge therefore hangs +the whole request pre-header (§2.2 T10). + +## 2.2 Terminal matrix — every way a stream can end + +| # | Trigger | Source route | Outcome | Classification | +|---|---|---|---|---| +| T1 | Connect end-stream with no `error` (clean, e.g. `{}`) | `parseConnectEndStream` → `null` (`proxy.ts:947-956`); handler `if (endError)` no-op (`1567`) | SSE closes only through the bridge-exit cascade T4. If the server ends the H2 stream, fine; if the server sends the envelope but keeps the stream open, nothing terminates. | **terminates cleanly via cascade / HANGS if stream not closed** — source-proven no-op; server behavior runtime | +| T2 | Connect end-stream with `error` JSON | `parseConnectEndStream` → `Error` (`950-955`); error branch (`1567-1579`) | Error content chunk + `stop` + usage + `[DONE]` + close + `bridge.end()` + map/timer cleanup | **terminates with error** — source-proven | +| T3 | Empty-body end-stream (zero bytes) | `JSON.parse("")` throws (`948-949`) → false `Error` (`957-958`) | Same error branch as T2. Per Connect spec a zero-byte end-stream means success, so a valid clean completion surfaces as an error. | **terminates with error (false positive)** — source-proven | +| T4 | Bridge exit code 0 (H2 stream `end` → `client.close()` → `exit(0)` after 100ms flush) | `h2-bridge.mjs:160-166`; `onClose` `proxy.ts:1585-1593` | If no exec received: clean `stop` + usage + `[DONE]` + close (`1594-1601`). If exec received: no SSE action (bridge retained for resume). | **terminates cleanly** — source-proven | +| T5 | Bridge exit code ≠ 0 (connect timeout, idle timeout, client error, stream error) | `h2-bridge.mjs:115/120` (killBridge), `130-134` (client error), `168-174` (stream error); `onClose` `proxy.ts:1585-1593` | If no exec received: **clean** `stop` + usage + `[DONE]` (`1594-1601`) — non-zero exit reported as clean success. If exec received: error `stop` + `[DONE]` + stale-entry delete (`1602-1609`). The clean branch matches upstream issue #33's silent-truncation/busy-loop symptom. | **terminates with error masked as clean success** — source-proven | +| T6 | H2 GOAWAY / session `close` without stream `end` or `error` | no `client.on("close")` handler in `h2-bridge.mjs`; stream may emit `error` (→ T5) or nothing | If nothing: idle timer never fires because the proxy's 5s heartbeat resets it via stdin (`proxy.ts:1631` → `h2-bridge.mjs:194-196`). Streaming SSE bounded only by Bun's 255s connection idle (`proxy.ts:521`) → truncated `other` body. | **HANGS (bounded ~255s streaming) / unknown-needs-runtime-evidence** — missing session-close handler source-proven; Node event sequence on GOAWAY runtime | +| T7 | Client abort (OpenCode cancels the SSE response) | `ReadableStream` has no `cancel()` handler (`proxy.ts:1422-1611`); `sendSSE` has no error guard (`1426-1430`) | Bridge not torn down; heartbeat interval keeps running (`1631`); bridge/H2 session leak until server ends the stream or process stop. OpenCode already aborted, so not an OpenCode-side hang — but a bridge/process leak and stale `activeBridges` entry. | **client-side terminates; bridge leaks** — source-proven leak; Bun enqueue-after-cancel behavior runtime | +| T8 | Tool pause with no resume | `proxy.ts:1523-1535`; no TTL/eviction for `activeBridges` (only `stopProxy` clears, `578-582`) | SSE closes cleanly; bridge + heartbeat timer + H2 session retained forever if the client never sends tool results. | **SSE terminates cleanly; bridge leaks indefinitely** — source-proven | +| T9 | Partial/malformed Connect frame at stream end | parser buffers partial data and waits (`proxy.ts:980-995`); child exit discards the pending buffer silently (reader loop ends `402-405`) → T4/T5 | Any text in the partial frame is lost; still reported clean (code 0) or masked error (code ≠ 0). | **silent truncation** — source-proven drop; frequency runtime | +| T10 | Silent stall (server sends nothing, never closes) | no data → no `resetTimeout` from H2, but the proxy's own 5s heartbeat resets the idle timer via stdin every 5s (`proxy.ts:1631` → `h2-bridge.mjs:194-196`) | Streaming: bounded only by Bun's 255s connection idle (`proxy.ts:521`) → truncated `other`. Pre-header (auth refresh L4, or non-streaming collect L-NS) and heartbeat-trickle stalls: **no bound at all**. | **HANGS** — source-proven (see §2.3 T-T2/T-T4) | + +## 2.3 Timer matrix + +| # | Timer | Where | Armed by | Reset by | On fire | Cleared by | Defeated by heartbeat? | +|---|---|---|---|---|---|---|---| +| T-T1 | Bridge initial connect bound (30s) | `h2-bridge.mjs:115` | bridge boot | *not* reset (one-shot until first `resetTimeout`) | `killBridge` → destroy + `exit(1)` (`123-127`) | first data/stdin write (`155`/`194-196`), stream `end`/`error`, client error | No — one-shot. Bounds a stalled connect, then surfaces as masked clean success (T5) | +| T-T2 | Bridge activity/idle bound (120s) | `h2-bridge.mjs:117-120` | first `resetTimeout` | **any** H2 data (`154-155`) and **any** stdin write (`194-196`) | `killBridge` → destroy + `exit(1)` | stream `end`/`error`, client error | **Yes — defeated indefinitely.** Proxy writes a ClientHeartbeat every 5s (`proxy.ts:1631`); server heartbeats also reset it. Effectively never fires while the proxy lives | +| T-T3 | Proxy client heartbeat interval (5s) | `proxy.ts:1631` | `startBridge` | n/a | writes a Connect `ClientHeartbeat` frame to the bridge | `bridge.onClose`, end-stream error branch, `stopProxy` | This is the *cause* of T-T2's defeat, not a termination bound | +| T-T4 | Bun.serve connection idle (255s max) | `proxy.ts:521` | server start | bytes flowing **on the SSE connection** | closes the HTTP connection → truncated SSE body (`other` error in OpenCode) | SSE writes | Not "defeated" but irrelevant to heartbeat trickle: ignored messages produce no SSE bytes, so the connection stays idle and this is the *only* streaming-stall bound | +| T-T5 | Unary RPC timeout (default 5s) | `proxy.ts:456-462` | `callCursorUnaryRpc` | n/a | kills the bridge proc; resolves with `timedOut: true` | bridge `onClose` | Not in the streaming path; discovery only | +| T-T6 | Conversation memory TTL (30min) | `proxy.ts:179` | conversation store | `lastAccessMs` updates | evicts `conversationStates` entry | n/a | Unrelated to request termination | +| T-T7 | Conversation disk TTL (7d) | `proxy.ts:183` | disk snapshot | — | prunes stale files at proxy start (`517`) | n/a | Unrelated | +| T-T8 | Non-streaming collect bound | **none** — `collectFullResponse` awaits `bridge.onClose` (`proxy.ts:1823-1840`) with no timeout and a no-op end-stream handler (`1820`) | — | — | — | — | Its only possible bound is T-T2, which is defeated. Pre-header HANG | +| T-T9 | Proxy-side stall watchdog | **none in baseline** — the 90s raw-byte watchdog (overlay P-3) was dropped (Part 1) | — | — | — | — | n/a | + +## 2.4 Message matrix + +Cases read from the generated `src/proto/agent_pb.ts`. "Ignored" means the message is decoded +successfully and then no branch matches — no SSE output, no response frame, no terminal. + +### AgentServerMessage — 6 cases (`agent_pb.ts:3666-3709`), dispatch in `proxy.ts:1079-1101` + +| Case | proto field | Disposition | Consequence of ignoring (where applicable) | +|---|---|---|---| +| `interactionUpdate` | 1 | **handled** → `handleInteractionUpdate` (`proxy.ts:1081-1082`) | — | +| `execServerMessage` | 2 | **handled** → `handleExecMessage` (`proxy.ts:1085-1092`, `1175-1353`) | — | +| `execServerControlMessage` | 5 | **ignored** — no branch in `processServerMessage`. Carries `abort` (`agent_pb.ts:3567-3574`) | A server-side abort signal is dropped. If the server aborts its turn and then waits for the client to acknowledge/close, the stream never terminates. Whether Cursor sends this in the failing models is runtime. | +| `conversationCheckpointUpdate` | 3 | **handled** (`proxy.ts:1093-1101`): token details + checkpoint persist | — | +| `kvServerMessage` | 4 | **handled** → `handleKvMessage` (`proxy.ts:1142-1173`) for `getBlobArgs`/`setBlobArgs` only | Other KV cases fall through silently; if the server blocks on an unanswered KV request the stream stalls. Cursor uses only the two blob cases today — runtime to confirm none others occur. | +| `interactionQuery` | 7 | **ignored** — no branch. Carries web-search/ask-question/switch-mode/exa/plan/VM queries (`agent_pb.ts:3300-3350`) | The proxy never answers with `interactionResponse` (the client-side schema case exists at `agent_pb.ts:3629-3635`). If the server waits for an interaction response, the request HANGS; if it proceeds, the feature is silently broken. Runtime to confirm whether these fire in the failing models. | + +### InteractionUpdate — 17 cases (`agent_pb.ts:3157-3277`), dispatch in `proxy.ts:1104-1123` + +| Case | proto field | Disposition | Consequence of ignoring | +|---|---|---|---| +| `textDelta` | 1 | **handled** → content SSE (`proxy.ts:1111-1113`) | — | +| `partialToolCall` | 7 | ignored — by comment policy (`1120-1122`); tool calls flow through exec messages | Safe while Cursor uses exec-based tools; if any model emits interaction tool calls instead, the call is never surfaced and output can stall. Runtime. | +| `toolCallDelta` | 15 | ignored (same policy) | same as above | +| `toolCallStarted` | 2 | ignored (same policy) | same as above | +| `toolCallCompleted` | 3 | ignored (same policy) | same as above | +| `thinkingDelta` | 4 | **handled** → reasoning SSE (`proxy.ts:1114-1116`) | — | +| `thinkingCompleted` | 5 | ignored | minor; no semantic output | +| `userMessageAppended` | 6 | ignored | Model-side transcript append (e.g. ask-question flows) is dropped; if the server waits on a relayed user response the stream can stall. Runtime. | +| `tokenDelta` | 8 | **handled** → usage (`proxy.ts:1117-1119`) | — | +| `summary` | 9 | ignored | long-context compaction text is dropped from the transcript; not a hang by itself | +| `summaryStarted` | 10 | ignored | minor | +| `summaryCompleted` | 11 | ignored | minor | +| `shellOutputDelta` | 12 | ignored | background-shell stream output not relayed; proxy rejects `backgroundShellSpawnArgs` (`proxy.ts:1301-1316`) so likely unused. Runtime. | +| `heartbeat` | 13 | ignored — but its transport arrival resets the bridge idle timer via `h2Stream.on("data")` (`h2-bridge.mjs:154-155`) | **The trickle hazard**: server heartbeats keep the only bridge-side bound (T-T2) from firing while producing zero semantic progress. | +| `turnEnded` | 14 | **ignored** | If the server signals turn completion here and does not immediately end the H2 stream, the proxy never terminates — a leading hang candidate. Whether Cursor sends `turnEnded` and whether it precedes stream end is runtime; the silent drop is source-proven. | +| `stepStarted` | 16 | ignored | minor | +| `stepCompleted` | 17 | ignored | minor | + +Summary: **3 of 17** `InteractionUpdate` cases produce output (`textDelta`, `thinkingDelta`, +`tokenDelta`); **4 of 6** `AgentServerMessage` cases are dispatched. All other cases are +silently discarded while any of them — even `heartbeat` — resets the bridge idle timer. + +### ExecServerMessage handling (`proxy.ts:1175-1353`, cases at `agent_pb.ts:6882-7002`) + +Every known exec case is answered: `requestContextArgs` builds context; `mcpArgs` pauses for a +tool result; native args (`readArgs`, `lsArgs`, `grepArgs`, `writeArgs`, `deleteArgs`, +`shellArgs`/`shellStreamArgs`, `backgroundShellSpawnArgs`, `writeShellStdinArgs`, `fetchArgs`, +`diagnosticsArgs`) are redirected or rejected; `listMcpResourcesExecArgs`, +`readMcpResourceExecArgs`, `recordScreenArgs`, `computerUseArgs` get an empty `McpResult` +(`proxy.ts:1339-1349`). **Unknown exec cases** log `[proxy] unhandled exec` and send nothing +(`1351-1353`) — the server waits on an unanswered exec → stall risk. Runtime to confirm any +newer exec case reaches the wire. + +## 2.5 Parse/write failure matrix + +| # | Failure | Where | What actually happens | Classification | +|---|---|---|---|---| +| F1 | Partial Connect frame at stream end | `proxy.ts:980-995` buffers; reader loop exits at `402-405` | pending bytes discarded silently; cascade to T4/T5 | **silent truncation** — source-proven | +| F2 | `fromBinary` protobuf parse failure | `proxy.ts:1554-1556` catch → skip; also `1812-1814` | frame silently dropped, stream stays open; if the dropped frame was terminal → HANG | **HANGS (potential)** — source-proven silent drop | +| F3 | End-stream body not valid JSON / empty | `proxy.ts:947-960` | false `Error` → T3 error terminal | **terminates with error (false positive)** — source-proven | +| F4 | Unknown message case decodes fine | `proxy.ts:1079-1101` falls through | silently ignored (§2.4) | **HANGS (potential)** — source-proven | +| F5 | `bridge.write` to a dead/exiting child | `proxy.ts:411-413` try/catch | throw swallowed; frame lost silently → resumed stream may wait forever | **HANGS (potential)** — source-proven | +| F6 | Write to closed/destroyed H2 stream | `h2-bridge.mjs:194-196` guard skip | frame dropped silently; process stays alive so the parent believes the bridge is healthy | **HANGS (potential)** — source-proven | +| F7 | SSE enqueue on a cancelled/errored stream | `proxy.ts:1426-1430` no guard; no `cancel()` handler | after client abort the bridge is never torn down (§2.2 T7) | **leak** — source-proven; Bun enqueue-after-cancel behavior runtime | +| F8 | Child stdout pipe write backpressure | `h2-bridge.mjs:27-32` | `process.stdout.write` without drain handling; parent drains continuously (`proxy.ts:334-369`), so risk is low | **low risk** — source-proven | + +## 2.6 Resume matrix + +Bridge retention starts at tool pause (`proxy.ts:1523-1530`); lookup and eligibility at +`proxy.ts:610-624`; actual resume at `handleToolResultResume` (`proxy.ts:1651-1732`). + +| # | Scenario | Route | Outcome | Classification | +|---|---|---|---|---| +| R1 | Live healthy resume | `activeBridges.get` → `alive` true → `handleToolResultResume` (`proxy.ts:615-617`) | mcpResult/native frames sent per `pendingExec` (`1661-1721`); a new `createBridgeStreamResponse` streams the rest (`1723-1727`) | **terminates cleanly when transport healthy** — source-proven | +| R2 | Process-alive but transport-dead bridge resume | eligibility checks only `alive` = process existence (`proxy.ts:410`, `615`) | writes silently dropped at F6 (`h2-bridge.mjs:194-196`) → resumed SSE never receives data → HANG (bounded ~255s streaming truncation) | **HANGS (risk)** — liveness gap source-proven; frequency runtime | +| R3 | Dead-bridge resume (process exited) | `alive` false → cleanup + `bridge.end()` + fresh request (`proxy.ts:619-624`) with `resumeAction` over reconstructed history (`909-911`) | context preserved; fresh bridge path | **terminates via fresh path** — source-proven | +| R4 | Bridge-key collision | `deriveBridgeKey` = sha256(model + first 200 chars of first user message) (`proxy.ts:1374-1381`) | two conversations with the same model+opening map to one `activeBridges` slot; the second pause overwrites the first entry without ending the first bridge (`1523-1530`) and `pendingExecs` are shared | **collision risk** — keying source-proven; consequences runtime | +| R5 | Dropped write during resume (child mid-exit race) | F5/F6 | result frame lost silently → resumed stream never progresses | **HANGS (risk)** — source-proven | +| R6 | Pause with no resume (abandoned tool call) | no TTL on `activeBridges`; only `stopProxy` clears (`578-582`) | bridge + heartbeat + H2 session leak indefinitely (§2.2 T8) | **leak** — source-proven | + +## 2.7 Verdicts on the five known mechanisms + +Status is against direct source reading in this part. + +1. **`parseConnectEndStream` clean no-op + empty-body false error — CONFIRMED (source).** + `proxy.ts:947-960` returns `null` for JSON without `error`; the caller is `if (endError)` + (`proxy.ts:1567`) so a clean end-stream is a no-op and termination depends entirely on the + bridge-exit cascade (T4). A zero-byte body makes `JSON.parse` throw → false error (T3). +2. **Unhandled message families + raw-byte timer resets — CONFIRMED (source).** 4 of 6 + `AgentServerMessage` and 3 of 17 `InteractionUpdate` cases are dispatched (§2.4); + `turnEnded` and `heartbeat` are dropped, and **any** inbound byte plus the proxy's own 5s + heartbeat defeats the only bridge-side idle bound (T-T2). The *hang outcome* of dropped + `turnEnded`/`interactionQuery` depends on whether the server waits (runtime), but the + silent drops themselves are source-proven. +3. **Non-zero bridge exit reported as clean success — CONFIRMED (source).** + `proxy.ts:1594-1601` emits `finish_reason: "stop"` + `[DONE]` for `code !== 0` whenever no + exec was received; matches upstream issue #33. +4. **Unbounded token refresh — CONFIRMED (source).** `refreshCursorToken` + (`auth.ts:143-177`) fetches with no `AbortSignal`/timeout and is awaited before headers in + the streaming path (`proxy.ts:543-545`) and in `collectFullResponse`'s caller — a stalled + exchange is a pre-header hang. +5. **Bridge reuse checks only process aliveness — CONFIRMED (source).** Eligibility is + `activeBridge.bridge.alive` (`proxy.ts:615`), which is `!exited` (`proxy.ts:410`); a live + child with a dead H2 session/stream accepts resume and silently drops writes + (`h2-bridge.mjs:194-196`). The failure *frequency* needs runtime evidence, but the + liveness gap itself is source-proven. + +**Mechanisms that still require runtime evidence from the controllable fixture:** +whether Cursor sends `turnEnded` (and whether it precedes stream end), `execServerControlMessage +abort`, or `interactionQuery` cases in the failing models; the server's behavior after a clean +end-stream envelope (does the H2 stream always end?); Node's event sequence on GOAWAY/session +close (T6); and Bun's SSE enqueue behavior after client abort (T7). + +## 2.8 Instrumentation gaps closed in this pass + +Only two diagnostics-only edits were made while mapping; both are gated, allowlisted, and +change no termination or protocol behavior: + +1. **`end_stream` stage** (`src/proxy.ts:1560-1564`, stage/field plumbing in + `src/auth.ts:24,30,66`). Previously the Connect end-stream event was invisible: a clean + end-stream produced no diagnostic and an empty/failed one was only visible through the + resulting error terminal. The new event carries `byteLength` and `status: "clean"|"error"` + only — no payload — so an operator can now distinguish "end-stream received cleanly but + stream still open" (no `terminal` after it) from "no end-stream at all". +2. **Bridge timeout-kill cause** (`src/h2-bridge.mjs:115-127`). Timeout kills previously + exited with **no** bridge-side diagnostic, so a 30s connect timeout, a 120s idle timeout, + a client error, and a stream error were indistinguishable in the proxy's `terminal` + record. `killBridge` now emits `terminal` with `errorName: "Timeout:connect"` or + `"Timeout:idle"` plus `exitCode: 1`. + +## 2.9 Diagnostic capture instructions + +**Enable:** set `CURSOR_PROXY_DIAGNOSTICS=1` in the environment of the OpenCode process (the +flag is read at `src/auth.ts:33-35`). Diagnostics are off by default; the child stderr pipe is +always drained regardless of the flag so disabling cannot reintroduce backpressure. + +**Reproduce a hang:** with the flag set, start OpenCode with a Cursor model, send a request +that exhibits the symptom, and wait past the stall. Capture the process stderr to a file: +`opencode 2>cursor-diag.log`. + +**Correlation:** every line for one request shares the same `requestId`. Bridge lines carry +`parentElapsedMs` (the proxy-side elapsed time at spawn) alongside their own `elapsedMs`, so +proxy and child records can be ordered across the process boundary. + +**Read the output to identify the stalled stage:** + +| Last stage(s) observed | Interpretation | +|---|---| +| `auth_access_start` … no `auth_access_complete` | stalled in the token provider / `refreshCursorToken` — pre-header hang (M4) | +| `bridge_spawn` … no `bridge_start` | child failed to boot or read config | +| `bridge_start` … no `h2_connect` | stalled connecting to `api2.cursor.sh`; expect `terminal exitCode 1` after 30s (`errorName: "Timeout:connect"`), then the masked-clean T5 path | +| `h2_connect` + `connect_stream` … no `bridge_data` | server accepted the stream but sends nothing — **the silent-stall signature** (T10); heartbeats keep the bridge timer defeated, so only Bun's 255s idle (streaming) or nothing (pre-header) bounds it | +| repeated `bridge_data` … no `connect_frame`/`message_dispatch` | bytes flow but no valid Connect frame parses (F1/F2) | +| repeated `message_dispatch` with `interactionCase: "heartbeat"`/`"turnEnded"`/`"stepCompleted"` and no `textDelta` | ignored-message trickle — semantic progress absent while timers keep resetting (§2.4 heartbeat/turnEnded rows) | +| `tool_pause` … no `tool_resume` | tool-call pause awaiting results (T8) | +| `end_stream` with `status: "clean"` … no `terminal` afterwards | clean end-stream was a no-op and the stream is being kept open (T1) — check whether the bridge ever exits | +| `terminal` with `exitCode: 1` (or `errorName: "Timeout:idle"`) followed by a clean `sse_emission` + `terminal` | masked non-zero bridge failure reported as clean success (T5) | +| `terminal` with `exitCode: 0` after `end_stream` | the normal clean cascade (T1→T4) | + +**Emitted-only-allowlist reminder:** stages, request ID, elapsed time, byte lengths, message +case names, tool counts, status/exit codes, and error class names. Tokens, authorization +headers, prompt text, tool arguments/results, and blob/checkpoint IDs are never serialized +(`src/auth.ts:48-67`; parent drain filter `src/proxy.ts:344-365`). diff --git a/src/auth.ts b/src/auth.ts index fdd25b4..e7b3aa5 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -21,12 +21,13 @@ const lifecycleDiagnosticContext = new AsyncLocalStorage<{ export type LifecycleDiagnosticStage = | "auth_access_start" | "auth_access_complete" | "auth_refresh_start" | "auth_refresh_complete" | "auth_refresh_failed" - | "proxy_entry" | "bridge_spawn" | "connect_frame" | "message_dispatch" + | "proxy_entry" | "bridge_spawn" | "connect_frame" | "end_stream" + | "message_dispatch" | "tool_pause" | "tool_resume" | "sse_emission" | "terminal"; type LifecycleDiagnosticDetails = Readonly<{ byteLength?: number; messageCase?: string; interactionCase?: string; - toolCount?: number; exitCode?: number; errorName?: string; + toolCount?: number; exitCode?: number; errorName?: string; status?: string; }>; /** Diagnostics are opt-in because even safe metadata is operational noise. */ @@ -62,6 +63,7 @@ export function emitLifecycleDiagnostic( if (typeof details.toolCount === "number") event.toolCount = details.toolCount; if (typeof details.exitCode === "number") event.exitCode = details.exitCode; if (typeof details.errorName === "string") event.errorName = details.errorName; + if (typeof details.status === "string") event.status = details.status; console.error(JSON.stringify(event)); } diff --git a/src/h2-bridge.mjs b/src/h2-bridge.mjs index 3270468..89e3482 100644 --- a/src/h2-bridge.mjs +++ b/src/h2-bridge.mjs @@ -111,15 +111,18 @@ client.on("connect", () => emitDiagnostic("h2_connect")); // Guard against initial connection failure. Reset on any h2 activity // so long-running agent conversations (with tool call round-trips) survive. -let timeout = setTimeout(killBridge, 30_000); +let timeoutReason = "connect"; +let timeout = setTimeout(() => killBridge(timeoutReason), 30_000); function resetTimeout() { clearTimeout(timeout); - timeout = setTimeout(killBridge, 120_000); + timeoutReason = "idle"; + timeout = setTimeout(() => killBridge(timeoutReason), 120_000); } -function killBridge() { +function killBridge(reason = "connect") { clearTimeout(timeout); + emitDiagnostic("terminal", { errorName: `Timeout:${reason}`, exitCode: 1 }); try { client.destroy(); } catch {} process.exit(1); } diff --git a/src/proxy.ts b/src/proxy.ts index 10d2f03..e721536 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1557,6 +1557,10 @@ function createBridgeStreamResponse( }, (endStreamBytes) => { const endError = parseConnectEndStream(endStreamBytes); + emitLifecycleDiagnostic("proxy", "end_stream", { + byteLength: endStreamBytes.length, + status: endError ? "error" : "clean", + }); if (process.env.CURSOR_PROXY_DEBUG) { console.error(`[proxy] endStream: ${endError ? endError.message : "clean"}`); } From e0531c595fc21f7b058cf09d0be2d91c6c863b0a Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:57:07 -0700 Subject: [PATCH 05/22] chore: exempt overlay-baseline.patch from whitespace checks docs/overlay-baseline.patch is a verbatim preserved diff artifact whose context lines for blank source lines are single-space lines, so git diff --check reports them as trailing whitespace. Exempt the file via .gitattributes instead of stripping the whitespace, which would corrupt the evidence and break git apply fidelity. Document the exemption so the artifact is never reflowed. --- .gitattributes | 5 +++++ docs/cursor-hang-root-cause.md | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5284921 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# docs/overlay-baseline.patch is a verbatim preserved evidence artifact: a unified diff +# represents a blank source line as a context line containing a single space, which +# `git diff --check` flags as trailing whitespace. This is intentional — never strip or +# reflow the file, or git apply fidelity is lost. +docs/overlay-baseline.patch -whitespace diff --git a/docs/cursor-hang-root-cause.md b/docs/cursor-hang-root-cause.md index 2f24e01..991f0d8 100644 --- a/docs/cursor-hang-root-cause.md +++ b/docs/cursor-hang-root-cause.md @@ -33,6 +33,12 @@ The complete, verbatim pre-disposition diff is preserved in [`docs/overlay-baseline.patch`](./overlay-baseline.patch). Every line removed below remains recoverable and reviewable from that artifact. Nothing was silently discarded. +> **Whitespace exemption (read before "tidying"):** `docs/overlay-baseline.patch` is excluded +> from git's whitespace checks via `.gitattributes` (`-whitespace`). A unified diff encodes a +> blank source line as a context line containing a single space, so `git diff --check` flags +> the file by design. Never reflow, reformat, or strip trailing whitespace from this artifact — +> doing so corrupts the evidence and breaks `git apply` fidelity. + ## Hunk inventory and disposition | # | File | Location | Change | Apparent intent | Hang mechanism affected | Verdict | From ce60edc81faa5a2413c17ab9f3afdd689a1bb1a1 Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:03:12 -0700 Subject: [PATCH 06/22] test(connect): add bounded Cursor transport fixture Provide a controllable HTTP/2 Connect backend for terminal, trickle, and transport-failure scenarios. Keep every synthetic failure bounded and assert fixture resource accounting so future regression tests cannot strand the suite. --- test/fixtures/fake-cursor-server.ts | 256 ++++++++++++++++++++++++++++ test/smoke.ts | 69 ++++++++ 2 files changed, 325 insertions(+) create mode 100644 test/fixtures/fake-cursor-server.ts diff --git a/test/fixtures/fake-cursor-server.ts b/test/fixtures/fake-cursor-server.ts new file mode 100644 index 0000000..d04c2b9 --- /dev/null +++ b/test/fixtures/fake-cursor-server.ts @@ -0,0 +1,256 @@ +import http2 from "node:http2"; +import type { AddressInfo } from "node:net"; +import { create, toBinary } from "@bufbuild/protobuf"; +import { + AgentServerMessageSchema, + ConversationStateStructureSchema, + HeartbeatUpdateSchema, + InteractionUpdateSchema, + TextDeltaUpdateSchema, +} from "../../src/proto/agent_pb"; + +const CONNECT_END_STREAM_FLAG = 0b00000010; + +export type CursorScenario = + | "clean-end-stream" + | "error-end-stream" + | "empty-end-stream" + | "missing-end-stream" + | "malformed-terminal" + | "partial-frame" + | "slow-semantic-progress" + | "heartbeat-trickle" + | "checkpoint-trickle" + | "abrupt-session-close" + | "frame-error" + | "goaway" + | "transport-death"; + +export const CURSOR_SCENARIOS: readonly CursorScenario[] = [ + "clean-end-stream", + "error-end-stream", + "empty-end-stream", + "missing-end-stream", + "malformed-terminal", + "partial-frame", + "slow-semantic-progress", + "heartbeat-trickle", + "checkpoint-trickle", + "abrupt-session-close", + "frame-error", + "goaway", + "transport-death", +] as const; + +export interface FakeCursorServer { + apiUrl: string; + setScenario: (scenario: CursorScenario) => void; + assertClean: () => void; + close: () => Promise; + /** Test-only resource accounting hooks for proving cleanup assertions fire. */ + trackChild: (pid: number) => void; + untrackChild: (pid: number) => void; + trackBridgeEntry: (key: string) => void; + untrackBridgeEntry: (key: string) => void; +} + +/** Connect envelope: flags, four-byte big-endian payload size, then payload. */ +export function frameConnectMessage(payload: Uint8Array, flags = 0): Buffer { + const frame = Buffer.alloc(5 + payload.length); + frame[0] = flags; + frame.writeUInt32BE(payload.length, 1); + frame.set(payload, 5); + return frame; +} + +export function frameConnectEndStream(payload: Uint8Array): Buffer { + return frameConnectMessage(payload, CONNECT_END_STREAM_FLAG); +} + +/** Deliberately violates the advertised Connect payload length. */ +export function frameMalformedConnectMessage(payload: Uint8Array, advertisedLength: number): Buffer { + const frame = frameConnectMessage(payload); + frame.writeUInt32BE(advertisedLength, 1); + return frame; +} + +function serverMessage(message: Parameters[1]): Buffer { + return toBinary(AgentServerMessageSchema, create(AgentServerMessageSchema, message as never)); +} + +function textDelta(text: string): Buffer { + return serverMessage({ + message: { + case: "interactionUpdate", + value: create(InteractionUpdateSchema, { + message: { + case: "textDelta", + value: create(TextDeltaUpdateSchema, { text }), + }, + }), + }, + }); +} + +function heartbeat(): Buffer { + return serverMessage({ + message: { + case: "interactionUpdate", + value: create(InteractionUpdateSchema, { + message: { case: "heartbeat", value: create(HeartbeatUpdateSchema, {}) }, + }), + }, + }); +} + +function checkpoint(): Buffer { + return serverMessage({ + message: { + case: "conversationCheckpointUpdate", + value: create(ConversationStateStructureSchema, {}), + }, + }); +} + +function isScenario(value: string): value is CursorScenario { + return (CURSOR_SCENARIOS as readonly string[]).includes(value); +} + +/** + * A local Connect-over-H2 server with bounded failure sequences. The request header + * x-fake-cursor-scenario selects a scenario; setScenario supplies the default. + */ +export async function createFakeCursorServer(options: { deadlineMs?: number } = {}): Promise { + const deadlineMs = options.deadlineMs ?? 100; + const server = http2.createServer(); + const sessions = new Set(); + const streams = new Set(); + const childPids = new Set(); + const bridgeEntries = new Set(); + let defaultScenario: CursorScenario = "clean-end-stream"; + + server.on("session", (session) => { + sessions.add(session); + session.once("close", () => sessions.delete(session)); + }); + + server.on("stream", (stream, headers) => { + streams.add(stream); + stream.once("close", () => streams.delete(stream)); + stream.resume(); + if (headers[":path"] !== "/agent.v1.AgentService/Run") { + stream.respond({ ":status": 404 }); + stream.end(); + return; + } + + const requested = String(headers["x-fake-cursor-scenario"] ?? defaultScenario); + const scenario = isScenario(requested) ? requested : defaultScenario; + let completed = false; + const timeout = setTimeout(() => { + if (!completed && !stream.closed && !stream.destroyed) { + stream.close(http2.constants.NGHTTP2_CANCEL); + } + }, deadlineMs); + const finish = () => { + completed = true; + clearTimeout(timeout); + }; + stream.once("close", finish); + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + + const endWith = (body: Uint8Array) => stream.end(frameConnectEndStream(body)); + const trickle = (payload: Buffer) => { + let sent = 0; + const interval = setInterval(() => { + if (stream.closed || stream.destroyed || ++sent > 3) { + clearInterval(interval); + if (!stream.closed && !stream.destroyed) stream.end(); + return; + } + stream.write(frameConnectMessage(payload)); + }, 10); + }; + + switch (scenario) { + case "clean-end-stream": + stream.end(Buffer.concat([frameConnectMessage(textDelta("complete")), frameConnectEndStream(new TextEncoder().encode("{}"))])); + break; + case "error-end-stream": + endWith(new TextEncoder().encode(JSON.stringify({ error: { code: "unavailable", message: "fixture failure" } }))); + break; + case "empty-end-stream": + endWith(new Uint8Array()); + break; + case "missing-end-stream": + stream.end(frameConnectMessage(textDelta("truncated"))); + break; + case "malformed-terminal": + stream.end(frameMalformedConnectMessage(new TextEncoder().encode("{"), 9)); + break; + case "partial-frame": + stream.end(frameConnectMessage(textDelta("partial")).subarray(0, 7)); + break; + case "slow-semantic-progress": { + let part = 0; + const interval = setInterval(() => { + if (stream.closed || stream.destroyed) return clearInterval(interval); + if (part < 3) stream.write(frameConnectMessage(textDelta(`part-${++part}`))); + else { + clearInterval(interval); + endWith(new TextEncoder().encode("{}")); + } + }, 15); + break; + } + case "heartbeat-trickle": + trickle(heartbeat()); + break; + case "checkpoint-trickle": + trickle(checkpoint()); + break; + case "abrupt-session-close": + stream.write(frameConnectMessage(textDelta("before-close"))); + setTimeout(() => stream.session.destroy(), 10); + break; + case "frame-error": + stream.write(frameConnectMessage(textDelta("before-reset"))); + setTimeout(() => stream.close(http2.constants.NGHTTP2_PROTOCOL_ERROR), 10); + break; + case "goaway": + stream.write(frameConnectMessage(textDelta("before-goaway"))); + setTimeout(() => stream.session.goaway(http2.constants.NGHTTP2_NO_ERROR), 10); + break; + case "transport-death": + setTimeout(() => stream.session.destroy(), 10); + break; + } + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as AddressInfo).port; + return { + apiUrl: `http://127.0.0.1:${port}`, + setScenario(scenario) { defaultScenario = scenario; }, + trackChild(pid) { childPids.add(pid); }, + untrackChild(pid) { childPids.delete(pid); }, + trackBridgeEntry(key) { bridgeEntries.add(key); }, + untrackBridgeEntry(key) { bridgeEntries.delete(key); }, + assertClean() { + const leaks = [ + sessions.size && `${sessions.size} H2 session(s)`, + streams.size && `${streams.size} H2 stream(s)`, + childPids.size && `${childPids.size} child process(es)`, + bridgeEntries.size && `${bridgeEntries.size} bridge map entry(ies)`, + ].filter(Boolean); + if (leaks.length) throw new Error(`Fake Cursor fixture leaked ${leaks.join(", ")}`); + }, + async close() { + for (const session of sessions) session.destroy(); + await new Promise((resolve, reject) => + server.close((error) => error ? reject(error) : resolve()), + ); + this.assertClean(); + }, + }; +} diff --git a/test/smoke.ts b/test/smoke.ts index 21e0616..58a51ef 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -6,6 +6,11 @@ import { GetUsableModelsResponseSchema, ModelDetailsSchema, } from "../src/proto/agent_pb"; +import { + CURSOR_SCENARIOS, + createFakeCursorServer, + type CursorScenario, +} from "./fixtures/fake-cursor-server"; type DiscoveryMode = "success" | "empty" | "auth-error"; @@ -420,6 +425,69 @@ async function testArrayContentParsing(modules: TestModules) { console.log("[test] Array content parsing OK"); } +async function runFakeScenario(apiUrl: string, scenario: CursorScenario): Promise { + const session = http2.connect(apiUrl); + const stream = session.request({ + ":method": "POST", + ":path": "/agent.v1.AgentService/Run", + "x-fake-cursor-scenario": scenario, + }); + const chunks: Buffer[] = []; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + stream.close(http2.constants.NGHTTP2_CANCEL); + session.destroy(); + reject(new Error(`Fixture scenario exceeded its deadline: ${scenario}`)); + }, 500); + const finish = (error?: Error) => { + clearTimeout(timer); + session.destroy(); + if (error) reject(error); + else resolve(Buffer.concat(chunks)); + }; + stream.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + stream.once("end", () => finish()); + stream.once("close", () => finish()); + stream.once("error", () => finish()); + stream.end(); + }); +} + +async function testFakeCursorScenarioHarness() { + console.log("[test] Testing bounded fake Cursor scenario harness..."); + const fixture = await createFakeCursorServer({ deadlineMs: 100 }); + try { + for (const scenario of CURSOR_SCENARIOS) { + fixture.setScenario(scenario); + await runFakeScenario(fixture.apiUrl, scenario); + } + + fixture.trackChild(4242); + let caughtLeak = false; + try { + fixture.assertClean(); + } catch (error) { + caughtLeak = error instanceof Error && error.message.includes("child process"); + } + assert(caughtLeak, "Fixture cleanup assertion did not detect a deliberately leaked child"); + fixture.untrackChild(4242); + fixture.trackBridgeEntry("deliberate-leak"); + let caughtBridgeLeak = false; + try { + fixture.assertClean(); + } catch (error) { + caughtBridgeLeak = error instanceof Error && error.message.includes("bridge map entry"); + } + assert(caughtBridgeLeak, "Fixture cleanup assertion did not detect a deliberately leaked bridge entry"); + fixture.untrackBridgeEntry("deliberate-leak"); + await Bun.sleep(25); + fixture.assertClean(); + } finally { + await fixture.close(); + } + console.log(`[test] Fake Cursor scenarios selectable (${CURSOR_SCENARIOS.length}) and cleanup assertions OK`); +} + async function testLifecycleDiagnostics(modules: TestModules) { console.log("[test] Testing redacted lifecycle diagnostics..."); const originalSetting = process.env.CURSOR_PROXY_DIAGNOSTICS; @@ -615,6 +683,7 @@ async function main() { await testTokenExpiry(modules); await testPluginShape(modules); await testArrayContentParsing(modules); + await testFakeCursorScenarioHarness(); await testLifecycleDiagnostics(modules); await testExpiredTokenRefreshBeforeDiscovery(modules, backend); await testDiscoveryFallbackAndSuccess(modules, backend); From dbabfaf2fdfa48abd15c8dc2b38ff06b9d89f45e Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:12:57 -0700 Subject: [PATCH 07/22] test(proxy): reproduce streaming termination failures Exercise deterministic Connect terminal sequences through the live proxy and preserve the observed baseline behavior. Keep pre-fix assertions quarantined so the smoke gate stays usable while later fixes promote them to required checks. --- docs/cursor-hang-root-cause.md | 24 ++++++ test/fixtures/fake-cursor-server.ts | 35 ++++++++ test/smoke.ts | 129 ++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+) diff --git a/docs/cursor-hang-root-cause.md b/docs/cursor-hang-root-cause.md index 991f0d8..2438fec 100644 --- a/docs/cursor-hang-root-cause.md +++ b/docs/cursor-hang-root-cause.md @@ -408,3 +408,27 @@ proxy and child records can be ordered across the process boundary. case names, tool counts, status/exit codes, and error class names. Tokens, authorization headers, prompt text, tool arguments/results, and blob/checkpoint IDs are never serialized (`src/auth.ts:48-67`; parent drain filter `src/proxy.ts:344-365`). + +## 2.10 Runtime reproduction verdicts + +The local fake Connect/HTTP2 backend now drives the proxy through isolated terminal sequences. +Each subprocess and fixture owns a deadline; the full smoke suite completes in about 7.8 seconds. +The intentionally failing assertions are quarantined in `test/smoke.ts`: they report their +observed baseline without making the smoke command fail. The fixture cannot prove what a live +Cursor server chooses to send; it can prove the proxy's response to each wire sequence below. + +| Candidate | Verdict | Harness evidence | +|---|---|---| +| Valid JSON end-stream is a no-op | **DOES NOT REPRODUCE as an open client SSE** | A `{}` end-stream followed by the local H2 lifecycle produced `[DONE]` through the bridge-exit cascade. The parser/caller no-op remains source-proven, but this fixture cannot keep the Node bridge open after that sequence, so it does not establish an externally visible hang. | +| Empty end-stream body | **REPRODUCES** | The clean zero-byte terminal produced `[Error: Failed to parse Connect end stream]`; the quarantined assertion requires a clean completion. | +| Missing end-stream | **REPRODUCES** | A text frame followed by transport EOF produced `[DONE]` and `finish_reason:"stop"`, with no error content. The required explicit protocol error assertion fails. | +| Non-zero bridge exit | **REPRODUCES** | An HTTP/2 protocol reset produced `[DONE]` plus `finish_reason:"stop"`, without error content. This is the false-success path reported upstream. | +| Malformed terminal frame | **REPRODUCES** | A terminal frame with an advertised length larger than its payload produced clean `[DONE]`, with no error content. The client receives silent truncation rather than a protocol error. | +| Heartbeat-only trickle | **REPRODUCES** | Repeated valid heartbeat frames generated no semantic SSE content. The fixture deadline finally forced transport closure, after which the proxy emitted clean `[DONE]` rather than an error. Because both bridge activity resets occur on every inbound byte/write, an unbounded producer can repeat this sequence indefinitely; the test fixture bounds the proof at one second so CI cannot hang. | +| `turnEnded` | **REPRODUCES as silent loss; live Cursor semantics remain deferred** | A valid `turnEnded` frame generated zero semantic SSE bytes, then the fixture deadline forced a clean `[DONE]` transport-close cascade. This proves local dropping and false success, not that real Cursor uses `turnEnded` as its terminal signal or sends it in this order. | +| Slow semantic progress | **PASSES** | Three delayed text deltas (`part-1` through `part-3`) all reached SSE and completed with `[DONE]` without an error. Any later liveness fix must preserve this behavior. | + +These results resolve the terminal-matrix runtime rows for T1, T3, T5, T9, and the +heartbeat/`turnEnded` portions of T10 as harness-confirmed. T6's precise Node GOAWAY/session +event ordering remains **unknown-needs-runtime-evidence**, since the local fixture cannot claim +that its event sequence matches Cursor's production transport. diff --git a/test/fixtures/fake-cursor-server.ts b/test/fixtures/fake-cursor-server.ts index d04c2b9..89952b0 100644 --- a/test/fixtures/fake-cursor-server.ts +++ b/test/fixtures/fake-cursor-server.ts @@ -7,12 +7,14 @@ import { HeartbeatUpdateSchema, InteractionUpdateSchema, TextDeltaUpdateSchema, + TurnEndedUpdateSchema, } from "../../src/proto/agent_pb"; const CONNECT_END_STREAM_FLAG = 0b00000010; export type CursorScenario = | "clean-end-stream" + | "clean-end-stream-kept-open" | "error-end-stream" | "empty-end-stream" | "missing-end-stream" @@ -20,6 +22,8 @@ export type CursorScenario = | "partial-frame" | "slow-semantic-progress" | "heartbeat-trickle" + | "heartbeat-trickle-kept-open" + | "turn-ended-kept-open" | "checkpoint-trickle" | "abrupt-session-close" | "frame-error" @@ -28,6 +32,7 @@ export type CursorScenario = export const CURSOR_SCENARIOS: readonly CursorScenario[] = [ "clean-end-stream", + "clean-end-stream-kept-open", "error-end-stream", "empty-end-stream", "missing-end-stream", @@ -35,6 +40,8 @@ export const CURSOR_SCENARIOS: readonly CursorScenario[] = [ "partial-frame", "slow-semantic-progress", "heartbeat-trickle", + "heartbeat-trickle-kept-open", + "turn-ended-kept-open", "checkpoint-trickle", "abrupt-session-close", "frame-error", @@ -103,6 +110,17 @@ function heartbeat(): Buffer { }); } +function turnEnded(): Buffer { + return serverMessage({ + message: { + case: "interactionUpdate", + value: create(InteractionUpdateSchema, { + message: { case: "turnEnded", value: create(TurnEndedUpdateSchema, {}) }, + }), + }, + }); +} + function checkpoint(): Buffer { return serverMessage({ message: { @@ -131,11 +149,13 @@ export async function createFakeCursorServer(options: { deadlineMs?: number } = server.on("session", (session) => { sessions.add(session); + session.on("error", () => {}); session.once("close", () => sessions.delete(session)); }); server.on("stream", (stream, headers) => { streams.add(stream); + stream.on("error", () => {}); stream.once("close", () => streams.delete(stream)); stream.resume(); if (headers[":path"] !== "/agent.v1.AgentService/Run") { @@ -171,11 +191,20 @@ export async function createFakeCursorServer(options: { deadlineMs?: number } = stream.write(frameConnectMessage(payload)); }, 10); }; + const trickleUntilClosed = (payload: Buffer) => { + const interval = setInterval(() => { + if (stream.closed || stream.destroyed) return clearInterval(interval); + stream.write(frameConnectMessage(payload)); + }, 10); + }; switch (scenario) { case "clean-end-stream": stream.end(Buffer.concat([frameConnectMessage(textDelta("complete")), frameConnectEndStream(new TextEncoder().encode("{}"))])); break; + case "clean-end-stream-kept-open": + stream.write(frameConnectEndStream(new TextEncoder().encode("{}"))); + break; case "error-end-stream": endWith(new TextEncoder().encode(JSON.stringify({ error: { code: "unavailable", message: "fixture failure" } }))); break; @@ -206,6 +235,12 @@ export async function createFakeCursorServer(options: { deadlineMs?: number } = case "heartbeat-trickle": trickle(heartbeat()); break; + case "heartbeat-trickle-kept-open": + trickleUntilClosed(heartbeat()); + break; + case "turn-ended-kept-open": + stream.write(frameConnectMessage(turnEnded())); + break; case "checkpoint-trickle": trickle(checkpoint()); break; diff --git a/test/smoke.ts b/test/smoke.ts index 58a51ef..4328318 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -488,6 +488,134 @@ async function testFakeCursorScenarioHarness() { console.log(`[test] Fake Cursor scenarios selectable (${CURSOR_SCENARIOS.length}) and cleanup assertions OK`); } +interface SseObservation { + body: string; + timedOut: boolean; +} + +async function observeProxyScenario(scenario: CursorScenario, deadlineMs = 125): Promise { + const fixture = await createFakeCursorServer({ deadlineMs: 1_000 }); + fixture.setScenario(scenario); + const childScript = ` + import { startProxy, stopProxy } from "./src/proxy.ts"; + const port = await startProxy(async () => "test-token"); + const response = await fetch("http://localhost:" + port + "/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "test", stream: true, messages: [{ role: "user", content: "reproduce termination behavior" }] }), + }); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let body = ""; + const drain = (async () => { + while (true) { + const { done, value } = await reader.read(); + if (done) return { body, timedOut: false }; + body += decoder.decode(value, { stream: true }); + } + })(); + const result = await Promise.race([ + drain, + new Promise((resolve) => setTimeout(() => resolve({ body, timedOut: true }), ${deadlineMs})), + ]); + void reader.cancel().catch(() => {}); + stopProxy(); + console.log(JSON.stringify(result)); + `; + const child = Bun.spawn({ + cmd: [process.execPath, "-e", childScript], + cwd: process.cwd(), + env: { ...process.env, CURSOR_API_URL: fixture.apiUrl }, + stdout: "pipe", + stderr: "pipe", + }); + try { + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + assertEqual(exitCode, 0, `Scenario subprocess failed (${scenario}): ${stderr}`); + return JSON.parse(stdout.trim()) as SseObservation; + } finally { + await fixture.close(); + } +} + +async function expectKnownFailure( + label: string, + expectedBehavior: () => Promise, + observed: () => string, +) { + try { + await expectedBehavior(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(`[known-failure] ${label}: ${observed()} (${message})`); + return; + } + throw new Error(`Known failure unexpectedly passed: ${label}; remove its quarantine and make it required`); +} + +async function testStreamingTerminationKnownFailures() { + console.log("[test] Reproducing quarantined streaming termination failures..."); + + const cleanEnd = await observeProxyScenario("clean-end-stream-kept-open"); + assert(!cleanEnd.timedOut && cleanEnd.body.includes("data: [DONE]"), "Clean end-stream must finish in the local H2 fixture"); + console.log("[test] Valid end-stream does not reproduce as an open SSE: bridge exit cascades to [DONE]"); + + const emptyEnd = await observeProxyScenario("empty-end-stream"); + await expectKnownFailure( + "empty end-stream is clean success", + async () => assert(!emptyEnd.body.includes("[Error:"), "Empty end-stream must not emit an error"), + () => `observed error=${emptyEnd.body.match(/\[Error:[^\]]+/)?.[0] ?? "none"}`, + ); + + const missingEnd = await observeProxyScenario("missing-end-stream"); + await expectKnownFailure( + "missing end-stream is explicit failure", + async () => assert(missingEnd.body.includes("[Error:"), "Missing end-stream must be surfaced as an error"), + () => `observed done=${missingEnd.body.includes("data: [DONE]")}, error=${missingEnd.body.includes("[Error:")}`, + ); + + const bridgeExit = await observeProxyScenario("frame-error"); + await expectKnownFailure( + "bridge exit is not clean success", + async () => assert(bridgeExit.body.includes("[Error:"), "Bridge exit must be surfaced as an error"), + () => `observed done=${bridgeExit.body.includes("data: [DONE]")}, finishStop=${bridgeExit.body.includes('"finish_reason":"stop"')}, error=${bridgeExit.body.includes("[Error:")}`, + ); + + const malformedTerminal = await observeProxyScenario("malformed-terminal"); + await expectKnownFailure( + "malformed terminal frame is explicit failure", + async () => assert(malformedTerminal.body.includes("[Error:"), "Malformed terminal frame must be surfaced as an error"), + () => `observed done=${malformedTerminal.body.includes("data: [DONE]")}, error=${malformedTerminal.body.includes("[Error:")}`, + ); + + const heartbeatTrickle = await observeProxyScenario("heartbeat-trickle-kept-open"); + await expectKnownFailure( + "heartbeat-only trickle cannot defer termination indefinitely", + async () => assert(heartbeatTrickle.body.includes("[Error:"), "Heartbeat-only traffic must reach an explicit error terminal"), + () => `observed timedOut=${heartbeatTrickle.timedOut}, semanticSse=${heartbeatTrickle.body.includes('"content"')}, done=${heartbeatTrickle.body.includes("data: [DONE]")}`, + ); + + const turnEnded = await observeProxyScenario("turn-ended-kept-open"); + await expectKnownFailure( + "turnEnded has a classified terminal response", + async () => assert(turnEnded.body.includes("[Error:"), "turnEnded must not become a clean transport-close success"), + () => `observed timedOut=${turnEnded.timedOut}, done=${turnEnded.body.includes("data: [DONE]")}, emittedBytes=${turnEnded.body.length}`, + ); + + const slowProgress = await observeProxyScenario("slow-semantic-progress", 500); + assert(!slowProgress.timedOut, "Slow semantic progress exceeded its bounded completion window"); + assert(!slowProgress.body.includes("[Error:"), "Slow semantic progress must not emit an error"); + for (const part of ["part-1", "part-2", "part-3"]) { + assert(slowProgress.body.includes(part), `Slow semantic progress lost ${part}`); + } + assert(slowProgress.body.includes("data: [DONE]"), "Slow semantic progress must finish normally"); + console.log("[test] Slow semantic progress survives bounded streaming completion"); +} + async function testLifecycleDiagnostics(modules: TestModules) { console.log("[test] Testing redacted lifecycle diagnostics..."); const originalSetting = process.env.CURSOR_PROXY_DIAGNOSTICS; @@ -684,6 +812,7 @@ async function main() { await testPluginShape(modules); await testArrayContentParsing(modules); await testFakeCursorScenarioHarness(); + await testStreamingTerminationKnownFailures(); await testLifecycleDiagnostics(modules); await testExpiredTokenRefreshBeforeDiscovery(modules, backend); await testDiscoveryFallbackAndSuccess(modules, backend); From 258e961b35264e5d26b3fbd3bc6ae622f5bd24a8 Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:22:17 -0700 Subject: [PATCH 08/22] test(proxy): reproduce resume and pre-header stalls Add bounded fixture coverage for tool resumption, bridge-key collisions, stalled refreshes, and non-streaming collection. Document the observed fallback behavior so later hardening can distinguish confirmed defects from transport states that remain unobservable in the fixture. --- docs/cursor-hang-root-cause.md | 27 ++++- test/fixtures/fake-cursor-server.ts | 59 ++++++++++- test/smoke.ts | 146 ++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+), 5 deletions(-) diff --git a/docs/cursor-hang-root-cause.md b/docs/cursor-hang-root-cause.md index 2438fec..b9c7e59 100644 --- a/docs/cursor-hang-root-cause.md +++ b/docs/cursor-hang-root-cause.md @@ -318,11 +318,11 @@ Bridge retention starts at tool pause (`proxy.ts:1523-1530`); lookup and eligibi | # | Scenario | Route | Outcome | Classification | |---|---|---|---|---| -| R1 | Live healthy resume | `activeBridges.get` → `alive` true → `handleToolResultResume` (`proxy.ts:615-617`) | mcpResult/native frames sent per `pendingExec` (`1661-1721`); a new `createBridgeStreamResponse` streams the rest (`1723-1727`) | **terminates cleanly when transport healthy** — source-proven | -| R2 | Process-alive but transport-dead bridge resume | eligibility checks only `alive` = process existence (`proxy.ts:410`, `615`) | writes silently dropped at F6 (`h2-bridge.mjs:194-196`) → resumed SSE never receives data → HANG (bounded ~255s streaming truncation) | **HANGS (risk)** — liveness gap source-proven; frequency runtime | +| R1 | Live healthy resume | `activeBridges.get` → `alive` true → `handleToolResultResume` (`proxy.ts:615-617`) | mcpResult/native frames sent per `pendingExec` (`1661-1721`); a new `createBridgeStreamResponse` streams the rest (`1723-1727`) | **terminates cleanly when transport healthy** — harness-confirmed: the paused bridge received its matching result and emitted `resumed-correctly` plus `[DONE]`. | +| R2 | Process-alive but transport-dead bridge resume | eligibility checks only `alive` = process existence (`proxy.ts:410`, `615`) | writes silently dropped at F6 (`h2-bridge.mjs:194-196`) → resumed SSE never receives data → HANG (bounded ~255s streaming truncation) | **HANGS (risk)** — liveness gap source-proven. The real H2 close fixture does **not** reproduce this exact state: the child exits, `alive` becomes false, and the proxy takes the fresh-request fallback. An alive child with a closed stream remains runtime-deferred. | | R3 | Dead-bridge resume (process exited) | `alive` false → cleanup + `bridge.end()` + fresh request (`proxy.ts:619-624`) with `resumeAction` over reconstructed history (`909-911`) | context preserved; fresh bridge path | **terminates via fresh path** — source-proven | -| R4 | Bridge-key collision | `deriveBridgeKey` = sha256(model + first 200 chars of first user message) (`proxy.ts:1374-1381`) | two conversations with the same model+opening map to one `activeBridges` slot; the second pause overwrites the first entry without ending the first bridge (`1523-1530`) and `pendingExecs` are shared | **collision risk** — keying source-proven; consequences runtime | -| R5 | Dropped write during resume (child mid-exit race) | F5/F6 | result frame lost silently → resumed stream never progresses | **HANGS (risk)** — source-proven | +| R4 | Bridge-key collision | `deriveBridgeKey` = sha256(model + first 200 chars of first user message) (`proxy.ts:1374-1381`) | two conversations with the same model+opening map to one `activeBridges` slot; the second pause overwrites the first entry without ending the first bridge (`1523-1530`) and `pendingExecs` are shared | **REPRODUCES** — harness-confirmed: resuming conversation A after conversation B paused sent A's result to B; B observed `resumed-with-wrong-result` and a clean `[DONE]`. Required behavior is per-conversation bridge isolation or an explicit error, never cross-conversation routing. | +| R5 | Dropped write during resume (child mid-exit race) | F5/F6 | result frame lost silently → resumed stream never progresses | **HANGS (risk)** — source-proven. The H2 close fixture does **not** reproduce a silent write: the child exits before resume and the proxy starts a fresh request. The process-alive write-drop race remains runtime-deferred. | | R6 | Pause with no resume (abandoned tool call) | no TTL on `activeBridges`; only `stopProxy` clears (`578-582`) | bridge + heartbeat + H2 session leak indefinitely (§2.2 T8) | **leak** — source-proven | ## 2.7 Verdicts on the five known mechanisms @@ -432,3 +432,22 @@ These results resolve the terminal-matrix runtime rows for T1, T3, T5, T9, and t heartbeat/`turnEnded` portions of T10 as harness-confirmed. T6's precise Node GOAWAY/session event ordering remains **unknown-needs-runtime-evidence**, since the local fixture cannot claim that its event sequence matches Cursor's production transport. + +## 2.11 Resume, refresh, and non-streaming reproduction verdicts + +The same bounded local fixture now exercises the tool-call continuation boundary. Expected +defects remain quarantined in the smoke output; a fresh request and a healthy continuation remain +ordinary passing assertions. + +| Candidate | Verdict | Harness evidence and required behavior | +|---|---|---| +| Live resume | **DOES NOT REPRODUCE** | A paused bridge received its matching tool result and emitted `resumed-correctly` followed by `[DONE]`. Later liveness work must preserve this path. | +| Dead resume | **DOES NOT REPRODUCE for a real H2 close** | Destroying the fixture session caused the Node child to exit. On resume, `alive` was false and the proxy started a fresh request, which returned a new `tool_calls` response. The exact process-alive/stream-dead state is still source-proven as a risk but runtime-deferred; correct behavior there is fresh fallback or an explicit error, never a silent write. | +| Bridge-key collision | **REPRODUCES** | Two paused conversations sharing the same model and first user-message prefix occupied one key. Resuming the first delivered its result to the second bridge, observed as `resumed-with-wrong-result` and clean `[DONE]`. Correct behavior requires distinct conversation identity in the bridge key or an explicit collision error. | +| Dropped resume write | **DOES NOT REPRODUCE for a real H2 close** | The close fixture exits the child before the proxy writes, so the fresh-request fallback wins. F5/F6 still prove that a process-alive write can be swallowed; a transport-dead-but-process-alive fixture is required to make that externally observable. Correct behavior is an explicit caller error and cleanup/fresh fallback. | +| Stalled token refresh | **REPRODUCES** | A local refresh endpoint accepted the request and never replied. After 125ms, the proxy request had returned no headers (`headersReturned=false`). Correct behavior is a bounded refresh failure before a client can wait indefinitely. | +| Non-streaming collection | **REPRODUCES** | A heartbeat-trickle fixture never ended the bridge; after 125ms the `stream:false` request still had no headers (`headersReturned=false`). Correct behavior is a bounded explicit terminal response independent of streaming SSE handling. | + +The bounded suite remains safe: subprocesses exit after recording each 125ms observation, and +the fake server closes every tracked session and stream. No production source seam was added for +these reproductions. diff --git a/test/fixtures/fake-cursor-server.ts b/test/fixtures/fake-cursor-server.ts index 89952b0..130a285 100644 --- a/test/fixtures/fake-cursor-server.ts +++ b/test/fixtures/fake-cursor-server.ts @@ -4,8 +4,10 @@ import { create, toBinary } from "@bufbuild/protobuf"; import { AgentServerMessageSchema, ConversationStateStructureSchema, + ExecServerMessageSchema, HeartbeatUpdateSchema, InteractionUpdateSchema, + McpArgsSchema, TextDeltaUpdateSchema, TurnEndedUpdateSchema, } from "../../src/proto/agent_pb"; @@ -28,7 +30,9 @@ export type CursorScenario = | "abrupt-session-close" | "frame-error" | "goaway" - | "transport-death"; + | "transport-death" + | "tool-pause-resume" + | "tool-pause-transport-death"; export const CURSOR_SCENARIOS: readonly CursorScenario[] = [ "clean-end-stream", @@ -47,6 +51,8 @@ export const CURSOR_SCENARIOS: readonly CursorScenario[] = [ "frame-error", "goaway", "transport-death", + "tool-pause-resume", + "tool-pause-transport-death", ] as const; export interface FakeCursorServer { @@ -130,6 +136,28 @@ function checkpoint(): Buffer { }); } +function toolPause(toolCallId: string): Buffer { + return serverMessage({ + message: { + case: "execServerMessage", + value: create(ExecServerMessageSchema, { + id: 1, + execId: `fixture-exec-${toolCallId}`, + message: { + case: "mcpArgs", + value: create(McpArgsSchema, { + name: "fixture-tool", + toolName: "fixture-tool", + toolCallId, + providerIdentifier: "fixture", + args: { input: new TextEncoder().encode("{}") }, + }), + }, + }), + }, + }); +} + function isScenario(value: string): value is CursorScenario { return (CURSOR_SCENARIOS as readonly string[]).includes(value); } @@ -146,6 +174,7 @@ export async function createFakeCursorServer(options: { deadlineMs?: number } = const childPids = new Set(); const bridgeEntries = new Set(); let defaultScenario: CursorScenario = "clean-end-stream"; + let toolPauseCount = 0; server.on("session", (session) => { sessions.add(session); @@ -179,6 +208,34 @@ export async function createFakeCursorServer(options: { deadlineMs?: number } = stream.once("close", finish); stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + if (scenario === "tool-pause-resume" || scenario === "tool-pause-transport-death") { + const toolCallId = `fixture-call-${++toolPauseCount}`; + let receivedInitialRequest = false; + let paused = false; + stream.on("data", (chunk) => { + if (!receivedInitialRequest) { + receivedInitialRequest = true; + paused = true; + stream.write(frameConnectMessage(toolPause(toolCallId))); + if (scenario === "tool-pause-transport-death") { + const session = stream.session; + setTimeout(() => session.destroy(), 10); + } + return; + } + if (!paused || stream.closed || stream.destroyed) return; + paused = false; + const receivedExpectedResult = Buffer.from(chunk).includes( + Buffer.from(`result-for-${toolCallId}`), + ); + stream.end(Buffer.concat([ + frameConnectMessage(textDelta(receivedExpectedResult ? "resumed-correctly" : "resumed-with-wrong-result")), + frameConnectEndStream(new TextEncoder().encode("{}")), + ])); + }); + return; + } + const endWith = (body: Uint8Array) => stream.end(frameConnectEndStream(body)); const trickle = (payload: Buffer) => { let sent = 0; diff --git a/test/smoke.ts b/test/smoke.ts index 4328318..1bdea10 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -616,6 +616,151 @@ async function testStreamingTerminationKnownFailures() { console.log("[test] Slow semantic progress survives bounded streaming completion"); } +async function runScenarioSubprocess( + apiUrl: string, + script: string, +): Promise> { + const child = Bun.spawn({ + cmd: [process.execPath, "-e", script], + cwd: process.cwd(), + env: { ...process.env, CURSOR_API_URL: apiUrl }, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + assertEqual(exitCode, 0, `Scenario subprocess failed: ${stderr}`); + return JSON.parse(stdout.trim()) as Record; +} + +async function observeToolResume( + apiUrl: string, + scenario: "tool-pause-resume" | "tool-pause-transport-death", + collision = false, +): Promise> { + return runScenarioSubprocess(apiUrl, ` + import { startProxy, stopProxy } from "./src/proxy.ts"; + const port = await startProxy(async () => "test-token"); + const base = "http://localhost:" + port + "/v1/chat/completions"; + const opening = "resume fixture collision-safe opening"; + const request = (messages) => fetch(base, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "test", stream: true, messages, tools: [{ type: "function", function: { name: "fixture-tool", parameters: { type: "object" } } }] }), + }); + const boundedText = async (response, deadlineMs = 150) => { + const reader = response.body.getReader(); let text = ""; + const drain = (async () => { while (true) { const { done, value } = await reader.read(); if (done) return { text, timedOut: false }; text += new TextDecoder().decode(value, { stream: true }); } })(); + const result = await Promise.race([drain, new Promise(resolve => setTimeout(() => resolve({ text, timedOut: true }), deadlineMs))]); + void reader.cancel().catch(() => {}); return result; + }; + const first = await boundedText(await request([{ role: "user", content: opening }])); + const firstCall = JSON.parse(first.text.match(/data: (.+)\\n\\n/)?.[1] ?? "{}").choices?.[0]?.delta?.tool_calls?.[0]?.id; + if ("${scenario}" === "tool-pause-transport-death") await Bun.sleep(30); + let second; let secondCall; + if (${collision}) { + second = await boundedText(await request([{ role: "user", content: opening }])); + secondCall = JSON.parse(second.text.match(/data: (.+)\\n\\n/)?.[1] ?? "{}").choices?.[0]?.delta?.tool_calls?.[0]?.id; + } + const resumed = await boundedText(await request([ + { role: "user", content: opening }, + { role: "assistant", content: null, tool_calls: [{ id: firstCall, type: "function", function: { name: "fixture-tool", arguments: "{}" } }] }, + { role: "tool", tool_call_id: firstCall, content: "result-for-" + firstCall }, + ])); + stopProxy(); + console.log(JSON.stringify({ first, second, secondCall, resumed })); + process.exit(0); + `); +} + +async function observeStalledRefresh(): Promise> { + return runScenarioSubprocess("http://127.0.0.1:1", ` + import http from "node:http"; + const server = http.createServer((_, res) => { void res; }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + process.env.CURSOR_REFRESH_URL = "http://127.0.0.1:" + port + "/auth/exchange_user_api_key"; + const { refreshCursorToken } = await import("./src/auth.ts"); + const { startProxy, stopProxy } = await import("./src/proxy.ts"); + const proxyPort = await startProxy(async () => (await refreshCursorToken("stalled-refresh")).access); + const pending = fetch("http://localhost:" + proxyPort + "/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: "test", stream: true, messages: [{ role: "user", content: "refresh stall" }] }) }); + const result = await Promise.race([pending.then(() => ({ headersReturned: true })), new Promise(resolve => setTimeout(() => resolve({ headersReturned: false }), 125))]); + stopProxy(); server.close(); console.log(JSON.stringify(result)); process.exit(0); + `); +} + +async function observeNonStreamingStall(apiUrl: string): Promise> { + return runScenarioSubprocess(apiUrl, ` + import { startProxy, stopProxy } from "./src/proxy.ts"; + const port = await startProxy(async () => "test-token"); + const pending = fetch("http://localhost:" + port + "/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: "test", stream: false, messages: [{ role: "user", content: "non-streaming stall" }] }) }); + const result = await Promise.race([pending.then(() => ({ headersReturned: true })), new Promise(resolve => setTimeout(() => resolve({ headersReturned: false }), 125))]); + stopProxy(); console.log(JSON.stringify(result)); process.exit(0); + `); +} + +async function testResumeRefreshAndNonStreamingKnownFailures() { + console.log("[test] Reproducing quarantined resume, refresh, and collection failures..."); + const fixture = await createFakeCursorServer({ deadlineMs: 1_000 }); + try { + fixture.setScenario("tool-pause-resume"); + const liveResume = await observeToolResume(fixture.apiUrl, "tool-pause-resume"); + assert( + String((liveResume.resumed as { text?: string }).text ?? "").includes("resumed-correctly"), + "Healthy tool resume must preserve the matching tool result", + ); + console.log("[test] Healthy tool resume preserves the paused bridge and result"); + + fixture.setScenario("tool-pause-transport-death"); + const deadResume = await observeToolResume(fixture.apiUrl, "tool-pause-transport-death"); + assert( + String((deadResume.resumed as { text?: string }).text ?? "").includes('"finish_reason":"tool_calls"'), + "A dead bridge must fall back to a new request instead of resuming the old stream", + ); + console.log("[test] Transport death exits the child; resume follows the fresh-request fallback"); + + fixture.setScenario("tool-pause-resume"); + const collision = await observeToolResume(fixture.apiUrl, "tool-pause-resume", true); + await expectKnownFailure( + "same-opening conversations retain isolated paused bridges", + async () => assert(String((collision.resumed as { text?: string }).text ?? "").includes("resumed-correctly"), "A resume must reach its own paused bridge"), + () => `observed secondCall=${String(collision.secondCall)}, body=${JSON.stringify((collision.resumed as { text?: string }).text ?? "")}`, + ); + + fixture.setScenario("tool-pause-transport-death"); + const droppedWrite = await observeToolResume(fixture.apiUrl, "tool-pause-transport-death"); + assert( + String((droppedWrite.resumed as { text?: string }).text ?? "").includes('"finish_reason":"tool_calls"'), + "A closed transport must not accept a dropped resume write as a live bridge", + ); + console.log("[test] Closed-stream fixture does not reproduce a silent dropped write; child exit triggers fallback"); + } finally { + await fixture.close(); + } + + const refresh = await observeStalledRefresh(); + await expectKnownFailure( + "stalled token refresh returns headers or an error", + async () => assert(refresh.headersReturned === true, "Refresh stall must not leave the request pre-header"), + () => `observed headersReturned=${String(refresh.headersReturned)}`, + ); + + const collectionFixture = await createFakeCursorServer({ deadlineMs: 1_000 }); + try { + collectionFixture.setScenario("heartbeat-trickle-kept-open"); + const nonStreaming = await observeNonStreamingStall(collectionFixture.apiUrl); + await expectKnownFailure( + "non-streaming collection reaches a bounded terminal response", + async () => assert(nonStreaming.headersReturned === true, "Non-streaming collection must not wait forever for bridge close"), + () => `observed headersReturned=${String(nonStreaming.headersReturned)}`, + ); + } finally { + await collectionFixture.close(); + } +} + async function testLifecycleDiagnostics(modules: TestModules) { console.log("[test] Testing redacted lifecycle diagnostics..."); const originalSetting = process.env.CURSOR_PROXY_DIAGNOSTICS; @@ -813,6 +958,7 @@ async function main() { await testArrayContentParsing(modules); await testFakeCursorScenarioHarness(); await testStreamingTerminationKnownFailures(); + await testResumeRefreshAndNonStreamingKnownFailures(); await testLifecycleDiagnostics(modules); await testExpiredTokenRefreshBeforeDiscovery(modules, backend); await testDiscoveryFallbackAndSuccess(modules, backend); From fb62d0352282ac31e56d5bbd38c84e8c8a4dd5b7 Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:34:08 -0700 Subject: [PATCH 09/22] fix(proxy): enforce Connect stream termination Complete valid Connect end-stream envelopes immediately and preserve Connect errors. Treat bridge and HTTP/2 closure without a verified terminal envelope as an explicit protocol failure while emitting a single terminal SSE sequence. --- src/h2-bridge.mjs | 22 +++++++++++++ src/proxy.ts | 81 +++++++++++++++++++++++++++++------------------ test/smoke.ts | 59 +++++++++++++++++----------------- 3 files changed, 101 insertions(+), 61 deletions(-) diff --git a/src/h2-bridge.mjs b/src/h2-bridge.mjs index 89e3482..9a62212 100644 --- a/src/h2-bridge.mjs +++ b/src/h2-bridge.mjs @@ -150,15 +150,37 @@ if (!unary) { const h2Stream = client.request(headers); emitDiagnostic("connect_stream"); +const CONNECT_END_STREAM_FLAG = 0b00000010; +let connectFrameBuffer = Buffer.alloc(0); +let sawConnectEndStream = false; + +function observeConnectFrames(chunk) { + if (unary) return; + connectFrameBuffer = Buffer.concat([connectFrameBuffer, chunk]); + while (connectFrameBuffer.length >= 5) { + const flags = connectFrameBuffer[0]; + const messageLength = connectFrameBuffer.readUInt32BE(1); + if (connectFrameBuffer.length < 5 + messageLength) return; + connectFrameBuffer = connectFrameBuffer.subarray(5 + messageLength); + if (flags & CONNECT_END_STREAM_FLAG) sawConnectEndStream = true; + } +} + // Forward H2 response data → stdout (length-prefixed) h2Stream.on("data", (chunk) => { resetTimeout(); + observeConnectFrames(chunk); emitDiagnostic("bridge_data", { byteLength: chunk.length }); writeMessage(chunk); }); h2Stream.on("end", () => { clearTimeout(timeout); + if (!unary && !sawConnectEndStream) { + emitDiagnostic("terminal", { errorName: "ConnectProtocolError", exitCode: 1 }); + try { client.close(); } catch {} + process.exit(1); + } emitDiagnostic("terminal", { exitCode: 0 }); client.close(); // Give stdout time to flush diff --git a/src/proxy.ts b/src/proxy.ts index e721536..28cc329 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -945,6 +945,8 @@ function buildCursorRequest( } function parseConnectEndStream(data: Uint8Array): Error | null { + if (data.length === 0) return null; + try { const payload = JSON.parse(new TextDecoder().decode(data)); const error = payload?.error; @@ -1418,6 +1420,7 @@ function createBridgeStreamResponse( ): Response { const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`; const created = Math.floor(Date.now() / 1000); + let cancelStream = () => {}; const stream = new ReadableStream({ start(controller) { @@ -1471,6 +1474,32 @@ function createBridgeStreamResponse( const tagFilter = createThinkingTagFilter(); let mcpExecReceived = false; + let connectEndStreamReceived = false; + let terminal = false; + + const finishTerminal = (error?: Error) => { + if (terminal) return; + terminal = true; + clearInterval(heartbeatTimer); + + const flushed = tagFilter.flush(); + if (flushed.reasoning) sendSSE(makeChunk({ reasoning_content: flushed.reasoning })); + if (flushed.content) sendSSE(makeChunk({ content: flushed.content })); + if (error) sendSSE(makeChunk({ content: `\n[Error: ${error.message}]` })); + sendSSE(makeChunk({}, "stop")); + sendSSE(makeUsageChunk()); + sendDone(); + closeController(); + activeBridges.delete(bridgeKey); + }; + + cancelStream = () => { + if (terminal) return; + terminal = true; + clearInterval(heartbeatTimer); + activeBridges.delete(bridgeKey); + bridge.end(); + }; const processChunk = createConnectFrameParser( (messageBytes) => { @@ -1556,6 +1585,7 @@ function createBridgeStreamResponse( } }, (endStreamBytes) => { + connectEndStreamReceived = true; const endError = parseConnectEndStream(endStreamBytes); emitLifecycleDiagnostic("proxy", "end_stream", { byteLength: endStreamBytes.length, @@ -1564,19 +1594,8 @@ function createBridgeStreamResponse( if (process.env.CURSOR_PROXY_DEBUG) { console.error(`[proxy] endStream: ${endError ? endError.message : "clean"}`); } - if (endError) { - // Surface the error and shut down: the server is done with this - // stream, and heartbeats would otherwise keep the bridge (and the - // SSE response) open forever. - sendSSE(makeChunk({ content: `\n[Error: ${endError.message}]` })); - sendSSE(makeChunk({}, "stop")); - sendSSE(makeUsageChunk()); - sendDone(); - closeController(); - activeBridges.delete(bridgeKey); - clearInterval(heartbeatTimer); - bridge.end(); - } + finishTerminal(endError ?? undefined); + bridge.end(); }, ); @@ -1591,27 +1610,27 @@ function createBridgeStreamResponse( stored.lastAccessMs = Date.now(); persistConversation(convKey, stored); } - if (!mcpExecReceived) { - const flushed = tagFilter.flush(); - if (flushed.reasoning) sendSSE(makeChunk({ reasoning_content: flushed.reasoning })); - if (flushed.content) sendSSE(makeChunk({ content: flushed.content })); - sendSSE(makeChunk({}, "stop")); - sendSSE(makeUsageChunk()); - sendDone(); - closeController(); - } else if (code !== 0) { - // Bridge died while tool calls are pending (timeout, crash, etc.). - // Close the SSE stream so the client doesn't hang forever. - sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" })); - sendSSE(makeChunk({}, "stop")); - sendSSE(makeUsageChunk()); - sendDone(); - closeController(); - // Remove stale entry so the next request doesn't try to resume it. - activeBridges.delete(bridgeKey); + if (terminal) return; + if (mcpExecReceived) { + if (code !== 0) activeBridges.delete(bridgeKey); + return; + } + + if (!connectEndStreamReceived) { + finishTerminal(new Error( + code === 0 + ? "Connect stream ended without end-of-stream frame" + : `Bridge exited with code ${code} before Connect end-stream`, + )); + return; } + + finishTerminal(new Error(`Bridge exited with code ${code} after Connect end-stream`)); }); }, + cancel() { + cancelStream(); + }, }); return new Response(stream, { headers: SSE_HEADERS }); diff --git a/test/smoke.ts b/test/smoke.ts index 1bdea10..2a43bf6 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -493,6 +493,10 @@ interface SseObservation { timedOut: boolean; } +function doneCount(body: string): number { + return body.split("data: [DONE]").length - 1; +} + async function observeProxyScenario(scenario: CursorScenario, deadlineMs = 125): Promise { const fixture = await createFakeCursorServer({ deadlineMs: 1_000 }); fixture.setScenario(scenario); @@ -562,49 +566,44 @@ async function testStreamingTerminationKnownFailures() { const cleanEnd = await observeProxyScenario("clean-end-stream-kept-open"); assert(!cleanEnd.timedOut && cleanEnd.body.includes("data: [DONE]"), "Clean end-stream must finish in the local H2 fixture"); + assertEqual(doneCount(cleanEnd.body), 1, "Clean end-stream must emit exactly one [DONE]"); console.log("[test] Valid end-stream does not reproduce as an open SSE: bridge exit cascades to [DONE]"); + const errorEnd = await observeProxyScenario("error-end-stream"); + assert(errorEnd.body.includes("Connect error unavailable: fixture failure"), "Connect end-stream errors must preserve their code and message"); + assertEqual(doneCount(errorEnd.body), 1, "Connect end-stream errors must emit exactly one [DONE]"); + console.log("[test] Connect end-stream errors propagate exactly once"); + const emptyEnd = await observeProxyScenario("empty-end-stream"); - await expectKnownFailure( - "empty end-stream is clean success", - async () => assert(!emptyEnd.body.includes("[Error:"), "Empty end-stream must not emit an error"), - () => `observed error=${emptyEnd.body.match(/\[Error:[^\]]+/)?.[0] ?? "none"}`, - ); + assert(!emptyEnd.timedOut, "Empty end-stream must terminate"); + assert(!emptyEnd.body.includes("[Error:"), "Empty end-stream must not emit an error"); + assertEqual(doneCount(emptyEnd.body), 1, "Empty end-stream must emit exactly one [DONE]"); + console.log("[test] Empty end-stream completes cleanly exactly once"); const missingEnd = await observeProxyScenario("missing-end-stream"); - await expectKnownFailure( - "missing end-stream is explicit failure", - async () => assert(missingEnd.body.includes("[Error:"), "Missing end-stream must be surfaced as an error"), - () => `observed done=${missingEnd.body.includes("data: [DONE]")}, error=${missingEnd.body.includes("[Error:")}`, - ); + assert(missingEnd.body.includes("[Error:"), "Missing end-stream must be surfaced as an error"); + assertEqual(doneCount(missingEnd.body), 1, "Missing end-stream must emit exactly one [DONE]"); + console.log("[test] Missing end-stream is an explicit error exactly once"); const bridgeExit = await observeProxyScenario("frame-error"); - await expectKnownFailure( - "bridge exit is not clean success", - async () => assert(bridgeExit.body.includes("[Error:"), "Bridge exit must be surfaced as an error"), - () => `observed done=${bridgeExit.body.includes("data: [DONE]")}, finishStop=${bridgeExit.body.includes('"finish_reason":"stop"')}, error=${bridgeExit.body.includes("[Error:")}`, - ); + assert(bridgeExit.body.includes("[Error:"), "Bridge exit must be surfaced as an error"); + assertEqual(doneCount(bridgeExit.body), 1, "Bridge exit must emit exactly one [DONE]"); + console.log("[test] Bridge exit is an explicit error exactly once"); const malformedTerminal = await observeProxyScenario("malformed-terminal"); - await expectKnownFailure( - "malformed terminal frame is explicit failure", - async () => assert(malformedTerminal.body.includes("[Error:"), "Malformed terminal frame must be surfaced as an error"), - () => `observed done=${malformedTerminal.body.includes("data: [DONE]")}, error=${malformedTerminal.body.includes("[Error:")}`, - ); + assert(malformedTerminal.body.includes("[Error:"), "Malformed terminal frame must be surfaced as an error"); + assertEqual(doneCount(malformedTerminal.body), 1, "Malformed terminal frame must emit exactly one [DONE]"); + console.log("[test] Malformed terminal frame is an explicit error exactly once"); const heartbeatTrickle = await observeProxyScenario("heartbeat-trickle-kept-open"); - await expectKnownFailure( - "heartbeat-only trickle cannot defer termination indefinitely", - async () => assert(heartbeatTrickle.body.includes("[Error:"), "Heartbeat-only traffic must reach an explicit error terminal"), - () => `observed timedOut=${heartbeatTrickle.timedOut}, semanticSse=${heartbeatTrickle.body.includes('"content"')}, done=${heartbeatTrickle.body.includes("data: [DONE]")}`, - ); + assert(heartbeatTrickle.body.includes("[Error:"), "Heartbeat-only traffic must reach an explicit error terminal after transport closure"); + assertEqual(doneCount(heartbeatTrickle.body), 1, "Heartbeat-only transport closure must emit exactly one [DONE]"); + console.log("[test] Heartbeat-only transport closure is an explicit error exactly once"); const turnEnded = await observeProxyScenario("turn-ended-kept-open"); - await expectKnownFailure( - "turnEnded has a classified terminal response", - async () => assert(turnEnded.body.includes("[Error:"), "turnEnded must not become a clean transport-close success"), - () => `observed timedOut=${turnEnded.timedOut}, done=${turnEnded.body.includes("data: [DONE]")}, emittedBytes=${turnEnded.body.length}`, - ); + assert(turnEnded.body.includes("[Error:"), "turnEnded transport closure must not become a clean success"); + assertEqual(doneCount(turnEnded.body), 1, "turnEnded transport closure must emit exactly one [DONE]"); + console.log("[test] turnEnded transport closure is an explicit error exactly once"); const slowProgress = await observeProxyScenario("slow-semantic-progress", 500); assert(!slowProgress.timedOut, "Slow semantic progress exceeded its bounded completion window"); From b9e76cec98d46a20a329a89850a582ead69b52e0 Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:43:45 -0700 Subject: [PATCH 10/22] fix(proxy): classify all Cursor server messages Dispatch every generated server and interaction case explicitly so protobuf additions become type errors instead of silent drops. Track semantic progress independently from liveness traffic and terminate requests that require unsupported query or control responses. --- src/proxy.ts | 144 +++++++++++++++++++++++++++++++++++++------------- test/smoke.ts | 55 +++++++++++++++++++ 2 files changed, 162 insertions(+), 37 deletions(-) diff --git a/src/proxy.ts b/src/proxy.ts index 28cc329..b1a51cd 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -65,6 +65,7 @@ import { type AgentServerMessage, type ConversationStateStructure, type ExecServerMessage, + type InteractionUpdate, type KvServerMessage, type McpToolDefinition, } from "./proto/agent_pb"; @@ -1053,11 +1054,14 @@ function createThinkingTagFilter(): { }; } -interface StreamState { +export interface StreamState { toolCallIndex: number; pendingExecs: PendingExec[]; outputTokens: number; totalTokens: number; + /** Semantic progress only; liveness and checkpoint traffic must not update it. */ + semanticProgressCount: number; + lastSemanticProgressAt: number; } function computeUsage(state: StreamState) { @@ -1067,7 +1071,7 @@ function computeUsage(state: StreamState) { return { prompt_tokens, completion_tokens, total_tokens }; } -function processServerMessage( +export function processServerMessage( msg: AgentServerMessage, blobStore: Map, mcpTools: McpToolDefinition[], @@ -1077,51 +1081,104 @@ function processServerMessage( onText: (text: string, isThinking?: boolean) => void, onMcpExec: (exec: PendingExec) => void, onCheckpoint?: (checkpointBytes: Uint8Array) => void, + onProtocolError?: (error: Error) => void, ): void { - const msgCase = msg.message.case; - - if (msgCase === "interactionUpdate") { - handleInteractionUpdate(msg.message.value, state, onText); - } else if (msgCase === "kvServerMessage") { - handleKvMessage(msg.message.value as KvServerMessage, blobStore, sendFrame); - } else if (msgCase === "execServerMessage") { - handleExecMessage( - msg.message.value as ExecServerMessage, - mcpTools, - cloudRule, - sendFrame, - onMcpExec, - ); - } else if (msgCase === "conversationCheckpointUpdate") { - const stateStructure = msg.message.value as ConversationStateStructure; - if (stateStructure.tokenDetails) { - state.totalTokens = stateStructure.tokenDetails.usedTokens; + const markSemanticProgress = () => { + state.semanticProgressCount += 1; + state.lastSemanticProgressAt = Date.now(); + }; + + switch (msg.message.case) { + case "interactionUpdate": + handleInteractionUpdate(msg.message.value, state, onText, markSemanticProgress); + return; + case "kvServerMessage": + handleKvMessage(msg.message.value, blobStore, sendFrame); + return; + case "execServerMessage": + markSemanticProgress(); + handleExecMessage(msg.message.value, mcpTools, cloudRule, sendFrame, onMcpExec); + return; + case "conversationCheckpointUpdate": { + const stateStructure = msg.message.value; + if (stateStructure.tokenDetails) state.totalTokens = stateStructure.tokenDetails.usedTokens; + if (onCheckpoint) onCheckpoint(toBinary(ConversationStateStructureSchema, stateStructure)); + return; } - if (onCheckpoint) { - onCheckpoint(toBinary(ConversationStateStructureSchema, stateStructure)); + case "execServerControlMessage": + // Cursor's abort control has no OpenAI-compatible acknowledgement path. + // Failing is safer than leaving the server waiting for an unsupported response. + onProtocolError?.(new Error(`Unsupported Cursor exec control message: ${msg.message.value.message.case ?? "unknown"}`)); + return; + case "interactionQuery": + // Query answers may require user interaction or Cursor-specific capabilities that + // this proxy cannot provide. Never silently leave the server waiting for one. + onProtocolError?.(new Error(`Unsupported Cursor interaction query: ${msg.message.value.query.case ?? "unknown"}`)); + return; + case undefined: + onProtocolError?.(new Error("Cursor server message had no message case")); + return; + default: { + const unhandled: never = msg.message; + throw new Error(`Unhandled Cursor server message: ${String(unhandled)}`); } } } function handleInteractionUpdate( - update: any, + update: InteractionUpdate, state: StreamState, onText: (text: string, isThinking?: boolean) => void, + markSemanticProgress: () => void, ): void { - const updateCase = update.message?.case; - - if (updateCase === "textDelta") { - const delta = update.message.value.text || ""; - if (delta) onText(delta, false); - } else if (updateCase === "thinkingDelta") { - const delta = update.message.value.text || ""; - if (delta) onText(delta, true); - } else if (updateCase === "tokenDelta") { - state.outputTokens += update.message.value.tokens ?? 0; + switch (update.message.case) { + case "textDelta": { + const delta = update.message.value.text || ""; + if (delta) { + markSemanticProgress(); + onText(delta, false); + } + return; + } + case "thinkingDelta": { + const delta = update.message.value.text || ""; + if (delta) { + markSemanticProgress(); + onText(delta, true); + } + return; + } + case "tokenDelta": { + const tokens = update.message.value.tokens ?? 0; + state.outputTokens += tokens; + if (tokens > 0) markSemanticProgress(); + return; + } + // Tool calls are handled through execServerMessage, not InteractionUpdate. + case "partialToolCall": + case "toolCallDelta": + case "toolCallStarted": + case "toolCallCompleted": + // These update Cursor's internal transcript or UI but do not establish model progress. + case "thinkingCompleted": + case "userMessageAppended": + case "summary": + case "summaryStarted": + case "summaryCompleted": + case "shellOutputDelta": + case "stepStarted": + case "stepCompleted": + // Cursor's turnEnded sequencing is unproven, so only a Connect end-stream may terminate. + case "heartbeat": + case "turnEnded": + return; + case undefined: + return; + default: { + const unhandled: never = update.message; + throw new Error(`Unhandled Cursor interaction update: ${String(unhandled)}`); + } } - // toolCallStarted, partialToolCall, toolCallDelta, toolCallCompleted - // are intentionally ignored. MCP tool calls flow through the exec - // message path (mcpArgs → mcpResult), not interaction updates. } /** Send a KV client response back to Cursor. */ @@ -1470,6 +1527,8 @@ function createBridgeStreamResponse( pendingExecs: [], outputTokens: 0, totalTokens: 0, + semanticProgressCount: 0, + lastSemanticProgressAt: Date.now(), }; const tagFilter = createThinkingTagFilter(); @@ -1573,6 +1632,10 @@ function createBridgeStreamResponse( persistConversation(convKey, stored); } }, + (error) => { + finishTerminal(error); + bridge.end(); + }, ); emitLifecycleDiagnostic("proxy", "message_dispatch", { messageCase: serverMessage.message.case, @@ -1789,7 +1852,7 @@ async function collectFullResponse( accessToken: string, convKey: string, ): Promise { - const { promise, resolve } = Promise.withResolvers(); + const { promise, resolve, reject } = Promise.withResolvers(); let fullText = ""; const { bridge, heartbeatTimer } = startBridge(accessToken, payload.requestBytes); @@ -1799,6 +1862,8 @@ async function collectFullResponse( pendingExecs: [], outputTokens: 0, totalTokens: 0, + semanticProgressCount: 0, + lastSemanticProgressAt: Date.now(), }; const tagFilter = createThinkingTagFilter(); @@ -1831,6 +1896,11 @@ async function collectFullResponse( persistConversation(convKey, stored); } }, + (error) => { + clearInterval(heartbeatTimer); + bridge.end(); + reject(error); + }, ); } catch { // Skip diff --git a/test/smoke.ts b/test/smoke.ts index 2a43bf6..b611113 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -3,8 +3,16 @@ import http2 from "node:http2"; import type { AddressInfo } from "node:net"; import { create, toBinary } from "@bufbuild/protobuf"; import { + AgentServerMessageSchema, + ConversationStateStructureSchema, + ExecServerControlMessageSchema, GetUsableModelsResponseSchema, + HeartbeatUpdateSchema, + InteractionQuerySchema, + InteractionUpdateSchema, ModelDetailsSchema, + TextDeltaUpdateSchema, + TurnEndedUpdateSchema, } from "../src/proto/agent_pb"; import { CURSOR_SCENARIOS, @@ -23,6 +31,7 @@ interface TestModules { CursorAuthPlugin: typeof import("../src/index").CursorAuthPlugin; getCursorModels: typeof import("../src/models").getCursorModels; clearModelCache: typeof import("../src/models").clearModelCache; + processServerMessage: typeof import("../src/proxy").processServerMessage; } interface TestCursorBackend { @@ -246,6 +255,7 @@ async function loadModules(): Promise { CursorAuthPlugin: index.CursorAuthPlugin, getCursorModels: models.getCursorModels, clearModelCache: models.clearModelCache, + processServerMessage: proxy.processServerMessage, }; } @@ -615,6 +625,50 @@ async function testStreamingTerminationKnownFailures() { console.log("[test] Slow semantic progress survives bounded streaming completion"); } +async function testServerMessageClassification(modules: TestModules) { + console.log("[test] Testing server-message progress and unsupported-query classification..."); + const state = { + toolCallIndex: 0, + pendingExecs: [], + outputTokens: 0, + totalTokens: 0, + semanticProgressCount: 0, + lastSemanticProgressAt: 0, + }; + const dispatch = (message: Parameters[1], onProtocolError?: (error: Error) => void) => + modules.processServerMessage( + create(AgentServerMessageSchema, message as never), + new Map(), [], undefined, () => {}, state, () => {}, () => {}, undefined, onProtocolError, + ); + + dispatch({ message: { case: "interactionUpdate", value: create(InteractionUpdateSchema, { + message: { case: "heartbeat", value: create(HeartbeatUpdateSchema, {}) }, + }) } }); + dispatch({ message: { case: "interactionUpdate", value: create(InteractionUpdateSchema, { + message: { case: "turnEnded", value: create(TurnEndedUpdateSchema, {}) }, + }) } }); + dispatch({ message: { case: "conversationCheckpointUpdate", value: create(ConversationStateStructureSchema, {}) } }); + assertEqual(state.semanticProgressCount, 0, "Heartbeat, turnEnded, and checkpoints must not count as semantic progress"); + + dispatch({ message: { case: "interactionUpdate", value: create(InteractionUpdateSchema, { + message: { case: "textDelta", value: create(TextDeltaUpdateSchema, { text: "progress" }) }, + }) } }); + assertEqual(state.semanticProgressCount, 1, "Text output must count as semantic progress"); + + let unsupportedError: Error | undefined; + dispatch({ message: { case: "interactionQuery", value: create(InteractionQuerySchema, { + id: 1, + query: { case: "webSearchRequestQuery", value: {} }, + }) } }, (error) => { unsupportedError = error; }); + assert(unsupportedError?.message.includes("Unsupported Cursor interaction query"), "Unsupported blocking queries must trigger an explicit terminal error"); + unsupportedError = undefined; + dispatch({ message: { case: "execServerControlMessage", value: create(ExecServerControlMessageSchema, { + message: { case: "abort", value: { id: 1 } }, + }) } }, (error) => { unsupportedError = error; }); + assert(unsupportedError?.message.includes("Unsupported Cursor exec control message"), "Unsupported control messages must trigger an explicit terminal error"); + console.log("[test] Liveness traffic is non-progress and unsupported queries terminate explicitly"); +} + async function runScenarioSubprocess( apiUrl: string, script: string, @@ -957,6 +1011,7 @@ async function main() { await testArrayContentParsing(modules); await testFakeCursorScenarioHarness(); await testStreamingTerminationKnownFailures(); + await testServerMessageClassification(modules); await testResumeRefreshAndNonStreamingKnownFailures(); await testLifecycleDiagnostics(modules); await testExpiredTokenRefreshBeforeDiscovery(modules, backend); From d7d3a8ac1105f9aac64203589a36bcdee5b4dc29 Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:49:09 -0700 Subject: [PATCH 11/22] fix(proxy): propagate terminal transport failures Route malformed server frames and rejected bridge writes through the existing idempotent terminal owner. Terminate non-resumable bridge children deterministically while preserving live tool-call pauses for result continuation. --- docs/cursor-hang-root-cause.md | 30 +++++++++++++++++- src/h2-bridge.mjs | 31 +++++++++++-------- src/proxy.ts | 47 +++++++++++++++++++++-------- test/fixtures/fake-cursor-server.ts | 8 +++++ test/smoke.ts | 31 +++++++++++++++++-- 5 files changed, 119 insertions(+), 28 deletions(-) diff --git a/docs/cursor-hang-root-cause.md b/docs/cursor-hang-root-cause.md index b9c7e59..d8b5bef 100644 --- a/docs/cursor-hang-root-cause.md +++ b/docs/cursor-hang-root-cause.md @@ -1,6 +1,6 @@ # Cursor Model Hang — Root Cause Analysis -Status: **Evidence baseline established; lifecycle and termination map complete.** This +Status: **Evidence baseline, lifecycle map, and terminal-contract runtime evidence complete.** This document opens the hang investigation: it preserves the speculative uncommitted overlay that predated this investigation, classifies every hunk with an explicit disposition, records baseline-vs-overlay behavior, and then (Part 2) enumerates every open-SSE path and termination @@ -451,3 +451,31 @@ ordinary passing assertions. The bounded suite remains safe: subprocesses exit after recording each 125ms observation, and the fake server closes every tracked session and stream. No production source seam was added for these reproductions. + +## 2.12 Terminal-contract evidence and remaining mechanism verdicts + +The streaming boundary now has one idempotent terminal owner. It flushes buffered output, emits +the error (when any), `stop`, usage, and one `[DONE]`, then releases the heartbeat, bridge-map +entry, child stdin, and SSE controller. A client cancellation takes the same resource-release +path without attempting a second SSE sequence. A tool-call pause is intentionally different: it +closes the current SSE response after `tool_calls`, but retains the live bridge and heartbeat for +the follow-up tool result. If that retained bridge later exits, its map entry is removed rather +than being treated as resumable. + +Complete Connect frames that fail protobuf decoding are now terminal protocol errors. This does +not make ordinary forward-compatible traffic fatal: protobuf preserves unknown fields, and known +but unsupported message families are explicitly classified. A decode exception means the payload +is structurally invalid, so continuing could silently discard the only terminal signal. Likewise, +a rejected parent-to-bridge write throws into the terminal owner; a write attempted after the +bridge H2 stream is closed makes the child exit non-zero rather than being silently discarded. + +| Originally theorized mechanism | Runtime verdict | Evidence and present disposition | +|---|---|---| +| Empty Connect end-stream is an error | **Refuted after fix** | The zero-byte fixture completes cleanly with exactly one `[DONE]`. | +| Missing end-stream / abrupt H2 close becomes clean success or retry fodder | **Confirmed before fix; closed** | Missing-end, stream-reset, and abrupt-session-close fixtures now emit one explicit error and one `[DONE]`; no empty success is observed. This closes the local boundary implicated by issue #33. | +| Malformed Connect terminal or protobuf message is silently lost | **Confirmed before fix; closed** | Partial/malformed terminal and malformed protobuf fixtures each reach one explicit error terminal. | +| Bridge write failure is silently dropped | **Confirmed by source; propagation covered** | A rejected KV response write propagates out of dispatch into the terminal path; the bridge also exits non-zero when stdin reaches a closed H2 stream. The process-alive, transport-dead resume race remains deferred to the resume-liveness work. | +| Tool-result pause must retain a live bridge | **Confirmed and preserved** | The healthy tool-resume fixture still returns `resumed-correctly`; pause is not terminal cleanup. | +| Heartbeat/checkpoint traffic can stand in for semantic progress | **Confirmed before classification; not a terminal-contract result** | Fixtures classify heartbeat/turn-ended traffic as non-progress and report transport closure explicitly. Stall detection itself remains separate work. | +| Unbounded refresh and non-streaming collection | **Confirmed and deferred** | Their quarantined bounded observations still show no headers at 125ms; no timeout, watchdog, or deadline was added here. | +| Bridge-key collision | **Confirmed and deferred** | The quarantined same-opening-conversation assertion still reproduces cross-routing; key derivation was not changed. | diff --git a/src/h2-bridge.mjs b/src/h2-bridge.mjs index 9a62212..a8db5fa 100644 --- a/src/h2-bridge.mjs +++ b/src/h2-bridge.mjs @@ -121,16 +121,21 @@ function resetTimeout() { } function killBridge(reason = "connect") { + failBridge(`Timeout:${reason}`); +} + +let failed = false; +function failBridge(errorName) { + if (failed) return; + failed = true; clearTimeout(timeout); - emitDiagnostic("terminal", { errorName: `Timeout:${reason}`, exitCode: 1 }); + emitDiagnostic("terminal", { errorName, exitCode: 1 }); try { client.destroy(); } catch {} process.exit(1); } client.on("error", (err) => { - clearTimeout(timeout); - emitDiagnostic("terminal", { errorName: err?.name || "Error", exitCode: 1 }); - process.exit(1); + failBridge(err?.name || "Error"); }); const headers = { @@ -177,9 +182,7 @@ h2Stream.on("data", (chunk) => { h2Stream.on("end", () => { clearTimeout(timeout); if (!unary && !sawConnectEndStream) { - emitDiagnostic("terminal", { errorName: "ConnectProtocolError", exitCode: 1 }); - try { client.close(); } catch {} - process.exit(1); + failBridge("ConnectProtocolError"); } emitDiagnostic("terminal", { exitCode: 0 }); client.close(); @@ -188,10 +191,7 @@ h2Stream.on("end", () => { }); h2Stream.on("error", (err) => { - clearTimeout(timeout); - emitDiagnostic("terminal", { errorName: err?.name || "Error", exitCode: 1 }); - try { client.close(); } catch {} - process.exit(1); + failBridge(err?.name || "Error"); }); // Forward stdin → H2 stream (after config message) @@ -212,9 +212,16 @@ if (unary) { // EOF or zero-length = done writing break; } - if (!h2Stream.closed && !h2Stream.destroyed) { + if (h2Stream.closed || h2Stream.destroyed) { + failBridge("BridgeWriteError"); + return; + } + try { resetTimeout(); h2Stream.write(msg); + } catch (error) { + failBridge(error?.name || "BridgeWriteError"); + return; } } diff --git a/src/proxy.ts b/src/proxy.ts index b1a51cd..533b00d 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -306,8 +306,11 @@ interface SpawnBridgeOptions { function spawnBridge(options: SpawnBridgeOptions): { proc: ReturnType; - write: (data: Uint8Array) => void; + /** Returns false when the child has already exited or its stdin rejects the frame. */ + write: (data: Uint8Array) => boolean; end: () => void; + /** Stop the child when this request has reached a non-resumable terminal state. */ + terminate: () => void; onData: (cb: (chunk: Buffer) => void) => void; onClose: (cb: (code: number) => void) => void; /** True while the bridge subprocess is still running. */ @@ -410,7 +413,13 @@ function spawnBridge(options: SpawnBridgeOptions): { proc, get alive() { return !exited; }, write(data) { - try { proc.stdin.write(lpEncode(data)); } catch {} + if (exited) return false; + try { + proc.stdin.write(lpEncode(data)); + return true; + } catch { + return false; + } }, end() { try { @@ -418,6 +427,10 @@ function spawnBridge(options: SpawnBridgeOptions): { proc.stdin.end(); } catch {} }, + terminate() { + try { proc.stdin.end(); } catch {} + try { proc.kill(); } catch {} + }, onData(cb) { cbs.data = cb; }, onClose(cb) { if (exited) { @@ -1540,6 +1553,7 @@ function createBridgeStreamResponse( if (terminal) return; terminal = true; clearInterval(heartbeatTimer); + activeBridges.delete(bridgeKey); const flushed = tagFilter.flush(); if (flushed.reasoning) sendSSE(makeChunk({ reasoning_content: flushed.reasoning })); @@ -1549,7 +1563,7 @@ function createBridgeStreamResponse( sendSSE(makeUsageChunk()); sendDone(); closeController(); - activeBridges.delete(bridgeKey); + bridge.terminate(); }; cancelStream = () => { @@ -1557,7 +1571,7 @@ function createBridgeStreamResponse( terminal = true; clearInterval(heartbeatTimer); activeBridges.delete(bridgeKey); - bridge.end(); + bridge.terminate(); }; const processChunk = createConnectFrameParser( @@ -1573,7 +1587,9 @@ function createBridgeStreamResponse( blobStore, mcpTools, cloudRule, - (data) => bridge.write(data), + (data) => { + if (!bridge.write(data)) throw new Error("Failed to write frame to Cursor bridge"); + }, state, (text, isThinking) => { if (isThinking) { @@ -1632,10 +1648,7 @@ function createBridgeStreamResponse( persistConversation(convKey, stored); } }, - (error) => { - finishTerminal(error); - bridge.end(); - }, + finishTerminal, ); emitLifecycleDiagnostic("proxy", "message_dispatch", { messageCase: serverMessage.message.case, @@ -1643,8 +1656,14 @@ function createBridgeStreamResponse( ? serverMessage.message.value.message?.case : undefined, }); - } catch { - // Skip unparseable messages + } catch (error) { + // A complete Connect message frame that cannot decode as an + // AgentServerMessage cannot be safely skipped: it may be the only + // terminal signal. Unknown fields remain protobuf-compatible; this + // path is reserved for structurally invalid payloads and writes. + finishTerminal(error instanceof Error + ? new Error(`Failed to process Cursor server message: ${error.message}`) + : new Error("Failed to process Cursor server message")); } }, (endStreamBytes) => { @@ -1658,7 +1677,6 @@ function createBridgeStreamResponse( console.error(`[proxy] endStream: ${endError ? endError.message : "clean"}`); } finishTerminal(endError ?? undefined); - bridge.end(); }, ); @@ -1675,7 +1693,10 @@ function createBridgeStreamResponse( } if (terminal) return; if (mcpExecReceived) { - if (code !== 0) activeBridges.delete(bridgeKey); + // Tool pause deliberately owns neither terminal cleanup nor bridge + // shutdown. Once the retained bridge itself exits, it is stale on + // either exit code and must no longer be resumable. + activeBridges.delete(bridgeKey); return; } diff --git a/test/fixtures/fake-cursor-server.ts b/test/fixtures/fake-cursor-server.ts index 130a285..257b253 100644 --- a/test/fixtures/fake-cursor-server.ts +++ b/test/fixtures/fake-cursor-server.ts @@ -21,6 +21,7 @@ export type CursorScenario = | "empty-end-stream" | "missing-end-stream" | "malformed-terminal" + | "malformed-server-message" | "partial-frame" | "slow-semantic-progress" | "heartbeat-trickle" @@ -41,6 +42,7 @@ export const CURSOR_SCENARIOS: readonly CursorScenario[] = [ "empty-end-stream", "missing-end-stream", "malformed-terminal", + "malformed-server-message", "partial-frame", "slow-semantic-progress", "heartbeat-trickle", @@ -274,6 +276,12 @@ export async function createFakeCursorServer(options: { deadlineMs?: number } = case "malformed-terminal": stream.end(frameMalformedConnectMessage(new TextEncoder().encode("{"), 9)); break; + case "malformed-server-message": + stream.end(Buffer.concat([ + frameConnectMessage(new Uint8Array([0xff])), + frameConnectEndStream(new TextEncoder().encode("{}")), + ])); + break; case "partial-frame": stream.end(frameConnectMessage(textDelta("partial")).subarray(0, 7)); break; diff --git a/test/smoke.ts b/test/smoke.ts index b611113..1024e5a 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -6,10 +6,12 @@ import { AgentServerMessageSchema, ConversationStateStructureSchema, ExecServerControlMessageSchema, + GetBlobArgsSchema, GetUsableModelsResponseSchema, HeartbeatUpdateSchema, InteractionQuerySchema, InteractionUpdateSchema, + KvServerMessageSchema, ModelDetailsSchema, TextDeltaUpdateSchema, TurnEndedUpdateSchema, @@ -605,6 +607,16 @@ async function testStreamingTerminationKnownFailures() { assertEqual(doneCount(malformedTerminal.body), 1, "Malformed terminal frame must emit exactly one [DONE]"); console.log("[test] Malformed terminal frame is an explicit error exactly once"); + const malformedMessage = await observeProxyScenario("malformed-server-message"); + assert(malformedMessage.body.includes("Failed to process Cursor server message"), "Malformed protobuf payloads must reach the terminal error owner"); + assertEqual(doneCount(malformedMessage.body), 1, "Malformed protobuf payloads must emit exactly one [DONE]"); + console.log("[test] Malformed protobuf payloads terminate explicitly exactly once"); + + const abruptClose = await observeProxyScenario("abrupt-session-close"); + assert(abruptClose.body.includes("[Error:"), "Abrupt HTTP/2 closure must be surfaced as an error"); + assertEqual(doneCount(abruptClose.body), 1, "Abrupt HTTP/2 closure must emit exactly one [DONE]"); + console.log("[test] Abrupt HTTP/2 closure is an explicit error exactly once"); + const heartbeatTrickle = await observeProxyScenario("heartbeat-trickle-kept-open"); assert(heartbeatTrickle.body.includes("[Error:"), "Heartbeat-only traffic must reach an explicit error terminal after transport closure"); assertEqual(doneCount(heartbeatTrickle.body), 1, "Heartbeat-only transport closure must emit exactly one [DONE]"); @@ -635,10 +647,14 @@ async function testServerMessageClassification(modules: TestModules) { semanticProgressCount: 0, lastSemanticProgressAt: 0, }; - const dispatch = (message: Parameters[1], onProtocolError?: (error: Error) => void) => + const dispatch = ( + message: Parameters[1], + onProtocolError?: (error: Error) => void, + sendFrame: (data: Uint8Array) => void = () => {}, + ) => modules.processServerMessage( create(AgentServerMessageSchema, message as never), - new Map(), [], undefined, () => {}, state, () => {}, () => {}, undefined, onProtocolError, + new Map(), [], undefined, sendFrame, state, () => {}, () => {}, undefined, onProtocolError, ); dispatch({ message: { case: "interactionUpdate", value: create(InteractionUpdateSchema, { @@ -666,6 +682,17 @@ async function testServerMessageClassification(modules: TestModules) { message: { case: "abort", value: { id: 1 } }, }) } }, (error) => { unsupportedError = error; }); assert(unsupportedError?.message.includes("Unsupported Cursor exec control message"), "Unsupported control messages must trigger an explicit terminal error"); + + let writeFailure: Error | undefined; + try { + dispatch({ message: { case: "kvServerMessage", value: create(KvServerMessageSchema, { + id: 1, + message: { case: "getBlobArgs", value: create(GetBlobArgsSchema, { blobId: new Uint8Array([1]) }) }, + }) } }, undefined, () => { throw new Error("bridge write rejected"); }); + } catch (error) { + writeFailure = error instanceof Error ? error : new Error(String(error)); + } + assertEqual(writeFailure?.message, "bridge write rejected", "Bridge write failures must propagate to the terminal owner"); console.log("[test] Liveness traffic is non-progress and unsupported queries terminate explicitly"); } From c37ecf402e5ada8a21b46916ed6df0152d1fa7cd Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:02:35 -0700 Subject: [PATCH 12/22] fix(proxy): contain unary protocol failures Await chat-completion handling inside the request error boundary so unary failures return structured responses. Ignore empty protobuf oneofs as non-progress while continuing to reject structurally invalid frames. --- src/proxy.ts | 21 +++++++++++++++++---- test/smoke.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/proxy.ts b/src/proxy.ts index 533b00d..1bee44e 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -557,7 +557,9 @@ export async function startProxy( emitLifecycleDiagnostic("proxy", "auth_access_start"); const accessToken = await proxyAccessTokenProvider(); emitLifecycleDiagnostic("proxy", "auth_access_complete"); - return handleChatCompletion(body, accessToken); + // Await so failures from either streaming or non-streaming handling + // remain inside this request's structured-error boundary. + return await handleChatCompletion(body, accessToken); } catch (err) { emitLifecycleDiagnostic("proxy", "terminal", { errorName: err instanceof Error ? err.name : "UnknownError", @@ -1129,7 +1131,10 @@ export function processServerMessage( onProtocolError?.(new Error(`Unsupported Cursor interaction query: ${msg.message.value.query.case ?? "unknown"}`)); return; case undefined: - onProtocolError?.(new Error("Cursor server message had no message case")); + // Protobuf decoders represent empty or forward-compatible messages as an + // unset oneof. They carry no semantic progress and must not turn a valid + // stream into a protocol error; structurally invalid payloads still throw + // during decoding and are terminated by the caller. return; default: { const unhandled: never = msg.message; @@ -1923,8 +1928,16 @@ async function collectFullResponse( reject(error); }, ); - } catch { - // Skip + } catch (error) { + // A complete Connect frame with an invalid protobuf payload cannot be + // safely ignored in unary mode either: it may contain the only + // terminal signal. Preserve the same explicit protocol failure that + // the streaming path emits. + clearInterval(heartbeatTimer); + bridge.end(); + reject(error instanceof Error + ? new Error(`Failed to process Cursor server message: ${error.message}`) + : new Error("Failed to process Cursor server message")); } }, () => {}, diff --git a/test/smoke.ts b/test/smoke.ts index 1024e5a..dcbd7c3 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -432,11 +432,41 @@ async function testArrayContentParsing(modules: TestModules) { ); } } + assertEqual(res.status, 200, "Empty protobuf frames must be ignored without failing a non-streaming response"); modules.stopProxy(); console.log("[test] Array content parsing OK"); } +async function testNonStreamingProtocolErrorsAreStructured() { + console.log("[test] Testing non-streaming protocol error handling..."); + const fixture = await createFakeCursorServer({ deadlineMs: 1_000 }); + fixture.setScenario("malformed-server-message"); + try { + const result = await runScenarioSubprocess(fixture.apiUrl, ` + import { startProxy, stopProxy } from "./src/proxy.ts"; + const port = await startProxy(async () => "test-token"); + const response = await fetch("http://localhost:" + port + "/v1/chat/completions", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "test", stream: false, messages: [{ role: "user", content: "malformed non-streaming response" }] }), + }); + const body = await response.json(); + stopProxy(); + console.log(JSON.stringify({ status: response.status, body })); + process.exit(0); + `); + assertEqual(result.status, 500, "Non-streaming protocol errors must return a structured server error"); + const body = result.body as { error?: { message?: string } }; + assert( + body.error?.message?.includes("Failed to process Cursor server message"), + "Non-streaming protocol errors must preserve the terminal error message", + ); + } finally { + await fixture.close(); + } + console.log("[test] Non-streaming protocol errors return structured responses"); +} + async function runFakeScenario(apiUrl: string, scenario: CursorScenario): Promise { const session = http2.connect(apiUrl); const stream = session.request({ @@ -1030,12 +1060,19 @@ async function main() { const modules = await loadModules(); + let unhandledRejection: unknown; + const onUnhandledRejection = (reason: unknown) => { + unhandledRejection = reason; + }; + process.on("unhandledRejection", onUnhandledRejection); + try { await testProxyStartStop(modules); await testAuthParams(modules); await testTokenExpiry(modules); await testPluginShape(modules); await testArrayContentParsing(modules); + await testNonStreamingProtocolErrorsAreStructured(); await testFakeCursorScenarioHarness(); await testStreamingTerminationKnownFailures(); await testServerMessageClassification(modules); @@ -1043,12 +1080,15 @@ async function main() { await testLifecycleDiagnostics(modules); await testExpiredTokenRefreshBeforeDiscovery(modules, backend); await testDiscoveryFallbackAndSuccess(modules, backend); + await Bun.sleep(0); + assert(!unhandledRejection, `Unhandled rejection escaped the smoke harness: ${String(unhandledRejection)}`); console.log("\n✓ All smoke tests passed"); process.exitCode = 0; } catch (err) { console.error("\n✗ Smoke test failed:", err); process.exitCode = 1; } finally { + process.off("unhandledRejection", onUnhandledRejection); modules.stopProxy(); await backend.close(); } From 0b6dea492d51007f4079640ccad29d55e243aea3 Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:07:53 -0700 Subject: [PATCH 13/22] fix(auth): bound token refresh requests --- src/auth.ts | 34 ++++++++++++++++++++++++++++++---- src/proxy.ts | 15 +++++++++++++-- test/smoke.ts | 41 +++++++++++++++++++++++++++++++++++------ 3 files changed, 78 insertions(+), 12 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index e7b3aa5..104b276 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -11,6 +11,21 @@ const POLL_MAX_ATTEMPTS = 150; const POLL_BASE_DELAY = 1000; const POLL_MAX_DELAY = 10_000; const POLL_BACKOFF_MULTIPLIER = 1.2; +const DEFAULT_REFRESH_TIMEOUT_MS = 15_000; +const MAX_REFRESH_TIMEOUT_MS = 60_000; + +export class CursorTokenRefreshTimeoutError extends Error { + constructor() { + super("Cursor token refresh timed out; retry the request or sign in again"); + this.name = "CursorTokenRefreshTimeoutError"; + } +} + +function getRefreshTimeoutMs(): number { + const configured = Number(process.env.CURSOR_REFRESH_TIMEOUT_MS); + if (!Number.isFinite(configured) || configured <= 0) return DEFAULT_REFRESH_TIMEOUT_MS; + return Math.min(Math.floor(configured), MAX_REFRESH_TIMEOUT_MS); +} const lifecycleDiagnosticContext = new AsyncLocalStorage<{ requestId: string; @@ -146,22 +161,33 @@ export async function refreshCursorToken( refreshToken: string, ): Promise { emitLifecycleDiagnostic("auth", "auth_refresh_start"); + const controller = new AbortController(); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, getRefreshTimeoutMs()); let response: Response; try { response = await fetch(CURSOR_REFRESH_URL, { method: "POST", headers: { Authorization: `Bearer ${refreshToken}`, "Content-Type": "application/json" }, body: "{}", + signal: controller.signal, }); } catch (error) { - emitLifecycleDiagnostic("auth", "auth_refresh_failed", { errorName: error instanceof Error ? error.name : "UnknownError" }); - throw error; + const refreshError = timedOut + ? new CursorTokenRefreshTimeoutError() + : new Error("Cursor token refresh request failed"); + emitLifecycleDiagnostic("auth", "auth_refresh_failed", { errorName: refreshError.name }); + throw refreshError; + } finally { + clearTimeout(timeout); } if (!response.ok) { emitLifecycleDiagnostic("auth", "auth_refresh_failed", { exitCode: response.status }); - const error = await response.text(); - throw new Error(`Cursor token refresh failed: ${error}`); + throw new Error(`Cursor token refresh failed with HTTP status ${response.status}`); } const data = (await response.json()) as { diff --git a/src/proxy.ts b/src/proxy.ts index 1bee44e..030416b 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -80,6 +80,7 @@ import { homedir } from "node:os"; import { resolve as pathResolve } from "node:path"; import { z } from "zod"; import { + CursorTokenRefreshTimeoutError, emitLifecycleDiagnostic, lifecycleDiagnosticContextInfo, lifecycleDiagnosticsEnabled, @@ -565,9 +566,19 @@ export async function startProxy( errorName: err instanceof Error ? err.name : "UnknownError", }); const message = err instanceof Error ? err.message : String(err); + const refreshTimedOut = err instanceof CursorTokenRefreshTimeoutError; return new Response( - JSON.stringify({ error: { message, type: "server_error", code: "internal_error" } }), - { status: 500, headers: { "Content-Type": "application/json" } }, + JSON.stringify({ + error: { + message, + type: refreshTimedOut ? "gateway_timeout" : "server_error", + code: refreshTimedOut ? "token_refresh_timeout" : "internal_error", + }, + }), + { + status: refreshTimedOut ? 504 : 500, + headers: { "Content-Type": "application/json" }, + }, ); } }); diff --git a/test/smoke.ts b/test/smoke.ts index dcbd7c3..93d1f5a 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -792,15 +792,37 @@ async function observeStalledRefresh(): Promise> { await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); const port = server.address().port; process.env.CURSOR_REFRESH_URL = "http://127.0.0.1:" + port + "/auth/exchange_user_api_key"; + process.env.CURSOR_REFRESH_TIMEOUT_MS = "25"; const { refreshCursorToken } = await import("./src/auth.ts"); const { startProxy, stopProxy } = await import("./src/proxy.ts"); const proxyPort = await startProxy(async () => (await refreshCursorToken("stalled-refresh")).access); const pending = fetch("http://localhost:" + proxyPort + "/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: "test", stream: true, messages: [{ role: "user", content: "refresh stall" }] }) }); - const result = await Promise.race([pending.then(() => ({ headersReturned: true })), new Promise(resolve => setTimeout(() => resolve({ headersReturned: false }), 125))]); + const result = await Promise.race([pending.then(async response => ({ headersReturned: true, status: response.status, body: await response.json() })), new Promise(resolve => setTimeout(() => resolve({ headersReturned: false }), 125))]); stopProxy(); server.close(); console.log(JSON.stringify(result)); process.exit(0); `); } +async function observeSuccessfulRefresh(): Promise> { + return runScenarioSubprocess("http://127.0.0.1:1", ` + import http from "node:http"; + const server = http.createServer((req, res) => { + if (req.headers.authorization !== "Bearer valid-refresh") throw new Error("unexpected refresh authorization"); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ accessToken: "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjQxMDAwMDAwMDB9.fakesig", refreshToken: "rotated-refresh" })); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + process.env.CURSOR_REFRESH_URL = "http://127.0.0.1:" + server.address().port + "/auth/exchange_user_api_key"; + process.env.CURSOR_REFRESH_TIMEOUT_MS = "25"; + const { refreshCursorToken } = await import("./src/auth.ts"); + const refreshed = await refreshCursorToken("valid-refresh"); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + console.log(JSON.stringify({ + accessMatches: refreshed.access === "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjQxMDAwMDAwMDB9.fakesig", + refreshMatches: refreshed.refresh === "rotated-refresh", + })); + `); +} + async function observeNonStreamingStall(apiUrl: string): Promise> { return runScenarioSubprocess(apiUrl, ` import { startProxy, stopProxy } from "./src/proxy.ts"; @@ -851,11 +873,18 @@ async function testResumeRefreshAndNonStreamingKnownFailures() { } const refresh = await observeStalledRefresh(); - await expectKnownFailure( - "stalled token refresh returns headers or an error", - async () => assert(refresh.headersReturned === true, "Refresh stall must not leave the request pre-header"), - () => `observed headersReturned=${String(refresh.headersReturned)}`, - ); + assert(refresh.headersReturned === true, "Refresh stall must not leave the request pre-header"); + assertEqual(refresh.status, 504, "Refresh timeout must return Gateway Timeout"); + const refreshError = refresh.body as { error?: { message?: string; type?: string; code?: string } }; + assertEqual(refreshError.error?.type, "gateway_timeout", "Refresh timeout must be a structured timeout error"); + assertEqual(refreshError.error?.code, "token_refresh_timeout", "Refresh timeout must identify the refresh boundary"); + assert(!JSON.stringify(refreshError).includes("stalled-refresh"), "Refresh timeout errors must not expose refresh credentials"); + console.log("[test] Stalled token refresh returns a bounded, redacted structured error"); + + const successfulRefresh = await observeSuccessfulRefresh(); + assertEqual(successfulRefresh.accessMatches, true, "Successful refresh must preserve the access token"); + assertEqual(successfulRefresh.refreshMatches, true, "Successful refresh must preserve a rotated refresh token"); + console.log("[test] Successful token refresh completes within the configured bound"); const collectionFixture = await createFakeCursorServer({ deadlineMs: 1_000 }); try { From 28e0ea53521b879cb0a8094612720f83bcaa3ba4 Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:12:44 -0700 Subject: [PATCH 14/22] fix(transport): detect failed HTTP2 sessions Probe live HTTP/2 sessions with bounded PINGs and terminate explicitly on session or stream lifecycle failures. Keep diagnostic error names allowlisted while expanding the local transport fixture coverage. --- src/h2-bridge.mjs | 79 ++++++++++++++++++++++++++--- src/proxy.ts | 11 +++- test/fixtures/fake-cursor-server.ts | 13 +++++ test/smoke.ts | 31 ++++++++++- 4 files changed, 123 insertions(+), 11 deletions(-) diff --git a/src/h2-bridge.mjs b/src/h2-bridge.mjs index a8db5fa..efa03fd 100644 --- a/src/h2-bridge.mjs +++ b/src/h2-bridge.mjs @@ -22,6 +22,17 @@ import http2 from "node:http2"; import crypto from "node:crypto"; const CURSOR_CLIENT_VERSION = "cli-2026.01.09-231024f"; +const DEFAULT_PING_INTERVAL_MS = 15_000; +const DEFAULT_PING_TIMEOUT_MS = 10_000; +const DEFAULT_SESSION_TIMEOUT_MS = 120_000; +const MAX_PING_INTERVAL_MS = 300_000; +const MAX_PING_TIMEOUT_MS = 60_000; +const MAX_SESSION_TIMEOUT_MS = 600_000; + +function boundedDuration(name, fallback, maximum) { + const value = Number.parseInt(process.env[name] ?? "", 10); + return Number.isFinite(value) && value > 0 ? Math.min(value, maximum) : fallback; +} /** Write one length-prefixed message to stdout. */ function writeMessage(data) { @@ -99,15 +110,56 @@ function emitDiagnostic(stage, details = {}) { elapsedMs: Math.round(performance.now() - diagnosticStartedAt), parentElapsedMs: diagnosticContext.elapsedMs, }; - for (const key of ["byteLength", "status", "exitCode"]) + for (const key of ["byteLength", "exitCode"]) if (typeof details[key] === "number") event[key] = details[key]; + if (typeof details.status === "string") event.status = details.status; if (typeof details.errorName === "string") event.errorName = details.errorName; try { process.stderr.write(`${JSON.stringify(event)}\n`); } catch {} } emitDiagnostic("bridge_start"); const client = http2.connect(url || "https://api2.cursor.sh"); -client.on("connect", () => emitDiagnostic("h2_connect")); +const pingIntervalMs = boundedDuration( + "CURSOR_BRIDGE_PING_INTERVAL_MS", DEFAULT_PING_INTERVAL_MS, MAX_PING_INTERVAL_MS, +); +const pingTimeoutMs = boundedDuration( + "CURSOR_BRIDGE_PING_TIMEOUT_MS", DEFAULT_PING_TIMEOUT_MS, MAX_PING_TIMEOUT_MS, +); +const sessionTimeoutMs = boundedDuration( + "CURSOR_BRIDGE_SESSION_TIMEOUT_MS", DEFAULT_SESSION_TIMEOUT_MS, MAX_SESSION_TIMEOUT_MS, +); +let pingInterval; +let pingTimeout; +let cleanExit = false; + +function clearLivenessTimers() { + clearTimeout(timeout); + if (pingInterval) clearInterval(pingInterval); + if (pingTimeout) clearTimeout(pingTimeout); + pingInterval = undefined; + pingTimeout = undefined; +} + +function sendPing() { + if (failed || cleanExit || pingTimeout) return; + try { + pingTimeout = setTimeout(() => failBridge("PingTimeout"), pingTimeoutMs); + client.ping((error) => { + if (pingTimeout) clearTimeout(pingTimeout); + pingTimeout = undefined; + if (error) failBridge("PingFailure"); + }); + } catch { + failBridge("PingFailure"); + } +} + +client.on("connect", () => { + emitDiagnostic("h2_connect"); + // PING validates the HTTP/2/TCP path, not model output. A 15s cadence and + // 10s response window leave normal multi-minute model thinking untouched. + pingInterval = setInterval(sendPing, pingIntervalMs); +}); // Guard against initial connection failure. Reset on any h2 activity // so long-running agent conversations (with tool call round-trips) survive. @@ -126,17 +178,23 @@ function killBridge(reason = "connect") { let failed = false; function failBridge(errorName) { - if (failed) return; + if (failed || cleanExit) return; failed = true; - clearTimeout(timeout); + clearLivenessTimers(); emitDiagnostic("terminal", { errorName, exitCode: 1 }); try { client.destroy(); } catch {} process.exit(1); } -client.on("error", (err) => { - failBridge(err?.name || "Error"); +client.on("error", () => failBridge("Http2SessionError")); + +client.on("goaway", () => failBridge("Http2Goaway")); +client.on("frameError", () => failBridge("Http2FrameError")); +client.on("timeout", () => failBridge("Http2SessionTimeout")); +client.on("close", () => { + if (!cleanExit) failBridge("Http2SessionClose"); }); +client.setTimeout(sessionTimeoutMs); const headers = { ":method": "POST", @@ -180,10 +238,11 @@ h2Stream.on("data", (chunk) => { }); h2Stream.on("end", () => { - clearTimeout(timeout); + clearLivenessTimers(); if (!unary && !sawConnectEndStream) { failBridge("ConnectProtocolError"); } + cleanExit = true; emitDiagnostic("terminal", { exitCode: 0 }); client.close(); // Give stdout time to flush @@ -191,7 +250,11 @@ h2Stream.on("end", () => { }); h2Stream.on("error", (err) => { - failBridge(err?.name || "Error"); + failBridge("Http2StreamError"); +}); +h2Stream.on("aborted", () => failBridge("Http2StreamAborted")); +h2Stream.on("close", () => { + if (!cleanExit) failBridge("Http2StreamClose"); }); // Forward stdin → H2 stream (after config message) diff --git a/src/proxy.ts b/src/proxy.ts index 030416b..f89fdfb 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -365,7 +365,16 @@ function spawnBridge(options: SpawnBridgeOptions): { for (const key of ["parentElapsedMs", "byteLength", "status", "exitCode"] as const) { if (typeof parsed[key] === "number") event[key] = parsed[key]; } - if (typeof parsed.errorName === "string") event.errorName = parsed.errorName; + const allowedBridgeErrorNames = new Set([ + "Timeout:connect", "Timeout:idle", "ConnectProtocolError", "BridgeWriteError", + "PingTimeout", "PingFailure", "Http2SessionError", "Http2Goaway", + "Http2FrameError", "Http2SessionTimeout", "Http2SessionClose", + "Http2StreamError", "Http2StreamAborted", "Http2StreamClose", + ]); + if ( + typeof parsed.errorName === "string" && + allowedBridgeErrorNames.has(parsed.errorName) + ) event.errorName = parsed.errorName; console.error(JSON.stringify(event)); } catch {} } diff --git a/test/fixtures/fake-cursor-server.ts b/test/fixtures/fake-cursor-server.ts index 257b253..ac1c402 100644 --- a/test/fixtures/fake-cursor-server.ts +++ b/test/fixtures/fake-cursor-server.ts @@ -31,6 +31,8 @@ export type CursorScenario = | "abrupt-session-close" | "frame-error" | "goaway" + | "stream-aborted" + | "silent-transport" | "transport-death" | "tool-pause-resume" | "tool-pause-transport-death"; @@ -52,6 +54,8 @@ export const CURSOR_SCENARIOS: readonly CursorScenario[] = [ "abrupt-session-close", "frame-error", "goaway", + "stream-aborted", + "silent-transport", "transport-death", "tool-pause-resume", "tool-pause-transport-death", @@ -321,6 +325,15 @@ export async function createFakeCursorServer(options: { deadlineMs?: number } = stream.write(frameConnectMessage(textDelta("before-goaway"))); setTimeout(() => stream.session.goaway(http2.constants.NGHTTP2_NO_ERROR), 10); break; + case "stream-aborted": + stream.write(frameConnectMessage(textDelta("before-abort"))); + setTimeout(() => stream.close(http2.constants.NGHTTP2_CANCEL), 10); + break; + case "silent-transport": + // Keep the H2 connection open without emitting frames. Tests lower the + // client session bound while disabling the first PING to exercise its + // explicit timeout path without waiting for production timings. + break; case "transport-death": setTimeout(() => stream.session.destroy(), 10); break; diff --git a/test/smoke.ts b/test/smoke.ts index 93d1f5a..a7d4255 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -539,7 +539,11 @@ function doneCount(body: string): number { return body.split("data: [DONE]").length - 1; } -async function observeProxyScenario(scenario: CursorScenario, deadlineMs = 125): Promise { +async function observeProxyScenario( + scenario: CursorScenario, + deadlineMs = 125, + environment: Record = {}, +): Promise { const fixture = await createFakeCursorServer({ deadlineMs: 1_000 }); fixture.setScenario(scenario); const childScript = ` @@ -571,7 +575,7 @@ async function observeProxyScenario(scenario: CursorScenario, deadlineMs = 125): const child = Bun.spawn({ cmd: [process.execPath, "-e", childScript], cwd: process.cwd(), - env: { ...process.env, CURSOR_API_URL: fixture.apiUrl }, + env: { ...process.env, CURSOR_API_URL: fixture.apiUrl, ...environment }, stdout: "pipe", stderr: "pipe", }); @@ -647,6 +651,29 @@ async function testStreamingTerminationKnownFailures() { assertEqual(doneCount(abruptClose.body), 1, "Abrupt HTTP/2 closure must emit exactly one [DONE]"); console.log("[test] Abrupt HTTP/2 closure is an explicit error exactly once"); + const frameError = await observeProxyScenario("frame-error"); + assert(frameError.body.includes("[Error:"), "HTTP/2 frame errors must be surfaced as an error"); + assertEqual(doneCount(frameError.body), 1, "HTTP/2 frame errors must emit exactly one [DONE]"); + console.log("[test] HTTP/2 frame errors are explicit exactly once"); + + const goaway = await observeProxyScenario("goaway"); + assert(goaway.body.includes("[Error:"), "HTTP/2 GOAWAY must be surfaced as an error"); + assertEqual(doneCount(goaway.body), 1, "HTTP/2 GOAWAY must emit exactly one [DONE]"); + console.log("[test] HTTP/2 GOAWAY is explicit exactly once"); + + const aborted = await observeProxyScenario("stream-aborted"); + assert(aborted.body.includes("[Error:"), "Aborted HTTP/2 streams must be surfaced as an error"); + assertEqual(doneCount(aborted.body), 1, "Aborted HTTP/2 streams must emit exactly one [DONE]"); + console.log("[test] Aborted HTTP/2 streams are explicit exactly once"); + + const sessionTimeout = await observeProxyScenario("silent-transport", 250, { + CURSOR_BRIDGE_SESSION_TIMEOUT_MS: "25", + CURSOR_BRIDGE_PING_INTERVAL_MS: "1000", + }); + assert(sessionTimeout.body.includes("[Error:"), "HTTP/2 session timeout must be surfaced as an error"); + assertEqual(doneCount(sessionTimeout.body), 1, "HTTP/2 session timeout must emit exactly one [DONE]"); + console.log("[test] HTTP/2 session timeout is explicit exactly once"); + const heartbeatTrickle = await observeProxyScenario("heartbeat-trickle-kept-open"); assert(heartbeatTrickle.body.includes("[Error:"), "Heartbeat-only traffic must reach an explicit error terminal after transport closure"); assertEqual(doneCount(heartbeatTrickle.body), 1, "Heartbeat-only transport closure must emit exactly one [DONE]"); From f100c3eef13cfe84848fa2e60302ae3d54cd543e Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:19:17 -0700 Subject: [PATCH 15/22] fix(proxy): isolate paused tool resumes Bind paused bridge lookup to server-issued tool-call identities and an opaque per-pause handle rather than caller message content. Track HTTP/2 transport writability across the child-process boundary, reject unavailable tool-result writes, and remove retained bridge entries on every terminal path. --- src/h2-bridge.mjs | 18 ++-- src/native-tools.ts | 13 ++- src/proxy.ts | 127 ++++++++++++++++++++-------- test/fixtures/fake-cursor-server.ts | 8 +- test/smoke.ts | 64 ++++++++++++-- 5 files changed, 176 insertions(+), 54 deletions(-) diff --git a/src/h2-bridge.mjs b/src/h2-bridge.mjs index efa03fd..5b48d3d 100644 --- a/src/h2-bridge.mjs +++ b/src/h2-bridge.mjs @@ -34,12 +34,17 @@ function boundedDuration(name, fallback, maximum) { return Number.isFinite(value) && value > 0 ? Math.min(value, maximum) : fallback; } -/** Write one length-prefixed message to stdout. */ -function writeMessage(data) { +/** Write one typed length-prefixed message to stdout. */ +function writeMessage(kind, data) { + const payload = Buffer.concat([Buffer.from([kind]), Buffer.from(data)]); const lenBuf = Buffer.alloc(4); - lenBuf.writeUInt32BE(data.length, 0); + lenBuf.writeUInt32BE(payload.length, 0); process.stdout.write(lenBuf); - process.stdout.write(data); + process.stdout.write(payload); +} + +function writeTransportStatus(transport) { + writeMessage(1, Buffer.from(JSON.stringify({ transport }))); } // --- Buffered stdin reader --- @@ -156,6 +161,7 @@ function sendPing() { client.on("connect", () => { emitDiagnostic("h2_connect"); + writeTransportStatus("writable"); // PING validates the HTTP/2/TCP path, not model output. A 15s cadence and // 10s response window leave normal multi-minute model thinking untouched. pingInterval = setInterval(sendPing, pingIntervalMs); @@ -180,6 +186,7 @@ let failed = false; function failBridge(errorName) { if (failed || cleanExit) return; failed = true; + writeTransportStatus("failed"); clearLivenessTimers(); emitDiagnostic("terminal", { errorName, exitCode: 1 }); try { client.destroy(); } catch {} @@ -234,7 +241,7 @@ h2Stream.on("data", (chunk) => { resetTimeout(); observeConnectFrames(chunk); emitDiagnostic("bridge_data", { byteLength: chunk.length }); - writeMessage(chunk); + writeMessage(0, chunk); }); h2Stream.on("end", () => { @@ -243,6 +250,7 @@ h2Stream.on("end", () => { failBridge("ConnectProtocolError"); } cleanExit = true; + writeTransportStatus("closed"); emitDiagnostic("terminal", { exitCode: 0 }); client.close(); // Give stdout time to flush diff --git a/src/native-tools.ts b/src/native-tools.ts index 7ab2cca..fa4502c 100644 --- a/src/native-tools.ts +++ b/src/native-tools.ts @@ -221,13 +221,14 @@ interface PendingNativeExec { * Convert the redirected tool's text result into the typed native result the * paused exec expects. Returns false when no faithful conversion exists * (caller falls back to an mcpResult). - * `sendMessage` receives an unframed AgentClientMessage binary. + * `sendMessage` receives an unframed AgentClientMessage binary and must report + * whether the retained transport accepted it. */ export function sendNativeExecResult( exec: PendingNativeExec, binding: NativeExecBinding, text: string, - sendMessage: (bytes: Uint8Array) => void, + sendMessage: (bytes: Uint8Array) => boolean, ): boolean { const args = binding.args; @@ -243,7 +244,9 @@ export function sendNativeExecResult( const clientMessage = create(AgentClientMessageSchema, { message: { case: "execClientMessage", value: execClientMessage }, }); - sendMessage(toBinary(AgentClientMessageSchema, clientMessage)); + if (!sendMessage(toBinary(AgentClientMessageSchema, clientMessage))) { + throw new Error("Failed to write native tool result to Cursor bridge"); + } }; switch (binding.resultType) { @@ -353,7 +356,9 @@ export function sendNativeExecResult( const clientMessage = create(AgentClientMessageSchema, { message: { case: "execClientControlMessage", value: controlMessage }, }); - sendMessage(toBinary(AgentClientMessageSchema, clientMessage)); + if (!sendMessage(toBinary(AgentClientMessageSchema, clientMessage))) { + throw new Error("Failed to write native tool result to Cursor bridge"); + } return true; } diff --git a/src/proxy.ts b/src/proxy.ts index f89fdfb..6605b8f 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -157,6 +157,10 @@ interface PendingExec { /** A bridge kept alive across requests for tool result continuation. */ interface ActiveBridge { + /** Opaque proxy-generated handle; never derived from caller content. */ + resumeId: string; + /** Cursor tool-call identifiers that resolve this exact paused interaction. */ + toolCallIds: Set; bridge: ReturnType; heartbeatTimer: NodeJS.Timeout; blobStore: Map; @@ -165,10 +169,11 @@ interface ActiveBridge { pendingExecs: PendingExec[]; } -// Active bridges keyed by a session token (derived from conversation state). -// When tool_calls are returned, the bridge stays alive. The next request -// with tool results looks up the bridge and sends mcpResult messages. +// Active bridges are keyed by an opaque, per-pause UUID. A separate index maps +// Cursor's tool-call identifiers back to that UUID on the follow-up request. +// Message content is deliberately not part of resume identity. const activeBridges = new Map(); +const activeBridgeToolCalls = new Map(); interface StoredConversation { conversationId: string; @@ -316,6 +321,8 @@ function spawnBridge(options: SpawnBridgeOptions): { onClose: (cb: (code: number) => void) => void; /** True while the bridge subprocess is still running. */ get alive(): boolean; + /** True only after the child reports a live, writable HTTP/2 transport. */ + get writable(): boolean; } { const proc = Bun.spawn(["node", BRIDGE_PATH], { stdin: "pipe", @@ -390,6 +397,7 @@ function spawnBridge(options: SpawnBridgeOptions): { // Track exit state so late onClose registrations fire immediately. let exited = false; let exitCode = 1; + let writable = false; (async () => { const reader = proc.stdout.getReader(); @@ -406,7 +414,19 @@ function spawnBridge(options: SpawnBridgeOptions): { if (pending.length < 4 + len) break; const payload = pending.subarray(4, 4 + len); pending = pending.subarray(4 + len); - cbs.data?.(Buffer.from(payload)); + // The child reserves the first byte for pipe-level control. H2 data + // remains opaque to this layer; control records only establish whether + // a retained bridge can safely accept a tool-result resume. + if (payload[0] === 1) { + try { + const status = JSON.parse(payload.subarray(1).toString("utf8")); + writable = status.transport === "writable"; + } catch { + writable = false; + } + } else if (payload[0] === 0) { + cbs.data?.(Buffer.from(payload.subarray(1))); + } } } } catch { @@ -422,6 +442,7 @@ function spawnBridge(options: SpawnBridgeOptions): { return { proc, get alive() { return !exited; }, + get writable() { return !exited && writable; }, write(data) { if (exited) return false; try { @@ -527,6 +548,11 @@ export function getProxyPort(): number | undefined { return proxyPort; } +/** Visible for lifecycle assertions; retained bridges are never resumable after termination. */ +export function getActiveBridgeCount(): number { + return activeBridges.size; +} + export async function startProxy( getAccessToken: () => Promise, models: ReadonlyArray<{ id: string; name: string }> = [], @@ -639,18 +665,19 @@ async function handleChatCompletion( ); } - // bridgeKey: model-specific, for active tool-call bridges - // convKey: model-independent, for conversation state that survives model switches - const bridgeKey = deriveBridgeKey(modelId, body.messages); + // A tool-result request has no trustworthy conversation ID in OpenAI's wire + // shape. Its tool_call_id is the only identifier bound to the paused server + // interaction, so resolve exclusively through that index and fail closed on + // missing, ambiguous, or conflicting IDs. const convKey = deriveConversationKey(body.messages); - const activeBridge = activeBridges.get(bridgeKey); + const activeBridge = findPausedBridge(toolResults); if (activeBridge && toolResults.length > 0) { - activeBridges.delete(bridgeKey); + removeActiveBridge(activeBridge); - if (activeBridge.bridge.alive) { + if (activeBridge.bridge.writable) { // Resume the live bridge with tool results - return handleToolResultResume(activeBridge, toolResults, userText, modelId, bridgeKey, convKey); + return handleToolResultResume(activeBridge, toolResults, userText, modelId, convKey); } // Bridge died (timeout, server disconnect, etc.). @@ -660,10 +687,10 @@ async function handleChatCompletion( } // Clean up stale bridge if present - if (activeBridge && activeBridges.has(bridgeKey)) { + if (activeBridge && activeBridges.has(activeBridge.resumeId)) { clearInterval(activeBridge.heartbeatTimer); activeBridge.bridge.end(); - activeBridges.delete(bridgeKey); + removeActiveBridge(activeBridge); } let stored = conversationStates.get(convKey); @@ -693,7 +720,7 @@ async function handleChatCompletion( if (body.stream === false) { return handleNonStreamingResponse(payload, accessToken, modelId, convKey); } - return handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey); + return handleStreamingResponse(payload, accessToken, modelId, convKey); } interface ToolResultInfo { @@ -1467,14 +1494,23 @@ function sendExecResult( sendFrame(frameConnectMessage(toBinary(AgentClientMessageSchema, clientMessage))); } -/** Derive a key for active bridge lookup (tool-call continuations). Model-specific. */ -function deriveBridgeKey(modelId: string, messages: OpenAIMessage[]): string { - const firstUserMsg = messages.find((m) => m.role === "user"); - const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : ""; - return createHash("sha256") - .update(`bridge:${modelId}:${firstUserText.slice(0, 200)}`) - .digest("hex") - .slice(0, 16); +function removeActiveBridge(active: ActiveBridge): void { + if (activeBridges.get(active.resumeId) === active) { + activeBridges.delete(active.resumeId); + } + for (const toolCallId of active.toolCallIds) { + if (activeBridgeToolCalls.get(toolCallId) === active.resumeId) { + activeBridgeToolCalls.delete(toolCallId); + } + } +} + +function findPausedBridge(toolResults: ToolResultInfo[]): ActiveBridge | undefined { + if (toolResults.length === 0 || toolResults.some((result) => !result.toolCallId)) return undefined; + const resumeIds = new Set(toolResults.map((result) => activeBridgeToolCalls.get(result.toolCallId))); + if (resumeIds.size !== 1) return undefined; + const resumeId = resumeIds.values().next().value; + return typeof resumeId === "string" ? activeBridges.get(resumeId) : undefined; } /** Derive a key for conversation state. Model-independent so context survives model switches. */ @@ -1510,7 +1546,6 @@ function createBridgeStreamResponse( mcpTools: McpToolDefinition[], cloudRule: string | undefined, modelId: string, - bridgeKey: string, convKey: string, ): Response { const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`; @@ -1578,7 +1613,9 @@ function createBridgeStreamResponse( if (terminal) return; terminal = true; clearInterval(heartbeatTimer); - activeBridges.delete(bridgeKey); + for (const active of activeBridges.values()) { + if (active.bridge === bridge) removeActiveBridge(active); + } const flushed = tagFilter.flush(); if (flushed.reasoning) sendSSE(makeChunk({ reasoning_content: flushed.reasoning })); @@ -1595,7 +1632,9 @@ function createBridgeStreamResponse( if (terminal) return; terminal = true; clearInterval(heartbeatTimer); - activeBridges.delete(bridgeKey); + for (const active of activeBridges.values()) { + if (active.bridge === bridge) removeActiveBridge(active); + } bridge.terminate(); }; @@ -1648,15 +1687,27 @@ function createBridgeStreamResponse( }], })); - // Keep the bridge alive for tool result continuation. - activeBridges.set(bridgeKey, { + // The server-issued tool-call ID identifies this paused + // interaction. Refuse a duplicate instead of overwriting an + // unrelated bridge and risking cross-conversation routing. + const existingResumeId = activeBridgeToolCalls.get(exec.toolCallId); + if (existingResumeId) { + finishTerminal(new Error("Cursor tool-call identity is already paused")); + return; + } + const resumeId = crypto.randomUUID(); + const active: ActiveBridge = { + resumeId, + toolCallIds: new Set([exec.toolCallId]), bridge, heartbeatTimer, blobStore, mcpTools, cloudRule, pendingExecs: state.pendingExecs, - }); + }; + activeBridges.set(resumeId, active); + activeBridgeToolCalls.set(exec.toolCallId, resumeId); sendSSE(makeChunk({}, "tool_calls")); sendDone(); @@ -1721,7 +1772,9 @@ function createBridgeStreamResponse( // Tool pause deliberately owns neither terminal cleanup nor bridge // shutdown. Once the retained bridge itself exits, it is stale on // either exit code and must no longer be resumable. - activeBridges.delete(bridgeKey); + for (const active of activeBridges.values()) { + if (active.bridge === bridge) removeActiveBridge(active); + } return; } @@ -1764,14 +1817,13 @@ function handleStreamingResponse( payload: CursorRequestPayload, accessToken: string, modelId: string, - bridgeKey: string, convKey: string, ): Response { const { bridge, heartbeatTimer } = startBridge(accessToken, payload.requestBytes); return createBridgeStreamResponse( bridge, heartbeatTimer, payload.blobStore, payload.mcpTools, payload.cloudRule, - modelId, bridgeKey, convKey, + modelId, convKey, ); } @@ -1781,7 +1833,6 @@ function handleToolResultResume( toolResults: ToolResultInfo[], userText: string, modelId: string, - bridgeKey: string, convKey: string, ): Response { const { bridge, heartbeatTimer, blobStore, mcpTools, cloudRule, pendingExecs } = active; @@ -1852,11 +1903,17 @@ function handleToolResultResume( ); } - return createBridgeStreamResponse( + try { + return createBridgeStreamResponse( bridge, heartbeatTimer, blobStore, mcpTools, cloudRule, - modelId, bridgeKey, convKey, - ); + modelId, convKey, + ); + } catch (error) { + clearInterval(heartbeatTimer); + bridge.terminate(); + throw error; + } } async function handleNonStreamingResponse( diff --git a/test/fixtures/fake-cursor-server.ts b/test/fixtures/fake-cursor-server.ts index ac1c402..e7a87d2 100644 --- a/test/fixtures/fake-cursor-server.ts +++ b/test/fixtures/fake-cursor-server.ts @@ -35,7 +35,8 @@ export type CursorScenario = | "silent-transport" | "transport-death" | "tool-pause-resume" - | "tool-pause-transport-death"; + | "tool-pause-transport-death" + | "tool-pause-clean-exit"; export const CURSOR_SCENARIOS: readonly CursorScenario[] = [ "clean-end-stream", @@ -59,6 +60,7 @@ export const CURSOR_SCENARIOS: readonly CursorScenario[] = [ "transport-death", "tool-pause-resume", "tool-pause-transport-death", + "tool-pause-clean-exit", ] as const; export interface FakeCursorServer { @@ -214,7 +216,7 @@ export async function createFakeCursorServer(options: { deadlineMs?: number } = stream.once("close", finish); stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); - if (scenario === "tool-pause-resume" || scenario === "tool-pause-transport-death") { + if (scenario === "tool-pause-resume" || scenario === "tool-pause-transport-death" || scenario === "tool-pause-clean-exit") { const toolCallId = `fixture-call-${++toolPauseCount}`; let receivedInitialRequest = false; let paused = false; @@ -226,6 +228,8 @@ export async function createFakeCursorServer(options: { deadlineMs?: number } = if (scenario === "tool-pause-transport-death") { const session = stream.session; setTimeout(() => session.destroy(), 10); + } else if (scenario === "tool-pause-clean-exit") { + setTimeout(() => stream.end(frameConnectEndStream(new TextEncoder().encode("{}"))), 10); } return; } diff --git a/test/smoke.ts b/test/smoke.ts index a7d4255..39d3d6f 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -28,6 +28,7 @@ interface TestModules { startProxy: typeof import("../src/proxy").startProxy; stopProxy: typeof import("../src/proxy").stopProxy; getProxyPort: typeof import("../src/proxy").getProxyPort; + getActiveBridgeCount: typeof import("../src/proxy").getActiveBridgeCount; generateCursorAuthParams: typeof import("../src/auth").generateCursorAuthParams; getTokenExpiry: typeof import("../src/auth").getTokenExpiry; CursorAuthPlugin: typeof import("../src/index").CursorAuthPlugin; @@ -252,6 +253,7 @@ async function loadModules(): Promise { startProxy: proxy.startProxy, stopProxy: proxy.stopProxy, getProxyPort: proxy.getProxyPort, + getActiveBridgeCount: proxy.getActiveBridgeCount, generateCursorAuthParams: auth.generateCursorAuthParams, getTokenExpiry: auth.getTokenExpiry, CursorAuthPlugin: index.CursorAuthPlugin, @@ -750,6 +752,23 @@ async function testServerMessageClassification(modules: TestModules) { writeFailure = error instanceof Error ? error : new Error(String(error)); } assertEqual(writeFailure?.message, "bridge write rejected", "Bridge write failures must propagate to the terminal owner"); + const { sendNativeExecResult } = await import("../src/native-tools"); + let nativeWriteFailure: Error | undefined; + try { + sendNativeExecResult( + { execId: "dead-bridge", execMsgId: 1 }, + { resultType: "readResult", args: { path: "/tmp/never-written" } }, + "unreachable", + () => false, + ); + } catch (error) { + nativeWriteFailure = error instanceof Error ? error : new Error(String(error)); + } + assertEqual( + nativeWriteFailure?.message, + "Failed to write native tool result to Cursor bridge", + "A dead bridge must explicitly reject native tool-result writes", + ); console.log("[test] Liveness traffic is non-progress and unsupported queries terminate explicitly"); } @@ -807,7 +826,7 @@ async function observeToolResume( { role: "tool", tool_call_id: firstCall, content: "result-for-" + firstCall }, ])); stopProxy(); - console.log(JSON.stringify({ first, second, secondCall, resumed })); + console.log(JSON.stringify({ first, firstCall, second, secondCall, resumed })); process.exit(0); `); } @@ -860,7 +879,7 @@ async function observeNonStreamingStall(apiUrl: string): Promise assert(String((collision.resumed as { text?: string }).text ?? "").includes("resumed-correctly"), "A resume must reach its own paused bridge"), - () => `observed secondCall=${String(collision.secondCall)}, body=${JSON.stringify((collision.resumed as { text?: string }).text ?? "")}`, + assert( + collision.secondCall !== undefined && collision.secondCall !== collision.firstCall, + "Identical-opening conversations must receive distinct paused tool-call identities", ); + assert( + String((collision.resumed as { text?: string }).text ?? "").includes("resumed-correctly"), + "An identical-opening conversation must resume its own paused bridge", + ); + assert( + !String((collision.resumed as { text?: string }).text ?? "").includes("resumed-with-wrong-result"), + "A tool result must never resume a different identical-opening conversation", + ); + console.log("[test] Identical-opening conversations retain isolated paused bridges"); fixture.setScenario("tool-pause-transport-death"); const droppedWrite = await observeToolResume(fixture.apiUrl, "tool-pause-transport-death"); @@ -894,7 +921,28 @@ async function testResumeRefreshAndNonStreamingKnownFailures() { String((droppedWrite.resumed as { text?: string }).text ?? "").includes('"finish_reason":"tool_calls"'), "A closed transport must not accept a dropped resume write as a live bridge", ); - console.log("[test] Closed-stream fixture does not reproduce a silent dropped write; child exit triggers fallback"); + assert( + !String((droppedWrite.resumed as { text?: string }).text ?? "").includes("resumed-correctly"), + "A dead bridge must reject its pending result rather than accepting a stale write", + ); + console.log("[test] Closed-stream bridge rejects stale writes and resumes through a fresh request"); + + fixture.setScenario("tool-pause-clean-exit"); + const port = await modules.startProxy(async () => "test-token"); + const paused = await fetch(`http://localhost:${port}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "test", stream: true, + messages: [{ role: "user", content: "paused bridge cleanup" }], + tools: [{ type: "function", function: { name: "fixture-tool", parameters: { type: "object" } } }], + }), + }); + await paused.text(); + await Bun.sleep(40); + assertEqual(modules.getActiveBridgeCount(), 0, "A clean terminal path must remove its paused bridge entry"); + modules.stopProxy(); + console.log("[test] Clean paused-bridge exit removes its active bridge entry"); } finally { await fixture.close(); } @@ -1132,7 +1180,7 @@ async function main() { await testFakeCursorScenarioHarness(); await testStreamingTerminationKnownFailures(); await testServerMessageClassification(modules); - await testResumeRefreshAndNonStreamingKnownFailures(); + await testResumeRefreshAndNonStreamingKnownFailures(modules); await testLifecycleDiagnostics(modules); await testExpiredTokenRefreshBeforeDiscovery(modules, backend); await testDiscoveryFallbackAndSuccess(modules, backend); From a17f01845aea84b8a2cef0220088d9aa919ca46e Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:26:16 -0700 Subject: [PATCH 16/22] fix(proxy): bound semantic response stalls --- src/proxy.ts | 141 ++++++++++++++++++++++++++++++++++++++++---------- test/smoke.ts | 79 ++++++++++++++-------------- 2 files changed, 153 insertions(+), 67 deletions(-) diff --git a/src/proxy.ts b/src/proxy.ts index 6605b8f..b48aa51 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -602,16 +602,17 @@ export async function startProxy( }); const message = err instanceof Error ? err.message : String(err); const refreshTimedOut = err instanceof CursorTokenRefreshTimeoutError; + const responseStalled = err instanceof CursorResponseStallTimeoutError; return new Response( JSON.stringify({ error: { message, - type: refreshTimedOut ? "gateway_timeout" : "server_error", - code: refreshTimedOut ? "token_refresh_timeout" : "internal_error", + type: refreshTimedOut || responseStalled ? "gateway_timeout" : "server_error", + code: refreshTimedOut ? "token_refresh_timeout" : responseStalled ? "response_stall_timeout" : "internal_error", }, }), { - status: refreshTimedOut ? 504 : 500, + status: refreshTimedOut || responseStalled ? 504 : 500, headers: { "Content-Type": "application/json" }, }, ); @@ -1126,6 +1127,63 @@ export interface StreamState { lastSemanticProgressAt: number; } +// The initial wait is deliberately longer: reasoning models may queue and +// plan for minutes before emitting their first token. Afterwards, five +// minutes still accommodates extended thinking while bounding heartbeat-only +// stalls. Hard caps keep invalid environment values from disabling this guard. +const DEFAULT_FIRST_PROGRESS_TIMEOUT_MS = 10 * 60 * 1000; +const DEFAULT_SEMANTIC_PROGRESS_TIMEOUT_MS = 5 * 60 * 1000; +const MAX_FIRST_PROGRESS_TIMEOUT_MS = 30 * 60 * 1000; +const MAX_SEMANTIC_PROGRESS_TIMEOUT_MS = 15 * 60 * 1000; + +function boundedDuration(name: string, fallback: number, maximum: number): number { + const value = Number.parseInt(process.env[name] ?? "", 10); + return Number.isFinite(value) && value > 0 ? Math.min(value, maximum) : fallback; +} + +class CursorResponseStallTimeoutError extends Error { + constructor(stage: "first_progress" | "semantic_progress") { + super(`Cursor response stalled waiting for ${stage.replace("_", " ")}`); + this.name = "CursorResponseStallTimeoutError"; + } +} + +function createSemanticProgressWatchdog( + state: StreamState, + onStall: (error: CursorResponseStallTimeoutError) => void, +): { observe: () => void; clear: () => void } { + const firstProgressTimeoutMs = boundedDuration( + "CURSOR_PROXY_FIRST_PROGRESS_TIMEOUT_MS", + DEFAULT_FIRST_PROGRESS_TIMEOUT_MS, + MAX_FIRST_PROGRESS_TIMEOUT_MS, + ); + const semanticProgressTimeoutMs = boundedDuration( + "CURSOR_PROXY_SEMANTIC_PROGRESS_TIMEOUT_MS", + DEFAULT_SEMANTIC_PROGRESS_TIMEOUT_MS, + MAX_SEMANTIC_PROGRESS_TIMEOUT_MS, + ); + let observedProgress = state.semanticProgressCount; + let timer: ReturnType | undefined; + + const arm = (timeoutMs: number, stage: "first_progress" | "semantic_progress") => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => onStall(new CursorResponseStallTimeoutError(stage)), timeoutMs); + }; + + arm(firstProgressTimeoutMs, "first_progress"); + return { + observe() { + if (state.semanticProgressCount === observedProgress) return; + observedProgress = state.semanticProgressCount; + arm(semanticProgressTimeoutMs, "semantic_progress"); + }, + clear() { + if (timer) clearTimeout(timer); + timer = undefined; + }, + }; +} + function computeUsage(state: StreamState) { const completion_tokens = state.outputTokens; const total_tokens = state.totalTokens || completion_tokens; @@ -1608,10 +1666,12 @@ function createBridgeStreamResponse( let mcpExecReceived = false; let connectEndStreamReceived = false; let terminal = false; + let semanticWatchdog: ReturnType; const finishTerminal = (error?: Error) => { if (terminal) return; terminal = true; + semanticWatchdog.clear(); clearInterval(heartbeatTimer); for (const active of activeBridges.values()) { if (active.bridge === bridge) removeActiveBridge(active); @@ -1631,6 +1691,7 @@ function createBridgeStreamResponse( cancelStream = () => { if (terminal) return; terminal = true; + semanticWatchdog.clear(); clearInterval(heartbeatTimer); for (const active of activeBridges.values()) { if (active.bridge === bridge) removeActiveBridge(active); @@ -1666,6 +1727,9 @@ function createBridgeStreamResponse( }, // onMcpExec — the model wants to execute a tool. (exec) => { + // A tool pause transfers control to the caller; it is not a + // model-output stall and must leave its live bridge resumable. + semanticWatchdog.clear(); emitLifecycleDiagnostic("proxy", "tool_pause"); state.pendingExecs.push(exec); mcpExecReceived = true; @@ -1726,6 +1790,7 @@ function createBridgeStreamResponse( }, finishTerminal, ); + semanticWatchdog.observe(); emitLifecycleDiagnostic("proxy", "message_dispatch", { messageCase: serverMessage.message.case, interactionCase: serverMessage.message.case === "interactionUpdate" @@ -1756,10 +1821,13 @@ function createBridgeStreamResponse( }, ); + semanticWatchdog = createSemanticProgressWatchdog(state, finishTerminal); + bridge.onData(processChunk); bridge.onClose((code) => { emitLifecycleDiagnostic("proxy", "terminal", { exitCode: code }); + semanticWatchdog.clear(); clearInterval(heartbeatTimer); const stored = conversationStates.get(convKey); if (stored) { @@ -1957,6 +2025,7 @@ async function collectFullResponse( ): Promise { const { promise, resolve, reject } = Promise.withResolvers(); let fullText = ""; + let settled = false; const { bridge, heartbeatTimer } = startBridge(accessToken, payload.requestBytes); @@ -1970,6 +2039,32 @@ async function collectFullResponse( }; const tagFilter = createThinkingTagFilter(); + const persistState = () => { + const stored = conversationStates.get(convKey); + if (stored) { + for (const [k, v] of payload.blobStore) stored.blobStore.set(k, v); + stored.lastAccessMs = Date.now(); + persistConversation(convKey, stored); + } + }; + let semanticWatchdog: ReturnType; + const finishCollection = (error?: Error) => { + if (settled) return; + settled = true; + semanticWatchdog.clear(); + clearInterval(heartbeatTimer); + persistState(); + if (error) { + bridge.terminate(); + reject(error); + return; + } + const flushed = tagFilter.flush(); + fullText += flushed.content; + bridge.terminate(); + resolve({ text: fullText, usage: computeUsage(state) }); + }; + bridge.onData(createConnectFrameParser( (messageBytes) => { try { @@ -1982,7 +2077,9 @@ async function collectFullResponse( payload.blobStore, payload.mcpTools, payload.cloudRule, - (data) => bridge.write(data), + (data) => { + if (!bridge.write(data)) throw new Error("Failed to write frame to Cursor bridge"); + }, state, (text, isThinking) => { if (isThinking) return; @@ -2000,42 +2097,34 @@ async function collectFullResponse( } }, (error) => { - clearInterval(heartbeatTimer); - bridge.end(); - reject(error); + finishCollection(error); }, ); + semanticWatchdog.observe(); } catch (error) { // A complete Connect frame with an invalid protobuf payload cannot be // safely ignored in unary mode either: it may contain the only // terminal signal. Preserve the same explicit protocol failure that // the streaming path emits. - clearInterval(heartbeatTimer); - bridge.end(); - reject(error instanceof Error + finishCollection(error instanceof Error ? new Error(`Failed to process Cursor server message: ${error.message}`) : new Error("Failed to process Cursor server message")); } }, - () => {}, + (endStreamBytes) => { + const endError = parseConnectEndStream(endStreamBytes); + finishCollection(endError ?? undefined); + }, )); - bridge.onClose(() => { - clearInterval(heartbeatTimer); - const stored = conversationStates.get(convKey); - if (stored) { - for (const [k, v] of payload.blobStore) stored.blobStore.set(k, v); - stored.lastAccessMs = Date.now(); - persistConversation(convKey, stored); - } - const flushed = tagFilter.flush(); - fullText += flushed.content; + semanticWatchdog = createSemanticProgressWatchdog(state, finishCollection); - const usage = computeUsage(state); - resolve({ - text: fullText, - usage, - }); + bridge.onClose((code) => { + finishCollection(new Error( + code === 0 + ? "Connect stream ended without end-of-stream frame" + : `Bridge exited with code ${code} before Connect end-stream`, + )); }); return promise; diff --git a/test/smoke.ts b/test/smoke.ts index 39d3d6f..eabe9a1 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -594,23 +594,8 @@ async function observeProxyScenario( } } -async function expectKnownFailure( - label: string, - expectedBehavior: () => Promise, - observed: () => string, -) { - try { - await expectedBehavior(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log(`[known-failure] ${label}: ${observed()} (${message})`); - return; - } - throw new Error(`Known failure unexpectedly passed: ${label}; remove its quarantine and make it required`); -} - -async function testStreamingTerminationKnownFailures() { - console.log("[test] Reproducing quarantined streaming termination failures..."); +async function testStreamingTermination() { + console.log("[test] Testing streaming termination behavior..."); const cleanEnd = await observeProxyScenario("clean-end-stream-kept-open"); assert(!cleanEnd.timedOut && cleanEnd.body.includes("data: [DONE]"), "Clean end-stream must finish in the local H2 fixture"); @@ -676,10 +661,24 @@ async function testStreamingTerminationKnownFailures() { assertEqual(doneCount(sessionTimeout.body), 1, "HTTP/2 session timeout must emit exactly one [DONE]"); console.log("[test] HTTP/2 session timeout is explicit exactly once"); - const heartbeatTrickle = await observeProxyScenario("heartbeat-trickle-kept-open"); - assert(heartbeatTrickle.body.includes("[Error:"), "Heartbeat-only traffic must reach an explicit error terminal after transport closure"); - assertEqual(doneCount(heartbeatTrickle.body), 1, "Heartbeat-only transport closure must emit exactly one [DONE]"); - console.log("[test] Heartbeat-only transport closure is an explicit error exactly once"); + const heartbeatTrickle = await observeProxyScenario("heartbeat-trickle-kept-open", 250, { + CURSOR_PROXY_FIRST_PROGRESS_TIMEOUT_MS: "25", + CURSOR_PROXY_SEMANTIC_PROGRESS_TIMEOUT_MS: "25", + }); + assert(!heartbeatTrickle.timedOut, "Heartbeat-only traffic must be bounded by semantic progress, not fixture closure"); + assert(heartbeatTrickle.body.includes("[Error: Cursor response stalled waiting for first progress]"), "Heartbeat-only traffic must reach an explicit semantic-stall error"); + assertEqual(doneCount(heartbeatTrickle.body), 1, "Heartbeat-only semantic stalls must emit exactly one [DONE]"); + console.log("[test] Heartbeat-only traffic is bounded by semantic progress exactly once"); + + const firstProgressStall = await observeProxyScenario("silent-transport", 250, { + CURSOR_PROXY_FIRST_PROGRESS_TIMEOUT_MS: "25", + CURSOR_BRIDGE_SESSION_TIMEOUT_MS: "1000", + CURSOR_BRIDGE_PING_INTERVAL_MS: "1000", + }); + assert(!firstProgressStall.timedOut, "First-progress stalls must be bounded before transport liveness expires"); + assert(firstProgressStall.body.includes("[Error: Cursor response stalled waiting for first progress]"), "First-progress stalls must surface an explicit error"); + assertEqual(doneCount(firstProgressStall.body), 1, "First-progress stalls must emit exactly one [DONE]"); + console.log("[test] First-progress stalls are bounded independently of transport liveness"); const turnEnded = await observeProxyScenario("turn-ended-kept-open"); assert(turnEnded.body.includes("[Error:"), "turnEnded transport closure must not become a clean success"); @@ -869,18 +868,8 @@ async function observeSuccessfulRefresh(): Promise> { `); } -async function observeNonStreamingStall(apiUrl: string): Promise> { - return runScenarioSubprocess(apiUrl, ` - import { startProxy, stopProxy } from "./src/proxy.ts"; - const port = await startProxy(async () => "test-token"); - const pending = fetch("http://localhost:" + port + "/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: "test", stream: false, messages: [{ role: "user", content: "non-streaming stall" }] }) }); - const result = await Promise.race([pending.then(() => ({ headersReturned: true })), new Promise(resolve => setTimeout(() => resolve({ headersReturned: false }), 125))]); - stopProxy(); console.log(JSON.stringify(result)); process.exit(0); - `); -} - -async function testResumeRefreshAndNonStreamingKnownFailures(modules: TestModules) { - console.log("[test] Reproducing quarantined resume, refresh, and collection failures..."); +async function testResumeRefreshAndNonStreaming(modules: TestModules) { + console.log("[test] Testing resume, refresh, and collection behavior..."); const fixture = await createFakeCursorServer({ deadlineMs: 1_000 }); try { fixture.setScenario("tool-pause-resume"); @@ -964,12 +953,20 @@ async function testResumeRefreshAndNonStreamingKnownFailures(modules: TestModule const collectionFixture = await createFakeCursorServer({ deadlineMs: 1_000 }); try { collectionFixture.setScenario("heartbeat-trickle-kept-open"); - const nonStreaming = await observeNonStreamingStall(collectionFixture.apiUrl); - await expectKnownFailure( - "non-streaming collection reaches a bounded terminal response", - async () => assert(nonStreaming.headersReturned === true, "Non-streaming collection must not wait forever for bridge close"), - () => `observed headersReturned=${String(nonStreaming.headersReturned)}`, - ); + const nonStreaming = await runScenarioSubprocess(collectionFixture.apiUrl, ` + process.env.CURSOR_PROXY_FIRST_PROGRESS_TIMEOUT_MS = "25"; + process.env.CURSOR_PROXY_SEMANTIC_PROGRESS_TIMEOUT_MS = "25"; + const { startProxy, stopProxy } = await import("./src/proxy.ts"); + const port = await startProxy(async () => "test-token"); + const response = await fetch("http://localhost:" + port + "/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: "test", stream: false, messages: [{ role: "user", content: "non-streaming stall" }] }) }); + const body = await response.json(); stopProxy(); console.log(JSON.stringify({ headersReturned: true, status: response.status, body })); process.exit(0); + `); + assert(nonStreaming.headersReturned === true, "Non-streaming collection must not wait forever for bridge close"); + assertEqual(nonStreaming.status, 504, "Non-streaming semantic stalls must return Gateway Timeout"); + const nonStreamingError = nonStreaming.body as { error?: { type?: string; code?: string } }; + assertEqual(nonStreamingError.error?.type, "gateway_timeout", "Non-streaming stalls must be structured timeout errors"); + assertEqual(nonStreamingError.error?.code, "response_stall_timeout", "Non-streaming stalls must identify the response boundary"); + console.log("[test] Non-streaming collection returns a bounded structured stall error"); } finally { await collectionFixture.close(); } @@ -1178,9 +1175,9 @@ async function main() { await testArrayContentParsing(modules); await testNonStreamingProtocolErrorsAreStructured(); await testFakeCursorScenarioHarness(); - await testStreamingTerminationKnownFailures(); + await testStreamingTermination(); await testServerMessageClassification(modules); - await testResumeRefreshAndNonStreamingKnownFailures(modules); + await testResumeRefreshAndNonStreaming(modules); await testLifecycleDiagnostics(modules); await testExpiredTokenRefreshBeforeDiscovery(modules, backend); await testDiscoveryFallbackAndSuccess(modules, backend); From 28a2e9a50e793c8802916a4be3b96e34307baebe Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:27:14 -0700 Subject: [PATCH 17/22] fix(proxy): preserve long reasoning waits --- src/proxy.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/proxy.ts b/src/proxy.ts index b48aa51..717be5d 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1128,13 +1128,13 @@ export interface StreamState { } // The initial wait is deliberately longer: reasoning models may queue and -// plan for minutes before emitting their first token. Afterwards, five +// plan for minutes before emitting their first token. Afterwards, ten // minutes still accommodates extended thinking while bounding heartbeat-only // stalls. Hard caps keep invalid environment values from disabling this guard. -const DEFAULT_FIRST_PROGRESS_TIMEOUT_MS = 10 * 60 * 1000; -const DEFAULT_SEMANTIC_PROGRESS_TIMEOUT_MS = 5 * 60 * 1000; -const MAX_FIRST_PROGRESS_TIMEOUT_MS = 30 * 60 * 1000; -const MAX_SEMANTIC_PROGRESS_TIMEOUT_MS = 15 * 60 * 1000; +const DEFAULT_FIRST_PROGRESS_TIMEOUT_MS = 15 * 60 * 1000; +const DEFAULT_SEMANTIC_PROGRESS_TIMEOUT_MS = 10 * 60 * 1000; +const MAX_FIRST_PROGRESS_TIMEOUT_MS = 60 * 60 * 1000; +const MAX_SEMANTIC_PROGRESS_TIMEOUT_MS = 30 * 60 * 1000; function boundedDuration(name: string, fallback: number, maximum: number): number { const value = Number.parseInt(process.env[name] ?? "", 10); From 1ffca147b461ea044e041626eb1ccc7465169526 Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:37:59 -0700 Subject: [PATCH 18/22] test(proxy): cover semantic progress rearming --- test/fixtures/fake-cursor-server.ts | 23 +++++++++++++++++++++++ test/smoke.ts | 21 +++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/test/fixtures/fake-cursor-server.ts b/test/fixtures/fake-cursor-server.ts index e7a87d2..3d66005 100644 --- a/test/fixtures/fake-cursor-server.ts +++ b/test/fixtures/fake-cursor-server.ts @@ -24,6 +24,8 @@ export type CursorScenario = | "malformed-server-message" | "partial-frame" | "slow-semantic-progress" + | "semantic-progress-stall" + | "semantic-progress-trickle" | "heartbeat-trickle" | "heartbeat-trickle-kept-open" | "turn-ended-kept-open" @@ -48,6 +50,8 @@ export const CURSOR_SCENARIOS: readonly CursorScenario[] = [ "malformed-server-message", "partial-frame", "slow-semantic-progress", + "semantic-progress-stall", + "semantic-progress-trickle", "heartbeat-trickle", "heartbeat-trickle-kept-open", "turn-ended-kept-open", @@ -305,6 +309,25 @@ export async function createFakeCursorServer(options: { deadlineMs?: number } = }, 15); break; } + case "semantic-progress-stall": + setTimeout(() => { + if (!stream.closed && !stream.destroyed) { + stream.write(frameConnectMessage(textDelta("before-semantic-stall"))); + } + }, 10); + break; + case "semantic-progress-trickle": { + let part = 0; + const interval = setInterval(() => { + if (stream.closed || stream.destroyed) return clearInterval(interval); + if (part < 4) stream.write(frameConnectMessage(textDelta(`trickle-${++part}`))); + else { + clearInterval(interval); + endWith(new TextEncoder().encode("{}")); + } + }, 20); + break; + } case "heartbeat-trickle": trickle(heartbeat()); break; diff --git a/test/smoke.ts b/test/smoke.ts index eabe9a1..730e534 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -693,6 +693,27 @@ async function testStreamingTermination() { } assert(slowProgress.body.includes("data: [DONE]"), "Slow semantic progress must finish normally"); console.log("[test] Slow semantic progress survives bounded streaming completion"); + + const semanticStall = await observeProxyScenario("semantic-progress-stall", 250, { + CURSOR_PROXY_FIRST_PROGRESS_TIMEOUT_MS: "1000", + CURSOR_PROXY_SEMANTIC_PROGRESS_TIMEOUT_MS: "25", + CURSOR_BRIDGE_SESSION_TIMEOUT_MS: "1000", + CURSOR_BRIDGE_PING_INTERVAL_MS: "1000", + }); + assert(!semanticStall.timedOut, "Post-progress stalls must be bounded before the observation deadline"); + assert(semanticStall.body.includes("[Error: Cursor response stalled waiting for semantic progress]"), "Post-progress stalls must use the between-progress bound, not the first-progress bound"); + assertEqual(doneCount(semanticStall.body), 1, "Post-progress semantic stalls must emit exactly one [DONE]"); + console.log("[test] Post-progress silence uses the semantic-progress stall bound"); + + const semanticTrickle = await observeProxyScenario("semantic-progress-trickle", 500, { + CURSOR_PROXY_FIRST_PROGRESS_TIMEOUT_MS: "1000", + CURSOR_PROXY_SEMANTIC_PROGRESS_TIMEOUT_MS: "25", + }); + assert(!semanticTrickle.timedOut, "Slow semantic progress must survive each re-armed between-progress bound"); + assert(!semanticTrickle.body.includes("[Error:"), "Re-armed semantic progress must not emit a stall error"); + assert(semanticTrickle.body.includes("trickle-4"), "Re-armed semantic progress must preserve the final delta"); + assertEqual(doneCount(semanticTrickle.body), 1, "Re-armed semantic progress must emit exactly one [DONE]"); + console.log("[test] Repeated semantic progress re-arms the between-progress bound"); } async function testServerMessageClassification(modules: TestModules) { From 39c6a154e3d93e4e95bfd15d46581f4d41c48755 Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:51:33 -0700 Subject: [PATCH 19/22] docs(baseline): record inherited stability floor Capture the verified tip gates and preserve the speculative overlay as evidence-only context before subsequent stability changes. --- docs/cursor-hang-root-cause.md | 25 +++++++++++++++++++++++++ docs/overlay-baseline.note.md | 18 ++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 docs/overlay-baseline.note.md diff --git a/docs/cursor-hang-root-cause.md b/docs/cursor-hang-root-cause.md index d8b5bef..3176556 100644 --- a/docs/cursor-hang-root-cause.md +++ b/docs/cursor-hang-root-cause.md @@ -39,6 +39,31 @@ recoverable and reviewable from that artifact. Nothing was silently discarded. > the file by design. Never reflow, reformat, or strip trailing whitespace from this artifact — > doing so corrupts the evidence and breaks `git apply` fidelity. +## Baseline status at inherited tip + +Wave 1 Task 1.1 re-verified the inherited stability floor at tip +`1ffca147b461ea044e041626eb1ccc7465169526` (`1ffca14`) on +`chore/stability-baseline`. The repository gates completed as follows: + +| Check | Result | +|---|---| +| `npx tsc -p tsconfig.json --noEmit` | PASS — no errors | +| `bun test/smoke.ts` | PASS — 48 required assertions passed; 2 documented quarantines remain | +| `bun run build` | PASS | +| `git diff --check` | PASS for edited documentation; `overlay-baseline.patch` remains a verbatim evidence artifact and is not reformatted | + +The smoke suite writes cache data under `~/.cache/opencode-cursor` and uses a +local fake Cursor backend; it does not contact Cursor. This record accepts the +inherited tip as the baseline floor before new stability behavior is changed. + +The overlay artifact is preserved evidence and **MUST NOT be reapplied**. Its +three-way disposition is: **kept/already landed** — stderr draining, sanitized +errors, defensive close/destroy guards; **rejected** — fixed 30→60s connect and +120→180s idle timeout bumps; **replaced** — the 90s raw-byte watchdog, +superseded by semantic-progress plus H2 liveness. See the adjacent +[`overlay-baseline.note.md`](./overlay-baseline.note.md); the patch itself was +not modified. + ## Hunk inventory and disposition | # | File | Location | Change | Apparent intent | Hang mechanism affected | Verdict | diff --git a/docs/overlay-baseline.note.md b/docs/overlay-baseline.note.md new file mode 100644 index 0000000..160f8c4 --- /dev/null +++ b/docs/overlay-baseline.note.md @@ -0,0 +1,18 @@ +# Overlay Baseline Disposition + +Status: **preserved evidence; accepted as historical input only.** The verbatim +unified diff in [`overlay-baseline.patch`](./overlay-baseline.patch) records the +speculative overlay that preceded the controlled stability work. It MUST NOT be +reapplied. Its meaningful whitespace is part of the evidence artifact and must +remain unchanged. + +At inherited tip `1ffca14` (`1ffca147b461ea044e041626eb1ccc7465169526`), the +overlay is dispositioned as follows: + +- **Kept/already landed:** stderr draining, sanitized errors, and defensive + close/destroy guards. +- **Rejected:** fixed 30→60s connect and 120→180s idle timeout bumps. +- **Replaced:** the 90s raw-byte watchdog, superseded by semantic-progress plus + H2 liveness. + +The repository gates were rerun against this tip and are recorded in the RCA. From 25bd23fa57160d64e9bbe6084944b34bceafee07 Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:54:13 -0700 Subject: [PATCH 20/22] docs(provenance): record Grok 4.6 research and prior-art survey verdicts --- docs/grok-4-6-provenance.md | 143 ++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/grok-4-6-provenance.md diff --git a/docs/grok-4-6-provenance.md b/docs/grok-4-6-provenance.md new file mode 100644 index 0000000..daa89b4 --- /dev/null +++ b/docs/grok-4-6-provenance.md @@ -0,0 +1,143 @@ +# Grok 4.6 Research Provenance and Prior-Art Survey + +Status: **Record of verified Grok 4.6 facts, explicit uncertainty, and prior-art verdicts.** +Evidence was gathered on 2026-08-12 by the workflow's researcher and scout (Field Notes below); +repository gates were re-verified by Wave 1 Task 1.1 at the inherited tip. This document satisfies +must-have **MH-1** and the non-PR-#34 portion of **MH-2**. The origin PR #34 verdict itself is +reserved for Wave 1 Task 1.3 and is explicitly left **pending** in [§8](#8-reserved-origin-pr-34-verdict-pending-wave-1-task-13). + +## 1. Verified Grok 4.6 facts + +Each row records the value, the authoritative source, and the Field Note that verified it. + +| Fact | Value | Source | +|---|---|---| +| xAI API model id | `grok-4.6` | https://docs.x.ai/developers/release-notes (August 2026 entry); https://docs.x.ai/developers/grok-4-6; `fn_20260812_vaamzb8z` | +| Cursor-facing surface | "Grok 4.6" with **256K default context**, capabilities **Agent + Thinking** | https://docs.cursor.com/models; `fn_20260812_vaamzb8z` | +| xAI direct-API context window | 500,000 tokens | https://docs.x.ai/developers/grok-4-6; `fn_20260812_vaamzb8z` | +| Input modalities | text and image | https://docs.x.ai/developers/grok-4-6; `fn_20260812_vaamzb8z` | +| Output modality | text only; **no stated output limit** | https://docs.x.ai/developers/grok-4-6; `fn_20260812_vaamzb8z` | +| Short-context pricing per 1M tokens | $2.00 input / $6.00 output (below 200k prompt tokens; $0.50 cached-read) | https://docs.x.ai/developers/grok-4-6; `fn_20260812_vaamzb8z`; `fn_20260812_o13isq9e` | +| Reasoning effort | low, medium, high (default), through **xhigh** | https://docs.x.ai/developers/grok-4-6; `fn_20260812_vaamzb8z` | + +Additional verified context from the same sources: knowledge cutoff February 1, 2026; Responses API +and Chat Completions API; tools function calling, web search, X search, and code execution; +distribution includes the xAI API, Grok Build, Cursor (all plans), OpenRouter, Vercel, and +Cloudflare (`fn_20260812_vaamzb8z`). + +**Metadata surfaces.** Cursor-facing values (256K default context, Agent + Thinking) come from +`docs.cursor.com/models` and describe what Cursor's Connect API serves. xAI direct-API values (500K +context, pricing, reasoning effort) come from `docs.x.ai`. The plugin proxies Cursor's Connect API, +so catalog limits should mirror the Cursor-facing 256K value; the 500K API discrepancy is documented +rather than hidden (`fn_20260812_vaamzb8z`, `fn_20260812_o13isq9e`). The existing `/grok/i` cost +pattern already routes `grok-4.6` to the `grok-4.20` key, whose $2/$6 rates match Grok 4.5/4.6 +short-context pricing; `grok-4.20` itself is a different xAI model at $1.25/$2.50 +(`fn_20260812_o13isq9e`, https://docs.x.ai/developers/models/grok-4.20-experimental-beta-0304). + +## 2. Explicit uncertainty + +These items are **not** asserted as verified. Each is labelled with its confidence grade from the +source Field Note. + +- **Cursor-facing model id string: UNVERIFIED (inference).** Presumed `grok-4.6`, matching the xAI + id and Cursor's docs path `/docs/models/grok-4-6`, but not yet confirmed against a live + `GetUsableModels` response (`fn_20260812_vaamzb8z`, "INFERENCE / UNCERTAIN"). Spec assumption A1 + and the risk table treat a differing id as an open risk; live confirmation is scheduled for Wave 5. + The fallback catalog entry is a best-effort resilience path, not the authoritative discovery path. +- **Billing pool: NO CLAIM MADE.** Whether Grok 4.6 on Cursor draws from the first-party pool or the + API/third-party pool is UNCONFIRMED. Launch-day Reddit reports are anecdotal and internally + inconsistent with Cursor's own grok page + (https://www.reddit.com/r/cursor/comments/1vmmfgc/grok_46_thoughts_usage/, INFERENCE grade, + `fn_20260812_vaamzb8z`). No plugin metadata depends on a pool, so this document makes no + billing-pool claim. +- **Rollout cadence: uncertain.** Cursor's changelog had no dedicated Grok 4.6 entry as of the + research date even though the models page lists the model; rollout may be gradual + (`fn_20260812_vaamzb8z`). + +## 3. Fork survey: no prior art to cherry-pick + +Origin `ephraimduncan/opencode-cursor` plus **24 surveyed forks** were searched via GitHub API fork, +commit, and code-search queries run 2026-08-12. Result: **zero `grok-4.6` code hits anywhere** +(`fn_20260812_gz125m33`). Independent corroboration: `gh search commits "grok 4.6"` and +`gh search issues "grok 4.6"` returned empty; the 50-item origin PR list contains no Grok PRs; six +local remote-only branches carry zero grok references in `src/`; origin/main (`a37a6ba`, v0.1.1) +fallback models contain only `grok-code-fast-1` (`fn_20260812_zyc1iqd0`). + +Verdict: **no cherry-pick exists.** Grok 4.6 catalog work is greenfield for this workflow. Upstream +issue #33, the acceptance-defining hang report, remains open (`fn_20260812_gz125m33`, +https://github.com/ephraimduncan/opencode-cursor/issues/33). + +## 4. Adapted prior art: tanushshukla a067e099 + +Commit [a067e099](https://github.com/tanushshukla/opencode-cursor/commit/a067e099) +("fix: end OpenAI stream on Cursor turnEnded", +203/-30 in `proxy.ts`) is recorded as **adapted +prior art, with attribution**, for two behaviors (`fn_20260812_gz125m33`, `fn_20260812_b0tcb20c`): + +- `turnEnded` as a clean terminal: finalize the SSE stream with `finish_reason: stop` plus `[DONE]`, + tear down the parked bridge, and guard the later close handler against double-reporting. +- `interactionQuery` answers with typed rejections (web search / ask / plan / exa / VM) so the model + can continue instead of waiting forever. + +**Behavioral adoption is evidence-gated, not committed here.** This branch currently treats +`turnEnded` as non-terminal (`src/proxy.ts:1294-1296`) and errors on `interactionQuery` +(`src/proxy.ts:1233-1237`) (`fn_20260812_b0tcb20c`). Whether to adopt the fork's behavior is decided +by Wave 3 deterministic fixtures plus isolated live observation, per spec assumption A7; any +adaptation must preserve this fork's stricter abnormal-path contract, which the upstream commit does +not have (`fn_20260812_b0tcb20c`; BLUEPRINT, "Prior art and provenance"). This section records +provenance only. + +## 5. Rejected upstream work: origin PR #36 + +Origin PR [#36](https://github.com/ephraimduncan/opencode-cursor/pull/36) +("fix: run on Node runtime (Desktop sidecar)", intellectronica, +19577/-97) replaces `Bun.*` with +`node:http` + `child_process` and changes module format. **Rejected as orthogonal**: the Bun runtime +is this workflow's constraint, the diff is large, and the conflict surface is high; it addresses no +Grok 4.6 or stall requirement here (`fn_20260812_gz125m33`, `fn_20260812_zyc1iqd0`). + +## 6. Disposition of the 18 inherited stability commits + +The branch carries 18 stability commits inherited from the earlier hang investigation (base +`origin/main` `a37a6ba`; tip `1ffca14`; +2505/-176; commit-level audit in `fn_20260812_fppld7hu`). +They are **already present in branch history**, so there is nothing to cherry-pick or reimplement. + +Wave 1 Task 1.1 re-verified them at the inherited tip `1ffca14` +(`1ffca147b461ea044e041626eb1ccc7465169526`) on `chore/stability-baseline`: `npx tsc -p tsconfig.json +--noEmit` PASS, `bun test/smoke.ts` PASS with 48 required assertions and 2 documented quarantines, +`bun run build` PASS. That record is the **accepted floor** for this workflow, written by commit +`39c6a15` (`docs(baseline): record inherited stability floor`) into +[`docs/cursor-hang-root-cause.md`](./cursor-hang-root-cause.md) ("Baseline status at inherited tip") +and [`docs/overlay-baseline.note.md`](./overlay-baseline.note.md) (`fn_20260812_fppld7hu`; +`docs/cursor-hang-root-cause.md:42-65`). + +Coverage those commits bring: bounded token refresh, strict Connect stream termination, an +idempotent terminal owner, a semantic-progress watchdog, HTTP/2 PING and session lifecycle handling, +tool-resume isolation, unary/non-streaming bounds, redacted lifecycle diagnostics, a fake +Connect/H2 fixture, and roughly 30 smoke assertions (`fn_20260812_fppld7hu`). + +## 7. Overlay disposition + +`docs/overlay-baseline.patch` is preserved as verbatim evidence and **MUST NOT be reapplied**; the +adjacent note and the RCA record the three-way split +([`docs/overlay-baseline.note.md`](./overlay-baseline.note.md); +`docs/cursor-hang-root-cause.md`, "Overlay provenance"): + +- **Kept / already landed:** stderr draining, sanitized error reporting, defensive close and destroy + guards. +- **Rejected:** the fixed timeout increases (30 to 60s connect; 120 to 180s idle). +- **Replaced:** the 90s raw-byte watchdog, superseded by semantic-progress plus H2 liveness. + +## 8. Reserved: origin PR #34 verdict (pending — Wave 1 Task 1.3) + +Origin PR [#34](https://github.com/ephraimduncan/opencode-cursor/pull/34) +("fix: handle cancelled SSE streams", noamkush, +4/-1) guards `sendSSE` with a `closed` flag to stop +`ERR_INVALID_STATE: Controller is already closed` when the client cancels +(`fn_20260812_gz125m33`). It is stall-adjacent to this fork's cancellation and termination path. + +**Status: PENDING.** Wave 1 Task 1.3 audits this fork's client-abort and request-cancellation path, +compares it against PR #34, and records the explicit adopt / adapt / reject verdict in this section. +This document asserts no conclusion about PR #34. + +## Verification performed for this document + +- `bun run build` — PASS (documentation-only change; no source, tests, or dependencies touched). +- `git diff --check` — PASS for the new document. From d7055cd2aa00b53510a8c77e8a1b725d13773cb8 Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:59:18 -0700 Subject: [PATCH 21/22] fix(proxy): tear down bridge on client stream cancellation Move the SSE closed-state guard into the shared response owner so consumer cancellation prevents late writes while using the established terminal cleanup path. Add a deterministic client-abort fixture that verifies the child-backed H2 stream is released, and record the adapted upstream cancellation verdict. --- docs/grok-4-6-provenance.md | 33 +++++++++++++++++++++++------ src/proxy.ts | 8 ++++++- test/fixtures/fake-cursor-server.ts | 7 ++++++ test/smoke.ts | 33 +++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/docs/grok-4-6-provenance.md b/docs/grok-4-6-provenance.md index daa89b4..2fd4f2f 100644 --- a/docs/grok-4-6-provenance.md +++ b/docs/grok-4-6-provenance.md @@ -3,8 +3,8 @@ Status: **Record of verified Grok 4.6 facts, explicit uncertainty, and prior-art verdicts.** Evidence was gathered on 2026-08-12 by the workflow's researcher and scout (Field Notes below); repository gates were re-verified by Wave 1 Task 1.1 at the inherited tip. This document satisfies -must-have **MH-1** and the non-PR-#34 portion of **MH-2**. The origin PR #34 verdict itself is -reserved for Wave 1 Task 1.3 and is explicitly left **pending** in [§8](#8-reserved-origin-pr-34-verdict-pending-wave-1-task-13). +must-have **MH-1** and **MH-2**. The origin PR #34 cancellation verdict and client-abort lifecycle +evidence are recorded in [§8](#8-origin-pr-34-verdict-adapted-client-abort-lifecycle). ## 1. Verified Grok 4.6 facts @@ -126,16 +126,37 @@ adjacent note and the RCA record the three-way split - **Rejected:** the fixed timeout increases (30 to 60s connect; 120 to 180s idle). - **Replaced:** the 90s raw-byte watchdog, superseded by semantic-progress plus H2 liveness. -## 8. Reserved: origin PR #34 verdict (pending — Wave 1 Task 1.3) +## 8. Origin PR #34 verdict: ADAPT (client-abort lifecycle) Origin PR [#34](https://github.com/ephraimduncan/opencode-cursor/pull/34) ("fix: handle cancelled SSE streams", noamkush, +4/-1) guards `sendSSE` with a `closed` flag to stop `ERR_INVALID_STATE: Controller is already closed` when the client cancels (`fn_20260812_gz125m33`). It is stall-adjacent to this fork's cancellation and termination path. -**Status: PENDING.** Wave 1 Task 1.3 audits this fork's client-abort and request-cancellation path, -compares it against PR #34, and records the explicit adopt / adapt / reject verdict in this section. -This document asserts no conclusion about PR #34. +**Verdict: ADAPT.** PR #34 correctly identifies the caller-cancellation boundary, but its `closed` +guard alone is insufficient for this fork: it only suppresses a later `controller.enqueue`, leaving +the bridge child, Connect/H2 stream, heartbeat, semantic-progress watchdog, paused-bridge indexes, +and this fork's idempotent terminal owner outside its scope. + +This fork already owns those resources through `createBridgeStreamResponse`'s idempotent terminal +owner: normal/error termination clears the semantic watchdog and heartbeat, removes matching +`activeBridges`/tool-call index entries, writes the one terminal SSE sequence, closes the controller, +and terminates the child. The bridge close callback persists current blobs and checkpoint state into +the existing `conversationStates` record, then cannot re-enter terminal cleanup. A paused bridge is +removed when its retained transport exits; durable conversation state is intentionally retained for +the TTL/disk-cache recovery contract. + +Wave 1 Task 1.3 adapts PR #34's missing cancellation guard to this ownership model: controller +closed-state now belongs to the shared response owner and `ReadableStream.cancel()` marks it before +clearing the watchdog/heartbeat, removing matching paused indexes, and terminating the bridge. It +does **not** enqueue or close the already consumer-cancelled controller, so late bridge callbacks +cannot write or produce a second close. `test/smoke.ts` uses the deterministic `client-abort` fake +Connect scenario to read one SSE frame, cancel the reader, and prove fixture-level H2/session cleanup +plus zero retained active bridges. Typecheck and the full deterministic smoke suite pass (2026-08-12). + +Evidence: origin PR [#34](https://github.com/ephraimduncan/opencode-cursor/pull/34) (+4/-1, +`closed` set by `cancel()`); `fn_20260812_gz125m33`; `src/proxy.ts:createBridgeStreamResponse`; +`test/fixtures/fake-cursor-server.ts:client-abort`; `test/smoke.ts:testClientAbortTeardown`. ## Verification performed for this document diff --git a/src/proxy.ts b/src/proxy.ts index 717be5d..abb03da 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1609,11 +1609,14 @@ function createBridgeStreamResponse( const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`; const created = Math.floor(Date.now() / 1000); let cancelStream = () => {}; + // `cancel()` is invoked outside the stream source's `start()` callback. Keep + // this state in the shared response owner so an aborted client prevents all + // later terminal or model-output writes to its already-cancelled controller. + let closed = false; const stream = new ReadableStream({ start(controller) { const encoder = new TextEncoder(); - let closed = false; const sendSSE = (data: object) => { if (closed) return; emitLifecycleDiagnostic("proxy", "sse_emission"); @@ -1691,6 +1694,9 @@ function createBridgeStreamResponse( cancelStream = () => { if (terminal) return; terminal = true; + // The consumer owns the controller after cancellation. Do not call + // controller.close() or enqueue a terminal SSE frame here. + closed = true; semanticWatchdog.clear(); clearInterval(heartbeatTimer); for (const active of activeBridges.values()) { diff --git a/test/fixtures/fake-cursor-server.ts b/test/fixtures/fake-cursor-server.ts index 3d66005..962aa68 100644 --- a/test/fixtures/fake-cursor-server.ts +++ b/test/fixtures/fake-cursor-server.ts @@ -34,6 +34,7 @@ export type CursorScenario = | "frame-error" | "goaway" | "stream-aborted" + | "client-abort" | "silent-transport" | "transport-death" | "tool-pause-resume" @@ -60,6 +61,7 @@ export const CURSOR_SCENARIOS: readonly CursorScenario[] = [ "frame-error", "goaway", "stream-aborted", + "client-abort", "silent-transport", "transport-death", "tool-pause-resume", @@ -356,6 +358,11 @@ export async function createFakeCursorServer(options: { deadlineMs?: number } = stream.write(frameConnectMessage(textDelta("before-abort"))); setTimeout(() => stream.close(http2.constants.NGHTTP2_CANCEL), 10); break; + case "client-abort": + // Send one frame, then intentionally remain open. The smoke test owns + // the consumer-side cancel and must cause this H2 stream to disappear. + stream.write(frameConnectMessage(textDelta("abort-ready"))); + break; case "silent-transport": // Keep the H2 connection open without emitting frames. Tests lower the // client session bound while disabling the first PING to exercise its diff --git a/test/smoke.ts b/test/smoke.ts index 730e534..48afb39 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -716,6 +716,38 @@ async function testStreamingTermination() { console.log("[test] Repeated semantic progress re-arms the between-progress bound"); } +async function testClientAbortTeardown() { + console.log("[test] Testing client-abort stream teardown..."); + const fixture = await createFakeCursorServer({ deadlineMs: 1_000 }); + fixture.setScenario("client-abort"); + try { + const result = await runScenarioSubprocess(fixture.apiUrl, ` + import { startProxy, stopProxy } from "./src/proxy.ts"; + const port = await startProxy(async () => "test-token"); + const response = await fetch("http://localhost:" + port + "/v1/chat/completions", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "test", stream: true, messages: [{ role: "user", content: "cancel this response" }] }), + }); + const reader = response.body.getReader(); + let text = ""; + while (!text.includes("abort-ready")) { + const next = await reader.read(); + if (next.done) break; + text += new TextDecoder().decode(next.value, { stream: true }); + } + await reader.cancel(); + await Bun.sleep(25); + stopProxy(); + console.log(JSON.stringify({ received: text.includes("abort-ready") })); + `); + assertEqual(result.received, true, "Client-abort fixture must deliver output before cancellation"); + fixture.assertClean(); + } finally { + await fixture.close(); + } + console.log("[test] Client abort closes the controller path and tears down bridge resources"); +} + async function testServerMessageClassification(modules: TestModules) { console.log("[test] Testing server-message progress and unsupported-query classification..."); const state = { @@ -1197,6 +1229,7 @@ async function main() { await testNonStreamingProtocolErrorsAreStructured(); await testFakeCursorScenarioHarness(); await testStreamingTermination(); + await testClientAbortTeardown(); await testServerMessageClassification(modules); await testResumeRefreshAndNonStreaming(modules); await testLifecycleDiagnostics(modules); From f3dc19bb58e221c3159419586eb6710b3f7885dc Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:07:20 -0700 Subject: [PATCH 22/22] docs(provenance): cite uejn0r60 registry surface map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add fn_20260812_uejn0r60 to the §1 Metadata surfaces cost-pattern routing claim it corroborates, and clarify §8 that proxy-side active-bridge removal is source-verified while H2/session cleanup is fixture-observed. --- docs/grok-4-6-provenance.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/grok-4-6-provenance.md b/docs/grok-4-6-provenance.md index 2fd4f2f..efb750a 100644 --- a/docs/grok-4-6-provenance.md +++ b/docs/grok-4-6-provenance.md @@ -30,8 +30,8 @@ Cloudflare (`fn_20260812_vaamzb8z`). context, pricing, reasoning effort) come from `docs.x.ai`. The plugin proxies Cursor's Connect API, so catalog limits should mirror the Cursor-facing 256K value; the 500K API discrepancy is documented rather than hidden (`fn_20260812_vaamzb8z`, `fn_20260812_o13isq9e`). The existing `/grok/i` cost -pattern already routes `grok-4.6` to the `grok-4.20` key, whose $2/$6 rates match Grok 4.5/4.6 -short-context pricing; `grok-4.20` itself is a different xAI model at $1.25/$2.50 +pattern already routes `grok-4.6` to the `grok-4.20` key (`fn_20260812_uejn0r60`), whose $2/$6 rates +match Grok 4.5/4.6 short-context pricing; `grok-4.20` itself is a different xAI model at $1.25/$2.50 (`fn_20260812_o13isq9e`, https://docs.x.ai/developers/models/grok-4.20-experimental-beta-0304). ## 2. Explicit uncertainty @@ -151,8 +151,9 @@ closed-state now belongs to the shared response owner and `ReadableStream.cancel clearing the watchdog/heartbeat, removing matching paused indexes, and terminating the bridge. It does **not** enqueue or close the already consumer-cancelled controller, so late bridge callbacks cannot write or produce a second close. `test/smoke.ts` uses the deterministic `client-abort` fake -Connect scenario to read one SSE frame, cancel the reader, and prove fixture-level H2/session cleanup -plus zero retained active bridges. Typecheck and the full deterministic smoke suite pass (2026-08-12). +Connect scenario to read one SSE frame, cancel the reader, and prove fixture-observed H2/session +cleanup, with zero retained proxy-side active bridges verified by source inspection. Typecheck and +the full deterministic smoke suite pass (2026-08-12). Evidence: origin PR [#34](https://github.com/ephraimduncan/opencode-cursor/pull/34) (+4/-1, `closed` set by `cancel()`); `fn_20260812_gz125m33`; `src/proxy.ts:createBridgeStreamResponse`;