Skip to content

Commit c34c6f0

Browse files
authored
perf(wasm): reduce browser transfer size and verify package identity
## What changed Reduce first-visit browser package transfer size with a dedicated size/throughput-balanced Rust profile. Keep the full command set, jq, Terminal, and resource policies. Ship source/tool/profile identity and a verifiable WASM hash in `build-info.json`. CI and npm publication now gate the same 24-case GNU Bash sample, raw/gzip/Brotli budgets, package integration suite, and npm archive contract. Add bounded browser/runtime measurement scripts and retained raw evidence. ## Why The brush-web studies found a large published/source compatibility gap and network-dominated mobile startup. npm 0.18.2 identifies the September 22 release, while the study's source is 269 commits newer. Rebuilding the exact release reproduces npm's identical five failures in our sample; this is unreleased interpreter work, not a divergent published feature configuration. ## Before / After Same current source, Rust 1.95.0, bindgen 0.2.128, Terminal enabled, Binaryen 125. Original opt-3/thin-LTO versus selected opt-s/fat-LTO: | Measure | Before | After | |---|---:|---:| | Brotli bytes, JS + WASM | 2,468,453 | 2,125,682 (-13.9%) | | gzip bytes | 3,570,277 | 2,973,501 (-16.7%) | | Raw bytes | 9,519,539 | 7,668,904 (-19.4%) | | Fast 4G package prompt median, 5 trials | 3,285.1 ms | 2,919.5 ms | | Slow 4G package prompt median, 5 trials | 16,177.1 ms | 14,250.7 ms | | Current-source GNU Bash sample | 24/24 | 24/24 | Published npm and rebuilt v0.18.2 both pass 19/24 with identical five failures. These 24 cases are a bounded regression sample, not a rerun of the study's three large-corpus percentages. [Methodology, raw samples, attribution, and runtime tradeoff](https://github.com/everruns/bashkit/blob/codex/wasm-startup/crates/bashkit-wasm/bench/results/wasm-startup-2026-10-15.md). Timings are exploratory on a development host; byte and semantic gates are deterministic. This page measures the package without brush-web's framework/renderer/worker overhead. Validation: `just pre-pr`; strict all-feature Clippy; `just test` (HTTP/default + serialized failpoints); 70 browser package tests; 24-case compatibility; compressed-size gate; 165 script tests; actual npm-pack/extract/execute smoke. ## Risk - Low/medium: code generation changes only for the browser package. Native release settings are unchanged. - Selected profile's arithmetic sample is ~12% slower (44.24 → 49.49 ms for 20,000 iterations); substitution/jq/text samples stay close. Rejected opt-z despite more transfer savings because arithmetic slowed ~50%. - No npm publication in this PR. `just check-nightly` currently fails the release contract: Advisory Audit in one of the last three nightly runs is red. npm still serves 0.18.2; merged code is distinct from published-artifact proof. ## Checklist - [x] Tests added or updated - [x] Backward compatibility considered
1 parent 8a817bf commit c34c6f0

22 files changed

Lines changed: 2771 additions & 12 deletions

‎.github/workflows/ci.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,11 @@ jobs:
167167
- name: Integration tests (headless Node)
168168
run: node --test "crates/bashkit-wasm/__test__/*.test.mjs"
169169

170+
- name: Browser compatibility and transfer-size gates
171+
run: |
172+
node crates/bashkit-wasm/bench/compat.mjs
173+
node crates/bashkit-wasm/bench/artifact.mjs crates/bashkit-wasm/pkg --check
174+
170175
audit:
171176
name: Audit
172177
runs-on: ubuntu-latest

‎.github/workflows/publish-wasm.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ jobs:
7272
- name: Integration tests (headless Node)
7373
run: node --test "crates/bashkit-wasm/__test__/*.test.mjs"
7474

75+
- name: Browser compatibility and transfer-size gates
76+
run: |
77+
node crates/bashkit-wasm/bench/compat.mjs
78+
node crates/bashkit-wasm/bench/artifact.mjs crates/bashkit-wasm/pkg --check
79+
7580
- name: Upload pkg
7681
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
7782
with:

‎Cargo.toml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,14 @@ codegen-units = 1
209209
panic = "unwind"
210210
strip = "symbols"
211211

212+
# Browser cold start is transfer-bound. Keep native release throughput settings;
213+
# use size-oriented LLVM optimization and whole-program LTO only for the JS-host
214+
# WASM package. Retain release's panic policy and all interpreter safety checks.
215+
[profile.wasm-release]
216+
inherits = "release"
217+
opt-level = "s"
218+
lto = "fat"
219+
212220
# Important decision: separate `bench` profile uses fat LTO for the most
213221
# aggressive cross-crate inlining. Criterion benches run rarely and we'd
214222
# rather spend extra compile time once than miss inlining opportunities

‎crates/bashkit-wasm/README.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,29 @@ just build-wasm
294294
## License
295295
296296
MIT, part of the [Bashkit](https://github.com/everruns/bashkit) project.
297+
298+
## Deployment and cold start
299+
300+
Serve `bashkit_wasm_bg.wasm` as `application/wasm` with Brotli or gzip HTTP
301+
compression. Raw npm file size is not transfer size. Use content-hashed asset
302+
URLs with long-lived immutable caching; keep the JS glue and WASM from the same
303+
build together. `initBashkit()` uses wasm-bindgen's streaming loader when the
304+
server sends the correct MIME type. A slow mobile connection can take more than
305+
15 seconds to download a multi-megabyte bundle, so choose startup deadlines from
306+
your application's measured network conditions.
307+
308+
The package includes `build-info.json`: source commit, dirty-checkout status,
309+
compiler/tools, Cargo profile/features, and the SHA-256 of the shipped WASM.
310+
Source archives without Git metadata record a null source identity. A source
311+
checkout between releases can have the same package version as an older npm
312+
release; compare the source commit as well as the version.
313+
314+
Maintainers can run the bounded compatibility and compressed-size gates:
315+
316+
```sh
317+
node crates/bashkit-wasm/bench/compat.mjs
318+
node crates/bashkit-wasm/bench/artifact.mjs crates/bashkit-wasm/pkg --check
319+
```
320+
321+
The [browser benchmark methodology](https://github.com/everruns/bashkit/blob/main/crates/bashkit-wasm/bench/README.md) describes reproducible
322+
cold-start and execution experiments and the measured profile tradeoff.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// A tiny valid WASM fixture keeps CLI/budget regression coverage bounded.
2+
import { test } from "node:test";
3+
import assert from "node:assert/strict";
4+
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
5+
import { tmpdir } from "node:os";
6+
import { join } from "node:path";
7+
import { spawnSync } from "node:child_process";
8+
9+
test("artifact CLI accepts --check with default package and enforces budgets", () => {
10+
const root = mkdtempSync(join(tmpdir(), "bashkit-artifact-"));
11+
try {
12+
const bench = join(root, "bench"), pkg = join(root, "pkg");
13+
mkdirSync(bench); mkdirSync(pkg);
14+
const script = join(bench, "artifact.mjs");
15+
copyFileSync(new URL("../bench/artifact.mjs", import.meta.url), script);
16+
writeFileSync(join(pkg, "index.js"), "export {};");
17+
writeFileSync(join(pkg, "bashkit_wasm.js"), "export {};");
18+
writeFileSync(join(pkg, "bashkit_wasm_bg.wasm"), Buffer.from([0, 97, 115, 109, 1, 0, 0, 0]));
19+
const budget = join(bench, "size-budget.json");
20+
writeFileSync(budget, '{"raw":1000,"gzip":1000,"brotli":1000}');
21+
const run = args => spawnSync(process.execPath, [script, ...args], { encoding: "utf8", timeout: 10000 });
22+
const result = run(["--check"]);
23+
assert.equal(result.status, 0, result.stderr);
24+
assert.equal(JSON.parse(result.stdout).total.raw, 28);
25+
const explicit = run([pkg, "--check"]);
26+
assert.equal(explicit.status, 0, explicit.stderr);
27+
assert.deepEqual(JSON.parse(explicit.stdout).total, JSON.parse(result.stdout).total);
28+
writeFileSync(budget, '{"raw":27}');
29+
const rejected = run(["--check"]);
30+
assert.notEqual(rejected.status, 0);
31+
assert.match(rejected.stderr, /28 exceeds 27 byte budget/);
32+
} finally { rmSync(root, { recursive: true, force: true }); }
33+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Exercise the archive's public contract, not just an unpacked build directory.
2+
import { test } from "node:test";
3+
import assert from "node:assert/strict";
4+
import { readFileSync } from "node:fs";
5+
import { createHash } from "node:crypto";
6+
import { execFileSync } from "node:child_process";
7+
import { Terminal } from "../pkg/index.js";
8+
const pkg = new URL("../pkg/", import.meta.url);
9+
const read = name => readFileSync(new URL(name, pkg));
10+
11+
test("package records verifiable source and artifact identity", () => {
12+
const info = JSON.parse(read("build-info.json"));
13+
if (info.sourceRevision === null) {
14+
assert.equal(info.sourceDirty, null);
15+
assert.notEqual(process.env.GITHUB_ACTIONS, "true", "CI/release must record Git identity");
16+
} else {
17+
assert.match(info.sourceRevision, /^[0-9a-f]{40}$/);
18+
assert.equal(typeof info.sourceDirty, "boolean");
19+
assert.equal(info.sourceRevision, execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim());
20+
}
21+
assert.equal(info.version, JSON.parse(read("package.json")).version);
22+
assert.equal(info.wasmSha256, createHash("sha256").update(read("bashkit_wasm_bg.wasm")).digest("hex"));
23+
assert.equal(info.profile, "wasm-release");
24+
assert.equal(info.features.includes("terminal"), Terminal !== undefined);
25+
});
26+
27+
test("npm archive contains every runtime file and build identity", () => {
28+
const [archive] = JSON.parse(execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], {
29+
cwd: pkg, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30000,
30+
}));
31+
const files = new Set(archive.files.map(file => file.path));
32+
for (const name of ["index.js", "index.d.ts", "bashkit_wasm.js", "bashkit_wasm.d.ts", "bashkit_wasm_bg.wasm", "build-info.json"]) assert.ok(files.has(name), `archive lacks ${name}`);
33+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Browser package experiments
2+
3+
These experiments complement the full package integration suite. Run from the
4+
repository root after `just build-wasm`. They require GNU Bash 5+ on `PATH` (on
5+
macOS, `/bin/bash` 3.2 is unsuitable) and Node 22+.
6+
7+
- `node crates/bashkit-wasm/bench/compat.mjs [pkg ...]`: 24 deterministic,
8+
self-contained shell cases against a fresh GNU Bash oracle and fresh Bashkit
9+
instances. Exact stdout bytes and status; stderr is not scored. Worker lifetime
10+
is capped at 30 seconds; each oracle process at two seconds. One package is a
11+
pass/fail CI gate; multiple packages produce a comparison without failing for
12+
an old release's known gaps. This is a regression sample, not a population
13+
compatibility percentage. No corpus or package is fetched by the gate.
14+
- `node crates/bashkit-wasm/bench/artifact.mjs [pkg] [--check]`: raw, gzip-9,
15+
Brotli-11 bytes for the actual JS + WASM load graph, SHA-256 identities, and
16+
WASM code/data/other sections. `--check` applies `size-budget.json`, with about
17+
4-5% headroom above the selected shipping profile. Review new measurements
18+
before deliberately raising a budget. No wall-clock threshold in CI.
19+
- `node crates/bashkit-wasm/bench/runtime.mjs pkg`: arithmetic, substitution,
20+
jq, and text pipelines; one warmup and seven measured fresh-instance runs.
21+
Output is checked for status and consistency. Compare on the same host,
22+
without competing builds. Times from a busy development host are exploratory.
23+
- `TRIALS=5 node crates/bashkit-wasm/bench/cold-start.mjs pkg-before pkg-after`:
24+
Chromium, Brotli-11 server, fresh HTTP cache per trial, rotated package order,
25+
loopback plus CDP Fast 4G (1,012,500 B/s, 165 ms) and Slow 4G (180,000 B/s,
26+
562.5 ms), page CPU throttled 4x. Requires an existing Playwright installation
27+
and Chrome; `PLAYWRIGHT_MODULE` can name its `index.mjs` and `CHROME_CHANNEL`
28+
selects a browser channel. Trials are limited to 1..20. Every visit must
29+
initialize the real package, execute `echo hi`, drive `Terminal` when present,
30+
and paint output over two animation frames. Each navigation/wait is capped at
31+
60 seconds; browser contexts and the loopback server close on completion/error.
32+
33+
The cold-start page intentionally has no framework, renderer, shared terminal
34+
WASM, or worker dependency. It measures the package, not brush-web's complete
35+
navigation-to-prompt path. All execution is on the page; CDP CPU throttling
36+
therefore covers this experiment's interpreter (unlike a worker experiment).
37+
Fresh contexts guarantee cold HTTP caches, but browser-process compiled-code
38+
caches may be shared. Network measurements remain representative; do not infer
39+
physical-phone compile time. The JSON records every resource and milestone,
40+
Chromium/Node versions, bytes, hashes, and raw samples.
41+
42+
Redirect JSON output to retain a run. Historical investigation results are in
43+
`results/`; these specialized package experiments are linked here rather than
44+
mixed with the native bashkit-bench or Criterion result schemas.
45+
46+
See also: [package guide](../README.md),
47+
[browser design](../../../knowledge/runtimes/browser-package.md).
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Transfer bytes, not raw bytes alone, determine cold-start network cost.
2+
import { readFileSync, existsSync, realpathSync } from "node:fs";
3+
import { resolve } from "node:path";
4+
import { fileURLToPath } from "node:url";
5+
import { createHash } from "node:crypto";
6+
import { gzipSync, brotliCompressSync, constants } from "node:zlib";
7+
8+
export function artifact(pkg) {
9+
const files = ["index.js", "bashkit_wasm.js", "bashkit_wasm_bg.wasm"].map(name => {
10+
const bytes = readFileSync(resolve(pkg, name));
11+
return { name, sha256: createHash("sha256").update(bytes).digest("hex"), raw: bytes.length,
12+
gzip: gzipSync(bytes, { level: 9 }).length,
13+
brotli: brotliCompressSync(bytes, { params: { [constants.BROTLI_PARAM_QUALITY]: 11 } }).length };
14+
});
15+
const bytes = readFileSync(resolve(pkg, "bashkit_wasm_bg.wasm"));
16+
let offset = 8;
17+
function leb() { let n = 0, shift = 0, b; do { b = bytes[offset++]; n += (b & 127) * 2 ** shift; shift += 7; } while (b & 128); return n; }
18+
const sections = [];
19+
while (offset < bytes.length) {
20+
const id = bytes[offset++], length = leb(), start = offset;
21+
let name = ({ 10: "code", 11: "data" })[id] ?? `section-${id}`;
22+
if (id === 0) { const n = leb(); name = `custom:${bytes.subarray(offset, offset + n).toString()}`; }
23+
sections.push({ name, raw: length });
24+
offset = start + length;
25+
}
26+
return { files, total: Object.fromEntries(["raw", "gzip", "brotli"].map(k => [k, files.reduce((n, f) => n + f[k], 0)])), sections };
27+
}
28+
// Node canonicalizes the module URL, but argv can retain a symlink (e.g. /var
29+
// versus /private/var on macOS). Imports must never run the CLI accidentally.
30+
if (process.argv[1] && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
31+
const paths = process.argv.slice(2).filter(arg => arg !== "--check");
32+
if (paths.length > 1) throw new Error("expected one package directory");
33+
const pkg = resolve(paths[0] ?? new URL("../pkg/", import.meta.url).pathname);
34+
const report = artifact(pkg);
35+
console.log(JSON.stringify(report, null, 2));
36+
if (process.argv.includes("--check")) {
37+
const budgets = JSON.parse(readFileSync(new URL("./size-budget.json", import.meta.url)));
38+
for (const [kind, budget] of Object.entries(budgets)) {
39+
if (report.total[kind] > budget) throw new Error(`${kind}: ${report.total[kind]} exceeds ${budget} byte budget`);
40+
}
41+
}
42+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Manual browser experiment. CI gates deterministic bytes/semantics, not noisy wall time.
2+
// Supply PLAYWRIGHT_MODULE when Playwright is installed outside this workspace.
3+
import { createServer } from "node:http";
4+
import { readFileSync } from "node:fs";
5+
import { resolve } from "node:path";
6+
import { pathToFileURL } from "node:url";
7+
import { brotliCompressSync, constants } from "node:zlib";
8+
import { artifact } from "./artifact.mjs";
9+
10+
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE ?? "playwright");
11+
const packages = process.argv.slice(2).map(resolvePath => resolve(resolvePath));
12+
if (!packages.length) throw new Error("pass package directories to compare");
13+
const trials = Number(process.env.TRIALS ?? 5);
14+
if (!Number.isInteger(trials) || trials < 1 || trials > 20) throw new Error("TRIALS must be 1..20");
15+
const conditions = {
16+
local: null,
17+
fast4g: { downloadThroughput: 1012500, uploadThroughput: 168750, latency: 165 },
18+
slow4g: { downloadThroughput: 180000, uploadThroughput: 84375, latency: 562.5 },
19+
};
20+
const routes = new Map();
21+
packages.forEach((pkg, i) => {
22+
for (const file of ["index.js", "bashkit_wasm.js", "bashkit_wasm_bg.wasm"]) {
23+
routes.set(`/${i}/${file}`, { type: file.endsWith("wasm") ? "application/wasm" : "text/javascript",
24+
bytes: brotliCompressSync(readFileSync(resolve(pkg, file)), { params: { [constants.BROTLI_PARAM_QUALITY]: 11 } }) });
25+
}
26+
const html = `<pre id="screen"></pre><script type="module">
27+
const marks = { start: performance.now() };
28+
const { initBashkit, Bash, Terminal } = await import('/${i}/index.js');
29+
marks.imported = performance.now();
30+
await initBashkit(); marks.initialized = performance.now();
31+
const bash = new Bash();
32+
const r = await bash.execute('echo hi');
33+
if (r.stdout !== 'hi\\n' || r.exitCode !== 0) throw new Error('first command failed');
34+
marks.command = performance.now();
35+
if (Terminal) {
36+
const terminal = new Terminal(); await terminal.runUntilIdle();
37+
terminal.send('echo hi\\r'); await terminal.runUntilIdle();
38+
document.querySelector('#screen').textContent = terminal.screenText();
39+
if (!terminal.screenText().includes('hi\\n$')) throw new Error('terminal failed');
40+
} else document.querySelector('#screen').textContent = r.stdout + '$';
41+
await new Promise(requestAnimationFrame); await new Promise(requestAnimationFrame);
42+
marks.prompt = performance.now();
43+
window.result = { marks, resources: performance.getEntriesByType('resource').map(r => ({ name: r.name.split('/').at(-1), start: r.startTime, end: r.responseEnd, transfer: r.transferSize, encoded: r.encodedBodySize })) };
44+
</script>`;
45+
routes.set(`/${i}/`, { type: "text/html", bytes: Buffer.from(html), plain: true });
46+
});
47+
const server = createServer((req, res) => {
48+
const route = routes.get(req.url);
49+
if (!route) { res.writeHead(404); res.end(); return; }
50+
res.writeHead(200, { "Content-Type": route.type, "Content-Length": route.bytes.length,
51+
"Cache-Control": "public, max-age=31536000, immutable", "Vary": "Accept-Encoding",
52+
...(route.plain ? {} : { "Content-Encoding": "br" }) });
53+
res.end(route.bytes);
54+
});
55+
await new Promise(r => server.listen(0, "127.0.0.1", r));
56+
let browser;
57+
try {
58+
browser = await chromium.launch({ channel: process.env.CHROME_CHANNEL ?? "chrome", headless: true });
59+
const samples = [];
60+
for (const [condition, throttle] of Object.entries(conditions)) {
61+
for (let trial = 0; trial < trials; trial++) {
62+
const order = packages.map((_, i) => (i + trial) % packages.length);
63+
if (trial % 2) order.reverse();
64+
for (const i of order) {
65+
const context = await browser.newContext(); // Cold HTTP cache per sample.
66+
try {
67+
const page = await context.newPage();
68+
const session = await context.newCDPSession(page);
69+
await session.send("Network.enable");
70+
if (throttle) await session.send("Network.emulateNetworkConditions", { offline: false, ...throttle });
71+
await session.send("Emulation.setCPUThrottlingRate", { rate: 4 });
72+
const errors = []; page.on("pageerror", e => errors.push(String(e)));
73+
await page.goto(`http://127.0.0.1:${server.address().port}/${i}/`, { waitUntil: "domcontentloaded", timeout: 60000 });
74+
await page.waitForFunction(() => window.result, null, { timeout: 60000 });
75+
const result = await page.evaluate(() => window.result);
76+
if (errors.length) throw new Error(errors.join("\n"));
77+
samples.push({ package: i, condition, trial, ...result });
78+
console.error(`${condition} ${trial + 1}/${trials} package ${i}: ${result.marks.prompt.toFixed(1)}ms`);
79+
} finally { await context.close(); }
80+
}
81+
}
82+
}
83+
console.log(JSON.stringify({ method: "Cold context, Brotli 11, CDP Fast/Slow 4G, page CPU 4x; main-thread JS-host WASM, no terminal renderer dependency; prompt after first echo and two animation frames", browser: browser.version(), node: process.version,
84+
packages: packages.map(pkg => ({ path: pkg, ...artifact(pkg) })), samples }, null, 2));
85+
} finally { await browser?.close(); await new Promise(r => server.close(r)); }
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
[
2+
{"name":"quoted array", "script":"a=('one two' three); printf '<%s>\\n' \"${a[@]}\""},
3+
{"name":"sparse array", "script":"a=([2]=two [7]=seven); printf '%s\\n' \"${!a[@]}\" \"${#a[@]}\""},
4+
{"name":"array slice", "script":"a=(a b c d e); printf '%s\\n' \"${a[@]: -2}\""},
5+
{"name":"associative array", "script":"declare -A a=([key]='two words'); printf '%s\\n' \"${a[key]}\""},
6+
{"name":"nameref assignment", "script":"a=old; declare -n r=a; r=new; echo \"$a $r\""},
7+
{"name":"nameref array", "script":"a=(x y z); declare -n r=a; echo \"${r[1]} ${#r[@]}\""},
8+
{"name":"case conversion", "script":"a='hEllo World'; echo \"${a^^} ${a,,}\""},
9+
{"name":"pattern replacement", "script":"a=abcabc; echo \"${a//a/X} ${a#*b} ${a%%b*}\""},
10+
{"name":"indirect expansion", "script":"a=hello; r=a; echo \"${!r}\""},
11+
{"name":"dynamic scope", "script":"a=outer; g() { echo \"$a\"; }; f() { local a=inner; g; }; f; echo \"$a\""},
12+
{"name":"arithmetic array", "script":"a=(4 5); ((a[1]+=3)); echo \"${a[1]}\""},
13+
{"name":"brace sequence", "script":"printf '%s\\n' {03..09..3}"},
14+
{"name":"command substitution", "script":"a=$(printf 'x\\n\\n'); printf '<%s>\\n' \"$a\""},
15+
{"name":"subshell isolation", "script":"a=parent; (a=child; echo \"$a\"); echo \"$a\""},
16+
{"name":"process substitution", "script":"read -r a < <(echo hello); echo \"$a\""},
17+
{"name":"here string", "script":"read -r a b <<< 'one two'; echo \"$b $a\""},
18+
{"name":"pipefail", "script":"set -o pipefail; false | true; echo $?"},
19+
{"name":"errexit exception", "script":"set -e; false && echo no; echo yes"},
20+
{"name":"case fallthrough", "script":"case x in x) echo a ;;& y) echo no ;; *) echo b ;; esac"},
21+
{"name":"regex capture", "script":"[[ abc123 =~ ([a-z]+)([0-9]+) ]]; echo \"${BASH_REMATCH[1]} ${BASH_REMATCH[2]}\""},
22+
{"name":"mapfile", "script":"mapfile -t a <<< $'one\\ntwo'; printf '<%s>\\n' \"${a[@]}\""},
23+
{"name":"child shell", "script":"bash -c 'echo child'"},
24+
{"name":"read delimiter", "script":"IFS=: read -r a b <<< 'one:two'; echo \"$a/$b\""},
25+
{"name":"negative status", "script":"false"}
26+
]

0 commit comments

Comments
 (0)