Skip to content

feat(interp): add global 10 MiB stdout cap to Runner.Run#161

Merged
thieman merged 5 commits intomainfrom
thieman/global-stdout-cap
Mar 27, 2026
Merged

feat(interp): add global 10 MiB stdout cap to Runner.Run#161
thieman merged 5 commits intomainfrom
thieman/global-stdout-cap

Conversation

@thieman
Copy link
Copy Markdown
Collaborator

@thieman thieman commented Mar 27, 2026

Summary

  • Adds maxStdoutBytes = 10 * 1024 * 1024 constant in runner_expand.go
  • Wraps the runner's stdout writer with a limitWriter in Run() — bytes beyond 10 MiB are silently discarded during execution so builtins never see a mid-stream write error
  • After the script finishes, Run() returns ErrOutputLimitExceeded (an exported sentinel) so callers get a well-defined, checkable error rather than silent truncation
  • Adds TestGlobalStdoutCapReturnsError to verify the error is returned, partial output up to the limit is still delivered, and output below the cap is not suppressed

Callers can detect this condition with errors.Is(err, interp.ErrOutputLimitExceeded).

Complements the existing per-variable (1 MiB) and per-cmdsubst (1 MiB) caps with an end-to-end guarantee at the runner level.

Test plan

  • go test ./interp/... passes
  • go test ./interp/tests/ -run TestGlobalStdoutCapReturnsError exercises the new cap and error return

🤖 Generated with Claude Code

Wrap the runner's stdout writer with a limitWriter at the start of each
Run() call. Bytes beyond maxStdoutBytes (10 MiB) are silently discarded
during execution so builtins never see a mid-stream write error, but the
exceeded flag is checked after the script finishes and Run() returns
ErrOutputLimitExceeded — a stable, exported sentinel — so callers get a
well-defined error rather than a silent truncation.

Also adds TestGlobalStdoutCapReturnsError to verify the cap fires, the
error is returned with the correct value, partial output up to the limit
is still delivered, and output below the cap is not suppressed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@thieman thieman force-pushed the thieman/global-stdout-cap branch from 3e9d397 to 438e9b5 Compare March 27, 2026 16:28
Copy link
Copy Markdown
Collaborator Author

@thieman thieman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(interp): add global 10 MiB stdout cap to Runner.Run

The overall approach is sound — wrapping r.stdout in a limitWriter per Run() call gives a clean, per-invocation cap without touching builtins. The exceeded flag strategy (silent discard during execution, error after) is correct for preventing mid-stream short-write errors. However there is one correctness bug (P1) and several other items worth addressing.


P1 — r.stdout is not unwrapped after Run() returns; successive calls accumulate limitWriter layers

P1 Badge

Run() sets r.stdout = stdoutCap (line 471) but never restores it before returning. The API doc for Run() explicitly says it "can be called multiple times synchronously to interpret programs incrementally". When a caller does exactly that — two or more Run() calls without an intervening Reset() — each call wraps whatever r.stdout currently is in a new limitWriter:

Call 1: r.stdout = limitWriter1{w: callerWriter,    n:0, limit:10MiB}
Call 2: r.stdout = limitWriter2{w: limitWriter1{...}, n:0, limit:10MiB}
         ↑ outer cap is fresh, inner cap still has n from call 1

If call 1 wrote 9 MiB, limitWriter1.n = 9*1024*1024. Call 2 gets a fresh outer cap (10 MiB) but traffic still flows through limitWriter1, which only has 1 MiB of headroom remaining. Any output beyond 1 MiB in call 2 is silently discarded, with no error returned (the outer stdoutCap.exceeded stays false because it delegated bytes to the inner writer which discarded them). The caller of call 2 gets nil error and a truncated buffer.

Fix: restore r.stdout before returning from Run(). The simplest approach is to save the pre-wrap value and defer the restore:

prevStdout := r.stdout
stdoutCap := &limitWriter{w: prevStdout, limit: maxStdoutBytes}
r.stdout = stdoutCap
defer func() { r.stdout = prevStdout }()

This also ensures the deferred panic-recovery path (line 452) does not leave the runner in a corrupted state.

Evidence: Reset() only captures origStdout on the very first call (inside if !r.didReset, lines 369–391). After the first Run(), r.stdout is a limitWriter; Reset() on a subsequent call restores to r.origStdout (line 399) — so Reset() between runs does correctly unwrap. But Run() itself (incremental, no Reset) does not.


P2 — ErrOutputLimitExceeded suppresses fatal errors and exit codes; callers cannot distinguish the two

P2 Badge

The exceeded-cap check (lines 492–495) comes before the fatal-error and exit-code checks (lines 496–503):

if stdoutCap.exceeded {
    return ErrOutputLimitExceeded          // ← always wins
}
if err := r.exit.err; err != nil { ... }   // ← never reached if cap exceeded
if code := r.exit.code; code != 0 { ... } // ← never reached

A script that both exceeds the cap and hits a fatal error (e.g. context cancelled, OOM in variable storage) returns only ErrOutputLimitExceeded. The caller has no way to know the script was also cancelled, and cannot distinguish "script ran to completion but wrote too much" from "script crashed and wrote too much".

For a security-critical shell where callers need to reason about what the script actually did, losing the fatal error signal is problematic. Consider returning the fatal error first (it represents a more severe condition), or wrapping: fmt.Errorf("%w (also: %w)", fatalErr, ErrOutputLimitExceeded) so both are checkable via errors.Is.


P2 — ErrOutputLimitExceeded declared as var with fmt.Errorf; shadowing and mutation are possible

P2 Badge

var ErrOutputLimitExceeded = fmt.Errorf("stdout limit exceeded: ...")

fmt.Errorf creates a plain *errors.errorString. errors.Is works correctly against the same pointer (the test passes), but because this is a mutable var, external packages can reassign it:

interp.ErrOutputLimitExceeded = nil  // silences all limit checks for the caller

The conventional Go sentinel pattern for exported errors is errors.New (same pointer semantics as fmt.Errorf without a format string, equally mutable) or a private type with a public value. Since maxStdoutBytes is not exported, the format string cannot be a constant either way. This is a style/robustness issue rather than a runtime bug, but it is worth noting for an exported API. Using errors.New for the base value and embedding the size in the message separately would be cleaner:

var ErrOutputLimitExceeded = errors.New(fmt.Sprintf("stdout limit exceeded: script produced more than %d MiB of output", maxStdoutBytes/(1024*1024)))

or just accept fmt.Errorf given that errors.Is already works correctly.


P2 — Missing test coverage: multiple Run() calls without Reset()

P2 Badge

The test coverage matrix from the PR description:

Code path Covered?
Script exceeds 10 MiB stdout → ErrOutputLimitExceeded returned Yes (TestGlobalStdoutCapReturnsError)
Script exits non-zero AND exceeds cap → what error is returned? No
Script hits cap exactly at boundary No
Multiple Run() calls — second call not affected by first cap No
Output below cap → nil error Implicitly via existing tests
ErrOutputLimitExceeded is checkable via errors.Is Yes

The multiple-Run case is the most important missing test given the P1 bug above. A test like:

// Run 1: writes 9 MiB (below cap)
// Run 2: writes 5 MiB (should succeed; should NOT be capped at 1 MiB)
// Assert: Run 2 returns nil, outBuf grows by 5 MiB

would catch the stacking bug immediately.


P3 — maxStdoutBytes constant placement differs from maxCmdSubstOutput

P3 Badge

maxCmdSubstOutput is in runner_expand.go at line 50, directly above the code that uses it. maxStdoutBytes is also in runner_expand.go (lines 52–56) but is used exclusively in api.go. Minor: moving maxStdoutBytes to api.go next to ErrOutputLimitExceeded and MaxScriptBytes would keep related constants co-located with their use sites.


P3 — Panic recovery path leaves r.stdout pointing at stdoutCap

P3 Badge

The deferred panic handler (lines 442–454) writes to r.stderr but does not restore r.stdout. After a panic, r.stdout still points to the limitWriter. This is a consequence of the same root cause as the P1 bug; the defer-restore fix above would also address this path.


Summary

The design is correct and the approach is the right one. The P1 bug (stdout writer stacking across incremental Run() calls) needs to be fixed before this lands — it causes silent truncation with a nil error return on the second and subsequent calls. The fix is a one-liner defer. The P2 items (fatal-error ordering, missing tests) should also be addressed. P3 items are minor cleanup.

Comment thread interp/api.go
// the exceeded flag is set so Run() can surface a well-defined error to
// the caller after the script finishes.
stdoutCap := &limitWriter{w: r.stdout, limit: maxStdoutBytes}
r.stdout = stdoutCap
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — r.stdout is never restored after Run() returns.

After this line, r.stdout points to stdoutCap. The next Run() call (without Reset()) will do:

stdoutCap2 := &limitWriter{w: stdoutCap  /* still from call 1 */, limit: maxStdoutBytes}
r.stdout = stdoutCap2

The outer limitWriter has a fresh n=0, so the per-call check works. But all writes flow through stdoutCap (from call 1) whose n still reflects call 1's bytes. If call 1 wrote 9 MiB, call 2 can only write 1 MiB before silent discard — and stdoutCap2.exceeded stays false, so Run() returns nil with truncated output.

Reset() between calls does restore r.origStdout correctly (line 399). But Run() is documented to be callable multiple times incrementally without Reset().

Fix:

prevStdout := r.stdout
stdoutCap := &limitWriter{w: prevStdout, limit: maxStdoutBytes}
r.stdout = stdoutCap
defer func() { r.stdout = prevStdout }()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9f6b8e2. Added prevStdout := r.stdout before wrapping and a defer func() { r.stdout = prevStdout }() so the writer is always restored — including through the panic recovery path.

Comment thread interp/api.go Outdated
return fmt.Errorf("node can only be File, Stmt, or Command: %T", node)
}
// If the script exceeded the stdout cap, report it regardless of exit code.
if stdoutCap.exceeded {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Cap check precedes fatal-error check; fatal errors are silently dropped when cap is also exceeded.

If a script both exceeds the cap and hits a fatal error (context cancellation, variable-storage exhaustion, etc.), this path returns ErrOutputLimitExceeded and the fatal error is lost. Callers that need to distinguish "wrote too much" from "was cancelled" cannot do so.

Consider checking r.exit.fatalExit first, or wrapping both errors so both are inspectable via errors.Is.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9f6b8e2. Moved the stdoutCap.exceeded check after the r.exit.err check so fatal and handler errors take precedence over ErrOutputLimitExceeded.

Comment thread interp/api.go Outdated
// ErrOutputLimitExceeded is returned by Run when a script produces more stdout
// than maxStdoutBytes. Partial output up to the limit is still delivered to the
// caller's writer. Use errors.Is to check for this condition.
var ErrOutputLimitExceeded = fmt.Errorf("stdout limit exceeded: script produced more than %d MiB of output", maxStdoutBytes/(1024*1024))
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — var sentinel is mutable; consider documenting this is intentional or use errors.New.

errors.Is works correctly (pointer equality against the same var). However, being a mutable var, external callers can reassign interp.ErrOutputLimitExceeded = nil and defeat all errors.Is checks. This is a standard Go tradeoff for sentinel errors, but is worth an explicit doc comment or a switch to errors.New (which has identical semantics but makes the intent clearer).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9f6b8e2. Switched from fmt.Errorf to errors.New — the error no longer wraps anything, making the sentinel cleaner.

assert.LessOrEqual(t, outBuf.Len(), 10*1024*1024,
"stdout must not exceed 10 MiB; got %d bytes", outBuf.Len())
assert.Greater(t, outBuf.Len(), 0, "expected non-empty stdout before cap")
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Missing: test for multiple Run() calls without Reset() to verify caps do not stack.

The current test only exercises a single Run() call. A second test that runs two scripts on the same runner (first writes 9 MiB, second writes 5 MiB) would catch the stdout-stacking bug and confirm the second call gets the full 10 MiB budget.

Also missing: a test for a script that both exits non-zero and exceeds the cap, to document which error takes priority.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9f6b8e2. Added TestGlobalStdoutCapMultipleRuns (covers the double-wrap regression — two 9 MiB runs on the same runner, each must succeed with a fresh cap) and TestGlobalStdoutCapPrecedenceOverExitCode (verifies ErrOutputLimitExceeded takes precedence over a non-zero exit code).

thieman and others added 3 commits March 27, 2026 12:45
- Restore r.stdout after each Run() via defer so repeated calls do not
  double-wrap the writer and silently inherit the previous call's byte count
- Move ErrOutputLimitExceeded check after r.exit.err so fatal execution
  errors take precedence over the stdout cap error
- Change ErrOutputLimitExceeded from fmt.Errorf to errors.New (no wrapping)
- Add TestGlobalStdoutCapMultipleRuns to cover the double-wrap regression
- Add TestGlobalStdoutCapPrecedenceOverExitCode to document error ordering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
interp/api.go uses errors.New to define the ErrOutputLimitExceeded
sentinel, but errors.New was missing from the interp allowlist, causing
TestInterpAllowedSymbols and TestVerificationInterpCleanPass to fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@thieman thieman marked this pull request as ready for review March 27, 2026 17:30
Copy link
Copy Markdown
Collaborator

@val06 val06 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

left a few comments

Comment thread interp/api.go Outdated
// ErrOutputLimitExceeded is returned by Run when a script produces more stdout
// than maxStdoutBytes. Partial output up to the limit is still delivered to the
// caller's writer. Use errors.Is to check for this condition.
var ErrOutputLimitExceeded = errors.New("stdout limit exceeded: script produced more than 10 MiB of output")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: should we replace the "magic number" 10 with the relevant const? (maxStdoutBytes)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Sonnet 4.6] Fixed in 2151ff4 — switched to errors.New(fmt.Sprintf("...%d MiB...", maxStdoutBytes/(1024*1024))) so the message is derived from the constant rather than hardcoded.

Comment thread interp/runner_expand.go
// are silently discarded so that callers do not see spurious short-write
// errors mid-execution. The exceeded flag can be checked after execution
// to surface the event as an error.
type limitWriter struct {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: let's add a comment stating this is not thread-safe object (it is passed by pointer today, and the thread-safety depends on the caller discipline: overwriting the stdout field

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Sonnet 4.6] Fixed in 2151ff4 — made limitWriter concurrency-safe by adding a sync.Mutex that is held for the full duration of each Write call (protecting both n and exceeded). Also added an isExceeded() method so the caller in api.go reads the flag under the same lock. The doc comment now describes the guarantee rather than a caveat.

…const

Two issues raised in code review:

1. limitWriter was not safe for concurrent use — the byte counter (n) and
   exceeded flag were unprotected plain fields. Since background shell jobs
   can write to r.stdout concurrently, limitWriter must protect its state.
   Added sync.Mutex; Write() holds it for the full duration so n and
   exceeded are always consistent. Added isExceeded() so callers can also
   read the flag safely.

2. ErrOutputLimitExceeded hardcoded "10 MiB" in the error string. If
   maxStdoutBytes ever changes the message becomes stale. Switch to
   fmt.Sprintf so the message is derived from the constant at init time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@thieman thieman added this pull request to the merge queue Mar 27, 2026
Merged via the queue into main with commit c4d7f67 Mar 27, 2026
34 checks passed
@thieman thieman deleted the thieman/global-stdout-cap branch March 27, 2026 17:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants