diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3decdf34..f023e2083 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -167,6 +167,14 @@ jobs: - name: Integration tests (headless Node) run: node --test "crates/bashkit-wasm/__test__/*.test.mjs" + - name: Browser streaming (main thread and worker) + run: | + npm install --prefix "$RUNNER_TEMP/bashkit-browser-test" --no-save playwright@1.62.1 + export PLAYWRIGHT_MODULE="$RUNNER_TEMP/bashkit-browser-test/node_modules/playwright/index.mjs" + node "$RUNNER_TEMP/bashkit-browser-test/node_modules/playwright/cli.js" install --with-deps chromium + export CHROME_EXECUTABLE=$(node --input-type=module -e 'const {chromium} = await import(process.env.PLAYWRIGHT_MODULE); console.log(chromium.executablePath())') + node crates/bashkit-wasm/scripts/test-streaming-browser.mjs + - name: Browser compatibility and transfer-size gates run: | node crates/bashkit-wasm/bench/compat.mjs diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 8915c65a8..bc411705f 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -186,11 +186,8 @@ jobs: - name: Run runtime-compat tests (Deno) if: matrix.runtime == 'deno' - run: | - for f in __test__/runtime-compat/*.test.mjs; do - echo "--- $f ---" - deno run -A "$f" - done + # node:test registers with Deno.test; deno run does not execute it. + run: deno test -A __test__/runtime-compat/*.test.mjs working-directory: crates/bashkit-js - name: Install example dependencies and link local build diff --git a/crates/bashkit-bench/results/bench-79f1c12bcc44-linux-x86_64-1792175675.json b/crates/bashkit-bench/results/bench-79f1c12bcc44-linux-x86_64-1792175675.json new file mode 100644 index 000000000..e4201c598 --- /dev/null +++ b/crates/bashkit-bench/results/bench-79f1c12bcc44-linux-x86_64-1792175675.json @@ -0,0 +1,4842 @@ +{ + "moniker": "79f1c12bcc44-linux-x86_64", + "timestamp": "1792175675", + "system": { + "hostname": "79f1c12bcc44", + "os": "linux", + "arch": "x86_64", + "cpus": 4, + "moniker": "79f1c12bcc44-linux-x86_64" + }, + "iterations": 10, + "warmup": 2, + "prewarm_cases": 3, + "runners": [ + "bashkit", + "bash" + ], + "runner_versions": { + "bash": "/usr/bin/bash (GNU Bash 5.2.37(1)-release)" + }, + "results": [ + { + "runner": "bashkit", + "case_name": "startup_empty", + "category": "startup", + "iterations": 10, + "times_ns": [ + 52259, + 51588, + 50696, + 49495, + 51728, + 66260, + 54712, + 50637, + 49875, + 46050 + ], + "mean_ns": 52330.0, + "stddev_ns": 5095.838380482646, + "min_ns": 46050, + "max_ns": 66260, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "startup_empty", + "category": "startup", + "iterations": 10, + "times_ns": [ + 1267433, + 1117016, + 1146300, + 1133011, + 1421155, + 1255555, + 1255645, + 1225080, + 1178959, + 1330579 + ], + "mean_ns": 1233073.3, + "stddev_ns": 90045.1006630011, + "min_ns": 1117016, + "max_ns": 1421155, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "startup_true", + "category": "startup", + "iterations": 10, + "times_ns": [ + 56575, + 59460, + 74112, + 50997, + 50426, + 66330, + 51458, + 50135, + 52550, + 48934 + ], + "mean_ns": 56097.7, + "stddev_ns": 7873.490624240306, + "min_ns": 48934, + "max_ns": 74112, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "startup_true", + "category": "startup", + "iterations": 10, + "times_ns": [ + 1208694, + 1039910, + 1158149, + 1062694, + 1082444, + 1245670, + 1278821, + 1394946, + 2342797, + 1098228 + ], + "mean_ns": 1291235.3, + "stddev_ns": 365996.50491474645, + "min_ns": 1039910, + "max_ns": 2342797, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "startup_echo", + "category": "startup", + "iterations": 10, + "times_ns": [ + 55904, + 52109, + 51318, + 50326, + 50817, + 50476, + 53912, + 50456, + 51167, + 51007 + ], + "mean_ns": 51749.2, + "stddev_ns": 1712.6930139403266, + "min_ns": 50326, + "max_ns": 55904, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "startup_echo", + "category": "startup", + "iterations": 10, + "times_ns": [ + 1061492, + 1102243, + 1088283, + 1096936, + 1148584, + 1242541, + 1191253, + 1133501, + 1346162, + 1163677 + ], + "mean_ns": 1157467.2, + "stddev_ns": 80862.87272883645, + "min_ns": 1061492, + "max_ns": 1346162, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "startup_exit", + "category": "startup", + "iterations": 10, + "times_ns": [ + 59950, + 54512, + 52089, + 50936, + 53050, + 52439, + 66120, + 61363, + 52739, + 51508 + ], + "mean_ns": 55470.6, + "stddev_ns": 4892.091499553131, + "min_ns": 50936, + "max_ns": 66120, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "startup_exit", + "category": "startup", + "iterations": 10, + "times_ns": [ + 1168223, + 1142855, + 1141363, + 1117928, + 1238459, + 1200132, + 1439674, + 1059259, + 1228174, + 1116275 + ], + "mean_ns": 1185234.2, + "stddev_ns": 99399.98419195047, + "min_ns": 1059259, + "max_ns": 1439674, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "var_assign_simple", + "category": "variables", + "iterations": 10, + "times_ns": [ + 62014, + 57887, + 56405, + 56365, + 55474, + 54582, + 55484, + 53711, + 54072, + 82395 + ], + "mean_ns": 58838.9, + "stddev_ns": 8170.918362705626, + "min_ns": 53711, + "max_ns": 82395, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "var_assign_simple", + "category": "variables", + "iterations": 10, + "times_ns": [ + 1473170, + 1143666, + 1209506, + 1065208, + 1086220, + 1073050, + 1162815, + 1176466, + 1161544, + 1123446 + ], + "mean_ns": 1167509.1, + "stddev_ns": 111281.70247659764, + "min_ns": 1065208, + "max_ns": 1473170, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "var_assign_many", + "category": "variables", + "iterations": 10, + "times_ns": [ + 76956, + 77196, + 78528, + 77587, + 77788, + 105960, + 86521, + 178710, + 84738, + 77077 + ], + "mean_ns": 92106.1, + "stddev_ns": 30092.41730220422, + "min_ns": 76956, + "max_ns": 178710, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "var_assign_many", + "category": "variables", + "iterations": 10, + "times_ns": [ + 1252941, + 1257879, + 1181353, + 1125709, + 1146771, + 1184408, + 1103465, + 1075473, + 1149635, + 1074131 + ], + "mean_ns": 1155176.5, + "stddev_ns": 61814.387592614716, + "min_ns": 1074131, + "max_ns": 1257879, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "var_default", + "category": "variables", + "iterations": 10, + "times_ns": [ + 58919, + 113742, + 63766, + 117798, + 60842, + 55924, + 57387, + 57026, + 55564, + 53641 + ], + "mean_ns": 69460.9, + "stddev_ns": 23328.494505432624, + "min_ns": 53641, + "max_ns": 117798, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "var_default", + "category": "variables", + "iterations": 10, + "times_ns": [ + 1119970, + 1085158, + 1149546, + 1369107, + 1109344, + 1263472, + 1823924, + 1118258, + 1126551, + 1054332 + ], + "mean_ns": 1221966.2, + "stddev_ns": 219256.7374767763, + "min_ns": 1054332, + "max_ns": 1823924, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "var_length", + "category": "variables", + "iterations": 10, + "times_ns": [ + 61183, + 57497, + 56785, + 56875, + 56756, + 56395, + 55403, + 54312, + 54983, + 54823 + ], + "mean_ns": 56501.2, + "stddev_ns": 1852.761873528274, + "min_ns": 54312, + "max_ns": 61183, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "var_length", + "category": "variables", + "iterations": 10, + "times_ns": [ + 1208990, + 1093340, + 1062123, + 1039630, + 1180738, + 1141833, + 1416167, + 1147502, + 1270178, + 1271279 + ], + "mean_ns": 1183178.0, + "stddev_ns": 107981.60345169912, + "min_ns": 1039630, + "max_ns": 1416167, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "var_substring", + "category": "variables", + "iterations": 10, + "times_ns": [ + 63636, + 59520, + 60161, + 60201, + 59430, + 57257, + 86721, + 84738, + 61453, + 58739 + ], + "mean_ns": 65185.6, + "stddev_ns": 10402.612692972856, + "min_ns": 57257, + "max_ns": 86721, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "var_substring", + "category": "variables", + "iterations": 10, + "times_ns": [ + 1115709, + 1066961, + 1080551, + 1048522, + 1294313, + 1151889, + 1473825, + 1659555, + 1586054, + 1245901 + ], + "mean_ns": 1272328.0, + "stddev_ns": 214063.0122426572, + "min_ns": 1048522, + "max_ns": 1659555, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "var_replace", + "category": "variables", + "iterations": 10, + "times_ns": [ + 110036, + 68393, + 152360, + 66059, + 60871, + 60511, + 60602, + 59540, + 60061, + 58999 + ], + "mean_ns": 75743.2, + "stddev_ns": 29436.781161669154, + "min_ns": 58999, + "max_ns": 152360, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "var_replace", + "category": "variables", + "iterations": 10, + "times_ns": [ + 1125730, + 1058358, + 1184107, + 1218619, + 1519243, + 1360404, + 1262536, + 1228865, + 1160462, + 1108143 + ], + "mean_ns": 1222646.7, + "stddev_ns": 127843.43985676387, + "min_ns": 1058358, + "max_ns": 1519243, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "var_nested", + "category": "variables", + "iterations": 10, + "times_ns": [ + 65910, + 62925, + 61873, + 60792, + 60482, + 59941, + 59269, + 59159, + 59489, + 59109 + ], + "mean_ns": 60894.9, + "stddev_ns": 2052.878634990388, + "min_ns": 59109, + "max_ns": 65910, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "var_nested", + "category": "variables", + "iterations": 10, + "times_ns": [ + 1124557, + 1082914, + 1102805, + 1063966, + 1157348, + 1118268, + 1306602, + 1383519, + 1379703, + 1110742 + ], + "mean_ns": 1183042.4, + "stddev_ns": 117592.50584726901, + "min_ns": 1063966, + "max_ns": 1383519, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "var_export", + "category": "variables", + "iterations": 10, + "times_ns": [ + 95044, + 67242, + 60852, + 59279, + 58739, + 59009, + 57797, + 57447, + 56495, + 201975 + ], + "mean_ns": 77387.9, + "stddev_ns": 42943.58211758772, + "min_ns": 56495, + "max_ns": 201975, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "var_export", + "category": "variables", + "iterations": 10, + "times_ns": [ + 1090396, + 1157493, + 1184699, + 1304899, + 1244839, + 1121664, + 1099650, + 1047832, + 1145279, + 1140366 + ], + "mean_ns": 1153711.7, + "stddev_ns": 71801.54931204478, + "min_ns": 1047832, + "max_ns": 1304899, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arith_basic", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 57447, + 54793, + 55344, + 53390, + 53330, + 54071, + 55023, + 81223, + 71799, + 120141 + ], + "mean_ns": 65656.1, + "stddev_ns": 20223.923711535306, + "min_ns": 53330, + "max_ns": 120141, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arith_basic", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 1088012, + 1210167, + 1161649, + 1263583, + 1790603, + 1164067, + 1089831, + 1149425, + 1168965, + 1209651 + ], + "mean_ns": 1229595.3, + "stddev_ns": 193714.71178310132, + "min_ns": 1088012, + "max_ns": 1790603, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arith_complex", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 61633, + 58689, + 56645, + 55304, + 54853, + 55804, + 54983, + 55263, + 53551, + 55334 + ], + "mean_ns": 56205.9, + "stddev_ns": 2209.310817879639, + "min_ns": 53551, + "max_ns": 61633, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arith_complex", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 1180452, + 1227263, + 1121943, + 1079269, + 1679651, + 1178479, + 1105869, + 1109134, + 1153772, + 1113541 + ], + "mean_ns": 1194937.3, + "stddev_ns": 166971.74994414474, + "min_ns": 1079269, + "max_ns": 1679651, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arith_variables", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 64698, + 62454, + 148544, + 107753, + 65058, + 62093, + 60201, + 60492, + 60812, + 61353 + ], + "mean_ns": 75345.8, + "stddev_ns": 27975.494489999637, + "min_ns": 60201, + "max_ns": 148544, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arith_variables", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 2155384, + 1297388, + 1180602, + 1142986, + 1511351, + 1493805, + 2102574, + 1280933, + 1493505, + 1309387 + ], + "mean_ns": 1496791.5, + "stddev_ns": 338759.4196273367, + "min_ns": 1142986, + "max_ns": 2155384, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arith_increment", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 71488, + 66550, + 64507, + 65008, + 63506, + 62634, + 62825, + 102866, + 86060, + 67081 + ], + "mean_ns": 71252.5, + "stddev_ns": 12442.06947617638, + "min_ns": 62634, + "max_ns": 102866, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arith_increment", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 1510310, + 1347094, + 1350369, + 1235745, + 1135634, + 1165339, + 1281414, + 1690421, + 1252361, + 1105388 + ], + "mean_ns": 1307407.5, + "stddev_ns": 170479.39463128673, + "min_ns": 1105388, + "max_ns": 1690421, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arith_modulo", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 56496, + 56185, + 53941, + 55374, + 54032, + 53991, + 54332, + 53771, + 53631, + 53121 + ], + "mean_ns": 54487.4, + "stddev_ns": 1076.6885529251254, + "min_ns": 53121, + "max_ns": 56496, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arith_modulo", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 1110717, + 1058177, + 1180131, + 1076215, + 1217909, + 2055303, + 1139290, + 1060250, + 1072799, + 1145039 + ], + "mean_ns": 1211583.0, + "stddev_ns": 285786.91161877936, + "min_ns": 1058177, + "max_ns": 2055303, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arith_loop_sum", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 108273, + 97167, + 94513, + 96065, + 113992, + 106781, + 97747, + 94523, + 118289, + 92950 + ], + "mean_ns": 102030.0, + "stddev_ns": 8613.201472158886, + "min_ns": 92950, + "max_ns": 118289, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arith_loop_sum", + "category": "arithmetic", + "iterations": 10, + "times_ns": [ + 1291570, + 1172600, + 1145619, + 1497260, + 1385932, + 1111478, + 1144848, + 1236276, + 1210117, + 1286953 + ], + "mean_ns": 1248265.3, + "stddev_ns": 114712.22955992965, + "min_ns": 1111478, + "max_ns": 1497260, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "ctrl_if_simple", + "category": "control", + "iterations": 10, + "times_ns": [ + 63396, + 63576, + 59770, + 58188, + 58308, + 57667, + 61643, + 59209, + 127803, + 63326 + ], + "mean_ns": 67288.6, + "stddev_ns": 20288.81441681598, + "min_ns": 57667, + "max_ns": 127803, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "ctrl_if_simple", + "category": "control", + "iterations": 10, + "times_ns": [ + 1164739, + 1108934, + 1187443, + 1219391, + 1283648, + 1260703, + 1203066, + 1109334, + 1310789, + 1233542 + ], + "mean_ns": 1208158.9, + "stddev_ns": 64587.306357286645, + "min_ns": 1108934, + "max_ns": 1310789, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "ctrl_if_else", + "category": "control", + "iterations": 10, + "times_ns": [ + 111999, + 67602, + 63035, + 60542, + 61252, + 59129, + 58859, + 80011, + 59840, + 58909 + ], + "mean_ns": 68117.8, + "stddev_ns": 15866.595897041054, + "min_ns": 58859, + "max_ns": 111999, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "ctrl_if_else", + "category": "control", + "iterations": 10, + "times_ns": [ + 1084287, + 1078468, + 1183737, + 1047892, + 1238570, + 1048523, + 1307975, + 1314755, + 1107852, + 1085389 + ], + "mean_ns": 1149744.8, + "stddev_ns": 98586.09194992973, + "min_ns": 1047892, + "max_ns": 1314755, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "ctrl_for_list", + "category": "control", + "iterations": 10, + "times_ns": [ + 83746, + 104428, + 83646, + 80852, + 78008, + 98959, + 77577, + 78188, + 77898, + 76536 + ], + "mean_ns": 83983.8, + "stddev_ns": 9246.923886352693, + "min_ns": 76536, + "max_ns": 104428, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "ctrl_for_list", + "category": "control", + "iterations": 10, + "times_ns": [ + 1306512, + 1312271, + 1141583, + 1104988, + 1313363, + 1200402, + 1063315, + 1183717, + 1088263, + 1271941 + ], + "mean_ns": 1198635.5, + "stddev_ns": 92644.86621097793, + "min_ns": 1063315, + "max_ns": 1313363, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "ctrl_for_range", + "category": "control", + "iterations": 10, + "times_ns": [ + 118118, + 88834, + 84828, + 85459, + 81894, + 151960, + 137627, + 90837, + 82595, + 82244 + ], + "mean_ns": 100439.6, + "stddev_ns": 24572.89026223818, + "min_ns": 81894, + "max_ns": 151960, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "ctrl_for_range", + "category": "control", + "iterations": 10, + "times_ns": [ + 1103485, + 1262155, + 1141483, + 1109214, + 1545293, + 1329056, + 1092029, + 1118859, + 1166301, + 1304159 + ], + "mean_ns": 1217203.4, + "stddev_ns": 137110.97046130188, + "min_ns": 1092029, + "max_ns": 1545293, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "ctrl_while", + "category": "control", + "iterations": 10, + "times_ns": [ + 124638, + 119810, + 122705, + 143446, + 148564, + 153111, + 125510, + 137337, + 127323, + 119410 + ], + "mean_ns": 132185.4, + "stddev_ns": 11793.582443006873, + "min_ns": 119410, + "max_ns": 153111, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "ctrl_while", + "category": "control", + "iterations": 10, + "times_ns": [ + 1076616, + 1311324, + 1513274, + 1172410, + 1237399, + 1316658, + 1135164, + 1144207, + 1297749, + 1172580 + ], + "mean_ns": 1237738.1, + "stddev_ns": 120377.68605638671, + "min_ns": 1076616, + "max_ns": 1513274, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "ctrl_case", + "category": "control", + "iterations": 10, + "times_ns": [ + 71899, + 66190, + 66150, + 64828, + 64267, + 66760, + 67402, + 64027, + 62915, + 63046 + ], + "mean_ns": 65748.4, + "stddev_ns": 2515.550086959113, + "min_ns": 62915, + "max_ns": 71899, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "ctrl_case", + "category": "control", + "iterations": 10, + "times_ns": [ + 1157477, + 1279362, + 1203607, + 1197468, + 1176727, + 2299701, + 1510119, + 2403258, + 1145409, + 1583801 + ], + "mean_ns": 1495692.9, + "stddev_ns": 451437.0342006624, + "min_ns": 1145409, + "max_ns": 2403258, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "ctrl_function", + "category": "control", + "iterations": 10, + "times_ns": [ + 82485, + 77517, + 322296, + 73971, + 65810, + 66140, + 66040, + 64727, + 70086, + 100602 + ], + "mean_ns": 98967.4, + "stddev_ns": 75168.31112403683, + "min_ns": 64727, + "max_ns": 322296, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "ctrl_function", + "category": "control", + "iterations": 10, + "times_ns": [ + 1099190, + 1178910, + 1074602, + 1071959, + 1220503, + 1066100, + 1176616, + 1171138, + 1097707, + 1165229 + ], + "mean_ns": 1132195.4, + "stddev_ns": 53060.580728446614, + "min_ns": 1066100, + "max_ns": 1220503, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "ctrl_function_return", + "category": "control", + "iterations": 10, + "times_ns": [ + 91117, + 158419, + 92349, + 84828, + 83727, + 83356, + 110257, + 90226, + 80602, + 80792 + ], + "mean_ns": 95567.3, + "stddev_ns": 22520.493778112417, + "min_ns": 80602, + "max_ns": 158419, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "ctrl_function_return", + "category": "control", + "iterations": 10, + "times_ns": [ + 1747579, + 1461737, + 1853198, + 1544872, + 1710392, + 1533665, + 1518863, + 1635079, + 1462969, + 1910565 + ], + "mean_ns": 1637891.9, + "stddev_ns": 152746.57777734334, + "min_ns": 1461737, + "max_ns": 1910565, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "ctrl_nested_loops", + "category": "control", + "iterations": 10, + "times_ns": [ + 106221, + 103636, + 104438, + 104047, + 99651, + 122495, + 104989, + 99460, + 100061, + 101393 + ], + "mean_ns": 104639.1, + "stddev_ns": 6372.249358742955, + "min_ns": 99460, + "max_ns": 122495, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "ctrl_nested_loops", + "category": "control", + "iterations": 10, + "times_ns": [ + 1120331, + 1632764, + 1626110, + 1188234, + 1304860, + 1522077, + 1156035, + 1334204, + 1298460, + 1163156 + ], + "mean_ns": 1334623.1, + "stddev_ns": 184345.0324480972, + "min_ns": 1120331, + "max_ns": 1632764, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "str_concat", + "category": "strings", + "iterations": 10, + "times_ns": [ + 89465, + 64948, + 296933, + 81003, + 66520, + 62554, + 60921, + 61623, + 60761, + 59550 + ], + "mean_ns": 90427.8, + "stddev_ns": 69473.61269258999, + "min_ns": 59550, + "max_ns": 296933, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "str_concat", + "category": "strings", + "iterations": 10, + "times_ns": [ + 1561076, + 1320163, + 1434505, + 1358881, + 1341015, + 1401627, + 1130617, + 1303062, + 1446734, + 1228284 + ], + "mean_ns": 1352596.4, + "stddev_ns": 113950.11360257611, + "min_ns": 1130617, + "max_ns": 1561076, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "str_printf", + "category": "strings", + "iterations": 10, + "times_ns": [ + 62184, + 57467, + 59049, + 55674, + 56234, + 54843, + 53541, + 55854, + 55534, + 55704 + ], + "mean_ns": 56608.4, + "stddev_ns": 2320.803533261702, + "min_ns": 53541, + "max_ns": 62184, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "str_printf", + "category": "strings", + "iterations": 10, + "times_ns": [ + 1107592, + 1274464, + 1335947, + 1153341, + 1420204, + 1170297, + 1475788, + 1165209, + 1279752, + 1165054 + ], + "mean_ns": 1254764.8, + "stddev_ns": 117887.25568508243, + "min_ns": 1107592, + "max_ns": 1475788, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "str_printf_pad", + "category": "strings", + "iterations": 10, + "times_ns": [ + 61142, + 85619, + 96125, + 59329, + 54242, + 79399, + 58668, + 56325, + 55144, + 54502 + ], + "mean_ns": 66049.5, + "stddev_ns": 14407.300380362727, + "min_ns": 54242, + "max_ns": 96125, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "str_printf_pad", + "category": "strings", + "iterations": 10, + "times_ns": [ + 1261069, + 1204674, + 1190608, + 1606009, + 1094222, + 1140858, + 1273502, + 1155103, + 1240042, + 1305692 + ], + "mean_ns": 1247177.9, + "stddev_ns": 134649.34124862993, + "min_ns": 1094222, + "max_ns": 1606009, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "str_echo_escape", + "category": "strings", + "iterations": 10, + "times_ns": [ + 58678, + 57127, + 55273, + 96396, + 57827, + 53851, + 52590, + 52619, + 52359, + 52118 + ], + "mean_ns": 58883.8, + "stddev_ns": 12716.077734899232, + "min_ns": 52118, + "max_ns": 96396, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "str_echo_escape", + "category": "strings", + "iterations": 10, + "times_ns": [ + 1127011, + 1179711, + 1287704, + 1155214, + 1110186, + 1178139, + 1360213, + 1260613, + 1126962, + 1170167 + ], + "mean_ns": 1195592.0, + "stddev_ns": 77041.33098668532, + "min_ns": 1110186, + "max_ns": 1360213, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "str_prefix_strip", + "category": "strings", + "iterations": 10, + "times_ns": [ + 66370, + 61483, + 62705, + 62444, + 60582, + 59790, + 107643, + 64878, + 58969, + 58288 + ], + "mean_ns": 66315.2, + "stddev_ns": 13983.11804140979, + "min_ns": 58288, + "max_ns": 107643, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "str_prefix_strip", + "category": "strings", + "iterations": 10, + "times_ns": [ + 1327214, + 1396288, + 1124798, + 1422837, + 1270058, + 2333723, + 1142334, + 1196045, + 1257594, + 1220452 + ], + "mean_ns": 1369134.3, + "stddev_ns": 334854.0372404221, + "min_ns": 1124798, + "max_ns": 2333723, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "str_suffix_strip", + "category": "strings", + "iterations": 10, + "times_ns": [ + 67632, + 61634, + 58899, + 59469, + 83917, + 77006, + 63185, + 59179, + 57306, + 58448 + ], + "mean_ns": 64667.5, + "stddev_ns": 8522.133010578982, + "min_ns": 57306, + "max_ns": 83917, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "str_suffix_strip", + "category": "strings", + "iterations": 10, + "times_ns": [ + 1719311, + 1136486, + 1255075, + 1184078, + 1377770, + 1391771, + 1178109, + 1310068, + 1203367, + 1183126 + ], + "mean_ns": 1293916.1, + "stddev_ns": 164338.34264860407, + "min_ns": 1136486, + "max_ns": 1719311, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "str_uppercase", + "category": "strings", + "iterations": 10, + "times_ns": [ + 63306, + 59029, + 119510, + 63336, + 60271, + 58979, + 57106, + 55965, + 55193, + 55985 + ], + "mean_ns": 64868.0, + "stddev_ns": 18418.65100923518, + "min_ns": 55193, + "max_ns": 119510, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "str_uppercase", + "category": "strings", + "iterations": 10, + "times_ns": [ + 1214763, + 1282266, + 1167452, + 1257774, + 1148579, + 1117578, + 1377124, + 1136005, + 1247123, + 1104627 + ], + "mean_ns": 1205329.1, + "stddev_ns": 82049.50835983113, + "min_ns": 1104627, + "max_ns": 1377124, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "str_lowercase", + "category": "strings", + "iterations": 10, + "times_ns": [ + 63015, + 62023, + 58979, + 59781, + 58789, + 87011, + 60391, + 93160, + 59810, + 55875 + ], + "mean_ns": 65883.4, + "stddev_ns": 12312.514155930949, + "min_ns": 55875, + "max_ns": 93160, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "str_lowercase", + "category": "strings", + "iterations": 10, + "times_ns": [ + 1059850, + 1094553, + 1408987, + 1482128, + 1212671, + 1114002, + 1101834, + 1083987, + 1105118, + 1149705 + ], + "mean_ns": 1181283.5, + "stddev_ns": 138821.38310019101, + "min_ns": 1059850, + "max_ns": 1482128, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arr_create", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 67652, + 87903, + 65068, + 169055, + 67002, + 66290, + 60782, + 59881, + 59209, + 60882 + ], + "mean_ns": 76372.4, + "stddev_ns": 31883.9689160556, + "min_ns": 59209, + "max_ns": 169055, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arr_create", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 1114562, + 1146390, + 1254414, + 1176836, + 1202024, + 1166326, + 1141613, + 1344680, + 1125299, + 1303168 + ], + "mean_ns": 1197531.2, + "stddev_ns": 74373.00797170974, + "min_ns": 1114562, + "max_ns": 1344680, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arr_all", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 66019, + 61993, + 62535, + 60801, + 62674, + 62044, + 61343, + 90246, + 145310, + 89595 + ], + "mean_ns": 76256.0, + "stddev_ns": 25468.2260434448, + "min_ns": 60801, + "max_ns": 145310, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arr_all", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 1297174, + 1280112, + 1238129, + 1171809, + 1244339, + 1091137, + 1153181, + 1179480, + 1117216, + 1122394 + ], + "mean_ns": 1189497.1, + "stddev_ns": 68053.09221549012, + "min_ns": 1091137, + "max_ns": 1297174, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arr_length", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 66971, + 113221, + 70837, + 63456, + 83336, + 65289, + 61553, + 60662, + 59730, + 59280 + ], + "mean_ns": 70433.5, + "stddev_ns": 15804.615574255515, + "min_ns": 59280, + "max_ns": 113221, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arr_length", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 1126510, + 1156466, + 1102995, + 1078679, + 1123135, + 1232140, + 1240102, + 1256707, + 1151558, + 1212891 + ], + "mean_ns": 1168118.3, + "stddev_ns": 59613.91087330204, + "min_ns": 1078679, + "max_ns": 1256707, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arr_iterate", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 81733, + 75524, + 146051, + 187163, + 88884, + 72750, + 70948, + 69394, + 69095, + 116276 + ], + "mean_ns": 97781.8, + "stddev_ns": 38037.5314914099, + "min_ns": 69095, + "max_ns": 187163, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arr_iterate", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 1185274, + 1351831, + 1296928, + 1134934, + 1138910, + 1164418, + 1183512, + 1093952, + 1096695, + 1110256 + ], + "mean_ns": 1175671.0, + "stddev_ns": 81376.36917434938, + "min_ns": 1093952, + "max_ns": 1351831, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arr_slice", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 93732, + 74763, + 67281, + 64167, + 63296, + 65079, + 62614, + 62174, + 61012, + 60952 + ], + "mean_ns": 67507.0, + "stddev_ns": 9553.899360993919, + "min_ns": 60952, + "max_ns": 93732, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arr_slice", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 1166471, + 1125219, + 1295125, + 1196086, + 1103506, + 1194794, + 1167192, + 1116045, + 1172780, + 1121112 + ], + "mean_ns": 1165833.0, + "stddev_ns": 53417.1173295602, + "min_ns": 1103506, + "max_ns": 1295125, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "arr_assign_index", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 68323, + 65829, + 64187, + 62925, + 60542, + 64287, + 62844, + 130947, + 73010, + 63145 + ], + "mean_ns": 71603.9, + "stddev_ns": 20055.4721083798, + "min_ns": 60542, + "max_ns": 130947, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "arr_assign_index", + "category": "arrays", + "iterations": 10, + "times_ns": [ + 1125950, + 1142305, + 1232531, + 1318761, + 1115113, + 1111237, + 1127232, + 1116716, + 1163106, + 1244149 + ], + "mean_ns": 1169710.0, + "stddev_ns": 67398.88460649776, + "min_ns": 1111237, + "max_ns": 1318761, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "pipe_simple", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 172821, + 84137, + 107452, + 82505, + 123586, + 75093, + 76956, + 73391, + 72319, + 71688 + ], + "mean_ns": 93994.8, + "stddev_ns": 30885.345773683675, + "min_ns": 71688, + "max_ns": 172821, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "pipe_simple", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 2087401, + 2085999, + 2093902, + 2234683, + 3608708, + 2153110, + 2096866, + 2203326, + 2210296, + 2034802 + ], + "mean_ns": 2280909.3, + "stddev_ns": 446919.71125092474, + "min_ns": 2034802, + "max_ns": 3608708, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "pipe_multi", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 158158, + 146851, + 141945, + 139180, + 169826, + 145219, + 143236, + 137378, + 134873, + 156797 + ], + "mean_ns": 147346.3, + "stddev_ns": 10411.64496177237, + "min_ns": 134873, + "max_ns": 169826, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "pipe_multi", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 2859972, + 2698914, + 3022518, + 2763622, + 2516454, + 2370989, + 2435696, + 2405771, + 2397735, + 2598413 + ], + "mean_ns": 2607008.4, + "stddev_ns": 211478.64964823276, + "min_ns": 2370989, + "max_ns": 3022518, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "pipe_command_subst", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 74773, + 93682, + 177227, + 151439, + 81023, + 67622, + 66310, + 109886, + 101704, + 148955 + ], + "mean_ns": 107262.1, + "stddev_ns": 37147.84527923524, + "min_ns": 66310, + "max_ns": 177227, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "pipe_command_subst", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 1487686, + 1508477, + 1442658, + 1481786, + 1445171, + 1398711, + 1743962, + 1477530, + 1427334, + 1372392 + ], + "mean_ns": 1478570.7, + "stddev_ns": 97006.90090199768, + "min_ns": 1372392, + "max_ns": 1743962, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "pipe_heredoc", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 70376, + 64738, + 65749, + 93732, + 65909, + 64587, + 63286, + 61733, + 61283, + 64527 + ], + "mean_ns": 67592.0, + "stddev_ns": 9036.160456742675, + "min_ns": 61283, + "max_ns": 93732, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "pipe_heredoc", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 1976454, + 2077375, + 1959989, + 2044216, + 1942542, + 2170026, + 2041772, + 2071748, + 2550811, + 1959278 + ], + "mean_ns": 2079421.1, + "stddev_ns": 170638.24459918123, + "min_ns": 1942542, + "max_ns": 2550811, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "pipe_herestring", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 145449, + 67281, + 67572, + 67833, + 250618, + 94653, + 66330, + 63496, + 62174, + 62725 + ], + "mean_ns": 94813.1, + "stddev_ns": 57405.75883036474, + "min_ns": 62174, + "max_ns": 250618, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "pipe_herestring", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 1968712, + 2100711, + 2050115, + 2067621, + 2028888, + 2317849, + 2267814, + 2123756, + 2182054, + 2105408 + ], + "mean_ns": 2121292.8, + "stddev_ns": 102099.56849546426, + "min_ns": 1968712, + "max_ns": 2317849, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "pipe_discard", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 72409, + 69846, + 66561, + 64727, + 63396, + 65459, + 64557, + 62945, + 63266, + 62815 + ], + "mean_ns": 65598.1, + "stddev_ns": 3038.0053143468986, + "min_ns": 62815, + "max_ns": 72409, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "pipe_discard", + "category": "pipes", + "iterations": 10, + "times_ns": [ + 1545053, + 1554747, + 1727368, + 1472783, + 1642700, + 1528497, + 1535137, + 1449508, + 1677322, + 1961962 + ], + "mean_ns": 1609507.7, + "stddev_ns": 143998.61690589253, + "min_ns": 1449508, + "max_ns": 1961962, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_grep_simple", + "category": "tools", + "iterations": 10, + "times_ns": [ + 88974, + 103156, + 95324, + 83096, + 134813, + 85759, + 80301, + 77187, + 75664, + 77777 + ], + "mean_ns": 90205.1, + "stddev_ns": 16997.293281284525, + "min_ns": 75664, + "max_ns": 134813, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_grep_simple", + "category": "tools", + "iterations": 10, + "times_ns": [ + 2532623, + 2262786, + 2328855, + 2369857, + 2254283, + 2432071, + 2478211, + 2287804, + 2459583, + 2452161 + ], + "mean_ns": 2385823.4, + "stddev_ns": 93602.774211238, + "min_ns": 2254283, + "max_ns": 2532623, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_grep_case", + "category": "tools", + "iterations": 10, + "times_ns": [ + 154153, + 151859, + 146801, + 171459, + 158329, + 148003, + 144358, + 143176, + 143567, + 142855 + ], + "mean_ns": 150456.0, + "stddev_ns": 8564.695651335194, + "min_ns": 142855, + "max_ns": 171459, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_grep_case", + "category": "tools", + "iterations": 10, + "times_ns": [ + 2369497, + 2432242, + 2363858, + 2414455, + 2347714, + 2611332, + 2331340, + 2311670, + 2288315, + 2327673 + ], + "mean_ns": 2379809.6, + "stddev_ns": 87763.77778696631, + "min_ns": 2288315, + "max_ns": 2611332, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_grep_count", + "category": "tools", + "iterations": 10, + "times_ns": [ + 84608, + 78729, + 81142, + 76776, + 76165, + 141683, + 101884, + 80071, + 75063, + 75114 + ], + "mean_ns": 87123.5, + "stddev_ns": 19681.88532254977, + "min_ns": 75063, + "max_ns": 141683, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_grep_count", + "category": "tools", + "iterations": 10, + "times_ns": [ + 2391581, + 2349377, + 2583370, + 2407044, + 2257869, + 2356908, + 2373283, + 2358060, + 2661197, + 2268875 + ], + "mean_ns": 2400756.4, + "stddev_ns": 120934.09894004256, + "min_ns": 2257869, + "max_ns": 2661197, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_grep_invert", + "category": "tools", + "iterations": 10, + "times_ns": [ + 85960, + 83746, + 80161, + 119851, + 84377, + 78969, + 77236, + 75664, + 75975, + 76205 + ], + "mean_ns": 83814.4, + "stddev_ns": 12527.769539706578, + "min_ns": 75664, + "max_ns": 119851, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_grep_invert", + "category": "tools", + "iterations": 10, + "times_ns": [ + 2377018, + 2774489, + 2411090, + 2387064, + 2674697, + 2248254, + 2247754, + 2554607, + 2687167, + 2314754 + ], + "mean_ns": 2467689.4, + "stddev_ns": 181807.91748777058, + "min_ns": 2247754, + "max_ns": 2774489, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_grep_regex", + "category": "tools", + "iterations": 10, + "times_ns": [ + 112340, + 109535, + 105759, + 217348, + 149015, + 112380, + 105519, + 105750, + 103726, + 102514 + ], + "mean_ns": 122388.6, + "stddev_ns": 34167.05528487932, + "min_ns": 102514, + "max_ns": 217348, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_grep_regex", + "category": "tools", + "iterations": 10, + "times_ns": [ + 2518922, + 2873989, + 2442387, + 3878430, + 2326597, + 4669890, + 2468757, + 2516630, + 2432462, + 2349091 + ], + "mean_ns": 2847715.5, + "stddev_ns": 748440.5420375422, + "min_ns": 2326597, + "max_ns": 4669890, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_sed_replace", + "category": "tools", + "iterations": 10, + "times_ns": [ + 83456, + 78248, + 107432, + 77607, + 72259, + 304670, + 116386, + 77497, + 72790, + 72099 + ], + "mean_ns": 106244.4, + "stddev_ns": 67724.97108629873, + "min_ns": 72099, + "max_ns": 304670, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_sed_replace", + "category": "tools", + "iterations": 10, + "times_ns": [ + 2545403, + 2634117, + 2666771, + 2448426, + 2512493, + 2433724, + 2919608, + 2566174, + 2463759, + 2878024 + ], + "mean_ns": 2606849.9, + "stddev_ns": 162962.03377992677, + "min_ns": 2433724, + "max_ns": 2919608, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_sed_global", + "category": "tools", + "iterations": 10, + "times_ns": [ + 77957, + 73130, + 71388, + 71818, + 70627, + 99621, + 77617, + 93712, + 70286, + 69345 + ], + "mean_ns": 77550.1, + "stddev_ns": 10034.378610058522, + "min_ns": 69345, + "max_ns": 99621, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_sed_global", + "category": "tools", + "iterations": 10, + "times_ns": [ + 2421897, + 2459533, + 2382287, + 2627136, + 2557190, + 2573815, + 2351680, + 2499744, + 2329457, + 2484040 + ], + "mean_ns": 2468677.9, + "stddev_ns": 93810.82282386186, + "min_ns": 2329457, + "max_ns": 2627136, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_sed_delete", + "category": "tools", + "iterations": 10, + "times_ns": [ + 78488, + 92971, + 79910, + 109545, + 73421, + 70346, + 99179, + 73882, + 68844, + 66901 + ], + "mean_ns": 81348.7, + "stddev_ns": 13649.341566903511, + "min_ns": 66901, + "max_ns": 109545, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_sed_delete", + "category": "tools", + "iterations": 10, + "times_ns": [ + 4543089, + 2384820, + 4776716, + 4779346, + 2546304, + 2635264, + 2638423, + 4540058, + 2398110, + 2557191 + ], + "mean_ns": 3379932.1, + "stddev_ns": 1050648.2943199833, + "min_ns": 2384820, + "max_ns": 4779346, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_sed_lines", + "category": "tools", + "iterations": 10, + "times_ns": [ + 72529, + 70076, + 67963, + 67983, + 67542, + 69876, + 93301, + 75394, + 67972, + 65539 + ], + "mean_ns": 71817.5, + "stddev_ns": 7639.9508800776985, + "min_ns": 65539, + "max_ns": 93301, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_sed_lines", + "category": "tools", + "iterations": 10, + "times_ns": [ + 2312571, + 2474125, + 2480189, + 14157213, + 4986282, + 2573045, + 2497491, + 2340924, + 2379842, + 2395406 + ], + "mean_ns": 3859708.8, + "stddev_ns": 3516764.84023296, + "min_ns": 2312571, + "max_ns": 14157213, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_sed_backrefs", + "category": "tools", + "iterations": 10, + "times_ns": [ + 183547, + 108714, + 232691, + 110166, + 101023, + 96676, + 94313, + 238650, + 269186, + 188304 + ], + "mean_ns": 162327.0, + "stddev_ns": 64505.421274494445, + "min_ns": 94313, + "max_ns": 269186, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_sed_backrefs", + "category": "tools", + "iterations": 10, + "times_ns": [ + 2354254, + 2555006, + 2413143, + 2793307, + 2494917, + 2399473, + 2551181, + 2453453, + 2380364, + 2431460 + ], + "mean_ns": 2482655.8, + "stddev_ns": 121967.46090560383, + "min_ns": 2354254, + "max_ns": 2793307, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_awk_print", + "category": "tools", + "iterations": 10, + "times_ns": [ + 85189, + 81162, + 79120, + 131478, + 80923, + 74943, + 73912, + 74883, + 71889, + 72850 + ], + "mean_ns": 82634.9, + "stddev_ns": 16781.51155557806, + "min_ns": 71889, + "max_ns": 131478, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_awk_print", + "category": "tools", + "iterations": 10, + "times_ns": [ + 2191258, + 2081167, + 2102624, + 2295460, + 2166852, + 2230417, + 2118308, + 2164383, + 2491431, + 2177327 + ], + "mean_ns": 2201922.7, + "stddev_ns": 113211.1583334876, + "min_ns": 2081167, + "max_ns": 2491431, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_awk_sum", + "category": "tools", + "iterations": 10, + "times_ns": [ + 130918, + 89455, + 84538, + 82905, + 81584, + 81093, + 80171, + 82304, + 78499, + 77908 + ], + "mean_ns": 86937.5, + "stddev_ns": 14984.83894641514, + "min_ns": 77908, + "max_ns": 130918, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_awk_sum", + "category": "tools", + "iterations": 10, + "times_ns": [ + 2305801, + 2249516, + 2081692, + 2291088, + 2361295, + 2276957, + 2432592, + 2172290, + 2170972, + 2077045 + ], + "mean_ns": 2241924.8, + "stddev_ns": 110058.39366518121, + "min_ns": 2077045, + "max_ns": 2432592, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_awk_pattern", + "category": "tools", + "iterations": 10, + "times_ns": [ + 146541, + 112200, + 106921, + 105579, + 104297, + 105309, + 139671, + 108634, + 101674, + 101133 + ], + "mean_ns": 113195.9, + "stddev_ns": 15335.195619554384, + "min_ns": 101133, + "max_ns": 146541, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_awk_pattern", + "category": "tools", + "iterations": 10, + "times_ns": [ + 2289396, + 2211789, + 3229386, + 2211098, + 2583469, + 2150527, + 2344334, + 2224213, + 3015603, + 2288175 + ], + "mean_ns": 2454799.0, + "stddev_ns": 355325.4469885319, + "min_ns": 2150527, + "max_ns": 3229386, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_awk_fieldsep", + "category": "tools", + "iterations": 10, + "times_ns": [ + 91268, + 80872, + 78148, + 76776, + 76125, + 77196, + 76145, + 76476, + 74572, + 73851 + ], + "mean_ns": 78142.9, + "stddev_ns": 4738.091502915493, + "min_ns": 73851, + "max_ns": 91268, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_awk_fieldsep", + "category": "tools", + "iterations": 10, + "times_ns": [ + 2326121, + 2277007, + 2243707, + 2271204, + 3064977, + 2155849, + 2188775, + 2088903, + 2251198, + 2311480 + ], + "mean_ns": 2317922.1, + "stddev_ns": 258343.33171941948, + "min_ns": 2088903, + "max_ns": 3064977, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_awk_nf", + "category": "tools", + "iterations": 10, + "times_ns": [ + 83216, + 79249, + 152880, + 90536, + 80061, + 77477, + 74783, + 73701, + 74943, + 73240 + ], + "mean_ns": 86008.6, + "stddev_ns": 22840.55647833476, + "min_ns": 73240, + "max_ns": 152880, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_awk_nf", + "category": "tools", + "iterations": 10, + "times_ns": [ + 3597360, + 2629119, + 4539783, + 5031064, + 3162305, + 2694933, + 3155954, + 2368295, + 2269446, + 2559284 + ], + "mean_ns": 3200754.3, + "stddev_ns": 886467.3056288145, + "min_ns": 2269446, + "max_ns": 5031064, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_awk_compute", + "category": "tools", + "iterations": 10, + "times_ns": [ + 87241, + 80953, + 78608, + 78189, + 129215, + 96767, + 79269, + 76265, + 200502, + 83406 + ], + "mean_ns": 99041.5, + "stddev_ns": 36990.31818260016, + "min_ns": 76265, + "max_ns": 200502, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_awk_compute", + "category": "tools", + "iterations": 10, + "times_ns": [ + 2870013, + 3287328, + 3099033, + 3000530, + 4333202, + 3205755, + 2510415, + 2766716, + 4154201, + 3784729 + ], + "mean_ns": 3301192.2, + "stddev_ns": 571310.0335094422, + "min_ns": 2510415, + "max_ns": 4333202, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_jq_identity", + "category": "tools", + "iterations": 10, + "times_ns": [ + 1059149, + 761861, + 859658, + 1052599, + 1595704, + 730242, + 749391, + 1105669, + 873258, + 760137 + ], + "mean_ns": 954766.8, + "stddev_ns": 252470.22228761952, + "min_ns": 730242, + "max_ns": 1595704, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_jq_identity", + "category": "tools", + "iterations": 10, + "times_ns": [ + 3707246, + 3827037, + 3690882, + 3653906, + 3991165, + 3939367, + 4792705, + 3326918, + 3459748, + 3377138 + ], + "mean_ns": 3776611.2, + "stddev_ns": 399616.6722249711, + "min_ns": 3326918, + "max_ns": 4792705, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_jq_field", + "category": "tools", + "iterations": 10, + "times_ns": [ + 731173, + 758164, + 731004, + 744324, + 725505, + 707949, + 751485, + 729071, + 721509, + 743743 + ], + "mean_ns": 734392.7, + "stddev_ns": 14274.805449112082, + "min_ns": 707949, + "max_ns": 758164, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_jq_field", + "category": "tools", + "iterations": 10, + "times_ns": [ + 3497731, + 3289315, + 3433568, + 3314389, + 3467405, + 3324759, + 3368405, + 3424490, + 5263286, + 3719750 + ], + "mean_ns": 3610309.8, + "stddev_ns": 563367.8974078661, + "min_ns": 3289315, + "max_ns": 5263286, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_jq_array", + "category": "tools", + "iterations": 10, + "times_ns": [ + 768680, + 795200, + 803903, + 743833, + 747458, + 742270, + 718595, + 675500, + 759767, + 777254 + ], + "mean_ns": 753246.0, + "stddev_ns": 35596.48301728698, + "min_ns": 675500, + "max_ns": 803903, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_jq_array", + "category": "tools", + "iterations": 10, + "times_ns": [ + 3405611, + 3702389, + 3652013, + 3590466, + 3444320, + 3419562, + 3405431, + 4841083, + 3418661, + 3601452 + ], + "mean_ns": 3648098.8, + "stddev_ns": 411795.5898830875, + "min_ns": 3405431, + "max_ns": 4841083, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_jq_filter", + "category": "tools", + "iterations": 10, + "times_ns": [ + 847710, + 767549, + 761289, + 812867, + 764363, + 865046, + 972859, + 814009, + 836102, + 703191 + ], + "mean_ns": 814498.5, + "stddev_ns": 70102.88754430876, + "min_ns": 703191, + "max_ns": 972859, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_jq_filter", + "category": "tools", + "iterations": 10, + "times_ns": [ + 4024936, + 3680036, + 3448416, + 3914880, + 3496683, + 3409838, + 3396222, + 3361855, + 3449858, + 3595538 + ], + "mean_ns": 3577826.2, + "stddev_ns": 217215.04262679417, + "min_ns": 3361855, + "max_ns": 4024936, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "tool_jq_map", + "category": "tools", + "iterations": 10, + "times_ns": [ + 757384, + 736542, + 720788, + 861531, + 758025, + 743132, + 733407, + 798546, + 813067, + 738314 + ], + "mean_ns": 766073.6, + "stddev_ns": 42192.60699980507, + "min_ns": 720788, + "max_ns": 861531, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "tool_jq_map", + "category": "tools", + "iterations": 10, + "times_ns": [ + 7644626, + 3655859, + 3907138, + 5322555, + 3854569, + 5399711, + 5217677, + 4002573, + 4158507, + 5104731 + ], + "mean_ns": 4826794.6, + "stddev_ns": 1141588.9666725234, + "min_ns": 3655859, + "max_ns": 7644626, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "complex_fibonacci", + "category": "complex", + "iterations": 10, + "times_ns": [ + 9053287, + 6458370, + 6077755, + 7051706, + 6051145, + 5999297, + 6493924, + 6425410, + 6031105, + 8382004 + ], + "mean_ns": 6802400.3, + "stddev_ns": 1015322.3609021964, + "min_ns": 5999297, + "max_ns": 9053287, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "complex_fibonacci", + "category": "complex", + "iterations": 10, + "times_ns": [ + 73270311, + 70747938, + 72943933, + 79399975, + 74153815, + 75438264, + 74246439, + 75051705, + 84114592, + 84352305 + ], + "mean_ns": 76371927.7, + "stddev_ns": 4448114.4181839805, + "min_ns": 70747938, + "max_ns": 84352305, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "complex_fibonacci_iter", + "category": "complex", + "iterations": 10, + "times_ns": [ + 165039, + 289788, + 140742, + 127042, + 206682, + 136346, + 124959, + 162124, + 130908, + 121974 + ], + "mean_ns": 160560.4, + "stddev_ns": 49585.98728310247, + "min_ns": 121974, + "max_ns": 289788, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "complex_fibonacci_iter", + "category": "complex", + "iterations": 10, + "times_ns": [ + 1495037, + 1429768, + 1347504, + 2249215, + 1362357, + 1198439, + 1448086, + 1398010, + 1216346, + 1301285 + ], + "mean_ns": 1444604.7, + "stddev_ns": 283156.8147546691, + "min_ns": 1198439, + "max_ns": 2249215, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "complex_nested_subst", + "category": "complex", + "iterations": 10, + "times_ns": [ + 153302, + 163927, + 150658, + 124408, + 142325, + 178410, + 158529, + 86411, + 78408, + 76896 + ], + "mean_ns": 131327.4, + "stddev_ns": 35812.7572526886, + "min_ns": 76896, + "max_ns": 178410, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "complex_nested_subst", + "category": "complex", + "iterations": 10, + "times_ns": [ + 3790878, + 2915121, + 2497295, + 2891104, + 2113750, + 2216265, + 2233401, + 2479142, + 2306772, + 2624237 + ], + "mean_ns": 2606796.5, + "stddev_ns": 472396.31402949156, + "min_ns": 2113750, + "max_ns": 3790878, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "complex_loop_compute", + "category": "complex", + "iterations": 10, + "times_ns": [ + 145199, + 121183, + 118138, + 118589, + 114874, + 115875, + 114273, + 189326, + 240403, + 153031 + ], + "mean_ns": 143089.1, + "stddev_ns": 39725.175069343626, + "min_ns": 114273, + "max_ns": 240403, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "complex_loop_compute", + "category": "complex", + "iterations": 10, + "times_ns": [ + 1195730, + 2254624, + 1224959, + 1314745, + 1720137, + 1651969, + 1708930, + 1195414, + 1370128, + 1323598 + ], + "mean_ns": 1496023.4, + "stddev_ns": 320594.775615636, + "min_ns": 1195414, + "max_ns": 2254624, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "complex_string_build", + "category": "complex", + "iterations": 10, + "times_ns": [ + 80001, + 77597, + 74623, + 106221, + 79850, + 155775, + 82174, + 73611, + 73541, + 73521 + ], + "mean_ns": 87691.4, + "stddev_ns": 24498.50175908723, + "min_ns": 73521, + "max_ns": 155775, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "complex_string_build", + "category": "complex", + "iterations": 10, + "times_ns": [ + 1505062, + 1161764, + 1155274, + 1182946, + 1179641, + 1213472, + 1104628, + 1245099, + 1170116, + 1308876 + ], + "mean_ns": 1222687.8, + "stddev_ns": 107768.94985365683, + "min_ns": 1104628, + "max_ns": 1505062, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "complex_json_transform", + "category": "complex", + "iterations": 10, + "times_ns": [ + 798095, + 836403, + 810724, + 781470, + 710152, + 730152, + 728450, + 749541, + 1094031, + 718294 + ], + "mean_ns": 795731.2, + "stddev_ns": 107402.03199269556, + "min_ns": 710152, + "max_ns": 1094031, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "complex_json_transform", + "category": "complex", + "iterations": 10, + "times_ns": [ + 4340222, + 3756436, + 3566720, + 4033139, + 4041441, + 3936823, + 4373803, + 3649860, + 3759846, + 3876437 + ], + "mean_ns": 3933472.7, + "stddev_ns": 257025.78033187645, + "min_ns": 3566720, + "max_ns": 4373803, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "complex_pipeline_text", + "category": "complex", + "iterations": 10, + "times_ns": [ + 198670, + 146571, + 158149, + 148545, + 135995, + 135124, + 134863, + 236517, + 139530, + 133371 + ], + "mean_ns": 156733.5, + "stddev_ns": 32424.604769372287, + "min_ns": 133371, + "max_ns": 236517, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "complex_pipeline_text", + "category": "complex", + "iterations": 10, + "times_ns": [ + 2596950, + 2864595, + 2749661, + 2513163, + 2832185, + 3425261, + 2952673, + 2625774, + 2663421, + 3065227 + ], + "mean_ns": 2828891.0, + "stddev_ns": 256225.3926382005, + "min_ns": 2513163, + "max_ns": 3425261, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "large_loop_1000", + "category": "large", + "iterations": 10, + "times_ns": [ + 3901149, + 4639304, + 3823612, + 4471279, + 4024106, + 4599023, + 4008732, + 4431460, + 3579714, + 4080831 + ], + "mean_ns": 4155921.0, + "stddev_ns": 340206.0802128616, + "min_ns": 3579714, + "max_ns": 4639304, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "large_loop_1000", + "category": "large", + "iterations": 10, + "times_ns": [ + 3710987, + 3449337, + 3235013, + 3057465, + 3646995, + 3420864, + 3050365, + 3136856, + 2958056, + 4723776 + ], + "mean_ns": 3438971.4, + "stddev_ns": 492862.9547598805, + "min_ns": 2958056, + "max_ns": 4723776, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "large_string_append_100", + "category": "large", + "iterations": 10, + "times_ns": [ + 445623, + 360905, + 345592, + 465973, + 353403, + 406774, + 350229, + 338912, + 470640, + 368747 + ], + "mean_ns": 390679.8, + "stddev_ns": 49443.61065456284, + "min_ns": 338912, + "max_ns": 470640, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "large_string_append_100", + "category": "large", + "iterations": 10, + "times_ns": [ + 1677613, + 1682029, + 1512153, + 1483550, + 1580656, + 2438862, + 1943103, + 1506775, + 1427025, + 2018617 + ], + "mean_ns": 1727038.3, + "stddev_ns": 301376.4579024214, + "min_ns": 1427025, + "max_ns": 2438862, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "large_array_fill_200", + "category": "large", + "iterations": 10, + "times_ns": [ + 2060290, + 1648779, + 1945347, + 1538573, + 1497961, + 2001461, + 1487025, + 2257218, + 1605864, + 1630903 + ], + "mean_ns": 1767342.1, + "stddev_ns": 259715.50616990504, + "min_ns": 1487025, + "max_ns": 2257218, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "large_array_fill_200", + "category": "large", + "iterations": 10, + "times_ns": [ + 2388496, + 1716040, + 1721789, + 1793568, + 1690662, + 1733777, + 1649521, + 1539003, + 1587848, + 1578864 + ], + "mean_ns": 1739956.8, + "stddev_ns": 229018.89553519376, + "min_ns": 1539003, + "max_ns": 2388496, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "large_nested_loops", + "category": "large", + "iterations": 10, + "times_ns": [ + 2165509, + 1839317, + 1773136, + 1989077, + 1713457, + 1621688, + 1754759, + 1628238, + 1949884, + 1713887 + ], + "mean_ns": 1814895.2, + "stddev_ns": 164350.3049305355, + "min_ns": 1621688, + "max_ns": 2165509, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "large_nested_loops", + "category": "large", + "iterations": 10, + "times_ns": [ + 2119970, + 2216576, + 2274634, + 2311159, + 2222915, + 2078618, + 2247563, + 2083795, + 2241815, + 2357809 + ], + "mean_ns": 2215485.4, + "stddev_ns": 89378.32434679003, + "min_ns": 2078618, + "max_ns": 2357809, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "large_fibonacci_12", + "category": "large", + "iterations": 10, + "times_ns": [ + 17518085, + 16890779, + 16463473, + 18374779, + 16978842, + 17573149, + 16051211, + 15996319, + 16461301, + 16638078 + ], + "mean_ns": 16894601.6, + "stddev_ns": 707448.887732704, + "min_ns": 15996319, + "max_ns": 18374779, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "large_fibonacci_12", + "category": "large", + "iterations": 10, + "times_ns": [ + 212262752, + 195673173, + 221280421, + 226968307, + 230895751, + 238641389, + 195160855, + 183879639, + 182695868, + 187944866 + ], + "mean_ns": 207540302.1, + "stddev_ns": 19890771.322722323, + "min_ns": 182695868, + "max_ns": 238641389, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "large_function_calls_500", + "category": "large", + "iterations": 10, + "times_ns": [ + 8393932, + 8920917, + 7083604, + 7202383, + 8024194, + 7074671, + 8036181, + 7582406, + 8246719, + 8330947 + ], + "mean_ns": 7889595.4, + "stddev_ns": 596679.3439738299, + "min_ns": 7074671, + "max_ns": 8920917, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "large_function_calls_500", + "category": "large", + "iterations": 10, + "times_ns": [ + 146318746, + 159277224, + 158677849, + 152659498, + 144324115, + 150804584, + 171232013, + 144029448, + 165612206, + 160225919 + ], + "mean_ns": 155316160.2, + "stddev_ns": 8757854.789050842, + "min_ns": 144029448, + "max_ns": 171232013, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "large_multiline_script", + "category": "large", + "iterations": 10, + "times_ns": [ + 403850, + 416900, + 388496, + 467926, + 399163, + 386193, + 407445, + 385421, + 408387, + 491112 + ], + "mean_ns": 415489.3, + "stddev_ns": 33880.05201309467, + "min_ns": 385421, + "max_ns": 491112, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "large_multiline_script", + "category": "large", + "iterations": 10, + "times_ns": [ + 1324079, + 1300253, + 1285952, + 1357590, + 1310989, + 1291339, + 1328877, + 1506714, + 1409908, + 1422378 + ], + "mean_ns": 1353807.9, + "stddev_ns": 67791.87926654637, + "min_ns": 1285952, + "max_ns": 1506714, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "large_pipeline_chain", + "category": "large", + "iterations": 10, + "times_ns": [ + 1068313, + 1186411, + 988613, + 1238410, + 1186591, + 1003284, + 947160, + 1008803, + 950525, + 1042905 + ], + "mean_ns": 1062101.5, + "stddev_ns": 99901.90925728096, + "min_ns": 947160, + "max_ns": 1238410, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "large_pipeline_chain", + "category": "large", + "iterations": 10, + "times_ns": [ + 2812025, + 2959749, + 3025698, + 2838174, + 2824635, + 2723011, + 2653987, + 2749070, + 2703281, + 2658223 + ], + "mean_ns": 2794785.3, + "stddev_ns": 117342.39951956837, + "min_ns": 2653987, + "max_ns": 3025698, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "large_assoc_array", + "category": "large", + "iterations": 10, + "times_ns": [ + 78008, + 73371, + 75274, + 72679, + 71057, + 71708, + 98870, + 77917, + 72239, + 71538 + ], + "mean_ns": 76266.1, + "stddev_ns": 7908.303211814782, + "min_ns": 71057, + "max_ns": 98870, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "large_assoc_array", + "category": "large", + "iterations": 10, + "times_ns": [ + 1095564, + 1101433, + 1103817, + 1230578, + 1322536, + 1223487, + 1207583, + 1281184, + 1262155, + 1151478 + ], + "mean_ns": 1197981.5, + "stddev_ns": 76941.83157067421, + "min_ns": 1095564, + "max_ns": 1322536, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "subshell_simple", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 63155, + 59139, + 58198, + 59300, + 120862, + 65679, + 57758, + 55213, + 54322, + 56455 + ], + "mean_ns": 65008.1, + "stddev_ns": 18901.863984538668, + "min_ns": 54322, + "max_ns": 120862, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "subshell_simple", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 1377019, + 1423709, + 1294524, + 1353604, + 1527336, + 1426053, + 1275846, + 1360614, + 1381876, + 1340604 + ], + "mean_ns": 1376118.5, + "stddev_ns": 68102.7243364757, + "min_ns": 1275846, + "max_ns": 1527336, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "subshell_isolation", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 73200, + 75114, + 70717, + 68874, + 71027, + 70065, + 68153, + 68905, + 66871, + 67432 + ], + "mean_ns": 70035.8, + "stddev_ns": 2453.9111149346872, + "min_ns": 66871, + "max_ns": 75114, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "subshell_isolation", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 1362597, + 1389628, + 1474256, + 1551187, + 1460149, + 1477620, + 1404380, + 1529499, + 1335536, + 1328837 + ], + "mean_ns": 1431368.9, + "stddev_ns": 74625.51057976086, + "min_ns": 1328837, + "max_ns": 1551187, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "subshell_nested", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 93271, + 89305, + 90587, + 86470, + 118650, + 92330, + 86300, + 88133, + 87322, + 85589 + ], + "mean_ns": 91795.7, + "stddev_ns": 9283.308914928986, + "min_ns": 85589, + "max_ns": 118650, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "subshell_nested", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 2406453, + 2347614, + 2842807, + 2793026, + 2796241, + 2821379, + 2840548, + 2567436, + 2658985, + 3101522 + ], + "mean_ns": 2717601.1, + "stddev_ns": 214597.23105131157, + "min_ns": 2347614, + "max_ns": 3101522, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "subshell_pipeline", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 116345, + 95765, + 105239, + 93922, + 133071, + 144518, + 359262, + 94202, + 107042, + 107863 + ], + "mean_ns": 135722.9, + "stddev_ns": 76174.52917931296, + "min_ns": 93922, + "max_ns": 359262, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "subshell_pipeline", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 2512252, + 2219510, + 2263958, + 2333072, + 2140742, + 2173010, + 2098838, + 2209505, + 2070526, + 2059770 + ], + "mean_ns": 2208118.3, + "stddev_ns": 130389.30251830477, + "min_ns": 2059770, + "max_ns": 2512252, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "subshell_capture_loop", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 145159, + 130247, + 125149, + 122495, + 122605, + 146301, + 126871, + 168685, + 123356, + 120302 + ], + "mean_ns": 133117.0, + "stddev_ns": 14756.283603943102, + "min_ns": 120302, + "max_ns": 168685, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "subshell_capture_loop", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 2661388, + 2592474, + 2544261, + 3369968, + 2557661, + 5643130, + 2603821, + 2492453, + 2983664, + 2529079 + ], + "mean_ns": 2997789.9, + "stddev_ns": 918893.6955466013, + "min_ns": 2492453, + "max_ns": 5643130, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "subshell_process_subst", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 95544, + 91298, + 118228, + 91508, + 89144, + 89335, + 89255, + 88313, + 87332, + 85319 + ], + "mean_ns": 92527.6, + "stddev_ns": 8951.162552428594, + "min_ns": 85319, + "max_ns": 118228, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "subshell_process_subst", + "category": "subshell", + "iterations": 10, + "times_ns": [ + 1872617, + 1836157, + 2024000, + 2006724, + 1920800, + 1700492, + 2276397, + 1859067, + 1746647, + 1747248 + ], + "mean_ns": 1899014.9, + "stddev_ns": 161869.9093620862, + "min_ns": 1700492, + "max_ns": 2276397, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "io_redirect_write", + "category": "io", + "iterations": 10, + "times_ns": [ + 129255, + 101914, + 98368, + 96605, + 124719, + 96666, + 97217, + 118379, + 98719, + 118509 + ], + "mean_ns": 108035.1, + "stddev_ns": 12409.596443478731, + "min_ns": 96605, + "max_ns": 129255, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "io_redirect_write", + "category": "io", + "iterations": 10, + "times_ns": [ + 3015828, + 2781083, + 2842811, + 3047790, + 2856087, + 2777663, + 2982062, + 2662820, + 2987430, + 2980490 + ], + "mean_ns": 2893406.4, + "stddev_ns": 120930.12489301414, + "min_ns": 2662820, + "max_ns": 3047790, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "io_append", + "category": "io", + "iterations": 10, + "times_ns": [ + 319808, + 151749, + 128975, + 122194, + 119651, + 154383, + 139551, + 118760, + 118839, + 192121 + ], + "mean_ns": 156603.1, + "stddev_ns": 58640.94907391933, + "min_ns": 118760, + "max_ns": 319808, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "io_append", + "category": "io", + "iterations": 10, + "times_ns": [ + 2890995, + 3051041, + 2715830, + 2836371, + 2815601, + 2678183, + 3874664, + 2640035, + 2958376, + 2840689 + ], + "mean_ns": 2930178.5, + "stddev_ns": 336534.9212982956, + "min_ns": 2640035, + "max_ns": 3874664, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "io_dev_null", + "category": "io", + "iterations": 10, + "times_ns": [ + 64177, + 58929, + 59159, + 57918, + 57577, + 59210, + 86591, + 58949, + 55654, + 56846 + ], + "mean_ns": 61501.0, + "stddev_ns": 8629.152380158783, + "min_ns": 55654, + "max_ns": 86591, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "io_dev_null", + "category": "io", + "iterations": 10, + "times_ns": [ + 1102224, + 1201168, + 1159000, + 1069555, + 1040661, + 1086410, + 1042173, + 1077377, + 1313583, + 1115825 + ], + "mean_ns": 1120797.6, + "stddev_ns": 79844.91365165348, + "min_ns": 1040661, + "max_ns": 1313583, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "io_stderr_redirect", + "category": "io", + "iterations": 10, + "times_ns": [ + 63035, + 60171, + 57957, + 56886, + 76856, + 60632, + 56745, + 58098, + 58489, + 57837 + ], + "mean_ns": 60670.6, + "stddev_ns": 5694.846322772899, + "min_ns": 56745, + "max_ns": 76856, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "io_stderr_redirect", + "category": "io", + "iterations": 10, + "times_ns": [ + 1187493, + 1100692, + 1100682, + 1189646, + 1146902, + 1148744, + 1155134, + 1077787, + 1112790, + 1101663 + ], + "mean_ns": 1132153.3, + "stddev_ns": 36917.919061209286, + "min_ns": 1077787, + "max_ns": 1189646, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "io_read_lines", + "category": "io", + "iterations": 10, + "times_ns": [ + 139982, + 122725, + 111969, + 111388, + 110407, + 109205, + 228415, + 130828, + 110747, + 109936 + ], + "mean_ns": 128560.2, + "stddev_ns": 34755.02381181748, + "min_ns": 109205, + "max_ns": 228415, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "io_read_lines", + "category": "io", + "iterations": 10, + "times_ns": [ + 1168003, + 1163316, + 1208785, + 1155535, + 1220212, + 1140401, + 1153371, + 1257458, + 1333363, + 1190417 + ], + "mean_ns": 1199086.1, + "stddev_ns": 56328.73067174512, + "min_ns": 1140401, + "max_ns": 1333363, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bashkit", + "case_name": "io_multiline_heredoc", + "category": "io", + "iterations": 10, + "times_ns": [ + 84838, + 80201, + 78268, + 77467, + 80341, + 81282, + 104769, + 84778, + 76615, + 154103 + ], + "mean_ns": 90266.2, + "stddev_ns": 22626.79654215329, + "min_ns": 76615, + "max_ns": 154103, + "errors": 0, + "error_messages": [], + "output_match": true + }, + { + "runner": "bash", + "case_name": "io_multiline_heredoc", + "category": "io", + "iterations": 10, + "times_ns": [ + 2962012, + 2452493, + 2729075, + 2706576, + 2650412, + 2386543, + 2706616, + 2734558, + 2675009, + 2683046 + ], + "mean_ns": 2668634.0, + "stddev_ns": 149607.69319924695, + "min_ns": 2386543, + "max_ns": 2962012, + "errors": 0, + "error_messages": [], + "output_match": true + } + ], + "summary": { + "total_cases": 96, + "runner_stats": { + "bashkit": { + "total_time_ms": 53.124428699999996, + "avg_time_ms": 0.553379465625, + "error_count": 0, + "error_rate": 0.0, + "output_match_rate": 1.0 + }, + "bash": { + "total_time_ms": 617.3108319, + "avg_time_ms": 6.430321165625, + "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-79f1c12bcc44-linux-x86_64-1792175675.md b/crates/bashkit-bench/results/bench-79f1c12bcc44-linux-x86_64-1792175675.md new file mode 100644 index 000000000..e73f66d6c --- /dev/null +++ b/crates/bashkit-bench/results/bench-79f1c12bcc44-linux-x86_64-1792175675.md @@ -0,0 +1,307 @@ +# Bashkit Benchmark Report + +## System Information + +- **Moniker**: `79f1c12bcc44-linux-x86_64` +- **Hostname**: 79f1c12bcc44 +- **OS**: linux +- **Architecture**: x86_64 +- **CPUs**: 4 +- **Timestamp**: 1792175675 +- **Iterations**: 10 +- **Warmup**: 2 +- **Prewarm cases**: 3 + +- **bash executable**: `/usr/bin/bash (GNU Bash 5.2.37(1)-release)` + +## Summary + +Benchmarked 96 cases across 2 runners. + +| Runner | Total Time (ms) | Avg/Case (ms) | Errors | Error Rate | Output Match | +|--------|-----------------|---------------|--------|------------|-------------| +| bashkit | 53.12 | 0.553 | 0 | 0.0% | 100.0% | +| bash | 617.31 | 6.430 | 0 | 0.0% | 100.0% | + +## Performance Comparison + +**Bashkit is 11.6x faster** than bash on average. + +## Results by Category + +### Arithmetic + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| arith_basic | bashkit | 0.066 | ±0.020 | - | ✓ | +| arith_basic | bash | 1.230 | ±0.194 | - | ✓ | +| arith_complex | bashkit | 0.056 | ±0.002 | - | ✓ | +| arith_complex | bash | 1.195 | ±0.167 | - | ✓ | +| arith_variables | bashkit | 0.075 | ±0.028 | - | ✓ | +| arith_variables | bash | 1.497 | ±0.339 | - | ✓ | +| arith_increment | bashkit | 0.071 | ±0.012 | - | ✓ | +| arith_increment | bash | 1.307 | ±0.170 | - | ✓ | +| arith_modulo | bashkit | 0.054 | ±0.001 | - | ✓ | +| arith_modulo | bash | 1.212 | ±0.286 | - | ✓ | +| arith_loop_sum | bashkit | 0.102 | ±0.009 | - | ✓ | +| arith_loop_sum | bash | 1.248 | ±0.115 | - | ✓ | + +### Arrays + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| arr_create | bashkit | 0.076 | ±0.032 | - | ✓ | +| arr_create | bash | 1.198 | ±0.074 | - | ✓ | +| arr_all | bashkit | 0.076 | ±0.025 | - | ✓ | +| arr_all | bash | 1.189 | ±0.068 | - | ✓ | +| arr_length | bashkit | 0.070 | ±0.016 | - | ✓ | +| arr_length | bash | 1.168 | ±0.060 | - | ✓ | +| arr_iterate | bashkit | 0.098 | ±0.038 | - | ✓ | +| arr_iterate | bash | 1.176 | ±0.081 | - | ✓ | +| arr_slice | bashkit | 0.068 | ±0.010 | - | ✓ | +| arr_slice | bash | 1.166 | ±0.053 | - | ✓ | +| arr_assign_index | bashkit | 0.072 | ±0.020 | - | ✓ | +| arr_assign_index | bash | 1.170 | ±0.067 | - | ✓ | + +### Complex + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| complex_fibonacci | bashkit | 6.802 | ±1.015 | - | ✓ | +| complex_fibonacci | bash | 76.372 | ±4.448 | - | ✓ | +| complex_fibonacci_iter | bashkit | 0.161 | ±0.050 | - | ✓ | +| complex_fibonacci_iter | bash | 1.445 | ±0.283 | - | ✓ | +| complex_nested_subst | bashkit | 0.131 | ±0.036 | - | ✓ | +| complex_nested_subst | bash | 2.607 | ±0.472 | - | ✓ | +| complex_loop_compute | bashkit | 0.143 | ±0.040 | - | ✓ | +| complex_loop_compute | bash | 1.496 | ±0.321 | - | ✓ | +| complex_string_build | bashkit | 0.088 | ±0.024 | - | ✓ | +| complex_string_build | bash | 1.223 | ±0.108 | - | ✓ | +| complex_json_transform | bashkit | 0.796 | ±0.107 | - | ✓ | +| complex_json_transform | bash | 3.933 | ±0.257 | - | ✓ | +| complex_pipeline_text | bashkit | 0.157 | ±0.032 | - | ✓ | +| complex_pipeline_text | bash | 2.829 | ±0.256 | - | ✓ | + +### Control + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| ctrl_if_simple | bashkit | 0.067 | ±0.020 | - | ✓ | +| ctrl_if_simple | bash | 1.208 | ±0.065 | - | ✓ | +| ctrl_if_else | bashkit | 0.068 | ±0.016 | - | ✓ | +| ctrl_if_else | bash | 1.150 | ±0.099 | - | ✓ | +| ctrl_for_list | bashkit | 0.084 | ±0.009 | - | ✓ | +| ctrl_for_list | bash | 1.199 | ±0.093 | - | ✓ | +| ctrl_for_range | bashkit | 0.100 | ±0.025 | - | ✓ | +| ctrl_for_range | bash | 1.217 | ±0.137 | - | ✓ | +| ctrl_while | bashkit | 0.132 | ±0.012 | - | ✓ | +| ctrl_while | bash | 1.238 | ±0.120 | - | ✓ | +| ctrl_case | bashkit | 0.066 | ±0.003 | - | ✓ | +| ctrl_case | bash | 1.496 | ±0.451 | - | ✓ | +| ctrl_function | bashkit | 0.099 | ±0.075 | - | ✓ | +| ctrl_function | bash | 1.132 | ±0.053 | - | ✓ | +| ctrl_function_return | bashkit | 0.096 | ±0.023 | - | ✓ | +| ctrl_function_return | bash | 1.638 | ±0.153 | - | ✓ | +| ctrl_nested_loops | bashkit | 0.105 | ±0.006 | - | ✓ | +| ctrl_nested_loops | bash | 1.335 | ±0.184 | - | ✓ | + +### Io + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| io_redirect_write | bashkit | 0.108 | ±0.012 | - | ✓ | +| io_redirect_write | bash | 2.893 | ±0.121 | - | ✓ | +| io_append | bashkit | 0.157 | ±0.059 | - | ✓ | +| io_append | bash | 2.930 | ±0.337 | - | ✓ | +| io_dev_null | bashkit | 0.062 | ±0.009 | - | ✓ | +| io_dev_null | bash | 1.121 | ±0.080 | - | ✓ | +| io_stderr_redirect | bashkit | 0.061 | ±0.006 | - | ✓ | +| io_stderr_redirect | bash | 1.132 | ±0.037 | - | ✓ | +| io_read_lines | bashkit | 0.129 | ±0.035 | - | ✓ | +| io_read_lines | bash | 1.199 | ±0.056 | - | ✓ | +| io_multiline_heredoc | bashkit | 0.090 | ±0.023 | - | ✓ | +| io_multiline_heredoc | bash | 2.669 | ±0.150 | - | ✓ | + +### Large + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| large_loop_1000 | bashkit | 4.156 | ±0.340 | - | ✓ | +| large_loop_1000 | bash | 3.439 | ±0.493 | - | ✓ | +| large_string_append_100 | bashkit | 0.391 | ±0.049 | - | ✓ | +| large_string_append_100 | bash | 1.727 | ±0.301 | - | ✓ | +| large_array_fill_200 | bashkit | 1.767 | ±0.260 | - | ✓ | +| large_array_fill_200 | bash | 1.740 | ±0.229 | - | ✓ | +| large_nested_loops | bashkit | 1.815 | ±0.164 | - | ✓ | +| large_nested_loops | bash | 2.215 | ±0.089 | - | ✓ | +| large_fibonacci_12 | bashkit | 16.895 | ±0.707 | - | ✓ | +| large_fibonacci_12 | bash | 207.540 | ±19.891 | - | ✓ | +| large_function_calls_500 | bashkit | 7.890 | ±0.597 | - | ✓ | +| large_function_calls_500 | bash | 155.316 | ±8.758 | - | ✓ | +| large_multiline_script | bashkit | 0.415 | ±0.034 | - | ✓ | +| large_multiline_script | bash | 1.354 | ±0.068 | - | ✓ | +| large_pipeline_chain | bashkit | 1.062 | ±0.100 | - | ✓ | +| large_pipeline_chain | bash | 2.795 | ±0.117 | - | ✓ | +| large_assoc_array | bashkit | 0.076 | ±0.008 | - | ✓ | +| large_assoc_array | bash | 1.198 | ±0.077 | - | ✓ | + +### Pipes + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| pipe_simple | bashkit | 0.094 | ±0.031 | - | ✓ | +| pipe_simple | bash | 2.281 | ±0.447 | - | ✓ | +| pipe_multi | bashkit | 0.147 | ±0.010 | - | ✓ | +| pipe_multi | bash | 2.607 | ±0.211 | - | ✓ | +| pipe_command_subst | bashkit | 0.107 | ±0.037 | - | ✓ | +| pipe_command_subst | bash | 1.479 | ±0.097 | - | ✓ | +| pipe_heredoc | bashkit | 0.068 | ±0.009 | - | ✓ | +| pipe_heredoc | bash | 2.079 | ±0.171 | - | ✓ | +| pipe_herestring | bashkit | 0.095 | ±0.057 | - | ✓ | +| pipe_herestring | bash | 2.121 | ±0.102 | - | ✓ | +| pipe_discard | bashkit | 0.066 | ±0.003 | - | ✓ | +| pipe_discard | bash | 1.610 | ±0.144 | - | ✓ | + +### Startup + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| startup_empty | bashkit | 0.052 | ±0.005 | - | ✓ | +| startup_empty | bash | 1.233 | ±0.090 | - | ✓ | +| startup_true | bashkit | 0.056 | ±0.008 | - | ✓ | +| startup_true | bash | 1.291 | ±0.366 | - | ✓ | +| startup_echo | bashkit | 0.052 | ±0.002 | - | ✓ | +| startup_echo | bash | 1.157 | ±0.081 | - | ✓ | +| startup_exit | bashkit | 0.055 | ±0.005 | - | ✓ | +| startup_exit | bash | 1.185 | ±0.099 | - | ✓ | + +### Strings + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| str_concat | bashkit | 0.090 | ±0.069 | - | ✓ | +| str_concat | bash | 1.353 | ±0.114 | - | ✓ | +| str_printf | bashkit | 0.057 | ±0.002 | - | ✓ | +| str_printf | bash | 1.255 | ±0.118 | - | ✓ | +| str_printf_pad | bashkit | 0.066 | ±0.014 | - | ✓ | +| str_printf_pad | bash | 1.247 | ±0.135 | - | ✓ | +| str_echo_escape | bashkit | 0.059 | ±0.013 | - | ✓ | +| str_echo_escape | bash | 1.196 | ±0.077 | - | ✓ | +| str_prefix_strip | bashkit | 0.066 | ±0.014 | - | ✓ | +| str_prefix_strip | bash | 1.369 | ±0.335 | - | ✓ | +| str_suffix_strip | bashkit | 0.065 | ±0.009 | - | ✓ | +| str_suffix_strip | bash | 1.294 | ±0.164 | - | ✓ | +| str_uppercase | bashkit | 0.065 | ±0.018 | - | ✓ | +| str_uppercase | bash | 1.205 | ±0.082 | - | ✓ | +| str_lowercase | bashkit | 0.066 | ±0.012 | - | ✓ | +| str_lowercase | bash | 1.181 | ±0.139 | - | ✓ | + +### Subshell + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| subshell_simple | bashkit | 0.065 | ±0.019 | - | ✓ | +| subshell_simple | bash | 1.376 | ±0.068 | - | ✓ | +| subshell_isolation | bashkit | 0.070 | ±0.002 | - | ✓ | +| subshell_isolation | bash | 1.431 | ±0.075 | - | ✓ | +| subshell_nested | bashkit | 0.092 | ±0.009 | - | ✓ | +| subshell_nested | bash | 2.718 | ±0.215 | - | ✓ | +| subshell_pipeline | bashkit | 0.136 | ±0.076 | - | ✓ | +| subshell_pipeline | bash | 2.208 | ±0.130 | - | ✓ | +| subshell_capture_loop | bashkit | 0.133 | ±0.015 | - | ✓ | +| subshell_capture_loop | bash | 2.998 | ±0.919 | - | ✓ | +| subshell_process_subst | bashkit | 0.093 | ±0.009 | - | ✓ | +| subshell_process_subst | bash | 1.899 | ±0.162 | - | ✓ | + +### Tools + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| tool_grep_simple | bashkit | 0.090 | ±0.017 | - | ✓ | +| tool_grep_simple | bash | 2.386 | ±0.094 | - | ✓ | +| tool_grep_case | bashkit | 0.150 | ±0.009 | - | ✓ | +| tool_grep_case | bash | 2.380 | ±0.088 | - | ✓ | +| tool_grep_count | bashkit | 0.087 | ±0.020 | - | ✓ | +| tool_grep_count | bash | 2.401 | ±0.121 | - | ✓ | +| tool_grep_invert | bashkit | 0.084 | ±0.013 | - | ✓ | +| tool_grep_invert | bash | 2.468 | ±0.182 | - | ✓ | +| tool_grep_regex | bashkit | 0.122 | ±0.034 | - | ✓ | +| tool_grep_regex | bash | 2.848 | ±0.748 | - | ✓ | +| tool_sed_replace | bashkit | 0.106 | ±0.068 | - | ✓ | +| tool_sed_replace | bash | 2.607 | ±0.163 | - | ✓ | +| tool_sed_global | bashkit | 0.078 | ±0.010 | - | ✓ | +| tool_sed_global | bash | 2.469 | ±0.094 | - | ✓ | +| tool_sed_delete | bashkit | 0.081 | ±0.014 | - | ✓ | +| tool_sed_delete | bash | 3.380 | ±1.051 | - | ✓ | +| tool_sed_lines | bashkit | 0.072 | ±0.008 | - | ✓ | +| tool_sed_lines | bash | 3.860 | ±3.517 | - | ✓ | +| tool_sed_backrefs | bashkit | 0.162 | ±0.065 | - | ✓ | +| tool_sed_backrefs | bash | 2.483 | ±0.122 | - | ✓ | +| tool_awk_print | bashkit | 0.083 | ±0.017 | - | ✓ | +| tool_awk_print | bash | 2.202 | ±0.113 | - | ✓ | +| tool_awk_sum | bashkit | 0.087 | ±0.015 | - | ✓ | +| tool_awk_sum | bash | 2.242 | ±0.110 | - | ✓ | +| tool_awk_pattern | bashkit | 0.113 | ±0.015 | - | ✓ | +| tool_awk_pattern | bash | 2.455 | ±0.355 | - | ✓ | +| tool_awk_fieldsep | bashkit | 0.078 | ±0.005 | - | ✓ | +| tool_awk_fieldsep | bash | 2.318 | ±0.258 | - | ✓ | +| tool_awk_nf | bashkit | 0.086 | ±0.023 | - | ✓ | +| tool_awk_nf | bash | 3.201 | ±0.886 | - | ✓ | +| tool_awk_compute | bashkit | 0.099 | ±0.037 | - | ✓ | +| tool_awk_compute | bash | 3.301 | ±0.571 | - | ✓ | +| tool_jq_identity | bashkit | 0.955 | ±0.252 | - | ✓ | +| tool_jq_identity | bash | 3.777 | ±0.400 | - | ✓ | +| tool_jq_field | bashkit | 0.734 | ±0.014 | - | ✓ | +| tool_jq_field | bash | 3.610 | ±0.563 | - | ✓ | +| tool_jq_array | bashkit | 0.753 | ±0.036 | - | ✓ | +| tool_jq_array | bash | 3.648 | ±0.412 | - | ✓ | +| tool_jq_filter | bashkit | 0.814 | ±0.070 | - | ✓ | +| tool_jq_filter | bash | 3.578 | ±0.217 | - | ✓ | +| tool_jq_map | bashkit | 0.766 | ±0.042 | - | ✓ | +| tool_jq_map | bash | 4.827 | ±1.142 | - | ✓ | + +### Variables + +| Benchmark | Runner | Mean (ms) | StdDev | Errors | Match | +|-----------|--------|-----------|--------|--------|-------| +| var_assign_simple | bashkit | 0.059 | ±0.008 | - | ✓ | +| var_assign_simple | bash | 1.168 | ±0.111 | - | ✓ | +| var_assign_many | bashkit | 0.092 | ±0.030 | - | ✓ | +| var_assign_many | bash | 1.155 | ±0.062 | - | ✓ | +| var_default | bashkit | 0.069 | ±0.023 | - | ✓ | +| var_default | bash | 1.222 | ±0.219 | - | ✓ | +| var_length | bashkit | 0.057 | ±0.002 | - | ✓ | +| var_length | bash | 1.183 | ±0.108 | - | ✓ | +| var_substring | bashkit | 0.065 | ±0.010 | - | ✓ | +| var_substring | bash | 1.272 | ±0.214 | - | ✓ | +| var_replace | bashkit | 0.076 | ±0.029 | - | ✓ | +| var_replace | bash | 1.223 | ±0.128 | - | ✓ | +| var_nested | bashkit | 0.061 | ±0.002 | - | ✓ | +| var_nested | bash | 1.183 | ±0.118 | - | ✓ | +| var_export | bashkit | 0.077 | ±0.043 | - | ✓ | +| var_export | bash | 1.154 | ±0.072 | - | ✓ | + +## 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 | PATH-selected Bash >=4, 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-capi/examples/streaming.c b/crates/bashkit-capi/examples/streaming.c new file mode 100644 index 000000000..2ecb08ad4 --- /dev/null +++ b/crates/bashkit-capi/examples/streaming.c @@ -0,0 +1,32 @@ +/* Live output is borrowed only for the duration of this callback. */ +#include "bashkit.h" +#include +#include + +struct Sink { size_t stdout_bytes; size_t stderr_bytes; }; +static uint32_t output(void *context, uint32_t stream, BashkitBytes bytes) { + struct Sink *sink = (struct Sink *)context; + if (stream == BASHKIT_STREAM_STDOUT) sink->stdout_bytes += bytes.len; + else if (stream == BASHKIT_STREAM_STDERR) sink->stderr_bytes += bytes.len; + else return 1; + return 0; +} + +int main(void) { + Bashkit *bash = NULL; + BashkitError *error = NULL; + BashkitResult *result = NULL; + struct Sink sink = {0, 0}; + assert(bashkit_create_default(&bash, &error) == BASHKIT_OK); + const char script[] = "printf 'hello'; printf 'error' >&2"; + BashkitBytes input = {(const uint8_t *)script, sizeof(script) - 1}; + assert(bashkit_execute_streaming(bash, input, 3, output, &sink, + &result, &error) == BASHKIT_OK); + assert(sink.stdout_bytes == 5 && sink.stderr_bytes == 5); + BashkitBytes captured = bashkit_result_stdout(result); + assert(captured.len == 3 && memcmp(captured.ptr, "hel", 3) == 0); + assert(bashkit_result_flags(result) & BASHKIT_RESULT_STDOUT_TRUNCATED); + bashkit_result_free(result); + bashkit_free(bash); + return 0; +} diff --git a/crates/bashkit-capi/include/bashkit.def b/crates/bashkit-capi/include/bashkit.def index 98b4c1c74..fb7b37f46 100644 --- a/crates/bashkit-capi/include/bashkit.def +++ b/crates/bashkit-capi/include/bashkit.def @@ -10,6 +10,9 @@ EXPORTS bashkit_error_free bashkit_error_message bashkit_execute + bashkit_execute_streaming + bashkit_cancel + bashkit_clear_cancel bashkit_free bashkit_mkdir bashkit_mount diff --git a/crates/bashkit-capi/include/bashkit.h b/crates/bashkit-capi/include/bashkit.h index 788657b87..3e226cffe 100644 --- a/crates/bashkit-capi/include/bashkit.h +++ b/crates/bashkit-capi/include/bashkit.h @@ -70,7 +70,8 @@ BASHKIT_API BashkitStatus bashkit_execute( typescript_external_functions arrays. Names require an enabled runtime and this constructor's non-NULL handler. One dispatcher receives both languages. - handler runs on bashkit_execute's caller thread and must return promptly. + handler runs on the execution caller thread (buffered or streaming) and must + return promptly. It receives OWNERSHIP of call: respond/fail and free it once, or transfer it to another thread and return. Request JSON contains language, name, args, and kwargs for Python. JSON values are bounded to 64 nesting levels, finite @@ -101,6 +102,21 @@ BASHKIT_API BashkitStatus bashkit_runtime_call_respond_json( BASHKIT_API BashkitStatus bashkit_runtime_call_fail(BashkitRuntimeCall *call); BASHKIT_API void bashkit_runtime_call_free(BashkitRuntimeCall *call); +/* Live byte delivery. The callback borrows bytes only for its duration and + returns 0 after consuming the whole chunk, nonzero to fail execution. + It runs on the thread calling bashkit_execute_streaming. It may call + bashkit_cancel, but must not free the instance or call blocking APIs on any + handle from the callback. + context and callback must remain valid until this function returns. + capture_limit is per stream; 0 disables capture. NULL callback discards output. */ +#define BASHKIT_STREAM_STDOUT 1u +#define BASHKIT_STREAM_STDERR 2u +typedef uint32_t (*BashkitOutputCallback)(void *context, uint32_t stream, BashkitBytes bytes); +BASHKIT_API BashkitStatus bashkit_execute_streaming( + Bashkit *bash, BashkitBytes script, size_t capture_limit, + BashkitOutputCallback callback, void *context, + BashkitResult **out_result, BashkitError **out_error); + /* Cancellation requires the `cancellation` capability. Both are lock-free and safe to call from any thread while bashkit_execute is blocked on the same handle; execution aborts at the next command boundary with diff --git a/crates/bashkit-capi/src/lib.rs b/crates/bashkit-capi/src/lib.rs index 98ddd70f1..ff232e1d8 100644 --- a/crates/bashkit-capi/src/lib.rs +++ b/crates/bashkit-capi/src/lib.rs @@ -13,7 +13,9 @@ use bashkit::{ RealFsMode, }; use serde::Deserialize; +use std::cell::RefCell; use std::collections::{BTreeMap, HashMap}; +use std::ffi::c_void; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::{Path, PathBuf}; use std::ptr; @@ -31,7 +33,7 @@ pub const BASHKIT_RESULT_STDERR_TRUNCATED: u32 = 1 << 1; const MAX_ERROR_BYTES: usize = 1024; const CAPABILITIES_JSON: &[u8] = - br#"{"abi":1,"features":["git","jq","vfs","realfs-mounts","cancellation","python","typescript","runtime-host-functions"]}"#; + br#"{"abi":1,"features":["git","jq","vfs","realfs-mounts","cancellation","python","typescript","runtime-host-functions","streaming-output"]}"#; const VERSION: &[u8] = env!("CARGO_PKG_VERSION").as_bytes(); #[repr(u32)] @@ -484,10 +486,37 @@ unsafe fn input_str<'a>(value: BashkitBytes, name: &str) -> Result<&'a str, ApiF }) } +thread_local! { + static OUTPUT_CALLBACK_HANDLES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +struct OutputCallbackGuard(usize); +impl OutputCallbackGuard { + fn enter(handle: usize) -> Self { + OUTPUT_CALLBACK_HANDLES.with(|handles| handles.borrow_mut().push(handle)); + Self(handle) + } +} +impl Drop for OutputCallbackGuard { + fn drop(&mut self) { + OUTPUT_CALLBACK_HANDLES.with(|handles| handles.borrow_mut().retain(|h| *h != self.0)); + } +} + +/// A synchronous byte sink, invoked on the thread driving execution. Return +/// zero after consuming the whole borrowed chunk, nonzero to abort delivery. +/// The context pointer and callback must remain valid until execute returns. +pub type BashkitOutputCallback = unsafe extern "C" fn(*mut c_void, u32, BashkitBytes) -> u32; + unsafe fn handle<'a>(bash: *mut Bashkit) -> Result<&'a Bashkit, ApiFailure> { if bash.is_null() { return Err(ApiFailure::invalid_argument("bash must not be NULL")); } + if OUTPUT_CALLBACK_HANDLES.with(|handles| handles.borrow().contains(&(bash as usize))) { + return Err(ApiFailure::invalid_argument( + "output callback cannot re-enter its bash instance", + )); + } Ok(unsafe { &*bash }) } @@ -689,6 +718,86 @@ pub unsafe extern "C" fn bashkit_execute( } } +/// Execute with live byte delivery and optional bounded final capture. +/// `capture_limit == 0` disables capture. A NULL callback discards live output. +/// +/// # Safety +/// Handles and borrowed views follow `bashkit_execute`. `callback` and `context` +/// remain valid for this entire call. The callback must not free or re-enter +/// the executing instance; `bashkit_cancel` is the sole allowed handle operation. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn bashkit_execute_streaming( + bash: *mut Bashkit, + script: BashkitBytes, + capture_limit: usize, + callback: Option, + context: *mut c_void, + out_result: *mut *mut BashkitResult, + out_error: *mut *mut BashkitError, +) -> BashkitStatus { + unsafe { + ffi_boundary(out_error, || { + let out_result = output_slot(out_result, "out_result")?; + let handle_id = bash as usize; + let instance = handle(bash)?; + let mut state = lock_state(instance)?; + if script.len > state.max_input_bytes { + return Err(ApiFailure::from_bash(BashError::ResourceLimit( + LimitExceeded::InputTooLarge(script.len, state.max_input_bytes), + ))); + } + let script = input_str(script, "script")?; + let context = context as usize; + let State { runtime, bash, .. } = &mut *state; + let mut execution = bash.execute(script); + if capture_limit > 0 { + execution = execution.capture(capture_limit); + } + if let Some(callback) = callback { + let emit = move |stream, bytes: Vec| async move { + let _guard = OutputCallbackGuard::enter(handle_id); + // Output callbacks obey the same no-nested-runtime rule + // as embedded host functions, including other handles. + let _callback = runtime_functions::enter_callback(); + let status = callback( + context as *mut c_void, + stream, + BashkitBytes::borrowed(&bytes), + ); + if status == 0 { + Ok(()) + } else { + Err(BashError::Io(std::io::Error::other( + "output callback failed", + ))) + } + }; + execution = execution + .stdout_fn(move |bytes| emit(1, bytes)) + .stderr_fn(move |bytes| emit(2, bytes)); + } + let result = runtime + .block_on(async move { execution.await }) + .map_err(ApiFailure::from_bash)?; + let mut flags = 0; + if result.stdout_truncated { + flags |= BASHKIT_RESULT_STDOUT_TRUNCATED; + } + if result.stderr_truncated { + flags |= BASHKIT_RESULT_STDERR_TRUNCATED; + } + *out_result = Box::into_raw(Box::new(BashkitResult { + stdout: result.stdout.into_bytes(), + stderr: result.stderr.into_bytes(), + exit_code: result.exit_code, + flags, + final_env_json: final_env_json(result.final_env)?, + })); + Ok(()) + }) + } +} + /// Requests cancellation of the execution currently running on `bash`. /// Requires the `cancellation` capability. Lock-free — never touches the state /// mutex — so it is safe to call from any thread while `bashkit_execute` is @@ -704,7 +813,10 @@ pub unsafe extern "C" fn bashkit_execute( pub unsafe extern "C" fn bashkit_cancel(bash: *mut Bashkit) -> BashkitStatus { unsafe { ffi_boundary(ptr::null_mut(), || { - let bash = handle(bash)?; + if bash.is_null() { + return Err(ApiFailure::invalid_argument("bash must not be NULL")); + } + let bash = &*bash; bash.cancelled.store(true, Ordering::SeqCst); Ok(()) }) @@ -792,7 +904,7 @@ fn lock_state(bash: &Bashkit) -> Result, ApiFai if runtime_functions::in_callback() { return Err(ApiFailure::new( BashkitStatus::Busy, - "blocking APIs cannot be called from a runtime handler", + "blocking APIs cannot be called from a callback", )); } if bash.runtime_callbacks { diff --git a/crates/bashkit-capi/src/runtime_functions.rs b/crates/bashkit-capi/src/runtime_functions.rs index 059f4be06..8dfd166e6 100644 --- a/crates/bashkit-capi/src/runtime_functions.rs +++ b/crates/bashkit-capi/src/runtime_functions.rs @@ -37,6 +37,10 @@ thread_local! { static IN_CALLBACK: Cell = const { Cell::new(false) }; } pub(super) fn in_callback() -> bool { IN_CALLBACK.get() } +pub(super) fn enter_callback() -> impl Drop { + CallbackScope(IN_CALLBACK.replace(true)) +} + struct CallbackScope(bool); impl Drop for CallbackScope { fn drop(&mut self) { @@ -199,7 +203,7 @@ fn invoke( lease: lease.clone(), budget: context.execution_budget(), }); - let _scope = CallbackScope(IN_CALLBACK.replace(true)); + let _scope = enter_callback(); // SAFETY: registration is host-provided and obeys the lifetime/no-unwind // contract. Ownership of this call transfers to that callback exactly once. unsafe { diff --git a/crates/bashkit-capi/tests/abi.rs b/crates/bashkit-capi/tests/abi.rs index 3fc31bfad..20d3f1de2 100644 --- a/crates/bashkit-capi/tests/abi.rs +++ b/crates/bashkit-capi/tests/abi.rs @@ -8,6 +8,90 @@ fn bytes(value: &[u8]) -> BashkitBytes { } } +struct LiveOutput { + bash: *mut Bashkit, + bytes: Vec, + reject: bool, + reentry: Option, +} + +unsafe extern "C" fn live_output( + context: *mut std::ffi::c_void, + stream: u32, + chunk: BashkitBytes, +) -> u32 { + let state = unsafe { &mut *(context as *mut LiveOutput) }; + assert!(stream == 1 || stream == 2); + state.bytes.extend(unsafe { borrowed(chunk) }); + let mut result = ptr::null_mut(); + let mut error = ptr::null_mut(); + state.reentry = + Some(unsafe { bashkit_execute(state.bash, bytes(b":"), &mut result, &mut error) }); + unsafe { bashkit_error_free(error) }; + u32::from(state.reject) +} + +#[test] +fn streaming_bytes_capture_and_reentry_contract() { + unsafe { + let mut bash = ptr::null_mut(); + let mut error = ptr::null_mut(); + assert_eq!( + bashkit_create_default(&mut bash, &mut error), + BashkitStatus::Ok + ); + let mut output = LiveOutput { + bash, + bytes: Vec::new(), + reject: false, + reentry: None, + }; + let mut result = ptr::null_mut(); + assert_eq!( + bashkit_execute_streaming( + bash, + bytes(b"printf '\\0\\377hello'"), + 3, + Some(live_output), + (&mut output as *mut LiveOutput).cast(), + &mut result, + &mut error + ), + BashkitStatus::Ok + ); + assert_eq!(output.bytes, b"\0\xffhello"); + assert_eq!(output.reentry, Some(BashkitStatus::InvalidArgument)); + assert_eq!(borrowed(bashkit_result_stdout(result)), b"\0\xffh"); + assert_ne!( + bashkit_result_flags(result) & BASHKIT_RESULT_STDOUT_TRUNCATED, + 0 + ); + bashkit_result_free(result); + output.reject = true; + assert_ne!( + bashkit_execute_streaming( + bash, + bytes(b"echo hello"), + 0, + Some(live_output), + (&mut output as *mut LiveOutput).cast(), + &mut result, + &mut error + ), + BashkitStatus::Ok + ); + bashkit_error_free(error); + assert!(result.is_null()); + assert_eq!( + bashkit_execute(bash, bytes(b"echo recovered"), &mut result, &mut error), + BashkitStatus::Ok + ); + assert_eq!(borrowed(bashkit_result_stdout(result)), b"recovered\n"); + bashkit_result_free(result); + bashkit_free(bash); + } +} + unsafe fn borrowed(value: BashkitBytes) -> Vec { if value.len == 0 { Vec::new() diff --git a/crates/bashkit-capi/tests/runtime_functions.rs b/crates/bashkit-capi/tests/runtime_functions.rs index 1702a6289..b1b144bb2 100644 --- a/crates/bashkit-capi/tests/runtime_functions.rs +++ b/crates/bashkit-capi/tests/runtime_functions.rs @@ -75,6 +75,24 @@ unsafe extern "C" fn handler(data: *mut c_void, call: *mut BashkitRuntimeCall) { bashkit_runtime_call_free(call); } 4 => bashkit_runtime_call_free(call), + 5 => { + let mut result = ptr::null_mut(); + let mut error = ptr::null_mut(); + let status = bashkit_execute_streaming( + host.bash.load(Ordering::SeqCst), + bytes(b"echo forbidden"), + 0, + None, + ptr::null_mut(), + &mut result, + &mut error, + ); + host.reentry.store(status as u32, Ordering::SeqCst); + bashkit_result_free(result); + bashkit_error_free(error); + bashkit_runtime_call_respond_json(call, bytes(b"42")); + bashkit_runtime_call_free(call); + } _ => { let mut result = ptr::null_mut(); let mut error = ptr::null_mut(); @@ -414,3 +432,107 @@ fn c_runtime_callbacks_preserve_hardened_python_memory_limit() { bashkit_free(bash); } } + +struct LiveOutput { + bytes: Vec, + caller: thread::ThreadId, + other: *mut Bashkit, + reentry: Option, +} +unsafe extern "C" fn collect_live(data: *mut c_void, stream: u32, chunk: BashkitBytes) -> u32 { + unsafe { + let output = &mut *data.cast::(); + if stream != 1 || thread::current().id() != output.caller { + return 1; + } + output.bytes.extend(view(chunk)); + let mut result = ptr::null_mut(); + let mut error = ptr::null_mut(); + output.reentry = Some(bashkit_execute_streaming( + output.other, + bytes(b"echo forbidden"), + 0, + None, + ptr::null_mut(), + &mut result, + &mut error, + )); + bashkit_result_free(result); + bashkit_error_free(error); + 0 + } +} + +#[test] +fn c_runtime_callbacks_work_with_live_output_and_capture() { + unsafe { + let mut other = ptr::null_mut(); + let mut error = ptr::null_mut(); + assert_eq!( + bashkit_create_default(&mut other, &mut error), + BashkitStatus::Ok + ); + for mode in [0, 1, 5] { + let host = Host::new(mode); + let bash = create(&host, 5000); + for capture in [0, 2, 64] { + for script in [ + "python -c 'print(lookup())' | cat", + "ts -c 'await lookup()' | cat", + ] { + let mut output = LiveOutput { + bytes: Vec::new(), + caller: thread::current().id(), + other, + reentry: None, + }; + let mut result = ptr::null_mut(); + assert_eq!( + bashkit_execute_streaming( + bash, + bytes(script.as_bytes()), + capture, + Some(collect_live), + ptr::from_mut(&mut output).cast(), + &mut result, + &mut error, + ), + BashkitStatus::Ok + ); + assert_eq!(bashkit_result_exit_code(result), 0); + assert_eq!(output.bytes, b"42\n"); + assert_eq!(output.reentry, Some(BashkitStatus::Busy)); + assert_eq!( + view(bashkit_result_stdout(result)), + &b"42\n"[..capture.min(3)] + ); + assert_eq!( + bashkit_result_flags(result) & BASHKIT_RESULT_STDOUT_TRUNCATED != 0, + capture == 2 + ); + bashkit_result_free(result); + } + } + if mode == 5 { + assert_eq!( + host.reentry.load(Ordering::SeqCst), + BashkitStatus::Busy as u32 + ); + } + assert!( + host.requests + .lock() + .unwrap() + .iter() + .all(|(id, _)| *id == thread::current().id()) + ); + host.join(); + bashkit_free(bash); + } + assert_eq!( + execute(other, "echo reusable"), + (BashkitStatus::Ok, 0, b"reusable\n".to_vec()) + ); + bashkit_free(other); + } +} diff --git a/crates/bashkit-cli/Cargo.toml b/crates/bashkit-cli/Cargo.toml index 370cde957..7dc458e93 100644 --- a/crates/bashkit-cli/Cargo.toml +++ b/crates/bashkit-cli/Cargo.toml @@ -40,7 +40,7 @@ interactive = ["dep:rustyline", "dep:terminal_size", "dep:signal-hook"] # named `TZ=` zones in `date`, which stopped being implied when chrono-tz # moved behind a feature. bashkit = { path = "../bashkit", version = "0.19.0", default-features = false, features = ["http_client", "ring", "git", "jq", "tzdata"] } -tokio = { workspace = true, features = ["macros", "net", "rt", "rt-multi-thread", "time"] } +tokio = { workspace = true, features = ["macros", "net", "rt", "rt-multi-thread", "time", "io-std"] } clap.workspace = true anyhow.workspace = true rustyline = { version = "18", optional = true } diff --git a/crates/bashkit-cli/src/interactive.rs b/crates/bashkit-cli/src/interactive.rs index f7d343864..74ee3f88f 100644 --- a/crates/bashkit-cli/src/interactive.rs +++ b/crates/bashkit-cli/src/interactive.rs @@ -16,7 +16,6 @@ use rustyline::hint::{Hint, Hinter}; use rustyline::validate::Validator; use rustyline::{Config, Context, Editor, Helper}; use std::borrow::Cow; -use std::io::Write; use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -530,19 +529,9 @@ pub async fn run(mut bash: bashkit::Bash, exit_state: Arc) -> Result< }); let exec_result = bash - .exec_streaming( - &input, - Box::new(|stdout, stderr| { - if !stdout.is_empty() { - print!("{stdout}"); - let _ = std::io::stdout().flush(); - } - if !stderr.is_empty() { - eprint!("{stderr}"); - let _ = std::io::stderr().flush(); - } - }), - ) + .execute(&input) + .stdout_fn(|bytes| crate::write_output_chunk(tokio::io::stdout(), bytes)) + .stderr_fn(|bytes| crate::write_output_chunk(tokio::io::stderr(), bytes)) .await; cancel_watcher.abort(); diff --git a/crates/bashkit-cli/src/main.rs b/crates/bashkit-cli/src/main.rs index c972f953b..47bfa821a 100644 --- a/crates/bashkit-cli/src/main.rs +++ b/crates/bashkit-cli/src/main.rs @@ -26,7 +26,7 @@ use anyhow::{Context, Result, bail}; use clap::{Parser, ValueEnum}; use std::{ ffi::OsStr, - io::{IsTerminal, Write}, + io::IsTerminal, path::{Path, PathBuf}, }; use tokio::runtime::Builder; @@ -431,20 +431,7 @@ fn split_invocation_args(args: &Args) -> (Option, Vec) { /// Build the per-execution options: positional parameters, `$0`, and stdin. fn exec_options(args: &Args, timeout: std::time::Duration) -> Result { - let mut options = bashkit::ExecOptions::new().streaming(Box::new(|stdout, stderr| { - // Write through as chunks arrive so long scripts are observable and a - // run aborted by a resource limit keeps what it already produced. - if !stdout.is_empty() { - let mut out = std::io::stdout().lock(); - let _ = out.write_all(stdout.as_bytes()); - let _ = out.flush(); - } - if !stderr.is_empty() { - let mut err = std::io::stderr().lock(); - let _ = err.write_all(stderr.as_bytes()); - let _ = err.flush(); - } - })); + let mut options = bashkit::ExecOptions::new(); // Left unset when there is nothing to install, so `$0` keeps its default // instead of being overwritten with an empty name. @@ -463,6 +450,18 @@ fn exec_options(args: &Args, timeout: std::time::Duration) -> Result( + mut writer: W, + bytes: Vec, +) -> bashkit::Result<()> { + use tokio::io::AsyncWriteExt; + writer.write_all(&bytes).await?; + writer.flush().await?; + Ok(()) +} + fn run_oneshot(args: Args, mode: CliMode) -> Result { Builder::new_current_thread() .enable_all() @@ -489,7 +488,13 @@ fn run_oneshot(args: Args, mode: CliMode) -> Result { // Boxed: the exec future is large, and `block_on` keeps this block on // the main thread's stack, which the recursion limit is sized // against (TM-DOS-020, two-MiB stack check in cli_oneshot). - let result = match Box::pin(bash.exec_with_options(&script, options)).await { + let result = match bash + .execute(&script) + .options(options) + .stdout_fn(|bytes| write_output_chunk(tokio::io::stdout(), bytes)) + .stderr_fn(|bytes| write_output_chunk(tokio::io::stderr(), bytes)) + .await + { Ok(result) => result, // Like bash: a syntax error with nothing runnable before it is // reported on stderr and exits 2 (the partial-run case already @@ -511,7 +516,7 @@ fn run_oneshot(args: Args, mode: CliMode) -> Result { }; // Output already reached the terminal through the streaming - // callback — printing `result.stdout` here would duplicate it. + // writers — printing `result.stdout` here would duplicate it. Ok(result.exit_code) }) } diff --git a/crates/bashkit-cli/tests/cli_oneshot.rs b/crates/bashkit-cli/tests/cli_oneshot.rs index ff4c5ef59..ad130587a 100644 --- a/crates/bashkit-cli/tests/cli_oneshot.rs +++ b/crates/bashkit-cli/tests/cli_oneshot.rs @@ -1502,3 +1502,45 @@ fn streamed_output_is_not_printed_twice() { assert_eq!(stdout(&out), "once\n"); assert_eq!(stderr(&out), "twice\n"); } + +#[cfg(feature = "realfs")] +#[test] +fn output_without_a_newline_arrives_before_the_script_is_released() { + let dir = tempfile::tempdir().unwrap(); + let mount = format!("{}:/gate", dir.path().display()); + let mut child = Command::new(BIN) + .args([ + "--no-stdin", + "--timeout", + "5", + "--mount-ro", + &mount, + "-c", + "printf first; while [ ! -e /gate/release ]; do sleep 0.01; done; printf last", + ]) + .env_remove("RUST_BACKTRACE") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let mut stdout = child.stdout.take().unwrap(); + let (tx, rx) = mpsc::channel(); + let reader = thread::spawn(move || { + let mut first = [0; 5]; + let read = stdout.read_exact(&mut first); + let _ = tx.send(read.map(|()| first)); + let mut rest = Vec::new(); + stdout.read_to_end(&mut rest).unwrap(); + rest + }); + let first = rx.recv_timeout(Duration::from_secs(2)); + let still_running = child.try_wait().unwrap().is_none(); + // Always release the producer, including a failed first-output check. + std::fs::write(dir.path().join("release"), b"").unwrap(); + let status = child.wait().unwrap(); + let rest = reader.join().unwrap(); + assert_eq!(first.unwrap().unwrap(), *b"first"); + assert!(still_running); + assert!(status.success()); + assert_eq!(rest, b"last"); +} diff --git a/crates/bashkit-js/__test__/runtime-compat/runtime-functions.test.mjs b/crates/bashkit-js/__test__/runtime-compat/runtime-functions.test.mjs index 1f64b461b..cbb3b2e8f 100644 --- a/crates/bashkit-js/__test__/runtime-compat/runtime-functions.test.mjs +++ b/crates/bashkit-js/__test__/runtime-compat/runtime-functions.test.mjs @@ -38,6 +38,22 @@ for (const Factory of [Bash, BashTool]) { results.map((r) => r.stdout), ["first\n", "second\n"], ); + for (const capture of [undefined, 64]) { + const chunks = []; + const live = await context.run("live", () => + bash.execute(`${command} | cat`, { + capture, + stdout: async (bytes) => { + await new Promise((resolve) => setTimeout(resolve, 1)); + assert.equal(context.getStore(), "live"); + chunks.push(new TextDecoder().decode(bytes)); + }, + }), + ); + assert.equal(live.exitCode, 0, live.stderr); + assert.equal(chunks.join(""), "live\n"); + assert.equal(live.stdout, capture === undefined ? "" : "live\n"); + } assert.notEqual(bash.executeSync(command).exitCode, 0); bash.reset(); assert.equal( diff --git a/crates/bashkit-js/__test__/runtime-compat/streaming-commands.test.mjs b/crates/bashkit-js/__test__/runtime-compat/streaming-commands.test.mjs new file mode 100644 index 000000000..9602ca6fa --- /dev/null +++ b/crates/bashkit-js/__test__/runtime-compat/streaming-commands.test.mjs @@ -0,0 +1,97 @@ +// Node, Bun and Deno CI exercise the shipped NAPI wrapper. +import { it } from "node:test"; +import assert from "node:assert/strict"; +import { Bash, BashTool } from "./_setup.mjs"; +import { native } from "../../internal.js"; + +it("native command I/O rejects forged and mismatched handles", () => { + const fs = native.__createFileSystem(); + for (const handle of [undefined, null, {}, fs]) { + assert.throws(() => native.__commandReadChunk(handle), /external|External/); + assert.throws( + () => native.__commandWrite(handle, Uint8Array.of(1)), + /external|External/, + ); + } +}); + +for (const [name, create] of [ + ["Bash", () => new Bash()], + ["BashTool", () => new BashTool()], +]) { + it( + `${name}: awaited byte commands deliver through a final filter`, + { timeout: 5000 }, + async () => { + const shell = create(); + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + shell.addCommand("produce", async (ctx) => { + await ctx.stdout.write(new TextEncoder().encode("error: first\n")); + await gate; + await ctx.stdout.write(new TextEncoder().encode("error: last\n")); + return 0; + }); + shell.addCommand("copy", async (ctx) => { + for await (const bytes of ctx.stdin) await ctx.stdout.write(bytes); + return { exitCode: 0 }; + }); + const chunks = []; + const result = await shell.execute("produce | copy | grep error", { + capture: 64, + async stdout(bytes) { + chunks.push(...bytes); + release(); + await new Promise((resolve) => setTimeout(resolve, 1)); + }, + }); + assert.equal(result.stdout, "error: first\nerror: last\n"); + assert.equal( + new TextDecoder().decode(new Uint8Array(chunks)), + result.stdout, + ); + assert.equal(result.stdoutTruncated, false); + }, + ); + it( + `${name}: binary capture, failure, cancellation and reuse`, + { timeout: 5000 }, + async () => { + const shell = create(); + let saved; + shell.addCommand("binary", async (ctx) => { + saved = ctx; + await ctx.stdout.write(Uint8Array.of(0, 255, 97, 98)); + return 7; + }); + const binary = await shell.execute("binary", { capture: 3 }); + assert.deepEqual([...binary.stdoutBytes], [0, 255, 97]); + assert.equal(binary.stdoutTruncated, true); + await assert.rejects( + saved.stdout.write(Uint8Array.of(1)), + /request closed/, + ); + const failed = await shell.execute("echo hello", { + stdout() { + throw new Error("sink failed"); + }, + }); + assert.notEqual(failed.exitCode, 0); + const controller = new AbortController(); + const aborted = await shell.execute("echo hello", { + signal: controller.signal, + async stdout() { + controller.abort(); + await new Promise(() => {}); + }, + }); + assert.equal(aborted.error, "execution cancelled"); + assert.equal( + (await shell.execute("echo recovered", { capture: 64 })).stdout, + "recovered\n", + ); + }, + ); +} diff --git a/crates/bashkit-js/__test__/runtime-functions.spec.ts b/crates/bashkit-js/__test__/runtime-functions.spec.ts index 58f1e7ebf..0ecd9c562 100644 --- a/crates/bashkit-js/__test__/runtime-functions.spec.ts +++ b/crates/bashkit-js/__test__/runtime-functions.spec.ts @@ -56,6 +56,83 @@ for (const language of ["python", "typescript"] as const) { t.deepEqual(seen, [["lookup", [21], { multiplier: 2 }]]); }); + for (const capture of [undefined, 64]) { + test(`${Factory.name}: ${language} live sinks preserve queued runtime sessions (capture=${capture})`, async (t) => { + const signals: AbortSignal[] = []; + const bash = new Factory( + options(async (_name, _args, _kwargs, ctx) => { + signals.push(ctx.signal); + const before = request.getStore(); + await delay(2); + t.is(request.getStore(), before); + return before; + }), + ); + const results = await Promise.all( + Array.from({ length: 3 }, (_, i) => + request.run(String(i), async () => { + const chunks: string[] = []; + const result = await bash.execute(`${call} | cat`, { + capture, + stdout: async (bytes) => { + const before = request.getStore(); + t.is(before, String(i)); + await delay(2); + t.is(request.getStore(), before); + chunks.push(new TextDecoder().decode(bytes)); + }, + }); + t.is(result.exitCode, 0, result.stderr); + t.is(result.stdout, capture === undefined ? "" : `${i}\n`); + return chunks.join(""); + }), + ), + ); + t.deepEqual(results, ["0\n", "1\n", "2\n"]); + t.is(signals.length, 3); + t.true(signals.every((signal) => signal.aborted)); + }); + } + + test(`${Factory.name}: ${language} live cancellation revokes host work and permits reuse`, async (t) => { + let started!: () => void; + const ready = new Promise((resolve) => { + started = resolve; + }); + let hostSignal: AbortSignal | undefined; + const bash = new Factory( + options(async (_name, _args, _kwargs, ctx) => { + hostSignal = ctx.signal; + started(); + await new Promise((_resolve, reject) => + ctx.signal.addEventListener( + "abort", + () => reject(new Error("cancelled")), + { once: true }, + ), + ); + }), + ); + const controller = new AbortController(); + const active = bash.execute(call, { + capture: 64, + stdout: async () => {}, + signal: controller.signal, + }); + await ready; + controller.abort(); + t.not((await active).exitCode, 0); + t.true(hostSignal!.aborted); + const chunks: string[] = []; + const result = await bash.execute("echo reusable", { + stdout: (bytes) => { + chunks.push(new TextDecoder().decode(bytes)); + }, + }); + t.is(result.exitCode, 0, result.stderr); + t.is(chunks.join(""), "reusable\n"); + }); + test(`${Factory.name}: ${language} callbacks retain queued request contexts`, async (t) => { const bash = new Factory( options(async () => { diff --git a/crates/bashkit-js/__test__/streaming-commands.spec.ts b/crates/bashkit-js/__test__/streaming-commands.spec.ts new file mode 100644 index 000000000..9b3325cca --- /dev/null +++ b/crates/bashkit-js/__test__/streaming-commands.spec.ts @@ -0,0 +1,130 @@ +// Exercise the public wrapper against the real native addon. +import test from "ava"; +import { Bash, BashTool, type CommandContext } from "../wrapper.js"; +import { native } from "../internal.js"; + +test("native command I/O rejects forged and mismatched handles", (t) => { + const fs = native.__createFileSystem(); + for (const handle of [undefined, null, {}, fs]) { + t.throws(() => native.__commandReadChunk(handle), { + message: /external|External/, + }); + t.throws(() => native.__commandWrite(handle, Uint8Array.of(1)), { + message: /external|External/, + }); + } +}); + +for (const [label, create] of [ + ["Bash", () => new Bash()], + ["BashTool", () => new BashTool()], +] as const) { + test(`${label}: final filter delivers before the host producer completes`, async (t) => { + const shell = create(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + shell.addCommand("produce", async (ctx) => { + await ctx.stdout.write(new TextEncoder().encode("error: first\n")); + await gate; + await ctx.stdout.write(new TextEncoder().encode("error: last\n")); + return 0; + }); + shell.addCommand("copy", async (ctx) => { + for await (const chunk of ctx.stdin) await ctx.stdout.write(chunk); + return { exitCode: 0 }; + }); + const chunks: Uint8Array[] = []; + const result = await shell.execute("produce | copy | grep error", { + capture: 64, + async stdout(bytes) { + chunks.push(bytes); + release(); + await new Promise((resolve) => setTimeout(resolve, 1)); + }, + }); + t.is(result.exitCode, 0); + t.is(result.stdout, "error: first\nerror: last\n"); + t.is(Buffer.concat(chunks).toString(), result.stdout); + }); + + test(`${label}: binary capture and revoked handles`, async (t) => { + const shell = create(); + let saved!: CommandContext; + shell.addCommand("binary", async (ctx) => { + saved = ctx; + await ctx.stdout.write(Uint8Array.of(0, 255, 97, 98)); + return 7; + }); + const result = await shell.execute("binary", { capture: 3 }); + t.deepEqual([...result.stdoutBytes], [0, 255, 97]); + t.true(result.stdoutTruncated); + t.is(result.exitCode, 7); + await t.throwsAsync(() => saved.stdout.write(Uint8Array.of(1)), { + message: /request closed/, + }); + await t.throwsAsync(() => saved.stdin.readChunk(), { + message: /request closed/, + }); + }); + + test(`${label}: writes snapshot mutable JavaScript byte arrays`, async (t) => { + const shell = create(); + shell.addCommand("snapshot", async (ctx) => { + const bytes = Uint8Array.of(65); + const pending = ctx.stdout.write(bytes); + bytes[0] = 66; + await pending; + return 0; + }); + t.is((await shell.execute("snapshot", { capture: 8 })).stdout, "A"); + }); + + test(`${label}: sink failure releases execution and sanitizes diagnostics`, async (t) => { + const shell = create(); + const result = await shell.execute("echo hello", { + stdout() { + throw new Error("failed at /host/private.ts:2"); + }, + }); + t.is(result.exitCode, 1); + t.truthy(result.error); + t.false(result.stderr.includes("/host/")); + t.is( + (await shell.execute("echo recovered", { capture: 64 })).stdout, + "recovered\n", + ); + }); + + test(`${label}: callbacks cannot re-enter their own session`, async (t) => { + const shell = create(); + const result = await shell.execute("echo hello", { + stdout: async () => { + await t.throwsAsync(() => shell.execute("echo nested"), { + message: /cannot re-enter/, + }); + }, + }); + t.is(result.exitCode, 0); + t.is(result.stdout, ""); + }); + + test(`${label}: abort interrupts a pending sink and the session recovers`, async (t) => { + const shell = create(); + const controller = new AbortController(); + const result = await shell.execute("echo hello", { + signal: controller.signal, + stdout: async () => { + controller.abort(); + await new Promise(() => {}); + }, + }); + t.is(result.exitCode, 1); + t.is(result.error, "execution cancelled"); + t.is( + (await shell.execute("echo recovered", { capture: 64 })).stdout, + "recovered\n", + ); + }); +} diff --git a/crates/bashkit-js/src/lib.rs b/crates/bashkit-js/src/lib.rs index c79de2991..dca97335e 100644 --- a/crates/bashkit-js/src/lib.rs +++ b/crates/bashkit-js/src/lib.rs @@ -14,6 +14,7 @@ //! holding a raw-pointer-derived `&self` across `block_on` or `.await` points. mod runtime_functions; +mod streaming; mod terminal; use bashkit::interop::fs::{ @@ -520,6 +521,42 @@ impl Default for NativeFileSystemState { } } +// Opaque, type-checked handles use the same boundary as the filesystem API. +// Clone Rust capabilities on the JS thread before spawning: pending I/O never +// borrows a JS class instance whose lifetime is controlled by garbage collection. +#[napi(js_name = "__commandReadChunk")] +pub fn command_read_chunk<'env>( + env: &'env Env, + input: &External, +) -> napi::Result>> +{ + let input = (**input).clone(); + env.spawn_future(async move { + input + .read_chunk() + .await + .map(|b| b.map(napi::bindgen_prelude::Uint8Array::new)) + .map_err(|e| napi::Error::from_reason(e.to_string())) + }) +} + +#[napi(js_name = "__commandWrite")] +pub fn command_write<'env>( + env: &'env Env, + output: &External, + bytes: napi::bindgen_prelude::Uint8Array, +) -> napi::Result> { + // Snapshot mutable JS bytes before returning its Promise. + let bytes = bytes.to_vec(); + let output = (**output).clone(); + env.spawn_future(async move { + output + .write_all(&bytes) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + }) +} + #[napi(js_name = "__createFileSystem")] pub fn create_file_system() -> External { External::new(NativeFileSystemState::new()) @@ -1938,6 +1975,51 @@ impl Bash { arc.store(false, Ordering::SeqCst); } + /// Register an async byte command; output is written before status returns. + #[napi( + ts_args_type = "name: string, callback: (requestPair: [string, unknown, unknown, unknown, unknown]) => Promise" + )] + pub fn add_command(&self, name: String, callback: streaming::Callback<'_>) -> napi::Result<()> { + streaming::register(&self.state, name, callback) + } + + #[napi( + ts_args_type = "commands: string, capture?: number, stdout?: (pair: [Uint8Array]) => Promise, stderr?: (pair: [Uint8Array]) => Promise, dispatcher?: (request: string) => Promise" + )] + pub fn execute_live<'env>( + &self, + env: &'env Env, + commands: String, + capture: Option, + stdout: Option< + napi::bindgen_prelude::Function< + 'env, + (napi::bindgen_prelude::Uint8Array,), + napi::bindgen_prelude::Promise<()>, + >, + >, + stderr: Option< + napi::bindgen_prelude::Function< + 'env, + (napi::bindgen_prelude::Uint8Array,), + napi::bindgen_prelude::Promise<()>, + >, + >, + dispatcher: Option< + napi::bindgen_prelude::Function<'env, String, napi::bindgen_prelude::Promise>, + >, + ) -> napi::Result> { + streaming::execute( + env, + self.state.clone(), + commands, + capture, + stdout, + stderr, + dispatcher, + ) + } + /// Register a JS callback as a custom bash builtin. /// /// The callback receives a JSON-serialized `BuiltinContext` @@ -2775,8 +2857,52 @@ impl BashTool { arc.store(false, Ordering::SeqCst); } - /// Register a JS callback as a custom bash builtin. - /// See [`Bash::add_builtin`] for semantics. + /// Register an async byte command. See [`Bash::add_command`]. + #[napi( + ts_args_type = "name: string, callback: (requestPair: [string, unknown, unknown, unknown, unknown]) => Promise" + )] + pub fn add_command(&self, name: String, callback: streaming::Callback<'_>) -> napi::Result<()> { + streaming::register(&self.state, name, callback) + } + + #[napi( + ts_args_type = "commands: string, capture?: number, stdout?: (pair: [Uint8Array]) => Promise, stderr?: (pair: [Uint8Array]) => Promise, dispatcher?: (request: string) => Promise" + )] + pub fn execute_live<'env>( + &self, + env: &'env Env, + commands: String, + capture: Option, + stdout: Option< + napi::bindgen_prelude::Function< + 'env, + (napi::bindgen_prelude::Uint8Array,), + napi::bindgen_prelude::Promise<()>, + >, + >, + stderr: Option< + napi::bindgen_prelude::Function< + 'env, + (napi::bindgen_prelude::Uint8Array,), + napi::bindgen_prelude::Promise<()>, + >, + >, + dispatcher: Option< + napi::bindgen_prelude::Function<'env, String, napi::bindgen_prelude::Promise>, + >, + ) -> napi::Result> { + streaming::execute( + env, + self.state.clone(), + commands, + capture, + stdout, + stderr, + dispatcher, + ) + } + + /// Register a buffered JS builtin. See [`Bash::add_builtin`]. #[napi( ts_args_type = "name: string, callback: (requestPair: [string, unknown]) => Promise" )] diff --git a/crates/bashkit-js/src/runtime_functions.rs b/crates/bashkit-js/src/runtime_functions.rs index d883c077a..8da46f47f 100644 --- a/crates/bashkit-js/src/runtime_functions.rs +++ b/crates/bashkit-js/src/runtime_functions.rs @@ -102,6 +102,19 @@ pub(super) fn typescript_handler() -> TypeScriptExternalFnHandler { }) } +// Both buffered and live execution must install the same request-local lease. +pub(super) fn extensions( + dispatcher: Function<'_, String, Promise>, +) -> napi::Result { + let dispatcher = Arc::new( + dispatcher + .build_threadsafe_function::() + .weak::() + .build()?, + ); + Ok(bashkit::ExecutionExtensions::new().with(Arc::new(RuntimeSession { dispatcher }))) +} + pub(super) fn execute<'env>( state: Arc, commands: String, @@ -110,14 +123,8 @@ pub(super) fn execute<'env>( ) -> napi::Result> { super::reject_on_output_reentry(&state)?; let env = dispatcher.value().env; - let dispatcher = Arc::new( - dispatcher - .build_threadsafe_function::() - .weak::() - .build()?, - ); + let extensions = extensions(dispatcher)?; let output = on_output.map(super::create_output_tsfn).transpose()?; - let session = Arc::new(RuntimeSession { dispatcher }); let promise = napi::bindgen_prelude::execute_tokio_future( env, async move { @@ -126,7 +133,6 @@ pub(super) fn execute<'env>( } let _slot = super::acquire_async_execute_slot(&state).await?; let mut bash = state.inner.lock().await; - let extensions = bashkit::ExecutionExtensions::new().with(session); let cancelled = bash.cancellation_token(); let requested = Arc::new(std::sync::atomic::AtomicBool::new(false)); let output = output.map(|tsfn| { diff --git a/crates/bashkit-js/src/streaming.rs b/crates/bashkit-js/src/streaming.rs new file mode 100644 index 000000000..dba481125 --- /dev/null +++ b/crates/bashkit-js/src/streaming.rs @@ -0,0 +1,161 @@ +//! Byte streaming across the NAPI boundary. Only one bounded chunk per sink +//! is dispatched at a time; awaiting a JS Promise backpressures the engine. + +use super::*; +use bashkit::{Command, CommandContext, CommandInput, CommandOutput, CommandStatus}; +use napi::bindgen_prelude::{Function, Promise, PromiseRaw, Uint8Array}; + +type CommandArgs = ( + String, + External, + External, + External, + External, +); +pub type Callback<'env> = Function<'env, CommandArgs, Promise>; +type CommandTsfn = napi::threadsafe_function::ThreadsafeFunction< + CommandArgs, + Promise, + CommandArgs, + napi::Status, + false, + true, +>; +type SinkTsfn = napi::threadsafe_function::ThreadsafeFunction< + (Uint8Array,), + Promise<()>, + (Uint8Array,), + napi::Status, + false, + true, +>; + +struct JsCommand { + name: String, + callback: Arc, + in_sync: Arc, +} + +#[async_trait] +impl Command for JsCommand { + async fn execute(&self, ctx: CommandContext<'_>) -> bashkit::Result { + if self.in_sync.load(Ordering::SeqCst) > 0 { + return Err(bashkit::Error::Execution( + SYNC_BUILTIN_DEADLOCK_ERROR.into(), + )); + } + let payload = serde_json::to_string(&serde_json::json!({ + "name": self.name, "argv": ctx.args, "env": ctx.env, "cwd": ctx.cwd, + })) + .map_err(|e| bashkit::Error::Execution(e.to_string()))?; + let args = ( + payload, + External::new(NativeFileSystemState::from_static(ctx.fs.clone())), + External::new(ctx.stdin.clone()), + External::new(ctx.stdout.clone()), + External::new(ctx.stderr.clone()), + ); + let result = async { + let promise = self.callback.call_async(args).await?; + promise.await + } + .await; + match result { + Ok(code) => Ok(CommandStatus::with_code(code)), + Err(error) => { + ctx.stderr.write_all(error.to_string().as_bytes()).await?; + Ok(CommandStatus::with_code(1)) + } + } + } +} + +pub(super) fn register( + state: &SharedState, + name: String, + callback: Callback<'_>, +) -> napi::Result<()> { + let callback = callback + .build_threadsafe_function::() + .weak::() + .build()?; + state.host_registry.insert_command( + name.clone(), + JsCommand { + name, + callback: Arc::new(callback), + in_sync: state.in_sync_execute_depth.clone(), + }, + ); + Ok(()) +} + +fn sink( + callback: Option>>, +) -> napi::Result>> { + callback + .map(|f| { + f.build_threadsafe_function::<(Uint8Array,)>() + .weak::() + .build() + .map(Arc::new) + }) + .transpose() +} + +pub(super) fn execute<'env>( + env: &'env Env, + state: Arc, + commands: String, + capture: Option, + stdout: Option>>, + stderr: Option>>, + dispatcher: Option>>, +) -> napi::Result> { + reject_on_output_reentry(&state)?; + let extensions = dispatcher + .map(runtime_functions::extensions) + .transpose()? + .unwrap_or_default(); + let stdout = sink(stdout)?; + let stderr = sink(stderr)?; + env.spawn_future(async move { + if let Some(result) = validate_async_execute_input(&state, &commands) { + return Ok(result); + } + let _slot = acquire_async_execute_slot(&state).await?; + let mut bash = state.inner.lock().await; + let mut execution = bash + .execute(&commands) + .options(bashkit::ExecOptions::new().extensions(extensions)); + if let Some(limit) = capture { + execution = execution.capture(limit as usize); + } + if let Some(callback) = stdout { + let depth = state.on_output_reentry_depth.clone(); + execution = + execution.stdout_fn(move |bytes| deliver(callback.clone(), depth.clone(), bytes)); + } + if let Some(callback) = stderr { + let depth = state.on_output_reentry_depth.clone(); + execution = + execution.stderr_fn(move |bytes| deliver(callback.clone(), depth.clone(), bytes)); + } + Ok(js_exec_result_from_bash_result(execution.await)) + }) +} + +async fn deliver( + callback: Arc, + depth: Arc, + bytes: Vec, +) -> bashkit::Result<()> { + let _reentry = OnOutputReentryScope::enter(depth); + let promise = callback + .call_async((Uint8Array::new(bytes),)) + .await + .map_err(|e| bashkit::Error::Execution(e.to_string()))?; + promise + .await + .map_err(|e| bashkit::Error::Execution(e.to_string())) +} diff --git a/crates/bashkit-js/tsconfig.json b/crates/bashkit-js/tsconfig.json index 6287d6d11..969b28fda 100644 --- a/crates/bashkit-js/tsconfig.json +++ b/crates/bashkit-js/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable", "ES2021.WeakRef"], "module": "ESNext", "moduleResolution": "Bundler", "types": ["node"], @@ -13,6 +14,14 @@ "noUnusedLocals": true, "noUnusedParameters": true }, - "include": ["wrapper.ts", "scripted.ts", "internal.ts", "langchain.ts", "anthropic.ts", "openai.ts", "ai.ts"], + "include": [ + "wrapper.ts", + "scripted.ts", + "internal.ts", + "langchain.ts", + "anthropic.ts", + "openai.ts", + "ai.ts" + ], "exclude": ["node_modules", "__test__"] } diff --git a/crates/bashkit-js/wrapper.ts b/crates/bashkit-js/wrapper.ts index 9c1c00fdf..fc0b25522 100644 --- a/crates/bashkit-js/wrapper.ts +++ b/crates/bashkit-js/wrapper.ts @@ -5,7 +5,10 @@ import { executeNativeRuntime, validateRuntimeFunctions, } from "./runtime-functions.js"; -import type { RuntimeFunctionOptions } from "./runtime-functions.js"; +import type { + RuntimeFunctionOptions, + RuntimeSession, +} from "./runtime-functions.js"; export type { ExternalHandler, TypeScriptExternalHandler, @@ -58,6 +61,12 @@ const NativeBash: typeof NativeBashType = native.Bash; const NativeBashTool: typeof NativeBashToolType = native.BashTool; const NativeTerminal: typeof NativeTerminalType = native.Terminal; const nativeGetVersion: () => string = native.getVersion; +const nativeCommandReadChunk: (input: unknown) => Promise = + native.__commandReadChunk; +const nativeCommandWrite: ( + output: unknown, + bytes: Uint8Array, +) => Promise = native.__commandWrite; const nativeCreateFileSystem: () => any = native.__createFileSystem; const nativeRealFileSystem: ( hostPath: string, @@ -365,6 +374,7 @@ export interface BashOptions extends RuntimeFunctionOptions { * ``` */ customBuiltins?: Record; + commands?: Record; } export interface SnapshotOptions { @@ -386,7 +396,30 @@ export interface OutputChunk { export type OnOutput = (chunk: OutputChunk) => void; +/** Request-scoped byte I/O for a live shell command. */ +export interface CommandContext { + name: string; + argv: string[]; + env: Record; + cwd: string; + fs: FileSystem; + stdin: AsyncIterable & { + readChunk(): Promise; + }; + stdout: { write(bytes: Uint8Array): Promise }; + stderr: { write(bytes: Uint8Array): Promise }; +} +export type CommandCallback = ( + ctx: CommandContext, +) => Promise; + export interface ExecuteOptions { + /** Capture this many bytes per stream in addition to optional live sinks. */ + capture?: number; + /** Awaited byte sinks apply backpressure. Omitted capture retains no output. */ + stdout?: (bytes: Uint8Array) => void | Promise; + stderr?: (bytes: Uint8Array) => void | Promise; + signal?: AbortSignal; /** * Live chunk callback. Must be synchronous. @@ -711,6 +744,145 @@ export class FileSystem { } } +function streamingError(error: unknown): Error { + const raw = error instanceof Error ? error.message : String(error); + return new Error( + raw + .replace(/file:\/\/[^\s]*/g, "") + .replace(/(?") + .slice(0, 256) || "host callback failed", + ); +} + +function makeCommandDispatcher( + callback: CommandCallback, + ownerRef: WeakRef, +) { + return async ([payload, fs, input, stdout, stderr]: [ + string, + unknown, + unknown, + unknown, + unknown, + ]): Promise => { + const readChunk = () => nativeCommandReadChunk(input); + const stdin: CommandContext["stdin"] = { + readChunk, + async *[Symbol.asyncIterator]() { + for (;;) { + const bytes = await readChunk(); + if (bytes == null) return; + yield bytes; + } + }, + }; + const owner = ownerRef.deref(); + const release = owner ? enterLiveOwner(owner) : () => {}; + try { + const status = await callback({ + ...JSON.parse(payload), + fs: FileSystem.fromNative(fs), + stdin, + stdout: { write: (bytes) => nativeCommandWrite(stdout, bytes) }, + stderr: { write: (bytes) => nativeCommandWrite(stderr, bytes) }, + }); + const code = typeof status === "number" ? status : status?.exitCode; + if (!Number.isInteger(code) || code < -0x80000000 || code > 0x7fffffff) + throw new TypeError("command must return a 32-bit integer exit status"); + return code; + } catch (error) { + throw streamingError(error); + } finally { + release(); + } + }; +} + +function registerCommands( + native: NativeBashType | NativeBashToolType, + commands?: Record, +) { + for (const [name, callback] of Object.entries(commands ?? {})) + native.addCommand( + name, + makeCommandDispatcher(callback, new WeakRef(native)), + ); +} + +const liveSinkOwners = new WeakMap>(); +function enterLiveOwner(owner: object): () => void { + const active = liveSinkOwners.get(owner) ?? new Set(); + const token = Symbol(); + active.add(token); + liveSinkOwners.set(owner, active); + return () => { + active.delete(token); + if (active.size === 0 && liveSinkOwners.get(owner) === active) + liveSinkOwners.delete(owner); + }; +} +function hasLiveOptions(options?: ExecuteOptions): boolean { + return ( + options?.capture !== undefined || + options?.stdout !== undefined || + options?.stderr !== undefined + ); +} +function validateLiveOptions(options?: ExecuteOptions): void { + if (!hasLiveOptions(options)) return; + if (options?.onOutput) + throw new TypeError( + "use stdout/stderr byte sinks instead of onOutput with streaming options", + ); + if ( + options?.capture !== undefined && + (!Number.isInteger(options.capture) || + options.capture < 0 || + options.capture > 0xffffffff) + ) { + throw new TypeError("capture must be a non-negative 32-bit byte limit"); + } + for (const sink of [options?.stdout, options?.stderr]) + if (sink !== undefined && typeof sink !== "function") + throw new TypeError("output sinks must be callable"); +} +function executeNative( + native: NativeBashType | NativeBashToolType, + commands: string, + options?: ExecuteOptions, + runtimeSession?: RuntimeSession, +): Promise { + if (!hasLiveOptions(options)) + return executeNativeRuntime( + native, + commands, + toNativeOnOutput(options?.onOutput), + runtimeSession, + ).finally(() => liveSinkOwners.delete(native)); + const wrap = (sink?: (bytes: Uint8Array) => void | Promise) => + sink === undefined + ? undefined + : AsyncResource.bind(async ([bytes]: [Uint8Array]) => { + const release = enterLiveOwner(native); + try { + await sink(bytes); + } catch (error) { + throw streamingError(error); + } finally { + release(); + } + }); + return native + .executeLive( + commands, + options?.capture, + wrap(options?.stdout), + wrap(options?.stderr), + runtimeSession?.dispatch, + ) + .finally(() => liveSinkOwners.delete(native)); +} + /** * Wrap a {@link BuiltinCallback} for the native `addBuiltin` ABI. * @@ -788,6 +960,7 @@ export class Bash { this.maxInputBytes = options?.maxInputBytes ?? DEFAULT_MAX_INPUT_BYTES; this.native = configureRuntimeFunctions(this.native, options); registerCustomBuiltins(this.native, options?.customBuiltins); + registerCommands(this.native, options?.commands); } /** @@ -811,9 +984,18 @@ export class Bash { instance.maxInputBytes = options?.maxInputBytes ?? DEFAULT_MAX_INPUT_BYTES; instance.native = configureRuntimeFunctions(instance.native, options); registerCustomBuiltins(instance.native, options?.customBuiltins); + registerCommands(instance.native, options?.commands); return instance; } + /** Register a live command with async byte I/O and separate exit status. */ + addCommand(name: string, callback: CommandCallback): void { + this.native.addCommand( + name, + makeCommandDispatcher(callback, new WeakRef(this.native)), + ); + } + /** * Register a JS callback as a custom bash builtin. * @@ -868,6 +1050,8 @@ export class Bash { * same instance from `onOutput` via `execute*`, `readFile`, `fs()`, etc. */ executeSync(commands: string, options?: ExecuteOptions): ExecResult { + if (hasLiveOptions(options)) + throw new TypeError("streaming I/O requires async execute()"); const inputLimitResult = inputTooLargeExecResult( commands, this.maxInputBytes, @@ -914,8 +1098,10 @@ export class Bash { commands: string, options?: ExecuteOptions, ): Promise { + if (liveSinkOwners.has(this.native)) + throw new Error("host callbacks cannot re-enter the same Bash instance"); + validateLiveOptions(options); const runtimeSession = captureRuntimeSession(this.native); - const nativeOnOutput = toNativeOnOutput(options?.onOutput); const signal = options?.signal; if (signal?.aborted) { return cancelledExecResult(); @@ -939,10 +1125,10 @@ export class Bash { }; signal.addEventListener("abort", onAbort, { once: true }); try { - return await executeNativeRuntime( + return await executeNative( this.native, commands, - nativeOnOutput, + options, runtimeSession, ); } finally { @@ -952,12 +1138,7 @@ export class Bash { } } } - return executeNativeRuntime( - this.native, - commands, - nativeOnOutput, - runtimeSession, - ); + return executeNative(this.native, commands, options, runtimeSession); }).finally(() => runtimeSession?.finish()); } @@ -1386,6 +1567,7 @@ export class BashTool { this.maxInputBytes = options?.maxInputBytes ?? DEFAULT_MAX_INPUT_BYTES; this.native = configureRuntimeFunctions(this.native, options); registerCustomBuiltins(this.native, options?.customBuiltins); + registerCommands(this.native, options?.commands); } /** @@ -1398,9 +1580,18 @@ export class BashTool { instance.maxInputBytes = options?.maxInputBytes ?? DEFAULT_MAX_INPUT_BYTES; instance.native = configureRuntimeFunctions(instance.native, options); registerCustomBuiltins(instance.native, options?.customBuiltins); + registerCommands(instance.native, options?.commands); return instance; } + /** Register a live command with async byte I/O and separate exit status. */ + addCommand(name: string, callback: CommandCallback): void { + this.native.addCommand( + name, + makeCommandDispatcher(callback, new WeakRef(this.native)), + ); + } + /** Register a JS callback as a custom builtin. See {@link Bash.addBuiltin}. */ addBuiltin(name: string, callback: BuiltinCallback): void { this.native.addBuiltin(name, makeBuiltinDispatcher(callback)); @@ -1424,6 +1615,8 @@ export class BashTool { * via `execute*`, `readFile`, `fs()`, etc. */ executeSync(commands: string, options?: ExecuteOptions): ExecResult { + if (hasLiveOptions(options)) + throw new TypeError("streaming I/O requires async execute()"); const inputLimitResult = inputTooLargeExecResult( commands, this.maxInputBytes, @@ -1462,8 +1655,10 @@ export class BashTool { commands: string, options?: ExecuteOptions, ): Promise { + if (liveSinkOwners.has(this.native)) + throw new Error("host callbacks cannot re-enter the same Bash instance"); + validateLiveOptions(options); const runtimeSession = captureRuntimeSession(this.native); - const nativeOnOutput = toNativeOnOutput(options?.onOutput); const signal = options?.signal; if (signal?.aborted) { return cancelledExecResult(); @@ -1487,10 +1682,10 @@ export class BashTool { }; signal.addEventListener("abort", onAbort, { once: true }); try { - return await executeNativeRuntime( + return await executeNative( this.native, commands, - nativeOnOutput, + options, runtimeSession, ); } finally { @@ -1500,12 +1695,7 @@ export class BashTool { } } } - return executeNativeRuntime( - this.native, - commands, - nativeOnOutput, - runtimeSession, - ); + return executeNative(this.native, commands, options, runtimeSession); }).finally(() => runtimeSession?.finish()); } @@ -1648,6 +1838,7 @@ export class BashTool { ); instance.native = configureRuntimeFunctions(instance.native, options); registerCustomBuiltins(instance.native, options?.customBuiltins); + registerCommands(instance.native, options?.commands); instance.maxInputBytes = options?.maxInputBytes ?? DEFAULT_MAX_INPUT_BYTES; return instance; } @@ -1669,6 +1860,7 @@ export class BashTool { ); instance.native = configureRuntimeFunctions(instance.native, options); registerCustomBuiltins(instance.native, options?.customBuiltins); + registerCommands(instance.native, options?.commands); instance.maxInputBytes = options?.maxInputBytes ?? DEFAULT_MAX_INPUT_BYTES; return instance; } diff --git a/crates/bashkit-python/bashkit/__init__.py b/crates/bashkit-python/bashkit/__init__.py index ddf1cb419..0ce8deb3f 100644 --- a/crates/bashkit-python/bashkit/__init__.py +++ b/crates/bashkit-python/bashkit/__init__.py @@ -65,6 +65,12 @@ get_version, ) +# Streaming command I/O needs the native asyncio bridge. +try: + from bashkit._bashkit import CommandContext, CommandInput, CommandOutput, CommandStatus +except ImportError: + pass # Reduced Pyodide builds do not export the native async command API. + __version__ = get_version() __all__ = [ "AnalyzedCommand", @@ -74,6 +80,10 @@ "ExecutionProfile", "BuiltinContext", "BuiltinResult", + "CommandContext", + "CommandInput", + "CommandOutput", + "CommandStatus", "BashTool", "CapabilityFingerprint", "ExecResult", @@ -107,3 +117,9 @@ def __getattr__(name: str): ) return getattr(importlib.import_module(target), name) raise AttributeError(f"module 'bashkit' has no attribute {name!r}") + + +if "CommandStatus" not in globals(): + __all__ = [ + name for name in __all__ if name not in {"CommandContext", "CommandInput", "CommandOutput", "CommandStatus"} + ] diff --git a/crates/bashkit-python/bashkit/_bashkit.pyi b/crates/bashkit-python/bashkit/_bashkit.pyi index 000047da6..d8f94fd19 100644 --- a/crates/bashkit-python/bashkit/_bashkit.pyi +++ b/crates/bashkit-python/bashkit/_bashkit.pyi @@ -136,6 +136,39 @@ BuiltinCallback = Callable[ str | BuiltinResult | Awaitable[str | BuiltinResult], ] +class CommandStatus: + """Command completion, separate from output. Native async API only.""" + + exit_code: int + def __init__(self, exit_code: int = 0) -> None: ... + +class CommandInput: + """Request-scoped byte reader. Clones share the cursor.""" + + def __aiter__(self) -> CommandInput: ... + async def __anext__(self) -> bytes: ... + async def read_chunk(self) -> bytes | None: ... + +class CommandOutput: + """Request-scoped byte writer. Awaiting write applies backpressure.""" + + async def write(self, data: bytes) -> None: ... + +class CommandContext: + """Live command capabilities; I/O is revoked when execution ends.""" + + name: str + argv: list[str] + env: dict[str, str] + cwd: str + fs: FileSystem + stdin: CommandInput + stdout: CommandOutput + stderr: CommandOutput + +CommandCallback = Callable[[CommandContext], Awaitable[CommandStatus | int]] +ByteSink = Callable[[bytes], Awaitable[None]] + class FileSystem: """Direct access to Bashkit's virtual filesystem or a standalone mountable FS. @@ -569,7 +602,19 @@ class Bash: """ ... - async def execute(self, commands: str, on_output: OutputHandler | None = None) -> ExecResult: + def add_command(self, name: str, callback: CommandCallback) -> None: + """Register a live byte command; requires native async execute().""" + ... + + async def execute( + self, + commands: str, + on_output: OutputHandler | None = None, + *, + capture: int | None = None, + stdout: ByteSink | None = None, + stderr: ByteSink | None = None, + ) -> ExecResult: """Execute bash commands asynchronously. Args: @@ -1194,7 +1239,19 @@ class BashTool: """ ... - async def execute(self, commands: str, on_output: OutputHandler | None = None) -> ExecResult: + def add_command(self, name: str, callback: CommandCallback) -> None: + """Register a live byte command; requires native async execute().""" + ... + + async def execute( + self, + commands: str, + on_output: OutputHandler | None = None, + *, + capture: int | None = None, + stdout: ByteSink | None = None, + stderr: ByteSink | None = None, + ) -> ExecResult: """Execute bash commands asynchronously. Async ``custom_builtins`` callbacks run on the caller's active asyncio diff --git a/crates/bashkit-python/src/lib.rs b/crates/bashkit-python/src/lib.rs index 43028df90..815931872 100644 --- a/crates/bashkit-python/src/lib.rs +++ b/crates/bashkit-python/src/lib.rs @@ -14,6 +14,8 @@ // wasm rather than scattering per-item cfgs through shared conversion helpers. #![cfg_attr(target_arch = "wasm32", allow(dead_code, unused_imports))] +#[cfg(not(target_arch = "wasm32"))] +mod streaming; mod terminal; // interop (capsule FS handoff), realfs (host mounts) and the credential-injection @@ -3450,6 +3452,7 @@ impl PyCallbackEngine { struct PyCallbackSession { state: StdMutex, + cancel_requested: AtomicBool, active_caller_tasks: Arc>>>, private_async_loop: Arc, engine: Arc, @@ -3479,6 +3482,7 @@ impl PyCallbackSession { needs_async_callbacks, use_caller_loop, )?), + cancel_requested: AtomicBool::new(false), active_caller_tasks: Arc::new(StdMutex::new(Vec::new())), private_async_loop, engine, @@ -3515,6 +3519,9 @@ impl PyCallbackSession { } fn cancel_active_caller_tasks(&self, py: Python<'_>) -> PyResult<()> { + // Python cancellation must be visible before the native bridge handles + // its wakeup: a queued request may acquire the shell mutex first. + self.cancel_requested.store(true, Ordering::Release); let Some(locals) = self.current_caller_loop_locals() else { return Ok(()); }; @@ -3550,6 +3557,13 @@ impl PyCallbackSession { self.engine.invoke(py, &context, callback, args) } + fn check_cancelled(&self) -> PyResult<()> { + if self.cancel_requested.load(Ordering::Acquire) { + return Err(Python::attach(asyncio_cancelled_error)?); + } + Ok(()) + } + fn run_awaitable_on_private_loop( &self, py: Python<'_>, @@ -4345,6 +4359,7 @@ async fn exec_bash_with_optional_output( ) -> PyResult { let mut execution_extensions = ExecutionExtensions::new(); if let Some(session) = builtin_session { + session.check_cancelled()?; let _ = execution_extensions.insert(session); } @@ -4375,7 +4390,8 @@ async fn exec_bash_with_optional_output( .await }; - Ok(py_exec_result_from_bash_result(result)) + let converted = py_exec_result_from_bash_result(result); + Ok(converted) } // Decision: external functions share the custom-builtin callback session. The @@ -5108,27 +5124,61 @@ impl PyBash { /// pyo3-async-runtimes, which the WebAssembly (Pyodide) build omits. On wasm, /// use `execute_sync()`. See knowledge/runtimes/emscripten-wheels.md. #[cfg(not(target_arch = "wasm32"))] - #[pyo3(signature = (commands, on_output=None))] + #[pyo3(signature = (commands, on_output=None, *, capture=None, stdout=None, stderr=None))] fn execute<'py>( &self, py: Python<'py>, commands: String, on_output: Option>, + capture: Option, + stdout: Option>, + stderr: Option>, ) -> PyResult> { self.reject_external_handler_reentry()?; - let builtin_session = capture_custom_builtin_session( - py, - &self.custom_builtins, - &self.builtin_engine, - true, - self.external_handler.is_some(), - )?; + let live_output = capture.is_some() || stdout.is_some() || stderr.is_some(); + if live_output && on_output.is_some() { + return Err(PyTypeError::new_err( + "use stdout/stderr byte sinks instead of on_output with streaming options", + )); + } + streaming::validate_sink(py, stdout.as_ref())?; + streaming::validate_sink(py, stderr.as_ref())?; + let builtin_session = if live_output { + Some(PyCallbackSession::capture( + py, + self.builtin_engine.clone(), + true, + true, + PySyncLoopMode::SharedAcrossSessions, + )?) + } else { + capture_custom_builtin_session( + py, + &self.custom_builtins, + &self.builtin_engine, + true, + self.external_handler.is_some(), + )? + }; let on_output = prepare_output_handler(py, on_output)?; let inner = self.inner.clone(); let cancel_session = builtin_session.clone(); let future = future_into_py(py, async move { let mut bash = inner.lock().await; - exec_bash_with_optional_output(&mut bash, &commands, on_output, builtin_session).await + if live_output { + streaming::execute( + &mut bash, + &commands, + capture, + stdout, + stderr, + builtin_session.expect("live output session"), + ) + .await + } else { + exec_bash_with_optional_output(&mut bash, &commands, on_output, builtin_session) + .await + } })?; if let Some(cancel_session) = cancel_session { attach_future_cancellation_callback(py, &future, cancel_session.clone())?; @@ -5257,6 +5307,18 @@ impl PyBash { /// Override precedence matches `custom_builtins` at construction time: /// shell function > POSIX special builtin > custom builtin > baked-in /// builtin > `PATH`. + #[cfg(not(target_arch = "wasm32"))] + fn add_command(&self, py: Python<'_>, name: String, callback: Py) -> PyResult<()> { + streaming::register( + py, + name, + callback, + &self.rt, + &self.custom_builtins, + &self.host_registry, + ) + } + fn add_builtin(&self, py: Python<'_>, name: String, callback: Py) -> PyResult<()> { let entry = build_py_custom_builtin_entry(py, name.clone(), callback)?; let adapter = PyCustomBuiltinAdapter::from_entry(py, &entry, &self.rt); @@ -6274,27 +6336,61 @@ impl BashTool { /// Native-only (see `Bash.execute`). Use `execute_sync()` on wasm. #[cfg(not(target_arch = "wasm32"))] - #[pyo3(signature = (commands, on_output=None))] + #[pyo3(signature = (commands, on_output=None, *, capture=None, stdout=None, stderr=None))] fn execute<'py>( &self, py: Python<'py>, commands: String, on_output: Option>, + capture: Option, + stdout: Option>, + stderr: Option>, ) -> PyResult> { self.reject_external_handler_reentry()?; - let builtin_session = capture_custom_builtin_session( - py, - &self.custom_builtins, - &self.builtin_engine, - true, - self.external_handler.is_some() || self.typescript_external_handler.is_some(), - )?; + let live_output = capture.is_some() || stdout.is_some() || stderr.is_some(); + if live_output && on_output.is_some() { + return Err(PyTypeError::new_err( + "use stdout/stderr byte sinks instead of on_output with streaming options", + )); + } + streaming::validate_sink(py, stdout.as_ref())?; + streaming::validate_sink(py, stderr.as_ref())?; + let builtin_session = if live_output { + Some(PyCallbackSession::capture( + py, + self.builtin_engine.clone(), + true, + true, + PySyncLoopMode::SharedAcrossSessions, + )?) + } else { + capture_custom_builtin_session( + py, + &self.custom_builtins, + &self.builtin_engine, + true, + self.external_handler.is_some() || self.typescript_external_handler.is_some(), + )? + }; let on_output = prepare_output_handler(py, on_output)?; let inner = self.inner.clone(); let cancel_session = builtin_session.clone(); let future = future_into_py(py, async move { let mut bash = inner.lock().await; - exec_bash_with_optional_output(&mut bash, &commands, on_output, builtin_session).await + if live_output { + streaming::execute( + &mut bash, + &commands, + capture, + stdout, + stderr, + builtin_session.expect("live output session"), + ) + .await + } else { + exec_bash_with_optional_output(&mut bash, &commands, on_output, builtin_session) + .await + } })?; if let Some(cancel_session) = cancel_session { attach_future_cancellation_callback(py, &future, cancel_session.clone())?; @@ -6405,6 +6501,18 @@ impl BashTool { /// Register a Python callback as a custom bash builtin. /// See [`PyBash::add_builtin`] for semantics. + #[cfg(not(target_arch = "wasm32"))] + fn add_command(&self, py: Python<'_>, name: String, callback: Py) -> PyResult<()> { + streaming::register( + py, + name, + callback, + &self.rt, + &self.custom_builtins, + &self.host_registry, + ) + } + fn add_builtin(&self, py: Python<'_>, name: String, callback: Py) -> PyResult<()> { let entry = build_py_custom_builtin_entry(py, name.clone(), callback)?; let adapter = PyCustomBuiltinAdapter::from_entry(py, &entry, &self.rt); @@ -7415,6 +7523,8 @@ fn _bashkit(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + #[cfg(not(target_arch = "wasm32"))] + streaming::add_classes(m)?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/crates/bashkit-python/src/streaming.rs b/crates/bashkit-python/src/streaming.rs new file mode 100644 index 000000000..7a1c88a16 --- /dev/null +++ b/crates/bashkit-python/src/streaming.rs @@ -0,0 +1,271 @@ +//! Native Python live command I/O. Each operation stays under the originating +//! Rust execution budget; Python never blocks the interpreter while awaiting it. + +use super::*; +use bashkit::{Command, CommandContext, CommandInput, CommandOutput, CommandStatus}; +use pyo3::exceptions::PyStopAsyncIteration; + +#[pyclass(name = "CommandStatus", module = "bashkit", get_all, from_py_object)] +#[derive(Clone)] +pub(crate) struct PyCommandStatus { + pub exit_code: i32, +} + +#[pymethods] +impl PyCommandStatus { + #[new] + #[pyo3(signature = (exit_code=0))] + fn new(exit_code: i32) -> Self { + Self { exit_code } + } +} + +#[pyclass(name = "CommandInput", module = "bashkit", skip_from_py_object)] +pub(crate) struct PyCommandInput { + input: CommandInput, +} + +#[pymethods] +impl PyCommandInput { + fn __aiter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + fn __anext__<'py>(&self, py: Python<'py>) -> PyResult> { + let input = self.input.clone(); + future_into_py(py, async move { + let chunk = input + .read_chunk() + .await + .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + match chunk { + Some(bytes) => { + Python::attach(|py| Ok(PyBytes::new(py, &bytes).into_any().unbind())) + } + None => Err(PyStopAsyncIteration::new_err(())), + } + }) + } + fn read_chunk<'py>(&self, py: Python<'py>) -> PyResult> { + let input = self.input.clone(); + future_into_py(py, async move { + let bytes = input + .read_chunk() + .await + .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + Python::attach(|py| { + Ok(bytes.map_or_else(|| py.None(), |b| PyBytes::new(py, &b).into_any().unbind())) + }) + }) + } +} + +#[pyclass(name = "CommandOutput", module = "bashkit", skip_from_py_object)] +pub(crate) struct PyCommandOutput { + output: CommandOutput, +} + +#[pymethods] +impl PyCommandOutput { + fn write<'py>(&self, py: Python<'py>, bytes: Vec) -> PyResult> { + let output = self.output.clone(); + future_into_py(py, async move { + output.write_all(&bytes).await.map_err(|e| match e { + bashkit::Error::Io(e) if e.kind() == std::io::ErrorKind::BrokenPipe => { + pyo3::exceptions::PyBrokenPipeError::new_err("pipe reader closed") + } + e => PyRuntimeError::new_err(e.to_string()), + })?; + Ok(()) + }) + } +} + +#[pyclass( + name = "CommandContext", + module = "bashkit", + get_all, + skip_from_py_object +)] +pub(crate) struct PyCommandContext { + name: String, + argv: Vec, + env: HashMap, + cwd: String, + fs: Py, + stdin: Py, + stdout: Py, + stderr: Py, +} + +struct PyCommand { + name: String, + callback: Py, + rt: PyRuntime, +} + +#[async_trait] +impl Command for PyCommand { + async fn execute(&self, ctx: CommandContext<'_>) -> bashkit::Result { + let session = ctx + .execution_extension::>() + .ok_or_else(|| bashkit::Error::Execution("missing Python command session".into()))?; + let callback_session = session + .try_with(Clone::clone) + .map_err(|_| bashkit::Error::Cancelled)?; + let arg = Python::attach(|py| -> PyResult> { + Ok(Py::new( + py, + PyCommandContext { + name: self.name.clone(), + argv: ctx.args.to_vec(), + env: ctx.env.clone(), + cwd: ctx.cwd.to_string_lossy().into_owned(), + fs: Py::new( + py, + PyFileSystem::from_static(ctx.fs.clone(), self.rt.clone()), + )?, + stdin: Py::new( + py, + PyCommandInput { + input: ctx.stdin.clone(), + }, + )?, + stdout: Py::new( + py, + PyCommandOutput { + output: ctx.stdout.clone(), + }, + )?, + stderr: Py::new( + py, + PyCommandOutput { + output: ctx.stderr.clone(), + }, + )?, + }, + )? + .into_any()) + }) + .map_err(|e| bashkit::Error::Execution(e.to_string()))?; + let result = session + .run(call_python_callback_async( + callback_session, + &self.name, + &self.callback, + vec![arg], + )) + .await + .map_err(|_| bashkit::Error::Cancelled)?; + match result { + Ok(value) => Python::attach(|py| { + let status = value + .bind(py) + .extract::>() + .map(|s| s.exit_code) + .or_else(|_| value.bind(py).extract::()); + status.map(CommandStatus::with_code).map_err(|_| { + bashkit::Error::Execution("command must return CommandStatus or int".into()) + }) + }), + Err(message) => { + ctx.stderr.write_all(message.as_bytes()).await?; + Ok(CommandStatus::with_code(1)) + } + } + } +} + +pub(crate) fn register( + py: Python<'_>, + name: String, + callback: Py, + rt: &PyRuntime, + entries: &StdMutex>, + registry: &BuiltinRegistry, +) -> PyResult<()> { + let entry = build_py_custom_builtin_entry(py, name.clone(), callback)?; + if !entry.is_async { + return Err(PyTypeError::new_err( + "streaming commands must be async callables", + )); + } + let command = PyCommand { + name: name.clone(), + callback: entry.callback.clone_ref(py), + rt: rt.clone(), + }; + let mut entries = entries + .lock() + .map_err(|_| PyRuntimeError::new_err("command registry lock poisoned"))?; + entries.retain(|e| e.name != name); + entries.push(entry); + registry.insert_command(name, command); + Ok(()) +} + +pub(crate) async fn execute( + bash: &mut Bash, + script: &str, + capture: Option, + stdout: Option>, + stderr: Option>, + session: Arc, +) -> PyResult { + session.check_cancelled()?; + let mut extensions = ExecutionExtensions::new(); + let _ = extensions.insert(session.clone()); + let mut execution = bash + .execute(script) + .options(bashkit::ExecOptions::new().extensions(extensions)); + if let Some(limit) = capture { + execution = execution.capture(limit); + } + if let Some(callback) = stdout { + let session = session.clone(); + execution = execution.stdout_fn(move |bytes| { + let callback = Python::attach(|py| callback.clone_ref(py)); + let session = session.clone(); + async move { deliver(session, callback, bytes).await } + }); + } + if let Some(callback) = stderr { + let session = session.clone(); + execution = execution.stderr_fn(move |bytes| { + let callback = Python::attach(|py| callback.clone_ref(py)); + let session = session.clone(); + async move { deliver(session, callback, bytes).await } + }); + } + Ok(py_exec_result_from_bash_result(execution.await)) +} + +async fn deliver( + session: Arc, + callback: Py, + bytes: Vec, +) -> bashkit::Result<()> { + let args = Python::attach(|py| vec![PyBytes::new(py, &bytes).into_any().unbind()]); + call_python_callback_async(session, "output sink", &callback, args) + .await + .map(|_| ()) + .map_err(bashkit::Error::Execution) +} + +pub(crate) fn validate_sink(py: Python<'_>, callback: Option<&Py>) -> PyResult<()> { + if let Some(callback) = callback + && !is_coroutine_callable(py, callback.bind(py))? + { + return Err(PyTypeError::new_err( + "output sinks must be async callables receiving bytes", + )); + } + Ok(()) +} + +pub(crate) fn add_classes(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/crates/bashkit-python/tests/test_external_functions.py b/crates/bashkit-python/tests/test_external_functions.py index 8926f34db..f20a3983b 100644 --- a/crates/bashkit-python/tests/test_external_functions.py +++ b/crates/bashkit-python/tests/test_external_functions.py @@ -364,7 +364,8 @@ async def handler(name, args, kwargs): @pytest.mark.asyncio -async def test_cancelling_queued_request_preserves_active_handler(runtime): +@pytest.mark.parametrize("capture", [None, 64]) +async def test_cancelling_queued_request_preserves_active_handler(runtime, capture): started, released = asyncio.Event(), asyncio.Event() async def handler(name, args, kwargs): @@ -373,9 +374,9 @@ async def handler(name, args, kwargs): return {"value": 42} tool = runtime.make_tool(handler) - active = tool.execute(runtime.script) + active = tool.execute(runtime.script, capture=capture) await asyncio.wait_for(started.wait(), 5) - queued = tool.execute("echo must-not-run > /tmp/cancelled-request") + queued = tool.execute("echo must-not-run > /tmp/cancelled-request", capture=capture) queued.cancel() with pytest.raises(asyncio.CancelledError): await queued diff --git a/crates/bashkit-python/tests/test_streaming_commands.py b/crates/bashkit-python/tests/test_streaming_commands.py new file mode 100644 index 000000000..7873b2351 --- /dev/null +++ b/crates/bashkit-python/tests/test_streaming_commands.py @@ -0,0 +1,171 @@ +"""Streaming command I/O is live, byte-native and scoped to one execution.""" + +import asyncio + +import pytest + +from bashkit import Bash, BashTool, CommandStatus + + +@pytest.mark.parametrize("factory", [Bash, BashTool]) +@pytest.mark.parametrize("capture", [None, 64]) +async def test_cancelled_queued_command_does_not_write_files(factory, capture): + shell = factory() + started, released = asyncio.Event(), asyncio.Event() + + async def hold(ctx): + started.set() + await released.wait() + await ctx.stdout.write(b"done") + return CommandStatus() + + shell.add_command("hold", hold) + active = shell.execute("hold", capture=capture) + await asyncio.wait_for(started.wait(), 5) + queued = shell.execute("echo forbidden > /tmp/cancelled-request", capture=capture) + queued.cancel() + with pytest.raises(asyncio.CancelledError): + await queued + released.set() + result = await asyncio.wait_for(active, 5) + assert result.stdout_bytes == b"done" + assert not shell.exists("/tmp/cancelled-request") + + +@pytest.mark.parametrize("runtime", ["python", "typescript"]) +async def test_host_functions_work_with_byte_sinks(runtime): + loop = asyncio.get_running_loop() + chunks = [] + + async def lookup(name, args, *kwargs): + assert asyncio.get_running_loop() is loop + assert name == "lookup" + await asyncio.sleep(0) + return args[0] * 2 + + async def sink(chunk): + chunks.append(chunk) + + if runtime == "python": + tool = BashTool( + python=True, + env={"BASHKIT_ALLOW_INPROCESS_PYTHON": "1"}, + external_functions=["lookup"], + external_handler=lookup, + ) + script = "python -c 'print(lookup(21))'" + else: + tool = BashTool( + typescript=True, + typescript_external_functions=["lookup"], + typescript_external_handler=lookup, + ) + script = "ts -c 'await lookup(21)'" + + result = await tool.execute(script, capture=64, stdout=sink) + assert result.error is None + assert result.exit_code == 0 + assert result.stdout_bytes == b"42\n" + assert b"".join(chunks) == result.stdout_bytes + + +@pytest.mark.parametrize("factory", [Bash, BashTool]) +async def test_final_filter_delivers_while_producer_is_still_running(factory): + shell = factory() + release = asyncio.Event() + first = asyncio.Event() + chunks = [] + + async def producer(ctx): + await ctx.stdout.write(b"error: first\n") + await release.wait() + await ctx.stdout.write(b"error: last\n") + return CommandStatus() + + async def copy(ctx): + async for chunk in ctx.stdin: + await ctx.stdout.write(chunk) + return CommandStatus() + + async def sink(chunk): + assert isinstance(chunk, bytes) + chunks.append(chunk) + first.set() + + shell.add_command("produce", producer) + shell.add_command("copy", copy) + execution = asyncio.ensure_future(shell.execute("produce | copy | grep error", capture=1024, stdout=sink)) + try: + await asyncio.wait_for(first.wait(), timeout=2) + assert not execution.done() + assert b"".join(chunks) == b"error: first\n" + finally: + release.set() + result = await asyncio.wait_for(execution, timeout=2) + assert result.error is None + assert result.stdout_bytes == b"error: first\nerror: last\n" + assert b"".join(chunks) == result.stdout_bytes + + +async def test_arbitrary_bytes_capture_limits_and_revoked_command_io(): + shell = Bash() + contexts = [] + + async def binary(ctx): + contexts.append(ctx) + await ctx.stdout.write(b"\x00\xffabcdefgh") + return CommandStatus(exit_code=7) + + shell.add_command("binary", binary) + result = await shell.execute("binary", capture=3) + assert result.stdout_bytes == b"\x00\xffa" + assert result.stdout_truncated + assert result.exit_code == 7 + with pytest.raises(RuntimeError, match="request closed"): + await contexts[0].stdout.write(b"late") + with pytest.raises(RuntimeError, match="request closed"): + await contexts[0].stdin.read_chunk() + result = await shell.execute("echo recovered", capture=64) + assert result.stdout_bytes == b"recovered\n" + + +async def test_sinks_without_capture_do_not_retain_output(): + shell = Bash() + chunks = [] + + async def sink(chunk): + chunks.append(chunk) + + result = await shell.execute("echo hello", stdout=sink) + assert result.stdout_bytes == b"" + assert not result.stdout_truncated + assert b"".join(chunks) == b"hello\n" + + +async def test_sync_sink_and_mixed_observer_options_are_rejected(): + shell = Bash() + with pytest.raises(TypeError, match="async callable"): + await shell.execute("echo hello", stdout=lambda chunk: None) + with pytest.raises(TypeError, match="instead of on_output"): + await shell.execute("echo hello", on_output=lambda a, b: None, capture=64) + + +async def test_sink_failure_and_cancellation_release_the_execution(): + shell = Bash() + + async def failed_sink(chunk): + raise ValueError("sink failed") + + result = await shell.execute("echo hello", stdout=failed_sink) + assert result.exit_code == 1 + assert result.error is not None + assert (await shell.execute("echo recovered", capture=64)).stdout_bytes == b"recovered\n" + + async def pending_sink(chunk): + shell.cancel() + await asyncio.Event().wait() + + result = await asyncio.wait_for(shell.execute("echo hello", stdout=pending_sink), timeout=2) + assert result.error is not None + shell.clear_cancel() + assert (await shell.execute("echo recovered", capture=64)).stdout_bytes == b"recovered\n" diff --git a/crates/bashkit-wasm/__test__/streaming.test.mjs b/crates/bashkit-wasm/__test__/streaming.test.mjs new file mode 100644 index 000000000..2da0e3d6a --- /dev/null +++ b/crates/bashkit-wasm/__test__/streaming.test.mjs @@ -0,0 +1,66 @@ +// Real single-threaded WASM package: no native addon or shared-memory fallback. +import {test} from "node:test"; +import assert from "node:assert/strict"; +import {readFileSync} from "node:fs"; +import {Bash, initBashkit} from "../pkg/index.js"; +await initBashkit(readFileSync(new URL("../pkg/bashkit_wasm_bg.wasm", import.meta.url))); + +test("live final filter, bounded capture and request-scoped binary I/O", {timeout: 5000}, async () => { + let release; + const gate = new Promise((resolve) => {release = resolve;}); + const encoder = new TextEncoder(); + let saved; + const shell = new Bash({commands: { + async produce(ctx) { + saved = ctx; + await ctx.stdout.write(encoder.encode("error: first\n")); + await gate; + await ctx.stdout.write(encoder.encode("error: last\n")); + return 0; + }, + async copy(ctx) { + for await (const bytes of ctx.stdin) await ctx.stdout.write(bytes); + return {exitCode: 0}; + }, + async binary(ctx) {await ctx.stdout.write(Uint8Array.of(0, 255, 97, 98)); return 7;}, + }}); + const chunks = []; + const result = await shell.execute("produce | copy | grep error", { + capture: 64, async stdout(bytes) {chunks.push(bytes); release(); await new Promise(r => setTimeout(r, 1));}, + }); + assert.equal(result.stdout, "error: first\nerror: last\n"); + assert.equal(Buffer.concat(chunks).toString(), result.stdout); + await assert.rejects(saved.stdout.write(Uint8Array.of(1)), /request closed/); + await assert.rejects(saved.stdin.readChunk(), /request closed/); + const binary = await shell.execute("binary", {capture: 3}); + assert.deepEqual([...binary.stdoutBytes], [0, 255, 97]); + assert.equal(binary.stdoutTruncated, true); + assert.equal(binary.exitCode, 7); +}); + +test("sink-only execution, sink failure, cancellation and recovery", {timeout: 5000}, async () => { + const shell = new Bash(); + const chunks = []; + const result = await shell.execute("echo hello", {stdout(bytes) {chunks.push(bytes);}}); + assert.equal(result.stdout, ""); + assert.equal(Buffer.concat(chunks).toString(), "hello\n"); + await assert.rejects(shell.execute("echo hello", {stdout() {throw new Error("sink failed");}}), /sink failed/); + const controller = new AbortController(); + await assert.rejects(shell.execute("echo hello", { + signal: controller.signal, + async stdout() {controller.abort(); await new Promise(() => {});}, + }), /cancelled/); + assert.equal((await shell.execute("echo recovered", {capture: 64})).stdout, "recovered\n"); +}); + + +test("signal-only options interrupt pending commands and preserve ordinary capture", {timeout: 5000}, async () => { + const controller = new AbortController(); + const shell = new Bash({commands: {async pending() { + controller.abort(); + await new Promise(() => {}); + return 0; + }}}); + await assert.rejects(shell.execute("pending", {signal: controller.signal}), /cancelled/); + assert.equal((await shell.execute("echo captured", {signal: new AbortController().signal})).stdout, "captured\n"); +}); diff --git a/crates/bashkit-wasm/js/index.d.ts b/crates/bashkit-wasm/js/index.d.ts index e306fece0..9a963b37f 100644 --- a/crates/bashkit-wasm/js/index.d.ts +++ b/crates/bashkit-wasm/js/index.d.ts @@ -39,6 +39,28 @@ export type CustomBuiltin = ( ctx: BuiltinRequest, ) => string | Promise; +/** Live byte I/O; handles are revoked when their execution ends. */ +export interface CommandContext { + readonly name: string; + readonly argv: string[]; + readonly env: Record; + readonly cwd: string; + readonly fs: FileSystem; + readonly stdin: AsyncIterable & { + readChunk(): Promise; + }; + readonly stdout: { write(bytes: Uint8Array): Promise }; + readonly stderr: { write(bytes: Uint8Array): Promise }; +} +export type CommandCallback = (ctx: CommandContext) => Promise; +export interface ExecuteOptions { + /** Maximum captured bytes per stream. Without capture, sinks retain no output. */ + capture?: number; + stdout?: (bytes: Uint8Array) => void | Promise; + stderr?: (bytes: Uint8Array) => void | Promise; + signal?: AbortSignal; +} + /** Closed set of named execution-policy baselines. */ export type ExecutionProfileName = "hardened" | "standard" | "interactive"; @@ -143,6 +165,8 @@ export interface BashOptions { fs?: HostFileSystem; /** JS callbacks registered as bash builtins. */ customBuiltins?: Record; + /** Async live byte commands. */ + commands?: Record; } /** Result of a bash execution. */ @@ -214,7 +238,9 @@ export class Bash { */ executeSync(commands: string): ExecResult; /** Execute asynchronously; supports async custom builtins. */ - execute(commands: string): Promise; + execute(commands: string, options?: ExecuteOptions): Promise; + /** Register an async byte command. */ + addCommand(name: string, callback: CommandCallback): void; /** Execute asynchronously and receive incremental stdout/stderr chunks. */ executeWithOutput( commands: string, @@ -250,7 +276,7 @@ export class Bash { } /** Options for constructing a {@link Terminal}. */ -export interface TerminalOptions extends BashOptions { +export interface TerminalOptions extends Omit { /** Rows (default 24, min 2). */ rows?: number; /** Columns (default 80, min 2). */ diff --git a/crates/bashkit-wasm/js/index.js b/crates/bashkit-wasm/js/index.js index 40064244d..a9017e35c 100644 --- a/crates/bashkit-wasm/js/index.js +++ b/crates/bashkit-wasm/js/index.js @@ -7,7 +7,32 @@ import init, * as glue from "./bashkit_wasm.js"; -const { Bash, ExecResult } = glue; +const { ExecResult } = glue; + +export class Bash extends glue.Bash { + constructor(options) { + super(options); + for (const [name, callback] of Object.entries(options?.commands ?? {})) this.addCommand(name, callback); + } + async execute(commands, options) { + const signal = options?.signal; + if (signal?.aborted) throw new DOMException("execution cancelled", "AbortError"); + let aborted = false; + const onAbort = () => { aborted = true; this.cancel(); }; + signal?.addEventListener("abort", onAbort, { once: true }); + try { return await super.execute(commands, options); } + finally { + signal?.removeEventListener("abort", onAbort); + if (aborted) this.clearCancel(); + } + } + executeSync(commands, options) { + if (options && (options.capture !== undefined || options.stdout || options.stderr)) { + throw new TypeError("streaming I/O requires async execute()"); + } + return super.executeSync(commands); + } +} /** * Interactive terminal session, or `undefined` when the bundle was built @@ -42,4 +67,4 @@ export function initBashkit(input) { return initPromise; } -export { Bash, ExecResult }; +export { ExecResult }; diff --git a/crates/bashkit-wasm/scripts/test-streaming-browser.mjs b/crates/bashkit-wasm/scripts/test-streaming-browser.mjs new file mode 100644 index 000000000..9c974d77c --- /dev/null +++ b/crates/bashkit-wasm/scripts/test-streaming-browser.mjs @@ -0,0 +1,90 @@ +// Optional real-browser gate. Requires Playwright and Chromium; no COOP/COEP. +// PLAYWRIGHT_MODULE and CHROME_EXECUTABLE can select installed dependencies. +import {createServer} from "node:http"; +import {readFile} from "node:fs/promises"; +import {resolve, extname} from "node:path"; +import {fileURLToPath} from "node:url"; +import {createRequire} from "node:module"; +import assert from "node:assert/strict"; +const {chromium} = process.env.PLAYWRIGHT_MODULE + ? await import(process.env.PLAYWRIGHT_MODULE) + : createRequire(import.meta.url)("playwright"); +const root = fileURLToPath(new URL("../pkg/", import.meta.url)); +const server = createServer(async (request, response) => { + if (request.url === "/") {response.end("Streaming test"); return;} + const path = resolve(root, `.${request.url.split("?")[0]}`); + if (!path.startsWith(root)) {response.writeHead(403).end(); return;} + try { + response.setHeader("Content-Type", extname(path) === ".wasm" ? "application/wasm" : "text/javascript"); + response.end(await readFile(path)); + } catch {response.writeHead(404).end();} +}); +await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); +const origin = `http://127.0.0.1:${server.address().port}`; +let browser; +try { + browser = await chromium.launch({executablePath: process.env.CHROME_EXECUTABLE ?? "/usr/bin/chromium", headless: true, args: ["--no-sandbox"]}); + const page = await browser.newPage(); + page.setDefaultTimeout(15000); + await page.goto(origin); + // The exact same scenario runs on the main thread and a module worker. + const scenario = async (origin) => { + const {Bash, initBashkit} = await import(`${origin}/index.js`); + await initBashkit(); + let release; + const gate = new Promise(resolve => {release = resolve;}); + const encoder = new TextEncoder(); + const shell = new Bash({commands: { + async produce(ctx) { + await ctx.stdout.write(encoder.encode("error: first\n")); + await gate; + await ctx.stdout.write(encoder.encode("error: last\n")); + return 0; + }, + async copy(ctx) {for await (const bytes of ctx.stdin) await ctx.stdout.write(bytes); return 0;}, + async binary(ctx) {await ctx.stdout.write(Uint8Array.of(0, 255, 97)); return 0;}, + }}); + const chunks = []; + let completed = false; + let deliveredBeforeCompletion = false; + const result = await shell.execute("produce | copy | grep error", { + capture: 64, async stdout(bytes) { + deliveredBeforeCompletion ||= !completed; + chunks.push(...bytes); + release(); + await new Promise(resolve => setTimeout(resolve, 2)); + }, + }); + completed = true; + const binary = await shell.execute("binary", {capture: 3}); + const controller = new AbortController(); + let cancelled = false; + try { + await shell.execute("echo pending", {signal: controller.signal, async stdout() { + controller.abort(); await new Promise(() => {}); + }}); + } catch (error) {cancelled = String(error).includes("cancelled");} + const recovered = await shell.execute("echo recovered", {capture: 64}); + return {deliveredBeforeCompletion, stdout: result.stdout, bytes: [...binary.stdoutBytes], cancelled, recovered: recovered.stdout, chunkText: new TextDecoder().decode(new Uint8Array(chunks))}; + }; + const expected = {deliveredBeforeCompletion: true, stdout: "error: first\nerror: last\n", bytes: [0, 255, 97], cancelled: true, recovered: "recovered\n", chunkText: "error: first\nerror: last\n"}; + const main = await page.evaluate(`Promise.race([(${scenario})(${JSON.stringify(origin)}), new Promise((_, reject) => setTimeout(() => reject(new Error("main thread timed out")), 15000))])`); + assert.deepEqual(main, expected); + const worker = await page.evaluate(async ({source, origin}) => { + const script = `(${source})(${JSON.stringify(origin)}).then(result => postMessage({result}), error => postMessage({error: String(error)}));`; + const url = URL.createObjectURL(new Blob([script], {type: "text/javascript"})); + const worker = new Worker(url, {type: "module"}); + try { + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("worker timed out")), 15000); + worker.onmessage = event => {clearTimeout(timer); event.data.error ? reject(new Error(event.data.error)) : resolve(event.data.result);}; + worker.onerror = event => {clearTimeout(timer); reject(new Error(event.message));}; + }); + } finally {worker.terminate(); URL.revokeObjectURL(url);} + }, {source: String(scenario), origin}); + assert.deepEqual(worker, expected); + console.log("Chromium main thread + worker: live delivery, bytes, async sink, cancellation and reuse passed"); +} finally { + await browser?.close(); + await new Promise(resolve => server.close(resolve)); +} diff --git a/crates/bashkit-wasm/src/lib.rs b/crates/bashkit-wasm/src/lib.rs index 6c9e19dce..e8b761a46 100644 --- a/crates/bashkit-wasm/src/lib.rs +++ b/crates/bashkit-wasm/src/lib.rs @@ -36,6 +36,7 @@ use wasm_bindgen::prelude::*; use wasm_bindgen_futures::{JsFuture, future_to_promise}; mod hostfs; +mod streaming; use hostfs::HostFs; #[cfg(feature = "terminal")] @@ -362,6 +363,7 @@ fn sanitize_js_error(value: &JsValue) -> String { /// Parsed, `Send`-free construction config. Kept so `reset()` can rebuild an /// equivalent interpreter. struct Config { + registry: bashkit::BuiltinRegistry, profile: bashkit::ExecutionProfileName, username: Option, hostname: Option, @@ -413,6 +415,19 @@ impl Bash { }) } + /// Register a live command. Cloned I/O handles expire with the execution. + #[wasm_bindgen(js_name = addCommand)] + pub fn add_command(&self, name: String, callback: js_sys::Function) { + self.config.registry.insert_command( + name.clone(), + streaming::JsCommand { + name, + callback: SendWrapper::new(callback), + in_sync: self.sync_flag.clone(), + }, + ); + } + /// Execute `commands` synchronously and return the result. /// /// Only valid for scripts that complete without yielding — i.e. plain bash @@ -446,15 +461,15 @@ impl Bash { // (e.g. execute() invoked from inside a builtin callback) into a clean error // instead of a panic, so holding it across the await point is sound here. #[allow(clippy::await_holding_refcell_ref)] - pub fn execute(&self, commands: String) -> js_sys::Promise { + pub fn execute(&self, commands: String, options: JsValue) -> js_sys::Promise { let inner = self.inner.clone(); future_to_promise(async move { let mut guard = inner.try_borrow_mut().map_err(|_| { JsError::new("bash instance is busy (reentrant execution is not supported)") })?; - match guard.exec(&commands).await { + match streaming::execute(&mut guard, &commands, options).await { Ok(r) => Ok(JsValue::from(ExecResult::from(r))), - Err(e) => Err(JsValue::from(JsError::new(&e.to_string()))), + Err(e) => Err(e), } }) } @@ -876,7 +891,7 @@ fn core_builder(config: &Config, sync_flag: &Arc) -> bashkit::BashBu ); } - builder + builder.builtin_registry(config.registry.clone()) } /// Seed pre-created files as normal writable VFS entries. @@ -896,6 +911,7 @@ fn seed_files(fs: &Arc, files: &[(String, String)]) -> Resu fn parse_options(options: &JsValue) -> Result { if options.is_undefined() || options.is_null() { return Ok(Config { + registry: bashkit::BuiltinRegistry::new(), profile: bashkit::ExecutionProfileName::Standard, username: None, hostname: None, @@ -981,6 +997,7 @@ fn parse_options(options: &JsValue) -> Result { } Ok(Config { + registry: bashkit::BuiltinRegistry::new(), profile, username: get_str("username"), hostname: get_str("hostname"), diff --git a/crates/bashkit-wasm/src/streaming.rs b/crates/bashkit-wasm/src/streaming.rs new file mode 100644 index 000000000..27d92fcfe --- /dev/null +++ b/crates/bashkit-wasm/src/streaming.rs @@ -0,0 +1,228 @@ +//! Single-threaded live JS commands and awaited byte sinks. + +use super::*; +use bashkit::{Command, CommandContext, CommandStatus}; + +#[wasm_bindgen(js_name = CommandInput)] +#[derive(Clone)] +pub struct Input { + inner: bashkit::CommandInput, +} + +#[wasm_bindgen(js_class = CommandInput)] +impl Input { + #[wasm_bindgen(js_name = readChunk)] + pub fn read_chunk(&self) -> js_sys::Promise { + let input = self.inner.clone(); + future_to_promise(async move { + let bytes = input.read_chunk().await.map_err(js_error)?; + Ok(bytes.map_or(JsValue::NULL, |b| { + js_sys::Uint8Array::from(b.as_slice()).into() + })) + }) + } + #[wasm_bindgen(js_name = "[Symbol.asyncIterator]")] + pub fn iterator(&self) -> Input { + self.clone() + } + pub fn next(&self) -> js_sys::Promise { + let input = self.inner.clone(); + future_to_promise(async move { + let bytes = input.read_chunk().await.map_err(js_error)?; + let result = js_sys::Object::new(); + js_sys::Reflect::set( + &result, + &"done".into(), + &JsValue::from_bool(bytes.is_none()), + )?; + if let Some(bytes) = bytes { + js_sys::Reflect::set( + &result, + &"value".into(), + &js_sys::Uint8Array::from(bytes.as_slice()), + )?; + } + Ok(result.into()) + }) + } +} + +#[wasm_bindgen(js_name = CommandOutput)] +pub struct Output { + inner: bashkit::CommandOutput, +} + +#[wasm_bindgen(js_class = CommandOutput)] +impl Output { + pub fn write(&self, bytes: js_sys::Uint8Array) -> js_sys::Promise { + let output = self.inner.clone(); + let bytes = bytes.to_vec(); + future_to_promise(async move { + output.write_all(&bytes).await.map_err(js_error)?; + Ok(JsValue::UNDEFINED) + }) + } +} + +fn js_error(error: impl ToString) -> JsValue { + JsError::new(&error.to_string()).into() +} + +pub(super) struct JsCommand { + pub name: String, + pub callback: SendWrapper, + pub in_sync: Arc, +} + +#[async_trait] +impl Command for JsCommand { + async fn execute(&self, ctx: CommandContext<'_>) -> bashkit::Result { + if self.in_sync.load(Ordering::SeqCst) { + return Err(bashkit::Error::Execution( + "streaming commands require async execute()".into(), + )); + } + // All JS objects are consumed before the Send-wrapped Promise await. + let promise = { + let request = BuiltinRequest { + name: &self.name, + argv: ctx.args, + stdin: None, + env: ctx.env, + cwd: ctx.cwd.to_string_lossy().into_owned(), + }; + let serializer = serde_wasm_bindgen::Serializer::json_compatible(); + let arg = request + .serialize(&serializer) + .map_err(|e| bashkit::Error::Execution(e.to_string()))?; + for (key, value) in [ + ( + "stdin", + JsValue::from(Input { + inner: ctx.stdin.clone(), + }), + ), + ( + "stdout", + JsValue::from(Output { + inner: ctx.stdout.clone(), + }), + ), + ( + "stderr", + JsValue::from(Output { + inner: ctx.stderr.clone(), + }), + ), + ("fs", JsValue::from(FileSystem::new(ctx.fs.clone()))), + ] { + js_sys::Reflect::set(&arg, &key.into(), &value).map_err(|_| { + bashkit::Error::Execution("failed to create command context".into()) + })?; + } + let value = self + .callback + .call1(&JsValue::UNDEFINED, &arg) + .map_err(|e| bashkit::Error::Execution(sanitize_js_error(&e)))?; + SendWrapper::new(JsFuture::from(js_sys::Promise::resolve(&value))) + }; + let result = promise.await; + match result { + Ok(value) => { + let code = value + .as_f64() + .or_else(|| { + js_sys::Reflect::get(&value, &"exitCode".into()) + .ok() + .and_then(|v| v.as_f64()) + }) + .filter(|n| n.fract() == 0.0 && *n >= i32::MIN as f64 && *n <= i32::MAX as f64) + .ok_or_else(|| { + bashkit::Error::Execution( + "command must return an integer exit status".into(), + ) + })?; + Ok(CommandStatus::with_code(code as i32)) + } + Err(error) => { + let message = sanitize_js_error(&error); + drop(error); + ctx.stderr.write_all(message.as_bytes()).await?; + Ok(CommandStatus::with_code(1)) + } + } + } +} + +fn sink(options: &JsValue, key: &str) -> Result>, JsError> { + if options.is_null() || options.is_undefined() { + return Ok(None); + } + let value = js_sys::Reflect::get(options, &key.into()) + .map_err(|_| JsError::new("invalid execution options"))?; + if value.is_null() || value.is_undefined() { + return Ok(None); + } + value + .dyn_into::() + .map(SendWrapper::new) + .map(Some) + .map_err(|_| JsError::new("output sinks must be callable")) +} + +pub(super) async fn execute( + bash: &mut CoreBash, + script: &str, + options: JsValue, +) -> Result { + let stdout = sink(&options, "stdout")?; + let stderr = sink(&options, "stderr")?; + let capture = if options.is_null() || options.is_undefined() { + JsValue::UNDEFINED + } else { + js_sys::Reflect::get(&options, &"capture".into())? + }; + let has_signal = !options.is_null() + && !options.is_undefined() + && js_sys::Reflect::get(&options, &"signal".into()) + .is_ok_and(|s| !s.is_null() && !s.is_undefined()); + let explicit_output = stdout.is_some() || stderr.is_some() || !capture.is_undefined(); + let live = explicit_output || has_signal; + if !live { + return bash.exec(script).await.map_err(js_error); + } + let mut execution = bash.execute(script); + if has_signal && !explicit_output { + // Signal-only execution keeps the buffered convenience result while + // using the driver that interrupts pending host Promises. + execution = execution.capture(usize::MAX); + } + if !capture.is_undefined() { + let limit = capture + .as_f64() + .filter(|n| n.fract() == 0.0 && *n >= 0.0 && *n <= u32::MAX as f64) + .ok_or_else(|| JsError::new("capture must be a non-negative 32-bit byte limit"))?; + execution = execution.capture(limit as usize); + } + if let Some(callback) = stdout { + execution = execution.stdout_fn(move |bytes| deliver(callback.clone(), bytes)); + } + if let Some(callback) = stderr { + execution = execution.stderr_fn(move |bytes| deliver(callback.clone(), bytes)); + } + execution.await.map_err(js_error) +} + +async fn deliver(callback: SendWrapper, bytes: Vec) -> bashkit::Result<()> { + let promise = { + let chunk = js_sys::Uint8Array::from(bytes.as_slice()); + let value = callback + .call1(&JsValue::UNDEFINED, &chunk) + .map_err(|e| bashkit::Error::Execution(sanitize_js_error(&e)))?; + SendWrapper::new(JsFuture::from(js_sys::Promise::resolve(&value))) + }; + promise + .await + .map(|_| ()) + .map_err(|e| bashkit::Error::Execution(sanitize_js_error(&e))) +} diff --git a/crates/bashkit/benches/results/criterion-parallel-79f1c12bcc44-linux-x86_64-1792174213.md b/crates/bashkit/benches/results/criterion-parallel-79f1c12bcc44-linux-x86_64-1792174213.md new file mode 100644 index 000000000..0a1a1a24a --- /dev/null +++ b/crates/bashkit/benches/results/criterion-parallel-79f1c12bcc44-linux-x86_64-1792174213.md @@ -0,0 +1,75 @@ +# Criterion Parallel Execution Benchmark + +## System Information + +- **Moniker**: `79f1c12bcc44-linux-x86_64` +- **Hostname**: 79f1c12bcc44 +- **OS**: linux +- **Architecture**: x86_64 +- **CPUs**: 5 +- **Timestamp**: 1792174213 + +## Workload Comparison (50 sessions) + +| Benchmark | Time | +|-----------|------| +| workload_types/light_sequential | 4.7671 ms | +| workload_types/light_parallel | 3.8270 ms | +| workload_types/medium_sequential | 19.462 ms | +| workload_types/medium_parallel | 11.116 ms | +| workload_types/heavy_sequential | 67.110 ms | +| workload_types/heavy_parallel | 40.803 ms | + +## Parallel Scaling (medium workload) + +| Benchmark | Time | +|-----------|------| +| parallel_scaling/medium_seq/10 | 5.7822 ms | +| parallel_scaling/medium_par/10 | 2.8052 ms | +| parallel_scaling/shared_fs/10 | 2.2985 ms | +| parallel_scaling/medium_seq/50 | 21.728 ms | +| parallel_scaling/medium_par/50 | 18.059 ms | +| parallel_scaling/shared_fs/50 | 12.548 ms | +| parallel_scaling/medium_seq/100 | 80.187 ms | +| parallel_scaling/medium_par/100 | 31.149 ms | +| parallel_scaling/shared_fs/100 | 17.277 ms | +| parallel_scaling/medium_seq/200 | 130.53 ms | +| parallel_scaling/medium_par/200 | 57.381 ms | +| parallel_scaling/shared_fs/200 | 46.624 ms | +| parallel_scaling/medium_seq/500 | 429.79 ms | +| parallel_scaling/medium_par/500 | 95.375 ms | +| parallel_scaling/shared_fs/500 | 74.238 ms | +| parallel_scaling/medium_seq/1000 | 485.25 ms | +| parallel_scaling/medium_par/1000 | 186.85 ms | +| parallel_scaling/shared_fs/1000 | 153.30 ms | + +## Single Operations + +| Benchmark | Time | +|-----------|------| +| single_bash_new | 51.480 µs | +| single_echo | 63.227 µs | +| single_file_write_read | 109.18 µs | +| single_grep | 136.37 µs | +| single_awk | 157.81 µs | +| single_sed | 110.85 µs | +| single_light_script | 94.890 µs | +| single_medium_script | 393.06 µs | +| single_heavy_script | 1.1201 ms | + +## Speedup Summary + +| Workload | Sequential | Parallel | Speedup | +|----------|-----------|----------|---------| +| light | 4.767 ms | 3.827 ms | **1.25x** | +| medium | 19.462 ms | 11.116 ms | **1.75x** | +| heavy | 67.110 ms | 40.803 ms | **1.64x** | + +| Sessions | Sequential | Parallel | Shared FS | Par Speedup | +|----------|-----------|----------|-----------|-------------| +| 10 | 5.782 ms | 2.805 ms | 2.299 ms | **2.06x** | +| 50 | 21.728 ms | 18.059 ms | 12.548 ms | **1.20x** | +| 100 | 80.187 ms | 31.149 ms | 17.277 ms | **2.57x** | +| 200 | 130.530 ms | 57.381 ms | 46.624 ms | **2.27x** | +| 500 | 429.790 ms | 95.375 ms | 74.238 ms | **4.51x** | +| 1000 | 485.250 ms | 186.850 ms | 153.300 ms | **2.60x** | diff --git a/crates/bashkit/docs/custom_builtins.md b/crates/bashkit/docs/custom_builtins.md index 60de52992..99d545f0a 100644 --- a/crates/bashkit/docs/custom_builtins.md +++ b/crates/bashkit/docs/custom_builtins.md @@ -12,6 +12,7 @@ safe: access fails deterministically after that `exec*()` completes or is cancel - [Hooks](./hooks.md) - Interceptor hooks for the execution pipeline - [Compatibility Reference](./compatibility.md) - Supported bash features - [Threat Model](./threat-model.md) - Security considerations +- [Streaming commands](streaming.md) - Live byte handlers, async sinks and explicit capture ## Quick Start diff --git a/crates/bashkit/docs/script-analysis.md b/crates/bashkit/docs/script-analysis.md index 4a522b78c..1faf41f37 100644 --- a/crates/bashkit/docs/script-analysis.md +++ b/crates/bashkit/docs/script-analysis.md @@ -242,14 +242,14 @@ assert_eq!(command.literal_args(), None); // not all args are liter Partial reconstruction is deliberately not offered: `"/tmp/$name.txt"` reports `None`, not `"/tmp/"`. A half-expanded path reads like a path but is not one. -## Contexts +## Command placement Commands inside substitutions and function bodies are walked, not skipped, `echo $(rm -rf /)` must never look like a bare `echo`. Each command carries a -[`CommandContext`](crate::CommandContext) so you can tell them apart: +[`CommandPlacement`](crate::CommandPlacement) so you can tell them apart: ```rust -use bashkit::{Bash, CommandContext}; +use bashkit::{Bash, CommandPlacement}; # fn main() -> bashkit::Result<()> { let bash = Bash::new(); @@ -257,9 +257,9 @@ let analysis = bash.analyze("cleanup() { rm -rf /data; }\necho $(date)")?; assert_eq!(analysis.functions, ["cleanup"]); assert_eq!(analysis.commands[0].name.as_deref(), Some("rm")); -assert_eq!(analysis.commands[0].context, CommandContext::FunctionBody); -assert_eq!(analysis.commands[1].context, CommandContext::Substitution); // date -assert_eq!(analysis.commands[2].context, CommandContext::Direct); // echo +assert_eq!(analysis.commands[0].context, CommandPlacement::FunctionBody); +assert_eq!(analysis.commands[1].context, CommandPlacement::Substitution); // date +assert_eq!(analysis.commands[2].context, CommandPlacement::Direct); // echo # Ok(()) # } ``` diff --git a/crates/bashkit/docs/streaming.md b/crates/bashkit/docs/streaming.md new file mode 100644 index 000000000..35be12dd0 --- /dev/null +++ b/crates/bashkit/docs/streaming.md @@ -0,0 +1,78 @@ +# Streaming commands + +Read command input and deliver output while a script runs. Register a `Command` +with `BashBuilder::command`; the handler writes bytes and returns its exit status +separately. + +**See also:** + +- [Custom builtins](custom_builtins.md), buffered handlers and shell extensions. +- [Streaming guide](https://bashkit.sh/docs/streaming/), Python/JS examples, runtime support and migration. +- [Execution API](https://docs.rs/bashkit/latest/bashkit/struct.Execution.html), all builder options. + +## Register a live command + +```rust +use bashkit::{async_trait, Bash, Command, CommandContext, CommandStatus}; + +struct Copy; + +#[async_trait] +impl Command for Copy { + async fn execute(&self, ctx: CommandContext<'_>) -> bashkit::Result { + while let Some(bytes) = ctx.stdin.read_chunk().await? { + ctx.stdout.write_all(&bytes).await?; + } + Ok(CommandStatus::success()) + } +} + +# #[tokio::main] +# async fn main() -> bashkit::Result<()> { +let mut bash = Bash::builder().command("copy", Copy).build(); +let mut delivered = Vec::new(); +let result = bash.execute("printf hello | copy") + .stdout(&mut delivered) + .capture(1024) + .await?; + +assert_eq!(delivered, b"hello"); +assert_eq!(result.stdout.as_bytes(), b"hello"); +assert_eq!(result.exit_code, 0); +# Ok(()) +# } +``` + +`.stdout(&mut writer)` and `.stderr(&mut writer)` accept Tokio async byte +writers. The example uses a `Vec` to collect delivered bytes in the host; +replace it with your destination's async writer to forward output. For async +callbacks, use `.stdout_fn(callback)` and `.stderr_fn(callback)`, which await +callbacks returning `Result<()>`. + +`.capture(n)` retains at most `n` bytes per stream in the final `ExecResult`. +Without capture, the result's output fields are empty. Output without a sink or +capture is drained and discarded. Use `as_bytes()` on captured `StreamData` for +exact bytes. + +Pass stdin, invocation arguments or request-scoped extensions through +`.options(ExecOptions)`. See the [Rust API reference](https://docs.rs/bashkit/latest/bashkit/struct.Execution.html) +for the full builder API. + +## Errors and cancellation + +Nonzero `exit_code` is an ordinary shell result. Sink failure, cancellation and +resource exhaustion return `Err`. A failed sink stops the engine, including +pending handlers. `stdout_truncated` and `stderr_truncated` report capture or +sandbox delivery truncation. + +`.cancel_with(token)` accepts an external atomic cancellation flag. The session's +`cancellation_token()` also remains active; reset a session cancellation flag +before the next execution. Deadlines cover pending sinks as well as engine work. +Command I/O and filesystem handles expire when the execution ends. + +Use buffered execution when `after_tool` or `after_exec` must inspect or replace +whole output. Live execution rejects those hooks and `ExecOptions` output +observers; use async byte sinks for live delivery. + +See the [public guide](https://bashkit.sh/docs/streaming/#capture-limits-and-flow-control) +for capture limits, memory ownership, routing and operations that still buffer. diff --git a/crates/bashkit/src/analysis.rs b/crates/bashkit/src/analysis.rs index 6e6c5376e..9e17fcdf9 100644 --- a/crates/bashkit/src/analysis.rs +++ b/crates/bashkit/src/analysis.rs @@ -11,7 +11,7 @@ //! unknown; enforcement stays with the builtin registry, the network //! allowlist, the mount policy, and the `before_tool` hook. //! Decision: substitutions and function bodies are walked (and tagged via -//! [`CommandContext`]) rather than skipped, so `echo $(rm -rf /)` never looks +//! [`CommandPlacement`]) rather than skipped, so `echo $(rm -rf /)` never looks //! like a bare `echo`. use crate::error::Result; @@ -63,7 +63,7 @@ pub fn is_command_wrapper(name: &str) -> bool { /// Where a command sits in the script. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] -pub enum CommandContext { +pub enum CommandPlacement { /// Runs when the script runs (possibly conditionally, e.g. an `if` branch). Direct, /// Inside `$(…)`, backticks, or a process substitution. @@ -72,13 +72,13 @@ pub enum CommandContext { FunctionBody, } -impl CommandContext { +impl CommandPlacement { /// Stable lowercase name, used by the language bindings. pub fn as_str(self) -> &'static str { match self { - CommandContext::Direct => "direct", - CommandContext::Substitution => "substitution", - CommandContext::FunctionBody => "function_body", + CommandPlacement::Direct => "direct", + CommandPlacement::Substitution => "substitution", + CommandPlacement::FunctionBody => "function_body", } } } @@ -120,7 +120,7 @@ pub struct AnalyzedCommand { /// One entry per argument; `None` when the argument is not fully literal. pub args: Vec>, /// Where the command sits. - pub context: CommandContext, + pub context: CommandPlacement, /// Names of prefix assignments (`FOO=1 cmd` → `["FOO"]`). pub assignments: Vec, } @@ -318,7 +318,7 @@ impl SourceCharacterIndex { /// Analyze an already-parsed script. pub(crate) fn analyze_ast(script: &Script) -> ScriptAnalysis { let mut walker = Walker::default(); - walker.walk_commands(&script.commands, CommandContext::Direct); + walker.walk_commands(&script.commands, CommandPlacement::Direct); walker.out } @@ -375,13 +375,13 @@ impl Walker { true } - fn walk_commands(&mut self, commands: &[Command], ctx: CommandContext) { + fn walk_commands(&mut self, commands: &[Command], ctx: CommandPlacement) { for command in commands { self.walk_command(command, ctx); } } - fn walk_command(&mut self, command: &Command, ctx: CommandContext) { + fn walk_command(&mut self, command: &Command, ctx: CommandPlacement) { match command { Command::Simple(simple) => self.walk_simple(simple, ctx), Command::Pipeline(pipeline) => self.walk_commands(&pipeline.commands, ctx), @@ -394,19 +394,19 @@ impl Walker { if !self.out.functions.iter().any(|f| f == &def.name) { self.out.functions.push(def.name.clone()); } - self.walk_command(&def.body, CommandContext::FunctionBody); + self.walk_command(&def.body, CommandPlacement::FunctionBody); } } } - fn walk_list(&mut self, list: &CommandList, ctx: CommandContext) { + fn walk_list(&mut self, list: &CommandList, ctx: CommandPlacement) { self.walk_command(&list.first, ctx); for (_, command) in &list.rest { self.walk_command(command, ctx); } } - fn walk_compound(&mut self, compound: &CompoundCommand, ctx: CommandContext) { + fn walk_compound(&mut self, compound: &CompoundCommand, ctx: CommandPlacement) { match compound { CompoundCommand::If(cmd) => { self.walk_commands(&cmd.condition, ctx); @@ -465,7 +465,7 @@ impl Walker { } } - fn walk_simple(&mut self, simple: &SimpleCommand, ctx: CommandContext) { + fn walk_simple(&mut self, simple: &SimpleCommand, ctx: CommandPlacement) { // Nested commands inside the name, arguments, and assignment values // (`$(…)`, `<(…)`) are recorded before the command itself, so the list // stays in source order. @@ -509,7 +509,7 @@ impl Walker { self.walk_redirects(&simple.redirects, ctx); } - fn walk_redirects(&mut self, redirects: &[Redirect], ctx: CommandContext) { + fn walk_redirects(&mut self, redirects: &[Redirect], ctx: CommandPlacement) { for redirect in redirects { let mode = match redirect.kind { // `<>` may create and overwrite the file: gate it as a write. @@ -542,7 +542,7 @@ impl Walker { } } - fn walk_words(&mut self, words: &[Word], ctx: CommandContext) { + fn walk_words(&mut self, words: &[Word], ctx: CommandPlacement) { for word in words { self.walk_word_parts(word, ctx); } @@ -552,11 +552,11 @@ impl Walker { /// /// A substitution inside a function body stays `FunctionBody`: it is still /// only reachable by calling the function. - fn walk_word_parts(&mut self, word: &Word, ctx: CommandContext) { - let nested = if ctx == CommandContext::FunctionBody { - CommandContext::FunctionBody + fn walk_word_parts(&mut self, word: &Word, ctx: CommandPlacement) { + let nested = if ctx == CommandPlacement::FunctionBody { + CommandPlacement::FunctionBody } else { - CommandContext::Substitution + CommandPlacement::Substitution }; for part in &word.parts { match part { @@ -614,7 +614,7 @@ mod tests { analysis.commands[0].literal_args(), Some(vec!["hello", "world"]) ); - assert_eq!(analysis.commands[0].context, CommandContext::Direct); + assert_eq!(analysis.commands[0].context, CommandPlacement::Direct); assert!(!analysis.is_opaque()); } @@ -657,12 +657,12 @@ mod tests { let analysis = a("echo $(rm -rf /data)"); assert_eq!(names("echo $(rm -rf /data)"), ["rm", "echo"]); assert!(analysis.has_command_substitution); - assert_eq!(analysis.commands[0].context, CommandContext::Substitution); + assert_eq!(analysis.commands[0].context, CommandPlacement::Substitution); assert_eq!( analysis.commands[0].literal_args(), Some(vec!["-rf", "/data"]) ); - assert_eq!(analysis.commands[1].context, CommandContext::Direct); + assert_eq!(analysis.commands[1].context, CommandPlacement::Direct); } #[test] @@ -845,8 +845,8 @@ mod tests { let analysis = a("cleanup() { rm -rf /data; }\necho done"); assert_eq!(analysis.functions, ["cleanup"]); assert_eq!(analysis.commands[0].name.as_deref(), Some("rm")); - assert_eq!(analysis.commands[0].context, CommandContext::FunctionBody); - assert_eq!(analysis.commands[1].context, CommandContext::Direct); + assert_eq!(analysis.commands[0].context, CommandPlacement::FunctionBody); + assert_eq!(analysis.commands[1].context, CommandPlacement::Direct); } #[test] @@ -856,7 +856,7 @@ mod tests { analysis .commands .iter() - .all(|c| c.context == CommandContext::FunctionBody), + .all(|c| c.context == CommandPlacement::FunctionBody), "{:?}", analysis.commands ); @@ -1013,9 +1013,9 @@ mod tests { #[test] fn context_and_mode_names_are_stable() { - assert_eq!(CommandContext::Direct.as_str(), "direct"); - assert_eq!(CommandContext::Substitution.as_str(), "substitution"); - assert_eq!(CommandContext::FunctionBody.as_str(), "function_body"); + assert_eq!(CommandPlacement::Direct.as_str(), "direct"); + assert_eq!(CommandPlacement::Substitution.as_str(), "substitution"); + assert_eq!(CommandPlacement::FunctionBody.as_str(), "function_body"); assert_eq!(RedirectMode::Read.as_str(), "read"); assert_eq!(RedirectMode::Write.as_str(), "write"); assert_eq!(RedirectMode::Append.as_str(), "append"); diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index b59c05104..93b468816 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -438,6 +438,18 @@ pub(crate) struct RegisteredBuiltin { } impl BuiltinRegistry { + /// Register or replace a streaming command in the live registry. + pub fn insert_command(&self, name: impl Into, command: impl crate::Command + 'static) { + let name = name.into(); + self.insert( + name.clone(), + Arc::new(crate::command::CommandAdapter { + name, + command: Arc::new(command), + }), + ); + } + /// Create an empty registry. pub fn new() -> Self { Self::default() @@ -1118,8 +1130,7 @@ pub(crate) struct StdoutStream(std::sync::Arc); impl StdoutStream { /// Wait for room in the pipe, then write. False once nobody reads. pub(crate) async fn write(&self, data: &[u8]) -> bool { - self.0.writable().await; - self.0.write(data) + self.0.write_bounded(data).await.is_ok() } } @@ -1351,6 +1362,11 @@ pub(crate) fn test_stream_opt(text: Option<&str>) -> Option<&'static crate::Stre /// or [`ExecResult::err`](crate::ExecResult::err) for errors with exit code. #[async_trait] pub trait Builtin: Send + Sync { + /// Internal bridge used by registered `Command` adapters. + #[doc(hidden)] + fn streams_io(&self) -> bool { + false + } /// Execute the builtin command. /// /// # Arguments @@ -1711,6 +1727,9 @@ fn cap_diagnostic(mut text: String, max_bytes: usize) -> String { #[async_trait] impl Builtin for std::sync::Arc { + fn streams_io(&self) -> bool { + (**self).streams_io() + } async fn execute(&self, ctx: Context<'_>) -> Result { (**self).execute(ctx).await } diff --git a/crates/bashkit/src/command.rs b/crates/bashkit/src/command.rs new file mode 100644 index 000000000..d4a28137f --- /dev/null +++ b/crates/bashkit/src/command.rs @@ -0,0 +1,358 @@ +//! Streaming host commands. I/O is request-scoped and byte-native; completion +//! never carries output. Buffered shell utilities use the interpreter's adapter. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use crate::interpreter::pipe::{PIPE_CAPACITY, Pipe}; +use crate::{ + Builtin, BuiltinContext, Error, ExecResult, ExecutionBudget, FileSystem, Result, StreamData, +}; +use async_trait::async_trait; + +/// Completion of a host command, separate from its output destinations. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct CommandStatus { + /// Shell exit status. Nonzero status is an ordinary command failure. + pub exit_code: i32, +} + +impl CommandStatus { + /// Successful completion. + pub const fn success() -> Self { + Self { exit_code: 0 } + } + /// Complete with an explicit shell status. + pub const fn with_code(exit_code: i32) -> Self { + Self { exit_code } + } +} + +/// Implement a live command registered with [`crate::BashBuilder::command`]. +#[async_trait] +pub trait Command: Send + Sync { + /// Consume input and write output before returning completion. + async fn execute(&self, ctx: CommandContext<'_>) -> Result; +} + +/// Capabilities available for one command invocation. +pub struct CommandContext<'a> { + /// Registered command name. + pub name: &'a str, + /// Expanded arguments, excluding the command name. + pub args: &'a [String], + /// Effective exported environment, including prefix assignments. + pub env: &'a HashMap, + /// Current virtual working directory. + pub cwd: &'a Path, + /// Request-scoped virtual filesystem. + pub fs: Arc, + /// Incremental standard input. An empty stream is EOF, not absent input. + pub stdin: CommandInput, + /// Standard output after shell routing. + pub stdout: CommandOutput, + /// Standard error after shell routing. + pub stderr: CommandOutput, + extensions: Arc, +} + +impl CommandContext<'_> { + /// A revocable request-scoped host extension. + pub fn execution_extension( + &self, + ) -> Option> { + self.extensions.get::() + } +} + +struct InputState { + buffered: StreamData, + offset: usize, + pipe: Option>, + _storage: crate::ExecutionBudgetLease, +} + +/// A clone shares the input cursor; every operation checks its request budget. +#[derive(Clone)] +pub struct CommandInput { + state: Arc>, + budget: ExecutionBudget, +} + +impl CommandInput { + /// Read at most one pipe-sized chunk. `None` means EOF. + pub async fn read_chunk(&self) -> Result>> { + self.budget.check()?; + let bytes = self + .budget + .run(async { + let mut state = self.state.lock().await; + if state.offset < state.buffered.len() { + let end = (state.offset + PIPE_CAPACITY).min(state.buffered.len()); + let bytes = state.buffered.as_bytes()[state.offset..end].to_vec(); + state.offset = end; + bytes + } else if let Some(pipe) = &state.pipe { + pipe.read_some().await + } else { + Vec::new() + } + }) + .await?; + self.budget.consume_input(bytes.len())?; + self.budget + .consume_work(1 + bytes.len().div_ceil(1024) as u64)?; + Ok((!bytes.is_empty()).then_some(bytes)) + } +} + +struct Capture { + bytes: crate::limits::BudgetedVec, + truncated: bool, + limit: usize, +} + +impl Capture { + fn new(budget: &ExecutionBudget, limit: usize) -> Result>> { + Ok(Arc::new(Mutex::new(Self { + bytes: crate::limits::BudgetedVec::new(Some(budget))?, + truncated: false, + limit, + }))) + } +} + +pub(crate) struct FileDestination { + fs: Arc, + path: PathBuf, + lock: tokio::sync::Mutex<()>, + _storage: crate::ExecutionBudgetLease, +} + +#[derive(Clone)] +pub(crate) enum CommandRoute { + Stdout, + Stderr, + File(Arc), + Pipe(Arc), + Discard, + Closed, +} + +impl CommandRoute { + pub(crate) fn file( + fs: Arc, + path: PathBuf, + budget: &ExecutionBudget, + ) -> Result { + let storage = + budget.lease_bytes(path.as_os_str().len() + std::mem::size_of::())?; + Ok(Self::File(Arc::new(FileDestination { + fs, + path, + lock: tokio::sync::Mutex::new(()), + _storage: storage, + }))) + } +} + +#[derive(Clone)] +enum Destination { + File(Arc), + Discard, + Closed, + Pipe(Arc), + Capture(Arc>), +} + +/// An async output destination. Clones share ordering and request lifetime. +#[derive(Clone)] +pub struct CommandOutput { + destination: Destination, + budget: ExecutionBudget, +} + +impl CommandOutput { + /// Write all bytes, suspending while a pipe is full. A reader that exits + /// causes `Error::Io` with `BrokenPipe`; handlers may handle that error. + pub async fn write_all(&self, bytes: &[u8]) -> Result<()> { + self.budget.check()?; + self.budget + .consume_work(1 + bytes.len().div_ceil(1024) as u64)?; + match &self.destination { + Destination::File(file) => { + self.budget + .run(async { + let _lock = file.lock.lock().await; + for chunk in bytes.chunks(PIPE_CAPACITY) { + file.fs.append_file(&file.path, chunk).await?; + } + Ok::<_, Error>(()) + }) + .await??; + } + Destination::Discard => {} + Destination::Closed => return Err(std::io::Error::from_raw_os_error(9).into()), + Destination::Pipe(pipe) => self.budget.run(pipe.write_bounded(bytes)).await??, + Destination::Capture(capture) => { + let mut capture = capture.lock().unwrap_or_else(|e| e.into_inner()); + let n = bytes + .len() + .min(capture.limit.saturating_sub(capture.bytes.len())); + let limit = capture.limit; + capture + .bytes + .try_extend_from_slice_with_limit(&bytes[..n], limit)?; + capture.truncated |= n < bytes.len(); + } + } + self.budget.check()?; + Ok(()) + } +} + +// Failure to report an ordinary I/O error through a closed stderr must not +// abort the shell. Request-budget failures remain fatal during diagnostics. +async fn write_diagnostic(output: &CommandOutput, bytes: &[u8]) -> Result<()> { + match output.write_all(bytes).await { + Err(Error::Io(_)) => Ok(()), + result => result, + } +} + +pub(crate) struct CommandAdapter { + pub name: String, + pub command: Arc, +} + +#[async_trait] +impl Builtin for CommandAdapter { + fn streams_io(&self) -> bool { + true + } + + async fn execute(&self, ctx: BuiltinContext<'_>) -> Result { + let budget = ctx + .execution_budget() + .ok_or_else(|| Error::Internal("missing command budget".into()))? + .try_with(Clone::clone) + .map_err(|_| Error::Cancelled)?; + let limits = ctx + .shell + .as_ref() + .map(|s| s.limits()) + .cloned() + .unwrap_or_default(); + let out_capture = Capture::new( + &budget, + if ctx.shell.as_ref().is_some_and(|s| s.stdout_pipe.is_some()) { + 0 + } else { + limits.max_stdout_bytes + }, + )?; + let err_capture = Capture::new(&budget, limits.max_stderr_bytes)?; + let stdout = ctx + .shell + .as_ref() + .and_then(|s| s.stdout_pipe.clone()) + .map(Destination::Pipe) + .unwrap_or_else(|| Destination::Capture(out_capture.clone())); + let stderr = ctx + .shell + .as_ref() + .and_then(|s| s.stderr_pipe.clone()) + .map(Destination::Pipe) + .unwrap_or_else(|| Destination::Capture(err_capture.clone())); + let resolve = |route: &CommandRoute| match route { + CommandRoute::Stdout => stdout.clone(), + CommandRoute::Stderr => stderr.clone(), + CommandRoute::File(file) => Destination::File(file.clone()), + CommandRoute::Pipe(pipe) => Destination::Pipe(pipe.clone()), + CommandRoute::Discard => Destination::Discard, + CommandRoute::Closed => Destination::Closed, + }; + let (stdout, stderr) = match ctx.shell.as_ref().and_then(|s| s.command_routes.as_ref()) { + Some([out, err]) => (resolve(out), resolve(err)), + None => (stdout, stderr), + }; + let input_pipe = ctx.shell.as_ref().and_then(|s| s.stdin_pipe.clone()); + let input_storage = budget.lease_bytes(ctx.stdin.map_or(0, StreamData::len))?; + let buffered = ctx.stdin.cloned().unwrap_or_default(); + let input = CommandInput { + budget: budget.clone(), + state: Arc::new(tokio::sync::Mutex::new(InputState { + buffered, + offset: 0, + pipe: input_pipe, + _storage: input_storage, + })), + }; + let error_output = CommandOutput { + destination: stderr.clone(), + budget: budget.clone(), + }; + let result = self.command.execute(CommandContext { + name: &self.name, + args: ctx.args, + env: ctx.env, + cwd: ctx.cwd, + fs: ctx.fs.clone(), + stdin: input, + stdout: CommandOutput { + destination: stdout, + budget: budget.clone(), + }, + stderr: CommandOutput { + destination: stderr, + budget: budget.clone(), + }, + extensions: ctx + .shell + .as_ref() + .map(|s| s.execution_extensions.clone()) + .unwrap_or_else(|| Arc::new(crate::ExecutionExtensions::new())), + }); + use futures_util::FutureExt; + let status = match budget + .run(std::panic::AssertUnwindSafe(result).catch_unwind()) + .await? + { + Ok(Ok(status)) => status, + Ok(Err(Error::Io(e))) if e.kind() == std::io::ErrorKind::BrokenPipe => { + CommandStatus::with_code(141) + } + Ok(Err(Error::Io(e))) => { + let message = format!( + "{}: write error: {}\n", + self.name, + crate::error::io_error_reason(&Error::Io(e)) + ); + write_diagnostic(&error_output, message.as_bytes()).await?; + CommandStatus::with_code(1) + } + Ok(Err(e)) => return Err(e), + Err(_) => { + write_diagnostic(&error_output, b"command failed unexpectedly\n").await?; + CommandStatus::with_code(1) + } + }; + let mut out = out_capture.lock().unwrap_or_else(|e| e.into_inner()); + let mut err = err_capture.lock().unwrap_or_else(|e| e.into_inner()); + Ok(ExecResult { + stdout: std::mem::replace(&mut out.bytes, crate::limits::BudgetedVec::new(None)?) + .into_parts() + .0 + .into(), + stderr: std::mem::replace(&mut err.bytes, crate::limits::BudgetedVec::new(None)?) + .into_parts() + .0 + .into(), + stdout_truncated: out.truncated, + stderr_truncated: err.truncated, + exit_code: status.exit_code, + ..Default::default() + }) + } +} diff --git a/crates/bashkit/src/execution.rs b/crates/bashkit/src/execution.rs new file mode 100644 index 000000000..788b7faf4 --- /dev/null +++ b/crates/bashkit/src/execution.rs @@ -0,0 +1,328 @@ +//! One execution builder, with explicit capture and async byte sinks. Engine +//! and output pumps run on the caller's executor; no threads are required. + +use crate::interpreter::pipe::{PIPE_CAPACITY, Pipe, ReadEnd, WriteEnd}; +use crate::{Bash, ExecOptions, ExecResult, ExecutionBudget, ExecutionLimits, Result}; +use std::future::{Future, IntoFuture}; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use tokio::io::{AsyncWrite, AsyncWriteExt}; + +pub(crate) struct ExecutionOutput { + pub stdout: Arc, + pub stderr: Arc, + capture: Option, + admitted: Mutex>, +} + +struct OutputAdmission { + stdout_output_limit: usize, + stderr_output_limit: usize, + stdout_limit: usize, + stderr_limit: usize, + budget: ExecutionBudget, + _storage: crate::ExecutionBudgetLease, + // The request ends after its final sink writes, not after engine polling. + _completion: crate::limits::ExecutionBudgetCompletionGuard, +} + +impl ExecutionOutput { + fn new(capture: Option) -> Arc { + Arc::new(Self { + stdout: Pipe::new(), + stderr: Pipe::new(), + capture, + admitted: Mutex::new(None), + }) + } + + pub(crate) fn install(&self, budget: &ExecutionBudget, limits: &ExecutionLimits) -> Result<()> { + let completion = budget.completion_guard(); + let out = self.capture.unwrap_or(0).min(limits.max_stdout_bytes); + let err = self.capture.unwrap_or(0).min(limits.max_stderr_bytes); + let bytes = out + .checked_add(err) + .and_then(|n| n.checked_add(2 * PIPE_CAPACITY)) + .ok_or_else(|| crate::Error::Execution("capture size overflow".into()))?; + let lease = budget.lease_bytes(bytes)?; + *self.admitted.lock().unwrap_or_else(|e| e.into_inner()) = Some(OutputAdmission { + stdout_output_limit: limits.max_stdout_bytes, + stderr_output_limit: limits.max_stderr_bytes, + stdout_limit: out, + stderr_limit: err, + budget: budget.clone(), + _storage: lease, + _completion: completion, + }); + Ok(()) + } + + fn capture_limit(&self, stderr: bool) -> usize { + self.admitted + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + .map_or(0, |a| { + if stderr { + a.stderr_limit + } else { + a.stdout_limit + } + }) + } + + fn output_limit(&self, stderr: bool) -> usize { + self.admitted + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + .map_or(0, |a| { + if stderr { + a.stderr_output_limit + } else { + a.stdout_output_limit + } + }) + } + + fn budget(&self) -> Option { + self.admitted + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + .map(|a| a.budget.clone()) + } +} + +/// A single execution. With no capture or writer, output is drained and discarded. +/// Use [`capture`](Self::capture) for a bounded final value, writers for live +/// delivery, or both. Awaiting the builder runs it exactly once. +#[must_use = "an execution does nothing until awaited"] +pub struct Execution<'a> { + bash: &'a mut Bash, + script: &'a str, + capture: Option, + stdout: Option>, + stderr: Option>, + options: ExecOptions, + cancel: Option>, +} + +impl<'a> Execution<'a> { + pub(crate) fn new(bash: &'a mut Bash, script: &'a str) -> Self { + Self { + bash, + script, + capture: None, + stdout: None, + stderr: None, + options: ExecOptions::new(), + cancel: None, + } + } + /// Capture at most this many bytes per stream, within sandbox output limits. + pub fn capture(mut self, max_bytes: usize) -> Self { + self.capture = Some(max_bytes); + self + } + /// Deliver stdout through an async byte writer; awaiting writes backpressures. + pub fn stdout(mut self, writer: &'a mut (dyn AsyncWrite + Unpin + Send)) -> Self { + self.stdout = Some(Sink::Writer(writer)); + self + } + /// Deliver stderr through an async byte writer. + pub fn stderr(mut self, writer: &'a mut (dyn AsyncWrite + Unpin + Send)) -> Self { + self.stderr = Some(Sink::Writer(writer)); + self + } + /// Deliver stdout to an async callback. Chunks are bounded owned bytes. + pub fn stdout_fn(mut self, mut callback: F) -> Self + where + F: FnMut(Vec) -> Fut + Send + 'a, + Fut: Future> + Send + 'a, + { + self.stdout = Some(Sink::Callback(Box::new(move |bytes| { + Box::pin(callback(bytes)) + }))); + self + } + /// Deliver stderr to an async callback. + pub fn stderr_fn(mut self, mut callback: F) -> Self + where + F: FnMut(Vec) -> Fut + Send + 'a, + Fut: Future> + Send + 'a, + { + self.stderr = Some(Sink::Callback(Box::new(move |bytes| { + Box::pin(callback(bytes)) + }))); + self + } + /// Cancel when an external atomic flag is set. Existing session cancellation + /// remains available through `Bash::cancellation_token`. + pub fn cancel_with(mut self, token: Arc) -> Self { + self.cancel = Some(token); + self + } + + /// Invocation parameters, input and request-scoped extensions. + pub fn options(mut self, options: ExecOptions) -> Self { + self.options = options; + self + } + + async fn run(self) -> Result { + if !self.bash.interpreter.hooks().after_tool.is_empty() + || !self.bash.interpreter.hooks().after_exec.is_empty() + { + return Err(crate::Error::Execution("live execution cannot apply whole-output after_tool/after_exec interceptors; use buffered execution".into())); + } + if self.options.output_callback.is_some() { + return Err(crate::Error::Execution( + "use stdout/stderr byte sinks instead of an ExecOptions output observer".into(), + )); + } + let timeout = self.bash.interpreter.limits().timeout; + let session_cancel = self.bash.cancellation_token(); + let output = ExecutionOutput::new(self.capture); + let mut options = self.options; + // The observer acts as the routing-scope marker already honored by + // substitutions, redirects and upstream pipeline stages. + options.output_callback = Some(Box::new(|_, _| {})); + options.stream_output = Some(output.clone()); + let _ = options.extensions.insert(output.clone()); + // Keep driver combinators small on the stack. The interpreter's + // recursive poll path is tested on a two-MiB native thread stack. + let engine = Box::pin(async { + let _stdout = WriteEnd(output.stdout.clone()); + let _stderr = WriteEnd(output.stderr.clone()); + // A shell failure still closes producer ends and drains accepted + // output. A sink failure, by contrast, immediately drops the engine. + Ok::<_, crate::Error>(Box::pin(self.bash.exec_with_options(self.script, options)).await) + }); + let driven = Box::pin(async { + tokio::try_join!( + engine, + Box::pin(pump(output.clone(), false, self.stdout)), + Box::pin(pump(output.clone(), true, self.stderr)), + ) + }); + let completed = async { + let externally_driven = async { + if let Some(cancel) = self.cancel { + tokio::select! { + result = driven => result, + () = wait_for_cancel(cancel) => Err(crate::Error::Cancelled), + } + } else { + driven.await + } + }; + tokio::select! { + result = externally_driven => result, + () = wait_for_cancel(session_cancel) => Err(crate::Error::Cancelled), + } + }; + let (result, (stdout, stdout_truncated), (stderr, stderr_truncated)) = + crate::time_compat::timeout(timeout, completed) + .await + .map_err(|_| { + crate::Error::ResourceLimit(crate::LimitExceeded::Timeout(timeout)) + })??; + let mut result = result?; + result.stdout = stdout.into(); + result.stderr = stderr.into(); + result.stdout_truncated |= stdout_truncated; + result.stderr_truncated |= stderr_truncated; + Ok(result) + } +} + +impl<'a> IntoFuture for Execution<'a> { + type Output = Result; + type IntoFuture = Pin + Send + 'a>>; + fn into_future(self) -> Self::IntoFuture { + Box::pin(self.run()) + } +} + +type SinkFuture<'a> = Pin> + Send + 'a>>; +enum Sink<'a> { + Writer(&'a mut (dyn AsyncWrite + Unpin + Send)), + Callback(Box) -> SinkFuture<'a> + Send + 'a>), +} + +async fn pump( + output: Arc, + stderr: bool, + mut sink: Option>, +) -> Result<(Vec, bool)> { + let pipe = if stderr { + output.stderr.clone() + } else { + output.stdout.clone() + }; + let _reader = ReadEnd(pipe.clone()); + let mut captured = Vec::new(); + let mut truncated = false; + let mut delivered = 0usize; + loop { + let mut chunk = pipe.read_some().await; + if chunk.is_empty() { + break; + } + // Sandbox output caps apply to byte delivery as well as capture. A + // smaller explicit capture limit never limits the sink itself. + let accepted = chunk + .len() + .min(output.output_limit(stderr).saturating_sub(delivered)); + truncated |= accepted < chunk.len(); + chunk.truncate(accepted); + delivered += accepted; + let limit = output.capture_limit(stderr); + if captured.capacity() == 0 && limit > 0 { + captured = Vec::with_capacity(limit); + } + let n = chunk.len().min(limit.saturating_sub(captured.len())); + captured.extend_from_slice(&chunk[..n]); + truncated |= output.capture.is_some() && n < chunk.len(); + if !chunk.is_empty() + && let Some(sink) = &mut sink + { + let budget = output.budget().ok_or(crate::Error::Cancelled)?; + match sink { + Sink::Writer(writer) => budget.run(writer.write_all(&chunk)).await??, + Sink::Callback(callback) => budget.run(callback(chunk)).await??, + } + } + } + if let Some(Sink::Writer(writer)) = &mut sink + && let Some(budget) = output.budget() + { + budget.run(writer.flush()).await??; + } + Ok((captured, truncated)) +} + +async fn wait_for_cancel(token: Arc) { + loop { + if token.load(std::sync::atomic::Ordering::Relaxed) { + return; + } + yield_once().await; + crate::time_compat::sleep(std::time::Duration::from_millis(10)).await; + } +} + +pub(crate) async fn yield_once() { + let mut yielded = false; + std::future::poll_fn(move |cx| { + if yielded { + std::task::Poll::Ready(()) + } else { + yielded = true; + cx.waker().wake_by_ref(); + std::task::Poll::Pending + } + }) + .await +} diff --git a/crates/bashkit/src/interpreter/completion.rs b/crates/bashkit/src/interpreter/completion.rs index 310ce1432..51c8b0c2e 100644 --- a/crates/bashkit/src/interpreter/completion.rs +++ b/crates/bashkit/src/interpreter/completion.rs @@ -671,7 +671,7 @@ impl Interpreter { let out = r.stdout.suffix_from(out_len); let err = r.stderr.suffix_from(err_len); let count = self.output_emit_count; - self.maybe_emit_output(&out, &err, count); + self.maybe_emit_output(&out, &err, count).await?; } self.leave_output_region(region); let result = result?; @@ -719,7 +719,8 @@ impl Interpreter { stderr.push_str(&self.diag("compgen: warning: -F option may not work as you expect\n")); let warn = crate::StreamData::from(std::mem::take(&mut stderr)); let count = self.output_emit_count; - self.maybe_emit_output(&crate::StreamData::new(), &warn, count); + self.maybe_emit_output(&crate::StreamData::new(), &warn, count) + .await?; let mut warn_kept = warn; match self.completion_function_matches(func, &word).await? { Ok((out, err, list)) => { diff --git a/crates/bashkit/src/interpreter/history.rs b/crates/bashkit/src/interpreter/history.rs index 0cf2583ed..aebfa6b72 100644 --- a/crates/bashkit/src/interpreter/history.rs +++ b/crates/bashkit/src/interpreter/history.rs @@ -278,7 +278,9 @@ impl Interpreter { text.push_str("exit\n"); } let prompt = crate::StreamData::from(text); - self.maybe_emit_output(&crate::StreamData::new(), &prompt, emit_before); + self.maybe_emit_output(&crate::StreamData::new(), &prompt, emit_before) + .await + .ok(); err.append(&prompt); (out, err) } @@ -300,7 +302,9 @@ impl Interpreter { Err(e) => ExecResult::err(self.diag(format!("{e}\n")), 2), }; self.last_exit_code = saved_status; - self.maybe_emit_output(&result.stdout, &result.stderr, emit_before); + self.maybe_emit_output(&result.stdout, &result.stderr, emit_before) + .await + .ok(); Some(result) } @@ -842,7 +846,8 @@ impl Interpreter { } let echo = crate::StreamData::from(format!("{command}\n")); let emit_before = self.output_emit_count; - self.maybe_emit_output(&crate::StreamData::new(), &echo, emit_before); + self.maybe_emit_output(&crate::StreamData::new(), &echo, emit_before) + .await?; let mut result = match self.parse_embedded_script(&command).await { Ok(script) => self.execute_command_sequence(&script.commands).await?, Err(e) => ExecResult::err(self.diag(format!("{e}\n")), 2), diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 0c7276845..71777d11d 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -419,6 +419,8 @@ pub(crate) struct ShellRef<'a> { pub(crate) stdout_pipe: Option>, /// Stdin of a streaming filter stage (see `Context::stdin_stream`). pub(crate) stdin_pipe: Option>, + pub(crate) stderr_pipe: Option>, + pub(crate) command_routes: Option<[crate::command::CommandRoute; 2]>, /// Enclosing loops in the current function (`break`/`continue`). pub(crate) loop_depth: usize, /// Active function calls and `source`s (`return` is valid when > 0). @@ -1601,6 +1603,46 @@ struct PipelineStageScope { prev_pipeline_stdin: Option>, } +// A suspended final stage can be cancelled before exit_pipeline_stage runs. +// Its subshell snapshot must still roll back, including session-owned leases. +struct PipelineStageGuard { + interpreter: *mut Interpreter, + scope: Option, +} + +// SAFETY: the guard never leaves the future borrowing this interpreter. The +// interpreter is Send, and its borrowed address stays stable until guard drop. +unsafe impl Send for PipelineStageGuard {} + +impl PipelineStageGuard { + fn new(interpreter: &mut Interpreter, scope: PipelineStageScope) -> Self { + Self { + interpreter, + scope: Some(scope), + } + } + + fn complete( + mut self, + interpreter: &mut Interpreter, + result: Result, + ) -> Result { + debug_assert_eq!(self.interpreter, interpreter as *mut Interpreter); + interpreter.exit_pipeline_stage(self.scope.take().expect("active pipeline scope"), result) + } +} + +impl Drop for PipelineStageGuard { + fn drop(&mut self) { + if let Some(scope) = self.scope.take() { + // SAFETY: awaited command futures (which borrow the interpreter) + // drop before this guard. No borrow remains during restoration. + let _ = unsafe { &mut *self.interpreter } + .exit_pipeline_stage(scope, Ok(ExecResult::default())); + } + } +} + struct SubshellSnapshot { scoped: ScopedState, env: Arc>, @@ -3848,67 +3890,100 @@ impl Interpreter { /// `emit_count_before` is the value of `output_emit_count` before the sub-call /// that produced this output. If the count advanced, sub-calls already emitted /// and we skip to avoid duplicates. - fn maybe_emit_output( - &mut self, - stdout: &crate::StreamData, - stderr: &crate::StreamData, + fn maybe_emit_output<'a>( + &'a mut self, + stdout: &'a crate::StreamData, + stderr: &'a crate::StreamData, emit_count_before: u64, - ) -> bool { - if self.output_callback.is_none() { - return false; - } - // Sub-calls already emitted — skip to avoid duplicates - if self.output_emit_count != emit_count_before { - return false; - } - // `exec >log` / `exec 2>/dev/null`: the shell's own output is routed - // at the top-level command (`route_exec_output`), not streamed. A - // pipeline stage's sink is a pipe, never the caller. - let (stdout, stderr) = if self.pipe_out.is_none() { - let empty = crate::StreamData::new(); - ( - if self.exec_fd_table.contains_key(&1) { - empty.clone() - } else { - stdout.clone() - }, - if self.exec_fd_table.contains_key(&2) { - empty - } else { - stderr.clone() - }, - ) - } else { - (stdout.clone(), stderr.clone()) - }; - let (stdout, stderr) = (&stdout, &stderr); - - let stdout_remaining = self - .limits - .max_stdout_bytes - .saturating_sub(self.output_stream_stdout_bytes); - let stderr_remaining = self - .limits - .max_stderr_bytes - .saturating_sub(self.output_stream_stderr_bytes); - // A stage's pipe is not caller-visible output: no stdout cap. - let stdout_chunk = if self.pipe_out.is_some() { - stdout.clone() - } else { - stdout.prefix(stdout_remaining) - }; - let stderr_chunk = stderr.prefix(stderr_remaining); - if stdout_chunk.is_empty() && stderr_chunk.is_empty() { - return false; - } + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + if self.output_callback.is_none() { + return Ok(false); + } + // Sub-calls already emitted — skip to avoid duplicates + if self.output_emit_count != emit_count_before { + return Ok(false); + } + // `exec >log` / `exec 2>/dev/null`: the shell's own output is routed + // at the top-level command (`route_exec_output`), not streamed. A + // pipeline stage's sink is a pipe, never the caller. + let (stdout, stderr) = if self.pipe_out.is_none() { + let empty = crate::StreamData::new(); + ( + if self.exec_fd_table.contains_key(&1) { + empty.clone() + } else { + stdout.clone() + }, + if self.exec_fd_table.contains_key(&2) { + empty + } else { + stderr.clone() + }, + ) + } else { + (stdout.clone(), stderr.clone()) + }; + let (stdout, stderr) = (&stdout, &stderr); + + let stdout_remaining = self + .limits + .max_stdout_bytes + .saturating_sub(self.output_stream_stdout_bytes); + let stderr_remaining = self + .limits + .max_stderr_bytes + .saturating_sub(self.output_stream_stderr_bytes); + // A stage's pipe is not caller-visible output: no stdout cap. + let output = self.execution_output().filter(|_| self.pipe_out.is_none()); + let stdout_chunk = if self.pipe_out.is_some() || output.is_some() { + stdout.clone() + } else { + stdout.prefix(stdout_remaining) + }; + let stderr_chunk = if output.is_some() { + stderr.clone() + } else { + stderr.prefix(stderr_remaining) + }; + if stdout_chunk.is_empty() && stderr_chunk.is_empty() { + return Ok(false); + } - if let Some(ref mut cb) = self.output_callback { - cb(&stdout_chunk, &stderr_chunk); - self.output_emit_count += 1; - self.output_stream_stdout_bytes += stdout_chunk.len(); - self.output_stream_stderr_bytes += stderr_chunk.len(); - } - true + let delivery = async { + if let Some(output) = output { + self.execution_budget + .run(output.stdout.write_bounded(stdout_chunk.as_bytes())) + .await??; + self.execution_budget + .run(output.stderr.write_bounded(stderr_chunk.as_bytes())) + .await??; + } else if let Some(pipe) = &self.pipe_out { + self.execution_budget + .run(pipe.write_bounded(stdout_chunk.as_bytes())) + .await??; + } + Ok::<_, crate::Error>(()) + } + .await; + if let Err(error) = delivery { + if self.pipe_out.is_some() + && matches!(&error, crate::Error::Io(e) if e.kind() == std::io::ErrorKind::BrokenPipe) + { + return Ok(true); + } + return Err(error); + } + if let Some(ref mut cb) = self.output_callback { + if self.pipe_out.is_none() { + cb(&stdout_chunk, &stderr_chunk); + } + self.output_emit_count += 1; + self.output_stream_stdout_bytes += stdout_chunk.len(); + self.output_stream_stderr_bytes += stderr_chunk.len(); + } + Ok(true) + }) } /// Set the HTTP client for network builtins (curl, wget). @@ -4156,7 +4231,8 @@ impl Interpreter { Self::merge_stderr_into_stdout(&mut result); } let emit_before = self.output_emit_count; - self.maybe_emit_output(&result.stdout, &result.stderr, emit_before); + self.maybe_emit_output(&result.stdout, &result.stderr, emit_before) + .await?; self.flush_exec_passthrough(&mut result); Ok(result) }) @@ -4202,6 +4278,7 @@ impl Interpreter { let mut exit_code = 0; let mut stdout_truncated = false; let mut stderr_truncated = false; + let retain_output = self.retain_streamed_output(); let max_stdout = self.limits.max_stdout_bytes; let max_stderr = self.limits.max_stderr_bytes; @@ -4277,13 +4354,14 @@ impl Interpreter { if self.merge_stderr { Self::merge_stderr_into_stdout(&mut result); } - self.maybe_emit_output(&result.stdout, &result.stderr, emit_before); + self.maybe_emit_output(&result.stdout, &result.stderr, emit_before) + .await?; if top_level { self.flush_exec_passthrough(&mut result); } // Accumulate stdout with truncation - if !stdout_truncated { + if retain_output && !stdout_truncated { let remaining = max_stdout.saturating_sub(stdout.len()); if remaining == 0 { if !result.stdout.is_empty() { @@ -4298,7 +4376,7 @@ impl Interpreter { } // Accumulate stderr with truncation - if !stderr_truncated { + if retain_output && !stderr_truncated { let remaining = max_stderr.saturating_sub(stderr.len()); if remaining == 0 { if !result.stderr.is_empty() { @@ -4311,6 +4389,9 @@ impl Interpreter { stderr_truncated = true; } } + if !retain_output { + stdout_truncated |= result.stdout_truncated; + } stderr_truncated |= result.stderr_truncated; exit_code = result.exit_code; @@ -4422,7 +4503,8 @@ impl Interpreter { if !stopped && let Some(message) = &view.get().trailing_error { let emit_before = self.output_emit_count; let err = crate::StreamData::from(message.clone()); - self.maybe_emit_output(&crate::StreamData::new(), &err, emit_before); + self.maybe_emit_output(&crate::StreamData::new(), &err, emit_before) + .await?; if !stderr_truncated { stderr.append(&err); } @@ -4444,7 +4526,8 @@ impl Interpreter { &trap_result.stdout, &trap_result.stderr, emit_before, - ); + ) + .await?; stdout.append(&trap_result.stdout); stderr.append(&trap_result.stderr); // `exit N` in the handler is the shell's status. @@ -4612,6 +4695,12 @@ impl Interpreter { // Whether ERR may fire after this command: bash decides before // running it, so a trap the command itself sets (`f` setting // ERR inside) does not fire for that same command. + if self.counters.commands > 0 + && self.counters.commands.is_multiple_of(64) + && self.execution_output().is_some() + { + crate::execution::yield_once().await; + } let skip_stage = std::mem::take(&mut self.err_trap_skip_stage); let err_armed = !skip_stage && self.err_trap_armed() && Self::fires_err_trap(command); let emit_before = self.output_emit_count; @@ -4850,7 +4939,9 @@ impl Interpreter { /// Run the ERR trap after a failing command, its output following the /// command's own (streamed first, so neither is dropped). async fn run_err_trap_after(&mut self, result: &mut ExecResult, emit_before: u64) { - self.maybe_emit_output(&result.stdout, &result.stderr, emit_before); + self.maybe_emit_output(&result.stdout, &result.stderr, emit_before) + .await + .ok(); self.last_exit_code = result.exit_code; let mut stdout = crate::StreamData::new(); let mut stderr = crate::StreamData::new(); @@ -5029,7 +5120,8 @@ impl Interpreter { &trap_result.stdout, &trap_result.stderr, emit_before, - ); + ) + .await?; res.stdout.append(&trap_result.stdout); res.stderr.append(&trap_result.stderr); } @@ -5187,7 +5279,7 @@ impl Interpreter { )); } - let mut acc = state::LoopAccumulator::new(); + let mut acc = state::LoopAccumulator::new(self.retain_streamed_output()); // Get iteration values: expand fields, then apply brace/glob expansion let values: Vec = if let Some(words) = &for_cmd.words { @@ -5267,7 +5359,8 @@ impl Interpreter { let emit_before = self.output_emit_count; let result = self.execute_command_sequence(&for_cmd.body).await; let result = DebugTrapOutput::prepend_opt(debug, result)?; - self.maybe_emit_output(&result.stdout, &result.stderr, emit_before); + self.maybe_emit_output(&result.stdout, &result.stderr, emit_before) + .await?; let should_errexit = self.errexit_active() && result.exit_code != 0 && result.control_flow == ControlFlow::None @@ -5472,9 +5565,12 @@ impl Interpreter { // Execute body let emit_before = self.output_emit_count; let result = self.execute_command_sequence(&select_cmd.body).await?; - self.maybe_emit_output(&result.stdout, &result.stderr, emit_before); - stdout.append(&result.stdout); - stderr.append(&result.stderr); + self.maybe_emit_output(&result.stdout, &result.stderr, emit_before) + .await?; + if self.retain_streamed_output() { + stdout.append(&result.stdout); + stderr.append(&result.stderr); + } exit_code = result.exit_code; // Check for break/continue @@ -5554,7 +5650,7 @@ impl Interpreter { &mut self, arith_for: &ArithmeticForCommand, ) -> Result { - let mut acc = state::LoopAccumulator::new(); + let mut acc = state::LoopAccumulator::new(self.retain_streamed_output()); // DEBUG output of the clauses (bash runs it before each one), put // ahead of the next body's output or the loop's result. let mut debug: Option> = None; @@ -5600,7 +5696,8 @@ impl Interpreter { let emit_before = self.output_emit_count; let result = self.execute_command_sequence(&arith_for.body).await; let result = DebugTrapOutput::prepend_opt(debug.take(), result)?; - self.maybe_emit_output(&result.stdout, &result.stderr, emit_before); + self.maybe_emit_output(&result.stdout, &result.stderr, emit_before) + .await?; let should_errexit = self.errexit_active() && result.exit_code != 0 && result.control_flow == ControlFlow::None @@ -6208,7 +6305,7 @@ impl Interpreter { body: &[Command], break_on_zero: bool, ) -> Result { - let mut acc = state::LoopAccumulator::new(); + let mut acc = state::LoopAccumulator::new(self.retain_streamed_output()); self.counters.enter_loop(); let result = async { @@ -6224,7 +6321,8 @@ impl Interpreter { &condition_result.stdout, &condition_result.stderr, emit_before_cond, - ); + ) + .await?; acc.stdout.append(&condition_result.stdout); acc.stderr.append(&condition_result.stderr); // `while break; do` leaves the loop from its condition. @@ -6252,7 +6350,8 @@ impl Interpreter { // Execute body let emit_before = self.output_emit_count; let result = self.execute_command_sequence(body).await?; - self.maybe_emit_output(&result.stdout, &result.stderr, emit_before); + self.maybe_emit_output(&result.stdout, &result.stderr, emit_before) + .await?; let should_errexit = self.errexit_active() && result.exit_code != 0 && result.control_flow == ControlFlow::None @@ -6396,7 +6495,8 @@ impl Interpreter { }; // The report is streamed on its own below; stream the command's // output first, or the caller would take the report for all of it. - self.maybe_emit_output(&result.stdout, &result.stderr, emit_start); + self.maybe_emit_output(&result.stdout, &result.stderr, emit_start) + .await?; let mut elapsed = start.elapsed(); // THREAT[TM-INF-033]: Hardened mode exposes only a 100ms lower-bound // bucket, preventing `time` from becoming a high-resolution oracle. @@ -6426,7 +6526,8 @@ impl Interpreter { self.append_time_stderr( &mut result, "time: formatted report exceeds output limit\n", - ); + ) + .await; result.exit_code = 1; return Ok(result); } @@ -6457,7 +6558,8 @@ impl Interpreter { // THREAT[TM-DOS-099]: `-o` must not bypass the diagnostic output cap. if report.len() > self.limits.max_stderr_bytes { - self.append_time_stderr(&mut result, "time: formatted report exceeds output limit\n"); + self.append_time_stderr(&mut result, "time: formatted report exceeds output limit\n") + .await; result.exit_code = 1; return Ok(result); } @@ -6474,24 +6576,27 @@ impl Interpreter { "time: cannot write output file '{}'\n", sanitize_time_path(&path) ), - ); + ) + .await; result.exit_code = 1; } } else { - self.append_time_stderr(&mut result, &report); + self.append_time_stderr(&mut result, &report).await; } Ok(result) } - fn append_time_stderr(&mut self, result: &mut ExecResult, message: &str) { + async fn append_time_stderr(&mut self, result: &mut ExecResult, message: &str) { result.stderr.push_str(message); let emit_before = self.output_emit_count; self.maybe_emit_output( &crate::StreamData::new(), &crate::StreamData::from(message), emit_before, - ); + ) + .await + .ok(); } async fn write_time_report(&self, path: &str, report: &[u8], append: bool) -> Result<()> { @@ -6941,7 +7046,8 @@ impl Interpreter { &crate::StreamData::new(), &crate::StreamData::from(warning.clone()), before, - ); + ) + .await?; } // Push call frame, apply options, execute, restore, pop @@ -7579,9 +7685,12 @@ impl Interpreter { Self::merge_stderr_into_stdout(&mut result); } self.reap_coprocs(); - self.maybe_emit_output(&result.stdout, &result.stderr, emit_before); - stdout.append(&result.stdout); - stderr.append(&result.stderr); + self.maybe_emit_output(&result.stdout, &result.stderr, emit_before) + .await?; + if self.retain_streamed_output() { + stdout.append(&result.stdout); + stderr.append(&result.stderr); + } exit_code = result.exit_code; self.last_exit_code = exit_code; @@ -7629,84 +7738,100 @@ impl Interpreter { /// handing over complete output. From the first stage that runs shell /// code (a loop, group, function), the rest run concurrently over /// bounded pipes (`execute_streaming_stages`), unless concurrency is off. - async fn execute_pipeline(&mut self, pipeline: &Pipeline) -> Result { - // Kept small: recursive pipelines (`f() { f | f; }`) nest this poll - // frame once per level, so bookkeeping lives in sync helpers. - let (stream_from, lastpipe) = self.plan_pipeline(pipeline); - let mut acc = PipelineAcc::default(); - let mut stdin_data: Option = None; - let count = pipeline.commands.len(); - // bash runs DEBUG for each simple-command stage from the shell - // itself, ahead of the stages' output; never inside a stage. - let mut debug = if count > 1 && self.has_debug_trap() { - self.pipeline_debug_traps(pipeline).await - } else { - None - }; - if let Some(r) = DebugTrapOutput::exit_result(&mut debug) { - return Ok(r); - } - let saved_dormant = self.debug_trap_dormant; - self.debug_trap_dormant |= count > 1; - acc.merge = self.merge_stderr; - self.merge_stderr &= count == 1; - - for (i, command) in pipeline.commands.iter().enumerate() { - if stream_from == Some(i) { - let stages = self - .execute_streaming_group(&pipeline.commands[i..], stdin_data.take(), lastpipe) - .await; - let stages = match stages { - Ok(s) => s, - Err(e) => { - self.debug_trap_dormant = saved_dormant; - return Err(e); - } - }; - for result in stages { - self.absorb_stage(&mut acc, result, true); - } - break; - } - let is_last = i == count - 1; - // Every stage of a multi-command pipeline runs in a subshell, - // except the last one under `shopt -s lastpipe` (bash, job - // control off). - let subshell = count > 1 && !(is_last && lastpipe); - // A non-last stage's stdout feeds the next stage, never the - // streaming observer. - let saved_callback = if is_last { - None + // THREAT[TM-DOS-089]: keep pipeline state out of execute_command's poll + // frame, which repeats even for simple commands inside nested $(...). + // Cancellation guards make that state large enough to overflow instrumented + // test stacks when it is embedded in every command future. + fn execute_pipeline<'a>( + &'a mut self, + pipeline: &'a Pipeline, + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + // Kept small: recursive pipelines (`f() { f | f; }`) nest this poll + // frame once per level, so bookkeeping lives in sync helpers. + let (stream_from, lastpipe) = self.plan_pipeline(pipeline); + let mut acc = PipelineAcc::default(); + let mut stdin_data: Option = None; + let count = pipeline.commands.len(); + // bash runs DEBUG for each simple-command stage from the shell + // itself, ahead of the stages' output; never inside a stage. + let mut debug = if count > 1 && self.has_debug_trap() { + self.pipeline_debug_traps(pipeline).await } else { - self.output_callback.take() + None }; - let result = match self.enter_pipeline_stage(command, stdin_data.take(), subshell) { - Ok((scope, stdin)) => { - let result = match command { - Command::Simple(simple) => self.execute_simple_command(simple, stdin).await, - _ => { - self.err_trap_skip_stage = count > 1; - self.execute_command(command).await + if let Some(r) = DebugTrapOutput::exit_result(&mut debug) { + return Ok(r); + } + let saved_dormant = self.debug_trap_dormant; + self.debug_trap_dormant |= count > 1; + acc.merge = self.merge_stderr; + self.merge_stderr &= count == 1; + + for (i, command) in pipeline.commands.iter().enumerate() { + if stream_from == Some(i) { + let stages = self + .execute_streaming_group( + &pipeline.commands[i..], + stdin_data.take(), + lastpipe, + ) + .await; + let stages = match stages { + Ok(s) => s, + Err(e) => { + self.debug_trap_dormant = saved_dormant; + return Err(e); } }; - self.exit_pipeline_stage(scope, result) + for result in stages { + self.absorb_stage(&mut acc, result, true); + } + break; } - Err(e) => Err(e), - }; - if let Some(cb) = saved_callback { - self.output_callback = Some(cb); - } - let result = match result { - Ok(r) => r, - Err(e) => { - self.debug_trap_dormant = saved_dormant; - return Err(e); + let is_last = i == count - 1; + // Every stage of a multi-command pipeline runs in a subshell, + // except the last one under `shopt -s lastpipe` (bash, job + // control off). + let subshell = count > 1 && !(is_last && lastpipe); + // A non-last stage's stdout feeds the next stage, never the + // streaming observer. + let saved_callback = if is_last { + None + } else { + self.output_callback.take() + }; + let result = match self.enter_pipeline_stage(command, stdin_data.take(), subshell) { + Ok((scope, stdin)) => { + let guard = PipelineStageGuard::new(self, scope); + let result = match command { + Command::Simple(simple) => { + self.execute_simple_command(simple, stdin).await + } + _ => { + self.err_trap_skip_stage = count > 1; + self.execute_command(command).await + } + }; + guard.complete(self, result) + } + Err(e) => Err(e), + }; + if let Some(cb) = saved_callback { + self.output_callback = Some(cb); } - }; - stdin_data = self.absorb_stage(&mut acc, result, is_last); - } - self.debug_trap_dormant = saved_dormant; - DebugTrapOutput::prepend_opt(debug, Ok(self.finish_pipeline(pipeline, acc))) + let result = match result { + Ok(r) => r, + Err(e) => { + self.debug_trap_dormant = saved_dormant; + return Err(e); + } + }; + stdin_data = self.absorb_stage(&mut acc, result, is_last); + } + self.debug_trap_dormant = saved_dormant; + DebugTrapOutput::prepend_opt(debug, Ok(self.finish_pipeline(pipeline, acc))) + }) } /// DEBUG before each simple-command stage of a pipeline, at its line. @@ -7847,7 +7972,8 @@ impl Interpreter { [WordPart::Literal(name)] => name.as_str(), _ => return false, }; - !matches!(name, "eval" | "source" | "." | "bash" | "sh") + !self.is_streaming_host_command(name) + && !matches!(name, "eval" | "source" | "." | "bash" | "sh") && !self.scoped.functions.contains_key(name) && !self.scoped.aliases.contains_key(name) && !Self::streams_stdout(simple) @@ -7874,6 +8000,30 @@ impl Interpreter { matches!(name, "cat" | "grep" | "tr") } + fn execution_output(&self) -> Option> { + self.current_execution_extensions() + .get::>()? + .try_with(Arc::clone) + .ok() + } + + /// Caller-delivered bytes belong to output pumps, not every list/loop. + fn retain_streamed_output(&self) -> bool { + self.output_callback.is_none() + || self.pipe_out.is_some() + || self.execution_output().is_none() + || !self.exec_fd_table.is_empty() + || self.pending_fd_capture_depth > 0 + } + + fn is_streaming_host_command(&self, name: &str) -> bool { + self.host_builtins + .as_ref() + .and_then(|r| r.lookup(name)) + .or_else(|| self.builtins.get(name).cloned()) + .is_some_and(|b| b.streams_io()) + } + /// Run `commands` (the tail of a pipeline) concurrently: every stage but /// the last on a forked shell writing into a bounded [`pipe::Pipe`], the /// last one here (a subshell unless `lastpipe`) so its output streams. @@ -7941,6 +8091,8 @@ impl Interpreter { // `( )` stage counts itself once. child.bash_subshell = self.bash_subshell; } + let command_handles_pipe = matches!(command, Command::Simple(c) + if matches!(c.name.parts.as_slice(), [WordPart::Literal(n)] if child.is_streaming_host_command(n))); let future = async move { let _read_end = read_end; if let Command::Simple(simple) = command @@ -7960,7 +8112,7 @@ impl Interpreter { if r.stdout.len() > sent { write_end.0.write(&r.stdout.as_bytes()[sent..]); } - if write_end.0.is_broken() { + if write_end.0.is_broken() && !command_handles_pipe { r.exit_code = 141; } else if let ControlFlow::Exit(code) | ControlFlow::Return(code) = r.control_flow { r.exit_code = code; @@ -7992,6 +8144,7 @@ impl Interpreter { { let mut last_fut = Box::pin(async { let (scope, stdin) = self.enter_pipeline_stage(last, None, !lastpipe)?; + let guard = PipelineStageGuard::new(self, scope); let result = match last { Command::Simple(simple) => self.execute_simple_command(simple, stdin).await, _ => { @@ -7999,7 +8152,7 @@ impl Interpreter { self.execute_command(last).await } }; - self.exit_pipeline_stage(scope, result) + guard.complete(self, result) }); // Readers first (last stage, then upstream), so each blocks on // its pipe before the writer feeding it runs. @@ -8264,7 +8417,8 @@ impl Interpreter { }; if let Some(result) = finished { - self.maybe_emit_output(&result.stdout, &result.stderr, emit_before); + self.maybe_emit_output(&result.stdout, &result.stderr, emit_before) + .await?; // Emit output immediately (background output goes to terminal in real bash) parent_stdout.append(&result.stdout); parent_stderr.append(&result.stderr); @@ -8333,9 +8487,12 @@ impl Interpreter { if self.merge_stderr { Self::merge_stderr_into_stdout(&mut result); } - self.maybe_emit_output(&result.stdout, &result.stderr, emit_before); - stdout.append(&result.stdout); - stderr.append(&result.stderr); + self.maybe_emit_output(&result.stdout, &result.stderr, emit_before) + .await?; + if self.retain_streamed_output() { + stdout.append(&result.stdout); + stderr.append(&result.stderr); + } exit_code = result.exit_code; self.last_exit_code = exit_code; control_flow = result.control_flow; @@ -8436,9 +8593,12 @@ impl Interpreter { if self.merge_stderr { Self::merge_stderr_into_stdout(&mut result); } - self.maybe_emit_output(&result.stdout, &result.stderr, emit_before); - stdout.append(&result.stdout); - stderr.append(&result.stderr); + self.maybe_emit_output(&result.stdout, &result.stderr, emit_before) + .await?; + if self.retain_streamed_output() { + stdout.append(&result.stdout); + stderr.append(&result.stderr); + } exit_code = result.exit_code; self.last_exit_code = exit_code; control_flow = result.control_flow; @@ -8863,7 +9023,9 @@ impl Interpreter { { let pending = self.subst_stderr.clone(); let before = self.output_emit_count; - self.maybe_emit_output(&crate::StreamData::new(), &pending, before); + self.maybe_emit_output(&crate::StreamData::new(), &pending, before) + .await + .ok(); } }) } @@ -9368,20 +9530,37 @@ impl Interpreter { stdin: Option, ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { - // The stage's own `yes`/`seq` writes straight into the pipe. - self.builtin_stdout_pipe = if self.stream_stdout_command - == Some(command as *const SimpleCommand as usize) - && command.redirects.is_empty() - && !self.scoped.functions.contains_key(name) + let streaming_host = self.is_streaming_host_command(name); + let output = self + .execution_output() + .filter(|_| self.output_callback.is_some()); + let can_stream = !Self::has_output_redirect(&command.redirects) + && !self.scoped.functions.contains_key(name); + self.builtin_stdout_pipe = if can_stream + && (self.stream_stdout_command == Some(command as *const SimpleCommand as usize) + || (self.output_callback.is_some() + && (streaming_host || Self::streams_stdout(command)))) { - self.pipe_out.clone() + self.pipe_out + .clone() + .or_else(|| output.as_ref().map(|o| o.stdout.clone())) } else { None }; - // A streaming filter (`cat`) reads its input pipe as it goes - // instead of having it collected up front. + let explicit_input = command.redirects.iter().any(|r| { + r.fd.unwrap_or(0) == 0 + && matches!( + r.kind, + RedirectKind::Input + | RedirectKind::HereDoc + | RedirectKind::HereDocStrip + | RedirectKind::HereString + | RedirectKind::DupInput + | RedirectKind::ReadWrite + ) + }); self.builtin_stdin_pipe = - if self.builtin_stdout_pipe.is_some() && Self::streams_stdin(name) { + if !explicit_input && (streaming_host || Self::streams_stdin(name)) { self.pipe_in.take() } else { None @@ -9913,6 +10092,8 @@ impl Interpreter { execution_extensions, stdout_pipe: None, stdin_pipe: None, + stderr_pipe: None, + command_routes: None, loop_depth: self.loop_depth, return_depth: self.return_depth, last_exit_code: self.last_exit_code, @@ -9969,6 +10150,33 @@ impl Interpreter { } else { (fs, None) }; + let streaming_command = builtin.streams_io(); + let command_routes = if streaming_command { + self.builtin_stdout_pipe = self + .pipe_out + .clone() + .filter(|_| self.output_callback.is_some()) + .or_else(|| { + self.output_callback + .as_ref() + .and_then(|_| self.execution_output()) + .map(|o| o.stdout.clone()) + }); + match self.live_command_routes(redirects, fs.clone()).await { + Ok(routes) => Some(routes), + Err(crate::Error::CommandFailure(message)) => { + return Ok(ExecResult::err(message, 1)); + } + Err(error) => return Err(error), + } + } else { + None + }; + let stderr_pipe = if builtin.streams_io() && self.output_callback.is_some() { + self.execution_output().map(|o| o.stderr.clone()) + } else { + None + }; let shell_ref = ShellRef { builtins: &self.builtins, host_builtins: self.host_builtins.as_ref(), @@ -9990,6 +10198,8 @@ impl Interpreter { execution_extensions, stdout_pipe: self.builtin_stdout_pipe.take(), stdin_pipe: self.builtin_stdin_pipe.take(), + stderr_pipe, + command_routes, loop_depth: self.loop_depth, return_depth: self.return_depth, last_exit_code: self.last_exit_code, @@ -10072,7 +10282,11 @@ impl Interpreter { self.apply_builtin_side_effects(&mut result).await?; - let result = self.apply_redirections(result, redirects).await?; + let result = if streaming_command { + result + } else { + self.apply_redirections(result, redirects).await? + }; let result = self.apply_after_tool(name, result); drop(diagnostic_storage); drop(std_storage); @@ -12250,7 +12464,8 @@ impl Interpreter { // A command that did not stream its own // output (`xargs -t echo`) streams it now, // after the driver's earlier `Emit`s. - self.maybe_emit_output(&r.stdout, &r.stderr, emit_before); + self.maybe_emit_output(&r.stdout, &r.stderr, emit_before) + .await?; } if let Some(dir) = saved_cwd { self.cwd = dir; @@ -12263,7 +12478,7 @@ impl Interpreter { } builtins::PlanStep::Emit { stdout, stderr } => { let before = self.output_emit_count; - self.maybe_emit_output(&stdout, &stderr, before); + self.maybe_emit_output(&stdout, &stderr, before).await?; } builtins::PlanStep::Done(result) => break Ok(result), } @@ -12631,6 +12846,9 @@ impl Interpreter { self.enter_subshell_pid(); self.xtrace_depth += 1; self.enter_nofork_scope(commands, true, 0); + // Input substitutions still collect into /dev/fd. Live producer + // bytes must stay in that capture instead of reaching outer sinks. + let saved_callback = self.output_callback.take(); let mut failed = None; for cmd in commands { match self.execute_command(cmd).await { @@ -12649,6 +12867,7 @@ impl Interpreter { } } } + self.output_callback = saved_callback; self.restore_subshell_state(*snapshot); self.leave_nofork_scope(); self.last_exit_code = last_exit_code; @@ -14679,7 +14898,9 @@ impl Interpreter { } Err(_) => return None, }; - self.maybe_emit_output(&trap_result.stdout, &trap_result.stderr, emit_before); + self.maybe_emit_output(&trap_result.stdout, &trap_result.stderr, emit_before) + .await + .ok(); stdout.append(&trap_result.stdout); stderr.append(&trap_result.stderr); Some((trap_result.control_flow, trap_result.exit_code)) @@ -14723,7 +14944,9 @@ impl Interpreter { let Some(trap_cmd) = self.scoped.traps.get("RETURN").cloned() else { return; }; - self.maybe_emit_output(&result.stdout, &result.stderr, emit_before); + self.maybe_emit_output(&result.stdout, &result.stderr, emit_before) + .await + .ok(); let saved_exit = self.last_exit_code; let flow = self .run_trap_command(&trap_cmd, &mut result.stdout, &mut result.stderr) @@ -15127,9 +15350,10 @@ mod tests { assert!(budget.live_bytes_for_test() > 0); drop(future); assert!(jobs.try_claim(1).is_some()); - // The caller still owns the final pipe until the ordinary cancelled - // request reconciliation releases its transient streaming state. - assert!(budget.live_bytes_for_test() > 0); + // Live final-stage dispatch owns its reader inside the command future. + // Dropping it restores the subshell and releases both endpoints at once; + // ordinary request reconciliation remains safe to repeat afterwards. + assert_eq!(budget.live_bytes_for_test(), 0); bash.interpreter.clear_cancelled_execution_state(); assert!(bash.interpreter.pipe_in.is_none()); assert_eq!(budget.live_bytes_for_test(), 0); diff --git a/crates/bashkit/src/interpreter/pipe.rs b/crates/bashkit/src/interpreter/pipe.rs index 58a68e4a2..89a36ba60 100644 --- a/crates/bashkit/src/interpreter/pipe.rs +++ b/crates/bashkit/src/interpreter/pipe.rs @@ -1,10 +1,9 @@ //! In-process pipe between concurrent pipeline stages. //! //! Important decision: a pipe is a shared byte buffer, not a channel of -//! chunks. The writing stage appends whole command outputs at command -//! boundaries (overshooting the capacity is allowed, so a single builtin's -//! output is never split) and then waits at its next command until the buffer -//! drops below [`PIPE_CAPACITY`]. The reading stage pulls bytes on demand. +//! chunks. Live commands and output pumps use strict bounded writes; buffered +//! command paths can still append at command boundaries and wait at the next +//! command. The reading stage pulls bytes on demand. //! Closing the read end makes the next append fail, which the writer turns //! into SIGPIPE (exit 141), like a write to a pipe with no readers. //! @@ -85,10 +84,14 @@ impl Pipe { pub(crate) fn new_admitted( budget: &crate::limits::ExecutionBudget, ) -> crate::error::Result> { - let storage = - budget.lease_bytes(std::mem::size_of::() + 2 * std::mem::size_of::())?; + let storage = budget.lease_bytes( + std::mem::size_of::() + 2 * std::mem::size_of::() + PIPE_CAPACITY, + )?; Ok(Arc::new(Self { - state: Mutex::new(State::default()), + state: Mutex::new(State { + buf: Vec::with_capacity(PIPE_CAPACITY), + ..Default::default() + }), _metadata_storage: Some(storage), })) } @@ -113,6 +116,37 @@ impl Pipe { true } + /// Strictly bounded writes for live command I/O. A large caller buffer + /// never becomes a large queued buffer, and reader close wakes the writer. + pub(crate) async fn write_bounded(&self, mut data: &[u8]) -> std::io::Result<()> { + while !data.is_empty() { + let written = poll_fn(|cx| { + let mut s = self.lock(); + if s.read_closed { + s.broken = true; + return Poll::Ready(Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe))); + } + let room = PIPE_CAPACITY.saturating_sub(s.available()); + if room == 0 { + s.write_waker = Some(cx.waker().clone()); + return Poll::Pending; + } + if s.pos > 0 { + let pos = s.pos; + s.buf.drain(..pos); + s.pos = 0; + } + let n = data.len().min(room); + s.buf.extend_from_slice(&data[..n]); + s.wake_reader(); + Poll::Ready(Ok(n)) + }) + .await?; + data = &data[written..]; + } + Ok(()) + } + /// A write hit a closed read end. pub(crate) fn is_broken(&self) -> bool { self.lock().broken @@ -215,6 +249,25 @@ mod tests { use super::*; use std::future::Future; + #[tokio::test] + async fn live_writes_are_bounded_and_reader_close_wakes_writer() { + let pipe = Pipe::new(); + let data = vec![0u8; PIPE_CAPACITY * 8]; + let mut write = Box::pin(pipe.write_bounded(&data)); + let waker = futures_util::task::noop_waker(); + let mut cx = Context::from_waker(&waker); + assert!(write.as_mut().poll(&mut cx).is_pending()); + assert_eq!(pipe.lock().available(), PIPE_CAPACITY); + assert_eq!(pipe.lock().buf.capacity(), PIPE_CAPACITY); + let _ = pipe.read_some().await; + assert!(write.as_mut().poll(&mut cx).is_pending()); + assert_eq!(pipe.lock().available(), PIPE_CAPACITY); + drop(ReadEnd(pipe.clone())); + assert!( + matches!(write.as_mut().poll(&mut cx), Poll::Ready(Err(e)) if e.kind() == std::io::ErrorKind::BrokenPipe) + ); + } + #[tokio::test] async fn read_sees_writes_then_eof() { let p = Pipe::new(); diff --git a/crates/bashkit/src/interpreter/redirection.rs b/crates/bashkit/src/interpreter/redirection.rs index a28d2e2d3..4334e980c 100644 --- a/crates/bashkit/src/interpreter/redirection.rs +++ b/crates/bashkit/src/interpreter/redirection.rs @@ -1003,8 +1003,10 @@ impl Interpreter { continue; } - if redirect.kind == RedirectKind::Output - && self.scoped.variables.get("SHOPT_C").map(|v| v.as_str()) == Some("1") + if matches!( + redirect.kind, + RedirectKind::Output | RedirectKind::OutputBoth + ) && self.scoped.variables.get("SHOPT_C").map(|v| v.as_str()) == Some("1") && !is_dev_null(&path) && self.fs.stat(&path).await.is_ok_and(clobber_protected) { @@ -1720,6 +1722,113 @@ fn redirect_reads(kind: RedirectKind) -> bool { ) } +impl Interpreter { + /// Resolve and open a live command's output fds before invoking host code. + /// Duplication copies the current destination, preserving redirect order. + pub(super) async fn live_command_routes( + &mut self, + redirects: &[Redirect], + fs: Arc, + ) -> Result<[crate::command::CommandRoute; 2]> { + use crate::command::CommandRoute as Route; + let mut fds = HashMap::from([(1, Route::Stdout), (2, Route::Stderr)]); + for (fd, target) in &self.exec_fd_table { + let route = match target { + FdTarget::Stdout => Route::Stdout, + FdTarget::Stderr => Route::Stderr, + FdTarget::WriteFile(path, _) | FdTarget::AppendFile(path, _) => { + Route::file(fs.clone(), path.clone(), &self.execution_budget)? + } + FdTarget::DevNull => Route::Discard, + FdTarget::Closed => Route::Closed, + FdTarget::Coproc(writer) => Route::Pipe(writer.pipe().clone()), + }; + fds.insert(*fd, route); + } + for redirect in redirects { + if !matches!( + redirect.kind, + RedirectKind::Output + | RedirectKind::Clobber + | RedirectKind::Append + | RedirectKind::OutputBoth + | RedirectKind::DupOutput + ) { + continue; + } + let source = redirect.fd.unwrap_or(1); + let target = self.expand_word(&redirect.target).await?; + let route = if redirect.kind == RedirectKind::DupOutput { + if target == "-" { + Route::Closed + } else { + let fd = target.parse::().map_err(|_| { + crate::Error::CommandFailure( + self.redirect_error(&target, "Bad file descriptor"), + ) + })?; + fds.get(&fd).cloned().ok_or_else(|| { + crate::Error::CommandFailure( + self.redirect_error(&target, "Bad file descriptor"), + ) + })? + } + } else { + let path = self.resolve_path(&target); + if let Some(fd) = self.fd_alias(&path) { + fds.get(&fd).cloned().ok_or_else(|| { + crate::Error::CommandFailure( + self.redirect_error(&target, "Bad file descriptor"), + ) + })? + } else if is_dev_null(&path) { + Route::Discard + } else { + if matches!( + redirect.kind, + RedirectKind::Output | RedirectKind::OutputBoth + ) && self + .scoped + .variables + .get("SHOPT_C") + .is_some_and(|v| v == "1") + && fs.stat(&path).await.is_ok_and(clobber_protected) + { + return Err(crate::Error::CommandFailure( + self.redirect_error(&target, "cannot overwrite existing file"), + )); + } + let open = if redirect.kind == RedirectKind::Append { + fs.append_file(&path, b"").await + } else { + fs.write_file(&path, b"").await + }; + match open { + Ok(()) => {} + Err(error @ crate::Error::Io(_)) => { + return Err(crate::Error::CommandFailure( + self.redirect_error(&target, &io_error_reason(&error)), + )); + } + Err(error) => return Err(error), + } + Route::file(fs.clone(), path, &self.execution_budget)? + } + }; + if redirect.kind == RedirectKind::OutputBoth { + fds.insert(1, route.clone()); + fds.insert(2, route); + } else { + fds.insert(source, route); + } + } + Ok([ + fds.remove(&1).unwrap_or(Route::Closed), + fds.remove(&2).unwrap_or(Route::Closed), + ]) + } +} + #[cfg(test)] mod input_admission_tests { use super::*; diff --git a/crates/bashkit/src/interpreter/state.rs b/crates/bashkit/src/interpreter/state.rs index 4080c1ff2..1421738c0 100644 --- a/crates/bashkit/src/interpreter/state.rs +++ b/crates/bashkit/src/interpreter/state.rs @@ -163,6 +163,7 @@ pub(crate) enum LoopAction { /// /// Eliminates duplicated tracking in for, arithmetic-for, and while/until loops. pub(crate) struct LoopAccumulator { + retain_output: bool, pub stdout: crate::StreamData, pub stderr: crate::StreamData, pub exit_code: i32, @@ -170,8 +171,9 @@ pub(crate) struct LoopAccumulator { } impl LoopAccumulator { - pub fn new() -> Self { + pub fn new(retain_output: bool) -> Self { Self { + retain_output, stdout: crate::StreamData::new(), stderr: crate::StreamData::new(), exit_code: 0, @@ -185,8 +187,10 @@ impl LoopAccumulator { /// For multi-level break/continue or return, builds a propagation /// `ExecResult` and returns `LoopAction::Exit`. pub fn accumulate(&mut self, result: ExecResult) -> LoopAction { - self.stdout.append(&result.stdout); - self.stderr.append(&result.stderr); + if self.retain_output { + self.stdout.append(&result.stdout); + self.stderr.append(&result.stderr); + } self.exit_code = result.exit_code; self.errexit_suppressed = result.errexit_suppressed; @@ -440,7 +444,7 @@ mod tests { #[test] fn loop_acc_accumulate_none() { - let mut acc = LoopAccumulator::new(); + let mut acc = LoopAccumulator::new(true); let r = ExecResult { stdout: "out".into(), stderr: "err".into(), @@ -457,7 +461,7 @@ mod tests { #[test] fn loop_acc_accumulate_break_level_1() { - let mut acc = LoopAccumulator::new(); + let mut acc = LoopAccumulator::new(true); let r = ExecResult { control_flow: ControlFlow::Break(1), ..Default::default() @@ -467,7 +471,7 @@ mod tests { #[test] fn loop_acc_accumulate_break_level_3() { - let mut acc = LoopAccumulator::new(); + let mut acc = LoopAccumulator::new(true); acc.stdout.push_str("prev "); let r = ExecResult { stdout: "body".into(), @@ -485,7 +489,7 @@ mod tests { #[test] fn loop_acc_accumulate_continue_level_1() { - let mut acc = LoopAccumulator::new(); + let mut acc = LoopAccumulator::new(true); let r = ExecResult { control_flow: ControlFlow::Continue(1), ..Default::default() @@ -495,7 +499,7 @@ mod tests { #[test] fn loop_acc_accumulate_return() { - let mut acc = LoopAccumulator::new(); + let mut acc = LoopAccumulator::new(true); let r = ExecResult { control_flow: ControlFlow::Return(42), ..Default::default() @@ -511,7 +515,7 @@ mod tests { #[test] fn loop_acc_finish() { - let mut acc = LoopAccumulator::new(); + let mut acc = LoopAccumulator::new(true); let r1 = ExecResult { stdout: "a".into(), exit_code: 1, diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 6a156827b..f1a63e8bc 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -436,9 +436,11 @@ compile_error!( /// Static, pre-execution introspection of a script. pub mod analysis; mod builtins; +mod command; #[cfg(feature = "http_client")] mod credential; mod error; +mod execution; mod execution_capability; mod fs; /// Interceptor hooks for the execution pipeline. @@ -485,10 +487,12 @@ mod time_compat; pub mod tool; /// Structured execution trace events. pub mod trace; +pub use command::{Command, CommandContext, CommandInput, CommandOutput, CommandStatus}; +pub use execution::Execution; pub use stream::StreamData; pub use analysis::{ - AnalyzedCommand, AnalyzedRedirect, CommandContext, RedirectMode, ScriptAnalysis, + AnalyzedCommand, AnalyzedRedirect, CommandPlacement, RedirectMode, ScriptAnalysis, }; pub use async_trait::async_trait; pub use builtins::git::GitConfig; @@ -693,6 +697,7 @@ pub struct ExecOptions { arg0: Option, positional: Option>, stdin: Option, + stream_output: Option>, } impl ExecOptions { @@ -868,6 +873,11 @@ fn inmem_fs_with_home(username: &str, limits: FsLimits) -> InMemoryFs { } impl Bash { + /// Execute with explicit capture and async byte sinks. Output is not + /// retained unless `.capture(max_bytes)` is selected on the builder. + pub fn execute<'a>(&'a mut self, script: &'a str) -> Execution<'a> { + Execution::new(self, script) + } /// Create a new Bash instance with default settings. pub fn new() -> Self { Self::builder().build() @@ -937,6 +947,7 @@ impl Bash { arg0, positional, stdin, + stream_output, } = options; let invocation = Invocation { arg0, @@ -946,7 +957,17 @@ impl Bash { self.interpreter.begin_execution_budget(); // THREAT[TM-ISO-027]: close every request-owned boundary on all exits, // including timeout/cancellation and unwinding teardown paths. - let _budget_completion = self.interpreter.execution_budget().completion_guard(); + let _budget_completion = if stream_output.is_none() { + Some(self.interpreter.execution_budget().completion_guard()) + } else { + None + }; + if let Some(output) = stream_output { + output.install( + self.interpreter.execution_budget(), + self.interpreter.limits(), + )?; + } self.interpreter.bind_command_hash_budget()?; // Expose active execution limits and deadline to builtins that need to // honor per-execution sandbox settings inside synchronous VM sections. @@ -2802,6 +2823,19 @@ impl BashBuilder { self } + /// Register a streaming host command. Completion is separate from byte I/O. + pub fn command(mut self, name: impl Into, command: impl Command + 'static) -> Self { + let name = name.into(); + self.custom_builtins.insert( + name.clone(), + Box::new(command::CommandAdapter { + name, + command: std::sync::Arc::new(command), + }), + ); + self + } + /// Register a builtin whose invocation is fulfilled by an [`ExecutionHandle`]. /// /// Calling this command through ordinary [`Bash::exec`] returns a shell @@ -4089,6 +4123,10 @@ pub mod script_analysis_guide {} #[doc = include_str!("../docs/custom_builtins.md")] pub mod custom_builtins_guide {} +/// Live byte commands, output sinks, capture and runtime support. +#[doc = include_str!("../docs/streaming.md")] +pub mod streaming_guide {} + /// Public guide for clap-backed custom builtins. /// /// This guide covers: diff --git a/crates/bashkit/tests/integration/backtick_quote_tests.rs b/crates/bashkit/tests/integration/backtick_quote_tests.rs index cfd0167a6..0ed7c710c 100644 --- a/crates/bashkit/tests/integration/backtick_quote_tests.rs +++ b/crates/bashkit/tests/integration/backtick_quote_tests.rs @@ -1,7 +1,7 @@ //! Quote-adjacent backticks: Bash parity and permission/budget invariants. use bashkit::hooks::{HookAction, ToolEvent}; -use bashkit::{Bash, CommandContext, ExecutionLimits}; +use bashkit::{Bash, CommandPlacement, ExecutionLimits}; use std::sync::{Arc, Mutex}; #[tokio::test] @@ -83,7 +83,7 @@ fn backticks_after_quotes_are_visible_to_analysis() { .iter() .find(|c| c.name.as_deref() == Some("rm")) .expect("substitution must be reported"); - assert_eq!(rm.context, CommandContext::Substitution); + assert_eq!(rm.context, CommandPlacement::Substitution); let echo = a .commands .iter() diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index 45d70d476..8131f2242 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -57,6 +57,7 @@ pub mod input_redirect_budget_tests; pub mod interactive_builtins_tests; pub mod make_substitution_tests; pub mod memory_growth_security_tests; +pub mod streaming_command_tests; pub mod tar_files_from_budget_tests; // Both assert named-IANA-zone behavior, which only exists with `tzdata`. // `date_timezone_no_tzdata_tests` covers the closed-to-UTC side. diff --git a/crates/bashkit/tests/integration/script_analysis.rs b/crates/bashkit/tests/integration/script_analysis.rs index a0093a7fc..86fec2a57 100644 --- a/crates/bashkit/tests/integration/script_analysis.rs +++ b/crates/bashkit/tests/integration/script_analysis.rs @@ -5,7 +5,7 @@ //! Unit-level coverage of each AST node kind lives in `src/analysis.rs`. use bashkit::hooks::{HookAction, ToolEvent}; -use bashkit::{Bash, CommandContext, ScriptAnalysis}; +use bashkit::{Bash, CommandPlacement, ScriptAnalysis}; use std::sync::{Arc, Mutex}; fn analyze(script: &str) -> ScriptAnalysis { @@ -247,7 +247,7 @@ fn contexts_separate_what_runs_now_from_what_may_run_later() { let direct: Vec<_> = analysis .commands .iter() - .filter(|c| c.context == CommandContext::Direct) + .filter(|c| c.context == CommandPlacement::Direct) .filter_map(|c| c.name.as_deref()) .collect(); assert_eq!(direct, ["ls"]); diff --git a/crates/bashkit/tests/integration/stack_overflow_regression_tests.rs b/crates/bashkit/tests/integration/stack_overflow_regression_tests.rs index d52421ee7..956f783c4 100644 --- a/crates/bashkit/tests/integration/stack_overflow_regression_tests.rs +++ b/crates/bashkit/tests/integration/stack_overflow_regression_tests.rs @@ -5,6 +5,26 @@ use bashkit::{Bash, ExecutionLimits}; +/// Live output routing must preserve the same substitution depth guarantee. +#[tokio::test] +async fn depth_32_live_execution_no_stack_overflow() { + let mut script = "echo hello".to_string(); + for _ in 0..32 { + script = format!("echo $({script})"); + } + let mut bash = Bash::builder() + .limits( + ExecutionLimits::new() + .max_commands(500) + .max_subst_depth(32) + .timeout(std::time::Duration::from_secs(5)), + ) + .build(); + if let Ok(result) = bash.execute(&script).capture(64).await { + assert!(!result.stdout.is_empty() || result.exit_code != 0); + } +} + /// Default max_subst_depth (32) must not cause stack overflow. /// Before the Box::pin fix, this would SIGABRT at ~20-30 levels. /// Regression test for issue #1089. diff --git a/crates/bashkit/tests/integration/streaming_command_tests.rs b/crates/bashkit/tests/integration/streaming_command_tests.rs new file mode 100644 index 000000000..34f993003 --- /dev/null +++ b/crates/bashkit/tests/integration/streaming_command_tests.rs @@ -0,0 +1,498 @@ +//! Live command I/O must make progress before producers finish. + +use async_trait::async_trait; +use bashkit::{Bash, Command, CommandContext, CommandStatus}; +use std::sync::Arc; +use tokio::sync::Notify; + +struct Copy; + +#[tokio::test] +async fn input_process_substitution_captures_streaming_producers() { + for script in [ + "paste <(seq 3) <(seq 4 6)", + "paste <(seq 3 | copy) <(seq 4 6 | copy)", + "paste <(seq 3) <(seq 4 6) | copy", + ] { + let mut bash = Bash::builder().command("copy", Copy).build(); + let result = bash.execute(script).capture(1024).await.unwrap(); + assert_eq!(result.exit_code, 0, "{script}"); + assert_eq!(result.stdout.as_bytes(), b"1\t4\n2\t5\n3\t6\n", "{script}"); + assert!(result.stderr.is_empty(), "{script}"); + } +} + +struct MixedOutput; +#[async_trait] +impl Command for MixedOutput { + async fn execute(&self, ctx: CommandContext<'_>) -> bashkit::Result { + ctx.stdout.write_all(b"a").await?; + ctx.stderr.write_all(b"b").await?; + ctx.stdout.write_all(b"c").await?; + Ok(CommandStatus::success()) + } +} + +#[tokio::test] +async fn command_redirects_preserve_write_and_duplication_order() { + let mut bash = Bash::builder().command("mixed", MixedOutput).build(); + let result = bash.execute("mixed 2>&1").capture(64).await.unwrap(); + assert_eq!(result.stdout.as_bytes(), b"abc"); + assert!(result.stderr.is_empty()); + let result = bash + .execute("mixed >/out 2>&1; cat /out") + .capture(64) + .await + .unwrap(); + assert_eq!(result.stdout.as_bytes(), b"abc"); + let result = bash + .execute("mixed 2>&1 >/out; cat /out") + .capture(64) + .await + .unwrap(); + assert_eq!(result.stdout.as_bytes(), b"bac"); +} + +#[tokio::test] +async fn redirected_command_writes_are_visible_before_it_completes() { + let release = Arc::new(Notify::new()); + let mut bash = Bash::builder() + .command( + "produce", + GatedProducer { + release: release.clone(), + }, + ) + .build(); + let fs = bash.fs(); + let execution = async { bash.execute("produce >/out").capture(64).await }; + let observer = async { + loop { + if fs + .read_file(std::path::Path::new("/out")) + .await + .is_ok_and(|b| b == b"error: first\n") + { + release.notify_one(); + break; + } + tokio::task::yield_now().await; + } + }; + let (result, ()) = tokio::time::timeout(std::time::Duration::from_secs(2), async { + tokio::join!(execution, observer) + }) + .await + .unwrap(); + assert!(result.unwrap().stdout.is_empty()); + assert_eq!( + fs.read_file(std::path::Path::new("/out")).await.unwrap(), + b"error: first\nerror: last\n" + ); +} + +struct BinaryProducer { + handle_broken_pipe: bool, +} + +#[async_trait] +impl Command for BinaryProducer { + async fn execute(&self, ctx: CommandContext<'_>) -> bashkit::Result { + let bytes: Vec = (0..1_100_000).map(|n| (n % 256) as u8).collect(); + match ctx.stdout.write_all(&bytes).await { + Err(bashkit::Error::Io(e)) + if self.handle_broken_pipe && e.kind() == std::io::ErrorKind::BrokenPipe => + { + Ok(CommandStatus::with_code(23)) + } + result => { + result?; + Ok(CommandStatus::success()) + } + } + } +} + +#[tokio::test] +async fn more_than_one_megabyte_crosses_command_and_builtin_stages_unchanged() { + let mut bash = Bash::builder() + .limits(bashkit::ExecutionLimits::default().max_stdout_bytes(1_200_000)) + .command( + "binary", + BinaryProducer { + handle_broken_pipe: false, + }, + ) + .command("copy", Copy) + .build(); + let result = bash + .execute("binary | copy | cat") + .capture(1_200_000) + .await + .unwrap(); + assert_eq!(result.stdout.len(), 1_100_000); + assert!( + result + .stdout + .as_bytes() + .iter() + .enumerate() + .all(|(n, &b)| b == (n % 256) as u8) + ); + assert!(!result.stdout_truncated); +} + +#[tokio::test] +async fn handled_broken_pipe_keeps_handler_status() { + let mut bash = Bash::builder() + .command( + "binary", + BinaryProducer { + handle_broken_pipe: true, + }, + ) + .build(); + let result = bash + .execute("binary | head -c1; printf ':%s' \"${PIPESTATUS[*]}\"") + .capture(64) + .await + .unwrap(); + assert_eq!(result.stdout.as_bytes(), b"\0:23 0"); +} + +#[async_trait] +impl Command for Copy { + async fn execute(&self, ctx: CommandContext<'_>) -> bashkit::Result { + while let Some(bytes) = ctx.stdin.read_chunk().await? { + ctx.stdout.write_all(&bytes).await?; + } + Ok(CommandStatus::success()) + } +} + +struct GatedProducer { + release: Arc, +} + +#[async_trait] +impl Command for GatedProducer { + async fn execute(&self, ctx: CommandContext<'_>) -> bashkit::Result { + ctx.stdout.write_all(b"error: first\n").await?; + self.release.notified().await; + ctx.stdout.write_all(b"error: last\n").await?; + Ok(CommandStatus::success()) + } +} + +#[tokio::test] +async fn final_filter_delivers_before_producer_finishes() { + let release = Arc::new(Notify::new()); + let mut bash = Bash::builder() + .command( + "produce", + GatedProducer { + release: release.clone(), + }, + ) + .command("copy", Copy) + .build(); + let (mut writer, mut reader) = tokio::io::duplex(64); + let execution = async { + bash.execute("produce | copy | grep error") + .stdout(&mut writer) + .capture(1024) + .await + }; + let observer = async { + use tokio::io::AsyncReadExt; + let mut first = [0; 13]; + reader.read_exact(&mut first).await.unwrap(); + assert_eq!(&first, b"error: first\n"); + release.notify_one(); + }; + let (result, ()) = tokio::time::timeout(std::time::Duration::from_secs(2), async { + tokio::join!(execution, observer) + }) + .await + .expect("final filter buffered until producer completion"); + let result = result.unwrap(); + assert_eq!(result.stdout.as_bytes(), b"error: first\nerror: last\n"); + assert_eq!(result.exit_code, 0); +} + +#[tokio::test] +async fn streaming_capture_and_command_substitution_are_distinct() { + let mut bash = Bash::builder().command("copy", Copy).build(); + let result = bash + .execute("v=$(printf hello | copy); printf '%s' \"$v\" | copy") + .capture(32) + .await + .unwrap(); + assert_eq!(result.stdout.as_bytes(), b"hello"); + assert!(!result.stdout_truncated); + let result = bash + .execute("printf 'abcdefgh' | copy") + .capture(3) + .await + .unwrap(); + assert_eq!(result.stdout.as_bytes(), b"abc"); + assert!(result.stdout_truncated); + let result = bash.execute("printf hello | copy").await.unwrap(); + assert!(result.stdout.is_empty()); +} + +#[tokio::test] +async fn command_redirection_keeps_output_out_of_caller_sink() { + let mut bash = Bash::builder().command("copy", Copy).build(); + let result = bash + .execute("printf hello | copy > /out; cat /out") + .capture(32) + .await + .unwrap(); + assert_eq!(result.stdout.as_bytes(), b"hello"); +} + +struct StalledWriter; + +impl tokio::io::AsyncWrite for StalledWriter { + fn poll_write( + self: std::pin::Pin<&mut Self>, + _: &mut std::task::Context<'_>, + _: &[u8], + ) -> std::task::Poll> { + std::task::Poll::Pending + } + fn poll_flush( + self: std::pin::Pin<&mut Self>, + _: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + fn poll_shutdown( + self: std::pin::Pin<&mut Self>, + _: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } +} + +#[tokio::test] +async fn deadline_interrupts_a_stalled_sink_and_session_recovers() { + let limits = bashkit::ExecutionLimits::default().timeout(std::time::Duration::from_millis(50)); + let mut bash = Bash::builder().limits(limits).build(); + let mut sink = StalledWriter; + let result = tokio::time::timeout(std::time::Duration::from_secs(1), async { + bash.execute("echo hello").stdout(&mut sink).await + }) + .await + .expect("sink was not subject to execution deadline"); + assert!(matches!( + result, + Err(bashkit::Error::ResourceLimit( + bashkit::LimitExceeded::Timeout(_) + )) + )); + assert_eq!( + bash.execute("echo recovered") + .capture(64) + .await + .unwrap() + .stdout + .as_bytes(), + b"recovered\n" + ); +} + +#[tokio::test] +async fn failed_sink_drops_pending_producer_and_revokes_handles() { + struct KeepHandles(Arc>>); + #[async_trait] + impl Command for KeepHandles { + async fn execute(&self, ctx: CommandContext<'_>) -> bashkit::Result { + *self.0.lock().unwrap() = Some(ctx.stdout.clone()); + ctx.stdout.write_all(b"first").await?; + std::future::pending().await + } + } + let handle = Arc::new(std::sync::Mutex::new(None)); + let mut bash = Bash::builder() + .command("produce", KeepHandles(handle.clone())) + .build(); + let result = tokio::time::timeout(std::time::Duration::from_secs(1), async { + bash.execute("produce") + .stdout_fn(|_| async { Err(std::io::Error::other("sink closed").into()) }) + .await + }) + .await + .unwrap(); + assert!(matches!(result, Err(bashkit::Error::Io(_)))); + let output = handle.lock().unwrap().take().unwrap(); + assert!(output.write_all(b"late").await.is_err()); + assert_eq!( + bash.execute("echo recovered") + .capture(64) + .await + .unwrap() + .stdout + .as_bytes(), + b"recovered\n" + ); +} + +#[tokio::test] +async fn cancellation_interrupts_pending_command_and_sink() { + for script in ["produce", "echo first"] { + let mut bash = Bash::builder() + .command( + "produce", + GatedProducer { + release: Arc::new(Notify::new()), + }, + ) + .build(); + let cancel = bash.cancellation_token(); + let mut writer = StalledWriter; + let execution = async { bash.execute(script).stdout(&mut writer).await }; + let cancellation = async { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + cancel.store(true, std::sync::atomic::Ordering::Relaxed); + }; + let (result, ()) = tokio::time::timeout(std::time::Duration::from_secs(1), async { + tokio::join!(execution, cancellation) + }) + .await + .unwrap(); + assert!(matches!(result, Err(bashkit::Error::Cancelled))); + bash.cancellation_token() + .store(false, std::sync::atomic::Ordering::Relaxed); + assert_eq!( + bash.execute("echo recovered") + .capture(64) + .await + .unwrap() + .stdout + .as_bytes(), + b"recovered\n" + ); + } +} + +#[tokio::test] +async fn redirecting_stdout_keeps_stderr_live() { + struct ErrorProducer(Arc); + #[async_trait] + impl Command for ErrorProducer { + async fn execute(&self, ctx: CommandContext<'_>) -> bashkit::Result { + ctx.stdout.write_all(b"file").await?; + ctx.stderr.write_all(b"first").await?; + self.0.notified().await; + Ok(CommandStatus::success()) + } + } + let release = Arc::new(Notify::new()); + let mut bash = Bash::builder() + .command("produce", ErrorProducer(release.clone())) + .build(); + let result = tokio::time::timeout(std::time::Duration::from_secs(1), async { + bash.execute("produce >/out") + .capture(64) + .stderr_fn(move |bytes| { + assert_eq!(bytes, b"first"); + release.notify_one(); + async { Ok(()) } + }) + .await + }) + .await + .expect("stderr was buffered behind an unrelated stdout redirect") + .unwrap(); + assert!(result.stdout.is_empty()); + assert_eq!(result.stderr.as_bytes(), b"first"); +} + +#[tokio::test] +async fn sandbox_output_caps_apply_to_binary_sinks_independently_of_capture() { + let mut bash = Bash::builder() + .limits( + bashkit::ExecutionLimits::default() + .max_stdout_bytes(3) + .max_stderr_bytes(2), + ) + .command("copy", Copy) + .build(); + let bytes = Arc::new(std::sync::Mutex::new(Vec::new())); + let sink = bytes.clone(); + let result = bash + .execute("printf '\\000\\377abcdef' | copy; printf 'error' >&2") + .capture(1) + .stdout_fn(move |chunk| { + sink.lock().unwrap().extend(chunk); + async { Ok(()) } + }) + .await + .unwrap(); + assert_eq!(*bytes.lock().unwrap(), b"\0\xffa"); + assert_eq!(result.stdout.as_bytes(), b"\0"); + assert_eq!(result.stderr.as_bytes(), b"e"); + assert!(result.stdout_truncated && result.stderr_truncated); + let result = bash.execute("printf 'abcdef'").await.unwrap(); + assert!(result.stdout.is_empty()); + assert!(result.stdout_truncated); +} + +#[tokio::test] +async fn substitution_in_a_streaming_producer_does_not_leak_to_its_pipe() { + let script = "for i in $(seq 1 20); do echo item-$i; done | grep -E 'item-[0-9]$' | sort -t- -k2 -n | wc -l"; + let mut bash = Bash::new(); + assert_eq!(bash.exec(script).await.unwrap().stdout.as_bytes(), b"9\n"); + assert_eq!( + bash.execute(script) + .capture(64) + .await + .unwrap() + .stdout + .as_bytes(), + b"9\n" + ); + let mut bash = Bash::builder().command("copy", Copy).build(); + assert_eq!( + bash.execute("{ v=$(printf hello | copy); printf '%s' \"$v\"; } | cat") + .capture(64) + .await + .unwrap() + .stdout + .as_bytes(), + b"hello" + ); +} + +#[tokio::test] +async fn accepted_output_is_drained_before_a_command_limit_error() { + let mut bash = Bash::builder() + .limits(bashkit::ExecutionLimits::default().max_commands(2)) + .build(); + let bytes = Arc::new(std::sync::Mutex::new(Vec::new())); + let sink = bytes.clone(); + let result = bash + .execute("echo one; echo two; echo three") + .stdout_fn(move |chunk| { + sink.lock().unwrap().extend(chunk); + async { Ok(()) } + }) + .await; + assert!(result.is_err()); + assert!(bytes.lock().unwrap().starts_with(b"one\n")); +} + +#[tokio::test] +async fn closed_diagnostic_fd_keeps_a_command_io_failure_an_ordinary_status() { + let mut bash = Bash::builder().command("mixed", MixedOutput).build(); + let result = bash + .execute("mixed >&- 2>&-; printf ':done'") + .capture(64) + .await + .unwrap(); + assert_eq!(result.stdout.as_bytes(), b":done"); + assert!(result.stderr.is_empty()); + assert_eq!(result.exit_code, 0); +} diff --git a/docs/c-api.md b/docs/c-api.md index 2038e0799..e28db7c02 100644 --- a/docs/c-api.md +++ b/docs/c-api.md @@ -139,13 +139,35 @@ Calls against one Bashkit instance are serialized. Independent instances may run concurrently. The caller owns handle lifetime synchronization and must not free a handle while another thread is using it. -## Initial exclusions - -ABI v1 does not expose custom builtins, streaming output, an asynchronous shell -execution handle, transport hooks, snapshots, or the cross-addon filesystem-handle -ABI. These remaining surfaces need explicit ownership and callback contracts. -Runtime host functions can complete asynchronously through owned call handles; -host filesystem mounts and cancellation are also supported. +## Streaming output + +`bashkit_execute_streaming(bash, script, capture_limit, callback, context, +&result, &error)` drives async engine output through synchronous byte callbacks +on the calling thread. The callback receives stream 1 (stdout) or 2 (stderr), +and a byte view borrowed only until the callback returns. Return zero to accept +the chunk; a nonzero return aborts execution with an error. A slow callback +backpressures the engine. Do not block indefinitely inside a callback: the +engine cannot preempt native callback code. + +`capture_limit` bounds retained bytes per stream; zero disables capture. The +callback still receives all delivered bytes when capture truncates. A null +callback discards delivery. Free the final result normally. See the runnable +[streaming C example](../crates/bashkit-capi/examples/streaming.c). +Runtime host functions configured on the handle also work with streaming +execution, including replies completed from another thread. + +Callbacks must not call blocking APIs on any handle; same-thread reentry is +rejected rather than waiting on a mutex or nesting runtimes. Cross-thread calls +that wait for a callback to return must not be joined from that callback. +Lock-free `bashkit_cancel()` is permitted. Do not +free the handle or unload the library while execution or a callback is active. +Independent handles may run concurrently. Cancellation cannot roll back host +side effects already performed by a callback. + +ABI v1 does not expose custom-command registration, asynchronous shell execution, +transport hooks, snapshots or external filesystem providers. These need their +own lifetime and unload contracts. See [Streaming execution](streaming.md) for +the runtime support matrix and remaining whole-buffer boundaries. ## Compatibility diff --git a/docs/custom_builtins_js.md b/docs/custom_builtins_js.md index 7c33ab44b..9d27466c0 100644 --- a/docs/custom_builtins_js.md +++ b/docs/custom_builtins_js.md @@ -12,6 +12,9 @@ exposes the same capability via `BashBuilder::builtin_registry`; see the for the Rust API and `crates/bashkit/tests/builtin_registry_tests.rs` for worked examples. +For live byte handlers with awaited writes, explicit capture and async sinks, +see [streaming commands](streaming.md). + ## Quick start ```typescript diff --git a/docs/streaming.md b/docs/streaming.md new file mode 100644 index 000000000..627ba8856 --- /dev/null +++ b/docs/streaming.md @@ -0,0 +1,318 @@ +# Streaming commands and execution + +Read command input and deliver output while a script runs. Streaming commands +work with shell pipes and redirections, so you can connect host APIs and async +services to commands such as `cat` and `grep`. + +A handler reads bytes from `stdin`, awaits writes to `stdout` and `stderr`, and +returns an exit status. Choose output sinks for live delivery, bounded capture +for a final result, or both. See [runtime support](#runtime-support) for the APIs +available on each surface. + +## Rust + +Implement `Command` and register it with `BashBuilder::command`: + +```rust +use bashkit::{async_trait, Bash, Command, CommandContext, CommandStatus}; + +struct Copy; + +#[async_trait] +impl Command for Copy { + async fn execute(&self, ctx: CommandContext<'_>) -> bashkit::Result { + while let Some(bytes) = ctx.stdin.read_chunk().await? { + ctx.stdout.write_all(&bytes).await?; + } + Ok(CommandStatus::success()) + } +} + +# #[tokio::main] +# async fn main() -> bashkit::Result<()> { +let mut bash = Bash::builder().command("copy", Copy).build(); +let mut delivered = Vec::new(); +let result = bash.execute("printf hello | copy") + .stdout(&mut delivered) + .capture(1024) + .await?; + +assert_eq!(delivered, b"hello"); +assert_eq!(result.stdout.as_bytes(), b"hello"); +assert_eq!(result.exit_code, 0); +# Ok(()) +# } +``` + +`.stdout(&mut writer)` and `.stderr(&mut writer)` accept Tokio async byte +writers. The example uses a `Vec` to collect delivered bytes in the host; +replace it with your destination's async writer to forward output. For async +callbacks, use `.stdout_fn(callback)` and `.stderr_fn(callback)`, which await +callbacks returning `Result<()>`. + +`.capture(n)` retains at most `n` bytes per stream in the final `ExecResult`. +Without capture, the result's output fields are empty. Output without a sink or +capture is drained and discarded. Use `as_bytes()` on captured `StreamData` for +exact bytes. + +Pass stdin, invocation arguments or request-scoped extensions through +`.options(ExecOptions)`. See the [Rust API reference](https://docs.rs/bashkit/latest/bashkit/struct.Execution.html) +for the full builder API. + +## Python + +Install the [native Python package](start-python.md), then register an async +callable with `add_command`: + +```python +import asyncio +from bashkit import Bash, CommandStatus + +async def copy(ctx): + async for chunk in ctx.stdin: + await ctx.stdout.write(chunk) + return CommandStatus() + +async def main(): + shell = Bash() + shell.add_command("copy", copy) + + async def show_output(chunk: bytes): + print(repr(chunk), flush=True) + + result = await shell.execute( + "printf hello | copy", stdout=show_output, capture=1024, + ) + assert result.stdout_bytes == b"hello" + assert result.exit_code == 0 + +asyncio.run(main()) +``` + +`BashTool` supports the same registration and execution options. Handlers return +`CommandStatus` or an integer exit status. Read input with `async for` or +`await ctx.stdin.read_chunk()`, which returns `bytes` or `None` at EOF. + +Output sinks must be async callables accepting `bytes`; await your destination +inside the callback. Use `stdout_bytes` and `stderr_bytes` for exact captured +output. The `stdout` and `stderr` text properties use lossy decoding. + +An ordinary `execute(script)` captures bounded output. Passing `stdout` or +`stderr` selects sink delivery; add `capture=n` to retain a final copy as well. +Live commands and byte sinks require async `execute()`; `execute_sync()` cannot +drive them. See the [Python API reference](https://bashkit.sh/api/python/). + +## JavaScript + +Install the [native JavaScript package](start-node.md) for Node, Bun or Deno: + +```javascript +import { Bash } from "@everruns/bashkit"; + +const shell = new Bash({ + commands: { + async copy(ctx) { + for await (const bytes of ctx.stdin) { + await ctx.stdout.write(bytes); + } + return { exitCode: 0 }; + }, + }, +}); + +const result = await shell.execute("printf hello | copy", { + capture: 1024, + async stdout(bytes) { + console.log(bytes); // Uint8Array + }, +}); +console.assert(result.stdout === "hello"); +console.assert(result.exitCode === 0); +``` + +`addCommand(name, callback)` registers a handler after construction. Handlers +return a Promise resolving to an integer or `{exitCode}`. Native `BashTool` +supports the same options. + +Configured [Python and TypeScript host functions](start-node.md#python-and-typescript-host-functions) +remain available with byte sinks and explicit capture. Their handlers retain +the caller's async context and receive an execution-scoped abort signal. + +Sinks receive `Uint8Array` and may return `void` or a Promise; return the +Promise when your destination needs time to consume a chunk. Use +`stdin.readChunk()` to read a chunk or `null` at EOF, and +`stdoutBytes`/`stderrBytes` for exact captured output. Ordinary `execute(script)` +captures bounded output; sink delivery retains a final copy only when `capture` +is specified. Live commands and byte sinks require async `execute()`. See the +[TypeScript API reference](https://bashkit.sh/api/typescript/). + +### Browser and worker + +The [browser package](start-browser.md), `@everruns/bashkit-wasm`, exposes the +same `commands`, `addCommand` and byte-sink APIs. Initialize it before creating +a shell: + +```javascript +import { Bash, initBashkit } from "@everruns/bashkit-wasm"; + +await initBashkit(); + +const shell = new Bash(); +const result = await shell.execute("printf hello", { + capture: 1024, + async stdout(bytes) { + console.log(bytes); + }, +}); +console.assert(result.stdout === "hello"); +``` + +This works on the main thread and in a module worker without `SharedArrayBuffer` +or cross-origin isolation headers. Use `execute()` for live handlers and sinks; +`executeSync()` cannot wait for host Promises. + +## Capture, limits and flow control + +Capture and delivery are separate choices: + +| Output policy | Sink receives bytes while running | Final output retained | +| --- | --- | --- | +| Capture only | No | Up to the capture limit per stream | +| Sink only | Yes | None | +| Sink and capture | Yes | Up to the capture limit per stream | +| Neither, with Rust `Bash::execute` | No; output is discarded | None | + +Python and JavaScript `execute(script)` default to bounded capture. Rust +`Bash::execute(script)` defaults to discard; use `.capture(n)` when you need a +final output value. Rust `exec()` remains the buffered convenience operation. + +Capture is clamped to the sandbox's stdout/stderr byte limits. A smaller capture +limit does not shorten sink delivery. The sandbox limits still cap total +output: bytes beyond those limits are drained and discarded, and the result's +truncation flags are set. Capture truncation also sets those flags. +See [sandbox configuration](configuration.md) for resource limits. + +Live pipes hold at most 4 KiB of queued bytes. The engine awaits each sink +callback before delivering its next chunk. Await writes inside handlers and +await downstream work inside sinks to preserve backpressure. Chunks are byte +segments, not complete lines or UTF-8 characters; use a streaming decoder when +converting text across chunk boundaries. + +Memory retained by your destination is separate from engine capture. A growing +host array or queue can still retain the whole output even when capture is +disabled. Rust async writers flush at completion; flush inside a byte callback +when a buffered destination must make each chunk visible immediately. + +## Routing, errors and cancellation + +A command context provides its name, expanded arguments (`args` in Rust, +`argv` in Python/JS), exported environment, cwd and a request-scoped filesystem, +along with its byte reader and writers. Use those capabilities inside handlers; +do not call execution or state methods on the same shell from its callbacks. + +Shell redirects apply to live command output. Files are opened before handler +invocation, and writes are visible before the handler completes. Redirection +order matters: + +```bash +command >/out 2>&1 # stdout and stderr go to /out +command 2>&1 >/out # stderr keeps the original stdout destination +``` + +Separate stdout/stderr sinks have no shared ordering guarantee. Merge streams +with `2>&1` when you need one destination in write order. + +Nonzero command status is a normal shell result. In Rust, an unhandled +broken-pipe write produces status 141; a handler that catches the error can +return its own status. Sink failure stops the execution and drops pending +engine work. Execution errors are reported as follows: + +| API | Sink, cancellation or resource-limit failure | +| --- | --- | +| Rust | `Err` | +| Native Python and JavaScript | The result's `error` field | +| JS/WASM | Rejected execution Promise | + +Deadlines cover pending sinks as well as engine work. In Rust, use the session's +`cancellation_token()` or `.cancel_with(token)` on the execution builder. In +Python, call `cancel()` and clear it with `clear_cancel()` before reusing the +shell. In JavaScript, pass `signal: controller.signal` in execute options to +interrupt pending delivery. Signal cancellation clears after the call finishes; +explicit `cancel()` remains set until `clearCancel()`. + +Command I/O and filesystem capabilities expire when their execution ends, +including cancellation or sink failure. Retaining a context does not authorize +later I/O. Cancellation does not roll back host side effects already performed. + +## Runtime support + +| Surface | Streaming API | Limitations | +| --- | --- | --- | +| Rust library | Live `Command`, async writers/callbacks, bounded capture | Async execution required. | +| Native Python | `Bash`/`BashTool.add_command`, async byte sinks and capture | Native asyncio API; unavailable in Pyodide. | +| Native JavaScript: Node, Bun, Deno | `Bash`/`BashTool.addCommand`, byte sinks and capture | Async `execute()` required. | +| Browser and module worker | `@everruns/bashkit-wasm` commands, sinks and capture | Async `execute()` required; sync cannot drive host Promises. | +| Other JS/edge hosts | Same WASM API where host facilities are available | Streaming support depends on the host's timers and async callback support. | +| CLI and interactive shell | Live stdout/stderr delivery | Host stdin is still read within its existing input cap. | +| In-process Terminal | Terminal polling, screen and output APIs | General byte-sink options and the `commands` constructor option are not Terminal APIs. | +| Rust tools and scripted tools | Existing informational output streams and final tool results | Existing tool queues do not provide end-to-end bounded backpressure. Native Python/JS `BashTool.execute` byte sinks are supported separately. | +| Native C ABI | `bashkit_execute_streaming`, byte callbacks and optional capture | Callbacks are synchronous; no custom-command registration. See [C API](c-api.md#streaming-output). | +| Pyodide / JupyterLite | Existing synchronous execution | Async commands and byte sinks are unavailable. | +| WASI libraries | Shared core library | No published streaming embedding API. | +| Non-JS component / Hyperlight | Experimental component API | No streaming interface across the component boundary. | +| Native npm package's threaded WASI/browser fallback | Existing generated addon API | Public command/sink wrapper helpers are not exposed through its browser entry. Use `@everruns/bashkit-wasm` for these APIs. | + +## Migration and limitations + +For a handler that needs live I/O, replace returned output strings with awaited +writes, buffered stdin with reader iteration, and the returned output value with +an exit status. Buffered `Builtin` callbacks remain available for whole-input +operations and supported synchronous execution paths. + +If you use Rust's script analysis API, update the type that describes where a +command appears in a script (for example, inside a function or command +substitution): + +```rust +// Before +use bashkit::CommandContext; + +// After +use bashkit::CommandPlacement; +``` + +Update references such as `CommandContext::Direct` to +`CommandPlacement::Direct` as well. Imports from `bashkit::analysis` also use +`CommandPlacement`. `bashkit::CommandContext` now names the context passed to +live command handlers, including their stdin/stdout/stderr. +This rename only affects Rust code using script analysis; see the +[script analysis guide](../crates/bashkit/docs/script-analysis.md). + +The new C streaming entry point is additive within ABI v1. + +Python `on_output`, native JS `onOutput` and WASM `executeWithOutput` remain +synchronous observers with buffered final results. Use the new byte sinks for +awaitable delivery. Native Python/JS execution rejects combining these observers +with explicit capture/sink options; Rust's execution builder also rejects an +`ExecOptions` output observer. + +Whole-output `after_tool`/`after_exec` hooks cannot inspect or replace bytes that +have already been delivered. Live execution rejects these hooks; use buffered +execution for whole-output transformations. + +Streaming is supported by live handlers and selected builtin paths, not every +shell operation. Whole-input utilities, command substitution `$(...)`, process +substitutions `<(...)`/`>(...)`, job results and text observers can still retain +complete values within existing limits. A live handler does not make a buffered +downstream utility incremental. + +## See also + +- [Rust custom builtins](../crates/bashkit/docs/custom_builtins.md) +- [JavaScript custom builtins](custom_builtins_js.md) +- [Sandbox configuration](configuration.md) +- [LLM tools](llm-tools.md) +- [Terminal](terminal.md) +- [C API](c-api.md) +- [Browser quickstart](start-browser.md) +- [Pyodide quickstart](start-pyodide.md) diff --git a/knowledge/foundations/builtins.md b/knowledge/foundations/builtins.md index a5c48e0a5..579d9fdd3 100644 --- a/knowledge/foundations/builtins.md +++ b/knowledge/foundations/builtins.md @@ -9,6 +9,15 @@ tags: # Builtin Commands +## Live host commands + +Use the public `Command` / `CommandContext` boundary for host handlers needing +incremental input or awaited output. Completion is `CommandStatus`, independent +of bytes. `BashBuilder::command` and `BuiltinRegistry::insert_command` install an +internal adapter; `Builtin` remains the buffered utility contract. See the +[streaming design](streaming-roadmap.md) for routing, budget lifetime, whole-output +hook restrictions, bindings, tests and explicitly buffered roadmap boundaries. + ## Status Implemented diff --git a/knowledge/foundations/index.md b/knowledge/foundations/index.md index 79e93fcd6..558d4e734 100644 --- a/knowledge/foundations/index.md +++ b/knowledge/foundations/index.md @@ -6,5 +6,6 @@ * [Builtin Commands](builtins.md) - Builtin command trait, execution planning, registration, and implementation conventions. * [jq Input Compatibility](jq.md) - jq JSON input normalization, strict-boundary scope, and resource-accounting rules. * [Parallel Execution](parallel-execution.md) - Threading model, shared ownership, and concurrency safety requirements. +* [Streaming Handler Roadmap](streaming-roadmap.md) - Proposed handler I/O contract, whole-buffer boundary audit, and acceptance gates for general streaming. * [Snapshot History and Deltas](snapshot-history.md) - Content-addressed snapshot objects, commit DAG with forks, chunked binary content, and the version and capability compatibility rules for restore. * [Execution Profiles](execution-profiles.md) - Typed policy bundles across execution, memory, filesystem, network, and embedded runtimes. diff --git a/knowledge/foundations/streaming-roadmap.md b/knowledge/foundations/streaming-roadmap.md new file mode 100644 index 000000000..e8cb8fa61 --- /dev/null +++ b/knowledge/foundations/streaming-roadmap.md @@ -0,0 +1,325 @@ +--- +type: Subsystem Design +title: Streaming Handler Roadmap +description: Live command I/O implementation, whole-buffer boundary audit, and remaining runtime acceptance gates. +tags: + - bashkit + - streaming + - handlers + - roadmap +--- + +# Streaming Handler Roadmap + +## Status and scope + +Implemented the public live command boundary and explicit execution sink/capture +policy. This is an incremental engine change; process substitutions, buffered +utilities and legacy tool/terminal queues remain separate work. Input process +substitutions suppress outer output sinks while collecting their producer bytes, +including incremental builtins and live host commands; this preserves the +existing buffered `/dev/fd` behavior without leaking intermediate output. +Native process +spawning, a browser process host, and jobs surviving `exec()` require separate +designs; the default remains an in-process sandbox. + +Design direction: prefer one coherent streaming-first API over preserving old +signatures. Breaking API changes are allowed. Measure and document migration +cost; do not retain parallel legacy APIs solely for compatibility. Buffered +capture remains a useful operation in the final API, not a legacy exception. +Request isolation, resource limits and shell semantics remain requirements. + +[brush-web PR #12](https://github.com/ShaulLavo/brush-web/pull/12) merged an +owner-approved WASM-first shell plan on 2026-10-16. It retains Bashkit's parser +and ports its bash rules into a new interpreter/engine. This is evidence of a +consumer requirement, not evidence that the replacement engine already works. +The plan identifies four whole-buffer boundaries and gates compatibility work +on browser streaming acceptance tests. Its local spike report is not present in +this repository; the audit below independently checks the named boundaries. + +Reviewed Bashkit: `76834b9e09e254cc857410f3ec94b58a9b0b1cee`. +brush-web's inspected Bashkit revision is +`491c3281f668b76d64b15210b8f5d7bf97244be0`; do not assume its findings describe +every later revision unchanged. + +## Implemented command boundary + +`Command::execute(CommandContext) -> Result` exposes shared-cursor +byte input and awaited byte output after descriptor routing. `Bash::execute` +selects async sinks, bounded capture, or discard. The existing `Builtin` value +contract remains for buffered utilities and synchronous hosts; it does not claim +live I/O. Root analysis `CommandContext` is renamed `CommandPlacement`. + +The adapter preserves duplicate-fd write ordering, opens redirects before host +invocation, and streams file writes. Pipes strictly bound live writes at 4 KiB; +reader close wakes writers. The request budget and completion guard remain +active through final sink flush; retained handles are revoked on every exit. +Sink errors drop pending engine futures. Sandbox byte caps still bound delivery +independently of a smaller explicit capture cap (TM-INT-009). Deadlines/cancellation cover sinks on +native and JS/WASM executors. Live output cannot safely apply whole-output +transforming after hooks, so those combinations reject before running. + +Pipeline futures have a separate heap boundary: their cancellation-guard state +must not enlarge every recursive simple-command poll frame. Both buffered and +live execution must preserve depth-32 substitution tests, including coverage +builds (TM-DOS-089); stack limits and substitution limits are not raised. + +List/loop/pipeline accumulation drops bytes already delivered by explicit live +execution. Final streaming filters and registered live commands receive stdin +pipes independently of output destination. A hidden command-substitution scope +must not inherit the surrounding producer's stdout pipe. Accepted output drains +before ordinary engine failure; a failed sink drops the engine immediately. Pipeline scope guards restore child +state on dropped futures. Async output emission stays boxed to respect the +existing native debug-build stack budget. Live execution yields every 64 +commands for fairness; buffered synchronous APIs retain their single-poll model. + +Native Python and NAPI JS expose live commands and async byte sinks on Bash and +BashTool; JS/WASM uses the same core on its cooperative executor. C ABI exposes +output-only synchronous callbacks. + +NAPI command I/O uses type-checked opaque `External` handles, matching the +filesystem boundary. Read/write entry points clone Rust capabilities on the JS +thread before spawning futures; pending I/O does not borrow generated JS class +instances. Forged and mismatched handles reject before execution. This removes +the generated class pointer-conversion paths flagged by CodeQL. + +CLI oneshot/interactive output uses the new builder. The +[public support matrix](../../docs/streaming.md) distinguishes verified native +and JS/WASM APIs from the remaining tool/Terminal, Pyodide, WASI and Hyperlight +limitations and host-specific edge verification. Process +substitutions are still buffered; the audit below records the original baseline +and remaining roadmap. New tests live in core `streaming_command_tests`, Python +`test_streaming_commands.py`, NAPI `streaming-commands.spec.ts`, WASM +`streaming.test.mjs`, and C ABI/compiled consumer suites. + +## Baseline contract before this change + +- `Builtin::execute(Context) -> Result` returns output values. + `Context::stdin` is borrowed `StreamData`; `CommandResolver::resolve` returns + an `Arc`, not a process handle. +- `Context::stdin_stream` and `stdout_stream` are crate-private and reached + through `ShellRef`. A public custom builtin cannot use them. Internal + `yes`, `seq`, plain `cat`, selected `grep` and `tr` paths already stream over + bounded pipes. Compound stages can emit at command boundaries. +- `OutputCallback` is synchronous and returns `()`. `exec_streaming` still + accumulates its final `ExecResult`; observing chunks does not provide an + awaitable, backpressured sink or eliminate capture. + +Sources: [builtin context and traits](../../crates/bashkit/src/builtins/mod.rs), +[dispatch and pipeline routing](../../crates/bashkit/src/interpreter/mod.rs), +[execution options](../../crates/bashkit/src/lib.rs), and +[result types](../../crates/bashkit/src/interpreter/state.rs). + +## Four whole-buffer boundaries + +| Boundary | Baseline behavior at the inspected revision | Roadmap target | +| --- | --- | --- | +| Final pipeline stage | `execute_streaming_stages` marks only producer commands with `stream_stdout_command`. `execute_dispatched_command_inner` supplies streaming stdin only when a builtin stdout pipe is present. A final simple filter therefore generally reaches `fill_stdin_from_pipe` and waits for EOF; `absorb_stage` keeps its returned stdout. A final compound loop can already stream through the observer. | Route every stage through descriptor-backed readers/writers, including the last stage's output destination. Add a test that observes a final filter's first output while its producer is still running. | +| Generic stdin collection | `fill_stdin_from_pipe` drains `StdinDemand::All` before dispatch. Internal streaming filters bypass it selectively; `Context::stdin_to_end` explicitly collects for buffered options. Unknown/custom commands have no public incremental reader. | Give handlers a reader at dispatch and let the implementation consume on demand. Whole-input utilities explicitly request bounded capture or spill; replacing name-based exceptions must not make `read` overconsume input. | +| Process substitution | `expand_process_substitution` runs `<(...)` to completion into a `String`, then registers `ProcSubData::Input`. `>(...)` stores bytes in a shared `Vec` and queues `deferred_proc_subs` for later execution. Neither is a live pipe. | Use concurrently scheduled substitution tasks and shared descriptor endpoints in both directions. Preserve subshell isolation, fd ownership, EOF, cancellation and byte fidelity. Keep `$(...)` as a separate, explicitly budgeted capture boundary. | +| Accumulated result output | `set_output_callback` explicitly retains output in `ExecResult`; pipeline/list/loop accumulators and finished-job output can also retain data. Output caps limit retained output but do not turn the observer into flow control. | Separate delivery from capture at the execution API and audit every accumulator. Offer a sink-only mode with status/side effects and explicit capture metadata; retain bounded capture where the caller needs a final value. | + +All interpreter symbols above are in +[interpreter/mod.rs](../../crates/bashkit/src/interpreter/mod.rs). +Supporting sources: [pipe.rs](../../crates/bashkit/src/interpreter/pipe.rs), +[jobs.rs](../../crates/bashkit/src/interpreter/jobs.rs), +[builtin I/O helpers](../../crates/bashkit/src/builtins/mod.rs), and +[process-substitution VFS](../../crates/bashkit/src/fs/proc_sub_fds.rs). +These paths interact: exposing a streaming handler alone does not remove +final-stage dispatch, substitution, or result-capture buffering. + +## Proposed handler contract + +Here, handler means a registered/resolved custom command, not `OutputCallback`, +a trap, or an embedded-language callback. Exact Rust types remain open. + +1. **Invocation.** Expanded command name/argv, effective exported environment + including command-prefix assignments, cwd, and the request-scoped VFS view. + Preserve current resolution precedence. Stream capability belongs to the + resolved implementation, not a command-name allowlist. Do not expose mutable + interpreter internals to external handlers; shell-state changes keep their + existing controlled channels. +2. **I/O.** Async byte reader and separate stdout/stderr writers, backed by + the same routing abstraction as builtins, redirects and pipes. Specify + partial reads/writes, explicit EOF and close, and shared endpoint ownership: + EOF follows the last writer close; broken pipe follows the last reader + close. A full sink suspends the producer without blocking the executor. + Redirection and descriptor duplication happen before handler execution; + nonstandard descriptors must be bound explicitly if supported, otherwise + rejected explicitly. Preserve byte order per stream; `2>&1` shares one + ordered destination. No implicit UTF-8 decoding. +3. **Completion.** Streaming handlers return exit status and permitted side + effects, without returning the emitted bytes a second time. Ordinary command + failures remain statuses; request cancellation, deadline and budget failures + remain execution errors. Broken-pipe policy must distinguish a producer + terminated with 141 from a handler that handles the write error and returns + its own status. Preserve `PIPESTATUS`, `pipefail`, `!` and `lastpipe` behavior. +4. **Lifecycle.** Reads, writes and host awaits share the originating request's + cancellation/deadline and revocable capability lease. Closing a request + wakes blocked operations, drops work and closes endpoints; late writes and + retained capabilities cannot reach a later request. CPU-only handler work + must cooperatively yield/check budgets. External host resources need an + explicit cleanup acknowledgment before request teardown completes. +5. **Memory.** Admit buffer growth before allocation. Define limits for pipe + storage, pending writes, handler workspaces, capture, and host/worker output + queues. Bounded pipes alone are insufficient. A slow sink backpressures; + whole-input operations spill through a quota-controlled facility or fail + explicitly. Record output truncation separately from successful delivery; + never silently drop a pipe write to satisfy a capture cap. +6. **Execution and capture.** Use one command I/O contract and a separate + completion value. Make bounded capture and caller-supplied output sinks + explicit execution policies; avoid separate legacy and streaming handler + families. Sink-only execution must not retain a hidden full-output copy. + Whole-input utilities use an explicit budgeted collection helper. Capturing + execution can tee delivered bytes into bounded `StreamData` and return + truncation/status metadata. Migrate `exec_streaming`, bindings and tools to + the final model together with their published contracts; a synchronous + observer is not a backpressure adapter. Tool JSON text encoding and + byte-oriented engine I/O remain distinct boundaries. + +The [Request Execution Lifecycle](../security/request-lifecycle.md) and +[Tool Contract](../integrations/tool-contract.md) remain authoritative for +implemented behavior. An optional real-process adapter would additionally need +spawn/wait/kill/readiness, descriptor bindings, and verified teardown; none is +implicitly granted by adding streaming handler I/O. + +## Breaking-change policy and public contract + +The [public streaming guide](../../docs/streaming.md) is the canonical +consumer-facing API guide, migration reference and runtime support matrix. +The runtime verification matrix below retains test evidence and outstanding checks. +Rust root analysis imports migrate to `CommandPlacement`; live handler +registration is available alongside the buffered utility operation. The command +API and the remaining roadmap have separate support boundaries; sharing the +core does not establish runtime parity. + +The largest source changes are custom handlers moving from input/output values +to readers/writers, and binding/host implementations gaining async I/O and +cleanup. Callers that only execute scripts and collect output should have a +small, mechanical migration to explicit capture. Existing script text normally +needs no rewrite; timing, early close and substitution semantics need +differential tests. C ABI changes obey its versioning rules even when Rust and +language APIs break deliberately. + +The public guide records per-language signatures, byte types, async restrictions, +capture defaults/caps, completion/errors and cancellation/reentry. Extending a +remaining surface requires its own runnable proof and updated matrix entry. +Do not infer runtime support from compilation. + +For release, every published surface must have runtime evidence for its stated +contract. Any unavailable capability must be explicit in the matrix and public +docs; reduced/experimental targets keep their existing qualification. An +unimplemented row cannot silently count as supported. No promise of universal +API parity or new process-host functionality follows from sharing the core. + +## Incremental roadmap and acceptance + +Current progress: the command boundary, explicit sinks/capture and live final +filter/handler routing are implemented. Whole-input collection helpers, general +fd lifecycle, process substitutions and the remaining tool/job/terminal queues +are still roadmap work. The matrix reports current support independently of +these future acceptance targets. + +0. **Contract and surface matrix.** Complete the per-language contract above, + record before/after migration examples and test ownership for every public + matrix row, and establish runnable proofs for constrained hosts. Public docs + and migration guidance are release deliverables, updated with each step. +1. **Public command I/O and capture policy.** Settle reader/writer, completion, + close, cancellation and capture semantics. Prove a custom handler works as + producer, intermediate filter and final filter with deterministic readiness + gates. Provide budgeted whole-input helpers for commands needing them. +2. **General stage routing.** Remove the final-stage and generic-stdin barriers + together; route sinks, redirects and explicit descriptor inheritance through + the same I/O contract. Test shell-state isolation and `lastpipe` separately. +3. **Live process substitutions.** Replace both capture/deferred paths with + scheduled tasks. Test descriptor duplication/last close, nested substitutions, + early consumer exit, arbitrary bytes and cancellation cleanup. +4. **End-to-end memory proof.** Audit list/loop/job accumulators and binding/tool + queues in sink-only mode. Demonstrate a retained-memory plateau with growing + output; report retained capture and peak intermediate memory separately. + +Every implementation step needs tests appropriate to its boundary: + +- A gated long-lived ticker through a final filter delivers a matching line + before producer completion. Assert the producing task is still active at + observation; checking concatenated chunks after completion proves no latency + property. +- More than 1 MiB, including NUL and invalid UTF-8, crosses builtin/handler, + handler/builtin, handler/handler and builtin/builtin pipelines unchanged. + Separate full-capture assertions from sink-only delivery assertions. +- Slow readers and output sinks bound memory and suspend producers; an + always-ready background task cannot starve foreground progress. Account for + oversized writes/records and utilities that intrinsically accumulate data. +- Early reader exit wakes blocked writers, releases endpoints/job slots/budget + leases, and gives correct statuses, including a handler that handles EPIPE. +- Cancellation and deadline expiry during read, write, capture and host awaits + leave no live work or late output; a subsequent request remains usable. +- Redirects, `2>&1`, substitutions, pipeline state isolation and `lastpipe` + preserve GNU bash semantics. Compare stdout bytes, stderr where applicable, + status and `PIPESTATUS` under identical oracle inputs. + +Extend the existing [pipeline integration tests](../../crates/bashkit/tests/integration/pipeline_tests.rs) +and [byte-stream tests](../../crates/bashkit/tests/integration/byte_stream_tests.rs); +neither alone establishes all of these gates. New command tests add gated +native and single-threaded WASM proofs, including a real browser main thread and +worker. Process-host tests remain separate and must assert actual children, +interrupt delivery and bounded worker/terminal queues. brush-web's sixteen tests across +seven scenarios are a useful consumer target, not a Bashkit test result. + +## Runtime verification matrix + +This table records engineering evidence and outstanding runtime checks. The +[public guide](../../docs/streaming.md#runtime-support) describes available APIs +and user-visible limitations without local-run or release-process language. + +| Surface | Implemented contract | Evidence / remaining gate | +| --- | --- | --- | +| Rust async library | Live handlers, byte sinks, explicit bounded capture | `streaming_command_tests`: gated final filter, binary >1 MB, routing, broken pipe, deadline, cancellation, revoked handles and recovery. | +| Native Python | Async byte commands and sinks on `Bash`/`BashTool` | `test_streaming_commands.py`; existing output-observer regression tests. Native Linux tested; other wheel platforms use CI. | +| Native Node | Same live command contract on `Bash`/`BashTool` | `streaming-commands.spec.ts`; output observer regressions. Linux addon tested. | +| Bun / Deno native addon | Same NAPI command/sink contract | `runtime-compat/streaming-commands.test.mjs` passes on Bun 1.4.3 and Deno 2.9.6; CI runs the runtime-specific suites. | +| JS/WASM | Async commands/sinks, no threads or COOP/COEP | `streaming.test.mjs`; browser main-thread/worker smoke. Sync remains restricted. | +| NAPI package threaded WASI/browser fallback | Existing generated addon API | New public command/sink wrapper helpers are not exposed through its browser entry. Use `@everruns/bashkit-wasm`; parity needs a separate runtime gate. | +| Other JS / edge hosts | WASM package where required JS facilities exist | Host-specific runtime verification required. | +| CLI / interactive shell | New async byte output driver, without retaining a final output copy | CLI regression suites; stdin is still read within its existing host-input cap. Interactive interrupt and slow-terminal checks remain runtime gates. | +| In-process Terminal | Existing polling and terminal output contract | Terminal regression tests; new `commands` registration and async sink options are not a Terminal API. | +| Rust tools / scripted tools | Existing informational output stream and final tool result | Tool streams still use their existing queue/encoding contract; do not claim end-to-end bounded backpressure. Python/JS `BashTool.execute` byte sinks are supported separately. | +| Native C ABI | `bashkit_execute_streaming`, synchronous byte callbacks, optional capture | ABI tests cover bytes, capture, callback failure and reentry. C consumer/header checks. No custom-command registration. | +| Pyodide / JupyterLite | Existing reduced synchronous API | New async commands/sinks unavailable; actual wheel remains a separate runtime gate. | +| WASI library targets | Shared core source remains portable | Compilation prerequisite only; no published WASI streaming entry point or tested embedding. | +| Non-JS component / Hyperlight | Existing experimental contract | No new component streaming boundary; host and micro-VM tests required before support claims. | + +## Local verification (2026-10-16) + +- Rust: 3,727 unit tests and 2,351 integration tests pass (four existing ignores). + Fifteen new streaming command cases cover live delivery, binary routing, + caps, substitutions, cancellation, sink failure, deadlines and cleanup. +- CLI with realfs: 89 unit and 68 oneshot tests pass, including four 2 MiB + stack checks and gated partial-line delivery before script completion. +- Native Python 3.12: 851 pass, six optional-dependency skips. +- Native Node: 605 AVA tests and 167 runtime compatibility tests pass. The + compatibility suite also passes on Bun 1.4.3 (167 tests) and Deno 2.9.6 + (27 tests, 163 nested steps). Deno CI uses `deno test`, not `deno run`. +- JS/WASM: 75 package tests pass. Real Chromium main-thread and module-worker + tests prove gated first delivery, async sink progress, bytes, abort and reuse. + Optimized package remains within existing raw/gzip/Brotli transfer budgets. +- C ABI: 20 contract tests pass; C11 and C++17 header consumers compile and the + new streaming C program runs. Header symbols match the Windows export list. +- Core and binding Clippy checks pass with warnings denied; formatting, Python + lint, TypeScript wrapper/test type checks, docs links, OKF and workflow parity + pass. `wasm32-wasip2` core check passes; this is not a runtime support claim. +- Criterion parallel execution and the 96-case Bash comparison harness ran with + saved reports in their established results directories. All 96 cases match + GNU Bash output with zero errors. Timings share this development host with + other checks and are exploratory; no before/after performance claim. +- Rustdoc guide examples pass with their required features (159 tests, 56 + existing ignores). Public site build and API-reference regeneration pass. + +## See also + +- [Parallel Execution](parallel-execution.md), current scheduling and pipe semantics +- [Builtin Commands](builtins.md), command registration and side effects +- [Known Limitations](../operations/limitations.md), L-PIPE-001, L-PROC-002/003/005 and L-STREAM-001 +- [Browser Package](../runtimes/browser-package.md), single-threaded WASM constraints +- [Testing Strategy](../operations/testing.md), differential and integration checks +- [Streaming design and migration](../../docs/streaming.md), public API direction and surface/test matrix diff --git a/knowledge/integrations/script-analysis.md b/knowledge/integrations/script-analysis.md index e95dda206..2a8d5e3bf 100644 --- a/knowledge/integrations/script-analysis.md +++ b/knowledge/integrations/script-analysis.md @@ -132,13 +132,18 @@ anything. |---|---|---| | `name` | `string \| null` | Command name if fully literal, else `null` | | `args` | `(string \| null)[]` | One entry per argument; `null` when not fully literal | -| `context` | `CommandContext` | Where the command sits | +| `context` | `CommandPlacement` | Where the command sits | | `assignments` | `string[]` | Names of prefix assignments (`FOO=1 cmd`) | -`CommandContext` is `Direct` (runs when the script runs), `Substitution` +`CommandPlacement` is `Direct` (runs when the script runs), `Substitution` (inside `$(…)`, backticks, or process substitution), or `FunctionBody` (inside a function definition, so it runs only if the function is called). +Rust exposes this enum as both `bashkit::CommandPlacement` and +`bashkit::analysis::CommandPlacement`. `CommandContext` is reserved for live +command handlers. The analysis field remains `context`, and its serialized +values remain `direct`, `substitution` and `function_body`. + ### `AnalyzedRedirect` | Field | Type | Meaning | diff --git a/knowledge/log.md b/knowledge/log.md index dc1fcae2d..dbd76a7e5 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,12 +2,26 @@ ## 2026-10-16 +* **C streaming callbacks**: Integrated C ABI runtime host functions with live output. Buffered and streaming entry points share admission guards, and output callbacks reject blocking reentry on any handle before a nested runtime can start. Combined tests cover immediate/cross-thread replies, capture and discard, caller affinity, and recursive streaming calls. See [C API](runtimes/c-api.md). + * **C runtime parity**: Added optional Python/TypeScript runtime flags and an additive ABI v1 host dispatcher. Owned, revocable runtime calls accept immediate or cross-thread replies; blocking callback reentry fails with BUSY. Added parallel-instance, caller-thread, cancellation/deadline/reuse and real C header/library tests. Corrected parity evidence for existing C cancellation and mounts. See [C API](runtimes/c-api.md). +* **Live runtime callbacks**: Integrated JavaScript Python/TypeScript host functions with byte sinks and explicit capture after the runtime-function API landed. Buffered and live execution share the request-local dispatcher lease and async/sync admission guard. Regression suites cover queued caller contexts, output delivery and signal revocation across Node, Bun and Deno. See [Node Package](runtimes/node-package.md). + * **Node runtime parity**: Added Python and TypeScript named host callbacks to Bash/BashTool and JS agent adapters, with per-execution caller context, cooperative abort, reentry guards and worker/platform tests. Private runtime ownership also handles forced worker teardown without dropping a Tokio runtime inside an async task. Added explicit runtime host-function capability rows and a shared bounded JSON codec for foreign bindings. See [Node Package](runtimes/node-package.md). +* **Node command I/O**: Replaced generated command-I/O classes with type-checked opaque handles and cloned Rust capabilities before async work. This removes CodeQL's flagged class pointer-conversion paths; invalid and cross-type handles reject at the binding boundary while the public streaming API stays unchanged. See [Streaming Handler Roadmap](foundations/streaming-roadmap.md). + +* **Python cancellation**: Record callback-session cancellation synchronously and check it before starting buffered or streaming execution. A cancelled request queued behind another handler cannot race the native cancellation bridge into filesystem side effects; other requests remain usable. Regression tests cover `Bash`/`BashTool`, live commands and Python/TypeScript host functions. See [Python Package](runtimes/python-package.md). + * **Python / agent integrations**: Added Python and TypeScript host function callbacks to `BashTool`, LangChain and deepagents. Reuse per-execution callback sessions for caller-loop affinity, ContextVars and cancellation; sync execution uses the dedicated private worker loop. Reentry is callback-context-specific so independent requests queue safely. Private-loop timeout cancellation now targets its own work item; native result deliveries drain before interpreter finalization to prevent a shutdown race. Added FastAPI, real Jupyter, alternate-loop and cross-thread regression coverage plus macOS/Windows callback CI. See [Python Package](runtimes/python-package.md). +* **Rust API naming**: Named the script-analysis enum `CommandPlacement` in the analysis module and crate exports. Live handlers retain `CommandContext`. Updated tests and public migration examples; the `context` field and serialized values remain unchanged. See [Script Analysis](integrations/script-analysis.md). + +* **Streaming command I/O**: Added public byte readers/writers with separate command status and explicit async sink/bounded capture execution. Final streaming filters and live commands bypass whole-input dispatch; redirects preserve write/duplication order. Request lifetime includes sink completion, deadlines, cancellation and revoked handles. Native Rust/Python/JS, JS/WASM and C output callbacks have runtime tests; CLI uses the new output driver. Public usage/migration docs and a per-surface test matrix identify remaining buffered substitutions, legacy tools/Terminal and unverified runtimes. See [Streaming Handler Roadmap](foundations/streaming-roadmap.md). + +* **Streaming documentation**: Reworked the public streaming guide as product documentation: present-tense API behavior, runnable language examples, explicit capture defaults, errors/cancellation and a user-facing runtime support matrix. Removed draft/release-gate language; retained detailed runtime/test evidence in the streaming engineering notes. Aligned the packaged Rustdoc guide and page metadata. See [Streaming Handler Roadmap](foundations/streaming-roadmap.md). + ## 2026-10-15 * **npm publication**: The 0.19.0 browser upload returned a staged-version E409, but public registry read-back showed the version and `latest` tag. Both npm lanes now publish one retained archive and require its SHA-512 integrity plus the intended public dist-tag. Only the specific staged conflict enters bounded verification; authentication errors and private or mismatched artifacts still fail. See [Release Process](operations/release-process.md). diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index 178d84280..d36913dfd 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -49,7 +49,7 @@ execution model. Evidence is a threat-model ID, a test, or `stance` | L-PROC-005 | Coprocesses run concurrently with bash's fd numbers and cleanup, with these gaps: a coproc fd as a compound command's stdin (`while read l; do ...; done <&${C[0]}`) is read to end of input up front; `read -n`/`-N` on a coproc fd take a whole line (the rest of it is lost); writing to a coproc that exited is silently dropped (bash takes SIGPIPE); a finished coproc stays (NAME set, output readable) until `wait` reaps it, where bash reaps on SIGCHLD; coprocs end with the `exec()` call; an empty `<&"${C[0]}"` reports `ambiguous redirect` without the quotes where bash says `Bad file descriptor` | Deterministic, sandboxed job model (no SIGCHLD, no process outliving the call) | `coproc_tests.rs` | | L-PROC-004 | `bash`/`sh` child shells nest at most 8 deep (and count against `max_function_depth`); deeper nesting fails `maximum nesting depth exceeded` | Child shells run in-process on one stack; unbounded nesting crashed the process (TM-DOS-125) | `l_proc_004_child_shell_depth` | | L-HIST-001 | `fc` with no `-l`/`-s` (and `fc -e EDITOR`) fails `fc: editing history is not supported in bashkit` instead of opening an editor on the selected commands; `fc -s` and `fc -e -` rerun them | No terminal and no external editor process in the sandbox | `history-fc.test.sh` | -| L-PIPE-001 | Pipeline stages stream only from the first stage that runs shell code (loop, group, function, `eval`). Leading single-builtin stages run to completion first and hand over their whole output, so they never get SIGPIPE (`sort big \| head -1` gives `PIPESTATUS` `0 0`, bash `141 0`); `yes`, `seq`, plain `cat`, `grep` and `tr` are the exception and stream. Downstream of a streaming stage, a single builtin reads all its input before it starts, except `head`, `read`, plain `cat`, `grep` (not `-c/-l/-L/-q`, context, `-b`) and `tr` (not `-s`): `while :; do echo; done \| sed p \| head -1` runs until a limit. Pipes hold 4 KiB, not 64 KiB, so a finite loop writing more than that into an early-exiting reader exits 141 where bash would exit 0. Pipelines nested more than 4 subshells deep run their stages in sequence | Builtins return their output as one value, not a stream; streaming them needs a reader/writer `Context`. The smaller pipe bounds how far a producer runs ahead (each byte costs budgeted commands); the nesting cap bounds native stack (TM-DOS-124) | `l_pipe_001_stages_run_sequentially` | +| L-PIPE-001 | Pipeline stages stream from the first stage that runs shell code (loop, group, function, `eval`) or a registered live `Command`. Leading single-builtin stages run to completion first and hand over their whole output, so they never get SIGPIPE (`sort big \| head -1` gives `PIPESTATUS` `0 0`, bash `141 0`); `yes`, `seq`, plain `cat`, `grep` and `tr` are the exception and stream. Downstream of a streaming stage, a single builtin reads all its input before it starts, except registered live commands, `head`, `read`, plain `cat`, `grep` (not `-c/-l/-L/-q`, context, `-b`) and `tr` (not `-s`): `while :; do echo; done \| sed p \| head -1` runs until a limit. Pipes hold 4 KiB, not 64 KiB, so a finite loop writing more than that into an early-exiting reader exits 141 where bash would exit 0. Pipelines nested more than 4 subshells deep run their stages in sequence | Builtins return their output as one value, not a stream; streaming them needs a reader/writer `Context`. The smaller pipe bounds how far a producer runs ahead (each byte costs budgeted commands); the nesting cap bounds native stack (TM-DOS-124) | `l_pipe_001_stages_run_sequentially` | | L-SUDO-001 | `sudo` runs its command as the one sandbox user: `-u`, `-g`, `-i` change nothing, `whoami` still prints `sandbox`, and there is never a password prompt. `busybox APPLET` runs bashkit's builtin of that name, not BusyBox's own applet (flags and messages are bashkit's) | The sandbox has no privilege boundary to cross; the VFS has no permission enforcement (L-FS-002) | `sudo-busybox.test.sh` | | L-RAND-001 | `uuidgen` makes random (v4) UUIDs only (no `-t`, `-m`, `-s`); `openssl` implements only `rand` | Time/MAC-based UUIDs would leak host identity; a full TLS/crypto toolkit is out of scope | `uuidgen_time_based_unsupported` | | L-FS-001 | Symlinks are followed, but `..` after a linked directory resolves lexically (`/link/..` is the link's parent, the `cd -L` view), and `ln` without `-s` (and `link`) makes a symlink, not a hard link | The interpreter normalizes paths before the VFS sees them; the VFS has no inodes to share | `symlink.test.sh`, `ln_default_symbolic` | diff --git a/knowledge/runtimes/c-api.md b/knowledge/runtimes/c-api.md index 717de5bc3..ef003f153 100644 --- a/knowledge/runtimes/c-api.md +++ b/knowledge/runtimes/c-api.md @@ -84,7 +84,7 @@ dispatcher receives JSON language/name/args/kwargs requests. Boundary values use the shared bounded core codec (depth 64, signed i64, finite numbers, string-key objects, arrays and scalars); request/response JSON is capped at 10 MB. -Each callback runs on the `bashkit_execute` caller's thread and receives ownership +Each callback runs on the buffered or streaming execution caller's thread and receives ownership of a `BashkitRuntimeCall`. It can reply immediately or transfer the call to an I/O worker and return promptly. Responses copy JSON into library-owned storage. One response is accepted; invalid JSON leaves it unanswered, freeing without a @@ -114,9 +114,25 @@ invalid values, abandonment and host failure. `examples/runtime_functions.c` compiles against the public header with warnings denied and runs against the actual shared library. +## Streaming output callbacks + +`bashkit_execute_streaming` is additive in ABI v1 (`streaming-output` +capability). It runs the shared execution builder with per-stream capture and +synchronous, borrowed byte callbacks. Return nonzero to fail the sink. Runtime +host functions remain available through the same execution lease. Both execution +entry points use the host-function admission guard. A thread-local callback scope +rejects blocking APIs on any handle from output or runtime callbacks, before +locking state or nesting a current-thread Tokio runtime. Lock-free cancellation +is allowed. Lifetime synchronization and +no unload/free during callbacks remain caller obligations. Native callback code +cannot be preempted by an engine timeout. The compiled C example verifies the +header and capture/delivery contract; ABI tests add reentry and failure recovery. +The combined runtime test covers immediate/cross-thread replies, live sinks, +capture/discard/truncation, caller affinity and recursive streaming entry. + ## Deferred surface -Custom builtins, streaming, +Custom builtins, asynchronous shell execution, transport hooks, snapshots, scripted tools, and external filesystem providers remain outside v1. They need explicit reentrancy, callback lifetime, and dynamic library unload rules before becoming permanent ABI. @@ -129,7 +145,7 @@ rejection. Mount tests cover the read-only round trip, live mount/unmount with shell state preserved, allowlist containment at config time and runtime, and the sensitive-path rule (refused under a broad allowlist entry, allowed when the entry names the root exactly). The C example runner compiles the public -header under C11 with warnings denied and executes two programs against the +header under C11 with warnings denied and executes four programs against the built shared library. ## See also diff --git a/knowledge/runtimes/node-package.md b/knowledge/runtimes/node-package.md index 2137a923a..04b07b82b 100644 --- a/knowledge/runtimes/node-package.md +++ b/knowledge/runtimes/node-package.md @@ -27,6 +27,12 @@ revocable execution extension owns a weak NAPI threadsafe dispatcher. Completion timeout or cancellation drops native waits and aborts the JS session's signal. Host I/O must observe that signal; arbitrary application side effects cannot be rolled back. Request context and callbacks are never snapshot data. +Live byte sinks and explicit capture install the same execution-local host-function +lease as buffered calls. Both paths use the async/sync admission guard; queued +Python/TypeScript callbacks preserve caller context and revoke their signals when +the execution and sinks finish. +Live sink callbacks bind an AsyncResource before crossing NAPI, preserving the +same caller context before and after awaiting downstream work. An AsyncLocalStorage owner marker rejects same-instance callback reentry, including live filesystem handles, while independent async requests queue. Native diff --git a/knowledge/runtimes/python-package.md b/knowledge/runtimes/python-package.md index f996760ff..919a272e6 100644 --- a/knowledge/runtimes/python-package.md +++ b/knowledge/runtimes/python-package.md @@ -145,7 +145,12 @@ Same-instance calls serialize on the existing interpreter mutex. Reentry protection uses a per-instance ContextVar set inside the handler task, not an instance-wide activity counter: recursive interpreter/live-FS access fails immediately, while unrelated requests can queue during callback I/O. Other -interpreter instances are usable from a handler. Private-loop work items carry +interpreter instances are usable from a handler. Cancelling a callback session +marks it synchronously before forwarding cancellation through the native bridge. +Both buffered and streaming execution check that flag after acquiring the shell +mutex, so a cancelled queued request cannot begin script side effects while its +native cancellation wakeup is pending. Other sessions remain independent. +Private-loop work items carry their own cancellation state/task handle so timeout/revocation cancels that item even if it has not started; cancelling an old item cannot cancel the next request on the shared loop. Worker teardown retains the interpreter-exit guard. diff --git a/scripts/gen_python_apidocs.py b/scripts/gen_python_apidocs.py index 70b225d7b..8b422dc76 100644 --- a/scripts/gen_python_apidocs.py +++ b/scripts/gen_python_apidocs.py @@ -38,6 +38,10 @@ "ShellState", "BuiltinContext", "BuiltinResult", + "CommandContext", + "CommandInput", + "CommandOutput", + "CommandStatus", "BashError", "create_langchain_tool_spec", "get_version", diff --git a/scripts/run-c-api-examples.sh b/scripts/run-c-api-examples.sh index f16ad6ade..e5f35e4b6 100755 --- a/scripts/run-c-api-examples.sh +++ b/scripts/run-c-api-examples.sh @@ -26,7 +26,7 @@ case "$(uname -s)" in ;; esac -for example in hello files runtime_functions; do +for example in hello files runtime_functions streaming; do cc -std=c11 -Wall -Wextra -Werror \ -I "$repo_root/crates/bashkit-capi/include" \ "$example_dir/$example.c" \ diff --git a/site/astro.config.mjs b/site/astro.config.mjs index bc2f03032..366dbf938 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -47,6 +47,7 @@ const DOC_LINKS = new Map([ ["start-browser.md", "/docs/start-browser/"], ["start-pyodide.md", "/docs/start-pyodide/"], ["structured-data.md", "/docs/structured-data/"], + ["streaming.md", "/docs/streaming/"], ["targets.md", "/docs/start/"], ["terminal.md", "/docs/terminal/"], ["threat-model.md", "/docs/security/"], diff --git a/site/src/content/apidocs/python.md b/site/src/content/apidocs/python.md index 211fafafd..858c3d51e 100644 --- a/site/src/content/apidocs/python.md +++ b/site/src/content/apidocs/python.md @@ -83,10 +83,18 @@ Example: ... ) ``` +### `add_command` + +```python +Bash.add_command(name: str, callback: CommandCallback) -> None +``` + +Register a live byte command; requires native async execute(). + ### `execute` ```python -Bash.execute(commands: str, on_output: OutputHandler | None = None) -> ExecResult +Bash.execute(commands: str, on_output: OutputHandler | None = None, capture: int | None = None, stdout: ByteSink | None = None, stderr: ByteSink | None = None) -> ExecResult ``` Execute bash commands asynchronously. @@ -649,10 +657,18 @@ bash '21\n21\n' ``` +### `add_command` + +```python +BashTool.add_command(name: str, callback: CommandCallback) -> None +``` + +Register a live byte command; requires native async execute(). + ### `execute` ```python -BashTool.execute(commands: str, on_output: OutputHandler | None = None) -> ExecResult +BashTool.execute(commands: str, on_output: OutputHandler | None = None, capture: int | None = None, stdout: ByteSink | None = None, stderr: ByteSink | None = None) -> ExecResult ``` Execute bash commands asynchronously. @@ -1614,6 +1630,55 @@ Shell-facing result for a custom builtin callback. BuiltinResult(stdout: str = '', stderr: str = '', exit_code: int = 0) -> None ``` +## CommandContext + +Live command capabilities; I/O is revoked when execution ends. + +### Fields + +- **`name`** — `str` +- **`argv`** — `list[str]` +- **`env`** — `dict[str, str]` +- **`cwd`** — `str` +- **`fs`** — `FileSystem` +- **`stdin`** — `CommandInput` +- **`stdout`** — `CommandOutput` +- **`stderr`** — `CommandOutput` + +## CommandInput + +Request-scoped byte reader. Clones share the cursor. + +### `read_chunk` + +```python +CommandInput.read_chunk() -> bytes | None +``` + +## CommandOutput + +Request-scoped byte writer. Awaiting write applies backpressure. + +### `write` + +```python +CommandOutput.write(data: bytes) -> None +``` + +## CommandStatus + +Command completion, separate from output. Native async API only. + +### Fields + +- **`exit_code`** — `int` + +### Constructor + +```python +CommandStatus(exit_code: int = 0) -> None +``` + ## BashError Exception raised when a bash command exits with non-zero status. diff --git a/site/src/content/apidocs/typescript.md b/site/src/content/apidocs/typescript.md index a7c3f9c82..ae914ec1f 100644 --- a/site/src/content/apidocs/typescript.md +++ b/site/src/content/apidocs/typescript.md @@ -41,6 +41,14 @@ Safe to call at any time — the new builtin is visible to the next `execute*()` invocation with no interpreter rebuild or VFS disturbance. Survives `reset()`. +### `addCommand` + +```typescript +bash.addCommand(name: string, callback: CommandCallback): void +``` + +Register a live command with async byte I/O and separate exit status. + ### `analyze` ```typescript @@ -514,6 +522,14 @@ bashTool.addBuiltin(name: string, callback: BuiltinCallback): void Register a JS callback as a custom builtin. See `Bash.addBuiltin`. +### `addCommand` + +```typescript +bashTool.addCommand(name: string, callback: CommandCallback): void +``` + +Register a live command with async byte I/O and separate exit status. + ### `analyze` ```typescript @@ -1298,6 +1314,7 @@ Options for creating a Bash or BashTool instance. Required for `mounts` and runtime `mount()` APIs. Mount targets must resolve under one of these prefixes; otherwise the call is rejected. +- **`commands?`** — `Record` - **`customBuiltins?`** — `Record` Custom JS callbacks registered as bash builtins. @@ -1439,6 +1456,21 @@ The environment that produced a commit. - **`features`** — `string[]` - **`fsBackend`** — `string` +## CommandContext + +Request-scoped byte I/O for a live shell command. + +### Fields + +- **`argv`** — `string[]` +- **`cwd`** — `string` +- **`env`** — `Record` +- **`fs`** — `FileSystem` +- **`name`** — `string` +- **`stderr`** — `{ write: }` +- **`stdin`** — `AsyncIterable, any, any> & { readChunk: }` +- **`stdout`** — `{ write: }` + ## CommitOptions Options for `Bash.commit`. @@ -1489,6 +1521,9 @@ Result from executing bash commands. ### Fields +- **`capture?`** — `number` + + Capture this many bytes per stream in addition to optional live sinks. - **`onOutput?`** — `OnOutput` Live chunk callback. Must be synchronous. @@ -1498,6 +1533,10 @@ Result from executing bash commands. binding rejects same-instance re-entry to avoid deadlocks and runtime panics. - **`signal?`** — `AbortSignal` +- **`stderr?`** — `(bytes: Uint8Array) => void | Promise` +- **`stdout?`** — `(bytes: Uint8Array) => void | Promise` + + Awaited byte sinks apply backpressure. Omitted capture retains no output. ## FileSystemRealOptions @@ -1773,7 +1812,7 @@ Arguments of `Terminal.call`, named as in `toolDefinition()`. ### Fields -- **`activity`** — `"prompt" | "continuation" | "running" | "input" | "exited"` +- **`activity`** — `"input" | "prompt" | "continuation" | "running" | "exited"` `input`: a running command waits for a typed line (see `input_prompt`). - **`closed?`** — `boolean` @@ -1875,6 +1914,12 @@ fine, so snapshots keep restoring after a bashkit upgrade adds one. type CheckoutPolicy = "strict" | "superset" | "force" ``` +## CommandCallback + +```typescript +type CommandCallback = (ctx: CommandContext) => Promise +``` + ## ExecutionProfileName ```typescript @@ -2260,6 +2305,7 @@ Options for configuring the bash tool adapter. Required for `mounts` and runtime `mount()` APIs. Mount targets must resolve under one of these prefixes; otherwise the call is rejected. +- **`commands?`** — `Record` - **`customBuiltins?`** — `Record` Custom JS callbacks registered as bash builtins. @@ -2489,6 +2535,7 @@ Options for configuring the bash tool adapter. Required for `mounts` and runtime `mount()` APIs. Mount targets must resolve under one of these prefixes; otherwise the call is rejected. +- **`commands?`** — `Record` - **`customBuiltins?`** — `Record` Custom JS callbacks registered as bash builtins. @@ -2699,6 +2746,7 @@ Options for configuring the bash tool adapter. Required for `mounts` and runtime `mount()` APIs. Mount targets must resolve under one of these prefixes; otherwise the call is rejected. +- **`commands?`** — `Record` - **`customBuiltins?`** — `Record` Custom JS callbacks registered as bash builtins. diff --git a/site/src/data/performance-timeline.json b/site/src/data/performance-timeline.json index f68a71df8..56f37748d 100644 --- a/site/src/data/performance-timeline.json +++ b/site/src/data/performance-timeline.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-10-13T04:29:43.000Z", + "generatedAt": "2026-10-16T18:34:35.000Z", "sources": { "bench": "crates/bashkit-bench/results/*.json", "criterion": "crates/bashkit/benches/results/*.md", @@ -8,21 +8,21 @@ "oilsSpec": "scripts/oils-spec/results/oils-spec-*.json" }, "summary": { - "benchRuns": 12, - "criterionRuns": 18, + "benchRuns": 13, + "criterionRuns": 19, "evalRuns": 57, "latestBench": { - "id": "bench-mykhailosmin-macos-aarch64-1791865783", + "id": "bench-79f1c12bcc44-linux-x86_64-1792175675", "kind": "bashkit-bench", - "label": "mykhailosmin-macos-aarch64", - "date": "2026-10-13", - "timestamp": "2026-10-13T04:29:43.000Z", - "source": "crates/bashkit-bench/results/bench-mykhailosmin-macos-aarch64-1791865783.json", - "reportSource": "crates/bashkit-bench/results/bench-mykhailosmin-macos-aarch64-1791865783.md", + "label": "79f1c12bcc44-linux-x86_64", + "date": "2026-10-16", + "timestamp": "2026-10-16T18:34:35.000Z", + "source": "crates/bashkit-bench/results/bench-79f1c12bcc44-linux-x86_64-1792175675.json", + "reportSource": "crates/bashkit-bench/results/bench-79f1c12bcc44-linux-x86_64-1792175675.md", "cases": 96, - "speedup": 23.9, - "bashkitMs": 103.2, - "bashMs": 2463.28, + "speedup": 11.6, + "bashkitMs": 53.12, + "bashMs": 617.31, "errorRate": 0, "matchRate": 100, "categories": [ @@ -30,97 +30,97 @@ "category": "startup", "description": "Small commands where interpreter startup dominates runtime.", "cases": 4, - "bashkitMedianMs": 0.031, - "bashMedianMs": 4.784, - "speedup": 155.1 + "bashkitMedianMs": 0.054, + "bashMedianMs": 1.209, + "speedup": 22.7 }, { - "category": "arithmetic", - "description": "Integer math, substitutions, and expression-heavy shell snippets.", - "cases": 6, - "bashkitMedianMs": 0.034, - "bashMedianMs": 4.785, - "speedup": 140.1 + "category": "strings", + "description": "String expansion, pattern handling, and text manipulation.", + "cases": 8, + "bashkitMedianMs": 0.065, + "bashMedianMs": 1.251, + "speedup": 19.4 }, { "category": "variables", "description": "Variable assignment, lookup, expansion, and environment handling.", "cases": 8, - "bashkitMedianMs": 0.035, - "bashMedianMs": 4.499, - "speedup": 133.7 + "bashkitMedianMs": 0.067, + "bashMedianMs": 1.183, + "speedup": 18.5 + }, + { + "category": "arithmetic", + "description": "Integer math, substitutions, and expression-heavy shell snippets.", + "cases": 6, + "bashkitMedianMs": 0.068, + "bashMedianMs": 1.239, + "speedup": 19.3 }, { "category": "arrays", "description": "Indexed array reads, writes, expansion, and iteration.", "cases": 6, - "bashkitMedianMs": 0.04, - "bashMedianMs": 7.029, - "speedup": 176.5 + "bashkitMedianMs": 0.074, + "bashMedianMs": 1.173, + "speedup": 16 + }, + { + "category": "subshell", + "description": "Command substitution and nested shell execution paths.", + "cases": 6, + "bashkitMedianMs": 0.092, + "bashMedianMs": 2.054, + "speedup": 20.8 }, { "category": "pipes", "description": "Pipeline construction, streaming, and command chaining.", "cases": 6, - "bashkitMedianMs": 0.043, - "bashMedianMs": 8.666, - "speedup": 200.8 + "bashkitMedianMs": 0.094, + "bashMedianMs": 2.1, + "speedup": 23.3 }, { - "category": "io", - "description": "File reads, writes, redirects, and filesystem-facing commands.", - "cases": 6, - "bashkitMedianMs": 0.048, - "bashMedianMs": 6.544, - "speedup": 149.7 + "category": "control", + "description": "Conditionals, loops, case statements, and branching scripts.", + "cases": 9, + "bashkitMedianMs": 0.096, + "bashMedianMs": 1.217, + "speedup": 14.3 }, { "category": "tools", "description": "Builtin and external-tool style command workloads.", "cases": 21, - "bashkitMedianMs": 0.049, - "bashMedianMs": 10.236, - "speedup": 197.1 + "bashkitMedianMs": 0.099, + "bashMedianMs": 2.607, + "speedup": 25.8 }, { - "category": "subshell", - "description": "Command substitution and nested shell execution paths.", + "category": "io", + "description": "File reads, writes, redirects, and filesystem-facing commands.", "cases": 6, - "bashkitMedianMs": 0.049, - "bashMedianMs": 8.872, - "speedup": 160.5 - }, - { - "category": "control", - "description": "Conditionals, loops, case statements, and branching scripts.", - "cases": 9, - "bashkitMedianMs": 0.055, - "bashMedianMs": 7.842, - "speedup": 167.3 - }, - { - "category": "strings", - "description": "String expansion, pattern handling, and text manipulation.", - "cases": 8, - "bashkitMedianMs": 0.07, - "bashMedianMs": 8.768, - "speedup": 152.8 + "bashkitMedianMs": 0.099, + "bashMedianMs": 1.934, + "speedup": 18.7 }, { "category": "complex", "description": "Mixed shell workflows that combine multiple language features.", "cases": 7, - "bashkitMedianMs": 0.108, - "bashMedianMs": 9.608, - "speedup": 71.7 + "bashkitMedianMs": 0.157, + "bashMedianMs": 2.607, + "speedup": 11.2 }, { "category": "large", "description": "Bigger scripts and higher-volume data paths.", "cases": 9, - "bashkitMedianMs": 1.016, - "bashMedianMs": 7.134, - "speedup": 16 + "bashkitMedianMs": 1.767, + "bashMedianMs": 2.215, + "speedup": 3.3 } ] }, @@ -1829,6 +1829,119 @@ "speedup": 16 } ] + }, + { + "id": "bench-79f1c12bcc44-linux-x86_64-1792175675", + "kind": "bashkit-bench", + "label": "79f1c12bcc44-linux-x86_64", + "date": "2026-10-16", + "timestamp": "2026-10-16T18:34:35.000Z", + "source": "crates/bashkit-bench/results/bench-79f1c12bcc44-linux-x86_64-1792175675.json", + "reportSource": "crates/bashkit-bench/results/bench-79f1c12bcc44-linux-x86_64-1792175675.md", + "cases": 96, + "speedup": 11.6, + "bashkitMs": 53.12, + "bashMs": 617.31, + "errorRate": 0, + "matchRate": 100, + "categories": [ + { + "category": "startup", + "description": "Small commands where interpreter startup dominates runtime.", + "cases": 4, + "bashkitMedianMs": 0.054, + "bashMedianMs": 1.209, + "speedup": 22.7 + }, + { + "category": "strings", + "description": "String expansion, pattern handling, and text manipulation.", + "cases": 8, + "bashkitMedianMs": 0.065, + "bashMedianMs": 1.251, + "speedup": 19.4 + }, + { + "category": "variables", + "description": "Variable assignment, lookup, expansion, and environment handling.", + "cases": 8, + "bashkitMedianMs": 0.067, + "bashMedianMs": 1.183, + "speedup": 18.5 + }, + { + "category": "arithmetic", + "description": "Integer math, substitutions, and expression-heavy shell snippets.", + "cases": 6, + "bashkitMedianMs": 0.068, + "bashMedianMs": 1.239, + "speedup": 19.3 + }, + { + "category": "arrays", + "description": "Indexed array reads, writes, expansion, and iteration.", + "cases": 6, + "bashkitMedianMs": 0.074, + "bashMedianMs": 1.173, + "speedup": 16 + }, + { + "category": "subshell", + "description": "Command substitution and nested shell execution paths.", + "cases": 6, + "bashkitMedianMs": 0.092, + "bashMedianMs": 2.054, + "speedup": 20.8 + }, + { + "category": "pipes", + "description": "Pipeline construction, streaming, and command chaining.", + "cases": 6, + "bashkitMedianMs": 0.094, + "bashMedianMs": 2.1, + "speedup": 23.3 + }, + { + "category": "control", + "description": "Conditionals, loops, case statements, and branching scripts.", + "cases": 9, + "bashkitMedianMs": 0.096, + "bashMedianMs": 1.217, + "speedup": 14.3 + }, + { + "category": "tools", + "description": "Builtin and external-tool style command workloads.", + "cases": 21, + "bashkitMedianMs": 0.099, + "bashMedianMs": 2.607, + "speedup": 25.8 + }, + { + "category": "io", + "description": "File reads, writes, redirects, and filesystem-facing commands.", + "cases": 6, + "bashkitMedianMs": 0.099, + "bashMedianMs": 1.934, + "speedup": 18.7 + }, + { + "category": "complex", + "description": "Mixed shell workflows that combine multiple language features.", + "cases": 7, + "bashkitMedianMs": 0.157, + "bashMedianMs": 2.607, + "speedup": 11.2 + }, + { + "category": "large", + "description": "Bigger scripts and higher-volume data paths.", + "cases": 9, + "bashkitMedianMs": 1.767, + "bashMedianMs": 2.215, + "speedup": 3.3 + } + ] } ], "criterionRuns": [ @@ -2212,6 +2325,27 @@ "us": 68.29 }, "bestImprovement": null + }, + { + "id": "criterion-parallel-79f1c12bcc44-linux-x86_64-1792174213", + "kind": "criterion", + "family": "parallel", + "label": "Criterion Parallel Execution Benchmark", + "date": "2026-10-16", + "timestamp": "2026-10-16T18:10:13.000Z", + "source": "crates/bashkit/benches/results/criterion-parallel-79f1c12bcc44-linux-x86_64-1792174213.md", + "reportSource": "crates/bashkit/benches/results/criterion-parallel-79f1c12bcc44-linux-x86_64-1792174213.md", + "cases": 33, + "medianUs": 17277, + "p95Us": 284026, + "medianChangePct": null, + "meanChangePct": null, + "bestChangePct": null, + "fastestCase": { + "name": "single_bash_new", + "us": 51.48 + }, + "bestImprovement": null } ], "evalRuns": [ @@ -9718,6 +9852,24 @@ "detail": "96 parity/perf cases on mykhailosmin-macos-aarch64; output match 100%.", "metric": 23.9, "source": "crates/bashkit-bench/results/bench-mykhailosmin-macos-aarch64-1791865783.json" + }, + { + "date": "2026-10-16", + "timestamp": "2026-10-16T18:10:13.000Z", + "kind": "Criterion", + "title": "parallel", + "detail": "single_bash_new at 51.48 us median", + "metric": 17277, + "source": "crates/bashkit/benches/results/criterion-parallel-79f1c12bcc44-linux-x86_64-1792174213.md" + }, + { + "date": "2026-10-16", + "timestamp": "2026-10-16T18:34:35.000Z", + "kind": "Benchmark", + "title": "11.6x faster than bash", + "detail": "96 parity/perf cases on 79f1c12bcc44-linux-x86_64; output match 100%.", + "metric": 11.6, + "source": "crates/bashkit-bench/results/bench-79f1c12bcc44-linux-x86_64-1792175675.json" } ] } diff --git a/site/src/pages/docs/_meta.ts b/site/src/pages/docs/_meta.ts index 3f3b39ef0..5773a33ce 100644 --- a/site/src/pages/docs/_meta.ts +++ b/site/src/pages/docs/_meta.ts @@ -347,6 +347,16 @@ export const DOC_META: DocMeta[] = [ sourceId: "custom_builtins_js", editPath: "docs/custom_builtins_js.md", }, + { + slug: "streaming", + title: "Streaming commands and execution", + summary: "Live byte commands, async sinks, bounded capture, and runtime support.", + seoTitle: "Stream Bashkit commands in Rust, Python and JavaScript", + seoDescription: + "Use Bashkit streaming commands in Rust, Python and JavaScript with async byte sinks, explicit capture, migration guidance and a runtime support matrix.", + section: "Extending", + editPath: "docs/streaming.md", + }, { slug: "clap-builtins", title: "Clap builtins",