Skip to content

fix(pm): git-history lets a piped answer drain before it exits, and a no-remote refusal names its floor - #21685

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-21659-git-history-log-pipe-drain
Oct 4, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-21659-git-history-log-pipe-drain

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21659
Clause-②: no

What was wrong

scripts/pm/git-history.mjs hands its whole answer to one process.stdout.write, and the entry ended with process.exit(main(...)). On POSIX, Node completes a write to a FILE at once, but a write to a PIPE only as far as the pipe takes it in one go; the rest is queued for the event loop, and process.exit drops that queue. So log ... | wc -l read one buffer-full, at exit 0. count and touch print one line, which always fits.

The rider: ensureWindowCovered() returned from its no-remote branch (a bare-sha or local-branch --ref) without the boundaries it had already read, so that refusal printed shallow floor: unknown. The refusal itself was correct.

The change (one file)

  • The entry sets process.exitCode and returns, so the process ends only after its last queued write has drained, on every stdout kind. This is the ruling's second arm. I did not take fs.writeSync(1, ...): once anything touches process.stdout, libuv puts a pipe stdout in non-blocking mode, and a sync write to a full non-blocking pipe throws EAGAIN, which would swap the truncation for a crash.
  • The two process.exit calls inside main() now return their codes (return 2 on the window refusal, return 1 on the self-test no-verdict branch). They are the same defect form in the same function: an exit after a write that may still be queued. touchMain() already returned its codes. The self-test handshake still prints its message and exits non-zero.
  • A process.stdout 'error' listener absorbs EPIPE only, and anything else rethrows. This is new behaviour that the fix makes necessary. Before, | head -1 exited 0 quietly because process.exit threw the queued rest away before EPIPE could surface. With the exit removed, the queued write meets the closed reader, and with no listener Node prints an unhandled write EPIPE trace and exits 1 (measured; ablation leg 2 below). A reader that closes early made its own choice, so the answer's exit code stands.
  • The rider: the no-remote return now carries shallow and boundaries, so the refusal prints the floor it read.
  • The header gains a "fifth trap" section with the measurement.

Reading one: the reproduction, before and after

Shared clone (shallow). The floor on 3711e0b763 is 2026-06-22 and the window is covered with no fetch (receipt: floor 2026-06-22 · tip 2026-09-29 · floor already predates the window (no fetch)). Command: node scripts/pm/git-history.mjs log --since=2026-08-14T00:00:00Z --ref=3711e0b763.

tree stdout lines producer exit
before (base 7d0781482d) piped to wc -l, three runs 346 · 346 · 346 0 · 0 · 0 (PIPESTATUS[0])
before (base 7d0781482d) redirected to a file 5455 (939,998 bytes) 0
after (d068d3faa1) piped to wc -l, three runs 5455 · 5455 · 5455 0 · 0 · 0
after (d068d3faa1) redirected to a file 5455, cmp-identical to the before file 0
after (d068d3faa1) piped to head -1 (1) 0; stderr is the one-line receipt, no EPIPE trace

Rider, same clone: count --since=2026-06-01T00:00:00Z --ref=3711e0b763 (no fetch: a bare sha names no remote). Exit 2 with empty stdout in both trees.

  • before: shallow floor: unknown (the oldest commit this clone can see on that ref)
  • after: shallow floor: 2026-06-22 (the oldest commit this clone can see on that ref)

Reading two: self-test cases, battery and floor

battery before (cases = pin) after (cases = pin)
pure decisions 10 10
real repos 15 17 (+2: the bare-sha refusal, exit 2 with empty stdout, and its floor 2026-07-06, not unknown)
historyHorizon 12 12
bare dates 16 16
touch 26 26
piped stdout: the answer a pipe reads is the answer a file holds (new) — 4
total printed 79 85
SELF_TEST_BATTERY_FLOOR (roster size) 5 6

How the battery and floor were handled: the AGENTS.md rule "Floor — pin battery NAMES, never one total". The new concern gets its own named battery. Each battery's pin rises to the count it now registers. The roster-size floor moves with the roster, the same way commit 55e6f14f moved it from 4 to 5.

The new battery:

  1. FIXTURE: on the 40-commit fixture, log redirected to a FILE holds the 21 answer lines at exit 0 and is over 1 MiB (1,376,277 bytes). Each line is four 16384-column padded %H fields. git 2.43 honours padding up to 16384 per field and prints a wider request unpadded (measured), so the format uses four fields per line.
  2. The same log, read through a pipe by a reader in its own process, gives the same 21 lines at exit 0. That reader attaches its listener at once, holds the stream paused for 200 ms, then drains it.
  3. The piped bytes ARE the file's bytes: same length, same sha256.
  4. A reader that closes after its first chunk (the | head -1 shape, confirmed by reading less than the file holds) leaves exit 0 and a one-line receipt on stderr, with no EPIPE trace.

The reader's pipe is a socketpair (Node child_process stdio), not a FIFO. Node treats both as PIPE with async writes. The default UNIX-socket send buffer here is 212,992 bytes, so the answer is about 6.5 of those, and 21 times a 64 KiB FIFO buffer.

Ablation (scripts/ablation-replace.mjs, wrap mode, at d068d3faa1)

Each leg's mutation landed on disk (anchor x1 to x0, replacement x0 to x1, blob changed). Each was restored and proven: blob 1a54ee0b67d4 equals HEAD's, and git diff HEAD is empty.

leg mutation self-test
1 entry back to process.exit(main(process.argv.slice(2)) || 0); 2 FAILED: the piped reader got 146,176 bytes / 2 lines against the file's 1,376,277 / 21, child exit 0. FIXTURE and early-close stayed green, as expected.
2 EPIPE absorption removed (every stdout error rethrown) 1 FAILED: early-close child exit 1 with Error: write EPIPE. My first attempt at this leg was a no-op: its replacement throw err; was a substring of the anchor, so ablation-replace refused before running anything (exit 1, restored). I re-ran it with a unique replacement.
3 no-remote return without shallow/boundaries 1 FAILED: shallow floor: unknown

Every leg went red in the expected direction. No ablation file is left in the tree.

Gates (at d068d3faa1)

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no path, with the change set read from git (1 path). It derives 32 commands, identical to the dispatch's list.
  • 31 ran outside the lock: all exit 0. The longest were check-comment-mask-corpus (116 s) and check-self-test-workflow-commands (78 s). node scripts/pm/git-history.mjs --self-test took 10 s.
  • pnpm check:pm-dispatch-gates ran under os-verify-lock.sh: VERDICT command-exit 0 · held the lock 1246s (20m46s) · waited 0s. The battery took 1244.7 s on this box (1976 cases).
  • dispatch-gates --ran with an exit code recorded per line: 32 derived famil(ies) accounted for — 32 run, 0 NOT-MEASURED (a DERIVED zero), 0 UNRUN.
  • Narrowed lint: eslint --no-inline-config --format json scripts/pm/git-history.mjs reports 1 file, 0 errors, 0 warnings. --print-config for this file shows two rules, no-restricted-imports and comment-swallow/no-code-inside-block-comment. Neither has parserOptions.project, so linting is not type-aware and this diff cannot move any other file's verdict. The full pnpm lint is left to CI.

Acceptance notes

  • usage() keeps its process.exit(1). Every call to it comes before anything is written to stdout, and its stderr text is under 1 KiB, a single write an empty pipe takes whole.
  • A text grep finds the spelling process.exit(main( or process.exit(await main( in 27 other files under scripts/ and scripts/pm/ (comments included). One probe, check-entry-guard.mjs --list (9,589 bytes), reads complete through a pipe. No other wrong answer was measured, so nothing is filed: the truncation needs an answer longer than one buffer-full, and none was found.
  • No changeset: scripts/pm/** publishes nothing. The PR carries skip-changeset.
  • The new battery adds about 0.5 s to the self-test (two 200 ms reader holds).

Generated by Claude Code

… no-remote refusal names its floor

The entry ended with process.exit(main(...)). On POSIX a write to a pipe
completes only as far as the pipe takes it in one go, and process.exit
dropped the queued rest, so `log ... | wc -l` read one buffer-full at
exit 0 (346 of 5455 lines). The entry now sets process.exitCode and
returns, main() returns its refusal code instead of exiting, and an
EPIPE from a reader that closed early is absorbed.

ensureWindowCovered() returned from its no-remote branch without the
boundaries it had already read, so a refusal on a bare-sha ref printed
"shallow floor: unknown". It now carries them.

The self-test gains a battery that pins a piped log, over 1 MiB, byte
for byte against the same log sent to a file, plus two real-repos cases
for the bare-sha refusal; the battery floor follows the roster to 6.

Claude-Session: https://claude.ai/code/session_01CB6W87z22K2yjUCDyVrJRk
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Oct 4, 2026
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Oct 4, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 4, 2026 06:06
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 4, 2026 06:06
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit 38bef8c Oct 4, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21659-git-history-log-pipe-drain branch October 4, 2026 06:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants