From e12439033d29cace787b622526cb18b2b8864bf7 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Mon, 7 Sep 2026 12:18:11 -0400 Subject: [PATCH 1/2] polyengine 0.6.7: the parking events.next returns; #292 workaround deleted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit polyengine 0.6.6 fixed #292 (an async export parked on a guest-internal waker with no host call outstanding stays pending instead of trapping as a deadlock), and 0.6.7 fixes the lift regression that fix introduced (#312: the lift re-derived its done() verdict after the driver exited, racing the settlement pump; polyvisor's `lifecycle.boot` never resolved on a reload with a bound store). This tree pins ^0.6.7. With that, the first design comes back: the runtime exports a parking `events.next` and the worker runs one pump over it, fanning each event into every tab's queue. `interface event-source` and its `drain`, the worker's `draining()` wrapper around every dispatched export, and the kernel's drain-only queue are deleted. Consequence stated in design.md rule 4: an event born of network activity alone — the other device confirming a pairing, an enrollment landing — reaches the screen with nothing pressed on this device. The e2e pairing helpers drop the Refresh presses that existed only to carry those phases across, so the suite now exercises the push path; `waitInDevices` takes a required `{ refresh }` so each wait states whether its value is pushed or pulled. Also: `web/fetch-endpoint.ts` derives the `@polymorph/iroh` version from the resolved specifier instead of a hand pin — the lock had moved to 0.6.1 while the cached endpoint artifact was still 0.6.0 under a hard-coded name. `just compose` plugs a stable, version-free path. The lock's @polymorph/{iroh,webcrypto,websocket} 0.6.0 -> 0.6.1 moves ride along deliberately (within the declared ^0.6 range; e2e green). --- deno.json | 14 +-- deno.lock | 50 +++++----- docs/design.md | 23 +++-- e2e/run.ts | 30 +++--- justfile | 2 +- runtime/component/src/component.rs | 32 +++---- runtime/crates/kernel/src/events.rs | 55 ++++++++--- runtime/crates/kernel/src/lib.rs | 11 +-- runtime/crates/kernel/tests/kernel.rs | 78 +++++++++++++--- runtime/wit/internal.wit | 40 ++++---- visor/src/state.rs | 3 +- visor/src/ui.rs | 3 +- web/fetch-endpoint.ts | 24 ++++- web/worker.ts | 127 +++++++++----------------- 14 files changed, 274 insertions(+), 218 deletions(-) diff --git a/deno.json b/deno.json index f299b20a..d2e7d863 100644 --- a/deno.json +++ b/deno.json @@ -1,14 +1,14 @@ { "nodeModulesDir": "auto", "imports": { - "@polyengine/runtime/embedder": "jsr:@polyengine/runtime@^0.6.4/embedder", - "@polyengine/runtime/shim": "jsr:@polyengine/runtime@^0.6.4/shim", - "@polyengine/runtime/plan": "jsr:@polyengine/runtime@^0.6.4/plan", - "@polyengine/translator": "jsr:@polyengine/translator@^0.6.4", + "@polyengine/runtime/embedder": "jsr:@polyengine/runtime@^0.6.7/embedder", + "@polyengine/runtime/shim": "jsr:@polyengine/runtime@^0.6.7/shim", + "@polyengine/runtime/plan": "jsr:@polyengine/runtime@^0.6.7/plan", + "@polyengine/translator": "jsr:@polyengine/translator@^0.6.7", "@polyengine/protocol": "jsr:@polyengine/protocol@^0.3.1", - "@polyengine/wasi": "jsr:@polyengine/wasi@^0.6.4", - "@polyengine/wasi/filesystem-web": "jsr:@polyengine/wasi@^0.6.4/filesystem-web", - "@polyengine/wasi/http": "jsr:@polyengine/wasi@^0.6.4/http", + "@polyengine/wasi": "jsr:@polyengine/wasi@^0.6.7", + "@polyengine/wasi/filesystem-web": "jsr:@polyengine/wasi@^0.6.7/filesystem-web", + "@polyengine/wasi/http": "jsr:@polyengine/wasi@^0.6.7/http", "@polymorph/webcrypto": "jsr:@polymorph/webcrypto@^0.6.0", "@polymorph/websocket": "jsr:@polymorph/websocket@^0.6.0", "@polymorph/webrtc-datachannels": "jsr:@polymorph/webrtc-datachannels@^0.6.0", diff --git a/deno.lock b/deno.lock index c2fe12b8..56a24883 100644 --- a/deno.lock +++ b/deno.lock @@ -2,16 +2,16 @@ "version": "5", "specifiers": { "jsr:@polyengine/protocol@~0.3.1": "0.3.1", - "jsr:@polyengine/runtime@~0.6.3": "0.6.4", - "jsr:@polyengine/runtime@~0.6.4": "0.6.4", - "jsr:@polyengine/translator@~0.6.3": "0.6.4", - "jsr:@polyengine/translator@~0.6.4": "0.6.4", - "jsr:@polyengine/wasi@~0.6.3": "0.6.4", - "jsr:@polyengine/wasi@~0.6.4": "0.6.4", - "jsr:@polymorph/iroh@0.6": "0.6.0", - "jsr:@polymorph/webcrypto@0.6": "0.6.0", + "jsr:@polyengine/runtime@~0.6.3": "0.6.7", + "jsr:@polyengine/runtime@~0.6.7": "0.6.7", + "jsr:@polyengine/translator@~0.6.3": "0.6.7", + "jsr:@polyengine/translator@~0.6.7": "0.6.7", + "jsr:@polyengine/wasi@~0.6.3": "0.6.7", + "jsr:@polyengine/wasi@~0.6.7": "0.6.7", + "jsr:@polymorph/iroh@0.6": "0.6.1", + "jsr:@polymorph/webcrypto@0.6": "0.6.1", "jsr:@polymorph/webrtc-datachannels@0.6": "0.6.2", - "jsr:@polymorph/websocket@0.6": "0.6.0", + "jsr:@polymorph/websocket@0.6": "0.6.1", "jsr:@std/assert@1": "1.0.19", "jsr:@std/fs@1": "1.0.24", "jsr:@std/internal@^1.0.12": "1.0.14", @@ -27,26 +27,26 @@ "@polyengine/protocol@0.3.1": { "integrity": "3cba0ded857c5cc911fcf9c0782c7566c79af89ddc8e0ec4e48ece60c807a518" }, - "@polyengine/runtime@0.6.4": { - "integrity": "e33a1bc82ffb128b0bfd2d0830b5d278396f96459b28ba49a07b8ed2669480fd", + "@polyengine/runtime@0.6.7": { + "integrity": "396f92f2a744b1fc99d382a911cb0f1ca388f1b36556b1a109b3acf96a932818", "dependencies": [ "jsr:@polyengine/protocol" ] }, - "@polyengine/translator@0.6.4": { - "integrity": "87c667f254ae93738ed1cdad0e62295814d211df5be0cbfe30e8545b40156193", + "@polyengine/translator@0.6.7": { + "integrity": "6797db05f37e00a5e466da7641722c9deb444051a9384ac04290e2b231944555", "dependencies": [ - "jsr:@polyengine/runtime@~0.6.4" + "jsr:@polyengine/runtime@~0.6.7" ] }, - "@polyengine/wasi@0.6.4": { - "integrity": "3402d542ede9126ec62102f51f7c6463c52d3183fb0539a63e3b00c58eb30d22", + "@polyengine/wasi@0.6.7": { + "integrity": "df9c0d2bc6b2c57373941852546c3a587e33cc6be91d6124d33250c594c4e204", "dependencies": [ "jsr:@polyengine/protocol" ] }, - "@polymorph/iroh@0.6.0": { - "integrity": "66608ff41b7de97bf1659003f25a91c6ac06cd20e2511c7be13400cb3ba32143", + "@polymorph/iroh@0.6.1": { + "integrity": "5a6007af90a84ef0b9a69c5dc74ecd51faeaed3d016d274cb21877ce5bf89605", "dependencies": [ "jsr:@polyengine/protocol", "jsr:@polyengine/runtime@~0.6.3", @@ -57,8 +57,8 @@ "jsr:@polymorph/websocket" ] }, - "@polymorph/webcrypto@0.6.0": { - "integrity": "3157cf069c2d3591d215e07b617870c81f88ae9eebe737c74ec5daeddc44b8b5", + "@polymorph/webcrypto@0.6.1": { + "integrity": "b21fdb9ee9ef0904db4161c8e393b9d3d7503cf147c6ad54da6de2da24dd8194", "dependencies": [ "jsr:@polyengine/protocol" ] @@ -70,8 +70,8 @@ "npm:werift" ] }, - "@polymorph/websocket@0.6.0": { - "integrity": "8e27a2603479d76edcc8c9a3bbfd22d05663da34e9bbdf4ad1326cf18d610c6c", + "@polymorph/websocket@0.6.1": { + "integrity": "e40c3143e53e919f23385b36c673918aa24b9e59627bc9b37328df5c4b0e4b4f", "dependencies": [ "jsr:@polyengine/protocol" ] @@ -422,9 +422,9 @@ "workspace": { "dependencies": [ "jsr:@polyengine/protocol@~0.3.1", - "jsr:@polyengine/runtime@~0.6.4", - "jsr:@polyengine/translator@~0.6.4", - "jsr:@polyengine/wasi@~0.6.4", + "jsr:@polyengine/runtime@~0.6.7", + "jsr:@polyengine/translator@~0.6.7", + "jsr:@polyengine/wasi@~0.6.7", "jsr:@polymorph/iroh@0.6", "jsr:@polymorph/webcrypto@0.6", "jsr:@polymorph/webrtc-datachannels@0.6", diff --git a/docs/design.md b/docs/design.md index eb36d11c..4003d1c5 100644 --- a/docs/design.md +++ b/docs/design.md @@ -70,14 +70,17 @@ Rules for `polyvisor:internal`: arrived on and supplied as a `session-id` parameter; never from the caller. Each public service interface has an internal mirror with the session first (`app-services`). -4. **Kernel events reach the visor as a long poll** (`events.next`, - served by each visor's glue from a local queue). The runtime's side is - a non-parking `event-source.drain` the worker glue calls after every - export it dispatches: polyengine traps an async export parked on a - guest-internal waker with no host call outstanding as a deadlock - (polyengine#292; wasmtime stays pending), and until the engine lands - every event is born inside a glue-dispatched export anyway. No - callbacks, no second mechanism. What the glue itself observes (a frame torn down by +4. **Kernel events reach the visor as a long poll** (`events.next`). The + runtime exports it and it parks while its queue is empty; the worker + glue runs one pump over that export and fans each event into every + connected tab's queue, and each visor's glue serves the visor's own + `events.next` import from that queue. So an event born of network + activity alone — the other device confirming a pairing, an enrollment + landing, a peer closing — reaches the screen with nothing pressed on + this device. (This was briefly a non-parking `event-source.drain` + while polyengine#292 stood; 0.6.6 fixed it and 0.6.7 fixes the lift + regression that fix introduced, polyengine#312.) No callbacks, no + second mechanism. What the glue itself observes (a frame torn down by the receiver) enters the same path through `apps.abort`, so the visor has one source of truth for session endings; `apps.close` — the visor's own act — emits nothing. @@ -287,12 +290,12 @@ native tests, so browser gates are mandatory for every visor change. |---|---|---| | Rust | 1.98.1 | current stable; satisfies stream-dom (1.98), subduction (1.91), keyhive (1.90) | | `wit-bindgen` | `=0.60.0`, workspace-wide | must equal stream-dom's pin: `StreamReader` (a wit-bindgen runtime type) crosses the delegation from our world's `run` into `stream_dom_dioxus::driver::run`. Different wit-bindgen versions *can* coexist in one component (the `wasip3_task_set` weak-symbol ABI exists for exactly that), but not across a shared runtime type. Bumps follow stream-dom's. `generate!` never sets `async: true`: that lowers sync WIT functions (resource constructors) async, which the canonical ABI forbids and only the translator catches; WIT's own `async func` annotations are the source of truth | -| `@polyengine/*` | 0.6.4, one version across the graph | first release with the #289 driver fix; brand symbols are per-version, so a partial upgrade fails at `instanceof` | +| `@polyengine/*` | 0.6.7, one version across the graph | first release where an async export may park on a guest waker (#292) without the 0.6.6 lift regression (#312); brand symbols are per-version, so a partial upgrade fails at `instanceof` | | `dioxus` | `=0.7.10` | dioxus-core state is shared with `stream-dom-dioxus`; skew breaks the build | | polymorph-stream-dom | git rev (see Cargo.toml / deno.json) | unpublished, moving; policy object and asset handles landed in #15 | | subduction | git `sansio` rev | above | | keyhive | git rev `a509a2d` | `keyhive_core` / `keyhive_crypto` / `beekem`, unreleased and moving. The sealed plaintext is keyhive's own `Envelope` and the read-back walk is keyhive's own `try_causal_decrypt`, so a rev bump is a wire-format change for every stored blob: its own PR | -| `@polymorph/*` | 0.6.0 | the 2026-09-05 cut matching polyengine 0.6.3 | +| `@polymorph/*` | 0.6.1 (iroh, webcrypto, websocket), 0.6.2 (webrtc-datachannels) | the cuts current at the polyengine 0.6.7 pin; taken within the `^0.6` range | | polymorph:iroh WIT | provisional | being upgraded upstream in parallel; re-checked before M3a, the first milestone that exercises it | | `wasi:*` WIT | 0.3.1 (consolidated WASI release) | what `@polyengine/wasi` serves on the `@0.3` track | diff --git a/e2e/run.ts b/e2e/run.ts index edd84c23..43ad1f0e 100644 --- a/e2e/run.ts +++ b/e2e/run.ts @@ -336,10 +336,12 @@ async function claimed(page: Page): Promise { // workaround for a missing feature; polling chrome is a thing the milestone // deliberately does not have. // -// Refresh is also what carries the kernel's `events.pairing-changed` -// across: the worker drains the runtime after every export call it -// dispatches (internal.wit `event-source`), and a phase the OTHER device -// caused has no export activation of ours behind it until we make one. +// Pairing phases are the exception, and they are not a press: the kernel +// pushes `events.pairing-changed` on every transition and the runtime's +// `events.next` parks, so a phase the OTHER device caused reaches this +// screen with nothing pressed here (internal.wit `interface events`). The +// scenarios below therefore wait on those without pressing Refresh, which +// is what makes them exercise the push path at all. // --------------------------------------------------------------------------- const devicesSheet = (page: Page) => sheet(page, "Devices"); @@ -389,15 +391,18 @@ async function endpointId(page: Page): Promise { } } -/** Wait for something in the Devices section, pressing Refresh between - * attempts — the only way a phase the other device caused reaches this - * screen. A pairing failure is worth more than a timeout, so it ends the - * wait with the kernel's own words. */ +/** Wait for something in the Devices section. `refresh` says how what is + * waited for arrives: `true` for a pull-only read (`sync.members`, + * `sync.peers`) that only a Refresh press re-reads, `false` for a pairing + * phase, which the kernel pushes. It is required at every call site + * because pressing Refresh on a pushed value would hide a broken event + * path behind a poll. A pairing failure is worth more than a timeout, so + * it ends the wait with the kernel's own words. */ async function waitInDevices( page: Page, what: string, ready: () => Promise, - ms = 60_000, + { refresh, ms = 60_000 }: { refresh: boolean; ms?: number }, ): Promise { const deadline = performance.now() + ms; for (;;) { @@ -411,7 +416,7 @@ async function waitInDevices( } if (performance.now() > deadline) throw new Failure(`never saw ${what}`); await new Promise((r) => setTimeout(r, 500)); - await refreshSettings(page); + if (refresh) await refreshSettings(page); } } @@ -427,6 +432,7 @@ async function offerPairing(page: Page): Promise { page, "a pairing code", async () => await code.count() > 0, + { refresh: false }, ); return (await code.textContent() ?? "").trim(); } @@ -448,6 +454,7 @@ async function sasDigits(page: Page): Promise { page, "the pairing digits", async () => await sas.count() > 0, + { refresh: false }, ); return (await sas.textContent() ?? "").trim(); } @@ -460,6 +467,7 @@ async function waitForMember(page: Page, peer: string): Promise { async () => await devicesSheet(page).locator(".member-row").filter({ hasText: peer }) .count() > 0, + { refresh: true }, ); } @@ -1137,7 +1145,7 @@ const scenarios: Scenario[] = [ if (await err.count() === 0) return false; return (await err.textContent() ?? "").includes("other device"); }, - 15_000, + { refresh: false, ms: 15_000 }, ); // Neither device is in the other's group: a declined ceremony never diff --git a/justfile b/justfile index d97fc932..01c35ecb 100644 --- a/justfile +++ b/justfile @@ -28,7 +28,7 @@ build-wasm: compose: deno run -A web/fetch-endpoint.ts wac plug target/wasm32-wasip2/release/polyvisor_runtime.wasm \ - --plug target/iroh_endpoint-0.6.0.wasm \ + --plug target/iroh_endpoint.wasm \ -o target/polyvisor_runtime.composed.wasm # web/dist: exactly what a home origin serves. diff --git a/runtime/component/src/component.rs b/runtime/component/src/component.rs index 8d7d48c7..50aeb615 100644 --- a/runtime/component/src/component.rs +++ b/runtime/component/src/component.rs @@ -683,25 +683,23 @@ impl guest::apps::Guest for Component { } } -impl guest::event_source::Guest for Component { - async fn drain() -> Vec { - // Before boot there is nothing to drain, and `drain` has no error - // channel: an empty list is the honest answer. +impl guest::events::Guest for Component { + async fn next() -> guest::events::Event { + // Before boot there is no queue and nothing can be pushed onto one, + // and `next` has no error channel: parking is the honest answer. + // The worker starts its pump only after `lifecycle.boot` returns + // anyway, so nothing waits here in practice. let Ok(kernel) = kernel() else { - return Vec::new(); + return std::future::pending().await; }; - kernel - .drain_events() - .into_iter() - .map(|event| match event { - polyvisor_kernel::Event::SessionEnded(session, why) => { - guest::event_source::Event::SessionEnded((session, why)) - } - polyvisor_kernel::Event::PairingChanged(p) => { - guest::event_source::Event::PairingChanged(phase(p)) - } - }) - .collect() + match kernel.next_event().await { + polyvisor_kernel::Event::SessionEnded(session, why) => { + guest::events::Event::SessionEnded((session, why)) + } + polyvisor_kernel::Event::PairingChanged(p) => { + guest::events::Event::PairingChanged(phase(p)) + } + } } } diff --git a/runtime/crates/kernel/src/events.rs b/runtime/crates/kernel/src/events.rs index 795e71ab..73c295d5 100644 --- a/runtime/crates/kernel/src/events.rs +++ b/runtime/crates/kernel/src/events.rs @@ -1,15 +1,21 @@ //! The kernel's outbound event queue. //! -//! Non-parking by contract (internal.wit `event-source`): the worker glue -//! drains after every export call it dispatches, and until the engine lands -//! every event is born inside an export activation the glue made, so draining -//! there misses nothing. The first design was a parking `next` — polyengine -//! traps an async export parked on a guest-internal waker with no host call -//! outstanding as a deadlock (polyengine#292), so there is no waker here at -//! all, only a queue. +//! A queue and one waker (internal.wit `interface events`): [`Events::next`] +//! answers immediately while something is queued and parks otherwise, and +//! [`Events::push`] wakes whoever is parked. That is what lets an event born +//! of network activity alone — a peer confirming, an enrollment landing — +//! reach a visor with no call made on this device. +//! +//! One waiter is the design, not a limitation worked around: the worker glue +//! runs a single pump over the runtime's `events.next` export. A second +//! `next` polled while another is parked therefore just replaces the stored +//! waker, and the displaced future is left for its own caller to re-poll. +//! There is deliberately no waiter list. use std::cell::RefCell; use std::collections::VecDeque; +use std::future::poll_fn; +use std::task::{Poll, Waker}; /// `polyvisor:internal/events.event`. #[derive(Debug, Clone, PartialEq, Eq)] @@ -27,15 +33,40 @@ pub enum Event { } #[derive(Default)] -pub struct Events(RefCell>); +struct Inner { + queue: VecDeque, + waker: Option, +} + +#[derive(Default)] +pub struct Events(RefCell); impl Events { pub fn push(&self, event: Event) { - self.0.borrow_mut().push_back(event); + // The waker is taken while the cell is borrowed and woken after it is + // released: `wake` may poll the waiting task synchronously, and that + // poll borrows this same cell. + let waker = { + let mut inner = self.0.borrow_mut(); + inner.queue.push_back(event); + inner.waker.take() + }; + if let Some(waker) = waker { + waker.wake(); + } } - /// Everything queued, in order, leaving the queue empty. - pub fn drain(&self) -> Vec { - self.0.borrow_mut().drain(..).collect() + /// The next event, parking while there is none. + pub fn next(&self) -> impl Future + '_ { + poll_fn(|cx| { + let mut inner = self.0.borrow_mut(); + match inner.queue.pop_front() { + Some(event) => Poll::Ready(event), + None => { + inner.waker = Some(cx.waker().clone()); + Poll::Pending + } + } + }) } } diff --git a/runtime/crates/kernel/src/lib.rs b/runtime/crates/kernel/src/lib.rs index 9642e3c5..f2634ff9 100644 --- a/runtime/crates/kernel/src/lib.rs +++ b/runtime/crates/kernel/src/lib.rs @@ -1057,12 +1057,11 @@ impl Kernel { // -- events -------------------------------------------------------------- - /// Everything queued since the last drain, in order (internal.wit - /// `event-source.drain`). Never parks: the glue drains after every export - /// call it dispatches, and an async export parked on a guest-internal - /// waker is a deadlock to polyengine (polyengine#292). - pub fn drain_events(&self) -> Vec { - self.events.drain() + /// The next queued event, parking while there is none (internal.wit + /// `events.next`). One waiter: the worker glue runs a single pump over + /// the runtime's export (see [`events`]). + pub async fn next_event(&self) -> Event { + self.events.next().await } /// The other half of [`Kernel::abort`]: an ending the visor did not ask diff --git a/runtime/crates/kernel/tests/kernel.rs b/runtime/crates/kernel/tests/kernel.rs index 7f215a68..3d8285b0 100644 --- a/runtime/crates/kernel/tests/kernel.rs +++ b/runtime/crates/kernel/tests/kernel.rs @@ -2128,30 +2128,85 @@ fn every_mutation_advances_the_revision_and_ids_order_the_items() { // -- events ------------------------------------------------------------------ -/// True while nothing is queued. Draining is the only way to ask, and it is -/// destructive, which is the whole of the `event-source` contract. +/// A waker that only records that it was woken. +struct Woken(std::sync::atomic::AtomicBool); + +impl Woken { + fn was_woken(&self) -> bool { + self.0.load(std::sync::atomic::Ordering::SeqCst) + } +} + +impl futures::task::ArcWake for Woken { + fn wake_by_ref(arc_self: &std::sync::Arc) { + arc_self.0.store(true, std::sync::atomic::Ordering::SeqCst); + } +} + +/// Poll `next_event` once with a noop waker. `Pending` is how a test asks +/// "is anything queued?" without parking the thread, and `Ready` takes the +/// one event at the head. +fn poll_event(kernel: &Kernel) -> Poll { + let mut next = std::pin::pin!(kernel.next_event()); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + next.as_mut().poll(&mut cx) +} + +/// True while nothing is queued. fn quiet(kernel: &Kernel) -> bool { - kernel.drain_events().is_empty() + poll_event(kernel).is_pending() +} + +/// Everything queued right now, in order, leaving the queue empty. +fn queued(kernel: &Kernel) -> Vec { + let mut events = Vec::new(); + while let Poll::Ready(event) = poll_event(kernel) { + events.push(event); + } + events +} + +#[test] +fn next_parks_until_a_push_arrives_and_wakes_the_waiter() { + // internal.wit `events.next`: "Resolves with the next event; parks while + // there is none." The worker glue's pump is parked in exactly this way + // when an event is born of network activity with no call outstanding, so + // the push must be what wakes it. + let kernel = boot(); + + let woken = std::sync::Arc::new(Woken(std::sync::atomic::AtomicBool::new(false))); + let mut next = std::pin::pin!(kernel.next_event()); + let waker = futures::task::waker(woken.clone()); + let mut cx = std::task::Context::from_waker(&waker); + assert!( + next.as_mut().poll(&mut cx).is_pending(), + "an empty queue parks" + ); + assert!(!woken.was_woken()); + + kernel.push_event(Event::SessionEnded(1, "first".into())); + assert!(woken.was_woken(), "the push woke the parked waiter"); + assert_eq!( + next.as_mut().poll(&mut cx), + Poll::Ready(Event::SessionEnded(1, "first".into())) + ); } #[test] -fn drain_returns_everything_queued_in_order_and_empties_the_queue() { - // internal.wit `event-source`: non-parking. The glue drains after every - // export call it dispatches, so `drain` must answer immediately and must - // not hand the same event over twice. +fn events_arrive_in_order_and_each_only_once() { let kernel = boot(); assert!(quiet(&kernel)); kernel.push_event(Event::SessionEnded(1, "first".into())); kernel.push_event(Event::SessionEnded(2, "second".into())); assert_eq!( - kernel.drain_events(), + queued(&kernel), vec![ Event::SessionEnded(1, "first".into()), Event::SessionEnded(2, "second".into()), ] ); - assert!(quiet(&kernel), "a drained event is gone"); + assert!(quiet(&kernel), "a delivered event is gone"); } #[test] @@ -2169,7 +2224,7 @@ fn abort_ends_the_session_and_announces_it_once() { "the session is no longer live" ); assert_eq!( - kernel.drain_events(), + queued(&kernel), vec![Event::SessionEnded( s, "the app's frame was closed: policy".into() @@ -2594,8 +2649,7 @@ fn pairing_enrolls_the_joiner_and_the_two_devices_then_sync() { // the *other* device drove — the SAS arriving, the enrollment landing — // reach a screen only as events. for kernel in [&joiner, &adder] { - let phases: Vec = kernel - .drain_events() + let phases: Vec = queued(kernel) .into_iter() .map(|event| match event { Event::PairingChanged(phase) => phase, diff --git a/runtime/wit/internal.wit b/runtime/wit/internal.wit index 605d4081..f8d151d0 100644 --- a/runtime/wit/internal.wit +++ b/runtime/wit/internal.wit @@ -385,8 +385,16 @@ interface apps { abort: async func(session: session-id, reason: string); } -/// Kernel-originated events, as the visor consumes them: a long poll the -/// visor's glue serves from a per-tab queue. +/// Kernel-originated events: a long poll. The runtime exports it and the +/// worker glue runs one pump over that export, fanning each event out to +/// every connected tab's queue; the visor imports it and its own glue +/// serves the poll from that tab's queue. Same interface, two sides of the +/// seam — nothing links the two directly. +/// +/// Because the runtime's side parks, an event needs no call to carry it: a +/// transition born of network activity alone (the other device confirming, +/// an enrollment landing, a peer closing) reaches the visor with no press +/// on this device. interface events { use types.{session-id, phase}; @@ -399,12 +407,11 @@ interface events { /// transition, including the ones the visor did not cause (the peer /// confirming, an offer expiring, the enrollment landing). /// - /// The visor has no timer and `pairing.status` may not park - /// (polyengine#292 — see `event-source`), so a ceremony that - /// advances because the *other* device acted has no other way to - /// reach the screen. `status` remains the authority and the visor - /// re-reads it after every act of its own; this is the push that - /// makes waiting cost nothing. + /// The visor has no timer and `pairing.status` may not park, so a + /// ceremony that advances because the *other* device acted has no + /// other way to reach the screen. `status` remains the authority + /// and the visor re-reads it after every act of its own; this is + /// the push that makes waiting cost nothing. pairing-changed(phase), } @@ -412,21 +419,6 @@ interface events { next: async func() -> event; } -/// Kernel-originated events, as the runtime produces them. Non-parking: -/// the worker glue drains after every export call it dispatches and fans -/// the result out to every tab's queue. Until the engine lands, every -/// event is born inside an export activation the glue made, so draining -/// there misses nothing. A parking `next` export was the first design; -/// polyengine traps an async export parked on a guest-internal waker with -/// no host call outstanding as a deadlock (polyengine#292), where wasmtime -/// would stay pending. Revisit when that is resolved or when the engine -/// produces events from host-call completions. -interface event-source { - use events.{event}; - - drain: async func() -> list; -} - /// Service dispatch for app sessions: each public service interface in /// polyvisor:app has a mirror here with the caller's session first. The /// glue supplies the session from the port the call arrived on. @@ -520,7 +512,7 @@ world runtime { export storage; export store; export apps; - export event-source; + export events; export app-services; } diff --git a/visor/src/state.rs b/visor/src/state.rs index 9e9ac788..ec76faa1 100644 --- a/visor/src/state.rs +++ b/visor/src/state.rs @@ -127,8 +127,7 @@ impl Gate { /// `pairing.status`, and the kernel additionally pushes /// `events.pairing-changed` on every transition (including the ones the /// *other* device caused, which is the only way they could arrive — this -/// world has no timer, and `pairing.status` may not park; internal.wit -/// `event-source`, polyengine#292). +/// world has no timer, and `pairing.status` may not park). #[derive(Clone, PartialEq, Eq, Debug, Default)] pub(crate) enum Phase { #[default] diff --git a/visor/src/ui.rs b/visor/src/ui.rs index e4c2414e..562aa238 100644 --- a/visor/src/ui.rs +++ b/visor/src/ui.rs @@ -296,8 +296,7 @@ pub(crate) fn Visor() -> Element { // The other device acted: a peer that confirmed, an offer // that expired, an enrollment that landed. Nothing else // could bring those to the screen — there is no timer here - // and `pairing.status` may not park (internal.wit - // `event-source`). + // and `pairing.status` may not park. Event::PairingChanged(next) => { apply_phase(next, pairing_phase, members, notice).await; } diff --git a/web/fetch-endpoint.ts b/web/fetch-endpoint.ts index 12e27a06..3df3d53f 100644 --- a/web/fetch-endpoint.ts +++ b/web/fetch-endpoint.ts @@ -25,13 +25,28 @@ import { join } from "@std/path"; import { loadArtifacts } from "@polymorph/iroh"; -/** The pinned endpoint build. Must track deno.json's `@polymorph/iroh` - * specifier: the filename carries the version so a bump cannot silently - * reuse a stale artifact, and `just compose` names the same path. */ -const VERSION = "0.6.0"; +// The pin is `import.meta.resolve`, not a literal here: deno.json maps +// `@polymorph/iroh` to a jsr semver range, and `deno.lock` is what actually +// resolves it to a concrete version each install. Resolving the specifier +// yields `https://jsr.io/@polymorph/iroh//...`; parsing +// out of that URL means the cache filename always names the version deno.lock +// actually picked, so a lockfile bump cannot silently reuse a stale artifact. +// `wac plug` (justfile `compose`) can't name a versioned path without +// re-deriving the version itself, so after fetching we also copy to a +// version-free stable path (`iroh_endpoint.wasm`) that's the one the +// justfile plugs. +const resolved = import.meta.resolve("@polymorph/iroh"); +const match = resolved.match(/^https:\/\/jsr\.io\/@polymorph\/iroh\/([^/]+)\//); +if (!match) { + throw new Error( + `fetch-endpoint: could not parse a version out of resolved specifier ${resolved}`, + ); +} +const VERSION = match[1]; const ROOT = new URL("..", import.meta.url).pathname.replace(/\/$/, ""); const OUT = join(ROOT, "target", `iroh_endpoint-${VERSION}.wasm`); +const STABLE = join(ROOT, "target", `iroh_endpoint.wasm`); async function exists(path: string): Promise { try { @@ -49,3 +64,4 @@ if (await exists(OUT)) { await Deno.writeFile(OUT, componentBytes); console.log(`fetch-endpoint: wrote ${OUT} (${componentBytes.length} bytes)`); } +await Deno.copyFile(OUT, STABLE); diff --git a/web/worker.ts b/web/worker.ts index adb005f4..d309b668 100644 --- a/web/worker.ts +++ b/web/worker.ts @@ -51,8 +51,10 @@ const I = { sync: "polyvisor:internal/sync@0.1.0", pairing: "polyvisor:internal/pairing@0.1.0", storage: "polyvisor:internal/storage@0.1.0", + // Both sides of one seam: the runtime EXPORTS `events` (the pump below + // consumes it) and each tab's visor IMPORTS it (served further down from + // that tab's queue). Same interface id, opposite directions. events: "polyvisor:internal/events@0.1.0", - eventSource: "polyvisor:internal/event-source@0.1.0", appServices: "polyvisor:internal/app-services@0.1.0", locks: "polyvisor:internal/locks@0.1.0", tasks: "polyvisor:app/tasks@0.1.0", @@ -67,10 +69,10 @@ interface KernelEvent { } /** One connected tab. `queue`/`waiter` are that tab's copy of the kernel - * event stream: the worker drains the runtime after every export call it - * dispatches and fans each event out to every tab, whose own glue serves the - * visor's `events.next` import from a local queue (internal.wit - * `interface events`). */ + * event stream: the worker pumps the runtime's parking `events.next` and + * fans each event out to every tab, whose own glue serves the visor's + * `events.next` import from a local queue (internal.wit `interface + * events`). */ interface Tab { port: MessagePort; queue: KernelEvent[]; @@ -278,23 +280,12 @@ ready.catch((err: unknown) => { }); // --------------------------------------------------------------------------- -// Events: drain after every dispatched export call +// Events: one long-poll pump over the runtime's export // -// internal.wit `interface event-source` and design.md "Contracts" rule 4. -// The first design was a parking `events.next` export the worker long-polled; -// polyengine traps an async export parked on a guest-internal waker with no -// host call outstanding as a deadlock (polyengine#292), where wasmtime would -// stay pending. So the runtime's side is `event-source.drain`, which never -// parks, and the glue calls it after every export activation it made. -// -// That misses nothing, and no longer only for now. The kernel's one event is -// `session-ended`, and it is born inside an export activation the glue itself -// dispatched (`apps.abort`, `apps.close`). The engine has landed and adds -// none: a remote change reaches an app through `tasks.revision`, which the -// app polls, and the visor re-reads `sync.peers` when Settings opens. Nothing -// in the kernel produces an event from a host-call completion, so there is -// nothing for a drain sited here to miss. An event with no export activation -// behind it is what would change that, and it would change this site. +// internal.wit `interface events` and design.md "Contracts" rule 4. The +// runtime's `next` parks while its queue is empty, so no call has to carry +// an event across: a phase the OTHER device drove — a peer confirming, an +// enrollment landing — wakes this pump with nothing pressed on this device. // --------------------------------------------------------------------------- function fanOut(events: KernelEvent[]): void { @@ -312,55 +303,23 @@ function fanOut(events: KernelEvent[]): void { } } -/** Drain the runtime and fan out. Never throws: a drain that failed must not - * turn into the answer of the call it followed, and it must not replace the - * error of a call that had already failed. */ -async function drainEvents(): Promise { - let exports_: Exports; - try { - exports_ = await ready; - } catch { - return; // Not booted: `ready.catch` already told every tab. - } +/** The pump. Started once the runtime is booted, never restarted: if `next` + * rejects, the runtime is gone, and a runtime that is gone was already + * reported to every tab through `ready`'s failure path. Retrying would spin + * on the same rejection forever. */ +void ready.then(async (exports_) => { + const next = exports_[I.events].next as () => Promise; try { - const drain = exports_[I.eventSource].drain as () => Promise; - fanOut(await drain()); + for (;;) fanOut([await next()]); } catch (err: unknown) { console.error( - "polyvisor: draining the kernel's events failed:", + "polyvisor: the kernel's event pump stopped:", (err as Error)?.message ?? err, ); } -} - -/** - * Wrap every member of an RPC impl so the kernel is drained after the call. - * - * One helper rather than a `drainEvents()` at each forwarding site: a - * forwarding site that forgets it strands events until the next call that - * did not, which is a bug that only shows up as a stale visor. Every place - * this worker dispatches a runtime export on behalf of a tab or a session - * port goes through here. - * - * `finally`, not "on success": an export that failed may still have pushed - * an event before failing (`apps.launch` that opened and then closed a - * session), and the original rejection is what propagates either way. - */ -function draining unknown>>( - impl: T, -): T { - const wrapped: Record = {}; - for (const [name, fn] of Object.entries(impl)) { - wrapped[name] = async (...args: unknown[]) => { - try { - return await (fn as (...a: unknown[]) => unknown)(...args); - } finally { - await drainEvents(); - } - }; - } - return wrapped as T; -} +}).catch(() => { + // Not booted: `ready.catch` already told every tab. +}); /** Bind a fresh MessageChannel to `session` and serve the session's two * interfaces on it. Returns the end to transfer to the frame. */ @@ -372,7 +331,7 @@ function mintSessionPort( const apps = exports_[I.apps]; const { port1, port2 } = new MessageChannel(); serveInterfaces(port1, { - [I.tasks]: draining({ + [I.tasks]: { revision: () => svc.tasksRevision(session), items: () => svc.tasksItems(session), add: (title: string) => svc.tasksAdd(session, title), @@ -381,14 +340,14 @@ function mintSessionPort( setTitle: (id: string, title: string) => svc.tasksSetTitle(session, id, title), remove: (id: string) => svc.tasksRemove(session, id), - }), + }, // Only these three: a session port is not a way to enumerate or launch // apps. - [I.apps]: draining({ + [I.apps]: { component: () => apps.component(session), assets: () => apps.assets(session), asset: (handle: Uint8Array) => apps.asset(session, handle), - }), + }, }); return port2; } @@ -459,7 +418,7 @@ self.onconnect = (ev: MessageEvent) => { }); serveInterfaces(port, { - [I.device]: draining({ + [I.device]: { status: async () => (await ready)[I.device].status(), setName: async (name: string) => (await ready)[I.device].setName(name), setHue: async (hue: number) => (await ready)[I.device].setHue(hue), @@ -469,37 +428,35 @@ self.onconnect = (ev: MessageEvent) => { unseal: async (passphrase: string) => (await ready)[I.device].unseal(passphrase), erase: async () => (await ready)[I.device].erase(), - }), - [I.store]: draining({ + }, + [I.store]: { devices: async () => (await ready)[I.store].devices(), - }), + }, // Control port only, like `device`: dialing another device is the // visor's act, and an app session has no business naming a peer. - [I.sync]: draining({ + [I.sync]: { connect: async (endpointId: string) => (await ready)[I.sync].connect(endpointId), peers: async () => (await ready)[I.sync].peers(), members: async () => (await ready)[I.sync].members(), - }), + }, // Control port only, like `sync`: pairing is a ceremony in the trusted // pixels, and an app session has no business starting or confirming - // one. `draining` matters more here than anywhere else — the kernel - // pushes `pairing-changed` on transitions the *other* device caused, - // and this drain is what carries them to the tabs. - [I.pairing]: draining({ + // one. + [I.pairing]: { offer: async () => (await ready)[I.pairing].offer(), claim: async (code: string) => (await ready)[I.pairing].claim(code), confirm: async () => (await ready)[I.pairing].confirm(), cancel: async () => (await ready)[I.pairing].cancel(), status: async () => (await ready)[I.pairing].status(), - }), + }, // Control port only, like `pairing`: connecting a store is a ceremony // in the trusted pixels, and an app session has no business naming a // provider — still less holding the one-shot code that crosses here. // The tokens themselves never cross this port: `oauth-complete` hands // the kernel a code, and what comes back the kernel seals for itself // (internal.wit `storage`). - [I.storage]: draining({ + [I.storage]: { status: async () => (await ready)[I.storage].status(), oauthStart: async (client: unknown) => (await ready)[I.storage].oauthStart(client), @@ -507,8 +464,8 @@ self.onconnect = (ev: MessageEvent) => { (await ready)[I.storage].oauthComplete(code, state), disconnect: async () => (await ready)[I.storage].disconnect(), syncNow: async () => (await ready)[I.storage].syncNow(), - }), - [I.apps]: draining({ + }, + [I.apps]: { installed: async () => (await ready)[I.apps].installed(), launch: async (app: string) => (await ready)[I.apps].launch(app), sessionApp: async (s: number) => (await ready)[I.apps].sessionApp(s), @@ -523,9 +480,9 @@ self.onconnect = (ev: MessageEvent) => { // itself with a reason of its own composing. abort: async (s: number, reason: string) => (await ready)[I.apps].abort(s, reason), - }), - // NOT `draining`: this is the tab's own local queue, not a runtime - // export — draining here would recurse into a call that never happened. + }, + // The tab side of `events`: served from this tab's own local queue, + // which the pump above fills. [I.events]: { // Parks while the tab's queue is empty, exactly as the WIT says. One // waiter per tab: a second concurrent `next()` would silently replace From dd628490b4d4444dbcae0273655a37b4832cd4bf Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Mon, 7 Sep 2026 12:25:07 -0400 Subject: [PATCH 2/2] e2e: waitInDevices re-asks the predicate before aborting on a sheet error A caller that is waiting FOR a failure (pairing-declined-aborts-both accepts "the other device went away") read the sheet before the error rendered; the harness then read it after and threw on the very text the wait was for. Seen on CI and once locally, independent of the runtime version. --- e2e/run.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/e2e/run.ts b/e2e/run.ts index 43ad1f0e..92b2ee2c 100644 --- a/e2e/run.ts +++ b/e2e/run.ts @@ -409,7 +409,10 @@ async function waitInDevices( await openSettings(page); if (await ready()) return; const failed = devicesSheet(page).locator(".sheet-error"); - if (await failed.count() > 0) { + // An error that appears between the predicate's read and this one may + // be the very thing the predicate is waiting for (a caller that expects + // a failure), so ask again before treating it as the wait's abort. + if (await failed.count() > 0 && !(await ready())) { throw new Failure( `${what}: the visor showed ${await failed.textContent()}`, );