Skip to content

Stabilize Cursor request lifecycle baseline - #37

Open
hffmnnj wants to merge 22 commits into
ephraimduncan:mainfrom
hffmnnj:chore/stability-baseline
Open

Stabilize Cursor request lifecycle baseline#37
hffmnnj wants to merge 22 commits into
ephraimduncan:mainfrom
hffmnnj:chore/stability-baseline

Conversation

@hffmnnj

@hffmnnj hffmnnj commented Aug 12, 2026

Copy link
Copy Markdown

Summary

  • re-verify and document the inherited Cursor stability floor
  • record Grok 4.6 research provenance and upstream survey decisions
  • make client cancellation tear down bridge and transport resources exactly once
  • add deterministic client-abort coverage

Verification

  • npx tsc -p tsconfig.json --noEmit
  • bun test/smoke.ts
  • bun run build
  • git diff 1ffca14..HEAD --check

The smoke suite passes with 48 required assertions and 2 documented quarantines.

hffmnnj added 22 commits August 9, 2026 19:23
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.
Keeps generated local workflow state out of version control.
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.
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/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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Capture the verified tip gates and preserve the speculative overlay as evidence-only context before subsequent stability changes.
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.
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.

@ephraimduncan ephraimduncan left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resume-identity, native-send cleanup, and unbounded-test-wait paths need to be tightened before merge; the docs also contradict the shipped code in two places.

Two findings without diff anchors:

  1. src/proxy.ts:1975-1977 — the plain MCP-result bridge.write() return value is unchecked (unlike line 1722), so on a transport failure Cursor never receives the result and the request stalls until timeout instead of failing promptly. Please check the acceptance and fail the request when the write is rejected.
  2. src/proxy.ts:632-647stopProxy clears activeBridges but never activeBridgeToolCalls (line 176), so stale tool-call IDs survive restarts and the duplicate check at 1763-1766 can kill valid new pauses on ID reuse. Please clear both maps.

Comment thread src/proxy.ts
Comment on lines +1760 to +1771
// 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]),

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each onMcpExec creates a new ActiveBridge with a fresh resumeId and a single-element toolCallIds set, but findPausedBridge at 1566-1572 requires exactly one resume ID across the follow-up's tool calls, so any pause with multiple tool calls fails to resume and leaks the original bridge and its heartbeat. Please reuse one ActiveBridge entry per bridge across its tool calls, and add a regression case with two tool calls in a single pause.

Comment thread src/native-tools.ts
message: { case: "execClientMessage", value: execClientMessage },
});
sendMessage(toBinary(AgentClientMessageSchema, clientMessage));
if (!sendMessage(toBinary(AgentClientMessageSchema, clientMessage))) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The throw on a rejected sendMessage escapes from the result loop at src/proxy.ts:1932 before the try/catch at 1980-1990 that clears heartbeatTimer and terminates the bridge, so a transport rejection leaks the interval and the live child. Please move that cleanup into a finally block or catch the send failure at the loop.

Comment thread test/smoke.ts
Comment on lines +585 to +589
const [exitCode, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
]);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

await child.exited has no deadline, and the per-request deadline at line 571 only applies after headers resolve, so a pre-bridge proxy hang — the exact regression class under test — hangs the whole run forever. The same pattern appears at 838-842. Please bound both waits with a timeout race.

Comment thread test/smoke.ts
Comment on lines +708 to +711
const semanticTrickle = await observeProxyScenario("semantic-progress-trickle", 500, {
CURSOR_PROXY_FIRST_PROGRESS_TIMEOUT_MS: "1000",
CURSOR_PROXY_SEMANTIC_PROGRESS_TIMEOUT_MS: "25",
});

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: the fixture trickles every 20ms against a 25ms semantic-progress timeout, a 5ms scheduling margin that will flake under load; widening the gap would keep the test deterministic.

Comment on lines +229 to +251
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);
} else if (scenario === "tool-pause-clean-exit") {
setTimeout(() => stream.end(frameConnectEndStream(new TextEncoder().encode("{}"))), 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("{}")),
]));
});

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tool-pause path treats each raw HTTP/2 data chunk as one message and substring-searches for result-for-<id> with no Connect frame buffering, making the resume tests chunk-boundary-dependent and letting invalid envelopes pass. Please buffer and decode Connect frames before matching.

Comment thread test/smoke.ts
Comment on lines +656 to +658
const sessionTimeout = await observeProxyScenario("silent-transport", 250, {
CURSOR_BRIDGE_SESSION_TIMEOUT_MS: "25",
CURSOR_BRIDGE_PING_INTERVAL_MS: "1000",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: every test sets CURSOR_BRIDGE_PING_INTERVAL_MS to 1000ms with sub-500ms windows, so the ping/timeout path at src/h2-bridge.mjs:148-160 never executes in any test; one scenario with a short interval would cover it.

Comment thread test/smoke.ts
Comment on lines +807 to +815
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,
);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: the send-rejection test injects a literal () => false, which covers only the local throw in sendNativeExecResult and bypasses the proxy cleanup path that actually leaks (see the src/native-tools.ts:247 comment); driving the rejection through the proxy would cover both.

toolName: "fixture-tool",
toolCallId,
providerIdentifier: "fixture",
args: { input: new TextEncoder().encode("{}") },

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: the fixture emits MCP args as raw JSON, which only works via decodeMcpArgValue's TextDecoder fallback at src/proxy.ts:817-823; encoding real ValueSchema bytes would exercise the canonical decode path.

Comment thread src/h2-bridge.mjs
Comment on lines +189 to +192
writeTransportStatus("failed");
clearLivenessTimers();
emitDiagnostic("terminal", { errorName, exitCode: 1 });
try { client.destroy(); } catch {}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: process.exit(1) immediately after the framed stdout status writes can truncate or drop the failure status when stdout is a pipe under backpressure; waiting for the write callback before exiting would keep the status reliable.


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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: the hunk inventory at lines 67-83 omits the patch's .goopspec/ .gitignore hunk despite this line claiming every hunk is classified; adding its disposition row would keep the inventory complete.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants