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)

オープン
#343 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る

まだ誰も着手していません。

評価

難易度
4/5
見積もり時間
3〜5日
初心者へのやさしさ
45/100
issue の種類
バグ
明瞭さ
おおむね明確
活発さ
静か
技術スタック
javascript, rust, wasm

調査の方向性

再現用ファイル world.wit、guest.js、host.mjs、run.mjs から始めます。記載されている依存関係をインストールし、ComponentizeJS のケースを実行して、host が実行される前に負の s64 traps が発生することを確認します。rust-guest のパリティ制御と比較し、core wasm stack が示している guest の lowering path を調べます。すべての負の s64 引数および戻り値のケースが問題なく完了し、host が期待される値を受け取れば完了です。

索引モデルが issue の本文から書いたものです。

説明

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)

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)

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:

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

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
主要言語
Rust
スター
392
フォーク
54
平均マージ
2日 18時間
マージ済み PR(30日)
3

コントリビューションガイド

このリポジトリのコントリビューションガイドは索引されていません

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

bytecodealliance/ComponentizeJS のほかの issue

bytecodealliance/ComponentizeJS の issue をすべて見る

似ている issue

Rust の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。