Skip to content

Commit f034bb3

Browse files
committed
fix(provider): recycle sidecar child after idle and terminal errors
Cursor's SDK memoizes its streaming transport with no reconnect. After an idle gap (15-60min+), the backend drops the session and every later run ends `status:"error"` until the process restarts — /new doesn't help because the state lives in the long-lived Node sidecar child. Recycle the child after 10 minutes idle (below the shortest reported onset) and after any terminal run error. Pooled agents resume from Cursor's checkpoint store on the next turn, same path as an opencode restart. Complements #52's per-agent resume retry, which cannot heal a dead transport shared by every subsequent agent in the same child.
1 parent fd8af5b commit f034bb3

4 files changed

Lines changed: 199 additions & 5 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,12 @@ opencode runs on [Bun](https://bun.sh), which has an `node:http2` incompatibilit
301301
SDK's streaming RPC. The plugin transparently hosts the Cursor SDK in a short-lived **Node child
302302
process** when running under Bun. Under Node it runs in-process.
303303

304+
The sidecar is recycled after 10 minutes idle, and automatically after any turn that ends with a
305+
terminal error — the SDK's cached streaming connection does not recover once Cursor's backend drops
306+
an idle session, so a fresh child (fresh connection) is the only reliable cure. Pooled sessions are
307+
unaffected: the Cursor agent resumes from its checkpoint on the next turn, exactly as it does across
308+
an opencode restart.
309+
304310
This is why **Node.js 22+ on your `PATH`** is required. If Node isn't found, the plugin warns once
305311
and falls back to in-process (native Cursor tools will misbehave until Node is available).
306312

@@ -310,6 +316,10 @@ Override with `OPENCODE_CURSOR_SIDECAR=1` (always sidecar) or `OPENCODE_CURSOR_S
310316

311317
- **Native Cursor tools hang / "Tool execution aborted" (`NGHTTP2_FRAME_SIZE_ERROR`).** Node isn't
312318
on your `PATH`. Install Node.js 22+, or force the sidecar with `OPENCODE_CURSOR_SIDECAR=1`.
319+
- **Every message fails with `Cursor run ended with status "error"` after opencode sits idle.**
320+
Cursor's backend dropped the idle session and the SDK's cached connection couldn't recover.
321+
Upgrade to a release with sidecar recycling (the sidecar now respawns after idle periods and after
322+
terminal errors); on older versions, restarting opencode is the only fix.
313323
- **"Running under Bun without a usable Node sidecar" warning.** Install Node.js 22+, or set
314324
`OPENCODE_CURSOR_SIDECAR=0` to accept in-process behavior and silence the warning.
315325
- **Plugin enabled but no `cursor` provider/models appear, or you see a stale-version warning.**

src/provider/sidecar-client.ts

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,25 @@ export interface SidecarClientOptions {
3838
env?: Record<string, string>;
3939
/** Mirror child stderr to this process (debug aid). */
4040
debug?: boolean;
41+
/**
42+
* Recycle the child after this much idle time (default
43+
* {@link DEFAULT_IDLE_RECYCLE_MS}). See the field docs on `stale` for why.
44+
*/
45+
idleRecycleMs?: number;
4146
}
4247

48+
/**
49+
* Idle lifetime after which the child is recycled. The SDK inside the child
50+
* memoizes its streaming transport (and auth token) at module scope with no
51+
* reconnect logic, so a backend session dropped while idle leaves every later
52+
* run ending `status:"error"` until the process restarts. 10 minutes sits
53+
* comfortably below the shortest reported failure onset (15-30min); the
54+
* respawn cost is a cheap Node spawn paid only after an idle gap, and pooled
55+
* agents resume from Cursor's checkpoint store exactly as they do across an
56+
* opencode restart.
57+
*/
58+
const DEFAULT_IDLE_RECYCLE_MS = 10 * 60 * 1000;
59+
4360
interface Pending {
4461
resolve: (msg: Record<string, unknown>) => void;
4562
reject: (err: Error) => void;
@@ -63,6 +80,14 @@ export class SidecarClient {
6380
private readonly pending = new Map<number, Pending>();
6481
private nextId = 1;
6582
private disposed = false;
83+
/**
84+
* Set when a run ends terminally bad (status:"error" or a stream error).
85+
* The SDK's memoized transport does not recover from a dead backend
86+
* session, so the child is recycled before the next request instead of
87+
* failing every turn until the whole process is restarted.
88+
*/
89+
private stale = false;
90+
private idleTimer: ReturnType<typeof setTimeout> | undefined;
6691

6792
constructor(options: SidecarClientOptions) {
6893
this.options = options;
@@ -71,7 +96,9 @@ export class SidecarClient {
7196
/** Spawn (or reuse) the child process. */
7297
private ensureChild(): ChildProcessByStdio<Writable, Readable, Readable> {
7398
if (this.disposed) throw new Error("cursor sidecar client disposed");
99+
if (this.child && this.stale && this.pending.size === 0) this.recycleChild();
74100
if (this.child) return this.child;
101+
this.stale = false;
75102

76103
const child = spawn(this.options.nodePath ?? "node", [this.options.scriptPath], {
77104
stdio: ["pipe", "pipe", "pipe"],
@@ -87,14 +114,20 @@ export class SidecarClient {
87114
}
88115
});
89116
child.on("exit", (code) => {
90-
this.failAll(new Error(`cursor sidecar exited (code ${code ?? "unknown"})`));
117+
// A recycled child's exit arrives after its replacement spawned; it
118+
// must not clobber the new child or reject its in-flight requests.
119+
if (this.child !== child) return;
120+
// Clear before failAll so updateRefs doesn't arm the idle timer (or
121+
// re-unref pipes) against a child that's already gone.
91122
this.child = undefined;
92123
this.reader?.close();
93124
this.reader = undefined;
125+
this.failAll(new Error(`cursor sidecar exited (code ${code ?? "unknown"})`));
94126
});
95127
child.on("error", (err) => {
96-
this.failAll(new Error(`cursor sidecar failed to start: ${err.message}`));
128+
if (this.child !== child) return;
97129
this.child = undefined;
130+
this.failAll(new Error(`cursor sidecar failed to start: ${err.message}`));
98131
});
99132
this.updateRefs();
100133
return child;
@@ -117,9 +150,44 @@ export class SidecarClient {
117150
for (const target of refable) target.ref?.();
118151
} else {
119152
for (const target of refable) target.unref?.();
153+
this.armIdleTimer();
120154
}
121155
}
122156

157+
/**
158+
* Kill the child so the next request spawns a fresh one (fresh SDK module
159+
* state). Only called with nothing in flight; pooled agents are resumable,
160+
* so nothing is lost. The exit handler's failAll no-ops on an empty pending
161+
* map.
162+
*/
163+
private recycleChild(): void {
164+
this.clearIdleTimer();
165+
this.stale = false;
166+
const child = this.child;
167+
this.child = undefined;
168+
this.reader?.close();
169+
this.reader = undefined;
170+
child?.kill();
171+
}
172+
173+
/**
174+
* Arm the idle-recycle timer. Unref'd like the child pipes so it can never
175+
* hold the parent's event loop open (see updateRefs).
176+
*/
177+
private armIdleTimer(): void {
178+
this.clearIdleTimer();
179+
if (this.disposed) return;
180+
this.idleTimer = setTimeout(() => {
181+
if (this.pending.size === 0) this.recycleChild();
182+
}, this.options.idleRecycleMs ?? DEFAULT_IDLE_RECYCLE_MS);
183+
this.idleTimer.unref?.();
184+
}
185+
186+
private clearIdleTimer(): void {
187+
if (this.idleTimer) clearTimeout(this.idleTimer);
188+
this.idleTimer = undefined;
189+
}
190+
123191
private failAll(err: Error): void {
124192
for (const pending of this.pending.values()) {
125193
pending.onStreamError?.(err);
@@ -150,12 +218,17 @@ export class SidecarClient {
150218
if (ev === "result") {
151219
this.pending.delete(id);
152220
this.updateRefs();
153-
pending.onResult?.(msg["result"] as { status: string; result?: string });
221+
const result = msg["result"] as { status: string; result?: string };
222+
// A terminally errored run marks the child stale: the SDK's memoized
223+
// transport can't be trusted after this, so recycle before next use.
224+
if (result.status === "error") this.stale = true;
225+
pending.onResult?.(result);
154226
return;
155227
}
156228
if (ev === "error") {
157229
this.pending.delete(id);
158230
this.updateRefs();
231+
this.stale = true;
159232
pending.onStreamError?.(reviveError(msg["error"]));
160233
return;
161234
}
@@ -178,6 +251,7 @@ export class SidecarClient {
178251
payload: Record<string, unknown>,
179252
hooks?: Pick<Pending, "onUpdate" | "onResult" | "onStreamError">,
180253
): Promise<Record<string, unknown>> {
254+
this.clearIdleTimer();
181255
const child = this.ensureChild();
182256
const id = this.nextId++;
183257
return new Promise<Record<string, unknown>>((resolve, reject) => {
@@ -262,6 +336,7 @@ export class SidecarClient {
262336
/** Kill the child and reject anything in flight. */
263337
dispose(): void {
264338
this.disposed = true;
339+
this.clearIdleTimer();
265340
this.failAll(new Error("cursor sidecar client disposed"));
266341
this.reader?.close();
267342
this.reader = undefined;

test/fixtures/fake-cursor-sdk.mjs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,21 @@
66
* Message-text driven behaviors:
77
* "busy" -> send() rejects with AgentBusyError unless local.force is set
88
* "hang" -> run.wait() never resolves (until cancel(), which resolves cancelled)
9+
* "error" -> run.wait() resolves {status:"error"} (mimics a dead backend session)
910
* other -> emits one text-delta "echo:<text>" update, wait() -> done:<text>
11+
*
12+
* When FAKE_SDK_LOAD_LOG is set, each loading child appends its pid so tests
13+
* can detect client-side child recycles (a new pid = a respawned child).
1014
*/
15+
import { appendFileSync } from "node:fs";
16+
17+
if (process.env.FAKE_SDK_LOAD_LOG) {
18+
try {
19+
appendFileSync(process.env.FAKE_SDK_LOAD_LOG, `${Date.now()} ${process.pid}\n`);
20+
} catch {
21+
// best effort
22+
}
23+
}
1124

1225
function makeAgent(agentId, options) {
1326
return {
@@ -21,6 +34,12 @@ function makeAgent(agentId, options) {
2134
throw err;
2235
}
2336
sendOptions?.onDelta?.({ update: { type: "text-delta", text: `echo:${text}` } });
37+
if (text === "error") {
38+
return {
39+
wait: async () => ({ status: "error" }),
40+
cancel: () => {},
41+
};
42+
}
2443
if (text === "hang") {
2544
let resolveWait;
2645
const waited = new Promise((resolve) => {

test/sidecar.test.ts

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { afterEach, describe, expect, it } from "vitest";
2+
import { readFileSync, rmSync } from "node:fs";
23
import { fileURLToPath } from "node:url";
34
import { SidecarClient } from "../src/provider/sidecar-client.js";
45

@@ -7,10 +8,16 @@ const FAKE_SDK = fileURLToPath(new URL("./fixtures/fake-cursor-sdk.mjs", import.
78

89
const clients: SidecarClient[] = [];
910

10-
function makeClient(): SidecarClient {
11+
function makeClient(options?: { idleRecycleMs?: number; loadLog?: string }): SidecarClient {
1112
const client = new SidecarClient({
1213
scriptPath: SCRIPT,
13-
env: { OPENCODE_CURSOR_SDK_PATH: FAKE_SDK },
14+
...(options?.idleRecycleMs !== undefined
15+
? { idleRecycleMs: options.idleRecycleMs }
16+
: {}),
17+
env: {
18+
OPENCODE_CURSOR_SDK_PATH: FAKE_SDK,
19+
...(options?.loadLog ? { FAKE_SDK_LOAD_LOG: options.loadLog } : {}),
20+
},
1421
});
1522
clients.push(client);
1623
return client;
@@ -96,4 +103,87 @@ describe("SidecarClient", () => {
96103
client.dispose();
97104
await expect(waited).rejects.toThrow(/sidecar/i);
98105
});
106+
107+
describe("child recycling", () => {
108+
let loadLog: string;
109+
let logSeq = 0;
110+
111+
afterEach(() => {
112+
rmSync(loadLog, { force: true });
113+
});
114+
115+
/** Fresh per-test log path (avoids cross-test timing bleed). */
116+
const nextLoadLog = (): string => {
117+
logSeq += 1;
118+
loadLog = fileURLToPath(
119+
new URL(`./fixtures/.load-log-${process.pid}-${logSeq}`, import.meta.url),
120+
);
121+
rmSync(loadLog, { force: true });
122+
return loadLog;
123+
};
124+
125+
/** Distinct pids that loaded the fake SDK (one per spawned child). */
126+
const spawnedPids = (): string[] => [
127+
...new Set(
128+
readFileSync(loadLog, "utf8")
129+
.split("\n")
130+
.filter(Boolean)
131+
.map((line) => line.split(" ")[1]!),
132+
),
133+
];
134+
135+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
136+
137+
it("recycles the child after a run ends with status error", async () => {
138+
const client = makeClient({ loadLog: nextLoadLog() });
139+
const agent = await client.createAgent(CREATE_OPTIONS);
140+
const run = await agent.send({ type: "user", text: "error" }, { mode: "agent" });
141+
await expect(run.wait()).resolves.toMatchObject({ status: "error" });
142+
143+
// The next turn must run in a fresh child (fresh SDK transport state).
144+
const next = await client.createAgent(CREATE_OPTIONS);
145+
const run2 = await next.send({ type: "user", text: "ok" }, { mode: "agent" });
146+
await expect(run2.wait()).resolves.toMatchObject({ status: "finished" });
147+
148+
expect(spawnedPids()).toHaveLength(2);
149+
});
150+
151+
it("recycles the child after the idle timeout", async () => {
152+
const client = makeClient({ loadLog: nextLoadLog(), idleRecycleMs: 50 });
153+
await client.createAgent(CREATE_OPTIONS);
154+
await sleep(150); // let the idle timer fire
155+
await client.createAgent(CREATE_OPTIONS);
156+
expect(spawnedPids()).toHaveLength(2);
157+
});
158+
159+
it("keeps one child across healthy turns", async () => {
160+
const client = makeClient({ loadLog: nextLoadLog(), idleRecycleMs: 60_000 });
161+
const agent = await client.createAgent(CREATE_OPTIONS);
162+
const run = await agent.send({ type: "user", text: "ok" }, { mode: "agent" });
163+
await run.wait();
164+
await client.createAgent(CREATE_OPTIONS);
165+
expect(spawnedPids()).toHaveLength(1);
166+
});
167+
168+
it("never recycles while a sibling request is still in flight", async () => {
169+
const client = makeClient({ loadLog: nextLoadLog() });
170+
const agent = await client.createAgent(CREATE_OPTIONS);
171+
// A hung send keeps the child busy while another turn errors (stale).
172+
const hung = await agent.send({ type: "user", text: "hang" }, { mode: "agent" });
173+
const errored = await agent.send({ type: "user", text: "error" }, { mode: "agent" });
174+
await expect(errored.wait()).resolves.toMatchObject({ status: "error" });
175+
176+
// Stale, but the hung send is still pending: no recycle yet.
177+
await client.createAgent(CREATE_OPTIONS);
178+
expect(spawnedPids()).toHaveLength(1);
179+
180+
// Once it settles, the next request lands on a fresh child.
181+
await hung.cancel();
182+
// Awaiting wait() guarantees the terminal event (and pending cleanup)
183+
// has been processed before we assert the recycle.
184+
await expect(hung.wait()).resolves.toMatchObject({ status: "cancelled" });
185+
await client.createAgent(CREATE_OPTIONS);
186+
expect(spawnedPids()).toHaveLength(2);
187+
});
188+
});
99189
});

0 commit comments

Comments
 (0)