From a7a62abb926201edabe35665a59546aa7cacfe65 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Tue, 25 Aug 2026 08:40:25 -0400 Subject: [PATCH] Follow-ups from the partial-availability round: citations that moved, an illustration that was never measured, and one CDP helper instead of three - solo.ts's one-sided-reload gap block cites the conn-status latch at its current home (lib.rs:4407 write / :4413-4420 read; the old :3700/:3706 numbers pointed into Gdrive pickup code), and names the filed issue (#113). The three expected-red scenario banners carried the same drifted numbers post-#109 and are corrected too, with #113 in their promotion paragraphs and #115 at relay-partition's 30s-first-tick note; worker-eviction cites #114 at the 120s-hang warning. - locks.ts's 'lock held, lease stale' illustration named a suspended TAB -- but the lease heartbeat is the SharedWorker's, and devstore row 55 measured that the question cannot even be posed in this build (CDP page freeze does not take). The illustration is now worker-true and the unmeasured question is named instead of implied answered. - cdp.ts's killSharedWorker learns title matching (SharedWorkerMatch: urlIncludes/title, exactly-one-match semantics) -- every solo/demo device runs the identical worker script, so only the SharedWorker's NAME (the target's title, pm-device-) can tell two devices' hosts apart. worker-eviction and convergence-soak drop their local copies and import it; the devstore copy stays, deliberately (no import edge into demo/e2e). setWebLifecycleState's hazard comment now also carries row 55's the-override-can-lie finding. Gates: e2e-check, runtime just check, just site, worker-eviction + one-sided-reload(xfail) + harness-faults green, soak green at seeds 1 and 2 (seed 2 exercises the new kill path). --- demo/e2e/cdp.ts | 80 +++++++++++--- demo/e2e/scenarios/convergence-soak.ts | 98 +++------------- demo/e2e/scenarios/one-sided-reload.ts | 13 ++- demo/e2e/scenarios/relay-partition-asym.ts | 9 +- demo/e2e/scenarios/relay-partition.ts | 21 ++-- demo/e2e/scenarios/worker-eviction.ts | 123 +++++---------------- demo/host/solo.ts | 8 +- runtime/device-store/locks.ts | 8 +- 8 files changed, 142 insertions(+), 218 deletions(-) diff --git a/demo/e2e/cdp.ts b/demo/e2e/cdp.ts index db965e0..7390d8b 100644 --- a/demo/e2e/cdp.ts +++ b/demo/e2e/cdp.ts @@ -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- +// `). 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 @@ -44,38 +62,60 @@ export async function listSharedWorkers(browser: Browser): Promise 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-` (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 { + 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 }); @@ -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", diff --git a/demo/e2e/scenarios/convergence-soak.ts b/demo/e2e/scenarios/convergence-soak.ts index d1f873a..8a2f620 100644 --- a/demo/e2e/scenarios/convergence-soak.ts +++ b/demo/e2e/scenarios/convergence-soak.ts @@ -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"; @@ -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-` 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 { - 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-`). 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 { - 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 ------------------- diff --git a/demo/e2e/scenarios/one-sided-reload.ts b/demo/e2e/scenarios/one-sided-reload.ts index 2b62d30..c6fc6b8 100644 --- a/demo/e2e/scenarios/one-sided-reload.ts +++ b/demo/e2e/scenarios/one-sided-reload.ts @@ -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 @@ -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 @@ -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 diff --git a/demo/e2e/scenarios/relay-partition-asym.ts b/demo/e2e/scenarios/relay-partition-asym.ts index b102817..4503e06 100644 --- a/demo/e2e/scenarios/relay-partition-asym.ts +++ b/demo/e2e/scenarios/relay-partition-asym.ts @@ -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 diff --git a/demo/e2e/scenarios/relay-partition.ts b/demo/e2e/scenarios/relay-partition.ts index 2e55416..82c5308 100644 --- a/demo/e2e/scenarios/relay-partition.ts +++ b/demo/e2e/scenarios/relay-partition.ts @@ -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. @@ -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 ──────────────────────────────────────── // @@ -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. diff --git a/demo/e2e/scenarios/worker-eviction.ts b/demo/e2e/scenarios/worker-eviction.ts index a3d56e0..622669b 100644 --- a/demo/e2e/scenarios/worker-eviction.ts +++ b/demo/e2e/scenarios/worker-eviction.ts @@ -11,7 +11,9 @@ // - row 52/52b: a port to a dead host hears NOTHING (there is no // peer-death event to hear — `"onclose" in port` is platform-given, // not a gap this store could close); a pending RPC against it ends -// only at the CLIENT'S OWN timeout deadline. +// only at the CLIENT'S OWN timeout deadline (client.ts's default +// 120s — filed as #114: an evicted host is invisible to its tabs +// until that hang expires). // - row 53: a fresh connect respawns the host, and the CHECKPOINTED // state is intact. // Those rows are unit-level: one page, one device, a raw second port. @@ -82,11 +84,13 @@ // SYNTHETIC LABELED VALUES THROUGHOUT: the client pair below is // obviously-fake app identity for an in-process fake, issued by nobody. -import type { Browser, Page } from "npm:playwright@1.57.0"; +import type { Page } from "npm:playwright@1.57.0"; import type { Ctx, Scenario } from "../run.ts"; import { act, assert, assertEquals, SOLO_KEYS, waitForBoot } from "../util.ts"; import { addTodo, appFrame, createAccount, pairPages, solo, until, WAITS } from "../solo-util.ts"; import { startFakeDrive } from "../../host/fake-drive.ts"; +import { killSharedWorker, listSharedWorkers } from "../cdp.ts"; + const ROOT = "pm-worker-eviction"; const CLIENT_ID = "SYNTHETIC-EVICTION-CLIENT"; @@ -115,101 +119,27 @@ const CONVERGE_AFTER_RECOVERY = 100_000; const KILL_POLL_MS = 50; const KILL_POLL_DEADLINE_MS = 5_000; -interface WorkerTarget { - targetId: string; - title: string; - url: string; -} - -/** Every `shared_worker` CDP target titled `pm-device-` — - * `Target.getTargets` is browser-wide, so this is not scoped to one - * page. COPIED FROM devstore's `sharedWorkersFor` - * (runtime/tests/devstore/run.ts:105-117) and demo/e2e/cdp.ts's - * `listSharedWorkers` (:43-55), with ONE difference from cdp.ts's - * exported helper: cdp.ts's `killSharedWorker` matches by URL - * substring, which cannot tell two devices' hosts apart because every - * solo/demo page runs the identical `./worker.js` script (cdp.ts's own - * banner names this as the reason its sequence needed re-deriving for - * a multi-device scenario) — client.ts instead names the SharedWorker - * itself `pm-device-`, which is what the CDP target's TITLE - * carries. SUGGESTED GENERALIZATION for cdp.ts: a title-matching - * sibling to `killSharedWorker` would let any multi-device scenario do - * this without a scenario-local copy — flagged in this track's report - * rather than added here, since cdp.ts is owned by another track this - * wave. Detaches its own browser-level CDP session in a `finally`, - * cdp.ts's own reason: a caller that polls this repeatedly must not - * accumulate one session per call. */ -async function sharedWorkersByTitle(browser: Browser, title: string): Promise { - const cdp = await browser.newBrowserCDPSession(); - try { - const { targetInfos } = await cdp.send("Target.getTargets") as { - targetInfos: (WorkerTarget & { type: string })[]; - }; - return targetInfos - .filter((t) => t.type === "shared_worker" && t.title === title) - .map((t) => ({ targetId: t.targetId, title: t.title, url: t.url })); - } finally { - await cdp.detach().catch(() => { /* already gone */ }); - } -} - -/** Kill the SharedWorker titled `pm-device-` via CDP - * `Target.closeTarget` — no attach/evaluate fallback needed, per - * demo/e2e/cdp.ts's own spike finding (its banner, and devstore row 51 - * re-confirming it for exactly this kind of target) that closeTarget - * alone is sufficient. Throws, NAMING the live titles, if none matches: - * a scenario claiming "the host died with no goodbye" must fail loudly - * rather than silently killing the wrong thing (or nothing) if the - * naming ever drifts. Also throws, naming every MATCHING target, if - * more than one comes back: the claim this helper is built on is that - * the title picks out ONE device's host (client.ts's "the name is the - * device"), and killing `targets[0]` of an unasserted multi-match would - * silently kill an arbitrary one of them rather than surface that the - * naming assumption had broken. */ -async function killWorkerByDeviceId(browser: Browser, deviceId: string): Promise { - const title = `pm-device-${deviceId}`; - const targets = await sharedWorkersByTitle(browser, title); - if (targets.length === 0) { - const cdp = await browser.newBrowserCDPSession(); - let live = ""; - try { - const { targetInfos } = await cdp.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 cdp.detach().catch(() => { /* already gone */ }); - } - throw new Error(`no shared worker titled ${JSON.stringify(title)} — live: ${live}`); - } - if (targets.length > 1) { - throw new Error( - `expected exactly one shared worker titled ${JSON.stringify(title)}, found ` + - `${targets.length}: ${JSON.stringify(targets.map((t) => t.targetId))} — the title is ` + - `supposed to pick out ONE device's host (client.ts), so more than one match means the ` + - `naming assumption this helper relies on has broken`, - ); - } - const target = targets[0]; - const cdp = await browser.newBrowserCDPSession(); - try { - await cdp.send("Target.closeTarget", { targetId: target.targetId }); - } finally { - await cdp.detach().catch(() => { /* already gone */ }); - } - return target; +/** Kill the SharedWorker titled `pm-device-` — via + * demo/e2e/cdp.ts's `killSharedWorker`, which matches by TITLE for + * exactly this reason: solo/demo pages all run the identical + * `./worker.js` script, so a URL substring cannot tell two devices' + * hosts apart, but client.ts names the SharedWorker itself + * `pm-device-`, which is what the CDP target's TITLE carries + * (cdp.ts's banner, and devstore row 51's measurement that first needed + * this distinction). cdp.ts's own exactly-one-match discipline covers + * the loud-failure requirement this scenario needs. */ +async function killWorkerByDeviceId(browser: Ctx["browser"], deviceId: string) { + return await killSharedWorker(browser, { title: `pm-device-${deviceId}` }); } /** Bounded poll for the killed target to actually stop appearing in - * `Target.getTargets` — verifying the kill rather than trusting that - * `Target.closeTarget` returning is the same thing as the target being - * gone (cdp.ts's spike measured them as effectively simultaneous, but - * this scenario checks its OWN kill rather than importing that - * measurement as a given). */ + * `Target.getTargets` (via cdp.ts's `listSharedWorkers`) — verifying + * the kill rather than trusting that `Target.closeTarget` returning is + * the same thing as the target being gone (cdp.ts's spike measured them + * as effectively simultaneous, but this scenario checks its OWN kill + * rather than importing that measurement as a given). */ async function untilWorkerGone( - browser: Browser, + browser: Ctx["browser"], deviceId: string, ): Promise<{ gone: boolean; waitedMs: number; polls: number }> { const title = `pm-device-${deviceId}`; @@ -218,13 +148,16 @@ async function untilWorkerGone( let polls = 0; for (;;) { polls++; - const targets = await sharedWorkersByTitle(browser, title); - if (targets.length === 0) return { gone: true, waitedMs: Date.now() - started, polls }; + const targets = await listSharedWorkers(browser); + if (!targets.some((t) => t.title === title)) { + return { gone: true, waitedMs: Date.now() - started, polls }; + } if (Date.now() >= deadline) return { gone: false, waitedMs: Date.now() - started, polls }; await new Promise((r) => setTimeout(r, KILL_POLL_MS)); } } + /** Keep a device (until-reseal), so it is on the picker for a page that * did not exist when the device was made. COPIED from * solo-offline-sync.ts's `keepDevice` (same ceremony, same reasoning: a diff --git a/demo/host/solo.ts b/demo/host/solo.ts index 788ba98..d8322dc 100644 --- a/demo/host/solo.ts +++ b/demo/host/solo.ts @@ -4332,8 +4332,10 @@ async function startApp( // connection handle from before, and has no way to learn that the // thing on the other end of it is gone. `conn-status` reports the // outcome of the HANDSHAKE and is never invalidated afterwards - // (engine/guest/src/lib.rs:3700 writes it once; :3706-3713 reads it - // back forever), and `sync-status` is one-shot per round rather than a + // (engine/guest/src/lib.rs:4407 writes it once — `s.conn_results.insert(id, + // outcome)`, with the error paths at :4343 and :4400 writing the same + // slot early — and :4413-4420's `conn_status` reads that slot back + // forever), and `sync-status` is one-shot per round rather than a // subscription's health. So the reader has no evidence of staleness at // all, and the only "fix" available to this file would be to re-dial // on a timer — a second connection and a second set of subductions for @@ -4347,7 +4349,7 @@ async function startApp( // The honest fix belongs in the engine — a `conn-status` that goes // false when the connection drops — and until it exists this page // cannot tell the difference between a healthy peer and a departed - // one. + // one. Filed as #113. /** Slow on purpose: the thing being waited for is another human * opening a browser. */ diff --git a/runtime/device-store/locks.ts b/runtime/device-store/locks.ts index 7439b48..b799e15 100644 --- a/runtime/device-store/locks.ts +++ b/runtime/device-store/locks.ts @@ -19,8 +19,12 @@ // state of a device that is mid-reload. The lease's staleness // window is precisely the grace period for that gap. // * lock held, lease stale → a host that is alive but has not -// written a lease in a while (a suspended tab, a long GC pause). -// Alive is alive. +// written a lease in a while (the worker wedged mid-GC, or a +// browser that froze the worker under memory pressure). Alive is +// alive. Whether a SUSPENDED TAB suspends the SharedWorker whose +// lease this is remains UNMEASURED: the devstore matrix's CDP page +// freeze does not take on a harness page in this build (row 55), so +// the question could not even be posed there, let alone answered. // // bfcache would complicate lock lifetimes; the page already holds a // live relay WebSocket and is bfcache-ineligible regardless, so the