From 0fe0d200cdee617e099118fcc1bc6587a7a155da Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:06:12 -0700 Subject: [PATCH] fix(native-chat): contain the WSL-gate advisory emit so a throwing subscriber can't kill the poll The unresolved-transcript notice was already contained, but its sibling emit in runAttempt's catch was not: a throw there escaped `void runAttempt()` as an unhandled rejection and skipped the reschedule below it, stranding the poll for good. Route both through one emitAdvisory helper. --- .../transcript-watch-resolve-poll.test.ts | 23 +++++++++++++++++++ src/main/native-chat/transcript-watch.ts | 20 ++++++++++------ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/main/native-chat/transcript-watch-resolve-poll.test.ts b/src/main/native-chat/transcript-watch-resolve-poll.test.ts index e40b2c5098e..ec8b69bf16b 100644 --- a/src/main/native-chat/transcript-watch-resolve-poll.test.ts +++ b/src/main/native-chat/transcript-watch-resolve-poll.test.ts @@ -198,6 +198,29 @@ describe('native chat transcript resolve polling', () => { subscription.unsubscribe() }) + // Sibling of the advisory case: the gate's emit runs inside runAttempt's catch, + // so a throw there escapes `void runAttempt()` and skips the reschedule below it. + it('keeps polling when the WSL-gate subscriber throws', async () => { + mocks.resolve.mockRejectedValue(new WslTranscriptFsError('unavailable', 'stuck permits')) + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session-id', + resolvePollIntervalMs: 10, + unresolvedNoticeMs: 10_000, + onAppend: () => {}, + onInitialSnapshot: () => { + throw new Error('subscriber blew up') + } + }) + + await vi.advanceTimersByTimeAsync(50) + const resolvesAtGateError = mocks.resolve.mock.calls.length + await vi.advanceTimersByTimeAsync(100) + expect(mocks.resolve.mock.calls.length).toBeGreaterThan(resolvesAtGateError) + + subscription.unsubscribe() + }) + it('still fails subscribe on non-gate resolver errors', async () => { mocks.resolve.mockRejectedValueOnce(new Error('resolver crashed')) diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index 87f4a104c4b..36bd853e40f 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -100,6 +100,17 @@ function subscribeViaResolvePoll( const unresolvedNoticeMs = args.unresolvedNoticeMs ?? UNRESOLVED_NOTICE_MS const resolveController = new AbortController() + /** Both advisories run under `void runAttempt()`, so a subscriber that throws + * (an emit on a dying RPC connection) would surface as an unhandled rejection + * and skip the rest of the attempt — including its reschedule. Contain it. */ + function emitAdvisory(message: string): void { + try { + args.onInitialSnapshot?.([], false, 0, message) + } catch { + // Advisory only: keep polling so a later real snapshot still replaces it. + } + } + /** Non-terminal: the poll keeps running and a later real snapshot replaces * this, so a slow-to-flush session still recovers (#8401). */ function emitUnresolvedNotice(): void { @@ -110,12 +121,7 @@ function subscribeViaResolvePoll( return } unresolvedNoticeEmitted = true - try { - args.onInitialSnapshot([], false, 0, UNRESOLVED_TRANSCRIPT_MESSAGE) - } catch { - // A subscriber that throws (an emit on a dying RPC connection) must not - // escape `void runAttempt()` as an unhandled rejection. - } + emitAdvisory(UNRESOLVED_TRANSCRIPT_MESSAGE) } function scheduleAttempt(): void { @@ -189,7 +195,7 @@ function subscribeViaResolvePoll( // runAttempt is invoked as `void runAttempt()`. if (error instanceof WslTranscriptFsError && !gateErrorEmitted && args.onInitialSnapshot) { gateErrorEmitted = true - args.onInitialSnapshot([], false, 0, error.message) + emitAdvisory(error.message) } result = null }