fix(coding-agent): configure HTTP idle timeout (#4759)

This commit is contained in:
Armin Ronacher
2026-05-20 12:45:00 +02:00
committed by GitHub
parent f1f7cd50c3
commit 849f9d9c5a
7 changed files with 136 additions and 12 deletions

View File

@@ -5,23 +5,16 @@
*
* Test with: npx tsx src/cli-new.ts [args...]
*/
import * as undici from "undici";
import { APP_NAME } from "./config.ts";
import { configureHttpDispatcher } from "./core/http-dispatcher.ts";
import { main } from "./main.ts";
process.title = APP_NAME;
process.env.PI_CODING_AGENT = "true";
process.emitWarning = (() => {}) as typeof process.emitWarning;
// bodyTimeout/headersTimeout default to 300s in undici; long local-LLM stalls
// (e.g. vLLM buffering a large tool call) exceed that and abort the SSE stream
// with UND_ERR_BODY_TIMEOUT. Disable both — provider SDKs enforce their own
// AbortController-based deadlines via retry.provider.timeoutMs.
undici.setGlobalDispatcher(new undici.EnvHttpProxyAgent({ allowH2: false, bodyTimeout: 0, headersTimeout: 0 }));
// Keep fetch and the dispatcher on the same undici implementation. Node 26.0's
// bundled fetch can otherwise consume compressed responses through npm undici's
// dispatcher without decompressing them, causing response.json() failures.
undici.install?.();
// Configure undici's global dispatcher before provider SDKs issue requests.
// Runtime settings are applied once SettingsManager has loaded global/project settings.
configureHttpDispatcher();
main(process.argv.slice(2));

View File

@@ -0,0 +1,55 @@
import * as undici from "undici";
export const DEFAULT_HTTP_IDLE_TIMEOUT_MS = 300_000;
export const HTTP_IDLE_TIMEOUT_CHOICES = [
{ label: "30 sec", timeoutMs: 30_000 },
{ label: "1 min", timeoutMs: 60_000 },
{ label: "2 min", timeoutMs: 120_000 },
{ label: "5 min", timeoutMs: 300_000 },
{ label: "disabled", timeoutMs: 0 },
] as const;
export function parseHttpIdleTimeoutMs(value: unknown): number | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.toLowerCase() === "disabled") {
return 0;
}
if (trimmed.length === 0) {
return undefined;
}
return parseHttpIdleTimeoutMs(Number(trimmed));
}
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
return undefined;
}
return Math.floor(value);
}
export function formatHttpIdleTimeoutMs(timeoutMs: number): string {
const choice = HTTP_IDLE_TIMEOUT_CHOICES.find((item) => item.timeoutMs === timeoutMs);
if (choice) {
return choice.label;
}
return `${timeoutMs / 1000} sec`;
}
export function configureHttpDispatcher(timeoutMs: number = DEFAULT_HTTP_IDLE_TIMEOUT_MS): void {
const normalizedTimeoutMs = parseHttpIdleTimeoutMs(timeoutMs);
if (normalizedTimeoutMs === undefined) {
throw new Error(`Invalid HTTP idle timeout: ${String(timeoutMs)}`);
}
undici.setGlobalDispatcher(
new undici.EnvHttpProxyAgent({
allowH2: false,
bodyTimeout: normalizedTimeoutMs,
headersTimeout: normalizedTimeoutMs,
}),
);
// Keep fetch and the dispatcher on the same undici implementation. Node 26.0's
// bundled fetch can otherwise consume compressed responses through npm undici's
// dispatcher without decompressing them, causing response.json() failures.
undici.install?.();
}

View File

@@ -4,6 +4,7 @@ import { homedir } from "os";
import { dirname, join } from "path";
import lockfile from "proper-lockfile";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts";
import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts";
export interface CompactionSettings {
enabled?: boolean; // default: true
@@ -110,6 +111,7 @@ export interface Settings {
markdown?: MarkdownSettings;
warnings?: WarningSettings;
sessionDir?: string; // Custom session storage directory (same format as --session-dir CLI flag)
httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it
}
/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
@@ -726,6 +728,27 @@ export class SettingsManager {
};
}
getHttpIdleTimeoutMs(): number {
const value = this.settings.httpIdleTimeoutMs;
const timeoutMs = parseHttpIdleTimeoutMs(value);
if (timeoutMs !== undefined) {
return timeoutMs;
}
if (value !== undefined) {
throw new Error(`Invalid httpIdleTimeoutMs setting: ${String(value)}`);
}
return DEFAULT_HTTP_IDLE_TIMEOUT_MS;
}
setHttpIdleTimeoutMs(timeoutMs: number): void {
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
throw new Error(`Invalid httpIdleTimeoutMs setting: ${String(timeoutMs)}`);
}
this.globalSettings.httpIdleTimeoutMs = Math.floor(timeoutMs);
this.markModified("httpIdleTimeoutMs");
this.save();
}
getProviderRetrySettings(): { timeoutMs?: number; maxRetries?: number; maxRetryDelayMs: number } {
return {
timeoutMs: this.settings.retry?.provider?.timeoutMs,

View File

@@ -26,6 +26,7 @@ import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts";
import { AuthStorage } from "./core/auth-storage.ts";
import { exportFromFile } from "./core/export-html/index.ts";
import type { ExtensionFactory } from "./core/extensions/types.ts";
import { configureHttpDispatcher } from "./core/http-dispatcher.ts";
import { KeybindingsManager } from "./core/keybindings.ts";
import type { ModelRegistry } from "./core/model-registry.ts";
import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.ts";
@@ -617,6 +618,7 @@ export async function main(args: string[], options?: MainOptions) {
});
const { services, session, modelFallbackMessage } = runtime;
const { settingsManager, modelRegistry, resourceLoader } = services;
configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs());
if (parsed.help) {
const extensionFlags = resourceLoader

View File

@@ -11,6 +11,7 @@ import {
Spacer,
Text,
} from "@earendil-works/pi-tui";
import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts";
import type { WarningSettings } from "../../../core/settings-manager.ts";
import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
@@ -40,6 +41,7 @@ export interface SettingsConfig {
steeringMode: "all" | "one-at-a-time";
followUpMode: "all" | "one-at-a-time";
transport: Transport;
httpIdleTimeoutMs: number;
thinkingLevel: ThinkingLevel;
availableThinkingLevels: ThinkingLevel[];
currentTheme: string;
@@ -68,6 +70,7 @@ export interface SettingsCallbacks {
onSteeringModeChange: (mode: "all" | "one-at-a-time") => void;
onFollowUpModeChange: (mode: "all" | "one-at-a-time") => void;
onTransportChange: (transport: Transport) => void;
onHttpIdleTimeoutMsChange: (timeoutMs: number) => void;
onThinkingLevelChange: (level: ThinkingLevel) => void;
onThemeChange: (theme: string) => void;
onThemePreview?: (theme: string) => void;
@@ -238,6 +241,14 @@ export class SettingsSelectorComponent extends Container {
currentValue: config.transport,
values: ["sse", "websocket", "websocket-cached", "auto"],
},
{
id: "http-idle-timeout",
label: "HTTP idle timeout",
description:
"Maximum idle gap while waiting for HTTP headers or body chunks. Disable for local models that pause longer than five minutes.",
currentValue: formatHttpIdleTimeoutMs(config.httpIdleTimeoutMs),
values: HTTP_IDLE_TIMEOUT_CHOICES.map((choice) => choice.label),
},
{
id: "hide-thinking",
label: "Hide thinking",
@@ -482,6 +493,13 @@ export class SettingsSelectorComponent extends Container {
case "transport":
callbacks.onTransportChange(newValue as Transport);
break;
case "http-idle-timeout": {
const choice = HTTP_IDLE_TIMEOUT_CHOICES.find((item) => item.label === newValue);
if (choice) {
callbacks.onHttpIdleTimeoutMsChange(choice.timeoutMs);
}
break;
}
case "hide-thinking":
callbacks.onHideThinkingBlockChange(newValue === "true");
break;

View File

@@ -71,6 +71,7 @@ import type {
ExtensionWidgetOptions,
} from "../../core/extensions/index.ts";
import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts";
import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts";
import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.ts";
import { createCompactionSummaryMessage } from "../../core/messages.ts";
import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.ts";
@@ -1566,6 +1567,7 @@ export class InteractiveMode {
}
private applyRuntimeSettings(): void {
configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs());
this.footer.setSession(this.session);
this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
this.footerDataProvider.setCwd(this.sessionManager.getCwd());
@@ -3846,6 +3848,7 @@ export class InteractiveMode {
steeringMode: this.session.steeringMode,
followUpMode: this.session.followUpMode,
transport: this.settingsManager.getTransport(),
httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(),
thinkingLevel: this.session.thinkingLevel,
availableThinkingLevels: this.session.getAvailableThinkingLevels(),
currentTheme: this.settingsManager.getTheme() || "dark",
@@ -3904,6 +3907,11 @@ export class InteractiveMode {
this.settingsManager.setTransport(transport);
this.session.agent.transport = transport;
},
onHttpIdleTimeoutMsChange: (timeoutMs) => {
this.settingsManager.setHttpIdleTimeoutMs(timeoutMs);
configureHttpDispatcher(timeoutMs);
this.showStatus(`HTTP idle timeout: ${formatHttpIdleTimeoutMs(timeoutMs)}`);
},
onThinkingLevelChange: (level) => {
this.session.setThinkingLevel(level);
this.footer.invalidate();
@@ -4891,6 +4899,7 @@ export class InteractiveMode {
try {
await this.session.reload();
configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs());
this.keybindings.reload();
const activeHeader = this.customHeader ?? this.builtInHeader;
if (isExpandable(activeHeader)) {

View File

@@ -2,7 +2,8 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import { homedir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { SettingsManager } from "../src/core/settings-manager.js";
import { DEFAULT_HTTP_IDLE_TIMEOUT_MS } from "../src/core/http-dispatcher.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
describe("SettingsManager", () => {
const testDir = join(process.cwd(), "test-settings-tmp");
@@ -257,6 +258,29 @@ describe("SettingsManager", () => {
});
});
describe("httpIdleTimeoutMs", () => {
it("should default to 5 minutes", () => {
const manager = SettingsManager.create(projectDir, agentDir);
expect(manager.getHttpIdleTimeoutMs()).toBe(DEFAULT_HTTP_IDLE_TIMEOUT_MS);
});
it("should use merged global and project settings", () => {
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ httpIdleTimeoutMs: 300000 }));
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ httpIdleTimeoutMs: 0 }));
const manager = SettingsManager.create(projectDir, agentDir);
expect(manager.getHttpIdleTimeoutMs()).toBe(0);
});
it("should reject invalid timeout values", () => {
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ httpIdleTimeoutMs: -1 }));
const manager = SettingsManager.create(projectDir, agentDir);
expect(() => manager.getHttpIdleTimeoutMs()).toThrow("Invalid httpIdleTimeoutMs setting");
});
});
describe("shellCommandPrefix", () => {
it("should load shellCommandPrefix from settings", () => {
const settingsPath = join(agentDir, "settings.json");