From 520e0aab9cbb6ad368e0fa3e1729801419227bdd Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 18:58:42 -0800 Subject: [PATCH] feat(companion): add AbortSignal support for waitForEvent --- README.md | 2 +- docs/plans/state.json | 13 +++++++++ src/companion/runtimeClient.test.ts | 13 +++++++++ src/companion/runtimeClient.ts | 42 +++++++++++++++++++++-------- 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index b86b949..c75c29f 100644 --- a/README.md +++ b/README.md @@ -1190,7 +1190,7 @@ Methods: - `system.capabilities` returns gateway protocol and node policy snapshot. 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`) and event helpers (`subscribeEvents()`, `subscribeEvent()`, `waitForEvent()`, `clearEventSubscriptions()`). +- `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`) and event helpers (`subscribeEvents()`, `subscribeEvent()`, `waitForEvent()` with timeout/predicate/abort support, `clearEventSubscriptions()`). - `src/companion/platformClients.ts` provides platform-focused wrappers: - `MacOSCompanionClient` (`platform: "macos"`, APNs push registration) - `IOSCompanionClient` (`platform: "ios"`, APNs push registration) diff --git a/docs/plans/state.json b/docs/plans/state.json index bded8f5..20bd68c 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -421,6 +421,19 @@ ], "test_status": "pnpm test:run src/companion/heartbeatLoop.test.ts src/companion/runtimeClient.test.ts src/companion/platformClients.test.ts src/companion/platformClients.integration.test.ts + pnpm typecheck passing" }, + "companion-runtime-wait-for-event-abort-signal": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Extended `waitForEvent()` with `AbortSignal` support so companion runtimes can cancel pending event waits deterministically (in addition to timeout-based cancellation).", + "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/heartbeatLoop.test.ts src/companion/platformClients.test.ts src/companion/platformClients.integration.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 416bdc0..d53dfb6 100644 --- a/src/companion/runtimeClient.test.ts +++ b/src/companion/runtimeClient.test.ts @@ -260,6 +260,19 @@ describe('CompanionRuntimeClient', () => { vi.useRealTimers(); }); + it('waitForEvent supports AbortSignal cancellation', async () => { + const client = new CompanionRuntimeClient({ + url: 'ws://127.0.0.1:1', + }); + const controller = new AbortController(); + + const awaited = expect( + client.waitForEvent('agent.stream', { signal: controller.signal, timeoutMs: 10_000 }), + ).rejects.toThrow('Aborted while waiting for event agent.stream'); + controller.abort(); + await awaited; + }); + it('connects and performs node registration + capability discovery', async () => { if (!LISTEN_ALLOWED) { return; diff --git a/src/companion/runtimeClient.ts b/src/companion/runtimeClient.ts index e8ffd88..5e36cbd 100644 --- a/src/companion/runtimeClient.ts +++ b/src/companion/runtimeClient.ts @@ -385,34 +385,54 @@ export class CompanionRuntimeClient { options?: { timeoutMs?: number; predicate?: CompanionEventPredicate; + signal?: AbortSignal; }, ): Promise { const timeoutMs = options?.timeoutMs ?? this.requestTimeoutMs; const predicate = options?.predicate; + const signal = options?.signal; return new Promise((resolve, reject) => { let settled = false; - const unsubscribe = this.subscribeEvent(eventName, (data) => { - if (predicate && !predicate(data)) { - return; - } + let abortCleanup: (() => void) | null = null; + + const finish = (fn: () => void) => { if (settled) { return; } settled = true; clearTimeout(timeout); unsubscribe(); - resolve(data); + if (abortCleanup) { + abortCleanup(); + abortCleanup = null; + } + fn(); + }; + + const unsubscribe = this.subscribeEvent(eventName, (data) => { + if (predicate && !predicate(data)) { + return; + } + finish(() => resolve(data)); }); const timeout = setTimeout(() => { - if (settled) { - return; - } - settled = true; - unsubscribe(); - reject(new Error(`Timed out waiting for event ${eventName}`)); + finish(() => reject(new Error(`Timed out waiting for event ${eventName}`))); }, timeoutMs); + + if (signal) { + const onAbort = () => { + finish(() => reject(new Error(`Aborted while waiting for event ${eventName}`))); + }; + signal.addEventListener('abort', onAbort, { once: true }); + abortCleanup = () => { + signal.removeEventListener('abort', onAbort); + }; + if (signal.aborted) { + onAbort(); + } + } }); }