From 3a2ea7560ce953435aae2732f6d1e127e1170445 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 21:36:25 +0000 Subject: [PATCH 1/2] Close the --ws socket for a command that ends by returning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An open socket holds the event loop and `WebSocket` has no `unref` on either runtime, so a command that finishes by returning rather than through `showStatsAndExit` never exited once a UI was attached — `config`, `plans`, `runs` and `sites` all hung on the flag. The option that opens the connection now closes it, in a `postAction` hook. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ns9EkzxKT5gL8BdmV1kJSX --- CHANGELOG.md | 8 ++++++++ CLAUDE.md | 2 +- src/commands/options/ws-option.ts | 14 ++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c08b3d26..4a681d7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2026-09-11 + +### Changes + +- `--ws` no longer hangs a command that ends by returning instead of exiting. The open socket held the + process alive, so `explorbot config`, `plans`, `runs` and `sites` never came back once a UI was + attached; they now close the connection and exit as they always did without the flag. + ## 2026-09-10 ### Changes diff --git a/CLAUDE.md b/CLAUDE.md index 2cf681fc..683f2bf0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -563,7 +563,7 @@ One class in one file, and nothing in explorbot knows about it. `remote` (the si Consequently `isInteractive()` (`src/ai/task-agent.ts`) is **`INK_RUNNING || executionController.hasInputCallback()`** — "somebody can answer", asked of the controller rather than of any particular front end. -**There are no frame types.** A frame is `{type, ts, ...whatever}`; `send(type, data)` puts data on the wire and the UI renders what it recognises. Neither side validates the other's shape, so either can start sending more at any time. It queues while disconnected, reconnects with backoff, and `remote.close(exitCode)` flushes before exit (called from `showStatsAndExit`). +**There are no frame types.** A frame is `{type, ts, ...whatever}`; `send(type, data)` puts data on the wire and the UI renders what it recognises. Neither side validates the other's shape, so either can start sending more at any time. It queues while disconnected, reconnects with backoff, and `remote.close(exitCode)` flushes before exit — called from `showStatsAndExit`, and from `WsOption`'s `postAction` for a command that ends by returning instead, since an open socket holds the event loop and `WebSocket` has no `unref` on either runtime. `--ws` itself is declared outside remote, as a `BaseOption` in `src/commands/options/` — see below — and its hook calls `remote.attach()`. diff --git a/src/commands/options/ws-option.ts b/src/commands/options/ws-option.ts index 7e990a16..2419bf0a 100644 --- a/src/commands/options/ws-option.ts +++ b/src/commands/options/ws-option.ts @@ -6,6 +6,20 @@ export class WsOption extends BaseOption { flags = '--ws '; description = 'Stream this run to a remote UI over WebSocket'; + /** + * An open socket holds the event loop, and `WebSocket` has no `unref` on + * either runtime — so a command that ends by returning rather than through + * `showStatsAndExit` would never exit once it is attached. The option that + * opened the connection is what closes it. + */ + override register(command: Command): void { + super.register(command); + command.hook('postAction', async () => { + if (!remote.isAttached()) return; + await remote.close(0); + }); + } + protected apply(options: Record, command: Command): void { const url = options.ws || process.env.EXPLORBOT_WS_URL; if (!url) return; From a635a21e077c0029de63d7798fd7f9cc7b599807 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Mon, 14 Sep 2026 00:52:22 +0300 Subject: [PATCH 2/2] Let concurrent closes of the remote share one shutdown The --ws postAction hook and showStatsAndExit (or a test's afterEach) can both call remote.close(). The second call returned at once while the first was still flushing, then the first woke up on the next attachment and tore it down. In tests/unit/remote.test.ts that dropped the interrupt frame and the suite timed out on CI. A close now returns the shutdown that is already running. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PXGkpoKXSnSGnTmMrH8WyU --- src/remote.ts | 37 ++++++++++++++++++++++--------------- tests/unit/remote.test.ts | 15 +++++++++++++++ 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/remote.ts b/src/remote.ts index 9ed001da..6e27537d 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -28,6 +28,7 @@ export class Remote implements LogDestination { private asks = new Map void>(); private askCounter = 0; private lastActivity: string | null = null; + private closing: Promise | null = null; attach(url: string, command: string): void { if (this.url) return; @@ -73,21 +74,10 @@ export class Remote implements LogDestination { }); } - async close(exitCode: number): Promise { - if (!this.url) return; - this.send('result', { ok: exitCode === 0, exitCode }); - await this.flush(); - - this.url = null; - if (this.reconnectTimer) clearTimeout(this.reconnectTimer); - // Whoever asks next has nobody to ask — leaving the callback installed would - // route them into a closed socket and park them until the ask times out. - executionController.clearInputCallback(); - for (const resolve of this.asks.values()) resolve(null); - this.asks.clear(); - this.queue = []; - this.socket?.close(); - this.socket = null; + close(exitCode: number): Promise { + if (!this.url) return Promise.resolve(); + if (!this.closing) this.closing = this.shutdown(exitCode); + return this.closing; } isEnabled(): boolean { @@ -116,6 +106,23 @@ export class Remote implements LogDestination { }); } + private async shutdown(exitCode: number): Promise { + this.send('result', { ok: exitCode === 0, exitCode }); + await this.flush(); + + this.url = null; + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + // Whoever asks next has nobody to ask — leaving the callback installed would + // route them into a closed socket and park them until the ask times out. + executionController.clearInputCallback(); + for (const resolve of this.asks.values()) resolve(null); + this.asks.clear(); + this.queue = []; + this.socket?.close(); + this.socket = null; + this.closing = null; + } + private connect(): void { if (!this.url) return; diff --git a/tests/unit/remote.test.ts b/tests/unit/remote.test.ts index 39a27ece..78cfe968 100644 --- a/tests/unit/remote.test.ts +++ b/tests/unit/remote.test.ts @@ -145,6 +145,21 @@ describe('remote', () => { expect(await waitFor(frameOf('result'))).toMatchObject({ ok: false, exitCode: 1 }); }); + test('a close that is still flushing never tears down the next attachment', async () => { + remote.attach(url(), 'explore'); + const first = remote.close(0); + await waitFor(frameOf('hello')); + await remote.close(0); + expect(remote.isAttached()).toBe(false); + + remote.attach(url(), 'explore'); + await first; + await waitFor(() => (received.filter((f) => f.type === 'hello').length >= 2 ? true : undefined)); + + expect(remote.isAttached()).toBe(true); + expect(received.filter((f) => f.type === 'result')).toHaveLength(1); + }); + test('run state logged as data reaches the UI as its own frame, never as a log line', async () => { ConfigParser.resetForTesting(); ConfigParser.setupTestConfig();