Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1364,10 +1364,11 @@ function createBridgeStreamResponse(
const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
const created = Math.floor(Date.now() / 1000);

let closed = false;
let cancelStream: (() => void) | undefined;
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
let closed = false;
const sendSSE = (data: object) => {
if (closed) return;
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
Expand All @@ -1376,11 +1377,27 @@ function createBridgeStreamResponse(
if (closed) return;
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
};
const stopBridge = () => {
if (activeBridges.get(bridgeKey)?.bridge === bridge) {
activeBridges.delete(bridgeKey);
}
clearInterval(heartbeatTimer);
// end() only half-closes the request; killing also stops the response.
try { bridge.proc.kill(); } catch {}
};
const closeController = () => {
if (closed) return;
closed = true;
controller.close();
};
// A paused bridge closes its controller on purpose and stays registered
// in activeBridges for tool-result continuation. Tearing that down would
// break the round-trip OpenCode is about to make.
cancelStream = () => {
if (closed) return;
closed = true;
stopBridge();
};

const makeChunk = (
delta: Record<string, unknown>,
Expand Down Expand Up @@ -1503,10 +1520,8 @@ function createBridgeStreamResponse(
sendSSE(makeChunk({}, "stop"));
sendSSE(makeUsageChunk());
sendDone();
stopBridge();
closeController();
activeBridges.delete(bridgeKey);
clearInterval(heartbeatTimer);
bridge.end();
}
},
);
Expand Down Expand Up @@ -1542,6 +1557,9 @@ function createBridgeStreamResponse(
}
});
},
cancel() {

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.

Setting closed = true mutes the SSE output but leaves the bridge fully alive: the heartbeat interval started at line 1563 keeps writing every 5s, and the code's own comment at 1499-1501 notes heartbeats otherwise keep the bridge open forever. bridge.onData also keeps running, so the exec path at 1464-1471 still registers the session in activeBridges even though the disconnected client will never send the follow-up that resumes it, leaving the entry, timer, and subprocess alive until a same-key retry or a config reload. Please mirror the endStream-error teardown at 1507-1509 here: clear heartbeatTimer, delete the activeBridges entry, and call bridge.end().

@noamkush noamkush Aug 18, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Disclaimer: used an llm to research, fix, and write this reply.

You are correct. closed = true does not stop the bridge. I pushed a follow-up commit.

But bridge.end() does not stop the bridge either. It does a half-close. It writes a zero-length message, and this message breaks the stdin loop in h2-bridge.mjs. The loop then calls h2Stream.end() on the request half only. The response half stays open, and h2Stream.on("data") continues to call resetTimeout(). Thus the subprocess stays alive.

bridge.onData also stays installed. The model sends its tool call, and the exec handler writes the activeBridges entry again. The teardown deleted that entry a moment earlier. The map has no TTL, so the new entry stays until a same-key retry or a call to stopProxy().

I did not assume this. I replaced kill() with end() and ran the new test. It failed in the same way as no fix at all.

The commit kills the bridge process on cancellation, clears the heartbeat, and removes the activeBridges entry only if it still belongs to that bridge. This identity check prevents cancellation from deleting a newer same-key bridge. The endStream error path uses the same teardown, changing that path from a half-close to a hard stop as well.

One detail needs a note. cancel() does nothing if the stream is already closed. The tool-call pause path closes its controller on purpose, but it keeps the bridge in activeBridges for the continuation. An unguarded cancel would stop a bridge that OpenCode must resume. The streams specification does not permit this sequence, but the guard costs nothing, and the failure is silent without it.

For the test, I changed the fake H2 backend to hold the Run stream open. A flag controls this behavior, so the non-streaming test still stops. The test aborts the fetch during the stream. It then checks that the upstream Run stream closes. Only the subprocess holds a handle to that stream. Thus a closed stream shows that the process stopped, and not only that it stopped to write.

The last point is optional. I traced the abort path in the Cursor CLI bundle. The client writes a ConversationAction{cancel_action} on the same bidi stream, waits for that write, and only then sends RST_STREAM(CANCEL). It never does a half-close, and it never keeps a cancelled stream for a resume. Thus a hard kill is correct, and cancel_action only lets the server stop and write a final checkpoint. The repo has all the necessary types, but I did not test this against the live server, so I kept it out of this PR.

cancelStream?.();
},
});

return new Response(stream, { headers: SSE_HEADERS });
Expand Down
114 changes: 113 additions & 1 deletion test/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import http2 from "node:http2";
import type { AddressInfo } from "node:net";
import { create, toBinary } from "@bufbuild/protobuf";
import {
AgentServerMessageSchema,
GetUsableModelsResponseSchema,
InteractionUpdateSchema,
ModelDetailsSchema,
} from "../src/proto/agent_pb";

Expand All @@ -25,10 +27,12 @@ interface TestCursorBackend {
refreshUrl: string;
setDiscoveryMode: (mode: DiscoveryMode) => void;
setDiscoveredModels: (models: Array<{ id: string; name: string; reasoning?: boolean }>) => void;
setHoldRunStream: (hold: boolean) => void;
resetObservations: () => void;
getDiscoveryAuthHeaders: () => string[];
getDiscoveryRequestBodies: () => Uint8Array[];
getRefreshAuthHeaders: () => string[];
waitForRunStreamClose: () => Promise<void>;
close: () => Promise<void>;
}

Expand All @@ -54,6 +58,27 @@ function assertArrayEqual(
}
}

async function withTimeout<T>(
promise: Promise<T>,
message: string,
timeoutMs = 10_000,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`Timed out after ${timeoutMs}ms waiting for: ${message}`)),
timeoutMs,
);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}

function makeJwt(expiresAtSeconds: number): string {
const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const payload = btoa(JSON.stringify({ exp: expiresAtSeconds }));
Expand All @@ -68,13 +93,33 @@ function frameConnectUnaryMessage(payload: Uint8Array): Buffer {
return frame;
}

/** A Connect-framed text delta, enough to make the proxy emit its first SSE chunk. */
function frameRunTextDelta(text: string): Buffer {
return frameConnectUnaryMessage(
toBinary(
AgentServerMessageSchema,
create(AgentServerMessageSchema, {
message: {
case: "interactionUpdate",
value: create(InteractionUpdateSchema, {
message: { case: "textDelta", value: { text } },
}),
},
}),
),
);
}

async function createTestCursorBackend(): Promise<TestCursorBackend> {
let discoveryMode: DiscoveryMode = "success";
let discoveredModels: Array<{ id: string; name: string; reasoning?: boolean }> = [
{ id: "composer-2", name: "Composer 2", reasoning: true },
];
const discoveryAuthHeaders: string[] = [];
const discoveryRequestBodies: Uint8Array[] = [];
let runStreamClosed = Promise.withResolvers<void>();
const heldRunStreams = new Set<http2.ServerHttp2Stream>();
let holdRunStream = false;
const refreshAuthHeaders: string[] = [];

const refreshServer = http.createServer((req, res) => {
Expand Down Expand Up @@ -109,11 +154,24 @@ async function createTestCursorBackend(): Promise<TestCursorBackend> {
const path = String(headers[":path"] ?? "");
const authHeader = String(headers.authorization ?? "");
if (path === "/agent.v1.AgentService/Run") {
const closed = runStreamClosed;
stream.respond({
":status": 200,
"content-type": "application/connect+proto",
});
stream.end();
stream.on("close", () => {
heldRunStreams.delete(stream);
closed.resolve();
});
if (holdRunStream) {
// Cancellation can only be observed on a stream still running when the
// client goes away. Bun also withholds SSE response headers until the
// first body byte, so emit one to unblock the caller's fetch.
heldRunStreams.add(stream);
stream.write(frameRunTextDelta("streaming"));
} else {
stream.end();
}
return;
}

Expand Down Expand Up @@ -180,10 +238,20 @@ async function createTestCursorBackend(): Promise<TestCursorBackend> {
setDiscoveredModels(models) {
discoveredModels = models;
},
setHoldRunStream(hold) {
holdRunStream = hold;
if (!hold) {
for (const stream of heldRunStreams) stream.close();
}
},
resetObservations() {
discoveryAuthHeaders.length = 0;
discoveryRequestBodies.length = 0;
refreshAuthHeaders.length = 0;
runStreamClosed = Promise.withResolvers<void>();
},
waitForRunStreamClose() {
return runStreamClosed.promise;
},
getDiscoveryAuthHeaders() {
return [...discoveryAuthHeaders];
Expand Down Expand Up @@ -276,6 +344,49 @@ async function testProxyStartStop(modules: TestModules) {
console.log("[test] Proxy stop OK");
}

async function testStreamCancellationStopsBridge(
modules: TestModules,
backend: TestCursorBackend,
) {
console.log("[test] Testing SSE cancellation teardown...");
backend.resetObservations();
backend.setHoldRunStream(true);
const controller = new AbortController();
try {
const port = await modules.startProxy(async () => "test-token");
const fetchTimeout = setTimeout(() => controller.abort(), 10_000);
let res: Response;
try {
res = await fetch(`http://localhost:${port}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "composer-2",
stream: true,
messages: [{ role: "user", content: "hello" }],
}),
signal: controller.signal,
});
} finally {
clearTimeout(fetchTimeout);
}
assertEqual(res.status, 200, "Expected a streaming response");

controller.abort();
// The bridge subprocess holds the only handle on this Run stream, so its
// closure proves the subprocess was killed.
await withTimeout(
backend.waitForRunStreamClose(),
"the Cursor Run stream to close after the client disconnects",
);
} finally {
controller.abort();
modules.stopProxy();
backend.setHoldRunStream(false);
}
console.log("[test] SSE cancellation teardown OK");
}

async function testAuthParams(modules: TestModules) {
console.log("[test] Generating auth params...");
const params = await modules.generateCursorAuthParams();
Expand Down Expand Up @@ -533,6 +644,7 @@ async function main() {

try {
await testProxyStartStop(modules);
await testStreamCancellationStopsBridge(modules, backend);
await testAuthParams(modules);
await testTokenExpiry(modules);
await testPluginShape(modules);
Expand Down