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