Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,11 @@ jobs:
- name: Integration tests (headless Node)
run: node --test "crates/bashkit-wasm/__test__/*.test.mjs"

- name: Browser compatibility and transfer-size gates
run: |
node crates/bashkit-wasm/bench/compat.mjs
node crates/bashkit-wasm/bench/artifact.mjs crates/bashkit-wasm/pkg --check

audit:
name: Audit
runs-on: ubuntu-latest
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/publish-wasm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ jobs:
- name: Integration tests (headless Node)
run: node --test "crates/bashkit-wasm/__test__/*.test.mjs"

- name: Browser compatibility and transfer-size gates
run: |
node crates/bashkit-wasm/bench/compat.mjs
node crates/bashkit-wasm/bench/artifact.mjs crates/bashkit-wasm/pkg --check

- name: Upload pkg
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
Expand Down
8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,14 @@ codegen-units = 1
panic = "unwind"
strip = "symbols"

# Browser cold start is transfer-bound. Keep native release throughput settings;
# use size-oriented LLVM optimization and whole-program LTO only for the JS-host
# WASM package. Retain release's panic policy and all interpreter safety checks.
[profile.wasm-release]
inherits = "release"
opt-level = "s"
lto = "fat"

# Important decision: separate `bench` profile uses fat LTO for the most
# aggressive cross-crate inlining. Criterion benches run rarely and we'd
# rather spend extra compile time once than miss inlining opportunities
Expand Down
26 changes: 26 additions & 0 deletions crates/bashkit-wasm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,3 +294,29 @@ just build-wasm
## License

MIT, part of the [Bashkit](https://github.com/everruns/bashkit) project.

## Deployment and cold start

Serve `bashkit_wasm_bg.wasm` as `application/wasm` with Brotli or gzip HTTP
compression. Raw npm file size is not transfer size. Use content-hashed asset
URLs with long-lived immutable caching; keep the JS glue and WASM from the same
build together. `initBashkit()` uses wasm-bindgen's streaming loader when the
server sends the correct MIME type. A slow mobile connection can take more than
15 seconds to download a multi-megabyte bundle, so choose startup deadlines from
your application's measured network conditions.

The package includes `build-info.json`: source commit, dirty-checkout status,
compiler/tools, Cargo profile/features, and the SHA-256 of the shipped WASM.
Source archives without Git metadata record a null source identity. A source
checkout between releases can have the same package version as an older npm
release; compare the source commit as well as the version.

Maintainers can run the bounded compatibility and compressed-size gates:

```sh
node crates/bashkit-wasm/bench/compat.mjs
node crates/bashkit-wasm/bench/artifact.mjs crates/bashkit-wasm/pkg --check
```

The [browser benchmark methodology](https://github.com/everruns/bashkit/blob/main/crates/bashkit-wasm/bench/README.md) describes reproducible
cold-start and execution experiments and the measured profile tradeoff.
33 changes: 33 additions & 0 deletions crates/bashkit-wasm/__test__/artifact.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// A tiny valid WASM fixture keeps CLI/budget regression coverage bounded.
import { test } from "node:test";
import assert from "node:assert/strict";
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";

test("artifact CLI accepts --check with default package and enforces budgets", () => {
const root = mkdtempSync(join(tmpdir(), "bashkit-artifact-"));
try {
const bench = join(root, "bench"), pkg = join(root, "pkg");
mkdirSync(bench); mkdirSync(pkg);
const script = join(bench, "artifact.mjs");
copyFileSync(new URL("../bench/artifact.mjs", import.meta.url), script);
writeFileSync(join(pkg, "index.js"), "export {};");
writeFileSync(join(pkg, "bashkit_wasm.js"), "export {};");
writeFileSync(join(pkg, "bashkit_wasm_bg.wasm"), Buffer.from([0, 97, 115, 109, 1, 0, 0, 0]));
const budget = join(bench, "size-budget.json");
writeFileSync(budget, '{"raw":1000,"gzip":1000,"brotli":1000}');
const run = args => spawnSync(process.execPath, [script, ...args], { encoding: "utf8", timeout: 10000 });
const result = run(["--check"]);
assert.equal(result.status, 0, result.stderr);
assert.equal(JSON.parse(result.stdout).total.raw, 28);
const explicit = run([pkg, "--check"]);
assert.equal(explicit.status, 0, explicit.stderr);
assert.deepEqual(JSON.parse(explicit.stdout).total, JSON.parse(result.stdout).total);
writeFileSync(budget, '{"raw":27}');
const rejected = run(["--check"]);
assert.notEqual(rejected.status, 0);
assert.match(rejected.stderr, /28 exceeds 27 byte budget/);
} finally { rmSync(root, { recursive: true, force: true }); }
});
33 changes: 33 additions & 0 deletions crates/bashkit-wasm/__test__/package.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Exercise the archive's public contract, not just an unpacked build directory.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { Terminal } from "../pkg/index.js";
const pkg = new URL("../pkg/", import.meta.url);
const read = name => readFileSync(new URL(name, pkg));

test("package records verifiable source and artifact identity", () => {
const info = JSON.parse(read("build-info.json"));
if (info.sourceRevision === null) {
assert.equal(info.sourceDirty, null);
assert.notEqual(process.env.GITHUB_ACTIONS, "true", "CI/release must record Git identity");
} else {
assert.match(info.sourceRevision, /^[0-9a-f]{40}$/);
assert.equal(typeof info.sourceDirty, "boolean");
assert.equal(info.sourceRevision, execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim());
}
assert.equal(info.version, JSON.parse(read("package.json")).version);
assert.equal(info.wasmSha256, createHash("sha256").update(read("bashkit_wasm_bg.wasm")).digest("hex"));
assert.equal(info.profile, "wasm-release");
assert.equal(info.features.includes("terminal"), Terminal !== undefined);
});

test("npm archive contains every runtime file and build identity", () => {
const [archive] = JSON.parse(execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], {
cwd: pkg, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30000,
}));
const files = new Set(archive.files.map(file => file.path));
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}`);
});
47 changes: 47 additions & 0 deletions crates/bashkit-wasm/bench/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Browser package experiments

These experiments complement the full package integration suite. Run from the
repository root after `just build-wasm`. They require GNU Bash 5+ on `PATH` (on
macOS, `/bin/bash` 3.2 is unsuitable) and Node 22+.

- `node crates/bashkit-wasm/bench/compat.mjs [pkg ...]`: 24 deterministic,
self-contained shell cases against a fresh GNU Bash oracle and fresh Bashkit
instances. Exact stdout bytes and status; stderr is not scored. Worker lifetime
is capped at 30 seconds; each oracle process at two seconds. One package is a
pass/fail CI gate; multiple packages produce a comparison without failing for
an old release's known gaps. This is a regression sample, not a population
compatibility percentage. No corpus or package is fetched by the gate.
- `node crates/bashkit-wasm/bench/artifact.mjs [pkg] [--check]`: raw, gzip-9,
Brotli-11 bytes for the actual JS + WASM load graph, SHA-256 identities, and
WASM code/data/other sections. `--check` applies `size-budget.json`, with about
4-5% headroom above the selected shipping profile. Review new measurements
before deliberately raising a budget. No wall-clock threshold in CI.
- `node crates/bashkit-wasm/bench/runtime.mjs pkg`: arithmetic, substitution,
jq, and text pipelines; one warmup and seven measured fresh-instance runs.
Output is checked for status and consistency. Compare on the same host,
without competing builds. Times from a busy development host are exploratory.
- `TRIALS=5 node crates/bashkit-wasm/bench/cold-start.mjs pkg-before pkg-after`:
Chromium, Brotli-11 server, fresh HTTP cache per trial, rotated package order,
loopback plus CDP Fast 4G (1,012,500 B/s, 165 ms) and Slow 4G (180,000 B/s,
562.5 ms), page CPU throttled 4x. Requires an existing Playwright installation
and Chrome; `PLAYWRIGHT_MODULE` can name its `index.mjs` and `CHROME_CHANNEL`
selects a browser channel. Trials are limited to 1..20. Every visit must
initialize the real package, execute `echo hi`, drive `Terminal` when present,
and paint output over two animation frames. Each navigation/wait is capped at
60 seconds; browser contexts and the loopback server close on completion/error.

The cold-start page intentionally has no framework, renderer, shared terminal
WASM, or worker dependency. It measures the package, not brush-web's complete
navigation-to-prompt path. All execution is on the page; CDP CPU throttling
therefore covers this experiment's interpreter (unlike a worker experiment).
Fresh contexts guarantee cold HTTP caches, but browser-process compiled-code
caches may be shared. Network measurements remain representative; do not infer
physical-phone compile time. The JSON records every resource and milestone,
Chromium/Node versions, bytes, hashes, and raw samples.

Redirect JSON output to retain a run. Historical investigation results are in
`results/`; these specialized package experiments are linked here rather than
mixed with the native bashkit-bench or Criterion result schemas.

See also: [package guide](../README.md),
[browser design](../../../knowledge/runtimes/browser-package.md).
42 changes: 42 additions & 0 deletions crates/bashkit-wasm/bench/artifact.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Transfer bytes, not raw bytes alone, determine cold-start network cost.
import { readFileSync, existsSync, realpathSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createHash } from "node:crypto";
import { gzipSync, brotliCompressSync, constants } from "node:zlib";

export function artifact(pkg) {
const files = ["index.js", "bashkit_wasm.js", "bashkit_wasm_bg.wasm"].map(name => {
const bytes = readFileSync(resolve(pkg, name));
return { name, sha256: createHash("sha256").update(bytes).digest("hex"), raw: bytes.length,
gzip: gzipSync(bytes, { level: 9 }).length,
brotli: brotliCompressSync(bytes, { params: { [constants.BROTLI_PARAM_QUALITY]: 11 } }).length };
});
const bytes = readFileSync(resolve(pkg, "bashkit_wasm_bg.wasm"));
let offset = 8;
function leb() { let n = 0, shift = 0, b; do { b = bytes[offset++]; n += (b & 127) * 2 ** shift; shift += 7; } while (b & 128); return n; }
const sections = [];
while (offset < bytes.length) {
const id = bytes[offset++], length = leb(), start = offset;
let name = ({ 10: "code", 11: "data" })[id] ?? `section-${id}`;
if (id === 0) { const n = leb(); name = `custom:${bytes.subarray(offset, offset + n).toString()}`; }
sections.push({ name, raw: length });
offset = start + length;
}
return { files, total: Object.fromEntries(["raw", "gzip", "brotli"].map(k => [k, files.reduce((n, f) => n + f[k], 0)])), sections };
}
// Node canonicalizes the module URL, but argv can retain a symlink (e.g. /var
// versus /private/var on macOS). Imports must never run the CLI accidentally.
if (process.argv[1] && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
const paths = process.argv.slice(2).filter(arg => arg !== "--check");
if (paths.length > 1) throw new Error("expected one package directory");
const pkg = resolve(paths[0] ?? new URL("../pkg/", import.meta.url).pathname);
const report = artifact(pkg);
console.log(JSON.stringify(report, null, 2));
if (process.argv.includes("--check")) {
const budgets = JSON.parse(readFileSync(new URL("./size-budget.json", import.meta.url)));
for (const [kind, budget] of Object.entries(budgets)) {
if (report.total[kind] > budget) throw new Error(`${kind}: ${report.total[kind]} exceeds ${budget} byte budget`);
}
}
}
85 changes: 85 additions & 0 deletions crates/bashkit-wasm/bench/cold-start.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Manual browser experiment. CI gates deterministic bytes/semantics, not noisy wall time.
// Supply PLAYWRIGHT_MODULE when Playwright is installed outside this workspace.
import { createServer } from "node:http";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { brotliCompressSync, constants } from "node:zlib";
import { artifact } from "./artifact.mjs";

const { chromium } = await import(process.env.PLAYWRIGHT_MODULE ?? "playwright");
const packages = process.argv.slice(2).map(resolvePath => resolve(resolvePath));
if (!packages.length) throw new Error("pass package directories to compare");
const trials = Number(process.env.TRIALS ?? 5);
if (!Number.isInteger(trials) || trials < 1 || trials > 20) throw new Error("TRIALS must be 1..20");
const conditions = {
local: null,
fast4g: { downloadThroughput: 1012500, uploadThroughput: 168750, latency: 165 },
slow4g: { downloadThroughput: 180000, uploadThroughput: 84375, latency: 562.5 },
};
const routes = new Map();
packages.forEach((pkg, i) => {
for (const file of ["index.js", "bashkit_wasm.js", "bashkit_wasm_bg.wasm"]) {
routes.set(`/${i}/${file}`, { type: file.endsWith("wasm") ? "application/wasm" : "text/javascript",
bytes: brotliCompressSync(readFileSync(resolve(pkg, file)), { params: { [constants.BROTLI_PARAM_QUALITY]: 11 } }) });
}
const html = `<pre id="screen"></pre><script type="module">
const marks = { start: performance.now() };
const { initBashkit, Bash, Terminal } = await import('/${i}/index.js');
marks.imported = performance.now();
await initBashkit(); marks.initialized = performance.now();
const bash = new Bash();
const r = await bash.execute('echo hi');
if (r.stdout !== 'hi\\n' || r.exitCode !== 0) throw new Error('first command failed');
marks.command = performance.now();
if (Terminal) {
const terminal = new Terminal(); await terminal.runUntilIdle();
terminal.send('echo hi\\r'); await terminal.runUntilIdle();
document.querySelector('#screen').textContent = terminal.screenText();
if (!terminal.screenText().includes('hi\\n$')) throw new Error('terminal failed');
} else document.querySelector('#screen').textContent = r.stdout + '$';
await new Promise(requestAnimationFrame); await new Promise(requestAnimationFrame);
marks.prompt = performance.now();
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 })) };
</script>`;
routes.set(`/${i}/`, { type: "text/html", bytes: Buffer.from(html), plain: true });
});
const server = createServer((req, res) => {
const route = routes.get(req.url);
if (!route) { res.writeHead(404); res.end(); return; }
res.writeHead(200, { "Content-Type": route.type, "Content-Length": route.bytes.length,
"Cache-Control": "public, max-age=31536000, immutable", "Vary": "Accept-Encoding",
...(route.plain ? {} : { "Content-Encoding": "br" }) });
res.end(route.bytes);
});
await new Promise(r => server.listen(0, "127.0.0.1", r));
let browser;
try {
browser = await chromium.launch({ channel: process.env.CHROME_CHANNEL ?? "chrome", headless: true });
const samples = [];
for (const [condition, throttle] of Object.entries(conditions)) {
for (let trial = 0; trial < trials; trial++) {
const order = packages.map((_, i) => (i + trial) % packages.length);
if (trial % 2) order.reverse();
for (const i of order) {
const context = await browser.newContext(); // Cold HTTP cache per sample.
try {
const page = await context.newPage();
const session = await context.newCDPSession(page);
await session.send("Network.enable");
if (throttle) await session.send("Network.emulateNetworkConditions", { offline: false, ...throttle });
await session.send("Emulation.setCPUThrottlingRate", { rate: 4 });
const errors = []; page.on("pageerror", e => errors.push(String(e)));
await page.goto(`http://127.0.0.1:${server.address().port}/${i}/`, { waitUntil: "domcontentloaded", timeout: 60000 });
await page.waitForFunction(() => window.result, null, { timeout: 60000 });
const result = await page.evaluate(() => window.result);
if (errors.length) throw new Error(errors.join("\n"));
samples.push({ package: i, condition, trial, ...result });
console.error(`${condition} ${trial + 1}/${trials} package ${i}: ${result.marks.prompt.toFixed(1)}ms`);
} finally { await context.close(); }
}
}
}
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,
packages: packages.map(pkg => ({ path: pkg, ...artifact(pkg) })), samples }, null, 2));
} finally { await browser?.close(); await new Promise(r => server.close(r)); }
26 changes: 26 additions & 0 deletions crates/bashkit-wasm/bench/compat-cases.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[
{"name":"quoted array", "script":"a=('one two' three); printf '<%s>\\n' \"${a[@]}\""},
{"name":"sparse array", "script":"a=([2]=two [7]=seven); printf '%s\\n' \"${!a[@]}\" \"${#a[@]}\""},
{"name":"array slice", "script":"a=(a b c d e); printf '%s\\n' \"${a[@]: -2}\""},
{"name":"associative array", "script":"declare -A a=([key]='two words'); printf '%s\\n' \"${a[key]}\""},
{"name":"nameref assignment", "script":"a=old; declare -n r=a; r=new; echo \"$a $r\""},
{"name":"nameref array", "script":"a=(x y z); declare -n r=a; echo \"${r[1]} ${#r[@]}\""},
{"name":"case conversion", "script":"a='hEllo World'; echo \"${a^^} ${a,,}\""},
{"name":"pattern replacement", "script":"a=abcabc; echo \"${a//a/X} ${a#*b} ${a%%b*}\""},
{"name":"indirect expansion", "script":"a=hello; r=a; echo \"${!r}\""},
{"name":"dynamic scope", "script":"a=outer; g() { echo \"$a\"; }; f() { local a=inner; g; }; f; echo \"$a\""},
{"name":"arithmetic array", "script":"a=(4 5); ((a[1]+=3)); echo \"${a[1]}\""},
{"name":"brace sequence", "script":"printf '%s\\n' {03..09..3}"},
{"name":"command substitution", "script":"a=$(printf 'x\\n\\n'); printf '<%s>\\n' \"$a\""},
{"name":"subshell isolation", "script":"a=parent; (a=child; echo \"$a\"); echo \"$a\""},
{"name":"process substitution", "script":"read -r a < <(echo hello); echo \"$a\""},
{"name":"here string", "script":"read -r a b <<< 'one two'; echo \"$b $a\""},
{"name":"pipefail", "script":"set -o pipefail; false | true; echo $?"},
{"name":"errexit exception", "script":"set -e; false && echo no; echo yes"},
{"name":"case fallthrough", "script":"case x in x) echo a ;;& y) echo no ;; *) echo b ;; esac"},
{"name":"regex capture", "script":"[[ abc123 =~ ([a-z]+)([0-9]+) ]]; echo \"${BASH_REMATCH[1]} ${BASH_REMATCH[2]}\""},
{"name":"mapfile", "script":"mapfile -t a <<< $'one\\ntwo'; printf '<%s>\\n' \"${a[@]}\""},
{"name":"child shell", "script":"bash -c 'echo child'"},
{"name":"read delimiter", "script":"IFS=: read -r a b <<< 'one:two'; echo \"$a/$b\""},
{"name":"negative status", "script":"false"}
]
Loading
Loading