From b38b936767423adf0c1b1dbc571d692dcbf7bca3 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:52:53 +0200 Subject: [PATCH 01/21] test: a re-read of an unchanged route sends the first tag, and a 304 keeps the same data (#916) --- ui/src/data/api.ts | 21 +++++++++++++++-- ui/src/data/live/connection.ts | 20 ++++++++++++---- ui/src/data/live/test-support.ts | 40 ++++++++++++++++++++++++-------- ui/src/state/queries.test.ts | 24 +++++++++++++++++++ ui/src/state/queries.ts | 5 +++- 5 files changed, 93 insertions(+), 17 deletions(-) diff --git a/ui/src/data/api.ts b/ui/src/data/api.ts index dac19009..f6014734 100644 --- a/ui/src/data/api.ts +++ b/ui/src/data/api.ts @@ -79,9 +79,19 @@ export interface ApiResponse { readonly date: string | null; } +export interface ReadOptions { + // why: the ETag of the last answer to this route; `undefined` asks for the whole answer. + readonly tag: string | undefined; +} + +export type ConditionalResponse = + | { readonly notModified: true; readonly date: string | null } + | (ApiResponse & { readonly notModified?: false; readonly tag: string | null }); + export interface ApiClient { getJson(path: ApiPath): Promise; get(path: ApiPath): Promise>; + get(path: ApiPath, options: ReadOptions): Promise>; // why: a stream's bound, 10 seconds unless `headTimeoutMs` says otherwise, covers its headers // only; the body stays open until it ends or `signal` aborts it. stream( @@ -144,11 +154,18 @@ export function createApiClient(options: ApiClientOptions): ApiClient { throw error; } }; - const get = (path: ApiPath) => - watched(path, authorized(), async (response) => ({ + function get(path: ApiPath): Promise>; + function get(path: ApiPath, readOptions: ReadOptions): Promise>; + function get( + path: ApiPath, + _readOptions?: ReadOptions, + ): Promise | ConditionalResponse> { + return watched(path, authorized(), async (response) => ({ body: (await response.json()) as T, date: response.headers.get("Date"), + tag: null, })); + } return { get, getJson: async (path: ApiPath) => (await get(path)).body, diff --git a/ui/src/data/live/connection.ts b/ui/src/data/live/connection.ts index f00fa013..501c378e 100644 --- a/ui/src/data/live/connection.ts +++ b/ui/src/data/live/connection.ts @@ -1,4 +1,10 @@ -import { ApiError, type ApiClient, type ApiPath, RequestTimeoutError } from "../api"; +import { + ApiError, + type ApiClient, + type ApiPath, + type ReadOptions, + RequestTimeoutError, +} from "../api"; import { createSseParser, type SseMessage } from "./sse"; import { clockOffset } from "./clock"; @@ -72,6 +78,10 @@ export function isRefusal(error: unknown): boolean { return error instanceof ApiError || error instanceof SyntaxError; } +export type ConditionalRead = + | { readonly notModified: true } + | { readonly notModified?: false; readonly body: T; readonly tag: string | null }; + export class LiveConnection { readonly #api: LiveApi; readonly #clock: Clock; @@ -108,11 +118,11 @@ export class LiveConnection { // 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 { + async read(path: ApiPath, options: ReadOptions): Promise> { for (;;) { let response; try { - response = await this.#api.get(path); + response = await this.#api.get(path, options); } catch (error: unknown) { if (this.#disposed) throw error; switch (this.#classify(error)) { @@ -132,7 +142,9 @@ export class LiveConnection { this.#answeredAt = this.#clock.now(); this.#daemonAnswered(); } - return response.body; + return response.notModified === true + ? { notModified: true } + : { body: response.body, tag: response.tag }; } } diff --git a/ui/src/data/live/test-support.ts b/ui/src/data/live/test-support.ts index 4d22b37c..2af0f337 100644 --- a/ui/src/data/live/test-support.ts +++ b/ui/src/data/live/test-support.ts @@ -1,6 +1,6 @@ import { vi } from "vitest"; -import { type ApiPath, type ApiResponse } from "../api"; +import { type ApiPath, type ApiResponse, type ConditionalResponse, type ReadOptions } from "../api"; import { type Clock, LiveConnection, @@ -39,7 +39,7 @@ function deferred(): Deferred { } class FakeDaemon implements LiveApi { - readonly gets: { readonly path: ApiPath; readonly at: number }[] = []; + readonly gets: { readonly path: ApiPath; readonly at: number; readonly tag?: string }[] = []; readonly healthChecks: number[] = []; readonly streams: FakeStream[] = []; reads: "answer" | "hold" | ((path: ApiPath) => Promise>) = "answer"; @@ -47,20 +47,40 @@ class FakeDaemon implements LiveApi { refuseStreams: (() => Promise) | undefined; health: "up" | "down" | "unreachable" = "up"; date: string | null = null; + // why: the tag every route answers with; a read that sends it back gets a 304. + etag: string | undefined; readonly #held: Deferred>[] = []; readonly #heldStreams: (() => void)[] = []; readonly #encoder = new TextEncoder(); - get(path: ApiPath): Promise> { - this.gets.push({ at: Date.now(), path }); + get(path: ApiPath): Promise>; + get(path: ApiPath, options: ReadOptions): Promise>; + get( + path: ApiPath, + options?: ReadOptions, + ): Promise | ConditionalResponse> { + this.gets.push({ + at: Date.now(), + path, + ...(options?.tag === undefined ? {} : { tag: options.tag }), + }); const reads = this.reads; - if (typeof reads === "function") return reads(path) as Promise>; - if (reads === "hold") { - const held = deferred>(); - this.#held.push(held); - return held.promise as Promise>; + if (reads === "answer" && this.etag !== undefined && options?.tag === this.etag) { + return Promise.resolve({ date: this.date, notModified: true }); } - return Promise.resolve({ body: { read: this.gets.length }, date: this.date } as never); + if (typeof reads === "function") return reads(path) as Promise>; + if (reads === "hold") return this.#hold() as Promise>; + return Promise.resolve({ + body: { read: this.gets.length }, + date: this.date, + tag: this.etag ?? null, + } as never); + } + + #hold(): Promise> { + const held = deferred>(); + this.#held.push(held); + return held.promise; } openHeldStreams(): void { diff --git a/ui/src/state/queries.test.ts b/ui/src/state/queries.test.ts index e3dd5163..a4701450 100644 --- a/ui/src/state/queries.test.ts +++ b/ui/src/state/queries.test.ts @@ -266,3 +266,27 @@ describe("a read's answers and failures", () => { expect(connection.serverNow() - Date.now()).toBe(-30_000); }); }); + +describe("a route's ETag", () => { + it("a re-read of an unchanged route sends the first tag, and a 304 keeps the same data object with no update", async () => { + const { daemon, queries } = start(); + daemon.etag = '"v1"'; + const observer = new QueryObserver(queries.client, { + ...queries.options("/v1/workers"), + notifyOnChangeProps: ["data"], + }); + const updates = vi.fn(); + disposers.push(observer.subscribe(updates)); + await settle(); + const first = observer.getCurrentResult().data; + expect(first).toBeDefined(); + expect(daemon.gets.map((get) => get.tag)).toEqual([undefined]); + updates.mockClear(); + + await vi.advanceTimersByTimeAsync(15_000); + + expect(daemon.gets.map((get) => get.tag)).toEqual([undefined, '"v1"']); + expect(observer.getCurrentResult().data).toBe(first); + expect(updates).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/state/queries.ts b/ui/src/state/queries.ts index 25c0a5e6..312d9169 100644 --- a/ui/src/state/queries.ts +++ b/ui/src/state/queries.ts @@ -123,7 +123,10 @@ export function createQueries(options: QueriesOptions): Queries { client.clear(); }, options: (path: ApiPath): QueryObserverOptions => ({ - queryFn: () => connection.read(path), + queryFn: async () => { + const answer = await connection.read(path, { tag: undefined }); + return answer.notModified === true ? (undefined as T) : answer.body; + }, queryKey: routeKey(path), staleTime: timesOf(routeKey(path)).freshMs, }), From a28db46bd029a95b45d77c03ad338a6c96ae414f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:53:30 +0200 Subject: [PATCH 02/21] feat(ui): the console sends each route's last tag back and keeps its data on a 304 (#916) --- ui/src/state/queries.ts | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/ui/src/state/queries.ts b/ui/src/state/queries.ts index 312d9169..f6af78e2 100644 --- a/ui/src/state/queries.ts +++ b/ui/src/state/queries.ts @@ -1,5 +1,6 @@ import { focusManager, + hashKey, onlineManager, type Query, QueryClient, @@ -78,6 +79,9 @@ export function createQueries(options: QueriesOptions): Queries { ); client.mount(); + // why: the ETag of each key's last answer, sent back so an unchanged answer costs no download. + const tags = new Map(); + // 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 => { @@ -122,13 +126,20 @@ export function createQueries(options: QueriesOptions): Queries { client.unmount(); client.clear(); }, - options: (path: ApiPath): QueryObserverOptions => ({ - queryFn: async () => { - const answer = await connection.read(path, { tag: undefined }); - return answer.notModified === true ? (undefined as T) : answer.body; - }, - queryKey: routeKey(path), - staleTime: timesOf(routeKey(path)).freshMs, - }), + options: (path: ApiPath): QueryObserverOptions => { + const key = routeKey(path); + const hash = hashKey(key); + return { + queryFn: async () => { + const answer = await connection.read(path, { tag: tags.get(hash) }); + if (answer.notModified === true) return client.getQueryData(key) as T; + if (answer.tag === null) tags.delete(hash); + else tags.set(hash, answer.tag); + return answer.body; + }, + queryKey: key, + staleTime: timesOf(key).freshMs, + }; + }, }; } From 8d976dd75ea193a56291a194397e8477e4b7ee76 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:54:00 +0200 Subject: [PATCH 03/21] test: a read with a tag sends If-None-Match and answers a 304 as not modified (#916) --- ui/src/data/api.test.ts | 30 ++++++++++++++++++++++++++++++ ui/src/data/api.ts | 1 - 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/ui/src/data/api.test.ts b/ui/src/data/api.test.ts index 06770e8b..7cfd5ac4 100644 --- a/ui/src/data/api.test.ts +++ b/ui/src/data/api.test.ts @@ -227,6 +227,36 @@ describe("createApiClient", () => { expect(await api.get("/v1/workers")).toEqual({ body: { workers: [] }, date }); }); + it("a read with a tag sends it as If-None-Match and answers a 304 as not modified, a 200 with its body and tag", async () => { + const date = "Fri, 02 Oct 2026 12:00:00 GMT"; + const sent: (string | undefined)[] = []; + let answer: () => Response = () => new Response(null, { headers: { Date: date }, status: 304 }); + const fetch: Fetch = async (_input, init) => { + sent.push(new Headers(init.headers).get("If-None-Match") ?? undefined); + return answer(); + }; + const api = createApiClient({ fetch, signOut: () => {}, token: () => "slk_op" }); + + expect(await api.get("/v1/workers", { tag: '"a"' })).toEqual({ date, notModified: true }); + answer = () => + new Response(JSON.stringify({ workers: [] }), { + headers: { Date: date, ETag: '"b"' }, + status: 200, + }); + expect(await api.get("/v1/workers", { tag: '"a"' })).toEqual({ + body: { workers: [] }, + date, + tag: '"b"', + }); + answer = () => new Response(JSON.stringify({ workers: [] }), { status: 200 }); + expect(await api.get("/v1/workers", { tag: undefined })).toEqual({ + body: { workers: [] }, + date: null, + tag: null, + }); + expect(sent).toEqual(['"a"', '"a"', undefined]); + }); + it("/v1/healthz is asked with no token, and only a success status counts as up", async () => { const date = "Fri, 02 Oct 2026 12:00:00 GMT"; const up = answering(200, { ok: true }); diff --git a/ui/src/data/api.ts b/ui/src/data/api.ts index f6014734..7d2f5816 100644 --- a/ui/src/data/api.ts +++ b/ui/src/data/api.ts @@ -163,7 +163,6 @@ export function createApiClient(options: ApiClientOptions): ApiClient { return watched(path, authorized(), async (response) => ({ body: (await response.json()) as T, date: response.headers.get("Date"), - tag: null, })); } return { From 3ff9e2fabcacc15550a3169a88488f985b5501ed Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:54:21 +0200 Subject: [PATCH 04/21] feat(ui): ApiClient.get sends If-None-Match and answers a 304 as not modified (#916) --- ui/src/data/api.ts | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/ui/src/data/api.ts b/ui/src/data/api.ts index 7d2f5816..910dc972 100644 --- a/ui/src/data/api.ts +++ b/ui/src/data/api.ts @@ -31,6 +31,7 @@ interface SendOptions { readonly signal?: AbortSignal; readonly timeoutMs?: number; readonly body?: string; + readonly ifNoneMatch?: string; } export async function send( @@ -57,6 +58,7 @@ export async function send( credentials: "same-origin", headers: { ...(options.token === undefined ? {} : { Authorization: `Bearer ${options.token}` }), + ...(options.ifNoneMatch === undefined ? {} : { "If-None-Match": options.ifNoneMatch }), ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), }, ...(options.body === undefined ? {} : { body: options.body }), @@ -141,7 +143,10 @@ export function createApiClient(options: ApiClientOptions): ApiClient { ): Promise => { try { return await send(path, sendOptions, async (response) => { - await refuseError(response, refuse); + // why: a 304 answers the tag this read sent, so it is not a refusal. + if (response.status !== 304 || sendOptions.ifNoneMatch === undefined) { + await refuseError(response, refuse); + } return read(response); }); } catch (error: unknown) { @@ -158,12 +163,21 @@ export function createApiClient(options: ApiClientOptions): ApiClient { function get(path: ApiPath, readOptions: ReadOptions): Promise>; function get( path: ApiPath, - _readOptions?: ReadOptions, + readOptions?: ReadOptions, ): Promise | ConditionalResponse> { - return watched(path, authorized(), async (response) => ({ - body: (await response.json()) as T, - date: response.headers.get("Date"), - })); + const tag = readOptions?.tag; + return watched( + path, + { ...authorized(), ...(tag === undefined ? {} : { ifNoneMatch: tag }) }, + async (response) => { + const date = response.headers.get("Date"); + if (response.status === 304) return { date, notModified: true as const }; + const body = (await response.json()) as T; + return readOptions === undefined + ? { body, date } + : { body, date, tag: response.headers.get("ETag") }; + }, + ); } return { get, From ea8ce49cf172466ebb10dcb365a75a3a85212e8f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:55:56 +0200 Subject: [PATCH 05/21] test: a re-read of an unchanged route keeps data on a 304, replaces it on a 200 and reads again without a tag when the data was dropped (#916) --- ui/src/data/live/test-support.ts | 7 +++++-- ui/src/state/queries.test.ts | 35 +++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/ui/src/data/live/test-support.ts b/ui/src/data/live/test-support.ts index 2af0f337..aa747b6e 100644 --- a/ui/src/data/live/test-support.ts +++ b/ui/src/data/live/test-support.ts @@ -42,7 +42,10 @@ class FakeDaemon implements LiveApi { readonly gets: { readonly path: ApiPath; readonly at: number; readonly tag?: string }[] = []; readonly healthChecks: number[] = []; readonly streams: FakeStream[] = []; - reads: "answer" | "hold" | ((path: ApiPath) => Promise>) = "answer"; + reads: + | "answer" + | "hold" + | ((path: ApiPath) => Promise | ConditionalResponse>) = "answer"; holdStreams = false; refuseStreams: (() => Promise) | undefined; health: "up" | "down" | "unreachable" = "up"; @@ -68,7 +71,7 @@ class FakeDaemon implements LiveApi { if (reads === "answer" && this.etag !== undefined && options?.tag === this.etag) { return Promise.resolve({ date: this.date, notModified: true }); } - if (typeof reads === "function") return reads(path) as Promise>; + if (typeof reads === "function") return reads(path); if (reads === "hold") return this.#hold() as Promise>; return Promise.resolve({ body: { read: this.gets.length }, diff --git a/ui/src/state/queries.test.ts b/ui/src/state/queries.test.ts index a4701450..d9557e7f 100644 --- a/ui/src/state/queries.test.ts +++ b/ui/src/state/queries.test.ts @@ -268,7 +268,7 @@ describe("a read's answers and failures", () => { }); describe("a route's ETag", () => { - it("a re-read of an unchanged route sends the first tag, and a 304 keeps the same data object with no update", async () => { + it("a re-read of an unchanged route sends the first tag: a 304 keeps the same data object with no update, a 200 brings new data and its tag, a 304 after the data was dropped is read again without a tag", async () => { const { daemon, queries } = start(); daemon.etag = '"v1"'; const observer = new QueryObserver(queries.client, { @@ -288,5 +288,38 @@ describe("a route's ETag", () => { expect(daemon.gets.map((get) => get.tag)).toEqual([undefined, '"v1"']); expect(observer.getCurrentResult().data).toBe(first); expect(updates).not.toHaveBeenCalled(); + + daemon.etag = '"v2"'; + await vi.advanceTimersByTimeAsync(15_000); + const second = observer.getCurrentResult().data; + expect(daemon.gets.map((get) => get.tag)).toEqual([undefined, '"v1"', '"v1"']); + expect(second).not.toBe(first); + expect(second).toEqual({ read: 3 }); + + await vi.advanceTimersByTimeAsync(15_000); + expect(daemon.gets.map((get) => get.tag)).toEqual([undefined, '"v1"', '"v1"', '"v2"']); + expect(observer.getCurrentResult().data).toBe(second); + + let release: () => void = () => {}; + daemon.reads = () => + new Promise((resolve) => { + release = () => { + daemon.reads = "answer"; + resolve({ date: null, notModified: true }); + }; + }); + await vi.advanceTimersByTimeAsync(15_000); + queries.client.removeQueries({ queryKey: routeKey("/v1/workers") }); + release(); + await settle(); + + expect(daemon.gets.map((get) => get.tag)).toEqual([ + undefined, + '"v1"', + '"v1"', + '"v2"', + '"v2"', + undefined, + ]); }); }); From ded5109fd4457d8d31255cd52078b0dec3d47099 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:56:23 +0200 Subject: [PATCH 06/21] feat(ui): a 304 after the data was dropped is read again without a tag (#916) --- ui/src/state/queries.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/ui/src/state/queries.ts b/ui/src/state/queries.ts index f6af78e2..7d3d7441 100644 --- a/ui/src/state/queries.ts +++ b/ui/src/state/queries.ts @@ -131,11 +131,20 @@ export function createQueries(options: QueriesOptions): Queries { const hash = hashKey(key); return { queryFn: async () => { - const answer = await connection.read(path, { tag: tags.get(hash) }); - if (answer.notModified === true) return client.getQueryData(key) as T; - if (answer.tag === null) tags.delete(hash); - else tags.set(hash, answer.tag); - return answer.body; + let tag = tags.get(hash); + for (;;) { + const answer = await connection.read(path, { tag }); + if (answer.notModified !== true) { + if (answer.tag === null) tags.delete(hash); + else tags.set(hash, answer.tag); + return answer.body; + } + const kept = client.getQueryData(key); + if (kept !== undefined) return kept; + // why: the data this tag stood for was dropped while the read was out, so the tag + // describes nothing and the whole answer is read again. + tag = undefined; + } }, queryKey: key, staleTime: timesOf(key).freshMs, From 001f3fd80f12fc3ef1b35e1caa00883da380c46a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:57:27 +0200 Subject: [PATCH 07/21] test: a lease renewal moves the lease's deadline in place with no read (#916) --- ui/src/state/queries.test.ts | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/ui/src/state/queries.test.ts b/ui/src/state/queries.test.ts index d9557e7f..d3ac5a70 100644 --- a/ui/src/state/queries.test.ts +++ b/ui/src/state/queries.test.ts @@ -323,3 +323,53 @@ describe("a route's ETag", () => { ]); }); }); + +describe("a lease renewal", () => { + const NOW = Date.parse("2026-10-02T12:00:00Z"); + const lease = (id: string, over: Record = {}) => ({ + deviceId: `dev_${id}`, + grantedAt: NOW, + id, + idChosenByRequester: false, + lastRenewedAt: NOW, + ownerId: "owner", + requesterId: "agent", + ttlDeadline: NOW + 60_000, + ttlMs: 60_000, + ...over, + }); + const renewed = (payload: Record, seq = 2) => + `event: lease.renewed\ndata: ${JSON.stringify({ + event: "lease.renewed", + id: `evt_${String(seq)}`, + payload, + seq, + timestamp: Date.now(), + })}\n\n`; + + it("moves the lease's deadline in place with no read: a worker event matches by id, lastRenewedAt and ttlMs follow, no match changes nothing", async () => { + const { daemon, reads, state, watch } = start(); + daemon.reads = () => + Promise.resolve({ body: { leases: [lease("l_1"), lease("l_2")] }, date: null }); + watch("/v1/leases"); + await settle(); + const before = state("/v1/leases").data as { leases: object[] }; + + await vi.advanceTimersByTimeAsync(5_000); + const newDeadline = Date.now() + 120_000; + daemon.openStream()?.send(renewed({ leaseId: "l_1", newDeadline })); + await settle(); + + const after = state("/v1/leases").data as { leases: object[] }; + expect(after.leases[0]).toEqual( + lease("l_1", { lastRenewedAt: Date.now(), ttlDeadline: newDeadline, ttlMs: 120_000 }), + ); + expect(after.leases[1]).toBe(before.leases[1]); + expect(reads("/v1/leases")).toBe(1); + + daemon.openStream()?.send(renewed({ leaseId: "l_9", newDeadline: newDeadline + 1_000 }, 3)); + await settle(); + expect(state("/v1/leases").data).toBe(after); + expect(reads("/v1/leases")).toBe(1); + }); +}); From 5d41810d6e6154b83aa82368b5d7ac48bc97210a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:57:54 +0200 Subject: [PATCH 08/21] feat(ui): a lease.renewed event moves the lease's deadline in the leases list (#916) --- ui/src/model/renewal.ts | 35 +++++++++++++++++++++++++++++++++++ ui/src/state/queries.ts | 13 +++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 ui/src/model/renewal.ts diff --git a/ui/src/model/renewal.ts b/ui/src/model/renewal.ts new file mode 100644 index 00000000..2a6ed6ed --- /dev/null +++ b/ui/src/model/renewal.ts @@ -0,0 +1,35 @@ +import type { ConsoleEvent } from "./events"; +import type { LeaseList, LeaseRecord } from "./leases"; + +export interface Renewal { + readonly leaseId: string; + readonly newDeadline: number; + readonly timestamp: number; +} + +// why: the payload is wire input, so a renewal missing either field is dropped rather than patched +// from a guess. +export function readRenewal(event: ConsoleEvent): Renewal | undefined { + if (event.event !== "lease.renewed") return undefined; + const payload = event.payload; + if (typeof payload !== "object" || payload === null) return undefined; + const { leaseId, newDeadline } = payload as Record; + if (typeof leaseId !== "string" || typeof newDeadline !== "number") return undefined; + return { leaseId, newDeadline, timestamp: event.timestamp }; +} + +function renewLease(lease: LeaseRecord, renewal: Renewal): LeaseRecord { + if (lease.id !== renewal.leaseId || renewal.newDeadline <= lease.ttlDeadline) return lease; + return { + ...lease, + lastRenewedAt: renewal.timestamp, + ttlDeadline: renewal.newDeadline, + ttlMs: renewal.newDeadline - renewal.timestamp, + }; +} + +// why: the same list when no lease changed, so an observer of it is not told of an update. +export function renewLeases(list: LeaseList, renewal: Renewal): LeaseList { + const leases = list.leases.map((lease) => renewLease(lease, renewal)); + return leases.some((lease, index) => lease !== list.leases[index]) ? { ...list, leases } : list; +} diff --git a/ui/src/state/queries.ts b/ui/src/state/queries.ts index 7d3d7441..2f57994f 100644 --- a/ui/src/state/queries.ts +++ b/ui/src/state/queries.ts @@ -9,6 +9,9 @@ import { import type { ApiPath } from "../data/api"; import type { Clock, LiveConnection, Visibility } from "../data/live/connection"; +import { readEvent } from "../model/events"; +import type { LeaseList } from "../model/leases"; +import { readRenewal, renewLeases } from "../model/renewal"; // why: data no page uses stays a minute, so going back to a page shows it at once. const DROP_MS = 60_000; @@ -116,9 +119,19 @@ export function createQueries(options: QueriesOptions): Queries { } }); + const stopEvents = connection.onEvent((streamEvent) => { + const event = readEvent(streamEvent.data); + const renewal = event === undefined ? undefined : readRenewal(event); + if (renewal === undefined) return; + client.setQueryData(["leases"], (old) => + old === undefined ? undefined : renewLeases(old, renewal), + ); + }); + return { client, dispose: () => { + stopEvents(); stopCache(); for (const hash of polls.keys()) stopPoll(hash); stopVisibility(); From eb9a98982d0d30d5616418304512d916414277c0 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:58:25 +0200 Subject: [PATCH 09/21] test: a lease renewal is matched by worker in a gateway and its deadline outlives an older answer for 60 seconds (#916) --- ui/src/state/queries.test.ts | 45 +++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/ui/src/state/queries.test.ts b/ui/src/state/queries.test.ts index d3ac5a70..f3871e9c 100644 --- a/ui/src/state/queries.test.ts +++ b/ui/src/state/queries.test.ts @@ -347,7 +347,7 @@ describe("a lease renewal", () => { timestamp: Date.now(), })}\n\n`; - it("moves the lease's deadline in place with no read: a worker event matches by id, lastRenewedAt and ttlMs follow, no match changes nothing", async () => { + it("moves the lease's deadline in place with no read: a worker event matches by id, a gateway event by worker and the worker's lease id in both lists, lastRenewedAt and ttlMs follow, a later answer with the old deadline keeps the new one for 60 seconds, no match changes nothing", async () => { const { daemon, reads, state, watch } = start(); daemon.reads = () => Promise.resolve({ body: { leases: [lease("l_1"), lease("l_2")] }, date: null }); @@ -371,5 +371,48 @@ describe("a lease renewal", () => { await settle(); expect(state("/v1/leases").data).toBe(after); expect(reads("/v1/leases")).toBe(1); + + const gateway = start(); + const old = { + leases: [ + lease("w1.l_1", { workerId: "w1", workerLeaseId: "l_1" }), + lease("w2.l_1", { workerId: "w2", workerLeaseId: "l_1" }), + ], + }; + const fleet = { + workers: [ + { id: "w1", leases: [lease("l_1")] }, + { id: "w2", leases: [lease("l_1")] }, + ], + }; + gateway.daemon.reads = (path) => + Promise.resolve({ body: path === "/v1/leases" ? old : fleet, date: null }); + gateway.watch("/v1/leases"); + gateway.watch("/v1/workers"); + await settle(); + const deadline = Date.now() + 120_000; + gateway.daemon + .openStream() + ?.send(renewed({ leaseId: "l_1", newDeadline: deadline, workerId: "w1" }, 4)); + await settle(); + + const moved = { lastRenewedAt: Date.now(), ttlDeadline: deadline, ttlMs: 120_000 }; + expect(gateway.state("/v1/leases").data).toEqual({ + leases: [lease("w1.l_1", { ...moved, workerId: "w1", workerLeaseId: "l_1" }), old.leases[1]], + }); + expect(gateway.state("/v1/workers").data).toEqual({ + workers: [{ id: "w1", leases: [lease("l_1", moved)] }, fleet.workers[1]], + }); + expect(gateway.reads("/v1/leases") + gateway.reads("/v1/workers")).toBe(2); + + await vi.advanceTimersByTimeAsync(10_000); + expect(gateway.reads("/v1/leases")).toBe(2); + const kept = gateway.state("/v1/leases").data as { leases: { ttlDeadline: number }[] }; + expect(kept.leases[0]?.ttlDeadline).toBe(deadline); + expect(kept.leases[1]?.ttlDeadline).toBe(NOW + 60_000); + + await vi.advanceTimersByTimeAsync(60_000); + const dropped = gateway.state("/v1/leases").data as { leases: { ttlDeadline: number }[] }; + expect(dropped.leases[0]?.ttlDeadline).toBe(NOW + 60_000); }); }); From c68d6a20e1f98164ec172512bb6374a57826175b Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:59:32 +0200 Subject: [PATCH 10/21] feat(ui): renewals match a gateway's leases and the worker page's, and outlive an older answer for 60 seconds (#916) --- e2e/console/actions.spec.ts | 3 +- e2e/console/activity.spec.ts | 3 +- e2e/console/full-fetch.ts | 8 +++++ e2e/console/inbox.spec.ts | 11 ++++--- e2e/console/long-lists.spec.ts | 3 +- e2e/console/overview.spec.ts | 5 +-- e2e/console/shell.spec.ts | 5 +-- e2e/console/update.spec.ts | 5 +-- e2e/console/views.spec.ts | 3 +- e2e/console/workers-answers.ts | 3 +- ui/src/model/renewal.ts | 56 +++++++++++++++++++++++++++++++--- ui/src/state/queries.ts | 28 +++++++++++++---- 12 files changed, 106 insertions(+), 27 deletions(-) create mode 100644 e2e/console/full-fetch.ts diff --git a/e2e/console/actions.spec.ts b/e2e/console/actions.spec.ts index 9cfa7acc..2ee62ec1 100644 --- a/e2e/console/actions.spec.ts +++ b/e2e/console/actions.spec.ts @@ -10,6 +10,7 @@ import { startFleet, test, } from "./fixtures.js"; +import { fetchFull } from "./full-fetch.js"; const ONE_DEVICE = { limits: { ios: { maxDevices: 1, maxRunning: 1 }, maxRunning: 1 } }; const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] }; @@ -673,7 +674,7 @@ test.describe("the console acts", () => { await page.route( (url) => url.pathname === "/v1/workers", async (route) => { - const response = await route.fetch(); + const response = await fetchFull(route); const body = (await response.json()) as { workers: Record[] }; await route.fulfill({ json: { ...body, workers: body.workers.map((view) => ({ ...view, drained: true })) }, diff --git a/e2e/console/activity.spec.ts b/e2e/console/activity.spec.ts index f0087b79..04ff66bc 100644 --- a/e2e/console/activity.spec.ts +++ b/e2e/console/activity.spec.ts @@ -10,6 +10,7 @@ import { settled, test, } from "./fixtures.js"; +import { fetchFull } from "./full-fetch.js"; const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] }; @@ -555,7 +556,7 @@ test.describe("the Activity page", () => { return; } // why: the page holds only the newest two events, so the rest must come from the daemon's older page. - const body = (await (await route.fetch()).json()) as { + const body = (await (await fetchFull(route)).json()) as { events: Array; }; all = body.events.toSorted((a, b) => b.seq - a.seq); diff --git a/e2e/console/full-fetch.ts b/e2e/console/full-fetch.ts new file mode 100644 index 00000000..ef335594 --- /dev/null +++ b/e2e/console/full-fetch.ts @@ -0,0 +1,8 @@ +import type { Route } from "@playwright/test"; + +// why: the console sends back the ETag of its last answer, and `route.fetch()` forwards the console's +// headers, so a stub that edits the daemon's answer would be handed a 304 with no body to edit. +export function fetchFull(route: Route): ReturnType { + const { "if-none-match": _tag, ...headers } = route.request().headers(); + return route.fetch({ headers }); +} diff --git a/e2e/console/inbox.spec.ts b/e2e/console/inbox.spec.ts index 2a22ec66..91c0d820 100644 --- a/e2e/console/inbox.spec.ts +++ b/e2e/console/inbox.spec.ts @@ -12,6 +12,7 @@ import { startFleet, test, } from "./fixtures.js"; +import { fetchFull } from "./full-fetch.js"; /** why: What `GET /v1/workers` says, as far as these tests read. */ interface WorkerView { @@ -218,7 +219,7 @@ test.describe("the Inbox view", () => { await page.route( (url) => url.pathname === "/v1/lease-requests", async (route: Route) => { - const response = await route.fetch(); + const response = await fetchFull(route); const now = new Date(response.headers().date ?? "").getTime(); const request = (id: string, waitedMs: number) => ({ createdAt: now - waitedMs, @@ -258,7 +259,7 @@ test.describe("the Inbox view", () => { await page.route( (url) => url.pathname === "/v1/leases", async (route: Route) => { - const response = await route.fetch(); + const response = await fetchFull(route); const now = new Date(response.headers().date ?? "").getTime(); const lease = (id: string, leftMs: number) => ({ deviceId: "dev_stub", @@ -297,7 +298,7 @@ test.describe("the Inbox view", () => { await page.route( (url) => url.pathname === "/v1/leases", async (route: Route) => { - const response = await route.fetch(); + const response = await fetchFull(route); const now = new Date(response.headers().date ?? "").getTime(); await route.fulfill({ json: { @@ -618,7 +619,7 @@ test.describe("the Inbox view", () => { await page.route( (url) => url.pathname === "/v1/lease-requests", async (route: Route) => { - const response = await route.fetch(); + const response = await fetchFull(route); const now = new Date(response.headers().date ?? "").getTime(); await route.fulfill({ json: { @@ -639,7 +640,7 @@ test.describe("the Inbox view", () => { await page.route( (url) => url.pathname === "/v1/leases", async (route: Route) => { - const response = await route.fetch(); + const response = await fetchFull(route); const now = new Date(response.headers().date ?? "").getTime(); await route.fulfill({ json: { diff --git a/e2e/console/long-lists.spec.ts b/e2e/console/long-lists.spec.ts index 56f00abe..1518a003 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 { fetchFull } from "./full-fetch.js"; const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] }; @@ -120,7 +121,7 @@ async function answer(page: Page, pathname: string, body: () => unknown): Promis await page.route( (url) => url.pathname === pathname, async (route: Route) => { - const response = await route.fetch(); + const response = await fetchFull(route); await route.fulfill({ json: body(), response }); }, ); diff --git a/e2e/console/overview.spec.ts b/e2e/console/overview.spec.ts index 4e6fe7be..617b5094 100644 --- a/e2e/console/overview.spec.ts +++ b/e2e/console/overview.spec.ts @@ -12,6 +12,7 @@ import { startFleet, test, } from "./fixtures.js"; +import { fetchFull } from "./full-fetch.js"; const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] }; const ONE_DEVICE = { limits: { ios: { maxDevices: 1, maxRunning: 1 }, maxRunning: 1 } }; @@ -445,7 +446,7 @@ test.describe("the Overview's five numbers", () => { await page.route( (url) => url.pathname === "/v1/lease-requests", async (route: Route) => { - const response = await route.fetch(); + const response = await fetchFull(route); const now = new Date(response.headers().date ?? "").getTime(); await route.fulfill({ json: { @@ -467,7 +468,7 @@ test.describe("the Overview's five numbers", () => { await page.route( (url) => url.pathname === "/v1/leases", async (route: Route) => { - const response = await route.fetch(); + const response = await fetchFull(route); const now = new Date(response.headers().date ?? "").getTime(); const { leases } = (await response.json()) as { leases: { id: string; ttlDeadline: number }[]; diff --git a/e2e/console/shell.spec.ts b/e2e/console/shell.spec.ts index 9c11e08e..b8f59e41 100644 --- a/e2e/console/shell.spec.ts +++ b/e2e/console/shell.spec.ts @@ -13,6 +13,7 @@ import { test, withActionSurfaces, } from "./fixtures.js"; +import { fetchFull } from "./full-fetch.js"; async function hasHorizontalScroll(page: Page): Promise { return page.evaluate( @@ -269,7 +270,7 @@ test.describe("the shell", () => { await page.route( (url) => url.pathname === "/v1/lease-requests", async (route: Route) => { - const response = await route.fetch(); + const response = await fetchFull(route); const now = new Date(response.headers().date ?? "").getTime(); await route.fulfill({ json: { @@ -291,7 +292,7 @@ test.describe("the shell", () => { await page.route( (url) => url.pathname === "/v1/leases", async (route: Route) => { - const response = await route.fetch(); + const response = await fetchFull(route); const now = new Date(response.headers().date ?? "").getTime(); await route.fulfill({ json: { diff --git a/e2e/console/update.spec.ts b/e2e/console/update.spec.ts index daeefeb5..3d743df5 100644 --- a/e2e/console/update.spec.ts +++ b/e2e/console/update.spec.ts @@ -1,6 +1,7 @@ import type { Page } from "@playwright/test"; import { disposeFleet, expect, type RunningDaemon, signIn, startFleet, test } from "./fixtures.js"; +import { fetchFull } from "./full-fetch.js"; const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] }; @@ -12,7 +13,7 @@ async function showWorkerFields(page: Page, fields: () => WorkerFields): Promise await page.route( (url) => url.pathname === "/v1/workers", async (route) => { - const response = await route.fetch(); + const response = await fetchFull(route); const body = (await response.json()) as { workers: Record[] }; await route.fulfill({ json: { ...body, workers: body.workers.map((view) => ({ ...view, ...fields() })) }, @@ -41,7 +42,7 @@ test.describe("the console updates a worker", () => { await page.route( (url) => url.pathname === "/v1/status", async (route) => { - const response = await route.fetch(); + const response = await fetchFull(route); const body = (await response.json()) as { daemon: Record }; await route.fulfill({ json: { ...body, daemon: { ...body.daemon, latestVersion: "0.4.0", version: "0.3.0" } }, diff --git a/e2e/console/views.spec.ts b/e2e/console/views.spec.ts index 41a55664..56480fe0 100644 --- a/e2e/console/views.spec.ts +++ b/e2e/console/views.spec.ts @@ -14,6 +14,7 @@ import { test, withActionSurfaces, } from "./fixtures.js"; +import { fetchFull } from "./full-fetch.js"; const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] }; const LEASE = ["lease", "--platform", "ios", "--device", "iPhone 16", "--os", "18.4"] as const; @@ -288,7 +289,7 @@ test.describe("every view", () => { await page.route( (url) => url.pathname === "/v1/lease-requests", async (route: Route) => { - const response = await route.fetch(); + const response = await fetchFull(route); const now = new Date(response.headers().date ?? "").getTime(); await route.fulfill({ json: { diff --git a/e2e/console/workers-answers.ts b/e2e/console/workers-answers.ts index a342fcb2..fb5ca8f0 100644 --- a/e2e/console/workers-answers.ts +++ b/e2e/console/workers-answers.ts @@ -1,4 +1,5 @@ import type { Page, Route } from "@playwright/test"; +import { fetchFull } from "./full-fetch.js"; interface WorkerPayload { connection?: string; @@ -17,7 +18,7 @@ const CONNECT_ERROR = /ECONNREFUSED|ECONNRESET|socket hang up/i; async function fetchOrDrop(route: Route): Promise> | undefined> { try { - return await route.fetch(); + return await fetchFull(route); } catch (error) { if (!(error instanceof Error) || !CONNECT_ERROR.test(error.message)) throw error; await route.abort().catch(() => undefined); diff --git a/ui/src/model/renewal.ts b/ui/src/model/renewal.ts index 2a6ed6ed..5032b1d1 100644 --- a/ui/src/model/renewal.ts +++ b/ui/src/model/renewal.ts @@ -1,10 +1,13 @@ import type { ConsoleEvent } from "./events"; import type { LeaseList, LeaseRecord } from "./leases"; +import type { WorkerList } from "./workers"; export interface Renewal { readonly leaseId: string; readonly newDeadline: number; readonly timestamp: number; + // why: set by a gateway, which relays the worker's own lease id. + readonly workerId?: string; } // why: the payload is wire input, so a renewal missing either field is dropped rather than patched @@ -13,13 +16,30 @@ export function readRenewal(event: ConsoleEvent): Renewal | undefined { if (event.event !== "lease.renewed") return undefined; const payload = event.payload; if (typeof payload !== "object" || payload === null) return undefined; - const { leaseId, newDeadline } = payload as Record; + const { leaseId, newDeadline, workerId } = payload as Record; if (typeof leaseId !== "string" || typeof newDeadline !== "number") return undefined; - return { leaseId, newDeadline, timestamp: event.timestamp }; + return { + leaseId, + newDeadline, + timestamp: event.timestamp, + ...(typeof workerId === "string" ? { workerId } : {}), + }; +} + +// why: a gateway's lease list names a lease by `.`, so its record is found by +// the worker and the worker's own id; a worker's records carry the id the event names. +function isRenewed(lease: LeaseRecord, renewal: Renewal): boolean { + return renewal.workerId === undefined + ? lease.id === renewal.leaseId + : lease.workerId === renewal.workerId && lease.workerLeaseId === renewal.leaseId; } -function renewLease(lease: LeaseRecord, renewal: Renewal): LeaseRecord { - if (lease.id !== renewal.leaseId || renewal.newDeadline <= lease.ttlDeadline) return lease; +function renewLease( + lease: LeaseRecord, + renewal: Renewal, + matches: (lease: LeaseRecord) => boolean, +): LeaseRecord { + if (!matches(lease) || renewal.newDeadline <= lease.ttlDeadline) return lease; return { ...lease, lastRenewedAt: renewal.timestamp, @@ -30,6 +50,32 @@ function renewLease(lease: LeaseRecord, renewal: Renewal): LeaseRecord { // why: the same list when no lease changed, so an observer of it is not told of an update. export function renewLeases(list: LeaseList, renewal: Renewal): LeaseList { - const leases = list.leases.map((lease) => renewLease(lease, renewal)); + const leases = list.leases.map((lease) => + renewLease(lease, renewal, (candidate) => isRenewed(candidate, renewal)), + ); return leases.some((lease, index) => lease !== list.leases[index]) ? { ...list, leases } : list; } + +// why: a worker's own page lists leases under their worker, by the worker's own lease id. +export function renewWorkers(list: WorkerList, renewal: Renewal): WorkerList { + const workers = list.workers.map((worker) => { + if (worker.leases === undefined) return worker; + if (renewal.workerId !== undefined && worker.id !== renewal.workerId) return worker; + const leases = worker.leases.map((lease) => + renewLease(lease, renewal, (candidate) => candidate.id === renewal.leaseId), + ); + return leases.some((lease, index) => lease !== worker.leases?.[index]) + ? { ...worker, leases } + : worker; + }); + return workers.some((worker, index) => worker !== list.workers[index]) + ? { ...list, workers } + : list; +} + +// why: the routes whose answers carry leases, each patched by its own match rule. +export function renewAnswer(route: string, data: T, renewal: Renewal): T { + if (route === "leases") return renewLeases(data as LeaseList, renewal) as T; + if (route === "workers") return renewWorkers(data as WorkerList, renewal) as T; + return data; +} diff --git a/ui/src/state/queries.ts b/ui/src/state/queries.ts index 2f57994f..7d19a120 100644 --- a/ui/src/state/queries.ts +++ b/ui/src/state/queries.ts @@ -10,12 +10,13 @@ import { import type { ApiPath } from "../data/api"; import type { Clock, LiveConnection, Visibility } from "../data/live/connection"; import { readEvent } from "../model/events"; -import type { LeaseList } from "../model/leases"; -import { readRenewal, renewLeases } from "../model/renewal"; +import { readRenewal, renewAnswer, type Renewal } from "../model/renewal"; // 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; +const RENEWAL_KEPT_MS = 60_000; +const RENEWED_ROUTES: readonly string[] = ["leases", "workers"]; interface Times { readonly pollMs?: number; @@ -119,13 +120,26 @@ export function createQueries(options: QueriesOptions): Queries { } }); + // why: a gateway relays a worker's event before it refreshes its view of that worker, so a read + // just after a renewal can still carry the old deadline. + const kept: { readonly renewal: Renewal; readonly until: number }[] = []; + const renewedAnswer = (route: string, data: T): T => { + const now = clock.now(); + for (let index = kept.length - 1; index >= 0; index -= 1) { + if ((kept[index]?.until ?? 0) <= now) kept.splice(index, 1); + } + return kept.reduce((answer, { renewal }) => renewAnswer(route, answer, renewal), data); + }; const stopEvents = connection.onEvent((streamEvent) => { const event = readEvent(streamEvent.data); const renewal = event === undefined ? undefined : readRenewal(event); if (renewal === undefined) return; - client.setQueryData(["leases"], (old) => - old === undefined ? undefined : renewLeases(old, renewal), - ); + kept.push({ renewal, until: clock.now() + RENEWAL_KEPT_MS }); + for (const route of RENEWED_ROUTES) { + client.setQueryData([route], (old: unknown) => + old === undefined ? undefined : renewAnswer(route, old, renewal), + ); + } }); return { @@ -150,7 +164,9 @@ export function createQueries(options: QueriesOptions): Queries { if (answer.notModified !== true) { if (answer.tag === null) tags.delete(hash); else tags.set(hash, answer.tag); - return answer.body; + return RENEWED_ROUTES.includes(key[0] ?? "") + ? renewedAnswer(key[0] ?? "", answer.body) + : answer.body; } const kept = client.getQueryData(key); if (kept !== undefined) return kept; From 5190e52f1cfeb7ebf449ba5a3a7cf10cc846fd27 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:59:55 +0200 Subject: [PATCH 11/21] docs(ui): the manual describes the ETag round trip and in-place renewals (#916) --- ui/AGENTS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ui/AGENTS.md b/ui/AGENTS.md index 9b2c0898..bf30e825 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -71,6 +71,13 @@ phase, the health checks and the clock offset, and does one classified GET for a 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. +A read sends back the ETag of the route's last answer (`ApiClient.get(path, { tag })`, kept per key in +`state/queries.ts`); a `304` keeps the cached data object, so nothing re-renders, and one that finds +its data dropped is read again without a tag. A `lease.renewed` event moves the lease's `ttlDeadline`, +`lastRenewedAt` and `ttlMs` in `['leases']` and `['workers']` in place (`model/renewal.ts`) with no read, +and the deadline is kept for 60 s against an older answer. Console browser stubs fetch through +`e2e/console/full-fetch.ts`, which drops `If-None-Match`. + ## Actions An action is described once per kind of item in `model/actions.ts` (`leaseActions`, `requestActions`, From 25a451620f15fcb4326c3878f4708d844a005c51 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 15:51:45 +0200 Subject: [PATCH 12/21] refactor(ui): keep the conditional read within the complexity bound (#916) --- ui/src/data/live/connection.ts | 11 ++++++++--- ui/src/data/live/test-support.ts | 9 +++------ ui/src/model/renewal.ts | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/ui/src/data/live/connection.ts b/ui/src/data/live/connection.ts index d6b46728..8d8c8f76 100644 --- a/ui/src/data/live/connection.ts +++ b/ui/src/data/live/connection.ts @@ -2,6 +2,7 @@ import { ApiError, type ApiClient, type ApiPath, + type ConditionalResponse, type ReadOptions, RequestTimeoutError, } from "../api"; @@ -82,6 +83,12 @@ export type ConditionalRead = | { readonly notModified: true } | { readonly notModified?: false; readonly body: T; readonly tag: string | null }; +function conditional(response: ConditionalResponse): ConditionalRead { + return response.notModified === true + ? { notModified: true } + : { body: response.body, tag: response.tag }; +} + export class LiveConnection { readonly #api: LiveApi; readonly #clock: Clock; @@ -143,9 +150,7 @@ export class LiveConnection { this.#answeredAt = this.#clock.now(); this.#daemonAnswered(); } - return response.notModified === true - ? { notModified: true } - : { body: response.body, tag: response.tag }; + return conditional(response); } } diff --git a/ui/src/data/live/test-support.ts b/ui/src/data/live/test-support.ts index aa747b6e..52ce171a 100644 --- a/ui/src/data/live/test-support.ts +++ b/ui/src/data/live/test-support.ts @@ -62,13 +62,10 @@ class FakeDaemon implements LiveApi { path: ApiPath, options?: ReadOptions, ): Promise | ConditionalResponse> { - this.gets.push({ - at: Date.now(), - path, - ...(options?.tag === undefined ? {} : { tag: options.tag }), - }); + const tag = options?.tag; + this.gets.push({ at: Date.now(), path, ...(tag === undefined ? {} : { tag }) }); const reads = this.reads; - if (reads === "answer" && this.etag !== undefined && options?.tag === this.etag) { + if (reads === "answer" && tag !== undefined && tag === this.etag) { return Promise.resolve({ date: this.date, notModified: true }); } if (typeof reads === "function") return reads(path); diff --git a/ui/src/model/renewal.ts b/ui/src/model/renewal.ts index 5032b1d1..7038eca4 100644 --- a/ui/src/model/renewal.ts +++ b/ui/src/model/renewal.ts @@ -57,7 +57,7 @@ export function renewLeases(list: LeaseList, renewal: Renewal): LeaseList { } // why: a worker's own page lists leases under their worker, by the worker's own lease id. -export function renewWorkers(list: WorkerList, renewal: Renewal): WorkerList { +function renewWorkers(list: WorkerList, renewal: Renewal): WorkerList { const workers = list.workers.map((worker) => { if (worker.leases === undefined) return worker; if (renewal.workerId !== undefined && worker.id !== renewal.workerId) return worker; From 14239a706535f21307acff44709437e5eb5e2b29 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 15:52:40 +0200 Subject: [PATCH 13/21] chore: renewal helpers stay private and the console's react-query is a bundled dev dependency (#916) --- .fallowrc.json | 1 + ui/src/model/renewal.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.fallowrc.json b/.fallowrc.json index 9d4ff779..bd6f2abc 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -54,6 +54,7 @@ "@fontsource-variable/geist-mono", "recharts", "@base-ui/react", + "@tanstack/react-query", "@tanstack/react-table", "@tanstack/react-virtual", // Recharts' peer dependency: installed for it, never imported by the console itself. diff --git a/ui/src/model/renewal.ts b/ui/src/model/renewal.ts index 7038eca4..ef98f9e9 100644 --- a/ui/src/model/renewal.ts +++ b/ui/src/model/renewal.ts @@ -49,7 +49,7 @@ function renewLease( } // why: the same list when no lease changed, so an observer of it is not told of an update. -export function renewLeases(list: LeaseList, renewal: Renewal): LeaseList { +function renewLeases(list: LeaseList, renewal: Renewal): LeaseList { const leases = list.leases.map((lease) => renewLease(lease, renewal, (candidate) => isRenewed(candidate, renewal)), ); From 4b6552f86f5c3dab35579f22ac32d99a4d676369 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 15:54:28 +0200 Subject: [PATCH 14/21] test: a renewal marks only what it leaves stale (#916) --- ui/src/state/queries.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/ui/src/state/queries.test.ts b/ui/src/state/queries.test.ts index d1ae018c..75b4b435 100644 --- a/ui/src/state/queries.test.ts +++ b/ui/src/state/queries.test.ts @@ -620,4 +620,34 @@ describe("a lease renewal", () => { const dropped = gateway.state("/v1/leases").data as { leases: { ttlDeadline: number }[] }; expect(dropped.leases[0]?.ttlDeadline).toBe(NOW + 60_000); }); + it("marks only what a renewal leaves stale: 20 renewals of ending leases in 1 s give one inbox read, a lease observed gives one read, otherwise no request", async () => { + const ending = { condition: "ending", group: "Heads-up", key: "lease/l_1/ending" }; + const inbox = { items: [{ ...ending, lease: lease("l_1") }] }; + const cases = [ + ["an ending lease in the inbox", "/v1/inbox", "l_1", 20, ["/v1/inbox"]], + ["a lease whose page is open", "/v1/leases/l_1", "l_1", 20, ["/v1/leases/l_1"]], + ["a lease nothing shows", "/v1/inbox", "l_2", 20, []], + ["another lease's page", "/v1/leases/l_1", "l_2", 20, []], + ] as const; + for (const [label, path, leaseId, count, expected] of cases) { + const { daemon, watch } = start(); + daemon.reads = () => Promise.resolve({ body: path === "/v1/inbox" ? inbox : {}, date: null }); + watch(path); + await settle(); + const before = daemon.gets.length; + + for (let renewal = 0; renewal < count; renewal += 1) { + daemon + .openStream() + ?.send(renewed({ leaseId, newDeadline: Date.now() + 120_000 + renewal }, renewal + 2)); + await vi.advanceTimersByTimeAsync(50); + } + await vi.advanceTimersByTimeAsync(300); + + expect( + daemon.gets.slice(before).map((get) => get.path), + label, + ).toEqual(expected); + } + }); }); From bfc43760f5e1c314644421ce0bf9e325e06be634 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 15:54:49 +0200 Subject: [PATCH 15/21] feat(ui): a renewal marks its inbox ending item and its open lease page stale (#916) --- ui/src/model/renewal.ts | 17 +++++++++++++++++ ui/src/state/queries.ts | 14 ++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/ui/src/model/renewal.ts b/ui/src/model/renewal.ts index ef98f9e9..cc3f2239 100644 --- a/ui/src/model/renewal.ts +++ b/ui/src/model/renewal.ts @@ -79,3 +79,20 @@ export function renewAnswer(route: string, data: T, renewal: Renewal): T { if (route === "workers") return renewWorkers(data as WorkerList, renewal) as T; return data; } + +// why: the Inbox shows a lease's time left only on its `ending` item, so only that item goes stale. +export function endsInInbox(data: unknown, renewal: Renewal): boolean { + const items = (data as { readonly items?: readonly unknown[] } | undefined)?.items; + return (items ?? []).some((item) => { + const { condition, lease } = item as { condition?: unknown; lease?: LeaseRecord }; + return condition === "ending" && lease !== undefined && isRenewed(lease, renewal); + }); +} + +// why: a gateway names a lease `.`, which is the id its detail page is read by. +export function leaseKeyOf(renewal: Renewal): readonly string[] { + return [ + "lease", + renewal.workerId === undefined ? renewal.leaseId : `${renewal.workerId}.${renewal.leaseId}`, + ]; +} diff --git a/ui/src/state/queries.ts b/ui/src/state/queries.ts index ec46868c..faf955aa 100644 --- a/ui/src/state/queries.ts +++ b/ui/src/state/queries.ts @@ -17,7 +17,7 @@ import { type Visibility, } from "../data/live/connection"; import { readEvent } from "../model/events"; -import { readRenewal, renewAnswer, type Renewal } from "../model/renewal"; +import { endsInInbox, leaseKeyOf, readRenewal, renewAnswer, type Renewal } from "../model/renewal"; 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. @@ -204,7 +204,17 @@ export function createQueries({ clock, connection, visibility }: QueriesOptions) typeof data === "object" && data !== null ? (data as { payload?: unknown }).payload : undefined; - const keys = staleKeys(event.event, payload); + // why: a renewal needs a read only where its lease's deadline is shown from another answer: the + // inbox's ending item and an open lease page. + const keys = [ + ...staleKeys(event.event, payload), + ...(renewal === undefined + ? [] + : [ + leaseKeyOf(renewal), + ...(endsInInbox(client.getQueryData(["inbox"]), renewal) ? [["inbox"]] : []), + ]), + ]; if (keys.length === 0) return; for (const queryKey of keys) { marked.set(queryKey.join("/"), queryKey); From 97a821de08778317a09c6c5c7586fbc4085e36c7 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 15:55:11 +0200 Subject: [PATCH 16/21] refactor(ui): keep the event handler within the complexity bound (#916) --- ui/src/state/queries.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/ui/src/state/queries.ts b/ui/src/state/queries.ts index faf955aa..3eba908f 100644 --- a/ui/src/state/queries.ts +++ b/ui/src/state/queries.ts @@ -188,6 +188,12 @@ export function createQueries({ clock, connection, visibility }: QueriesOptions) readMarked(later); }, AGAIN_MS); }; + // why: a renewal needs a read only where its lease's deadline is shown from another answer: the + // inbox's ending item and an open lease page. + const renewalKeys = (renewal: Renewal): StaleKey[] => [ + leaseKeyOf(renewal), + ...(endsInInbox(client.getQueryData(["inbox"]), renewal) ? [["inbox"]] : []), + ]; const stopEvents = connection.onEvent((event: StreamEvent) => { const { data } = event; const parsed = readEvent(data); @@ -204,16 +210,9 @@ export function createQueries({ clock, connection, visibility }: QueriesOptions) typeof data === "object" && data !== null ? (data as { payload?: unknown }).payload : undefined; - // why: a renewal needs a read only where its lease's deadline is shown from another answer: the - // inbox's ending item and an open lease page. const keys = [ ...staleKeys(event.event, payload), - ...(renewal === undefined - ? [] - : [ - leaseKeyOf(renewal), - ...(endsInInbox(client.getQueryData(["inbox"]), renewal) ? [["inbox"]] : []), - ]), + ...(renewal === undefined ? [] : renewalKeys(renewal)), ]; if (keys.length === 0) return; for (const queryKey of keys) { From b53ad53df28ad9908f269d721b5370bb13053740 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 15:55:36 +0200 Subject: [PATCH 17/21] refactor(ui): the renewal patch is its own step (#916) --- ui/src/state/queries.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/ui/src/state/queries.ts b/ui/src/state/queries.ts index 3eba908f..18f2ad9f 100644 --- a/ui/src/state/queries.ts +++ b/ui/src/state/queries.ts @@ -194,18 +194,19 @@ export function createQueries({ clock, connection, visibility }: QueriesOptions) leaseKeyOf(renewal), ...(endsInInbox(client.getQueryData(["inbox"]), renewal) ? [["inbox"]] : []), ]; + const patchRenewal = (renewal: Renewal): void => { + kept.push({ renewal, until: clock.now() + RENEWAL_KEPT_MS }); + for (const route of RENEWED_ROUTES) { + client.setQueryData([route], (old: unknown) => + old === undefined ? undefined : renewAnswer(route, old, renewal), + ); + } + }; const stopEvents = connection.onEvent((event: StreamEvent) => { const { data } = event; const parsed = readEvent(data); const renewal = parsed === undefined ? undefined : readRenewal(parsed); - if (renewal !== undefined) { - kept.push({ renewal, until: clock.now() + RENEWAL_KEPT_MS }); - for (const route of RENEWED_ROUTES) { - client.setQueryData([route], (old: unknown) => - old === undefined ? undefined : renewAnswer(route, old, renewal), - ); - } - } + if (renewal !== undefined) patchRenewal(renewal); const payload = typeof data === "object" && data !== null ? (data as { payload?: unknown }).payload From f49b192dae3ed0993151b8cf9977aa96096082a7 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 16:03:45 +0200 Subject: [PATCH 18/21] test: the renewal test straddles the kept window, tells a worker's leases apart, and refuses a malformed payload (#916) --- ui/src/model/renewal.ts | 6 ++--- ui/src/state/queries.test.ts | 52 ++++++++++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/ui/src/model/renewal.ts b/ui/src/model/renewal.ts index cc3f2239..65e98daf 100644 --- a/ui/src/model/renewal.ts +++ b/ui/src/model/renewal.ts @@ -80,12 +80,12 @@ export function renewAnswer(route: string, data: T, renewal: Renewal): T { return data; } -// why: the Inbox shows a lease's time left only on its `ending` item, so only that item goes stale. +// why: the Inbox shows a lease's time left only on its `ending` item, the only item that embeds a lease. export function endsInInbox(data: unknown, renewal: Renewal): boolean { const items = (data as { readonly items?: readonly unknown[] } | undefined)?.items; return (items ?? []).some((item) => { - const { condition, lease } = item as { condition?: unknown; lease?: LeaseRecord }; - return condition === "ending" && lease !== undefined && isRenewed(lease, renewal); + const { lease } = item as { lease?: LeaseRecord }; + return lease !== undefined && isRenewed(lease, renewal); }); } diff --git a/ui/src/state/queries.test.ts b/ui/src/state/queries.test.ts index 75b4b435..da21ade2 100644 --- a/ui/src/state/queries.test.ts +++ b/ui/src/state/queries.test.ts @@ -552,7 +552,7 @@ describe("a lease renewal", () => { timestamp: Date.now(), })}\n\n`; - it("moves the lease's deadline in place with no read: a worker event matches by id, a gateway event by worker and the worker's lease id in both lists, lastRenewedAt and ttlMs follow, a later answer with the old deadline keeps the new one for 60 seconds, no match changes nothing", async () => { + it("moves the lease's deadline in place with no read: a worker event matches by id, a gateway event by worker and the worker's lease id in both lists, lastRenewedAt and ttlMs follow, a later answer with the old deadline keeps the new one for 60 seconds and one with a later deadline wins, no match or a malformed payload changes nothing", async () => { const { daemon, reads, state, watch } = start(); daemon.reads = () => Promise.resolve({ body: { leases: [lease("l_1"), lease("l_2")] }, date: null }); @@ -577,16 +577,21 @@ describe("a lease renewal", () => { expect(state("/v1/leases").data).toBe(after); expect(reads("/v1/leases")).toBe(1); + daemon.openStream()?.send(renewed({ leaseId: "l_2", newDeadline: "soon" }, 5)); + await settle(); + expect(state("/v1/leases").data).toBe(after); + const gateway = start(); const old = { leases: [ lease("w1.l_1", { workerId: "w1", workerLeaseId: "l_1" }), + lease("w1.l_2", { workerId: "w1", workerLeaseId: "l_2" }), lease("w2.l_1", { workerId: "w2", workerLeaseId: "l_1" }), ], }; const fleet = { workers: [ - { id: "w1", leases: [lease("l_1")] }, + { id: "w1", leases: [lease("l_1"), lease("l_2")] }, { id: "w2", leases: [lease("l_1")] }, ], }; @@ -603,10 +608,14 @@ describe("a lease renewal", () => { const moved = { lastRenewedAt: Date.now(), ttlDeadline: deadline, ttlMs: 120_000 }; expect(gateway.state("/v1/leases").data).toEqual({ - leases: [lease("w1.l_1", { ...moved, workerId: "w1", workerLeaseId: "l_1" }), old.leases[1]], + leases: [ + lease("w1.l_1", { ...moved, workerId: "w1", workerLeaseId: "l_1" }), + old.leases[1], + old.leases[2], + ], }); expect(gateway.state("/v1/workers").data).toEqual({ - workers: [{ id: "w1", leases: [lease("l_1", moved)] }, fleet.workers[1]], + workers: [{ id: "w1", leases: [lease("l_1", moved), lease("l_2")] }, fleet.workers[1]], }); expect(gateway.reads("/v1/leases") + gateway.reads("/v1/workers")).toBe(2); @@ -616,7 +625,26 @@ describe("a lease renewal", () => { expect(kept.leases[0]?.ttlDeadline).toBe(deadline); expect(kept.leases[1]?.ttlDeadline).toBe(NOW + 60_000); - await vi.advanceTimersByTimeAsync(60_000); + const later = deadline + 5_000; + gateway.daemon.reads = (path) => + Promise.resolve({ + body: + path === "/v1/leases" + ? { leases: [{ ...old.leases[0], ttlDeadline: later }, ...old.leases.slice(1)] } + : fleet, + date: null, + }); + await vi.advanceTimersByTimeAsync(10_000); + const newer = gateway.state("/v1/leases").data as { leases: { ttlDeadline: number }[] }; + expect(newer.leases[0]?.ttlDeadline).toBe(later); + + gateway.daemon.reads = (path) => + Promise.resolve({ body: path === "/v1/leases" ? old : fleet, date: null }); + await vi.advanceTimersByTimeAsync(30_000); + const still = gateway.state("/v1/leases").data as { leases: { ttlDeadline: number }[] }; + expect(still.leases[0]?.ttlDeadline).toBe(deadline); + + await vi.advanceTimersByTimeAsync(30_000); const dropped = gateway.state("/v1/leases").data as { leases: { ttlDeadline: number }[] }; expect(dropped.leases[0]?.ttlDeadline).toBe(NOW + 60_000); }); @@ -628,6 +656,7 @@ describe("a lease renewal", () => { ["a lease whose page is open", "/v1/leases/l_1", "l_1", 20, ["/v1/leases/l_1"]], ["a lease nothing shows", "/v1/inbox", "l_2", 20, []], ["another lease's page", "/v1/leases/l_1", "l_2", 20, []], + ["a gateway lease's page", "/v1/leases/w1.l_1", "l_1", 20, ["/v1/leases/w1.l_1"]], ] as const; for (const [label, path, leaseId, count, expected] of cases) { const { daemon, watch } = start(); @@ -637,9 +666,16 @@ describe("a lease renewal", () => { const before = daemon.gets.length; for (let renewal = 0; renewal < count; renewal += 1) { - daemon - .openStream() - ?.send(renewed({ leaseId, newDeadline: Date.now() + 120_000 + renewal }, renewal + 2)); + daemon.openStream()?.send( + renewed( + { + leaseId, + newDeadline: Date.now() + 120_000 + renewal, + ...(path.includes("w1.") ? { workerId: "w1" } : {}), + }, + renewal + 2, + ), + ); await vi.advanceTimersByTimeAsync(50); } await vi.advanceTimersByTimeAsync(300); From 03ec8e21f9b432bf9dd97a98f03cff537d3f7159 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 16:14:39 +0200 Subject: [PATCH 19/21] fix(ui): a renewal that moves no lease leaves the poll alone, and a requester-named lease's page is marked stale (#916) --- ui/src/model/renewal.ts | 14 ++++++++------ ui/src/state/queries.test.ts | 28 ++++++++++++++++++++++++++-- ui/src/state/queries.ts | 11 +++++++---- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/ui/src/model/renewal.ts b/ui/src/model/renewal.ts index 65e98daf..16de5328 100644 --- a/ui/src/model/renewal.ts +++ b/ui/src/model/renewal.ts @@ -89,10 +89,12 @@ export function endsInInbox(data: unknown, renewal: Renewal): boolean { }); } -// why: a gateway names a lease `.`, which is the id its detail page is read by. -export function leaseKeyOf(renewal: Renewal): readonly string[] { - return [ - "lease", - renewal.workerId === undefined ? renewal.leaseId : `${renewal.workerId}.${renewal.leaseId}`, - ]; +// why: a gateway routes a lease under `.`, or under the bare id its requester +// chose; the lease list says which, and an unlisted lease is taken as prefixed. +export function leaseKeyOf(renewal: Renewal, leases: unknown): readonly string[] { + if (renewal.workerId === undefined) return ["lease", renewal.leaseId]; + const listed = (leases as LeaseList | undefined)?.leases?.find((lease) => + isRenewed(lease, renewal), + ); + return ["lease", listed?.id ?? `${renewal.workerId}.${renewal.leaseId}`]; } diff --git a/ui/src/state/queries.test.ts b/ui/src/state/queries.test.ts index da21ade2..743f8dfb 100644 --- a/ui/src/state/queries.test.ts +++ b/ui/src/state/queries.test.ts @@ -581,6 +581,17 @@ describe("a lease renewal", () => { await settle(); expect(state("/v1/leases").data).toBe(after); + // why: a renewal that moves no lease leaves the cache, and so the poll's schedule, alone. + const readsBefore = reads("/v1/leases"); + for (let tick = 0; tick < 5; tick += 1) { + await vi.advanceTimersByTimeAsync(5_000); + daemon + .openStream() + ?.send(renewed({ leaseId: "l_9", newDeadline: Date.now() + 1_000 }, 10 + tick)); + } + await vi.advanceTimersByTimeAsync(1_000); + expect(reads("/v1/leases") - readsBefore, "polls through 26 s of unmatched renewals").toBe(2); + const gateway = start(); const old = { leases: [ @@ -657,10 +668,23 @@ describe("a lease renewal", () => { ["a lease nothing shows", "/v1/inbox", "l_2", 20, []], ["another lease's page", "/v1/leases/l_1", "l_2", 20, []], ["a gateway lease's page", "/v1/leases/w1.l_1", "l_1", 20, ["/v1/leases/w1.l_1"]], + [ + "a gateway lease its requester named, whose page has no worker prefix", + "/v1/leases/my-lease", + "my-lease", + 20, + ["/v1/leases/my-lease"], + ], ] as const; for (const [label, path, leaseId, count, expected] of cases) { const { daemon, watch } = start(); - daemon.reads = () => Promise.resolve({ body: path === "/v1/inbox" ? inbox : {}, date: null }); + const listed = { leases: [lease("my-lease", { workerId: "w1", workerLeaseId: "my-lease" })] }; + daemon.reads = (read) => + Promise.resolve({ + body: read === "/v1/inbox" ? inbox : read === "/v1/leases" ? listed : {}, + date: null, + }); + watch("/v1/leases"); watch(path); await settle(); const before = daemon.gets.length; @@ -671,7 +695,7 @@ describe("a lease renewal", () => { { leaseId, newDeadline: Date.now() + 120_000 + renewal, - ...(path.includes("w1.") ? { workerId: "w1" } : {}), + ...(path.includes("w1.") || path.includes("my-lease") ? { workerId: "w1" } : {}), }, renewal + 2, ), diff --git a/ui/src/state/queries.ts b/ui/src/state/queries.ts index 18f2ad9f..33fa911e 100644 --- a/ui/src/state/queries.ts +++ b/ui/src/state/queries.ts @@ -191,15 +191,18 @@ export function createQueries({ clock, connection, visibility }: QueriesOptions) // why: a renewal needs a read only where its lease's deadline is shown from another answer: the // inbox's ending item and an open lease page. const renewalKeys = (renewal: Renewal): StaleKey[] => [ - leaseKeyOf(renewal), + leaseKeyOf(renewal, client.getQueryData(["leases"])), ...(endsInInbox(client.getQueryData(["inbox"]), renewal) ? [["inbox"]] : []), ]; const patchRenewal = (renewal: Renewal): void => { kept.push({ renewal, until: clock.now() + RENEWAL_KEPT_MS }); for (const route of RENEWED_ROUTES) { - client.setQueryData([route], (old: unknown) => - old === undefined ? undefined : renewAnswer(route, old, renewal), - ); + // why: setting data, even unchanged, reschedules the route's poll, so a renewal that moves + // no lease leaves the cache alone. + const old = client.getQueryData([route]); + if (old === undefined) continue; + const patched = renewAnswer(route, old, renewal); + if (patched !== old) client.setQueryData([route], patched); } }; const stopEvents = connection.onEvent((event: StreamEvent) => { From 0e82b04e6a557b01b38c420bf9c0a1a131172bd1 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 16:23:18 +0200 Subject: [PATCH 20/21] fix(ui): a patched renewal leaves the poll's timer running, only a read moves it (#916) --- ui/src/model/renewal.ts | 4 ++-- ui/src/state/queries.test.ts | 14 +++++++++++--- ui/src/state/queries.ts | 7 ++++--- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/ui/src/model/renewal.ts b/ui/src/model/renewal.ts index 16de5328..61d4a0f9 100644 --- a/ui/src/model/renewal.ts +++ b/ui/src/model/renewal.ts @@ -26,8 +26,8 @@ export function readRenewal(event: ConsoleEvent): Renewal | undefined { }; } -// why: a gateway's lease list names a lease by `.`, so its record is found by -// the worker and the worker's own id; a worker's records carry the id the event names. +// why: a gateway's lease list names a lease by `.` unless the requester chose +// the id, so its record is found by the worker and the worker's own id; a worker's records carry the id the event names. function isRenewed(lease: LeaseRecord, renewal: Renewal): boolean { return renewal.workerId === undefined ? lease.id === renewal.leaseId diff --git a/ui/src/state/queries.test.ts b/ui/src/state/queries.test.ts index 743f8dfb..239b6010 100644 --- a/ui/src/state/queries.test.ts +++ b/ui/src/state/queries.test.ts @@ -581,16 +581,24 @@ describe("a lease renewal", () => { await settle(); expect(state("/v1/leases").data).toBe(after); - // why: a renewal that moves no lease leaves the cache, and so the poll's schedule, alone. + // why: a renewal, matched or not, is no read, so it leaves the poll's schedule alone. const readsBefore = reads("/v1/leases"); for (let tick = 0; tick < 5; tick += 1) { await vi.advanceTimersByTimeAsync(5_000); daemon .openStream() - ?.send(renewed({ leaseId: "l_9", newDeadline: Date.now() + 1_000 }, 10 + tick)); + ?.send( + renewed( + { leaseId: tick % 2 === 0 ? "l_9" : "l_1", newDeadline: Date.now() + 120_000 }, + 10 + tick, + ), + ); } await vi.advanceTimersByTimeAsync(1_000); - expect(reads("/v1/leases") - readsBefore, "polls through 26 s of unmatched renewals").toBe(2); + expect( + reads("/v1/leases") - readsBefore, + "polls at 10, 20 and 30 s through renewals every 5 s, listed and unlisted", + ).toBe(3); const gateway = start(); const old = { diff --git a/ui/src/state/queries.ts b/ui/src/state/queries.ts index 33fa911e..aaaf5249 100644 --- a/ui/src/state/queries.ts +++ b/ui/src/state/queries.ts @@ -139,7 +139,9 @@ export function createQueries({ clock, connection, visibility }: QueriesOptions) schedulePoll(event.query); } else if ( event.type === "updated" && - (event.action.type === "success" || event.action.type === "error") + // why: a patch written by setQueryData (manual) is no read, so it leaves the poll's timer running. + (event.action.type === "error" || + (event.action.type === "success" && event.action.manual !== true)) ) { schedulePoll(event.query); } @@ -197,8 +199,7 @@ export function createQueries({ clock, connection, visibility }: QueriesOptions) const patchRenewal = (renewal: Renewal): void => { kept.push({ renewal, until: clock.now() + RENEWAL_KEPT_MS }); for (const route of RENEWED_ROUTES) { - // why: setting data, even unchanged, reschedules the route's poll, so a renewal that moves - // no lease leaves the cache alone. + // why: a renewal that moves no lease leaves the cache, and its observers, alone. const old = client.getQueryData([route]); if (old === undefined) continue; const patched = renewAnswer(route, old, renewal); From 1638a8d9c96006e68a02c3088960841dbbb49142 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 17:44:47 +0200 Subject: [PATCH 21/21] test: the toast test's table is shown before time freezes, and an action's menu waits for the frozen clock to show the answer it needs (#916) --- e2e/console/actions.spec.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/e2e/console/actions.spec.ts b/e2e/console/actions.spec.ts index b62cd1f5..159fa199 100644 --- a/e2e/console/actions.spec.ts +++ b/e2e/console/actions.spec.ts @@ -543,6 +543,8 @@ test.describe("the console acts", () => { try { await page.clock.install(); await openPage(page, fleet.gateway, "/fleet", "Fleet"); + // why: the page tells its views of an answer through a timer, which a frozen clock never fires, so a table still loading when time freezes never shows. + await expect(rowOf(page, "toast-d")).toBeVisible(); // why: with time frozen, only the fast-forwards below age a toast, however slow the machine. await page.clock.pauseAt(new Date(Date.now() + 1_000)); const toasts = page.getByRole("region", { name: "Notifications" }); @@ -552,10 +554,16 @@ test.describe("the console acts", () => { await rowOf(page, worker) .getByRole("button", { name: `Actions for ${worker}` }) .press("Enter"); - await page + const entry = page .getByRole("menu", { name: `Actions for ${worker}` }) - .getByRole("menuitem", { name: item, exact: true }) - .click(); + .getByRole("menuitem", { name: item, exact: true }); + // why: the answer to the read an earlier action set off lands in real time, and the page shows it only once the frozen clock moves again, so until the menu offers the item the clock moves a little at a time. + let tries = 0; + await expect(async () => { + if (tries++ > 0) await page.clock.runFor(500); + await expect(entry).toBeVisible({ timeout: 1_000 }); + }).toPass({ timeout: 20_000 }); + await entry.click(); // why: a pointer left on the clicked item can sit where the toast appears, and a hovered // toast never times out. await page.mouse.move(0, 0);