ECO-355: DB-backed framework tests — embedded Postgres provisioning + js-hedgedoc; disable native addon loading#111
ECO-355: DB-backed framework tests — embedded Postgres provisioning + js-hedgedoc; disable native addon loading#111Arshia001 wants to merge 20 commits into
Conversation
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>
|
Added the second DB-backed app, js-rssmonster (MySQL), with three harness extensions:
js-rssmonster is green on Node/QuickJS-native/QuickJS-WASIX locally. 🤖 Generated with Claude Code |
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>
|
Added the third DB-backed app, js-uptime-kuma (MySQL, Kuma's mariadb mode), which surfaced two more parity/harness fixes:
js-uptime-kuma is green on Node/QuickJS-native/QuickJS-WASIX locally (companion commit on wasmerio/examples#2). 🤖 Generated with Claude Code |
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>
|
Added the fourth and final planned DB app, js-firekylin (MySQL, ThinkJS 3), with two findings:
That completes ECO-355 Phase DB: HedgeDoc (Postgres), RSSMonster (MySQL), Uptime Kuma (MySQL/mariadb-mode), Firekylin (MySQL) — all green on Node + QuickJS native, first three green on WASIX. 🤖 Generated with Claude Code |
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>
|
Root-caused the WASIX cluster IPC failure with Arshia — it was not fd passing at all. Full chain:
After the libuv fix: fork/ Regressions: js-hedgedoc/js-uptime-kuma WASIX green with the new libuv; wasix node suite shows only load-induced timing flakes locally (machine was at load 20 from an unrelated workload — failing sets don't reproduce individually and differ run to run). CI will confirm on quiet runners. 🤖 Generated with Claude Code |
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>
|
SO_REUSEPORT works properly under WASIX — investigated end to end with Arshia:
Verified under WASIX on wasmer 7.2.0-rc.1:
Combined with the fork-IPC fix, all the primitives for a reuseport-based cluster scheduling strategy on WASIX are now in place (workers bind the shared port themselves instead of receiving handles via SCM_RIGHTS) — that scheduler change in edgejs's cluster internals is the remaining piece to unskip js-firekylin on WASIX. 🤖 Generated with Claude Code |
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>
|
js-firekylin is now green on the full matrix — WASIX included. The magic, in order:
Verified: two cluster workers serving HTTP on one port under WASIX with requests balanced 6/6, and js-firekylin (ThinkJS, cluster-served) passing the framework test — removed from the WASIX skip list. Regressions green: firekylin native (round-robin untouched), hedgedoc + rssmonster WASIX, svelte native. All four ECO-355 DB apps now pass Node + QuickJS native + QuickJS WASIX. 🤖 Generated with Claude Code |
| // EdgeJS deliberately does not load native addons: the native and WASIX | ||
| // targets must expose the same functionality, and WASIX has no dynamic | ||
| // linking. Fail before any dlopen() so no addon static constructor runs | ||
| // (a constructor referencing an unresolved symbol such as | ||
| // napi_module_register would abort the whole process uncatchably) and | ||
| // optional accelerators like bufferutil can fall back to their pure-JS | ||
| // implementations, exactly as they do under WASIX. |
There was a problem hiding this comment.
This is wrong. This changes to disable dynamic linking in Edge.js are wrong and can't be merged. We can disable at most them on WASIX, but not natively
There was a problem hiding this comment.
The idea is to keep native and WASIX behaving exactly the same. Since we don't enable DL for WASIX yet, this keeps them in sync. I can undo the change (but IMO it makes sense).
There was a problem hiding this comment.
Hard disagree on that, we should keep compatibility with native modules for now on the native Edge.js (but trigger an error if compiled target is WASIX for now, saying that we will support them soon)
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>
|
Relocated the cluster reuseport strategy out of Implementation: EdgeRuntime installs an embedded script in WASIX cluster workers (before the main builtin runs, while Re-verified: cluster workers balance raw TCP 13/11 under WASIX (an earlier 12/0 read was HTTP agent keep-alive correctly pinning one reused connection), js-firekylin green on WASIX + native, uptime-kuma WASIX and svelte native regressions green. 🤖 Generated with Claude Code |
|
@syrusakbary in the spirit of keeping node's JS sources untouched, the approach we came up with involves injecting JS code from the native side. I'm quite certain this can't be done with zero JS code. Is this good enough? |
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>
|
Ran the previously-skipped cluster/fork tests locally against the new stack — 11 of 17 pass and are unskipped, including TCP cluster serving ( Of the 6 that still fail: 2 were misfiled (moved to the unix-socket and crypto groups — their failures are unrelated to cluster/fork), and 4 are UDP cluster listens, which still go through shared-handle passing (the reuseport strategy covers TCP only). One fun find: the Full wasix quickjs suite locally: 1681 passed / 0 failed (up from the 1671 baseline). 🤖 Generated with Claude Code |
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>
|
Extended the reuseport strategy to UDP — the cluster/fork skip list is now empty. The worker-side override now handles Every cluster/fork test in the wasix lanes passes — 🤖 Generated with Claude Code |
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>
| // TCP and UDP port listens bypass the queryServer round trip entirely: the | ||
| // worker binds its own SO_REUSEPORT socket and reports the 'listening' act | ||
| // for the primary's bookkeeping (worker state, cluster 'listening' event). | ||
| // fd and pipe listens keep the upstream path. Known deviations vs the | ||
| // upstream flow: obj._getServerData/_setServerData is not round-tripped | ||
| // through the primary (e.g. TLS session ticket keys are per-worker), and | ||
| // UDP datagrams are distributed by the kernel's reuseport source hash | ||
| // (flows pin to a worker) instead of shared-socket delivery. | ||
| constexpr const char kInstallScript[] = R"JS( | ||
| (function installWasixClusterReusePort(cluster, net, dgram, sendHelper, | ||
| UV_TCP_REUSEPORT, UV_UDP_REUSEPORT) { | ||
| 'use strict'; | ||
|
|
||
| if (!cluster.isWorker || typeof cluster._getServer !== 'function') | ||
| return; | ||
|
|
||
| const originalGetServer = cluster._getServer; | ||
| const ownHandles = new Set(); | ||
| let disconnectHookInstalled = false; | ||
|
|
||
| cluster._getServer = function(obj, options, cb) { | ||
| const isTcp = options.addressType === 4 || options.addressType === 6; | ||
| const isUdp = options.addressType === 'udp4' || | ||
| options.addressType === 'udp6'; | ||
| // dgram passes the raw bind() arguments through: port can be null, |
There was a problem hiding this comment.
This is even a bigger hack and can't be merged. What native call is behaving differently and why we need to do this? That's the question that will solve things properly and not force any hacks
There was a problem hiding this comment.
because on native, node.js uses unix sockets and passes FDs through them using ancillary data, none of which are supported on WASIX, so we need a different implementation if we hope to get cluster working.
There was a problem hiding this comment.
Posted on Linear as well. What we need to do is to raise an issue on usage of Cluster.fork so it fails in WASIX for now.
We should still allow it to be imported and used, except of forking:
const cluster = require('node:cluster');
const http = require('node:http');
const numCPUs = require('node:os').availableParallelism();
if (cluster.isPrimary) {
console.log(`Primary ${process.pid} is running`);
// Fork workers.
for (let i = 0; i < numCPUs; i++) {
cluster.fork(); // <-------------------------------- THIS SHOULD FAIL
}
cluster.on('exit', (worker, code, signal) => {
console.log(`worker ${worker.process.pid} died`);
});
} else {
// Workers can share any TCP connection
// In this case it is an HTTP server
http.createServer((req, res) => {
res.writeHead(200);
res.end('hello world\n');
}).listen(8000);
console.log(`Worker ${process.pid} started`);
}
Kicks off the ECO-355 DB phase (SQLite was resolved as no-go; all former Tier B apps except Actual Budget can run against MySQL/Postgres instead).
Harness: ephemeral embedded databases
Backend apps declare their DB in a top-level
databaseblock inroutes.json; the harness starts an ephemeral PostgreSQL before the app server (realembedded-postgresbinaries as plain child processes — no Docker, since framework tests also run on macOS CI runners and locally via make), injects the declared env vars ({dbUrl}/{dbHost}/{dbPort}/…, plus{port}expanded at spawn time), and tears everything down per stage. Binaries install on demand into.framework-test/db-toolsusing the shared pnpm store. The WASIX runner's env allowlist is extended viaFRAMEWORK_TEST_EXTRA_ENV; the guest reaches the host DB over loopback with--net.Runtime: native addon loading disabled (WASIX parity)
Native and WASIX must expose the same functionality, and WASIX has no dynamic linking —
process.dlopennow throws a catchableERR_DLOPEN_FAILEDbefore ever callingdlopen(). Failing early matters: legacyNAPI_MODULE-style prebuilds (bufferutil, utf-8-validate) call the unexportednapi_module_registerfrom a static constructor during dlopen, which previously killed the process with an uncatchable dynamic-linker error on native. Now optional accelerators fall back to pure JS exactly as under WASIX, and the unreachable dlopen machinery is removed (−123 lines). N-API is unaffected: native napi tests are statically linked; safe mode / WASIX use the wasmer napi extension.First app: js-hedgedoc
HedgeDoc 1.11.0 + PostgreSQL via wasmerio/examples#2 (submodule bump — merge that PR first). Auto-migrations on boot, unauthenticated assert routes. Green on Node → QuickJS native → QuickJS WASIX; js-totaljs-cms re-validated on the modified harness.
Next up per the triage in
plans/eco-355-selfhosted-app-framework-tests.md: RSSMonster (needs a MySQL provider in the DB module), Uptime Kuma, Firekylin. Blocked regardless of DB: Umami (Prisma 7 WASM query compiler), Ghost (sharp), Directus (sharp + isolated-vm + argon2).🤖 Generated with Claude Code