diff --git a/scripts/pm/git-history.mjs b/scripts/pm/git-history.mjs index 838505ba352..1a54ee0b67d 100644 --- a/scripts/pm/git-history.mjs +++ b/scripts/pm/git-history.mjs @@ -149,6 +149,33 @@ * what git is handed and what the receipt prints, and a complete instant is * parsed exactly and never consults the clock. * + * ## The fifth trap, no git at all: a piped answer is cut at one buffer-full + * + * The answer leaves in ONE `process.stdout.write`. Node completes that write + * at once when stdout is a FILE, but when stdout is a PIPE (on POSIX) it puts + * in only what the pipe takes in one go and queues the rest for the event + * loop. The entry used to end with `process.exit(main(...))`, which tears the + * process down before that queue drains — so `log … | wc -l`, the natural + * spelling of a count taken through `log`, read one buffer-full at exit 0 + * (#21659). Measured at 7d0781482d on this repository's shallow clone, the + * same `log --since=2026-08-14T00:00:00Z --ref=3711e0b763` two ways: + * + * | stdout | lines | exit | + * |--------------------------------|-------|------| + * | redirected to a file | 5455 | 0 | + * | piped to `wc -l`, three runs | 346 | 0 | + * + * 346 lines is about one 64 KiB pipe buffer. `count` and `touch` print one + * line, which always fits, so they never showed it. The entry therefore sets + * `process.exitCode` and returns: the process ends when its last queued write + * has drained, on every stdout kind. `main()` and `touchMain()` RETURN their + * exit codes; the one `process.exit` left is `usage()`'s, and every call to it + * comes before anything is written to stdout. A reader that closes early + * (`| head -1`) turns the queued rest into EPIPE; that is the reader's choice, + * not a lost answer, so it is absorbed and the answer's own exit code stands. + * The self-test pins a piped `log`, many buffers long, byte for byte against + * the same `log` sent to a file. + * * ## Cost, measured — because a tool nobody runs fixes nothing * * | case | wall | @@ -162,7 +189,8 @@ */ import { execFileSync, spawnSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { closeSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { isEntrypoint } from '../invoked-as.mjs'; @@ -185,15 +213,16 @@ import { isEntrypoint } from '../invoked-as.mjs'; // remedy is to find what stopped registering. const SELF_TEST_BATTERIES = Object.freeze({ 'pure decisions': 10, - 'real repos': 15, + 'real repos': 17, 'historyHorizon: the read-only reading the #9902 adopters call': 12, 'bare dates: the instant the proof reads is the instant git counts': 16, 'touch: the provenance reading a shallow clone fabricates': 26, + 'piped stdout: the answer a pipe reads is the answer a file holds': 4, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 5; +const SELF_TEST_BATTERY_FLOOR = 6; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -303,9 +332,14 @@ export function ensureWindowCovered({ cwd, ref, sinceMs, allowFetch = true, allo const remotes = (git(['remote'], { cwd, allowFail: true }) || '').split('\n').filter(Boolean); const target = splitRemoteRef(ref, remotes); if (!target) { + // The boundaries were READ above; a ref that names no remote (a bare sha, a + // local branch) cannot be deepened, but its floor is still known, so the + // refusal prints it rather than `shallow floor: unknown`. return { covered: false, steps, + shallow: shallow0, + boundaries: boundaries0, reason: `history is truncated inside the window and '${ref}' names no remote to deepen from ` + `(remotes here: ${remotes.length ? remotes.join(', ') : 'none'})`, @@ -751,7 +785,7 @@ function main(argv) { + 'so no success line was printed. Exiting 0 here would report a self-test\n' + 'that never finished as a self-test that passed.\n', ); - process.exit(1); + return 1; } return selfTestCode; } @@ -785,7 +819,7 @@ function main(argv) { ` commits are invisible to git log, which reports no error (#9878).\n` + ` Remedy: git -C ${cwd} fetch --unshallow ${splitRemoteRef(opts.ref, ['origin']) ? 'origin' : ''}\n`, ); - process.exit(2); + return 2; } const receipt = @@ -1055,6 +1089,21 @@ function selfTest() { t('the clone is still shallow after that answer — proving the predicate is the floor, ' + 'not the shallow flag', isShallow(shallowDeep) === true); + // A ref that names no remote — a bare sha, the way a tree is usually quoted — + // cannot be deepened, so a window crossing its floor is refused. The floor + // printed beside that refusal is the one already READ, never `unknown`. + // Nothing is fetched on this path, so `shallowDeep` keeps its depth. + const shallowDeepTip = g(['rev-parse', 'origin/main'], shallowDeep).trim(); + const bareShaRefusal = runCliAllowFail(['count', `--since=${WINDOW_SINCE}`, `--ref=${shallowDeepTip}`], shallowDeep); + t('a bare-sha --ref whose window crosses the shallow floor is REFUSED, exit 2 with EMPTY stdout, ' + + 'because it names no remote to deepen from', + bareShaRefusal.code === 2 && bareShaRefusal.stdout.trim() === '' + && /names no remote to deepen from/.test(bareShaRefusal.stderr), JSON.stringify(bareShaRefusal)); + t('and the refusal prints the floor it READ, 2026-07-06, not `shallow floor: unknown` — the no-remote ' + + 'branch used to return without the boundaries it had already read', + /shallow floor: 2026-07-06 /.test(bareShaRefusal.stderr) && !/shallow floor: unknown/.test(bareShaRefusal.stderr), + bareShaRefusal.stderr); + // ── historyHorizon: the read-only reading the #9902 adopters call ─────── battery('historyHorizon: the read-only reading the #9902 adopters call'); const hFull = historyHorizon({ cwd: full, ref: 'origin/main', sinceMs: Date.parse(WINDOW_SINCE) }); @@ -1373,6 +1422,90 @@ function selfTest() { never.code === 1 && never.stdout.trim() === '' && /no commit on/.test(never.stderr), JSON.stringify(never)); const noPath = runCliAllowFail(['touch'], full); t('touch without --path is usage', noPath.code === 1 && noPath.stdout.trim() === '', JSON.stringify(noPath)); + + // ── piped stdout: the answer a pipe reads is the answer a file holds ──── + // The fifth trap in the header. A file takes the whole answer in one write; + // a pipe takes what fits and the rest waits on the event loop, which the + // entry's old `process.exit()` ended. Every answer above fits in one + // buffer-full, so none of them could see it. This one is padded to many: + // `%<(16384)` pads a field to 16384 columns (git ignores wider), four to a + // line, 21 lines. A child's pipe here is a socketpair, so a buffer-full is + // the socket's send buffer (212992 bytes by default on Linux) rather than a + // FIFO's 64 KiB; the answer is sized well past both. `full` is read-only + // here, so this battery's place after the deepening ones changes nothing. + battery('piped stdout: the answer a pipe reads is the answer a file holds'); + const wideArgs = [ + 'log', `--since=${WINDOW_SINCE}`, `--until=${WINDOW_UNTIL}`, `--format=${'%<(16384)%H'.repeat(4)}`, `--cwd=${full}`, + ]; + const newlines = (buf) => buf.reduce((n, b) => n + (b === 10 ? 1 : 0), 0); + + const wideFile = join(root, 'wide-answer.txt'); + const wideFd = openSync(wideFile, 'w'); + let toFile; + try { + toFile = spawnSync(process.execPath, [self, ...wideArgs], { cwd: full, encoding: 'utf8', stdio: ['ignore', wideFd, 'pipe'] }); + } finally { + closeSync(wideFd); + } + const fileBytes = readFileSync(wideFile); + const fileLines = newlines(fileBytes); + t('FIXTURE — the wide `log` sent to a FILE holds the 21 answer lines, exit 0, and is over 1 MiB: many times ' + + 'what a pipe takes in one go. Read first, because an answer that fits one buffer-full passes the piped ' + + 'pins below with or without the fix', + toFile.status === 0 && fileLines === 21 && fileBytes.length > 1024 * 1024, + `exit ${toFile.status} lines ${fileLines} bytes ${fileBytes.length} stderr ${JSON.stringify(toFile.stderr)}`); + + // The reader is its own process, so the tool's stdout is a real pipe whose + // far end nothing else drains. It attaches its listener at once (Node + // discards a child's unread output when the child exits, which would look + // like a truncation of the reader's own making), holds the stream paused + // for 200 ms so the tool fills the pipe and has to wait, then reads to EOF — + // or, in `close-early` mode, takes one chunk and closes, the shape of `| head -1`. + const PIPE_READER = [ + "const { spawn } = require('node:child_process');", + "const { createHash } = require('node:crypto');", + 'const [mode, ...cmd] = process.argv.slice(1);', + "const child = spawn(process.execPath, cmd, { stdio: ['ignore', 'pipe', 'pipe'] });", + "const hash = createHash('sha256');", + "let bytes = 0; let lines = 0; let stderr = '';", + "child.stderr.on('data', (d) => { stderr += d; });", + 'const take = (d) => { bytes += d.length; lines += d.reduce((n, b) => n + (b === 10 ? 1 : 0), 0); hash.update(d); };', + "if (mode === 'close-early') child.stdout.once('data', (d) => { take(d); child.stdout.destroy(); });", + "else child.stdout.on('data', take);", + 'child.stdout.pause();', + 'setTimeout(() => child.stdout.resume(), 200);', + "child.on('close', (code, signal) => process.stdout.write(JSON.stringify({ code, signal, bytes, lines, sha256: hash.digest('hex'), stderr })));", + ].join('\n'); + const readThroughPipe = (mode) => { + const r = spawnSync(process.execPath, ['-e', PIPE_READER, mode, self, ...wideArgs], { + cwd: full, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 60_000, + }); + try { + return JSON.parse(r.stdout); + } catch { + return { code: null, readerExit: r.status, readerSignal: r.signal, readerError: String(r.error ?? ''), + readerStdout: String(r.stdout).slice(0, 300), stderr: String(r.stderr).slice(0, 300) }; + } + }; + + const piped = readThroughPipe('slow'); + t('the same `log` through a pipe whose reader holds off reads the SAME 21 lines, exit 0 — where the entry\'s ' + + 'old process.exit() delivered one buffer-full and still exited 0', + piped.code === 0 && piped.lines === fileLines, JSON.stringify(piped)); + t('and the piped bytes ARE the file\'s bytes: the same length and the same sha256, not just the same line count', + piped.bytes === fileBytes.length && piped.sha256 === createHash('sha256').update(fileBytes).digest('hex'), + `piped ${piped.bytes} bytes ${piped.sha256} · file ${fileBytes.length} bytes`); + + const early = readThroughPipe('close-early'); + t('a reader that closes after its first chunk (the `| head -1` shape, proved by reading less than the file ' + + 'holds) costs the tool no `write EPIPE` trace: it exits with the answer\'s code, 0, and its stderr is the ' + + 'one-line method receipt', + early.code === 0 && early.bytes > 0 && early.bytes < fileBytes.length + && /^method: git log [^\n]*\n$/.test(early.stderr ?? '') && !/EPIPE/.test(early.stderr ?? ''), + JSON.stringify(early)); } finally { rmSync(root, { recursive: true, force: true }); } @@ -1429,5 +1562,13 @@ function selfTest() { const invokedDirectly = isEntrypoint(import.meta.url); if (invokedDirectly) { - process.exit(main(process.argv.slice(2)) || 0); + // The fifth trap in the header: `process.exit()` here ended the process with + // most of a piped `log` answer still queued. Setting the exit code and + // returning lets the queued writes drain first, on every stdout kind. + process.stdout.on('error', (err) => { + // A reader that stopped reading (`| head -1`) took what it wanted; the rest + // of the answer has nowhere to go. Anything else is a real failure. + if (err?.code !== 'EPIPE') throw err; + }); + process.exitCode = main(process.argv.slice(2)) || 0; }