diff --git a/CHANGELOG.md b/CHANGELOG.md index 961ca7b58..4748204d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,6 +111,12 @@ 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. + This now uses published git-cas 6.5.11 and Plumbing 3.3.2; the temporary + source-checkout Plumbing patch is removed. + - Staged-file and committed-tree path guards now reject machine-local temporary worktree paths as well as personal home paths. A real pre-commit regression verifies that cleaning the working copy cannot conceal unsafe staged content. 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/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/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(); + } + }); +}); diff --git a/test/unit/scripts/dependency-hygiene.test.ts b/test/unit/scripts/dependency-hygiene.test.ts index a26a47adc..2c9227600 100644 --- a/test/unit/scripts/dependency-hygiene.test.ts +++ b/test/unit/scripts/dependency-hygiene.test.ts @@ -7,9 +7,13 @@ 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+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/trailer-codec@2.1.1`', +]; const PACKAGE_FILE_SCHEMA = z.object({ dependencies: z.record(z.string()), 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 () => {