Skip to content

ECO-394: externref support in the WebAssembly bindings; add js-umami framework test - #112

Closed
Arshia001 wants to merge 25 commits into
mainfrom
eco-394-externref-edgejs
Closed

ECO-394: externref support in the WebAssembly bindings; add js-umami framework test#112
Arshia001 wants to merge 25 commits into
mainfrom
eco-394-externref-edgejs

Conversation

@Arshia001

Copy link
Copy Markdown
Member

Summary

Wires externref support through src/webassembly/edge_wasm.cc on top of the new wasmer C-API reference surface (WARP-70 Part A), unblocking wasm-bindgen modules — most importantly Prisma 7's wasm query compiler — under edgejs+quickjs on both native and WASIX. Adds js-umami (Umami v3.2.0, Postgres) as the acceptance framework test: every Prisma query it makes runs through the wasm-bindgen query compiler and its __wbindgen_externrefs table.

edge_wasm.cc changes

  • Externref registry: JS values passed into wasm are rooted in a state-held JS array; the index rides in the foreign object's host info (wasm_foreign_new + wasm_ref_set_host_info). Only JS null maps to the null reference — undefined/true/false are real externrefs (wasm-bindgen sentinel slots).
  • Real Table.get/set/grow/new, including funcref Table.get wrapping via wasm_ref_as_func; JS-created tables are now backed by wasm_table_new (the local_only bookkeeping is gone, so they can be imported too).
  • ToNumber coercion in JsToWasmVal: wasm-bindgen predicate imports return booleans for i32-typed results; strict napi_get_value_int32 rejected them.
  • Multi-value export results now surface as a JS array (the glue indexes r[0]/r[2]; returning only the first value silently produced __wbg_ptr = 0 → "null pointer passed to rust").
  • Memory.buffer detach-on-grow: the buffer is cached per memory object and detached at JS↔wasm boundaries when the backing storage moves/grows, matching JS-API semantics that wasm-bindgen's view caching relies on. (Previously a fresh external ArrayBuffer per access left cached views silently stale — and dangling — after memory.grow.)

Intl fix

Intl.DateTimeFormat/Intl.NumberFormat are now callable without new (ECMA-402 legacy behavior). Umami's isValidTimezone calls Intl.DateTimeFormat(undefined, { timeZone }) as a plain function; the previous throw made every timezone "invalid" and 400'd all timezone-parameterized endpoints.

js-umami framework test

Vendors the final next build artifact (output: standalone) rather than source — umami's real build needs a live DATABASE_URL and network at build time (see EDGEJS-NOTES.md in the app dir). Routes cover the Next server, static assets, POST /api/auth/login (bcrypt + Prisma via the wasm compiler), and the full POST /api/send analytics ingest (bot check, GeoIP, session + event insert).

Verification

make framework-test-quickjs-native js-umami and WASMER_BIN=<dev wasmer> make framework-test-quickjs-wasix js-umami are green locally (6/6 routes on every stage, no regressions vs Node).

Dependencies / merge order

🤖 Generated with Claude Code

Arshia001 and others added 25 commits July 6, 2026 08:54
ECO-355 phase DB: backend apps that need a real database declare it in a
top-level 'database' block in routes.json; the harness then starts an
ephemeral PostgreSQL before the app server and injects the declared env
vars ({dbUrl}/{dbHost}/{dbPort}/{dbUser}/{dbPassword}/{dbName} placeholders,
plus {port} expanded at spawn time), tearing everything down afterwards.

Postgres comes from the embedded-postgres npm package (real zonky.io
binaries run as plain child processes), installed on demand into
.framework-test/db-tools with the shared pnpm store. No Docker: framework
tests run on macOS CI runners (no Docker daemon) and locally via make.

The WASIX framework runner forwards only an env allowlist into the guest;
the harness now lists the app-specific names in FRAMEWORK_TEST_EXTRA_ENV
and the runner forwards those too.

Also update the ECO-355 plan: SQLite spike resolved as no-go; former
Tier B apps fold into the external-DB phase (triage table included).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First DB-backed framework-test app, exercising the new embedded-database
provisioning. Green on Node, QuickJS native, and QuickJS WASIX.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Native and WASIX must expose the exact same functionality, and WASIX has
no dynamic linking — so process.dlopen now throws a catchable
ERR_DLOPEN_FAILED before ever calling dlopen(), on every target. Failing
early is load-bearing: legacy NAPI_MODULE-style prebuilds (bufferutil,
utf-8-validate, most node-gyp-build prebuilds) call the unexported
napi_module_register symbol from a static constructor *during* dlopen,
which previously killed the whole process with an uncatchable dynamic
linker error. With the early throw, optional native accelerators fall
back to their pure-JS implementations exactly as they do under WASIX.

The now-unreachable dlopen machinery (library cache, initializer lookup,
open/close helpers) is removed. N-API support itself is unaffected:
native napi tests are statically linked, and safe mode / WASIX load
addons through the wasmer napi extension.

Verified: process.dlopen throws catchably on both the QuickJS and V8
native binaries; bufferutil resolves to its JS fallback; js-hedgedoc
passes Node/native/WASIX and js-totaljs-cms native unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… artifacts

Three harness extensions driven by js-rssmonster (second DB-backed app):

- MySQL provider (kind: "mysql") via mysql-memory-server: uses a matching
  system mysqld when available, otherwise downloads official binaries once
  and caches them. The harness user is created with a password through the
  init SQL since the package's own user is passwordless and localhost-only.

- database.setup: routes.json can list shell commands (sequelize
  migrations/seeds) that run after the database is up and before the app
  server starts. They always run on the host toolchain: package .bin
  launchers prefer the sibling node_modules/.bin/node over PATH — on Edge
  stages that is the injected Edge runner — so the harness points that shim
  at host Node for the duration and restores the stage runner afterwards.

- Never delete git-tracked GENERATED_FRAMEWORK_PATHS entries: vendored apps
  commit prebuilt final artifacts (js-hedgedoc's public/build, js-rssmonster's
  dist), and the node stage used to remove them from the working tree —
  which is how js-hedgedoc's assets silently went missing from its original
  commit (restored in the examples submodule alongside js-rssmonster).

Bump wasmer-examples: js-rssmonster (RSSMonster + MySQL, green on
Node/native/WASIX) and the js-hedgedoc asset restore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Native and WASIX must expose the same functionality, and process.platform
differing ('linux' vs 'wasi') made packages that switch on it behave
differently per target — playwright-core's registry throws at require time
on unknown platforms, which broke Uptime Kuma's boot only on WASIX. WASIX
emulates Linux syscall semantics, so report 'linux'.

Consequence: node tests guarded by common.isLinux now run on the WASIX
suite; skip test-pipe-abstract-socket-http (abstract sockets are a Linux
kernel feature WASIX does not implement). Full wasix quickjs suite green
locally: 1671 passed, 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Readiness used to accept any HTTP response, so apps that expose a
temporary server during boot-time migrations (Uptime Kuma's migration
page 404s API routes) were validated too early. The readiness probe now
polls the first route until it answers with one of its expected statuses,
and a top-level routes.json serverReadyTimeoutMs can extend the window
for slow-migrating apps. Document the readiness rule, the database block,
and serverReadyTimeoutMs in plans/framework-integration-tests.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Green on Node, QuickJS native, and QuickJS WASIX.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The MySQL provider now creates its user with mysql_native_password and pins
the server range to 8.0.x: apps on the legacy 'mysql' 2.x driver (Firekylin's
think-model-mysql -> think-mysql -> mysql chain) cannot authenticate with
MySQL 8's default caching_sha2_password, and 8.4+ disables the native
password plugin by default. mysql2-based apps (RSSMonster, Uptime Kuma) are
unaffected — all four DB apps re-validated on native and WASIX.

Bump wasmer-examples: js-firekylin (Firekylin 1.7.3 + MySQL), green on Node
and QuickJS native. Skipped on the WASIX framework target: ThinkJS always
serves through cluster.fork(), and the cluster IPC channel is not functional
under WASIX (worker process.send fails with EPIPE — extra fds are not passed
through wasmer's process spawn; the worker can never receive its listen
handle, so the master respawns it forever). Tracked as a WASIX runtime
capability gap in the ECO-355 plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The IPC read path was stubbed to ENOSYS under __wasi__, killing every
child_process.fork / cluster.fork message channel on first readiness
(stream error -> fd closed -> sends fail EPIPE). With the fix, fork IPC
message channels work end-to-end under WASIX: worker online handshake,
process.send in both directions, and cluster's listen negotiation all
function. Cluster serving still needs connection handle-passing (or a
reuseport-style strategy) and js-firekylin stays skipped on WASIX.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The cluster IPC message channel works after the libuv-wasix fix; the
remaining gap is connection handle-passing for cluster serving.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
uv__sock_reuseport failed UV_ENOTSUP under __wasi__ before the runtime was
consulted, even though the whole path below works (wasix-libc maps
SO_REUSEPORT to sock_set_opt_flag; wasmer applies it to the host socket
before bind). Verified under WASIX: same-port listeners in one process and
across forked processes, EADDRINUSE still enforced without the flag,
40 connections balanced 18/22 across two worker processes, and Node's
test-dgram-reuseport.js passes.

This provides the primitive for the reuseport-based cluster scheduling
strategy that would let cluster-served apps (js-firekylin) run on WASIX.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WASIX cannot pass listen handles between processes (no SCM_RIGHTS), which
made both of Node cluster's scheduling strategies unusable there: round
robin passes every accepted connection to a worker, and the shared-handle
mode passes the listen handle itself. SO_REUSEPORT, however, works end to
end under WASIX (wasix-libc -> sock_set_opt_flag -> wasmer applies it to
the host socket), with genuine kernel-level connection balancing across
forked guest processes.

Add a third scheduling handle: under WASIX, queryServer for TCP listens
answers with a ReusePortHandle that binds nothing in the primary and
replies { reusePort: true }; the worker then creates its own listen handle
with UV_TCP_REUSEPORT. All cluster bookkeeping (listening events, worker
registry, close tracking) works unchanged. UDP, fd, and pipe listens keep
their existing paths, as does everything on native targets.

Because process.platform reports 'linux' under WASIX, the branch uses a
new isWasix constant on the process_methods binding.

Verified under WASIX: two cluster workers serve HTTP on one port with
requests balanced 6/6, cluster 'listening' events fire in the primary, and
js-firekylin (ThinkJS, cluster-served) passes the framework test — so it
is removed from the WASIX skip list. Regressions green: js-firekylin
native (round robin unchanged), js-hedgedoc/js-rssmonster WASIX,
js-svelte native.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Project policy keeps the Node lib/ tree byte-identical to upstream, so the
reuseport scheduling strategy moves from lib/internal/cluster into
src/edge_cluster_wasix.cc. lib/ is restored to its pre-change state.

The strategy is now installed from EdgeRuntime before the main builtin
executes: in WASIX cluster workers (NODE_UNIQUE_ID still present at that
point), an embedded script replaces the worker-side cluster._getServer —
an exported, replaceable property — so TCP port listens bind their own
UV_TCP_REUSEPORT handle instead of asking the primary for one, and report
the 'listening' act for the primary's bookkeeping. No primary-side changes
are needed at all: the primary never learns a handle key, so its registry
and cleanup paths are untouched. UDP, fd, and pipe listens keep the
upstream path, as does everything on native targets (compile-time gate).

Known limitation inherited from the child-only shape: _getServerData/
_setServerData is not round-tripped through the primary (e.g. TLS session
ticket keys stay per-worker).

The isWasix constant on process_methods is removed again — the native
implementation is compile-time gated and nothing else consumes it.

Verified under WASIX: cluster workers balance raw TCP connections 13/11
(the earlier 12/0 observation was HTTP agent keep-alive correctly reusing
one connection), js-firekylin passes the framework test, and regressions
are green (firekylin native round-robin, uptime-kuma WASIX, svelte
native).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With fork IPC (libuv-wasix plain read) and the cluster reuseport strategy
in place, 11 of the 17 tests in WASIX_SKIP_CLUSTER_FORK_TESTS pass and are
removed from the skip list, including TCP cluster serving
(test-http-server-drop-connections-in-cluster, test-tls-ticket-cluster)
and the child_process fork/messaging tests
(test-diagnostics-channel-process, the domain and http fork harnesses).

Two entries were misfiled and move to their real groups:
test-http-client-with-create-connection fails on a unix-socket listen
(unix-socket group) and test-crypto-secure-heap fails on OpenSSL secure
heap (crypto group). What remains cluster-specific is UDP cluster listens,
which still go through shared-handle passing, plus the known_issues
negative test whose error-swallowing path (exit 0 on non-success worker
messages) engages now that fork IPC delivers messages — the upstream known
issue is not observable under WASIX.

Full wasix quickjs suite locally: 1681 passed, 0 failed
(baseline before: 1671 with the old skip list).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
UDP cluster listens went through shared-handle passing and were the last
cluster capability broken under WASIX. The worker-side override now also
covers udp4/udp6 port listens via dgram._createSocketHandle with
UV_UDP_REUSEPORT; the kernel distributes datagrams between the workers by
source hash (flows pin to a worker) instead of shared-socket delivery.

Two contract details surfaced by the upstream tests:
- _getServer callbacks must stay asynchronous (an IPC round trip
  upstream); the override now defers cb via process.nextTick, which
  test-dgram-cluster-close-during-bind's close-during-bind window depends
  on.
- dgram passes the raw bind() arguments through: options.port can be
  null, undefined, or the bind callback function (socket.bind(cb)); all
  of those mean an ephemeral-port listen per the
  bind([port][, address][, callback]) signature.

WASIX_SKIP_CLUSTER_FORK_TESTS is now empty: every cluster/fork test in
the wasix lanes passes, including the known_issues negative test (back to
failing-as-expected: with reuseport the port-0 rebind scenario behaves
deterministically again). Verified: cluster UDP echo distributes 11/5
across two workers; full wasix quickjs suite 1686 passed / 0 failed;
js-firekylin green on WASIX and native.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The test spawns an external cksum binary in the guest; the first exec
cold-downloads and LLVM-compiles wasmer/coreutils, which exceeds the
per-test timeout on CI runners with an empty wasmer cache. It passes
locally with a warm cache, so this is an environment cost, not a
cluster/fork or subprocess capability gap. Filed under the
subprocess-shell group. (CI wasix suite was otherwise green:
1685 passed / 1 failed.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same class as test-http-chunk-problem: the test execs ab through a shell,
and on CI runners with an empty wasmer cache the first external exec
cold-downloads and compiles wasmer/bash + wasmer/coreutils, exceeding the
per-test timeout. Locally the test self-skips gracefully ('problem
spawning ab') because the warm-cached shell starts fast enough. These two
are the only external-binary tests among the recent cluster/fork unskips;
the remaining nine are node-child-only and passed CI twice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ipping

test-http-chunk-problem (spawns cat) and test-http-full-response (execs ab
through a shell) rely on guest binaries that ARE available (wasmer/bash,
wasmer/coreutils); their CI timeouts came from the first exec cold-
downloading and compiling those packages, not from a capability gap. Give
them the scaled timeout (WASIX_SLOW_TESTS, 12x) and keep the coverage
rather than skipping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…x + arch)

Regression exposed by 'Report process.platform as linux under WASIX': the
app's start command is `serve` (Vercel static server), which statically
imports clipboardy -> arch@2.2.0. arch's getconf-execSync path is gated on
process.platform === 'linux' and only reached because process.arch is
'unknown' under WASIX (the x64/ia32 fast-returns don't fire). WASIX cannot
spawn /bin/sh (EACCES), so `serve` crashes at import. Under the old 'wasi'
platform arch returned 'x86' without shelling out, so this passed.

Skip on the WASIX edge stage only (matches js-astro-ssr-standalone); Node
baseline and QuickJS native keep full coverage. process.platform='linux'
stays — it is load-bearing for Uptime Kuma (playwright). Proper long-term
fix is to serve static-site apps via the harness's internal static server on
edge stages so `serve` is never invoked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ace (ECO-394)

- externref registry: JS values rooted in a state-held array, id in foreign host info
- real Table.get/set/grow/new incl. funcref wrap via wasm_ref_as_func
- ToNumber coercion for i32/f32/f64 (glue predicates return booleans)
- multi-value export results return a JS array
- Memory.buffer cached + detached on growth at JS<->wasm boundaries

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… legacy behavior)

Umami's isValidTimezone calls Intl.DateTimeFormat(undefined, {timeZone}) as a
plain function; the stub threw, rejecting every timezone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sm query compiler)

The ECO-355/ECO-394 acceptance app: every Prisma query runs through the
wasm-bindgen query compiler and its __wbindgen_externrefs table. Routes
cover login (bcrypt + Prisma) and the full /api/send analytics ingest.
Green locally on native and WASIX with a dev wasmer carrying the WARP-70
Part A C API surface and the wasm_c_api_v0 bridge fixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/edge_cluster_wasix.cc
Comment on lines +1 to +6
#include "edge_cluster_wasix.h"

#if defined(__wasi__)

#include <cstdlib>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove all changes to cluster from this PR, so it's focused solely on the Wasm externref fixes

Comment thread src/edge_cluster_wasix.h
@@ -0,0 +1,22 @@
#ifndef EDGE_CLUSTER_WASIX_H_

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove all changes to cluster from this PR, so it's focused solely on the Wasm externref fixes

Comment thread src/edge_intl.cc
return true;
}

// ECMA-402 legacy behavior: Intl.DateTimeFormat and Intl.NumberFormat may be

@syrusakbary syrusakbary Jul 14, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove all changes to Intl from this PR, so it's focused solely on the Wasm externref fixes

Comment thread src/edge_process.cc
std::string g_process_title = "node";
uint32_t g_process_debug_port = 9229;
std::mutex g_process_umask_mutex;
std::mutex g_process_dlopen_mutex;

@syrusakbary syrusakbary Jul 14, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a no go.

Remove all changes to the native dlopen from this PR, so it's focused solely on the Wasm externref fixes

Comment thread src/edge_runtime.cc
// Under WASIX, cluster workers get the reuseport scheduling strategy
// installed before the main builtin runs pre-execution (which consumes
// NODE_UNIQUE_ID). No-op elsewhere.
EdgeMaybeInstallWasixClusterReusePort(env);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove all changes to cluster from this PR, so it's focused solely on the Wasm externref fixes

@syrusakbary syrusakbary left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR should focus ONLY on externref support for now, so it's easier to review.

Remove all other changes unrelated do that

@Arshia001

Copy link
Copy Markdown
Member Author

Superseded — split into #116 (externref wiring), #117 (Intl legacy call behavior), and #118 (framework tests incl. js-umami). #118's tree is byte-identical to this branch's tip.

@Arshia001 Arshia001 closed this Jul 15, 2026
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