Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions deno.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
50 changes: 25 additions & 25 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 13 additions & 10 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<u8>` (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 |

Expand Down
35 changes: 23 additions & 12 deletions e2e/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,10 +336,12 @@ async function claimed(page: Page): Promise<boolean> {
// 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");
Expand Down Expand Up @@ -389,29 +391,35 @@ async function endpointId(page: Page): Promise<string> {
}
}

/** 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<boolean>,
ms = 60_000,
{ refresh, ms = 60_000 }: { refresh: boolean; ms?: number },
): Promise<void> {
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);
}
}

Expand All @@ -427,6 +435,7 @@ async function offerPairing(page: Page): Promise<string> {
page,
"a pairing code",
async () => await code.count() > 0,
{ refresh: false },
);
return (await code.textContent() ?? "").trim();
}
Expand All @@ -448,6 +457,7 @@ async function sasDigits(page: Page): Promise<string> {
page,
"the pairing digits",
async () => await sas.count() > 0,
{ refresh: false },
);
return (await sas.textContent() ?? "").trim();
}
Expand All @@ -460,6 +470,7 @@ async function waitForMember(page: Page, peer: string): Promise<void> {
async () =>
await devicesSheet(page).locator(".member-row").filter({ hasText: peer })
.count() > 0,
{ refresh: true },
);
}

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 15 additions & 17 deletions runtime/component/src/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -683,25 +683,23 @@ impl guest::apps::Guest for Component {
}
}

impl guest::event_source::Guest for Component {
async fn drain() -> Vec<guest::event_source::Event> {
// 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))
}
}
}
}

Expand Down
55 changes: 43 additions & 12 deletions runtime/crates/kernel/src/events.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand All @@ -27,15 +33,40 @@ pub enum Event {
}

#[derive(Default)]
pub struct Events(RefCell<VecDeque<Event>>);
struct Inner {
queue: VecDeque<Event>,
waker: Option<Waker>,
}

#[derive(Default)]
pub struct Events(RefCell<Inner>);

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<Event> {
self.0.borrow_mut().drain(..).collect()
/// The next event, parking while there is none.
pub fn next(&self) -> impl Future<Output = Event> + '_ {
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
}
}
})
}
}
11 changes: 5 additions & 6 deletions runtime/crates/kernel/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Event> {
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
Expand Down
Loading