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)

Open
#343 0 comments 0 reactions 0 assignees View on GitHub
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.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.