Skip to content

Commit 5e60dad

Browse files
committed
Avoid Windows in-process teardown deadlock
Do not retry removal of the in-process runtime's session home while its Vitest worker still owns a locked session database. Retrying until the hook timeout prevents the worker from exiting and releasing the lock. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e902b9a-5527-4a32-a5a3-e0bf5bfef3f7
1 parent a4f17fb commit 5e60dad

6 files changed

Lines changed: 30 additions & 193 deletions

File tree

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

Lines changed: 16 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,19 @@ on:
1010
pull_request:
1111
types: [opened, synchronize, reopened, ready_for_review]
1212
paths:
13-
- "nodejs/**"
14-
- "test/**"
15-
- ".github/workflows/nodejs-sdk-tests.yml"
16-
- "!nodejs/scripts/**"
17-
- "!**/*.md"
18-
- "!**/LICENSE*"
19-
- "!**/.gitignore"
20-
- "!**/.editorconfig"
21-
- "!**/*.png"
22-
- "!**/*.jpg"
23-
- "!**/*.jpeg"
24-
- "!**/*.gif"
25-
- "!**/*.svg"
13+
- 'nodejs/**'
14+
- 'test/**'
15+
- '.github/workflows/nodejs-sdk-tests.yml'
16+
- '!nodejs/scripts/**'
17+
- '!**/*.md'
18+
- '!**/LICENSE*'
19+
- '!**/.gitignore'
20+
- '!**/.editorconfig'
21+
- '!**/*.png'
22+
- '!**/*.jpg'
23+
- '!**/*.jpeg'
24+
- '!**/*.gif'
25+
- '!**/*.svg'
2626
workflow_dispatch:
2727
merge_group:
2828

@@ -38,8 +38,8 @@ jobs:
3838
strategy:
3939
fail-fast: false
4040
matrix:
41-
os: [windows-latest]
42-
transport: ["inprocess"]
41+
os: [ubuntu-latest, macos-latest, windows-latest]
42+
transport: ["default", "inprocess"]
4343
runs-on: ${{ matrix.os }}
4444
defaults:
4545
run:
@@ -80,38 +80,8 @@ jobs:
8080
if: matrix.transport == 'inprocess'
8181
run: |
8282
echo "COPILOT_SDK_DEFAULT_CONNECTION=inprocess" >> "$GITHUB_ENV"
83-
echo "COPILOT_SDK_TEST_DIAGNOSTICS=1" >> "$GITHUB_ENV"
8483
8584
- name: Run Node.js SDK tests
8685
env:
8786
COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }}
88-
COPILOT_SDK_PRESERVE_TEST_HOME: "1"
89-
run: |
90-
pwsh.exe -NoProfile -Command '
91-
while ($true) {
92-
$cpu = [math]::Round((Get-Counter "\Processor(_Total)\% Processor Time").CounterSamples.CookedValue)
93-
$memory = [math]::Round((Get-Counter "\Memory\Available MBytes").CounterSamples.CookedValue)
94-
$nodeCount = @(Get-Process -Name node -ErrorAction SilentlyContinue).Count
95-
Write-Host "[resource-diagnostic] $(Get-Date -Format o) cpu=$cpu availableMb=$memory nodeProcesses=$nodeCount"
96-
Start-Sleep -Seconds 5
97-
}
98-
' &
99-
monitor_pid=$!
100-
trap 'kill "$monitor_pid"' EXIT
101-
npm test
102-
103-
- name: Collect runtime diagnostics
104-
if: failure()
105-
shell: pwsh
106-
run: |
107-
$paths = Get-ChildItem $env:TEMP -Directory -Filter "copilot-test-home-*"
108-
if ($paths) {
109-
Compress-Archive -Path $paths.FullName -DestinationPath runtime-diagnostics.zip
110-
}
111-
112-
- name: Upload runtime diagnostics
113-
if: failure()
114-
uses: actions/upload-artifact@v4
115-
with:
116-
name: nodejs-runtime-diagnostics
117-
path: nodejs/runtime-diagnostics.zip
87+
run: npm test

nodejs/src/client.ts

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,6 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: s
121121
}
122122
}
123123

124-
function logTestDiagnostic(message: string, startMs?: number): void {
125-
if (process.env.COPILOT_SDK_TEST_DIAGNOSTICS !== "1") {
126-
return;
127-
}
128-
const elapsed = startMs === undefined ? "" : ` elapsed=${Date.now() - startMs}ms`;
129-
process.stderr.write(`[copilot-sdk-diagnostic pid=${process.pid}] ${message}${elapsed}\n`);
130-
}
131-
132124
async function waitForChildExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
133125
if (child.exitCode != null || child.signalCode != null) {
134126
return true;
@@ -885,26 +877,21 @@ export class CopilotClient {
885877
return;
886878
}
887879

888-
const startMs = Date.now();
889-
logTestDiagnostic(`client.start begin transport=${this.connectionConfig.kind}`);
890880
this.state = "connecting";
891881

892882
try {
893883
// Only start CLI server process if not connecting to external server
894884
if (this.connectionConfig.kind === "inprocess") {
895885
await this.startInProcessFfi();
896-
logTestDiagnostic("client.start FFI host ready", startMs);
897886
} else if (!this.isExternalServer) {
898887
await this.startCLIServer();
899888
}
900889

901890
// Connect to the server
902891
await this.connectToServer();
903-
logTestDiagnostic("client.start JSON-RPC connected", startMs);
904892

905893
// Verify protocol version compatibility
906894
await this.verifyProtocolVersion();
907-
logTestDiagnostic("client.start protocol verified", startMs);
908895

909896
// If a session filesystem provider was configured, register it
910897
if (this.sessionFsConfig) {
@@ -924,10 +911,8 @@ export class CopilotClient {
924911
}
925912

926913
this.state = "connected";
927-
logTestDiagnostic("client.start complete", startMs);
928914
} catch (error) {
929915
this.state = "error";
930-
logTestDiagnostic("client.start failed", startMs);
931916
throw error;
932917
}
933918
}
@@ -957,12 +942,10 @@ export class CopilotClient {
957942
* ```
958943
*/
959944
async stop(): Promise<Error[]> {
960-
const stopMs = Date.now();
961945
const errors: Error[] = [];
962946

963947
// Disconnect all active sessions with retry logic
964948
const activeSessions = [...this.sessions.values()];
965-
logTestDiagnostic(`client.stop begin sessions=${activeSessions.length}`);
966949
// TEMPORARY: over the in-process (FFI) transport the runtime shares this
967950
// process, so a turn still running when the runtime disposes the session
968951
// can leave that session's SQLite session.db handle open — it isn't
@@ -976,7 +959,6 @@ export class CopilotClient {
976959
// may still resume. Remove once the runtime cleans up fully on shutdown.
977960
if (this.connectionConfig.kind === "inprocess") {
978961
await Promise.allSettled(activeSessions.map((session) => session.abort()));
979-
logTestDiagnostic("client.stop session aborts settled", stopMs);
980962
}
981963
for (const session of activeSessions) {
982964
const sessionId = session.sessionId;
@@ -1007,7 +989,6 @@ export class CopilotClient {
1007989
);
1008990
}
1009991
}
1010-
logTestDiagnostic("client.stop session disconnects complete", stopMs);
1011992
for (const session of activeSessions) {
1012993
session._markDisconnected();
1013994
}
@@ -1042,7 +1023,6 @@ export class CopilotClient {
10421023
);
10431024
}
10441025
}
1045-
logTestDiagnostic("client.stop runtime shutdown stage complete", stopMs);
10461026

10471027
// Close connection. Suppress writer failures first: tearing down the
10481028
// transport can reject an in-flight server→client response write with
@@ -1066,7 +1046,6 @@ export class CopilotClient {
10661046
this._rpc = null;
10671047
this._internalRpc = null;
10681048
}
1069-
logTestDiagnostic("client.stop JSON-RPC disposed", stopMs);
10701049

10711050
// Clear models cache
10721051
this.modelsCache = null;
@@ -1125,9 +1104,7 @@ export class CopilotClient {
11251104
const host = this.ffiHost;
11261105
this.ffiHost = null;
11271106
try {
1128-
logTestDiagnostic("client.stop FFI host dispose begin", stopMs);
11291107
host.dispose();
1130-
logTestDiagnostic("client.stop FFI host dispose complete", stopMs);
11311108
} catch (error) {
11321109
errors.push(
11331110
new Error(
@@ -1145,7 +1122,6 @@ export class CopilotClient {
11451122
this.runtimePort = null;
11461123
this.stderrBuffer = "";
11471124
this.processExitPromise = null;
1148-
logTestDiagnostic(`client.stop complete errors=${errors.length}`, stopMs);
11491125

11501126
return errors;
11511127
}
@@ -1440,11 +1416,8 @@ export class CopilotClient {
14401416
}
14411417

14421418
async createSession(config: SessionConfig): Promise<CopilotSession> {
1443-
const createMs = Date.now();
1444-
logTestDiagnostic(`client.createSession begin connected=${this.connection !== null}`);
14451419
if (!this.connection) {
14461420
await this.start();
1447-
logTestDiagnostic("client.createSession client started", createMs);
14481421
}
14491422

14501423
config = { ...this.configDefaultsForMode(), ...config };
@@ -1615,7 +1588,6 @@ export class CopilotClient {
16151588
expAssignments: config.expAssignments,
16161589
enableManagedSettings: config.enableManagedSettings,
16171590
});
1618-
logTestDiagnostic("client.createSession RPC complete", createMs);
16191591

16201592
const {
16211593
sessionId: returnedSessionId,
@@ -1657,7 +1629,6 @@ export class CopilotClient {
16571629
throw e;
16581630
}
16591631

1660-
logTestDiagnostic("client.createSession complete", createMs);
16611632
return session;
16621633
}
16631634

@@ -2589,8 +2560,6 @@ export class CopilotClient {
25892560

25902561
/** Starts the in-process FFI runtime with SDK-managed typed options. */
25912562
private async startInProcessFfi(): Promise<void> {
2592-
const startMs = Date.now();
2593-
logTestDiagnostic("client.startInProcessFfi begin");
25942563
const entrypoint = this.resolveCliPathForFfi();
25952564
// Load the FFI host lazily so the native `koffi` addon (and its
25962565
// platform-specific `koffi.node`) is only loaded on the in-process path;
@@ -2634,7 +2603,6 @@ export class CopilotClient {
26342603
);
26352604
this.ffiHost = host;
26362605
await host.start();
2637-
logTestDiagnostic("client.startInProcessFfi complete", startMs);
26382606
}
26392607

26402608
/**

nodejs/src/ffiRuntimeHost.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,6 @@ const SYMBOL_PREFIX = "copilot_runtime_";
2828
// connection is open (see start()); the exact interval is irrelevant.
2929
const KEEP_ALIVE_INTERVAL_MS = 1 << 30;
3030

31-
function logTestDiagnostic(message: string, startMs?: number): void {
32-
if (process.env.COPILOT_SDK_TEST_DIAGNOSTICS !== "1") {
33-
return;
34-
}
35-
const elapsed = startMs === undefined ? "" : ` elapsed=${Date.now() - startMs}ms`;
36-
process.stderr.write(`[copilot-sdk-diagnostic pid=${process.pid}] ${message}${elapsed}\n`);
37-
}
38-
3931
type KoffiFunction = ReturnType<ReturnType<typeof koffi.load>["func"]>;
4032
type KoffiType = ReturnType<typeof koffi.pointer>;
4133
type KoffiRegisteredCallback = ReturnType<typeof koffi.register>;
@@ -194,8 +186,6 @@ export class FfiRuntimeHost {
194186
* waits for readiness, and opens the FFI JSON-RPC connection.
195187
*/
196188
async start(): Promise<void> {
197-
const startMs = Date.now();
198-
logTestDiagnostic("ffi.start begin");
199189
const argvJson = buildArgvJson(this.cliEntrypoint, this.args);
200190
const envJson = buildEnvJson(this.environment);
201191

@@ -226,7 +216,6 @@ export class FfiRuntimeHost {
226216
`copilot_runtime_host_start failed (library '${this.libraryPath}', entrypoint '${this.cliEntrypoint}').`
227217
);
228218
}
229-
logTestDiagnostic("ffi.start hostStart complete", startMs);
230219

231220
this.outboundCallback = koffi.register(
232221
(_userData: unknown, bytesPtr: unknown, bytesLen: number | bigint) =>
@@ -251,7 +240,6 @@ export class FfiRuntimeHost {
251240
this.serverId = 0;
252241
throw new Error("copilot_runtime_connection_open failed.");
253242
}
254-
logTestDiagnostic("ffi.start connectionOpen complete", startMs);
255243

256244
// The in-process transport has no socket/pipe handle to keep the Node event loop
257245
// alive while the SDK is idle awaiting a server→client frame. koffi delivers the
@@ -323,10 +311,6 @@ export class FfiRuntimeHost {
323311
return;
324312
}
325313
this.disposed = true;
326-
const disposeMs = Date.now();
327-
logTestDiagnostic(
328-
`ffi.dispose begin server=${this.serverId !== 0} connection=${this.connectionId !== 0}`
329-
);
330314

331315
if (this.keepAliveTimer !== undefined) {
332316
clearInterval(this.keepAliveTimer);
@@ -335,28 +319,23 @@ export class FfiRuntimeHost {
335319

336320
try {
337321
if (this.connectionId) {
338-
logTestDiagnostic("ffi.dispose connectionClose begin", disposeMs);
339322
this.lib.connectionClose(this.connectionId);
340323
this.connectionId = 0;
341-
logTestDiagnostic("ffi.dispose connectionClose complete", disposeMs);
342324
}
343325
} catch {
344326
// Ignore teardown failures.
345327
}
346328

347329
try {
348330
if (this.serverId) {
349-
logTestDiagnostic("ffi.dispose hostShutdown begin", disposeMs);
350331
this.lib.hostShutdown(this.serverId);
351332
this.serverId = 0;
352-
logTestDiagnostic("ffi.dispose hostShutdown complete", disposeMs);
353333
}
354334
} catch {
355335
// Ignore teardown failures.
356336
}
357337

358338
this.receiveStream.end();
359339
this.unregisterCallback();
360-
logTestDiagnostic("ffi.dispose complete", disposeMs);
361340
}
362341
}

nodejs/src/session.ts

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,6 @@ import type {
6161
UserInputResponse,
6262
} from "./types.js";
6363

64-
function logTestDiagnostic(message: string, startMs?: number): void {
65-
if (process.env.COPILOT_SDK_TEST_DIAGNOSTICS !== "1") {
66-
return;
67-
}
68-
const elapsed = startMs === undefined ? "" : ` elapsed=${Date.now() - startMs}ms`;
69-
process.stderr.write(`[copilot-sdk-diagnostic pid=${process.pid}] ${message}${elapsed}\n`);
70-
}
71-
7264
/**
7365
* Convert a raw hook input received over the wire into its public-facing shape.
7466
* This deserializes the numeric Unix-ms `timestamp` field on BaseHookInput
@@ -300,8 +292,6 @@ export class CopilotSession {
300292
optionsOrPrompt: MessageOptions | string,
301293
timeout?: number
302294
): Promise<AssistantMessageEvent | undefined> {
303-
const sendMs = Date.now();
304-
logTestDiagnostic(`session.sendAndWait begin session=${this.sessionId}`);
305295
const options: MessageOptions =
306296
typeof optionsOrPrompt === "string" ? { prompt: optionsOrPrompt } : optionsOrPrompt;
307297
const effectiveTimeout = timeout ?? 60_000;
@@ -332,10 +322,6 @@ export class CopilotSession {
332322
let timeoutId: ReturnType<typeof setTimeout> | undefined;
333323
try {
334324
await this.send(options);
335-
logTestDiagnostic(
336-
`session.sendAndWait send complete session=${this.sessionId}`,
337-
sendMs
338-
);
339325

340326
const timeoutPromise = new Promise<never>((_, reject) => {
341327
timeoutId = setTimeout(
@@ -349,15 +335,13 @@ export class CopilotSession {
349335
);
350336
});
351337
await Promise.race([idlePromise, timeoutPromise]);
352-
logTestDiagnostic(`session.sendAndWait idle session=${this.sessionId}`, sendMs);
353338

354339
return lastAssistantMessage;
355340
} finally {
356341
if (timeoutId !== undefined) {
357342
clearTimeout(timeoutId);
358343
}
359344
unsubscribe();
360-
logTestDiagnostic(`session.sendAndWait complete session=${this.sessionId}`, sendMs);
361345
}
362346
}
363347

0 commit comments

Comments
 (0)