Skip to content

perf(interpreter): CoW subshell snapshots, attribute bitset, flag cache - #1767

Merged
chaliy merged 3 commits into
mainfrom
claude/focused-cray-2iHu9
May 26, 2026
Merged

chaliy merged 3 commits into
mainfrom
claude/focused-cray-2iHu9

Conversation

@chaliy

@chaliy chaliy commented May 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Hot-path overhaul for script execution. New cargo bench --bench hotpath micro-benchmarks show median −36.6%, mean −39.7%, best −64.9% across 23 cases versus a saved baseline taken before this change. All micro-benches improved; nothing regressed.

Bench Before (µs) After (µs) Change
startup/empty 100.86 35.40 −64.9%
startup/echo_hi 99.81 36.47 −63.5%
cmdsubst/subst_nested_3 131.64 52.05 −60.5%
param_exp/default_op_500 1338.87 616.85 −53.9%
param_exp/uppercase_300 837.59 395.38 −52.8%
pipelines/seq_grep_wc 177.51 91.61 −48.4%
loops/for_list_100 462.18 258.23 −44.1%
variables/assign_200 532.08 307.56 −42.2%
shopt/plain_1k_loop 4706.60 2959.84 −37.1%
loops/nested_for_50x50 9740.39 6362.39 −34.7%
loops/while_inc_1k 3062.82 2111.25 −31.1%
variables/read_200 1312.51 974.66 −25.7%
functions/call_500 3293.78 2686.13 −18.4%
functions/recursive_fib_10 3002.32 2570.39 −14.4%

Full table: crates/bashkit-bench/results/criterion-hotpath-perf-linux-x86_64-1779744742.md.

What

  1. CoW subshell snapshots — wrap variables / arrays / assoc_arrays / functions / traps / aliases / var_attrs / namerefs in Arc<HashMap<…>>. $(...) and ((...)) substitution snapshot is now an O(1) refcount bump instead of an O(n) deep clone; mutations go through vars_mut() / arrays_mut() / etc. which call Arc::make_mut (zero clone at refcount=1, one clone when a live subshell mutates).
  2. Attribute bitset — replace _READONLY_X / _INTEGER_X / _LOWER_X / _UPPER_X / _NAMEREF_X formatted-key markers (which lived inside variables) with a typed VarAttrs bitset map and a dedicated namerefs map. Every variable assignment used to allocate 4–5 Strings just for attribute checks; now it's one map probe + bit test.
  3. BashFlags cache — cache shell-option flags (set -e/-u/-x/-o pipefail/-a/-f/-C, shopt expand_aliases) in a BashFlags bitfield. Hot-path checks (is_nounset, is_pipefail, is_errexit_enabled, set -a allexport in set_variable, etc.) are now a single bit test instead of a HashMap lookup + string compare. SHOPT_X writes stay in variables so $- / set -o rendering keeps working; apply_builtin_side_effects refreshes the cache after every builtin so set builtin mutations stay coherent.
  4. Skip spawn_blocking for small scripts — scripts ≤ 16 KB parse inline. The parser's max_parser_operations fuel budget bounds runaway parses without the threadpool/timer hop. Larger scripts keep the pre-emptable route.
  5. set_variable clone reduction — call-stack walk uses get_mut instead of entry(.clone()); the global insert path moves value straight in when allexport is off. execute_for moves the iteration value into set_variable instead of cloning it.
  6. Subshell snapshot consolidation — Subshell(...), execute_cmd_subst, and expand_command_subs_in_arithmetic now share snapshot_subshell_state / restore_subshell_state. Same struct also covers var_attrs, namerefs, and the flags bitfield — these used to silently leak across substitution boundaries.
  7. [profile.bench] — inherits release with lto = "fat" + codegen-units = 1 so criterion measurements get maximum cross-crate inlining. Day-to-day release builds stay on lto = "thin".
  8. New crates/bashkit/benches/hotpath.rs — criterion bench covering startup, loops, variables, command substitution, SHOPT flag overhead, pipelines, functions, and parameter expansion. Use:
    cargo bench --bench hotpath -- --save-baseline before
    # land changes
    cargo bench --bench hotpath -- --baseline before

Why

Two of the lowest-hanging perf items in the deep analysis:

  • Every $(...) substitution was eagerly cloning all of variables, arrays, assoc_arrays, functions, traps, aliases, memory_budget, and exec_fd_table. For a script with N variables that's O(N) per substitution. CoW Arc snapshots turn the no-mutation case (the common case — $(echo $x), command queries) into pure refcount work.
  • Every variable assignment ran format!("_READONLY_{}", name), format!("_INTEGER_{}", name), format!("_LOWER_{}", name), format!("_UPPER_{}", name) and four matching HashMap probes — even when no attributes were set. Tight for i in ...; do x=...; done loops paid 4–5 allocations per iteration just to discover the variable has no attributes.

How

  • Field types changed:
    • variables: HashMap<String, String> → Arc<HashMap<String, String>>
    • arrays, assoc_arrays, functions, traps, aliases → wrapped in Arc<>
    • New: var_attrs: Arc<HashMap<String, VarAttrs>>, namerefs: Arc<HashMap<String, String>>, flags: BashFlags
  • Mutation sites use new vars_mut() / arrays_mut() / assoc_arrays_mut() / functions_mut() / traps_mut() / var_attrs_mut() / namerefs_mut() helpers that Arc::make_mut once and return &mut HashMap. Context and ShellRef (passed to builtins) hold &mut HashMap borrowed via Arc::make_mut inline at the construction site, so the builtin API is unchanged.
  • Migrated all callers in interpreter/mod.rs, builtins/vars.rs (Unset, Readonly), builtins/export.rs to the new attribute API. ShellRef exposes is_var_readonly, mark_var_readonly, readonly_names for builtins.
  • Updated specs/threat-model.md: TM-INJ-009 / TM-INJ-018 / TM-INJ-019 / TM-INJ-020 / TM-INJ-021 mitigation text now reflects the VarAttrs bitset + dedicated namerefs storage. The legacy prefix block (is_internal_variable) is kept as defense-in-depth.

Test plan

  • cargo test -p bashkit --lib --features http_client — 2342 pass, 1 fail (the pre-existing builtins::rg::tests::diff_rg_matches_real_rg_cases differential against the host rg binary — reproduces on baseline, unrelated to this PR).
  • cargo test -p bashkit --features http_client,jq --test security_audit_pocs — 32 pass, 0 fail (TM-INJ injection POCs against declare/readonly/export/local).
  • cargo test -p bashkit --features http_client,jq --test threat_model_tests — 181 pass, 0 fail.
  • cargo test -p bashkit --features http_client,jq --test blackbox_security_tests — 88 pass, 0 fail.
  • cargo test -p bashkit --features http_client,jq --test custom_builtins_tests — 20 pass, 0 fail.
  • cargo clippy --all-targets -- -D warnings — clean (also fixed one pre-existing useless_conversion warning in passing).
  • cargo fmt --check — clean.
  • Smoke tests via bashkit CLI: declare -r readonly enforcement, declare -u/-l case attrs, declare -n namerefs, set -e/-u/-a, for loops, command substitution, subshell isolation, pipelines — all behave as before.
  • cargo bench --bench hotpath -- --baseline before — median −36.6% vs. pre-change baseline (full table in crates/bashkit-bench/results/).

Generated by Claude Code

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 25, 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 32b3624 Commit Preview URL

Branch Preview URL
May 26 2026, 12:21 AM

chaliy added 3 commits May 26, 2026 00:19
Major hot-path overhaul targeting the allocation-heavy paths identified
in the analysis. Median -36% on the new criterion micro-benchmarks
(see crates/bashkit-bench/results/criterion-hotpath-perf-*.md).

Key changes:

- Wrap variables / arrays / assoc_arrays / functions / traps / aliases /
  var_attrs / namerefs in `Arc<HashMap<...>>`. Subshell snapshot is now
  an O(1) refcount bump instead of an O(n) deep clone; mutations go
  through `vars_mut()` / `arrays_mut()` / etc. which `Arc::make_mut`
  the inner map (zero-clone when refcount==1, one clone when a live
  subshell mutates). cmdsubst/subst_nested_3: -60%.

- Replace the `_READONLY_X` / `_INTEGER_X` / `_LOWER_X` / `_UPPER_X` /
  `_NAMEREF_X` `format!()`-built keys in `variables` with dedicated
  `VarAttrs` bitset map and `namerefs: HashMap<String, String>`. Every
  variable assignment used to allocate 4-5 `String`s for attribute
  checks; now it's a single hashmap probe + bit test. set_variable
  also moves through `get_mut` instead of `entry(.clone())` for the
  call-stack walk so the common no-local-binding path is alloc-free.

- Cache shell option flags (`set -e/-u/-x/-o pipefail/-a/-f/-C/etc.`)
  in a `BashFlags` bitfield. Hot-path checks (`is_nounset`,
  `is_pipefail`, `is_errexit_enabled`, etc.) are now one bit test
  instead of a HashMap lookup + string compare. SHOPT_X writes stay
  in `variables` so `$-` / `set -o` rendering keeps working;
  apply_builtin_side_effects refreshes the cache after every builtin
  so set/shopt mutations stay coherent.

- Skip the `tokio::task::spawn_blocking` + `time::timeout` round-trip
  for scripts <= 16 KB. The parser's `max_parser_operations` fuel
  budget already bounds runaway parses, so the threadpool hop was
  pure overhead on the inline-exec path. Large scripts still take
  the pre-empted route. startup/empty: -65%.

- New `bench` profile inheriting release with fat LTO + codegen-units=1
  so criterion measurements get the best cross-crate inlining. Day-to-day
  release builds stay on thin LTO.

- New `crates/bashkit/benches/hotpath.rs` criterion benchmark covering
  startup, loops, variables, command substitution, SHOPT flag overhead,
  pipelines, functions, and parameter expansion. Use:
    cargo bench --bench hotpath -- --save-baseline before
    # land changes
    cargo bench --bench hotpath -- --baseline before

Mechanical: `set_variable`, `unset`, `readonly`, `declare`, `export`,
`local` all migrated to the new helpers. ShellRef exposes
`var_attrs` / `namerefs` (mut) and helper methods (`is_var_readonly`,
`mark_var_readonly`, `readonly_names`) so builtins don't reach into
the legacy marker scheme.

Test counts unchanged (2281 pass; the single pre-existing rg
differential test failure reproduces on baseline and is unrelated).
- Remove dead `set_shopt_value` / `remove_shopt_value` / `is_var_integer` /
  `is_var_lower` / `is_var_upper` helpers. The flag cache and attribute
  reads are now driven directly from the live mutation paths; the helpers
  were left for future call sites that didn't materialize.
- Drop the commented-out `aliases_mut`; aliases are only mutated by
  builtins via ShellRef, which `Arc::make_mut`s inline at the
  construction site.
- Update `specs/threat-model.md` (TM-INJ-009 / TM-INJ-018 / TM-INJ-019 /
  TM-INJ-020 / TM-INJ-021) to describe the new `VarAttrs` bitset and
  dedicated `namerefs` map instead of the historical
  `_READONLY_X` / `_NAMEREF_X` marker scheme.
…ute_shell

The `bash -e -c '...'` subprocess launcher (and `set -e ... unset SHOPT_e`
variants) saved the parent's prior `SHOPT_*` value, applied the child's
flag through `insert_variable_checked` (which now updates the `flags`
bitfield), but on restore only did a bare `vars_mut().remove(&var)` when
the parent had no entry — leaving the cached `BashFlags::ERREXIT` /
`NOUNSET` / etc. bit set in the parent. Subsequent commands in the
parent then mis-detected errexit, breaking out of `execute_script_body`
before the user's follow-up commands could run.

The spec_tests harness caught this:

  [bash-flags.test] bash_e_errexit          got "" expected "exit:1\n"
  [bash-flags.test] bash_o_errexit          got "" expected "exit:1\n"
  [bash-flags.test] bash_combined_eu        got "" expected "exit:1\n"
  [bash-flags.test] bash_e_does_not_leak    got "" expected "still running\n"
  [bash-flags.test] bash_duplicate_errexit_flags_do_not_leak  got "" expected "still running\n"

`execute_shell` now also clears the bit via `BashFlags::from_shopt_name`
when removing the SHOPT entry — symmetric with `insert_variable_checked`.

Reproduced locally; `cargo test -p bashkit --test spec_tests -- bash_spec_tests` now passes.
@chaliy
chaliy force-pushed the claude/focused-cray-2iHu9 branch from 8bb2303 to 32b3624 Compare May 26, 2026 00:20
@chaliy
chaliy merged commit 10cd01d into main May 26, 2026
38 of 40 checks passed
@chaliy
chaliy deleted the claude/focused-cray-2iHu9 branch May 26, 2026 00:44
chaliy added a commit that referenced this pull request May 27, 2026
## Summary

Maintenance release rolling up 50 commits since v0.7.1.

## Highlights

- **Maintenance release** — rolls up ~25 `rg` hardening fixes, a CoW
subshell-snapshot perf landmark (#1767), security tightening (FD cap
#1780, tool-hook enforcement for `command` #1781), a `BuiltinHelper`
refactor (#1788), and test/build hygiene work.

## Publish-readiness report

| Check | Result |
|-------|--------|
| `cargo fmt --check` | clean |
| `cargo clippy --all-targets -- -D warnings` | clean |
| `cargo test --features http_client` | 2965 pass (environmental rg-diff
failure resolved with pinned ripgrep 15.1) |
| `cargo publish --dry-run -p bashkit` (with CI's monty strip) |
succeeds |
| `cargo publish --dry-run -p bashkit-cli` | packaging succeeds;
resolution fails only because bashkit 0.7.2 isn't on crates.io yet
(chicken-and-egg, resolves post-merge per `publish.yml`) |
| Version sync (Cargo.toml, bashkit-cli/Cargo.toml,
bashkit-js/package.json, Cargo.lock) | all 0.7.2 |
| Registry version checks (crates.io / npm / PyPI) | all currently
0.7.1; 0.7.2 > 0.7.1 |

## Test plan

- [ ] CI green on this PR
- [ ] Merge → `release.yml` creates GitHub Release + tag `v0.7.2`
- [ ] `publish.yml` publishes bashkit + bashkit-cli to crates.io
- [ ] `publish-python.yml` publishes wheels to PyPI
- [ ] `publish-js.yml` publishes `@everruns/bashkit` to npm
- [ ] `cli-binaries.yml` ships prebuilt binaries and bumps Homebrew tap
chaliy added a commit that referenced this pull request May 30, 2026
…he (#1767)

Hot-path overhaul for script execution. Median -36.6% across 23 criterion micro-benchmarks vs. pre-change baseline.

- CoW subshell snapshots: wrap variables/arrays/assoc_arrays/functions/traps/aliases/var_attrs/namerefs in Arc<HashMap<...>> so $(...) snapshot is O(1) refcount bump (Arc::make_mut on mutate). cmdsubst/subst_nested_3: -60%.
- Attribute bitset: replace _READONLY_X/_INTEGER_X/_LOWER_X/_UPPER_X/_NAMEREF_X formatted-key markers with typed VarAttrs map + dedicated namerefs map. Removes 4-5 format!() allocations per variable assignment.
- BashFlags cache: set -e/-u/-x/-o pipefail/-a/-f/-C/expand_aliases tracked in a bitfield. Hot-path checks become bit tests. apply_builtin_side_effects refreshes the cache after every builtin; execute_shell clears bits when restoring removed SHOPT_* entries.
- Inline parse for scripts <= 16 KB: skip tokio spawn_blocking + timeout when parser fuel already bounds runaway. startup/empty: -65%.
- set_variable clone reduction: call-stack walk uses get_mut instead of entry(.clone()); execute_for moves loop value instead of cloning.
- [profile.bench] inherits release with lto=fat + codegen-units=1 for criterion measurements.
- New crates/bashkit/benches/hotpath.rs criterion bench covering startup, loops, variables, command substitution, SHOPT overhead, pipelines, functions, parameter expansion.

Updated specs/threat-model.md (TM-INJ-009/018/019/020/021) to describe the new attribute storage. is_internal_variable() still rejects the legacy prefixes from every write path as defense-in-depth.
chaliy added a commit that referenced this pull request May 30, 2026
## Summary

Maintenance release rolling up 50 commits since v0.7.1.

## Highlights

- **Maintenance release** — rolls up ~25 `rg` hardening fixes, a CoW
subshell-snapshot perf landmark (#1767), security tightening (FD cap
#1780, tool-hook enforcement for `command` #1781), a `BuiltinHelper`
refactor (#1788), and test/build hygiene work.

## Publish-readiness report

| Check | Result |
|-------|--------|
| `cargo fmt --check` | clean |
| `cargo clippy --all-targets -- -D warnings` | clean |
| `cargo test --features http_client` | 2965 pass (environmental rg-diff
failure resolved with pinned ripgrep 15.1) |
| `cargo publish --dry-run -p bashkit` (with CI's monty strip) |
succeeds |
| `cargo publish --dry-run -p bashkit-cli` | packaging succeeds;
resolution fails only because bashkit 0.7.2 isn't on crates.io yet
(chicken-and-egg, resolves post-merge per `publish.yml`) |
| Version sync (Cargo.toml, bashkit-cli/Cargo.toml,
bashkit-js/package.json, Cargo.lock) | all 0.7.2 |
| Registry version checks (crates.io / npm / PyPI) | all currently
0.7.1; 0.7.2 > 0.7.1 |

## Test plan

- [ ] CI green on this PR
- [ ] Merge → `release.yml` creates GitHub Release + tag `v0.7.2`
- [ ] `publish.yml` publishes bashkit + bashkit-cli to crates.io
- [ ] `publish-python.yml` publishes wheels to PyPI
- [ ] `publish-js.yml` publishes `@everruns/bashkit` to npm
- [ ] `cli-binaries.yml` ships prebuilt binaries and bumps Homebrew tap
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