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...] * Test with: npx tsx src/cli-new.ts [args...]
*/ */
import * as undici from "undici";
import { APP_NAME } from "./config.ts"; import { APP_NAME } from "./config.ts";
import { configureHttpDispatcher } from "./core/http-dispatcher.ts";
import { main } from "./main.ts"; import { main } from "./main.ts";
process.title = APP_NAME; process.title = APP_NAME;
process.env.PI_CODING_AGENT = "true"; process.env.PI_CODING_AGENT = "true";
process.emitWarning = (() => {}) as typeof process.emitWarning; process.emitWarning = (() => {}) as typeof process.emitWarning;
// bodyTimeout/headersTimeout default to 300s in undici; long local-LLM stalls // Configure undici's global dispatcher before provider SDKs issue requests.
// (e.g. vLLM buffering a large tool call) exceed that and abort the SSE stream // Runtime settings are applied once SettingsManager has loaded global/project settings.
// with UND_ERR_BODY_TIMEOUT. Disable both — provider SDKs enforce their own configureHttpDispatcher();
// 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?.();
main(process.argv.slice(2)); 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 { dirname, join } from "path";
import lockfile from "proper-lockfile"; import lockfile from "proper-lockfile";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts";
import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts";
export interface CompactionSettings { export interface CompactionSettings {
enabled?: boolean; // default: true enabled?: boolean; // default: true
@@ -110,6 +111,7 @@ export interface Settings {
markdown?: MarkdownSettings; markdown?: MarkdownSettings;
warnings?: WarningSettings; warnings?: WarningSettings;
sessionDir?: string; // Custom session storage directory (same format as --session-dir CLI flag) 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 */ /** 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 } { getProviderRetrySettings(): { timeoutMs?: number; maxRetries?: number; maxRetryDelayMs: number } {
return { return {
timeoutMs: this.settings.retry?.provider?.timeoutMs, 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 { AuthStorage } from "./core/auth-storage.ts";
import { exportFromFile } from "./core/export-html/index.ts"; import { exportFromFile } from "./core/export-html/index.ts";
import type { ExtensionFactory } from "./core/extensions/types.ts"; import type { ExtensionFactory } from "./core/extensions/types.ts";
import { configureHttpDispatcher } from "./core/http-dispatcher.ts";
import { KeybindingsManager } from "./core/keybindings.ts"; import { KeybindingsManager } from "./core/keybindings.ts";
import type { ModelRegistry } from "./core/model-registry.ts"; import type { ModelRegistry } from "./core/model-registry.ts";
import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.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 { services, session, modelFallbackMessage } = runtime;
const { settingsManager, modelRegistry, resourceLoader } = services; const { settingsManager, modelRegistry, resourceLoader } = services;
configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs());
if (parsed.help) { if (parsed.help) {
const extensionFlags = resourceLoader const extensionFlags = resourceLoader

View File

@@ -11,6 +11,7 @@ import {
Spacer, Spacer,
Text, Text,
} from "@earendil-works/pi-tui"; } 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 type { WarningSettings } from "../../../core/settings-manager.ts";
import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.ts"; import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts"; import { DynamicBorder } from "./dynamic-border.ts";
@@ -40,6 +41,7 @@ export interface SettingsConfig {
steeringMode: "all" | "one-at-a-time"; steeringMode: "all" | "one-at-a-time";
followUpMode: "all" | "one-at-a-time"; followUpMode: "all" | "one-at-a-time";
transport: Transport; transport: Transport;
httpIdleTimeoutMs: number;
thinkingLevel: ThinkingLevel; thinkingLevel: ThinkingLevel;
availableThinkingLevels: ThinkingLevel[]; availableThinkingLevels: ThinkingLevel[];
currentTheme: string; currentTheme: string;
@@ -68,6 +70,7 @@ export interface SettingsCallbacks {
onSteeringModeChange: (mode: "all" | "one-at-a-time") => void; onSteeringModeChange: (mode: "all" | "one-at-a-time") => void;
onFollowUpModeChange: (mode: "all" | "one-at-a-time") => void; onFollowUpModeChange: (mode: "all" | "one-at-a-time") => void;
onTransportChange: (transport: Transport) => void; onTransportChange: (transport: Transport) => void;
onHttpIdleTimeoutMsChange: (timeoutMs: number) => void;
onThinkingLevelChange: (level: ThinkingLevel) => void; onThinkingLevelChange: (level: ThinkingLevel) => void;
onThemeChange: (theme: string) => void; onThemeChange: (theme: string) => void;
onThemePreview?: (theme: string) => void; onThemePreview?: (theme: string) => void;
@@ -238,6 +241,14 @@ export class SettingsSelectorComponent extends Container {
currentValue: config.transport, currentValue: config.transport,
values: ["sse", "websocket", "websocket-cached", "auto"], 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", id: "hide-thinking",
label: "Hide thinking", label: "Hide thinking",
@@ -482,6 +493,13 @@ export class SettingsSelectorComponent extends Container {
case "transport": case "transport":
callbacks.onTransportChange(newValue as Transport); callbacks.onTransportChange(newValue as Transport);
break; 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": case "hide-thinking":
callbacks.onHideThinkingBlockChange(newValue === "true"); callbacks.onHideThinkingBlockChange(newValue === "true");
break; break;

View File

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

View File

@@ -2,7 +2,8 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import { homedir } from "os"; import { homedir } from "os";
import { join } from "path"; import { join } from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; 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", () => { describe("SettingsManager", () => {
const testDir = join(process.cwd(), "test-settings-tmp"); 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", () => { describe("shellCommandPrefix", () => {
it("should load shellCommandPrefix from settings", () => { it("should load shellCommandPrefix from settings", () => {
const settingsPath = join(agentDir, "settings.json"); const settingsPath = join(agentDir, "settings.json");