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.
This commit is contained in:
Brennan Benson
2026-08-23 14:35:53 -07:00
parent ac60c2d99a
commit 0fe0d200cd
2 changed files with 36 additions and 7 deletions
@@ -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'))
+13 -7
View File
@@ -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
}