Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
21 changes: 18 additions & 3 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ import { defaultJoinSessionPermissionHandler } from "./types.js";
*/
const MIN_PROTOCOL_VERSION = 3;
const RUNTIME_SHUTDOWN_TIMEOUT_MS = 10_000;
const SESSION_ABORT_TIMEOUT_MS = 5_000;
const SESSION_DISCONNECT_TIMEOUT_MS = 5_000;

/**
* Check if value is a Zod schema (has toJSONSchema method)
Expand Down Expand Up @@ -946,6 +948,7 @@ export class CopilotClient {

// Disconnect all active sessions with retry logic
const activeSessions = [...this.sessions.values()];
const connectedSessions = activeSessions.filter((session) => !session._isDisconnected());
// TEMPORARY: over the in-process (FFI) transport the runtime shares this
// process, so a turn still running when the runtime disposes the session
// can leave that session's SQLite session.db handle open — it isn't
Expand All @@ -958,16 +961,28 @@ export class CopilotClient {
// own the runtime and aborting would cancel pending work other clients
// may still resume. Remove once the runtime cleans up fully on shutdown.
if (this.connectionConfig.kind === "inprocess") {
await Promise.allSettled(activeSessions.map((session) => session.abort()));
await Promise.allSettled(
connectedSessions.map((session) =>
withTimeout(
session.abort(),
SESSION_ABORT_TIMEOUT_MS,
`session.abort timed out after ${SESSION_ABORT_TIMEOUT_MS}ms for ${session.sessionId}`
)
Comment thread
roji marked this conversation as resolved.
Outdated
)
);
}
for (const session of activeSessions) {
for (const session of connectedSessions) {
const sessionId = session.sessionId;
let lastError: Error | null = null;

// Try up to 3 times with exponential backoff
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await session.disconnect();
await withTimeout(
session.disconnect(),
SESSION_DISCONNECT_TIMEOUT_MS,
`session.disconnect timed out after ${SESSION_DISCONNECT_TIMEOUT_MS}ms`
);
Comment thread
roji marked this conversation as resolved.
Outdated
lastError = null;
break; // Success
} catch (error) {
Expand Down
5 changes: 5 additions & 0 deletions nodejs/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,11 @@ export class CopilotSession {
this.transformCallbacks?.clear();
}

/** @internal */
_isDisconnected(): boolean {
return this.disconnected;
}

/**
* Subscribes to events from this session.
*
Expand Down
35 changes: 35 additions & 0 deletions nodejs/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3193,5 +3193,40 @@ describe("CopilotClient", () => {
await expect(externalClient.stop()).resolves.toEqual([]);
expect(externalSendRequest).not.toHaveBeenCalled();
});

it("only aborts and disconnects sessions that are still connected", async () => {
const client = new CopilotClient({
connection: RuntimeConnection.forInProcess(),
});

const connectedSession = {
sessionId: "connected-session",
abort: vi.fn(async () => {}),
disconnect: vi.fn(async () => {}),
_markDisconnected: vi.fn(),
_isDisconnected: vi.fn(() => false),
};
const disconnectedSession = {
sessionId: "disconnected-session",
abort: vi.fn(async () => {}),
disconnect: vi.fn(async () => {}),
_markDisconnected: vi.fn(),
_isDisconnected: vi.fn(() => true),
};

(client as any).sessions = new Map([
["connected-session", connectedSession],
["disconnected-session", disconnectedSession],
]);

await expect(client.stop()).resolves.toEqual([]);

expect(connectedSession.abort).toHaveBeenCalledTimes(1);
expect(connectedSession.disconnect).toHaveBeenCalledTimes(1);
expect(disconnectedSession.abort).not.toHaveBeenCalled();
expect(disconnectedSession.disconnect).not.toHaveBeenCalled();
expect(connectedSession._markDisconnected).toHaveBeenCalledTimes(1);
expect(disconnectedSession._markDisconnected).toHaveBeenCalledTimes(1);
});
});
});
Loading