Repository navigation
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 intoOct 4, 2026
Conversation
… 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>
objectstack-fleet
Bot
deleted the
claude/issue-21659-git-history-log-pipe-drain
branch
October 4, 2026 06:34
This was referenced Oct 4, 2026
This was referenced Oct 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #21659
Clause-②: no
What was wrong
scripts/pm/git-history.mjshands its whole answer to oneprocess.stdout.write, and the entry ended withprocess.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, andprocess.exitdrops that queue. Solog ... | wc -lread one buffer-full, at exit 0.countandtouchprint 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 printedshallow floor: unknown. The refusal itself was correct.The change (one file)
process.exitCodeand 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 takefs.writeSync(1, ...): once anything touchesprocess.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.process.exitcalls insidemain()now return their codes (return 2on the window refusal,return 1on 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.process.stdout'error'listener absorbs EPIPE only, and anything else rethrows. This is new behaviour that the fix makes necessary. Before,| head -1exited 0 quietly becauseprocess.exitthrew 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 unhandledwrite EPIPEtrace and exits 1 (measured; ablation leg 2 below). A reader that closes early made its own choice, so the answer's exit code stands.shallowandboundaries, so the refusal prints the floor it read.Reading one: the reproduction, before and after
Shared clone (shallow). The floor on
3711e0b763is 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.7d0781482d)wc -l, three runsPIPESTATUS[0])7d0781482d)d068d3faa1)wc -l, three runsd068d3faa1)cmp-identical to the before filed068d3faa1)head -1Rider, 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.shallow floor: unknown (the oldest commit this clone can see on that ref)shallow floor: 2026-06-22 (the oldest commit this clone can see on that ref)Reading two: self-test cases, battery and floor
2026-07-06, notunknown)SELF_TEST_BATTERY_FLOOR(roster size)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
55e6f14fmoved it from 4 to 5.The new battery:
logredirected 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%Hfields. 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.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.| head -1shape, 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, atd068d3faa1)Each leg's mutation landed on disk (anchor x1 to x0, replacement x0 to x1, blob changed). Each was restored and proven: blob
1a54ee0b67d4equals HEAD's, andgit diff HEADis empty.process.exit(main(process.argv.slice(2)) || 0);Error: write EPIPE. My first attempt at this leg was a no-op: its replacementthrow 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.shallow/boundariesshallow floor: unknownEvery 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.check-comment-mask-corpus(116 s) andcheck-self-test-workflow-commands(78 s).node scripts/pm/git-history.mjs --self-testtook 10 s.pnpm check:pm-dispatch-gatesran underos-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 --ranwith an exit code recorded per line:32 derived famil(ies) accounted for — 32 run, 0 NOT-MEASURED (a DERIVED zero), 0 UNRUN.eslint --no-inline-config --format json scripts/pm/git-history.mjsreports 1 file, 0 errors, 0 warnings.--print-configfor this file shows two rules,no-restricted-importsandcomment-swallow/no-code-inside-block-comment. Neither hasparserOptions.project, so linting is not type-aware and this diff cannot move any other file's verdict. The fullpnpm lintis left to CI.Acceptance notes
usage()keeps itsprocess.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.process.exit(main(orprocess.exit(await main(in 27 other files underscripts/andscripts/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.scripts/pm/**publishes nothing. The PR carriesskip-changeset.Generated by Claude Code