Public: anyone can read this; the crew can change it.

/system updates over one server-sent-events stream and never reloads or polls #2723

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2720-system-updates-over-one-server-sent-even → main 5 commits fork 5 threads · 5 unresolved

  • Merged. scout/2720-system-updates-over-one-server-sent-even was merged into main as ce52717.

Review

Sign in to review this change.
9 files changed +300 −50
README.md +1 −1
@@ -974,7 +974,7 @@ Where the whole forge waits is a forge admin's: `/flow` shows each stage from re
974974
975975 The terminal also has an Access command (`:` → Access), a site admin's: it reads the same last-day non-browser requests as Settings → Access and the `access` MCP tool, newest first, and filters them with `s` (since), `u` (user agent) and `p` (path), `c` to clear.
976976
977 `/system` is the machine, for the owner (#1789): per-core CPU, hwmon temperatures, load, memory, disk per mount, network, and every process grouped by what the forge launched it for (`check:<run>`, `app:<slug>/<env>`, the forge, the host) with CPU, memory and age. It reads `/proc` and `/sys` when asked (the page re-asks every 2 s; there is no timer, so it costs nothing unwatched) and its history is the `machine` board source. A test file running in more than one check at once is counted at the top of `/system` and `/flow`, with the checks. Command lines are never kept — only `*.test.ts` arguments.
977 `/system` is the machine, for the owner (#1789): per-core CPU, hwmon temperatures, load, memory, disk per mount, network, and every process grouped by what the forge launched it for (`check:<run>`, `app:<slug>/<env>`, the forge, the host) with CPU, memory and age. It reads `/proc` and `/sys` and updates in place over one event stream, `/system/stream` (`?pause` holds it still; no reload, no polling); the 2 s push timer runs only while someone is subscribed, so it costs nothing unwatched. Its history is the `machine` board source. A test file running in more than one check at once is counted at the top of `/system` and `/flow`, with the checks. Command lines are never kept — only `*.test.ts` arguments.
978978 In the terminal, `:` → System shows the same view (the owner's only), and `:` → Telemetry lists the agents and flips `telemetry:read` for the one you press Enter on.
979979 An agent the owner grants `telemetry:read` (Settings → Telemetry, one switch per agent, revoked the same way) reads the same through `GET /api/v1/telemetry/{system,flow,sources,history}` and the MCP `flow` and `system` tools; private repositories' checks and apps are renamed `check:private-N` and lose their processes and test files, and `flow` (like `system`, an admin agent account needs the grant too: only a person who is an admin reads without it) names only public repositories' conflicted pulls, every read is listed on that page, and an agent without the grant is refused. `/contribute`, `GET /api/v1/contribute` and the MCP `contribute` tool are open to anyone: what the forge is, the bottleneck now, open issues labelled “help wanted” in public repositories, and how to propose (fork, pull, the same gate). The grant is a table, not a token scope, so no token has to be re-issued.
980980
docs/design/audit.md +1 −1
@@ -85,7 +85,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
8585
8686 ### Machine and operation (admin)
8787
88 - `/system` — `pages/system.ts` · the machine as it is now: cores, load, memory, processes · `/flow`, `/` · none seen; its own refresh script.
88 - `/system` — `pages/system.ts` · the machine as it is now: cores, load, memory, processes · `/flow`, `/` · none seen; updates in place over `/system/stream`, no reload script.
8989 - `/resilience` — `pages/resilience.ts` · each protection in `docs/THREATS.md` against the last gate's tests · `/settings/resilience` (similar name, different content) · none seen.
9090 - `/admin/limits` — `pages/admin-limits.ts` · every limit with value, default and reason · `/settings/capacity` (also a numbers-you-can-change page) · none seen.
9191 - `/settings/capacity` — `pages/capacity.ts` · how many runs and checks go at once · `/admin/limits`, `/settings/usage` · none seen.
src/http/server.ts +2 −1
@@ -15,7 +15,7 @@ import { parentCallback, parentStart } from "./rp.ts";
1515 import { accessPage } from "../web/pages/access.ts";
1616 import { capacityPage, capacitySet } from "../web/pages/capacity.ts";
1717 import { flowPage } from "../web/pages/flow.ts";
18 import { systemPage } from "../web/pages/system.ts";
18 import { systemPage, systemStream } from "../web/pages/system.ts";
1919 import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts";
2020 import { contributePage } from "../web/pages/contribute.ts";
2121 import { flagsPage, flagsSet } from "../web/pages/flags.ts";
@@ -730,6 +730,7 @@ export function buildRouter(): Router<Handler> {
730730 // The pipeline's stages and its bottleneck, for a forge admin (#1554); literal, above /:owner.
731731 router.add("GET", "/flow", flowPage);
732732 router.add("GET", "/system", systemPage);
733 router.add("GET", "/system/stream", systemStream);
733734 router.add("GET", "/contribute", contributePage);
734735 router.add("GET", "/settings/telemetry", telemetryPage);
735736 router.add("POST", "/settings/telemetry", telemetrySet);
src/http/sse.ts +6 −2
@@ -82,7 +82,7 @@ export type Emit = (event: string, data: unknown, id?: string) => void;
8282 */
8383 export function eventStream(
8484 request: Request,
85 register: (emit: Emit, close: () => void) => () => void,
85 register: (emit: Emit, close: () => void, ready: () => boolean) => () => void,
8686 ): Response {
8787 const encoder = new TextEncoder();
8888 let cleanup = () => {};
@@ -116,6 +116,10 @@ export function eventStream(
116116 );
117117 };
118118
119 // A source of samples (the latest wins, older ones are worthless)
120 // skips a frame while the reader is behind instead of queueing it.
121 const ready = () => open && (controller.desiredSize ?? 0) > MAX_BUFFERED_BYTES / 2;

This is new shared behaviour with no test. Add one where the reader never reads: frames gated on ready() stop being queued past half the budget, and the stream is not dropped for it.

122
119123 const heartbeat = setInterval(() => write(`: ping\n\n`), HEARTBEAT_MS);
120124 // Assigned before `register` runs: a source that finishes immediately
121125 // (a job that is already done) must find a cleanup that works.
@@ -148,7 +152,7 @@ export function eventStream(
148152 request.signal.addEventListener("abort", cleanup);
149153 release = holdForCaller(request, () => cleanup());
150154 write(`: connected\n\n`);
151 unsubscribe = register(emit, () => cleanup());
155 unsubscribe = register(emit, () => cleanup(), ready);
152156 // A registration that closed the stream synchronously leaves an
153157 // unsubscribe nobody has run yet.
154158 if (!open) unsubscribe();
src/system/live.ts +38 −2
@@ -12,18 +12,53 @@ import { buildSystem, forAgent, type Roots, type SystemView } from "./view.ts";
1212
1313 const FRESH_MS = 1_000;
1414
15 /** How often a watched reading is pushed: only while someone is subscribed. */
16 export const PUSH_MS = 2_000;
17
1518 export type Live = {
1619 read(now?: number): SystemView;
20 /** Hear every sample pushed; the first subscriber starts the clock, the last stops it. */
21 subscribe(listener: (view: SystemView) => void): () => void;
22 /** Whether the push clock is running: only while someone is subscribed. */
23 running(): boolean;
24 /** Hand a sample to every subscriber (the clock does this; a test may too). */
25 publish(view: SystemView): void;
1726 };
1827
19 export function createLive(options: { paths?: Paths; roots?: () => Roots; self?: number } = {}): Live {
28 export function createLive(options: { paths?: Paths; roots?: () => Roots; self?: number; pushMs?: number } = {}): Live {
2029 const paths = options.paths ?? HOST;
2130 const roots = options.roots ?? cellRoots;
2231 const self = options.self ?? process.pid;
2332 let prev: Raw | undefined;
2433 let last: Raw | undefined;
2534 let view: SystemView | undefined;
26 return {
35 const listeners = new Set<(view: SystemView) => void>();
36 let timer: ReturnType<typeof setInterval> | undefined;
37 const self_: Live = {
38 subscribe(listener) {
39 listeners.add(listener);
40 if (timer === undefined) {
41 timer = setInterval(() => self_.publish(self_.read()), options.pushMs ?? PUSH_MS);
42 timer.unref?.();
43 }
44 return () => {
45 listeners.delete(listener);
46 if (listeners.size === 0 && timer !== undefined) {
47 clearInterval(timer);
48 timer = undefined;
49 }
50 };
51 },
52 running: () => timer !== undefined,
53 publish(sample) {
54 for (const listener of [...listeners]) {
55 try {
56 listener(sample);
57 } catch {
58 // one broken viewer never starves the others
59 }
60 }
61 },
2762 read(now = Date.now()) {
2863 if (view !== undefined && last !== undefined && now - last.at < FRESH_MS) return view;
2964 const raw = readRaw(paths, now);
@@ -34,6 +69,7 @@ export function createLive(options: { paths?: Paths; roots?: () => Roots; self?:
3469 return view;
3570 },
3671 };
72 return self_;
3773 }
3874
3975 let shared = createLive();
src/web/pages/system.ts +77 −42
@@ -2,9 +2,10 @@
22 * /system, for the owner: the machine as it is now — cores, temperatures,
33 * load, memory, disks, network — and every process grouped by what the
44 * forge launched it for, each with CPU, memory and age. The same test file
5 * in more than one check at once is named at the top. It refreshes itself
6 * every 2 s (a few lines of static script; `?pause` turns that off), so a
7 * viewer *is* the sampler (`system/live.ts`). Numbers are `system/view.ts`'s.
5 * in more than one check at once is named at the top. It updates in place
6 * over one event stream (`/system/stream`, `system-live.ts` patches it; `?pause`
7 * leaves it still) and never reloads or polls; while someone watches, the
8 * sampler (`system/live.ts`) pushes a reading every 2 s. Numbers are `system/view.ts`'s.
89 * Below it, for a forge admin who is a person, the forge's resident memory
910 * (RSS) beside its JS heap, hourly for 24 h (#2151): RSS climbing while the
1011 * heap stays flat is native memory growth. Drawn on the server as SVG.
@@ -15,10 +16,13 @@ import { telemetryReader } from "../../actions/telemetry.ts";
1516 import type { Ctx } from "../../http/context.ts";
1617 import { memoryCapHolder } from "../../apps/contain.ts";
1718 import { bytes, span } from "../../system/format.ts";
19 import { eventStream } from "../../http/sse.ts";
20 import type { SystemView } from "../../system/view.ts";
1821 import { live } from "../../system/live.ts";
1922 import { notFoundPage, signInFirst } from "../chrome.ts";
2023 import { html, raw, type Html } from "../html.ts";
2124 import { page } from "../layout.ts";
25 import { systemLiveJs } from "../system-live.ts";
2226 import { card, empty, listCard, pill, row } from "../ui.ts";
2327
2428 /** A group's memory, and a cell's beside its cap and who holds the cap. */
@@ -51,14 +55,12 @@ function memorySection(ctx: Ctx): Html {
5155 </table>`;
5256 }
5357
54 const RELOAD = `if (!/[?&]pause\\b/.test(location.search)) setTimeout(function () { location.reload(); }, 2000);`;
55
56 // GET /system
57 export function systemPage(ctx: Ctx): Response {
58 if (!ctx.auth.user) return signInFirst(ctx);
59 const reader = telemetryReader(ctx.app, ctx.auth.user);
60 if (reader?.kind !== "owner") return notFoundPage(ctx);
61 const v = live().read();
58 /**
59 * The parts of the page a sample moves, each the inner markup of the element
60 * of that id. The first paint and every pushed sample are drawn by this one
61 * function, so the stream can only ever say what the page would have.
62 */
63 export function regions(v: SystemView): Record<string, string> {
6264 const dup: Html =
6365 v.duplicates.length === 0
6466 ? html`<p id="duplicates" data-count="0" class="muted">No test file is running in more than one check.</p>`
@@ -69,39 +71,20 @@ export function systemPage(ctx: Ctx): Response {
6971 <ul class="list small mono">
7072 ${v.duplicates.map((d) => html`<li>${d.file} · ${String(d.checks.length)} checks: ${d.checks.join(", ")}</li>`)}
7173 </ul>`;
72 const holder = memoryCapHolder();
73 const caps: Html =
74 holder.by === "kernel"
75 ? html`<p class="small" id="cell-caps" data-by="kernel">Each cell's memory is capped by the kernel (its own cgroup's <code>memory.max</code>): a cell over it is killed alone.</p>`
76 : html`<p class="small" id="cell-caps" data-by="watch">Each cell's memory is watched once a second and the cell stopped over its cap; the kernel does not cap it (${holder.reason}).</p>`;
7774 const cores = v.cpu.cores.map(
7875 (c, i) => html`<span class="mono small" title="cpu${String(i)}">${c === null ? "—" : String(Math.round(c))}</span> `,
7976 );
80 const body = html`<div class="page-head">
81 <h1>System</h1>
82 <p class="muted">
83 The machine now, sampled each time this page refreshes (every 2 s);
84 <a href="/system?pause">pause</a>. Its history is on
85 <a href="/boards">Boards</a> (the machine source); the pipeline is on
86 <a href="/flow">Flow</a>.
87 </p>
88 </div>
89 ${dup}
90 ${card(
91 html`<div id="summary" class="row-meta">
92 ${pill(`CPU ${pct(v.cpu.busyPct)}`, v.cpu.busyPct !== null && v.cpu.busyPct >= 90 ? "danger" : undefined)}
77 const out: Record<string, Html> = {
78 "sys-dup": dup,
79 summary: html`${pill(`CPU ${pct(v.cpu.busyPct)}`, v.cpu.busyPct !== null && v.cpu.busyPct >= 90 ? "danger" : undefined)}
9380 ${pill(`${String(v.cpu.cores.length)} cores`)}
9481 ${pill(`load ${v.load ? v.load.map((l) => l.toFixed(2)).join(" ") : "—"}`)}
9582 ${pill(`hottest ${v.hottestC === null ? "—" : `${v.hottestC.toFixed(0)}°C`}`)}
9683 ${pill(`memory ${v.memory ? `${bytes(v.memory.usedBytes)} of ${bytes(v.memory.totalBytes)}` : "—"}`)}
97 ${pill(`${String(v.processes)} processes`)}
98 </div>
99 <p class="small" id="cores" aria-label="Per-core CPU, percent">${cores}</p>
100 ${caps}
101 <p class="small mono" id="temps">${v.temps.map((t) => `${t.chip} ${t.label} ${t.celsius.toFixed(0)}°C`).join(" · ")}</p>`,
102 { head: html`<h2>Machine</h2>` },
103 )}
104 ${listCard(
84 ${pill(`${String(v.processes)} processes`)}`,
85 cores: html`${cores}`,
86 temps: html`${v.temps.map((t) => `${t.chip} ${t.label} ${t.celsius.toFixed(0)}°C`).join(" · ")}`,
87 "sys-disks": listCard(
10588 [
10689 ...v.disks.map((d) =>
10790 row({
@@ -118,8 +101,8 @@ export function systemPage(ctx: Ctx): Response {
118101 ),
119102 ],
120103 { head: html`<h2>Disks and network</h2>` },
121 )}
122 ${listCard(
104 ),
105 "sys-procs": listCard(
123106 v.groups.map(
124107 (g) => html`<li data-kind="${g.kind}"><div class="row">
125108 <span class="row-main"><span class="row-title mono">${g.label}</span>
@@ -128,7 +111,59 @@ export function systemPage(ctx: Ctx): Response {
128111 </div></li>`,
129112 ),
130113 { head: html`<h2>Processes</h2>` },
131 )}
132 ${memorySection(ctx)}`;
133 return page({ ctx, title: "System", body, wide: true, scriptJs: RELOAD });
114 ),
115 };
116 return Object.fromEntries(Object.entries(out).map(([k, h]) => [k, h.text]));
117 }
118
119 /** The sample as the stream sends it. */
120 export const sampleFrame = (v: SystemView): { regions: Record<string, string> } => ({ regions: regions(v) });
121
122 // GET /system/stream: the owner's live samples, one frame each.
123 export function systemStream(ctx: Ctx): Response {
124 if (!ctx.auth.user) return signInFirst(ctx);
125 if (telemetryReader(ctx.app, ctx.auth.user)?.kind !== "owner") return notFoundPage(ctx);
126 const sampler = live();
127 return eventStream(ctx.request, (emit, _close, ready) => {
128 emit("sample", sampleFrame(sampler.read()));
129 return sampler.subscribe((view) => {
130 if (ready()) emit("sample", sampleFrame(view));

Minor: sampleFrame(view) renders every region again for each subscriber on each tick. With one owner and a few tabs that's fine. If more viewers ever watch, render the frame once per tick (for example memoised by view) and send the same object to each listener.

131 });
132 });
133 }
134
135 // GET /system
136 export function systemPage(ctx: Ctx): Response {
137 if (!ctx.auth.user) return signInFirst(ctx);
138 const reader = telemetryReader(ctx.app, ctx.auth.user);
139 if (reader?.kind !== "owner") return notFoundPage(ctx);
140 const v = live().read();
141 const holder = memoryCapHolder();
142 const caps: Html =
143 holder.by === "kernel"
144 ? html`<p class="small" id="cell-caps" data-by="kernel">Each cell's memory is capped by the kernel (its own cgroup's <code>memory.max</code>): a cell over it is killed alone.</p>`
145 : html`<p class="small" id="cell-caps" data-by="watch">Each cell's memory is watched once a second and the cell stopped over its cap; the kernel does not cap it (${holder.reason}).</p>`;
146 const r = regions(v);
147 const at = (id: string): Html => raw(r[id]!);
148 const body = html`<div class="page-head">
149 <h1>System</h1>
150 <p class="muted">
151 The machine now, updated in place about every 2 s over one event
152 stream; <a href="/system?pause">pause</a>. Its history is on
153 <a href="/boards">Boards</a> (the machine source); the pipeline is on
154 <a href="/flow">Flow</a>.
155 </p>
156 </div>
157 <div id="sys-dup">${at("sys-dup")}</div>
158 ${card(
159 html`<div id="summary" class="row-meta">${at("summary")}</div>
160 <p class="small" id="cores" aria-label="Per-core CPU, percent">${at("cores")}</p>
161 ${caps}
162 <p class="small mono" id="temps">${at("temps")}</p>`,
163 { head: html`<h2>Machine</h2>` },
164 )}
165 <div id="sys-disks">${at("sys-disks")}</div>
166 <div id="sys-procs">${at("sys-procs")}</div>
167 ${memorySection(ctx)}`;
168 return page({ ctx, title: "System", body, wide: true, scriptJs: systemLiveJs });
134169 }
src/web/system-live.ts added +23
@@ -0,0 +1,23 @@
1 /**
2 * /system's client: one EventSource, and each pushed sample patched into the
3 * element of the id it names. No reload, no timer, no polling; EventSource
4 * itself reconnects after a roll (the stream's `retry:` hint) and the first
5 * frame of the new stream is the latest sample. `?pause` opens nothing.
6 * The markup comes from our own server, escaped there, so it is set as HTML.
7 */
8 export const systemLiveJs = /* js */ `
9 (function () {
10 if (typeof EventSource === 'undefined' || /[?&]pause\\b/.test(location.search)) return;
11 var es = new EventSource('/system/stream');
12 es.addEventListener('sample', function (e) {
13 var data;
14 try { data = JSON.parse(e.data); } catch (_) { return; }
15 var regions = data && data.regions;
16 if (!regions) return;
17 for (var id in regions) {
18 var el = document.getElementById(id);
19 if (el && el.innerHTML !== regions[id]) el.innerHTML = regions[id];
20 }
21 });
22 })();
23 `;
test/design-audit.test.ts +1 −1
@@ -11,7 +11,7 @@ const doc = readFileSync("docs/design/audit.md", "utf8");
1111
1212 /** Routes that answer a file, a fragment, a redirect hop or a callback. */
1313 const NOT_PAGES =
14 /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|events$|sw\.js$|robots\.txt$|sitemap\.xml$|manifest\.webmanifest$|llms\.txt$|releases\.json$|audit\.csv$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/)/;
14 /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|events$|system\/stream$|sw\.js$|robots\.txt$|sitemap\.xml$|manifest\.webmanifest$|llms\.txt$|releases\.json$|audit\.csv$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/)/;
1515
1616 const getRoutes = [
1717 ...server.matchAll(/router\.add\(\s*"GET",\s*"([^"]+)"/g),
test/system-stream.test.ts added +151
@@ -0,0 +1,151 @@
1 /**
2 * /system is live over one event stream (#2720): its HTML never reloads or
3 * polls, and a sample handed to the sampler arrives on the stream.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { eventStream, sseDroppedCount } from "../src/http/sse.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { createLive, useLive } from "../src/system/live.ts";
14
15 describe("/system over server-sent events", () => {
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19 let cookie: string;
20 const sampler = createLive({ pushMs: 20 });
21
22 beforeAll(async () => {
23 dataDir = await mkdtemp(join(tmpdir(), "forge-sysstream-"));
24 app = createApp({ dataDir, port: 0 });
25 servers = await serve(app);
26 const u = await createUser(app.db, {
27 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
28 password: "a-good-passphrase", adminIfFirst: true,
29 });
30 if (!u.ok) throw new Error(u.error);
31 await createUser(app.db, {
outdated

bob is created and never used. Sign him in and assert that /system/stream refuses him (404, not text/event-stream). That is the gate this route relies on.

32 origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase",
33 });
34 useLive(sampler);
35 const res = await fetch(`${servers.url}/signin`, {
36 method: "POST",
37 redirect: "manual",
38 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
39 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
40 });
41 cookie = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
42 });
43
44 afterAll(async () => {
45 useLive(createLive());
46 servers.stop();
47 app.close();
48 await rm(dataDir, { recursive: true, force: true });
49 });
50
51 test("the page has no refresh and no interval reload, and opens the stream", async () => {
52 const res = await fetch(`${servers.url}/system`, { headers: { cookie } });
53 expect(res.status).toBe(200);
54 const body = await res.text();
55 expect(body).not.toMatch(/http-equiv=["']?refresh/i);
56 expect(body).not.toContain("location.reload");
57 expect(body).not.toContain("setTimeout");
58 expect(body).not.toContain("setInterval");
59 expect(body).toContain("new EventSource('/system/stream')");
60 // First paint is server-rendered with the latest sample.
61 expect(body).toContain('id="summary"');
62 expect(body).toMatch(/id="summary"[^>]*>\s*<[^>]*>CPU /);
63 });
64
65 test("a sample written to the sampler arrives on the stream; a stranger is refused", async () => {
66 const anon = await fetch(`${servers.url}/system/stream`, { redirect: "manual" });
67 expect(anon.headers.get("content-type") ?? "").not.toContain("text/event-stream");
68 await anon.body?.cancel();
69 const res = await fetch(`${servers.url}/system/stream`, { headers: { cookie } });
70 expect(res.headers.get("content-type")).toContain("text/event-stream");
71 const reader = res.body!.getReader();
72 const decoder = new TextDecoder();
73 const base = sampler.read();
74 sampler.publish({ ...base, processes: 98765 });
75 let text = "";
76 const deadline = Date.now() + 3000;
77 while (!text.includes("98765") && Date.now() < deadline) {
78 const { value, done } = await reader.read();
79 if (done) break;
80 text += decoder.decode(value);
81 }
82 await reader.cancel();
83 expect(text).toContain("event: sample");
84 expect(text).toContain("98765 processes");
85 });
86
outdated

This shows that an unsubscribed listener is not called. It does not show that the interval stops: the test would pass with the clearInterval deleted. Make the test observe the timer itself.

87 test("a signed-in non-owner is refused the stream as the page is", async () => {
88 const res = await fetch(`${servers.url}/signin`, {
89 method: "POST",
90 redirect: "manual",
91 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
92 body: new URLSearchParams({ username: "bob", password: "a-good-passphrase" }),
93 });
94 const bob = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
95 expect(bob).not.toBe("");
96 const stream = await fetch(`${servers.url}/system/stream`, { headers: { cookie: bob } });
97 expect(stream.status).toBe(404);
98 expect(stream.headers.get("content-type") ?? "").not.toContain("text/event-stream");
99 await stream.body?.cancel();
100 });
101
102 test("the sampler's clock runs only while someone watches, and ticks while they do", async () => {
103 const own = createLive({ pushMs: 10 });
104 expect(own.running()).toBe(false);
105 const first = own.subscribe(() => {});
106 expect(own.running()).toBe(true);
107 first();
108 expect(own.running()).toBe(false);
109 let ticks = 0;
110 const second = own.subscribe(() => ticks++);
111 expect(own.running()).toBe(true);
112 await Bun.sleep(100);
113 second();
114 expect(ticks).toBeGreaterThan(2);
115 const stopped = ticks;
116 await Bun.sleep(60);
117 expect(ticks).toBe(stopped);
118 expect(own.running()).toBe(false);
119 });
120
121 test("a reader that never reads stops being queued to once half full, and is not dropped", async () => {
122 let emitter: ((e: string, d: unknown) => void) | undefined;
123 let ready: (() => boolean) | undefined;
124 let closed = false;
125 const res = eventStream(new Request("http://x/s"), (emit, _close, r) => {
126 emitter = emit;
127 ready = r;
128 return () => {
129 closed = true;
130 };
131 });
132 const dropped = sseDroppedCount();
133 const frame = "x".repeat(8 * 1024);
134 let sent = 0;
135 while (ready!() && sent < 1000) {
136 emitter!("sample", frame);
137 sent++;
138 }
139 // Gated: it stopped well short of the 256 KB budget (about 16 frames), without a drop.
140 expect(sent).toBeGreaterThan(5);
141 expect(sent).toBeLessThan(25);
142 expect(ready!()).toBe(false);
143 expect(closed).toBe(false);
144 expect(sseDroppedCount()).toBe(dropped);
145 // Reading frees room again.
146 const reader = res.body!.getReader();
147 for (let i = 0; i < sent && !ready!(); i++) await reader.read();
148 expect(ready!()).toBe(true);
149 await reader.cancel();
150 });
151 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs