fix(coding-agent): handle undici mid-stream client errors

closes #6133
This commit is contained in:
Mario Zechner
2026-06-30 11:42:39 +02:00
parent 939c39ab84
commit 2117b61c6b
2 changed files with 35 additions and 1 deletions
+1
View File
@@ -8,6 +8,7 @@
### Fixed
- Fixed a crash when undici emits an internal client error while terminating a mid-stream HTTP response ([#6133](https://github.com/earendil-works/pi/issues/6133)).
- Fixed the compaction event regression test to cover status indicator cleanup and keep CI passing.
- Fixed interactive status indicators so ending work, retry, compaction, or branch-summary indicators no longer shrink the TUI when clear-on-shrink is enabled ([#6026](https://github.com/earendil-works/pi/pull/6026)).
- Fixed `--session` and `SessionManager.open()` to reject non-empty invalid session files without overwriting them ([#6002](https://github.com/earendil-works/pi/issues/6002)).
@@ -1,3 +1,4 @@
import { EventEmitter } from "node:events";
import * as undici from "undici";
export const DEFAULT_HTTP_IDLE_TIMEOUT_MS = 300_000;
@@ -46,18 +47,50 @@ export function applyHttpProxySettings(httpProxy: string | undefined): void {
process.env.HTTPS_PROXY ??= proxy;
}
const ignoreUndiciDispatcherError = (_error: unknown): void => {};
// Undici can emit an internal Client "error" while terminating a mid-stream
// fetch body. The body stream still rejects through reader.read(); this listener
// only prevents EventEmitter's unhandled "error" special case from crashing pi.
function withUndiciErrorListener<T extends undici.Dispatcher>(dispatcher: T): T {
if (dispatcher instanceof EventEmitter) {
EventEmitter.prototype.on.call(dispatcher, "error", ignoreUndiciDispatcherError);
}
return dispatcher;
}
function createUndiciClient(origin: string | URL, options: object): undici.Dispatcher {
return withUndiciErrorListener(new undici.Client(origin, options as undici.Client.Options));
}
function createUndiciOriginDispatcher(origin: string | URL, options: object): undici.Dispatcher {
const dispatcherOptions = options as undici.Pool.Options;
if (dispatcherOptions.connections === 1) {
return createUndiciClient(origin, dispatcherOptions);
}
return withUndiciErrorListener(
new undici.Pool(origin, {
...dispatcherOptions,
factory: createUndiciClient,
}),
);
}
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(
const dispatcher = withUndiciErrorListener(
new undici.EnvHttpProxyAgent({
allowH2: false,
bodyTimeout: normalizedTimeoutMs,
headersTimeout: normalizedTimeoutMs,
clientFactory: createUndiciClient,
factory: createUndiciOriginDispatcher,
}),
);
undici.setGlobalDispatcher(dispatcher);
// 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.