From 5e370df08e5c79ade9a2c18e04ac308c1dac9e7b Mon Sep 17 00:00:00 2001 From: song Date: Mon, 28 Sep 2026 23:06:53 +0800 Subject: [PATCH 1/4] fix(chat): reconnect a Turn stream that stops delivering bytes The Chat service sends an SSE heartbeat every 15 seconds, but the reader never checked for it. When the connection stalled without closing, the reader blocked in reader.read() forever and the pending reply kept showing its last phase with no sign that nothing was arriving. Each attempt now aborts after 45 seconds without any byte (three missed heartbeats) and resumes from its cursor through the existing bounded retry. While it reconnects the pending reply shows a local phase that never moves the cursor. A caller abort still ends the stream without a retry. Signed-off-by: song --- apps/presentation/dashboard/src/data/chat.ts | 31 +++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 66b81c6c1..13902a4ea 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -866,11 +866,18 @@ function parseSseBlock(block: string): ChatStreamEvent | null { } } +// The Chat service sends an SSE heartbeat every 15 seconds while a Turn runs. +// Three missed heartbeats mean the connection is stuck rather than slow, so the +// reader reconnects from its cursor instead of waiting on a silent socket. +export const CHAT_STREAM_STALL_TIMEOUT_MS = 45_000; + export async function streamChatTurn( eventsUrl: string, onEvent: (event: ChatStreamEvent) => void, signal?: AbortSignal, + options: { stallTimeoutMs?: number } = {}, ) { + const stallTimeoutMs = options.stallTimeoutMs ?? CHAT_STREAM_STALL_TIMEOUT_MS; let cursor = ""; let attempts = 0; let terminal = false; @@ -878,11 +885,20 @@ export async function streamChatTurn( const origin = typeof window === "undefined" ? "http://127.0.0.1" : window.location.origin; const url = new URL(chatApiUrl(eventsUrl), origin); if (cursor) url.searchParams.set("after", cursor); + const attempt = new AbortController(); + const abortAttempt = () => attempt.abort(); + signal?.addEventListener("abort", abortAttempt, { once: true }); + let stallTimer: ReturnType | undefined; + const armStallTimer = () => { + if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer); + stallTimer = globalThis.setTimeout(abortAttempt, stallTimeoutMs); + }; try { + armStallTimer(); const response = await fetch(url, { cache: "no-store", headers: { Accept: "text/event-stream" }, - signal, + signal: attempt.signal, }); if (!response.ok || !response.body) { throw new ChatApiError(`SSE HTTP ${response.status}`, { status: response.status }); @@ -891,6 +907,7 @@ export async function streamChatTurn( const decoder = new TextDecoder(); let buffer = ""; while (true) { + armStallTimer(); const { done, value } = await reader.read(); buffer += decoder.decode(value, { stream: !done }).replaceAll("\r\n", "\n"); let boundary = buffer.indexOf("\n\n"); @@ -912,7 +929,19 @@ export async function streamChatTurn( if (signal?.aborted) throw error; attempts += 1; if (attempts >= 4) throw error; + // A local phase keeps the pending reply honest while the reader resumes + // from its cursor. It carries no event id, so the cursor is unchanged. + onEvent({ + created_at: new Date().toISOString(), + event_id: "", + kind: "agent.phase", + payload: { label: "连接中断,正在重连…", method: "client/reconnect" }, + sequence: 0, + }); await new Promise((resolve) => globalThis.setTimeout(resolve, 250 * 2 ** (attempts - 1))); + } finally { + if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer); + signal?.removeEventListener("abort", abortAttempt); } } if (!terminal) { From b49e9e121ffa33f778b48c2ec4e9f33164e8f45d Mon Sep 17 00:00:00 2001 From: song Date: Mon, 28 Sep 2026 23:06:53 +0800 Subject: [PATCH 2/4] test(chat): cover a stalled Turn stream A stub stream sends one event and then goes silent. The reader must resume from that event's cursor, report the reconnect phase, complete the Turn, and still end immediately on a caller abort. Signed-off-by: song --- apps/presentation/dashboard/package.json | 1 + .../smoke/chat-stream-stall-smoke.ts | 54 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 apps/presentation/dashboard/smoke/chat-stream-stall-smoke.ts diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index f4c7348c9..7a9d0d438 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -20,6 +20,7 @@ "smoke:benchmark-study-browser": "node ../../../examples/dashboard-benchmark-study-browser-smoke.mjs", "smoke:capability-configuration": "rm -rf node_modules/.cache/loopx-capability-configuration-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --types node --skipLibCheck --strict --outDir node_modules/.cache/loopx-capability-configuration-smoke smoke/capability-configuration-smoke.ts src/data/capability-configuration.ts && node node_modules/.cache/loopx-capability-configuration-smoke/smoke/capability-configuration-smoke.js", "smoke:chat-route": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir node_modules/.cache/loopx-chat-route-smoke smoke/chat-route-smoke.ts src/data/chat-model.ts src/vite-env.d.ts && node node_modules/.cache/loopx-chat-route-smoke/smoke/chat-route-smoke.js", + "smoke:chat-stream-stall": "vite build --ssr smoke/chat-stream-stall-smoke.ts --outDir node_modules/.cache/loopx-chat-stream-stall --emptyOutDir && node node_modules/.cache/loopx-chat-stream-stall/chat-stream-stall-smoke.js", "smoke:chat-turn-acceptance-retry": "vite build --ssr smoke/chat-turn-acceptance-retry-smoke.ts --outDir node_modules/.cache/loopx-chat-turn-acceptance-retry --emptyOutDir && node node_modules/.cache/loopx-chat-turn-acceptance-retry/chat-turn-acceptance-retry-smoke.js", "smoke:demo-readiness": "bash ../../../scripts/loopx-python.sh --exec ../../../examples/dashboard-demo-readiness-smoke.py", "smoke:frontstage-browser": "node ../../../examples/dashboard-frontstage-browser-smoke.mjs", diff --git a/apps/presentation/dashboard/smoke/chat-stream-stall-smoke.ts b/apps/presentation/dashboard/smoke/chat-stream-stall-smoke.ts new file mode 100644 index 000000000..150667907 --- /dev/null +++ b/apps/presentation/dashboard/smoke/chat-stream-stall-smoke.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; + +import { type ChatStreamEvent, streamChatTurn } from "../src/data/chat.ts"; + +// A stuck SSE connection delivers headers and a first event, then no bytes at +// all, not even the service heartbeat. The reader must abandon it, resume from +// its cursor, and tell the pending reply it is reconnecting. +const encoder = new TextEncoder(); +const originalFetch = globalThis.fetch; +const requests: string[] = []; + +function sseBlock(eventId: string, kind: string, payload: Record) { + return encoder.encode(`id: ${eventId}\nevent: ${kind}\ndata: ${JSON.stringify({ created_at: "2026-09-28T00:00:00Z", event_id: eventId, kind, payload, sequence: 1 })}\n\n`); +} + +function silentAfter(first: Uint8Array, signal: AbortSignal | null | undefined) { + return new ReadableStream({ + start(controller) { + controller.enqueue(first); + signal?.addEventListener("abort", () => controller.error(new DOMException("aborted", "AbortError")), { once: true }); + }, + }); +} + +globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url); + requests.push(url.search); + if (!url.searchParams.has("after")) { + return new Response(silentAfter(sseBlock("event-1", "turn.started", {}), init?.signal), { status: 200 }); + } + return new Response(sseBlock("event-2", "turn.completed", { response: { message: "done" } }), { status: 200 }); +}) as typeof fetch; + +try { + const events: ChatStreamEvent[] = []; + const started = Date.now(); + await streamChatTurn("/api/chat/sessions/s/turns/t/events", (event) => events.push(event), undefined, { stallTimeoutMs: 100 }); + assert.ok(Date.now() - started < 5_000, "a stalled stream is abandoned after the stall timeout"); + assert.deepEqual(requests, ["", "?after=event-1"], "the reader resumes from the last delivered event"); + assert.deepEqual(events.map((event) => event.kind), ["turn.started", "agent.phase", "turn.completed"]); + assert.equal(events[1].payload.method, "client/reconnect", "the pending reply learns it is reconnecting"); + assert.equal(events[1].event_id, "", "the local reconnect phase never moves the cursor"); + + // A caller abort still ends the stream immediately instead of reconnecting. + requests.length = 0; + const caller = new AbortController(); + const aborted = streamChatTurn("/api/chat/sessions/s/turns/t/events", () => caller.abort(), caller.signal, { stallTimeoutMs: 10_000 }); + await assert.rejects(aborted, (error: unknown) => error instanceof DOMException && error.name === "AbortError"); + assert.deepEqual(requests, [""], "a caller abort is not retried"); +} finally { + globalThis.fetch = originalFetch; +} + +console.log("chat-stream-stall-smoke: ok"); From 9f3e89e03e4c7a942fa3ae3d381640b2c9de72d2 Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:06:38 -0400 Subject: [PATCH 3/4] fix(chat): honor caller aborts and type stalled-stream exhaustion The stall watchdog gave each attempt its own AbortController, so an abort that happened before the call or during the retry backoff never reached the next attempt and a new GET still opened. Check the caller signal before every attempt, after every delivered event, and end the backoff wait early on abort. Four stalled connections ended with the watchdog's raw AbortError, which the resume wrapper does not recognize, so the same-Turn recovery metadata (reconnectable, session, turn, events URL) was lost. Exhausting on a stall now throws the same typed ChatApiError as other exhausted reconnects. The smoke covers aborts before the call, during backoff and during a read, and checks the resume wrapper's full error payload after four stalls. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: song <22676124+songoow@users.noreply.github.com> --- .../smoke/chat-stream-stall-smoke.ts | 88 ++++++++++++++++--- apps/presentation/dashboard/src/data/chat.ts | 36 +++++++- 2 files changed, 108 insertions(+), 16 deletions(-) diff --git a/apps/presentation/dashboard/smoke/chat-stream-stall-smoke.ts b/apps/presentation/dashboard/smoke/chat-stream-stall-smoke.ts index 150667907..0213b2e04 100644 --- a/apps/presentation/dashboard/smoke/chat-stream-stall-smoke.ts +++ b/apps/presentation/dashboard/smoke/chat-stream-stall-smoke.ts @@ -1,54 +1,116 @@ import assert from "node:assert/strict"; -import { type ChatStreamEvent, streamChatTurn } from "../src/data/chat.ts"; +import { + CHAT_STREAM_STALL_TIMEOUT_MS, + ChatApiError, + type ChatStreamEvent, + resumeChatTurnStreaming, + streamChatTurn, +} from "../src/data/chat.ts"; // A stuck SSE connection delivers headers and a first event, then no bytes at // all, not even the service heartbeat. The reader must abandon it, resume from -// its cursor, and tell the pending reply it is reconnecting. +// its cursor, and tell the pending reply it is reconnecting. A caller abort is +// different: it ends transport activity at any point and is never retried. const encoder = new TextEncoder(); const originalFetch = globalThis.fetch; +const originalSetTimeout = globalThis.setTimeout; +const eventsUrl = "/api/chat/sessions/s/turns/t/events"; const requests: string[] = []; +let serve: (url: URL, signal: AbortSignal | null | undefined) => Response; function sseBlock(eventId: string, kind: string, payload: Record) { return encoder.encode(`id: ${eventId}\nevent: ${kind}\ndata: ${JSON.stringify({ created_at: "2026-09-28T00:00:00Z", event_id: eventId, kind, payload, sequence: 1 })}\n\n`); } -function silentAfter(first: Uint8Array, signal: AbortSignal | null | undefined) { +function silentAfter(first: Uint8Array | null, signal: AbortSignal | null | undefined) { return new ReadableStream({ start(controller) { - controller.enqueue(first); + if (first) controller.enqueue(first); signal?.addEventListener("abort", () => controller.error(new DOMException("aborted", "AbortError")), { once: true }); }, }); } +function isAbort(error: unknown) { + return error instanceof DOMException && error.name === "AbortError"; +} + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url); requests.push(url.search); - if (!url.searchParams.has("after")) { - return new Response(silentAfter(sseBlock("event-1", "turn.started", {}), init?.signal), { status: 200 }); - } - return new Response(sseBlock("event-2", "turn.completed", { response: { message: "done" } }), { status: 200 }); + return serve(url, init?.signal); }) as typeof fetch; try { + // A stalled stream resumes from its cursor. + serve = (url, signal) => url.searchParams.has("after") + ? new Response(sseBlock("event-2", "turn.completed", { response: { message: "done" } }), { status: 200 }) + : new Response(silentAfter(sseBlock("event-1", "turn.started", {}), signal), { status: 200 }); const events: ChatStreamEvent[] = []; const started = Date.now(); - await streamChatTurn("/api/chat/sessions/s/turns/t/events", (event) => events.push(event), undefined, { stallTimeoutMs: 100 }); + await streamChatTurn(eventsUrl, (event) => events.push(event), undefined, { stallTimeoutMs: 100 }); assert.ok(Date.now() - started < 5_000, "a stalled stream is abandoned after the stall timeout"); assert.deepEqual(requests, ["", "?after=event-1"], "the reader resumes from the last delivered event"); assert.deepEqual(events.map((event) => event.kind), ["turn.started", "agent.phase", "turn.completed"]); assert.equal(events[1].payload.method, "client/reconnect", "the pending reply learns it is reconnecting"); assert.equal(events[1].event_id, "", "the local reconnect phase never moves the cursor"); - // A caller abort still ends the stream immediately instead of reconnecting. + // A caller abort from inside a delivered event ends the stream immediately. requests.length = 0; - const caller = new AbortController(); - const aborted = streamChatTurn("/api/chat/sessions/s/turns/t/events", () => caller.abort(), caller.signal, { stallTimeoutMs: 10_000 }); - await assert.rejects(aborted, (error: unknown) => error instanceof DOMException && error.name === "AbortError"); + const inEvent = new AbortController(); + await assert.rejects(streamChatTurn(eventsUrl, () => inEvent.abort(), inEvent.signal, { stallTimeoutMs: 10_000 }), isAbort); assert.deepEqual(requests, [""], "a caller abort is not retried"); + + // An abort that happened before the call opens no connection. + requests.length = 0; + const before = new AbortController(); + before.abort(); + const beforeEvents: ChatStreamEvent[] = []; + await assert.rejects(streamChatTurn(eventsUrl, (event) => beforeEvents.push(event), before.signal), isAbort); + assert.deepEqual(requests, [], "an already aborted caller opens no connection"); + assert.deepEqual(beforeEvents, []); + + // An abort during the retry backoff ends the wait and opens no new connection. + requests.length = 0; + serve = () => new Response("unavailable", { status: 503 }); + const backoff = new AbortController(); + const backoffEvents: ChatStreamEvent[] = []; + const backoffStarted = Date.now(); + const backoffRun = streamChatTurn(eventsUrl, (event) => backoffEvents.push(event), backoff.signal); + originalSetTimeout(() => backoff.abort(), 30); + await assert.rejects(backoffRun, isAbort); + assert.ok(Date.now() - backoffStarted < 250, "the backoff wait ends when the caller aborts"); + assert.deepEqual(requests, [""], "no connection opens after the caller aborts during backoff"); + assert.deepEqual(backoffEvents.map((event) => event.payload.method), ["client/reconnect"]); + + // An abort while a read waits ends that read without reconnecting. + requests.length = 0; + serve = (_url, signal) => new Response(silentAfter(null, signal), { status: 200 }); + const reading = new AbortController(); + const readingRun = streamChatTurn(eventsUrl, () => {}, reading.signal, { stallTimeoutMs: 10_000 }); + originalSetTimeout(() => reading.abort(), 30); + await assert.rejects(readingRun, isAbort); + assert.deepEqual(requests, [""], "an abort during a read is not retried"); + + // Four stalled connections end with the resume wrapper's typed recovery + // error, like any other exhausted reconnect. Only the watchdog clock is + // shortened; the retry backoff keeps its production delays. + requests.length = 0; + globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => + originalSetTimeout(handler, timeout === CHAT_STREAM_STALL_TIMEOUT_MS ? 50 : timeout, ...args)) as typeof setTimeout; + const exhausted = await resumeChatTurnStreaming("s", "t").then(() => null, (error: unknown) => error); + globalThis.setTimeout = originalSetTimeout; + assert.equal(requests.length, 4, "the watchdog reconnects a bounded number of times"); + assert.ok(exhausted instanceof ChatApiError, "stall exhaustion is a typed Chat error, not a raw abort"); + assert.equal(exhausted.payload.reconnectable, true); + assert.equal(exhausted.payload.session_id, "s"); + assert.equal(exhausted.payload.turn_id, "t"); + assert.equal(exhausted.payload.events_url, eventsUrl); + assert.equal(exhausted.payload.reconnect_attempts, 4); } finally { globalThis.fetch = originalFetch; + globalThis.setTimeout = originalSetTimeout; } console.log("chat-stream-stall-smoke: ok"); diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 13902a4ea..56409433b 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -871,6 +871,20 @@ function parseSseBlock(block: string): ChatStreamEvent | null { // reader reconnects from its cursor instead of waiting on a silent socket. export const CHAT_STREAM_STALL_TIMEOUT_MS = 45_000; +// Resolves early when the caller aborts, so the next attempt sees the abort +// instead of opening a connection the caller no longer wants. +function waitForRetry(ms: number, signal?: AbortSignal) { + return new Promise((resolve) => { + const done = () => { + globalThis.clearTimeout(timer); + signal?.removeEventListener("abort", done); + resolve(); + }; + const timer = globalThis.setTimeout(done, ms); + signal?.addEventListener("abort", done, { once: true }); + }); +} + export async function streamChatTurn( eventsUrl: string, onEvent: (event: ChatStreamEvent) => void, @@ -882,16 +896,21 @@ export async function streamChatTurn( let attempts = 0; let terminal = false; while (!terminal && attempts < 4) { + signal?.throwIfAborted(); const origin = typeof window === "undefined" ? "http://127.0.0.1" : window.location.origin; const url = new URL(chatApiUrl(eventsUrl), origin); if (cursor) url.searchParams.set("after", cursor); const attempt = new AbortController(); const abortAttempt = () => attempt.abort(); signal?.addEventListener("abort", abortAttempt, { once: true }); + let stalled = false; let stallTimer: ReturnType | undefined; const armStallTimer = () => { if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer); - stallTimer = globalThis.setTimeout(abortAttempt, stallTimeoutMs); + stallTimer = globalThis.setTimeout(() => { + stalled = true; + attempt.abort(); + }, stallTimeoutMs); }; try { armStallTimer(); @@ -918,6 +937,7 @@ export async function streamChatTurn( if (event) { if (event.event_id) cursor = event.event_id; onEvent(event); + signal?.throwIfAborted(); terminal = ["turn.completed", "turn.interrupted", "turn.failed"].includes(event.kind); } boundary = buffer.indexOf("\n\n"); @@ -928,7 +948,17 @@ export async function streamChatTurn( } catch (error) { if (signal?.aborted) throw error; attempts += 1; - if (attempts >= 4) throw error; + if (attempts >= 4) { + // A stalled connection is a transport failure, not a caller abort, so + // it ends with the same typed error as any other exhausted reconnect. + if (stalled) { + throw new ChatApiError("Agent 事件流连接已断开。", { + reconnect_attempts: attempts, + stall_timeout_ms: stallTimeoutMs, + }); + } + throw error; + } // A local phase keeps the pending reply honest while the reader resumes // from its cursor. It carries no event id, so the cursor is unchanged. onEvent({ @@ -938,7 +968,7 @@ export async function streamChatTurn( payload: { label: "连接中断,正在重连…", method: "client/reconnect" }, sequence: 0, }); - await new Promise((resolve) => globalThis.setTimeout(resolve, 250 * 2 ** (attempts - 1))); + await waitForRetry(250 * 2 ** (attempts - 1), signal); } finally { if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer); signal?.removeEventListener("abort", abortAttempt); From 1995d6b87a3bb77b511913c7dab51e16046f8ca1 Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:21:00 -0400 Subject: [PATCH 4/4] fix(chat): skip the retry backoff after a callback abort A callback that aborted while handling the local reconnect phase did so before waitForRetry started listening, so the stream sat out the whole backoff (up to one second) before the next attempt threw. The wait now returns at once for an already aborted signal. The stall smoke aborts from the reconnect phase callback and requires the stream to reject without waiting and without a second request. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: song <22676124+songoow@users.noreply.github.com> --- .../dashboard/smoke/chat-stream-stall-smoke.ts | 15 +++++++++++++++ apps/presentation/dashboard/src/data/chat.ts | 3 +++ 2 files changed, 18 insertions(+) diff --git a/apps/presentation/dashboard/smoke/chat-stream-stall-smoke.ts b/apps/presentation/dashboard/smoke/chat-stream-stall-smoke.ts index 0213b2e04..1e944a996 100644 --- a/apps/presentation/dashboard/smoke/chat-stream-stall-smoke.ts +++ b/apps/presentation/dashboard/smoke/chat-stream-stall-smoke.ts @@ -84,6 +84,21 @@ try { assert.deepEqual(requests, [""], "no connection opens after the caller aborts during backoff"); assert.deepEqual(backoffEvents.map((event) => event.payload.method), ["client/reconnect"]); + // An abort from the reconnect phase's own callback ends the stream without + // sitting out the backoff. + requests.length = 0; + serve = () => new Response("unavailable", { status: 503 }); + const onPhase = new AbortController(); + let phaseAt = 0; + const onPhaseRun = streamChatTurn(eventsUrl, (event) => { + if (event.payload.method !== "client/reconnect") return; + phaseAt = Date.now(); + onPhase.abort(); + }, onPhase.signal); + await assert.rejects(onPhaseRun, isAbort); + assert.ok(phaseAt > 0 && Date.now() - phaseAt < 200, "an abort from the reconnect callback skips the backoff"); + assert.deepEqual(requests, [""], "no connection opens after the reconnect callback aborts"); + // An abort while a read waits ends that read without reconnecting. requests.length = 0; serve = (_url, signal) => new Response(silentAfter(null, signal), { status: 200 }); diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 56409433b..1a7768267 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -874,6 +874,9 @@ export const CHAT_STREAM_STALL_TIMEOUT_MS = 45_000; // Resolves early when the caller aborts, so the next attempt sees the abort // instead of opening a connection the caller no longer wants. function waitForRetry(ms: number, signal?: AbortSignal) { + // A callback may abort while handling the reconnect phase, before this wait + // starts listening; that abort must not sit out the backoff. + if (signal?.aborted) return Promise.resolve(); return new Promise((resolve) => { const done = () => { globalThis.clearTimeout(timer);