fix(coding-agent): configure HTTP idle timeout (#4759)
This commit is contained in:
@@ -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));
|
||||||
|
|||||||
55
packages/coding-agent/src/core/http-dispatcher.ts
Normal file
55
packages/coding-agent/src/core/http-dispatcher.ts
Normal 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?.();
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user