Repository navigation
Commit 38bef8c
fix(pm): git-history lets a piped answer drain before it exits, and a no-remote refusal names its floor (#21685)
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](https://claude.ai/code/session_01CB6W87z22K2yjUCDyVrJRk)_
Co-authored-by: Claude <noreply@anthropic.com>1 parent cef2bd7 commit 38bef8c
1 file changed
Lines changed: 147 additions & 6 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
149 | 149 | | |
150 | 150 | | |
151 | 151 | | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
152 | 179 | | |
153 | 180 | | |
154 | 181 | | |
| |||
162 | 189 | | |
163 | 190 | | |
164 | 191 | | |
165 | | - | |
| 192 | + | |
| 193 | + | |
166 | 194 | | |
167 | 195 | | |
168 | 196 | | |
| |||
185 | 213 | | |
186 | 214 | | |
187 | 215 | | |
188 | | - | |
| 216 | + | |
189 | 217 | | |
190 | 218 | | |
191 | 219 | | |
| 220 | + | |
192 | 221 | | |
193 | 222 | | |
194 | 223 | | |
195 | 224 | | |
196 | | - | |
| 225 | + | |
197 | 226 | | |
198 | 227 | | |
199 | 228 | | |
| |||
303 | 332 | | |
304 | 333 | | |
305 | 334 | | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
306 | 338 | | |
307 | 339 | | |
308 | 340 | | |
| 341 | + | |
| 342 | + | |
309 | 343 | | |
310 | 344 | | |
311 | 345 | | |
| |||
751 | 785 | | |
752 | 786 | | |
753 | 787 | | |
754 | | - | |
| 788 | + | |
755 | 789 | | |
756 | 790 | | |
757 | 791 | | |
| |||
785 | 819 | | |
786 | 820 | | |
787 | 821 | | |
788 | | - | |
| 822 | + | |
789 | 823 | | |
790 | 824 | | |
791 | 825 | | |
| |||
1055 | 1089 | | |
1056 | 1090 | | |
1057 | 1091 | | |
| 1092 | + | |
| 1093 | + | |
| 1094 | + | |
| 1095 | + | |
| 1096 | + | |
| 1097 | + | |
| 1098 | + | |
| 1099 | + | |
| 1100 | + | |
| 1101 | + | |
| 1102 | + | |
| 1103 | + | |
| 1104 | + | |
| 1105 | + | |
| 1106 | + | |
1058 | 1107 | | |
1059 | 1108 | | |
1060 | 1109 | | |
| |||
1373 | 1422 | | |
1374 | 1423 | | |
1375 | 1424 | | |
| 1425 | + | |
| 1426 | + | |
| 1427 | + | |
| 1428 | + | |
| 1429 | + | |
| 1430 | + | |
| 1431 | + | |
| 1432 | + | |
| 1433 | + | |
| 1434 | + | |
| 1435 | + | |
| 1436 | + | |
| 1437 | + | |
| 1438 | + | |
| 1439 | + | |
| 1440 | + | |
| 1441 | + | |
| 1442 | + | |
| 1443 | + | |
| 1444 | + | |
| 1445 | + | |
| 1446 | + | |
| 1447 | + | |
| 1448 | + | |
| 1449 | + | |
| 1450 | + | |
| 1451 | + | |
| 1452 | + | |
| 1453 | + | |
| 1454 | + | |
| 1455 | + | |
| 1456 | + | |
| 1457 | + | |
| 1458 | + | |
| 1459 | + | |
| 1460 | + | |
| 1461 | + | |
| 1462 | + | |
| 1463 | + | |
| 1464 | + | |
| 1465 | + | |
| 1466 | + | |
| 1467 | + | |
| 1468 | + | |
| 1469 | + | |
| 1470 | + | |
| 1471 | + | |
| 1472 | + | |
| 1473 | + | |
| 1474 | + | |
| 1475 | + | |
| 1476 | + | |
| 1477 | + | |
| 1478 | + | |
| 1479 | + | |
| 1480 | + | |
| 1481 | + | |
| 1482 | + | |
| 1483 | + | |
| 1484 | + | |
| 1485 | + | |
| 1486 | + | |
| 1487 | + | |
| 1488 | + | |
| 1489 | + | |
| 1490 | + | |
| 1491 | + | |
| 1492 | + | |
| 1493 | + | |
| 1494 | + | |
| 1495 | + | |
| 1496 | + | |
| 1497 | + | |
| 1498 | + | |
| 1499 | + | |
| 1500 | + | |
| 1501 | + | |
| 1502 | + | |
| 1503 | + | |
| 1504 | + | |
| 1505 | + | |
| 1506 | + | |
| 1507 | + | |
| 1508 | + | |
1376 | 1509 | | |
1377 | 1510 | | |
1378 | 1511 | | |
| |||
1429 | 1562 | | |
1430 | 1563 | | |
1431 | 1564 | | |
1432 | | - | |
| 1565 | + | |
| 1566 | + | |
| 1567 | + | |
| 1568 | + | |
| 1569 | + | |
| 1570 | + | |
| 1571 | + | |
| 1572 | + | |
| 1573 | + | |
1433 | 1574 | | |
0 commit comments