From 38f50597d84cc9a90fab686cef43603cf599dc85 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Sun, 23 Aug 2026 16:55:27 -0400 Subject: [PATCH] The graph rides 0.5.1: polyengine#239 closes the checkpoint's starvation of its sibling drivers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field report was 'driveAsync: a resumed-activation claim was never released' on mobile Firefox during Drive testing, and both halves of the obvious reading were wrong: not Firefox (a nine-case JSPI ordering probe shows SpiderMonkey and V8 byte-identical on every primitive this rests on), and not network latency (a first diagnosis read its own 4s proxy as the cause; the control run fires the assert at ZERO delay). The mechanism, proven causally on stock 0.4.0: driveAsync's unconditional speculative pending-resumption entry is a store-wide gate. The device's own two periodic drivers collide with it — the worker's 500ms-debounced NON-BLOCKING checkpoint (a recorded design decision) is near-always in flight, and the solo page's 1s us-events drain arrives into it and can only hop; ten thousand hops later the assert fires with the user's exact message. A phone is simply where slow OPFS widens the window. Deleting the one speculative-entry line takes the reproduction from 18 firings to 0; upstream #239 (in v0.5.1) guards exactly that line and adds the driver-arrival wake. Matrix row 47b is the regression gate and measures the starvation itself: 12s of back-to-back checkpoint + us-events on one store. On 0.4.0: one drain in twelve seconds, then the trap. On 0.5.1: 108 drains, no trap — the drain count recovering is specifically the driver-arrival wake, the half a delete-the-line test cannot exercise. The bump rode A22 (embedder is application-only now): the error vocabulary — ComponentException, isComponentException, isTrap, toCloneable, fromCloneable — moved to @polyengine/protocol across eleven files, and the pin-comment's module-identity reasoning was rewritten for the new shape (ports couple to the protocol package, not the runtime; the one-runtime obligation stands on its own). The webrtc sibling advances to v0.5.0 — the only resolution that keeps one runtime in the graph AND the lazy-bundling property (JSR's 0.5.0 re-trips the documented node: bundle hazard; the CI sibling pin follows). wasi/fs's in-window mutate-directory tightening lands on the permissive side of our writable:true preopens — checked, not assumed, since the previous bump's checklist wrongly claimed an empty window. Harness, from the same investigation: Playwright Firefox gains the persistent-storage pref (navigator.storage.persist() never settles headless without it; four more scenarios now pass under Gecko) and a recorded Juggler hazard (promising exports called from page.evaluate frames SIGSEGV the content process — Firefox-lane scenarios drive through page scripts). Gates: devstore matrix ALL PASS with 47b red-then-green across the bump, e2e 24/24, all four bringups, engine check/resume/pair, invariants 9/9, resolved graph exactly one runtime@0.5.1 and one protocol@0.2.3. --- demo/deno.json | 158 ++++++++++++++++++++----------- demo/deno.lock | 66 ++++++------- demo/e2e/run.ts | 36 ++++++- demo/host/bringup.ts | 2 +- demo/host/demo.ts | 7 +- demo/host/probe-net.ts | 2 +- runtime/PERSISTENCE.md | 80 ++++++++++++++++ runtime/device-store/client.ts | 10 +- runtime/device-store/rpc.ts | 5 +- runtime/device-store/worker.ts | 7 +- runtime/keystore.ts | 2 +- runtime/pairing-engine.ts | 2 +- runtime/store-egress.ts | 2 +- runtime/stubs.ts | 2 +- runtime/tests/devstore/deno.json | 25 +++-- runtime/tests/devstore/deno.lock | 55 +++++------ runtime/tests/devstore/page.ts | 58 +++++++++++- runtime/tests/devstore/run.ts | 51 ++++++++++ scripts/setup.sh | 2 +- spikes/worker-host/worker.ts | 2 +- 20 files changed, 429 insertions(+), 145 deletions(-) diff --git a/demo/deno.json b/demo/deno.json index 10bb880..ab8ad48 100644 --- a/demo/deno.json +++ b/demo/deno.json @@ -1,68 +1,118 @@ { "//": [ - "MODULE-IDENTITY CONSTRAINT (from polymorph-iroh/host-polyengine): the", - "sibling polyengine port modules import @polyengine/runtime/embedder by bare", - "specifier; this file maps the specifier ONCE for the whole graph so", - "`instanceof ComponentException` holds across every boundary.", + "MODULE-IDENTITY CONSTRAINT, RESHAPED BY POLYENGINE AMENDMENT A22. The", + "constraint still exists and this file still discharges it, but it now", + "rests on a DIFFERENT package than it used to.", + "", + " WAS TRUE UNTIL 0.5.0: the sibling polyengine port modules imported", + " @polyengine/runtime/embedder by bare specifier, so this file had to", + " map that specifier ONCE for the whole graph or `instanceof", + " ComponentException` stopped holding across the boundary — and every", + " port's own `^` pin had to land on the same runtime version this file", + " pinned.", + "", + " TRUE NOW (A22, polyengine 1e31210): `@polyengine/runtime/embedder` is", + " APPLICATION-ONLY. Published host modules must not import it at all;", + " they couple to @polyengine/protocol instead, and recognize polyengine", + " values by BRAND PREDICATE (`isComponentException`, `isTrap`, …) rather", + " than by `instanceof`. Protocol copies are harmless BY CONSTRUCTION —", + " identity rests on `Symbol.for` registry symbols, never on the package", + " — so the ports no longer constrain, and are no longer constrained by,", + " our runtime version. @polymorph/webcrypto@0.5.0 and", + " @polymorph/websocket@0.5.0 accordingly name NO @polyengine/runtime", + " specifier anywhere; both take @polyengine/protocol@^0.2.2, which lands", + " on the 0.2.3 pinned above.", + "", + " WHAT THIS FILE STILL OWES THE GRAPH: exactly one @polyengine/runtime,", + " because the APPLICATION's own modules (runtime/engine.ts and the", + " device host) hold real embedder state — and one @polyengine/protocol,", + " for tidiness rather than for correctness. Re-verified post-bump with", + " `deno info`: one @polyengine/runtime@0.5.1, one", + " @polyengine/protocol@0.2.3, no 0.4.x residue.", "", "JSR PINS (jsr-pins branch; replaces the IROH_CHECKOUT-era sibling", - "checkouts for these ports; see engine/justfile's PINS", - "block for the endpoint-wasm/relay half of this same pin pair):", - " @polymorph/webcrypto@0.4.0 and @polymorph/websocket@0.4.0 both pin", - " @polyengine/runtime@^0.4.0, the SAME minor line as this file's own", - " @polyengine/runtime@0.4.0 pin above, so ^0.4.0 resolves to 0.4.0", - " and a single copy still serves the whole graph; re-verified", - " post-bump with `deno info` (exactly one @polyengine/runtime@0.4.0,", - " no 0.3.x residue). Both are consumed straight from JSR.", + "checkouts for these ports; see engine/justfile's PINS block for the", + "endpoint-wasm/relay half of this same pin pair).", + "", + " 0.4.0 -> 0.5.1 IS A BREAKING MINOR, and the break is A22 above: the", + " embedder's A9 courtesy re-exports and its concrete handle classes are", + " GONE. `ComponentException`, `isComponentException`, `isTrap`,", + " `toCloneable`, `fromCloneable` and `suspending` all moved to", + " @polyengine/protocol; ~12 modules here changed their import line and", + " nothing else. `instantiate` and `artifactsFromEnvelope` stayed put —", + " they are application surface, which is precisely A22's dividing line.", "", - " 0.3.1 -> 0.4.0 IS A BREAKING MINOR, and it breaks in two places at", - " once, which is why everything above moved together rather than in", - " patch steps. (1) polyengine amendment A19: the embedder gains", - " `toCloneable`/`fromCloneable` -- a sanctioned, round-trip-exact", - " crossing for the branded error taxonomy over a structured-clone", - " boundary, built with this repo's SharedWorker device host as its", - " named consumer driver -- and renames the brand key again", - " (`witError` -> `componentException`), a hard break with no", - " diagnostic. runtime/device-store/rpc.ts consequently DELETED its", - " hand-rolled brand rather than chase the spelling a third time.", - " (2) polymorph webcrypto#392: SigningKey/VerifyingKey/Ikm gain", - " token-gated constructors plus `fromCryptoKey`/`toCryptoKey`, the", - " by-value crossing that supersedes #390's inject factory and", - " unblocks the platform-posture slice (PERSISTENCE.md's T-A/T-B).", - " The prior 0.3.0 -> 0.3.1 note is kept for the record: context", - " intrinsics resolve their thread by declaring instance", - " (polyengine#213, the #24 residue; ended the browser user-create", - " guest trap, #49).", + " WHY THE BUMP HAPPENED AT ALL: polyengine#239 (af97c13), contained in", + " v0.5.1. 0.4.0's `driveAsync` took its speculative pending-resumption", + " entry UNCONDITIONALLY (0.4.0 src/exec/boundary.ts:1064), and that", + " entry is a STORE-WIDE scheduling gate. Two of this device's OWN", + " periodic drivers collide with it — the worker's 500 ms-debounced", + " non-blocking state-checkpoint (runtime/device-store/worker.ts, a", + " recorded design decision) is near-always in flight, and the solo", + " page's 1 s us-events drain (demo/host/solo.ts) arrives into it and can", + " only hop at the top of its own loop. Ten thousand hops later the", + " internal-bug assert fires: \"driveAsync: a resumed-activation claim was", + " never released\". NO network latency and NO egress are involved, and", + " there is no Gecko differential — it was field-reported from mobile", + " Firefox only because slow OPFS widens the window. Devstore row 47b is", + " the regression gate (RED on 0.4.0, GREEN here).", "", - " @polymorph/webrtc-datachannels@0.4.0 stays on the sibling checkout", - " DELIBERATELY: it type-checks and its @polyengine/runtime pin matches", - " the same way, but its published module graph breaks this file's", - " `deno bundle --external node-datachannel --external", - " node-datachannel/polyfill` browser-bundling trick -- the resulting", - " serve/demo.js contains eager top-level `import ... from", + " The prior notes are kept for the record. 0.3.1 -> 0.4.0: polyengine", + " A19/A20 gave the embedder `toCloneable`/`fromCloneable` and renamed", + " the brand key (`witError` -> `componentException`), so", + " runtime/device-store/rpc.ts deleted its hand-rolled brand rather than", + " chase the spelling a third time; polymorph webcrypto#392 added the", + " token-gated constructors plus `fromCryptoKey`/`toCryptoKey`. 0.3.0 ->", + " 0.3.1: context intrinsics resolve their thread by declaring instance", + " (polyengine#213, the #24 residue).", + "", + " Also in this window, and absorbed with NO code change here:", + " polyengine f07a3b9 tightened wasi/fs so directory-mutating ops require", + " the `mutate-directory` flag specifically and refuse read-only with the", + " WIT-mandated code. Every preopen we build passes `writable: true`,", + " which the provider turns into read+write+mutate-directory on the", + " preopened descriptor, so the sealed-fs layer and the engine's state", + " root are unaffected — devstore row 7 and the resume batteries confirm.", + "", + " @polymorph/webrtc-datachannels STAYS ON THE SIBLING CHECKOUT, and the", + " reason is unchanged and was RE-MEASURED at this bump: the JSR copy", + " defeats this file's `deno bundle --external node-datachannel --external", + " node-datachannel/polyfill` browser-bundling trick. Pointed at", + " jsr:@polymorph/webrtc-datachannels@0.5.0, serve/solo.js comes out", + " carrying eager top-level `import ... from", " \"node:url\"/\"node:path\"/\"node:module\"/\"node:stream\"/\"node:events\"`", - " (node-datachannel's native-binding loader), which the browser", - " cannot resolve (CORS-blocked fetch of a bare node: specifier at", - " the page origin) and every e2e scenario times out on page boot.", - " The sibling checkout's identical dynamic `import(\"node-datachannel", - " /polyfill\")` stays genuinely lazy under the same --external flags", - " (this is the README 'Findings' entry on webrtc bundling); something", - " about how JSR's own import-map scoping resolves the nested", - " \"node-datachannel\"/\"node-datachannel/polyfill\" specifiers changes", - " what deno bundle's --external string-matches against. Root-causing", - " that is out of this track's scope -- flagged, not worked around." + " (node-datachannel's native-binding loader reached through", + " `createRequire`), the justfile's `node:` tripwire fires, and every e2e", + " scenario would time out on page boot. The sibling checkout's identical", + " DYNAMIC `import(\"node-datachannel/polyfill\")` stays genuinely lazy", + " under the same --external flags; something about how JSR's own", + " import-map scoping resolves the nested specifiers changes what deno", + " bundle string-matches against. Root-causing that remains out of scope", + " — flagged, not worked around.", + "", + " BUT THE SIBLING CHECKOUT MUST BE AT ITS v0.5.0, and this is a real", + " prerequisite rather than a nicety: at 0.4.0 the sibling's own", + " polyengine-impl/deno.json declares `@polyengine/runtime@^0.4.0`, which", + " Deno DOES consult for this relative import — `deno info` showed a", + " second @polyengine/runtime@0.4.0 in the graph beside our 0.5.1, the", + " exact duplication this block exists to prevent. At v0.5.0 the sibling", + " is A22-clean: it names no @polyengine/runtime specifier at all and", + " takes @polyengine/protocol@^0.2.2, so one runtime serves the graph and", + " the lazy-bundling property is retained. Same class of prerequisite the", + " 0.3.1 -> 0.4.0 bump had, when the sibling was already on its 0.4.0." ], "nodeModulesDir": "auto", "minimumDependencyAge": "PT1M", "imports": { - "@polyengine/runtime/embedder": "jsr:@polyengine/runtime@0.4.0/embedder", - "@polyengine/translator": "jsr:@polyengine/translator@0.4.0", - "@polyengine/wasi": "jsr:@polyengine/wasi@0.4.0", - "@polyengine/wasi/http": "jsr:@polyengine/wasi@0.4.0/http", - "@polyengine/wasi/filesystem-node": "jsr:@polyengine/wasi@0.4.0/filesystem-node", - "@polyengine/wasi/filesystem-web": "jsr:@polyengine/wasi@0.4.0/filesystem-web", - "@polymorph/webcrypto-polyengine": "jsr:@polymorph/webcrypto@0.4.0", - "@polymorph/websocket-polyengine": "jsr:@polymorph/websocket@0.4.0", + "@polyengine/protocol": "jsr:@polyengine/protocol@0.2.3", + "@polyengine/runtime/embedder": "jsr:@polyengine/runtime@0.5.1/embedder", + "@polyengine/translator": "jsr:@polyengine/translator@0.5.1", + "@polyengine/wasi": "jsr:@polyengine/wasi@0.5.1", + "@polyengine/wasi/http": "jsr:@polyengine/wasi@0.5.1/http", + "@polyengine/wasi/filesystem-node": "jsr:@polyengine/wasi@0.5.1/filesystem-node", + "@polyengine/wasi/filesystem-web": "jsr:@polyengine/wasi@0.5.1/filesystem-web", + "@polymorph/webcrypto-polyengine": "jsr:@polymorph/webcrypto@0.5.0", + "@polymorph/websocket-polyengine": "jsr:@polymorph/websocket@0.5.0", "@polymorph/webrtc-polyengine": "../../polymorph-webrtc-datachannels/polyengine-impl/src/webrtc.ts", "node-datachannel": "npm:node-datachannel@0.32.3", "node-datachannel/polyfill": "npm:node-datachannel@0.32.3/polyfill", diff --git a/demo/deno.lock b/demo/deno.lock index cede804..1d7c93a 100644 --- a/demo/deno.lock +++ b/demo/deno.lock @@ -1,13 +1,15 @@ { "version": "5", "specifiers": { - "jsr:@polyengine/protocol@~0.2.1": "0.2.1", - "jsr:@polyengine/runtime@0.4": "0.4.0", - "jsr:@polyengine/runtime@0.4.0": "0.4.0", - "jsr:@polyengine/translator@0.4.0": "0.4.0", - "jsr:@polyengine/wasi@0.4.0": "0.4.0", - "jsr:@polymorph/webcrypto@0.4.0": "0.4.0", - "jsr:@polymorph/websocket@0.4.0": "0.4.0", + "jsr:@polyengine/protocol@0.2.3": "0.2.3", + "jsr:@polyengine/protocol@~0.2.2": "0.2.3", + "jsr:@polyengine/protocol@~0.2.3": "0.2.3", + "jsr:@polyengine/runtime@0.5.1": "0.5.1", + "jsr:@polyengine/runtime@~0.5.1": "0.5.1", + "jsr:@polyengine/translator@0.5.1": "0.5.1", + "jsr:@polyengine/wasi@0.5.1": "0.5.1", + "jsr:@polymorph/webcrypto@0.5.0": "0.5.0", + "jsr:@polymorph/websocket@0.5.0": "0.5.0", "jsr:@std/cli@^1.0.12": "1.0.32", "jsr:@std/encoding@^1.0.7": "1.0.11", "jsr:@std/fmt@^1.0.5": "1.0.10", @@ -23,38 +25,37 @@ "npm:werift@0.22.2": "0.22.2" }, "jsr": { - "@polyengine/protocol@0.2.1": { - "integrity": "857bef84afedc414d47daedf203448f72e8803cf96301afb783b1ac975f3dcec" + "@polyengine/protocol@0.2.3": { + "integrity": "e3f0ac4165eb691d7231164ce28cf75e275c99496aa538e032297febdc52b371" }, - "@polyengine/runtime@0.4.0": { - "integrity": "d34a59d9df58bd48541483619d1987319d8aad4d2e0f3775890177ffb0878a5a", + "@polyengine/runtime@0.5.1": { + "integrity": "6337c94d623bc2013fdeb324a5206860a916b8897b39cf4bec65b1bf87c287d0", "dependencies": [ - "jsr:@polyengine/protocol" + "jsr:@polyengine/protocol@~0.2.3" ] }, - "@polyengine/translator@0.4.0": { - "integrity": "227cf2f92a5b11bce6155742b2a42cf14a642a89d3867ffd1789d6e6e8d49fdc", + "@polyengine/translator@0.5.1": { + "integrity": "16d7ac88997f89f10145cc109737b749bfebd37c9ea1aefe0a41b44c01b75586", "dependencies": [ - "jsr:@polyengine/runtime@0.4" + "jsr:@polyengine/runtime@~0.5.1" ] }, - "@polyengine/wasi@0.4.0": { - "integrity": "b946443255e98cb6156237de90f051af7e483d2afdc0d721bdba64392cdbd896", + "@polyengine/wasi@0.5.1": { + "integrity": "1cb71ff91c24415d85a3b15bbb35462757586fa1478febba605cab4c1b2dbe72", "dependencies": [ - "jsr:@polyengine/protocol", - "jsr:@polyengine/runtime@0.4" + "jsr:@polyengine/protocol@~0.2.3" ] }, - "@polymorph/webcrypto@0.4.0": { - "integrity": "ffedef7d0141571a81aa55c47d892d3ff95e3ca6796815e231729f81f0286d3d", + "@polymorph/webcrypto@0.5.0": { + "integrity": "b9b03e2855a83f8817bb9d6f6a0cb5ec261a4dd3c09c957b741eeb7eba639ef9", "dependencies": [ - "jsr:@polyengine/runtime@0.4" + "jsr:@polyengine/protocol@~0.2.2" ] }, - "@polymorph/websocket@0.4.0": { - "integrity": "f2b77720ca02ba95d405a51abd4ca930074a22188ce23a5731c0a6b40f9c4bf2", + "@polymorph/websocket@0.5.0": { + "integrity": "84a7862e9bdeaedca92f7b57b12f5ee1348878734906feebb4d37634ac6659ae", "dependencies": [ - "jsr:@polyengine/runtime@0.4" + "jsr:@polyengine/protocol@~0.2.2" ] }, "@std/cli@1.0.32": { @@ -675,18 +676,19 @@ }, "workspace": { "dependencies": [ - "jsr:@polyengine/runtime@0.4.0", - "jsr:@polyengine/translator@0.4.0", - "jsr:@polyengine/wasi@0.4.0", - "jsr:@polymorph/webcrypto@0.4.0", - "jsr:@polymorph/websocket@0.4.0", + "jsr:@polyengine/protocol@0.2.3", + "jsr:@polyengine/runtime@0.5.1", + "jsr:@polyengine/translator@0.5.1", + "jsr:@polyengine/wasi@0.5.1", + "jsr:@polymorph/webcrypto@0.5.0", + "jsr:@polymorph/websocket@0.5.0", "npm:node-datachannel@0.32.3", "npm:werift@0.22.2" ], "links": { - "jsr:@polymorph/webrtc-datachannels@0.4.0": { + "jsr:@polymorph/webrtc-datachannels@0.5.0": { "dependencies": [ - "jsr:@polyengine/runtime@0.4", + "jsr:@polyengine/protocol@~0.2.2", "npm:node-datachannel@0.32.3", "npm:werift@0.22.2" ] diff --git a/demo/e2e/run.ts b/demo/e2e/run.ts index 44780cc..9363d48 100644 --- a/demo/e2e/run.ts +++ b/demo/e2e/run.ts @@ -505,8 +505,21 @@ async function main() { // absent on OPFS files in this build. The device store never asks for // one — it writes through `createWritable` — so the boot is green // regardless. Worth knowing before someone reaches for the sync API.) - const FIREFOX_PREFS: Record = { + // THE SECOND PREF, and it is a HARNESS fact rather than a lag in + // Playwright's build. `navigator.storage.persist()` under headless + // Playwright Firefox NEVER SETTLES — the promise neither resolves nor + // rejects — because the persistent-storage permission prompt has no UI + // to answer it and headless has nobody to click. Every kept-device + // ceremony awaits that call, so without this pref the whole + // keep-this-device family wedges on a promise that will not settle, + // which reads as a deadline failure in whatever act happened to be + // first. Setting the permission to 1 (ALLOW) answers the prompt the way + // a user who chose to keep the device already answered it. Measured + // 2026-08-23: with it, solo-persistence, solo-storage, solo-gdrive and + // solo-account-storage all pass under Gecko; without it, all four hang. + const FIREFOX_PREFS: Record = { "javascript.options.wasm_js_promise_integration": true, + "permissions.default.persistent-storage": 1, }; let firefoxBrowser: Browser | null = null; const launchFirefox = () => @@ -514,7 +527,26 @@ async function main() { /** Which engine the scenario now running asked for. `ctx.browser` and * `fresh` both read it through `current()` rather than closing over a * browser, for the same reason the chromium handle is a getter: the - * runner replaces a wedged browser underneath a scenario. */ + * runner replaces a wedged browser underneath a scenario. + * + * THE JUGGLER HAZARD, and it constrains how a Firefox-lane scenario may + * be WRITTEN, not just which browser it gets. Calling a + * `WebAssembly.promising` export from inside a `page.evaluate` frame + * SIGSEGVs the Firefox content process — measured 2026-08-23, 4 of 4 + * minimal cases, with the identical code in a PAGE SCRIPT running clean + * every time. The fault is in the Juggler protocol's evaluate frame + * meeting a JSPI stack switch, not in the engine: nothing in the wasm + * or the embedder differs between the two paths. Playwright reports it + * as a bare "Target crashed", which names nothing and sends the reader + * looking at the engine. + * + * So: a scenario on the firefox lane must reach engine work through + * page scripts, exposed bindings or event hooks — anything the page + * itself drives — and must never sit in an evaluate frame that reaches + * a promising export. `firefox-smoke` is written to that rule: it + * evaluates only FEATURE PROBES (typeof checks, constructor presence), + * which touch no promising export, and leaves engine instantiation to + * the page's own boot. */ let engine: "chromium" | "firefox" = "chromium"; const current = (): Browser => engine === "firefox" ? firefoxBrowser! : browser; diff --git a/demo/host/bringup.ts b/demo/host/bringup.ts index 4eb05d7..a7a7d7e 100644 --- a/demo/host/bringup.ts +++ b/demo/host/bringup.ts @@ -13,7 +13,7 @@ import { type Engine, hex, newEngine, unhex, until } from "../../runtime/engine.ts"; import { probeNet, probeNoNet, probeReaderNet } from "./probe-net.ts"; import { type FakeDrive, type FakeSpace, startFakeDrive } from "./fake-drive.ts"; -import { ComponentException } from "@polyengine/runtime/embedder"; +import { ComponentException } from "@polyengine/protocol"; import type { EngineNet, StoreFetch } from "../../runtime/engine.ts"; import { filesystemNode } from "@polyengine/wasi/filesystem-node"; diff --git a/demo/host/demo.ts b/demo/host/demo.ts index f1fa0b3..2060963 100644 --- a/demo/host/demo.ts +++ b/demo/host/demo.ts @@ -10,11 +10,8 @@ // `polyvisor:tasks` import is wired DIRECTLY to the engine // instance's export — the framework-links-apps-to-services topology. -import { - artifactsFromEnvelope, - ComponentException, - instantiate, -} from "@polyengine/runtime/embedder"; +import { artifactsFromEnvelope, instantiate } from "@polyengine/runtime/embedder"; +import { ComponentException } from "@polyengine/protocol"; import { createRunner, type Runner } from "../../visor/surface/runner.ts"; import { createFrameBackend } from "../../visor/frame/frame-backend.ts"; import { createSurface } from "../../visor/surface/surface.ts"; diff --git a/demo/host/probe-net.ts b/demo/host/probe-net.ts index 6b6c725..5007182 100644 --- a/demo/host/probe-net.ts +++ b/demo/host/probe-net.ts @@ -10,7 +10,7 @@ // the SHAPE under test is which import a call site travels through and // what each seam will refuse, not per-user authority. -import { ComponentException } from "@polyengine/runtime/embedder"; +import { ComponentException } from "@polyengine/protocol"; import type { EngineNet, StoreFetch, StoreSign } from "../../runtime/engine.ts"; function refuse(message: string): never { diff --git a/runtime/PERSISTENCE.md b/runtime/PERSISTENCE.md index 6cbe1de..7435e04 100644 --- a/runtime/PERSISTENCE.md +++ b/runtime/PERSISTENCE.md @@ -531,3 +531,83 @@ shipped. out yet. - POLYVISOR-1 (#83): the docs spike's vendored deltic-0.1.0 bundle — still its own turn, untouched by this bump. + +### Addendum — 0.4.0 → 0.5.1, EXECUTED 2026-08-23 + +A second bump followed within the day, and it was a BUG FIX first and a +version-tracking exercise second. + +- **Why**: polyengine#239 (af97c13, in v0.5.1). 0.4.0's `driveAsync` took + its speculative pending-resumption entry UNCONDITIONALLY + (0.4.0 `src/exec/boundary.ts:1064`), and that entry is a **store-wide** + scheduling gate: `Store.tick` refuses while it is non-empty, and every + `driveAsync` hops at its top under a 10,000-hop bound. Two of this + device's OWN periodic drivers collide there. The worker's 500 ms + debounced, **non-blocking** state-checkpoint (worker.ts's "Ordinary + driver/tasks calls are NOT blocked behind a checkpoint" — a recorded + design decision, not an accident) is near-always in flight; the solo + page's 1 s `us-events` drain (demo/host/solo.ts) arrives into it and can + only hop. Ten thousand hops later the internal-bug assert fires with the + user-facing text `driveAsync: a resumed-activation claim was never + released`. The fix guards exactly that line: the entry is taken only by + the sole driver, plus a driver-arrival one-shot that wakes the incumbent + promptly. +- **What it is NOT**, because the first diagnosis got this wrong and the + retraction is worth keeping legible: there is **no latency threshold** + and no egress involved — the storm reproduces with the harness + answering instantly. There is **no Gecko differential**; a nine-case + JSPI ordering probe found SpiderMonkey ≡ V8. It was field-reported from + mobile Firefox only because slow OPFS widens the window between the two + drivers. The fix is upstream, not a polyvisor-side serialization, which + would have contradicted the non-blocking-checkpoint decision above. +- **The gate**: devstore **row 47b** — 12 s of back-to-back + `state-checkpoint` and `us-events` calls on one store. RED on stock + 0.4.0 (21 checkpoints, **1** drain, then the trap); GREEN on 0.5.1 (107 + checkpoints, **108** drains, no trap). The drain count is the + interesting half: it measures the starvation directly, and its recovery + is what validates the shipped fix's driver-arrival wake rather than + merely the removal of the entry. +- **Pins** (done): demo/deno.json and runtime/tests/devstore/deno.json — + @polyengine runtime/wasi/translator 0.4.0 → **0.5.1**; @polymorph + webcrypto/websocket 0.4.0 → **0.5.0**; plus a NEW + **@polyengine/protocol@0.2.3** pin, which A22 makes necessary. +- **A22 is the breaking part** (polyengine 1e31210): `@polyengine/runtime + /embedder` is application-only now — the A9 courtesy re-exports and the + concrete handle classes are gone. `ComponentException`, + `isComponentException`, `isTrap`, `toCloneable`, `fromCloneable` and + `suspending` all moved to `@polyengine/protocol`. Eleven modules changed + their import line and nothing else: pairing-engine, stubs, keystore, + store-egress, device-store/{worker,client}, tests/devstore/page, + demo/host/{demo,bringup,probe-net}, spikes/worker-host/worker. + `instantiate`/`artifactsFromEnvelope` stayed in the embedder — that is + A22's dividing line, and engine.ts and solo.ts were untouched. + Consequently the ports no longer constrain our runtime version at all: + webcrypto and websocket 0.5.0 name no `@polyengine/runtime` specifier + anywhere. +- **wasi/fs** (watched, no change needed): f07a3b9 made directory-mutating + ops require the `mutate-directory` flag specifically, refusing read-only + with the WIT-mandated code. Every preopen this repo builds passes + `writable: true`, which the provider turns into + read+write+mutate-directory on the preopened descriptor + (`fs_provider.ts`'s `PREOPEN_FLAGS`), so the sealed-fs wrapper and the + engine's state root are unaffected — sealed-fs row 7 and the resume + batteries confirm. Note this DOES invalidate the previous bump's "no + wasi/ changes in the window" line; there were changes, they just landed + on the permissive side of our usage. +- **The webrtc sibling checkout must now be at its v0.5.0**, and this is a + hard prerequisite rather than hygiene: at 0.4.0 the sibling's own + `polyengine-impl/deno.json` declares `@polyengine/runtime@^0.4.0`, and + Deno **does** consult it for our relative import — `deno info` showed a + second `@polyengine/runtime@0.4.0` beside our 0.5.1. Moving to the JSR + copy instead is still blocked by the same `deno bundle --external` + hazard the last bump recorded, re-measured here (the `node:` tripwire + fires). At v0.5.0 the sibling is A22-clean — no runtime specifier at all + — so one runtime serves the graph and lazy bundling is retained. +- **Harness findings folded in**: `demo/e2e/run.ts`'s FIREFOX_PREFS gains + `"permissions.default.persistent-storage": 1` (headless Playwright + Firefox never settles `navigator.storage.persist()` without it, wedging + every kept-device ceremony), and the engine-selection machinery carries + the measured Juggler hazard — calling a `WebAssembly.promising` export + from inside a `page.evaluate` frame SIGSEGVs the Firefox content + process, so Firefox-lane scenarios must drive through page + scripts/hooks. diff --git a/runtime/device-store/client.ts b/runtime/device-store/client.ts index 2c0b237..38aed58 100644 --- a/runtime/device-store/client.ts +++ b/runtime/device-store/client.ts @@ -18,9 +18,11 @@ // header), so THE TAB resolves the pointer and hands the worker a // concrete id. // -// ONE PACKAGE IMPORT, AND IT IS NEW AT 0.4.0. This module used to be -// package-free; `fromCloneable` (@polyengine/runtime/embedder) changed -// that, deliberately. It is what turns the worker's engine rejection +// ONE PACKAGE IMPORT, NEW AT 0.4.0 AND RE-HOMED AT 0.5.1. This module +// used to be package-free; `fromCloneable` changed that, deliberately. It +// came from `@polyengine/runtime/embedder` until polyengine A22 made that +// module application-only and moved the vocabulary to +// `@polyengine/protocol`, which is where it is imported from now. It is what turns the worker's engine rejection // back into a REAL branded `ComponentException` in this realm — payload, // cause chain, sender's stack — instead of the facsimile the old // hand-rolled brand produced. The trade is stated rather than hidden: @@ -29,7 +31,7 @@ // (runtime/README.md's resolution model). The engine TYPES below are // type-only and still erase. -import { fromCloneable } from "@polyengine/runtime/embedder"; +import { fromCloneable } from "@polyengine/protocol"; import type { Driver, Tasks } from "../engine.ts"; import { adoptAnchor, setAnchor } from "./anchor.ts"; import { diff --git a/runtime/device-store/rpc.ts b/runtime/device-store/rpc.ts index 10a350b..80aec0d 100644 --- a/runtime/device-store/rpc.ts +++ b/runtime/device-store/rpc.ts @@ -16,8 +16,9 @@ // say so plainly rather than let a reader infer it. Since 0.4.0 the // engine-error path crosses as the embedder's SANCTIONED CLONEABLE FORM // (A20), so worker.ts calls `toCloneable` and client.ts calls -// `fromCloneable` — both real value imports from -// @polyengine/runtime/embedder. worker.ts always needed the pin (it +// `fromCloneable` — both real value imports from @polyengine/protocol +// (they lived in @polyengine/runtime/embedder until A22 made that module +// application-only). worker.ts always needed the pin (it // instantiates the engine); client.ts did not, and now does. What is // still package-free is everything BELOW the host: the index, the // namespace, the seal ladder, the locks, the anchor and this file — so a diff --git a/runtime/device-store/worker.ts b/runtime/device-store/worker.ts index fb3a83d..1c1bc97 100644 --- a/runtime/device-store/worker.ts +++ b/runtime/device-store/worker.ts @@ -47,7 +47,7 @@ import { isComponentException, isTrap, toCloneable, -} from "@polyengine/runtime/embedder"; +} from "@polyengine/protocol"; import { type DeviceIdentityFragment, type Engine, @@ -57,8 +57,9 @@ import { } from "../engine.ts"; // THE STORAGE EGRESS SEAMS AND THE ESCROW, both runtime modules and // both already inside this file's pin set: keystore.ts and -// store-egress.ts import `@polyengine/runtime/embedder`, which worker.ts -// pins anyway for the cloneable error forms and `ComponentException`, so +// store-egress.ts import `@polyengine/protocol` (they took it from +// `@polyengine/runtime/embedder` until A22 moved the vocabulary), which +// worker.ts pins anyway for the cloneable forms and `ComponentException`, so // neither adds a resolution burden to this graph (runtime/README.md's // model; the device-store CORE modules stay package-free, this entry // point never was). The keystore read is a plain same-origin IndexedDB diff --git a/runtime/keystore.ts b/runtime/keystore.ts index a2b0785..286aad3 100644 --- a/runtime/keystore.ts +++ b/runtime/keystore.ts @@ -26,7 +26,7 @@ // whole chain inside the platform (a webcrypto SigV4 primitive), which // does not exist. -import { ComponentException } from "@polyengine/runtime/embedder"; +import { ComponentException } from "@polyengine/protocol"; const DB_NAME = "pm-demo-keystore"; const DB_VERSION = 1; diff --git a/runtime/pairing-engine.ts b/runtime/pairing-engine.ts index 1db2d34..79e4bef 100644 --- a/runtime/pairing-engine.ts +++ b/runtime/pairing-engine.ts @@ -55,7 +55,7 @@ import type { Driver, PairAddState, PairJoinState, UsEvent, UsMark, UsProfile } from "./engine.ts"; import { hex, unhex } from "./engine.ts"; -import { isComponentException } from "@polyengine/runtime/embedder"; +import { isComponentException } from "@polyengine/protocol"; import type { PairAddState as MockPairAddState, PairEnrollment as MockPairEnrollment, diff --git a/runtime/store-egress.ts b/runtime/store-egress.ts index 9059981..9a7a4b1 100644 --- a/runtime/store-egress.ts +++ b/runtime/store-egress.ts @@ -15,7 +15,7 @@ // `fetch`/`URL`/`TextEncoder`/`TextDecoder`/`btoa` (all present in // workers). -import { ComponentException } from "@polyengine/runtime/embedder"; +import { ComponentException } from "@polyengine/protocol"; import type { StoreFetch } from "./engine.ts"; /** `throw new ComponentException(payload)` is the err side of a diff --git a/runtime/stubs.ts b/runtime/stubs.ts index b687bd9..69a60ac 100644 --- a/runtime/stubs.ts +++ b/runtime/stubs.ts @@ -26,7 +26,7 @@ // extension once polyengine's wasi-shims grow p3 `wasi:sockets` providers // (polymorph-components/polyengine#4); this file is where that provider would be wired. -import { ComponentException } from "@polyengine/runtime/embedder"; +import { ComponentException } from "@polyengine/protocol"; /** `wasi:sockets/types@0.3.0`'s `ip-address-family` enum. */ export type IpAddressFamily = "ipv4" | "ipv6"; diff --git a/runtime/tests/devstore/deno.json b/runtime/tests/devstore/deno.json index 01d77e1..14510a5 100644 --- a/runtime/tests/devstore/deno.json +++ b/runtime/tests/devstore/deno.json @@ -20,23 +20,34 @@ "@polymorph/webrtc-datachannels stays on the sibling checkout for the", "reason demo/deno.json documents at length (the JSR copy defeats", "`deno bundle --external`, leaving eager `node:` imports the browser", - "cannot fetch). This file sits three directories below the repo root", + "cannot fetch). The sibling checkout MUST BE AT ITS v0.5.0: at 0.4.0 its", + "own deno.json's `@polyengine/runtime@^0.4.0` pulls a SECOND runtime", + "into this graph (measured with `deno info` at this bump). This file", + "sits three directories below the repo root", "(runtime/tests/devstore), and a relative import-map entry resolves", "against THIS file, so the sibling checkout is four `..` away — two", "more than demo/deno.json's.", "", + "SINCE A22 the worker recognizes the engine's err arms by BRAND", + "PREDICATE out of @polyengine/protocol (`isComponentException`/`isTrap`)", + "rather than by `instanceof` against an embedder class, so protocol", + "copies would be harmless. The one-runtime requirement above is", + "UNCHANGED regardless: the worker holds real embedder state, and two", + "runtimes would be two engines.", + "", "Any bump in demo/deno.json must be mirrored here or this harness", "stops measuring the engine the demo ships." ], "nodeModulesDir": "auto", "minimumDependencyAge": "PT1M", "imports": { - "@polyengine/runtime/embedder": "jsr:@polyengine/runtime@0.4.0/embedder", - "@polyengine/wasi": "jsr:@polyengine/wasi@0.4.0", - "@polyengine/wasi/http": "jsr:@polyengine/wasi@0.4.0/http", - "@polyengine/wasi/filesystem-web": "jsr:@polyengine/wasi@0.4.0/filesystem-web", - "@polymorph/webcrypto-polyengine": "jsr:@polymorph/webcrypto@0.4.0", - "@polymorph/websocket-polyengine": "jsr:@polymorph/websocket@0.4.0", + "@polyengine/protocol": "jsr:@polyengine/protocol@0.2.3", + "@polyengine/runtime/embedder": "jsr:@polyengine/runtime@0.5.1/embedder", + "@polyengine/wasi": "jsr:@polyengine/wasi@0.5.1", + "@polyengine/wasi/http": "jsr:@polyengine/wasi@0.5.1/http", + "@polyengine/wasi/filesystem-web": "jsr:@polyengine/wasi@0.5.1/filesystem-web", + "@polymorph/webcrypto-polyengine": "jsr:@polymorph/webcrypto@0.5.0", + "@polymorph/websocket-polyengine": "jsr:@polymorph/websocket@0.5.0", "@polymorph/webrtc-polyengine": "../../../../polymorph-webrtc-datachannels/polyengine-impl/src/webrtc.ts", "node-datachannel": "npm:node-datachannel@0.32.3", "node-datachannel/polyfill": "npm:node-datachannel@0.32.3/polyfill", diff --git a/runtime/tests/devstore/deno.lock b/runtime/tests/devstore/deno.lock index 1889ccc..4bc38ee 100644 --- a/runtime/tests/devstore/deno.lock +++ b/runtime/tests/devstore/deno.lock @@ -1,12 +1,13 @@ { "version": "5", "specifiers": { - "jsr:@polyengine/protocol@~0.2.1": "0.2.1", - "jsr:@polyengine/runtime@0.4": "0.4.0", - "jsr:@polyengine/runtime@0.4.0": "0.4.0", - "jsr:@polyengine/wasi@0.4.0": "0.4.0", - "jsr:@polymorph/webcrypto@0.4.0": "0.4.0", - "jsr:@polymorph/websocket@0.4.0": "0.4.0", + "jsr:@polyengine/protocol@0.2.3": "0.2.3", + "jsr:@polyengine/protocol@~0.2.2": "0.2.3", + "jsr:@polyengine/protocol@~0.2.3": "0.2.3", + "jsr:@polyengine/runtime@0.5.1": "0.5.1", + "jsr:@polyengine/wasi@0.5.1": "0.5.1", + "jsr:@polymorph/webcrypto@0.5.0": "0.5.0", + "jsr:@polymorph/websocket@0.5.0": "0.5.0", "jsr:@std/cli@^1.0.12": "1.0.32", "jsr:@std/encoding@^1.0.7": "1.0.11", "jsr:@std/fmt@^1.0.5": "1.0.10", @@ -22,32 +23,31 @@ "npm:werift@0.22.2": "0.22.2" }, "jsr": { - "@polyengine/protocol@0.2.1": { - "integrity": "857bef84afedc414d47daedf203448f72e8803cf96301afb783b1ac975f3dcec" + "@polyengine/protocol@0.2.3": { + "integrity": "e3f0ac4165eb691d7231164ce28cf75e275c99496aa538e032297febdc52b371" }, - "@polyengine/runtime@0.4.0": { - "integrity": "d34a59d9df58bd48541483619d1987319d8aad4d2e0f3775890177ffb0878a5a", + "@polyengine/runtime@0.5.1": { + "integrity": "6337c94d623bc2013fdeb324a5206860a916b8897b39cf4bec65b1bf87c287d0", "dependencies": [ - "jsr:@polyengine/protocol" + "jsr:@polyengine/protocol@~0.2.3" ] }, - "@polyengine/wasi@0.4.0": { - "integrity": "b946443255e98cb6156237de90f051af7e483d2afdc0d721bdba64392cdbd896", + "@polyengine/wasi@0.5.1": { + "integrity": "1cb71ff91c24415d85a3b15bbb35462757586fa1478febba605cab4c1b2dbe72", "dependencies": [ - "jsr:@polyengine/protocol", - "jsr:@polyengine/runtime@0.4" + "jsr:@polyengine/protocol@~0.2.3" ] }, - "@polymorph/webcrypto@0.4.0": { - "integrity": "ffedef7d0141571a81aa55c47d892d3ff95e3ca6796815e231729f81f0286d3d", + "@polymorph/webcrypto@0.5.0": { + "integrity": "b9b03e2855a83f8817bb9d6f6a0cb5ec261a4dd3c09c957b741eeb7eba639ef9", "dependencies": [ - "jsr:@polyengine/runtime@0.4" + "jsr:@polyengine/protocol@~0.2.2" ] }, - "@polymorph/websocket@0.4.0": { - "integrity": "f2b77720ca02ba95d405a51abd4ca930074a22188ce23a5731c0a6b40f9c4bf2", + "@polymorph/websocket@0.5.0": { + "integrity": "84a7862e9bdeaedca92f7b57b12f5ee1348878734906feebb4d37634ac6659ae", "dependencies": [ - "jsr:@polyengine/runtime@0.4" + "jsr:@polyengine/protocol@~0.2.2" ] }, "@std/cli@1.0.32": { @@ -668,17 +668,18 @@ }, "workspace": { "dependencies": [ - "jsr:@polyengine/runtime@0.4.0", - "jsr:@polyengine/wasi@0.4.0", - "jsr:@polymorph/webcrypto@0.4.0", - "jsr:@polymorph/websocket@0.4.0", + "jsr:@polyengine/protocol@0.2.3", + "jsr:@polyengine/runtime@0.5.1", + "jsr:@polyengine/wasi@0.5.1", + "jsr:@polymorph/webcrypto@0.5.0", + "jsr:@polymorph/websocket@0.5.0", "npm:node-datachannel@0.32.3", "npm:werift@0.22.2" ], "links": { - "jsr:@polymorph/webrtc-datachannels@0.4.0": { + "jsr:@polymorph/webrtc-datachannels@0.5.0": { "dependencies": [ - "jsr:@polyengine/runtime@0.4", + "jsr:@polyengine/protocol@~0.2.2", "npm:node-datachannel@0.32.3", "npm:werift@0.22.2" ] diff --git a/runtime/tests/devstore/page.ts b/runtime/tests/devstore/page.ts index 980f08f..03c5956 100644 --- a/runtime/tests/devstore/page.ts +++ b/runtime/tests/devstore/page.ts @@ -46,7 +46,7 @@ import type { OauthStartSpec, StoreBinding } from "../../device-store/rpc.ts"; // the 0.4.0 bump is that `fromCloneable` mints a value this copy // recognizes — so the predicate has to be the page's own, not the // worker's, and not a field the worker asserted about itself. -import { isComponentException, isTrap } from "@polyengine/runtime/embedder"; +import { isComponentException, isTrap } from "@polyengine/protocol"; import { adoptAnchor, anchorIsLive, @@ -1322,6 +1322,62 @@ const ops: Record Promise> = { return { attempt: await refuses(() => conn.driver.ensureBucket()) }; }, + /** RECONCILIATION ROUND — the MINIMAL flip for polyengine#239. + * + * No slow network, no egress at all. Two of this device's OWN periodic + * drivers, run back to back on one store: + * + * * `state-checkpoint` — the worker's non-blocking, debounced + * checkpoint (device-store/worker.ts ~1737: "Ordinary driver/tasks + * calls are NOT blocked behind a checkpoint"). While it is parked in + * `driveAsync`'s awaiting-race it holds the SPECULATIVE + * pending-resumption entry, which 0.4.0 takes unconditionally + * (0.4.0 src/exec/boundary.ts:1064). + * * `us-events` — the account event drain the solo page runs every + * second forever (demo/host/solo.ts:3271, `poll(1000, + * drainAndAdopt)`). + * + * The entry is a STORE-WIDE gate, so the us-events driver can only hop + * at the top of its own loop until the checkpoint's host calls answer. + * Past 10,000 hops that is an assert — a TRAP, not a refusal. + * af97c13 (#239) bounds the entry to the sole driver. + * + * Measured: the trigger is CONCURRENCY, not latency — it fires with the + * recorder answering instantly. */ + "hc-driver-gate-storm": async (arg: { id: string; ms?: number }) => { + const conn = conns.get(arg.id)!; + const until = Date.now() + (arg.ms ?? 12_000); + let checkpoints = 0; + let drains = 0; + let trap = ""; + const note = (e: unknown) => { + const m = String((e as Error)?.message ?? e); + if (trap === "" && /resumed-activation claim|driveAsync/.test(m)) trap = m.slice(0, 240); + }; + const checkpointLoop = (async () => { + while (Date.now() < until && trap === "") { + try { + await conn.checkpoint(); + checkpoints++; + } catch (e) { + note(e); + } + } + })(); + const drainLoop = (async () => { + while (Date.now() < until && trap === "") { + try { + await conn.driver.usEvents(); + drains++; + } catch (e) { + note(e); + } + } + })(); + await Promise.all([checkpointLoop, drainLoop]); + return { checkpoints, drains, trap, alive: await refuses(() => conn.status()) }; + }, + /** ROW 32's other half: `unbindStore` refuses at the seam, never * silently. */ "hc-unbind": async (arg: { id: string }) => { diff --git a/runtime/tests/devstore/run.ts b/runtime/tests/devstore/run.ts index a6bc4de..93f960a 100644 --- a/runtime/tests/devstore/run.ts +++ b/runtime/tests/devstore/run.ts @@ -2599,6 +2599,57 @@ async function main() { await probe(page, "hc-forget", { ids: [id] }); }); + // --- 47b: the MINIMAL driver-gate storm (no egress, no latency) ------- + // + // THE REGRESSION GATE for polyengine#239, and the reason this repo + // moved to @polyengine/runtime 0.5.1. + // + // Two of the device's OWN periodic drivers, run back to back on one + // store — no network, no egress, nothing slow. 0.4.0's `driveAsync` + // took its speculative pending-resumption entry unconditionally + // (0.4.0 src/exec/boundary.ts:1064) and that entry is a store-wide + // gate, so the second driver could only hop at the top of its loop; + // 10,000 hops later the internal-bug assert fires. af97c13 bounds the + // entry to the sole driver and adds a driver-arrival wake. + // + // THE DRAIN COUNT IS THE INTERESTING NUMBER, not just the trap: it + // measures the starvation directly. Measured 21 checkpoints / 1 drain + // / trap on stock 0.4.0; 107 / 108 / no trap on 0.5.1. A fix that + // merely deleted the entry would clear the trap; the drain count + // recovering is what shows the shipped driver-arrival wake working. + await guard(async () => { + const made = await probe(page, "hc-make", { + petname: "gate storm", + policy: "until-reseal", + promote: true, + }); + const id = made.id as string; + await probe(page, "hc-open", { id, unseal: { passphrase: PASS, untilReseal: true } }); + await probe(page, "hc-add", { id, titles: ["gate-storm"] }); + const r = await probe(page, "hc-driver-gate-storm", { id, ms: 12_000 }); + record( + "47b store-egress", + "two of the device's own periodic drivers do not trip the speculative resume gate", + r.trap === "" && r.checkpoints > 0 && r.drains > 0, + `12 s of back-to-back \`state-checkpoint\` and \`us-events\` calls on ONE store: ` + + `${r.checkpoints} checkpoints and ${r.drains} drains completed, trap=${j(r.trap)}, ` + + `and the device still answers afterwards (refused=${r.alive.refused}). Both callers ` + + `are drivers this device runs on its OWN timers — the worker's 500 ms debounced ` + + `non-blocking checkpoint (worker.ts) and the solo page's 1 s us-events drain ` + + `(solo.ts) — so this collision is the ordinary case, not an exotic one. THE TRIGGER ` + + `IS CONCURRENCY, NOT LATENCY: no slow network, no egress and no Drive round trip is ` + + `involved anywhere in this row, and there is no Gecko differential; mobile Firefox ` + + `merely reported it first because slow OPFS widens the window. On 0.4.0 this row ` + + `FAILS — the us-events driver is starved to a single drain and then dies on ` + + `"driveAsync: a resumed-activation claim was never released" (polyengine#239, the ` + + `store-wide speculative resume gate). af97c13 in v0.5.1 bounds that entry to the ` + + `sole driver and wakes the incumbent on driver arrival, which is what the drain ` + + `count recovering measures.`, + ); + await probe(page, "hc-close", { id }); + await probe(page, "hc-forget", { ids: [id] }); + }); + await ctx.close(); } finally { await browser.close(); diff --git a/scripts/setup.sh b/scripts/setup.sh index 7de07e8..338b706 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -45,7 +45,7 @@ JUST_VERSION="${JUST_VERSION:-1.54.0}" # still a live sibling consumer (demo/deno.json maps it there) and # stays checked out below. WEBRTC_REPO=https://github.com/polymorph-components/polymorph-webrtc-datachannels.git -WEBRTC_PIN=42f76c988c3356e62abf33af6a04f1daf8f0839c +WEBRTC_PIN=db187f4b7d9d72bdc673ddb91c3170f0d9c7e325 # v0.5.0 — A22-clean: no runtime pin of its own, couples to @polyengine/protocol; the 0.5.1 bump needs it log() { printf '\n==> %s\n' "$1"; } diff --git a/spikes/worker-host/worker.ts b/spikes/worker-host/worker.ts index a2bcf90..ded5d27 100644 --- a/spikes/worker-host/worker.ts +++ b/spikes/worker-host/worker.ts @@ -11,7 +11,7 @@ // Loaded as a MODULE SharedWorker (`{type:"module"}`) — see page.ts. import { hex, newEngine, unhex } from "../../runtime/engine.ts"; -import { ComponentException } from "@polyengine/runtime/embedder"; +import { ComponentException } from "@polyengine/protocol"; import { filesystemWeb } from "@polyengine/wasi/filesystem-web"; // --- worker-global identity + boot counter (question 4) ---------------------