fix: propagate stream timeout abort reason (#76633)

This commit is contained in:
Peter Steinberger
2026-05-03 12:56:37 +01:00
parent ffd3dfa4f5
commit 4fff25438c
4 changed files with 51 additions and 5 deletions

View File

@@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai
- Plugins/tools: keep disabled bundled tool plugins out of explicit runtime allowlist ownership and fall back from loaded-but-empty channel registries to tool-bearing plugin registries, so Active Memory can use bundled `memory-core` search/get tools even when `memory-lancedb` is disabled. Fixes #76603. Thanks @jwong-art.
- Plugins/install: run `npm install` from the managed npm-root manifest so installing one `@openclaw/*` plugin preserves already installed sibling plugins instead of pruning them. Fixes #76571. (#76602) Thanks @byungskers and @crpol.
- Channels/QQ Bot: resolve structured `clientSecret` SecretRefs before QQ token exchange, expose the QQ Bot secret contract to secrets tooling, and reject legacy `secretref:/...` marker strings. (#74772) Thanks @xialonglee.
- Agents: keep active streamed provider replies alive by refreshing guarded fetch timeouts on raw body chunks and surface true prompt stream timeouts as explicit errors instead of partial assistant fragments. Fixes #76307. (#76633) Thanks @MkDev11.
- Plugins/externalization: keep official ACPX, Google Chat, and LINE install specs on production package names, leaving beta-tag probing to the explicit OpenClaw beta update channel. Thanks @vincentkoc.
- CLI/doctor: keep missing-plugin repair from overriding official catalog metadata with runtime fallbacks, so ACPX repairs preserve the official npm spec during the externalization rollout. Thanks @vincentkoc.
- CLI/doctor: match stale bundled-plugin install records by exact parsed package name so doctor does not remove external npm or ClawHub records that only share an OpenClaw package-name prefix.

View File

@@ -170,10 +170,9 @@ describe("handleAssistantFailover", () => {
it("leaves plain timeouts on the continue_normal path for the runner's timeout-payload synthesis", async () => {
// `run.ts` already emits an explicit timeout payload when
// `buildEmbeddedRunPayloads` produces no assistant content (see
// the `timedOut && !timedOutDuringCompaction &&
// !payloadsWithToolMedia.length` block). Throwing a FailoverError
// here would short-circuit that synthesis and break
// `buildEmbeddedRunPayloads` produces no assistant content or only a
// partial prompt-timeout fragment. Throwing a FailoverError here would
// short-circuit that synthesis and break
// timeout-compaction retry coverage in
// `run.timeout-triggered-compaction.test.ts`. The throw path is
// reserved for concrete provider failures that have no other

View File

@@ -1,3 +1,4 @@
import { Stream } from "openai/streaming";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const warn = vi.hoisted(() => vi.fn());
@@ -30,6 +31,10 @@ describe("buildTimeoutAbortSignal", () => {
await vi.advanceTimersByTimeAsync(25);
expect(signal?.aborted).toBe(true);
expect(signal?.reason).toMatchObject({
name: "TimeoutError",
message: "request timed out",
});
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
"fetch timeout reached; aborting operation",
@@ -45,6 +50,45 @@ describe("buildTimeoutAbortSignal", () => {
cleanup();
});
it("keeps timeout aborts visible to OpenAI SSE streams instead of cleanly ending", async () => {
const { signal, cleanup } = buildTimeoutAbortSignal({
timeoutMs: 25,
operation: "unit-test",
});
const encoder = new TextEncoder();
const response = new Response(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('data: {"ok": true}\n\n'));
signal?.addEventListener(
"abort",
() => controller.error(signal.reason ?? new Error("request timed out")),
{ once: true },
);
},
}),
{ headers: { "content-type": "text/event-stream" } },
);
const iterator = Stream.fromSSEResponse(response, new AbortController())[
Symbol.asyncIterator
]();
await expect(iterator.next()).resolves.toMatchObject({
done: false,
value: { ok: true },
});
const pending = iterator.next().catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(25);
await expect(pending).resolves.toMatchObject({
name: "TimeoutError",
message: "request timed out",
});
cleanup();
});
it("annotates timeout logs when the timer fires late", async () => {
vi.setSystemTime(0);
const { cleanup } = buildTimeoutAbortSignal({

View File

@@ -87,7 +87,9 @@ function abortDueToTimeout(
...(operation ? { operation } : {}),
...(sanitizedUrl ? { url: sanitizedUrl } : {}),
});
controller.abort();
const error = new Error("request timed out");
error.name = "TimeoutError";
controller.abort(error);
}
export function buildTimeoutAbortSignal(params: TimeoutAbortSignalParams): {