Skip to content

Commit e72a7d7

Browse files
TEMP: trace in-process FFI frames to diagnose macOS RPC stalls
Adds env-gated (COPILOT_FFI_TRACE=1) stderr tracing of FFI frame writes, write completions, inbound arrivals, deliveries, and an event-loop heartbeat, with JSON-RPC id/method correlation. Enabled on the Node inprocess CI cell to capture exactly where the macOS session.rpc.permissions.* round-trips stall (write vs inbound delivery vs loop parking). To be reverted once the root cause is fixed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1553944 commit e72a7d7

2 files changed

Lines changed: 50 additions & 3 deletions

File tree

.github/workflows/nodejs-sdk-tests.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,9 @@ jobs:
7878

7979
- name: Select inprocess transport
8080
if: matrix.transport == 'inprocess'
81-
run: echo "COPILOT_SDK_DEFAULT_CONNECTION=inprocess" >> "$GITHUB_ENV"
81+
run: |
82+
echo "COPILOT_SDK_DEFAULT_CONNECTION=inprocess" >> "$GITHUB_ENV"
83+
echo "COPILOT_FFI_TRACE=1" >> "$GITHUB_ENV"
8284
8385
- name: Run Node.js SDK tests
8486
env:

nodejs/src/ffiRuntimeHost.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,38 @@ import { PassThrough, Writable } from "node:stream";
2424

2525
const SYMBOL_PREFIX = "copilot_runtime_";
2626

27+
/**
28+
* Temporary, env-gated tracing (COPILOT_FFI_TRACE=1) for diagnosing in-process FFI
29+
* transport stalls. Logs frame writes/completions/inbound arrivals with a monotonic
30+
* sequence and the JSON-RPC id/method, plus a periodic heartbeat, so we can tell
31+
* whether a stalled round-trip is stuck on the write, on inbound delivery, or on the
32+
* event loop parking. Writes to stderr to avoid interleaving with JSON-RPC stdout.
33+
*/
34+
const FFI_TRACE = process.env.COPILOT_FFI_TRACE === "1";
35+
function ffiTrace(message: string): void {
36+
if (FFI_TRACE) {
37+
process.stderr.write(`[ffi ${Date.now() % 100000} pid=${process.pid}] ${message}\n`);
38+
}
39+
}
40+
41+
/** Extracts a compact `id/method` tag from a JSON-RPC frame for trace correlation. */
42+
function frameTag(frame: Buffer): string {
43+
if (!FFI_TRACE) {
44+
return "";
45+
}
46+
try {
47+
const text = frame.toString("utf8");
48+
const bodyStart = text.indexOf("\r\n\r\n");
49+
const body = bodyStart >= 0 ? text.slice(bodyStart + 4) : text;
50+
const parsed = JSON.parse(body) as { id?: unknown; method?: unknown };
51+
const id = parsed.id !== undefined ? `id=${JSON.stringify(parsed.id)}` : "";
52+
const method = parsed.method !== undefined ? `m=${String(parsed.method)}` : "";
53+
return [id, method].filter(Boolean).join(" ") || "(no id/method)";
54+
} catch {
55+
return `(unparsed ${frame.length}B)`;
56+
}
57+
}
58+
2759
type KoffiFunction = ReturnType<ReturnType<typeof koffi.load>["func"]>;
2860
type KoffiType = ReturnType<typeof koffi.pointer>;
2961
type KoffiRegisteredCallback = ReturnType<typeof koffi.register>;
@@ -273,14 +305,23 @@ export class FfiRuntimeHost {
273305
throw new Error("copilot_runtime_connection_open failed.");
274306
}
275307

276-
this.keepAlive = setInterval(() => {}, FfiRuntimeHost.INBOUND_PUMP_INTERVAL_MS);
308+
let heartbeat = 0;
309+
this.keepAlive = setInterval(() => {
310+
if (FFI_TRACE && ++heartbeat % 250 === 0) {
311+
ffiTrace(`~tick ${heartbeat} pending=${this.inboundQueue.length}`);
312+
}
313+
}, FfiRuntimeHost.INBOUND_PUMP_INTERVAL_MS);
277314
}
278315

316+
private writeSeq = 0;
317+
279318
private writeFrame(frame: Buffer, callback: (error?: Error | null) => void): void {
280319
if (this.disposed || !this.connectionId) {
281320
callback(new Error("The in-process runtime connection is closed."));
282321
return;
283322
}
323+
const seq = ++this.writeSeq;
324+
ffiTrace(`>>W seq=${seq} len=${frame.length} ${frameTag(frame)}`);
284325
// Asynchronous FFI call: runs on a libuv worker thread and invokes this callback
285326
// when the native write completes, keeping the JS event loop free to service
286327
// inbound native→JS callbacks in the meantime (see the sendStream comment).
@@ -289,6 +330,7 @@ export class FfiRuntimeHost {
289330
frame,
290331
frame.length,
291332
(error: Error | null, result: boolean) => {
333+
ffiTrace(`>>WDONE seq=${seq} ok=${!error && result} err=${error?.message ?? "-"}`);
292334
// Node-API completion callback: guard so a throw here (e.g. the stream's
293335
// own callback rejecting after teardown) can't escape across the FFI
294336
// boundary as an uncaught Node-API callback exception (DEP0168).
@@ -342,7 +384,9 @@ export class FfiRuntimeHost {
342384
bytesPtr,
343385
koffi.array("uint8", length, "Typed")
344386
) as Uint8Array;
345-
this.inboundQueue.push(Buffer.from(bytes));
387+
const frame = Buffer.from(bytes);
388+
ffiTrace(`<<R len=${length} ${frameTag(frame)}`);
389+
this.inboundQueue.push(frame);
346390
if (!this.drainScheduled) {
347391
this.drainScheduled = true;
348392
setImmediate(() => this.drainInbound());
@@ -363,6 +407,7 @@ export class FfiRuntimeHost {
363407
}
364408
let frame: Buffer | undefined;
365409
while ((frame = this.inboundQueue.shift()) !== undefined) {
410+
ffiTrace(`<<DELIVER len=${frame.length} ${frameTag(frame)}`);
366411
this.receiveStream.write(frame);
367412
}
368413
}

0 commit comments

Comments
 (0)