From a3aa13df9b62aa25732b33d12102642346fc1f9d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 24 Apr 2026 00:34:47 +0100 Subject: [PATCH] fix: prevent embedded runs from lowering undici timeouts --- CHANGELOG.md | 1 + .../run/attempt-http-runtime.ts | 5 ++++- .../attempt.spawn-workspace.timeout.test.ts | 17 ++++++++++++++-- .../net/undici-global-dispatcher.test.ts | 20 +++++++++++++++++++ src/infra/net/undici-global-dispatcher.ts | 2 +- 5 files changed, 41 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 808149cf885..1fb8c037a31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/transport: stop embedded runs from lowering the process-wide undici stream timeouts, so slow Gemini image generation and other long-running provider requests no longer inherit short run-attempt headers timeouts. Fixes #70423. Thanks @giangthb. - Memory/QMD: recreate stale managed QMD collections when startup repair finds the collection name already exists, so root memory narrows back to `MEMORY.md` instead of staying on broad workspace markdown indexing. - Agents/OpenAI: surface selected-model capacity failures from PI, Codex, and auto-reply harness paths with a model-switch hint instead of the generic empty-response error. Thanks @vincentkoc. - Providers/OpenAI: route `openai/gpt-image-2` through configured Codex OAuth directly when an `openai-codex` profile is active, instead of probing `OPENAI_API_KEY` first. diff --git a/src/agents/pi-embedded-runner/run/attempt-http-runtime.ts b/src/agents/pi-embedded-runner/run/attempt-http-runtime.ts index ad2d2f38b06..95453742a87 100644 --- a/src/agents/pi-embedded-runner/run/attempt-http-runtime.ts +++ b/src/agents/pi-embedded-runner/run/attempt-http-runtime.ts @@ -1,4 +1,5 @@ import { + DEFAULT_UNDICI_STREAM_TIMEOUT_MS, ensureGlobalUndiciEnvProxyDispatcher, ensureGlobalUndiciStreamTimeouts, } from "../../../infra/net/undici-global-dispatcher.js"; @@ -7,5 +8,7 @@ export function configureEmbeddedAttemptHttpRuntime(params: { timeoutMs: number // Proxy bootstrap must happen before timeout tuning so the timeouts wrap the // active EnvHttpProxyAgent instead of being replaced by a bare proxy dispatcher. ensureGlobalUndiciEnvProxyDispatcher(); - ensureGlobalUndiciStreamTimeouts({ timeoutMs: params.timeoutMs }); + ensureGlobalUndiciStreamTimeouts({ + timeoutMs: Math.max(params.timeoutMs, DEFAULT_UNDICI_STREAM_TIMEOUT_MS), + }); } diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.timeout.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.timeout.test.ts index 625b2f0603f..5db5b486423 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.timeout.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.timeout.test.ts @@ -1,11 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ + DEFAULT_UNDICI_STREAM_TIMEOUT_MS: 30 * 60 * 1000, ensureGlobalUndiciEnvProxyDispatcher: vi.fn(), ensureGlobalUndiciStreamTimeouts: vi.fn(), })); vi.mock("../../../infra/net/undici-global-dispatcher.js", () => ({ + DEFAULT_UNDICI_STREAM_TIMEOUT_MS: mocks.DEFAULT_UNDICI_STREAM_TIMEOUT_MS, ensureGlobalUndiciEnvProxyDispatcher: mocks.ensureGlobalUndiciEnvProxyDispatcher, ensureGlobalUndiciStreamTimeouts: mocks.ensureGlobalUndiciStreamTimeouts, })); @@ -18,12 +20,23 @@ describe("runEmbeddedAttempt undici timeout wiring", () => { mocks.ensureGlobalUndiciStreamTimeouts.mockReset(); }); - it("forwards the configured run timeout into global undici stream tuning", () => { + it("does not lower global undici stream tuning below the shared default", () => { configureEmbeddedAttemptHttpRuntime({ timeoutMs: 123_456 }); expect(mocks.ensureGlobalUndiciEnvProxyDispatcher).toHaveBeenCalledOnce(); expect(mocks.ensureGlobalUndiciStreamTimeouts).toHaveBeenCalledWith({ - timeoutMs: 123_456, + timeoutMs: mocks.DEFAULT_UNDICI_STREAM_TIMEOUT_MS, + }); + }); + + it("preserves run timeouts above the shared default", () => { + const timeoutMs = mocks.DEFAULT_UNDICI_STREAM_TIMEOUT_MS + 1_000; + + configureEmbeddedAttemptHttpRuntime({ timeoutMs }); + + expect(mocks.ensureGlobalUndiciEnvProxyDispatcher).toHaveBeenCalledOnce(); + expect(mocks.ensureGlobalUndiciStreamTimeouts).toHaveBeenCalledWith({ + timeoutMs, }); }); }); diff --git a/src/infra/net/undici-global-dispatcher.test.ts b/src/infra/net/undici-global-dispatcher.test.ts index fbdfc4fe978..0dcfc2f2f43 100644 --- a/src/infra/net/undici-global-dispatcher.test.ts +++ b/src/infra/net/undici-global-dispatcher.test.ts @@ -142,6 +142,26 @@ describe("ensureGlobalUndiciStreamTimeouts", () => { expect(setGlobalDispatcher).toHaveBeenCalledTimes(1); }); + it("does not lower global stream timeouts below the default floor", () => { + ensureGlobalUndiciStreamTimeouts({ timeoutMs: 15_000 }); + + expect(setGlobalDispatcher).toHaveBeenCalledTimes(1); + const next = getCurrentDispatcher() as { options?: Record }; + expect(next.options?.bodyTimeout).toBe(DEFAULT_UNDICI_STREAM_TIMEOUT_MS); + expect(next.options?.headersTimeout).toBe(DEFAULT_UNDICI_STREAM_TIMEOUT_MS); + }); + + it("honors explicit global stream timeouts above the default floor", () => { + const timeoutMs = DEFAULT_UNDICI_STREAM_TIMEOUT_MS + 1_000; + + ensureGlobalUndiciStreamTimeouts({ timeoutMs }); + + expect(setGlobalDispatcher).toHaveBeenCalledTimes(1); + const next = getCurrentDispatcher() as { options?: Record }; + expect(next.options?.bodyTimeout).toBe(timeoutMs); + expect(next.options?.headersTimeout).toBe(timeoutMs); + }); + it("re-applies when autoSelectFamily decision changes", () => { getDefaultAutoSelectFamily.mockReturnValue(true); ensureGlobalUndiciStreamTimeouts(); diff --git a/src/infra/net/undici-global-dispatcher.ts b/src/infra/net/undici-global-dispatcher.ts index a5c7d76a5f0..6eac302e968 100644 --- a/src/infra/net/undici-global-dispatcher.ts +++ b/src/infra/net/undici-global-dispatcher.ts @@ -110,10 +110,10 @@ export function ensureGlobalUndiciEnvProxyDispatcher(): void { export function ensureGlobalUndiciStreamTimeouts(opts?: { timeoutMs?: number }): void { const timeoutMsRaw = opts?.timeoutMs ?? DEFAULT_UNDICI_STREAM_TIMEOUT_MS; - const timeoutMs = Math.max(1, Math.floor(timeoutMsRaw)); if (!Number.isFinite(timeoutMsRaw)) { return; } + const timeoutMs = Math.max(DEFAULT_UNDICI_STREAM_TIMEOUT_MS, Math.floor(timeoutMsRaw)); const kind = resolveCurrentDispatcherKind(); if (kind === null) { return;