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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11167-the-tty-registry-the-cell-watcher-and-th → main 4 commits fork
17 files
changed +283
−103
src/apps/contain.ts +19 −11
| @@ -403,10 +403,14 @@ async function tryOnce(extra: readonly string[] = []): Promise<Verdict> { | ||
| 403 | 403 | ], |
| 404 | 404 | { stdin: "ignore", stdout: "ignore", stderr: "pipe" }, |
| 405 | 405 | ); |
| 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 | ); | |
| 410 | 414 | const [text, code] = await Promise.all([ |
| 411 | 415 | new Response(proc.stderr).text(), |
| 412 | 416 | proc.exited, |
| @@ -952,13 +956,17 @@ export function killTree(root: number): void { | ||
| 952 | 956 | // already gone |
| 953 | 957 | } |
| 954 | 958 | } |
| 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(); | |
| 962 | 970 | } |
| 963 | 971 | |
| 964 | 972 | /** Send `signal` to every process under `root`, not `root` (bubblewrap) itself. */ |
src/apps/egress_refused.ts +2 −1
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | * which the proxy asks on every connection. |
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | import { during } from "../perf/jobs.ts"; | |
| 14 | 15 | import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; |
| 15 | 16 | import { isIP } from "node:net"; |
| 16 | 17 | import { join } from "node:path"; |
| @@ -119,7 +120,7 @@ function save(dataDir: string, repoId: string, now: boolean): void { | ||
| 119 | 120 | b.dirty = true; |
| 120 | 121 | if (now) return flushBook(dataDir, repoId); |
| 121 | 122 | 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); | |
| 123 | 124 | b.timer.unref?.(); |
| 124 | 125 | } |
| 125 | 126 |
src/apps/runtime.ts +16 −12
| @@ -1763,18 +1763,22 @@ export class AppsRuntime { | ||
| 1763 | 1763 | } |
| 1764 | 1764 | const delay = RESTART_BACKOFF_MS[state.restarts.length] ?? 30_000; |
| 1765 | 1765 | 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 | ); | |
| 1778 | 1782 | }) |
| 1779 | 1783 | .catch(() => {}); |
| 1780 | 1784 | } |
src/apps/tty_registry.ts +5 −3
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | 12 | import type { App } from "../app.ts"; |
| 13 | import { during } from "../perf/jobs.ts"; | |
| 13 | 14 | |
| 14 | 15 | export interface AppTtyEntry { |
| 15 | 16 | readonly id: string; |
| @@ -76,9 +77,10 @@ export function openAppTty(app: App, open: OpenAppTty): AppTtyHandle { | ||
| 76 | 77 | hangup: open.hangup, |
| 77 | 78 | ended: false, |
| 78 | 79 | 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 | }), | |
| 82 | 84 | Math.max(10, Math.min(open.idleMs(), open.checkMs ?? CHECK_MS)), |
| 83 | 85 | ), |
| 84 | 86 | }; |
src/core/route_timing.ts +2 −1
| @@ -18,6 +18,7 @@ | ||
| 18 | 18 | * with what strangers type. Not the forge's health: that is `kernel/vitals`. |
| 19 | 19 | */ |
| 20 | 20 | |
| 21 | import { during } from "../perf/jobs.ts"; | |
| 21 | 22 | import type { Database } from "bun:sqlite"; |
| 22 | 23 | import { withoutWaiting } from "../kernel/db.ts"; |
| 23 | 24 | import { quietly } from "./write_clock.ts"; |
| @@ -108,7 +109,7 @@ export function createRouteTiming( | ||
| 108 | 109 | clear(); |
| 109 | 110 | timer = setTimeout(() => { |
| 110 | 111 | try { |
| 111 | flush(); | |
| 112 | during("route-timing-flush", flush); | |
| 112 | 113 | } catch { |
| 113 | 114 | // flush put the records back and rearmed the retry. |
| 114 | 115 | } |
src/core/usage_count.ts +2 −1
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | * caller on the request path must wrap it in try/catch. |
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | import { during } from "../perf/jobs.ts"; | |
| 16 | 17 | import type { Database } from "bun:sqlite"; |
| 17 | 18 | import { withoutWaiting } from "../kernel/db.ts"; |
| 18 | 19 | import { quietly } from "./write_clock.ts"; |
| @@ -105,7 +106,7 @@ export function createUsageCounter( | ||
| 105 | 106 | clear(); |
| 106 | 107 | timer = setTimeout(() => { |
| 107 | 108 | try { |
| 108 | flush(); | |
| 109 | during("usage-count-flush", flush); | |
| 109 | 110 | } catch { |
| 110 | 111 | // flush already put the counts back and rearmed the retry. |
| 111 | 112 | } |
src/crew/early.ts +2 −1
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | * its head and fills it in (`fillPull`); it never opens a second. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { during } from "../perf/jobs.ts"; | |
| 13 | 14 | import { openPull } from "../actions/pulls.ts"; |
| 14 | 15 | import { editIssue, addComment, setIssueState } from "../actions/issues.ts"; |
| 15 | 16 | import type { App } from "../app.ts"; |
| @@ -163,7 +164,7 @@ export function watchForPull(input: EarlyInput): EarlyPull { | ||
| 163 | 164 | () => { |
| 164 | 165 | looks++; |
| 165 | 166 | busy = busy |
| 166 | .then(tick) | |
| 167 | .then(() => during("early-watch", tick)) | |
| 167 | 168 | .catch(() => undefined) |
| 168 | 169 | .then(schedule); |
| 169 | 170 | }, |
src/crew/routines.ts +2 −1
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * What a routine says is deterministic (`routine_report.ts`): no model, no spend. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { during } from "../perf/jobs.ts"; | |
| 10 | 11 | import type { App } from "../app.ts"; |
| 11 | 12 | import { appendTurn, getConversation, listConversations, newConversation, renameConversation, type Conversation } from "../core/conversations.ts"; |
| 12 | 13 | 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 { | ||
| 78 | 79 | const loop = (wait: number): void => { |
| 79 | 80 | timer = setTimeout(() => { |
| 80 | 81 | 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)); | |
| 82 | 83 | }, wait); |
| 83 | 84 | timer.unref(); |
| 84 | 85 | }; |
src/http/access_log.ts +2 −1
| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | * the `access` MCP tool). |
| 18 | 18 | */ |
| 19 | 19 | |
| 20 | import { during } from "../perf/jobs.ts"; | |
| 20 | 21 | import { |
| 21 | 22 | appendFileSync, |
| 22 | 23 | mkdirSync, |
| @@ -270,7 +271,7 @@ export function recordAccess( | ||
| 270 | 271 | for (const q of pending.values()) count += q.lines.length; |
| 271 | 272 | if (count >= FLUSH_LINES) flushAccess(at); |
| 272 | 273 | else if (timer === undefined) { |
| 273 | timer = setTimeout(() => flushAccess(), FLUSH_MS); | |
| 274 | timer = setTimeout(() => during("access-flush", flushAccess), FLUSH_MS); | |
| 274 | 275 | timer.unref(); |
| 275 | 276 | } |
| 276 | 277 | } catch { |
src/nightly/output.ts +2 −1
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | * the issues themselves are the truth. Plain data. |
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | import { during } from "../perf/jobs.ts"; | |
| 16 | 17 | import { closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs"; |
| 17 | 18 | import { appendFile } from "node:fs/promises"; |
| 18 | 19 | import { join } from "node:path"; |
| @@ -60,7 +61,7 @@ export function startOutput(dataDir: string, name: string): Output { | ||
| 60 | 61 | waiting += text; |
| 61 | 62 | if (waiting.length >= FLUSH_BYTES) void flush(); |
| 62 | 63 | else if (timer === undefined) { |
| 63 | timer = setTimeout(() => void flush(), FLUSH_MS); | |
| 64 | timer = setTimeout(() => void during("nightly-output-flush", flush), FLUSH_MS); | |
| 64 | 65 | timer.unref?.(); |
| 65 | 66 | } |
| 66 | 67 | }; |
src/release/prove.ts +2 −1
| @@ -26,6 +26,7 @@ import { smoke, type Failure, type Heavy } from "./smoke.ts"; | ||
| 26 | 26 | import { UNSTAMPED } from "../kernel/version.ts"; |
| 27 | 27 | import { ownProbeHeaders } from "../kernel/probe.ts"; |
| 28 | 28 | import type { Log } from "../kernel/log.ts"; |
| 29 | import { during } from "../perf/jobs.ts"; | |
| 29 | 30 | |
| 30 | 31 | /** How long the public URL has to answer as the new version. */ |
| 31 | 32 | export const GIVE_UP_MS = 10 * 60_000; |
| @@ -250,7 +251,7 @@ export function startProving( | ||
| 250 | 251 | // True from a proof's start until it settles: a proof is never started over one still running. |
| 251 | 252 | let running = false; |
| 252 | 253 | const next = (): void => { |
| 253 | if (!stopped) cancel = schedule(() => void tick(), everyMs); | |
| 254 | if (!stopped) cancel = schedule(() => void during("release-prove", tick), everyMs); | |
| 254 | 255 | }; |
| 255 | 256 | const tick = async (): Promise<void> => { |
| 256 | 257 | if (running) { |
src/snapshot/bucket.ts +2 −1
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | * without touching the network. Plain data in, plain data out. |
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | import { during } from "../perf/jobs.ts"; | |
| 16 | 17 | import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; |
| 17 | 18 | import { join } from "node:path"; |
| 18 | 19 | import type { DrillResult } from "./drill.ts"; |
| @@ -361,7 +362,7 @@ export function watchBucket( | ||
| 361 | 362 | }; |
| 362 | 363 | const next = (ms: number): void => { |
| 363 | 364 | 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); | |
| 365 | 366 | }; |
| 366 | 367 | next(Math.min(firstMs, everyMs)); |
| 367 | 368 | return { |
src/spawn/usage.ts +12 −7
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * figures of its own and reads "shared". Plain data in, plain data out. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { during } from "../perf/jobs.ts"; | |
| 8 | 9 | import { readFileSync } from "node:fs"; |
| 9 | 10 | import { join } from "node:path"; |
| 10 | 11 | import { diskBytes, cellCounters } from "../apps/contain.ts"; |
| @@ -41,13 +42,17 @@ function diskOf(dir: string, now: number): number | undefined { | ||
| 41 | 42 | const kept = disks.get(dir); |
| 42 | 43 | if ((kept === undefined || now - kept.at >= DISK_FRESH_MS) && !walking.has(dir)) { |
| 43 | 44 | 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(); | |
| 51 | 56 | } |
| 52 | 57 | return kept?.bytes; |
| 53 | 58 | } |
src/web/studio/home_cache.ts +3 −2
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | * again. The waiting line stays on the write clock: the asks count must be |
| 15 | 15 | * exact. |
| 16 | 16 | */ |
| 17 | import { during } from "../../perf/jobs.ts"; | |
| 17 | 18 | import type { Database } from "bun:sqlite"; |
| 18 | 19 | import type { Answer } from "../../boards/query.ts"; |
| 19 | 20 | import { writeStamp } from "../../core/write_clock.ts"; |
| @@ -112,7 +113,7 @@ function servedStale<V>( | ||
| 112 | 113 | if (!keys.has(key)) { |
| 113 | 114 | const held = keys; |
| 114 | 115 | held.add(key); |
| 115 | setTimeout(() => { | |
| 116 | setTimeout(() => during("home-refresh", () => { | |
| 116 | 117 | try { |
| 117 | 118 | if (!db.inTransaction) cache.put(key, TTL, refresh()); |
| 118 | 119 | } catch (error) { |
| @@ -121,7 +122,7 @@ function servedStale<V>( | ||
| 121 | 122 | } finally { |
| 122 | 123 | held.delete(key); |
| 123 | 124 | } |
| 124 | }, 0); | |
| 125 | }), 0); | |
| 125 | 126 | } |
| 126 | 127 | return kept.value; |
| 127 | 128 | } |
src/web/studio/home_last.ts +14 −11
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | * The variant is built by `homeVariant` from the few query parameters home |
| 12 | 12 | * reads, validated, so the keys a client can make are bounded. |
| 13 | 13 | */ |
| 14 | import { during } from "../../perf/jobs.ts"; | |
| 14 | 15 | import type { Database } from "bun:sqlite"; |
| 15 | 16 | import { notePhase } from "../../core/route_timing.ts"; |
| 16 | 17 | import { forgetAll, recall, remember } from "../../core/region_memory.ts"; |
| @@ -78,17 +79,19 @@ export async function homeRegion( | ||
| 78 | 79 | drawing.set(key, Number.MAX_SAFE_INTEGER); |
| 79 | 80 | // Read when the redraw is decided, not when the timer fires: a write that lands in between must leave the copy stale, not current. |
| 80 | 81 | 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 | ); | |
| 92 | 95 | } |
| 93 | 96 | notePhase(request, name, performance.now() - started, true); |
| 94 | 97 | return wrap(had.html, !current); |
test/job-slices.test.ts +16
| @@ -243,6 +243,22 @@ const UNTIMED: Record<string, string> = { | ||
| 243 | 243 | "roll-probe": "cluster only (FORGE_RELEASE_KEEPER=on): one HTTP probe", |
| 244 | 244 | "apps-sweep": "AppsRuntime's sweep over running app processes; the fixture has no apps", |
| 245 | 245 | "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", | |
| 246 | 262 | "egress-idle": "a closure in the egress proxy over its live connections; needs the proxy's unix socket", |
| 247 | 263 | "git-readers": "closes idle git cat-file readers: a map walk over child processes the fixture has none of", |
| 248 | 264 | "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 @@ | ||
| 6 | 6 | * The scan reads src/ — not the browser files under src/web, src/web/vendor |
| 7 | 7 | * or the terminal UI in src/tui — as syntax, so a timer written inside a |
| 8 | 8 | * 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). | |
| 19 | 15 | */ |
| 20 | 16 | |
| 21 | 17 | import { describe, expect, test } from "bun:test"; |
| @@ -31,25 +27,6 @@ const SKIPPED_DIRS = new Set(["src/tui", "src/web/vendor"]); | ||
| 31 | 27 | const isBrowserFile = (path: string): boolean => |
| 32 | 28 | path.startsWith("src/web/") && /(^|[-_/])client\.ts$/.test(path); |
| 33 | 29 | |
| 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 | ||
| 53 | 30 | type Call = "setInterval" | "setTimeout"; |
| 54 | 31 | |
| 55 | 32 | interface Allowed { |
| @@ -67,17 +44,113 @@ interface Allowed { | ||
| 67 | 44 | */ |
| 68 | 45 | const PER_CONNECTION = "one per open connection: wrapped, a burst of them evicts the real jobs from the 256-span ring"; |
| 69 | 46 | |
| 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 | */ | |
| 70 | 62 | 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 | ]), | |
| 81 | 154 | ]; |
| 82 | 155 | |
| 83 | 156 | function sources(dir: string, out: string[] = []): string[] { |
| @@ -92,14 +165,18 @@ function sources(dir: string, out: string[] = []): string[] { | ||
| 92 | 165 | } |
| 93 | 166 | |
| 94 | 167 | /** 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 }[] { | |
| 96 | 169 | 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 }[] = []; | |
| 98 | 171 | const visit = (node: ts.Node): void => { |
| 99 | 172 | if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === call) { |
| 100 | 173 | const first = node.arguments[0]; |
| 101 | 174 | 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 | }); | |
| 103 | 180 | } |
| 104 | 181 | ts.forEachChild(node, visit); |
| 105 | 182 | }; |
| @@ -126,6 +203,7 @@ function isDuring(expr: ts.Expression): boolean { | ||
| 126 | 203 | |
| 127 | 204 | /** A statement that does no work of its own: a guard that returns, or a flag set to a plain value. */ |
| 128 | 205 | function isInert(st: ts.Statement): boolean { |
| 206 | if (ts.isExpressionStatement(st) && isBookkeeping(st.expression)) return true; | |
| 129 | 207 | if (ts.isIfStatement(st)) |
| 130 | 208 | return st.elseStatement === undefined && |
| 131 | 209 | (ts.isReturnStatement(st.thenStatement) && st.thenStatement.expression === undefined || |
| @@ -135,6 +213,14 @@ function isInert(st: ts.Statement): boolean { | ||
| 135 | 213 | return operatorToken.kind === ts.SyntaxKind.EqualsToken && (ts.isLiteralExpression(right) || right.kind === ts.SyntaxKind.TrueKeyword || right.kind === ts.SyntaxKind.FalseKeyword || ts.isIdentifier(right)); |
| 136 | 214 | } |
| 137 | 215 | |
| 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 | ||
| 138 | 224 | /** Every statement is inert or a `during` (a `try` counts by its block), and at least one is a `during`. */ |
| 139 | 225 | function blockNamed(statements: readonly ts.Statement[]): boolean { |
| 140 | 226 | let any = false; |
| @@ -158,13 +244,22 @@ function isNamed(node: ts.Node): boolean { | ||
| 158 | 244 | export const isWrapped = (text: string): boolean => callbacks(text, "setInterval")[0]?.named ?? false; |
| 159 | 245 | |
| 160 | 246 | /** 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[] = []; | |
| 163 | 259 | for (const file of sources(SRC)) { |
| 164 | 260 | const text = readFileSync(join(ROOT, file), "utf8"); |
| 165 | 261 | 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 }); | |
| 168 | 263 | } |
| 169 | 264 | return out; |
| 170 | 265 | } |
| @@ -192,6 +287,9 @@ describe("background timers run inside during", () => { | ||
| 192 | 287 | expect(named(`() => { if (x) f(); else return; during("a", g); }`)).toBe(false); |
| 193 | 288 | expect(named(`() => { if (!stopped) during("a", f); }`)).toBe(true); |
| 194 | 289 | 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); | |
| 195 | 293 | }); |
| 196 | 294 | |
| 197 | 295 | test("a setInterval inside a template string is page text, not a timer", () => { |
| @@ -201,15 +299,49 @@ describe("background timers run inside during", () => { | ||
| 201 | 299 | test("every server-side timer callback is wrapped in during or allowed by name", () => { |
| 202 | 300 | const unnamed = timers() |
| 203 | 301 | .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)}`); | |
| 205 | 303 | expect(unnamed).toEqual([]); |
| 206 | 304 | }); |
| 207 | 305 | |
| 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 | ||
| 208 | 339 | test("each allowed entry names exactly one timer, and says why", () => { |
| 209 | 340 | const all = timers(); |
| 210 | 341 | for (const a of ALLOWED) { |
| 211 | 342 | 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 }); | |
| 213 | 345 | } |
| 214 | 346 | }); |
| 215 | 347 | }); |