From abc22387dc49fb62231be2adb658bce5e4ce74ae Mon Sep 17 00:00:00 2001 From: Hussain Phalasiya Date: Mon, 5 Oct 2026 09:21:36 +0300 Subject: [PATCH 1/2] fix(pty): drain Linux master before premature stream EOF (VC-639) --- apps/desktop/THIRD-PARTY-NOTICES | 3 +- .../host-core/src/pty/manager.pty.test.ts | 35 +++ .../host-core/src/pty/node-pty-eof.test.ts | 285 ++++++++++++++++++ patches/node-pty@1.1.0.patch | 275 +++++++++++++++++ pnpm-lock.yaml | 9 +- pnpm-workspace.yaml | 4 + 6 files changed, 606 insertions(+), 5 deletions(-) create mode 100644 packages/host-core/src/pty/node-pty-eof.test.ts create mode 100644 patches/node-pty@1.1.0.patch diff --git a/apps/desktop/THIRD-PARTY-NOTICES b/apps/desktop/THIRD-PARTY-NOTICES index 2941d2072..cda45ccf7 100644 --- a/apps/desktop/THIRD-PARTY-NOTICES +++ b/apps/desktop/THIRD-PARTY-NOTICES @@ -12931,7 +12931,7 @@ SOFTWARE. ========================================================================================================= -8. MODIFIED PACKAGES (2) +8. MODIFIED PACKAGES (3) ========================================================================================================= These packages are modified before they are bundled, by the pnpm patches recorded @@ -12939,6 +12939,7 @@ in pnpm-workspace.yaml. The patch files are in the repository and state the chan @dnd-kit/core@6.3.1 — patches/@dnd-kit__core@6.3.1.patch @earendil-works/pi-codemode@1.0.0 — patches/@earendil-works__pi-codemode@1.0.0.patch + node-pty@1.1.0 — patches/node-pty@1.1.0.patch ========================================================================================================= 9. ADDITIONAL NOTICES (2) diff --git a/packages/host-core/src/pty/manager.pty.test.ts b/packages/host-core/src/pty/manager.pty.test.ts index 1a0e60b84..76be94cce 100644 --- a/packages/host-core/src/pty/manager.pty.test.ts +++ b/packages/host-core/src/pty/manager.pty.test.ts @@ -235,6 +235,41 @@ describe("terminal supervisor on node-pty under plain Node (VC-560)", () => { await until(() => ledger.exited.has(0), "the shell to exit after its window closed"); }); + it("delivers a large final output and UTF-8 tail before exit", async () => { + const owner = makeClient("owner"); + const sessionId = await start(owner); + const publish = owner.sink.publish.bind(owner.sink); + owner.sink.publish = (topic, payload) => { + publish(topic, payload); + if (topic === "terminal-data") { + const data = payload as TerminalDataEvent; + // Model a consuming client, after the pipeline has accounted its send. + queueMicrotask(() => manager.ack(owner.sink, sessionId, data.data.length)); + } + }; + manager.write( + owner.sink, + sessionId, + "printf '%0200000d' 0; printf '\\342\\230\\203-%s\\n' $((4+4)); exit 3\r", + ); + await until( + () => owner.events.some((event) => event.topic === "terminal-exit"), + "the exit event", + ); + const exitIndex = owner.events.findIndex((event) => event.topic === "terminal-exit"); + const output = owner.events + .slice(0, exitIndex) + .filter((event) => event.topic === "terminal-data") + .map((event) => (event.payload as TerminalDataEvent).data) + .join(""); + expect(output).toContain("0".repeat(200_000)); + expect(output).toContain("☃-8"); + expect(owner.events[exitIndex]?.payload).toEqual({ sessionId, exitCode: 3 }); + expect(owner.events.slice(exitIndex + 1).some((event) => event.topic === "terminal-data")).toBe( + false, + ); + }); + it("delivers the shell's exit to the attached client after its final output", async () => { const owner = makeClient("owner"); const sessionId = await start(owner); diff --git a/packages/host-core/src/pty/node-pty-eof.test.ts b/packages/host-core/src/pty/node-pty-eof.test.ts new file mode 100644 index 000000000..3bc63914a --- /dev/null +++ b/packages/host-core/src/pty/node-pty-eof.test.ts @@ -0,0 +1,285 @@ +import { readFileSync } from "node:fs"; +import * as fs from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { Readable } from "node:stream"; +import { runInNewContext } from "node:vm"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +// Exercise the installed patch, not a second implementation of it. Fake the +// kernel boundary: libuv reports EOF while read(2) still has the shell's tail. +// The constructor regression runs unchanged on macOS and Linux without loading +// the native addon. The real-shell contract is covered by manager.pty.test.ts. +const require = createRequire(import.meta.url); +const lib = dirname(require.resolve("node-pty")); +const packageRequire = createRequire(join(lib, "unixTerminal.js")); +const sockets: Readable[] = []; + +const errno = (code: string) => Object.assign(new Error(code), { code }); +function kernel(...chunks: Array) { + const readSync = vi.fn( + (_fd: number, buffer: Buffer, offset: number, length: number, _position: null) => { + const next = chunks.shift(); + if (next instanceof Error) throw next; + if (next === undefined) return 0; + const count = Math.min(next.length, length); + next.copy(buffer, offset, 0, count); + if (count < next.length) chunks.unshift(next.subarray(count)); + return count; + }, + ); + return { readSync }; +} + +function stream() { + const socket = new Readable({ read() {} }); + sockets.push(socket); + return socket; +} + +function evaluate(file: string, load: (id: string) => unknown): Record { + const exports: Record = {}; + runInNewContext(readFileSync(join(lib, file), "utf8"), { + exports, + require: load, + __dirname: lib, + Buffer, + Date, + process: { platform: "linux", env: {}, cwd: () => "/project" }, + setTimeout: (...args: Parameters) => setTimeout(...args), + clearTimeout: (timer: NodeJS.Timeout) => clearTimeout(timer), + setImmediate, + clearImmediate, + console, + }); + return exports; +} + +type Guard = (socket: Readable, fd: number) => () => void; +function guard(raw: ReturnType): Guard { + return evaluate("linuxPtyEof.js", (id) => { + if (id !== "fs") throw new Error(`unexpected dependency: ${id}`); + return raw; + }).guardLinuxPtyEof as Guard; +} + +const tick = () => new Promise((done) => setImmediate(done)); +afterEach(() => { + for (const socket of sockets.splice(0)) socket.destroy(); + vi.useRealTimers(); +}); + +describe("node-pty Linux EOF drain (VC-639)", () => { + it.each(["EOF first", "native exit first"])( + "delivers the shell's final bytes before exit after premature EOF (%s)", + async (order) => { + const socket = stream(); + const raw = kernel(Buffer.from("bye-8\r\n"), errno("EIO")); + let nativeExit: ((code: number, signal: number) => void) | undefined; + const patched = evaluate("unixTerminal.js", (id) => { + if (id === "fs") return { ...fs, ...raw }; + if (id === "tty") + return { + ReadStream: function () { + return socket; + }, + }; + if (id === "./linuxPtyEof") return { guardLinuxPtyEof: guard(raw) }; + if (id === "./utils") { + return { + assign: Object.assign, + loadNativeModule: () => ({ + dir: "/native", + module: { + fork: (...args: unknown[]) => { + nativeExit = args[10] as typeof nativeExit; + return { fd: 42, pid: 123, pty: "/dev/pts/1" }; + }, + }, + }), + }; + } + return packageRequire(id); + }); + const Pty = patched.UnixTerminal as new ( + file: string, + args: string[], + options: object, + ) => { + onData(listener: (data: string) => void): void; + onExit(listener: (event: { exitCode: number }) => void): void; + }; + const pty = new Pty("/bin/sh", [], { cwd: "/project", env: {} }); + const events: Array = []; + pty.onData((data) => events.push(data)); + const exited = new Promise((done) => { + pty.onExit(({ exitCode }) => { + events.push(exitCode); + done(); + }); + }); + socket.push(Buffer.from("echo bye-$((4+4)); exit 3\r\n")); + if (order === "native exit first") nativeExit?.(3, 0); + socket.push(null); // libuv's false EOF; the kernel tail is still readable. + if (order === "EOF first") nativeExit?.(3, 0); + await exited; + expect(events).toEqual(["echo bye-$((4+4)); exit 3\r\n", "bye-8\r\n", 3]); + expect(raw.readSync).toHaveBeenCalledWith(42, expect.any(Buffer), 0, 65536, null); + }, + ); + + it("preserves a UTF-8 character split across the last libuv read and the raw tail", async () => { + const socket = stream(); + socket.setEncoding("utf8"); + const emoji = Buffer.from("😀"); + const raw = kernel(emoji.subarray(2), errno("EIO")); + guard(raw)(socket, 42); + const data: string[] = []; + socket.on("data", (chunk: string) => data.push(chunk)); + socket.push(emoji.subarray(0, 2)); + socket.push(null); + await tick(); + expect(data.join("")).toBe("😀"); + }); + + it("copies raw chunks retained by a paused stream instead of reusing its read buffer", () => { + const socket = stream(); + const raw = kernel(Buffer.from("first"), Buffer.from("second")); + guard(raw)(socket, 42); + socket.push(null); + expect(socket.read()?.toString()).toBe("firstsecond"); + }); + + it.each(["EAGAIN", "EWOULDBLOCK"])( + "retries %s without flushing EOF or the decoder early", + async (code) => { + vi.useFakeTimers(); + const socket = stream(); + const raw = kernel(errno(code), Buffer.from("tail"), errno("EIO")); + guard(raw)(socket, 42); + const data: Buffer[] = []; + socket.on("data", (chunk: Buffer) => data.push(chunk)); + socket.push(null); + socket.push(null); + expect(raw.readSync).toHaveBeenCalledTimes(1); + expect(socket.readableEnded).toBe(false); + await vi.runAllTimersAsync(); + expect(Buffer.concat(data).toString()).toBe("tail"); + expect(raw.readSync).toHaveBeenCalledTimes(3); + expect(vi.getTimerCount()).toBe(0); + }, + ); + + it("bounds a fd that never reaches EOF", async () => { + vi.useFakeTimers(); + const socket = stream(); + const raw = kernel(); + raw.readSync.mockImplementation(() => { + throw errno("EAGAIN"); + }); + guard(raw)(socket, 42); + socket.resume(); + socket.push(null); + await vi.runAllTimersAsync(); + expect(socket.readableEnded).toBe(true); + expect(raw.readSync).toHaveBeenCalledTimes(200); + expect(vi.getTimerCount()).toBe(0); + }); + + it("bounds bytes from a descendant that keeps writing after hangup", () => { + const socket = stream(); + const raw = kernel(); + raw.readSync.mockImplementation((_fd, buffer, offset, length) => { + buffer.fill("x", offset, offset + length); + return length; + }); + guard(raw)(socket, 42); + socket.push(null); + expect(raw.readSync).toHaveBeenCalledTimes(16); + expect((socket.read() as Buffer).length).toBe(1024 * 1024); + }); + + it("counts ordinary libuv data against the post-exit byte bound, even if a descendant keeps writing", async () => { + const socket = stream(); + const raw = kernel(); + const onExit = guard(raw)(socket, 42); + const data: Buffer[] = []; + socket.on("data", (chunk: Buffer) => { + data.push(chunk); + socket.pause(); + }); + onExit(); + for (let i = 0; i < 32; i++) socket.push(Buffer.alloc(65536, "x")); + await tick(); + expect(Buffer.concat(data).length).toBe(1024 * 1024); + expect(socket.readableEnded).toBe(true); + expect(raw.readSync).not.toHaveBeenCalled(); + }); + + it("does not extend the native-exit watchdog when EOF arrives later", async () => { + vi.useFakeTimers(); + const socket = stream(); + const raw = kernel(); + raw.readSync.mockImplementation(() => { + throw errno("EAGAIN"); + }); + const onExit = guard(raw)(socket, 42); + onExit(); + await vi.advanceTimersByTimeAsync(150); + socket.push(null); + await vi.advanceTimersByTimeAsync(49); + expect(socket.readableEnded).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(socket.readableEnded).toBe(true); + expect(raw.readSync.mock.calls.length).toBeLessThanOrEqual(51); + await vi.runAllTimersAsync(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("cancels retries on explicit destruction and never reads a closed/reused fd", async () => { + vi.useFakeTimers(); + const socket = stream(); + const raw = kernel(errno("EAGAIN")); + guard(raw)(socket, 42); + socket.push(null); + socket.destroy(); + await vi.runAllTimersAsync(); + expect(raw.readSync).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it("keeps backpressure while running, but resumes and drains it at native process death", async () => { + const socket = stream(); + const pause = vi.spyOn(socket, "pause"); + const raw = kernel(Buffer.from("tail"), errno("EIO")); + const onExit = guard(raw)(socket, 42); + socket.pause(); + const data: Buffer[] = []; + socket.on("data", (chunk: Buffer) => { + data.push(chunk); + socket.pause(); + }); + onExit(); + socket.push(null); + await tick(); + expect(Buffer.concat(data).toString()).toBe("tail"); + expect(socket.readableEnded).toBe(true); + expect(pause).toHaveBeenCalledOnce(); + }); + + it("retries EINTR in bounded turns and surfaces unexpected read errors", async () => { + vi.useFakeTimers(); + const socket = stream(); + const raw = kernel(...Array.from({ length: 33 }, () => errno("EINTR")), errno("EBADF")); + const errors: Error[] = []; + socket.on("error", (error: Error) => errors.push(error)); + guard(raw)(socket, 42); + socket.push(null); + await vi.runAllTimersAsync(); + expect(raw.readSync).toHaveBeenCalledTimes(34); + expect(errors).toHaveLength(1); + expect(errors[0]?.message).toBe("EBADF"); + expect(socket.destroyed).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/patches/node-pty@1.1.0.patch b/patches/node-pty@1.1.0.patch new file mode 100644 index 000000000..6930abf28 --- /dev/null +++ b/patches/node-pty@1.1.0.patch @@ -0,0 +1,275 @@ +diff --git a/lib/linuxPtyEof.js b/lib/linuxPtyEof.js +new file mode 100644 +index 0000000000000000000000000000000000000000..5618b376d57310a93aa5b979c3dcf9c63a3685fa +--- /dev/null ++++ b/lib/linuxPtyEof.js +@@ -0,0 +1,111 @@ ++"use strict"; ++Object.defineProperty(exports, "__esModule", { value: true }); ++exports.guardLinuxPtyEof = guardLinuxPtyEof; ++// VC-639: libuv can report EOF on a Linux PTY's POLLHUP while ++// read(2) still returns bytes (libuv#4992/#5165). Drain before push(null): ++// waiting after 'end' is too late, both for the fd and the UTF-8 decoder. ++const fs = require("fs"); ++function guardLinuxPtyEof(socket, fd) { ++ const push = socket.push; ++ const pause = socket.pause; ++ let exited = false; ++ let pendingEof = false; ++ let completed = false; ++ let retry; ++ let deadline = 0; ++ let retriesLeft = 200; ++ // Bound ALL post-exit bytes, including ordinary libuv pushes, in case a ++ // descendant keeps writing. The same budget covers raw EOF recovery. ++ let bytesLeft = 1024 * 1024; ++ let buffer; ++ const finish = () => { ++ if (completed || socket.destroyed) ++ return; ++ completed = true; ++ if (retry !== undefined) ++ clearTimeout(retry); ++ retry = undefined; ++ push.call(socket, null); ++ }; ++ const drain = () => { ++ retry = undefined; ++ const readBuffer = buffer !== null && buffer !== void 0 ? buffer : (buffer = Buffer.alloc(65536)); ++ for (let reads = 0; reads < 32 && !socket.destroyed; reads++) { ++ let count; ++ try { ++ count = fs.readSync(fd, readBuffer, 0, Math.min(readBuffer.length, bytesLeft), null); ++ } ++ catch (error) { ++ const code = error.code; ++ if (code === 'EIO') { ++ finish(); ++ return; ++ } ++ if (code === 'EINTR') ++ continue; ++ if (code === 'EAGAIN' || code === 'EWOULDBLOCK') ++ break; ++ socket.destroy(error); ++ return; ++ } ++ if (count === 0) { ++ finish(); ++ return; ++ } ++ bytesLeft -= count; ++ // push uses the stream's existing decoder, including a split UTF-8 ++ // character. Copy: a paused stream may retain this buffer after return. ++ push.call(socket, Buffer.from(readBuffer.subarray(0, count))); ++ if (bytesLeft === 0) { ++ finish(); ++ return; ++ } ++ } ++ if (socket.destroyed) ++ return; ++ if (Date.now() >= deadline || --retriesLeft === 0) { ++ finish(); ++ return; ++ } ++ retry = setTimeout(drain, 1); ++ }; ++ socket.push = (chunk, encoding) => { ++ if (completed) ++ return false; ++ if (chunk !== null) { ++ if (!exited) ++ return push.call(socket, chunk, encoding); ++ // libuv supplies Buffers; preserve string pushes as well. ++ const bytes = typeof chunk === 'string' ? Buffer.from(chunk, encoding) : chunk; ++ if (!Buffer.isBuffer(bytes)) ++ return push.call(socket, chunk, encoding); ++ const count = Math.min(bytes.length, bytesLeft); ++ bytesLeft -= count; ++ const result = push.call(socket, bytes.subarray(0, count)); ++ if (bytesLeft === 0) ++ finish(); ++ return result; ++ } ++ if (!pendingEof && !socket.destroyed) { ++ pendingEof = true; ++ if (deadline === 0) ++ deadline = Date.now() + 200; ++ drain(); ++ } ++ return false; ++ }; ++ socket.once('close', () => { ++ if (retry !== undefined) ++ clearTimeout(retry); ++ }); ++ // Client backpressure must not stop the final drain after process death. ++ socket.pause = () => exited ? socket : pause.call(socket); ++ return () => { ++ exited = true; ++ // Match node-pty's existing watchdog: EOF retries cannot extend the ++ // 200ms process-exit deadline. EOF observed earlier keeps its own bound. ++ const exitDeadline = Date.now() + 200; ++ deadline = deadline === 0 ? exitDeadline : Math.min(deadline, exitDeadline); ++ socket.resume(); ++ }; ++} +diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js +index 1ec12f796a822c78fba9ad7f6448c3987e325c23..0479c403006d0ef41853edae61ff47ad1b571afc 100644 +--- a/lib/unixTerminal.js ++++ b/lib/unixTerminal.js +@@ -23,6 +23,7 @@ var fs = require("fs"); + var path = require("path"); + var tty = require("tty"); + var terminal_1 = require("./terminal"); ++var linuxPtyEof_1 = require("./linuxPtyEof"); + var utils_1 = require("./utils"); + var native = utils_1.loadNativeModule('pty'); + var pty = native.module; +@@ -62,7 +63,9 @@ var UnixTerminal = /** @class */ (function (_super) { + env.TERM = name; + var parsedEnv = _this._parseEnv(env); + var encoding = (opt.encoding === undefined ? 'utf8' : opt.encoding); ++ var drainOnExit; + var onexit = function (code, signal) { ++ if (drainOnExit) drainOnExit(); + // XXX Sometimes a data event is emitted after exit. Wait til socket is + // destroyed. + if (!_this._emittedClose) { +@@ -91,6 +94,9 @@ var UnixTerminal = /** @class */ (function (_super) { + // fork + var term = pty.fork(file, args, parsedEnv, cwd, _this._cols, _this._rows, uid, gid, (encoding === 'utf8'), helperPath, onexit); + _this._socket = new tty.ReadStream(term.fd); ++ if (process.platform === 'linux') { ++ drainOnExit = linuxPtyEof_1.guardLinuxPtyEof(_this._socket, term.fd); ++ } + if (encoding !== null) { + _this._socket.setEncoding(encoding); + } +diff --git a/src/linuxPtyEof.ts b/src/linuxPtyEof.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..e63611ace5cfce2566578fd660bb34fb35ea8365 +--- /dev/null ++++ b/src/linuxPtyEof.ts +@@ -0,0 +1,88 @@ ++// VC-639: libuv can report EOF on a Linux PTY's POLLHUP while ++// read(2) still returns bytes (libuv#4992/#5165). Drain before push(null): ++// waiting after 'end' is too late, both for the fd and the UTF-8 decoder. ++import fs = require('fs'); ++import type { ReadStream } from 'tty'; ++ ++export function guardLinuxPtyEof(socket: ReadStream, fd: number): () => void { ++ const push = socket.push; ++ const pause = socket.pause; ++ let exited = false; ++ let pendingEof = false; ++ let completed = false; ++ let retry: NodeJS.Timeout | undefined; ++ let deadline = 0; ++ let retriesLeft = 200; ++ // Bound ALL post-exit bytes, including ordinary libuv pushes, in case a ++ // descendant keeps writing. The same budget covers raw EOF recovery. ++ let bytesLeft = 1024 * 1024; ++ let buffer: Buffer | undefined; ++ ++ const finish = (): void => { ++ if (completed || socket.destroyed) return; ++ completed = true; ++ if (retry !== undefined) clearTimeout(retry); ++ retry = undefined; ++ push.call(socket, null); ++ }; ++ const drain = (): void => { ++ retry = undefined; ++ const readBuffer = buffer ?? (buffer = Buffer.alloc(65536)); ++ for (let reads = 0; reads < 32 && !socket.destroyed; reads++) { ++ let count: number; ++ try { ++ count = fs.readSync(fd, readBuffer, 0, Math.min(readBuffer.length, bytesLeft), null); ++ } catch (error) { ++ const code = (error as NodeJS.ErrnoException).code; ++ if (code === 'EIO') { finish(); return; } ++ if (code === 'EINTR') continue; ++ if (code === 'EAGAIN' || code === 'EWOULDBLOCK') break; ++ socket.destroy(error as Error); ++ return; ++ } ++ if (count === 0) { finish(); return; } ++ bytesLeft -= count; ++ // push uses the stream's existing decoder, including a split UTF-8 ++ // character. Copy: a paused stream may retain this buffer after return. ++ push.call(socket, Buffer.from(readBuffer.subarray(0, count))); ++ if (bytesLeft === 0) { finish(); return; } ++ } ++ if (socket.destroyed) return; ++ if (Date.now() >= deadline || --retriesLeft === 0) { finish(); return; } ++ retry = setTimeout(drain, 1); ++ }; ++ ++ socket.push = (chunk: unknown, encoding?: BufferEncoding): boolean => { ++ if (completed) return false; ++ if (chunk !== null) { ++ if (!exited) return push.call(socket, chunk, encoding); ++ // libuv supplies Buffers; preserve string pushes as well. ++ const bytes = typeof chunk === 'string' ? Buffer.from(chunk, encoding) : chunk; ++ if (!Buffer.isBuffer(bytes)) return push.call(socket, chunk, encoding); ++ const count = Math.min(bytes.length, bytesLeft); ++ bytesLeft -= count; ++ const result = push.call(socket, bytes.subarray(0, count)); ++ if (bytesLeft === 0) finish(); ++ return result; ++ } ++ if (!pendingEof && !socket.destroyed) { ++ pendingEof = true; ++ if (deadline === 0) deadline = Date.now() + 200; ++ drain(); ++ } ++ return false; ++ }; ++ socket.once('close', () => { ++ if (retry !== undefined) clearTimeout(retry); ++ }); ++ // Client backpressure must not stop the final drain after process death. ++ socket.pause = () => exited ? socket : pause.call(socket); ++ return () => { ++ exited = true; ++ // Match node-pty's existing watchdog: EOF retries cannot extend the ++ // 200ms process-exit deadline. EOF observed earlier keeps its own bound. ++ const exitDeadline = Date.now() + 200; ++ deadline = deadline === 0 ? exitDeadline : Math.min(deadline, exitDeadline); ++ socket.resume(); ++ }; ++} +diff --git a/src/unixTerminal.ts b/src/unixTerminal.ts +index 98733dc0cd752b554bd94e45904ca341ad141bba..a2f104acb6800ee23aeecf71d9f56da959de954a 100644 +--- a/src/unixTerminal.ts ++++ b/src/unixTerminal.ts +@@ -8,6 +8,7 @@ import * as net from 'net'; + import * as path from 'path'; + import * as tty from 'tty'; + import { Terminal, DEFAULT_COLS, DEFAULT_ROWS } from './terminal'; ++import { guardLinuxPtyEof } from './linuxPtyEof'; + import { IProcessEnv, IPtyForkOptions, IPtyOpenOptions } from './interfaces'; + import { ArgvOrCommandLine, IDisposable } from './types'; + import { assign, loadNativeModule } from './utils'; +@@ -75,7 +76,9 @@ export class UnixTerminal extends Terminal { + + const encoding = (opt.encoding === undefined ? 'utf8' : opt.encoding); + ++ let drainOnExit: (() => void) | undefined; + const onexit = (code: number, signal: number): void => { ++ drainOnExit?.(); + // XXX Sometimes a data event is emitted after exit. Wait til socket is + // destroyed. + if (!this._emittedClose) { +@@ -106,6 +109,9 @@ export class UnixTerminal extends Terminal { + const term = pty.fork(file, args, parsedEnv, cwd, this._cols, this._rows, uid, gid, (encoding === 'utf8'), helperPath, onexit); + + this._socket = new tty.ReadStream(term.fd); ++ if (process.platform === 'linux') { ++ drainOnExit = guardLinuxPtyEof(this._socket as tty.ReadStream, term.fd); ++ } + if (encoding !== null) { + this._socket.setEncoding(encoding); + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a5b739b5..9666cc0e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,7 @@ packageExtensionsChecksum: sha256-q5WdBF7BCWKzly/tgcivWtVAQ00t1e/WaEGl0+mhjaI= patchedDependencies: '@dnd-kit/core@6.3.1': b27bef7dadf707ad29bd0f46903d9a5511eb602aae423b29b62b58aeef24571d '@earendil-works/pi-codemode@1.0.0': 12e49843dc75a3b17dc6c57e9c92b5b78a74e79ac2ca8bb2f1a45c604bd5adc3 + node-pty@1.1.0: f9b699ab3d20d07279b7182ab258876383daeba2cac7c6aa7a57ba798383fbc8 importers: @@ -201,7 +202,7 @@ importers: version: 6.0.1 node-pty: specifier: 1.1.0 - version: 1.1.0 + version: 1.1.0(patch_hash=f9b699ab3d20d07279b7182ab258876383daeba2cac7c6aa7a57ba798383fbc8) radix-ui: specifier: ^1.6.7 version: 1.6.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -351,7 +352,7 @@ importers: version: 30.0.1(@noble/hashes@2.3.0) node-pty: specifier: 1.1.0 - version: 1.1.0 + version: 1.1.0(patch_hash=f9b699ab3d20d07279b7182ab258876383daeba2cac7c6aa7a57ba798383fbc8) sharp: specifier: 0.35.4 version: 0.35.4(@types/node@26.3.0) @@ -580,7 +581,7 @@ importers: version: 13.0.3 node-pty: specifier: 1.1.0 - version: 1.1.0 + version: 1.1.0(patch_hash=f9b699ab3d20d07279b7182ab258876383daeba2cac7c6aa7a57ba798383fbc8) yaml: specifier: 2.9.0 version: 2.9.0 @@ -13989,7 +13990,7 @@ snapshots: node-mock-http@1.0.5: {} - node-pty@1.1.0: + node-pty@1.1.0(patch_hash=f9b699ab3d20d07279b7182ab258876383daeba2cac7c6aa7a57ba798383fbc8): dependencies: node-addon-api: 7.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 473fc7b6e..bccf6d2a9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -116,6 +116,10 @@ patchedDependencies: # main's memory for the length of its deadline. Upstream bounds the VM heap # but not what the host accumulates from it. Absent, upstream behavior. "@earendil-works/pi-codemode@1.0.0": patches/@earendil-works__pi-codemode@1.0.0.patch + # VC-639: Linux/libuv can report PTY EOF with bytes still on the master + # (libuv#4992/#5165). Drain before stream EOF destroys the fd/decoder; + # delaying the supervisor's public exit cannot recover those bytes. + node-pty@1.1.0: patches/node-pty@1.1.0.patch peerDependencyRules: allowAny: From 009fe77948b1c2af5c20a5283f92bbabf20653d3 Mon Sep 17 00:00:00 2001 From: Hussain Phalasiya Date: Mon, 5 Oct 2026 10:10:44 +0300 Subject: [PATCH 2/2] fix(pty): use Node 24.21 upstream libuv fix instead of node-pty patch --- .devcontainer/host/Dockerfile | 4 +- .nvmrc | 2 +- CONTRIBUTING.md | 2 +- README.md | 2 +- apps/desktop/THIRD-PARTY-NOTICES | 3 +- .../docs/reference/build-from-source.mdx | 2 +- docs/development/host-linux.md | 14 +- package.json | 2 +- packages/host-core/src/db-open-failure.ts | 2 +- .../host-core/src/pty/node-pty-eof.test.ts | 285 ------------------ patches/node-pty@1.1.0.patch | 275 ----------------- pnpm-lock.yaml | 9 +- pnpm-workspace.yaml | 6 +- 13 files changed, 22 insertions(+), 586 deletions(-) delete mode 100644 packages/host-core/src/pty/node-pty-eof.test.ts delete mode 100644 patches/node-pty@1.1.0.patch diff --git a/.devcontainer/host/Dockerfile b/.devcontainer/host/Dockerfile index 8572fc152..0c04301ce 100644 --- a/.devcontainer/host/Dockerfile +++ b/.devcontainer/host/Dockerfile @@ -1,6 +1,6 @@ -# Multi-arch Node 24.15.0 bookworm-slim index (amd64 on CI/dogfood, arm64 locally). +# Multi-arch Node 24.21.0 bookworm-slim index (amd64 on CI/dogfood, arm64 locally). # Update the tag AND digest with .nvmrc; the build refuses version drift. -FROM node:24.15.0-bookworm-slim@sha256:4e6b70dd6cbfc88c8157ba19aa3d9f9cce6ba4703576d55459e45efcbc9c5f5d +FROM node:24.21.0-bookworm-slim@sha256:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 # Development tools for git/worktrees and future Node-ABI native builds, plus # standalone Chromium's runtime libraries (not Electron or a browser binary). diff --git a/.nvmrc b/.nvmrc index 5bf4400f2..df6ae3370 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.15.0 +24.21.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 74e01d17b..4ebd216cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Volli is a local-first macOS workspace for Tickets, Sessions, worktrees, and rev ## Prerequisites -Use macOS, Node.js `^24.15.0`, and pnpm `11.10.0`. A `.nvmrc` carries the pin (`nvm use`), and the range is enforced rather than advisory: `pnpm install` hard-fails outside it (`engineStrict` in `pnpm-workspace.yaml`), and the desktop dev/start scripts preflight the running Node before anything spawns — an unsupported Node builds native modules (better-sqlite3, node-pty) against the wrong ABI, which surfaces later as a dead database and a greyed-out sign-in. Install dependencies from the repository root: +Use macOS, Node.js `^24.16.0`, and pnpm `11.10.0`. A `.nvmrc` carries the pin (`nvm use`), and the range is enforced rather than advisory: `pnpm install` hard-fails outside it (`engineStrict` in `pnpm-workspace.yaml`), and the desktop dev/start scripts preflight the running Node before anything spawns — an unsupported Node builds native modules (better-sqlite3, node-pty) against the wrong ABI, which surfaces later as a dead database and a greyed-out sign-in. Install dependencies from the repository root: ```bash pnpm install diff --git a/README.md b/README.md index 21ab4967f..9e88e9fb1 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Projects, tickets, and chat history stay on your machine. Model requests go to t ## Build from source -Requirements: macOS, Node.js `^24.15.0` (a `.nvmrc` is provided — `nvm use`), and pnpm `11.10.0`. The Node range is enforced: `pnpm install` and the dev scripts refuse to run under an unsupported Node, because native modules built against the wrong ABI leave the app unable to open its database (and sign-in disabled). +Requirements: macOS, Node.js `^24.16.0` (a `.nvmrc` is provided — `nvm use`), and pnpm `11.10.0`. The Node range is enforced: `pnpm install` and the dev scripts refuse to run under an unsupported Node, because native modules built against the wrong ABI leave the app unable to open its database (and sign-in disabled). ```bash git clone https://github.com/hussainph/volli-code.git diff --git a/apps/desktop/THIRD-PARTY-NOTICES b/apps/desktop/THIRD-PARTY-NOTICES index cda45ccf7..2941d2072 100644 --- a/apps/desktop/THIRD-PARTY-NOTICES +++ b/apps/desktop/THIRD-PARTY-NOTICES @@ -12931,7 +12931,7 @@ SOFTWARE. ========================================================================================================= -8. MODIFIED PACKAGES (3) +8. MODIFIED PACKAGES (2) ========================================================================================================= These packages are modified before they are bundled, by the pnpm patches recorded @@ -12939,7 +12939,6 @@ in pnpm-workspace.yaml. The patch files are in the repository and state the chan @dnd-kit/core@6.3.1 — patches/@dnd-kit__core@6.3.1.patch @earendil-works/pi-codemode@1.0.0 — patches/@earendil-works__pi-codemode@1.0.0.patch - node-pty@1.1.0 — patches/node-pty@1.1.0.patch ========================================================================================================= 9. ADDITIONAL NOTICES (2) diff --git a/apps/docs/src/content/docs/reference/build-from-source.mdx b/apps/docs/src/content/docs/reference/build-from-source.mdx index bb8c035a5..eae8918f5 100644 --- a/apps/docs/src/content/docs/reference/build-from-source.mdx +++ b/apps/docs/src/content/docs/reference/build-from-source.mdx @@ -4,7 +4,7 @@ description: Install Volli's development dependencies, start the desktop develop --- Use these commands to run the desktop app from the repository. You need macOS, -Git, Node `^24.15.0`, and pnpm `11.10.0`, as pinned in the root `package.json`. +Git, Node `^24.16.0`, and pnpm `11.10.0`, as pinned in the root `package.json`. ## Start the development app diff --git a/docs/development/host-linux.md b/docs/development/host-linux.md index febd7b3be..99e41dee8 100644 --- a/docs/development/host-linux.md +++ b/docs/development/host-linux.md @@ -74,7 +74,7 @@ The VC-552 inventory comment records the module lists and test caveats. The image in `.devcontainer/host/Dockerfile` is a **development toolchain**, not a production hostd deployment. Its multi-architecture base is pinned by digest; -its Node version must equal `.nvmrc` (currently 24.15.0), and Corepack installs +its Node version must equal `.nvmrc` (currently 24.21.0), and Corepack installs the root manifest's exact pnpm version. It includes git/SSH, Python/make/g++ for native compilation, fonts and Chromium runtime libraries. It contains neither Electron, Chromium itself, source code, installed workspace dependencies nor @@ -103,11 +103,13 @@ for your own checkout if needed. No production secrets are baked or mounted. Standalone Chromium provisioning belongs to the browser/worker ticket; these libraries support it without installing another browser in every dev image. -The previous 24.13.0 `.nvmrc` pin could not install the current dependency graph: -jsdom 30.0.1 requires Node ^24.15.0 in the 24.x line, and `engineStrict` rejects -older versions. The host lane/image therefore use 24.15.0, and the root -`engines.node` floor is ^24.15.0 too. The host lane's exact-version install -checks the full graph rather than inferring compatibility from the caret range. +The host lane/image use Node 24.21.0, and the root `engines.node` floor is +^24.16.0. Node 24.16.0 added libuv 1.52.1, including the Linux PTY premature-EOF +fix (libuv#4992/#4997); the older 24.15.0 pin could lose a shell's final output +before node-pty delivered exit (VC-639). Electron 44 already includes this fix. +The host lane's exact-version install checks the full graph rather than inferring +compatibility from the caret range. The residual libuv#5165 case needs libuv +1.53.0; revisit once Node ships that version (nodejs/node#66282). When bumping Node, update `.nvmrc`, the Dockerfile's tag and multi-arch digest together; the image build catches drift. A root `packageManager` bump also diff --git a/package.json b/package.json index 54d24a2cf..7b36c1ccc 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "yaml": "2.9.0" }, "engines": { - "node": "^24.15.0" + "node": "^24.16.0" }, "packageManager": "pnpm@11.10.0" } diff --git a/packages/host-core/src/db-open-failure.ts b/packages/host-core/src/db-open-failure.ts index c55cc3109..b272b3b32 100644 --- a/packages/host-core/src/db-open-failure.ts +++ b/packages/host-core/src/db-open-failure.ts @@ -39,7 +39,7 @@ import { DatabaseFromNewerVersionError } from "./db/schema-compatibility"; * — main cannot read the root manifest at runtime (it is not packaged), so * the test is what keeps this string from drifting into a lie. */ -export const REQUIRED_NODE_RANGE = "^24.15.0"; +export const REQUIRED_NODE_RANGE = "^24.16.0"; /** * The signatures a wrong-ABI or missing native build leaves in its error. diff --git a/packages/host-core/src/pty/node-pty-eof.test.ts b/packages/host-core/src/pty/node-pty-eof.test.ts deleted file mode 100644 index 3bc63914a..000000000 --- a/packages/host-core/src/pty/node-pty-eof.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { readFileSync } from "node:fs"; -import * as fs from "node:fs"; -import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; -import { Readable } from "node:stream"; -import { runInNewContext } from "node:vm"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -// Exercise the installed patch, not a second implementation of it. Fake the -// kernel boundary: libuv reports EOF while read(2) still has the shell's tail. -// The constructor regression runs unchanged on macOS and Linux without loading -// the native addon. The real-shell contract is covered by manager.pty.test.ts. -const require = createRequire(import.meta.url); -const lib = dirname(require.resolve("node-pty")); -const packageRequire = createRequire(join(lib, "unixTerminal.js")); -const sockets: Readable[] = []; - -const errno = (code: string) => Object.assign(new Error(code), { code }); -function kernel(...chunks: Array) { - const readSync = vi.fn( - (_fd: number, buffer: Buffer, offset: number, length: number, _position: null) => { - const next = chunks.shift(); - if (next instanceof Error) throw next; - if (next === undefined) return 0; - const count = Math.min(next.length, length); - next.copy(buffer, offset, 0, count); - if (count < next.length) chunks.unshift(next.subarray(count)); - return count; - }, - ); - return { readSync }; -} - -function stream() { - const socket = new Readable({ read() {} }); - sockets.push(socket); - return socket; -} - -function evaluate(file: string, load: (id: string) => unknown): Record { - const exports: Record = {}; - runInNewContext(readFileSync(join(lib, file), "utf8"), { - exports, - require: load, - __dirname: lib, - Buffer, - Date, - process: { platform: "linux", env: {}, cwd: () => "/project" }, - setTimeout: (...args: Parameters) => setTimeout(...args), - clearTimeout: (timer: NodeJS.Timeout) => clearTimeout(timer), - setImmediate, - clearImmediate, - console, - }); - return exports; -} - -type Guard = (socket: Readable, fd: number) => () => void; -function guard(raw: ReturnType): Guard { - return evaluate("linuxPtyEof.js", (id) => { - if (id !== "fs") throw new Error(`unexpected dependency: ${id}`); - return raw; - }).guardLinuxPtyEof as Guard; -} - -const tick = () => new Promise((done) => setImmediate(done)); -afterEach(() => { - for (const socket of sockets.splice(0)) socket.destroy(); - vi.useRealTimers(); -}); - -describe("node-pty Linux EOF drain (VC-639)", () => { - it.each(["EOF first", "native exit first"])( - "delivers the shell's final bytes before exit after premature EOF (%s)", - async (order) => { - const socket = stream(); - const raw = kernel(Buffer.from("bye-8\r\n"), errno("EIO")); - let nativeExit: ((code: number, signal: number) => void) | undefined; - const patched = evaluate("unixTerminal.js", (id) => { - if (id === "fs") return { ...fs, ...raw }; - if (id === "tty") - return { - ReadStream: function () { - return socket; - }, - }; - if (id === "./linuxPtyEof") return { guardLinuxPtyEof: guard(raw) }; - if (id === "./utils") { - return { - assign: Object.assign, - loadNativeModule: () => ({ - dir: "/native", - module: { - fork: (...args: unknown[]) => { - nativeExit = args[10] as typeof nativeExit; - return { fd: 42, pid: 123, pty: "/dev/pts/1" }; - }, - }, - }), - }; - } - return packageRequire(id); - }); - const Pty = patched.UnixTerminal as new ( - file: string, - args: string[], - options: object, - ) => { - onData(listener: (data: string) => void): void; - onExit(listener: (event: { exitCode: number }) => void): void; - }; - const pty = new Pty("/bin/sh", [], { cwd: "/project", env: {} }); - const events: Array = []; - pty.onData((data) => events.push(data)); - const exited = new Promise((done) => { - pty.onExit(({ exitCode }) => { - events.push(exitCode); - done(); - }); - }); - socket.push(Buffer.from("echo bye-$((4+4)); exit 3\r\n")); - if (order === "native exit first") nativeExit?.(3, 0); - socket.push(null); // libuv's false EOF; the kernel tail is still readable. - if (order === "EOF first") nativeExit?.(3, 0); - await exited; - expect(events).toEqual(["echo bye-$((4+4)); exit 3\r\n", "bye-8\r\n", 3]); - expect(raw.readSync).toHaveBeenCalledWith(42, expect.any(Buffer), 0, 65536, null); - }, - ); - - it("preserves a UTF-8 character split across the last libuv read and the raw tail", async () => { - const socket = stream(); - socket.setEncoding("utf8"); - const emoji = Buffer.from("😀"); - const raw = kernel(emoji.subarray(2), errno("EIO")); - guard(raw)(socket, 42); - const data: string[] = []; - socket.on("data", (chunk: string) => data.push(chunk)); - socket.push(emoji.subarray(0, 2)); - socket.push(null); - await tick(); - expect(data.join("")).toBe("😀"); - }); - - it("copies raw chunks retained by a paused stream instead of reusing its read buffer", () => { - const socket = stream(); - const raw = kernel(Buffer.from("first"), Buffer.from("second")); - guard(raw)(socket, 42); - socket.push(null); - expect(socket.read()?.toString()).toBe("firstsecond"); - }); - - it.each(["EAGAIN", "EWOULDBLOCK"])( - "retries %s without flushing EOF or the decoder early", - async (code) => { - vi.useFakeTimers(); - const socket = stream(); - const raw = kernel(errno(code), Buffer.from("tail"), errno("EIO")); - guard(raw)(socket, 42); - const data: Buffer[] = []; - socket.on("data", (chunk: Buffer) => data.push(chunk)); - socket.push(null); - socket.push(null); - expect(raw.readSync).toHaveBeenCalledTimes(1); - expect(socket.readableEnded).toBe(false); - await vi.runAllTimersAsync(); - expect(Buffer.concat(data).toString()).toBe("tail"); - expect(raw.readSync).toHaveBeenCalledTimes(3); - expect(vi.getTimerCount()).toBe(0); - }, - ); - - it("bounds a fd that never reaches EOF", async () => { - vi.useFakeTimers(); - const socket = stream(); - const raw = kernel(); - raw.readSync.mockImplementation(() => { - throw errno("EAGAIN"); - }); - guard(raw)(socket, 42); - socket.resume(); - socket.push(null); - await vi.runAllTimersAsync(); - expect(socket.readableEnded).toBe(true); - expect(raw.readSync).toHaveBeenCalledTimes(200); - expect(vi.getTimerCount()).toBe(0); - }); - - it("bounds bytes from a descendant that keeps writing after hangup", () => { - const socket = stream(); - const raw = kernel(); - raw.readSync.mockImplementation((_fd, buffer, offset, length) => { - buffer.fill("x", offset, offset + length); - return length; - }); - guard(raw)(socket, 42); - socket.push(null); - expect(raw.readSync).toHaveBeenCalledTimes(16); - expect((socket.read() as Buffer).length).toBe(1024 * 1024); - }); - - it("counts ordinary libuv data against the post-exit byte bound, even if a descendant keeps writing", async () => { - const socket = stream(); - const raw = kernel(); - const onExit = guard(raw)(socket, 42); - const data: Buffer[] = []; - socket.on("data", (chunk: Buffer) => { - data.push(chunk); - socket.pause(); - }); - onExit(); - for (let i = 0; i < 32; i++) socket.push(Buffer.alloc(65536, "x")); - await tick(); - expect(Buffer.concat(data).length).toBe(1024 * 1024); - expect(socket.readableEnded).toBe(true); - expect(raw.readSync).not.toHaveBeenCalled(); - }); - - it("does not extend the native-exit watchdog when EOF arrives later", async () => { - vi.useFakeTimers(); - const socket = stream(); - const raw = kernel(); - raw.readSync.mockImplementation(() => { - throw errno("EAGAIN"); - }); - const onExit = guard(raw)(socket, 42); - onExit(); - await vi.advanceTimersByTimeAsync(150); - socket.push(null); - await vi.advanceTimersByTimeAsync(49); - expect(socket.readableEnded).toBe(false); - await vi.advanceTimersByTimeAsync(1); - expect(socket.readableEnded).toBe(true); - expect(raw.readSync.mock.calls.length).toBeLessThanOrEqual(51); - await vi.runAllTimersAsync(); - expect(vi.getTimerCount()).toBe(0); - }); - - it("cancels retries on explicit destruction and never reads a closed/reused fd", async () => { - vi.useFakeTimers(); - const socket = stream(); - const raw = kernel(errno("EAGAIN")); - guard(raw)(socket, 42); - socket.push(null); - socket.destroy(); - await vi.runAllTimersAsync(); - expect(raw.readSync).toHaveBeenCalledTimes(1); - expect(vi.getTimerCount()).toBe(0); - }); - - it("keeps backpressure while running, but resumes and drains it at native process death", async () => { - const socket = stream(); - const pause = vi.spyOn(socket, "pause"); - const raw = kernel(Buffer.from("tail"), errno("EIO")); - const onExit = guard(raw)(socket, 42); - socket.pause(); - const data: Buffer[] = []; - socket.on("data", (chunk: Buffer) => { - data.push(chunk); - socket.pause(); - }); - onExit(); - socket.push(null); - await tick(); - expect(Buffer.concat(data).toString()).toBe("tail"); - expect(socket.readableEnded).toBe(true); - expect(pause).toHaveBeenCalledOnce(); - }); - - it("retries EINTR in bounded turns and surfaces unexpected read errors", async () => { - vi.useFakeTimers(); - const socket = stream(); - const raw = kernel(...Array.from({ length: 33 }, () => errno("EINTR")), errno("EBADF")); - const errors: Error[] = []; - socket.on("error", (error: Error) => errors.push(error)); - guard(raw)(socket, 42); - socket.push(null); - await vi.runAllTimersAsync(); - expect(raw.readSync).toHaveBeenCalledTimes(34); - expect(errors).toHaveLength(1); - expect(errors[0]?.message).toBe("EBADF"); - expect(socket.destroyed).toBe(true); - expect(vi.getTimerCount()).toBe(0); - }); -}); diff --git a/patches/node-pty@1.1.0.patch b/patches/node-pty@1.1.0.patch deleted file mode 100644 index 6930abf28..000000000 --- a/patches/node-pty@1.1.0.patch +++ /dev/null @@ -1,275 +0,0 @@ -diff --git a/lib/linuxPtyEof.js b/lib/linuxPtyEof.js -new file mode 100644 -index 0000000000000000000000000000000000000000..5618b376d57310a93aa5b979c3dcf9c63a3685fa ---- /dev/null -+++ b/lib/linuxPtyEof.js -@@ -0,0 +1,111 @@ -+"use strict"; -+Object.defineProperty(exports, "__esModule", { value: true }); -+exports.guardLinuxPtyEof = guardLinuxPtyEof; -+// VC-639: libuv can report EOF on a Linux PTY's POLLHUP while -+// read(2) still returns bytes (libuv#4992/#5165). Drain before push(null): -+// waiting after 'end' is too late, both for the fd and the UTF-8 decoder. -+const fs = require("fs"); -+function guardLinuxPtyEof(socket, fd) { -+ const push = socket.push; -+ const pause = socket.pause; -+ let exited = false; -+ let pendingEof = false; -+ let completed = false; -+ let retry; -+ let deadline = 0; -+ let retriesLeft = 200; -+ // Bound ALL post-exit bytes, including ordinary libuv pushes, in case a -+ // descendant keeps writing. The same budget covers raw EOF recovery. -+ let bytesLeft = 1024 * 1024; -+ let buffer; -+ const finish = () => { -+ if (completed || socket.destroyed) -+ return; -+ completed = true; -+ if (retry !== undefined) -+ clearTimeout(retry); -+ retry = undefined; -+ push.call(socket, null); -+ }; -+ const drain = () => { -+ retry = undefined; -+ const readBuffer = buffer !== null && buffer !== void 0 ? buffer : (buffer = Buffer.alloc(65536)); -+ for (let reads = 0; reads < 32 && !socket.destroyed; reads++) { -+ let count; -+ try { -+ count = fs.readSync(fd, readBuffer, 0, Math.min(readBuffer.length, bytesLeft), null); -+ } -+ catch (error) { -+ const code = error.code; -+ if (code === 'EIO') { -+ finish(); -+ return; -+ } -+ if (code === 'EINTR') -+ continue; -+ if (code === 'EAGAIN' || code === 'EWOULDBLOCK') -+ break; -+ socket.destroy(error); -+ return; -+ } -+ if (count === 0) { -+ finish(); -+ return; -+ } -+ bytesLeft -= count; -+ // push uses the stream's existing decoder, including a split UTF-8 -+ // character. Copy: a paused stream may retain this buffer after return. -+ push.call(socket, Buffer.from(readBuffer.subarray(0, count))); -+ if (bytesLeft === 0) { -+ finish(); -+ return; -+ } -+ } -+ if (socket.destroyed) -+ return; -+ if (Date.now() >= deadline || --retriesLeft === 0) { -+ finish(); -+ return; -+ } -+ retry = setTimeout(drain, 1); -+ }; -+ socket.push = (chunk, encoding) => { -+ if (completed) -+ return false; -+ if (chunk !== null) { -+ if (!exited) -+ return push.call(socket, chunk, encoding); -+ // libuv supplies Buffers; preserve string pushes as well. -+ const bytes = typeof chunk === 'string' ? Buffer.from(chunk, encoding) : chunk; -+ if (!Buffer.isBuffer(bytes)) -+ return push.call(socket, chunk, encoding); -+ const count = Math.min(bytes.length, bytesLeft); -+ bytesLeft -= count; -+ const result = push.call(socket, bytes.subarray(0, count)); -+ if (bytesLeft === 0) -+ finish(); -+ return result; -+ } -+ if (!pendingEof && !socket.destroyed) { -+ pendingEof = true; -+ if (deadline === 0) -+ deadline = Date.now() + 200; -+ drain(); -+ } -+ return false; -+ }; -+ socket.once('close', () => { -+ if (retry !== undefined) -+ clearTimeout(retry); -+ }); -+ // Client backpressure must not stop the final drain after process death. -+ socket.pause = () => exited ? socket : pause.call(socket); -+ return () => { -+ exited = true; -+ // Match node-pty's existing watchdog: EOF retries cannot extend the -+ // 200ms process-exit deadline. EOF observed earlier keeps its own bound. -+ const exitDeadline = Date.now() + 200; -+ deadline = deadline === 0 ? exitDeadline : Math.min(deadline, exitDeadline); -+ socket.resume(); -+ }; -+} -diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js -index 1ec12f796a822c78fba9ad7f6448c3987e325c23..0479c403006d0ef41853edae61ff47ad1b571afc 100644 ---- a/lib/unixTerminal.js -+++ b/lib/unixTerminal.js -@@ -23,6 +23,7 @@ var fs = require("fs"); - var path = require("path"); - var tty = require("tty"); - var terminal_1 = require("./terminal"); -+var linuxPtyEof_1 = require("./linuxPtyEof"); - var utils_1 = require("./utils"); - var native = utils_1.loadNativeModule('pty'); - var pty = native.module; -@@ -62,7 +63,9 @@ var UnixTerminal = /** @class */ (function (_super) { - env.TERM = name; - var parsedEnv = _this._parseEnv(env); - var encoding = (opt.encoding === undefined ? 'utf8' : opt.encoding); -+ var drainOnExit; - var onexit = function (code, signal) { -+ if (drainOnExit) drainOnExit(); - // XXX Sometimes a data event is emitted after exit. Wait til socket is - // destroyed. - if (!_this._emittedClose) { -@@ -91,6 +94,9 @@ var UnixTerminal = /** @class */ (function (_super) { - // fork - var term = pty.fork(file, args, parsedEnv, cwd, _this._cols, _this._rows, uid, gid, (encoding === 'utf8'), helperPath, onexit); - _this._socket = new tty.ReadStream(term.fd); -+ if (process.platform === 'linux') { -+ drainOnExit = linuxPtyEof_1.guardLinuxPtyEof(_this._socket, term.fd); -+ } - if (encoding !== null) { - _this._socket.setEncoding(encoding); - } -diff --git a/src/linuxPtyEof.ts b/src/linuxPtyEof.ts -new file mode 100644 -index 0000000000000000000000000000000000000000..e63611ace5cfce2566578fd660bb34fb35ea8365 ---- /dev/null -+++ b/src/linuxPtyEof.ts -@@ -0,0 +1,88 @@ -+// VC-639: libuv can report EOF on a Linux PTY's POLLHUP while -+// read(2) still returns bytes (libuv#4992/#5165). Drain before push(null): -+// waiting after 'end' is too late, both for the fd and the UTF-8 decoder. -+import fs = require('fs'); -+import type { ReadStream } from 'tty'; -+ -+export function guardLinuxPtyEof(socket: ReadStream, fd: number): () => void { -+ const push = socket.push; -+ const pause = socket.pause; -+ let exited = false; -+ let pendingEof = false; -+ let completed = false; -+ let retry: NodeJS.Timeout | undefined; -+ let deadline = 0; -+ let retriesLeft = 200; -+ // Bound ALL post-exit bytes, including ordinary libuv pushes, in case a -+ // descendant keeps writing. The same budget covers raw EOF recovery. -+ let bytesLeft = 1024 * 1024; -+ let buffer: Buffer | undefined; -+ -+ const finish = (): void => { -+ if (completed || socket.destroyed) return; -+ completed = true; -+ if (retry !== undefined) clearTimeout(retry); -+ retry = undefined; -+ push.call(socket, null); -+ }; -+ const drain = (): void => { -+ retry = undefined; -+ const readBuffer = buffer ?? (buffer = Buffer.alloc(65536)); -+ for (let reads = 0; reads < 32 && !socket.destroyed; reads++) { -+ let count: number; -+ try { -+ count = fs.readSync(fd, readBuffer, 0, Math.min(readBuffer.length, bytesLeft), null); -+ } catch (error) { -+ const code = (error as NodeJS.ErrnoException).code; -+ if (code === 'EIO') { finish(); return; } -+ if (code === 'EINTR') continue; -+ if (code === 'EAGAIN' || code === 'EWOULDBLOCK') break; -+ socket.destroy(error as Error); -+ return; -+ } -+ if (count === 0) { finish(); return; } -+ bytesLeft -= count; -+ // push uses the stream's existing decoder, including a split UTF-8 -+ // character. Copy: a paused stream may retain this buffer after return. -+ push.call(socket, Buffer.from(readBuffer.subarray(0, count))); -+ if (bytesLeft === 0) { finish(); return; } -+ } -+ if (socket.destroyed) return; -+ if (Date.now() >= deadline || --retriesLeft === 0) { finish(); return; } -+ retry = setTimeout(drain, 1); -+ }; -+ -+ socket.push = (chunk: unknown, encoding?: BufferEncoding): boolean => { -+ if (completed) return false; -+ if (chunk !== null) { -+ if (!exited) return push.call(socket, chunk, encoding); -+ // libuv supplies Buffers; preserve string pushes as well. -+ const bytes = typeof chunk === 'string' ? Buffer.from(chunk, encoding) : chunk; -+ if (!Buffer.isBuffer(bytes)) return push.call(socket, chunk, encoding); -+ const count = Math.min(bytes.length, bytesLeft); -+ bytesLeft -= count; -+ const result = push.call(socket, bytes.subarray(0, count)); -+ if (bytesLeft === 0) finish(); -+ return result; -+ } -+ if (!pendingEof && !socket.destroyed) { -+ pendingEof = true; -+ if (deadline === 0) deadline = Date.now() + 200; -+ drain(); -+ } -+ return false; -+ }; -+ socket.once('close', () => { -+ if (retry !== undefined) clearTimeout(retry); -+ }); -+ // Client backpressure must not stop the final drain after process death. -+ socket.pause = () => exited ? socket : pause.call(socket); -+ return () => { -+ exited = true; -+ // Match node-pty's existing watchdog: EOF retries cannot extend the -+ // 200ms process-exit deadline. EOF observed earlier keeps its own bound. -+ const exitDeadline = Date.now() + 200; -+ deadline = deadline === 0 ? exitDeadline : Math.min(deadline, exitDeadline); -+ socket.resume(); -+ }; -+} -diff --git a/src/unixTerminal.ts b/src/unixTerminal.ts -index 98733dc0cd752b554bd94e45904ca341ad141bba..a2f104acb6800ee23aeecf71d9f56da959de954a 100644 ---- a/src/unixTerminal.ts -+++ b/src/unixTerminal.ts -@@ -8,6 +8,7 @@ import * as net from 'net'; - import * as path from 'path'; - import * as tty from 'tty'; - import { Terminal, DEFAULT_COLS, DEFAULT_ROWS } from './terminal'; -+import { guardLinuxPtyEof } from './linuxPtyEof'; - import { IProcessEnv, IPtyForkOptions, IPtyOpenOptions } from './interfaces'; - import { ArgvOrCommandLine, IDisposable } from './types'; - import { assign, loadNativeModule } from './utils'; -@@ -75,7 +76,9 @@ export class UnixTerminal extends Terminal { - - const encoding = (opt.encoding === undefined ? 'utf8' : opt.encoding); - -+ let drainOnExit: (() => void) | undefined; - const onexit = (code: number, signal: number): void => { -+ drainOnExit?.(); - // XXX Sometimes a data event is emitted after exit. Wait til socket is - // destroyed. - if (!this._emittedClose) { -@@ -106,6 +109,9 @@ export class UnixTerminal extends Terminal { - const term = pty.fork(file, args, parsedEnv, cwd, this._cols, this._rows, uid, gid, (encoding === 'utf8'), helperPath, onexit); - - this._socket = new tty.ReadStream(term.fd); -+ if (process.platform === 'linux') { -+ drainOnExit = guardLinuxPtyEof(this._socket as tty.ReadStream, term.fd); -+ } - if (encoding !== null) { - this._socket.setEncoding(encoding); - } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9666cc0e1..5a5b739b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,7 +35,6 @@ packageExtensionsChecksum: sha256-q5WdBF7BCWKzly/tgcivWtVAQ00t1e/WaEGl0+mhjaI= patchedDependencies: '@dnd-kit/core@6.3.1': b27bef7dadf707ad29bd0f46903d9a5511eb602aae423b29b62b58aeef24571d '@earendil-works/pi-codemode@1.0.0': 12e49843dc75a3b17dc6c57e9c92b5b78a74e79ac2ca8bb2f1a45c604bd5adc3 - node-pty@1.1.0: f9b699ab3d20d07279b7182ab258876383daeba2cac7c6aa7a57ba798383fbc8 importers: @@ -202,7 +201,7 @@ importers: version: 6.0.1 node-pty: specifier: 1.1.0 - version: 1.1.0(patch_hash=f9b699ab3d20d07279b7182ab258876383daeba2cac7c6aa7a57ba798383fbc8) + version: 1.1.0 radix-ui: specifier: ^1.6.7 version: 1.6.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -352,7 +351,7 @@ importers: version: 30.0.1(@noble/hashes@2.3.0) node-pty: specifier: 1.1.0 - version: 1.1.0(patch_hash=f9b699ab3d20d07279b7182ab258876383daeba2cac7c6aa7a57ba798383fbc8) + version: 1.1.0 sharp: specifier: 0.35.4 version: 0.35.4(@types/node@26.3.0) @@ -581,7 +580,7 @@ importers: version: 13.0.3 node-pty: specifier: 1.1.0 - version: 1.1.0(patch_hash=f9b699ab3d20d07279b7182ab258876383daeba2cac7c6aa7a57ba798383fbc8) + version: 1.1.0 yaml: specifier: 2.9.0 version: 2.9.0 @@ -13990,7 +13989,7 @@ snapshots: node-mock-http@1.0.5: {} - node-pty@1.1.0(patch_hash=f9b699ab3d20d07279b7182ab258876383daeba2cac7c6aa7a57ba798383fbc8): + node-pty@1.1.0: dependencies: node-addon-api: 7.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bccf6d2a9..a31844f72 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,7 +3,7 @@ packages: - packages/* # Hard-fail `pnpm install` when the running Node is outside the root -# `engines.node` pin (^24.15.0). Without this, an install under a stray older +# `engines.node` pin (^24.16.0). Without this, an install under a stray older # Node (a machine's /usr/local/bin/node beside Homebrew's current, say) # succeeds and quietly builds native modules against the wrong ABI — the # desktop app then boots into a dead database with sign-in greyed out (VC-76). @@ -116,10 +116,6 @@ patchedDependencies: # main's memory for the length of its deadline. Upstream bounds the VM heap # but not what the host accumulates from it. Absent, upstream behavior. "@earendil-works/pi-codemode@1.0.0": patches/@earendil-works__pi-codemode@1.0.0.patch - # VC-639: Linux/libuv can report PTY EOF with bytes still on the master - # (libuv#4992/#5165). Drain before stream EOF destroys the fd/decoder; - # delaying the supervisor's public exit cannot recover those bytes. - node-pty@1.1.0: patches/node-pty@1.1.0.patch peerDependencyRules: allowAny: