From 842fef1f958f72e0388c0a6b2bba0ffbb7e68aaa Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 11 Nov 2026 14:48:18 -0800 Subject: [PATCH] Let the sealed-output guard check pull requests larger than 256 MiB (GST-2494) (#4300) * Let the sealed-output guard read pull requests larger than 256 MiB (GST-2494) The guard's text check and landed-patch check buffered git diff-tree's whole output under spawnSync's 256 MiB maxBuffer, so a PR with millions of generated lines always refused with an empty-stderr error. Both now spool git's stdout to a private temp file and read it a line at a time after git exits 0; failures name the exit code, signal or timeout. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Accept the platform spawn error text in the missing-git test (Windows says Executable not found) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Reword the sealed guard changelog entry to pass the prose check Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../sealed-guard-stream-large-diffs.md | 4 + docs/troubleshooting.md | 11 + lib/sealed-guard.ts | 110 ++++++-- test/sealed-stream.test.ts | 254 ++++++++++++++++++ 4 files changed, 353 insertions(+), 26 deletions(-) create mode 100644 changelog.d/sealed-guard-stream-large-diffs.md create mode 100644 test/sealed-stream.test.ts diff --git a/changelog.d/sealed-guard-stream-large-diffs.md b/changelog.d/sealed-guard-stream-large-diffs.md new file mode 100644 index 000000000..6082a7f57 --- /dev/null +++ b/changelog.d/sealed-guard-stream-large-diffs.md @@ -0,0 +1,4 @@ +--- +important: blocker +--- +- The sealed-output guard now reads pull requests of any size. The pre-push hook and `gstack-pregate sealed` read every published commit's patch through `git diff-tree`, and held all of git's output in memory under a 256 MiB limit. A pull request with about 4 million lines of generated fixtures produced 352 MB for the text check and 515 MB for the already-landed check, so the guard always refused it with `SEALED_OUTPUT_REFUSED` and an empty `git diff-tree -p failed:`. Both reads now write git's output to a private temporary file and check it one line at a time, so memory stays flat whatever the patch size. Findings, skipped commits and refusal codes are unchanged, and a test runs the old and new code on the same commits and gets the same answer. A git failure still refuses, and the message now names the exit code, the signal or the timeout, for example `git diff-tree -p failed (exit 128): fatal: ...` or `(killed by SIGKILL)`. The temporary file needs free disk space about the size of the patch. docs/troubleshooting.md#sealed-output-refused. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b2a7b2d07..f16e69621 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -5007,6 +5007,17 @@ destination sets `"visibility": "private"` under `sealed` in the base branch's `.gstack/pregate.json`. Capy's pull-request tool may not run local hooks, so run `gstack-pregate sealed` yourself before any push or PR creation. +**`could not read the published commits: git diff-tree -p failed ()`.** +The guard could not read the published patches, so it refuses instead of +passing them unread. `` names what failed: an exit code followed by git's +own error (`exit 128): fatal: ...`), a signal (`killed by SIGKILL`), the +120-second timeout, or a git that could not start. The guard writes git's +output to a temporary file in the system temp directory and reads it a line at +a time, so patch size has no limit, but that directory needs free space about +the size of the patch (hundreds of MB for millions of generated lines); a full +disk shows up as a git exit. Fix the cause and rerun `gstack-pregate sealed`. +Older gstack refused any patch over 256 MiB here with nothing after the colon. + ### `EVAL_GATE_UNPRICED` diff --git a/lib/sealed-guard.ts b/lib/sealed-guard.ts index 773671b6a..38ebcee6f 100644 --- a/lib/sealed-guard.ts +++ b/lib/sealed-guard.ts @@ -41,7 +41,9 @@ * combined-diff matching; any lookup failure scans everything. */ import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import { sha256Hex, writeReceipt } from './egress-receipt'; import { containedRelative, globToRegExp } from './ship-policy'; @@ -88,11 +90,63 @@ export function sealedErrors(raw: unknown): Array<{ path: string; message: strin return errors; } +const GIT_TIMEOUT_MS = 120_000; + function git(repoRoot: string, args: string[], input?: string, encoding: BufferEncoding = 'utf8'): { ok: boolean; out: string; err: string } { - const r = spawnSync('git', args, { cwd: repoRoot, encoding, input, timeout: 120_000, maxBuffer: 256 * 1024 * 1024 }); + const r = spawnSync('git', args, { cwd: repoRoot, encoding, input, timeout: GIT_TIMEOUT_MS, maxBuffer: 256 * 1024 * 1024 }); return { ok: r.status === 0, out: r.stdout ?? '', err: (r.stderr ?? '').trim() }; } +/** Test seams for the streamed reads: read size, git binary and timeout. */ +export interface GitStreamOptions { chunkBytes?: number; bin?: string; timeoutMs?: number } +type GitStdin = { text: string } | { file: string } | null; + +/** Runs git with stdout written to a private temp file, so no output size meets spawnSync's maxBuffer, and passes that file to `use` only after git exits 0. A spawn error, timeout, signal or non-zero exit throws `git