Skip to content

WebAssembly W5: the Memory, Table and Global constructors, Module.imports/exports/customSections, and function references from tables - #1410

Merged
ctate merged 4 commits into
mainfrom
opt/wasm-w5
Oct 20, 2026
Merged

ctate merged 4 commits into
mainfrom
opt/wasm-w5

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

Stacked on #1395 (WebAssembly W3–W4). This PR targets main, but its diff includes #1395's commit until that merges; review only the top commit. It implements stage W5 of the WebAssembly design (results/opt-wasm-design/DESIGN.md). The goal is pi's codemode: quickjs-wasi's extension loader (dist/extensions.js), which every program importing quickjs-wasi reaches, needed new WebAssembly.Global, WebAssembly.Module.imports and WebAssembly.Module.customSections, which #1395 still refused with SC2020.

quickjs-wasi now loads its native extensions through its own glue, with output identical to Node. The real package was tested, with url.so, encoding.so, headers.so and structured-clone.so (it is not a corpus program). pi codemode's static coverage goes from 83% to 84% (1491 to 1500 of 1778 statements, on pi 482c1381a, with the same tree and machine for both). No WebAssembly blocker is left in the codemode chain.

What changed

  • Constructors, with Node 24's semantics and messages:
    • new WebAssembly.Memory(descriptor): reads initial, maximum and shared in V8's order and applies V8's EnforceUint32 messages ("must be non-negative", "must be in the unsigned long range", the hexadecimal lower bound). A shared memory is validated, then refused with a TypeError (threads are W9).
    • new WebAssembly.Table(descriptor, value): anyfunc or externref, with the default fill value, an initial value, and V8's errors.
    • new WebAssembly.Global(descriptor, value): i32, i64, f32, f64, externref and anyfunc, with V8's defaults for a missing or undefined value and its v128 error.
    • Calling Module, Instance, Memory, Table or Global without new throws Node's TypeError.
    • Constructed objects are the same runtime objects an instance exports, so either kind can be imported. Constructed memories use guard pages exactly when the translated modules do.
  • WebAssembly.Module.imports, exports and customSections read the descriptor. They return fresh arrays of fresh descriptor objects, and fresh ArrayBuffer copies of the custom sections. A translated module carries its custom sections only when the program calls customSections (IrModule.wasmCustomSections), so debug sections cost nothing otherwise.
  • Link checks for imported memories, tables and globals. Each check has V8's message: memory size and maximum, table size and maximum (V8 prints the table maximum in hexadecimal), mutability before type for globals, and funcref global imports.
  • Export identity, as in V8. An imported Memory, Table or Global is exported as the same object. Each memory and table has one object, however often it is exported. A defined global gets a new object per export.
  • Function references reach JavaScript as functions.
    • table.get(i) and global.value return a callable function for any function a module references, not only for exported functions.
    • The translator emits one call thunk per signature, plus a per-function thunk table in the descriptor.
    • Exported functions can be stored in tables (table.set, table.grow, the Table constructor) and in globals, from the same or another instance. quickjs-wasi's loader relies on this for GOT.func symbols.
  • Smaller fixes to WebAssembly W3–W4: translate modules to native code at build time, implement the JavaScript API over them, and send modules to workers #1395:
    • Table.grow(n) on an externref table fills with undefined, not null.
    • The Table.get/set/grow and Memory.grow index errors now use all of V8's messages.
    • table.set and table.grow use V8's funcref messages.
    • An invalid funcref global import is a LinkError; before, it threw a pending TypeError and continued.
    • global.valueOf() answers the value when the receiver's static type is WebAssembly.Global (wasm.valueOf). The generic dyn path forwards valueOf only for Date handles.
    • Memories with explicit bounds checks (sanitizer builds, Windows) keep the blocks that grow replaced, so an ArrayBuffer created before a growth never dangles.
  • Discovery also accepts .so paths spelled the ways .wasm paths are (require.resolve("quickjs-wasi/url.so")) when the file is a module. WebAssembly shared libraries use .so.
  • Descriptor ABI v2. It appends the custom sections and the call-thunk table. Memories and tables record their declared maximum.

Evidence

All differential programs (Node vs. compiled; stdout, stderr and exit code identical) pass in the plain and the sanitized lane (ASan, RC audit, explicit bounds checks). That covers the four new programs and all 14 webassembly-* programs.

  • tests/corpus/webassembly-jsapi.mjs (184 output lines):
    • every constructor's defaults, conversions, evaluation order and errors;
    • constructed objects as imports, shared with the module (globals, memory growth from both sides, tables, call_indirect through a constructed table);
    • export identity and another instance's exports as imports;
    • Module.imports, exports and customSections;
    • unexported functions read from a table;
    • every link error.
  • tests/corpus/webassembly-wpt-jsapi.mjs, a port of 261 web-platform-tests wasm/jsapi tests: Global constructor and value/valueOf, Memory constructor and grow, Table constructor, get/set and grow, and Module imports, exports and customSections.
    • The modules the tests build at run time with WasmModuleBuilder are inline base64 that the same builder produced.
    • Left out: assertions on the classes as values (prototypes, name and length, property descriptors, branding through call), Symbol arguments, Object.isFrozen/isExtensible of ArrayBuffers (a general scriptc gap), and the old buffer's detachment after grow (a documented difference).
  • tests/corpus/webassembly-dylink/ runs quickjs-wasi 3.6.2's dist/extensions.js, unmodified, with its MIT license.
    • Two small modules stand in for quickjs.wasm and an extension .so, shaped as wasi-sdk shared libraries: dylink.0, GOT.mem and GOT.func, __memory_base and __table_base, an unresolved symbol, a symbol resolved by the extension's own export, and data relocations.
    • The program covers: parsing dylink.0, loading, versions, init, calls in both directions through the shared table, the shared stack pointer, the snapshot-restore load at fixed bases, a second instance, and the loader's errors.
  • The 2.0 spec suite.
    • The runner's spectest module now has the reference harness's memory and table (new WebAssembly.Memory({initial: 1, maximum: 2}), new WebAssembly.Table({initial: 10, maximum: 20, element: "anyfunc"})).
    • gen.mjs therefore no longer skips modules that import them.
    • A new group, webassembly-spec-imports, adds imports.wast, table.wast and names.wast: 131 modules and 574 commands.
    • The memory and table groups gain their spectest-importing modules (26 modules).
    • The int, float and control groups regenerate byte-identically (wabt 1.0.37, spec wg-2.0).
  • quickjs-wasi itself (not a corpus program; it is 637 KB of wasm):
    • QuickJS.create({ wasm, extensions: [url, encoding, headers, structured-clone] }) through the package's own glue evaluates URL, URLSearchParams, TextEncoder/TextDecoder, Headers and structuredClone scripts with output identical to Node.
    • Build: 12.7 s wall. Binary: 4.87 MB.
  • Tests and snapshots:
    • a translator unit test for custom sections and call thunks;
    • a coverage-fixture test for .so discovery (ext.so is found, an ELF native.so is not);
    • the WebAssembly fences diagnostic fixture trimmed to what still refuses: the streaming functions and the classes as values.

Zero cost

  • Gate shard 1/3. scripts/zero-cost-gate.mjs with baseline = WebAssembly W3–W4: translate modules to native code at build time, implement the JavaScript API over them, and send modules to workers #1395's tip 78184a986, built --runtime, covers bench + self + corpus shard 1/3: 827 programs.
    • Result: PASS. IR, LLVM and release executables are identical for all of them.
    • Compile time: bench geomean +0.42% (95% CI −0.86%, +1.10%); self-hosted compiler +1.22% (one pair).
  • Gate shards 2/3 and 3/3 (corpus, 796 programs each): 795 identical in each. The only differences are the two WebAssembly programs, webassembly-typed.ts and webassembly-worker.ts. They gain the ABI v2 descriptor fields and a call thunk, and the larger scr_wasm.c grows webassembly-worker.ts by 1.3%. The gate reports these as differences; they are intended.
  • nm check: none of the 28 bench workloads or hello world, built with this branch, has a scr_wasm_* symbol.
  • Gating. All new runtime code is in the link-gated scr_wasm.c, and no always-linked unit changed. The new libCalls are wasm.*, so they set the wasm link feature.

Validation

  • Focused tests: 1,541 unit and harness tests across the wasm, IR and backend suites, the wasm spec cases, the sanitizer, coverage, diagnostics and surface-manifest suites, plus the 14 WebAssembly differential programs in both lanes. Two tests failed in that run:
    • zig COFF dry-run cache: a load flake; it passes alone on this tree and on the base.
    • coverage's whole-corpus test: it hits its hard 600 s timeout when unsharded in the sandbox (CI shards it). Each new or changed corpus program is 100% static under scriptc coverage.
  • Lint and baselines. pnpm lint and pnpm format:check pass. pnpm test:ts7 --baselines-only passes; the full re-record added exactly the four new baselines and changed none.
  • Generated outputs. pnpm manifest and pnpm node-compat change only the evidence list of globals: WebAssembly (still partial) and its surface-manifest note. The public docs artifact is unchanged.
  • Docs gate. NEXT_DIST_DIR=.next-check pnpm check passes (25 route tests).
  • Self-hosting. The self-hosting IR build finishes with no diagnostics (608 MB of IR). tests/harness/self-hosting-native-driver.test.ts passes on the final tree (2,102 s).

Known differences from Node (limitations page updated)

  • Growing a memory does not detach the previous ArrayBuffer. Detaching it needs a registry of views in scr_bytes.c, which every program links, so it would not be free.
  • valueOf() answers a Global's value only on receivers typed WebAssembly.Global. Through an untyped value, and in conversions to a primitive, a Global is the object.
  • Shared memories are not constructed.
  • Instances are never freed (W6).

What remains

  • Codemode blockers, none of them WebAssembly:
    • Promise<…> | VmChannel returns and the wasm: object | Promise<…> members (SC2009);
    • conditional spreads;
    • a class constructed through a rejected class value;
    • an AbortSignal | undefined field;
    • the settle binding;
    • vm.callFunction(...).dispose;
    • element access on non-arrays;
    • getRandomValues as a value;
    • delete on JSException;
    • Object.assign;
    • BigInt64Array.
  • Found on the way. quickjs-wasi's restoreExtensions fails in scriptc with "expected number at $[0].dylink.needed[0], got string". parseDylink's needed: [] is a never[], whose element type maps to f64. That is a type-mapper gap, not a WebAssembly one; the corpus program exercises the same restore path through loadExtension.
  • Later stages: W6 (instance tracing), W7 (externref tables for wasm-bindgen), W8–W13.

🤖 Generated with Claude Code

…orts/exports/customSections, and function references from tables

new WebAssembly.Memory, Table and Global construct the runtime objects an
instance exports, with Node 24's descriptor order, defaults, conversions
and messages (V8's EnforceUint32 errors, the v128 and shared-memory
errors; a valid shared memory is refused with a TypeError until threads),
so either kind can be imported. Imports of memories, tables and globals
now check limits, types and mutability with V8's LinkError messages, and
exports keep V8's identity (imported objects re-export as themselves, one
object per memory and table). The classes called without new throw Node's
TypeError.

WebAssembly.Module.imports, exports and customSections read the
descriptor (ABI v2). Custom sections are carried only by programs that
call customSections. Every function a module references reaches
JavaScript as a callable function: the translator emits one call thunk
per signature, so table.get and funcref globals return the module's
unexported functions too, and exported functions can be stored in tables
and globals from any instance. Discovery also finds .so WebAssembly
shared libraries at resolved paths.

Fixes to W3/W4 found on the way: externref Table.grow fills with
undefined; Memory.grow/Table index errors and funcref messages match V8;
an invalid funcref global import is a LinkError; global.valueOf() on a
WebAssembly.Global-typed receiver answers the value; explicit-bounds
memories keep the blocks grow replaced, so old ArrayBuffers never
dangle.

This unblocks quickjs-wasi's extension loader: quickjs-wasi with its url,
encoding, headers and structured-clone extensions runs through its own
glue identically to Node, and pi codemode's static coverage goes from 83%
to 84% with no WebAssembly blocker left.

Evidence: differential corpus programs for the API (webassembly-jsapi),
a 261-test port of WPT wasm/jsapi (webassembly-wpt-jsapi), quickjs-wasi's
dist/extensions.js run unmodified against dylink-shaped modules
(webassembly-dylink), and the 2.0 spec suite's modules that import the
spectest memory and table (new webassembly-spec-imports group plus the
memory and table groups); all match Node in the plain and sanitized
lanes. The zero-cost gate leaves every non-WebAssembly program
byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Oct 20, 2026 5:09pm UTC

@vercel-security-reviewer

Copy link
Copy Markdown

Security review details

@cramforce
cramforce requested a review from ctate October 19, 2026 20:13
@ctate
ctate merged commit 8ac2d85 into main Oct 20, 2026
23 of 24 checks passed
@ctate
ctate deleted the opt/wasm-w5 branch October 20, 2026 17:12
euphemism pushed a commit to euphemism/scriptc that referenced this pull request Oct 20, 2026
…e headers

The backend can emit scr_wasm_memory_construct, scr_wasm_table_construct,
scr_wasm_global_construct, scr_wasm_construct_without_new,
scr_wasm_module_imports, scr_wasm_module_exports,
scr_wasm_module_custom_sections and scr_wasm_value_of (vercel-labs#1410), and
scr_stream_dyn_native, scr_stream_pipe_dyn and scr_stream_unpipe_dyn
(vercel-labs#1524). Their prototypes were in scr_wasm.c or nowhere, so the runtime
ABI guard failed. They are now in scr_wasm.h and scr_stream.h, and
scr_wasm.c no longer repeats the header's declarations.

The speculative-hoisting test relied on `v instanceof Num` in a
JavaScript function failing, which constructor-function instanceof now
compiles (vercel-labs#1512). Its hoisted function reads the prototype through
Reflect.getPrototypeOf instead, which still fences.

This branch was successfully deployed

1 active deployment
Preview — ae3642d1 Deployed Oct 20, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants