diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2b67e030a..f501a3492 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -7,6 +7,7 @@ - Fixed `glob` rejecting safe `memory://root//**` patterns. Memory globs now resolve their directory prefix inside the project memory root while rejecting traversal and percent-encoded path separators across the complete glob path. - Fixed `omp --resume ` prompting to fork sessions from another existing directory instead of switching the process and cwd-scoped settings into the resumed session's recorded directory ([#6752](https://github.com/can1357/oh-my-pi/issues/6752)). - Fixed deferred CLI model roles resolving ambiguous bare model IDs to a preferred but unauthenticated provider instead of the authenticated provider selected by the eager path ([#6727](https://github.com/can1357/oh-my-pi/issues/6727)). +- Fixed Windows sessions crashing with an unhandled `EPIPE: broken pipe, write` when an LSP server closed its stdin between filesystem mutations; LSP writes now observe asynchronous `FileSink.write()` failures and route them through the existing request/notification failure path. ## [17.1.4] - 2026-07-26 diff --git a/packages/coding-agent/src/lsp/client.ts b/packages/coding-agent/src/lsp/client.ts index c02e73cd6..cc7439b57 100644 --- a/packages/coding-agent/src/lsp/client.ts +++ b/packages/coding-agent/src/lsp/client.ts @@ -200,10 +200,10 @@ function abortReason(signal: AbortSignal): Error { return signal.reason instanceof Error ? signal.reason : new ToolAbortError(); } -class LspFlushAbortError extends Error { +class LspDrainAbortError extends Error { constructor(readonly reason: Error) { super(reason.message); - this.name = "LspFlushAbortError"; + this.name = "LspDrainAbortError"; } } @@ -216,26 +216,30 @@ async function writeMessage( throw abortReason(signal); } const content = JSON.stringify(message); - sink.write(`Content-Length: ${Buffer.byteLength(content, "utf-8")}\r\n\r\n${content}`); - const flush = Promise.resolve(sink.flush()); + const write = Promise.resolve( + sink.write(`Content-Length: ${Buffer.byteLength(content, "utf-8")}\r\n\r\n${content}`), + ); + // Attach before flush(): it may throw synchronously after write() returned a + // rejected Promise, and leaving that rejection unobserved kills the host. + void write.catch(() => {}); + const drain = Promise.all([write, Promise.resolve(sink.flush())]).then(() => {}); if (!signal) { - await flush; + await drain; return; } - // The sink's flush blocks on the OS-level pipe drain: if the server is - // alive but stopped reading stdin, `await sink.flush()` never resolves. - // Race the flush against the caller's signal so a wedged server surfaces - // as the tool's normal timeout/cancel instead of a permanent hang. + // Either sink operation can block on the OS-level pipe drain when a live + // server stops reading stdin. Race the combined drain against the caller's + // signal so a wedged server surfaces as the tool's normal timeout/cancel. const { promise, resolve, reject } = Promise.withResolvers(); const onAbort = () => { signal.removeEventListener("abort", onAbort); - // The underlying flush stays pending in the background; suppress its + // The underlying drain stays pending in the background; suppress its // eventual settlement so we do not surface an unhandled rejection. - flush.catch(() => {}); - reject(new LspFlushAbortError(abortReason(signal))); + drain.catch(() => {}); + reject(new LspDrainAbortError(abortReason(signal))); }; signal.addEventListener("abort", onAbort, { once: true }); - flush.then( + drain.then( () => { signal.removeEventListener("abort", onAbort); resolve(); @@ -249,8 +253,8 @@ async function writeMessage( } /** - * Kill a client whose write queue is stuck (aborted flush left the sink's - * flush promise pending, so subsequent writes queue behind a wedge forever). + * Kill a client whose write queue is stuck (an aborted drain left a sink + * operation pending, so subsequent writes queue behind the wedge forever). * Remove it from `clients` immediately so concurrent `getOrCreateClient` * callers do not grab the corpse before `proc.exited` cleans up. */ @@ -270,8 +274,8 @@ function queueWriteMessage( ): Promise { const write = client.writeQueue.catch(() => {}).then(() => writeMessage(client.proc.stdin, message, signal)); const result = write.catch((err: unknown) => { - if (err instanceof LspFlushAbortError) { - // Only an abort that raced this write's in-flight flush leaves + if (err instanceof LspDrainAbortError) { + // Only an abort that raced this write's in-flight drain leaves // the sink pending. Pre-write aborts and queued caller timeouts // must not kill a healthy shared client. teardownWedgedClient(client); @@ -1067,7 +1071,7 @@ const WATCHED_FILES_NOTIFY_TIMEOUT_MS = 2_000; * This covers sibling files that are not open text documents, such as generated * CSS modules or type files that another edited document imports immediately. * - * The underlying stdin flush is self-bounded by + * The underlying stdin write drain is self-bounded by * {@link WATCHED_FILES_NOTIFY_TIMEOUT_MS}; only an abort of the caller's * `signal` rejects. */ diff --git a/packages/coding-agent/test/tools/lsp-regressions.test.ts b/packages/coding-agent/test/tools/lsp-regressions.test.ts index 0b562f9ed..4272a8e86 100644 --- a/packages/coding-agent/test/tools/lsp-regressions.test.ts +++ b/packages/coding-agent/test/tools/lsp-regressions.test.ts @@ -3079,6 +3079,48 @@ describe("lsp regressions", () => { expect(writes).toHaveLength(1); }); + it("surfaces an asynchronous stdin write rejection instead of resolving the notification", async () => { + const epipe = Object.assign(new Error("EPIPE: broken pipe, write"), { + code: "EPIPE", + syscall: "write", + }); + const client: LspClient = { + name: "fake-lsp-write-epipe:/tmp", + cwd: "/tmp", + config: { command: "fake-lsp-write-epipe", fileTypes: ["ts"], rootMarkers: [] }, + proc: { + exited: Promise.withResolvers().promise, + exitCode: null, + stdin: { + write: () => Promise.reject(epipe), + flush: () => 0, + }, + stdout: new ReadableStream(), + peekStderr: () => "", + kill() {}, + } as unknown as LspClient["proc"], + requestId: 0, + diagnostics: new Map(), + diagnosticsVersion: 0, + openFiles: new Map(), + pendingRequests: new Map(), + messageBuffer: new Uint8Array(0), + isReading: false, + status: "ready", + lastActivity: Date.now(), + writeQueue: Promise.resolve(), + activeProgressTokens: new Set(), + projectLoaded: Promise.resolve(), + resolveProjectLoaded: () => {}, + }; + + await expect( + lspClient.sendNotification(client, "workspace/didChangeWatchedFiles", { + changes: [{ uri: "file:///tmp/example.ts", type: 2 }], + }), + ).rejects.toBe(epipe); + }); + it("bounds a wedged notification flush on the caller signal and tears down the client", async () => { // Custom fake: stdin.flush is gated by a controllable promise so we // can simulate a server that stopped draining stdin AFTER init has