bytecodealliance / bytecodealliance/ComponentizeJS
Negative `s64` import argument traps a ComponentizeJS guest with `unreachable` during argument lowering (positive `s64`, `s32`, and `u64`-with-bit-63-set are all fine)
- Dominant language
- Rust
- Stars
- 391
- Forks
- 53
- Avg merge
- 3d 5h
- Merged PRs (30d)
- 1
Description
## Summary
A component built with **ComponentizeJS** traps with `RuntimeError: unreachable`
(`wasm 'unreachable' instruction executed`) whenever the JS guest calls an
**imported function with a negative `s64` argument**. The trap fires **inside the
guest's own core wasm module, during argument lowering, before the host import
body runs** — the host never receives the value.
What traps and what doesn't (empirically determined, see the table below):
- **Negative `s64`** (`-1`, `-2`, `-2^63`): **TRAP**, host never runs.
- Negative `s64` as the **second of two args** (`take-u64-s64(u64, s64)`,
mirroring a real `update-stock(item: u64, delta: s64)`): **TRAP**, host never
runs — the arg count does not change the manifestation.
- Negative `s64` on a **return-typed import** (`echo-s64(s64) -> s64`): **TRAP**
on lowering the negative *argument*; we never reach the return.
- **Positive `s64`** (`0 .. 2^63-1`, including the max `9223372036854775807`):
clean.
- Negative **`s32`**: clean.
- **`u64` with bit 63 set** (`2^63`, and `2^64-1` = all bits set): **clean** —
the host receives the value. So this is specifically a **signed** `s64`
lowering defect, *not* "any 64-bit value with the high bit set."
A **Rust / cargo-component guest built against the identical WIT**, transpiled
and run through the **identical** `jco transpile --map` + `host.mjs`, handles
**every** case cleanly — including `take-s64(-2)`, the two-arg negative, and
`echo-s64(-2) -> -2`. So this is a 64-bit **signed lowering** bug in the
ComponentizeJS / StarlingMonkey guest, not "`s64` is unsupported," and not a
problem with the WIT, the canonical ABI, or `jco transpile`.
## Toolchain / environment
| Item | Value |
| --- | --- |
| `@bytecodealliance/componentize-js` | `0.21.0` |
| `@bytecodealliance/jco` | `1.24.3` |
| `cargo-component` (parity control) | `0.21.1` |
| Node | `v24.15.0` |
| OS / arch | macOS (Darwin) arm64 (Apple Silicon) |
Engine provenance: the JS engine is the prebuilt StarlingMonkey/SpiderMonkey
runtime **bundled inside componentize-js 0.21.0**. There is no separately
pinnable engine version — pinning `componentize-js@0.21.0` pins the engine.
## Repro layout
Save these files into one directory (e.g. `s64-repro/`):
```
s64-repro/
package.json # devDeps: componentize-js 0.21.0, jco 1.24.3
world.wit # the WIT below (shared by both guests)
guest.js # the JS guest
host.mjs # the host import implementation (logs what it receives)
run.mjs # canonical runner (node run.mjs | node run.mjs rust)
wit-rust/world.wit # identical copy of world.wit for the Rust control
rust-guest/ # cargo-component parity control (Cargo.toml + src/lib.rs; bindings.rs is generated)
```
`npm install`, then build + run **from this directory**. The component is
transpiled into `out/` (and the Rust control into `out-rust/`); because the
generated modules live one level down, the host import is mapped to `../host.mjs`.
## WIT (`world.wit`)
```wit
package example:s64bug;
interface host {
take-s64: func(x: s64);
take-s32: func(x: s32);
take-u64-s64: func(a: u64, x: s64); // real shape: update-stock(item, delta)
take-u64: func(x: u64); // u64 high-bit probe
echo-s64: func(x: s64) -> s64; // s64 return path
}
world repro {
import host;
// single-arg s64 lowering (the original minimal shape)
export run-neg-s64: func(); // take-s64(-2)
export run-pos-s64: func(); // take-s64(2)
export run-neg-s32: func(); // take-s32(-2)
export run-max-pos-s64: func(); // take-s64(2^63 - 1)
export run-min-neg-s64: func(); // take-s64(-2^63)
export run-neg-one-s64: func(); // take-s64(-1)
// two-arg shape (the real update-stock case)
export run-u64-s64-neg: func(); // take-u64-s64(7, -2)
export run-u64-s64-pos: func(); // take-u64-s64(7, 2)
// u64 high-bit probe
export run-u64-low: func(); // take-u64(2)
export run-u64-bit63: func(); // take-u64(2^63)
export run-u64-max: func(); // take-u64(2^64 - 1)
// s64 return path
export run-echo-neg-s64: func() -> s64; // echo-s64(-2)
export run-echo-pos-s64: func() -> s64; // echo-s64(2)
}
```
## JS guest (`guest.js`)
```js
import { takeS64, takeS32, takeU64S64, takeU64, echoS64 } from 'example:s64bug/host';
export function runNegS64() { takeS64(-2n); }
export function runPosS64() { takeS64(2n); }
export function runNegS32() { takeS32(-2); }
export function runMaxPosS64() { takeS64(9223372036854775807n); } // 2^63 - 1
export function runMinNegS64() { takeS64(-9223372036854775808n); } // -2^63
export function runNegOneS64() { takeS64(-1n); }
export function runU64S64Neg() { takeU64S64(7n, -2n); }
export function runU64S64Pos() { takeU64S64(7n, 2n); }
export function runU64Low() { takeU64(2n); }
export function runU64Bit63() { takeU64(9223372036854775808n); } // 2^63
export function runU64Max() { takeU64(18446744073709551615n); } // 2^64 - 1
export function runEchoNegS64() { return echoS64(-2n); }
export function runEchoPosS64() { return echoS64(2n); }
```
## Host (`host.mjs`)
Each host fn logs what it received, so we can tell whether the host body ran at
all before any trap:
```js
export function takeS64(x) {
console.log(` [host] takeS64 received: ${x} (typeof ${typeof x})`);
}
export function takeS32(x) {
console.log(` [host] takeS32 received: ${x} (typeof ${typeof x})`);
}
export function takeU64S64(a, x) {
console.log(` [host] takeU64S64 received: a=${a} x=${x} (typeof a ${typeof a}, x ${typeof x})`);
}
export function takeU64(x) {
console.log(` [host] takeU64 received: ${x} (typeof ${typeof x})`);
}
export function echoS64(x) {
console.log(` [host] echoS64 received: ${x} (typeof ${typeof x}) -> returning it`);
return x;
}
```
## Build + run
```sh
npm install
# 1) build the JS guest component
jco componentize guest.js --wit world.wit --world-name repro --disable all -o repro.wasm
# 2) transpile, mapping the host import to the local logging module
jco transpile repro.wasm -o out/ --map 'example:s64bug/host=../host.mjs'
# 3) run the canonical runner
node run.mjs
```
`run.mjs` runs **each** case in its own child process and prints a per-case
CLEAN/TRAP verdict plus whether the host body ran. (Child processes are required
because a wasm trap permanently poisons the StarlingMonkey instance — every
subsequent call in the same process then also throws `unreachable`, and the trap
is additionally re-raised asynchronously as an uncaught exception. Isolating each
case gives an honest verdict and lets the runner exit 0.)
### Observed output (`node run.mjs`) — **exit code 0**
```
JS / ComponentizeJS (StarlingMonkey) guest
module: ./out/repro.js host import -> ../host.mjs
CASE VERDICT HOST-RAN?
---------------------------------------------------------------------
take-s64(2) [s64, sign clear] CLEAN yes
take-s64(2^63-1) [s64, sign clear, max pos] CLEAN yes
take-s32(-2) [s32 negative] CLEAN yes
take-s64(-1) [s64 negative] TRAP no
take-s64(-2) [s64 negative] TRAP no
take-s64(-2^63) [s64 negative, min] TRAP no
take-u64-s64(7, 2) [2-arg, s64 pos] CLEAN yes
take-u64-s64(7, -2) [2-arg, s64 neg] TRAP no
take-u64(2) [u64, bit63 clear] CLEAN yes
take-u64(2^63) [u64, bit63 SET] CLEAN yes
take-u64(2^64-1) [u64, all bits set] CLEAN yes
echo-s64(2) -> s64 [return, pos] CLEAN yes returned 2
echo-s64(-2) -> s64 [return, neg] TRAP no
---------------------------------------------------------------------
13 cases, 5 trapped, 8 clean.
(HOST-RAN? = did the host import body log before the result? "no" on a TRAP
means the trap fired DURING argument lowering, before the host ever ran.)
```
`HOST-RAN? = no` on every TRAP row: for the negative-`s64` cases (single-arg,
two-arg, and return-typed) the host log never prints — the trap fires **before**
the host body runs. The two-arg case behaves exactly like the single-arg case
(trap during lowering); the argument count does not change the manifestation.
### Raw trap (one negative-`s64` call, no handler)
```
RuntimeError: unreachable
at wasm://wasm/02fd7152:wasm-function[12600]:0x8be2b6
at wasm://wasm/02fd7152:wasm-function[4756]:0x1ea6cf
at wasm://wasm/02fd7152:wasm-function[4751]:0x1e9d99
at wasm://wasm/02fd7152:wasm-function[5164]:0x225adf
at wasm://wasm/02fd7152:wasm-function[5179]:0x23c909
...
```
The boundary is **the signedness**, not bit 63 alone: every value in `0 .. 2^63-1`
is clean, every **negative `s64`** traps, and a **`u64` with bit 63 set is clean**
(`2^63` and `2^64-1` both delivered to the host). The defect is specifically in
lowering a *signed-negative* `s64`.
## Why this is the engine, not jco's transpile glue (ruling out the ABI / codegen)
The trap fires inside the guest's **core wasm module**
(`wasm://wasm/...:wasm-function[12600]` … the top frames are all `wasm-function`),
**before** the jco-generated import trampoline (`_trampoline*` in `out/repro.js`,
which is plain JS) ever runs — consistent with `HOST-RAN? = no`. The Rust control
uses the **identical** `jco transpile --map 'example:s64bug/host=../host.mjs'`
pipeline and the **identical** `host.mjs`, and is clean on all 13 cases. That
rules out `jco transpile` and the canonical ABI; the defect is the
ComponentizeJS / StarlingMonkey guest's signed-64 argument lowering.
## Prior art
Searched existing issues. The closest, **jco#1393** (fixed by **#1421**), is
about `jco transpile --js` (asm.js) codegen splitting i64 into i32 pairs — that
is a transpiler-codegen issue, **unrelated** to this engine-side `s64` lowering
trap (which occurs with a normal `jco transpile`, not `--js`, and inside the core
wasm module itself).
## Expected
Negative `s64` import arguments should be lowered correctly and delivered to the
host (exactly as the Rust guest does), not trap the guest with `unreachable`.
## Rust parity control (`node run.mjs rust`) — **exit code 0, all clean**
Same `world.wit` (copied to `wit-rust/world.wit`), a Rust guest via
`cargo component build --release`, transpiled and run through the **identical**
`jco transpile --map` + `host.mjs`:
```
Rust / cargo-component guest (parity control)
module: ./out-rust/rust_guest.js host import -> ../host.mjs
CASE VERDICT HOST-RAN?
---------------------------------------------------------------------
take-s64(2) [s64, sign clear] CLEAN yes
take-s64(2^63-1) [s64, sign clear, max pos] CLEAN yes
take-s32(-2) [s32 negative] CLEAN yes
take-s64(-1) [s64 negative] CLEAN yes
take-s64(-2) [s64 negative] CLEAN yes
take-s64(-2^63) [s64 negative, min] CLEAN yes
take-u64-s64(7, 2) [2-arg, s64 pos] CLEAN yes
take-u64-s64(7, -2) [2-arg, s64 neg] CLEAN yes
take-u64(2) [u64, bit63 clear] CLEAN yes
take-u64(2^63) [u64, bit63 SET] CLEAN yes
take-u64(2^64-1) [u64, all bits set] CLEAN yes
echo-s64(2) -> s64 [return, pos] CLEAN yes returned 2
echo-s64(-2) -> s64 [return, neg] CLEAN yes returned -2
---------------------------------------------------------------------
13 cases, 0 trapped, 13 clean.
```
The Rust guest delivers every negative `s64` correctly (including the two-arg and
the `echo-s64(-2) -> -2` return path). Only the ComponentizeJS / StarlingMonkey
guest traps.
```
# build the Rust control:
# (in rust-guest/) cargo component build --release
# jco transpile rust-guest/target/wasm32-wasip1/release/rust_guest.wasm \
# -o out-rust/ --map 'example:s64bug/host=../host.mjs'
# node run.mjs rust
```
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.