From 65b0df0263997e55eab030c33c895a45c71f7566 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 25 May 2026 21:37:46 +0000 Subject: [PATCH 1/3] perf(interpreter): CoW subshell snapshots, attribute bitset, flag cache 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>`. 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`. 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). --- Cargo.lock | 1 + Cargo.toml | 10 + .../bench-after-perf-linux-x86_64.json | 4839 +++++++++++++++++ .../results/bench-after-perf-linux-x86_64.md | 306 ++ ...on-hotpath-perf-linux-x86_64-1779744742.md | 38 + crates/bashkit/Cargo.toml | 9 + crates/bashkit/benches/hotpath.rs | 214 + crates/bashkit/src/builtins/export.rs | 7 +- crates/bashkit/src/builtins/vars.rs | 45 +- crates/bashkit/src/interpreter/mod.rs | 934 ++-- crates/bashkit/src/lib.rs | 35 +- 11 files changed, 6104 insertions(+), 334 deletions(-) create mode 100644 crates/bashkit-bench/results/bench-after-perf-linux-x86_64.json create mode 100644 crates/bashkit-bench/results/bench-after-perf-linux-x86_64.md create mode 100644 crates/bashkit-bench/results/criterion-hotpath-perf-linux-x86_64-1779744742.md create mode 100644 crates/bashkit/benches/hotpath.rs diff --git a/Cargo.lock b/Cargo.lock index bd0a4478e..914ef8866 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -351,6 +351,7 @@ dependencies = [ "async-trait", "base64", "bigdecimal", + "bitflags", "chrono", "clap", "criterion", diff --git a/Cargo.toml b/Cargo.toml index 228f8a625..2ea510922 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -125,3 +125,13 @@ codegen-units = 1 # Keep release unwinding enabled so panic containment works. panic = "unwind" strip = "symbols" + +# Important decision: separate `bench` profile uses fat LTO for the most +# aggressive cross-crate inlining. Criterion benches run rarely and we'd +# rather spend extra compile time once than miss inlining opportunities +# across the interpreter / parser / builtins boundary on every measurement. +# `release` keeps `thin` LTO so day-to-day builds stay fast. +[profile.bench] +inherits = "release" +lto = "fat" +codegen-units = 1 diff --git a/crates/bashkit-bench/results/bench-after-perf-linux-x86_64.json b/crates/bashkit-bench/results/bench-after-perf-linux-x86_64.json new file mode 100644 index 000000000..6ffa2d01e --- /dev/null +++ b/crates/bashkit-bench/results/bench-after-perf-linux-x86_64.json @@ -0,0 +1,4839 @@ +{ + "moniker": "vm-linux-x86_64", + "timestamp": "1779744905", + "system": { + "hostname": "vm", + "os": "linux", + "arch": "x86_64", + "cpus": 4, + "moniker": "vm-linux-x86_64" + }, + "iterations": 10, + "warmup": 2, + "prewarm_cases": 3, + "runners": [ + "bashkit", + "bash" + ], + "results": [ + { + "runner": "bashkit", + "case_name": "startup_empty", + "category": "startup", + "iterations": 10, + "times_ns": [ + 58761, + 57437, + 56961, + 56587, + 89120, + 59179, + 59346, + 56725, + 57825, + 54001 + ], + "mean_ns": 60594.2, + "stddev_ns": 9622.77429642824, + "min_ns": 54001, + "max_ns": 89120, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "startup_empty", + "category": "startup", + "iterations": 10, + "times_ns": [ + 1707519, + 1685781, + 1613511, + 1549835, + 1484586, + 1517929, + 1565571, + 1545727, + 1702583, + 1584902 + ], + "mean_ns": 1595794.4, + "stddev_ns": 75086.01431318618, + "min_ns": 1484586, + "max_ns": 1707519, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "startup_true", + "category": "startup", + "iterations": 10, + "times_ns": [ + 52479, + 57412, + 56977, + 41331, + 36372, + 35741, + 34805, + 64884, + 38190, + 36049 + ], + "mean_ns": 45424.0, + "stddev_ns": 10728.86276359242, + "min_ns": 34805, + "max_ns": 64884, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "startup_true", + "category": "startup", + "iterations": 10, + "times_ns": [ + 1640154, + 1666743, + 1914730, + 1785322, + 1757592, + 5502142, + 1641123, + 1708993, + 1680473, + 1831754 + ], + "mean_ns": 2112902.6, + "stddev_ns": 1132875.952588473, + "min_ns": 1640154, + "max_ns": 5502142, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "startup_echo", + "category": "startup", + "iterations": 10, + "times_ns": [ + 40838, + 39023, + 38066, + 37237, + 74284, + 39946, + 37843, + 36875, + 39895, + 37785 + ], + "mean_ns": 42179.2, + "stddev_ns": 10771.598152549137, + "min_ns": 36875, + "max_ns": 74284, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "startup_echo", + "category": "startup", + "iterations": 10, + "times_ns": [ + 1791354, + 1686571, + 1613082, + 1703416, + 1688755, + 1666450, + 1783122, + 1671867, + 1890428, + 1604337 + ], + "mean_ns": 1709938.2, + "stddev_ns": 83349.64834694864, + "min_ns": 1604337, + "max_ns": 1890428, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "startup_exit", + "category": "startup", + "iterations": 10, + "times_ns": [ + 40293, + 39076, + 37521, + 37443, + 37294, + 35795, + 35855, + 36472, + 36006, + 65305 + ], + "mean_ns": 40106.0, + "stddev_ns": 8511.394045630832, + "min_ns": 35795, + "max_ns": 65305, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "startup_exit", + "category": "startup", + "iterations": 10, + "times_ns": [ + 1852742, + 1749733, + 1587307, + 1606049, + 1641633, + 8370555, + 1809879, + 1804792, + 1756948, + 1731198 + ], + "mean_ns": 2391083.6, + "stddev_ns": 1994952.2909869398, + "min_ns": 1587307, + "max_ns": 8370555, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "var_assign_simple", + "category": "variables", + "iterations": 10, + "times_ns": [ + 46029, + 44019, + 43968, + 46062, + 80607, + 45113, + 42997, + 42472, + 41864, + 41185 + ], + "mean_ns": 47431.6, + "stddev_ns": 11170.27312289185, + "min_ns": 41185, + "max_ns": 80607, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "var_assign_simple", + "category": "variables", + "iterations": 10, + "times_ns": [ + 1779574, + 1764186, + 1743405, + 1804378, + 1713919, + 1743380, + 3928949, + 5494865, + 1684781, + 1747416 + ], + "mean_ns": 2340485.3, + "stddev_ns": 1236720.902250144, + "min_ns": 1684781, + "max_ns": 5494865, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "var_assign_many", + "category": "variables", + "iterations": 10, + "times_ns": [ + 61054, + 61130, + 59346, + 57619, + 57293, + 85210, + 62088, + 56478, + 55779, + 54570 + ], + "mean_ns": 61056.7, + "stddev_ns": 8391.998940061898, + "min_ns": 54570, + "max_ns": 85210, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "var_assign_many", + "category": "variables", + "iterations": 10, + "times_ns": [ + 1678280, + 1820466, + 1695952, + 1854721, + 1746913, + 1839936, + 1714158, + 1657117, + 1724579, + 1707279 + ], + "mean_ns": 1743940.1, + "stddev_ns": 66416.03061076444, + "min_ns": 1657117, + "max_ns": 1854721, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "var_default", + "category": "variables", + "iterations": 10, + "times_ns": [ + 44237, + 43667, + 40799, + 41365, + 40325, + 41431, + 40535, + 39230, + 39536, + 38909 + ], + "mean_ns": 41003.4, + "stddev_ns": 1681.3880099489231, + "min_ns": 38909, + "max_ns": 44237, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "var_default", + "category": "variables", + "iterations": 10, + "times_ns": [ + 1644350, + 1623029, + 1586694, + 1561721, + 1579204, + 1680277, + 1611020, + 1539957, + 1524279, + 1735745 + ], + "mean_ns": 1608627.6, + "stddev_ns": 61773.037371008395, + "min_ns": 1524279, + "max_ns": 1735745, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "var_length", + "category": "variables", + "iterations": 10, + "times_ns": [ + 48406, + 62503, + 50798, + 44739, + 43523, + 42087, + 41913, + 41108, + 41092, + 40497 + ], + "mean_ns": 45666.6, + "stddev_ns": 6465.995811938018, + "min_ns": 40497, + "max_ns": 62503, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "var_length", + "category": "variables", + "iterations": 10, + "times_ns": [ + 1540820, + 1701110, + 1526640, + 1572271, + 6239883, + 1696266, + 3638000, + 1605009, + 1728046, + 1699946 + ], + "mean_ns": 2294799.1, + "stddev_ns": 1446030.9009661896, + "min_ns": 1526640, + "max_ns": 6239883, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "var_substring", + "category": "variables", + "iterations": 10, + "times_ns": [ + 77236, + 48741, + 46396, + 44604, + 43784, + 50458, + 43538, + 41955, + 42024, + 99299 + ], + "mean_ns": 53803.5, + "stddev_ns": 18111.21241800228, + "min_ns": 41955, + "max_ns": 99299, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "var_substring", + "category": "variables", + "iterations": 10, + "times_ns": [ + 1723211, + 6792507, + 2032730, + 6063224, + 1947229, + 1916861, + 1740434, + 1983039, + 5133353, + 3453439 + ], + "mean_ns": 3278602.7, + "stddev_ns": 1876568.4741874489, + "min_ns": 1723211, + "max_ns": 6792507, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "var_replace", + "category": "variables", + "iterations": 10, + "times_ns": [ + 46977, + 43576, + 43686, + 43667, + 42559, + 42486, + 43176, + 42169, + 69787, + 46384 + ], + "mean_ns": 46446.7, + "stddev_ns": 7927.429722804233, + "min_ns": 42169, + "max_ns": 69787, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "var_replace", + "category": "variables", + "iterations": 10, + "times_ns": [ + 6136146, + 1612249, + 1681866, + 1688239, + 1726582, + 1632714, + 3749534, + 1725254, + 2246804, + 3912952 + ], + "mean_ns": 2611234.0, + "stddev_ns": 1441759.2470917604, + "min_ns": 1612249, + "max_ns": 6136146, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "var_nested", + "category": "variables", + "iterations": 10, + "times_ns": [ + 50564, + 46331, + 44112, + 42813, + 48826, + 42290, + 41834, + 41349, + 123755, + 45174 + ], + "mean_ns": 52704.8, + "stddev_ns": 23858.704435907664, + "min_ns": 41349, + "max_ns": 123755, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "var_nested", + "category": "variables", + "iterations": 10, + "times_ns": [ + 1619679, + 1780189, + 1646779, + 6866932, + 1710204, + 6227773, + 1674561, + 1784431, + 1640671, + 1646979 + ], + "mean_ns": 2659819.8, + "stddev_ns": 1949751.2501807916, + "min_ns": 1619679, + "max_ns": 6866932, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "var_export", + "category": "variables", + "iterations": 10, + "times_ns": [ + 46614, + 44724, + 44444, + 41677, + 76747, + 52320, + 45019, + 43796, + 58938, + 47323 + ], + "mean_ns": 50160.2, + "stddev_ns": 10051.490215883414, + "min_ns": 41677, + "max_ns": 76747, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "var_export", + "category": "variables", + "iterations": 10, + "times_ns": [ + 1639299, + 1755854, + 1609010, + 1626757, + 1656589, + 1838297, + 1700660, + 1649274, + 2032136, + 1652229 + ], + "mean_ns": 1716010.5, + "stddev_ns": 124122.9896137295, + "min_ns": 1609010, + "max_ns": 2032136, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arith_basic", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 44082, + 43514, + 43225, + 63487, + 49494, + 41569, + 40651, + 39097, + 38499, + 39986 + ], + "mean_ns": 44360.4, + "stddev_ns": 7049.893874378536, + "min_ns": 38499, + "max_ns": 63487, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arith_basic", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 1650428, + 1836209, + 1638455, + 1637931, + 1644777, + 1613860, + 1773660, + 1646289, + 1664848, + 1697168 + ], + "mean_ns": 1680362.5, + "stddev_ns": 66946.49980880255, + "min_ns": 1613860, + "max_ns": 1836209, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arith_complex", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 55625, + 44953, + 189493, + 49132, + 44265, + 43097, + 42999, + 42235, + 43155, + 43392 + ], + "mean_ns": 59834.6, + "stddev_ns": 43392.7877468134, + "min_ns": 42235, + "max_ns": 189493, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arith_complex", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 1646166, + 1667220, + 1828812, + 1635225, + 1669420, + 1663527, + 1672630, + 4488550, + 1952137, + 1798983 + ], + "mean_ns": 2002267.0, + "stddev_ns": 834476.1173108551, + "min_ns": 1635225, + "max_ns": 4488550, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arith_variables", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 52297, + 49235, + 48422, + 46315, + 46539, + 45755, + 44259, + 45225, + 44338, + 43260 + ], + "mean_ns": 46564.5, + "stddev_ns": 2591.2976768406984, + "min_ns": 43260, + "max_ns": 52297, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arith_variables", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 1714783, + 1776808, + 1726156, + 5259146, + 1763046, + 1972406, + 2186192, + 9779816, + 1829644, + 1756040 + ], + "mean_ns": 2976403.7, + "stddev_ns": 2489983.8446840593, + "min_ns": 1714783, + "max_ns": 9779816, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arith_increment", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 49663, + 48425, + 52147, + 47347, + 43801, + 43438, + 42746, + 42687, + 40521, + 41134 + ], + "mean_ns": 45190.9, + "stddev_ns": 3728.446203179013, + "min_ns": 40521, + "max_ns": 52147, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arith_increment", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 2653765, + 3854960, + 1699498, + 1717842, + 6217706, + 1726762, + 1746934, + 1716584, + 6286404, + 6755540 + ], + "mean_ns": 3437599.5, + "stddev_ns": 2058343.1813256626, + "min_ns": 1699498, + "max_ns": 6755540, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arith_modulo", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 42898, + 45478, + 42094, + 41676, + 41536, + 41700, + 41436, + 40929, + 39607, + 39731 + ], + "mean_ns": 41708.5, + "stddev_ns": 1575.1895282790576, + "min_ns": 39607, + "max_ns": 45478, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arith_modulo", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 1728028, + 1682393, + 1734853, + 1698509, + 9195378, + 1790998, + 1676134, + 1891052, + 1772119, + 5273094 + ], + "mean_ns": 2844255.8, + "stddev_ns": 2364473.3770150933, + "min_ns": 1676134, + "max_ns": 9195378, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arith_loop_sum", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 111789, + 83454, + 77650, + 76589, + 83746, + 77465, + 75920, + 75006, + 72176, + 72958 + ], + "mean_ns": 80675.3, + "stddev_ns": 10990.530806562529, + "min_ns": 72176, + "max_ns": 111789, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arith_loop_sum", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 1772337, + 1808607, + 7434165, + 1761180, + 10148568, + 1910200, + 1772975, + 1803173, + 1735981, + 3238663 + ], + "mean_ns": 3338584.9, + "stddev_ns": 2825934.3301936598, + "min_ns": 1735981, + "max_ns": 10148568, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "ctrl_if_simple", + "category": "control", + "iterations": 10, + "times_ns": [ + 75316, + 46211, + 42750, + 41157, + 41551, + 41691, + 40316, + 47080, + 39991, + 39880 + ], + "mean_ns": 45594.3, + "stddev_ns": 10184.504995825766, + "min_ns": 39880, + "max_ns": 75316, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "ctrl_if_simple", + "category": "control", + "iterations": 10, + "times_ns": [ + 1698075, + 1644031, + 1737079, + 1680428, + 1759488, + 1714220, + 1914302, + 1650615, + 1661456, + 1762200 + ], + "mean_ns": 1722189.4, + "stddev_ns": 75782.70989902643, + "min_ns": 1644031, + "max_ns": 1914302, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "ctrl_if_else", + "category": "control", + "iterations": 10, + "times_ns": [ + 47504, + 44577, + 43905, + 42666, + 48731, + 40797, + 41339, + 90525, + 43438, + 42763 + ], + "mean_ns": 48624.5, + "stddev_ns": 14165.7461946062, + "min_ns": 40797, + "max_ns": 90525, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "ctrl_if_else", + "category": "control", + "iterations": 10, + "times_ns": [ + 1855035, + 1657083, + 1677533, + 1646540, + 1711301, + 1726500, + 1726402, + 1682027, + 1675290, + 1857102 + ], + "mean_ns": 1721481.3, + "stddev_ns": 71949.75659729503, + "min_ns": 1646540, + "max_ns": 1857102, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "ctrl_for_list", + "category": "control", + "iterations": 10, + "times_ns": [ + 67334, + 59161, + 56415, + 55215, + 54633, + 53548, + 54535, + 54227, + 53123, + 52881 + ], + "mean_ns": 56107.2, + "stddev_ns": 4126.754724962461, + "min_ns": 52881, + "max_ns": 67334, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "ctrl_for_list", + "category": "control", + "iterations": 10, + "times_ns": [ + 1894351, + 1663170, + 5650816, + 4009503, + 1741545, + 1716766, + 1781760, + 1734031, + 1729020, + 1701959 + ], + "mean_ns": 2362292.1, + "stddev_ns": 1288653.0784569175, + "min_ns": 1663170, + "max_ns": 5650816, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "ctrl_for_range", + "category": "control", + "iterations": 10, + "times_ns": [ + 64741, + 62322, + 60514, + 60451, + 58280, + 58285, + 56782, + 57560, + 56494, + 93819 + ], + "mean_ns": 62924.8, + "stddev_ns": 10587.810716101796, + "min_ns": 56494, + "max_ns": 93819, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "ctrl_for_range", + "category": "control", + "iterations": 10, + "times_ns": [ + 10051209, + 1838353, + 3960952, + 1707977, + 1716205, + 1716137, + 2613121, + 1750339, + 1818901, + 1670289 + ], + "mean_ns": 2884348.3, + "stddev_ns": 2483925.5042708525, + "min_ns": 1670289, + "max_ns": 10051209, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "ctrl_while", + "category": "control", + "iterations": 10, + "times_ns": [ + 87334, + 84509, + 84694, + 82177, + 82528, + 119713, + 84160, + 82531, + 87782, + 80385 + ], + "mean_ns": 87581.3, + "stddev_ns": 10927.318829886863, + "min_ns": 80385, + "max_ns": 119713, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "ctrl_while", + "category": "control", + "iterations": 10, + "times_ns": [ + 1773582, + 1816477, + 4886589, + 1612718, + 1719330, + 1681083, + 2030878, + 1828144, + 1762112, + 5330540 + ], + "mean_ns": 2444145.3, + "stddev_ns": 1339952.9006323358, + "min_ns": 1612718, + "max_ns": 5330540, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "ctrl_case", + "category": "control", + "iterations": 10, + "times_ns": [ + 50753, + 48543, + 83706, + 49803, + 48041, + 47084, + 46990, + 52373, + 46399, + 46243 + ], + "mean_ns": 51993.5, + "stddev_ns": 10738.70055686441, + "min_ns": 46243, + "max_ns": 83706, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "ctrl_case", + "category": "control", + "iterations": 10, + "times_ns": [ + 1825573, + 1848616, + 1989250, + 2906853, + 1769098, + 1698860, + 1685639, + 1790664, + 1676348, + 1922051 + ], + "mean_ns": 1911295.2, + "stddev_ns": 345633.6934660161, + "min_ns": 1676348, + "max_ns": 2906853, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "ctrl_function", + "category": "control", + "iterations": 10, + "times_ns": [ + 50302, + 46411, + 52580, + 45606, + 43666, + 83036, + 47355, + 44491, + 45208, + 44162 + ], + "mean_ns": 50281.7, + "stddev_ns": 11242.907444695966, + "min_ns": 43666, + "max_ns": 83036, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "ctrl_function", + "category": "control", + "iterations": 10, + "times_ns": [ + 1684513, + 1866254, + 1776887, + 1957198, + 2083898, + 1706421, + 2308279, + 1658427, + 1758344, + 1658824 + ], + "mean_ns": 1845904.5, + "stddev_ns": 202808.15456546613, + "min_ns": 1658427, + "max_ns": 2308279, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "ctrl_function_return", + "category": "control", + "iterations": 10, + "times_ns": [ + 63744, + 60587, + 58179, + 57338, + 88512, + 62982, + 65094, + 57766, + 57307, + 63714 + ], + "mean_ns": 63522.3, + "stddev_ns": 8800.627944073083, + "min_ns": 57307, + "max_ns": 88512, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "ctrl_function_return", + "category": "control", + "iterations": 10, + "times_ns": [ + 2328475, + 2269458, + 5844007, + 2284210, + 2373916, + 2177786, + 2223403, + 2310879, + 2376311, + 2325893 + ], + "mean_ns": 2651433.8, + "stddev_ns": 1065823.6499433478, + "min_ns": 2177786, + "max_ns": 5844007, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "ctrl_nested_loops", + "category": "control", + "iterations": 10, + "times_ns": [ + 74701, + 70172, + 68192, + 101642, + 70554, + 67883, + 67590, + 73479, + 67231, + 158673 + ], + "mean_ns": 82011.7, + "stddev_ns": 27344.237199271072, + "min_ns": 67231, + "max_ns": 158673, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "ctrl_nested_loops", + "category": "control", + "iterations": 10, + "times_ns": [ + 1902105, + 1851557, + 1866274, + 1847503, + 1763997, + 1850378, + 1809630, + 5895990, + 5011531, + 1727728 + ], + "mean_ns": 2552669.3, + "stddev_ns": 1464746.9803202224, + "min_ns": 1727728, + "max_ns": 5895990, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "str_concat", + "category": "strings", + "iterations": 10, + "times_ns": [ + 67266, + 52193, + 43403, + 40945, + 40832, + 40261, + 40704, + 39322, + 46621, + 38796 + ], + "mean_ns": 45034.3, + "stddev_ns": 8355.46872473352, + "min_ns": 38796, + "max_ns": 67266, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "str_concat", + "category": "strings", + "iterations": 10, + "times_ns": [ + 1659277, + 1720275, + 1663720, + 6513974, + 3249266, + 3811329, + 1711342, + 1679896, + 1718596, + 1657466 + ], + "mean_ns": 2538514.1, + "stddev_ns": 1516815.5932607923, + "min_ns": 1657466, + "max_ns": 6513974, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "str_printf", + "category": "strings", + "iterations": 10, + "times_ns": [ + 42553, + 46537, + 37782, + 38559, + 37684, + 37240, + 37587, + 37729, + 36651, + 37124 + ], + "mean_ns": 38944.6, + "stddev_ns": 2975.838275175585, + "min_ns": 36651, + "max_ns": 46537, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "str_printf", + "category": "strings", + "iterations": 10, + "times_ns": [ + 1656876, + 1670309, + 1755889, + 3963093, + 1639791, + 1856956, + 1643915, + 1651180, + 1687700, + 1630918 + ], + "mean_ns": 1915662.7, + "stddev_ns": 685643.0253477461, + "min_ns": 1630918, + "max_ns": 3963093, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "str_printf_pad", + "category": "strings", + "iterations": 10, + "times_ns": [ + 41579, + 40872, + 46169, + 38953, + 36645, + 35829, + 35400, + 36572, + 35170, + 35704 + ], + "mean_ns": 38289.3, + "stddev_ns": 3413.069236039609, + "min_ns": 35170, + "max_ns": 46169, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "str_printf_pad", + "category": "strings", + "iterations": 10, + "times_ns": [ + 1662966, + 1777720, + 1763189, + 1723313, + 1647783, + 2210327, + 2026626, + 1834241, + 1632302, + 2220425 + ], + "mean_ns": 1849889.2, + "stddev_ns": 212266.6225018903, + "min_ns": 1632302, + "max_ns": 2220425, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "str_echo_escape", + "category": "strings", + "iterations": 10, + "times_ns": [ + 43596, + 40391, + 68739, + 40388, + 38028, + 38907, + 36813, + 36124, + 35424, + 35649 + ], + "mean_ns": 41405.9, + "stddev_ns": 9431.510848745284, + "min_ns": 35424, + "max_ns": 68739, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "str_echo_escape", + "category": "strings", + "iterations": 10, + "times_ns": [ + 1673299, + 1625383, + 1677952, + 1706521, + 2645269, + 1672781, + 8822655, + 1668974, + 1636817, + 1950080 + ], + "mean_ns": 2507973.1, + "stddev_ns": 2125384.3720727535, + "min_ns": 1625383, + "max_ns": 8822655, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "str_prefix_strip", + "category": "strings", + "iterations": 10, + "times_ns": [ + 43463, + 41938, + 73134, + 43940, + 40095, + 39063, + 41110, + 39871, + 39134, + 39841 + ], + "mean_ns": 44158.9, + "stddev_ns": 9793.264261215461, + "min_ns": 39063, + "max_ns": 73134, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "str_prefix_strip", + "category": "strings", + "iterations": 10, + "times_ns": [ + 1686981, + 1765128, + 1655057, + 9430526, + 1699114, + 3892335, + 1771241, + 1851230, + 1722214, + 6546984 + ], + "mean_ns": 3202081.0, + "stddev_ns": 2559941.730651579, + "min_ns": 1655057, + "max_ns": 9430526, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "str_suffix_strip", + "category": "strings", + "iterations": 10, + "times_ns": [ + 45916, + 42854, + 41265, + 40761, + 39915, + 39328, + 38806, + 38752, + 98308, + 42508 + ], + "mean_ns": 46841.3, + "stddev_ns": 17282.490382175827, + "min_ns": 38752, + "max_ns": 98308, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "str_suffix_strip", + "category": "strings", + "iterations": 10, + "times_ns": [ + 1689114, + 1658551, + 1868742, + 1707204, + 8986804, + 1654132, + 1669986, + 1742501, + 1755105, + 1668558 + ], + "mean_ns": 2440069.7, + "stddev_ns": 2183109.695357109, + "min_ns": 1654132, + "max_ns": 8986804, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "str_uppercase", + "category": "strings", + "iterations": 10, + "times_ns": [ + 45662, + 71773, + 44204, + 40584, + 40139, + 39373, + 39639, + 39541, + 39890, + 39271 + ], + "mean_ns": 44007.6, + "stddev_ns": 9488.334207857562, + "min_ns": 39271, + "max_ns": 71773, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "str_uppercase", + "category": "strings", + "iterations": 10, + "times_ns": [ + 1644940, + 1694549, + 1640420, + 1803416, + 2015552, + 2602223, + 2395457, + 2566165, + 1788780, + 3622288 + ], + "mean_ns": 2177379.0, + "stddev_ns": 599078.7562497939, + "min_ns": 1640420, + "max_ns": 3622288, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "str_lowercase", + "category": "strings", + "iterations": 10, + "times_ns": [ + 45018, + 41898, + 40212, + 40617, + 69160, + 42202, + 40300, + 40674, + 39077, + 38817 + ], + "mean_ns": 43797.5, + "stddev_ns": 8619.445437497705, + "min_ns": 38817, + "max_ns": 69160, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "str_lowercase", + "category": "strings", + "iterations": 10, + "times_ns": [ + 2813376, + 1695416, + 1690466, + 1668754, + 1665171, + 1667295, + 1732169, + 1867494, + 1638342, + 5724682 + ], + "mean_ns": 2216316.5, + "stddev_ns": 1216885.6038811742, + "min_ns": 1638342, + "max_ns": 5724682, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arr_create", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 43636, + 42051, + 40042, + 40289, + 69501, + 58332, + 42143, + 40129, + 39298, + 38847 + ], + "mean_ns": 45426.8, + "stddev_ns": 9673.744505619323, + "min_ns": 38847, + "max_ns": 69501, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arr_create", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 1668425, + 1650646, + 1763558, + 1734235, + 1765993, + 4689639, + 1655510, + 1812902, + 1661920, + 1675074 + ], + "mean_ns": 2007790.2, + "stddev_ns": 895566.3247532033, + "min_ns": 1650646, + "max_ns": 4689639, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arr_all", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 44002, + 41395, + 64573, + 50330, + 42800, + 41432, + 39398, + 38819, + 38574, + 38244 + ], + "mean_ns": 43956.7, + "stddev_ns": 7672.765538578643, + "min_ns": 38244, + "max_ns": 64573, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arr_all", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 1708676, + 1659082, + 1670663, + 1729849, + 1685241, + 2482879, + 1657185, + 1773377, + 4618606, + 1676425 + ], + "mean_ns": 2066198.3, + "stddev_ns": 883268.96570513, + "min_ns": 1657185, + "max_ns": 4618606, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arr_length", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 43571, + 41938, + 41834, + 40228, + 40301, + 39876, + 38574, + 37360, + 39047, + 39502 + ], + "mean_ns": 40223.1, + "stddev_ns": 1723.1063490104143, + "min_ns": 37360, + "max_ns": 43571, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arr_length", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 1682452, + 1775920, + 1658764, + 1663301, + 1795194, + 9993694, + 1766529, + 1665182, + 1675393, + 1667579 + ], + "mean_ns": 2534400.8, + "stddev_ns": 2486937.657894174, + "min_ns": 1658764, + "max_ns": 9993694, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arr_iterate", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 49236, + 49633, + 46729, + 45905, + 45553, + 52965, + 45801, + 46083, + 45609, + 45788 + ], + "mean_ns": 47330.2, + "stddev_ns": 2355.2469000085744, + "min_ns": 45553, + "max_ns": 52965, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arr_iterate", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 1671147, + 1774378, + 1697044, + 1736698, + 1663333, + 3357015, + 1674298, + 1716258, + 1674316, + 3129745 + ], + "mean_ns": 2009423.2, + "stddev_ns": 619926.723412824, + "min_ns": 1663333, + "max_ns": 3357015, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arr_slice", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 108117, + 49180, + 43576, + 50470, + 44549, + 42526, + 42486, + 42332, + 40379, + 40640 + ], + "mean_ns": 50425.5, + "stddev_ns": 19490.874789244324, + "min_ns": 40379, + "max_ns": 108117, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arr_slice", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 1663332, + 1841899, + 1687142, + 3010750, + 1724723, + 5110487, + 3398185, + 1727958, + 2012118, + 4272639 + ], + "mean_ns": 2644923.3, + "stddev_ns": 1185050.4693215434, + "min_ns": 1663332, + "max_ns": 5110487, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arr_assign_index", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 44707, + 52109, + 43139, + 40238, + 40137, + 41336, + 39827, + 39974, + 39730, + 74098 + ], + "mean_ns": 45529.5, + "stddev_ns": 10185.876331960839, + "min_ns": 39730, + "max_ns": 74098, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arr_assign_index", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 7512359, + 1726554, + 2008135, + 1805524, + 4106035, + 2044503, + 1748987, + 1808790, + 1767463, + 1813127 + ], + "mean_ns": 2634147.7, + "stddev_ns": 1763611.5275605936, + "min_ns": 1726554, + "max_ns": 7512359, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "pipe_simple", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 67705, + 58753, + 93020, + 59669, + 57802, + 55062, + 54706, + 53559, + 53210, + 51917 + ], + "mean_ns": 60540.3, + "stddev_ns": 11655.898541511076, + "min_ns": 51917, + "max_ns": 93020, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "pipe_simple", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 10697575, + 7578549, + 10031755, + 3564554, + 3371230, + 3144359, + 6205762, + 15628393, + 5573722, + 3286341 + ], + "mean_ns": 6908224.0, + "stddev_ns": 3922446.050382924, + "min_ns": 3144359, + "max_ns": 15628393, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "pipe_multi", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 85129, + 95948, + 81288, + 80681, + 109409, + 88441, + 80915, + 77933, + 77045, + 77038 + ], + "mean_ns": 85382.7, + "stddev_ns": 9755.964340340734, + "min_ns": 77038, + "max_ns": 109409, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "pipe_multi", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 3819669, + 8396326, + 10273253, + 11529396, + 4870166, + 3768508, + 13495428, + 12593179, + 14055414, + 3807991 + ], + "mean_ns": 8660933.0, + "stddev_ns": 4051266.695739667, + "min_ns": 3768508, + "max_ns": 14055414, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "pipe_command_subst", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 47619, + 44810, + 70903, + 46569, + 43348, + 43696, + 43489, + 42338, + 42200, + 40559 + ], + "mean_ns": 46553.1, + "stddev_ns": 8352.021317621262, + "min_ns": 40559, + "max_ns": 70903, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "pipe_command_subst", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 2090986, + 2494969, + 2210691, + 2123098, + 2173917, + 2202569, + 2266773, + 2160507, + 2140863, + 4962566 + ], + "mean_ns": 2482693.9, + "stddev_ns": 833530.6778771193, + "min_ns": 2090986, + "max_ns": 4962566, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "pipe_heredoc", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 57962, + 54487, + 51451, + 51091, + 52620, + 49321, + 49903, + 56488, + 51151, + 50933 + ], + "mean_ns": 52540.7, + "stddev_ns": 2718.363737618643, + "min_ns": 49321, + "max_ns": 57962, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "pipe_heredoc", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 4853445, + 3206644, + 8754186, + 5592187, + 3186255, + 3308416, + 3132564, + 3269366, + 3560007, + 3189194 + ], + "mean_ns": 4205226.4, + "stddev_ns": 1713233.4383724362, + "min_ns": 3132564, + "max_ns": 8754186, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "pipe_herestring", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 56114, + 56317, + 62230, + 53480, + 52580, + 50884, + 50789, + 49598, + 86919, + 54762 + ], + "mean_ns": 57367.3, + "stddev_ns": 10440.827927420314, + "min_ns": 49598, + "max_ns": 86919, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "pipe_herestring", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 12946911, + 3397750, + 3587555, + 3252920, + 9549879, + 3625426, + 3379880, + 3220519, + 3260060, + 3213738 + ], + "mean_ns": 4943463.8, + "stddev_ns": 3245554.539148335, + "min_ns": 3213738, + "max_ns": 12946911, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "pipe_discard", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 46910, + 45460, + 43094, + 50577, + 43095, + 40323, + 43272, + 42107, + 42001, + 41081 + ], + "mean_ns": 43792.0, + "stddev_ns": 2919.14669038745, + "min_ns": 40323, + "max_ns": 50577, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "pipe_discard", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 2179432, + 2023918, + 8193698, + 4659962, + 2541513, + 2466707, + 2575529, + 2250970, + 2212302, + 2119588 + ], + "mean_ns": 3122361.9, + "stddev_ns": 1839503.8210611823, + "min_ns": 2023918, + "max_ns": 8193698, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_grep_simple", + "category": "tools", + "iterations": 10, + "times_ns": [ + 52525, + 49030, + 46824, + 49283, + 55108, + 46757, + 47549, + 45331, + 44785, + 44579 + ], + "mean_ns": 48177.1, + "stddev_ns": 3250.9550427528216, + "min_ns": 44579, + "max_ns": 55108, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_grep_simple", + "category": "tools", + "iterations": 10, + "times_ns": [ + 5808400, + 6353854, + 5580243, + 5233814, + 3552050, + 9381957, + 3485589, + 6195460, + 6554292, + 7790736 + ], + "mean_ns": 5993639.5, + "stddev_ns": 1680157.4409319058, + "min_ns": 3485589, + "max_ns": 9381957, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_grep_case", + "category": "tools", + "iterations": 10, + "times_ns": [ + 182048, + 167570, + 162044, + 167498, + 155876, + 176152, + 158953, + 152674, + 151178, + 149678 + ], + "mean_ns": 162367.1, + "stddev_ns": 10319.001176955066, + "min_ns": 149678, + "max_ns": 182048, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_grep_case", + "category": "tools", + "iterations": 10, + "times_ns": [ + 3437669, + 3541904, + 4238847, + 3804651, + 7081535, + 3438739, + 3481432, + 3536332, + 3404879, + 8314614 + ], + "mean_ns": 4428060.2, + "stddev_ns": 1674925.5952602075, + "min_ns": 3404879, + "max_ns": 8314614, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_grep_count", + "category": "tools", + "iterations": 10, + "times_ns": [ + 52289, + 49536, + 137739, + 51105, + 47793, + 53964, + 47598, + 44470, + 43523, + 41417 + ], + "mean_ns": 56943.4, + "stddev_ns": 27194.216286556228, + "min_ns": 41417, + "max_ns": 137739, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_grep_count", + "category": "tools", + "iterations": 10, + "times_ns": [ + 3424536, + 4719138, + 7833891, + 5810214, + 3346583, + 10824514, + 3341936, + 3410713, + 3958499, + 5600815 + ], + "mean_ns": 5227083.9, + "stddev_ns": 2326201.7911909726, + "min_ns": 3341936, + "max_ns": 10824514, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_grep_invert", + "category": "tools", + "iterations": 10, + "times_ns": [ + 59079, + 57194, + 50175, + 47549, + 86117, + 48504, + 46925, + 46084, + 44809, + 51624 + ], + "mean_ns": 53806.0, + "stddev_ns": 11646.517874455008, + "min_ns": 44809, + "max_ns": 86117, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_grep_invert", + "category": "tools", + "iterations": 10, + "times_ns": [ + 12463682, + 3345777, + 3431155, + 3323732, + 3651994, + 4118827, + 3737599, + 5868286, + 10158739, + 6717333 + ], + "mean_ns": 5681712.4, + "stddev_ns": 3058918.3939284226, + "min_ns": 3323732, + "max_ns": 12463682, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_grep_regex", + "category": "tools", + "iterations": 10, + "times_ns": [ + 76175, + 74675, + 101019, + 78652, + 73161, + 70282, + 70236, + 68537, + 68201, + 66237 + ], + "mean_ns": 74717.5, + "stddev_ns": 9508.615737845335, + "min_ns": 66237, + "max_ns": 101019, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_grep_regex", + "category": "tools", + "iterations": 10, + "times_ns": [ + 3728852, + 3449314, + 6877107, + 3528982, + 3411319, + 3114211, + 3282660, + 3305237, + 3401684, + 3349278 + ], + "mean_ns": 3744864.4, + "stddev_ns": 1055239.2212948871, + "min_ns": 3114211, + "max_ns": 6877107, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_sed_replace", + "category": "tools", + "iterations": 10, + "times_ns": [ + 136666, + 139215, + 130831, + 157543, + 144193, + 128757, + 122119, + 118912, + 125380, + 120039 + ], + "mean_ns": 132365.5, + "stddev_ns": 11581.491322364318, + "min_ns": 118912, + "max_ns": 157543, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_sed_replace", + "category": "tools", + "iterations": 10, + "times_ns": [ + 4016475, + 7969302, + 3709964, + 3730710, + 3801102, + 3899662, + 4698985, + 3846758, + 6537218, + 3554521 + ], + "mean_ns": 4576469.7, + "stddev_ns": 1406652.5020930402, + "min_ns": 3554521, + "max_ns": 7969302, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_sed_global", + "category": "tools", + "iterations": 10, + "times_ns": [ + 224158, + 153966, + 128981, + 121106, + 164076, + 120977, + 121572, + 124722, + 117832, + 124010 + ], + "mean_ns": 140140.0, + "stddev_ns": 31650.372531772828, + "min_ns": 117832, + "max_ns": 224158, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_sed_global", + "category": "tools", + "iterations": 10, + "times_ns": [ + 3423448, + 3590259, + 5083055, + 3501979, + 3410515, + 3583287, + 7532658, + 3572270, + 8029919, + 3668552 + ], + "mean_ns": 4539594.2, + "stddev_ns": 1689484.7335574715, + "min_ns": 3410515, + "max_ns": 8029919, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_sed_delete", + "category": "tools", + "iterations": 10, + "times_ns": [ + 72936, + 53251, + 70108, + 76575, + 68574, + 65899, + 66507, + 67935, + 49025, + 41732 + ], + "mean_ns": 63254.2, + "stddev_ns": 10731.6383166784, + "min_ns": 41732, + "max_ns": 76575, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_sed_delete", + "category": "tools", + "iterations": 10, + "times_ns": [ + 6773413, + 4665834, + 7952106, + 4091321, + 5598464, + 6380748, + 3616093, + 8310451, + 4028851, + 3519835 + ], + "mean_ns": 5493711.6, + "stddev_ns": 1692120.0879054773, + "min_ns": 3519835, + "max_ns": 8310451, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_sed_lines", + "category": "tools", + "iterations": 10, + "times_ns": [ + 54754, + 44425, + 41962, + 40935, + 41525, + 38695, + 39758, + 48339, + 40493, + 38143 + ], + "mean_ns": 42902.9, + "stddev_ns": 4848.032166766223, + "min_ns": 38143, + "max_ns": 54754, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_sed_lines", + "category": "tools", + "iterations": 10, + "times_ns": [ + 3454890, + 3428492, + 5879153, + 3609134, + 5680546, + 3494798, + 3433570, + 3569021, + 3346820, + 12013359 + ], + "mean_ns": 4790978.3, + "stddev_ns": 2574449.614196442, + "min_ns": 3346820, + "max_ns": 12013359, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_sed_backrefs", + "category": "tools", + "iterations": 10, + "times_ns": [ + 346055, + 308772, + 196254, + 196820, + 176536, + 304479, + 289966, + 186734, + 287484, + 274767 + ], + "mean_ns": 256786.7, + "stddev_ns": 58240.970244407836, + "min_ns": 176536, + "max_ns": 346055, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_sed_backrefs", + "category": "tools", + "iterations": 10, + "times_ns": [ + 3532779, + 5591635, + 3353101, + 3391096, + 3421422, + 3414078, + 3656330, + 3358771, + 11135482, + 8032902 + ], + "mean_ns": 4888759.6, + "stddev_ns": 2530388.2364767347, + "min_ns": 3353101, + "max_ns": 11135482, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_awk_print", + "category": "tools", + "iterations": 10, + "times_ns": [ + 47271, + 80347, + 47860, + 44908, + 43248, + 42088, + 43075, + 41925, + 48175, + 41279 + ], + "mean_ns": 48017.6, + "stddev_ns": 11046.87186673223, + "min_ns": 41279, + "max_ns": 80347, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_awk_print", + "category": "tools", + "iterations": 10, + "times_ns": [ + 3186401, + 3531495, + 3534418, + 10752766, + 7381887, + 6175877, + 3427092, + 3529453, + 9036541, + 4241920 + ], + "mean_ns": 5479785.0, + "stddev_ns": 2587410.443700574, + "min_ns": 3186401, + "max_ns": 10752766, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_awk_sum", + "category": "tools", + "iterations": 10, + "times_ns": [ + 56356, + 52225, + 50774, + 49829, + 48511, + 46493, + 83383, + 52120, + 47678, + 46686 + ], + "mean_ns": 53405.5, + "stddev_ns": 10391.404690897185, + "min_ns": 46493, + "max_ns": 83383, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_awk_sum", + "category": "tools", + "iterations": 10, + "times_ns": [ + 3596945, + 12394417, + 3207865, + 6264388, + 8131690, + 3606525, + 4058439, + 3364391, + 3519362, + 3281042 + ], + "mean_ns": 5142506.4, + "stddev_ns": 2858521.0245406344, + "min_ns": 3207865, + "max_ns": 12394417, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_awk_pattern", + "category": "tools", + "iterations": 10, + "times_ns": [ + 78456, + 74046, + 117210, + 74465, + 71681, + 79022, + 72648, + 122444, + 86862, + 70082 + ], + "mean_ns": 84691.6, + "stddev_ns": 18177.19539532983, + "min_ns": 70082, + "max_ns": 122444, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_awk_pattern", + "category": "tools", + "iterations": 10, + "times_ns": [ + 3367441, + 3549405, + 3191136, + 13397749, + 11967289, + 3480072, + 3665185, + 3704401, + 3511486, + 3412228 + ], + "mean_ns": 5324639.2, + "stddev_ns": 3695403.154165288, + "min_ns": 3191136, + "max_ns": 13397749, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_awk_fieldsep", + "category": "tools", + "iterations": 10, + "times_ns": [ + 50897, + 48017, + 47799, + 45697, + 44858, + 43037, + 44930, + 49522, + 60489, + 42809 + ], + "mean_ns": 47805.5, + "stddev_ns": 4924.686025524876, + "min_ns": 42809, + "max_ns": 60489, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_awk_fieldsep", + "category": "tools", + "iterations": 10, + "times_ns": [ + 3431040, + 3325072, + 3283757, + 3313395, + 3322557, + 3447516, + 3330163, + 3377824, + 3243317, + 3235973 + ], + "mean_ns": 3331061.4, + "stddev_ns": 67189.15941310771, + "min_ns": 3235973, + "max_ns": 3447516, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_awk_nf", + "category": "tools", + "iterations": 10, + "times_ns": [ + 50528, + 50435, + 45905, + 51916, + 44649, + 43403, + 43941, + 42336, + 41448, + 42459 + ], + "mean_ns": 45702.0, + "stddev_ns": 3657.668957136498, + "min_ns": 41448, + "max_ns": 51916, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_awk_nf", + "category": "tools", + "iterations": 10, + "times_ns": [ + 3585116, + 3731779, + 3553630, + 3649092, + 3570955, + 3571847, + 3375905, + 3651817, + 3435523, + 7514586 + ], + "mean_ns": 3964025.0, + "stddev_ns": 1187542.9020580268, + "min_ns": 3375905, + "max_ns": 7514586, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_awk_compute", + "category": "tools", + "iterations": 10, + "times_ns": [ + 49627, + 48237, + 46499, + 46660, + 44965, + 42803, + 43847, + 43845, + 44741, + 41898 + ], + "mean_ns": 45312.2, + "stddev_ns": 2304.2604800673034, + "min_ns": 41898, + "max_ns": 49627, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_awk_compute", + "category": "tools", + "iterations": 10, + "times_ns": [ + 4017814, + 3214349, + 3441343, + 3346218, + 4022349, + 3627773, + 3592935, + 3746156, + 3465773, + 3551298 + ], + "mean_ns": 3602600.8, + "stddev_ns": 251744.9539092293, + "min_ns": 3214349, + "max_ns": 4022349, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_jq_identity", + "category": "tools", + "iterations": 10, + "times_ns": [ + 594821, + 874359, + 657296, + 631729, + 581865, + 640289, + 600040, + 772195, + 630159, + 579301 + ], + "mean_ns": 656205.4, + "stddev_ns": 89966.65456734512, + "min_ns": 579301, + "max_ns": 874359, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_jq_identity", + "category": "tools", + "iterations": 10, + "times_ns": [ + 9237003, + 9095683, + 4977976, + 4653698, + 4883829, + 4905449, + 7205673, + 5290050, + 4851917, + 4548659 + ], + "mean_ns": 5964993.7, + "stddev_ns": 1753062.7664553856, + "min_ns": 4548659, + "max_ns": 9237003, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_jq_field", + "category": "tools", + "iterations": 10, + "times_ns": [ + 636780, + 587013, + 680437, + 637943, + 585739, + 611518, + 605142, + 600237, + 604213, + 657310 + ], + "mean_ns": 620633.2, + "stddev_ns": 29729.85975681688, + "min_ns": 585739, + "max_ns": 680437, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_jq_field", + "category": "tools", + "iterations": 10, + "times_ns": [ + 7755390, + 4955969, + 4775603, + 4798715, + 4811080, + 4963002, + 4768291, + 4608150, + 4833334, + 4510897 + ], + "mean_ns": 5078043.1, + "stddev_ns": 901962.8369876943, + "min_ns": 4510897, + "max_ns": 7755390, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_jq_array", + "category": "tools", + "iterations": 10, + "times_ns": [ + 577033, + 654203, + 611100, + 645483, + 615002, + 607730, + 578741, + 608001, + 574416, + 614288 + ], + "mean_ns": 608599.7, + "stddev_ns": 25646.198884240137, + "min_ns": 574416, + "max_ns": 654203, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_jq_array", + "category": "tools", + "iterations": 10, + "times_ns": [ + 9111518, + 17059084, + 5268893, + 4846248, + 8898730, + 4841416, + 11068135, + 4893224, + 8853945, + 5026665 + ], + "mean_ns": 7986785.8, + "stddev_ns": 3743336.041970098, + "min_ns": 4841416, + "max_ns": 17059084, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_jq_filter", + "category": "tools", + "iterations": 10, + "times_ns": [ + 665420, + 601303, + 660056, + 633542, + 731824, + 630581, + 592644, + 610844, + 613152, + 586297 + ], + "mean_ns": 632566.3, + "stddev_ns": 41536.537017546376, + "min_ns": 586297, + "max_ns": 731824, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_jq_filter", + "category": "tools", + "iterations": 10, + "times_ns": [ + 5239703, + 4894832, + 5031696, + 5159421, + 4753526, + 4935351, + 9111904, + 5005431, + 5676592, + 4722296 + ], + "mean_ns": 5453075.2, + "stddev_ns": 1246987.3829543586, + "min_ns": 4722296, + "max_ns": 9111904, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_jq_map", + "category": "tools", + "iterations": 10, + "times_ns": [ + 614572, + 627887, + 667828, + 630387, + 618346, + 566489, + 622782, + 574834, + 646784, + 616390 + ], + "mean_ns": 618629.9, + "stddev_ns": 28482.60563730081, + "min_ns": 566489, + "max_ns": 667828, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_jq_map", + "category": "tools", + "iterations": 10, + "times_ns": [ + 5026547, + 4797621, + 7098402, + 5079469, + 4928945, + 4664247, + 4857370, + 4778381, + 9298883, + 6210334 + ], + "mean_ns": 5674019.9, + "stddev_ns": 1415654.6418457753, + "min_ns": 4664247, + "max_ns": 9298883, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "complex_fibonacci", + "category": "complex", + "iterations": 10, + "times_ns": [ + 3893062, + 8101134, + 8091308, + 3840349, + 8025909, + 3885617, + 7972386, + 3883949, + 3779270, + 3837769 + ], + "mean_ns": 5531075.3, + "stddev_ns": 2055297.9204142669, + "min_ns": 3779270, + "max_ns": 8101134, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "complex_fibonacci", + "category": "complex", + "iterations": 10, + "times_ns": [ + 143412300, + 114951561, + 89795486, + 131328432, + 153283916, + 151564921, + 123870911, + 140904438, + 78281932, + 106751210 + ], + "mean_ns": 123414510.7, + "stddev_ns": 24433072.152701516, + "min_ns": 78281932, + "max_ns": 153283916, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "complex_fibonacci_iter", + "category": "complex", + "iterations": 10, + "times_ns": [ + 149930, + 107896, + 89324, + 85917, + 106435, + 93436, + 99078, + 87299, + 98717, + 96173 + ], + "mean_ns": 101420.5, + "stddev_ns": 17636.42311382895, + "min_ns": 85917, + "max_ns": 149930, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "complex_fibonacci_iter", + "category": "complex", + "iterations": 10, + "times_ns": [ + 1859361, + 1751551, + 1766821, + 1873198, + 1733370, + 1859380, + 1940969, + 1967533, + 1732231, + 1782714 + ], + "mean_ns": 1826712.8, + "stddev_ns": 81234.62305913655, + "min_ns": 1732231, + "max_ns": 1967533, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "complex_nested_subst", + "category": "complex", + "iterations": 10, + "times_ns": [ + 53630, + 52979, + 50307, + 50571, + 82954, + 53310, + 49667, + 50369, + 57981, + 84065 + ], + "mean_ns": 58583.3, + "stddev_ns": 12676.873013878461, + "min_ns": 49667, + "max_ns": 84065, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "complex_nested_subst", + "category": "complex", + "iterations": 10, + "times_ns": [ + 3148586, + 3308079, + 3166921, + 3084810, + 3656414, + 3123278, + 3034470, + 3008283, + 2772219, + 3959574 + ], + "mean_ns": 3226263.4, + "stddev_ns": 325590.94018237054, + "min_ns": 2772219, + "max_ns": 3959574, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "complex_loop_compute", + "category": "complex", + "iterations": 10, + "times_ns": [ + 124858, + 120696, + 101104, + 106619, + 116830, + 188333, + 99411, + 96343, + 99940, + 120754 + ], + "mean_ns": 117488.8, + "stddev_ns": 25618.574858098567, + "min_ns": 96343, + "max_ns": 188333, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "complex_loop_compute", + "category": "complex", + "iterations": 10, + "times_ns": [ + 1692182, + 1729743, + 1768129, + 4638363, + 1730033, + 6023388, + 1839994, + 1884458, + 1729230, + 1751690 + ], + "mean_ns": 2478721.0, + "stddev_ns": 1460316.2531316974, + "min_ns": 1692182, + "max_ns": 6023388, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "complex_string_build", + "category": "complex", + "iterations": 10, + "times_ns": [ + 92183, + 92064, + 78182, + 77916, + 77920, + 110256, + 80208, + 71528, + 48472, + 47008 + ], + "mean_ns": 77573.7, + "stddev_ns": 18212.087414956033, + "min_ns": 47008, + "max_ns": 110256, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "complex_string_build", + "category": "complex", + "iterations": 10, + "times_ns": [ + 5762267, + 1801668, + 1763674, + 1841596, + 1742669, + 1830988, + 7124904, + 1677312, + 1706213, + 1713059 + ], + "mean_ns": 2696435.0, + "stddev_ns": 1898865.7685015546, + "min_ns": 1677312, + "max_ns": 7124904, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "complex_json_transform", + "category": "complex", + "iterations": 10, + "times_ns": [ + 649393, + 650356, + 615682, + 588147, + 623378, + 570254, + 702501, + 644989, + 612107, + 633476 + ], + "mean_ns": 629028.3, + "stddev_ns": 34877.549306251436, + "min_ns": 570254, + "max_ns": 702501, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "complex_json_transform", + "category": "complex", + "iterations": 10, + "times_ns": [ + 6654240, + 4506764, + 4490209, + 4593134, + 4642370, + 4667238, + 4864219, + 5048353, + 5018224, + 6059650 + ], + "mean_ns": 5054440.1, + "stddev_ns": 689795.3163687689, + "min_ns": 4490209, + "max_ns": 6654240, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "complex_pipeline_text", + "category": "complex", + "iterations": 10, + "times_ns": [ + 237340, + 241585, + 232406, + 202335, + 249255, + 198693, + 201268, + 183809, + 184436, + 220868 + ], + "mean_ns": 215199.5, + "stddev_ns": 22887.053070458853, + "min_ns": 183809, + "max_ns": 249255, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "complex_pipeline_text", + "category": "complex", + "iterations": 10, + "times_ns": [ + 3947063, + 8097837, + 5249600, + 3828659, + 3959028, + 3857366, + 3762772, + 5404387, + 15333825, + 6387890 + ], + "mean_ns": 5982842.7, + "stddev_ns": 3395253.8563206745, + "min_ns": 3762772, + "max_ns": 15333825, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "large_loop_1000", + "category": "large", + "iterations": 10, + "times_ns": [ + 3484443, + 3392917, + 3865613, + 8621004, + 7360674, + 3457549, + 7058134, + 9904556, + 3969938, + 3524927 + ], + "mean_ns": 5463975.5, + "stddev_ns": 2379176.1178370654, + "min_ns": 3392917, + "max_ns": 9904556, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "large_loop_1000", + "category": "large", + "iterations": 10, + "times_ns": [ + 5037210, + 4703196, + 4452505, + 4430776, + 4224778, + 8947758, + 4565235, + 4637169, + 4354084, + 4212094 + ], + "mean_ns": 4956480.5, + "stddev_ns": 1350508.6866229516, + "min_ns": 4212094, + "max_ns": 8947758, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "large_string_append_100", + "category": "large", + "iterations": 10, + "times_ns": [ + 238559, + 245712, + 283576, + 267707, + 247752, + 236044, + 254538, + 229044, + 374713, + 293978 + ], + "mean_ns": 267162.3, + "stddev_ns": 40977.66624650554, + "min_ns": 229044, + "max_ns": 374713, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "large_string_append_100", + "category": "large", + "iterations": 10, + "times_ns": [ + 1913305, + 2047962, + 2170257, + 2094400, + 1994946, + 2133491, + 4208490, + 2219422, + 2066601, + 2067685 + ], + "mean_ns": 2291655.9, + "stddev_ns": 644167.6784096591, + "min_ns": 1913305, + "max_ns": 4208490, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "large_array_fill_200", + "category": "large", + "iterations": 10, + "times_ns": [ + 1504506, + 1735735, + 1537246, + 1550758, + 1631855, + 1545835, + 1577871, + 1674896, + 1547182, + 1579870 + ], + "mean_ns": 1588575.4, + "stddev_ns": 67707.73088828188, + "min_ns": 1504506, + "max_ns": 1735735, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "large_array_fill_200", + "category": "large", + "iterations": 10, + "times_ns": [ + 2346229, + 2222910, + 2339071, + 2295589, + 2723658, + 2277539, + 2256572, + 2392828, + 2525006, + 2363328 + ], + "mean_ns": 2374273.0, + "stddev_ns": 141277.37436900503, + "min_ns": 2222910, + "max_ns": 2723658, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "large_nested_loops", + "category": "large", + "iterations": 10, + "times_ns": [ + 1562446, + 1812035, + 1543130, + 1426753, + 1605570, + 1539570, + 1491074, + 1507084, + 1479673, + 1576885 + ], + "mean_ns": 1554422.0, + "stddev_ns": 99014.28164461933, + "min_ns": 1426753, + "max_ns": 1812035, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "large_nested_loops", + "category": "large", + "iterations": 10, + "times_ns": [ + 3111926, + 3775077, + 3037412, + 2950541, + 2866014, + 2910146, + 2801280, + 2881706, + 2905572, + 2806469 + ], + "mean_ns": 3004614.3, + "stddev_ns": 272540.7697351169, + "min_ns": 2801280, + "max_ns": 3775077, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "large_fibonacci_12", + "category": "large", + "iterations": 10, + "times_ns": [ + 10838706, + 14459508, + 15645375, + 11541305, + 16798011, + 16066676, + 11886817, + 11791284, + 11848563, + 11329127 + ], + "mean_ns": 13220537.2, + "stddev_ns": 2146810.9496798175, + "min_ns": 10838706, + "max_ns": 16798011, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "large_fibonacci_12", + "category": "large", + "iterations": 10, + "times_ns": [ + 370127700, + 371321874, + 431045873, + 473345882, + 438613509, + 399276211, + 391978199, + 418303628, + 463558606, + 375134039 + ], + "mean_ns": 413270552.1, + "stddev_ns": 35841442.17890252, + "min_ns": 370127700, + "max_ns": 473345882, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "large_function_calls_500", + "category": "large", + "iterations": 10, + "times_ns": [ + 5591576, + 5053539, + 5188346, + 5072615, + 5015441, + 5602959, + 5735751, + 5515264, + 5614968, + 5604244 + ], + "mean_ns": 5399470.3, + "stddev_ns": 266782.9628810843, + "min_ns": 5015441, + "max_ns": 5735751, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "large_function_calls_500", + "category": "large", + "iterations": 10, + "times_ns": [ + 169019682, + 160538602, + 258584033, + 339654045, + 213709950, + 194834782, + 333679101, + 301068901, + 233749395, + 155733945 + ], + "mean_ns": 236057243.6, + "stddev_ns": 66218764.23661219, + "min_ns": 155733945, + "max_ns": 339654045, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "large_multiline_script", + "category": "large", + "iterations": 10, + "times_ns": [ + 326934, + 377161, + 399814, + 437998, + 463752, + 427908, + 438094, + 432633, + 421816, + 452546 + ], + "mean_ns": 417865.6, + "stddev_ns": 38351.8000990827, + "min_ns": 326934, + "max_ns": 463752, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "large_multiline_script", + "category": "large", + "iterations": 10, + "times_ns": [ + 1974399, + 2193808, + 1851535, + 2002350, + 1960964, + 2004869, + 1860080, + 1906140, + 1877889, + 2099796 + ], + "mean_ns": 1973183.0, + "stddev_ns": 103864.32317884712, + "min_ns": 1851535, + "max_ns": 2193808, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "large_pipeline_chain", + "category": "large", + "iterations": 10, + "times_ns": [ + 918938, + 767153, + 734886, + 826783, + 778568, + 780347, + 755964, + 722276, + 820035, + 1011089 + ], + "mean_ns": 811603.9, + "stddev_ns": 85218.28327119714, + "min_ns": 722276, + "max_ns": 1011089, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "large_pipeline_chain", + "category": "large", + "iterations": 10, + "times_ns": [ + 5850698, + 17664297, + 5280870, + 7840384, + 7973805, + 8322114, + 4594791, + 11155780, + 11795778, + 10626408 + ], + "mean_ns": 9110492.5, + "stddev_ns": 3688768.545830119, + "min_ns": 4594791, + "max_ns": 17664297, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "large_assoc_array", + "category": "large", + "iterations": 10, + "times_ns": [ + 78596, + 49760, + 47067, + 46100, + 46316, + 44877, + 46098, + 45607, + 46124, + 45013 + ], + "mean_ns": 49555.8, + "stddev_ns": 9766.589740538915, + "min_ns": 44877, + "max_ns": 78596, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "large_assoc_array", + "category": "large", + "iterations": 10, + "times_ns": [ + 2023356, + 3288777, + 1665852, + 1673239, + 2649346, + 1691254, + 5883124, + 3850305, + 1657157, + 1687626 + ], + "mean_ns": 2607003.6, + "stddev_ns": 1320218.1388891155, + "min_ns": 1657157, + "max_ns": 5883124, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "subshell_simple", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 39000, + 64515, + 43221, + 39235, + 38765, + 37497, + 36249, + 41444, + 41097, + 42857 + ], + "mean_ns": 42388.0, + "stddev_ns": 7676.13887315752, + "min_ns": 36249, + "max_ns": 64515, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "subshell_simple", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 2449807, + 2231146, + 2111629, + 2146374, + 2120396, + 2177055, + 2135122, + 2153560, + 6472182, + 2215505 + ], + "mean_ns": 2621277.6, + "stddev_ns": 1287024.443199833, + "min_ns": 2111629, + "max_ns": 6472182, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "subshell_isolation", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 47820, + 46922, + 45894, + 44405, + 43900, + 43974, + 43388, + 77233, + 45867, + 44243 + ], + "mean_ns": 48364.6, + "stddev_ns": 9719.807921970474, + "min_ns": 43388, + "max_ns": 77233, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "subshell_isolation", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 2404853, + 2183393, + 2311275, + 2226235, + 2326967, + 2294980, + 2225233, + 2331243, + 2154226, + 2316420 + ], + "mean_ns": 2277482.5, + "stddev_ns": 73435.475860445, + "min_ns": 2154226, + "max_ns": 2404853, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "subshell_nested", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 55924, + 53939, + 52384, + 52093, + 51732, + 51663, + 50056, + 51160, + 49554, + 49987 + ], + "mean_ns": 51849.2, + "stddev_ns": 1838.243988158264, + "min_ns": 49554, + "max_ns": 55924, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "subshell_nested", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 3912541, + 3963150, + 3917146, + 3769660, + 4032451, + 3728531, + 3843388, + 3735034, + 3514211, + 4001205 + ], + "mean_ns": 3841731.7, + "stddev_ns": 149637.87684142677, + "min_ns": 3514211, + "max_ns": 4032451, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "subshell_pipeline", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 44839, + 42425, + 41131, + 40299, + 39716, + 39871, + 75147, + 43611, + 40974, + 40428 + ], + "mean_ns": 44844.1, + "stddev_ns": 10225.420025113883, + "min_ns": 39716, + "max_ns": 75147, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "subshell_pipeline", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 4544537, + 9058158, + 3712640, + 5927983, + 4139939, + 4437426, + 6933960, + 5094825, + 7976346, + 6164279 + ], + "mean_ns": 5799009.3, + "stddev_ns": 1668231.1318680064, + "min_ns": 3712640, + "max_ns": 9058158, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "subshell_capture_loop", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 89642, + 132524, + 93957, + 90126, + 83151, + 83100, + 82745, + 81616, + 82652, + 79933 + ], + "mean_ns": 89944.6, + "stddev_ns": 14821.756469460695, + "min_ns": 79933, + "max_ns": 132524, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "subshell_capture_loop", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 4242861, + 4187101, + 4286275, + 4143410, + 7544804, + 4643299, + 4213235, + 4219618, + 4151947, + 11749599 + ], + "mean_ns": 5338214.9, + "stddev_ns": 2354588.279338936, + "min_ns": 4143410, + "max_ns": 11749599, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "subshell_process_subst", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 63654, + 61525, + 59553, + 58338, + 59740, + 59290, + 92555, + 58288, + 56543, + 55540 + ], + "mean_ns": 62502.6, + "stddev_ns": 10252.718880375098, + "min_ns": 55540, + "max_ns": 92555, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "subshell_process_subst", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 2644643, + 2416499, + 2434581, + 3088489, + 2655944, + 2661428, + 2514445, + 2633732, + 2387918, + 2694668 + ], + "mean_ns": 2613234.7, + "stddev_ns": 192145.8902521987, + "min_ns": 2387918, + "max_ns": 3088489, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "io_redirect_write", + "category": "io", + "iterations": 10, + "times_ns": [ + 123514, + 83860, + 69215, + 73573, + 112217, + 123642, + 118354, + 109619, + 107249, + 100439 + ], + "mean_ns": 102168.2, + "stddev_ns": 18981.132446721927, + "min_ns": 69215, + "max_ns": 123642, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "io_redirect_write", + "category": "io", + "iterations": 10, + "times_ns": [ + 4585128, + 4820462, + 4402396, + 4439490, + 4207788, + 4437184, + 4161196, + 7892386, + 4423134, + 4769134 + ], + "mean_ns": 4813829.8, + "stddev_ns": 1045417.8760279357, + "min_ns": 4161196, + "max_ns": 7892386, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "io_append", + "category": "io", + "iterations": 10, + "times_ns": [ + 84717, + 78932, + 102772, + 78580, + 75305, + 88259, + 116837, + 84735, + 73981, + 72895 + ], + "mean_ns": 85701.3, + "stddev_ns": 13323.930223849116, + "min_ns": 72895, + "max_ns": 116837, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "io_append", + "category": "io", + "iterations": 10, + "times_ns": [ + 4543880, + 8502324, + 4554406, + 4312190, + 4493810, + 4396159, + 4528935, + 4387896, + 8793969, + 4807951 + ], + "mean_ns": 5332152.0, + "stddev_ns": 1664067.7677022652, + "min_ns": 4312190, + "max_ns": 8793969, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "io_dev_null", + "category": "io", + "iterations": 10, + "times_ns": [ + 45339, + 41576, + 39342, + 37955, + 38146, + 37369, + 36421, + 35905, + 36660, + 37052 + ], + "mean_ns": 38576.5, + "stddev_ns": 2740.8796124602045, + "min_ns": 35905, + "max_ns": 45339, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "io_dev_null", + "category": "io", + "iterations": 10, + "times_ns": [ + 1653387, + 1731291, + 1674476, + 5394345, + 1776569, + 1608052, + 1720582, + 1798664, + 1673554, + 1815395 + ], + "mean_ns": 2084631.5, + "stddev_ns": 1105036.8903866739, + "min_ns": 1608052, + "max_ns": 5394345, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "io_stderr_redirect", + "category": "io", + "iterations": 10, + "times_ns": [ + 44274, + 40937, + 39452, + 38182, + 37776, + 53766, + 37036, + 35906, + 35808, + 36127 + ], + "mean_ns": 39926.4, + "stddev_ns": 5246.56421289209, + "min_ns": 35808, + "max_ns": 53766, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "io_stderr_redirect", + "category": "io", + "iterations": 10, + "times_ns": [ + 1737646, + 1691931, + 1743593, + 1671607, + 1696178, + 1611824, + 1731786, + 1648721, + 1585583, + 1673779 + ], + "mean_ns": 1679264.8, + "stddev_ns": 50097.55284602233, + "min_ns": 1585583, + "max_ns": 1743593, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "io_read_lines", + "category": "io", + "iterations": 10, + "times_ns": [ + 81577, + 76876, + 75289, + 113912, + 80782, + 77144, + 74958, + 80527, + 74055, + 73888 + ], + "mean_ns": 80900.8, + "stddev_ns": 11329.238657562124, + "min_ns": 73888, + "max_ns": 113912, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "io_read_lines", + "category": "io", + "iterations": 10, + "times_ns": [ + 1819919, + 1725135, + 1746229, + 4961098, + 2084086, + 1773920, + 1678034, + 1804160, + 1682588, + 1767472 + ], + "mean_ns": 2104264.1, + "stddev_ns": 958477.3693949638, + "min_ns": 1678034, + "max_ns": 4961098, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "io_multiline_heredoc", + "category": "io", + "iterations": 10, + "times_ns": [ + 74824, + 62655, + 57976, + 56639, + 57431, + 55172, + 78549, + 65955, + 55991, + 54924 + ], + "mean_ns": 62011.6, + "stddev_ns": 8080.855031492646, + "min_ns": 54924, + "max_ns": 78549, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "io_multiline_heredoc", + "category": "io", + "iterations": 10, + "times_ns": [ + 3456452, + 3317390, + 3220804, + 3356780, + 3185508, + 3153797, + 3333682, + 4721947, + 12151792, + 3690395 + ], + "mean_ns": 4358854.7, + "stddev_ns": 2634135.991337465, + "min_ns": 3153797, + "max_ns": 12151792, + "errors": 0, + "error_messages": [], + "output_match": true + } + ], + "summary": { + "total_cases": 96, + "runner_stats": { + "bash": { + "total_time_ms": 1095.0619233999998, + "avg_time_ms": 11.406895035416666, + "error_count": 0, + "error_rate": 0.0, + "output_match_rate": 1.0 + }, + "bashkit": { + "total_time_ms": 43.160844299999994, + "avg_time_ms": 0.44959212812499993, + "error_count": 0, + "error_rate": 0.0, + "output_match_rate": 1.0 + } + } + } +} \ No newline at end of file diff --git a/crates/bashkit-bench/results/bench-after-perf-linux-x86_64.md b/crates/bashkit-bench/results/bench-after-perf-linux-x86_64.md new file mode 100644 index 000000000..0acc44584 --- /dev/null +++ b/crates/bashkit-bench/results/bench-after-perf-linux-x86_64.md @@ -0,0 +1,306 @@ +# Bashkit Benchmark Report + +## System Information + +- **Moniker**: `vm-linux-x86_64` +- **Hostname**: vm +- **OS**: linux +- **Architecture**: x86_64 +- **CPUs**: 4 +- **Timestamp**: 1779744905 +- **Iterations**: 10 +- **Warmup**: 2 +- **Prewarm cases**: 3 + +## Summary + +Benchmarked 96 cases across 2 runners. + +| Runner | Total Time (ms) | Avg/Case (ms) | Errors | Error Rate | Output Match | +|--------|-----------------|---------------|--------|------------|-------------| +| bashkit | 43.16 | 0.450 | 0 | 0.0% | 100.0% | +| bash | 1095.06 | 11.407 | 0 | 0.0% | 100.0% | + +## Performance Comparison + +**Bashkit is 25.4x faster** than bash on average. + +## Results by Category + +### Arithmetic + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| arith_basic | bashkit | 0.044 | ±0.007 | - | ✓ | +| arith_basic | bash | 1.680 | ±0.067 | - | ✓ | +| arith_complex | bashkit | 0.060 | ±0.043 | - | ✓ | +| arith_complex | bash | 2.002 | ±0.834 | - | ✓ | +| arith_variables | bashkit | 0.047 | ±0.003 | - | ✓ | +| arith_variables | bash | 2.976 | ±2.490 | - | ✓ | +| arith_increment | bashkit | 0.045 | ±0.004 | - | ✓ | +| arith_increment | bash | 3.438 | ±2.058 | - | ✓ | +| arith_modulo | bashkit | 0.042 | ±0.002 | - | ✓ | +| arith_modulo | bash | 2.844 | ±2.364 | - | ✓ | +| arith_loop_sum | bashkit | 0.081 | ±0.011 | - | ✓ | +| arith_loop_sum | bash | 3.339 | ±2.826 | - | ✓ | + +### Arrays + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| arr_create | bashkit | 0.045 | ±0.010 | - | ✓ | +| arr_create | bash | 2.008 | ±0.896 | - | ✓ | +| arr_all | bashkit | 0.044 | ±0.008 | - | ✓ | +| arr_all | bash | 2.066 | ±0.883 | - | ✓ | +| arr_length | bashkit | 0.040 | ±0.002 | - | ✓ | +| arr_length | bash | 2.534 | ±2.487 | - | ✓ | +| arr_iterate | bashkit | 0.047 | ±0.002 | - | ✓ | +| arr_iterate | bash | 2.009 | ±0.620 | - | ✓ | +| arr_slice | bashkit | 0.050 | ±0.019 | - | ✓ | +| arr_slice | bash | 2.645 | ±1.185 | - | ✓ | +| arr_assign_index | bashkit | 0.046 | ±0.010 | - | ✓ | +| arr_assign_index | bash | 2.634 | ±1.764 | - | ✓ | + +### Complex + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| complex_fibonacci | bashkit | 5.531 | ±2.055 | - | ✓ | +| complex_fibonacci | bash | 123.415 | ±24.433 | - | ✓ | +| complex_fibonacci_iter | bashkit | 0.101 | ±0.018 | - | ✓ | +| complex_fibonacci_iter | bash | 1.827 | ±0.081 | - | ✓ | +| complex_nested_subst | bashkit | 0.059 | ±0.013 | - | ✓ | +| complex_nested_subst | bash | 3.226 | ±0.326 | - | ✓ | +| complex_loop_compute | bashkit | 0.117 | ±0.026 | - | ✓ | +| complex_loop_compute | bash | 2.479 | ±1.460 | - | ✓ | +| complex_string_build | bashkit | 0.078 | ±0.018 | - | ✓ | +| complex_string_build | bash | 2.696 | ±1.899 | - | ✓ | +| complex_json_transform | bashkit | 0.629 | ±0.035 | - | ✓ | +| complex_json_transform | bash | 5.054 | ±0.690 | - | ✓ | +| complex_pipeline_text | bashkit | 0.215 | ±0.023 | - | ✓ | +| complex_pipeline_text | bash | 5.983 | ±3.395 | - | ✓ | + +### Control + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| ctrl_if_simple | bashkit | 0.046 | ±0.010 | - | ✓ | +| ctrl_if_simple | bash | 1.722 | ±0.076 | - | ✓ | +| ctrl_if_else | bashkit | 0.049 | ±0.014 | - | ✓ | +| ctrl_if_else | bash | 1.721 | ±0.072 | - | ✓ | +| ctrl_for_list | bashkit | 0.056 | ±0.004 | - | ✓ | +| ctrl_for_list | bash | 2.362 | ±1.289 | - | ✓ | +| ctrl_for_range | bashkit | 0.063 | ±0.011 | - | ✓ | +| ctrl_for_range | bash | 2.884 | ±2.484 | - | ✓ | +| ctrl_while | bashkit | 0.088 | ±0.011 | - | ✓ | +| ctrl_while | bash | 2.444 | ±1.340 | - | ✓ | +| ctrl_case | bashkit | 0.052 | ±0.011 | - | ✓ | +| ctrl_case | bash | 1.911 | ±0.346 | - | ✓ | +| ctrl_function | bashkit | 0.050 | ±0.011 | - | ✓ | +| ctrl_function | bash | 1.846 | ±0.203 | - | ✓ | +| ctrl_function_return | bashkit | 0.064 | ±0.009 | - | ✓ | +| ctrl_function_return | bash | 2.651 | ±1.066 | - | ✓ | +| ctrl_nested_loops | bashkit | 0.082 | ±0.027 | - | ✓ | +| ctrl_nested_loops | bash | 2.553 | ±1.465 | - | ✓ | + +### Io + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| io_redirect_write | bashkit | 0.102 | ±0.019 | - | ✓ | +| io_redirect_write | bash | 4.814 | ±1.045 | - | ✓ | +| io_append | bashkit | 0.086 | ±0.013 | - | ✓ | +| io_append | bash | 5.332 | ±1.664 | - | ✓ | +| io_dev_null | bashkit | 0.039 | ±0.003 | - | ✓ | +| io_dev_null | bash | 2.085 | ±1.105 | - | ✓ | +| io_stderr_redirect | bashkit | 0.040 | ±0.005 | - | ✓ | +| io_stderr_redirect | bash | 1.679 | ±0.050 | - | ✓ | +| io_read_lines | bashkit | 0.081 | ±0.011 | - | ✓ | +| io_read_lines | bash | 2.104 | ±0.958 | - | ✓ | +| io_multiline_heredoc | bashkit | 0.062 | ±0.008 | - | ✓ | +| io_multiline_heredoc | bash | 4.359 | ±2.634 | - | ✓ | + +### Large + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| large_loop_1000 | bashkit | 5.464 | ±2.379 | - | ✓ | +| large_loop_1000 | bash | 4.956 | ±1.351 | - | ✓ | +| large_string_append_100 | bashkit | 0.267 | ±0.041 | - | ✓ | +| large_string_append_100 | bash | 2.292 | ±0.644 | - | ✓ | +| large_array_fill_200 | bashkit | 1.589 | ±0.068 | - | ✓ | +| large_array_fill_200 | bash | 2.374 | ±0.141 | - | ✓ | +| large_nested_loops | bashkit | 1.554 | ±0.099 | - | ✓ | +| large_nested_loops | bash | 3.005 | ±0.273 | - | ✓ | +| large_fibonacci_12 | bashkit | 13.221 | ±2.147 | - | ✓ | +| large_fibonacci_12 | bash | 413.271 | ±35.841 | - | ✓ | +| large_function_calls_500 | bashkit | 5.399 | ±0.267 | - | ✓ | +| large_function_calls_500 | bash | 236.057 | ±66.219 | - | ✓ | +| large_multiline_script | bashkit | 0.418 | ±0.038 | - | ✓ | +| large_multiline_script | bash | 1.973 | ±0.104 | - | ✓ | +| large_pipeline_chain | bashkit | 0.812 | ±0.085 | - | ✓ | +| large_pipeline_chain | bash | 9.110 | ±3.689 | - | ✓ | +| large_assoc_array | bashkit | 0.050 | ±0.010 | - | ✓ | +| large_assoc_array | bash | 2.607 | ±1.320 | - | ✓ | + +### Pipes + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| pipe_simple | bashkit | 0.061 | ±0.012 | - | ✓ | +| pipe_simple | bash | 6.908 | ±3.922 | - | ✓ | +| pipe_multi | bashkit | 0.085 | ±0.010 | - | ✓ | +| pipe_multi | bash | 8.661 | ±4.051 | - | ✓ | +| pipe_command_subst | bashkit | 0.047 | ±0.008 | - | ✓ | +| pipe_command_subst | bash | 2.483 | ±0.834 | - | ✓ | +| pipe_heredoc | bashkit | 0.053 | ±0.003 | - | ✓ | +| pipe_heredoc | bash | 4.205 | ±1.713 | - | ✓ | +| pipe_herestring | bashkit | 0.057 | ±0.010 | - | ✓ | +| pipe_herestring | bash | 4.943 | ±3.246 | - | ✓ | +| pipe_discard | bashkit | 0.044 | ±0.003 | - | ✓ | +| pipe_discard | bash | 3.122 | ±1.840 | - | ✓ | + +### Startup + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| startup_empty | bashkit | 0.061 | ±0.010 | - | ✓ | +| startup_empty | bash | 1.596 | ±0.075 | - | ✓ | +| startup_true | bashkit | 0.045 | ±0.011 | - | ✓ | +| startup_true | bash | 2.113 | ±1.133 | - | ✓ | +| startup_echo | bashkit | 0.042 | ±0.011 | - | ✓ | +| startup_echo | bash | 1.710 | ±0.083 | - | ✓ | +| startup_exit | bashkit | 0.040 | ±0.009 | - | ✓ | +| startup_exit | bash | 2.391 | ±1.995 | - | ✓ | + +### Strings + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| str_concat | bashkit | 0.045 | ±0.008 | - | ✓ | +| str_concat | bash | 2.539 | ±1.517 | - | ✓ | +| str_printf | bashkit | 0.039 | ±0.003 | - | ✓ | +| str_printf | bash | 1.916 | ±0.686 | - | ✓ | +| str_printf_pad | bashkit | 0.038 | ±0.003 | - | ✓ | +| str_printf_pad | bash | 1.850 | ±0.212 | - | ✓ | +| str_echo_escape | bashkit | 0.041 | ±0.009 | - | ✓ | +| str_echo_escape | bash | 2.508 | ±2.125 | - | ✓ | +| str_prefix_strip | bashkit | 0.044 | ±0.010 | - | ✓ | +| str_prefix_strip | bash | 3.202 | ±2.560 | - | ✓ | +| str_suffix_strip | bashkit | 0.047 | ±0.017 | - | ✓ | +| str_suffix_strip | bash | 2.440 | ±2.183 | - | ✓ | +| str_uppercase | bashkit | 0.044 | ±0.009 | - | ✓ | +| str_uppercase | bash | 2.177 | ±0.599 | - | ✓ | +| str_lowercase | bashkit | 0.044 | ±0.009 | - | ✓ | +| str_lowercase | bash | 2.216 | ±1.217 | - | ✓ | + +### Subshell + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| subshell_simple | bashkit | 0.042 | ±0.008 | - | ✓ | +| subshell_simple | bash | 2.621 | ±1.287 | - | ✓ | +| subshell_isolation | bashkit | 0.048 | ±0.010 | - | ✓ | +| subshell_isolation | bash | 2.277 | ±0.073 | - | ✓ | +| subshell_nested | bashkit | 0.052 | ±0.002 | - | ✓ | +| subshell_nested | bash | 3.842 | ±0.150 | - | ✓ | +| subshell_pipeline | bashkit | 0.045 | ±0.010 | - | ✓ | +| subshell_pipeline | bash | 5.799 | ±1.668 | - | ✓ | +| subshell_capture_loop | bashkit | 0.090 | ±0.015 | - | ✓ | +| subshell_capture_loop | bash | 5.338 | ±2.355 | - | ✓ | +| subshell_process_subst | bashkit | 0.063 | ±0.010 | - | ✓ | +| subshell_process_subst | bash | 2.613 | ±0.192 | - | ✓ | + +### Tools + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| tool_grep_simple | bashkit | 0.048 | ±0.003 | - | ✓ | +| tool_grep_simple | bash | 5.994 | ±1.680 | - | ✓ | +| tool_grep_case | bashkit | 0.162 | ±0.010 | - | ✓ | +| tool_grep_case | bash | 4.428 | ±1.675 | - | ✓ | +| tool_grep_count | bashkit | 0.057 | ±0.027 | - | ✓ | +| tool_grep_count | bash | 5.227 | ±2.326 | - | ✓ | +| tool_grep_invert | bashkit | 0.054 | ±0.012 | - | ✓ | +| tool_grep_invert | bash | 5.682 | ±3.059 | - | ✓ | +| tool_grep_regex | bashkit | 0.075 | ±0.010 | - | ✓ | +| tool_grep_regex | bash | 3.745 | ±1.055 | - | ✓ | +| tool_sed_replace | bashkit | 0.132 | ±0.012 | - | ✓ | +| tool_sed_replace | bash | 4.576 | ±1.407 | - | ✓ | +| tool_sed_global | bashkit | 0.140 | ±0.032 | - | ✓ | +| tool_sed_global | bash | 4.540 | ±1.689 | - | ✓ | +| tool_sed_delete | bashkit | 0.063 | ±0.011 | - | ✓ | +| tool_sed_delete | bash | 5.494 | ±1.692 | - | ✓ | +| tool_sed_lines | bashkit | 0.043 | ±0.005 | - | ✓ | +| tool_sed_lines | bash | 4.791 | ±2.574 | - | ✓ | +| tool_sed_backrefs | bashkit | 0.257 | ±0.058 | - | ✓ | +| tool_sed_backrefs | bash | 4.889 | ±2.530 | - | ✓ | +| tool_awk_print | bashkit | 0.048 | ±0.011 | - | ✓ | +| tool_awk_print | bash | 5.480 | ±2.587 | - | ✓ | +| tool_awk_sum | bashkit | 0.053 | ±0.010 | - | ✓ | +| tool_awk_sum | bash | 5.143 | ±2.859 | - | ✓ | +| tool_awk_pattern | bashkit | 0.085 | ±0.018 | - | ✓ | +| tool_awk_pattern | bash | 5.325 | ±3.695 | - | ✓ | +| tool_awk_fieldsep | bashkit | 0.048 | ±0.005 | - | ✓ | +| tool_awk_fieldsep | bash | 3.331 | ±0.067 | - | ✓ | +| tool_awk_nf | bashkit | 0.046 | ±0.004 | - | ✓ | +| tool_awk_nf | bash | 3.964 | ±1.188 | - | ✓ | +| tool_awk_compute | bashkit | 0.045 | ±0.002 | - | ✓ | +| tool_awk_compute | bash | 3.603 | ±0.252 | - | ✓ | +| tool_jq_identity | bashkit | 0.656 | ±0.090 | - | ✓ | +| tool_jq_identity | bash | 5.965 | ±1.753 | - | ✓ | +| tool_jq_field | bashkit | 0.621 | ±0.030 | - | ✓ | +| tool_jq_field | bash | 5.078 | ±0.902 | - | ✓ | +| tool_jq_array | bashkit | 0.609 | ±0.026 | - | ✓ | +| tool_jq_array | bash | 7.987 | ±3.743 | - | ✓ | +| tool_jq_filter | bashkit | 0.633 | ±0.042 | - | ✓ | +| tool_jq_filter | bash | 5.453 | ±1.247 | - | ✓ | +| tool_jq_map | bashkit | 0.619 | ±0.028 | - | ✓ | +| tool_jq_map | bash | 5.674 | ±1.416 | - | ✓ | + +### Variables + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| var_assign_simple | bashkit | 0.047 | ±0.011 | - | ✓ | +| var_assign_simple | bash | 2.340 | ±1.237 | - | ✓ | +| var_assign_many | bashkit | 0.061 | ±0.008 | - | ✓ | +| var_assign_many | bash | 1.744 | ±0.066 | - | ✓ | +| var_default | bashkit | 0.041 | ±0.002 | - | ✓ | +| var_default | bash | 1.609 | ±0.062 | - | ✓ | +| var_length | bashkit | 0.046 | ±0.006 | - | ✓ | +| var_length | bash | 2.295 | ±1.446 | - | ✓ | +| var_substring | bashkit | 0.054 | ±0.018 | - | ✓ | +| var_substring | bash | 3.279 | ±1.877 | - | ✓ | +| var_replace | bashkit | 0.046 | ±0.008 | - | ✓ | +| var_replace | bash | 2.611 | ±1.442 | - | ✓ | +| var_nested | bashkit | 0.053 | ±0.024 | - | ✓ | +| var_nested | bash | 2.660 | ±1.950 | - | ✓ | +| var_export | bashkit | 0.050 | ±0.010 | - | ✓ | +| var_export | bash | 1.716 | ±0.124 | - | ✓ | + +## Runner Descriptions + +| Runner | Type | Description | +|--------|------|-------------| +| bashkit | in-process | Rust library call, no fork/exec | +| bashkit-cli | subprocess | bashkit binary, new process per run | +| bashkit-js | persistent child | Node.js + @everruns/bashkit, warm interpreter | +| bashkit-py | persistent child | Python + bashkit package, warm interpreter | +| bash | subprocess | /bin/bash, new process per run | +| gbash | subprocess | gbash binary (Go), new process per run | +| gbash-server | persistent child | gbash JSON-RPC server, warm interpreter | +| just-bash | subprocess | just-bash CLI, new process per run | +| just-bash-inproc | persistent child | Node.js + just-bash library, warm interpreter | + +## Assumptions & Notes + +- Times measured in nanoseconds, displayed in milliseconds +- Prewarm phase runs first few cases to warm up JIT/compilation +- Per-benchmark warmup iterations excluded from timing +- Output match compares against bash output when available +- Errors include execution failures and exit code mismatches +- In-process: interpreter runs inside the benchmark process +- Subprocess: new process spawned per benchmark run +- Persistent child: long-lived child process, amortizes startup cost + diff --git a/crates/bashkit-bench/results/criterion-hotpath-perf-linux-x86_64-1779744742.md b/crates/bashkit-bench/results/criterion-hotpath-perf-linux-x86_64-1779744742.md new file mode 100644 index 000000000..a085d67ae --- /dev/null +++ b/crates/bashkit-bench/results/criterion-hotpath-perf-linux-x86_64-1779744742.md @@ -0,0 +1,38 @@ +# Hot-path Performance: Before / After + +Criterion micro-benchmarks (`cargo bench --bench hotpath`). +Baseline saved BEFORE the perf changes, comparison shows AFTER. + +| Bench | Before (µs) | After (µs) | Change | +|---|---:|---:|---:| +| cmdsubst/subst_nested_3 | 131.64 | 52.05 | **-60.5%** | +| cmdsubst/subst_simple_100 | 794.74 | 571.86 | **-28.0%** | +| cmdsubst/subst_with_many_vars | 586.23 | 426.15 | **-27.3%** | +| cmdsubst/subst_with_vars_50 | 588.00 | 399.92 | **-32.0%** | +| functions/call_500 | 3293.78 | 2686.13 | **-18.4%** | +| functions/recursive_fib_10 | 3002.32 | 2570.39 | **-14.4%** | +| loops/for_list_100 | 462.18 | 258.23 | **-44.1%** | +| loops/for_range_1k_arith | 4560.79 | 3040.68 | **-33.3%** | +| loops/nested_for_50x50 | 9740.39 | 6362.39 | **-34.7%** | +| loops/while_inc_1k | 3062.82 | 2111.25 | **-31.1%** | +| param_exp/default_op_500 | 1338.87 | 616.85 | **-53.9%** | +| param_exp/substring_500 | 1384.09 | 768.55 | **-44.5%** | +| param_exp/uppercase_300 | 837.59 | 395.38 | **-52.8%** | +| pipelines/seq_grep_wc | 177.51 | 91.61 | **-48.4%** | +| pipelines/seq_sort_uniq | 183.09 | 116.04 | **-36.6%** | +| shopt/plain_1k_loop | 4706.60 | 2959.84 | **-37.1%** | +| shopt/strict_mode_1k_loop | 4589.75 | 2956.80 | **-35.6%** | +| startup/assign_echo | 108.57 | 38.76 | **-64.3%** | +| startup/echo_hi | 99.81 | 36.47 | **-63.5%** | +| startup/empty | 100.86 | 35.40 | **-64.9%** | +| variables/assign_200 | 532.08 | 307.56 | **-42.2%** | +| variables/local_in_function | 1522.69 | 1225.45 | **-19.5%** | +| variables/read_200 | 1312.51 | 974.66 | **-25.7%** | + +## Summary + +- 23 cases benchmarked +- median change: **-36.6%** +- mean change: **-39.7%** +- best: **-64.9%** (startup/empty) +- worst: **-14.4%** (functions/recursive_fib_10) diff --git a/crates/bashkit/Cargo.toml b/crates/bashkit/Cargo.toml index eaeb57846..9a276a60e 100644 --- a/crates/bashkit/Cargo.toml +++ b/crates/bashkit/Cargo.toml @@ -51,6 +51,11 @@ fail = { workspace = true, optional = true } # URL parsing url = "2" +# Compact flag bitsets used by the interpreter for variable attributes +# (readonly/integer/lower/upper) and shell options (set -e/-u/-x/-o pipefail). +# Stays the format!()-based `_READONLY_X`/`SHOPT_*` keys off the hot path. +bitflags = "2" + # JSON processing (jq) - optional, enabled with jq feature jaq-core = { workspace = true, optional = true } jaq-std = { workspace = true, optional = true } @@ -168,6 +173,10 @@ serial_test = { workspace = true } name = "parallel_execution" harness = false +[[bench]] +name = "hotpath" +harness = false + [[bench]] name = "sqlite" harness = false diff --git a/crates/bashkit/benches/hotpath.rs b/crates/bashkit/benches/hotpath.rs new file mode 100644 index 000000000..eb0089c0b --- /dev/null +++ b/crates/bashkit/benches/hotpath.rs @@ -0,0 +1,214 @@ +//! Hot-path micro-benchmarks for the interpreter. +//! +//! Targets the specific code paths flagged in the perf analysis: +//! - tight integer loops (set_variable + expand_variable) +//! - command substitution (subshell state snapshot cost) +//! - SHOPT flag checks (set -e, set -u, pipefail) +//! - parameter expansion / attribute lookups +//! - large pipelines +//! +//! Run with: `cargo bench --bench hotpath` +//! Save baseline: `cargo bench --bench hotpath -- --save-baseline before` +//! Compare: `cargo bench --bench hotpath -- --baseline before` + +use bashkit::Bash; +use criterion::{Criterion, criterion_group, criterion_main}; +use tokio::runtime::Runtime; + +fn run_script(rt: &Runtime, script: &str) { + rt.block_on(async { + let mut bash = Bash::new(); + let result = bash.exec(script).await.expect("exec failed"); + std::hint::black_box(result); + }); +} + +fn bench_loops(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let mut g = c.benchmark_group("loops"); + g.bench_function("for_range_1k_arith", |b| { + b.iter(|| { + run_script( + &rt, + "n=0; for ((i=0; i<1000; i++)); do n=$((n+i)); done; echo $n", + ) + }) + }); + g.bench_function("while_inc_1k", |b| { + b.iter(|| { + run_script( + &rt, + "i=0; while [[ $i -lt 1000 ]]; do i=$((i+1)); done; echo $i", + ) + }) + }); + g.bench_function("for_list_100", |b| { + b.iter(|| { + run_script( + &rt, + "s=0; for x in $(seq 1 100); do s=$((s+x)); done; echo $s", + ) + }) + }); + g.bench_function("nested_for_50x50", |b| { + b.iter(|| { + run_script( + &rt, + "n=0; for ((i=0;i<50;i++)); do for ((j=0;j<50;j++)); do n=$((n+1)); done; done; echo $n", + ) + }) + }); + g.finish(); +} + +fn bench_variables(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let mut g = c.benchmark_group("variables"); + g.bench_function("assign_200", |b| { + let mut script = String::new(); + for i in 0..200 { + script.push_str(&format!("v{}={}\n", i, i)); + } + script.push_str("echo done"); + b.iter(|| run_script(&rt, &script)); + }); + g.bench_function("read_200", |b| { + let mut script = String::from("a=1\nb=2\nc=3\n"); + for _ in 0..200 { + script.push_str("echo $a $b $c >/dev/null\n"); + } + b.iter(|| run_script(&rt, &script)); + }); + g.bench_function("local_in_function", |b| { + b.iter(|| { + run_script( + &rt, + "f() { local a=1; local b=2; local c=3; echo $((a+b+c)); }; \ + for i in $(seq 1 100); do f >/dev/null; done", + ) + }) + }); + g.finish(); +} + +fn bench_cmdsubst(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let mut g = c.benchmark_group("cmdsubst"); + g.bench_function("subst_simple_100", |b| { + let mut s = String::new(); + for _ in 0..100 { + s.push_str("x=$(echo hello)\n"); + } + b.iter(|| run_script(&rt, &s)); + }); + g.bench_function("subst_nested_3", |b| { + b.iter(|| run_script(&rt, "echo $(echo $(echo $(echo deep)))")) + }); + g.bench_function("subst_with_vars_50", |b| { + let mut s = String::from("a=1\nb=2\nc=3\n"); + for _ in 0..50 { + s.push_str("r=$(echo $a $b $c)\n"); + } + b.iter(|| run_script(&rt, &s)); + }); + g.bench_function("subst_with_many_vars", |b| { + let mut s = String::new(); + for i in 0..50 { + s.push_str(&format!("v{}={}\n", i, i)); + } + s.push_str("for i in $(seq 1 30); do r=$(echo done); done"); + b.iter(|| run_script(&rt, &s)); + }); + g.finish(); +} + +fn bench_shopt(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let mut g = c.benchmark_group("shopt"); + g.bench_function("strict_mode_1k_loop", |b| { + b.iter(|| { + run_script( + &rt, + "set -euo pipefail\nn=0; for ((i=0;i<1000;i++)); do n=$((n+i)); done; echo $n", + ) + }) + }); + g.bench_function("plain_1k_loop", |b| { + b.iter(|| { + run_script( + &rt, + "n=0; for ((i=0;i<1000;i++)); do n=$((n+i)); done; echo $n", + ) + }) + }); + g.finish(); +} + +fn bench_pipelines(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let mut g = c.benchmark_group("pipelines"); + g.bench_function("seq_grep_wc", |b| { + b.iter(|| run_script(&rt, "seq 1 500 | grep 5 | wc -l")) + }); + g.bench_function("seq_sort_uniq", |b| { + b.iter(|| run_script(&rt, "seq 1 200 | sort | uniq | wc -l")) + }); + g.finish(); +} + +fn bench_functions(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let mut g = c.benchmark_group("functions"); + g.bench_function("call_500", |b| { + let s = "f() { echo $1; }; for i in $(seq 1 500); do f $i >/dev/null; done"; + b.iter(|| run_script(&rt, s)); + }); + g.bench_function("recursive_fib_10", |b| { + let s = "fib() { if [[ $1 -le 1 ]]; then echo $1; else \ + echo $(($(fib $(($1-1))) + $(fib $(($1-2))))); fi; }; fib 10"; + b.iter(|| run_script(&rt, s)); + }); + g.finish(); +} + +fn bench_param_exp(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let mut g = c.benchmark_group("param_exp"); + g.bench_function("default_op_500", |b| { + let s = "x=hello\nfor i in $(seq 1 500); do y=${x:-default}; done; echo $y"; + b.iter(|| run_script(&rt, s)); + }); + g.bench_function("substring_500", |b| { + let s = "x=helloworld\nfor i in $(seq 1 500); do y=${x:2:5}; done; echo $y"; + b.iter(|| run_script(&rt, s)); + }); + g.bench_function("uppercase_300", |b| { + let s = "x=hello\nfor i in $(seq 1 300); do y=${x^^}; done; echo $y"; + b.iter(|| run_script(&rt, s)); + }); + g.finish(); +} + +fn bench_startup(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let mut g = c.benchmark_group("startup"); + g.bench_function("empty", |b| b.iter(|| run_script(&rt, "true"))); + g.bench_function("echo_hi", |b| b.iter(|| run_script(&rt, "echo hi"))); + g.bench_function("assign_echo", |b| { + b.iter(|| run_script(&rt, "x=42; echo $x")) + }); + g.finish(); +} + +criterion_group!( + benches, + bench_startup, + bench_loops, + bench_variables, + bench_cmdsubst, + bench_shopt, + bench_pipelines, + bench_functions, + bench_param_exp, +); +criterion_main!(benches); diff --git a/crates/bashkit/src/builtins/export.rs b/crates/bashkit/src/builtins/export.rs index 60d2e6dde..9ce0ea114 100644 --- a/crates/bashkit/src/builtins/export.rs +++ b/crates/bashkit/src/builtins/export.rs @@ -47,7 +47,12 @@ impl Builtin for Export { } // THREAT[TM-INJ-021]: Refuse to overwrite readonly variables and // surface the error so callers cannot mistake a silent skip for success. - if ctx.variables.contains_key(&format!("_READONLY_{}", name)) { + if ctx + .shell + .as_ref() + .map(|s| s.is_var_readonly(name)) + .unwrap_or(false) + { stderr.push_str(&format!("bash: export: {name}: readonly variable\n")); exit_code = 1; continue; diff --git a/crates/bashkit/src/builtins/vars.rs b/crates/bashkit/src/builtins/vars.rs index b07d40545..ac101b76e 100644 --- a/crates/bashkit/src/builtins/vars.rs +++ b/crates/bashkit/src/builtins/vars.rs @@ -14,7 +14,7 @@ pub struct Unset; #[async_trait] impl Builtin for Unset { // THREAT[TM-INJ-009]: Block unset of internal variables and readonly variables - async fn execute(&self, ctx: Context<'_>) -> Result { + async fn execute(&self, mut ctx: Context<'_>) -> Result { let mut stderr = String::new(); let mut exit_code = 0; for name in ctx.args { @@ -26,9 +26,14 @@ impl Builtin for Unset { exit_code = 1; continue; } - // Block unsetting readonly variables - let readonly_key = format!("_READONLY_{name}"); - if ctx.variables.contains_key(&readonly_key) { + // Block unsetting readonly variables — attribute lookup is now a + // single map probe instead of a format!("_READONLY_{}", ...). + let is_readonly = ctx + .shell + .as_ref() + .map(|s| s.is_var_readonly(name)) + .unwrap_or(false); + if is_readonly { stderr.push_str(&format!( "bash: unset: {name}: cannot unset: readonly variable\n" )); @@ -36,6 +41,11 @@ impl Builtin for Unset { continue; } ctx.variables.remove(name); + // Clear any non-readonly attributes / nameref binding for this name. + if let Some(shell) = ctx.shell.as_mut() { + shell.var_attrs.remove(name); + shell.namerefs.remove(name); + } // Note: env is immutable in our model - environment variables // are inherited and can't be unset by the shell } @@ -263,15 +273,19 @@ pub struct Readonly; #[async_trait] impl Builtin for Readonly { - async fn execute(&self, ctx: Context<'_>) -> Result { + async fn execute(&self, mut ctx: Context<'_>) -> Result { // Handle -p flag to print readonly variables if ctx.args.first().map(|s| s.as_str()) == Some("-p") { let mut output = String::new(); - for (name, _) in ctx.variables.iter() { - if let Some(var_name) = name.strip_prefix("_READONLY_") - && let Some(value) = ctx.variables.get(var_name) - { - output.push_str(&format!("declare -r {}=\"{}\"\n", var_name, value)); + // Readonly markers live in `shell.var_attrs` now, not in + // `variables` under the `_READONLY_X` prefix. + if let Some(shell) = ctx.shell.as_ref() { + let mut names: Vec<&str> = shell.readonly_names().collect(); + names.sort_unstable(); + for var_name in names { + if let Some(value) = ctx.variables.get(var_name) { + output.push_str(&format!("declare -r {}=\"{}\"\n", var_name, value)); + } } } return Ok(ExecResult::ok(output)); @@ -287,17 +301,18 @@ impl Builtin for Readonly { } // Set the variable ctx.variables.insert(name.to_string(), value.to_string()); - // Mark as readonly - ctx.variables - .insert(format!("_READONLY_{}", name), "1".to_string()); + if let Some(shell) = ctx.shell.as_mut() { + shell.mark_var_readonly(name); + } } else { // THREAT[TM-INJ-013]: Block internal variable prefix injection via readonly if is_internal_variable(arg) { continue; } // Just mark existing variable as readonly - ctx.variables - .insert(format!("_READONLY_{}", arg), "1".to_string()); + if let Some(shell) = ctx.shell.as_mut() { + shell.mark_var_readonly(arg); + } } } Ok(ExecResult::ok(String::new())) diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index b2ecb28fa..eba9f0b98 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -256,10 +256,17 @@ fn is_keyword(name: &str) -> bool { /// All fields are disjoint from `Context`'s mutable borrows (variables, cwd), /// enabling safe split borrowing in `dispatch_command`. pub(crate) struct ShellRef<'a> { - /// Direct mutable access to shell aliases. + /// Direct mutable access to shell aliases. Backed by `Arc::make_mut` of + /// the parent's Arc-wrapped aliases map. pub(crate) aliases: &'a mut HashMap, /// Direct mutable access to trap handlers. pub(crate) traps: &'a mut HashMap, + /// Variable attribute table (readonly/integer/lower/upper). Mutable so + /// `readonly`/`declare`/`unset` builtins can update attributes without + /// re-allocating `_READONLY_X`-style marker strings. + pub(crate) var_attrs: &'a mut HashMap, + /// Nameref bindings (`declare -n`). Mutable so `unset -n` can clear them. + pub(crate) namerefs: &'a mut HashMap, /// Registered builtin commands (read-only, accessed via `has_builtin`). pub(crate) builtins: &'a HashMap>, /// Defined shell functions (read-only, accessed via `has_function`). @@ -314,6 +321,33 @@ impl ShellRef<'_> { pub(crate) fn jobs(&self) -> &SharedJobTable { self.jobs } + + /// Check if a variable is marked readonly via the attribute table. + pub(crate) fn is_var_readonly(&self, name: &str) -> bool { + self.var_attrs + .get(name) + .copied() + .unwrap_or_default() + .contains(VarAttrs::READONLY) + } + + /// Mark a variable as readonly. The ShellRef already holds a `&mut HashMap` + /// borrowed via `Arc::make_mut` from the interpreter, so this touches the + /// HashMap directly with no extra refcount work. + pub(crate) fn mark_var_readonly(&mut self, name: &str) { + let entry = self.var_attrs.entry(name.to_string()).or_default(); + entry.insert(VarAttrs::READONLY); + } + + /// Iterator over names of variables currently marked readonly. Used by + /// `readonly -p` to render the marker list without scanning `variables` + /// for legacy `_READONLY_X` prefixes. + pub(crate) fn readonly_names(&self) -> impl Iterator { + self.var_attrs + .iter() + .filter(|(_, attrs)| attrs.contains(VarAttrs::READONLY)) + .map(|(name, _)| name.as_str()) + } } pub(crate) struct ExecutionExtensionsGuard { @@ -737,15 +771,112 @@ fn deserialize_function_from_source( deserialize_function_from_source_with_limits(name, source, 100, 100_000) } +// Important decision: variable attributes (readonly/integer/lower/upper) and +// namerefs are stored in dedicated maps rather than the `variables` HashMap with +// `_READONLY_X` / `_INTEGER_X` / `_LOWER_X` / `_UPPER_X` / `_NAMEREF_X` keys. +// The legacy format!()-based marker scheme allocated 4-5 Strings per assignment +// and per attribute read; the bitset/map approach removes those allocations +// from the hot path. `is_internal_variable` no longer needs to filter these +// prefixes because they never enter `variables` at runtime. +bitflags::bitflags! { + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] + pub(crate) struct VarAttrs: u8 { + const READONLY = 0b0000_0001; + const INTEGER = 0b0000_0010; + const LOWER = 0b0000_0100; + const UPPER = 0b0000_1000; + const EXPORT = 0b0001_0000; + } +} + +// Important decision: shell option flags (set -e, set -u, set -x, set -o +// pipefail, etc.) are cached in a bitfield in addition to the SHOPT_X entries +// in `variables`. Hot-path checks (errexit after every command, nounset on +// every $VAR, etc.) read the bitfield directly instead of doing a HashMap +// lookup + string compare. Writes go through `set_shopt_flag` which keeps +// the bitfield and the SHOPT_X variable in sync. +bitflags::bitflags! { + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] + pub(crate) struct BashFlags: u16 { + const ERREXIT = 0b0000_0000_0000_0001; // set -e / SHOPT_e + const XTRACE = 0b0000_0000_0000_0010; // set -x / SHOPT_x + const NOUNSET = 0b0000_0000_0000_0100; // set -u / SHOPT_u + const NOGLOB = 0b0000_0000_0000_1000; // set -f / SHOPT_f + const VERBOSE = 0b0000_0000_0001_0000; // set -v / SHOPT_v + const ALLEXPORT = 0b0000_0000_0010_0000; // set -a / SHOPT_a + const NOEXEC = 0b0000_0000_0100_0000; // set -n / SHOPT_n + const NOCLOBBER = 0b0000_0000_1000_0000; // set -C / SHOPT_C + const PIPEFAIL = 0b0000_0001_0000_0000; // set -o pipefail / SHOPT_pipefail + const EXPAND_ALIAS = 0b0000_0010_0000_0000; // shopt expand_aliases + } +} + +impl BashFlags { + /// Map a shell option variable name to its flag bit (None if unknown). + fn from_shopt_name(name: &str) -> Option { + match name { + "SHOPT_e" => Some(Self::ERREXIT), + "SHOPT_x" => Some(Self::XTRACE), + "SHOPT_u" => Some(Self::NOUNSET), + "SHOPT_f" => Some(Self::NOGLOB), + "SHOPT_v" => Some(Self::VERBOSE), + "SHOPT_a" => Some(Self::ALLEXPORT), + "SHOPT_n" => Some(Self::NOEXEC), + "SHOPT_C" => Some(Self::NOCLOBBER), + "SHOPT_pipefail" => Some(Self::PIPEFAIL), + "SHOPT_expand_aliases" => Some(Self::EXPAND_ALIAS), + _ => None, + } + } +} + +/// All interpreter state mutated inside a `$(...)` / arithmetic substitution +/// subshell. Captured before the substitution runs and restored after, so +/// mutations don't leak to the parent. The `Arc<...>` fields snapshot in O(1) +/// via a refcount bump; only mutations inside the subshell pay a clone +/// (`Arc::make_mut`). For substitutions that don't mutate state at all (the +/// common case — `$(echo $x)`, command queries) this saves an entire deep +/// HashMap clone per substitution. +struct SubshellSnapshot { + variables: Arc>, + arrays: Arc>>, + assoc_arrays: Arc>>, + functions: Arc>, + traps: Arc>, + aliases: Arc>, + var_attrs: Arc>, + namerefs: Arc>, + flags: BashFlags, + cwd: PathBuf, + memory_budget: crate::limits::MemoryBudget, + exec_fd_table: HashMap, +} + /// Interpreter state. pub struct Interpreter { fs: Arc, env: HashMap, - variables: HashMap, - /// Arrays - stored as name -> index -> value - arrays: HashMap>, - /// Associative arrays (declare -A) - stored as name -> key -> value - assoc_arrays: HashMap>, + // Important decision: the maps that get snapshotted by subshell ($(...)) + // boundaries are wrapped in `Arc` so the snapshot is an O(1) + // refcount bump instead of an O(n) HashMap clone. Mutations go through + // `vars_mut()` / `arrays_mut()` / etc. which call `Arc::make_mut`; when + // the refcount is 1 (no live snapshot) this is just a `&mut` borrow with + // zero clone cost. When the refcount is 2 (a subshell is active and + // mutates), it pays one clone — the same cost the eager-clone scheme + // paid unconditionally, but only when actually needed. + variables: Arc>, + /// Variable attribute flags (readonly/integer/lower/upper/export). Keyed by + /// the *resolved* variable name (post-nameref). Empty entry == no attrs. + var_attrs: Arc>, + /// Nameref bindings: name → target variable name. Used by `declare -n`. + namerefs: Arc>, + /// Cached shell option flags. Synchronized with `SHOPT_*` entries in + /// `variables` via `set_shopt_flag` / `set_shopt_value`. + flags: BashFlags, + /// Arrays - stored as name -> index -> value. Arc-wrapped for CoW subshell snapshots. + arrays: Arc>>, + /// Associative arrays (declare -A) - stored as name -> key -> value. Arc-wrapped for CoW subshell snapshots. + assoc_arrays: Arc>>, cwd: PathBuf, last_exit_code: i32, /// Built-in commands (default + custom). @@ -760,8 +891,8 @@ pub struct Interpreter { /// override baked-in commands. Survives `reset_transient_state` because /// it lives behind an `Arc` shared with the embedder. host_builtins: Option, - /// Defined functions - functions: HashMap, + /// Defined functions. Arc-wrapped for CoW subshell snapshots. + functions: Arc>, /// Call stack for local variable scoping call_stack: Vec, /// Source file stack for BASH_SOURCE array @@ -807,12 +938,12 @@ pub struct Interpreter { output_emit_count: u64, /// Pending nounset (set -u) error message, consumed by execute_command. nounset_error: Option, - /// Trap handlers: signal/event name -> command string - traps: HashMap, + /// Trap handlers: signal/event name -> command string. Arc-wrapped for CoW subshell snapshots. + traps: Arc>, /// PIPESTATUS: exit codes of the last pipeline's commands pipestatus: Vec, - /// Shell aliases: name -> expansion value - aliases: HashMap, + /// Shell aliases: name -> expansion value. Arc-wrapped for CoW subshell snapshots. + aliases: Arc>, /// Aliases currently being expanded (prevents infinite recursion). /// When alias `foo` expands to `foo bar`, the inner `foo` is not re-expanded. expanding_aliases: HashSet, @@ -1179,14 +1310,17 @@ impl Interpreter { Self { fs, env: HashMap::new(), - variables, - arrays, - assoc_arrays: HashMap::new(), + variables: Arc::new(variables), + var_attrs: Arc::new(HashMap::new()), + namerefs: Arc::new(HashMap::new()), + flags: BashFlags::empty(), + arrays: Arc::new(arrays), + assoc_arrays: Arc::new(HashMap::new()), cwd: PathBuf::from("/home/user"), last_exit_code: 0, builtins, host_builtins, - functions: HashMap::new(), + functions: Arc::new(HashMap::new()), call_stack: Vec::new(), bash_source_stack: Vec::new(), limits: ExecutionLimits::default(), @@ -1210,9 +1344,9 @@ impl Interpreter { ))), output_emit_count: 0, nounset_error: None, - traps: HashMap::new(), + traps: Arc::new(HashMap::new()), pipestatus: Vec::new(), - aliases: HashMap::new(), + aliases: Arc::new(HashMap::new()), expanding_aliases: HashSet::new(), history: Vec::new(), history_file: None, @@ -1274,6 +1408,58 @@ impl Interpreter { self.hooks = hooks; } + // === CoW accessors === + // `Arc::make_mut` returns a `&mut HashMap`, cloning the inner map only + // when the Arc has more than one strong reference (i.e. a live subshell + // snapshot). In the steady state (refcount==1) this is just a plain + // mutable borrow with zero clone cost. The compiler can usually inline + // these into the call site. + + #[inline] + fn vars_mut(&mut self) -> &mut HashMap { + Arc::make_mut(&mut self.variables) + } + + #[inline] + fn arrays_mut(&mut self) -> &mut HashMap> { + Arc::make_mut(&mut self.arrays) + } + + #[inline] + fn assoc_arrays_mut(&mut self) -> &mut HashMap> { + Arc::make_mut(&mut self.assoc_arrays) + } + + #[inline] + fn functions_mut(&mut self) -> &mut HashMap { + Arc::make_mut(&mut self.functions) + } + + #[inline] + fn traps_mut(&mut self) -> &mut HashMap { + Arc::make_mut(&mut self.traps) + } + + // Aliases are only mutated by builtins via ShellRef (which gets a + // `&mut HashMap` via Arc::make_mut inline at the construction site), so + // a parallel `aliases_mut` helper isn't called from the interpreter + // itself. Kept commented for symmetry — if a new interpreter call site + // ever needs &mut aliases, just uncomment. + // #[inline] + // fn aliases_mut(&mut self) -> &mut HashMap { + // Arc::make_mut(&mut self.aliases) + // } + + #[inline] + fn var_attrs_mut(&mut self) -> &mut HashMap { + Arc::make_mut(&mut self.var_attrs) + } + + #[inline] + fn namerefs_mut(&mut self) -> &mut HashMap { + Arc::make_mut(&mut self.namerefs) + } + /// Check if cancellation has been requested. fn check_cancelled(&self) -> Result<()> { if self.cancelled.load(Ordering::Relaxed) { @@ -1293,22 +1479,115 @@ impl Interpreter { .enumerate() .map(|(i, s)| (i, s.clone())) .collect(); - self.arrays.insert("BASH_SOURCE".to_string(), arr); + self.arrays_mut().insert("BASH_SOURCE".to_string(), arr); } fn is_errexit_enabled(&self) -> bool { - self.variables - .get("SHOPT_e") - .map(|v| v == "1") - .unwrap_or(false) + self.flags.contains(BashFlags::ERREXIT) } /// Check if xtrace (set -x) is enabled. fn is_xtrace_enabled(&self) -> bool { - self.variables - .get("SHOPT_x") - .map(|v| v == "1") - .unwrap_or(false) + self.flags.contains(BashFlags::XTRACE) + } + + /// Cache-aware setter for `SHOPT_X` entries in `variables`. Mirrors the + /// scalar string write into the `flags` bitfield so hot-path checks don't + /// need a HashMap lookup + string compare. Use this whenever code writes + /// `SHOPT_` or `SHOPT_expand_aliases`/etc.; plain `set -e`/`-u`/etc. + /// inputs flow through here from `execute_set_builtin` / `execute_shopt`. + #[allow(dead_code)] // exposed for builtin callers; unused in current tree + fn set_shopt_value(&mut self, name: &str, value: &str) { + if let Some(bit) = BashFlags::from_shopt_name(name) { + if value == "1" { + self.flags.insert(bit); + } else { + self.flags.remove(bit); + } + } + self.vars_mut().insert(name.to_string(), value.to_string()); + } + + /// Cache-aware remover for `SHOPT_X` entries in `variables`. + #[allow(dead_code)] // exposed for builtin callers; unused in current tree + fn remove_shopt_value(&mut self, name: &str) { + if let Some(bit) = BashFlags::from_shopt_name(name) { + self.flags.remove(bit); + } + self.vars_mut().remove(name); + } + + /// Rehydrate the SHOPT flag cache from any `SHOPT_*` entries currently in + /// `self.variables`. Call after bulk-restoring `variables` from a snapshot + /// or builder so the cache doesn't drift. + fn refresh_shopt_flags(&mut self) { + self.flags = BashFlags::empty(); + for (name, value) in self.variables.iter() { + if let Some(bit) = BashFlags::from_shopt_name(name) + && value == "1" + { + self.flags.insert(bit); + } + } + } + + // === Variable attribute helpers === + // Reading/writing readonly/integer/lower/upper attributes via the + // dedicated `var_attrs` HashMap. The old `_READONLY_X`/etc. format!() + // approach has been removed from the hot path; see VarAttrs above. + + fn var_attrs_get(&self, name: &str) -> VarAttrs { + self.var_attrs.get(name).copied().unwrap_or_default() + } + + fn is_var_readonly(&self, name: &str) -> bool { + self.var_attrs_get(name).contains(VarAttrs::READONLY) + } + + #[allow(dead_code)] // sibling helper; kept symmetric with READONLY/LOWER/UPPER + fn is_var_integer(&self, name: &str) -> bool { + self.var_attrs_get(name).contains(VarAttrs::INTEGER) + } + + #[allow(dead_code)] // sibling helper; kept symmetric with READONLY/INTEGER/UPPER + fn is_var_lower(&self, name: &str) -> bool { + self.var_attrs_get(name).contains(VarAttrs::LOWER) + } + + #[allow(dead_code)] // sibling helper; kept symmetric with READONLY/INTEGER/LOWER + fn is_var_upper(&self, name: &str) -> bool { + self.var_attrs_get(name).contains(VarAttrs::UPPER) + } + + fn add_var_attr(&mut self, name: &str, attr: VarAttrs) { + // entry-by-string-slice — only allocates when inserting new entry + match self.var_attrs_mut().get_mut(name) { + Some(existing) => existing.insert(attr), + None => { + self.var_attrs_mut().insert(name.to_string(), attr); + } + } + } + + fn remove_var_attr(&mut self, name: &str, attr: VarAttrs) { + if let Some(existing) = self.var_attrs_mut().get_mut(name) { + existing.remove(attr); + if existing.is_empty() { + self.var_attrs_mut().remove(name); + } + } + } + + fn clear_var_attrs(&mut self, name: &str) { + self.var_attrs_mut().remove(name); + } + + fn set_nameref(&mut self, name: &str, target: String) { + self.namerefs_mut().insert(name.to_string(), target); + } + + fn remove_nameref(&mut self, name: &str) { + self.namerefs_mut().remove(name); } /// Set execution limits. @@ -1358,10 +1637,13 @@ impl Interpreter { /// `shopt` options (expand_aliases, extglob, etc.) are intentionally /// preserved — they are persistent session configuration. pub fn reset_transient_state(&mut self) { - self.traps.clear(); + self.traps_mut().clear(); self.last_exit_code = 0; for var in Self::SET_OPTION_VARS { - self.variables.remove(*var); + self.vars_mut().remove(*var); + if let Some(bit) = BashFlags::from_shopt_name(var) { + self.flags.remove(bit); + } } } @@ -1372,7 +1654,14 @@ impl Interpreter { /// Set a shell variable (public API for builder). pub fn set_var(&mut self, key: &str, value: &str) { - self.variables.insert(key.to_string(), value.to_string()); + if let Some(bit) = BashFlags::from_shopt_name(key) { + if value == "1" { + self.flags.insert(bit); + } else { + self.flags.remove(bit); + } + } + self.vars_mut().insert(key.to_string(), value.to_string()); } /// Set the current working directory. @@ -1484,20 +1773,23 @@ impl Interpreter { } pub(crate) fn shell_state_with_options(&self, options: ShellStateOptions) -> ShellState { + // Deref through Arc and clone the inner HashMap for the public + // ShellState struct (which holds plain HashMaps so users can mutate + // it freely). ShellState { env: self.env.clone(), - variables: self.variables.clone(), - arrays: self.arrays.clone(), - assoc_arrays: self.assoc_arrays.clone(), + variables: (*self.variables).clone(), + arrays: (*self.arrays).clone(), + assoc_arrays: (*self.assoc_arrays).clone(), cwd: self.cwd.clone(), last_exit_code: self.last_exit_code, functions: if options.include_functions { - self.functions.clone() + (*self.functions).clone() } else { HashMap::new() }, - aliases: self.aliases.clone(), - traps: self.traps.clone(), + aliases: (*self.aliases).clone(), + traps: (*self.traps).clone(), } } @@ -1505,22 +1797,23 @@ impl Interpreter { pub fn shell_state_view(&self) -> ShellStateView { ShellStateView { env: self.env.clone(), - variables: self.variables.clone(), - arrays: self.arrays.clone(), - assoc_arrays: self.assoc_arrays.clone(), + variables: (*self.variables).clone(), + arrays: (*self.arrays).clone(), + assoc_arrays: (*self.assoc_arrays).clone(), cwd: self.cwd.clone(), last_exit_code: self.last_exit_code, - aliases: self.aliases.clone(), - traps: self.traps.clone(), + aliases: (*self.aliases).clone(), + traps: (*self.traps).clone(), } } /// Restore shell state from a snapshot. pub fn restore_shell_state(&mut self, state: &ShellState) { self.env = state.env.clone(); - self.variables = state.variables.clone(); - self.arrays = state.arrays.clone(); - self.assoc_arrays = state.assoc_arrays.clone(); + self.variables = Arc::new(state.variables.clone()); + self.refresh_shopt_flags(); + self.arrays = Arc::new(state.arrays.clone()); + self.assoc_arrays = Arc::new(state.assoc_arrays.clone()); self.cwd = state.cwd.clone(); self.last_exit_code = state.last_exit_code; // THREAT[TM-DOS-061]: Re-parse and budget-check restored functions so @@ -1558,9 +1851,9 @@ impl Interpreter { function_memory_budget.record_function_insert(body_bytes, true, 0); restored_functions.insert(name, parsed_func); } - self.functions = restored_functions; - self.aliases = state.aliases.clone(); - self.traps = state.traps.clone(); + self.functions = Arc::new(restored_functions); + self.aliases = Arc::new(state.aliases.clone()); + self.traps = Arc::new(state.traps.clone()); // Recompute memory budget from restored state to prevent desync let func_count = self.functions.len(); let func_bytes: usize = self @@ -2070,7 +2363,7 @@ impl Interpreter { is_new, old_body_bytes, ); - self.functions + self.functions_mut() .insert(func_def.name.clone(), func_def.clone()); } Ok(ExecResult::ok(String::new())) @@ -2093,25 +2386,19 @@ impl Interpreter { // Subshells run in fully isolated scope: variables, arrays, // functions, cwd, traps, positional params, and options are // all snapshot/restored so mutations don't leak to the parent. - let saved_vars = self.variables.clone(); - let saved_arrays = self.arrays.clone(); - let saved_assoc = self.assoc_arrays.clone(); - let saved_functions = self.functions.clone(); - let saved_cwd = self.cwd.clone(); - let saved_traps = self.traps.clone(); + // The Arc-wrapped maps make each snapshot an O(1) refcount + // bump; only mutations inside the subshell pay a clone. + let snap = self.snapshot_subshell_state(); let saved_call_stack = self.call_stack.clone(); let saved_exit = self.last_exit_code; - let saved_aliases = self.aliases.clone(); let saved_coproc = self.coproc_buffers.clone(); - let saved_memory_budget = self.memory_budget.clone(); - let saved_exec_fd_table = self.exec_fd_table.clone(); let mut result = self.execute_command_sequence(commands).await; // Fire EXIT trap set inside the subshell before restoring parent state if let Some(trap_cmd) = self.traps.get("EXIT").cloned() { // Only fire if the subshell set its own EXIT trap (different from parent) - let parent_had_same = saved_traps.get("EXIT") == Some(&trap_cmd); + let parent_had_same = snap.traps.get("EXIT") == Some(&trap_cmd); if !parent_had_same { // THREAT[TM-DOS-030]: Propagate interpreter parser limits if let Ok(trap_script) = Parser::with_limits( @@ -2138,18 +2425,10 @@ impl Interpreter { } } - self.variables = saved_vars; - self.arrays = saved_arrays; - self.assoc_arrays = saved_assoc; - self.functions = saved_functions; - self.cwd = saved_cwd; - self.traps = saved_traps; + self.restore_subshell_state(snap); self.call_stack = saved_call_stack; self.last_exit_code = saved_exit; - self.aliases = saved_aliases; self.coproc_buffers = saved_coproc; - self.memory_budget = saved_memory_budget; - self.exec_fd_table = saved_exec_fd_table; // Consume Exit and Return control flow at subshell boundary — // they only terminate the subshell, not the parent shell. @@ -2282,8 +2561,10 @@ impl Interpreter { // Check loop iteration limit self.counters.tick_loop(&self.limits)?; - // Set loop variable (respects nameref) - self.set_variable(for_cmd.variable.clone(), value.clone()); + // Set loop variable (respects nameref). `value` is moved + // straight into `set_variable` — previously we cloned it + // even though `values` already owned the String for us. + self.set_variable(for_cmd.variable.clone(), value); // Execute body let emit_before = self.output_emit_count; @@ -2795,15 +3076,16 @@ impl Interpreter { for (i, m) in captures.iter().enumerate() { rematch.insert(i, m.map(|m| m.as_str().to_string()).unwrap_or_default()); } - self.arrays.insert("BASH_REMATCH".to_string(), rematch); + self.arrays_mut() + .insert("BASH_REMATCH".to_string(), rematch); true } else { - self.arrays.remove("BASH_REMATCH"); + self.arrays_mut().remove("BASH_REMATCH"); false } } Err(_) => { - self.arrays.remove("BASH_REMATCH"); + self.arrays_mut().remove("BASH_REMATCH"); false } } @@ -3171,18 +3453,18 @@ impl Interpreter { let mut arr = HashMap::new(); arr.insert(0, read_fd.to_string()); arr.insert(1, write_fd.to_string()); - self.arrays.insert(name.clone(), arr); + self.arrays_mut().insert(name.clone(), arr); // Set NAME_PID to a virtual PID (use job table counter) let virtual_pid = { let table = self.jobs.lock().await; table.last_job_id().unwrap_or(0) + 1000 }; - self.variables + self.vars_mut() .insert(format!("{}_PID", name), virtual_pid.to_string()); // Also set $! (last background PID) - self.variables + self.vars_mut() .insert("_LAST_BG_PID".to_string(), virtual_pid.to_string()); // Coproc itself returns success with empty output (stdout was captured) @@ -3526,7 +3808,7 @@ impl Interpreter { let saved_optind = self.variables.get("OPTIND").cloned(); let saved_optchar = self.variables.get("_OPTCHAR_IDX").cloned(); self.insert_variable_checked("OPTIND".to_string(), "1".to_string()); - self.variables.remove("_OPTCHAR_IDX"); + self.vars_mut().remove("_OPTCHAR_IDX"); // Forward piped stdin to child when executing a script file or -c command let saved_stdin = self.pipeline_stdin.take(); @@ -3555,18 +3837,18 @@ impl Interpreter { if let Some(val) = saved_optind { self.insert_variable_checked("OPTIND".to_string(), val); } else { - self.variables.remove("OPTIND"); + self.vars_mut().remove("OPTIND"); } if let Some(val) = saved_optchar { self.insert_variable_checked("_OPTCHAR_IDX".to_string(), val); } else { - self.variables.remove("_OPTCHAR_IDX"); + self.vars_mut().remove("_OPTCHAR_IDX"); } for (var, prev) in saved_opts { if let Some(val) = prev { self.insert_variable_checked(var, val); } else { - self.variables.remove(&var); + self.vars_mut().remove(&var); } } self.call_stack.pop(); @@ -3795,7 +4077,7 @@ impl Interpreter { for (i, code) in pipe_statuses.iter().enumerate() { ps_arr.insert(i, code.to_string()); } - self.arrays.insert("PIPESTATUS".to_string(), ps_arr); + self.arrays_mut().insert("PIPESTATUS".to_string(), ps_arr); // pipefail: return rightmost non-zero exit code from pipeline if self.is_pipefail() @@ -3851,13 +4133,13 @@ impl Interpreter { let job_result = ExecResult::with_code(String::new(), exit_code); let handle = tokio::spawn(async move { job_result }); let job_id = self.jobs.lock().await.spawn(handle); - self.variables + self.vars_mut() .insert("_LAST_BG_PID".to_string(), job_id.to_string()); // Background commands always return exit code 0 to the parent self.last_exit_code = 0; // But store the real exit code for $? after wait - self.variables + self.vars_mut() .insert("_BG_EXIT_CODE".to_string(), exit_code.to_string()); Ok(()) } @@ -4054,7 +4336,7 @@ impl Interpreter { if is_new_entry { self.memory_budget.record_array_insert(1); } - let arr = self.assoc_arrays.entry(resolved_name).or_default(); + let arr = self.assoc_arrays_mut().entry(resolved_name).or_default(); if assignment.append { let existing = arr.get(&key).cloned().unwrap_or_default(); arr.insert(key, existing + &value); @@ -4089,7 +4371,7 @@ impl Interpreter { if is_new_entry { self.memory_budget.record_array_insert(1); } - let arr = self.arrays.entry(resolved_name).or_default(); + let arr = self.arrays_mut().entry(resolved_name).or_default(); if assignment.append { let existing = arr.get(&index).cloned().unwrap_or_default(); arr.insert(index, existing + &value); @@ -4199,7 +4481,7 @@ impl Interpreter { self.insert_variable_checked(vname, v); } None => { - self.variables.remove(&vname); + self.vars_mut().remove(&vname); } } } @@ -4298,7 +4580,7 @@ impl Interpreter { self.insert_variable_checked(name, v); } None => { - self.variables.remove(&name); + self.vars_mut().remove(&name); } } } @@ -4798,8 +5080,10 @@ impl Interpreter { let shell_ref = ShellRef { builtins: &self.builtins, functions: &self.functions, - aliases: &mut self.aliases, - traps: &mut self.traps, + aliases: Arc::make_mut(&mut self.aliases), + traps: Arc::make_mut(&mut self.traps), + var_attrs: Arc::make_mut(&mut self.var_attrs), + namerefs: Arc::make_mut(&mut self.namerefs), call_stack: &self.call_stack, history: &self.history, jobs: &self.jobs, @@ -4808,7 +5092,7 @@ impl Interpreter { let plan_ctx = builtins::Context { args, env: &self.env, - variables: &mut self.variables, + variables: Arc::make_mut(&mut self.variables), cwd: &mut self.cwd, fs: Arc::clone(&self.fs), stdin, @@ -4849,8 +5133,10 @@ impl Interpreter { let shell_ref = ShellRef { builtins: &self.builtins, functions: &self.functions, - aliases: &mut self.aliases, - traps: &mut self.traps, + aliases: Arc::make_mut(&mut self.aliases), + traps: Arc::make_mut(&mut self.traps), + var_attrs: Arc::make_mut(&mut self.var_attrs), + namerefs: Arc::make_mut(&mut self.namerefs), call_stack: &self.call_stack, history: &self.history, jobs: &self.jobs, @@ -4859,7 +5145,7 @@ impl Interpreter { let ctx = builtins::Context { args, env: &self.env, - variables: &mut self.variables, + variables: Arc::make_mut(&mut self.variables), cwd: &mut self.cwd, fs: Arc::clone(&self.fs), stdin, @@ -4891,10 +5177,7 @@ impl Interpreter { for arg in args { if let Some(eq_pos) = arg.find('=') { let var_name = &arg[..eq_pos]; - if self - .variables - .contains_key(&format!("_READONLY_{}", var_name)) - { + if self.is_var_readonly(var_name) { continue; } if let Some(value) = self.variables.get(var_name) { @@ -5223,14 +5506,20 @@ impl Interpreter { // Subprocess isolation: path-based script execution only inherits // exported variables (env), not the full parent shell state. // This matches real bash behavior where ./script.sh spawns a subprocess. - let saved_vars = self.variables.clone(); - let saved_arrays = self.arrays.clone(); - let saved_assoc = self.assoc_arrays.clone(); - let saved_functions = self.functions.clone(); - let saved_traps = self.traps.clone(); + // `bash -c '...'` subprocess: save then reset. Each Arc clone is an + // O(1) refcount bump now; the child resets its own state and the + // parent restores by dropping the child's Arcs and putting these back. + let saved_vars = Arc::clone(&self.variables); + let saved_arrays = Arc::clone(&self.arrays); + let saved_assoc = Arc::clone(&self.assoc_arrays); + let saved_functions = Arc::clone(&self.functions); + let saved_traps = Arc::clone(&self.traps); + let saved_aliases = Arc::clone(&self.aliases); + let saved_var_attrs = Arc::clone(&self.var_attrs); + let saved_namerefs = Arc::clone(&self.namerefs); + let saved_flags = self.flags; let saved_call_stack = self.call_stack.clone(); let saved_exit = self.last_exit_code; - let saved_aliases = self.aliases.clone(); let saved_coproc = self.coproc_buffers.clone(); let saved_env = self.env.clone(); let saved_memory_budget = self.memory_budget.clone(); @@ -5239,14 +5528,19 @@ impl Interpreter { // Child only sees exported variables (env), not all shell variables. // Reset last_exit_code so $? starts at 0 (matches real bash subprocess). // Clear nounset_error to prevent parent expansion errors from leaking. - self.variables = self.env.clone(); - self.arrays.clear(); - self.arrays + // Reset attributes/namerefs/flags too — the child gets a fresh option + // surface like real bash. + self.variables = Arc::new(self.env.clone()); + self.arrays = Arc::new(HashMap::new()); + self.arrays_mut() .insert("BASH_VERSINFO".to_string(), compat_bash_versinfo_array()); - self.assoc_arrays.clear(); - self.functions.clear(); - self.traps.clear(); - self.aliases.clear(); + self.assoc_arrays = Arc::new(HashMap::new()); + self.functions = Arc::new(HashMap::new()); + self.traps = Arc::new(HashMap::new()); + self.aliases = Arc::new(HashMap::new()); + self.var_attrs = Arc::new(HashMap::new()); + self.namerefs = Arc::new(HashMap::new()); + self.flags = BashFlags::empty(); self.coproc_buffers.clear(); self.last_exit_code = 0; self.nounset_error = None; @@ -5275,9 +5569,12 @@ impl Interpreter { self.assoc_arrays = saved_assoc; self.functions = saved_functions; self.traps = saved_traps; + self.aliases = saved_aliases; + self.var_attrs = saved_var_attrs; + self.namerefs = saved_namerefs; + self.flags = saved_flags; self.call_stack = saved_call_stack; self.last_exit_code = saved_exit; - self.aliases = saved_aliases; self.coproc_buffers = saved_coproc; self.env = saved_env; self.memory_budget = saved_memory_budget; @@ -5715,7 +6012,9 @@ impl Interpreter { .enumerate() .map(|(i, f)| (i, f.name.clone())) .collect(); - let prev_funcname = self.arrays.insert("FUNCNAME".to_string(), funcname_arr); + let prev_funcname = self + .arrays_mut() + .insert("FUNCNAME".to_string(), funcname_arr); // BASH_SOURCE: duplicate current top entry for function calls let current_source = self.bash_source_stack.last().cloned().unwrap_or_default(); @@ -5740,9 +6039,9 @@ impl Interpreter { // Restore previous FUNCNAME (or set from remaining stack) if self.call_stack.is_empty() { - self.arrays.remove("FUNCNAME"); + self.arrays_mut().remove("FUNCNAME"); } else if let Some(prev) = prev_funcname { - self.arrays.insert("FUNCNAME".to_string(), prev); + self.arrays_mut().insert("FUNCNAME".to_string(), prev); } let mut result = result?; @@ -5796,7 +6095,10 @@ impl Interpreter { if is_compound { let inner = &value[1..value.len() - 1]; if flags.assoc { - let arr = self.assoc_arrays.entry(var_name.to_string()).or_default(); + let arr = self + .assoc_arrays_mut() + .entry(var_name.to_string()) + .or_default(); arr.clear(); let mut rest = inner.trim(); while let Some(bracket_start) = rest.find('[') { @@ -5833,7 +6135,7 @@ impl Interpreter { } } } else { - let arr = self.arrays.entry(var_name.to_string()).or_default(); + let arr = self.arrays_mut().entry(var_name.to_string()).or_default(); arr.clear(); for (idx, val) in inner.split_whitespace().enumerate() { arr.insert(idx, val.trim_matches('"').to_string()); @@ -5846,21 +6148,19 @@ impl Interpreter { } else if flags.integer { let int_val = self.evaluate_arithmetic_with_assign(value); self.insert_local_checked(var_name.to_string(), int_val.to_string()); - self.variables - .insert(format!("_INTEGER_{}", var_name), "1".to_string()); + self.add_var_attr(var_name, VarAttrs::INTEGER); } else { self.insert_local_checked(var_name.to_string(), value.to_string()); } } else if !is_internal_variable(arg) { self.insert_local_checked(arg.to_string(), String::new()); if flags.assoc { - self.assoc_arrays.entry(arg.to_string()).or_default(); + self.assoc_arrays_mut().entry(arg.to_string()).or_default(); } else if flags.array { - self.arrays.entry(arg.to_string()).or_default(); + self.arrays_mut().entry(arg.to_string()).or_default(); } if flags.integer { - self.variables - .insert(format!("_INTEGER_{}", arg), "1".to_string()); + self.add_var_attr(arg, VarAttrs::INTEGER); } } } @@ -5871,8 +6171,7 @@ impl Interpreter { let var_name = &arg[..eq_pos]; let value = &arg[eq_pos + 1..]; if !is_internal_variable(var_name) { - self.variables - .insert(format!("_NAMEREF_{}", var_name), value.to_string()); + self.set_nameref(var_name, value.to_string()); } } } @@ -5891,7 +6190,10 @@ impl Interpreter { if is_compound { let inner = &value[1..value.len() - 1]; if flags.assoc { - let arr = self.assoc_arrays.entry(var_name.to_string()).or_default(); + let arr = self + .assoc_arrays_mut() + .entry(var_name.to_string()) + .or_default(); arr.clear(); let mut rest = inner.trim(); while let Some(bracket_start) = rest.find('[') { @@ -5928,23 +6230,22 @@ impl Interpreter { } } } else { - let arr = self.arrays.entry(var_name.to_string()).or_default(); + let arr = self.arrays_mut().entry(var_name.to_string()).or_default(); arr.clear(); for (idx, val) in inner.split_whitespace().enumerate() { arr.insert(idx, val.trim_matches('"').to_string()); } } } else if flags.nameref { - self.variables - .insert(format!("_NAMEREF_{}", var_name), value.to_string()); + self.set_nameref(var_name, value.to_string()); } else { self.insert_variable_checked(var_name.to_string(), value.to_string()); } } else if !is_internal_variable(arg) { if flags.assoc { - self.assoc_arrays.entry(arg.to_string()).or_default(); + self.assoc_arrays_mut().entry(arg.to_string()).or_default(); } else if flags.array { - self.arrays.entry(arg.to_string()).or_default(); + self.arrays_mut().entry(arg.to_string()).or_default(); } else { self.insert_variable_checked(arg.to_string(), String::new()); } @@ -6001,7 +6302,7 @@ impl Interpreter { for arg in &var_args { if unset_function { - self.functions.remove(arg.as_str()); + self.functions_mut().remove(arg.as_str()); continue; } if let Some(bracket) = arg.find('[') @@ -6011,9 +6312,9 @@ impl Interpreter { let key = &arg[bracket + 1..arg.len() - 1]; let expanded_key = self.expand_variable_or_literal(key); let resolved_name = self.resolve_nameref(arr_name).to_string(); - if let Some(arr) = self.assoc_arrays.get_mut(&resolved_name) { + if let Some(arr) = self.assoc_arrays_mut().get_mut(&resolved_name) { arr.remove(&expanded_key); - } else if let Some(arr) = self.arrays.get_mut(&resolved_name) + } else if let Some(arr) = self.arrays_mut().get_mut(&resolved_name) && let Ok(idx) = key.parse::() { arr.remove(&idx); @@ -6021,7 +6322,7 @@ impl Interpreter { continue; } if unset_nameref { - self.variables.remove(&format!("_NAMEREF_{}", arg)); + self.remove_nameref(arg); } else { let resolved = self.resolve_nameref(arg).to_string(); // THREAT[TM-INJ-009]: Block unset of internal marker variables @@ -6034,20 +6335,19 @@ impl Interpreter { } // THREAT[TM-INJ-019]: Refuse to unset readonly variables and surface // the error so callers cannot mistake a silent skip for success. - if self - .variables - .contains_key(&format!("_READONLY_{}", resolved)) - { + if self.is_var_readonly(&resolved) { stderr.push_str(&format!( "bash: unset: {resolved}: cannot unset: readonly variable\n" )); exit_code = 1; continue; } - self.variables.remove(&resolved); + self.vars_mut().remove(&resolved); self.env.remove(&resolved); - self.arrays.remove(&resolved); - self.assoc_arrays.remove(&resolved); + self.arrays_mut().remove(&resolved); + self.assoc_arrays_mut().remove(&resolved); + self.clear_var_attrs(&resolved); + self.remove_nameref(&resolved); for frame in self.call_stack.iter_mut().rev() { frame.locals.remove(&resolved); } @@ -6117,7 +6417,7 @@ impl Interpreter { if !current_arg.starts_with('-') || current_arg == "-" || current_arg == "--" { self.insert_variable_checked(varname.clone(), "?".to_string()); if current_arg == "--" { - self.variables + self.vars_mut() .insert("OPTIND".to_string(), (optind + 1).to_string()); } return Ok(ExecResult { @@ -6142,9 +6442,9 @@ impl Interpreter { if char_idx >= opt_chars.len() { // Should not happen, but advance - self.variables + self.vars_mut() .insert("OPTIND".to_string(), (optind + 1).to_string()); - self.variables.remove("_OPTCHAR_IDX"); + self.vars_mut().remove("_OPTCHAR_IDX"); self.insert_variable_checked(varname.clone(), "?".to_string()); return Ok(ExecResult { stdout: String::new(), @@ -6170,25 +6470,25 @@ impl Interpreter { // Rest of current arg is the argument let arg_val: String = opt_chars[char_idx + 1..].iter().collect(); self.insert_variable_checked("OPTARG".to_string(), arg_val); - self.variables + self.vars_mut() .insert("OPTIND".to_string(), (optind + 1).to_string()); - self.variables.remove("_OPTCHAR_IDX"); + self.vars_mut().remove("_OPTCHAR_IDX"); } else if optind < parse_args.len() { // Next arg is the argument - self.variables + self.vars_mut() .insert("OPTARG".to_string(), parse_args[optind].clone()); - self.variables + self.vars_mut() .insert("OPTIND".to_string(), (optind + 2).to_string()); - self.variables.remove("_OPTCHAR_IDX"); + self.vars_mut().remove("_OPTCHAR_IDX"); } else { // Missing argument - self.variables.remove("OPTARG"); - self.variables + self.vars_mut().remove("OPTARG"); + self.vars_mut() .insert("OPTIND".to_string(), (optind + 1).to_string()); - self.variables.remove("_OPTCHAR_IDX"); + self.vars_mut().remove("_OPTCHAR_IDX"); if silent { self.insert_variable_checked(varname.clone(), ":".to_string()); - self.variables + self.vars_mut() .insert("OPTARG".to_string(), opt_char.to_string()); } else { self.insert_variable_checked(varname.clone(), "?".to_string()); @@ -6203,33 +6503,33 @@ impl Interpreter { } } else { // No argument needed - self.variables.remove("OPTARG"); + self.vars_mut().remove("OPTARG"); if char_idx + 1 < opt_chars.len() { // More chars in this arg - self.variables + self.vars_mut() .insert("_OPTCHAR_IDX".to_string(), (char_idx + 1).to_string()); } else { // Move to next arg - self.variables + self.vars_mut() .insert("OPTIND".to_string(), (optind + 1).to_string()); - self.variables.remove("_OPTCHAR_IDX"); + self.vars_mut().remove("_OPTCHAR_IDX"); } } } else { // Unknown option - self.variables.remove("OPTARG"); + self.vars_mut().remove("OPTARG"); if char_idx + 1 < opt_chars.len() { - self.variables + self.vars_mut() .insert("_OPTCHAR_IDX".to_string(), (char_idx + 1).to_string()); } else { - self.variables + self.vars_mut() .insert("OPTIND".to_string(), (optind + 1).to_string()); - self.variables.remove("_OPTCHAR_IDX"); + self.vars_mut().remove("_OPTCHAR_IDX"); } if silent { self.insert_variable_checked(varname.clone(), "?".to_string()); - self.variables + self.vars_mut() .insert("OPTARG".to_string(), opt_char.to_string()); } else { self.insert_variable_checked(varname.clone(), "?".to_string()); @@ -6353,8 +6653,10 @@ impl Interpreter { let shell_ref = ShellRef { builtins: &self.builtins, functions: &self.functions, - aliases: &mut self.aliases, - traps: &mut self.traps, + aliases: Arc::make_mut(&mut self.aliases), + traps: Arc::make_mut(&mut self.traps), + var_attrs: Arc::make_mut(&mut self.var_attrs), + namerefs: Arc::make_mut(&mut self.namerefs), call_stack: &self.call_stack, history: &self.history, jobs: &self.jobs, @@ -6363,7 +6665,7 @@ impl Interpreter { let ctx = builtins::Context { args: builtin_args, env: &self.env, - variables: &mut self.variables, + variables: Arc::make_mut(&mut self.variables), cwd: &mut self.cwd, fs: Arc::clone(&self.fs), stdin: _stdin.as_deref(), @@ -6499,10 +6801,7 @@ impl Interpreter { let var_name = name.split('=').next().unwrap_or(name); if let Some(value) = self.variables.get(var_name) { let mut attrs = String::from("--"); - if self - .variables - .contains_key(&format!("_READONLY_{}", var_name)) - { + if self.is_var_readonly(var_name) { attrs = String::from("-r"); } output.push_str(&format!("declare {} {}=\"{}\"\n", attrs, var_name, value)); @@ -6556,10 +6855,7 @@ impl Interpreter { // THREAT[TM-INJ-020]: Refuse to overwrite readonly variables and // surface the error so callers cannot mistake a silent skip for success. - if self - .variables - .contains_key(&format!("_READONLY_{}", var_name)) - { + if self.is_var_readonly(var_name) { declare_stderr .push_str(&format!("bash: declare: {var_name}: readonly variable\n")); declare_exit_code = 1; @@ -6570,7 +6866,10 @@ impl Interpreter { if (flags.assoc || flags.array) && value.starts_with('(') && value.ends_with(')') { let inner = &value[1..value.len() - 1]; if flags.assoc { - let arr = self.assoc_arrays.entry(var_name.to_string()).or_default(); + let arr = self + .assoc_arrays_mut() + .entry(var_name.to_string()) + .or_default(); arr.clear(); // Parse [key]="value" pairs let mut rest = inner.trim(); @@ -6609,7 +6908,7 @@ impl Interpreter { } } else { // Indexed array: declare -a arr=(a b c) - let arr = self.arrays.entry(var_name.to_string()).or_default(); + let arr = self.arrays_mut().entry(var_name.to_string()).or_default(); arr.clear(); for (idx, val) in inner.split_whitespace().enumerate() { arr.insert(idx, val.trim_matches('"').to_string()); @@ -6617,15 +6916,13 @@ impl Interpreter { } } else if flags.nameref { // declare -n ref=target: create nameref - self.variables - .insert(format!("_NAMEREF_{}", var_name), value.to_string()); + self.set_nameref(var_name, value.to_string()); } else if flags.integer { // Evaluate as arithmetic expression let int_val = self.evaluate_arithmetic_with_assign(value); self.insert_variable_checked(var_name.to_string(), int_val.to_string()); // Set persistent integer attribute marker - self.variables - .insert(format!("_INTEGER_{}", var_name), "1".to_string()); + self.add_var_attr(var_name, VarAttrs::INTEGER); } else { // Apply case conversion attributes let final_value = if is_lowercase { @@ -6640,18 +6937,15 @@ impl Interpreter { // Set case conversion attribute markers if is_lowercase { - self.variables - .insert(format!("_LOWER_{}", var_name), "1".to_string()); - self.variables.remove(&format!("_UPPER_{}", var_name)); + self.add_var_attr(var_name, VarAttrs::LOWER); + self.remove_var_attr(var_name, VarAttrs::UPPER); } if is_uppercase { - self.variables - .insert(format!("_UPPER_{}", var_name), "1".to_string()); - self.variables.remove(&format!("_LOWER_{}", var_name)); + self.add_var_attr(var_name, VarAttrs::UPPER); + self.remove_var_attr(var_name, VarAttrs::LOWER); } if is_readonly { - self.variables - .insert(format!("_READONLY_{}", var_name), "1".to_string()); + self.add_var_attr(var_name, VarAttrs::READONLY); } if is_export { self.env.insert( @@ -6663,42 +6957,37 @@ impl Interpreter { // Declare without value if remove_nameref { // typeset +n ref: remove nameref attribute - self.variables.remove(&format!("_NAMEREF_{}", name)); + self.remove_nameref(name); } else if flags.nameref { // typeset -n ref (without =value): use existing variable value as target if let Some(existing) = self.variables.get(name.as_str()).cloned() && !existing.is_empty() { - self.variables - .insert(format!("_NAMEREF_{}", name), existing); + self.set_nameref(name, existing); } } else if flags.assoc { // Initialize empty associative array - self.assoc_arrays.entry(name.to_string()).or_default(); + self.assoc_arrays_mut().entry(name.to_string()).or_default(); } else if flags.array { // Initialize empty indexed array - self.arrays.entry(name.to_string()).or_default(); + self.arrays_mut().entry(name.to_string()).or_default(); } else if !self.variables.contains_key(name.as_str()) { self.insert_variable_checked(name.to_string(), String::new()); } // Set case conversion attribute markers if is_lowercase { - self.variables - .insert(format!("_LOWER_{}", name), "1".to_string()); - self.variables.remove(&format!("_UPPER_{}", name)); + self.add_var_attr(name, VarAttrs::LOWER); + self.remove_var_attr(name, VarAttrs::UPPER); } if is_uppercase { - self.variables - .insert(format!("_UPPER_{}", name), "1".to_string()); - self.variables.remove(&format!("_LOWER_{}", name)); + self.add_var_attr(name, VarAttrs::UPPER); + self.remove_var_attr(name, VarAttrs::LOWER); } if is_readonly { - self.variables - .insert(format!("_READONLY_{}", name), "1".to_string()); + self.add_var_attr(name, VarAttrs::READONLY); } if flags.integer { - self.variables - .insert(format!("_INTEGER_{}", name), "1".to_string()); + self.add_var_attr(name, VarAttrs::INTEGER); } if is_export { self.env.insert( @@ -6978,7 +7267,7 @@ impl Interpreter { } if self.call_stack.is_empty() { - self.arrays.remove("FUNCNAME"); + self.arrays_mut().remove("FUNCNAME"); } else { let funcname_arr: HashMap = self .call_stack @@ -6987,12 +7276,20 @@ impl Interpreter { .enumerate() .map(|(i, f)| (i, f.name.clone())) .collect(); - self.arrays.insert("FUNCNAME".to_string(), funcname_arr); + self.arrays_mut() + .insert("FUNCNAME".to_string(), funcname_arr); } } /// Process structured side effects from builtin execution. fn apply_builtin_side_effects(&mut self, result: &ExecResult) { + // Builtins that mutate SHOPT_* directly via `ctx.variables` (e.g. the + // `set -e` / `set +u` paths in the `set` builtin) don't update the + // cached `flags` bitfield. Resync once after every builtin so the + // bit cache can stay authoritative on the hot path. The scan covers + // ~10 SHOPT_* entries — cheaper than threading a structured "shopt + // changed" channel through every builtin. + self.refresh_shopt_flags(); for effect in &result.side_effects { match effect { builtins::BuiltinSideEffect::SetArray { name, elements } => { @@ -7007,13 +7304,13 @@ impl Interpreter { builtins::BuiltinSideEffect::SetIndexedArray { name, entries } => { let arr: HashMap = entries.iter().cloned().collect(); // Remove existing array first (mirrors mapfile behavior) - self.arrays.remove(name); + self.arrays_mut().remove(name); if !arr.is_empty() { self.insert_array_checked(name.clone(), arr); } } builtins::BuiltinSideEffect::RemoveArray(name) => { - self.arrays.remove(name); + self.arrays_mut().remove(name); } builtins::BuiltinSideEffect::ShiftPositional(n) => { if let Some(frame) = self.call_stack.last_mut() { @@ -7497,7 +7794,7 @@ impl Interpreter { 'K' => value.clone(), 'a' => { let mut attrs = String::new(); - if self.variables.contains_key(&format!("_READONLY_{}", name)) { + if self.is_var_readonly(name) { attrs.push('r'); } if self.env.contains_key(name) { @@ -7564,6 +7861,43 @@ impl Interpreter { // helper to cap per-level stack usage. Without this, each $(...) nesting level // adds the full expand_word state machine to the call stack, causing overflow // at moderate depths despite the logical depth limit. + /// Snapshot the subshell-isolated portion of interpreter state. + /// Used by `$(...)` and arithmetic substitution to undo any mutations the + /// substituted command performed. Each `Arc` clones in O(1) + /// (refcount bump); only a substitution that actually mutates state pays + /// for a real HashMap clone, and only the maps it actually touched. + fn snapshot_subshell_state(&self) -> SubshellSnapshot { + SubshellSnapshot { + variables: Arc::clone(&self.variables), + arrays: Arc::clone(&self.arrays), + assoc_arrays: Arc::clone(&self.assoc_arrays), + functions: Arc::clone(&self.functions), + traps: Arc::clone(&self.traps), + aliases: Arc::clone(&self.aliases), + var_attrs: Arc::clone(&self.var_attrs), + namerefs: Arc::clone(&self.namerefs), + flags: self.flags, + cwd: self.cwd.clone(), + memory_budget: self.memory_budget.clone(), + exec_fd_table: self.exec_fd_table.clone(), + } + } + + fn restore_subshell_state(&mut self, snap: SubshellSnapshot) { + self.variables = snap.variables; + self.arrays = snap.arrays; + self.assoc_arrays = snap.assoc_arrays; + self.functions = snap.functions; + self.traps = snap.traps; + self.aliases = snap.aliases; + self.var_attrs = snap.var_attrs; + self.namerefs = snap.namerefs; + self.flags = snap.flags; + self.cwd = snap.cwd; + self.memory_budget = snap.memory_budget; + self.exec_fd_table = snap.exec_fd_table; + } + fn execute_cmd_subst<'a>( &'a mut self, commands: &'a [Command], @@ -7571,15 +7905,7 @@ impl Interpreter { Box::pin(async move { // Command substitution runs in a subshell: snapshot all // mutable state so mutations don't leak to the parent. - let saved_traps = self.traps.clone(); - let saved_functions = self.functions.clone(); - let saved_vars = self.variables.clone(); - let saved_arrays = self.arrays.clone(); - let saved_assoc = self.assoc_arrays.clone(); - let saved_aliases = self.aliases.clone(); - let saved_cwd = self.cwd.clone(); - let saved_memory_budget = self.memory_budget.clone(); - let saved_exec_fd_table = self.exec_fd_table.clone(); + let snapshot = self.snapshot_subshell_state(); let mut stdout = String::new(); for cmd in commands { let cmd_result = self.execute_command(cmd).await?; @@ -7591,7 +7917,7 @@ impl Interpreter { } // Fire EXIT trap set inside the command substitution if let Some(trap_cmd) = self.traps.get("EXIT").cloned() - && saved_traps.get("EXIT") != Some(&trap_cmd) + && snapshot.traps.get("EXIT") != Some(&trap_cmd) && let Ok(trap_script) = Parser::with_limits( &trap_cmd, self.limits.max_ast_depth, @@ -7602,16 +7928,7 @@ impl Interpreter { { stdout.push_str(&trap_result.stdout); } - // Restore parent state - self.traps = saved_traps; - self.functions = saved_functions; - self.variables = saved_vars; - self.arrays = saved_arrays; - self.assoc_arrays = saved_assoc; - self.aliases = saved_aliases; - self.cwd = saved_cwd; - self.memory_budget = saved_memory_budget; - self.exec_fd_table = saved_exec_fd_table; + self.restore_subshell_state(snapshot); self.counters.pop_subst(); self.subst_generation += 1; let trimmed = stdout.trim_end_matches('\n'); @@ -7843,23 +8160,22 @@ impl Interpreter { operand, colon_variant, } => { - let nameref_key = format!("_NAMEREF_{}", name); - let is_nameref = self.variables.contains_key(&nameref_key); + let nameref_target = self.namerefs.get(name).cloned(); + let is_nameref = nameref_target.is_some(); if is_nameref && operator.is_none() { // Nameref without operator: ${!ref} returns the // name the nameref points to (original behavior). - if let Some(target) = self.variables.get(&nameref_key).cloned() { - result.push_str(&target); + if let Some(ref target) = nameref_target { + result.push_str(target); } } else { // Resolve the indirect target variable name - let resolved_name = - if let Some(target) = self.variables.get(&nameref_key).cloned() { - target - } else { - self.expand_variable(name) - }; + let resolved_name = if let Some(target) = nameref_target { + target + } else { + self.expand_variable(name) + }; if let Some(op) = operator { // Indirect + operator: resolve indirect, then @@ -9856,82 +10172,82 @@ impl Interpreter { if Self::is_internal_variable(&name) { return; } - // Resolve nameref: if `name` is a nameref, assign to the target instead - let resolved = self.resolve_nameref(&name).to_string(); + // Resolve nameref: if `name` is a nameref, assign to the target + // instead. The common case (no nameref) reuses `name` without + // allocating; only nameref hops allocate a fresh owned target. + let resolved_string: String = { + let resolved = self.resolve_nameref(&name); + if std::ptr::eq(resolved.as_ptr(), name.as_ptr()) { + name + } else { + resolved.to_string() + } + }; + let resolved: &str = resolved_string.as_str(); // RANDOM=N reseeds the PRNG (matches bash behavior) if resolved == "RANDOM" { self.random_state .store(value.parse::().unwrap_or(0), Ordering::Relaxed); return; } + // Attribute lookup is now a single map probe + bit test. + let attrs = self.var_attrs_get(resolved); // THREAT[TM-INJ-019/020/021]: Block assignment to readonly variables - if self - .variables - .contains_key(&format!("_READONLY_{}", resolved)) - { + if attrs.contains(VarAttrs::READONLY) { return; } // Apply integer attribute (declare -i): evaluate as arithmetic - let value = if self - .variables - .get(&format!("_INTEGER_{}", resolved)) - .map(|v| v == "1") - .unwrap_or(false) - { + let value = if attrs.contains(VarAttrs::INTEGER) { self.evaluate_arithmetic_with_assign(&value).to_string() } else { value }; // Apply case conversion attributes (declare -l / declare -u) - let value = if self - .variables - .get(&format!("_LOWER_{}", resolved)) - .map(|v| v == "1") - .unwrap_or(false) - { + let value = if attrs.contains(VarAttrs::LOWER) { value.to_lowercase() - } else if self - .variables - .get(&format!("_UPPER_{}", resolved)) - .map(|v| v == "1") - .unwrap_or(false) - { + } else if attrs.contains(VarAttrs::UPPER) { value.to_uppercase() } else { value }; - // Check allexport (set -a): auto-export to env - let allexport = self - .variables - .get("SHOPT_a") - .map(|v| v == "1") - .unwrap_or(false); - - for frame in self.call_stack.iter_mut().rev() { - if let std::collections::hash_map::Entry::Occupied(mut e) = - frame.locals.entry(resolved.clone()) - { - // Local variable update — track byte delta but no count change - let old_val_len = e.get().len(); + // Check allexport (set -a): auto-export to env — now a bit test. + let allexport = self.flags.contains(BashFlags::ALLEXPORT); + + // Walk the call stack top-down looking for an existing local binding. + // The previous implementation cloned `resolved_string` for the + // `entry()` API in every frame even when the key was absent — for a + // tight `for i in {1..N}; do x=$((x+1)); done` inside a function this + // is one clone per iteration. Using `get_mut` skips the clone unless + // we actually have a local to update. + for frame_idx in (0..self.call_stack.len()).rev() { + if let Some(existing) = self.call_stack[frame_idx].locals.get_mut(resolved) { + let old_val_len = existing.len(); self.memory_budget.variable_bytes = self .memory_budget .variable_bytes .saturating_add(value.len()) .saturating_sub(old_val_len); if allexport { - let env_key = resolved.clone(); let env_value = value.clone(); - e.insert(value); - self.insert_env_checked(env_key, env_value); + *existing = value; + self.insert_env_checked(resolved_string, env_value); return; } - e.insert(value); + *existing = value; return; } } - self.insert_variable_checked(resolved.clone(), value.clone()); - if allexport && self.variables.contains_key(&resolved) { - self.insert_env_checked(resolved, value); + // No local frame matched — insert at global scope. Only allexport + // needs the extra clone for the env mirror; the common path moves + // `value` straight into `variables`. + if allexport { + let env_value = value.clone(); + self.insert_variable_checked(resolved_string.clone(), value); + if self.variables.contains_key(&resolved_string) { + self.insert_env_checked(resolved_string, env_value); + } + } else { + self.insert_variable_checked(resolved_string, value); } } @@ -9961,7 +10277,7 @@ impl Interpreter { if is_new_entry { self.memory_budget.record_array_insert(1); } - self.assoc_arrays + self.assoc_arrays_mut() .entry(resolved_name) .or_default() .insert(expanded_key, value); @@ -9994,7 +10310,7 @@ impl Interpreter { if is_new_entry { self.memory_budget.record_array_insert(1); } - self.arrays + self.arrays_mut() .entry(resolved_name) .or_default() .insert(index, value); @@ -10038,7 +10354,18 @@ impl Interpreter { old_value_len, ); } - self.variables.insert(key, value); + // Keep the SHOPT flag cache in sync whenever SHOPT_* gets written. + // Internal callers that bulk-insert variables (snapshot restore, + // SHOPT bookkeeping in `execute_shell`) go through this routine, so + // hooking it here is the single sync point. + if let Some(bit) = BashFlags::from_shopt_name(&key) { + if value == "1" { + self.flags.insert(bit); + } else { + self.flags.remove(bit); + } + } + self.vars_mut().insert(key, value); } /// Insert a variable into the current local frame with memory budget checking. @@ -10119,7 +10446,7 @@ impl Interpreter { } self.memory_budget.array_entries = self.memory_budget.array_entries.saturating_sub(old_entries) + new_entries; - self.arrays.insert(name, arr); + self.arrays_mut().insert(name, arr); true } @@ -10140,19 +10467,22 @@ impl Interpreter { } self.memory_budget.array_entries = self.memory_budget.array_entries.saturating_sub(old_entries) + new_entries; - self.assoc_arrays.insert(name, arr); + self.assoc_arrays_mut().insert(name, arr); true } /// Resolve nameref chains: if `name` has a `_NAMEREF_` marker, /// follow the chain (up to 10 levels to prevent infinite loops). fn resolve_nameref<'a>(&'a self, name: &'a str) -> &'a str { + // Fast path: most variables aren't namerefs. One hashmap lookup decides. + if self.namerefs.is_empty() { + return name; + } let mut current = name; let mut visited = std::collections::HashSet::new(); visited.insert(name); for _ in 0..10 { - let key = format!("_NAMEREF_{}", current); - if let Some(target) = self.variables.get(&key) { + if let Some(target) = self.namerefs.get(current) { // THREAT[TM-INJ-011]: Detect cyclic namerefs and stop. if !visited.insert(target.as_str()) { // Cycle detected — return original name (Bash emits a warning) @@ -10210,26 +10540,10 @@ impl Interpreter { if self.counters.push_subst(&self.limits).is_err() { result.push('0'); } else { - let saved_vars = self.variables.clone(); - let saved_arrays = self.arrays.clone(); - let saved_assoc = self.assoc_arrays.clone(); - let saved_functions = self.functions.clone(); - let saved_traps = self.traps.clone(); - let saved_aliases = self.aliases.clone(); - let saved_cwd = self.cwd.clone(); - let saved_memory_budget = self.memory_budget.clone(); - let saved_exec_fd_table = self.exec_fd_table.clone(); + let snapshot = self.snapshot_subshell_state(); let cmd_result = self.execute_command_sequence(&script.commands).await?; - self.variables = saved_vars; - self.arrays = saved_arrays; - self.assoc_arrays = saved_assoc; - self.functions = saved_functions; - self.traps = saved_traps; - self.aliases = saved_aliases; - self.cwd = saved_cwd; - self.memory_budget = saved_memory_budget; - self.exec_fd_table = saved_exec_fd_table; + self.restore_subshell_state(snapshot); self.counters.pop_subst(); let trimmed = cmd_result.stdout.trim_end_matches('\n'); if trimmed.is_empty() { @@ -10470,18 +10784,12 @@ impl Interpreter { /// Check if nounset (`set -u`) is active. fn is_nounset(&self) -> bool { - self.variables - .get("SHOPT_u") - .map(|v| v == "1") - .unwrap_or(false) + self.flags.contains(BashFlags::NOUNSET) } /// Check if pipefail (`set -o pipefail`) is active. fn is_pipefail(&self) -> bool { - self.variables - .get("SHOPT_pipefail") - .map(|v| v == "1") - .unwrap_or(false) + self.flags.contains(BashFlags::PIPEFAIL) } /// Run ERR trap if registered. Appends trap output to stdout/stderr. diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 46a667479..3d9dfd8a1 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -707,7 +707,6 @@ impl Bash { let parser_timeout = self.parser_timeout; let max_ast_depth = self.max_ast_depth; let max_parser_operations = self.max_parser_operations; - let script_owned = script.to_owned(); #[cfg(feature = "logging")] tracing::debug!( @@ -718,12 +717,22 @@ impl Bash { "Parsing script" ); + // Important decision: skip the tokio `spawn_blocking` + `time::timeout` + // round-trip for small scripts. The parser already enforces a fuel + // budget via `max_parser_operations`, so a runaway script still + // terminates without the timer-driven path. For ~99% of inline scripts + // (REPL, agent commands, short shell snippets) the threadpool hop + // dominated startup latency. The threshold matches the input byte + // size; above it we keep the original behavior so very large scripts + // can be pre-empted. + const SPAWN_BLOCKING_THRESHOLD: usize = 16 * 1024; + // On WASM, tokio::task::spawn_blocking and tokio::time::timeout don't // work (no blocking thread pool, timer driver unreliable). Parse inline. #[cfg(target_family = "wasm")] let ast = { let parser = Parser::with_limits_and_timeout( - &script_owned, + script, max_ast_depth, max_parser_operations, Some(parser_timeout), @@ -731,10 +740,26 @@ impl Bash { parser.parse()? }; - // On native targets, parse with timeout using spawn_blocking since - // parsing is sync and we don't want to block the async runtime. + // On native targets, parse inline for small scripts (avoid threadpool + // hop) and use spawn_blocking + timeout for larger ones so the async + // runtime can pre-empt a runaway parser. #[cfg(not(target_family = "wasm"))] - let ast = { + let ast = if input_len <= SPAWN_BLOCKING_THRESHOLD { + let parser = Parser::with_limits(script, max_ast_depth, max_parser_operations); + match parser.parse() { + Ok(ast) => { + #[cfg(feature = "logging")] + tracing::debug!(target: "bashkit::parser", "Parse completed (inline)"); + ast + } + Err(e) => { + #[cfg(feature = "logging")] + tracing::warn!(target: "bashkit::parser", error = %e, "Parse error (inline)"); + return Err(e); + } + } + } else { + let script_owned = script.to_owned(); let parse_result = tokio::time::timeout(parser_timeout, async { tokio::task::spawn_blocking(move || { let parser = From 92b59c0110e80f906cddfafca939ad15924a94aa Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 25 May 2026 22:09:55 +0000 Subject: [PATCH 2/3] chore(interpreter): drop unused attr/shopt helpers, update threat-model - 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. --- crates/bashkit/src/interpreter/mod.rs | 51 --------------------------- specs/threat-model.md | 37 +++++++++++-------- 2 files changed, 23 insertions(+), 65 deletions(-) diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index eba9f0b98..6cc7c2674 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -1440,16 +1440,6 @@ impl Interpreter { Arc::make_mut(&mut self.traps) } - // Aliases are only mutated by builtins via ShellRef (which gets a - // `&mut HashMap` via Arc::make_mut inline at the construction site), so - // a parallel `aliases_mut` helper isn't called from the interpreter - // itself. Kept commented for symmetry — if a new interpreter call site - // ever needs &mut aliases, just uncomment. - // #[inline] - // fn aliases_mut(&mut self) -> &mut HashMap { - // Arc::make_mut(&mut self.aliases) - // } - #[inline] fn var_attrs_mut(&mut self) -> &mut HashMap { Arc::make_mut(&mut self.var_attrs) @@ -1491,32 +1481,6 @@ impl Interpreter { self.flags.contains(BashFlags::XTRACE) } - /// Cache-aware setter for `SHOPT_X` entries in `variables`. Mirrors the - /// scalar string write into the `flags` bitfield so hot-path checks don't - /// need a HashMap lookup + string compare. Use this whenever code writes - /// `SHOPT_` or `SHOPT_expand_aliases`/etc.; plain `set -e`/`-u`/etc. - /// inputs flow through here from `execute_set_builtin` / `execute_shopt`. - #[allow(dead_code)] // exposed for builtin callers; unused in current tree - fn set_shopt_value(&mut self, name: &str, value: &str) { - if let Some(bit) = BashFlags::from_shopt_name(name) { - if value == "1" { - self.flags.insert(bit); - } else { - self.flags.remove(bit); - } - } - self.vars_mut().insert(name.to_string(), value.to_string()); - } - - /// Cache-aware remover for `SHOPT_X` entries in `variables`. - #[allow(dead_code)] // exposed for builtin callers; unused in current tree - fn remove_shopt_value(&mut self, name: &str) { - if let Some(bit) = BashFlags::from_shopt_name(name) { - self.flags.remove(bit); - } - self.vars_mut().remove(name); - } - /// Rehydrate the SHOPT flag cache from any `SHOPT_*` entries currently in /// `self.variables`. Call after bulk-restoring `variables` from a snapshot /// or builder so the cache doesn't drift. @@ -1544,21 +1508,6 @@ impl Interpreter { self.var_attrs_get(name).contains(VarAttrs::READONLY) } - #[allow(dead_code)] // sibling helper; kept symmetric with READONLY/LOWER/UPPER - fn is_var_integer(&self, name: &str) -> bool { - self.var_attrs_get(name).contains(VarAttrs::INTEGER) - } - - #[allow(dead_code)] // sibling helper; kept symmetric with READONLY/INTEGER/UPPER - fn is_var_lower(&self, name: &str) -> bool { - self.var_attrs_get(name).contains(VarAttrs::LOWER) - } - - #[allow(dead_code)] // sibling helper; kept symmetric with READONLY/INTEGER/LOWER - fn is_var_upper(&self, name: &str) -> bool { - self.var_attrs_get(name).contains(VarAttrs::UPPER) - } - fn add_var_attr(&mut self, name: &str, attr: VarAttrs) { // entry-by-string-slice — only allocates when inserting new entry match self.var_attrs_mut().get_mut(name) { diff --git a/specs/threat-model.md b/specs/threat-model.md index d3dd1be93..ba55c2d6a 100644 --- a/specs/threat-model.md +++ b/specs/threat-model.md @@ -579,9 +579,9 @@ Bash::builder() | TM-INJ-011 | Cyclic nameref silent resolution | Cyclic namerefs (a→b→a) silently resolve after 10 iterations instead of erroring | `resolve_nameref()` tracks visited names in a `HashSet`; on cycle detection it returns the original name and the lookup sees no value | **MITIGATED** | | TM-INJ-018 | `dotenv` internal variable prefix injection | `.env` file with `_NAMEREF_x=target` sets internal interpreter variables via `ctx.variables.insert()` | `Dotenv::execute` calls `is_internal_variable(&full_key)` and skips injected internal-prefix keys (`builtins/dotenv.rs:138`) | **MITIGATED** | -| TM-INJ-019 | `unset` removes readonly variables | `readonly X=v; unset X` removes the variable despite readonly attribute | `execute_unset_builtin` and `Unset` builtin both check `_READONLY_` markers, emit `bash: unset: : cannot unset: readonly variable`, and return exit 1 | **MITIGATED** | -| TM-INJ-020 | `declare` overwrites readonly variables | `readonly X=v; declare X=new` overwrites without error | `declare` assignment path checks `_READONLY_`, emits `bash: declare: : readonly variable`, returns exit 1 | **MITIGATED** | -| TM-INJ-021 | `export` overwrites readonly variables | `readonly X=v; export X=new` overwrites without error | `export NAME=VALUE` checks `_READONLY_`, emits `bash: export: : readonly variable`, returns exit 1 | **MITIGATED** | +| TM-INJ-019 | `unset` removes readonly variables | `readonly X=v; unset X` removes the variable despite readonly attribute | `execute_unset_builtin` and `Unset` builtin both consult the `VarAttrs::READONLY` flag in the dedicated `var_attrs` map, emit `bash: unset: : cannot unset: readonly variable`, and return exit 1 | **MITIGATED** | +| TM-INJ-020 | `declare` overwrites readonly variables | `readonly X=v; declare X=new` overwrites without error | `declare` assignment path consults `VarAttrs::READONLY` via `is_var_readonly()`, emits `bash: declare: : readonly variable`, returns exit 1 | **MITIGATED** | +| TM-INJ-021 | `export` overwrites readonly variables | `readonly X=v; export X=new` overwrites without error | `export NAME=VALUE` consults `VarAttrs::READONLY` via `ShellRef::is_var_readonly()`, emits `bash: export: : readonly variable`, returns exit 1 | **MITIGATED** | **TM-INJ-011** (mitigated): `interpreter/mod.rs::resolve_nameref` tracks visited names in a `HashSet`. When a cycle (e.g. `a→b→a`, `a→a`, or longer chains) is detected on the next hop, @@ -592,17 +592,26 @@ silent traversal to a stale variable — is upheld. Regression tests: `tests/threat_model_tests.rs::tm_inj_011_*`. **TM-INJ-018** (mitigated): `builtins/dotenv.rs:138` — `Dotenv::execute` calls -`is_internal_variable(&full_key)` and skips any key in the internal namespace (`_NAMEREF_*`, -`_READONLY_*`, `_UPPER_*`, `_LOWER_*`, `_INTEGER_*`, `_ARRAY_READ_*`, `_SHIFT_COUNT`, -`_SET_POSITIONAL`). Regression test: -`tests/threat_model_tests.rs::tm_inj_018_dotenv_rejects_internal_prefixes`. - -**TM-INJ-009**: The interpreter uses magic variable prefixes as internal control signals: -`_NAMEREF_` (nameref), `_READONLY_` (readonly), `_SHIFT_COUNT`, `_SET_POSITIONAL`, -`_UPPER_`, `_LOWER_`. User scripts can set these directly to bypass readonly -protection, create unauthorized namerefs, or manipulate builtins. `${!_NAMEREF_*}` also exposes -all internal variables. Fix: use separate `HashMap` for internal state, or reject assignments -to reserved prefixes. +`is_internal_variable(&full_key)` and skips any key in the historically-reserved namespace +(`_NAMEREF_*`, `_READONLY_*`, `_UPPER_*`, `_LOWER_*`, `_INTEGER_*`, `_ARRAY_READ_*`, +`_SHIFT_COUNT`, `_SET_POSITIONAL`). The interpreter no longer stores attribute markers in +`variables` under these prefixes — readonly/integer/lower/upper attributes live in a +dedicated `VarAttrs` bitset map and namerefs in a dedicated `namerefs: HashMap` +— so even a dotenv key that slipped past the filter could not influence interpreter behavior. +The prefix block is kept as defense-in-depth (and to suppress confusing output like +`echo $_NAMEREF_x`). Regression test: `tests/threat_model_tests.rs::tm_inj_018_dotenv_rejects_internal_prefixes`. + +**TM-INJ-009** (mitigated): historically the interpreter used magic variable prefixes +(`_NAMEREF_`, `_READONLY_`, `_INTEGER_`, `_UPPER_`, `_LOWER_`) +to record attributes inside the shared `variables` HashMap. That allowed user scripts to +forge attributes by writing the prefixed key directly. The interpreter now stores +attributes in a dedicated `var_attrs: HashMap` bitset and namerefs in a +dedicated `namerefs` map; user scripts cannot reach these from bash. `is_internal_variable()` +still rejects writes to the legacy prefixes from every write path (`set_variable`, `read`, +`printf -v`, `local`, `declare`, `readonly`, `export`, `dotenv`, `unset`) as +defense-in-depth, and `set` / `declare -p` filter the prefixes from listings (TM-INF-017). +Regression tests live in `tests/security_audit_pocs.rs` (declare/readonly/export/local can +not inject any of the legacy markers) and `tests/threat_model_tests.rs::tm_inj_009_*`. **Example**: ```bash From 32b3624b07d9748e0df637a3e8eaea5e735fc61d Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 26 May 2026 00:14:06 +0000 Subject: [PATCH 3/3] fix(interpreter): clear flag bit when restoring shell options in execute_shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/bashkit/src/interpreter/mod.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 6cc7c2674..8de9ebf2f 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -3793,11 +3793,20 @@ impl Interpreter { } else { self.vars_mut().remove("_OPTCHAR_IDX"); } + // Restoring the parent's SHOPT_* values must also clear the cached + // `flags` bit when the parent had no entry — otherwise an inner + // `bash -e -c '...'` would leak errexit into the parent shell via + // the bitfield even though the `SHOPT_e` variable was correctly + // removed. `insert_variable_checked` handles the set case; for + // remove we go through `vars_mut().remove` *and* clear the bit. for (var, prev) in saved_opts { if let Some(val) = prev { self.insert_variable_checked(var, val); } else { self.vars_mut().remove(&var); + if let Some(bit) = BashFlags::from_shopt_name(&var) { + self.flags.remove(bit); + } } } self.call_stack.pop();