Skip to content

safe-bash: redirect/pipeline/subshell cleanups register on the exec-root scope and retain 15–36 KB per loop iteration — for i in $(seq 1 6000); do echo $i >f; done = fatal OOM on a 128 MB isolate #664

Description

@kamilio

Where: packages/safe-bash/src/shell/cleanup.tsInvocationScope.register() only appends to #callbacks, drained only in close(). Redirects register on the caller's scope (src/shell/runtime.ts :2293, registerCleanup: cleanup => io[invocationScope].register(cleanup)); pipelines do the same via the InvocationCancellationOwner constructor (:977, parent.register(async () => {...}), invoked at ~:1761 with io[invocationScope]). Inside a loop body io[invocationScope] is the exec root, so every iteration leaks a closure (file output target / streams / cancellation owner) until the whole exec finishes. Instrumentation confirms 1 root registration per echo >f, 4 per echo | cat, 1 per $(...) / ( ), versus 5 total for a plain : loop.

PoC (≤50 bytes each, default limits, within maxCommands=10 000):

for i in $(seq 1 6000); do echo $i >/work/f; done
for i in $(seq 1 4000); do echo $i | cat; done | wc -l
for i in $(seq 1 3000); do echo $i | cat | cat; done | wc -l

Run: NODE_OPTIONS=--max-old-space-size=128 npx tsx harness.mts '<script>'

Measured: forced-GC sampling (--expose-gc) grows linearly: echo $i >/work/f ×6000 → 93 MB live at end (≈15.5 KB/iter); echo $i | cat ×3000 → 109 MB (≈36 KB/iter); { echo $i; } >f ×3000 → 50 MB; (echo $i) ×3000 → 28 MB; x=$(echo $i) ×3000 → 30 MB; all freed after exec. Default-heap peaks for 1000/2000/4000/8000 redirects: 38/51/117/216 MB. 128 MB isolate: all three PoCs FATAL ERROR: … JavaScript heap out of memory (exit 0 on unconstrained Node; re-verified independently). Controls that stay flat (~5 MB): for … do echo $i; done >/work/f and cat <<<$i loops — the #633 heredoc fix does not cover these sites. cloudflareWorkerLimits survives only because its maxCommands is 1000 (peak ~30 MB).

Impact: (d) — a ≤50-byte script kills the isolate with every budget green. Same family as closed #613/#632/#633 (per-invocation retention), at different sites: per-redirect target cleanup, per-pipeline cancellation owner, subshell, command substitution.

Fix: register redirect/pipeline cleanups on the per-command child scope (snapshotScope from ~:1934, or a child() created in redirect()/pipeline setup) and close it when the command completes; or have InvocationScope.register drop callbacks once their owner is finalized.

Found in security audit v3 (2026-09-07).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions