Skip to content

fix(cli): bound clap frame size by splitting oversized subcommand enums (Refs #215) - #216

Merged
noahgift merged 1 commit into
mainfrom
fix/bound-recursion-215
Aug 11, 2026
Merged

fix(cli): bound clap frame size by splitting oversized subcommand enums (Refs #215)#216
noahgift merged 1 commit into
mainfrom
fix/bound-recursion-215

Conversation

@noahgift

Copy link
Copy Markdown
Contributor

Fixes #215.

It is not recursion

The issue originally guessed "deep recursion in the bash parser." Wrong — corrected on the issue. gdb shows six frames, no cycle:

#2 args::Commands::augment_subcommands                 (27 variants)
#1 args_corpus::CorpusCommands::augment_subcommands    (73 variants)
#0 args_corpus_analysis::CorpusAnalysisCommands::augment_subcommands (63)  ← crash

The failing test only calls Cli::parse_from(vec!["rash","check","test.rs"]).

Why the frames are huge

Per clap_derive-4.6.4 gen_augment, the body is a flat sequence with one new binding per variant:

let __clap_app = __clap_app.subcommand({ ...variant N... });

At opt-level 0 each binding gets its own uncoalesced stack slot and clap::Command is large. Measured ~12KB/variant: deleting the 63-variant flatten moved the requirement 2816KB → 2048KB.

The fix

Bound variants per generated function. clap emits #[command(flatten)] children as sequential calls, so peak = parent + max(child), not parent + sum — and flatten adds no CLI nesting. The repo already used this pattern (CorpusAnalysisCommands was split out of CorpusCommands the same way).

enum before after
CorpusCommands 73 5 shell + 23/24/24
CorpusAnalysisCommands 63 3 shell + 21/21/21

Results (with [profile.test] opt-level left at 0)

before after
stack required 2816KB 1664KB
margin vs 2MB budget −768KB +384KB
cli::tests SIGABRT 13 passed
full suite aborts 14593 passed, 0 overflows

opt-level = 1 also hides the symptom, but it compensates instead of removing the cause, and that profile's own comment flags coverage sensitivity. This split holds at any optimization level.

CLI surface is unchanged — the whole point

Diffed the full raw --help text of every node against a pristine build of HEAD:

3286 lines across 208 subcommand nodes — byte-identical.

Known unrelated failure

test_cov_keyring_init_with_import (a tempdir write race) fails in the full suite — and reproduces identically on pristine HEAD, verified by differential run. Not from this change.

Once this ships, the RUST_MIN_STACK=16777216 workaround in paiml/infra#170's clean-room gate can be removed.

🤖 Generated with Claude Code

…ms (Refs #215)

`cargo test -p bashrs --lib` aborted with SIGABRT on pristine main:

    thread 'cli::tests::test_cli_check_command' has overflowed its stack

The test only calls `Cli::parse_from(vec!["rash","check","test.rs"])`.

IT IS NOT RECURSION. #215 originally guessed "deep recursion in the bash
parser"; that was wrong and is corrected on the issue. gdb shows SIX frames and
no cycle:

    #2 args::Commands::augment_subcommands                 (27 variants)
    #1 args_corpus::CorpusCommands::augment_subcommands    (73 variants)
    #0 args_corpus_analysis::CorpusAnalysisCommands::augment_subcommands (63)

Three nested clap-derive functions, each ONE ENORMOUS FRAME. Per
clap_derive-4.6.4 gen_augment, the generated body is a flat sequence with one
NEW binding per variant:

    let __clap_app = __clap_app.subcommand({ ...variant N... });

At opt-level 0 every binding gets its own stack slot with no coalescing, and
clap::Command is large. Measured ~12KB of stack PER VARIANT: deleting the
63-variant Analysis flatten moved the requirement from ~2816KB to ~2048KB.

So the thing to bound is VARIANTS PER GENERATED FUNCTION, not depth. clap emits
`#[command(flatten)]` children as SEQUENTIAL calls, so peak stack is
parent + MAX(child), not parent + SUM(children) — and flatten adds no CLI
nesting level. This repo already used that pattern: CorpusAnalysisCommands was
itself split out of CorpusCommands this way.

  CorpusCommands          73 variants -> 5 shell + groups of 23/24/24
  CorpusAnalysisCommands  63 variants -> 3 shell + groups of 21/21/21

Measured on this tree with [profile.test] opt-level left at 0:
  * stack requirement 2816KB -> 1664KB; margin vs the 2MB default thread stack
    goes from -768KB to +384KB
  * cli::tests: 13 passed (was SIGABRT)
  * full suite: 14593 passed, 0 stack overflows

opt-level = 1 in [profile.test] also makes the symptom disappear, but it
compensates instead of removing the cause and that profile's own comment flags
coverage sensitivity. This split holds at any optimization level.

CLI SURFACE IS UNCHANGED — the whole point. Verified by diffing the full raw
`--help` text of every node against a pristine build of HEAD:
3286 lines across 208 subcommand nodes, BYTE-IDENTICAL.

The one remaining suite failure (test_cov_keyring_init_with_import, a tempdir
write race) reproduces identically on pristine HEAD and is unrelated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@noahgift
noahgift merged commit 35ae821 into main Aug 11, 2026
15 of 17 checks passed
@noahgift
noahgift deleted the fix/bound-recursion-215 branch August 11, 2026 09:25
@noahgift noahgift mentioned this pull request Aug 12, 2026
noahgift added a commit that referenced this pull request Aug 12, 2026
Ships the user-facing fixes accumulated since 6.66.2, none of which
reach anyone until this is published:

  - five lint false positives that were driving users to disable
    `bashrs lint` (#219, GH-217, GH-209)
  - CLI stack overflow from an oversized clap frame (#216, #215)
  - RUSTSEC-2026-0204, crossbeam-epoch 0.9.20 (#210)

plus internal repairs: kani harnesses compile under cfg(kani) again
(#221), bashrs-oracle's test module compiles and the workspace is
actually tested (#223), and a workflow template stopped being run as a
workflow (#222).

Cargo.lock regenerated in the same commit -- forjar's 1.12.4 release
tripped its lockfile-preflight by bumping Cargo.toml alone.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

cargo test --lib aborts with a stack overflow; invisible because CI uses nextest (process-per-test)

1 participant