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

The tty registry, the cell watcher and the app runtime run their timers inside `during`, and a test finds any server timer outside it #11175

Merged opened this

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

Merged

scout/forge:scout/11167-the-tty-registry-the-cell-watcher-and-th → main 4 commits fork

  • Merged. scout/11167-the-tty-registry-the-cell-watcher-and-th was merged into main as 3700fe8.

Review

Sign in to review this change.
17 files changed +283 −103
src/apps/contain.ts +19 −11
@@ -403,10 +403,14 @@ async function tryOnce(extra: readonly string[] = []): Promise<Verdict> {
403403 ],
404404 { stdin: "ignore", stdout: "ignore", stderr: "pipe" },
405405 );
406 const timer = setTimeout(() => {
407 timedOut = true;
408 proc.kill("SIGKILL");
409 }, PROBE_TIMEOUT_MS);
406 const timer = setTimeout(
407 () =>
408 during("cell-probe-deadline", () => {
409 timedOut = true;
410 proc.kill("SIGKILL");
411 }),
412 PROBE_TIMEOUT_MS,
413 );
410414 const [text, code] = await Promise.all([
411415 new Response(proc.stderr).text(),
412416 proc.exited,
@@ -952,13 +956,17 @@ export function killTree(root: number): void {
952956 // already gone
953957 }
954958 }
955 setTimeout(() => {
956 try {
957 process.kill(root, "SIGKILL");
958 } catch {
959 // gone, as it should be
960 }
961 }, 1_000).unref();
959 setTimeout(
960 () =>
961 during("cell-kill-root", () => {
962 try {
963 process.kill(root, "SIGKILL");
964 } catch {
965 // gone, as it should be
966 }
967 }),
968 1_000,
969 ).unref();
962970 }
963971
964972 /** Send `signal` to every process under `root`, not `root` (bubblewrap) itself. */
src/apps/egress_refused.ts +2 −1
@@ -11,6 +11,7 @@
1111 * which the proxy asks on every connection.
1212 */
1313
14 import { during } from "../perf/jobs.ts";
1415 import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1516 import { isIP } from "node:net";
1617 import { join } from "node:path";
@@ -119,7 +120,7 @@ function save(dataDir: string, repoId: string, now: boolean): void {
119120 b.dirty = true;
120121 if (now) return flushBook(dataDir, repoId);
121122 if (b.timer !== undefined) return;
122 b.timer = setTimeout(() => flushBook(dataDir, repoId), FLUSH_MS);
123 b.timer = setTimeout(() => during("egress-refused-flush", () => flushBook(dataDir, repoId)), FLUSH_MS);
123124 b.timer.unref?.();
124125 }
125126
src/apps/runtime.ts +16 −12
@@ -1763,18 +1763,22 @@ export class AppsRuntime {
17631763 }
17641764 const delay = RESTART_BACKOFF_MS[state.restarts.length] ?? 30_000;
17651765 state.restarts.push(now);
1766 setTimeout(() => {
1767 if (this.stopped) return;
1768 const fresh = getRepoById(this.app.db, repo.id);
1769 if (fresh)
1770 this.requestDeploy(
1771 state.app,
1772 fresh,
1773 state.env,
1774 live.commitOid,
1775 oom ? "oom" : "restart",
1776 );
1777 }, delay);
1766 setTimeout(
1767 () =>
1768 during("apps-restart", () => {
1769 if (this.stopped) return;
1770 const fresh = getRepoById(this.app.db, repo.id);
1771 if (fresh)
1772 this.requestDeploy(
1773 state.app,
1774 fresh,
1775 state.env,
1776 live.commitOid,
1777 oom ? "oom" : "restart",
1778 );
1779 }),
1780 delay,
1781 );
17781782 })
17791783 .catch(() => {});
17801784 }
src/apps/tty_registry.ts +5 −3
@@ -10,6 +10,7 @@
1010 */
1111
1212 import type { App } from "../app.ts";
13 import { during } from "../perf/jobs.ts";
1314
1415 export interface AppTtyEntry {
1516 readonly id: string;
@@ -76,9 +77,10 @@ export function openAppTty(app: App, open: OpenAppTty): AppTtyHandle {
7677 hangup: open.hangup,
7778 ended: false,
7879 timer: setInterval(
79 () => {
80 if (idleFor(entry, open.idleMs())) stop(app, entry);
81 },
80 () =>
81 during("app-tty-idle", () => {
82 if (idleFor(entry, open.idleMs())) stop(app, entry);
83 }),
8284 Math.max(10, Math.min(open.idleMs(), open.checkMs ?? CHECK_MS)),
8385 ),
8486 };
src/core/route_timing.ts +2 −1
@@ -18,6 +18,7 @@
1818 * with what strangers type. Not the forge's health: that is `kernel/vitals`.
1919 */
2020
21 import { during } from "../perf/jobs.ts";
2122 import type { Database } from "bun:sqlite";
2223 import { withoutWaiting } from "../kernel/db.ts";
2324 import { quietly } from "./write_clock.ts";
@@ -108,7 +109,7 @@ export function createRouteTiming(
108109 clear();
109110 timer = setTimeout(() => {
110111 try {
111 flush();
112 during("route-timing-flush", flush);
112113 } catch {
113114 // flush put the records back and rearmed the retry.
114115 }
src/core/usage_count.ts +2 −1
@@ -13,6 +13,7 @@
1313 * caller on the request path must wrap it in try/catch.
1414 */
1515
16 import { during } from "../perf/jobs.ts";
1617 import type { Database } from "bun:sqlite";
1718 import { withoutWaiting } from "../kernel/db.ts";
1819 import { quietly } from "./write_clock.ts";
@@ -105,7 +106,7 @@ export function createUsageCounter(
105106 clear();
106107 timer = setTimeout(() => {
107108 try {
108 flush();
109 during("usage-count-flush", flush);
109110 } catch {
110111 // flush already put the counts back and rearmed the retry.
111112 }
src/crew/early.ts +2 −1
@@ -10,6 +10,7 @@
1010 * its head and fills it in (`fillPull`); it never opens a second.
1111 */
1212
13 import { during } from "../perf/jobs.ts";
1314 import { openPull } from "../actions/pulls.ts";
1415 import { editIssue, addComment, setIssueState } from "../actions/issues.ts";
1516 import type { App } from "../app.ts";
@@ -163,7 +164,7 @@ export function watchForPull(input: EarlyInput): EarlyPull {
163164 () => {
164165 looks++;
165166 busy = busy
166 .then(tick)
167 .then(() => during("early-watch", tick))
167168 .catch(() => undefined)
168169 .then(schedule);
169170 },
src/crew/routines.ts +2 −1
@@ -7,6 +7,7 @@
77 * What a routine says is deterministic (`routine_report.ts`): no model, no spend.
88 */
99
10 import { during } from "../perf/jobs.ts";
1011 import type { App } from "../app.ts";
1112 import { appendTurn, getConversation, listConversations, newConversation, renameConversation, type Conversation } from "../core/conversations.ts";
1213 import { due, listRoutines, markRan, ROUTINE_NAMES, type RoutineKind } from "../core/talk_routines.ts";
@@ -78,7 +79,7 @@ export function startRoutines(app: App, signal: AbortSignal): () => void {
7879 const loop = (wait: number): void => {
7980 timer = setTimeout(() => {
8081 if (signal.aborted) return;
81 void runDue(app, new Date()).finally(() => loop(60_000));
82 void during("routines", () => runDue(app, new Date())).finally(() => loop(60_000));
8283 }, wait);
8384 timer.unref();
8485 };
src/http/access_log.ts +2 −1
@@ -17,6 +17,7 @@
1717 * the `access` MCP tool).
1818 */
1919
20 import { during } from "../perf/jobs.ts";
2021 import {
2122 appendFileSync,
2223 mkdirSync,
@@ -270,7 +271,7 @@ export function recordAccess(
270271 for (const q of pending.values()) count += q.lines.length;
271272 if (count >= FLUSH_LINES) flushAccess(at);
272273 else if (timer === undefined) {
273 timer = setTimeout(() => flushAccess(), FLUSH_MS);
274 timer = setTimeout(() => during("access-flush", flushAccess), FLUSH_MS);
274275 timer.unref();
275276 }
276277 } catch {
src/nightly/output.ts +2 −1
@@ -13,6 +13,7 @@
1313 * the issues themselves are the truth. Plain data.
1414 */
1515
16 import { during } from "../perf/jobs.ts";
1617 import { closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs";
1718 import { appendFile } from "node:fs/promises";
1819 import { join } from "node:path";
@@ -60,7 +61,7 @@ export function startOutput(dataDir: string, name: string): Output {
6061 waiting += text;
6162 if (waiting.length >= FLUSH_BYTES) void flush();
6263 else if (timer === undefined) {
63 timer = setTimeout(() => void flush(), FLUSH_MS);
64 timer = setTimeout(() => void during("nightly-output-flush", flush), FLUSH_MS);
6465 timer.unref?.();
6566 }
6667 };
src/release/prove.ts +2 −1
@@ -26,6 +26,7 @@ import { smoke, type Failure, type Heavy } from "./smoke.ts";
2626 import { UNSTAMPED } from "../kernel/version.ts";
2727 import { ownProbeHeaders } from "../kernel/probe.ts";
2828 import type { Log } from "../kernel/log.ts";
29 import { during } from "../perf/jobs.ts";
2930
3031 /** How long the public URL has to answer as the new version. */
3132 export const GIVE_UP_MS = 10 * 60_000;
@@ -250,7 +251,7 @@ export function startProving(
250251 // True from a proof's start until it settles: a proof is never started over one still running.
251252 let running = false;
252253 const next = (): void => {
253 if (!stopped) cancel = schedule(() => void tick(), everyMs);
254 if (!stopped) cancel = schedule(() => void during("release-prove", tick), everyMs);
254255 };
255256 const tick = async (): Promise<void> => {
256257 if (running) {
src/snapshot/bucket.ts +2 −1
@@ -13,6 +13,7 @@
1313 * without touching the network. Plain data in, plain data out.
1414 */
1515
16 import { during } from "../perf/jobs.ts";
1617 import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1718 import { join } from "node:path";
1819 import type { DrillResult } from "./drill.ts";
@@ -361,7 +362,7 @@ export function watchBucket(
361362 };
362363 const next = (ms: number): void => {
363364 if (stopped) return;
364 timer = setTimeout(() => void pass().catch(() => {}).finally(() => next(everyMs)), ms);
365 timer = setTimeout(() => void during("bucket-watch", pass).catch(() => {}).finally(() => next(everyMs)), ms);
365366 };
366367 next(Math.min(firstMs, everyMs));
367368 return {
src/spawn/usage.ts +12 −7
@@ -5,6 +5,7 @@
55 * figures of its own and reads "shared". Plain data in, plain data out.
66 */
77
8 import { during } from "../perf/jobs.ts";
89 import { readFileSync } from "node:fs";
910 import { join } from "node:path";
1011 import { diskBytes, cellCounters } from "../apps/contain.ts";
@@ -41,13 +42,17 @@ function diskOf(dir: string, now: number): number | undefined {
4142 const kept = disks.get(dir);
4243 if ((kept === undefined || now - kept.at >= DISK_FRESH_MS) && !walking.has(dir)) {
4344 walking.add(dir);
44 setTimeout(() => {
45 try {
46 disks.set(dir, { at: Date.now(), bytes: diskBytes([dir]) });
47 } finally {
48 walking.delete(dir);
49 }
50 }, 0).unref();
45 setTimeout(
46 () =>
47 during("disk-walk", () => {
48 try {
49 disks.set(dir, { at: Date.now(), bytes: diskBytes([dir]) });
50 } finally {
51 walking.delete(dir);
52 }
53 }),
54 0,
55 ).unref();
5156 }
5257 return kept?.bytes;
5358 }
src/web/studio/home_cache.ts +3 −2
@@ -14,6 +14,7 @@
1414 * again. The waiting line stays on the write clock: the asks count must be
1515 * exact.
1616 */
17 import { during } from "../../perf/jobs.ts";
1718 import type { Database } from "bun:sqlite";
1819 import type { Answer } from "../../boards/query.ts";
1920 import { writeStamp } from "../../core/write_clock.ts";
@@ -112,7 +113,7 @@ function servedStale<V>(
112113 if (!keys.has(key)) {
113114 const held = keys;
114115 held.add(key);
115 setTimeout(() => {
116 setTimeout(() => during("home-refresh", () => {
116117 try {
117118 if (!db.inTransaction) cache.put(key, TTL, refresh());
118119 } catch (error) {
@@ -121,7 +122,7 @@ function servedStale<V>(
121122 } finally {
122123 held.delete(key);
123124 }
124 }, 0);
125 }), 0);
125126 }
126127 return kept.value;
127128 }
src/web/studio/home_last.ts +14 −11
@@ -11,6 +11,7 @@
1111 * The variant is built by `homeVariant` from the few query parameters home
1212 * reads, validated, so the keys a client can make are bounded.
1313 */
14 import { during } from "../../perf/jobs.ts";
1415 import type { Database } from "bun:sqlite";
1516 import { notePhase } from "../../core/route_timing.ts";
1617 import { forgetAll, recall, remember } from "../../core/region_memory.ts";
@@ -78,17 +79,19 @@ export async function homeRegion(
7879 drawing.set(key, Number.MAX_SAFE_INTEGER);
7980 // Read when the redraw is decided, not when the timer fires: a write that lands in between must leave the copy stale, not current.
8081 const before = stamp;
81 setTimeout(() => {
82 void (async () => {
83 try {
84 remember(db, viewerId, name, variant, (await draw()).text, before, access);
85 } catch {
86 // The page's own swap asks again and shows the failure.
87 } finally {
88 drawing.set(key, Date.now());
89 }
90 })();
91 }, 0);
82 setTimeout(
83 () =>
84 void during("home-last", async () => {
85 try {
86 remember(db, viewerId, name, variant, (await draw()).text, before, access);
87 } catch {
88 // The page's own swap asks again and shows the failure.
89 } finally {
90 drawing.set(key, Date.now());
91 }
92 }),
93 0,
94 );
9295 }
9396 notePhase(request, name, performance.now() - started, true);
9497 return wrap(had.html, !current);
test/job-slices.test.ts +16
@@ -243,6 +243,22 @@ const UNTIMED: Record<string, string> = {
243243 "roll-probe": "cluster only (FORGE_RELEASE_KEEPER=on): one HTTP probe",
244244 "apps-sweep": "AppsRuntime's sweep over running app processes; the fixture has no apps",
245245 "cell-watch": "watches running app cells' cgroups; needs cells, skipped in this sandbox mode",
246 "cell-probe-deadline": "a cell's start probe running out of time: needs cells, skipped in this sandbox mode",
247 "cell-kill-root": "kills a cell's root process after its grace: needs cells, skipped in this sandbox mode",
248 "apps-restart": "restarts an app process after it exits; the fixture has no apps",
249 "app-tty-idle": "closes an app terminal left idle; the fixture has no app terminals",
250 "route-timing-flush": "a debounced write of route timings: runs after requests, the jobs here make none",
251 "usage-count-flush": "a debounced write of usage counts: runs after requests, the jobs here make none",
252 "access-flush": "a debounced write of the access log: runs after requests, the jobs here make none",
253 "egress-refused-flush": "a debounced write of an app's refused hosts; needs the egress proxy and apps",
254 "nightly-output-flush": "a debounced write of a nightly job's output: inside a nightly run",
255 "home-refresh": "redraws a stale home panel after a request; the jobs here make no requests",
256 "home-last": "redraws a home panel after a request; the jobs here make no requests",
257 "disk-walk": "walks one spawn directory for its size: I/O, after a request",
258 "bucket-watch": "reads the snapshot lanes in the bucket: network, off without a bucket",
259 routines: "a closure in startRoutines; the fixture has no routines",
260 "early-watch": "watches one run's branch for an early look; needs a running crew run",
261 "release-prove": "proves a release against its serving copy: cluster and network",
246262 "egress-idle": "a closure in the egress proxy over its live connections; needs the proxy's unix socket",
247263 "git-readers": "closes idle git cat-file readers: a map walk over child processes the fixture has none of",
248264 "github-traffic": "pulls GitHub's traffic API: network, and off unless a GitHub token is configured",
test/timers-named.test.ts +180 −48
@@ -6,16 +6,12 @@
66 * The scan reads src/ — not the browser files under src/web, src/web/vendor
77 * or the terminal UI in src/tui — as syntax, so a timer written inside a
88 * template string (the toolbar's browser script, a scaffolded app) is text,
9 * not a timer. Every `setInterval(` callback, and every `setTimeout(` callback
10 * in the files #11117 names, must do its work inside `during`: be a `during`
11 * call (behind `void`, `&&`, `.catch`, `.finally` or `.then(() => during(…))`),
12 * or a body of such calls with only guards (`if (stopped) return;`), plain
13 * flag writes and `try` around them; else it must be one occurrence on the
14 * allow-list below, with its reason.
15 *
16 * `setTimeout` is read only in the named files: elsewhere in src/ it is a
17 * one-shot deadline or a delay (over a hundred of them), not a loop, and
18 * wrapping each would put a ring slot per request into the job watch.
9 * not a timer. Every `setInterval(` and `setTimeout(` callback in every file
10 * must do its work inside `during`: be a `during` call (behind `void`, `&&`,
11 * `.catch`, `.finally` or `.then(() => during(…))`), or a body of such calls
12 * with only guards (`if (stopped) return;`), plain flag writes, counter bumps,
13 * `set.delete(x)` and `try` around them; else it must be one occurrence on the
14 * allow-list below, with its reason (#11167).
1915 */
2016
2117 import { describe, expect, test } from "bun:test";
@@ -31,25 +27,6 @@ const SKIPPED_DIRS = new Set(["src/tui", "src/web/vendor"]);
3127 const isBrowserFile = (path: string): boolean =>
3228 path.startsWith("src/web/") && /(^|[-_/])client\.ts$/.test(path);
3329
34 /** The files #11117 names: their `setTimeout`s are read as well. */
35 const NAMED = new Set([
36 "src/perf/views_store.ts",
37 "src/perf/traffic_store.ts",
38 "src/perf/reads.ts",
39 "src/perf/duty.ts",
40 "src/perf/github_traffic.ts",
41 "src/apps/contain.ts",
42 "src/apps/runtime.ts",
43 "src/apps/toolbar.ts",
44 "src/apps/presence.ts",
45 "src/apps/egress.ts",
46 "src/apps/scaffold.ts",
47 "src/apps/tty_registry.ts",
48 "src/system/live.ts",
49 "src/runs/lease.ts",
50 "src/snapshot/snapshot.ts",
51 ]);
52
5330 type Call = "setInterval" | "setTimeout";
5431
5532 interface Allowed {
@@ -67,17 +44,113 @@ interface Allowed {
6744 */
6845 const PER_CONNECTION = "one per open connection: wrapped, a burst of them evicts the real jobs from the 256-span ring";
6946
47 /** A one-shot whose callback only settles the promise a caller awaits: the work after the await runs in that caller. */
48 const SLEEP = "a sleep: it only resolves the promise its caller awaits, and the caller's work after the await is the caller's job";
49 /** A one-shot that rejects, aborts or stops one call in flight: no work of its own. */
50 const DEADLINE = "a one-shot deadline on one call in flight: it settles, aborts or stops that call and does no work of its own";
51 /** A one-shot that signals one child process. */
52 const KILL = "a kill deadline on one child process: it sends a signal, nothing runs on the loop";
53
54 const allow = (call: Call, why: string, at: readonly (readonly [string, string])[]): Allowed[] =>
55 at.map(([file, starts]) => ({ file, call, starts, why }));
56
57 /**
58 * Each entry names one timer (an entry listed twice names two alike). The one-shots are
59 * many but of three kinds — a sleep, a deadline on one call, a kill on one child — and none
60 * does work on the loop: a span around each would only fill the ring with nothing.
61 */
7062 const ALLOWED: readonly Allowed[] = [
71 { file: "src/http/sse.ts", call: "setInterval", starts: "() => write(", why: `SSE heartbeat, ${PER_CONNECTION}` },
72 { file: "src/web/pages/talk.ts", call: "setInterval", starts: "tick", why: `a talk stream's poll, ${PER_CONNECTION}` },
73 { file: "src/machines/shell.ts", call: "setInterval", starts: "() => { if (idle(session", why: `a shell session's idle check, ${PER_CONNECTION}` },
74 { file: "src/apps/tty_registry.ts", call: "setInterval", starts: "() => { if (idleFor(entry", why: `an app terminal's idle check, ${PER_CONNECTION}` },
75 { file: "src/git/progress.ts", call: "setInterval", starts: "() => { slices.push(current)", why: `a git fetch's stall check, ${PER_CONNECTION}` },
76 { file: "src/git/capped-fetch.ts", call: "setInterval", starts: "() => { void newBytes()", why: `a capped fetch's size check, ${PER_CONNECTION}` },
77 { file: "src/apps/toolbar.ts", call: "setTimeout", starts: "resolve", why: "a one-shot deadline on reading one page, not a loop" },
78 { file: "src/apps/contain.ts", call: "setTimeout", starts: "() => { timedOut = true;", why: "a one-shot deadline on the sandbox probe, not a loop" },
79 { file: "src/apps/contain.ts", call: "setTimeout", starts: "() => { try { process.kill(root", why: "a one-shot kill a second after the tree's, not a loop" },
80 { file: "src/apps/runtime.ts", call: "setTimeout", starts: "() => { if (this.stopped) return;", why: "a one-shot restart after backoff; the deploy it asks for is its own job" },
63 ...allow("setInterval", `SSE heartbeat, ${PER_CONNECTION}`, [["src/http/sse.ts", "() => write("]]),
64 ...allow("setInterval", `a talk stream's poll, ${PER_CONNECTION}`, [["src/web/pages/talk.ts", "tick"]]),
65 ...allow("setInterval", `a shell session's idle check, ${PER_CONNECTION}`, [["src/machines/shell.ts", "() => { if (idle(session"]]),
66 ...allow("setInterval", `a git fetch's stall check, ${PER_CONNECTION}`, [["src/git/progress.ts", "() => { slices.push(current)"]]),
67 ...allow("setInterval", `a capped fetch's size check, ${PER_CONNECTION}`, [["src/git/capped-fetch.ts", "() => { void newBytes()"]]),
68 ...allow("setTimeout", `a device-flow stream's poll, ${PER_CONNECTION}`, [
69 ["src/http/flow_stream.ts", "() => void step()"],
70 ["src/http/flow_stream.ts", "() => void step()"],
71 ["src/http/flow_stream.ts", "() => { emit(\"flow.settled\""],
72 ]),
73 ...allow("setTimeout", `a shell session's resize debounce, ${PER_CONNECTION}`, [["src/machines/shell.ts", "() => session.size === resize"]]),
74 ...allow("setTimeout", `an ssh login's auth deadline, ${PER_CONNECTION}`, [["src/ssh/server.ts", "() => { this.transport.disconnect("]]),
75 ...allow("setTimeout", `a media stream's idle deadline, ${PER_CONNECTION}`, [["src/git/read.ts", "() => { finish(); controller.error("]]),
76 ...allow("setTimeout", `a git upload's idle deadline, ${PER_CONNECTION}`, [["src/git/smart-http.ts", "stop"]]),
77 ...allow("setTimeout", "the loop-lag meter's own tick: it measures how late the loop runs, and a span around it would count that lateness as its own work", [["src/kernel/loop_lag.ts", "() => { // Early"]]),
78 ...allow("setTimeout", "the next slice of a sliced flow computation: one span per slice would fill the 256-span ring in one refresh", [["src/system/flow_cache.ts", "fn"]]),
79 ...allow("setTimeout", "the release prover's scheduler seam: its caller passes a callback already inside during(\"release-prove\")", [["src/release/prove.ts", "fn"]]),
80 ...allow("setTimeout", "track() only holds the round's promise; the round itself runs inside during(\"probes\")", [["src/contract/probes.ts", "() => { void track(during("]]),
81 ...allow("setTimeout", "clears a perf capture after its window: five field writes", [["src/perf/capture.ts", "drop"]]),
82 ...allow("setTimeout", "clears an image loader's in-flight flag: one comparison and one write", [["src/web/og/raster.ts", "() => loading === mine"]]),
83 ...allow("setTimeout", "retries removing one emptied cgroup directory: one rmdir", [["src/apps/cgroup.ts", "() => removeDir(dir"]]),
84 ...allow("setTimeout", SLEEP, [
85 ["src/kernel/breathe.ts", "done"],
86 ["src/maintenance/checks.ts", "done"],
87 ["src/chaos/faults.ts", "r"],
88 ["src/perf/sample.ts", "r"],
89 ["src/cluster/jobs.ts", "r"],
90 ["src/release/serving.ts", "r"],
91 ["src/apps/holding.ts", "resolve"],
92 ["src/checks/service.ts", "resolve"],
93 ["src/checks/install.ts", "() => { signal.removeEventListener("],
94 ["src/origins/providers/forgejo.ts", "resolve"],
95 ["src/origins/providers/github.ts", "resolve"],
96 ["src/origins/operator.ts", "resolve"],
97 ["src/origins/fork-route.ts", "resolve"],
98 ["src/origins/hook.ts", "resolve"],
99 ["src/origins/mirror.ts", "resolve"],
100 ["src/core/pulls.ts", "resolve"],
101 ["src/core/search.ts", "resolve"],
102 ["src/spawn/reach.ts", "done"],
103 ["src/spawn/live.ts", "done"],
104 ["src/crew/network.ts", "done"],
105 ["src/crew/dispatch.ts", "resolve"],
106 ["src/crew/workdir.ts", "resolve"],
107 ["src/cli/commands/sync.ts", "resolve"],
108 ["src/cli/commands/talk.ts", "resolve"],
109 ]),
110 ...allow("setTimeout", DEADLINE, [
111 ["src/apps/toolbar.ts", "resolve"],
112 ["src/looks/capture.ts", "() => reject("],
113 ["src/actions/search.ts", "() => resolve(\"timeout\")"],
114 ["src/checks/service.ts", "() => { overran = true; budget.abort(); }"],
115 ["src/kernel/mail.ts", "() => finish(false)"],
116 ["src/apps/domains.ts", "() => reject("],
117 ["src/apps/preview_spec.ts", "() => { stop.abort(); reject("],
118 ["src/apps/process.ts", "() => { why ="],
119 ["src/data/query.ts", "() => done({ ok: false, error: \"timeout\""],
120 ["src/web/code_rows_pool.ts", "() => { waiting.delete(id); resolve(undefined); }"],
121 ["src/snapshot/drill.ts", "() => reject("],
122 ["src/snapshot/store.ts", "() => reject("],
123 ["src/snapshot/lanes.ts", "() => reject("],
124 ["src/release/devtools.ts", "() => { if (pending.delete(n))"],
125 ["src/release/devtools.ts", "() => { waiting.set(event"],
126 ["src/release/prove.ts", "() => resolve(\"hung\")"],
127 ["src/crew/dispatch.ts", "() => resolve(\"late\")"],
128 ["src/crew/first_look.ts", "() => resolve(undefined)"],
129 ["src/crew/plan.ts", "() => resolve(undefined)"],
130 ["src/crew/decide.ts", "() => abort.abort()"],
131 ["src/crew/runner.ts", "() => { overran = true; controller.abort(); }"],
132 ["src/crew/claude.ts", "() => { input.log("],
133 ["src/crew/claude.ts", "() => { why = \"timeout\"; stopChild(); }"],
134 ["src/display/known.ts", "() => resolve(undefined)"],
135 ]),
136 ...allow("setTimeout", KILL, [
137 ["src/checks/install.ts", "stop"],
138 ["src/apps/process.ts", "() => { if (proc.exitCode === null"],
139 ["src/release/launch.ts", "() => child.kill(\"SIGKILL\")"],
140 ["src/release/checkout.ts", "() => proc.kill(\"SIGKILL\")"],
141 ["src/crew/sandbox.ts", "() => proc.kill(\"SIGKILL\")"],
142 ["src/crew/workdir.ts", "() => { timedOut = true; proc.kill(\"SIGKILL\"); }"],
143 ["src/crew/plan.ts", "() => { try { proc.kill(\"SIGKILL\"); }"],
144 ["src/crew/runner.ts", "() => proc.kill(\"SIGKILL\")"],
145 ["src/crew/claude.ts", "() => { if (proc.exitCode === null && proc.signalCode === null) { input"],
146 ["src/crew/claude.ts", "() => { if (proc.exitCode === null && proc.signalCode === null) signal"],
147 ["src/crew/claude.ts", "() => proc.kill(\"SIGKILL\")"],
148 ["src/git/env.ts", "() => proc.kill(\"SIGKILL\")"],
149 ["src/git/env.ts", "() => { timedOut = true; proc.kill(\"SIGKILL\"); }"],
150 ["src/git/progress.ts", "() => { stop(); kill(); }"],
151 ["src/git/progress.ts", "kill"],
152 ["src/git/local.ts", "() => proc.kill(\"SIGKILL\")"],
153 ]),
81154 ];
82155
83156 function sources(dir: string, out: string[] = []): string[] {
@@ -92,14 +165,18 @@ function sources(dir: string, out: string[] = []): string[] {
92165 }
93166
94167 /** The first argument of each `call(` in `text`: its text, whitespace collapsed, and whether its work runs inside `during`. */
95 export function callbacks(text: string, call: Call): { callback: string; named: boolean }[] {
168 export function callbacks(text: string, call: Call): { callback: string; named: boolean; line: number }[] {
96169 const source = ts.createSourceFile("x.ts", text, ts.ScriptTarget.Latest, true);
97 const found: { callback: string; named: boolean }[] = [];
170 const found: { callback: string; named: boolean; line: number }[] = [];
98171 const visit = (node: ts.Node): void => {
99172 if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === call) {
100173 const first = node.arguments[0];
101174 if (first !== undefined)
102 found.push({ callback: first.getText(source).replace(/\s+/g, " ").trim(), named: isNamed(first) });
175 found.push({
176 callback: first.getText(source).replace(/\s+/g, " ").trim(),
177 named: isNamed(first),
178 line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
179 });
103180 }
104181 ts.forEachChild(node, visit);
105182 };
@@ -126,6 +203,7 @@ function isDuring(expr: ts.Expression): boolean {
126203
127204 /** A statement that does no work of its own: a guard that returns, or a flag set to a plain value. */
128205 function isInert(st: ts.Statement): boolean {
206 if (ts.isExpressionStatement(st) && isBookkeeping(st.expression)) return true;
129207 if (ts.isIfStatement(st))
130208 return st.elseStatement === undefined &&
131209 (ts.isReturnStatement(st.thenStatement) && st.thenStatement.expression === undefined ||
@@ -135,6 +213,14 @@ function isInert(st: ts.Statement): boolean {
135213 return operatorToken.kind === ts.SyntaxKind.EqualsToken && (ts.isLiteralExpression(right) || right.kind === ts.SyntaxKind.TrueKeyword || right.kind === ts.SyntaxKind.FalseKeyword || ts.isIdentifier(right));
136214 }
137215
216 /** A counter bump (`looks++`) or a timer forgetting itself (`timers.delete(timer)`): no work of its own. */
217 function isBookkeeping(expr: ts.Expression): boolean {
218 if (ts.isPostfixUnaryExpression(expr) || ts.isPrefixUnaryExpression(expr))
219 return ts.isIdentifier(expr.operand) && (expr.operator === ts.SyntaxKind.PlusPlusToken || expr.operator === ts.SyntaxKind.MinusMinusToken);
220 return ts.isCallExpression(expr) && ts.isPropertyAccessExpression(expr.expression) && expr.expression.name.text === "delete" &&
221 ts.isIdentifier(expr.expression.expression) && expr.arguments.length === 1 && ts.isIdentifier(expr.arguments[0]!);
222 }
223
138224 /** Every statement is inert or a `during` (a `try` counts by its block), and at least one is a `during`. */
139225 function blockNamed(statements: readonly ts.Statement[]): boolean {
140226 let any = false;
@@ -158,13 +244,22 @@ function isNamed(node: ts.Node): boolean {
158244 export const isWrapped = (text: string): boolean => callbacks(text, "setInterval")[0]?.named ?? false;
159245
160246 /** Each timer callback the scan reads, with the file and call it came from. */
161 function timers(): { file: string; call: Call; callback: string; named: boolean }[] {
162 const out: { file: string; call: Call; callback: string; named: boolean }[] = [];
247 interface Timer {
248 file: string;
249 line: number;
250 call: Call;
251 callback: string;
252 named: boolean;
253 }
254 let scanned: Timer[] | undefined;
255 /** The scan is read once per run: every test below shares it. */
256 const timers = (): Timer[] => (scanned ??= scan());
257 function scan(): Timer[] {
258 const out: Timer[] = [];
163259 for (const file of sources(SRC)) {
164260 const text = readFileSync(join(ROOT, file), "utf8");
165261 if (!/\bset(Interval|Timeout)\(/.test(text)) continue;
166 const calls: Call[] = NAMED.has(file) ? ["setInterval", "setTimeout"] : ["setInterval"];
167 for (const call of calls) for (const { callback, named } of callbacks(text, call)) out.push({ file, call, callback, named });
262 for (const call of ["setInterval", "setTimeout"] as const) for (const c of callbacks(text, call)) out.push({ file, call, ...c });
168263 }
169264 return out;
170265 }
@@ -192,6 +287,9 @@ describe("background timers run inside during", () => {
192287 expect(named(`() => { if (x) f(); else return; during("a", g); }`)).toBe(false);
193288 expect(named(`() => { if (!stopped) during("a", f); }`)).toBe(true);
194289 expect(named(`tick`)).toBe(false);
290 expect(named(`() => { timers.delete(timer); if (!stopped) void during("a", f); }`)).toBe(true);
291 expect(named(`() => { looks++; busy = busy.then(() => during("a", f)); }`)).toBe(true);
292 expect(named(`() => { timers.delete(f()); during("a", g); }`)).toBe(false);
195293 });
196294
197295 test("a setInterval inside a template string is page text, not a timer", () => {
@@ -201,15 +299,49 @@ describe("background timers run inside during", () => {
201299 test("every server-side timer callback is wrapped in during or allowed by name", () => {
202300 const unnamed = timers()
203301 .filter((t) => !t.named && !ALLOWED.some((a) => matches(a, t)))
204 .map((t) => `${t.file}: ${t.call}(${t.callback.slice(0, 60)}`);
302 .map((t) => `${t.file}:${t.line}: ${t.call}(${t.callback.slice(0, 60)}`);
205303 expect(unnamed).toEqual([]);
206304 });
207305
306 test("the tty idle check, the cell probe deadline and root kill, and the app restart each run inside during (#11167)", () => {
307 const all = timers();
308 const wrapped = (file: string, name: string) =>
309 all.filter((t) => t.file === file && t.named && t.callback.includes(`during("${name}"`)).length;
310 expect(wrapped("src/apps/tty_registry.ts", "app-tty-idle")).toBe(1);
311 expect(wrapped("src/apps/contain.ts", "cell-probe-deadline")).toBe(1);
312 expect(wrapped("src/apps/contain.ts", "cell-kill-root")).toBe(1);
313 expect(wrapped("src/apps/runtime.ts", "apps-restart")).toBe(1);
314 });
315
316 test("setTimeout is read in every server file, not only the ones #11117 named (#11167)", () => {
317 const all = timers();
318 const wrapped = (file: string, name: string) =>
319 all.filter((t) => t.file === file && t.call === "setTimeout" && t.named && t.callback.includes(`during("${name}"`)).length;
320 expect(wrapped("src/core/route_timing.ts", "route-timing-flush")).toBe(1);
321 expect(wrapped("src/core/usage_count.ts", "usage-count-flush")).toBe(1);
322 expect(wrapped("src/http/access_log.ts", "access-flush")).toBe(1);
323 expect(wrapped("src/apps/egress_refused.ts", "egress-refused-flush")).toBe(1);
324 expect(wrapped("src/nightly/output.ts", "nightly-output-flush")).toBe(1);
325 expect(wrapped("src/web/studio/home_cache.ts", "home-refresh")).toBe(1);
326 expect(wrapped("src/web/studio/home_last.ts", "home-last")).toBe(1);
327 expect(wrapped("src/spawn/usage.ts", "disk-walk")).toBe(1);
328 expect(wrapped("src/snapshot/bucket.ts", "bucket-watch")).toBe(1);
329 expect(wrapped("src/crew/routines.ts", "routines")).toBe(1);
330 expect(wrapped("src/crew/early.ts", "early-watch")).toBe(1);
331 expect(all.filter((t) => t.file === "src/release/prove.ts" && t.callback.includes(`during("release-prove"`)).length).toBe(0);
332 expect(readFileSync(join(ROOT, "src/release/prove.ts"), "utf8")).toContain(`schedule(() => void during("release-prove", tick)`);
333 });
334
335 test("a failure names the file and line", () => {
336 expect(callbacks("\n\nsetInterval(tick, 5);", "setInterval")[0]?.line).toBe(3);
337 });
338
208339 test("each allowed entry names exactly one timer, and says why", () => {
209340 const all = timers();
210341 for (const a of ALLOWED) {
211342 expect(a.why.length).toBeGreaterThan(10);
212 expect({ ...a, found: all.filter((t) => matches(a, t)).length }).toEqual({ ...a, found: 1 });
343 const listed = ALLOWED.filter((b) => b.file === a.file && b.call === a.call && b.starts === a.starts).length;
344 expect({ ...a, found: all.filter((t) => matches(a, t)).length }).toEqual({ ...a, found: listed });
213345 }
214346 });
215347 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs