From 6821e3779fefbb6172ff3fd76f171fbd0f96028f Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 23:29:40 -0800 Subject: [PATCH] fix(companion): preserve manual disconnect snapshot metadata --- docs/plans/state.json | 12 +++++++++ src/companion/runtimeClient.test.ts | 42 +++++++++++++++++++++++++++++ src/companion/runtimeClient.ts | 10 +++---- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/docs/plans/state.json b/docs/plans/state.json index 21aa6b0..a94ff1a 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -1224,6 +1224,18 @@ ], "test_status": "pnpm test:run src/companion/runtimeClient.test.ts src/companion/platformClients.test.ts src/companion/platformClients.integration.test.ts src/companion/heartbeatLoop.test.ts + pnpm typecheck passing" }, + "companion-runtime-disconnect-metadata-clobber-guard": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Prevented manual disconnect metadata (`code`/`reason`) from being overwritten by the subsequent local socket `close` callback by only applying transport close metadata for active connections (`this.ws === ws`), with regression coverage.", + "files_modified": [ + "src/companion/runtimeClient.ts", + "src/companion/runtimeClient.test.ts", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/companion/runtimeClient.test.ts src/companion/platformClients.test.ts src/companion/platformClients.integration.test.ts src/companion/heartbeatLoop.test.ts + pnpm typecheck passing" + }, "browser-tools-activation-clarity": { "status": "completed", "date": "2026-02-17", diff --git a/src/companion/runtimeClient.test.ts b/src/companion/runtimeClient.test.ts index bf09fd2..e2c0de3 100644 --- a/src/companion/runtimeClient.test.ts +++ b/src/companion/runtimeClient.test.ts @@ -842,6 +842,48 @@ describe('CompanionRuntimeClient', () => { }); }); + it('manual disconnect metadata is not overwritten by local close event', async () => { + class FakeWebSocket extends EventEmitter { + readyState: number = WebSocket.CONNECTING; + + constructor() { + super(); + queueMicrotask(() => { + this.readyState = WebSocket.OPEN; + this.emit('open'); + }); + } + + send(_payload: string, callback?: (error?: Error) => void): void { + callback?.(); + } + + close(): void { + this.readyState = WebSocket.CLOSED; + this.emit('close'); + } + } + + const client = new CompanionRuntimeClient({ + url: 'ws://127.0.0.1:1', + websocketFactory: () => new FakeWebSocket() as unknown as WebSocket, + }); + await client.connect(); + + client.disconnect(4100, 'manual stop'); + + expect(client.getConnectionSnapshot()).toEqual({ + connected: false, + eventSubscriptionCount: 0, + pendingRequestCount: 0, + pendingEventWaitCount: 0, + hasPendingWork: false, + idle: true, + lastDisconnectCode: 4100, + lastDisconnectReason: 'manual stop', + }); + }); + it('waitForIdle resolves immediately when no work is pending', async () => { const client = new CompanionRuntimeClient({ url: 'ws://127.0.0.1:1', diff --git a/src/companion/runtimeClient.ts b/src/companion/runtimeClient.ts index 60abb25..d072552 100644 --- a/src/companion/runtimeClient.ts +++ b/src/companion/runtimeClient.ts @@ -412,14 +412,14 @@ export class CompanionRuntimeClient { this.ws = ws; this.ws.on('message', (raw) => this.handleMessage(raw.toString())); this.ws.on('close', (code, reason) => { - this.lastDisconnectCode = code; - const reasonText = reason?.toString().trim(); - this.lastDisconnectReason = reasonText ? reasonText : undefined; if (this.ws === ws) { + this.lastDisconnectCode = code; + const reasonText = reason?.toString().trim(); + this.lastDisconnectReason = reasonText ? reasonText : undefined; this.ws = null; + this.rejectAllPending(new Error('WebSocket closed')); + this.rejectEventWaits(new Error('WebSocket closed')); } - this.rejectAllPending(new Error('WebSocket closed')); - this.rejectEventWaits(new Error('WebSocket closed')); }); this.ws.on('error', () => { // close event handles pending rejection