Skip to content

test(indirect): enforce real bounds in the hostile-cycle harness - #2676

Merged
chaliy merged 8 commits into
mainfrom
codex/indirect-expansion-recursion
Oct 11, 2026
Merged

chaliy merged 8 commits into
mainfrom
codex/indirect-expansion-recursion

Conversation

@chaliy

@chaliy chaliy commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Corrective follow-up to #2674, resolving the remaining review (#2674 (comment)).

What changed (tests only; no interpreter change)

The hostile-cycle child runner now enforces real bounds instead of collecting everything and truncating after the fact:

  • one reader thread per pipe drains concurrently (a chatty child can no longer deadlock the runner on a full pipe);
  • an 8 KiB per-pipe sample cap: past the cap the reader stops, the child is killed, and the run is marked oversized — a harness failure, never a silent cut;
  • wall-clock watchdog kills past the limit; the real producer exit status is recorded;
  • the child launches through fail-closed ulimit -t/-s CPU/stack caps (|| exit 99 before exec, so a refusal can never run unbounded) with exec keeping the CLI as the real producer; effective values are read back and asserted per run (Rust std exposes no portable per-child CPU/stack/memory caps; Darwin address-space limits are documented as unenforced, not claimed).

Helper proof (fails visibly on regression)

  • bounded_helper_kills_hung_child: sleep 30 under a 3 s limit must report timeout;
  • bounded_helper_flags_oversize_output: a ~100 KB overproducer must report oversize with a capped sample.
  • Hostile self/mutual cycles assert refusal (invalid variable name, real producer exit 1, empty stdout) plus non-oversize.

Risk

  • Low (test-only)
  • What can break: environments without bash for the launcher shell (the suite already requires bash for real-bash differentials)

Checklist

  • Tests added or updated
  • Backward compatibility considered (no interpreter change)

Follow-up to #2674; resolves the remaining review comment linked above.

chaliy and others added 6 commits October 10, 2026 19:40
…sion

An indirect array target such as r='a[0]:-${!r}]' passed validation whole, so desugar re-parsed ${a[0]:-${!r}]} and the unset-element default re-entered indirect expansion without bound (stack overflow, SIGABRT).

Truncate the resolved bracket target at the first ] after [ before validation. Real bash resolves a[0] and ignores the rest (verified oracles), so trailing operands — including nested ${!...} — never reach the re-parse. Plain names, a[$i], a[@], assoc subscripts pass through unchanged; $(/backquote/process-substitution guards unchanged.

Co-Authored-By: yolop <yolop@everruns.com>
…oundary

PR 2673 truncated indirect array targets at the first ']', which broke valid nested subscripts (a[b[0]] errored; both real bash versions print two). The boundary now ends at the close of the first complete [...] reference, tracking nested brackets and skipping quoted/escaped text, so trailing operators still never reach the desugar re-parse (self/mutual ${!...} cycles terminate with bash-parity output) while nested and quoted-key references resolve whole.

Tests: hostile self/mutual cycles run in a bounded child CLI under a wall-clock watchdog with output caps and recorded producer status; finite nested indexed indirects assert byte equality with real bash; assoc direct/indirect agreement. Renumbers the prior TM-DOS-134 rows to TM-DOS-135 (tilde budget work owns 134).

Co-Authored-By: yolop <yolop@everruns.com>
…plete

Modern bash (5.x) rejects indirect array targets with trailing operator/nesting text (invalid variable name); older 3.2 ignored the tail. Refusal (instead of stripping) keeps nested ${!...} from the desugar re-parse while valid nested subscripts (a[b[0]]) resolve whole, matching bash 5.x in every probed shape.

Hostile self/mutual cycles run in a bounded child CLI under a wall-clock watchdog with output caps and recorded producer status; finite nested indexed indirects assert byte equality with real bash on both bash generations for complete references; assoc direct/indirect agreement. TM-DOS-135 (tilde owns 134).

Co-Authored-By: yolop <yolop@everruns.com>
The bounded child runner now drains each pipe on its own reader thread with an 8 KiB sample cap, kills the child past the wall limit or on oversize, and launches through best-effort ulimit CPU/stack caps with exec keeping the CLI as producer. Oversize and timeout are harness failures, never silent cuts.

Adds helper self-tests: a hung child must report timeout, an overproducer must report oversize. Hostile self/mutual cycles assert refusal with the real producer exit.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
bashkit 32dd777 Commit Preview URL

Branch Preview URL
Oct 11 2026, 04:17 AM

@chaliy

chaliy commented Oct 11, 2026

Copy link
Copy Markdown
Contributor Author

Review of head ceba71b:

The concurrent readers and visible oversize flag resolve the old silent
truncate-after-collection concern. Two remaining bounded-runner gaps need
resolution before marking the earlier review complete or merging this draft:

  1. CPU/stack bounds are explicitly best-effort: the launcher suppresses both
    ulimit failures and continues to exec. The required hostile-child evidence
    must fail closed when a required supported-platform limit cannot be applied.
    Verify both limit-setting statuses before exec; record/read back effective
    limits on Linux/macOS and exercise launcher failure with a harmless child.
    Keep Darwin address-space limitation explicit, not claimed as enforced. Do
    not remove hooks, weaken platform checks, or introduce a casual dependency.

  2. The poll loop exits as soon as both output buffers arrive, regardless of
    whether the child has exited. It then calls child.wait() without a deadline.
    Both pipes reaching EOF does not establish process termination; a child that
    closes output but remains running can bypass the wall watchdog. Likewise,
    after a status is observed, blocking recv/reader joins have no deadline if a
    descendant retains pipe handles. Keep child termination and pipe draining
    within the same explicit wall deadline, reap the intended process/tree using
    existing platform conventions, and fail visibly on incomplete readers rather
    than enter unbounded waits. Add harmless helper-level coverage for early pipe
    closure while the producer remains live and for inherited pipe retention;
    do not use an uncontrolled hostile Bash/OOM probe.

Read all actual comment bodies completely, not counts or head snippets. Retain
self/mutual rejection, finite compatibility, hung-child and overproducer cases
with raw producer exits. Current CI is pending, not failed. Keep draft until
the real review concerns and required CI are resolved; actual merged-revision
bounded proof still precedes finding closure. Do not duplicate this PR or the
already queued recovery. Future commits/squash/body keep human attribution.

The hostile-cycle harness launcher now refuses without CPU/stack caps (exit 99) instead of suppressing ulimit failures, and reports the effective limits for the harness to assert. One shared wall deadline covers child exit, pipe drain, and reaping: pipe EOF alone never ends the run, retained pipes are reported incomplete within bounds, and oversize still fails visibly.

Self-tests cover launcher refusal, early pipe close while live, retained pipes, hung children, and overproducers.
@chaliy

chaliy commented Oct 11, 2026

Copy link
Copy Markdown
Contributor Author

Resolving each item in the blocking verification note (#2676 (comment)):

  1. Fail-closed limits — done. The launcher no longer suppresses anything: ulimit -s 4096 || exit 99; ulimit -t 10 || exit 99; a refusal exits 99 before the producer starts, which fails any harness expecting a real result. Effective values are read back (harness-limits: stack=… cpu=… on stderr, stripped into the run record) and the hostile test asserts stack=4096/cpu=10 on the actual hostile run. bounded_launcher_failure_is_fail_closed shadows ulimit with a failing function in a harmless instant child and asserts exit 99 with no producer output. Darwin address-space limits remain documented as unenforced, not claimed; no new dependencies (std threads + pipes only).

  2. Termination vs pipe-EOF race — done. One shared wall deadline now covers child exit, pipe drain, and reaping: pipe EOF alone never ends the run (only an observed exit does), every post-deadline recv/join is grace-bounded, the child is always reaped explicitly, and readers unfinished after the grace are reported incomplete_readers (a harness failure) with their threads dropped, never joined without a deadline. Direct children are reaped via wait; foreign descendants are detected, not claimed as reaped (no platform group APIs in std). Coverage: bounded_helper_covers_early_pipe_close (echo hi; exec 1>&- 2>&-; sleep 30— both pipes EOF while live — must time out, and would hang the old code forever),bounded_helper_flags_retained_pipes(real bash exits while a backgroundedsleep` retains the pipes — must report incomplete with the real exit 0, never hang). Prior hung/oversize/hostile/boundary/differential cases retained with raw producer exits.

Draft stays until CI is green; no merge before that.

@chaliy

chaliy commented Oct 11, 2026

Copy link
Copy Markdown
Contributor Author

Review of head2df6999233ed1486f847712a477b0da09e6667f7:

Fail-closed ulimit setup/readback and early-EOF/inherited-pipe coverage are substantial improvements. One remaining timeout-status race needs resolution before merging: the loop kills at the wall deadline, then polls try_wait, then sets timed_out = status.is_none(). If SIGKILL is reaped by that poll, a genuinely timed-out live child is reported timed_out=false. Both hung-child self-tests can therefore pass/fail based on scheduling, rather than the deadline event. Record the deadline decision from the pre-kill observed liveness and preserve that timeout outcome while subsequently reaping the real status. Keep the exited-child/retained-pipe case distinct. Add repeated harmless short-deadline coverage or a deterministic helper invariant; do not loosen the timeout assertions.

Also update the editable PR body: it still describes CPU/stack caps as best-effort, unlike the new fail-closed implementation. Correct the review-response permalink (6104616156 belongs to2674, not2676). Existing draft/required CI and actual post-merge proof remain required; do not create another follow-up or rewrite merged history.

Record the wall-deadline verdict from pre-kill observed liveness instead of recomputing it after polling try_wait, so reaping the SIGKILL victim cannot flip a genuine timeout. Covers the verdict with repeated short-deadline runs.
@chaliy

chaliy commented Oct 11, 2026

Copy link
Copy Markdown
Contributor Author

Resolving the remaining race review (#2676 (comment); correction: my previous reply wrongly linked the 2674 comment 6104616156 — this is the actual 2676 thread):

  1. Timeout-status race — fixed deterministically. The deadline branch now records timed_out = true only from pre-kill observed liveness (status.is_none() && !oversized) and breaks immediately: no post-kill try_wait poll feeds the verdict, so reaping the SIGKILL victim cannot flip a genuine timeout on scheduling luck. An oversized run that also hits the deadline keeps its oversize verdict instead of gaining a spurious timeout. bounded_helper_kills_hung_child now repeats the short deadline (3 s + 3× 2 s) asserting timeout every time; no timeout assertion was loosened anywhere.

  2. PR body — corrected: 2676's body no longer describes caps as best-effort; it states fail-closed (|| exit 99), readback, and the Darwin/address-space documentation. (2674's merged body was likewise corrected earlier for the exit-0-vs-exit-1 claim; merged history itself untouched.)

Draft stays until this round of CI is green; no merge before that.

@chaliy
chaliy marked this pull request as ready for review October 11, 2026 04:44
@chaliy
chaliy merged commit e11d55f into main Oct 11, 2026
33 checks passed
@chaliy
chaliy deleted the codex/indirect-expansion-recursion branch October 11, 2026 04:44
@codecov

codecov Bot commented Oct 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

chaliy added a commit that referenced this pull request Oct 11, 2026
## What changed
Generalizes `run_cli_bounded` with a `run_cli_argv_bounded(script,
extra_args, limit)` variant and deletes the hand-rolled argv reader the
TM-DOS-136 regression carried. One file, net -46 lines.

## Why
The #2676 harness rewrite changed `BoundedRun`'s shape
(oversized/incomplete tracking, fail-closed CPU/stack launcher). The
TM-DOS-136 multiline-token x long-ARG0 regression still constructed the
old shape, so the integration test binary did not compile on main after
the #2675 squash merge — main is red (`Test` + `Lint` fail on ec540f4).
This routes the vector through the new harness, keeping the fail-closed
caps and real producer exit codes.

## Before / After
Before: `cargo test -p bashkit --test integration --no-run` fails with
E0063 on main.
After: compiles; `threat_model` 214 passed (skipping only the
pre-existing macOS stack-overflow abort in
`misconfig_huge_ast_depth_still_safe`, identical on unmodified code);
`error::` 14 passed; fmt + clippy clean.

## Risk
- Test-only change. No production code touched.

## Checklist
- [x] Tests added or updated
- [x] Backward compatibility considered (internal test harness)
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