From 5217f97814f3d8d2ddd51829efd7f85cb03b40fe Mon Sep 17 00:00:00 2001 From: Rostislav Rotaru Date: Sat, 14 Nov 2026 18:37:17 -0800 Subject: [PATCH] Treat a write macOS reports as not connected as closed input On macOS a command's input is a Unix socket, and a write after Git has closed it can fail with ENOTCONN rather than EPIPE. InputWriter and the harness's Git runs now take both as the program ending early. Agent: DEV3 (developer) Agent-Model: Anthropic Opus 5.5, Medium --- documents/ARCHITECTURE.md | 2 +- src/noldova.checkpoints/src/resources.ts | 2 +- .../fixtures/checkpoints-harness.fixture.ts | 2 +- .../tests/services/input-writer.test.ts | 23 +++++++++++++++++-- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/documents/ARCHITECTURE.md b/documents/ARCHITECTURE.md index 10c1802..2f2b166 100644 --- a/documents/ARCHITECTURE.md +++ b/documents/ARCHITECTURE.md @@ -152,7 +152,7 @@ modules/checkpoints/ - A read writes its requests to `cat-file` while it takes the answers, since `cat-file` stops reading once its output is full. - `cat-file` runs with `--buffer`, and each read's requests end with `flush`, so it writes its answers in full buffers, as do the one-shot `cat-file --batch` runs that list checkpoints and stage a restore's files, which end at the end of their input; without it, each answer is a pipe write of its own. - The module names what it reads by object id; a `:` name makes Git search the commit's trees again for each one, so ten thousand names in one folder of ten thousand files took 40 seconds where their ids took under half a second, and only `checkpoint.json` is read by its path. - - Writing to a command waits while it reads more slowly than the module writes; a command that ends early closes its input, what is written after that is dropped, and its exit says what went wrong. + - Writing to a command waits while it reads more slowly than the module writes; a command that ends early closes its input, which fails a write as a broken pipe or, on macOS, at times as no longer connected; what is written after that is dropped, and its exit says what went wrong. - `cat-file`'s output is read as it comes, in parts cut anywhere: a name with no object answers ` missing`, and that name may hold a space; output that ends inside a content gives no object for it; and a name `cat-file --batch` gave no answer for reads as missing. - `cat-file` finds what a capture wrote after it started, since Git looks for new packs when an object is missing and reads each reference as it's asked for. - A store's commands end once neither has been used for 10 minutes, before its housekeeping ([Keeping and deleting](#keeping-and-deleting)), before its folder is deleted, after a request cancelled while it ran, and as the runtime part deactivates: `cat-file` at the end of its input, `fast-import` at its `done`, each stopped if it still runs 10 seconds later. diff --git a/src/noldova.checkpoints/src/resources.ts b/src/noldova.checkpoints/src/resources.ts index dbe3202..21726d3 100644 --- a/src/noldova.checkpoints/src/resources.ts +++ b/src/noldova.checkpoints/src/resources.ts @@ -76,7 +76,7 @@ export class Resources { public static readonly checkpointReferencePrefix: string = "refs/checkpoints/"; public static readonly checkpointTree: string = "files/ tree"; public static readonly cleaningRecord: string = "Cleaning"; - public static readonly closedInputCodes: readonly string[] = ["EPIPE", "EOF", "ERR_STREAM_DESTROYED", "ERR_STREAM_WRITE_AFTER_END"]; + public static readonly closedInputCodes: readonly string[] = ["ENOTCONN", "EOF", "EPIPE", "ERR_STREAM_DESTROYED", "ERR_STREAM_WRITE_AFTER_END"]; public static readonly commitAuthor: string = "Checkpoints "; public static readonly commitMark: string = ":1"; public static readonly configFile: string = "config"; diff --git a/src/noldova.checkpoints/tests/fixtures/checkpoints-harness.fixture.ts b/src/noldova.checkpoints/tests/fixtures/checkpoints-harness.fixture.ts index 8cb259b..eadf911 100644 --- a/src/noldova.checkpoints/tests/fixtures/checkpoints-harness.fixture.ts +++ b/src/noldova.checkpoints/tests/fixtures/checkpoints-harness.fixture.ts @@ -280,7 +280,7 @@ export class CheckpointsHarness { const git = await this.processes.startAsync(new ProcessRequest(this.build.program, args, this.root, environment)); const refused: Error[] = []; git.input.on("error", (error: Error) => { - if (!FailureFixture.hasCode(error, "EPIPE")) + if (!FailureFixture.hasCode(error, "EPIPE") && !FailureFixture.hasCode(error, "ENOTCONN")) refused.push(error); }); git.input.end(input); diff --git a/src/noldova.checkpoints/tests/services/input-writer.test.ts b/src/noldova.checkpoints/tests/services/input-writer.test.ts index 8c5f5b4..2947e9f 100644 --- a/src/noldova.checkpoints/tests/services/input-writer.test.ts +++ b/src/noldova.checkpoints/tests/services/input-writer.test.ts @@ -14,7 +14,13 @@ import { describe, it } from "node:test"; import { InputWriter } from "../../src/services/input-writer.ts"; class PipeError extends Error { - public readonly code: string = "EPIPE"; + public readonly code: string; + + public constructor(code: string) { + super(`write ${code}`); + + this.code = code; + } } describe("InputWriter", { concurrency: true }, () => { @@ -58,7 +64,20 @@ describe("InputWriter", { concurrency: true }, () => { const writer = new InputWriter(stream); const writing = writer.writeAsync("more than four bytes"); - stream.destroy(new PipeError("write EPIPE")); + stream.destroy(new PipeError("EPIPE")); + await writing; + await writer.writeAsync("after"); + writer.end(); + + assert.equal(stream.destroyed, true); + }); + + it("stops waiting when the program ends early and macOS reports its input as no longer connected, and drops what is written after", async () => { + const stream = new PassThrough({ highWaterMark: 4 }); + const writer = new InputWriter(stream); + + const writing = writer.writeAsync("more than four bytes"); + stream.destroy(new PipeError("ENOTCONN")); await writing; await writer.writeAsync("after"); writer.end();