fix(companion): reject event waiters on unexpected socket close

This commit is contained in:
William Valentin
2026-02-16 19:42:23 -08:00
parent 61533bd816
commit 8837843df1
4 changed files with 61 additions and 2 deletions
+1 -1
View File
@@ -1190,7 +1190,7 @@ Methods:
- `system.capabilities` returns gateway protocol and node policy snapshot. - `system.capabilities` returns gateway protocol and node policy snapshot.
Companion runtime helper: Companion runtime helper:
- `src/companion/runtimeClient.ts` provides a typed Node/WebSocket client for companion runtimes (macOS/iOS/Android workers) with wrappers for `node.register`, `node.capabilities.get`, `node.location.set/get`, `node.status.set`, `node.push_token.set`, `system.capabilities`, `system.nodes`, and canvas artifact RPCs (`canvas.put/get/list/delete/clear`), plus convenience helpers (`bootstrapNode`, optional `autoConnect`, `dispose()`) and event helpers (`subscribeEvents()`, `subscribeEvent()`, `subscribeAgentStream()`, `subscribeAgentTyping()`, `subscribeContextWarning()`, `waitForEvent()` with timeout/predicate/abort support and deterministic teardown cancellation, `waitForAnyEvent()`, `waitForAgentStream()`, `waitForAgentTyping()`, `waitForContextWarning()`, `clearEventSubscriptions()`, `listKnownEventNames()`, `eventSubscriptionCount`). - `src/companion/runtimeClient.ts` provides a typed Node/WebSocket client for companion runtimes (macOS/iOS/Android workers) with wrappers for `node.register`, `node.capabilities.get`, `node.location.set/get`, `node.status.set`, `node.push_token.set`, `system.capabilities`, `system.nodes`, and canvas artifact RPCs (`canvas.put/get/list/delete/clear`), plus convenience helpers (`bootstrapNode`, optional `autoConnect`, `dispose()`) and event helpers (`subscribeEvents()`, `subscribeEvent()`, `subscribeAgentStream()`, `subscribeAgentTyping()`, `subscribeContextWarning()`, `waitForEvent()` with timeout/predicate/abort support and deterministic teardown cancellation (including socket-close rejection), `waitForAnyEvent()`, `waitForAgentStream()`, `waitForAgentTyping()`, `waitForContextWarning()`, `clearEventSubscriptions()`, `listKnownEventNames()`, `eventSubscriptionCount`).
- `src/companion/platformClients.ts` provides platform-focused wrappers: - `src/companion/platformClients.ts` provides platform-focused wrappers:
- `MacOSCompanionClient` (`platform: "macos"`, APNs push registration) - `MacOSCompanionClient` (`platform: "macos"`, APNs push registration)
- `IOSCompanionClient` (`platform: "ios"`, APNs push registration) - `IOSCompanionClient` (`platform: "ios"`, APNs push registration)
+13
View File
@@ -772,6 +772,19 @@
], ],
"test_status": "pnpm test:run src/companion/heartbeatLoop.test.ts src/companion/platformClients.test.ts src/companion/runtimeClient.test.ts src/companion/platformClients.integration.test.ts + pnpm typecheck passing" "test_status": "pnpm test:run src/companion/heartbeatLoop.test.ts src/companion/platformClients.test.ts src/companion/runtimeClient.test.ts src/companion/platformClients.integration.test.ts + pnpm typecheck passing"
}, },
"companion-runtime-socket-close-waiter-rejection": {
"status": "completed",
"date": "2026-02-17",
"updated": "2026-02-17",
"summary": "Hardened runtime close-path behavior so unexpected WebSocket closure clears stale socket references and rejects pending event waiters with a deterministic `WebSocket closed` error.",
"files_modified": [
"src/companion/runtimeClient.ts",
"src/companion/runtimeClient.test.ts",
"README.md",
"docs/plans/state.json"
],
"test_status": "pnpm test:run src/companion/runtimeClient.test.ts src/companion/platformClients.test.ts src/companion/heartbeatLoop.test.ts src/companion/platformClients.integration.test.ts + pnpm typecheck passing"
},
"browser-tools-activation-clarity": { "browser-tools-activation-clarity": {
"status": "completed", "status": "completed",
"date": "2026-02-17", "date": "2026-02-17",
+40
View File
@@ -1,6 +1,8 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { resolve } from 'path'; import { resolve } from 'path';
import { createServer } from 'net'; import { createServer } from 'net';
import { EventEmitter } from 'events';
import { WebSocket } from 'ws';
import type { GatewayServerConfig } from '../gateway/server.js'; import type { GatewayServerConfig } from '../gateway/server.js';
import { GatewayServer } from '../gateway/server.js'; import { GatewayServer } from '../gateway/server.js';
import { CompanionRuntimeClient, GatewayRpcError } from './runtimeClient.js'; import { CompanionRuntimeClient, GatewayRpcError } from './runtimeClient.js';
@@ -378,6 +380,44 @@ describe('CompanionRuntimeClient', () => {
await awaited; await awaited;
}); });
it('waitForEvent rejects when websocket closes unexpectedly', 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(_code?: number, _reason?: string): 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();
const awaited = expect(
client.waitForEvent('agent.stream', { timeoutMs: 10_000 }),
).rejects.toThrow('WebSocket closed');
const ws = (client as unknown as { ws: WebSocket | null }).ws;
ws?.close();
await awaited;
expect(client.connected).toBe(false);
});
it('waitForAgentStream resolves on agent.stream events', async () => { it('waitForAgentStream resolves on agent.stream events', async () => {
const client = new CompanionRuntimeClient({ const client = new CompanionRuntimeClient({
url: 'ws://127.0.0.1:1', url: 'ws://127.0.0.1:1',
+7 -1
View File
@@ -330,7 +330,13 @@ export class CompanionRuntimeClient {
settled = true; settled = true;
this.ws = ws; this.ws = ws;
this.ws.on('message', (raw) => this.handleMessage(raw.toString())); this.ws.on('message', (raw) => this.handleMessage(raw.toString()));
this.ws.on('close', () => this.rejectAllPending(new Error('WebSocket closed'))); this.ws.on('close', () => {
if (this.ws === ws) {
this.ws = null;
}
this.rejectAllPending(new Error('WebSocket closed'));
this.rejectEventWaits(new Error('WebSocket closed'));
});
this.ws.on('error', () => { this.ws.on('error', () => {
// close event handles pending rejection // close event handles pending rejection
}); });