From 1f286db74db8928c307ca161c68aceaf2739ee1f Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 2 Oct 2026 03:00:45 -0700 Subject: [PATCH 1/4] Fix: recover stale mktree transports after Git GC --- CHANGELOG.md | 4 + patches/@git-stunts+plumbing+3.3.1.patch | 63 +++++++++++ patches/README.md | 14 +++ .../adapters/mktree-session-recovery.test.mjs | 104 ++++++++++++++++++ 4 files changed, 185 insertions(+) create mode 100644 patches/@git-stunts+plumbing+3.3.1.patch create mode 100644 test/unit/infrastructure/adapters/mktree-session-recovery.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 165f3a220..b9a588403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Attachment reads after checkpoint and Git GC recover from a stale mktree + process with one fresh-process retry. Broken-pipe and closed-input errors are + classified at the dependency protocol boundary; unrelated errors still fail. + - Skip edge-property rows with invalid runtime identifier or slot fields, consistently across iterators and attachment projections, while retaining their stored registers. - Validate checkpoint projection property values and preserve absent lifecycle witnesses instead of fabricating invalid birth events. diff --git a/patches/@git-stunts+plumbing+3.3.1.patch b/patches/@git-stunts+plumbing+3.3.1.patch new file mode 100644 index 000000000..901e9f78e --- /dev/null +++ b/patches/@git-stunts+plumbing+3.3.1.patch @@ -0,0 +1,63 @@ +diff --git a/node_modules/@git-stunts/plumbing/src/infrastructure/protocols/GitMktreeSession.js b/node_modules/@git-stunts/plumbing/src/infrastructure/protocols/GitMktreeSession.js +--- a/node_modules/@git-stunts/plumbing/src/infrastructure/protocols/GitMktreeSession.js ++++ b/node_modules/@git-stunts/plumbing/src/infrastructure/protocols/GitMktreeSession.js +@@ -1,3 +1,4 @@ ++import GitPlumbingError from '../../domain/errors/GitPlumbingError.js'; + import GitProtocolError from '../../domain/errors/GitProtocolError.js'; + import InvalidArgumentError from '../../domain/errors/InvalidArgumentError.js'; + import ByteReader from '../ByteReader.js'; +@@ -47,10 +48,10 @@ + const framed = new Uint8Array(record.length + 1); + framed.set(record); + protocolStarted = true; +- await this._session.write(framed); ++ await this._write(framed); + } + protocolStarted = true; +- await this._session.write(NUL); ++ await this._write(NUL); + return await this._readOid('GitMktreeSession.write'); + } catch (error) { + if (protocolStarted) { +@@ -76,7 +77,7 @@ + try { + const payload = await prepareTreeBatch(batch); + protocolStarted = true; +- await this._session.write(payload); ++ await this._write(payload); + const oids = []; + for (let index = 0; index < batch.length; index += 1) { + oids.push(await this._readOid('GitMktreeSession.writeMany')); +@@ -89,6 +90,21 @@ + throw error; + } + }); ++ } ++ ++ async _write(input) { ++ try { ++ await this._session.write(input); ++ } catch (error) { ++ if (isClosedMktreeInput(error)) { ++ throw new GitProtocolError( ++ 'git mktree input closed before its tree response', ++ 'GitMktreeSession._write', ++ { cause: error } ++ ); ++ } ++ throw error; ++ } + } + + async _readOid(operation) { +@@ -267,3 +283,10 @@ + ); + } + } ++ ++function isClosedMktreeInput(error) { ++ if (error instanceof GitPlumbingError) { ++ return error.details.code === 'SESSION_INPUT_CLOSED'; ++ } ++ return error instanceof Error && error.code === 'EPIPE'; ++} diff --git a/patches/README.md b/patches/README.md index 0f6e9b54d..2bfaab074 100644 --- a/patches/README.md +++ b/patches/README.md @@ -15,3 +15,17 @@ This directory contains local modifications to dependencies, managed by [`patch- dependency. - **Status:** Required until upstream publishes equivalent package declarations. + +### `@git-stunts/plumbing@3.3.1` + +- **Issue:** A reused mktree process may exit after external GC repacks its + object database. A raw broken-pipe error bypasses CAS's bounded protocol + recovery, making checkpoint/GC attachment reads fail depending on timing. +- **Fix:** Classify `EPIPE` and typed closed-session input at the mktree write + boundary as `GitProtocolError`. CAS invalidates the session and retries once + with a fresh process. Producer errors, unrelated failures, and reference + publication are unchanged. +- **Evidence:** Deterministic single/batch transport fault injection, retry + exhaustion and error preservation, plus real attachment checkpoint/GC tests. +- **Tracking:** [git-warp #923](https://github.com/git-stunts/git-warp/issues/923). + Remove this patch when the locked upstream dependency includes the repair. diff --git a/test/unit/infrastructure/adapters/mktree-session-recovery.test.mjs b/test/unit/infrastructure/adapters/mktree-session-recovery.test.mjs new file mode 100644 index 000000000..429b2b2bb --- /dev/null +++ b/test/unit/infrastructure/adapters/mktree-session-recovery.test.mjs @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { GitPersistenceAdapter } from '@git-stunts/git-cas'; +import { CommandSession, GitMktreeSession, GitPlumbingError, GitProtocolError } from '@git-stunts/plumbing'; +import { Readable } from 'node:stream'; +import { TextEncoder } from 'node:util'; + +const OID = 'a'.repeat(40); +const TREE = [`100644 blob ${'b'.repeat(40)}\tcontent`]; + +function command(failure) { + let finish; + const finished = new Promise((resolve) => { finish = resolve; }); + function settle({ code, terminated }) { + finish({ code, error: null, signal: null, stderr: '', terminated, timedOut: false }); + } + return new CommandSession({ + stdoutStream: Readable.from([new TextEncoder().encode(`${OID}\n`)]), + finished, + write: async () => { if (failure !== null) { throw failure; } }, + closeInput: async () => { settle({ code: 0, terminated: false }); }, + terminate: () => { settle({ code: 1, terminated: true }); }, + }); +} + +function fixture(failures) { + let openings = 0; + const persistence = new GitPersistenceAdapter({ + plumbing: { + openMktreeSession: async () => { + const failure = failures[openings] ?? null; + openings += 1; + return new GitMktreeSession(command(failure)); + }, + }, + policy: { execute: (operation) => operation() }, + sessionIdleTimeoutMs: 60_000, + }); + return { persistence, openings: () => openings }; +} + +function brokenPipe() { + return Object.assign(new Error('broken pipe'), { code: 'EPIPE' }); +} + +describe('mktree transport recovery', () => { + it.each(['one tree', 'tree batch'])('reopens the process once for a broken pipe writing %s', async (mode) => { + const { persistence, openings } = fixture([brokenPipe()]); + try { + const result = mode === 'one tree' + ? await persistence.writeTree(TREE) + : await persistence.writeTrees([TREE]); + expect(result).toEqual(mode === 'one tree' ? OID : [OID]); + expect(openings()).toBe(2); + } finally { + await persistence.close(); + } + }); + + it('also recovers when process completion wins the race with the next write', async () => { + const failure = new GitPlumbingError('input closed', 'write', { code: 'SESSION_INPUT_CLOSED' }); + const { persistence, openings } = fixture([failure]); + try { + await expect(persistence.writeTree(TREE)).resolves.toBe(OID); + expect(openings()).toBe(2); + } finally { + await persistence.close(); + } + }); + + it('reports a typed failure after two broken processes, without an unbounded retry', async () => { + const { persistence, openings } = fixture([brokenPipe(), brokenPipe()]); + try { + await expect(persistence.writeTree(TREE)).rejects.toBeInstanceOf(GitProtocolError); + expect(openings()).toBe(2); + } finally { + await persistence.close(); + } + }); + + it('preserves other write failures and does not retry them', async () => { + const failure = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const { persistence, openings } = fixture([failure]); + try { + await expect(persistence.writeTree(TREE)).rejects.toBe(failure); + expect(openings()).toBe(1); + } finally { + await persistence.close(); + } + }); + + it('preserves a producer failure even when it has the same error code', async () => { + const failure = brokenPipe(); + const session = new GitMktreeSession(command(null)); + async function* entries() { + yield { mode: '100644', type: 'blob', oid: 'b'.repeat(40), name: 'content' }; + throw failure; + } + try { + await expect(session.write(entries())).rejects.toBe(failure); + } finally { + await session.terminate(); + } + }); +}); From c6dc2ea3f692a6f9cb7d925491545815437f7982 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 2 Oct 2026 03:05:26 -0700 Subject: [PATCH 2/4] Fix: register mktree recovery in the dependency patch inventory --- test/unit/scripts/dependency-hygiene.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/unit/scripts/dependency-hygiene.test.ts b/test/unit/scripts/dependency-hygiene.test.ts index a26a47adc..0e67ef8ed 100644 --- a/test/unit/scripts/dependency-hygiene.test.ts +++ b/test/unit/scripts/dependency-hygiene.test.ts @@ -7,9 +7,15 @@ import { z } from 'zod'; const REPO_ROOT = new URL('../../../', import.meta.url); -const PATCH_PACKAGE_FILES: readonly string[] = ['@git-stunts+trailer-codec+2.1.1.patch']; +const PATCH_PACKAGE_FILES: readonly string[] = [ + '@git-stunts+plumbing+3.3.1.patch', + '@git-stunts+trailer-codec+2.1.1.patch', +]; -const PATCH_PACKAGE_README_HEADINGS: readonly string[] = ['### `@git-stunts/trailer-codec@2.1.1`']; +const PATCH_PACKAGE_README_HEADINGS: readonly string[] = [ + '### `@git-stunts/plumbing@3.3.1`', + '### `@git-stunts/trailer-codec@2.1.1`', +]; const PACKAGE_FILE_SCHEMA = z.object({ dependencies: z.record(z.string()), From 4cd1996bc174ff672a234406bd33f80b0a247da4 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 2 Oct 2026 05:57:08 -0700 Subject: [PATCH 3/4] Fix: adopt published git-cas and Plumbing recovery releases --- CHANGELOG.md | 2 + package-lock.json | 18 +++--- package.json | 4 +- patches/@git-stunts+plumbing+3.3.1.patch | 63 -------------------- patches/README.md | 14 ----- test/runtime/deno/deno.json | 4 +- test/unit/scripts/dependency-hygiene.test.ts | 2 - 7 files changed, 15 insertions(+), 92 deletions(-) delete mode 100644 patches/@git-stunts+plumbing+3.3.1.patch diff --git a/CHANGELOG.md b/CHANGELOG.md index fee8073e5..98275ac3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Attachment reads after checkpoint and Git GC recover from a stale mktree process with one fresh-process retry. Broken-pipe and closed-input errors are classified at the dependency protocol boundary; unrelated errors still fail. + This now uses published git-cas 6.5.11 and Plumbing 3.3.2; the temporary + source-checkout Plumbing patch is removed. - Skip edge-property rows with invalid runtime identifier or slot fields, consistently across iterators and attachment projections, while retaining their stored registers. diff --git a/package-lock.json b/package-lock.json index e4782dbe6..1558d997d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,8 +16,8 @@ "@flyingrobots/bijou-node": "^7.2.0", "@flyingrobots/bijou-tui": "^7.2.0", "@git-stunts/alfred": "^0.10.4", - "@git-stunts/git-cas": "^6.5.10", - "@git-stunts/plumbing": "^3.3.1", + "@git-stunts/git-cas": "^6.5.11", + "@git-stunts/plumbing": "^3.3.2", "@git-stunts/trailer-codec": "^2.1.1", "@noble/hashes": "^2.2.0", "cbor-x": "^1.6.0", @@ -541,9 +541,9 @@ "license": "Apache-2.0" }, "node_modules/@git-stunts/git-cas": { - "version": "6.5.10", - "resolved": "https://registry.npmjs.org/@git-stunts/git-cas/-/git-cas-6.5.10.tgz", - "integrity": "sha512-JemWVMtuzqArMyxnIMBBQFMOIQ3CsvhXKfza9aIjNPxQ3MBt7J/Hm2ePr3k7Z+wtM+MO38BTGA7gzCZ/Hpld4w==", + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/@git-stunts/git-cas/-/git-cas-6.5.11.tgz", + "integrity": "sha512-C6coWmKmOeRZ+X5nP5Ek7spwYpA5q6mgif09u9dDO0ISQQ+Hz4KVxAXrOPRRhNJzLEP74Ndbra55fxX5Cms5gw==", "license": "Apache-2.0", "dependencies": { "@flyingrobots/bijou": "^7.2.0", @@ -551,7 +551,7 @@ "@flyingrobots/bijou-tui": "^7.2.0", "@flyingrobots/bijou-tui-app": "^7.2.0", "@git-stunts/alfred": "^0.10.0", - "@git-stunts/plumbing": "^3.3.0", + "@git-stunts/plumbing": "^3.3.2", "@git-stunts/vault": "^1.0.1", "cbor-x": "^1.6.0", "commander": "14.0.3", @@ -565,9 +565,9 @@ } }, "node_modules/@git-stunts/plumbing": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@git-stunts/plumbing/-/plumbing-3.3.1.tgz", - "integrity": "sha512-/pUSgdv4wE0czYywnCMJy4n4XoJud9B9gBpFcbfNQz/ZOF9WHvGuM/9T6KELcIiy2mWBNMUS+syRyLfYzL1Vmw==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@git-stunts/plumbing/-/plumbing-3.3.2.tgz", + "integrity": "sha512-yGhABF9e+o+Gc2b8L8LkXJgE0ACrJI0MbytxRyhZcHKLJfZFv9LAs8RNxv4KtIXLxQHj2StlW7T14wDk0bXWPw==", "license": "Apache-2.0", "dependencies": { "zod": "^3.24.1" diff --git a/package.json b/package.json index f891b9d8f..72cfc39e1 100644 --- a/package.json +++ b/package.json @@ -163,8 +163,8 @@ "@flyingrobots/bijou-node": "^7.2.0", "@flyingrobots/bijou-tui": "^7.2.0", "@git-stunts/alfred": "^0.10.4", - "@git-stunts/git-cas": "^6.5.10", - "@git-stunts/plumbing": "^3.3.1", + "@git-stunts/git-cas": "^6.5.11", + "@git-stunts/plumbing": "^3.3.2", "@git-stunts/trailer-codec": "^2.1.1", "@noble/hashes": "^2.2.0", "cbor-x": "^1.6.0", diff --git a/patches/@git-stunts+plumbing+3.3.1.patch b/patches/@git-stunts+plumbing+3.3.1.patch deleted file mode 100644 index 901e9f78e..000000000 --- a/patches/@git-stunts+plumbing+3.3.1.patch +++ /dev/null @@ -1,63 +0,0 @@ -diff --git a/node_modules/@git-stunts/plumbing/src/infrastructure/protocols/GitMktreeSession.js b/node_modules/@git-stunts/plumbing/src/infrastructure/protocols/GitMktreeSession.js ---- a/node_modules/@git-stunts/plumbing/src/infrastructure/protocols/GitMktreeSession.js -+++ b/node_modules/@git-stunts/plumbing/src/infrastructure/protocols/GitMktreeSession.js -@@ -1,3 +1,4 @@ -+import GitPlumbingError from '../../domain/errors/GitPlumbingError.js'; - import GitProtocolError from '../../domain/errors/GitProtocolError.js'; - import InvalidArgumentError from '../../domain/errors/InvalidArgumentError.js'; - import ByteReader from '../ByteReader.js'; -@@ -47,10 +48,10 @@ - const framed = new Uint8Array(record.length + 1); - framed.set(record); - protocolStarted = true; -- await this._session.write(framed); -+ await this._write(framed); - } - protocolStarted = true; -- await this._session.write(NUL); -+ await this._write(NUL); - return await this._readOid('GitMktreeSession.write'); - } catch (error) { - if (protocolStarted) { -@@ -76,7 +77,7 @@ - try { - const payload = await prepareTreeBatch(batch); - protocolStarted = true; -- await this._session.write(payload); -+ await this._write(payload); - const oids = []; - for (let index = 0; index < batch.length; index += 1) { - oids.push(await this._readOid('GitMktreeSession.writeMany')); -@@ -89,6 +90,21 @@ - throw error; - } - }); -+ } -+ -+ async _write(input) { -+ try { -+ await this._session.write(input); -+ } catch (error) { -+ if (isClosedMktreeInput(error)) { -+ throw new GitProtocolError( -+ 'git mktree input closed before its tree response', -+ 'GitMktreeSession._write', -+ { cause: error } -+ ); -+ } -+ throw error; -+ } - } - - async _readOid(operation) { -@@ -267,3 +283,10 @@ - ); - } - } -+ -+function isClosedMktreeInput(error) { -+ if (error instanceof GitPlumbingError) { -+ return error.details.code === 'SESSION_INPUT_CLOSED'; -+ } -+ return error instanceof Error && error.code === 'EPIPE'; -+} diff --git a/patches/README.md b/patches/README.md index 2bfaab074..0f6e9b54d 100644 --- a/patches/README.md +++ b/patches/README.md @@ -15,17 +15,3 @@ This directory contains local modifications to dependencies, managed by [`patch- dependency. - **Status:** Required until upstream publishes equivalent package declarations. - -### `@git-stunts/plumbing@3.3.1` - -- **Issue:** A reused mktree process may exit after external GC repacks its - object database. A raw broken-pipe error bypasses CAS's bounded protocol - recovery, making checkpoint/GC attachment reads fail depending on timing. -- **Fix:** Classify `EPIPE` and typed closed-session input at the mktree write - boundary as `GitProtocolError`. CAS invalidates the session and retries once - with a fresh process. Producer errors, unrelated failures, and reference - publication are unchanged. -- **Evidence:** Deterministic single/batch transport fault injection, retry - exhaustion and error preservation, plus real attachment checkpoint/GC tests. -- **Tracking:** [git-warp #923](https://github.com/git-stunts/git-warp/issues/923). - Remove this patch when the locked upstream dependency includes the repair. diff --git a/test/runtime/deno/deno.json b/test/runtime/deno/deno.json index fcecdd5d0..f04ff5a42 100644 --- a/test/runtime/deno/deno.json +++ b/test/runtime/deno/deno.json @@ -1,8 +1,8 @@ { "imports": { "@git-stunts/alfred": "npm:@git-stunts/alfred@^0.10.3", - "@git-stunts/git-cas": "npm:@git-stunts/git-cas@^6.5.10", - "@git-stunts/plumbing": "npm:@git-stunts/plumbing@^3.3.1", + "@git-stunts/git-cas": "npm:@git-stunts/git-cas@^6.5.11", + "@git-stunts/plumbing": "npm:@git-stunts/plumbing@^3.3.2", "@git-stunts/trailer-codec": "npm:@git-stunts/trailer-codec@^2.1.1", "@noble/hashes/blake3.js": "npm:@noble/hashes@^2.2.0/blake3.js", "cbor-x": "npm:cbor-x@^1.6.0", diff --git a/test/unit/scripts/dependency-hygiene.test.ts b/test/unit/scripts/dependency-hygiene.test.ts index 0e67ef8ed..2c9227600 100644 --- a/test/unit/scripts/dependency-hygiene.test.ts +++ b/test/unit/scripts/dependency-hygiene.test.ts @@ -8,12 +8,10 @@ import { z } from 'zod'; const REPO_ROOT = new URL('../../../', import.meta.url); const PATCH_PACKAGE_FILES: readonly string[] = [ - '@git-stunts+plumbing+3.3.1.patch', '@git-stunts+trailer-codec+2.1.1.patch', ]; const PATCH_PACKAGE_README_HEADINGS: readonly string[] = [ - '### `@git-stunts/plumbing@3.3.1`', '### `@git-stunts/trailer-codec@2.1.1`', ]; From 08bde0479afa7974a758797fa7f509ad88aaa86b Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 2 Oct 2026 06:09:07 -0700 Subject: [PATCH 4/4] Fix: advance migration golden for published CAS manifest stamp --- .../v18-v17-public-read-legacy-reading-builder.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/unit/scripts/v18-v17-public-read-legacy-reading-builder.test.ts b/test/unit/scripts/v18-v17-public-read-legacy-reading-builder.test.ts index 863ba7004..83c927a95 100644 --- a/test/unit/scripts/v18-v17-public-read-legacy-reading-builder.test.ts +++ b/test/unit/scripts/v18-v17-public-read-legacy-reading-builder.test.ts @@ -47,9 +47,10 @@ describe('v18 v17 public-read legacy reading builder', () => { 'alice', ]); // git-cas stamps its package version into the manifest formatVersion, so dependency - // bumps intentionally advance this migration-reading golden handle. + // bumps intentionally advance this migration-reading golden handle and manifestHash. + // Verified against published git-cas 6.5.11 with unchanged payload bytes. expect(reading.facts.find((fact) => fact.factKey === 'node:alpha:_content')?.value) - .toBe('git-cas:1:asset:manifest-tree:cbor:sha1:09e785fbd98adf5c00f250598d3a92b0e3e75a33'); + .toBe('git-cas:1:asset:manifest-tree:cbor:sha1:2b67e47826ccb797a7441019756abc3e697792d3'); }); it('fails closed when a restored v17 writer ref drifts after restore', async () => {