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..92b2ee2c 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,29 +391,35 @@ 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 (;;) { 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()}`, ); } 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 +435,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 +457,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 +470,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 +1148,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