Skip to content

feat: background job log size ceiling, hardened by an adversarial pass - #12

Merged
lloydsk merged 13 commits into
mainfrom
lloydsk/log-size-ceiling
Sep 20, 2026
Merged

lloydsk merged 13 commits into
mainfrom
lloydsk/log-size-ceiling

Conversation

@lloydsk

@lloydsk lloydsk commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator

What

Cap a background job's log so a runaway job (yes, a spew loop, a pathological build) cannot fill the disk, and make every reader honest about a log that lost its end. The branch also carries the follow-up work it accumulated: deprecating the machine-global jobs dir, consolidating CI on Bun, and two bggrep test gaps.

The ceiling

stdout+stderr went straight to the log fd with no write bound. The read side was already bounded; the write side is now capped too, inside the detached process tree so it still holds after pi exits or crashes:

  • Keeps the first N bytes. maxLogBytes / PI_BGRUN_MAX_LOG_BYTES, default 64 MiB; 0 = unlimited (the previous behavior, verbatim wrapper path).
  • The job is not killed and keeps its real exit code: the copier drains past the cap instead of SIGPIPE'ing the producer into 141.
  • Mechanism: a fifo plus one copier process (perl, else dd/head) that caps, flags, and drains; the exit code travels out-of-band, and completion follows the command's pid, not the stream — so sleep 30 & echo done wakes immediately instead of hanging.
  • Visible: __BGRUN_TRUNC__ output truncated: kept the first <N> bytes before the marker, and the marker itself carries truncated=<N> (or nocap=1 when mkfifo was unavailable and the job ran uncapped). Reserved __BGRUN_*__ lines; readers classify by the marker flag, never by matching text, so a command echoing a notice-shaped line cannot fake a capped log.
  • Every agent-facing surface is labelled: wake Stats gains log truncated at <N>, bgtail/bggrep append a note and report truncatedAtBytes, and a configured digest scorecard is skipped rather than scored against a log that lost its end.
  • Readers stay readable-whole: the widest bytes window is the ceiling plus the wrapper's overhead (a capped log always exceeds the cap), and a window is additionally limited to its last 500 000 lines — 64 MiB of one-character lines was measured at >3 GB of RSS. Both limits announce themselves.

Behavior change: the cap is on by default. maxLogBytes: 0 restores uncapped logs.

Ten defects an adversarial pass reproduced, all fixed and pinned

  1. A ceiling literal sh cannot parse emptied the log (1e21head -c 1e+21); a fractional cap truncated to 0, which means unlimited. → integer normalization, floor of 1.
  2. A failed mkfifo ran uncapped silently__BGRUN_NOCAP__ notice + nocap=1.
  3. Truncation was inferable from printable text → flag in the marker, reserved notice lines.
  4. A backgrounded child held the job open: no wake for 30 s, never for a daemon → wait on the command's pid + bounded drain grace.
  5. The widest window could not cover a capped log → window max = ceiling + overhead.
  6. A window could be materialized line-by-line without bound (>3 GB) → 500k-line scan bound, reported.
  7. countLogLines bounded its scan by the cap, dropping the line count for every capped job → fstatSync tail offset, window-max bound.
  8. Stale staging files were never swept, while sweeping by mtime alone would delete a running job's fifo → sweep by mtime, skipping stems whose .pid is alive.
  9. The line bound fired on logs that fit (false caveat) → only report a trim when bytes were actually dropped.
  10. The CI allowlist guard could pass without checking anything (a failing pack printed ? files and the step stayed green).

Measured, not asserted: with this branch's test file run against the pre-round source (e7f0669, the tee | head -c | cat shape), 14 tests fail — including the backgrounded-child test timing out, which is the old hang.

Also in this branch

  • Deprecated jobs dir: project-scoped logs are the model; PI_BGRUN_GLOBAL_DIR / ~/.pi-bgrun/jobs are documented as deprecated with a migration path. No behavior change today — an absolute jobsDir/PI_BGRUN_DIR still works.
  • CI is bun-only (lint, tests, pack). The V8/worker_threads side of bggrep's bounded matching is verified by hand with node --test; two new tests close the worker-abort and sync-fallback gaps.
  • The allowlist guard now runs npm pack, the packer that actually publishes (release.yml uses npm publish with OIDC); both packers emit the same 7 files today, and the step now fails loudly instead of reporting ? files.
  • Docs: docs/log-size-ceiling.md (design rationale, measurements, rejected alternatives, the defects above as why the wrapper is not simpler), docs/dogfooding.md (the maintainers' local config, gitignored here — per-contributor state, not repo policy), and README/SKILL updated to the real notice literals, which they had never matched.

Verification

  • bun test extension/index.test.ts — 193/193 (what CI runs)
  • node --test extension/index.test.ts — 193/193 (Node 24.15, run by hand; the V8 path)
  • tsc --noEmit clean, actionlint clean
  • npm pack --dry-run guard exercised against both packers: 7 files, no test or docs leak

The write side of a job log was unbounded: stdout+stderr went straight to the
log fd, so a runaway job (`yes`, a spew loop, a pathological build) could fill
the disk. The read side was already bounded, so the fix is a ceiling enforced
INSIDE the detached process tree — it has to hold after pi exits or crashes.

Wrapper (extension/index.ts): the command's output is tee'd into a byte counter
and piped through `head -c CAP` into the log, with `cat >/dev/null` draining the
rest, so the producer never gets SIGPIPE and the job still exits with its real
code (the code travels through a file — a pipeline's `$?` is the reader's). The
count comes from the tee'd copy, not from "what head left behind": head
over-reads into its buffer, so the remainder under-reports (a 1500-byte stream
capped at 1000 leaves 0, not 500), and the log fd cannot be re-opened for
sizing (it is O_WRONLY). If mkfifo fails the wrapper falls back to uncapped —
losing output is worse than losing the ceiling.

- `maxLogBytes` / `PI_BGRUN_MAX_LOG_BYTES`, default 64 MiB, 0 = unlimited (a
  blank env var does NOT mean unlimited). Read per job at spawn time.
- The cap keeps the FIRST N bytes; there is no portable in-tree tail cap, and a
  ring buffer would break the marker-at-tail contract every reader relies on.
- Truncation is never silent: the log gets `[pi-bgrun] output truncated at N
  bytes (first N bytes kept)` on the line before the exit marker, and readers
  filter it like the exit marker. The wake's Stats line says so, a configured
  digest scorecard is SKIPPED rather than scored against a log that lost its end
  (summaries and failure lists live there), and bgtail/bggrep append a note and
  report truncatedAtBytes in their details.
- bgtail/bggrep take `bytes` (2 MiB default, max 64 MiB). Widening only changes
  how much is SCANNED — the returned text stays capped by the condenser, so a
  wider window costs latency and memory, not context. A changed window resets
  bgtail's delta instead of reporting never-seen lines as new.
- countLogLines is bounded (64 MiB, else the stat is omitted) and takes its tail
  offset from fstatSync; the sweep reclaims `.tmp-*.{log,ec,fifo,cnt}` staging
  files; tailBookmarks is evicted when a log is removed and capped at 1000.

Tests: 18 new, covering the cap boundary (exact-cap is not "truncated"),
exit-code preservation through the pipeline, a multi-megabyte flood not dying of
SIGPIPE, no staging strays, digest/stat/last-line honesty, the `bytes` window,
and config normalization.
Project-scoped logs are the model: one shared `~/.pi-bgrun/jobs` means
cross-project clutter, ambiguous `bgstatus` scope, cleanup reaching into other
projects' runs, and logs outside the workspace that project-sandboxed analysis
tools would have to be handed by absolute path.

Staged retirement, nothing breaks today:

- `PI_BGRUN_GLOBAL_DIR` and the `~/.pi-bgrun/jobs` destination are marked
  deprecated in the env table, the Roadmap and the run-bg skill, with a
  dedicated section covering why, what is lost (cross-project discovery) and how
  to migrate. Removal is reserved for a future major.
- An existing absolute `jobsDir` / `PI_BGRUN_DIR` keeps working exactly as
  before, and a cwd with no project root still falls back to the global dir —
  the alternative is scattering logs into an arbitrary cwd.

Also resolves home through a HOME-first `homeDir()` helper
(`process.env.HOME || homedir()`) for the global jobs dir, `~` expansion, the
project-root exclusion and the user-config path. Node's os.homedir() already
behaves this way; Bun's ignores HOME entirely, which is why the suite had to
lean on PI_BGRUN_GLOBAL_DIR (and why resolveConfig carries a userConfigPath test
seam). Tests now pin HOME, so retiring the deprecated knob will not require
rewriting them. Green under both runners: bun and `node --test` (183/183 each).
Bun already ran every step: `bunx tsc` covers `npx tsc`, `npm test` just shells
`bun test`, and `bun pm pack --dry-run` prints the same packed file list plus
`Total files: N` that the tarball-allowlist guard needs (the guard's parse is
re-pointed at Bun's casing). The setup-node step was installing a toolchain
nothing used.

What this gives up: the V8/worker_threads side of bggrep's bounded matching is
no longer exercised in CI. Bun implements node:worker_threads, so the worker
MECHANISM is still covered — only V8's backtracking behaviour is not. Insurance
is a comment in the workflow: `node --test extension/index.test.ts` passes
183/183 in ~16s (Node 24.15); re-run it by hand after touching the worker path.
…ical test

`bggrep: a pathological regex returns within the budget instead of hanging`
never produced a pathological match. The log line was 60 000 "a"s plus a "b",
but bggrep pre-truncates every line to BGGREP_LINE_CAP = 10 000 BEFORE matching,
so the "b" that forces backtracking was cut away and `^(a+)+$` matched the
remaining all-"a" line in 0ms on both engines. The failing character now sits
inside the cap window, which makes the test real: on Node/V8 the same input
spends the full 2s budget and requires the worker to be terminated (2004ms
measured), while on Bun/JSC it answers in ~250ms.

That split is the point: V8 backtracks exponentially where JSC does not —
`^(a+)+$` over 100 "a"s + "!" hangs Node past 10s and returns on Bun in ~250ms —
so an input-driven catastrophic pattern cannot assert the terminate() behaviour
portably. matchLinesWithBudget() therefore takes an injectable worker body
(defaulting to BGGREP_WORKER_SOURCE) and is exported for tests: a worker that
never returns proves the budget still ends it, on any engine, in ~300ms.

No behaviour change: the parameter is optional and only tests pass it.
matchLinesSyncBounded runs only where node:worker_threads is unavailable — never
on Node or Bun — so nothing exercised it: a regression there would ship silently
and surface as "bggrep behaves differently in that environment", which is the
worst way to find out. Exported for tests (like the other seams), and covered:

- Parity with the worker path: same matchIdx for matches, misses, the per-line
  cap (a NEEDLE past cap 10 must be missed by both), and the same `invalid`
  outcome for a bad pattern. Parity is the contract — the fallback exists to
  degrade, not to disagree.
- Both budget guards: fired before the first line when the budget is already
  spent, and re-checked mid-scan (a 0ms budget over 300 000 lines aborts instead
  of finishing the corpus). That guard is what keeps an unbounded scan off the
  main thread, which is the whole reason the worker exists.

Suite: 186 pass / 0 fail under bun, and 186/186 under `node --test`.
bun publish has no OIDC trusted-publishing or provenance support — it
authenticates with a long-lived NPM_CONFIG_TOKEN — while this workflow relies on
id-token: write and stores no token at all. Recorded next to the Node setup so a
future 'make CI bun-only' sweep does not quietly downgrade the release path.
An adversarial pass over the committed ceiling (e7f0669, the
`tee | head -c | cat` shape) reproduced eight defects. Each is now fixed and
pinned by a test; with this test file run against e7f0669, 14 tests fail.

- A ceiling literal `sh` cannot parse emptied the log: `1e21` reached the
  wrapper as `head -c 1e+21` (errors, writes nothing), and a fractional cap
  truncated to 0, which means unlimited — so the cap silently vanished.
  Normalize to an integer in 1..MAX_SAFE_INTEGER before it is interpolated.
- A failed `mkfifo` ran uncapped *silently*: the fallback now prints a notice
  and sets the marker's `nocap` flag, so "ceiling unavailable" is not
  indistinguishable from "output was that small".
- Truncation was inferable from printable text (a command echoing a
  notice-shaped line could fake a capped log). The state now comes from the
  writer: a fifo drain reports the byte budget, and the flag rides the exit
  marker's own line.
- A backgrounded child held the job open: as a pipeline stage the wrapper waited
  for pipe EOF, so `sleep 30 & echo done` produced no wake for 30s — never, for
  a daemon. Completion now follows the command's pid, with a bounded drain
  grace.
- The widest `bytes` window could not cover a capped log: it was clamped to the
  ceiling itself, which a capped log always exceeds. `readWindowMax()` is the
  ceiling plus the wrapper's overhead.
- A window could be materialized line-by-line without bound (>3 GB of RSS for
  64 MiB of one-character lines, i.e. an OOM on the log class the ceiling exists
  for). Bound to the last 500k lines, with a labelled note so a trimmed window
  is never reported as a plain "none" — and only report that note when the
  line limit actually trimmed bytes.
- `countLogLines` scoped its scan bound to the cap, which would drop the line
  count for every capped job; it takes the tail offset from `fstatSync` and
  bounds the scan by the window bound instead.
- Staging files outlived the sweep, while sweeping them on mtime alone would
  delete a running job's staged fifo. Sweep by mtime, skipping any stem whose
  `.pid` is a live process.
The tarball that ships is built by `npm publish` (release.yml, OIDC trusted
publishing), so the allowlist must be checked with the packer that actually
produces it. Both packers emit the same 7 files today; keeping that true is the
point of the step. npm is preinstalled on the runner, so ci.yml stays bun-only
for every other step.

Also fix the guard's silent-pass hole: a failing `bun pm pack` printed
`? files` and the step still went green, because the leak greps below cannot
fail on an empty listing. It now fails on a non-zero pack status or a missing
file count, and the count is extracted with sed (npm lowercases "total files:",
and GNU `grep -oP` was never portable).
…ne bounds)

Reading a capped log is now honestly bounded and honestly described: the flag
lives in the exit marker rather than in printable text (so a command echoing a
notice-shaped line is content, not a signal), the widest `bytes` window is the
ceiling plus 4 KiB so it can span the whole kept log, a window is additionally
limited to its last 500k lines, and when that bites bgtail/bggrep say so.
The design record for the ceiling and the seven review passes behind it: why the
cap keeps the first bytes, why the mechanism lives inside the detached tree, the
fifo/copier shape and the alternatives it beat, and each defect an adversarial
pass reproduced against the previous shape.

Not part of the published tarball (package.json files[]).
The brief at the repo root was a process artifact — status line, commit list,
time estimate, "re-verified against <sha>" — exactly the content that rots once
it is tracked. Its substance now lives in docs/log-size-ceiling.md: the problem
and the five constraints, the shipped fifo/copier shape with each load-bearing
oddity explained, the measurement table that rules out the cheap truncation
detectors, the visible-and-unforgeable contract, reader bounds, config
normalization, the defects already paid for, and the rejected alternatives.
docs/ is the home for design notes of this kind from here on.

Also fix what the docs claimed the truncation notice looks like. README and the
run-bg skill promised `[pi-bgrun] output truncated at <N> bytes (first <N>
bytes kept)`; the wrapper has never printed that. The shipped lines are
`__BGRUN_TRUNC__ output truncated: kept the first <N> bytes` and, when the
ceiling could not be installed, `__BGRUN_NOCAP__ log ceiling unavailable`. Both
are reserved `__BGRUN_*__` lines, identified by the marker's flag rather than by
matching text — an agent following the skill was looking for a string that does
not exist.

And describe both wrapper paths in "How it works", which still showed only the
uncapped one-liner.
`.pi/pi-bgrun.json` is per-contributor state, not repo policy: it is read only
for a trusted project, it changes what every bgrun job in the checkout does
(`showCompletedJobs`, plus a digest that shells out at wake time), and two of the
three keys it usually carries are noise (`jobsDir` restates the default). So the
repo now ignores its own copy and the maintainer setup lives in
docs/dogfooding.md — the config to copy, what each key does, how to see the
scorecard on a real run, and how config layering unwinds it.

The doc is the live file's own content, so it stays honest by being exercised:
both of this maintainer's worktrees run it. README's configuration section says
plainly why such a file is not shared.
@github-actions

Copy link
Copy Markdown

CI report

Check Result
tsc --noEmit success
tests success
npm pack --dry-run success (7 files in tarball)

Ref: f11906d796ab0b24efc942da69d08245242d8547

@lloydsk
lloydsk merged commit ae62842 into main Sep 20, 2026
1 check passed
@lloydsk
lloydsk deleted the lloydsk/log-size-ceiling branch September 20, 2026 23:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant