feat(sw): SW-thread worker-mode VM — DR-C respondWith loop (D-19 PR-2)#280
Conversation
D-19 PR-2 (`#11-service-workers-vm` PR-A): bind the ServiceWorkerGlobalScope realm onto the bytecode VM so PR7 boa-deletion (D-26) does not regress, over the existing engine-indep `elidex-api-sw` infra. Surfaces (WHATWG SW §4): `GlobalScopeKind::ServiceWorker` + `register_service_worker_global_scope_prototype` (`self` / `clients` / `skipWaiting` / `importScripts` + `oninstall`/`onactivate`/`onfetch`/ `onmessage`); `ExtendableEvent.waitUntil`; `FetchEvent.respondWith` real-promise loop (DR-C); `Clients`/`Client` (matchAll/get/claim + postMessage); a VM `sw_thread::sw_thread_main` + per-SW event loop. DR-C (the central novelty boa cannot do): after `dispatch_script_event` fires `onfetch`, the loop drains microtasks + timers + the network tick until the `respondWith` promise settles, so `respondWith(fetch(req))` actually resolves. The same pump backs `waitUntil` (install/activate lifetime promises). Lifecycle success is spec-faithful (waitUntil-driven only; a synchronous handler throw without waitUntil → success — Chrome parity, stricter-correct than boa's dispatch_sw_event). Design note: FetchEvent/ExtendableEvent stay `ObjectKind::Event` (so they dispatch through the shared `dispatch_script_event`, the MessageEvent precedent) and are branded by their `fetch_event_states` / `extendable_event_states` side-stores — NOT own ObjectKinds. The plan's "own brand + dispatch coexist" premise is falsified by the code (dispatch reads the event's ObjectKind::Event slots; StorageEvent's own brand only works because it is never dispatched). Only `ObjectKind::Client` is added. Cache delivery in the SW realm reuses `PendingTask::CacheDeliver` (the pump drains tasks too). 29 harness tests drive the exact ContentToSw/SwToContent IPC contract (densest on respondWith), so the D-26 spawn swap is mechanical. Cross-realm cache sharing (window-put → SW-match) asserted via a shared `Arc<Mutex<SqliteConnection>>` (DR-A). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…se (review) Pre-push /code-review (3-agent) fixes: - CRIT GC-safety: the respondWith/waitUntil promise lives ONLY in the fetch_event_states/extendable_event_states side-store between the native and the SW loop reading it back — a window that straddles GC-enabled listener code. The side-stores were not GC roots, so a GC mid-listener (e.g. allocation after respondWith) could sweep the promise → panic or silent passthrough. Mark them as roots in the gc/collect.rs mark phase (a no-op when empty); the mod.rs/trace.rs docstrings now match. Simplify run_fetch/run_lifecycle (drop the now-redundant promise stack-pushes; keep the event stack-root). Regression test allocates 200k objects after respondWith and asserts the Response survives. - Idle timeout: reset last_activity AFTER handle_message (a slow respondWith/waitUntil pump no longer consumes the whole idle budget). - SW §4.6.7: respondWith(Response.error()) → network passthrough (not a status-0 response delivered to the page). Test added. - Dedup: one promise_resolve helper in service_worker/mod.rs (was duplicated in event.rs + clients.rs). - Doc-link fixes (pump_response / DedicatedWorker). FetchEvent.waitUntil lifetime-extension past the response is documented as best-effort + deferred (slot #11-sw-fetchevent-waituntil). 27 SW harness tests; 5518 lib tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pre-push /elidex-review (5-axis, 0 CRIT / 4 IMP clerical) fixes:
- Spec citations (Axis 4, webref-verified):
- obtain_sw_source: SW §4.2 (= Client interface) + non-existent term →
SW "Update" algorithm + HTML §10.2.4 "fetch a classic worker script".
- SW base-URL comment: HTML §8.1.3.8 (non-existent) → §8.1.3.2 API base URL.
- parse_request_mode/parse_redirect_mode: Fetch §5.3 (Body mixin) → §5.4
(Request class).
- VisibilityState: drop the unverifiable Page Visibility §4.1 number.
- Defer hygiene (Axis 3/5): FetchEvent.waitUntil lifetime-extension is an
impl-discovered gap (the plan covered only ExtendableEvent.waitUntil for
install/activate) — reframed in-code as a landing-time 4-question-audit
candidate rather than an already-registered slot.
Agents 1+2 independently confirmed the core design (ObjectKind::Event for
FetchEvent/ExtendableEvent is the lesson-#276 call; side-stores correct;
GC rooting complete). MIN F5 (parse_*_mode reuse — different &str-vs-JsValue
signature) + F8 (vm/mod.rs line count, tracked by existing defer) accepted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds a VM-backed Service Worker global scope and SW-thread event loop, binding the existing engine-independent elidex-api-sw IPC/types into the bytecode VM so ServiceWorkerGlobalScope can run fully in “worker-mode” (including the DR-C respondWith() promise-settlement pump) without relying on the legacy boa runtime.
Changes:
- Introduces
GlobalScopeKind::ServiceWorker, installs SW globals/prototypes (ServiceWorkerGlobalScope,ExtendableEvent,FetchEvent,Clients,Client), and adds the SW-thread entry + per-event promise pump. - Adds SW-side-stores (
fetch_event_states,extendable_event_states,client_states,sw_clients,sw_outgoing) plus GC rooting/retention rules and unbind cleanup. - Adds a dedicated SW harness test suite verifying lifecycle (
waitUntil), DR-CrespondWithpump behavior (incl. network tick), client snapshot behavior, and cross-realm Cache sharing.
Reviewed changes
Copilot reviewed 24 out of 25 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/script/elidex-js/src/vm/vm_api.rs | Clears SW-specific side-stores/snapshots/outbound IPC on unbind to avoid stale cross-bind observation. |
| crates/script/elidex-js/src/vm/tests/tests_service_worker.rs | New SW-thread harness tests covering lifecycle, respondWith pump, clients, postMessage, and cache cross-realm sharing. |
| crates/script/elidex-js/src/vm/tests/mod.rs | Registers SW tests behind cfg(feature = "engine"). |
| crates/script/elidex-js/src/vm/sw_thread.rs | New SW-thread entry + loop; implements DR-C promise pump draining tasks/microtasks/timers/network. |
| crates/script/elidex-js/src/vm/object_kind.rs | Adds ObjectKind::Client brand; documents SW event branding via side-stores. |
| crates/script/elidex-js/src/vm/mod.rs | Adds GlobalScopeKind::ServiceWorker and SW side-store/prototype fields on VmInner. |
| crates/script/elidex-js/src/vm/init.rs | Adds Vm::new_service_worker and initializes SW side-stores/prototype slots. |
| crates/script/elidex-js/src/vm/host/worker_scope.rs | Shares importScripts native across dedicated worker + Service Worker realms. |
| crates/script/elidex-js/src/vm/host/structured_clone.rs | Marks SW Client as non-serializable in structured clone classification. |
| crates/script/elidex-js/src/vm/host/service_worker/mod.rs | Adds SW host binding module; installs SW scope prototype + SW interface globals and promise helper. |
| crates/script/elidex-js/src/vm/host/service_worker/marshal.rs | Implements SwRequest→Request and Response→SwResponse marshalling for fetch dispatch/IPC. |
| crates/script/elidex-js/src/vm/host/service_worker/event.rs | Implements ExtendableEvent/FetchEvent prototypes plus waitUntil/respondWith natives and SW-event allocation/dispatch helpers. |
| crates/script/elidex-js/src/vm/host/service_worker/clients.rs | Implements Clients/Client interfaces, snapshot-backed lookup, and outbound postMessage routing. |
| crates/script/elidex-js/src/vm/host/mod.rs | Exposes the new service_worker host module under cfg(feature = "engine"). |
| crates/script/elidex-js/src/vm/host/event_handler_attrs.rs | Installs SW-specific handler IDL attributes (oninstall/onactivate/onfetch/onmessage). |
| crates/script/elidex-js/src/vm/host/cache/backend.rs | Adds shared-connection CacheBackend::new for cross-realm cache visibility. |
| crates/script/elidex-js/src/vm/globals.rs | Registers SW realm globals and installs SW interface prototypes/singletons conditionally. |
| crates/script/elidex-js/src/vm/gc/trace.rs | Adds ObjectKind::Client trace arm (payload-free brand). |
| crates/script/elidex-js/src/vm/gc/roots.rs | Expands proto_roots capacity to include SW prototypes. |
| crates/script/elidex-js/src/vm/gc/collect.rs | Roots SW prototypes and roots/retains in-flight respondWith/waitUntil promises; prunes SW side-stores on sweep. |
| crates/script/elidex-js/Cargo.toml | Adds elidex-api-sw to the engine feature set and dependencies. |
| crates/script/elidex-js-boa/src/sw_thread.rs | Keeps legacy boa SW runtime compatible with new ContentToSw::ClientList by ignoring it. |
| crates/api/elidex-api-sw/src/types.rs | Adds ClientSnapshot and related enums; adds ContentToSw::ClientList. |
| crates/api/elidex-api-sw/src/lib.rs | Re-exports newly added SW client snapshot types. |
| Cargo.lock | Locks new elidex-api-sw dependency. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…spatch flag
Copilot flagged (2 IMPORTANT, one shared root): `FetchEvent.respondWith` and
`ExtendableEvent.waitUntil` did not gate on the event's dispatch flag, so a late
call from a timer/network callback during the pump (after the SW loop snapshotted
the side-store) silently mutated state instead of throwing the spec-mandated
`InvalidStateError`.
One structural fix — read the shared `dispatched_events` §2.9 dispatch-window
bracket (set/cleared by `dispatch_event_at_sw_scope`) in both natives:
- `respondWith` → SW §4.6.7 step 2 (dispatch-flag), ordered before the existing
step-3 respond-with-entered (`responded`) guard. This also makes the impl
finally honor its own docstring ("any call after dispatch settled ... throws
InvalidStateError" — the #277 docstring-contract<->body axis).
- `waitUntil` → the dispatch-flag half of the spec's "active" predicate (§4.4.1);
the pending-promises (lifetime-extension chaining) half stays in the deferred
`#11-sw-fetchevent-waituntil` slot, so a late call throws rather than silently
dropping.
Regression test `wait_until_after_dispatch_throws_invalid_state` drives the gate
via a `setTimeout` callback — the genuine post-dispatch window (a microtask
`.then` instead runs inside `dispatch_script_event`'s trailing checkpoint, flag
still set, and is correctly accepted).
Gate: clippy + doc + nextest (10744/10744) green; fix-delta /elidex-review
(5-axis) 0C/0I/0M/6FP — both new spec citations webref-verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
No further action needed on my side right now: working tree is clean, and local |
* feat(sw): navigator.serviceWorker client container — register/lifecycle/identity (D-19 PR-3) Closes #11-service-workers-vm — the window-realm `navigator.serviceWorker` binding over the existing shell SW infra (the 3rd of the D-19 carve-out after Cache #275 + SW-thread #280). Surface (WHATWG SW §3.1/§3.2/§3.4): - ServiceWorkerContainer (brand-via-prototype singleton): register / getRegistration(s) / ready / controller / startMessages / onmessage / oncontrollerchange. - ServiceWorkerRegistration: scope / installing / waiting / active / updateViaCache / update / unregister / onupdatefound. - ServiceWorker: scriptURL / state / postMessage / onstatechange. - DR-B' back-channel: `Vm::deliver_sw_client_update` (7th `deliver_*` member) settles register()/unregister() promises + fires statechange / updatefound / controllerchange / message; harness-driven over the engine-indep `SwClientUpdate` contract (event_loop.rs→VM wire = D-26). Architecture: per-realm object identity via the `wrapper_intern` seam (WrapperOwner::Scope + WrapperKind::{ServiceWorkerRegistration,ServiceWorker}) so `reg === getRegistration()` / `reg.active === controller` / worker identity survives state transitions; op-segregated pending-promise lists force-marked + a registry-walk GC mark loop; coordinator emits SwRegistered(true)/ SwStateChanged/SwControllerSet (boa no-op consumer, zero regression). Validation stays in elidex-api-sw via the typed `SwRegisterError`. Folds the PR-2 `Client` `[SameObject]` bug (NG-1) through the same intern machinery. 43 SW tests (14 window-realm client + NG-1 + 27 PR-2 + ...); 10768 workspace tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sw): apply /code-review findings — container ObjectKind, deliver ordering, updateViaCache - Altitude: give the container an `ObjectKind::ServiceWorkerContainer` brand (uniform with the other VmObject EventTargets) instead of a brand-via-prototype + `dispatch_target` `Ordinary if sw_container==id` special-case; drop the now-dead `sw_container_prototype` field. - deliver: gate `ControllerSet` on registry-presence + only-on-change (no spurious controllerchange on out-of-scope same-origin tabs); drain+settle register waiters BEFORE firing `updatefound` (reentrant same-scope register() can't be settled by this deliver); factor the new-installing `updatefound` edge-detect into one helper. - message queue: a `message` listener (onmessage / addEventListener) now enables the queue + flushes the buffer (SW §3.4.6), not just startMessages(). - updateViaCache: parse `options.updateViaCache`, carry it in SwClientRequest::Register, and seed the registry entry from SwClientUpdate::Registered so the getter is live. + 3 tests (updateViaCache round-trip, out-of-scope controller ignored, onmessage flush). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sw): apply /elidex-review + /simplify findings — citations, predicate, dedup elidex-review (5-axis, 0 CRIT): - Axis 4: correct the systematic off-by-one §number cluster — the spec inserts a "Getting X instances" §.1 subsection per interface that the citations didn't count. scriptURL §3.1.1→§3.1.2, state §3.1.2→§3.1.3, postMessage §3.1.3→§3.1.4; registration scope §3.2.1→§3.2.6, updateViaCache §3.2.6→§3.2.7 (+ types.rs / deliver.rs siblings); the "Register" algorithms are dfns, not §3.1 (the interface) → §3.4.3 + algorithm names; container scope-match §8.1 (= Acks) → "Match Service Worker Registration" algorithm. - Axis 1: move the ServiceWorkerState WebIDL string into the engine-indep crate as SwState::as_str() (uniform with UpdateViaCache::as_str()), drop the host-side re-impl. - Axis 2: unify the `active`-slot predicate via SwState::is_active_slot() ({activating, activated}) so `ready`'s lazy resolve matches `.active`/the deliver — a page reading `.ready` while its worker is `activating` now resolves. /simplify: - factor the rooted-settle body into one `settle_rooted` (was 4 copies); - collapse the three byte-identical `fire_*` event firers into `fire_simple`; - deliver_state_changed: one `get_mut` instead of 3 registry lookups + an `.expect`. + ready_lazily_resolves_for_activating_worker test. 10772 workspace tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sw): PR283 Copilot review — 3 findings resolved - ControllerSet: also gate on `matches_scope(scope, document_url)`, not just `contains_key` — with multiple same-origin registrations, a non-controlling one must not become the controller / fire controllerchange. - ServiceWorker.scriptURL: capture it as an immutable readonly own-data prop at worker-object creation (was a getter reading the registry → "" after `unregister`), so a JS-held redundant worker keeps its original URL (SW §3.1.2); `state` stays a getter (mutable → "redundant"). - container.rs: module docstring updated to the ObjectKind brand (was stale "brand-via-prototype"). + 2 regression tests. 10774 workspace tests pass; fix-delta re-review CLEAN (layering + data-flow). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(sw): PR283 Codex review — add #match-service-worker-registration dfn anchor (MIN) Codex Axis-4 MIN: the SW "Match Service Worker Registration" algorithm cite in deliver_controller_set (and the sibling cite in container.rs getRegistration) named the algorithm without its dfn anchor. Add `#match-service-worker-registration` to both (webref-verified). Comment-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
D-19 PR-2: Service Workers on the VM — SW-thread worker-mode realm
Second of the three-PR D-19 carve-out (PR-1 Cache API = #275). Binds the
ServiceWorkerGlobalScoperealm onto the bytecode VM over the existingengine-independent
elidex-api-swinfra, so the PR7 boa-deletion (D-26) doesnot regress. Lifecycle state machine stays SHELL-owned (
sw_coordinator.rs);this PR is the VM-binding twin of the boa
sw_thread.rs.Surfaces (WHATWG Service Workers §4)
GlobalScopeKind::ServiceWorker+register_service_worker_global_scope_prototype—
self/clients/skipWaiting/importScripts, andoninstall/onactivate/onfetch/onmessagehandler attributes.ExtendableEvent.waitUntil(install/activate lifetime promises).FetchEvent.respondWithreal-promise loop (DR-C, the central novelty).Clients/Client—matchAll/get/claim+postMessage.vm/sw_thread.rssw_thread_main+ per-SW event loop.DR-C — the respondWith pump
After
dispatch_script_eventfiresonfetch, the loop drains microtasks +timers + the network tick until the
respondWithpromise settles, sorespondWith(fetch(req))actually resolves. The same pump backswaitUntil.Lifecycle success is spec-faithful (waitUntil-driven only; a synchronous
handler throw without
waitUntil→ success, Chrome parity — stricter-correctthan boa's
dispatch_sw_event). This is the thing boa structurally cannot do;the 29 harness tests pin the exact
ContentToSw/SwToContentIPC contract sothe D-26 spawn swap is mechanical.
Key design note (deviation from plan, agent-confirmed)
FetchEvent/ExtendableEventstayObjectKind::Event(dispatching throughthe shared
dispatch_script_event, the MessageEvent precedent) and are brandedby their
fetch_event_states/extendable_event_statesside-stores — notown ObjectKinds. The plan's "own brand + dispatch coexist" premise is falsified
by the code (
dispatch_script_eventreads the event'sObjectKind::Eventslots;
StorageEvent's own brand only works because it is never dispatched).Only
ObjectKind::Clientis added. Cross-realm cache sharing(window-put → SW-match) is asserted via the shared
Arc<Mutex<SqliteConnection>>(DR-A).Pre-push gate (all GREEN)
/elidex-plan-reviewCLEAR (0 CRIT / 2 IMP applied).mise run cigreen; clippy/doc clean; 27 SW harness + 5518 lib tests./code-review(3-agent) caught a CRIT GC-safety bug: therespondWith/waitUntil promise lives only in the side-store across a
GC-enabled listener window → now rooted in the
gc/collect.rsmark phase,with a 200k-alloc regression test asserting the
Responsesurvives./elidex-review(5-axis) 0 CRIT / 4 IMP (clerical: webref-verified speccitations + defer-slot hygiene) applied.
🤖 Generated with Claude Code