Repository navigation
build(deps): migrate to @wasmer/sdk 0.19.0 - #97
TakalaWang wants to merge 7 commits into
Conversation
Python execution needs the CPython interpreter module so the runtime core can meter and run it. `@wasmer/sdk` 0.10.0 exposed it through `Command.binary()`, which the SDK removed in 0.11.0 and has no equivalent in 0.19.0. Add a minimal WEBC v3 reader that returns one named atom from the container's atoms section, and pin the SHA-256 of the Python package's `python` atom so callers can verify the extracted module. The bytes are identical to what `Command.binary()` returned for the pinned package. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 0.19.0 @wasmer/sdk 0.10.0 is unmaintained and has a teardown race: `Command.run` sends the exit code and closes its thread pool before the WASI runner that owns the stdout/stderr writers is dropped, so `Instance.wait()` can wait forever for EOF. It stalled about 4% of concurrent Python runtime preparations and also TypeScript builds under load. 0.11.0 rewrote the SDK around clients, packages and sandboxes; 0.19.0 does not have the race. Server and browser now create a `Wasmer` client, load each pinned WEBC or raw Wasm package once, and run Clang, wasm-ld, rustc and TypeScript in a fresh sandbox per build. Processes run to exit through piped streams: rustc traps after printing errors, which rejects `wait()` but keeps the captured diagnostics. This replaces the output-ready polling that 0.10.0 required, so the mounted-output stability observer is gone. Sandboxes keep project files in `/workspace`; Clang adds macro and debug prefix maps and a VFS overlay for the admitted libc++ PCH, and Rust remaps the new root, so all 78 compared C, C++, Rust and TypeScript artifacts are byte-identical to 0.10.0 builds. Project PCH cache entries are keyed by the root because a PCH records absolute input paths. Python reads its interpreter module from the WEBC atom and exports runtime files with `sandbox.command().run()`; the browser runner no longer retains SDK package handles. WASM-OJ never calls the SDK's `kill()` or `close()`. A killed CPU-bound guest keeps running on its worker and `close()` then waits forever, and upstream reports that termination can hang the process inside the shared allocator. Timeouts abandon the process, and cancellation still discards the whole Worker generation or server child. The browser no longer bundles the SDK. A Vite plugin ships its browser runtime files unbundled under `assets/wasmer-sdk-<digest>/`, and Workers import them and point the SDK's thread Workers at a same-origin blob bootstrap. The plugin omits the WISP networking module, the SDK's only path to its AGPL-3.0 `@mercuryworkshop/wisp-js` dependency, and replaces the glue's `new Function` host-function trampoline with the equivalent closure so strict CSP still works. `@wasm-oj/browser` therefore no longer depends on `@wasmer/sdk`, and the custom thread Worker and its policy are removed. The Sites build drops the SDK runtime duplicates from server output, and the runtime identity records the new SDK. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SDK moved to wasmerio/wasmer-sdk and, from 0.11.0, ships under Wasmer's Modified MIT License, which adds an attribution condition for commercial products above 1 million monthly active users or 1 million US dollars of monthly revenue. Replace the MIT text with the new license and name it accordingly. `@wasm-oj/browser` now distributes the SDK's wasm-bindgen snippets, which bundle acorn 8.18.0, so its MIT notice joins the SDK license material. Regenerate the cargo-about inventory for the `wasmer-sdk-js` crate at the 0.19.0 release commit (316 packages for wasm32-unknown-unknown) with the repository toolchain, and update the notices, component manifest and the SDK verifier pins. The notices state the one source change WASM-OJ makes and that the AGPL-3.0 `@mercuryworkshop/wisp-js` dependency is not shipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… processes The SDK upgrade keeps forge's rule that cancellation and timeouts tear down the whole server child or Worker instead of killing an SDK guest. Add a gated integration test that repeatedly cancels C++ and Rust builds, Python runtime preparation on a fresh cache, and CPU-bound and output-heavy native runs at random points, then requires each cancellation to settle within a minute and the same engine to compile and run again. Run it with WASM_OJ_RUN_CANCELLATION_STRESS=1; WASM_OJ_CANCELLATION_ROUNDS sets the number of rounds (default 4). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Record how compilers and Python runtime preparation use the SDK now: one sandbox per build rooted at `/workspace`, processes that run to exit, and no SDK kill or client shutdown inside a live Worker or child. Document the unbundled `assets/wasmer-sdk-<digest>/` runtime directory that browser hosts copying `@wasm-oj/browser` assets must keep, and add the Unreleased changelog entry, including the SDK's new license. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@wasmer/sdk 0.19 hashes every WEBC and atom with SHA-256 in Wasm when a package loads. With one child per build, every server build paid that again: about +2.2 s per Rust build and +0.4 to +0.6 s per C++ or TypeScript build compared with 0.10. The isolated build child and its rustc stage now serve sequential requests over stdin and answer on fd 3, keeping their Wasmer client and loaded packages. Each build still runs in a fresh SDK sandbox, and the Clang object graph is cleared after every build. A child serves one build at a time. Cancellation, timeout, failure or an unreadable response discards it, and the next build starts a new one. The rustc child is replaced after the browser's Rust stage budget (two builds), because rustc leaves a busy SDK worker behind after each run. Idle children exit after 30 s, are unreferenced so they never keep the host alive, and exit on stdin EOF, so they also exit when their parent dies instead of lingering as orphans. Python runtime preparation stays one-shot: it runs once per cache directory, and the command-binary path reads the WEBC atom without loading the package. Go and Java stages do not use the SDK and stay one-shot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The architecture and library contract said every server build uses a fresh or one-shot child. Describe the reused child, its fresh sandbox per build, when it is discarded, and the Rust stage budget, and record the change in the CHANGELOG. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JacobLinCool
left a comment
There was a problem hiding this comment.
Changes needed before merging. This review covers correctness, install and ops only. The Wasmer license terms are being decided separately.
What I verified works:
- Server builds match main. 11 successful builds produce byte-identical artifacts: C/C++ including PCH and a locked C++ dependency, Rust including a crate dependency, and TypeScript. 6 failing builds give the same parsed diagnostics as main, except the link-error message noted inline.
- Browser Rust/C/C++ compile and link errors return full diagnostics in Chromium.
- The Python export stall is gone: 0 stalls in 360 stress runs of the real stage script at concurrency 6, against 1 in 120 on main.
Please address (details are inline, except the README item)
npm installfails on linux-arm64.@wasmer/sdk@0.19.0depends onwisp-js, which depends onbufferutil, a native install script with no arm64 Linux prebuild (packages/server/package.json:68).- A warm compiler child holds 0.6–1.8 GB, not 250–360 MB. Mixed Rust/C++ builds peak at about 1.8× main. The judge creates one engine per submission, so it never reuses these children, yet keeps them alive into judging (
src/server/server-compiler.ts:87). - SDK host failures become terminal
compile-errorverdicts instead of retried infrastructure errors (src/compiler/sandbox-command.ts:49). - The README copy command skips the new SDK directory (
README.md:129, not in this diff).cp node_modules/@wasm-oj/browser/dist/assets/* dist/assets/now skipswasmer-sdk-<digest>/(macOS prints "is a directory (not copied)") and exits 1.- A host that ignores the error deploys a page whose first C/C++/JS/TS/Rust compile or Python run 404s on
wasmer-sdk-<digest>/dist/index.js. - Fix:
- Change the command to
cp -R node_modules/@wasm-oj/browser/dist/assets/. dist/assets/. - In
docs/integration-guide.md:106, drop "instead of bundling them". README:123–124 says bundlers can't see these assets, so every host has to copy them. - Mark the layout change as "host action required" in the CHANGELOG.
- Change the command to
Smaller
- fd-3 responses are decoded one chunk at a time, which can turn non-ASCII rustc output into U+FFFD (
src/server/reusable-stage.ts:127). - The dev
servebranch loads the wasm-bindgen module twice (scripts/wasmer-sdk-runtime.mjs:94). - The CSP guard checks the call site but not the trampoline body (
scripts/wasmer-sdk-runtime.mjs:64). - Two CHANGELOG lines claim too much:
- Link-error messages do change (
CHANGELOG.md:26). - Stale cached artifacts fail once before they are rebuilt (
CHANGELOG.md:28).
- Link-error messages do change (
Rebase notes (#94, #96 and #99 are now on main)
- Expected conflicts (
git merge-treeagainst current main):CHANGELOG.md,docs/architecture.md,src/compiler/wasmer-engine.ts,src/core/runtime-identity.ts,src/runtime/runner.worker.tsandsrc/server/server-runner-stage.mjs. server-runner-stage.mjs: take your version of the whole file (--theirsduringgit rebase). Do not resolve it hunk by hunk: #96'stry { … } finally { instance.free(); }around the export can survive outside the conflict markers, which is a syntax error. Typecheck skips.mjs, butpnpm run lintreports it.- Delete what becomes dead:
- #96 added
src/core/process-output.ts(readProcessMessage,completeJsonObject) andreadRuntimeFilesExport. They now cover both the Python export and the TypeScript compile path (transpileScriptProject). Once your sandboxrun()path replaces both, delete them and their tests. - #96's CHANGELOG line about finishing without waiting for
Instance.wait(). - Your Python export (
run()) andrunSandboxCommandboth read to EOF, so dropping #96's reader relies on 0.19 not having the race. The evidence: 0 stalls in 360 Python export stress runs, and your 140/140 conformance run for the compile paths.
- #96 added
runner.worker.ts:- Keep #99's nested-Worker code and
compileRuntimeCorenext to yourensureSdkClient. - Take your body for the runtime-file export.
- Drop the imports that become unused (
package-handle-cache,wasmer-thread.worker,readRuntimeFilesExport, …).
- Keep #99's nested-Worker code and
runtime-identity.ts:- Keep main's runtime-core pins (#99 rebuilt runtime-core) and your SDK pins, then recompute
WASM_OJ_RUNTIME_IDENTITY_SHA256overruntimeIdentityBytes(). Neither side's digest is correct after the merge, andruntime-identity.test.tschecks the value. - No
runtime:buildis needed, because #97 touches no crates, vendor or generated runtime files.
- Keep main's runtime-core pins (#99 rebuilt runtime-core) and your SDK pins, then recompute
docs/architecture.md: keep both the reusable-child text and #99's nested-Worker paragraph.- Release: the new identity breaks the
calibration.profilesof every published judge package, so Official Submit returns 409cost-profile-identityuntil they are republished. #97 changes no metering, so ship it in the same release as #95/#99. Judge data is then recalibrated once, not twice. - After rebasing, run:
pnpm run ci:verify. It includespython-compiler.test.tsandserver-compiler.test.ts, and its lint step catches the.mjsproblem above.scripts/verify-browser-csp.mjsby hand, since CI has no browser coverage.
| "@wasm-oj/contracts": "workspace:0.2.3", | ||
| "@wasm-oj/core": "workspace:0.2.3", | ||
| "@wasmer/sdk": "0.10.0", | ||
| "@wasmer/sdk": "0.19.0", |
There was a problem hiding this comment.
npm install of @wasm-oj/server, @wasm-oj/cli or @wasm-oj/sdk fails on linux-arm64 hosts without a C toolchain.
- Dependency chain:
@wasmer/sdk@0.19.0hard-depends on@mercuryworkshop/wisp-js@^0.4.1, which hard-depends onbufferutil@^4.0.9. bufferutil's install script isnode-gyp-build.bufferutil@4.1.0ships prebuilds only for darwin-arm64/x64, linux-x64 (glibc) and win32-ia32/x64.- On any other platform it falls back to
node-gyp rebuild, which needs python3, make and g++. bufferutil is not optional, so when that build fails npm aborts the whole install.
- Repro in
node:24-slimarm64:@wasm-oj/cli@0.2.3installs andwoj --versionprintswoj 0.2.3.- The same package with
"overrides": { "@wasmer/sdk": "0.19.0" }exits 1 withgyp ERR! … Could not find any Python installationand leaves nonode_modules. node:24-alpinearm64 fails the same way. Alpine amd64 installs fine.- win-arm64 has no prebuild either; not tested.
- Regression:
- On main the server's SDK dependency tree was only
web-worker, with no install scripts. - The CLI's other native dependency,
@napi-rs/keyring, ships linux-arm64 (gnu and musl) and win32-arm64 binaries. - So
wojinstalls on arm64 Linux today, for example in arm64 Docker on Apple Silicon.
- On main the server's SDK dependency tree was only
- Nothing in this chain runs on Node. wisp-js is only imported from
dist/wisp-network.jswhennetwork.mode === "wisp". On Node that path throwsCAPABILITY_UNAVAILABLEbefore the import, and forge never passesnetwork. - Not affected:
- CI, the judge image and Sites. They install with pnpm 10, which skips bufferutil's script because it isn't in
onlyBuiltDependencies. npm install --ignore-scripts.- Nothing breaks until the next npm release.
- CI, the judge image and Sites. They install with pnpm 10, which skips bufferutil's script because it isn't in
Fix:
- Ship the SDK's Node runtime files inside
@wasm-oj/server's dist, withoutdist/wisp-network.js, and drop@wasmer/sdkfrom the server's runtime dependencies.scripts/wasmer-sdk-runtime.mjsalready walks this graph for the browser.- For Node the roots are
dist/node.js,dist/node-worker.jsandpkg/wasmer_sdk_js_bg.wasm, and the walker has to allownode:specifiers.
- Point the three
@wasmer/sdk/nodeimports at the shipped copy:server-compiler.ts,rustc-stage.mjsandserver-runner-stage.mjs. - If that is too much for this PR, at minimum document
pnpm addornpm install --ignore-scriptsfor arm64 in the README and CHANGELOG before the npm release.
| const SERVER_BUILD_RESPONSE_LIMIT_BYTES = 256 * 1024 * 1024; | ||
| const SERVER_BUILD_REQUEST_LIMIT_BYTES = 768 * 1024 * 1024; | ||
| const SERVER_COMPILER_STAGE_RESPONSE_LIMIT_BYTES = 256 * 1024 * 1024; | ||
| const SERVER_STAGE_IDLE_TIMEOUT_MS = 30_000; |
There was a problem hiding this comment.
A warm child that just ran Clang or rustc holds about 0.6–1.8 GB, not the 250–360 MB in the PR body. The judge container gets no reuse from it.
- Measured:
- macOS arm64, Node 24:
- The idle rustc-stage child holds about 1.5–1.8 GB after one hello-world Rust build.
- The build-stage child holds 0.9–1.7 GB after C++ builds, until 30 s pass with no further build.
- Only the build child after a Rust-only build is close to the PR's figure (about 150 MB).
- Linux arm64 Docker, 1 vCPU: the rustc child keeps 1.3–1.4 GB and the C++ build child about 600 MB.
- No leak: over a 100-build soak, RSS stayed between 1.4 and 1.8 GB with no upward trend, and idle CPU is about 0.
- macOS arm64, Node 24:
- The two children stack. The rustc grandchild stays warm for 30 s inside the build child:
- Rust then C++ peaks at about 3.1 GB.
- C++ then Rust peaks at about 3.6 GB.
- On main the children exit after every build, so the peak is one build's worth, 1.5–2.0 GB. A host sized for main's peak could OOM.
- The judge path pays the memory cost without the benefit.
container/server.mjsbuilds one engine per submission and disposes it after judging, so ServerCompiler compiles exactly once.- Every Official Submit runs cold. On Linux with 1 vCPU, Rust goes from 5.9–6.2 s to 8.4–8.8 s and C++ from 2.8 s to 3.5 s. That is within budget, and your one-shot column already shows the slowdown, but reuse never offsets it on this path.
- The children then stay resident for up to 30 s of judging. They sit next to programs allowed up to 4 GiB (
MAX_MEMORY_LIMIT_BYTES), and interactive problems run two programs at once.
Fix:
- Make the idle timeout an option on
ServerCompilerOptionsandServerEngineOptions, defaulting to 30 s. It can be@internal, likeverifiedDistribution. - Have
container/server.mjspass0, so the build child retires right after the compile. Its rustc grandchild exits with it on stdin EOF. - Correct the memory figure in the PR body.
- Optional: retire the warm rustc stage when a non-Rust build starts, which removes the stacking.
| : undefined, | ||
| readAll(child.stdout), | ||
| readAll(child.stderr), | ||
| child.wait({ check: false }).then((output) => output.ok, () => false), |
There was a problem hiding this comment.
SDK host failures end up as terminal compile-error verdicts.
- What gets swallowed:
() => falsediscards every rejection, and a resolved non-ok result is handled the same way. - 0.19 doesn't always reject. In a probe with a hand-built module that has an unresolvable import:
spawn()succeeds.- The SDK logs
Failed to create WASI context. wait()resolves{ ok: false, exitCode: 45 }with empty stdout and stderr.- I haven't reproduced a real host-side trigger. The concern is how such a failure gets classified when it does happen.
- How it becomes a verdict:
- The callers return
success: falsewith "clang exited with code 1.", "rustc failed without a diagnostic." or "TypeScript … did not return every compiled output". container/server.mjs:204-206then emitscompile-error. That verdict is terminal and not retried automatically. It also counts as a contestant fault, which ICPC contests can penalize throughpenalizedVerdicts.
- The callers return
- On main: a compiler process that produced no output and no diagnostic made the build throw, at the latest after the 55 s / 180 s output deadline.
- The container returned 500
container-infrastructure-error. - The workflow retried once on a new container, and a second failure ended as
infrastructure-error. - The PR's risk list already mentions this gap.
- The container returned 500
Fix:
- Keep the exit code and the rejection reason.
- Throw when the process failed without writing anything:
!ok && stdout.byteLength === 0 && stderr.byteLength === 0.- Clang, wasm-ld and rustc report errors on stderr, including rustc's trap after diagnostics. The TypeScript wrapper answers on stdout. So real compile errors stay compile errors.
- A throw already discards the server child or the browser Worker.
- Add a test with a fake sandbox whose
wait()resolves{ ok: false }with empty streams.
|
|
||
| private receive(chunk: Buffer): void { | ||
| if (!this.pending) return; | ||
| this.buffered += chunk.toString(); |
There was a problem hiding this comment.
Calling chunk.toString() on each chunk corrupts a multi-byte UTF-8 character that spans two pipe chunks.
- Where it shows up: the rustc response is one fd-3 line. It carries
wasmBase64first (0.25–0.7 MB, so several chunks), thenstdoutandstderr.- A non-ASCII identifier in a warning can arrive as
unused variable: `名��`. compileRustre-parses diagnostics from thatstderr, so the parsed diagnostics are garbled too.- It needs a chunk boundary to fall inside non-ASCII stdout/stderr text, so it is rare at normal warning sizes. Builds don't fail, because the JSON stays valid.
- A non-ASCII identifier in a warning can arrive as
- Evidence:
- A synthetic probe sent about 64 KB of CJK warnings after a 400 KB payload. 4 of 20 responses contained U+FFFD.
- Main collected raw bytes and decoded them once.
- Quadratic cost: each chunk also re-splits and re-measures the whole buffer.
- A 16 MB line takes 1.35 s and a 32 MB line takes 14.3 s, against a 256 MB line limit.
- Real Rust responses are under 1 MB, so this only matters for very large artifacts.
Fix:
- Call
responses.setEncoding("utf8")before attaching the listener, or use aStringDecoder. - Split only when the new chunk contains
\n. - Track the buffered byte count incrementally.
| if (id !== RESOLVED_VIRTUAL_MODULE) return null; | ||
| if (command === "serve") { | ||
| const url = (relative) => JSON.stringify(`/@fs${path.join(SDK_ROOT, relative).split(path.sep).join("/")}`); | ||
| return `export const wasmerSdkEntryUrl = ${url(ENTRY)};\nexport const wasmerSdkBindingUrl = ${url(BINDING)};\n`; |
There was a problem hiding this comment.
Dev mode (serve) loads two instances of the wasm-bindgen module.
- Mismatch: Vite rewrites the SDK entry's own import to
/@fs/…/pkg/wasmer_sdk_js.js?v=<hash>. This line exports the binding URL without the?v=query. - Result:
createBrowserWasmer()runsimport(wasmerSdkBindingUrl)and gets a second instance that was never initialized.binding.setWorkerUrl()then callspassStringToWasm0(url, wasm.__wbindgen_malloc, …)whilewasmis undefined.
- Evidence: I ran a minimal Vite 8 dev server with only this plugin. It serves the entry with the
?v=import, while the virtual module exports the binding URL without it. - Scope:
- Production builds are fine, because both URLs resolve to the same emitted asset.
- Nobody can hit this while
pnpm devis broken on main, but the branch can't work as written.
Fix: fail fast in serve with a clear "dev mode unsupported" error until dev mode is fixed and tested. Alternatively, export the same specifier Vite gives the entry's import, so both resolve to one module instance.
| function cspSafeRuntimeSource(relative, source) { | ||
| if (relative !== BINDING) return source; | ||
| const text = source.toString("utf8"); | ||
| if (text.split(DYNAMIC_FUNCTION).length !== 2) { |
There was a problem hiding this comment.
Nit: this guard pins the glue call site but not the trampoline body.
- Failure case: a future SDK changes the body string inside the wasm, for example to rest args.
- The build still passes.
__wasmOjFunctionfalls through tonew Function.- Browser compiles and Python runs then fail at runtime under the strict CSP.
- Nothing would catch it: CI doesn't run
verify-browser-csp.mjs. - Covered today: the exact
0.19.0pin and the per-file digests inverify-wasmer-sdk-init.mjs.
Cheap fix: in wasmerSdkRuntimeFiles(), also assert that pkg/wasmer_sdk_js_bg.wasm contains return f(Array.prototype.slice.call(arguments, 1)). It occurs 3 times in 0.19.0.
| - Python execution reads the interpreter module directly from the pinned WEBC package and | ||
| verifies its digest. | ||
| - Raw Clang output names `/workspace/...` instead of `/project/...` source paths. Parsed | ||
| diagnostics and emitted artifacts are unchanged. |
There was a problem hiding this comment.
Nit: this doesn't hold for link errors.
- The wasm-ld diagnostic message is built from
stderr.trim(). - So
wasm-ld: error: /project/build/0000.o: undefined symbol: missingbecomes…/workspace/build/0000.o…. - I saw this when comparing
ServerCompileroutput on main and on this PR, and in Chromium on the PR build. - Clang file, line and column are unchanged.
Fix, either:
- Reword it, e.g. "Raw Clang and wasm-ld output, including link-error messages, names
/workspace/...". - Or map
/workspace/back to/project/in the failure message.
| - Raw Clang output names `/workspace/...` instead of `/project/...` source paths. Parsed | ||
| diagnostics and emitted artifacts are unchanged. | ||
| - The runtime identity changes with the SDK, so artifacts cached by earlier releases are | ||
| rebuilt. |
There was a problem hiding this comment.
Nit: hosts with a persistent artifact cache get one failed compile before the rebuild.
- Why:
FileSystemArtifactStore.load()rejects the stale cost profile, deletes the file and rethrows.CompileCoordinator.loadCacheddoesn't catch that error.- So the first compile after the upgrade fails with
Artifact cost profile '…:runtime-<previous identity>:…' does not match …. Only the next attempt rebuilds.
- Who it hits:
woj(persistentengine/artifacts) and long-lived server hosts. The judge container is not affected (artifactCache: false). - Not new: any identity change takes this path, including fix(runtime): meter interactive programs in-module like standalone runs #95's.
Fix, either:
- Treat a
load()failure as a cache miss inloadCached(one try/catch). - Or drop "rebuilt" from this line.
Why
@wasmer/sdk0.10.0 is unmaintained. It also has a teardown race:Command.run()sends the exit code and closes its thread pool before the WASI runner that owns the stdout/stderr writers is dropped, soInstance.wait()can wait forever for EOF. Under concurrency this stalled about 4% of Python runtime preparations (#96 works around it by not waiting for EOF). The same race stalled TypeScript compilation in a baseline run:typescript-wasip1: Server compilation exceeded 120000 ms. The SDK was rewritten in 0.11.0 (it now lives atwasmerio/wasmer-sdk), and 0.19.0 does not have this race.Change
Wasmerclient, load each pinned WEBC or raw Wasm package once, and run Clang/wasm-ld, rustc/wasm-ld, and TypeScript in a fresh sandbox per build. Processes run to exit through piped streams (runSandboxCommand). This replaces the 0.10.0 output-ready polling, soMountedOutputStabilityObserver,PackageHandleCache, the customwasmer-thread.worker, andwasmer-runtime.tsare deleted.unreachable) after it prints errors, which makeswait()reject. The piped streams still end, so the diagnostics are kept./workspace. Clang maps the pinned/projectarguments, adds-fmacro-prefix-map/-fdebug-prefix-map, and uses a-ivfsoverlayfor the admitted libc++ PCH, which records/project/wasm-oj.libcxx.hpp. Rust moves its root and remaps/workspace=.. All 78 compared artifacts are byte-identical to 0.10.0 (C, C++ incl. debug,__FILE__/assert, admitted and project PCH, Rust, TypeScript). Project-PCH cache keys include the root, so PCHs persisted under/projectare not reused.Command.binary()replacement.src/runner/webc.tsis a small WEBC v3 reader that returns the named atom from the atoms section. Callers check it against the pinnedPYTHON_COMMAND_SHA256; the bytes are identical to the oldCommand.binary(). Server: the runner stage'scommand-binaryoperation no longer starts the SDK. Browser: the runner Worker reads the atom from the verified package bytes.scripts/wasmer-sdk-runtime.mjsis a Vite plugin used by the library build and the app build:assets/wasmer-sdk-<digest>/and exposes their URLs throughvirtual:wasm-oj/wasmer-sdk.createBrowserWasmer()imports the SDK from there and points its thread Workers at a same-origin blob bootstrap, as before.dist/wisp-network.js, the SDK's only path to its AGPL-3.0@mercuryworkshop/wisp-jsdependency. WASM-OJ sandboxes have networking disabled.new Function("f", "return f(Array.prototype.slice.call(arguments, 1))")with the equivalent closure. Without this, 0.19.0 fails under strict CSP for every language. The build fails if that pattern changes.@wasm-oj/browserno longer depends on@wasmer/sdk. Hosts that copydist/assets/must copy it recursively; NOJV already does.scripts/because the judge image's.dockerignoreadmitsscripts/but notbuild/.server-build-stage.mjsandrustc-stage.mjsnow keep theirWasmerclient and loaded packages and serve one build at a time (src/server/reusable-stage.ts), so the slower 0.19 package load is paid once per child instead of once per build. Every build still gets a fresh sandbox.server-build-stage.mjskept spinning for hours after its parent was killed.Process.kill()orWasmer.close(). A stage that exceeds its budget is abandoned, not killed. Cancellation, timeouts, and quiescing still discard the whole server child or Worker generation. The rustc stage terminates its owned SDK Workers before closing.wasmer-sdk-js-v0.19.0,7e69332) replace the old material.components.json, andverify-wasmer-sdk-init.mjsare updated.WASM_OJ_RUN_CANCELLATION_STRESS=1, cancels compiles, Python preparation, and runs at random points.The public
@wasm-oj/*APIs are unchanged.Verification
pnpm run ci:verify: exit 0 (typecheck, lint, 948 tests, Sites build,verify-browser-assets).src/server/reusable-stage.test.ts:ps; they pass on macOS, and CI will be their first Linux run.pnpm run library:verify(needs pnpm 10.34.5 onPATHfor the NodeNext consumer, which is environmental).licenses:verify,wasmer-sdk:verify,docs:verify.WASM_OJ_RUN_JUDGE_INTEGRATION=1 vitest run src/server/judge.integration.test.ts: 2/2.WASM_OJ_RUN_CONFORMANCE=1 …SUITE=full): 140/140 samples pass. Baseline 0.10.0 was 139/140 because of the TypeScript stall above. The test then fails while writing evidence becauseexperiments/wasm-oj-contract-2-conformance/SPEC.mdis missing; this is the same on main.scripts/verify-browser-csp.mjs, 87 fixtures in all 8 languages plus capability probes):python-16mb-recursion_erroralso fails on main (a JS stack limit in runtime-core).vite preview, app CSP + COOP/COEP + chunk service worker, Chromium): the app's own compiler and runner Workers compiled and ran C++ (42) and Python (42) through/_next/static/wasmer-sdk-<digest>/.stage-stress: 300 Python runtime-file exports at concurrency 6, each archive digest verified.driver: 50 iterations × 2 engines, C++ + Python.fix(server): keep a stdin error handler after cancelling a run); without it the host crashed withwrite EPIPEafter 42 iterations, which is a known issue on main.kill()(#542) investigation:Wasmer.close()never resolved in 57 of 60 processes.close()hung for all 15 CPU-bound kills; there was no page hang.kill()orclose().Server builds with reused children (median of 5 rounds, ms; builds 1 / 2 / 3 on a fresh engine)
Performance before reuse (median of 3, two interleaved rounds, 0.10.0 → 0.19.0, ms)
startupEntropyBytes)Shipped browser bytes (JS + Wasm):
@wasm-oj/browserraw@wasm-oj/browsergzipRisks / follow-ups
Server package loading is slower, but each child pays it once.
packages.load()hashes every WEBC and atom with SHA-256 in Wasm (Rust 1.28 → 3.56 s, Clang 0.53 → 1.51 s, Python 0.12 → 0.31 s). Only a cold child pays it:A burst of builds after an idle period or a cancellation still pays the cold cost once. A warm idle child holds about 250–360 MB until it exits.
SDK
kill()/close()are unsafe for CPU-bound guests, as shown above. Forge only uses process and Worker teardown and must keep it that way.rustc leaves one busy SDK worker per invocation, and
Wasmer.close()then never resolves. Server children exit, and the browser Rust stage still recycles after 2 builds (MAX_OUTPUT_READY_RUST_STAGES_PER_WORKER). The Clang/Rust stage budgets and their "output-ready" names were kept as they were; with run-to-exit processes the Clang budget could probably be relaxed, but that needs measurement first.Raw Clang stderr now names
/workspace/.... Parsed diagnostics and artifacts are unchanged.Licensing. The new SDK license condition and the AGPL
wisp-jstransitive install for@wasm-oj/serverconsumers need owner acknowledgement. Forge does not shipwisp-jsin its tarballs or app.Dev mode is untested. The plugin's
servebranch could not be exercised. On main as well,pnpm devfails locally: workerd supports compatibility dates up to 2026-08-08, the config uses 2026-08-09, and vinext dev returns 404 for client modules. Forge's module-Worker loader also rejects Vite dev Worker URLs because they carry a query string.Conflicts with fix(runtime): stop waiting for stream EOF when exporting runtime files #96. fix(runtime): stop waiting for stream EOF when exporting runtime files #96 (no-EOF workaround) conflicts with this branch in
server-runner-stage.mjsandrunner.worker.ts. If it lands first, keep this branch'srun()path and dropreadRuntimeFilesExport.Test gaps worth closing in review:
dist/assets/wasmer-sdk-*.runSandboxCommandmaps everywait()rejection to a failed build, so an SDK failure that is not a guest trap appears as a compile error with empty stderr.Pre-existing issues found along the way (not fixed here):
startupEntropyBytes, soengine.interactfails in the browser.SPEC.mdis missing.🤖 Generated with Claude Code