From 07416b036b63682089ce7ead2ae1dbc281875bc4 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:37:25 +0200 Subject: [PATCH 01/11] test: a page on screen polls its keys; leaving or hiding stops it (#914) --- package.json | 1 + pnpm-lock.yaml | 24 +++++++++ ui/src/state/queries.test.ts | 97 ++++++++++++++++++++++++++++++++++++ ui/src/state/queries.ts | 25 ++++++++++ 4 files changed, 147 insertions(+) create mode 100644 ui/src/state/queries.test.ts create mode 100644 ui/src/state/queries.ts diff --git a/package.json b/package.json index a7abfd033..0d16afd95 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@hono/node-server": "2.0.10", "@hono/zod-openapi": "1.6.3", "@modelcontextprotocol/sdk": "1.29.0", + "@tanstack/react-query": "^5.104.5", "hono": "^4.13.5", "jose": "6.2.9", "ws": "8.18.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 755ab3db6..ac0cb2922 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -225,6 +225,9 @@ importers: '@modelcontextprotocol/sdk': specifier: 1.29.0 version: 1.29.0(supports-color@7.2.0)(zod@4.6.5) + '@tanstack/react-query': + specifier: ^5.104.5 + version: 5.104.5(@types/react@19.3.0)(react@19.3.0) hono: specifier: ^4.13.5 version: 4.13.5 @@ -1256,6 +1259,18 @@ packages: '@stryker-mutator/core': 10.0.0 vitest: '>=2.0.0' + '@tanstack/query-core@5.104.5': + resolution: {integrity: sha512-Q94gI8Opd+CbdVNOdj8ZCyV8WqLYitrDq1t96Xc8WZT6lY0r5iRnJLXYDxMex7EEf2v+mXvn4FcjaBKSxXZQ9A==} + + '@tanstack/react-query@5.104.5': + resolution: {integrity: sha512-RORgFe052U+93weaNuqg7qHq7vNVNzXN/VrXqD4bbAuQCWUqOl2lV5Ui9flWk5CVvMyL/Bh27QWhzwH0aaOx9g==} + peerDependencies: + '@types/react': ^18 || ^19 + react: ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@tanstack/react-store@0.11.2': resolution: {integrity: sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==} peerDependencies: @@ -4526,6 +4541,15 @@ snapshots: tslib: 2.8.1 vitest: 4.1.10(@types/node@26.1.1)(vite@8.3.2(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.1)) + '@tanstack/query-core@5.104.5': {} + + '@tanstack/react-query@5.104.5(@types/react@19.3.0)(react@19.3.0)': + dependencies: + '@tanstack/query-core': 5.104.5 + react: 19.3.0 + optionalDependencies: + '@types/react': 19.3.0 + '@tanstack/react-store@0.11.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@tanstack/store': 0.11.2 diff --git a/ui/src/state/queries.test.ts b/ui/src/state/queries.test.ts new file mode 100644 index 000000000..e086415c9 --- /dev/null +++ b/ui/src/state/queries.test.ts @@ -0,0 +1,97 @@ +import { QueryObserver } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { ApiPath } from "../data/api"; +import { connect, fakeClock, settle } from "../data/live/test-support"; +import { createQueries, type Queries } from "./queries"; + +const disposers: (() => void)[] = []; + +afterEach(() => { + for (const dispose of disposers.splice(0)) dispose(); + vi.useRealTimers(); +}); + +function start() { + const live = connect(); + const queries: Queries = createQueries({ + clock: fakeClock, + connection: live.connection, + visibility: live.visibility, + }); + disposers.push(() => { + queries.dispose(); + live.connection.dispose(); + }); + const watch = (path: ApiPath) => + new QueryObserver(queries.client, queries.options(path)).subscribe(() => undefined); + const reads = (path: ApiPath) => live.daemon.gets.filter((get) => get.path === path).length; + return { ...live, queries, reads, watch }; +} + +describe("the query layer", () => { + it("a page on screen polls its keys; leaving or hiding stops it", async () => { + const intervals = [ + ["/v1/lease-requests", 5_000], + ["/v1/leases", 10_000], + ["/v1/leases/l_1", 10_000], + ["/v1/workers", 15_000], + ["/v1/stats?since=1h", 60_000], + ] as const; + for (const [path, every] of intervals) { + const { reads, watch } = start(); + const leave = watch(path); + await settle(); + expect(reads(path), `${path} first read`).toBe(1); + await vi.advanceTimersByTimeAsync(every - 1); + expect(reads(path), `${path} just before ${every}`).toBe(1); + await vi.advanceTimersByTimeAsync(1); + expect(reads(path), `${path} at ${every}`).toBe(2); + await vi.advanceTimersByTimeAsync(every); + expect(reads(path), `${path} at twice ${every}`).toBe(3); + + leave(); + await vi.advanceTimersByTimeAsync(every * 3); + expect(reads(path), `${path} after leaving`).toBe(3); + } + + const { reads: tokenReads, watch: watchTokens } = start(); + watchTokens("/v1/tokens"); + await settle(); + await vi.advanceTimersByTimeAsync(600_000); + expect(tokenReads("/v1/tokens"), "tokens are read once and never polled").toBe(1); + + const { daemon, visibility, watch: watchPage } = start(); + watchPage("/v1/workers"); + watchPage("/v1/leases"); + await settle(); + expect(daemon.gets).toHaveLength(2); + + visibility.set(true); + await vi.advanceTimersByTimeAsync(60_000); + expect(daemon.gets, "hidden").toHaveLength(2); + + visibility.set(false); + await settle(); + expect( + daemon.gets + .map((get) => get.path) + .slice(2) + .sort(), + ).toEqual(["/v1/leases", "/v1/workers"]); + + daemon.health = "down"; + daemon.openStream()?.end(); + await vi.advanceTimersByTimeAsync(60_000); + expect(daemon.gets, "disconnected").toHaveLength(4); + + daemon.health = "up"; + await vi.advanceTimersByTimeAsync(10_000); + expect( + daemon.gets + .map((get) => get.path) + .slice(4) + .sort(), + ).toEqual(["/v1/leases", "/v1/workers"]); + }); +}); diff --git a/ui/src/state/queries.ts b/ui/src/state/queries.ts new file mode 100644 index 000000000..4b7505c8d --- /dev/null +++ b/ui/src/state/queries.ts @@ -0,0 +1,25 @@ +import { QueryClient, type QueryObserverOptions } from "@tanstack/react-query"; + +import type { ApiPath } from "../data/api"; +import type { Clock, LiveConnection, Visibility } from "../data/live/connection"; + +export interface QueriesOptions { + readonly connection: LiveConnection; + readonly clock: Clock; + readonly visibility: Visibility; +} + +export interface Queries { + readonly client: QueryClient; + options(path: ApiPath): QueryObserverOptions; + dispose(): void; +} + +export function createQueries(_options: QueriesOptions): Queries { + const client = new QueryClient(); + return { + client, + dispose: () => undefined, + options: (path) => ({ queryFn: () => Promise.resolve(undefined as never), queryKey: [path] }), + }; +} From 08bb5caf15bb7e81ce2a8ac9f24ebe24533c280c Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:41:18 +0200 Subject: [PATCH 02/11] feat(ui): the console reads each route only while a page shows it, at that route's own interval (#914) --- e2e/console/workers-answers.ts | 2 +- .../workers-gateway-stops-mid-fetch.spec.ts | 2 +- ui/AGENTS.md | 18 +- ui/src/data/live/connection.test.ts | 251 +----------------- ui/src/data/live/connection.ts | 168 ++++-------- ui/src/state/events.ts | 8 +- ui/src/state/figures.ts | 4 +- ui/src/state/inbox.ts | 10 +- ui/src/state/leases.ts | 24 +- ui/src/state/live.tsx | 31 ++- ui/src/state/queries.test.ts | 179 ++++++++++++- ui/src/state/queries.ts | 116 +++++++- ui/src/state/search.ts | 4 +- ui/src/state/shell.ts | 4 +- ui/src/state/waiting.ts | 6 +- ui/src/state/workers.ts | 14 +- .../src/content/docs/docs/worker/console.mdx | 2 +- 17 files changed, 425 insertions(+), 418 deletions(-) diff --git a/e2e/console/workers-answers.ts b/e2e/console/workers-answers.ts index 08dce2603..a342fcb2c 100644 --- a/e2e/console/workers-answers.ts +++ b/e2e/console/workers-answers.ts @@ -9,7 +9,7 @@ interface LoadPayload { memory?: { availableBytes?: number; pressure?: string; totalBytes?: number } | undefined; } -// why: the page re-reads the list every second, so a re-read can be intercepted just as the test's +// why: the page re-reads the list every 15 seconds, so a re-read can be intercepted just as the test's // `finally` stops the gateway; the refused connect it gets is the fleet going away, not a failure of // the test, and Playwright would otherwise charge the rejection to it. Only connect-level errors are // dropped: any other error is a real fault and must still kill the test. diff --git a/e2e/console/workers-gateway-stops-mid-fetch.spec.ts b/e2e/console/workers-gateway-stops-mid-fetch.spec.ts index fa66b12bc..6936b2312 100644 --- a/e2e/console/workers-gateway-stops-mid-fetch.spec.ts +++ b/e2e/console/workers-gateway-stops-mid-fetch.spec.ts @@ -7,7 +7,7 @@ import { disposeFleet, expect, type RunningDaemon, signIn, startFleet, test } fr /** * why: reproduces #642. The workers page's response helpers (`answerWithoutLoad`, `answerMemory`) * answer the page's re-reads of `/v1/workers` by fetching them from the gateway through - * `route.fetch()`, and the page re-reads every second. A re-read intercepted but not yet fetched + * `route.fetch()`, and the page re-reads every 15 seconds. A re-read intercepted but not yet fetched * when the test's `finally` disposes the fleet gets a refused connect — a listener destroyed * mid-request reads the same way as a socket hang up — and Playwright attributes the handler's * rejection to the test, so the spec dies on the helper's `route.fetch()` line although every diff --git a/ui/AGENTS.md b/ui/AGENTS.md index 81bd2d294..9b2c08983 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -31,7 +31,7 @@ Each layer imports only the layers below it. `.dependency-cruiser.mjs` enforces (`overviewOf` in `overview.ts`). Shared rules live once: `workerHealth`, `workerSlots` (`fleet.ts`), `eventSummary`, `eventTone` (`activity.ts`), `inboxTone` (`inbox.ts`), the word → tone tables (`tones.ts`), page paths (`paths.ts`). -2. State: a hook in `src/state/.ts` reads routes with `useLiveResource`, turns each into a +2. State: a hook in `src/state/.ts` reads routes with `useRouteQuery`, turns each into a `LoadView` (`resource.ts`: `loadView`, `mapView`, `bothViews`), and maps it through the builder. It re-exports the view-model types the screen needs. 3. Screen: `src/screens//index.tsx` exports `Screen`, wraps content in `Loaded`, and @@ -55,6 +55,22 @@ a failed repeat keeps the last values, and the timer stops with the live connect and `useDaemonFacts` (`state/live.tsx`) give them to state and to the sidebar's update notice (ADR 0037 §4, ADR 0042). +## Reads + +A page reads a route only while it is on screen (`useRouteQuery`, `state/live.tsx`). `state/queries.ts` +holds the TanStack `QueryClient` (it is allowed only in `state/`) and the keys and times of ADR 0044: +`['workers']` every 15 s, `['leases']` and `['lease', id]` 10 s, `['lease-requests']` 5 s, +`['stats', range]` 60 s, `['tokens']` never; a read stays fresh 2 s (stats 30 s, tokens 5 min); +data no page uses is dropped after 60 s. A key polls only while a page uses it, the tab is shown and +the connection is up; a hidden tab or a lost connection reads nothing, and coming back reads each key +on screen once. An event marks keys stale through `model/stale-keys.ts`, and the burst window reads +them once, 300 ms after the last event of a burst (at most 1 s after its first), and again 1.5 s later; +a key no page shows is only marked. `LiveConnection` (`data/live/connection.ts`) keeps the stream, the +phase, the health checks and the clock offset, and does one classified GET for a read +(`connection.read`): a refusal is the route's own answer, a timeout while the daemon starts is asked +again, any other failure is the connection lost (`ConnectionLost`), which a page never shows as an +error. A refused stream is asked for again a second later. + ## Actions An action is described once per kind of item in `model/actions.ts` (`leaseActions`, `requestActions`, diff --git a/ui/src/data/live/connection.test.ts b/ui/src/data/live/connection.test.ts index c445fc723..e91186c66 100644 --- a/ui/src/data/live/connection.test.ts +++ b/ui/src/data/live/connection.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { ApiError, type ApiPath, RequestTimeoutError } from "../api"; -import type { StreamOpened } from "./connection"; +import { ApiError } from "../api"; import { connect, settle } from "./test-support"; const EVENT = 'event: lease.granted\ndata: {"seq":1,"timestamp":1,"event":"lease.granted"}\n\n'; @@ -10,110 +9,28 @@ afterEach(() => { vi.useRealTimers(); }); -function readsSince(gets: readonly { readonly path: ApiPath }[], from: number): ApiPath[] { - return gets.slice(from).map((get) => get.path); -} - describe("LiveConnection", () => { - it("an event refetches the routes the current screen reads", async () => { - const { connection, daemon } = connect(); - connection.watch("/v1/workers", () => {}); - connection.watch("/v1/leases", () => {}); - connection.watch("/v1/leases/l_1", () => {})(); - await settle(); - const before = daemon.gets.length; - - daemon.openStream()?.send(EVENT); - await settle(); - - expect(readsSince(daemon.gets, before)).toEqual(["/v1/workers", "/v1/leases"]); - expect(daemon.gets.slice(before).every((get) => get.at === Date.now())).toBe(true); - }); - - it("a burst of events while a request is in flight causes exactly one more request", async () => { - const { connection, daemon } = connect(); - daemon.reads = "hold"; - connection.watch("/v1/workers", () => {}); - await settle(); - expect(daemon.gets).toHaveLength(1); - - for (let event = 0; event < 5; event += 1) daemon.openStream()?.send(EVENT); - await settle(); - expect(daemon.gets).toHaveLength(1); - - daemon.release(); - await settle(); - expect(daemon.gets).toHaveLength(2); - - daemon.release(); - await settle(); - expect(daemon.gets).toHaveLength(2); - }); - - it("the screen is refetched every second while the tab is visible", async () => { - const { connection, daemon } = connect(); - const start = Date.now(); - connection.watch("/v1/workers", () => {}); - await settle(); - - await vi.advanceTimersByTimeAsync(5_000); - - expect(daemon.gets.map((get) => get.at - start)).toEqual([ - 0, 1_000, 2_000, 3_000, 4_000, 5_000, - ]); - }); - - it("polling stops and the stream closes while the tab is hidden, and both resume when it is shown", async () => { + it("the stream closes while the tab is hidden and reopens when it is shown", async () => { const { connection, daemon, visibility } = connect(); - connection.watch("/v1/workers", () => {}); await settle(); const stream = daemon.openStream(); expect(stream).toBeDefined(); visibility.set(true); await settle(); - const hiddenAt = daemon.gets.length; expect(vi.getTimerCount()).toBe(0); await vi.advanceTimersByTimeAsync(5_000); expect(stream?.closedByConsole()).toBe(true); expect(daemon.openStream()).toBeUndefined(); - expect(daemon.gets).toHaveLength(hiddenAt); const opened: (number | undefined)[] = []; connection.onStreamOpened((event) => opened.push(event.closedForMs)); visibility.set(false); await settle(); - expect(daemon.gets).toHaveLength(hiddenAt + 1); expect(daemon.streams).toHaveLength(2); expect(daemon.openStream()).toBe(daemon.streams[1]); expect(opened).toEqual([5_000]); - await vi.advanceTimersByTimeAsync(1_000); - expect(daemon.gets).toHaveLength(hiddenAt + 2); - }); - - it("a request that fails at the network level marks the console disconnected", async () => { - const { connection, daemon } = connect(); - daemon.reads = () => Promise.reject(new ApiError(500, "INTERNAL", "Internal error")); - connection.watch("/v1/workers", () => {}); - await settle(); - expect(connection.state().phase).toBe("connected"); - expect(connection.resource("/v1/workers").error).toBeInstanceOf(ApiError); - - daemon.reads = () => Promise.reject(new TypeError("Failed to fetch")); - await vi.advanceTimersByTimeAsync(1_000); - - expect(connection.state().phase).toBe("disconnected"); - expect(daemon.openStream()).toBeUndefined(); - }); - - it("a request that gives up after 10 seconds marks the console disconnected", async () => { - const { connection, daemon } = connect(); - daemon.reads = () => Promise.reject(new RequestTimeoutError("/v1/workers")); - connection.watch("/v1/workers", () => {}); - await settle(); - - expect(connection.state().phase).toBe("disconnected"); }); it("a stream the daemon closes marks the console disconnected", async () => { @@ -128,7 +45,6 @@ describe("LiveConnection", () => { it("a stream silent for 40 seconds marks the console disconnected", async () => { const { connection, daemon } = connect(); - connection.watch("/v1/workers", () => {}); await settle(); await vi.advanceTimersByTimeAsync(30_000); @@ -147,7 +63,6 @@ describe("LiveConnection", () => { asked.push(Date.now()); return Promise.resolve(answer); }); - connection.watch("/v1/workers", () => {}); await settle(); const lostAt = Date.now(); daemon.openStream()?.end(); @@ -180,7 +95,6 @@ describe("LiveConnection", () => { const { connection, daemon, visibility } = connect( () => new Promise((resolve) => (answer = resolve)), ); - connection.watch("/v1/workers", () => {}); await settle(); daemon.openStream()?.end(); await settle(); @@ -209,31 +123,26 @@ describe("LiveConnection", () => { it("while disconnected, /v1/healthz is called after 1, 2, 4 and 8 seconds, then every 10", async () => { const { connection, daemon } = connect(); - connection.watch("/v1/workers", () => {}); await settle(); daemon.health = "down"; daemon.openStream()?.end(); await settle(); const lostAt = Date.now(); - const reads = daemon.gets.length; await vi.advanceTimersByTimeAsync(45_000); const offsets = daemon.healthChecks.map((at) => at - lostAt); expect(offsets).toEqual([1_000, 3_000, 7_000, 15_000, 25_000, 35_000, 45_000]); - expect(daemon.gets).toHaveLength(reads); expect(daemon.streams).toHaveLength(1); expect(connection.state().phase).toBe("disconnected"); }); - it("when /v1/healthz answers, the screen is refetched and the stream reopened", async () => { + it("when /v1/healthz answers, the stream is reopened", async () => { const { connection, daemon } = connect(); - connection.watch("/v1/workers", () => {}); await settle(); daemon.health = "unreachable"; daemon.openStream()?.end(); await settle(); - const reads = daemon.gets.length; await vi.advanceTimersByTimeAsync(3_000); expect(connection.state().phase).toBe("disconnected"); const phases: string[] = []; @@ -242,48 +151,18 @@ describe("LiveConnection", () => { connection.onStreamOpened((event) => opened.push(event.closedForMs)); daemon.health = "up"; - daemon.reads = "hold"; daemon.holdStreams = true; await vi.advanceTimersByTimeAsync(4_000); expect(connection.state().phase).toBe("starting"); - expect(readsSince(daemon.gets, reads)).toEqual(["/v1/workers"]); expect(daemon.streams).toHaveLength(2); - daemon.release(); + daemon.openHeldStreams(); await settle(); expect(connection.state().phase).toBe("connected"); expect(phases).toEqual(["starting", "connected"]); expect(daemon.openStream()).toBe(daemon.streams[1]); expect(opened).toEqual([7_000]); - - daemon.reads = "answer"; - const recovered = daemon.gets.length; - await vi.advanceTimersByTimeAsync(1_000); - expect(daemon.gets).toHaveLength(recovered + 1); - }); - - it("while the daemon is starting, a request that gives up is sent again rather than marking the console disconnected", async () => { - const { connection, daemon } = connect(); - connection.watch("/v1/workers", () => {}); - await settle(); - daemon.openStream()?.end(); - await settle(); - const givesUp = (path: ApiPath) => - new Promise((_resolve, reject) => { - setTimeout(() => reject(new RequestTimeoutError(path)), 10_000); - }); - daemon.reads = givesUp; - daemon.refuseStreams = () => givesUp("/v1/events/stream"); - const reads = daemon.gets.length; - const streams = daemon.streams.length; - - await vi.advanceTimersByTimeAsync(21_000); - - expect(connection.state().phase).toBe("starting"); - expect(daemon.gets.length - reads).toBe(3); - expect(daemon.streams.length - streams).toBe(3); - expect(daemon.healthChecks).toHaveLength(1); }); it("a stream the console closes itself does not mark the console disconnected", async () => { @@ -314,36 +193,6 @@ describe("LiveConnection", () => { expect(daemon.healthChecks).toEqual([]); }); - it("a loss while the stream opens leaves the next open saying how long it was closed", async () => { - const { connection, daemon } = connect(); - connection.watch("/v1/workers", () => {}); - await settle(); - const opened: StreamOpened[] = []; - connection.onStreamOpened((event) => opened.push(event)); - daemon.openStream()?.end(); - await settle(); - let failRead: (error: unknown) => void = () => {}; - daemon.reads = () => - new Promise((_resolve, reject) => { - failRead = reject; - }); - daemon.holdStreams = true; - await vi.advanceTimersByTimeAsync(1_000); - expect(connection.state().phase).toBe("starting"); - - daemon.openHeldStreams(); - failRead(new TypeError("Failed to fetch")); - await settle(); - expect(connection.state().phase).toBe("disconnected"); - expect(opened).toEqual([]); - - daemon.reads = "answer"; - daemon.holdStreams = false; - await vi.advanceTimersByTimeAsync(1_000); - expect(connection.state().phase).toBe("connected"); - expect(opened).toEqual([{ closedForMs: 2_000 }]); - }); - it("each loss starts the /v1/healthz backoff from 1 second again", async () => { const { connection, daemon } = connect(); await settle(); @@ -367,11 +216,9 @@ describe("LiveConnection", () => { it("a daemon that is starting is ready once it opens the stream", async () => { const { connection, daemon } = connect(); - connection.watch("/v1/workers", () => {}); await settle(); daemon.openStream()?.end(); await settle(); - daemon.reads = "hold"; daemon.holdStreams = true; await vi.advanceTimersByTimeAsync(1_000); expect(connection.state().phase).toBe("starting"); @@ -382,29 +229,6 @@ describe("LiveConnection", () => { expect(connection.state().phase).toBe("connected"); }); - it("nothing is asked while the tab is hidden: not a coalesced read, not /v1/healthz", async () => { - const { connection, daemon, visibility } = connect(); - let failRead: (error: unknown) => void = () => {}; - daemon.reads = () => - new Promise((_resolve, reject) => { - failRead = reject; - }); - connection.watch("/v1/workers", () => {}); - await settle(); - daemon.openStream()?.send(EVENT); - await settle(); - visibility.set(true); - await settle(); - const reads = daemon.gets.length; - - failRead(new TypeError("Failed to fetch")); - await vi.advanceTimersByTimeAsync(30_000); - - expect(connection.state().phase).toBe("disconnected"); - expect(daemon.gets).toHaveLength(reads); - expect(daemon.healthChecks).toEqual([]); - }); - it("a tab shown again while disconnected asks /v1/healthz at once", async () => { const { connection, daemon, visibility } = connect(); await settle(); @@ -425,41 +249,7 @@ describe("LiveConnection", () => { expect(daemon.healthChecks.map((at) => at - shownAt)).toEqual([0, 1_000, 3_000]); }); - it("a refused read keeps the data the console had and the console connected", async () => { - const { connection, daemon } = connect(); - connection.watch("/v1/workers", () => {}); - await settle(); - const before = connection.resource("/v1/workers").data; - expect(before).toBeDefined(); - - daemon.reads = () => Promise.reject(new ApiError(500, "INTERNAL", "Internal error")); - await vi.advanceTimersByTimeAsync(1_000); - expect(connection.resource("/v1/workers")).toEqual({ - data: before, - error: expect.any(ApiError), - }); - - daemon.reads = () => Promise.reject(new SyntaxError("Unexpected token <")); - await vi.advanceTimersByTimeAsync(1_000); - expect(connection.resource("/v1/workers").error).toBeInstanceOf(SyntaxError); - expect(connection.state().phase).toBe("connected"); - }); - - it("a daemon that is starting and refuses a read is ready", async () => { - const { connection, daemon } = connect(); - connection.watch("/v1/workers", () => {}); - await settle(); - daemon.openStream()?.end(); - await settle(); - daemon.reads = () => Promise.reject(new ApiError(501, "UNSUPPORTED_IN_WORKER_MODE", "No")); - daemon.holdStreams = true; - - await vi.advanceTimersByTimeAsync(1_000); - - expect(connection.state().phase).toBe("connected"); - }); - - it("a refused stream leaves the console connected and is asked for again with the next poll", async () => { + it("a refused stream leaves the console connected and is asked for again a second later", async () => { const { connection, daemon } = connect(); await settle(); daemon.refuseStreams = () => Promise.reject(new ApiError(503, "INTERNAL", "Busy")); @@ -501,31 +291,16 @@ describe("LiveConnection", () => { ]); }); - it("the data's age is the time of the latest answer to a read", async () => { - const { connection, daemon } = connect(); - connection.watch("/v1/workers", () => {}); - await vi.advanceTimersByTimeAsync(3_000); - const lastAnswer = Date.now(); - - daemon.reads = () => Promise.reject(new TypeError("Failed to fetch")); - await vi.advanceTimersByTimeAsync(1_000); - - expect(connection.state()).toEqual({ answeredAt: lastAnswer, phase: "disconnected" }); - }); - it("disposing the connection closes the stream and stops every timer", async () => { const { connection, daemon } = connect(); - connection.watch("/v1/workers", () => {}); await settle(); const stream = daemon.openStream(); - const reads = daemon.gets.length; connection.dispose(); expect(vi.getTimerCount()).toBe(0); await vi.advanceTimersByTimeAsync(60_000); expect(stream?.closedByConsole()).toBe(true); - expect(daemon.gets).toHaveLength(reads); expect(daemon.healthChecks).toEqual([]); }); @@ -542,22 +317,6 @@ describe("LiveConnection", () => { expect(daemon.healthChecks).toEqual([]); }); - - it("durations use the clock offset from the latest Date header", async () => { - const { connection, daemon } = connect(); - const browserNow = Date.now(); - connection.watch("/v1/workers", () => {}); - daemon.date = new Date(browserNow + 1_000 + 90_000).toUTCString(); - await vi.advanceTimersByTimeAsync(1_000); - expect(connection.serverNow() - Date.now()).toBe(90_000); - - daemon.date = new Date(Date.now() + 1_000 - 30_000).toUTCString(); - await vi.advanceTimersByTimeAsync(1_000); - expect(connection.serverNow() - Date.now()).toBe(-30_000); - daemon.date = "not a date"; - await vi.advanceTimersByTimeAsync(1_000); - expect(connection.serverNow() - Date.now()).toBe(-30_000); - }); }); describe("StreamOpened", () => { diff --git a/ui/src/data/live/connection.ts b/ui/src/data/live/connection.ts index 63c1d61cd..f00fa0130 100644 --- a/ui/src/data/live/connection.ts +++ b/ui/src/data/live/connection.ts @@ -2,7 +2,7 @@ import { ApiError, type ApiClient, type ApiPath, RequestTimeoutError } from "../ import { createSseParser, type SseMessage } from "./sse"; import { clockOffset } from "./clock"; -const POLL_INTERVAL_MS = 1_000; +const STREAM_RETRY_MS = 1_000; // why: more than two of the daemon's 15-second keepalives, so one lost keepalive never marks a live // stream dead. const STREAM_SILENCE_MS = 40_000; @@ -47,11 +47,13 @@ export interface StreamOpened { readonly closedForMs?: number; } -interface Resource { - state: ResourceState; - readonly listeners: Set<() => void>; - inFlight: boolean; - again: boolean; +// why: a read lost to the network is not a fact about its route; the connection takes it as its +// own loss, and a page never shows it as an error. +export class ConnectionLost extends Error { + constructor() { + super("The connection to the daemon was lost"); + this.name = "ConnectionLost"; + } } // why: in a proxy session a loss may be the proxy's own session ending, which only the session can @@ -75,7 +77,6 @@ export class LiveConnection { readonly #clock: Clock; readonly #visibility: Visibility; readonly #whenLost: (() => Promise) | undefined; - readonly #resources = new Map(); readonly #stateListeners = new Set<() => void>(); readonly #eventListeners = new Set<(event: StreamEvent) => void>(); readonly #openListeners = new Set<(opened: StreamOpened) => void>(); @@ -84,7 +85,7 @@ export class LiveConnection { #answeredAt: number | undefined; #offset = 0; #disposed = false; - #pollTimer: unknown; + #streamRetryTimer: unknown; #healthTimer: unknown; #healthAttempt = 0; #silenceTimer: unknown; @@ -101,22 +102,38 @@ export class LiveConnection { this.#stopWatchingVisibility = this.#visibility.subscribe(() => this.#visibilityChanged()); if (!this.#visibility.hidden()) { this.#openStream(); - this.#schedulePoll(); } } - watch(path: ApiPath, listener: () => void): () => void { - const resource = this.#resource(path); - const first = resource.listeners.size === 0; - resource.listeners.add(listener); - if (first && this.#reading()) this.#fetch(path, resource); - return () => { - resource.listeners.delete(listener); - }; - } - - resource(path: ApiPath): ResourceState { - return this.#resource(path).state as ResourceState; + // why: one GET with the classification every read gets: a refusal is the route's own answer and + // the daemon is there; a timeout while the daemon starts is asked again; anything else is the + // connection lost. + async read(path: ApiPath): Promise { + for (;;) { + let response; + try { + response = await this.#api.get(path); + } catch (error: unknown) { + if (this.#disposed) throw error; + switch (this.#classify(error)) { + case "refused": + this.#daemonAnswered(); + throw error; + case "retry": + if (this.#reading()) continue; + throw error; + case "lost": + this.#lose(); + throw new ConnectionLost(); + } + } + if (!this.#disposed) { + this.#observe(response.date); + this.#answeredAt = this.#clock.now(); + this.#daemonAnswered(); + } + return response.body; + } } state(): ConnectionState { @@ -151,7 +168,7 @@ export class LiveConnection { dispose(): void { this.#disposed = true; this.#stopWatchingVisibility(); - this.#stopPoll(); + this.#stopStreamRetry(); this.#closeStream(); this.#clock.clearTimeout(this.#healthTimer); this.#stateListeners.clear(); @@ -159,73 +176,10 @@ export class LiveConnection { this.#openListeners.clear(); } - #resource(path: ApiPath): Resource { - let resource = this.#resources.get(path); - if (resource === undefined) { - resource = { again: false, inFlight: false, listeners: new Set(), state: {} }; - this.#resources.set(path, resource); - } - return resource; - } - #reading(): boolean { return !this.#disposed && !this.#visibility.hidden() && this.#state.phase !== "disconnected"; } - #refetchScreen(): void { - for (const [path, resource] of this.#resources) { - if (resource.listeners.size > 0) this.#fetch(path, resource); - } - } - - #fetch(path: ApiPath, resource: Resource): void { - if (resource.inFlight) { - resource.again = true; - return; - } - resource.inFlight = true; - this.#api.get(path).then( - (response) => { - resource.inFlight = false; - if (this.#disposed) return; - this.#observe(response.date); - this.#answeredAt = this.#clock.now(); - this.#daemonAnswered(); - this.#update(resource, { data: response.body }); - this.#again(path, resource); - }, - (error: unknown) => { - resource.inFlight = false; - if (this.#disposed) return; - this.#failed(resource, error); - this.#again(path, resource); - }, - ); - } - - #again(path: ApiPath, resource: Resource): void { - if (!resource.again) return; - resource.again = false; - if (this.#reading() && resource.listeners.size > 0) this.#fetch(path, resource); - } - - #failed(resource: Resource, error: unknown): void { - switch (this.#classify(error)) { - case "refused": { - // why: a fact about this route, not about the connection. - this.#daemonAnswered(); - const { data } = resource.state; - this.#update(resource, data === undefined ? { error } : { data, error }); - return; - } - case "retry": - resource.again = true; - return; - case "lost": - this.#lose(); - } - } - // why: a starting daemon holds every request until it is ready, so a request that gave up waiting // (`retry`) is sent again instead of counted as a loss. #classify(error: unknown): "refused" | "retry" | "lost" { @@ -234,11 +188,6 @@ export class LiveConnection { return "lost"; } - #update(resource: Resource, state: ResourceState): void { - resource.state = state; - for (const listener of resource.listeners) listener(); - } - #observe(date: string | null): void { const offset = clockOffset(date, this.#clock.now()); if (offset !== undefined) this.#offset = offset; @@ -247,7 +196,6 @@ export class LiveConnection { #daemonAnswered(): void { if (this.#state.phase !== "starting") return; this.#setState("connected"); - this.#schedulePoll(); } #setState(phase: ConnectionPhase): void { @@ -257,20 +205,9 @@ export class LiveConnection { for (const listener of this.#stateListeners) listener(); } - #schedulePoll(): void { - if (this.#pollTimer !== undefined) return; - this.#pollTimer = this.#clock.setTimeout(() => { - this.#pollTimer = undefined; - if (!this.#reading() || this.#state.phase !== "connected") return; - this.#refetchScreen(); - if (this.#stream === undefined) this.#openStream(); - this.#schedulePoll(); - }, POLL_INTERVAL_MS); - } - - #stopPoll(): void { - this.#clock.clearTimeout(this.#pollTimer); - this.#pollTimer = undefined; + #stopStreamRetry(): void { + this.#clock.clearTimeout(this.#streamRetryTimer); + this.#streamRetryTimer = undefined; } #openStream(): void { @@ -321,15 +258,24 @@ export class LiveConnection { if (outcome === "retry") this.#openStream(); else if (outcome === "lost") this.#lose(); else { - // why: refused means the daemon is there: no stream for now, and the next poll asks for one - // again. + // why: refused means the daemon is there: no stream for now, and one is asked for again a + // second later. this.#stream = undefined; this.#daemonAnswered(); + this.#retryStreamLater(); } return undefined; } } + #retryStreamLater(): void { + this.#stopStreamRetry(); + this.#streamRetryTimer = this.#clock.setTimeout(() => { + this.#streamRetryTimer = undefined; + if (this.#reading() && this.#stream === undefined) this.#openStream(); + }, STREAM_RETRY_MS); + } + async #follow(controller: AbortController, body: ReadableStream): Promise { const reader = body.getReader(); const parser = createSseParser(); @@ -373,12 +319,11 @@ export class LiveConnection { data = undefined; } for (const listener of this.#eventListeners) listener({ data, event: message.event }); - if (this.#reading()) this.#refetchScreen(); } #lose(): void { if (this.#disposed || this.#state.phase === "disconnected") return; - this.#stopPoll(); + this.#stopStreamRetry(); this.#closeStream(); this.#setState("disconnected"); this.#healthAttempt = 0; @@ -402,7 +347,6 @@ export class LiveConnection { #reconnect(): void { this.#setState("starting"); - this.#refetchScreen(); this.#openStream(); } @@ -436,7 +380,7 @@ export class LiveConnection { #visibilityChanged(): void { if (this.#disposed) return; if (this.#visibility.hidden()) { - this.#stopPoll(); + this.#stopStreamRetry(); this.#closeStream(); this.#clock.clearTimeout(this.#healthTimer); this.#healthTimer = undefined; @@ -447,8 +391,6 @@ export class LiveConnection { void this.#checkHealth(); return; } - this.#refetchScreen(); this.#openStream(); - if (this.#state.phase === "connected") this.#schedulePoll(); } } diff --git a/ui/src/state/events.ts b/ui/src/state/events.ts index 890af7a1c..883d70b57 100644 --- a/ui/src/state/events.ts +++ b/ui/src/state/events.ts @@ -23,7 +23,7 @@ import type { LeaseList, LeaseRecord } from "../model/leases"; import { itemIdFrom } from "../model/paths"; import type { WaitingList, WaitingRequest } from "../model/waiting"; import type { WorkerList, WorkerView } from "../model/workers"; -import { useLiveEvents, useLiveResource, useNow, useStreamOpened } from "./live"; +import { useLiveEvents, useRouteQuery, useNow, useStreamOpened } from "./live"; import { loadView, mapView } from "./resource"; import { setSearch, usePath, useSearch } from "./router"; import { useApi } from "./services"; @@ -83,9 +83,9 @@ export function useEvents(): LoadView { const events = feed.data; // why: the places an event links to are read for the links only; a route that is slow, refused // or not served here costs the feed nothing, only the link. - const workers = useLiveResource("/v1/workers").data?.workers ?? NO_WORKERS; - const leases = useLiveResource("/v1/leases").data?.leases ?? NO_LEASES; - const requests = useLiveResource("/v1/lease-requests").data?.requests ?? NO_REQUESTS; + const workers = useRouteQuery("/v1/workers").data?.workers ?? NO_WORKERS; + const leases = useRouteQuery("/v1/leases").data?.leases ?? NO_LEASES; + const requests = useRouteQuery("/v1/lease-requests").data?.requests ?? NO_REQUESTS; const now = useNow().server; const address = useSearch(); const subject = readSubject(address); diff --git a/ui/src/state/figures.ts b/ui/src/state/figures.ts index d1e1748ca..a47836de7 100644 --- a/ui/src/state/figures.ts +++ b/ui/src/state/figures.ts @@ -4,7 +4,7 @@ import { type ConsoleRange, statsPath } from "../model/range"; export type { FiguresState } from "../model/figures"; import { useRef } from "react"; -import { useLiveResource, useNow } from "./live"; +import { useRouteQuery, useNow } from "./live"; import { loadView } from "./resource"; // why: the stats are their own read, not joined with the fleet's: a refused read hides only the @@ -22,7 +22,7 @@ export function useStats( | { readonly kind: "loading" } | { readonly kind: "refused"; readonly message: string } | { readonly kind: "ready"; readonly data: T; readonly refusal?: string } { - const view = loadView(useLiveResource(statsPath(range))); + const view = loadView(useRouteQuery(statsPath(range))); const now = useNow(); // why: a new range is a new read; until it answers, the last answer stays drawn, so switching // the range does not swap every chart and tile for a loading note and back. diff --git a/ui/src/state/inbox.ts b/ui/src/state/inbox.ts index 2a7bb0d1a..a8c4a3abf 100644 --- a/ui/src/state/inbox.ts +++ b/ui/src/state/inbox.ts @@ -19,7 +19,7 @@ import type { WorkerList } from "../model/workers"; import { type ListDrawerState, useListDrawer } from "./drawer"; import { useRecentEvents } from "./events"; import { useActions } from "./actions"; -import { useDaemonMode, useLiveResource, useNow } from "./live"; +import { useDaemonMode, useRouteQuery, useNow } from "./live"; import { bothViews, loadView, mapView } from "./resource"; import { usePath } from "./router"; @@ -67,10 +67,10 @@ export function inboxReadsOf( export function useInboxReads(): InboxReads { return inboxReadsOf( - useLiveResource("/v1/lease-requests"), - useLiveResource("/v1/leases"), - useLiveResource("/v1/tokens"), - useLiveResource("/v1/workers"), + useRouteQuery("/v1/lease-requests"), + useRouteQuery("/v1/leases"), + useRouteQuery("/v1/tokens"), + useRouteQuery("/v1/workers"), ); } diff --git a/ui/src/state/leases.ts b/ui/src/state/leases.ts index 546630aed..01ce1d395 100644 --- a/ui/src/state/leases.ts +++ b/ui/src/state/leases.ts @@ -25,7 +25,7 @@ import type { WorkerList } from "../model/workers"; import { useActions } from "./actions"; import { useRecentEvents } from "./events"; import { useListDrawer, type ListDrawerState } from "./drawer"; -import { useLiveResource, useNow } from "./live"; +import { useRouteQuery, useNow } from "./live"; import { usePaging } from "./paging"; import { loadView, mapView } from "./resource"; import { setSearch, usePath, useSearch } from "./router"; @@ -67,9 +67,9 @@ export function useLeaseDrawerMenu(id: string): { readonly entries: readonly MenuEntry[]; } { const { entries } = useActions(); - const leases = useLiveResource("/v1/leases").data?.leases ?? []; - const tokens = useLiveResource("/v1/tokens").data?.tokens ?? []; - const workers = useLiveResource("/v1/workers").data?.workers ?? []; + const leases = useRouteQuery("/v1/leases").data?.leases ?? []; + const tokens = useRouteQuery("/v1/tokens").data?.tokens ?? []; + const workers = useRouteQuery("/v1/workers").data?.workers ?? []; const now = useNow(); const row = leaseRows({ leases, now: now.server, tokens, workers }).find( (candidate) => candidate.id === id, @@ -87,9 +87,9 @@ export function useLeaseDrawer(urlId: string | undefined): ListDrawerState { // or were refused a holder shows its id alone and the leases still show. export function useLeases(): Leases { const urlId = itemIdFrom("/leases", usePath()); - const leases = loadView(useLiveResource("/v1/leases")); - const tokens = useLiveResource("/v1/tokens").data?.tokens ?? []; - const workers = useLiveResource("/v1/workers").data?.workers ?? []; + const leases = loadView(useRouteQuery("/v1/leases")); + const tokens = useRouteQuery("/v1/tokens").data?.tokens ?? []; + const workers = useRouteQuery("/v1/workers").data?.workers ?? []; const now = useNow(); const [filter, setFilter] = useState("all"); const all = mapView(leases, (list) => @@ -130,12 +130,12 @@ function isUnknownLease(error: unknown): boolean { // why: a lease that was released or has expired is gone, and the page says so instead of keeping // the details it last held; `undefined` is that answer. export function useLeaseInspection(id: string): LoadView { - const details = useLiveResource<{ readonly lease: LeaseDetails }>( + const details = useRouteQuery<{ readonly lease: LeaseDetails }>( `/v1/leases/${encodeURIComponent(id)}`, ); - const list = useLiveResource("/v1/leases").data?.leases ?? []; - const tokens = useLiveResource("/v1/tokens").data?.tokens ?? []; - const workers = useLiveResource("/v1/workers").data?.workers ?? []; + const list = useRouteQuery("/v1/leases").data?.leases ?? []; + const tokens = useRouteQuery("/v1/tokens").data?.tokens ?? []; + const workers = useRouteQuery("/v1/workers").data?.workers ?? []; const now = useNow(); if (isUnknownLease(details.error)) return { data: undefined, kind: "ready" }; return mapView(loadView(details), ({ lease }) => @@ -152,6 +152,6 @@ export function useLeaseInspection(id: string): LoadView { const feed = useRecentEvents(); - const workers = useLiveResource("/v1/workers").data?.workers ?? []; + const workers = useRouteQuery("/v1/workers").data?.workers ?? []; return mapView(loadView(feed), (held) => eventRows(eventsOfLease(held, id), workers, "all")); } diff --git a/ui/src/state/live.tsx b/ui/src/state/live.tsx index cfe391737..3d098753c 100644 --- a/ui/src/state/live.tsx +++ b/ui/src/state/live.tsx @@ -8,12 +8,16 @@ import { useSyncExternalStore, } from "react"; +import { useQuery } from "@tanstack/react-query"; + import type { ApiPath } from "../data/api"; import { type DaemonFacts, type DaemonMode, type ModeStore, watchMode } from "../data/mode"; import { useApi, useSession } from "./services"; +import { createQueries, type Queries } from "./queries"; import { browserClock, documentVisibility } from "../data/live/browser"; import { type Clock, + ConnectionLost, type ConnectionState, LiveConnection, type ResourceState, @@ -29,6 +33,7 @@ export interface Now { export interface Live { readonly connection: LiveConnection; readonly mode: ModeStore; + readonly queries: Queries; readonly ticker: Ticker; } @@ -48,8 +53,14 @@ export function LiveProvider({ children }: { readonly children: ReactNode }) { }); const ticker = createTicker(browserClock, () => connection.serverNow()); const mode = watchMode({ api, clock: browserClock, connection }); - setLive({ connection, mode, ticker }); + const queries = createQueries({ + clock: browserClock, + connection, + visibility: documentVisibility, + }); + setLive({ connection, mode, queries, ticker }); return () => { + queries.dispose(); mode.dispose(); ticker.dispose(); connection.dispose(); @@ -65,14 +76,16 @@ function useLive(): Live { return live; } -export function useLiveResource(path: ApiPath): ResourceState { - const { connection } = useLive(); - const subscribe = useCallback( - (listener: () => void) => connection.watch(path, listener), - [connection, path], - ); - const snapshot = useCallback(() => connection.resource(path), [connection, path]); - return useSyncExternalStore(subscribe, snapshot); +// why: a read lost to the network is the connection's loss, which the sidebar says; the page +// keeps its data, or its loading, and shows no error for it. +export function useRouteQuery(path: ApiPath): ResourceState { + const { queries } = useLive(); + const { data, error } = useQuery(queries.options(path), queries.client); + const refusal = error instanceof ConnectionLost ? null : error; + return { + ...(data === undefined ? {} : { data }), + ...(refusal === null || refusal === undefined ? {} : { error: refusal }), + }; } // why: `undefined` until a read of `GET /v1/status` has said which kind of daemon this is. diff --git a/ui/src/state/queries.test.ts b/ui/src/state/queries.test.ts index e086415c9..e3dd5163b 100644 --- a/ui/src/state/queries.test.ts +++ b/ui/src/state/queries.test.ts @@ -1,9 +1,11 @@ import { QueryObserver } from "@tanstack/react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ApiPath } from "../data/api"; +import { ApiError, type ApiPath, RequestTimeoutError } from "../data/api"; import { connect, fakeClock, settle } from "../data/live/test-support"; -import { createQueries, type Queries } from "./queries"; +import { createQueries, type Queries, routeKey } from "./queries"; + +const EVENT = 'event: lease.granted\ndata: {"seq":1,"timestamp":1,"event":"lease.granted"}\n\n'; const disposers: (() => void)[] = []; @@ -12,21 +14,29 @@ afterEach(() => { vi.useRealTimers(); }); -function start() { +function start(keepAlive = true) { const live = connect(); const queries: Queries = createQueries({ clock: fakeClock, connection: live.connection, visibility: live.visibility, }); + // why: a stream silent for 40 seconds counts as lost, as the daemon's keepalives prevent. + const keepalive = setInterval(() => live.daemon.openStream()?.send(": keepalive\n\n"), 15_000); + if (!keepAlive) clearInterval(keepalive); disposers.push(() => { + clearInterval(keepalive); queries.dispose(); live.connection.dispose(); }); const watch = (path: ApiPath) => new QueryObserver(queries.client, queries.options(path)).subscribe(() => undefined); + const state = (path: ApiPath) => { + const query = queries.client.getQueryState(routeKey(path)); + return { data: query?.data, error: query?.error ?? undefined }; + }; const reads = (path: ApiPath) => live.daemon.gets.filter((get) => get.path === path).length; - return { ...live, queries, reads, watch }; + return { ...live, queries, reads, state, watch }; } describe("the query layer", () => { @@ -95,3 +105,164 @@ describe("the query layer", () => { ).toEqual(["/v1/leases", "/v1/workers"]); }); }); + +describe("a read's answers and failures", () => { + it("a read that fails at the network level marks the console disconnected", async () => { + const { daemon, connection, state, watch } = start(false); + daemon.reads = () => Promise.reject(new ApiError(500, "INTERNAL", "Internal error")); + watch("/v1/workers"); + await settle(); + expect(connection.state().phase).toBe("connected"); + expect(state("/v1/workers").error).toBeInstanceOf(ApiError); + + daemon.reads = () => Promise.reject(new TypeError("Failed to fetch")); + await vi.advanceTimersByTimeAsync(15_000); + + expect(connection.state().phase).toBe("disconnected"); + expect(daemon.openStream()).toBeUndefined(); + }); + + it("a read that gives up after 10 seconds marks the console disconnected", async () => { + const { connection, daemon, watch } = start(false); + daemon.reads = () => Promise.reject(new RequestTimeoutError("/v1/workers")); + watch("/v1/workers"); + await settle(); + + expect(connection.state().phase).toBe("disconnected"); + }); + + it("while the daemon is starting, a read that gives up is sent again rather than marking the console disconnected", async () => { + const { connection, daemon, watch } = start(false); + watch("/v1/workers"); + await settle(); + daemon.openStream()?.end(); + await settle(); + const givesUp = (path: ApiPath) => + new Promise((_resolve, reject) => { + setTimeout(() => reject(new RequestTimeoutError(path)), 10_000); + }); + daemon.reads = givesUp; + daemon.refuseStreams = () => givesUp("/v1/events/stream"); + const reads = daemon.gets.length; + const streams = daemon.streams.length; + + await vi.advanceTimersByTimeAsync(21_000); + + expect(connection.state().phase).toBe("starting"); + expect(daemon.gets.length - reads).toBe(3); + expect(daemon.streams.length - streams).toBe(3); + expect(daemon.healthChecks).toHaveLength(1); + }); + + it("a loss while the stream opens leaves the next open saying how long it was closed", async () => { + const { connection, daemon, watch } = start(false); + watch("/v1/workers"); + await settle(); + const opened: { closedForMs?: number }[] = []; + connection.onStreamOpened((event) => opened.push(event)); + daemon.openStream()?.end(); + await settle(); + let failRead: (error: unknown) => void = () => {}; + daemon.reads = () => + new Promise((_resolve, reject) => { + failRead = reject; + }); + daemon.holdStreams = true; + await vi.advanceTimersByTimeAsync(1_000); + expect(connection.state().phase).toBe("starting"); + + daemon.openHeldStreams(); + failRead(new TypeError("Failed to fetch")); + await settle(); + expect(connection.state().phase).toBe("disconnected"); + expect(opened).toEqual([]); + + daemon.reads = "answer"; + daemon.holdStreams = false; + await vi.advanceTimersByTimeAsync(1_000); + expect(connection.state().phase).toBe("connected"); + expect(opened).toEqual([{ closedForMs: 2_000 }]); + }); + + it("nothing is asked while the tab is hidden: not a coalesced read, not /v1/healthz", async () => { + const { connection, daemon, visibility, watch } = start(false); + let failRead: (error: unknown) => void = () => {}; + daemon.reads = () => + new Promise((_resolve, reject) => { + failRead = reject; + }); + watch("/v1/workers"); + await settle(); + daemon.openStream()?.send(EVENT); + await settle(); + visibility.set(true); + await settle(); + const reads = daemon.gets.length; + + failRead(new TypeError("Failed to fetch")); + await vi.advanceTimersByTimeAsync(30_000); + + expect(connection.state().phase).toBe("disconnected"); + expect(daemon.gets).toHaveLength(reads); + expect(daemon.healthChecks).toEqual([]); + }); + + it("a refused read keeps the data the console had and the console connected", async () => { + const { connection, daemon, state, watch } = start(); + watch("/v1/workers"); + await settle(); + const before = state("/v1/workers").data; + expect(before).toBeDefined(); + + daemon.reads = () => Promise.reject(new ApiError(500, "INTERNAL", "Internal error")); + await vi.advanceTimersByTimeAsync(15_000); + expect(state("/v1/workers")).toEqual({ data: before, error: expect.any(ApiError) }); + + daemon.reads = () => Promise.reject(new SyntaxError("Unexpected token <")); + await vi.advanceTimersByTimeAsync(15_000); + expect(state("/v1/workers").error).toBeInstanceOf(SyntaxError); + expect(connection.state().phase).toBe("connected"); + }); + + it("a daemon that is starting and refuses a read is ready", async () => { + const { connection, daemon, watch } = start(false); + watch("/v1/workers"); + await settle(); + daemon.openStream()?.end(); + await settle(); + daemon.reads = () => Promise.reject(new ApiError(501, "UNSUPPORTED_IN_WORKER_MODE", "No")); + daemon.holdStreams = true; + + await vi.advanceTimersByTimeAsync(1_000); + + expect(connection.state().phase).toBe("connected"); + }); + + it("the data's age is the time of the latest answer to a read", async () => { + const { connection, daemon, watch } = start(); + watch("/v1/workers"); + await vi.advanceTimersByTimeAsync(15_000); + const lastAnswer = Date.now(); + + daemon.reads = () => Promise.reject(new TypeError("Failed to fetch")); + await vi.advanceTimersByTimeAsync(15_000); + + expect(connection.state()).toEqual({ answeredAt: lastAnswer, phase: "disconnected" }); + }); + + it("durations use the clock offset from the latest Date header", async () => { + const { connection, daemon, watch } = start(); + const browserNow = Date.now(); + watch("/v1/workers"); + daemon.date = new Date(browserNow + 15_000 + 90_000).toUTCString(); + await vi.advanceTimersByTimeAsync(15_000); + expect(connection.serverNow() - Date.now()).toBe(90_000); + + daemon.date = new Date(Date.now() + 15_000 - 30_000).toUTCString(); + await vi.advanceTimersByTimeAsync(15_000); + expect(connection.serverNow() - Date.now()).toBe(-30_000); + daemon.date = "not a date"; + await vi.advanceTimersByTimeAsync(15_000); + expect(connection.serverNow() - Date.now()).toBe(-30_000); + }); +}); diff --git a/ui/src/state/queries.ts b/ui/src/state/queries.ts index 4b7505c8d..25c0a5e63 100644 --- a/ui/src/state/queries.ts +++ b/ui/src/state/queries.ts @@ -1,8 +1,34 @@ -import { QueryClient, type QueryObserverOptions } from "@tanstack/react-query"; +import { + focusManager, + onlineManager, + type Query, + QueryClient, + type QueryObserverOptions, +} from "@tanstack/react-query"; import type { ApiPath } from "../data/api"; import type { Clock, LiveConnection, Visibility } from "../data/live/connection"; +// why: data no page uses stays a minute, so going back to a page shows it at once. +const DROP_MS = 60_000; +const FRESH_MS = 2_000; + +interface Times { + readonly pollMs?: number; + readonly freshMs: number; +} + +const TIMES: Readonly> = { + lease: { freshMs: FRESH_MS, pollMs: 10_000 }, + "lease-requests": { freshMs: FRESH_MS, pollMs: 5_000 }, + leases: { freshMs: FRESH_MS, pollMs: 10_000 }, + stats: { freshMs: 30_000, pollMs: 60_000 }, + tokens: { freshMs: 300_000 }, + workers: { freshMs: FRESH_MS, pollMs: 15_000 }, +}; + +const DEFAULT_TIMES: Times = { freshMs: FRESH_MS }; + export interface QueriesOptions { readonly connection: LiveConnection; readonly clock: Clock; @@ -15,11 +41,91 @@ export interface Queries { dispose(): void; } -export function createQueries(_options: QueriesOptions): Queries { - const client = new QueryClient(); +// why: the key a route is cached under: its name, and for one lease or one stats range the id or +// range after it. +export function routeKey(path: ApiPath): readonly string[] { + const [route = "", search = ""] = path.slice("/v1/".length).split("?"); + const [head = "", id] = route.split("/"); + if (head === "stats") return ["stats", new URLSearchParams(search).get("since") ?? ""]; + if (head === "leases" && id !== undefined) return ["lease", decodeURIComponent(id)]; + return [head]; +} + +function timesOf(key: readonly unknown[]): Times { + return TIMES[String(key[0])] ?? DEFAULT_TIMES; +} + +export function createQueries(options: QueriesOptions): Queries { + const { clock, connection, visibility } = options; + const client = new QueryClient({ + defaultOptions: { + queries: { + gcTime: DROP_MS, + refetchOnReconnect: "always", + refetchOnWindowFocus: "always", + retry: false, + }, + }, + }); + // why: the browser's own focus and online events would fight the connection's view of both. + focusManager.setEventListener(() => () => undefined); + onlineManager.setEventListener(() => () => undefined); + focusManager.setFocused(!visibility.hidden()); + onlineManager.setOnline(connection.state().phase !== "disconnected"); + const stopVisibility = visibility.subscribe(() => focusManager.setFocused(!visibility.hidden())); + const stopConnection = connection.subscribe(() => + onlineManager.setOnline(connection.state().phase !== "disconnected"), + ); + client.mount(); + + // why: one poll timer per key, not per page using it, so two pages on one key do not read twice. + const polls = new Map(); + const stopPoll = (hash: string): void => { + clock.clearTimeout(polls.get(hash)); + polls.delete(hash); + }; + const schedulePoll = (query: Query): void => { + stopPoll(query.queryHash); + const { pollMs } = timesOf(query.queryKey); + if (pollMs === undefined || query.getObserversCount() === 0) return; + polls.set( + query.queryHash, + clock.setTimeout(() => { + polls.delete(query.queryHash); + if (visibility.hidden() || !onlineManager.isOnline()) return; + // why: a read already in flight answers this poll too, so it is not cancelled and sent again. + void client.refetchQueries( + { exact: true, queryKey: query.queryKey, type: "active" }, + { cancelRefetch: false }, + ); + }, pollMs), + ); + }; + const stopCache = client.getQueryCache().subscribe((event) => { + if (event.type === "observerAdded" || event.type === "observerRemoved") { + schedulePoll(event.query); + } else if ( + event.type === "updated" && + (event.action.type === "success" || event.action.type === "error") + ) { + schedulePoll(event.query); + } + }); + return { client, - dispose: () => undefined, - options: (path) => ({ queryFn: () => Promise.resolve(undefined as never), queryKey: [path] }), + dispose: () => { + stopCache(); + for (const hash of polls.keys()) stopPoll(hash); + stopVisibility(); + stopConnection(); + client.unmount(); + client.clear(); + }, + options: (path: ApiPath): QueryObserverOptions => ({ + queryFn: () => connection.read(path), + queryKey: routeKey(path), + staleTime: timesOf(routeKey(path)).freshMs, + }), }; } diff --git a/ui/src/state/search.ts b/ui/src/state/search.ts index 418acbce4..0f254c8bf 100644 --- a/ui/src/state/search.ts +++ b/ui/src/state/search.ts @@ -8,7 +8,7 @@ import { buildResults, groupStatuses, searchGroups } from "../model/search"; import { type WaitingList, waitingTable } from "../model/waiting"; import type { WorkerList } from "../model/workers"; import { useActions } from "./actions"; -import { useDaemonMode, useLiveResource, useNow } from "./live"; +import { useDaemonMode, useRouteQuery, useNow } from "./live"; import { loadView } from "./resource"; import { navigate } from "./router"; @@ -69,7 +69,7 @@ function sourceOf(state: ResourceState): Source { // why: a group already shown never changes while the dialog is open, so each read is kept as it // stood the first time it settled, answered or failed; a live update after that changes nothing. function useSettledOnce(path: ApiPath): Source { - const live = sourceOf(useLiveResource(path)); + const live = sourceOf(useRouteQuery(path)); const [kept, keep] = useState(live); if (kept.kind === "loading" && live.kind !== "loading") keep(live); return kept.kind === "loading" ? live : kept; diff --git a/ui/src/state/shell.ts b/ui/src/state/shell.ts index bae5205e7..2d8fb3ab9 100644 --- a/ui/src/state/shell.ts +++ b/ui/src/state/shell.ts @@ -6,11 +6,11 @@ import { formatDuration } from "../model/time"; import type { Toned } from "../model/tones"; import type { WorkerList } from "../model/workers"; import { useInboxReads } from "./inbox"; -import { useConnection, useLiveResource, useNow } from "./live"; +import { useConnection, useRouteQuery, useNow } from "./live"; import { useApi, useSession } from "./services"; export function useInboxCount(): number | undefined { - const workers = useLiveResource("/v1/workers").data?.workers; + const workers = useRouteQuery("/v1/workers").data?.workers; const { leases, tokens, waiting } = useInboxReads(); const now = useNow(); if (workers === undefined) return undefined; diff --git a/ui/src/state/waiting.ts b/ui/src/state/waiting.ts index 63faebc31..e0546268b 100644 --- a/ui/src/state/waiting.ts +++ b/ui/src/state/waiting.ts @@ -15,7 +15,7 @@ import { import type { WorkerList } from "../model/workers"; import { useActions } from "./actions"; import { useFigures } from "./figures"; -import { useLiveResource, useNow } from "./live"; +import { useRouteQuery, useNow } from "./live"; import { usePaging } from "./paging"; import { loadView, mapView } from "./resource"; import { usePath, useSearch } from "./router"; @@ -38,8 +38,8 @@ export function useQueueMenu(): (row: WaitingRow) => readonly MenuEntry[] { } export function useWaiting(): LoadView { - const requests = loadView(useLiveResource("/v1/lease-requests")); - const workers = useLiveResource("/v1/workers").data?.workers ?? []; + const requests = loadView(useRouteQuery("/v1/lease-requests")); + const workers = useRouteQuery("/v1/workers").data?.workers ?? []; const figures = useFigures("1h"); const now = useNow(); const id = itemIdFrom("/queue", usePath()); diff --git a/ui/src/state/workers.ts b/ui/src/state/workers.ts index 127748f21..b87e6af53 100644 --- a/ui/src/state/workers.ts +++ b/ui/src/state/workers.ts @@ -25,7 +25,7 @@ import { type EventRow, eventRows } from "../model/event-list"; import { eventsOfWorker } from "../model/events"; import { useActions } from "./actions"; import { useRecentEvents } from "./events"; -import { useDaemonMode, useLiveResource, useNow } from "./live"; +import { useDaemonMode, useRouteQuery, useNow } from "./live"; import { usePaging } from "./paging"; import { loadView, mapView } from "./resource"; import { navigate, setSearch, usePath, useSearch } from "./router"; @@ -99,7 +99,7 @@ export function useFleetAddress(): FleetAddress { } export function useFleet(): LoadView { - const workers = loadView(useLiveResource("/v1/workers")); + const workers = loadView(useRouteQuery("/v1/workers")); const search = useSearch(); const filter = readFilter(search); const ready = workers.kind === "ready"; @@ -127,9 +127,9 @@ export function useFleet(): LoadView { // while either has not arrived or was refused, a holder shows its id and the devices still show. // `undefined` is a worker the fleet does not list. export function useWorkerPage(id: string): LoadView { - const workers = loadView(useLiveResource("/v1/workers")); - const tokens = useLiveResource("/v1/tokens").data?.tokens ?? []; - const leases = useLiveResource("/v1/leases").data?.leases ?? []; + const workers = loadView(useRouteQuery("/v1/workers")); + const tokens = useRouteQuery("/v1/tokens").data?.tokens ?? []; + const leases = useRouteQuery("/v1/leases").data?.leases ?? []; const now = useNow(); const search = useSearch(); const chosen = @@ -176,7 +176,7 @@ export function useDeviceDrawer( // why: a leased device's UDID is known to the console only through its lease. export function useLeaseUdid(leaseId: string): string | undefined { - return useLiveResource<{ readonly lease: LeaseDetails }>( + return useRouteQuery<{ readonly lease: LeaseDetails }>( `/v1/leases/${encodeURIComponent(leaseId)}`, ).data?.lease.udid; } @@ -185,7 +185,7 @@ export function useLeaseUdid(leaseId: string): string | undefined { export function useWorkerEvents(workerId: string): LoadView { const feed = useRecentEvents(); const mode = useDaemonMode(); - const workers = useLiveResource("/v1/workers").data?.workers ?? []; + const workers = useRouteQuery("/v1/workers").data?.workers ?? []; return mapView(loadView(feed), (held) => eventRows(eventsOfWorker(held, workerId, mode), workers, "all"), ); diff --git a/website/src/content/docs/docs/worker/console.mdx b/website/src/content/docs/docs/worker/console.mdx index a64507bea..7f2a4970d 100644 --- a/website/src/content/docs/docs/worker/console.mdx +++ b/website/src/content/docs/docs/worker/console.mdx @@ -91,7 +91,7 @@ The sidebar leads to every page: **Overview**, **Workers**, **Leases**, **Waitin Every page but the overview is a list on the left and the item you chose on the right. Lists show 25 rows at a time, with a pager for 50 or 100. -Every page keeps itself current: a lease being granted or a device booting shows within about a second, with no reload. The bottom of the sidebar says how the connection is: +Every page keeps itself current: a lease being granted or a device booting shows within about a second, with no reload. Readings with no event of their own, such as host load, refresh within 15 seconds. The bottom of the sidebar says how the connection is: | It says | What it means | | --- | --- | From 1534529829662ed0ac4b84d8bb068e9e83462a24 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:42:34 +0200 Subject: [PATCH 03/11] test: data no page uses is dropped a minute after its last page closes (#914) --- ui/src/state/queries.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/ui/src/state/queries.test.ts b/ui/src/state/queries.test.ts index e3dd5163b..03f27b5bf 100644 --- a/ui/src/state/queries.test.ts +++ b/ui/src/state/queries.test.ts @@ -106,6 +106,42 @@ describe("the query layer", () => { }); }); +describe("data no page uses", () => { + it("is dropped a minute after the last page using it closes", async () => { + // why: each row is path, ms away, whether a read is sent again, whether the kept data shows at once. + const cases = [ + ["/v1/workers", 1_999, false, true], + ["/v1/workers", 2_000, true, true], + ["/v1/workers", 59_999, true, true], + ["/v1/workers", 60_000, true, false], + ["/v1/stats?since=1h", 29_999, false, true], + ["/v1/stats?since=1h", 30_000, true, true], + ["/v1/tokens", 59_999, false, true], + ["/v1/tokens", 60_000, true, false], + ] as const; + for (const [path, gone, rereads, keptAtOnce] of cases) { + const { queries, reads, watch } = start(); + const leave = watch(path); + await settle(); + expect(reads(path)).toBe(1); + leave(); + + await vi.advanceTimersByTimeAsync(gone); + const observer = new QueryObserver(queries.client, queries.options(path)); + const label = `${path} back after ${gone} ms`; + expect(observer.getCurrentResult().data !== undefined, `${label}: data at once`).toBe( + keptAtOnce, + ); + observer.subscribe(() => undefined); + await settle(); + expect(reads(path), `${label}: reads`).toBe(rereads ? 2 : 1); + expect(observer.getCurrentResult().data, `${label}: data after the read`).toEqual({ + read: rereads ? 2 : 1, + }); + } + }); +}); + describe("a read's answers and failures", () => { it("a read that fails at the network level marks the console disconnected", async () => { const { daemon, connection, state, watch } = start(false); From e8916d8c93b76c989ef61f981fdaebdfe53c70b8 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:43:35 +0200 Subject: [PATCH 04/11] test: events mark keys stale per the map, in bursts (#914) --- ui/src/state/queries.test.ts | 102 +++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/ui/src/state/queries.test.ts b/ui/src/state/queries.test.ts index 03f27b5bf..cb21d7cce 100644 --- a/ui/src/state/queries.test.ts +++ b/ui/src/state/queries.test.ts @@ -142,6 +142,108 @@ describe("data no page uses", () => { }); }); +function send( + daemon: ReturnType["daemon"], + name: string, + payload: Record = {}, +): void { + const data = JSON.stringify({ event: name, payload, seq: 1, timestamp: 1 }); + daemon.openStream()?.send(`event: ${name}\ndata: ${data}\n\n`); +} + +describe("events mark keys stale", () => { + it("per the map, in bursts", async () => { + const LEASES: ApiPath[] = ["/v1/leases", "/v1/leases/l_1", "/v1/leases/l_2"]; + const WATCHED = [ + ...LEASES, + "/v1/lease-requests", + "/v1/workers", + "/v1/stats?since=1h", + "/v1/tokens", + ] as const; + // why: each row is an event, its payload, and exactly the routes it marks. + const rows: readonly (readonly [string, Record, readonly string[]])[] = [ + ["lease.granted", {}, [...LEASES, "/v1/workers"]], + ["lease.released", {}, [...LEASES, "/v1/workers"]], + ["lease.expired", {}, [...LEASES, "/v1/workers"]], + ["lease.requested", {}, ["/v1/lease-requests"]], + ["lease.queued", {}, ["/v1/lease-requests"]], + ["lease.rejected", {}, ["/v1/lease-requests"]], + ["lease.declined", {}, ["/v1/lease-requests"]], + ["request.granted", {}, ["/v1/lease-requests"]], + ["request.dispatched", {}, ["/v1/lease-requests"]], + ["queue.changed", {}, ["/v1/lease-requests"]], + ["lease.renewed", { leaseId: "l_1" }, []], + ["device.ready", {}, ["/v1/workers"]], + ["device.quarantined", { leaseId: "l_1" }, ["/v1/workers", ...LEASES.slice(1)]], + ["worker.connected", {}, ["/v1/workers"]], + ["worker.drain-started", {}, ["/v1/workers"]], + ["capacity.changed", {}, ["/v1/workers"]], + ["component.installed", {}, ["/v1/workers"]], + ["daemon.update-started", {}, ["/v1/workers"]], + ["disk.pressure-detected", {}, []], + ["lease.something-new", {}, []], + ]; + for (const [name, payload, marked] of rows) { + const { daemon, watch } = start(); + for (const path of WATCHED) watch(path); + await settle(); + const before = daemon.gets.length; + + send(daemon, name, payload); + await vi.advanceTimersByTimeAsync(300); + + expect( + daemon.gets + .slice(before) + .map((get) => get.path) + .sort(), + name, + ).toEqual([...marked].sort()); + } + + const burst = start(); + burst.watch("/v1/workers"); + await settle(); + for (let event = 0; event < 50; event += 1) { + send(burst.daemon, "lease.granted"); + await vi.advanceTimersByTimeAsync(4); + } + await vi.advanceTimersByTimeAsync(295); + expect(burst.reads("/v1/workers"), "300 ms after the last of 50 events, just before").toBe(1); + await vi.advanceTimersByTimeAsync(1); + expect(burst.reads("/v1/workers"), "300 ms after the last of 50 events").toBe(2); + await vi.advanceTimersByTimeAsync(1_499); + expect(burst.reads("/v1/workers"), "1.5 s later, just before").toBe(2); + await vi.advanceTimersByTimeAsync(1); + expect(burst.reads("/v1/workers"), "1.5 s later").toBe(3); + + const steady = start(); + steady.watch("/v1/workers"); + await settle(); + for (let event = 0; event < 4; event += 1) { + send(steady.daemon, "lease.granted"); + await vi.advanceTimersByTimeAsync(200); + } + send(steady.daemon, "lease.granted"); + await vi.advanceTimersByTimeAsync(199); + expect(steady.reads("/v1/workers"), "events every 200 ms, just before 1 s").toBe(1); + await vi.advanceTimersByTimeAsync(1); + expect(steady.reads("/v1/workers"), "events every 200 ms, 1 s after the first").toBe(2); + + const away = start(); + away.watch("/v1/workers"); + away.watch("/v1/leases")(); + await settle(); + send(away.daemon, "lease.granted"); + await vi.advanceTimersByTimeAsync(1_900); + expect(away.reads("/v1/leases"), "a key no page shows is not read").toBe(1); + away.watch("/v1/leases"); + await settle(); + expect(away.reads("/v1/leases"), "and is read once a page shows it").toBe(2); + }); +}); + describe("a read's answers and failures", () => { it("a read that fails at the network level marks the console disconnected", async () => { const { daemon, connection, state, watch } = start(false); From c66fe51848d881d73631fbca663765603fd15477 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:45:18 +0200 Subject: [PATCH 05/11] feat(ui): an event marks the keys it changes stale, read once per burst (#914) --- ui/src/model/stale-keys.ts | 42 ++++++++++++++++++++++++++++ ui/src/state/queries.ts | 57 +++++++++++++++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 ui/src/model/stale-keys.ts diff --git a/ui/src/model/stale-keys.ts b/ui/src/model/stale-keys.ts new file mode 100644 index 000000000..b1b762cd0 --- /dev/null +++ b/ui/src/model/stale-keys.ts @@ -0,0 +1,42 @@ +// why: a key is the front of a query key; `["lease"]` stands for every `["lease", id]`. +export type StaleKey = readonly string[]; + +const LEASES: readonly StaleKey[] = [["leases"], ["workers"], ["lease"]]; +const REQUESTS: readonly StaleKey[] = [["lease-requests"]]; +const WORKERS: readonly StaleKey[] = [["workers"]]; +const NONE: readonly StaleKey[] = []; + +function hasLeaseId(payload: unknown): boolean { + if (typeof payload !== "object" || payload === null) return false; + return typeof (payload as { readonly leaseId?: unknown }).leaseId === "string"; +} + +// why: the data an event changes is stale from then on; a renewal changes only a deadline, which +// the console takes from the event itself, and any name not listed changes nothing a page shows. +export function staleKeys(name: string, payload: unknown): readonly StaleKey[] { + switch (name) { + case "lease.granted": + case "lease.released": + case "lease.expired": + return LEASES; + case "lease.requested": + case "lease.queued": + case "lease.rejected": + case "lease.declined": + case "queue.changed": + return REQUESTS; + case "capacity.changed": + return WORKERS; + default: + } + if (name.startsWith("request.")) return REQUESTS; + if (name.startsWith("device.")) return hasLeaseId(payload) ? [["workers"], ["lease"]] : WORKERS; + if ( + name.startsWith("worker.") || + name.startsWith("component.") || + name.startsWith("daemon.update-") + ) { + return WORKERS; + } + return NONE; +} diff --git a/ui/src/state/queries.ts b/ui/src/state/queries.ts index 25c0a5e63..c186b7f05 100644 --- a/ui/src/state/queries.ts +++ b/ui/src/state/queries.ts @@ -7,11 +7,18 @@ import { } from "@tanstack/react-query"; import type { ApiPath } from "../data/api"; -import type { Clock, LiveConnection, Visibility } from "../data/live/connection"; +import type { Clock, LiveConnection, StreamEvent, Visibility } from "../data/live/connection"; +import { type StaleKey, staleKeys } from "../model/stale-keys"; // why: data no page uses stays a minute, so going back to a page shows it at once. const DROP_MS = 60_000; const FRESH_MS = 2_000; +// why: events of one change come in a run, so a read waits until the run has been quiet this long, +// but never more than the cap after the first, and is sent once more later for a change that +// landed after the first answer was made. +const QUIET_MS = 300; +const CAP_MS = 1_000; +const AGAIN_MS = 1_500; interface Times { readonly pollMs?: number; @@ -112,9 +119,57 @@ export function createQueries(options: QueriesOptions): Queries { } }); + const marked = new Map(); + const again = new Map(); + let quietTimer: unknown; + let capTimer: unknown; + let againTimer: unknown; + const readMarked = (keys: ReadonlyMap): void => { + for (const queryKey of keys.values()) { + void client.refetchQueries({ queryKey, type: "active" }, { cancelRefetch: false }); + } + }; + const flush = (): void => { + clock.clearTimeout(quietTimer); + clock.clearTimeout(capTimer); + quietTimer = undefined; + capTimer = undefined; + const keys = new Map(marked); + marked.clear(); + readMarked(keys); + for (const [hash, key] of keys) again.set(hash, key); + clock.clearTimeout(againTimer); + againTimer = clock.setTimeout(() => { + againTimer = undefined; + const later = new Map(again); + again.clear(); + readMarked(later); + }, AGAIN_MS); + }; + const stopEvents = connection.onEvent((event: StreamEvent) => { + const { data } = event; + const payload = + typeof data === "object" && data !== null + ? (data as { payload?: unknown }).payload + : undefined; + const keys = staleKeys(event.event, payload); + if (keys.length === 0) return; + for (const queryKey of keys) { + marked.set(queryKey.join("/"), queryKey); + void client.invalidateQueries({ queryKey, refetchType: "none" }); + } + clock.clearTimeout(quietTimer); + quietTimer = clock.setTimeout(flush, QUIET_MS); + capTimer ??= clock.setTimeout(flush, CAP_MS); + }); + return { client, dispose: () => { + stopEvents(); + clock.clearTimeout(quietTimer); + clock.clearTimeout(capTimer); + clock.clearTimeout(againTimer); stopCache(); for (const hash of polls.keys()) stopPoll(hash); stopVisibility(); From af47ea6ecdfb7908a73335bec2639d57f21dbc1e Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:45:47 +0200 Subject: [PATCH 06/11] test: a read lost to the network leaves the page its data, or its loading, and no error (#914) --- ui/src/state/queries.test.ts | 32 +++++++++++++++++++++++++++++++- ui/src/state/queries.ts | 15 ++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/ui/src/state/queries.test.ts b/ui/src/state/queries.test.ts index cb21d7cce..9e081c1c4 100644 --- a/ui/src/state/queries.test.ts +++ b/ui/src/state/queries.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ApiError, type ApiPath, RequestTimeoutError } from "../data/api"; import { connect, fakeClock, settle } from "../data/live/test-support"; -import { createQueries, type Queries, routeKey } from "./queries"; +import { createQueries, type Queries, resourceOf, routeKey } from "./queries"; const EVENT = 'event: lease.granted\ndata: {"seq":1,"timestamp":1,"event":"lease.granted"}\n\n'; @@ -260,6 +260,36 @@ describe("a read's answers and failures", () => { expect(daemon.openStream()).toBeUndefined(); }); + it("a read lost to the network leaves the page its data, or its loading, and no error", async () => { + const lost = () => Promise.reject(new TypeError("Failed to fetch")); + const refused = () => Promise.reject(new ApiError(500, "INTERNAL", "Internal error")); + for (const [fails, error] of [ + [refused, expect.any(ApiError)], + [lost, undefined], + ] as const) { + const { connection, daemon, queries } = start(false); + const observer = new QueryObserver(queries.client, queries.options("/v1/workers")); + observer.subscribe(() => undefined); + await settle(); + expect(resourceOf(observer.getCurrentResult())).toEqual({ data: { read: 1 } }); + + daemon.reads = fails; + await vi.advanceTimersByTimeAsync(15_000); + expect(resourceOf(observer.getCurrentResult())).toEqual({ + data: { read: 1 }, + ...(error === undefined ? {} : { error }), + }); + expect(connection.state().phase).toBe(error === undefined ? "disconnected" : "connected"); + } + + const { daemon, queries } = start(false); + daemon.reads = lost; + const observer = new QueryObserver(queries.client, queries.options("/v1/workers")); + observer.subscribe(() => undefined); + await settle(); + expect(resourceOf(observer.getCurrentResult())).toEqual({}); + }); + it("a read that gives up after 10 seconds marks the console disconnected", async () => { const { connection, daemon, watch } = start(false); daemon.reads = () => Promise.reject(new RequestTimeoutError("/v1/workers")); diff --git a/ui/src/state/queries.ts b/ui/src/state/queries.ts index c186b7f05..37a7d01fc 100644 --- a/ui/src/state/queries.ts +++ b/ui/src/state/queries.ts @@ -7,7 +7,13 @@ import { } from "@tanstack/react-query"; import type { ApiPath } from "../data/api"; -import type { Clock, LiveConnection, StreamEvent, Visibility } from "../data/live/connection"; +import type { + Clock, + LiveConnection, + ResourceState, + StreamEvent, + Visibility, +} from "../data/live/connection"; import { type StaleKey, staleKeys } from "../model/stale-keys"; // why: data no page uses stays a minute, so going back to a page shows it at once. @@ -58,6 +64,13 @@ export function routeKey(path: ApiPath): readonly string[] { return [head]; } +export function resourceOf(_result: { + readonly data: T | undefined; + readonly error: unknown; +}): ResourceState { + return {}; +} + function timesOf(key: readonly unknown[]): Times { return TIMES[String(key[0])] ?? DEFAULT_TIMES; } From e2d42054ffdb08738359651b50c1af151517c698 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:46:05 +0200 Subject: [PATCH 07/11] feat(ui): a read lost to the network shows the page no error (#914) --- ui/src/state/live.tsx | 12 ++---------- ui/src/state/queries.ts | 24 ++++++++++++++++-------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/ui/src/state/live.tsx b/ui/src/state/live.tsx index 3d098753c..7fd37f754 100644 --- a/ui/src/state/live.tsx +++ b/ui/src/state/live.tsx @@ -13,11 +13,10 @@ import { useQuery } from "@tanstack/react-query"; import type { ApiPath } from "../data/api"; import { type DaemonFacts, type DaemonMode, type ModeStore, watchMode } from "../data/mode"; import { useApi, useSession } from "./services"; -import { createQueries, type Queries } from "./queries"; +import { createQueries, type Queries, resourceOf } from "./queries"; import { browserClock, documentVisibility } from "../data/live/browser"; import { type Clock, - ConnectionLost, type ConnectionState, LiveConnection, type ResourceState, @@ -76,16 +75,9 @@ function useLive(): Live { return live; } -// why: a read lost to the network is the connection's loss, which the sidebar says; the page -// keeps its data, or its loading, and shows no error for it. export function useRouteQuery(path: ApiPath): ResourceState { const { queries } = useLive(); - const { data, error } = useQuery(queries.options(path), queries.client); - const refusal = error instanceof ConnectionLost ? null : error; - return { - ...(data === undefined ? {} : { data }), - ...(refusal === null || refusal === undefined ? {} : { error: refusal }), - }; + return resourceOf(useQuery(queries.options(path), queries.client)); } // why: `undefined` until a read of `GET /v1/status` has said which kind of daemon this is. diff --git a/ui/src/state/queries.ts b/ui/src/state/queries.ts index 37a7d01fc..704a8ce9b 100644 --- a/ui/src/state/queries.ts +++ b/ui/src/state/queries.ts @@ -7,12 +7,13 @@ import { } from "@tanstack/react-query"; import type { ApiPath } from "../data/api"; -import type { - Clock, - LiveConnection, - ResourceState, - StreamEvent, - Visibility, +import { + type Clock, + ConnectionLost, + type LiveConnection, + type ResourceState, + type StreamEvent, + type Visibility, } from "../data/live/connection"; import { type StaleKey, staleKeys } from "../model/stale-keys"; @@ -64,11 +65,18 @@ export function routeKey(path: ApiPath): readonly string[] { return [head]; } -export function resourceOf(_result: { +// why: a read lost to the network is the connection's loss, which the sidebar says; the page keeps +// its data, or its loading, and shows no error for it. +export function resourceOf(result: { readonly data: T | undefined; readonly error: unknown; }): ResourceState { - return {}; + const { data, error } = result; + const refusal = error instanceof ConnectionLost ? undefined : error; + return { + ...(data === undefined ? {} : { data }), + ...(refusal === undefined || refusal === null ? {} : { error: refusal }), + }; } function timesOf(key: readonly unknown[]): Times { From 96fac1d3f200813c554f444ceb7a00680769c848 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 13:00:16 +0200 Subject: [PATCH 08/11] test(e2e): console specs return to the tab for a fresh read instead of waiting for a 1 s poll (#914) --- e2e/console/leases.spec.ts | 5 ++++- e2e/console/long-lists.spec.ts | 4 ++++ e2e/console/proxy-sign-in.spec.ts | 8 +++++++- e2e/console/tab.ts | 16 ++++++++++++++++ e2e/console/usage.spec.ts | 11 +++++------ e2e/console/workers.spec.ts | 2 ++ 6 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 e2e/console/tab.ts diff --git a/e2e/console/leases.spec.ts b/e2e/console/leases.spec.ts index caf8e1925..c2f682435 100644 --- a/e2e/console/leases.spec.ts +++ b/e2e/console/leases.spec.ts @@ -10,6 +10,7 @@ import { startFleet, test, } from "./fixtures.js"; +import { returnToTab } from "./tab.js"; /** One lease as `simlock list --leases` prints it, as far as these tests read. */ interface ListedLease { @@ -620,7 +621,9 @@ test.describe("the leases page", () => { ), ); - await expect(expiry).toContainText(/(49|50) min/, { timeout: 10_000 }); + await returnToTab(page); + + await expect(expiry).toContainText(/(49|50) min/); } finally { await host.dispose(); } diff --git a/e2e/console/long-lists.spec.ts b/e2e/console/long-lists.spec.ts index 56f00abed..9bf7115ed 100644 --- a/e2e/console/long-lists.spec.ts +++ b/e2e/console/long-lists.spec.ts @@ -11,6 +11,7 @@ import { startDaemon, test as base, } from "./fixtures.js"; +import { returnToTab } from "./tab.js"; const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] }; @@ -276,6 +277,7 @@ test.describe("long lists page", () => { await expect(range).toHaveText("51–60 of 60"); count = 30; + await returnToTab(page); // why: the list refresh is asynchronous; its final page may take more than one poll cycle. // The next refresh leaves no third page: the second, now the last, shows. @@ -310,6 +312,7 @@ test.describe("long lists page", () => { // Twice: the daemon answers once more, then the row's countdown moves, so a render follows. for (let refresh = 0; refresh < 2; refresh += 1) { const since = served; + await returnToTab(page); await expect.poll(() => served, { timeout: 3_000 }).toBeGreaterThan(since); const before = await expires.textContent(); await expect.poll(() => expires.textContent(), { timeout: 3_000 }).not.toBe(before); @@ -372,6 +375,7 @@ test.describe("long lists page", () => { // One row more and the list needs a second page: the pager shows. count = 26; + await returnToTab(page); await expect(pager(page, "Leases").locator(".pager-range")).toHaveText("1–25 of 26"); }); diff --git a/e2e/console/proxy-sign-in.spec.ts b/e2e/console/proxy-sign-in.spec.ts index 6b5ec7151..64a59408e 100644 --- a/e2e/console/proxy-sign-in.spec.ts +++ b/e2e/console/proxy-sign-in.spec.ts @@ -1,4 +1,5 @@ import { expect, NAV_LABELS, openFromNav, signIn, startProxiedDaemon, test } from "./fixtures.js"; +import { returnToTab } from "./tab.js"; const EXPIRED_LINE = "Your sign-in proxy's identity was refused: it has expired. You can still sign in with an operator token."; @@ -96,8 +97,10 @@ test.describe("signing in through a proxy", () => { await page.goto(proxied.url); await expect(page.getByText("Signed in as Alice")).toBeVisible(); - // why: the console polls every second, so the next request can come before any click. + // why: the console reads again when the operator returns to the tab, so the next request can + // come before any click. proxied.setMode("expired"); + await returnToTab(page); await expect(page.getByLabel("Operator token")).toBeVisible(); await expect(page.getByRole("alert")).toHaveText(EXPIRED_LINE); @@ -112,6 +115,7 @@ test.describe("signing in through a proxy", () => { await redirected.goto(proxied.url); await expect(redirected.getByText("Signed in as Alice")).toBeVisible(); proxied.setMode("redirect"); + await returnToTab(redirected); await expect(redirected).toHaveURL(proxied.loginUrl); await expect(redirected.getByRole("heading", { name: "Proxy login" })).toBeVisible(); await redirected.close(); @@ -121,6 +125,7 @@ test.describe("signing in through a proxy", () => { await reloaded.goto(proxied.url); await expect(reloaded.getByText("Signed in as Alice")).toBeVisible(); proxied.setMode("html"); + await returnToTab(reloaded); await expect(reloaded.getByRole("heading", { name: "Proxy sign-in" })).toBeVisible(); proxied.setMode("signed-in"); @@ -130,6 +135,7 @@ test.describe("signing in through a proxy", () => { (window as unknown as { loadedOnce: boolean }).loadedOnce = true; }); proxied.setMode("html"); + await returnToTab(reloaded); await expect(reloaded.getByText("Your sign-in has ended.")).toBeVisible(); await expect(reloaded.getByRole("button", { name: "Sign in again" })).toBeVisible(); diff --git a/e2e/console/tab.ts b/e2e/console/tab.ts new file mode 100644 index 000000000..52c62a3c5 --- /dev/null +++ b/e2e/console/tab.ts @@ -0,0 +1,16 @@ +import type { Page } from "@playwright/test"; + +// why: the console reads each route on its own interval (leases every 10 s, workers every 15 s, +// stats every 60 s) and again when the operator comes back to the tab, so a spec that changes what +// the daemon answers and wants the page to show it at once hides the tab and shows it again, which +// is that return. +export async function returnToTab(page: Page): Promise { + await page.evaluate(() => { + const show = (state: "hidden" | "visible") => { + Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state }); + document.dispatchEvent(new Event("visibilitychange")); + }; + show("hidden"); + show("visible"); + }); +} diff --git a/e2e/console/usage.spec.ts b/e2e/console/usage.spec.ts index 14cfc9e28..e674e6fbc 100644 --- a/e2e/console/usage.spec.ts +++ b/e2e/console/usage.spec.ts @@ -2,6 +2,7 @@ import type { Page } from "@playwright/test"; import { waitFor } from "../helpers/wait.js"; import { expect, type RunningDaemon, settled, signIn, startDaemon, test } from "./fixtures.js"; +import { returnToTab } from "./tab.js"; const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] }; const ONE_DEVICE = { limits: { ios: { maxDevices: 1, maxRunning: 1 }, maxRunning: 1 } }; @@ -201,12 +202,10 @@ test.describe("the Usage page", () => { const granted = await host.cli([...LEASE, "--agent-id", "late", "--detach"]); expect(granted.code, granted.stderr).toBe(0); - await expect(tile(page, "Requests").locator(".stat-value")).toHaveText("1", { - timeout: 10_000, - }); - await expect(tile(page, "Granted").locator(".stat-value")).toHaveText("1", { - timeout: 10_000, - }); + await returnToTab(page); + + await expect(tile(page, "Requests").locator(".stat-value")).toHaveText("1"); + await expect(tile(page, "Granted").locator(".stat-value")).toHaveText("1"); } finally { await host.dispose(); } diff --git a/e2e/console/workers.spec.ts b/e2e/console/workers.spec.ts index d6e1dc02b..06f293f1e 100644 --- a/e2e/console/workers.spec.ts +++ b/e2e/console/workers.spec.ts @@ -18,6 +18,7 @@ import { startFleet, test, } from "./fixtures.js"; +import { returnToTab } from "./tab.js"; interface WorkerView { readonly id: string; @@ -1049,6 +1050,7 @@ test.describe("the workers page", () => { [undefined, 50, "neutral"], ] as const) { claim = { usedPercent, ...(level === undefined ? {} : { pressure: level }) }; + await returnToTab(page); const want = tone === "neutral" ? neutral : tones[TONES.indexOf(tone)]; const at = `${usedPercent}%${level === undefined ? " with no word" : ` and ${level}`}`; await expect From 03b2f713c2058f1261e47ebe22670417db34a09f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 13:00:50 +0200 Subject: [PATCH 09/11] refactor(ui): createQueries takes its options as parameters (#914) --- ui/src/state/queries.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/src/state/queries.ts b/ui/src/state/queries.ts index 704a8ce9b..c238dba3b 100644 --- a/ui/src/state/queries.ts +++ b/ui/src/state/queries.ts @@ -83,8 +83,7 @@ function timesOf(key: readonly unknown[]): Times { return TIMES[String(key[0])] ?? DEFAULT_TIMES; } -export function createQueries(options: QueriesOptions): Queries { - const { clock, connection, visibility } = options; +export function createQueries({ clock, connection, visibility }: QueriesOptions): Queries { const client = new QueryClient({ defaultOptions: { queries: { From d9faae16f377638e37a07c597dc6d8db9c2ee55b Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 13:18:11 +0200 Subject: [PATCH 10/11] docs: the console no longer polls worker.list every second (#914) --- src/daemon/dispatcher.test.ts | 2 +- src/daemon/dispatcher.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/daemon/dispatcher.test.ts b/src/daemon/dispatcher.test.ts index 43000594a..6008dc39f 100644 --- a/src/daemon/dispatcher.test.ts +++ b/src/daemon/dispatcher.test.ts @@ -635,7 +635,7 @@ describe("Dispatcher: the fleet operations on a worker", () => { const { clock, dispatcher, driver } = await buildDispatcher(); const catalogReads = vi.spyOn(driver, "listCatalog"); - // A console polls every second; every poll up to the last millisecond of the interval + // why: calls arrive every second; every call up to the last millisecond of the interval // answers from the one read. await dispatcher.dispatch("worker.list", {}, admin); for (let elapsed = 1_000; elapsed < WORKER_VIEW_REFRESH_INTERVAL_MS; elapsed += 1_000) { diff --git a/src/daemon/dispatcher.ts b/src/daemon/dispatcher.ts index d5a9f20c8..79837b72b 100644 --- a/src/daemon/dispatcher.ts +++ b/src/daemon/dispatcher.ts @@ -255,11 +255,11 @@ export class Dispatcher { */ readonly #handlers: Record; /** - * `worker.list`'s catalog: the last `catalog.get` answer and when it was read. Kept for + * why: `worker.list`'s catalog: the last `catalog.get` answer and when it was read. Kept for * `WORKER_VIEW_REFRESH_INTERVAL_MS`, the rhythm a gateway re-reads a worker's catalog at, and * dropped on `WORKER_VIEW_CATALOG_EVENTS`, the events a gateway re-reads it on. Without it * every `worker.list` runs each driver's catalog read (`simctl list` on iOS), and the console - * polls that route every second. Holds the promise, so calls that arrive while a read runs + * polls that route. Holds the promise, so calls that arrive while a read runs * share it; a read that fails is dropped, so the next call reads again. */ #viewCatalog: { readonly readAt: number; readonly catalog: Promise } | undefined; From 3b619308f9612e9efe8d2c7b2223455376809b3f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 13:35:47 +0200 Subject: [PATCH 11/11] fix(ui): a hidden tab reads nothing at a burst, a read lost while hidden is no error, react-query is a devDependency (#914) --- package.json | 2 +- pnpm-lock.yaml | 6 +++--- ui/AGENTS.md | 2 +- ui/src/data/live/connection.ts | 3 ++- ui/src/state/queries.test.ts | 35 ++++++++++++++++++++++++++++++++++ ui/src/state/queries.ts | 2 ++ 6 files changed, 44 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 0d16afd95..1e46881d5 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,6 @@ "@hono/node-server": "2.0.10", "@hono/zod-openapi": "1.6.3", "@modelcontextprotocol/sdk": "1.29.0", - "@tanstack/react-query": "^5.104.5", "hono": "^4.13.5", "jose": "6.2.9", "ws": "8.18.3", @@ -87,6 +86,7 @@ "@readme/openapi-parser": "^9.1.0", "@stryker-mutator/core": "^10.0.0", "@stryker-mutator/vitest-runner": "^10.0.0", + "@tanstack/react-query": "^5.104.5", "@tanstack/react-table": "^9.2.4", "@tanstack/react-virtual": "^3.14.13", "@types/node": "^26.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac0cb2922..cf853632c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -225,9 +225,6 @@ importers: '@modelcontextprotocol/sdk': specifier: 1.29.0 version: 1.29.0(supports-color@7.2.0)(zod@4.6.5) - '@tanstack/react-query': - specifier: ^5.104.5 - version: 5.104.5(@types/react@19.3.0)(react@19.3.0) hono: specifier: ^4.13.5 version: 4.13.5 @@ -265,6 +262,9 @@ importers: '@stryker-mutator/vitest-runner': specifier: ^10.0.0 version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.1.1))(vitest@4.1.10(@types/node@26.1.1)(vite@8.3.2(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.1))) + '@tanstack/react-query': + specifier: ^5.104.5 + version: 5.104.5(@types/react@19.3.0)(react@19.3.0) '@tanstack/react-table': specifier: ^9.2.4 version: 9.2.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0) diff --git a/ui/AGENTS.md b/ui/AGENTS.md index 9b2c08983..6090d5e98 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -58,7 +58,7 @@ and `useDaemonFacts` (`state/live.tsx`) give them to state and to the sidebar's ## Reads A page reads a route only while it is on screen (`useRouteQuery`, `state/live.tsx`). `state/queries.ts` -holds the TanStack `QueryClient` (it is allowed only in `state/`) and the keys and times of ADR 0044: +holds the TanStack `QueryClient` (the dependency rules keep it out of `data/` and `model/`) and the keys and times of ADR 0044: `['workers']` every 15 s, `['leases']` and `['lease', id]` 10 s, `['lease-requests']` 5 s, `['stats', range]` 60 s, `['tokens']` never; a read stays fresh 2 s (stats 30 s, tokens 5 min); data no page uses is dropped after 60 s. A key polls only while a page uses it, the tab is shown and diff --git a/ui/src/data/live/connection.ts b/ui/src/data/live/connection.ts index f00fa0130..e380d6f76 100644 --- a/ui/src/data/live/connection.ts +++ b/ui/src/data/live/connection.ts @@ -121,7 +121,8 @@ export class LiveConnection { throw error; case "retry": if (this.#reading()) continue; - throw error; + // why: a read that gave up while the tab is hidden is a read lost, not the route's answer. + throw new ConnectionLost(); case "lost": this.#lose(); throw new ConnectionLost(); diff --git a/ui/src/state/queries.test.ts b/ui/src/state/queries.test.ts index 9e081c1c4..5a385c328 100644 --- a/ui/src/state/queries.test.ts +++ b/ui/src/state/queries.test.ts @@ -375,6 +375,41 @@ describe("a read's answers and failures", () => { expect(daemon.healthChecks).toEqual([]); }); + it("an event just before the tab hides reads nothing while it is hidden, at the burst or after it", async () => { + const { daemon, reads, visibility, watch } = start(); + watch("/v1/workers"); + await settle(); + daemon.openStream()?.send(EVENT); + await vi.advanceTimersByTimeAsync(100); + visibility.set(true); + const before = reads("/v1/workers"); + + await vi.advanceTimersByTimeAsync(10_000); + + expect(reads("/v1/workers")).toBe(before); + }); + + it("a read held while the tab is hidden and the daemon starts leaves the page no error", async () => { + const { daemon, queries, visibility } = start(false); + const observer = new QueryObserver(queries.client, queries.options("/v1/workers")); + observer.subscribe(() => undefined); + await settle(); + daemon.openStream()?.end(); + await settle(); + daemon.reads = (path) => + new Promise((_resolve, reject) => { + setTimeout(() => reject(new RequestTimeoutError(path)), 10_000); + }); + daemon.refuseStreams = () => new Promise(() => undefined); + await vi.advanceTimersByTimeAsync(15_000); + visibility.set(true); + await vi.advanceTimersByTimeAsync(15_000); + visibility.set(false); + await vi.advanceTimersByTimeAsync(1_000); + + expect(resourceOf(observer.getCurrentResult())).toEqual({ data: { read: 1 } }); + }); + it("a refused read keeps the data the console had and the console connected", async () => { const { connection, daemon, state, watch } = start(); watch("/v1/workers"); diff --git a/ui/src/state/queries.ts b/ui/src/state/queries.ts index c238dba3b..2e0edac9d 100644 --- a/ui/src/state/queries.ts +++ b/ui/src/state/queries.ts @@ -145,6 +145,8 @@ export function createQueries({ clock, connection, visibility }: QueriesOptions) let capTimer: unknown; let againTimer: unknown; const readMarked = (keys: ReadonlyMap): void => { + // why: a hidden tab or a lost connection reads nothing; coming back reads each key on screen once. + if (visibility.hidden() || !onlineManager.isOnline()) return; for (const queryKey of keys.values()) { void client.refetchQueries({ queryKey, type: "active" }, { cancelRefetch: false }); }