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
80 changes: 64 additions & 16 deletions demo/e2e/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,30 @@
// commands through in the first place — pursuing it would have meant
// reimplementing CDP session routing by hand for a path this repo does
// not need.
//
// WHY TITLE MATCHING EXISTS, learned twice downstream before landing
// here: every solo/demo device runs the same worker script, so a URL
// substring cannot tell two devices' hosts apart, only the
// SharedWorker's NAME can — and the `shared_worker` target's `title`
// IS that name (`runtime/device-store/client.ts`'s
// `new SharedWorker(url, { name: nsDbName(deviceId) })`, `pm-device-
// <deviceId>`). Devstore row 51 needed exactly this distinction first.

import type { Browser, Page } from "npm:playwright@1.57.0";

export interface SharedWorkerTarget {
targetId: string;
url: string;
title: string;
}

/** What to match a single SharedWorker target against. At least one
* field is required — an empty matcher would match everything, which
* is never what a caller wants from a function whose whole point is
* "find the ONE worker I mean". */
export interface SharedWorkerMatch {
urlIncludes?: string;
title?: string;
}

/** Every `shared_worker`-typed CDP target in this browser (all
Expand All @@ -44,38 +62,60 @@ export async function listSharedWorkers(browser: Browser): Promise<SharedWorkerT
const cdp = await browser.newBrowserCDPSession();
try {
const { targetInfos } = await cdp.send("Target.getTargets") as {
targetInfos: { targetId: string; type: string; url: string }[];
targetInfos: { targetId: string; type: string; url: string; title: string }[];
};
return targetInfos
.filter((t) => t.type === "shared_worker")
.map((t) => ({ targetId: t.targetId, url: t.url }));
.map((t) => ({ targetId: t.targetId, url: t.url, title: t.title }));
} finally {
await cdp.detach().catch(() => { /* already gone */ });
}
}

/** Terminate the one shared worker whose URL contains `urlSubstring`,
* and return what was killed. `Target.closeTarget` is sufficient by
* itself — see the file banner's spike finding — so this needs no
* attach/evaluate fallback. Throws, NAMING the live targets, if no
* worker matches: a scenario asserting "the device host's worker died"
* must fail loudly rather than silently killing the wrong one (or
* nothing) when a URL is renamed out from under it. Detaches its own
/** Terminate exactly one shared worker matching `match`, and return
* what was killed. `Target.closeTarget` is sufficient by itself — see
* the file banner's spike finding — so this needs no attach/evaluate
* fallback.
*
* TITLE, NOT JUST URL: every solo/demo device runs the identical
* worker script (`./solo-worker.js` / `./worker.js`), so a URL
* substring cannot distinguish two devices' hosts from each other —
* only their SharedWorker NAME does, and the `shared_worker` target's
* `title` IS that name. `client.ts`'s `new SharedWorker(url, { name:
* nsDbName(deviceId) })` sets it to `pm-device-<deviceId>` (devstore
* row 51's measurement first needed this distinction to kill one
* device's host without touching a sibling's). `urlIncludes` is kept
* for callers where a shared script IS the intended discriminator (a
* scenario with only one device up).
*
* Requires EXACTLY ONE match — not zero, not more than one — and fails
* loudly naming every matching target AND every live target otherwise:
* a scenario asserting "the device host's worker died" must fail
* loudly rather than silently killing the wrong one (or an arbitrary
* one of several) when a match is ambiguous. Detaches its own
* browser-level session in a `finally`, same reasoning as
* `listSharedWorkers`. */
export async function killSharedWorker(
browser: Browser,
urlSubstring: string,
match: SharedWorkerMatch,
): Promise<SharedWorkerTarget> {
if (match.urlIncludes === undefined && match.title === undefined) {
throw new Error("killSharedWorker: match needs at least one of urlIncludes/title");
}
const workers = await listSharedWorkers(browser);
const target = workers.find((w) => w.url.includes(urlSubstring));
if (!target) {
const matching = workers.filter((w) =>
(match.urlIncludes === undefined || w.url.includes(match.urlIncludes)) &&
(match.title === undefined || w.title === match.title)
);
if (matching.length !== 1) {
throw new Error(
`no shared worker matching ${JSON.stringify(urlSubstring)} — live targets: ${
JSON.stringify(workers.map((w) => w.url))
}`,
`killSharedWorker: expected exactly one match for ${JSON.stringify(match)}, found ` +
`${matching.length} — matching: ${JSON.stringify(matching)}; all live targets: ${
JSON.stringify(workers)
}`,
);
}
const target = matching[0];
const cdp = await browser.newBrowserCDPSession();
try {
await cdp.send("Target.closeTarget", { targetId: target.targetId });
Expand Down Expand Up @@ -121,7 +161,15 @@ export async function killSharedWorker(
* state must do it through ANOTHER page (a second device's view of the
* same account, a SharedWorker another tab can still reach) or must
* thaw it (`setWebLifecycleState(page, "active")`) before evaluating
* against it again. */
* against it again.
*
* THE OVERRIDE CAN LIE, per devstore row 55: on a page opened the way
* the devstore matrix opens one, `setWebLifecycleState(page, "frozen")`
* reported `{ success: true }` and did NOTHING — the counter kept
* ticking, no freeze event fired (measured 2026-08-24). So a caller
* building a claim on "this page is frozen" must verify the freeze
* actually took (read the timer back, or watch for the lifecycle
* event) rather than trusting the command's own success response. */
export async function setWebLifecycleState(
page: Page,
state: "frozen" | "active",
Expand Down
98 changes: 16 additions & 82 deletions demo/e2e/scenarios/convergence-soak.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ import {
WAITS,
} from "../solo-util.ts";
import { startFakeDrive } from "../../host/fake-drive.ts";
import { killSharedWorker } from "../cdp.ts";


const ROOT = "pm-convergence-soak";
const CLIENT_ID = "SYNTHETIC-SOAK-CLIENT";
Expand Down Expand Up @@ -222,91 +224,23 @@ function drawAction(rand: () => number): ActionName {

// --- CDP: killing a device's SharedWorker by TITLE -------------------------
//
// COPIED, NOT IMPORTED, from runtime/tests/devstore/run.ts:101-144
// (`sharedWorkersFor` / `killWorkerFor`), which in turn lifted the kill
// sequence from demo/e2e/cdp.ts:43-86 with the same one change this
// scenario needs: THE SELECTOR IS THE TARGET'S TITLE, NOT ITS URL.
// demo/e2e/cdp.ts's `killSharedWorker` matches on a URL substring, and
// both devices here run the same `./solo-worker.js`, so a URL match
// cannot tell A's host from B's. The title is the SharedWorker's NAME,
// which runtime/device-store/client.ts:411-413 sets to
// `nsDbName(deviceId)` — `pm-device-<id>` on the shared_worker target
// info. The kill itself is cdp.ts's spike finding, relied on and
// re-confirmed by devstore row 51: `Target.closeTarget` on a
// `shared_worker` target terminates it, with no attach/evaluate
// fallback needed.

interface WorkerTarget {
targetId: string;
title: string;
}

/** Every `shared_worker` target hosting THIS device. Browser-wide —
* `Target.getTargets` is not scoped to a page — and it detaches its own
* browser-level session in a `finally`, cdp.ts's reason: a soak that
* calls this once per eviction must not accumulate CDP sessions. */
async function sharedWorkersFor(
browser: Browser,
deviceId: string,
): Promise<WorkerTarget[]> {
const cdp = await browser.newBrowserCDPSession();
try {
const { targetInfos } = await cdp.send("Target.getTargets") as {
targetInfos: { targetId: string; type: string; title: string }[];
};
return targetInfos
.filter((t) =>
t.type === "shared_worker" && t.title === `pm-device-${deviceId}`
)
.map((t) => ({ targetId: t.targetId, title: t.title }));
} finally {
await cdp.detach().catch(() => {/* already gone */});
}
}

/** Terminate the SharedWorker hosting this device, NAMING what is live
* when the match is not exactly one: a step whose whole subject is "the
* host died" must fail loudly rather than quietly kill nothing — or
* quietly kill an arbitrary one of several. */
// demo/e2e/cdp.ts's `killSharedWorker({ title })` does this now — see
// its banner for why title (not URL) is the match: both devices here
// run the same `./solo-worker.js`, so a URL substring cannot tell A's
// host from B's, but the title IS the SharedWorker's NAME, which
// runtime/device-store/client.ts:411-413 sets to `nsDbName(deviceId)`
// (`pm-device-<id>`). The kill itself is cdp.ts's spike finding,
// re-confirmed by devstore row 51.

/** Terminate the SharedWorker hosting this device — thin wrapper over
* cdp.ts's `killSharedWorker`, kept so call sites here read by
* deviceId rather than assembling the title match inline. */
async function killWorkerFor(
browser: Browser,
deviceId: string,
): Promise<WorkerTarget> {
const targets = await sharedWorkersFor(browser, deviceId);
if (targets.length !== 1) {
// NEITHER ZERO NOR TWO. Zero means the step's whole subject —
// "the host died" — would be a no-op quietly reported as done. TWO
// means the title match is no longer the identity it is documented
// to be (one SharedWorker per device namespace), and killing an
// arbitrary one of them would make the step mean something nobody
// wrote down. Both fail here, naming every live shared_worker.
const all = await browser.newBrowserCDPSession();
let live = "";
try {
const { targetInfos } = await all.send("Target.getTargets") as {
targetInfos: { type: string; title: string }[];
};
live = JSON.stringify(
targetInfos.filter((t) => t.type === "shared_worker").map((t) =>
t.title
),
);
} finally {
await all.detach().catch(() => {});
}
throw new Error(
`expected exactly one shared worker for device ${deviceId}, found ${targets.length}` +
` — live shared workers: ${live}`,
);
}
const target = targets[0];
const cdp = await browser.newBrowserCDPSession();
try {
await cdp.send("Target.closeTarget", { targetId: target.targetId });
} finally {
await cdp.detach().catch(() => {});
}
return target;
): Promise<{ targetId: string; title: string }> {
const target = await killSharedWorker(browser, { title: `pm-device-${deviceId}` });
return { targetId: target.targetId, title: target.title };
}

// --- the two ceremonies this soak needs as PRECONDITIONS -------------------
Expand Down
13 changes: 7 additions & 6 deletions demo/e2e/scenarios/one-sided-reload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@
// problem: it still holds a connection HANDLE from before the reload,
// and has no way to learn the thing on the other end of it is gone.
// `conn-status` reports the handshake's outcome once and is never
// invalidated afterwards (engine/guest/src/lib.rs:4262 writes the
// outcome into `conn_results` once per connection, and :4268-4275's
// invalidated afterwards (engine/guest/src/lib.rs:4407 writes the
// outcome into `conn_results` once per connection, and :4413-4420's
// `conn_status` reads that same entry back forever), so the reader sees
// a "healthy" connection to a page that no longer exists and never
// re-dials. The honest fix belongs in the engine — a `conn-status` that
// goes false when the connection drops — and until it lands, this file
// cannot tell the difference between a healthy peer and a departed one.
// Filed as #113.
//
// WHY THIS IS A GATE RATHER THAN MORE PROSE: `solo-resume-sync` proves
// the BOTH-sides-reload case recovers (the ordinary one — a user closes
Expand Down Expand Up @@ -44,8 +45,8 @@
// resume-wire — the only code path that would make B re-dial —
// never runs at all. B's ORIGINAL ceremony-time connection object
// is still sitting there, and `conn-status` on it still answers
// with the handshake's old, one-time-written "true"
// (engine/guest/src/lib.rs:4262/:4268-4275). B has no symptom to
// with the handshake's old, one-time-written "true"
// (engine/guest/src/lib.rs:4407/:4413-4420). B has no symptom to
// act on and no trigger to re-dial: nothing in B's code ever asks
// "is this still good?" once the handshake result is latched.
// So the crossing asserted is B → A: a todo authored on B after A's
Expand Down Expand Up @@ -181,8 +182,8 @@ const scenario: Scenario = {
// resume-wire never runs — B never reloaded, so B's original
// connection object is still there, and `conn-status` on it
// still answers with the handshake's old, one-time "true"
// (engine/guest/src/lib.rs:4262 writes it, :4268-4275 reads it
// back).
// (engine/guest/src/lib.rs:4407 writes it, :4413-4420 reads it
// back). Filed as #113.
await addTodo(pageB, "call the bank");

// DIAGNOSIS RIDES WITH THE FAILURE: on a red run this wait times
Expand Down
9 changes: 5 additions & 4 deletions demo/e2e/scenarios/relay-partition-asym.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,17 @@
// * and neither side can learn the wire died at all: `conn-status`
// latches the handshake's outcome and is never invalidated
// — the outcome goes into `conn_results` once and is never
// removed (engine/guest/src/lib.rs:4262, with :4198/:4255 for the
// removed (engine/guest/src/lib.rs:4407, with :4343/:4400 for the
// error outcomes), and `conn-status` reads it back for ever
// (:4268-4275). solo.ts:2988-3012 already writes this down for the
// (:4413-4420). solo.ts:2988-3012 already writes this down for the
// neighbouring one-sided-reload case, though by the latch's OLD
// line numbers — the code moved, the fact did not.
//
// THE MISSING PIECE, in the engine's own terms: a `conn-status` that
// goes false when the connection drops (named as the honest fix at
// solo.ts:3009-3012), and a page-side retry armed for the life of the
// page rather than only on the resumed-boot path.
// solo.ts:3009-3012 — filed as #113), and a page-side retry armed for
// the life of the page rather than only on the resumed-boot path.

//
// NOTHING RELOADS HERE, and no storage is bound — see relay-partition.ts
// for both, in full. A reload would enter `resumeWire` and heal (that is
Expand Down
21 changes: 11 additions & 10 deletions demo/e2e/scenarios/relay-partition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@
// outcome of the HANDSHAKE and is never invalidated afterwards
// — `iroh-start`'s spawned wiring writes the outcome into
// `conn_results` once and nothing ever removes it
// (engine/guest/src/lib.rs:4262, and :4198/:4255 for the two error
// (engine/guest/src/lib.rs:4407, and :4343/:4400 for the two error
// outcomes), and `conn-status` reads that map back for ever
// (:4268-4275). `sync-status` is one-shot per round rather than a
// (:4413-4420). `sync-status` is one-shot per round rather than a
// subscription's health. solo.ts:2988-3012 writes this down
// already, for the neighbouring one-sided-reload case; the relay
// outage is the same engine limit reached by a different road.
Expand All @@ -70,12 +70,13 @@
//
// THE MISSING PIECE, in the engine's own terms: a `conn-status` that
// goes false when the connection drops (solo.ts:3009-3012 names exactly
// this), plus a page-side retry that is armed for the life of the page
// rather than only on the resumed-boot path. With the first, the second
// is cheap and cannot double-dial; without it, any re-dial-on-a-timer
// would be the double-dialling solo.ts's direction discipline exists to
// prevent — which is why this is pinned as a gap rather than papered
// over in a scenario-local workaround.
// this — filed as #113), plus a page-side retry that is armed for the
// life of the page rather than only on the resumed-boot path. With the
// first, the second is cheap and cannot double-dial; without it, any
// re-dial-on-a-timer would be the double-dialling solo.ts's direction
// discipline exists to prevent — which is why this is pinned as a gap
// rather than papered over in a scenario-local workaround.

//
// ─── WHAT IS NOT THE SUBJECT ────────────────────────────────────────
//
Expand Down Expand Up @@ -297,8 +298,8 @@ const scenario: Scenario = {
// 2ms and each local `addTodo` in ~85ms, but the FIRST `tick`
// after the relay dies takes ~30s — the drain runs a driver call
// that goes out over the dead transport and unwinds on one of
// solo.ts's own 30s `until` deadlines (:2617, :2641). It happens
// ONCE; every later tick is milliseconds again, which is why the
// solo.ts's own 30s `until` deadlines (:2617, :2641). Filed as
// #115. It happens ONCE; every later tick is milliseconds again, which is why the
// liveness act after this one costs nothing. Worth knowing before
// anyone reads the act's wall clock as a bug in the watch window.

Expand Down
Loading
Loading