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)

Offen
#343 0 Kommentare 0 Reaktionen 0 zugewiesene Personen Auf GitHub ansehen
Vorherrschende Sprache
Rust
Sterne
391
Forks
53
Ø Merge
3 T. 5 Std.
Gemergte PRs (30 T.)
1

Beschreibung

## 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
```

Beitragsleitfaden

Für dieses Repository ist kein Beitragsleitfaden indexiert

Rechercherichtung

Start with the reproduction files world.wit, guest.js, host.mjs, and run.mjs; install the listed dependencies and run the ComponentizeJS case to confirm the negative s64 traps before the host runs. Compare it with the rust-guest parity control and inspect the guest lowering path implicated by the core wasm stack. Done means all negative s64 argument and return cases complete cleanly and the host receives the expected values.

Vom Indexierungsmodell aus dem Issue-Text verfasst.

Bewertung

Tech-Stack
javascript, rust, wasm
Bereich
compilers, devtools
Issue-Typ
Bug
Schwierigkeit
4/5
Geschätzter Aufwand
3-5 Tage
Aktivitätsstatus
Ruhig
Klarheit
Größtenteils klar
Anfängerfreundlichkeit
45/100

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.