● Phase D — Worker glue + deploy ergonomics #29

Merged
navicore merged 2 commits from wasm-tier-2-phase-D into main 2026-06-21 01:34:04 +00:00
Owner

D1g — emit overrideable glue (crates/compiler/src/worker_glue.rs, new)

  • plgc build --target worker prog.pl now drops three files next to the reactor .wasm:
    • worker.js — a Cloudflare/workerd fetch handler driving the buffer ABI (init once → alloc
      → write query → plg_rt_run_query → decode the (len<<32)|ptr BigInt → free). Query from
      ?query= or POST body; 400 on missing query; per-request limit knobs exposed.
    • wrangler.toml — Cloudflare deploy config (CompiledWasm rule, sanitized worker name).
    • config.capnp — local workerd serve config.
  • Write-if-absent: a rebuild regenerates the .wasm but never clobbers edited glue (delete a
    file to refresh it). The CLI reports what it wrote vs. preserved, with deploy/serve hints.
  • Wired at the CLI layer (main.rs Build handler, per C3) — a glue failure is a warning, not a
    build failure. Unit tests cover name sanitization and the write-then-preserve behavior.

D2g — just wasm-worker-serve <prog.pl> (justfile)

  • Compiles to target/worker// (emitting glue there) and exec workerd serve
    config.capnp. Productizes the spike's serve loop.

Verification — proven on real workerd (the actual Workers runtime, V8)

Installed workerd locally and ran the emitted config.capnp:

  • GET ?query=needs(app, X) → 5 solutions
  • POST body depends_on(app, D) → 2 solutions
  • Missing query → 400
  • Error path byte-identical to native over HTTP: nope(x) → {"error":"Runtime error:
    error(existence_error(procedure, /(nope, 1)), ...)"} matched the native binary exactly

That closes the Tier-2 Definition-of-Done gate: "--target worker → a .wasm (+ glue) that
answers HTTP on workerd byte-identical to native, including errors." Combined with
reactor-smoke's automated 1,000,000-deep-recursion-on-V8 proof, the two load-bearing DoD
items are both met and repeatable.

Full workspace green; fmt + clippy clean (default and --features wasm). Stray build artifacts
cleaned up; workerd lives in /tmp only.

D1g — emit overrideable glue (crates/compiler/src/worker_glue.rs, new) - plgc build --target worker prog.pl now drops three files next to the reactor .wasm: - worker.js — a Cloudflare/workerd fetch handler driving the buffer ABI (init once → alloc → write query → plg_rt_run_query → decode the (len<<32)|ptr BigInt → free). Query from ?query= or POST body; 400 on missing query; per-request limit knobs exposed. - wrangler.toml — Cloudflare deploy config (CompiledWasm rule, sanitized worker name). - config.capnp — local workerd serve config. - Write-if-absent: a rebuild regenerates the .wasm but never clobbers edited glue (delete a file to refresh it). The CLI reports what it wrote vs. preserved, with deploy/serve hints. - Wired at the CLI layer (main.rs Build handler, per C3) — a glue failure is a warning, not a build failure. Unit tests cover name sanitization and the write-then-preserve behavior. D2g — just wasm-worker-serve <prog.pl> (justfile) - Compiles to target/worker/<stem>/ (emitting glue there) and exec workerd serve config.capnp. Productizes the spike's serve loop. Verification — proven on real workerd (the actual Workers runtime, V8) Installed workerd locally and ran the emitted config.capnp: - GET ?query=needs(app, X) → 5 solutions ✅ - POST body depends_on(app, D) → 2 solutions ✅ - Missing query → 400 ✅ - Error path byte-identical to native over HTTP: nope(x) → {"error":"Runtime error: error(existence_error(procedure, /(nope, 1)), ...)"} matched the native binary exactly ✅ That closes the Tier-2 Definition-of-Done gate: "--target worker → a .wasm (+ glue) that answers HTTP on workerd byte-identical to native, including errors." Combined with reactor-smoke's automated 1,000,000-deep-recursion-on-V8 proof, the two load-bearing DoD items are both met and repeatable. Full workspace green; fmt + clippy clean (default and --features wasm). Stray build artifacts cleaned up; workerd lives in /tmp only.
● Phase D — Worker glue + deploy ergonomics
All checks were successful
CI - Linux / CI - Linux x86_64 (pull_request) Successful in 1m0s
568f27db0e
D1g — emit overrideable glue (crates/compiler/src/worker_glue.rs, new)
  - plgc build --target worker prog.pl now drops three files next to the reactor .wasm:
    - worker.js — a Cloudflare/workerd fetch handler driving the buffer ABI (init once → alloc
  → write query → plg_rt_run_query → decode the (len<<32)|ptr BigInt → free). Query from
  ?query= or POST body; 400 on missing query; per-request limit knobs exposed.
    - wrangler.toml — Cloudflare deploy config (CompiledWasm rule, sanitized worker name).
    - config.capnp — local workerd serve config.
  - Write-if-absent: a rebuild regenerates the .wasm but never clobbers edited glue (delete a
  file to refresh it). The CLI reports what it wrote vs. preserved, with deploy/serve hints.
  - Wired at the CLI layer (main.rs Build handler, per C3) — a glue failure is a warning, not a
  build failure. Unit tests cover name sanitization and the write-then-preserve behavior.

  D2g — just wasm-worker-serve <prog.pl> (justfile)
  - Compiles to target/worker/<stem>/ (emitting glue there) and exec workerd serve
  config.capnp. Productizes the spike's serve loop.

  Verification — proven on real workerd (the actual Workers runtime, V8)

  Installed workerd locally and ran the emitted config.capnp:
  - GET ?query=needs(app, X) → 5 solutions 
  - POST body depends_on(app, D) → 2 solutions 
  - Missing query → 400 
  - Error path byte-identical to native over HTTP: nope(x) → {"error":"Runtime error:
  error(existence_error(procedure, /(nope, 1)), ...)"} matched the native binary exactly 

  That closes the Tier-2 Definition-of-Done gate: "--target worker → a .wasm (+ glue) that
  answers HTTP on workerd byte-identical to native, including errors." Combined with
  reactor-smoke's automated 1,000,000-deep-recursion-on-V8 proof, the two load-bearing DoD
  items are both met and repeatable.

  Full workspace green; fmt + clippy clean (default and --features wasm). Stray build artifacts
  cleaned up; workerd lives in /tmp only.
Author
Owner

Review — #29 Phase D: Worker glue + deploy ergonomics

The scaffolding model is right and the glue is well-built. Write-if-absent is the correct ergonomic call — the .wasm is the real artifact (always regenerated) and the three glue files are editable scaffolding (never clobbered), with the CLI reporting wrote-vs-preserved and a glue failure correctly demoted to a warning since the build already succeeded. The unit tests pin both the sanitizer and the write-then-preserve behavior (including the edited-file case). A few things in the generated worker.js I specifically checked and liked:

  • Fresh ex.memory.buffer after plg_rt_run_query. The result Uint8Array is created after the call returns, so a memory growth during solving that detaches the old ArrayBuffer can't produce a stale view. This is the classic wasm-host footgun and it's avoided.
  • runQuery is fully synchronous. The only await is request.text() for the POST body, before the ABI dance; alloc→run→free never yields. So the reactor's single-in-flight contract (D3) holds automatically even when workerd runs multiple fetch invocations concurrently in one isolate — they serialize on the event loop. That's the subtle correctness property and it's correct.
  • Buffer hygiene: both the query and result buffers are freed, the query buffer by its requested length (matches the exact-Layout alloc/free contract from Phase A), and the i64 step_limit is passed as a BigInt. 400 on missing/empty query, query-param precedence over POST body.

Real concern

1. The deployable worker.js is a third, untested copy of the buffer ABI. reactor-smoke — the automated test added per the #28 review — drives scripts/reactor-smoke.mjs, which states in its own header: "This is a TEST driver, not the deployable worker glue (that is productized in Phase D)." So the marshalling sequence (plg_initallocrun_query(ptr,len,limit,steps,depth) → decode (len<<32)|ptrfree) now lives in three places that must move in lockstep:

  1. the Rust reactor (plg_rt_run_query's 5-arg signature),
  2. scripts/reactor-smoke.mjs (tested by just reactor-smoke),
  3. the WORKER_JS template (this PR — tested by nothing).

The artifact users actually deploy is #3, and its only validation is the one-time manual workerd serve run in the PR description. The per-request limit args are precisely the kind of signature change that already happened once (#27 grew run_query from 2 args to 5); the next such change has to be mirrored in all three, and CI guards only one of them. A silent drift in worker.js ships green and breaks at deploy.

Concretely, any one of these closes it:

  • Point reactor-smoke at the emitted worker.js instead of a parallel .mjs — e.g. a thin Node shim that imports the generated runQuery/fetch and round-trips the same queries. Then the deployable glue is the thing under test and the .mjs can go away (one fewer copy).
  • Or factor the marshalling into a single generated JS module that both worker.js and the smoke driver import, so there's one copy to drift.
  • Or, cheapest: a Rust unit test asserting WORKER_JS contains the current 5-arg plg_rt_run_query(qptr, bytes.length, limit, BigInt(stepLimit), depthLimit) call, so a reactor-ABI change that isn't mirrored in the template fails cargo test.

I'd want at least the cheap option before merge — it's the same gap shape as #28 (the tested thing isn't the shipped thing), one level up.

Small observations

2. worker_name's second strip recomputes its own receiver.

.strip_suffix(".worker")
.unwrap_or_else(|| wasm_file.strip_suffix(".wasm").unwrap_or(wasm_file))

The closure recomputes an expression identical to the value .strip_suffix(".worker") was called on, so it can only ever return that same value. let stem = wasm_file.strip_suffix(".wasm").unwrap_or(wasm_file); stem.strip_suffix(".worker").unwrap_or(stem) is equivalent and reads as what it means. Correct as-is, just needlessly clever.

3. GET query isn't trimmed but POST is. ?query=%20%20 is truthy → forwarded to the reactor as a parse error, while the same goal via POST body is .trim()'d to empty → 400. Harmless (worst case is a parse-error JSON), just an asymmetry; trimming the param too would make them consistent.

4. worker_name doesn't collapse runs of hyphens or cap length. my__appmy--app, and a long stem passes through whole. Cloudflare is generally lenient on internal double-hyphens and the input is a short filename stem, so this is low-stakes — noting it only because the function's whole job is "produce a Cloudflare-legal name."

Net: #1 is the one worth addressing — everything about the glue content looks correct, but the deployable copy has no automated guard against ABI drift, and the ABI has already proven it drifts. The rest are polish.

## Review — #29 Phase D: Worker glue + deploy ergonomics The scaffolding model is right and the glue is well-built. Write-if-absent is the correct ergonomic call — the `.wasm` is the real artifact (always regenerated) and the three glue files are editable scaffolding (never clobbered), with the CLI reporting wrote-vs-preserved and a glue failure correctly demoted to a warning since the build already succeeded. The unit tests pin both the sanitizer and the write-then-preserve behavior (including the edited-file case). A few things in the generated `worker.js` I specifically checked and liked: - **Fresh `ex.memory.buffer` after `plg_rt_run_query`.** The result `Uint8Array` is created *after* the call returns, so a memory growth during solving that detaches the old `ArrayBuffer` can't produce a stale view. This is the classic wasm-host footgun and it's avoided. - **`runQuery` is fully synchronous.** The only `await` is `request.text()` for the POST body, *before* the ABI dance; alloc→run→free never yields. So the reactor's single-in-flight contract (D3) holds automatically even when workerd runs multiple `fetch` invocations concurrently in one isolate — they serialize on the event loop. That's the subtle correctness property and it's correct. - **Buffer hygiene:** both the query and result buffers are freed, the query buffer by its *requested* length (matches the exact-`Layout` alloc/free contract from Phase A), and the i64 `step_limit` is passed as a `BigInt`. 400 on missing/empty query, query-param precedence over POST body. ### Real concern **1. The deployable `worker.js` is a third, untested copy of the buffer ABI.** `reactor-smoke` — the automated test added per the #28 review — drives `scripts/reactor-smoke.mjs`, which states in its own header: *"This is a TEST driver, not the deployable worker glue (that is productized in Phase D)."* So the marshalling sequence (`plg_init` → `alloc` → `run_query(ptr,len,limit,steps,depth)` → decode `(len<<32)|ptr` → `free`) now lives in **three** places that must move in lockstep: 1. the Rust reactor (`plg_rt_run_query`'s 5-arg signature), 2. `scripts/reactor-smoke.mjs` (tested by `just reactor-smoke`), 3. the `WORKER_JS` template (this PR — tested by *nothing*). The artifact users actually deploy is #3, and its only validation is the one-time manual `workerd serve` run in the PR description. The per-request limit args are precisely the kind of signature change that already happened once (#27 grew `run_query` from 2 args to 5); the next such change has to be mirrored in all three, and CI guards only one of them. A silent drift in `worker.js` ships green and breaks at deploy. Concretely, any one of these closes it: - Point `reactor-smoke` at the *emitted* `worker.js` instead of a parallel `.mjs` — e.g. a thin Node shim that imports the generated `runQuery`/`fetch` and round-trips the same queries. Then the deployable glue is the thing under test and the `.mjs` can go away (one fewer copy). - Or factor the marshalling into a single generated JS module that both `worker.js` and the smoke driver import, so there's one copy to drift. - Or, cheapest: a Rust unit test asserting `WORKER_JS` contains the current 5-arg `plg_rt_run_query(qptr, bytes.length, limit, BigInt(stepLimit), depthLimit)` call, so a reactor-ABI change that isn't mirrored in the template fails `cargo test`. I'd want at least the cheap option before merge — it's the same gap shape as #28 (the tested thing isn't the shipped thing), one level up. ### Small observations **2. `worker_name`'s second strip recomputes its own receiver.** ```rust .strip_suffix(".worker") .unwrap_or_else(|| wasm_file.strip_suffix(".wasm").unwrap_or(wasm_file)) ``` The closure recomputes an expression identical to the value `.strip_suffix(".worker")` was called on, so it can only ever return that same value. `let stem = wasm_file.strip_suffix(".wasm").unwrap_or(wasm_file); stem.strip_suffix(".worker").unwrap_or(stem)` is equivalent and reads as what it means. Correct as-is, just needlessly clever. **3. GET query isn't trimmed but POST is.** `?query=%20%20` is truthy → forwarded to the reactor as a parse error, while the same goal via POST body is `.trim()`'d to empty → 400. Harmless (worst case is a parse-error JSON), just an asymmetry; trimming the param too would make them consistent. **4. `worker_name` doesn't collapse runs of hyphens or cap length.** `my__app` → `my--app`, and a long stem passes through whole. Cloudflare is generally lenient on internal double-hyphens and the input is a short filename stem, so this is low-stakes — noting it only because the function's whole job is "produce a Cloudflare-legal name." Net: #1 is the one worth addressing — everything about the glue *content* looks correct, but the deployable copy has no automated guard against ABI drift, and the ABI has already proven it drifts. The rest are polish.
● All green — full workspace, fmt, clippy (default + --features wasm). PR #29 feedback
All checks were successful
CI - Linux / CI - Linux x86_64 (pull_request) Successful in 1m0s
a7bbf279ce
addressed:

  PR #29 (Phase D) feedback addressed

  #1 — the deployable glue is no longer an untested copy (the real concern). The buffer-ABI
  marshalling is now single-sourced into an emitted reactor.mjs (runQuery + assertExports),
  imported by both the deployed worker.js and the smoke driver. So:
  - worker.js shrank to host wiring (instantiate + fetch handler) and imports the marshalling.
  - scripts/reactor-smoke.mjs is now a thin harness that dynamically imports the emitted
  reactor.mjs next to the wasm — i.e. just reactor-smoke exercises the exact code that ships,
  not a parallel copy. The 5-arg plg_rt_run_query call now lives in one place; the next ABI
  change can't drift between tested and deployed.
  - assertExports (the #28 export-existence guard) moved into reactor.mjs too, so worker.js
  also runs it at instantiation.
  - Re-verified on real workerd that the split glue (worker.js → reactor.mjs → wasm, three
  modules in config.capnp) still answers HTTP correctly.

  #2 — worker_name's clever double-strip replaced with plain sequential strip_suffix (.wasm
  then .worker), reads as what it means.

  #3 — GET param now trimmed, consistent with POST. A whitespace-only ?query= is falsy and
  falls through to the POST body — verified on workerd (?query=%20%20 + POST body → answered
  from the body).

  #4 — worker_name collapses runs of non-alphanumerics to a single - and caps length at 63
  (my__app → my-app); a unit test pins the length cap.

  Tests: worker_glue unit tests now 3 (added the length-cap case) and assert the marshalling
  lives in reactor.mjs + the worker.js→reactor.mjs import; reactor-smoke green driving the
  emitted glue; full workspace green; fmt + clippy clean.

  Net effect on the review's framing ("the tested thing isn't the shipped thing"): the
  marshalling is now one file, and that file is what both the test and the deploy use. E and F
  (CI wiring + docs) are next.
navicore deleted branch wasm-tier-2-phase-D 2026-06-21 01:34:05 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
navicore/patch-prolog!29
No description provided.