Skip to content

Commit e3dcfc0

Browse files
TS: pump in-process FFI inbound frames promptly (fix macOS RPC hangs)
The in-process FFI host has no libuv handle of its own, so koffi delivers inbound (server→client) frames only when the event loop turns. The keep-alive timer used a 60s interval, which kept the loop from exiting but let it park between ticks. When the SDK issues a bare request and only awaits the response — e.g. the sequential `session.rpc.permissions.*` calls — there is no other loop activity, so on macOS the inbound response frame sat undelivered until the next tick and the round-trip stalled past the test timeout. Streaming turns kept the loop busy and so were unaffected, which is why only a handful of RPC-only tests hung and only on macOS. Shorten the pump interval so inbound frames are serviced within a few milliseconds. The empty callback is cheap and only runs while a connection is open. This is the koffi analogue of the .NET host's thread-safe Channel callback, which wakes its reader immediately regardless of loop state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent afecf46 commit e3dcfc0

1 file changed

Lines changed: 18 additions & 6 deletions

File tree

nodejs/src/ffiRuntimeHost.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,25 @@ export class FfiRuntimeHost {
125125
private disposed = false;
126126
private outboundCallback: KoffiRegisteredCallback | undefined;
127127
/**
128-
* Keeps the libuv event loop alive while the FFI connection is open. Unlike the
129-
* stdio/TCP transports (whose pipe/socket handles keep the loop alive), the FFI
130-
* transport has no libuv handle of its own, and native→JS callbacks are delivered
131-
* via the event loop. Without a live handle the loop can park with no work and the
132-
* queued callback delivery races into a crash.
128+
* Keeps the libuv event loop alive AND turning while the FFI connection is open.
129+
* Unlike the stdio/TCP transports (whose pipe/socket handles keep the loop alive),
130+
* the FFI transport has no libuv handle of its own, and native→JS callbacks
131+
* (inbound server→client frames) are delivered via the event loop. Without a live
132+
* handle the loop can park with no work, and koffi's cross-thread callback delivery
133+
* is only serviced when the loop next turns.
134+
*
135+
* The interval is deliberately short: when the SDK issues a bare request and then
136+
* only `await`s the response (e.g. `session.rpc.permissions.*`), there is no other
137+
* loop activity, so a coarse interval let the loop sleep and the inbound response
138+
* frame sat undelivered until the next tick — on macOS this stalled such round-trips
139+
* past the test timeout (streaming turns kept the loop busy and so were unaffected).
140+
* A few-millisecond tick keeps inbound delivery prompt; the empty callback is cheap
141+
* and only runs while a connection is open. This is the koffi analogue of the .NET
142+
* host's thread-safe `Channel` callback, which wakes its reader immediately.
133143
*/
134144
private keepAlive: ReturnType<typeof setInterval> | null = null;
145+
/** Interval (ms) for {@link keepAlive}; short so inbound frames are pumped promptly. */
146+
private static readonly INBOUND_PUMP_INTERVAL_MS = 4;
135147

136148
/** The stream JSON-RPC reads server→client frames from. */
137149
readonly receiveStream: PassThrough;
@@ -249,7 +261,7 @@ export class FfiRuntimeHost {
249261
throw new Error("copilot_runtime_connection_open failed.");
250262
}
251263

252-
this.keepAlive = setInterval(() => {}, 60_000);
264+
this.keepAlive = setInterval(() => {}, FfiRuntimeHost.INBOUND_PUMP_INTERVAL_MS);
253265
}
254266

255267
private writeFrame(frame: Buffer): void {

0 commit comments

Comments
 (0)