feat(wasm): run bashkit as a wasm component with no JS and no WASI - #2311
Conversation
bashkit assumed that wasm32-unknown-unknown means a JS host: the clock (web-time), timers (gloo-timers), entropy (getrandom/wasm_js) and chrono's Utc::now (chrono/wasmbind) all resolve to wasm-bindgen imports. A wasm runtime without a JS engine cannot satisfy those, and the failure lands at instantiation as an unresolved __wbg_* symbol. Those paths now sit behind a bashkit 'wasm_js' feature, on for the JS packages and the wasm32-unknown-unknown CI check. With it off, embedders get time_compat::host_clock: the clock becomes one symbol the embedder defines (__bashkit_host_now_micros), mirroring getrandom's custom-backend contract, so a missing clock is a link error rather than a panic on first use. sleep spins on it and timeout polls against it, since a guest with no timers and no second thread cannot be woken by a pending timer future. chrono's wasmbind is now off on every target: Utc::now() must not be called directly in the crate, time_compat::now_utc() reads the clock the target actually has. The JS packages re-enable wasmbind so nothing changes for them. WASI targets now read std::time directly instead of going through web-time (which was a plain std re-export there anyway).
…t shape) examples/hyperlight builds bashkit as a wasm component with no JS and no WASI imports, the shape a Hyperlight micro-VM guest needs, and runs a script through it under wasmtime (which is what Hyperlight-Wasm compiles into its guest). The host boundary is two functions, now-micros and random-bytes, and the build script asserts the guest's import list contains nothing else. Adds a CI job for it: no other job covers the wasm_js-off build, where a stray JS-backed dependency only shows up as an unresolvable import. Knowledge in knowledge/runtimes/non-js-wasm.md records the boundary, the spin-timer decision, measured artifact sizes, and how far this was verified (wasmtime and hyperlight-wasm-aot yes, micro-VM boot no: it needs KVM or WHP hardware).
history (environ.rs) and touch's two-digit-year path (fileops.rs) still called chrono::Utc::now() directly. chrono reads the clock through std::time::SystemTime, which panics on wasm32-unknown-unknown, so both were broken on that target as soon as chrono's JS wasmbind binding is off. Extends the time_compat scan test to cover chrono's clock alongside std::time's, which is what found these, and repoints it at the time_compat module directory.
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
bashkit | 537307f | Commit Preview URL Branch Preview URL |
Aug 19 2026, 06:00 AM |
chaliy
left a comment
There was a problem hiding this comment.
Deep review. Read the full diff (27 files), traced the wasm_js feature graph, the time_compat shim on all four target configurations, the WIT boundary, and the new CI guard. All 43 checks green, branch is a clean fast-forward on 986bfc7 (latest main).
The design holds up. Making web-time / gloo-timers / send_wrapper / getrandom/wasm_js / chrono/wasmbind all optional behind one wasm_js feature is the right seam — wasm32-unknown-unknown was never a proxy for "JS host", and the failure mode it produced (unresolved __wbg_* at instantiation, naming no dependency) is exactly the kind that needs a compile-time gate rather than docs. Dropping chrono's default features so wasmbind can't sneak back in, and extending the time_compat scan test to Utc::now()/Local::now(), closes the loop: the guard is what found the history and touch bugs, so the test is carrying real weight rather than restating the invariant. The wasm-component CI job is the part that keeps this from rotting — a stray JS-backed dep surfaces nowhere else.
Two findings, both minor and both scoped to the new non-JS target. Neither blocks.
1. host_clock::SystemTime cannot represent pre-epoch instants; touch silently clamps.
host_clock::SystemTime wraps an unsigned Duration, and Sub<Duration> saturates:
fn sub(self, rhs: Duration) -> SystemTime {
SystemTime(self.0.saturating_sub(rhs))
}So from_chrono of any pre-1970 datetime returns UNIX_EPOCH instead of a point before it. That path is reachable from user input: parse_touch_timestamp (fileops.rs:503) maps the 10-digit form with yy >= 69 to 1900 + yy, so touch -t 6912312359 asks for 1969-12-31, and the 12-digit form takes any year outright (touch -t 195001010000).
- native / WASI /
wasm_js:std::time::SystemTimeandweb_time::SystemTimeboth represent pre-epoch, and the mtime round-trips. wasm_jsoff: mtime becomes 1970-01-01, no error.
The round_trip_pre_epoch_subsecond test proves the to_chrono_utc/from_chrono decomposition is right, but it only ever runs against std::time::SystemTime on the host — nothing exercises the host_clock types, so this is invisible to the suite. Narrow enough to accept knowingly; worth either a signed representation, or a line in knowledge/runtimes/non-js-wasm.md recording it as a known divergence of the wasm_js-off build.
2. The WIT comment understates the now-micros contract.
wit/bashkit.wit:
// Microseconds since an arbitrary but monotonic epoch. Used for shell
// deadlines and for the wall clock (`date`, file mtimes)."Arbitrary but monotonic epoch" and "wall clock" are contradictory, and the second one is the binding constraint — host_clock feeds this straight into SystemTime, so date and mtimes are wrong by exactly the epoch offset if an embedder takes the first clause at its word. host_clock.rs states it correctly ("Microseconds since the Unix epoch, assumed non-decreasing"), and the host impl uses UNIX_EPOCH. Since the WIT is the file an embedder reads first, it should say Unix epoch and note the doubles-as-monotonic caveat that host_clock.rs already spells out.
Not blocking, no action needed: cargo install wasm-tools --version 1.256.0 --locked in the new job builds from source and isn't covered by rust-cache, so it adds a few minutes per run — fine now, a candidate for a prebuilt binary if the job gets noisy.
Holding off on merging: this isn't in one of the queues my scheduled run is authorized to ship from (issue-derived PRs, aardvark-labelled PRs, dependabot PRs), and it carries no reviews yet. Flagging it as ready from my side.
Generated by Claude Code
|
This is really interesting! I've seen a bunch of people "port" bashkit to their language of choice so it can be embedded without external dependencies. Having a WASM build that can be simply embedded using Wasmtime in e.g. .NET makes this directly available. Nice! |
What changed
Bashkit can now run as a WebAssembly component in a host with no JS engine
and no WASI — plain wasmtime, and by extension the
no_stdwasmtime thathyperlight-wasm compiles
into a Hyperlight micro-VM
guest.
examples/hyperlight/builds that guest and runs a script through it.The interpreter needed nothing: it already runs single-threaded over an
in-memory VFS and makes no syscalls. Four dependencies assumed a JS host, and
all four now sit behind a new
wasm_jsfeature (on for the JS packages, off foreveryone else):
wasm_jsweb-time(Performance.now)time_compat::host_clock, embedder symbolsleep,timeout)gloo-timers(setTimeout)getrandom/wasm_jsgetrandomcustom backendchrono::Utc::nowchrono/wasmbind(JSDate)time_compat::now_utcThe host boundary is two functions, and the build script asserts the guest
imports nothing else:
Two real bugs fell out of this.
historyandtouch's two-digit-year pathcalled
chrono::Utc::now()directly, which readsstd::time::SystemTimeandpanics on
wasm32-unknown-unknown. The existingtime_compatscan test onlyguarded
std::time; it now guards chrono's clock too, which is what foundthem.
Why
Asked whether bashkit could run inside Hyperlight. It can — the sandbox model
lines up almost exactly (Hyperlight guests have "no kernel or OS in the VM",
which is the environment bashkit already targets). The blockers were all
JS assumptions baked into
wasm32-unknown-unknown, and they fail in the worstpossible way: an unresolved
__wbg_*import at instantiation time, naming nodependency.
Before / After
Before — the guest could not be built at all; a
wasm_js-off build pulled in JS:After:
And the component AOT-compiles for the actual micro-VM guest target:
Sizes: 7.2 MB core module → 6.9 MB component → 22 MB
.aot.Not verified: booting an actual micro-VM. That needs
/dev/kvmor WindowsWHP, which the dev container does not have. Recorded as the open tier in
knowledge/runtimes/non-js-wasm.md.No behavior change for existing consumers: the JS packages enable
wasm_jsandresolve to exactly the dependencies they used before.
Risk
wasm_jssplit is the risk surface. If a JS-backed dependency were missed,it breaks the non-JS build (new, covered by the added CI job), not the JS
one —
bashkit-wasmand the wasm CI check both enable the feature and compilethe same code as before.
chrono'swasmbindis now off outside the JS packages. Any newUtc::now()call is a lurking wasm panic, which the extended scan test nowfails on.
Checklist
time_compatscan test to chrono'sclock (caught the two bugs above), added
now_utctests, and aWASM component (no JS, no WASI)CI job that builds the component,asserts its import list, and runs a script through it under wasmtime
wasm_jsand are unchangedLocal runs:
cargo fmt --all --check,cargo clippy --workspace --all-targets --features http_client,ssh,sqlite -- -D warnings, the CI test slice (5191passed), both wasm target checks, and the component smoke script. One unrelated
failure:
ssh_supabase_connectstimes out because this sandbox has no outboundSSH — it exercises a live public endpoint and runs in the
sshCI job.Generated by Claude Code