Skip to content

feat(sw): SW-thread worker-mode VM — DR-C respondWith loop (D-19 PR-2)#280

Merged
send merged 4 commits into
mainfrom
feat/11-service-workers-vm-pr2
Jun 4, 2026
Merged

feat(sw): SW-thread worker-mode VM — DR-C respondWith loop (D-19 PR-2)#280
send merged 4 commits into
mainfrom
feat/11-service-workers-vm-pr2

Conversation

@send

@send send commented Jun 3, 2026

Copy link
Copy Markdown
Owner

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
ServiceWorkerGlobalScope realm onto the bytecode VM over the existing
engine-independent elidex-api-sw infra, so the PR7 boa-deletion (D-26) does
not 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, and
    oninstall / onactivate / onfetch / onmessage handler attributes.
  • ExtendableEvent.waitUntil (install/activate lifetime promises).
  • FetchEvent.respondWith real-promise loop (DR-C, the central novelty).
  • Clients / ClientmatchAll / get / claim + postMessage.
  • vm/sw_thread.rs sw_thread_main + per-SW event loop.

DR-C — the respondWith pump

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.
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). This is the thing boa structurally cannot do;
the 29 harness tests pin the exact ContentToSw/SwToContent IPC contract so
the D-26 spawn swap is mechanical.

Key design note (deviation from plan, agent-confirmed)

FetchEvent/ExtendableEvent stay ObjectKind::Event (dispatching 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_script_event reads the event's ObjectKind::Event
slots; StorageEvent's own brand only works because it is never dispatched).
Only ObjectKind::Client is 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-review CLEAR (0 CRIT / 2 IMP applied).
  • mise run ci green; clippy/doc clean; 27 SW harness + 5518 lib tests.
  • /code-review (3-agent) caught a CRIT GC-safety bug: the
    respondWith/waitUntil promise lives only in the side-store across a
    GC-enabled listener window → now rooted in the gc/collect.rs mark phase,
    with a 200k-alloc regression test asserting the Response survives.
  • /elidex-review (5-axis) 0 CRIT / 4 IMP (clerical: webref-verified spec
    citations + defer-slot hygiene) applied.

🤖 Generated with Claude Code

send and others added 3 commits June 4, 2026 02:25
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>
Copilot AI review requested due to automatic review settings June 3, 2026 18:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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-C respondWith pump 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 SwRequestRequest and ResponseSwResponse 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.

Comment thread crates/script/elidex-js/src/vm/host/service_worker/event.rs
Comment thread crates/script/elidex-js/src/vm/host/service_worker/event.rs
…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>
@send

send commented Jun 4, 2026

Copy link
Copy Markdown
Owner Author

@codex[agent]

@Codex

Codex AI commented Jun 4, 2026

Copy link
Copy Markdown

@codex[agent]

No further action needed on my side right now: working tree is clean, and local cargo fmt --check / cargo clippy -p elidex-js --all-features -D warnings / cargo test -p elidex-js --all-features are green at cde22e1. If you had a specific follow-up you want me to handle in this PR, point me at it and I’ll take it.

@send send merged commit 9c984e9 into main Jun 4, 2026
7 checks passed
@send send deleted the feat/11-service-workers-vm-pr2 branch June 4, 2026 10:13
send added a commit that referenced this pull request Jun 5, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants