Public: anyone can read this; the crew can change it.
Every background timer on the forge runs inside `during`, so no loop stall is blamed on `unknown` #11127
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11117-every-background-timer-on-the-forge-runs → main 6 commits fork 3 threads · 3 unresolved
14 files
changed +247
−19
src/apps/contain.ts +2 −1
| @@ -48,6 +48,7 @@ import { | ||
| 48 | 48 | type CgroupHost, |
| 49 | 49 | type IoMax, |
| 50 | 50 | } from "./cgroup.ts"; |
| 51 | import { during } from "../perf/jobs.ts"; | |
| 51 | 52 | |
| 52 | 53 | /** `ceiling`: a declaration was cut to the machine's maximum; `declared`: forge.json's own; `default`: neither. */ |
| 53 | 54 | export type CeilingFrom = "env" | "pod" | "default"; |
| @@ -1047,7 +1048,7 @@ export function watchTree( | ||
| 1047 | 1048 | cap, |
| 1048 | 1049 | }; |
| 1049 | 1050 | watched.add(entry); |
| 1050 | timer ??= setInterval(tick, WATCH.tickMs); | |
| 1051 | timer ??= setInterval(() => during("cell-watch", tick), WATCH.tickMs); | |
| 1051 | 1052 | return () => { |
| 1052 | 1053 | watched.delete(entry); |
| 1053 | 1054 | if (watched.size === 0 && timer !== undefined) { |
src/apps/egress.ts +3 −2
| @@ -21,6 +21,7 @@ import { lookup } from "node:dns/promises"; | ||
| 21 | 21 | import { isIP } from "node:net"; |
| 22 | 22 | import type { Socket } from "bun"; |
| 23 | 23 | import { neverGrantable } from "../core/egress_grants.ts"; |
| 24 | import { during } from "../perf/jobs.ts"; | |
| 24 | 25 | |
| 25 | 26 | export interface EgressPolicy { |
| 26 | 27 | /** Host names from `forge.json` `egress`: exact, or `*.example.com`. */ |
| @@ -791,7 +792,7 @@ export function startEgress( | ||
| 791 | 792 | // that stopped sending) is cut, so it cannot hold an upstream open. |
| 792 | 793 | const idleMs = policy.idleMs ?? IDLE_MS; |
| 793 | 794 | const sweep = setInterval( |
| 794 | () => { | |
| 795 | () => during("egress-idle", () => { | |
| 795 | 796 | const now = Date.now(); |
| 796 | 797 | for (const client of live) |
| 797 | 798 | if (now - client.data.last > idleMs) { |
| @@ -800,7 +801,7 @@ export function startEgress( | ||
| 800 | 801 | client.terminate(); |
| 801 | 802 | live.delete(client); |
| 802 | 803 | } |
| 803 | }, | |
| 804 | }), | |
| 804 | 805 | Math.max(50, Math.min(5_000, idleMs / 2)), |
| 805 | 806 | ); |
| 806 | 807 | sweep.unref(); |
src/apps/presence.ts +2 −1
| @@ -20,6 +20,7 @@ import { hostname } from "node:os"; | ||
| 20 | 20 | import { join } from "node:path"; |
| 21 | 21 | import { startWarming } from "../http/health.ts"; |
| 22 | 22 | import type { Log } from "../kernel/log.ts"; |
| 23 | import { during } from "../perf/jobs.ts"; | |
| 23 | 24 | |
| 24 | 25 | export const BEAT_MS = 5_000; |
| 25 | 26 | /** Three beats missed: the forge that wrote it is gone. */ |
| @@ -115,7 +116,7 @@ export function takePresence(dataDir: string, beatMs = BEAT_MS): Presence { | ||
| 115 | 116 | } |
| 116 | 117 | }; |
| 117 | 118 | write(); |
| 118 | let timer: ReturnType<typeof setInterval> | undefined = setInterval(write, beatMs); | |
| 119 | let timer: ReturnType<typeof setInterval> | undefined = setInterval(() => during("presence", write), beatMs); | |
| 119 | 120 | timer.unref?.(); |
| 120 | 121 | return { |
| 121 | 122 | others: (at = Date.now()) => beatsOf(dataDir, self.id).some((b) => alive(b, at, self.host)), |
src/apps/runtime.ts +2 −1
| @@ -121,6 +121,7 @@ import { | ||
| 121 | 121 | type StartError, |
| 122 | 122 | type StartInput, |
| 123 | 123 | } from "./process.ts"; |
| 124 | import { during } from "../perf/jobs.ts"; | |
| 124 | 125 | |
| 125 | 126 | const log = createLog("apps"); |
| 126 | 127 | |
| @@ -780,7 +781,7 @@ export class AppsRuntime { | ||
| 780 | 781 | this.pruneDirs(known); |
| 781 | 782 | await this.sampleStorage(); |
| 782 | 783 | this.sweeper = setInterval( |
| 783 | () => void this.sweep(), | |
| 784 | () => void during("apps-sweep", () => this.sweep()), | |
| 784 | 785 | this.options.sweepEveryMs ?? SWEEP_EVERY_MS, |
| 785 | 786 | ); |
| 786 | 787 | } |
src/git/read.ts +2 −1
| @@ -9,6 +9,7 @@ import { defect } from "../kernel/error.ts"; | ||
| 9 | 9 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 10 | 10 | import { gitEnv, mediaGate, runGit, runGitRaw, type GitFailure } from "./env.ts"; |
| 11 | 11 | import { isSafeOid, isSafeRef } from "./guard.ts"; |
| 12 | import { during } from "../perf/jobs.ts"; | |
| 12 | 13 | |
| 13 | 14 | export type ObjectType = "blob" | "tree" | "commit" | "tag"; |
| 14 | 15 | |
| @@ -127,7 +128,7 @@ export function readerFor(dir: string): RepoReader { | ||
| 127 | 128 | const reader = new RepoReader(dir); |
| 128 | 129 | pool.set(dir, { reader, usedAt: Date.now() }); |
| 129 | 130 | if (sweeper === undefined) { |
| 130 | sweeper = setInterval(() => sweepReaders(), READER_IDLE_MS); | |
| 131 | sweeper = setInterval(() => during("git-readers", () => sweepReaders()), READER_IDLE_MS); | |
| 131 | 132 | // The pool must never be the reason a process refuses to exit. |
| 132 | 133 | sweeper.unref?.(); |
| 133 | 134 | } |
src/perf/github_traffic.ts +3 −2
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | |
| 12 | 12 | import type { Database } from "bun:sqlite"; |
| 13 | 13 | import { quietly } from "../core/write_clock.ts"; |
| 14 | import { during } from "./jobs.ts"; | |
| 14 | 15 | |
| 15 | 16 | const API = "https://api.github.com"; |
| 16 | 17 | const REPO = /^(?!\.+\/)[A-Za-z0-9_.-]{1,100}\/(?!\.+$)[A-Za-z0-9_.-]{1,100}$/; |
| @@ -149,8 +150,8 @@ export function startGithubPull( | ||
| 149 | 150 | if (!pullDue(db, config, Date.now())) return; |
| 150 | 151 | pullGithub(db, config, fetcher).catch((error) => log.warn("could not pull GitHub traffic", { error: String(error) })); |
| 151 | 152 | }; |
| 152 | const first = setTimeout(run, 30_000); | |
| 153 | const tick = setInterval(run, CHECK_MS); | |
| 153 | const first = setTimeout(() => during("github-traffic", run), 30_000); | |
| 154 | const tick = setInterval(() => during("github-traffic", run), CHECK_MS); | |
| 154 | 155 | first.unref(); |
| 155 | 156 | tick.unref(); |
| 156 | 157 | return { |
src/perf/reads.ts +2 −1
| @@ -16,6 +16,7 @@ import type { Database } from "bun:sqlite"; | ||
| 16 | 16 | import { runGit } from "../git/env.ts"; |
| 17 | 17 | import { withoutWaiting } from "../kernel/db.ts"; |
| 18 | 18 | import { quietly } from "../core/write_clock.ts"; |
| 19 | import { during } from "./jobs.ts"; | |
| 19 | 20 | |
| 20 | 21 | const HOUR_MS = 3_600_000; |
| 21 | 22 | const MAX_KEYS = 2_000; |
| @@ -189,7 +190,7 @@ export function createReadWriter( | ||
| 189 | 190 | log.warn("could not write read history", { error: String(error) }); |
| 190 | 191 | } |
| 191 | 192 | }; |
| 192 | const tick = setInterval(flush, flushMs); | |
| 193 | const tick = setInterval(() => during("reads", flush), flushMs); | |
| 193 | 194 | tick.unref(); |
| 194 | 195 | return { |
| 195 | 196 | flush, |
src/perf/traffic_store.ts +3 −2
| @@ -31,6 +31,7 @@ import { | ||
| 31 | 31 | type TrafficTops, |
| 32 | 32 | type TrafficTotals, |
| 33 | 33 | } from "./traffic.ts"; |
| 34 | import { during } from "./jobs.ts"; | |
| 34 | 35 | |
| 35 | 36 | const MINUTE_MS = 60_000; |
| 36 | 37 | const HOUR_MINUTES = 60; |
| @@ -141,14 +142,14 @@ export function createTrafficWriter( | ||
| 141 | 142 | trimmedHour = hour; |
| 142 | 143 | ledger.markWritten(snaps.map((s) => s.minute)); |
| 143 | 144 | }; |
| 144 | const tick = setInterval(() => { | |
| 145 | const tick = setInterval(() => during("traffic", () => { | |
| 145 | 146 | try { |
| 146 | 147 | flush(); |
| 147 | 148 | } catch (error) { |
| 148 | 149 | // The minutes stay in the ledger; the next tick writes them. |
| 149 | 150 | log.warn("could not write traffic history", { error: String(error) }); |
| 150 | 151 | } |
| 151 | }, flushMs); | |
| 152 | }), flushMs); | |
| 152 | 153 | tick.unref(); |
| 153 | 154 | return { |
| 154 | 155 | flush, |
src/perf/views_store.ts +2 −1
| @@ -16,6 +16,7 @@ import { withoutWaiting } from "../kernel/db.ts"; | ||
| 16 | 16 | import { dayOf, saltFor } from "../web/measure.ts"; |
| 17 | 17 | import { forgetVisits } from "./views_report.ts"; |
| 18 | 18 | import { isScraperAgent, type Origin } from "./sources.ts"; |
| 19 | import { during } from "./jobs.ts"; | |
| 19 | 20 | |
| 20 | 21 | export const BUFFER_MAX = 5_000; |
| 21 | 22 | export const DAY_MAX = 50_000; |
| @@ -130,7 +131,7 @@ export function createViewWriter( | ||
| 130 | 131 | log.warn("could not write page views", { error: String(error) }); |
| 131 | 132 | } |
| 132 | 133 | }; |
| 133 | const tick = setInterval(run, flushMs); | |
| 134 | const tick = setInterval(() => during("views", run), flushMs); | |
| 134 | 135 | tick.unref(); |
| 135 | 136 | return { |
| 136 | 137 | stop() { |
src/runs/lease.ts +2 −1
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | |
| 13 | 13 | import type { Database } from "bun:sqlite"; |
| 14 | 14 | import { heartbeatRuns } from "../core/runs.ts"; |
| 15 | import { during } from "../perf/jobs.ts"; | |
| 15 | 16 | |
| 16 | 17 | /** How often a held lease is renewed. */ |
| 17 | 18 | export const HEARTBEAT_MS = 15_000; |
| @@ -46,7 +47,7 @@ export function holdLease(db: Database, runId: string): () => void { | ||
| 46 | 47 | ids.add(runId); |
| 47 | 48 | heartbeatRuns(db, [runId]); |
| 48 | 49 | if (timer === undefined) { |
| 49 | timer = setInterval(renewAll, HEARTBEAT_MS); | |
| 50 | timer = setInterval(() => during("lease-renew", renewAll), HEARTBEAT_MS); | |
| 50 | 51 | timer.unref(); |
| 51 | 52 | } |
| 52 | 53 | return () => { |
src/snapshot/snapshot.ts +4 −3
| @@ -39,6 +39,7 @@ import { countRows, type DrillCounts } from "./counts.ts"; | ||
| 39 | 39 | import { pack, partPath, PART_BYTES, type PackedFile } from "./parts.ts"; |
| 40 | 40 | import type { Store } from "./store.ts"; |
| 41 | 41 | import { unwrapKey, wrapKey, WRAPPED_KEY } from "./wrap.ts"; |
| 42 | import { during } from "../perf/jobs.ts"; | |
| 42 | 43 | |
| 43 | 44 | const log = createLog("snapshot"); |
| 44 | 45 | |
| @@ -291,7 +292,7 @@ export async function takeSnapshot( | ||
| 291 | 292 | elapsedMs: Date.now() - started, |
| 292 | 293 | }); |
| 293 | 294 | }; |
| 294 | const ticker = setInterval(say, everyMs); | |
| 295 | const ticker = setInterval(() => during("snapshot-report", say), everyMs); | |
| 295 | 296 | const pending = new Set<Promise<void>>(); |
| 296 | 297 | const before = forge.resume ? await readJournal(store, prefix) : new Map<string, Landed>(); |
| 297 | 298 | const journal = new Map<string, Landed>(); |
| @@ -308,9 +309,9 @@ export async function takeSnapshot( | ||
| 308 | 309 | }; |
| 309 | 310 | // A release kills the process without running a `finally`: what landed must |
| 310 | 311 | // already be written, so a changed journal goes out on a clock too. |
| 311 | const journalTicker = setInterval(() => { | |
| 312 | const journalTicker = setInterval(() => during("snapshot-journal", () => { | |
| 312 | 313 | if (unwritten) writeJournal(); |
| 313 | }, forge.journalEveryMs ?? JOURNAL_MS); | |
| 314 | }), forge.journalEveryMs ?? JOURNAL_MS); | |
| 314 | 315 | const remember = (path: string, l: Landed): void => { |
| 315 | 316 | journal.set(path, l); |
| 316 | 317 | unwritten = true; |
src/spawn/host.ts +3 −2
| @@ -33,6 +33,7 @@ import { awakeRefusal, idleAfterMs, WakeRate } from "./sleep.ts"; | ||
| 33 | 33 | import { wakingResponse } from "../web/pages/waking.ts"; |
| 34 | 34 | import { takeDoor } from "../http/strangers.ts"; |
| 35 | 35 | import { vouched } from "./vouch.ts"; |
| 36 | import { during } from "../perf/jobs.ts"; | |
| 36 | 37 | import { readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; |
| 37 | 38 | import { join } from "node:path"; |
| 38 | 39 | |
| @@ -233,7 +234,7 @@ const floorWatchers = new WeakMap<App, ReturnType<typeof setInterval>>(); | ||
| 233 | 234 | |
| 234 | 235 | function keepWatchingFloor(parent: App): void { |
| 235 | 236 | if (floorWatchers.has(parent)) return; |
| 236 | const timer = setInterval(() => void sleepBelowFloor(parent).catch(() => {}), FLOOR_EVERY_MS); | |
| 237 | const timer = setInterval(() => void during("spawn-floor", () => sleepBelowFloor(parent)).catch(() => {}), FLOOR_EVERY_MS); | |
| 237 | 238 | timer.unref?.(); |
| 238 | 239 | floorWatchers.set(parent, timer); |
| 239 | 240 | } |
| @@ -282,7 +283,7 @@ async function closeOne(parent: App, cache: Map<string, App>, bodyId: string, ch | ||
| 282 | 283 | function keepSweeping(parent: App): void { |
| 283 | 284 | if (sweepers.has(parent)) return; |
| 284 | 285 | const every = Math.max(1000, Math.min(60_000, Math.floor(idleAfterMs() / 4))); |
| 285 | const timer = setInterval(() => void sleepIdleChildren(parent).catch(() => {}), every); | |
| 286 | const timer = setInterval(() => void during("spawn-idle", () => sleepIdleChildren(parent)).catch(() => {}), every); | |
| 286 | 287 | timer.unref?.(); |
| 287 | 288 | sweepers.set(parent, timer); |
| 288 | 289 | } |
src/system/live.ts +2 −1
| @@ -9,6 +9,7 @@ import type { App } from "../app.ts"; | ||
| 9 | 9 | import { cellRoots } from "../apps/contain.ts"; |
| 10 | 10 | import { HOST, readRaw, type Paths, type Raw } from "./proc.ts"; |
| 11 | 11 | import { buildSystem, forAgent, type Roots, type SystemView } from "./view.ts"; |
| 12 | import { during } from "../perf/jobs.ts"; | |
| 12 | 13 | |
| 13 | 14 | const FRESH_MS = 1_000; |
| 14 | 15 | |
| @@ -32,7 +33,7 @@ export type Live = { | ||
| 32 | 33 | export type Schedule = (tick: () => void, ms: number) => () => void; |
| 33 | 34 | |
| 34 | 35 | const realSchedule: Schedule = (tick, ms) => { |
| 35 | const t = setInterval(tick, ms); | |
| 36 | const t = setInterval(() => during("system-live", tick), ms); | |
| 36 | 37 | t.unref?.(); |
| 37 | 38 | return () => clearInterval(t); |
| 38 | 39 | }; |
test/timers-named.test.ts added +215
| @@ -0,0 +1,215 @@ | ||
| 1 | /** | |
| 2 | * Every server-side background timer names its job (#11117): a loop stall is | |
| 3 | * blamed only on work run inside `during` (`src/perf/jobs.ts`), so a timer | |
| 4 | * whose callback is not wrapped reads `unknown` in the stall blame. | |
| 5 | * | |
| 6 | * The scan reads src/ — not the browser files under src/web, src/web/vendor | |
| 7 | * or the terminal UI in src/tui — as syntax, so a timer written inside a | |
| 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. | |
| 19 | */ | |
| 20 | ||
| 21 | import { describe, expect, test } from "bun:test"; | |
| 22 | import { readdirSync, readFileSync, statSync } from "node:fs"; | |
| 23 | import { join, relative } from "node:path"; | |
| 24 | import * as ts from "typescript-api"; | |
| 25 | ||
| 26 | const ROOT = join(import.meta.dir, ".."); | |
| 27 | const SRC = join(ROOT, "src"); | |
| 28 | ||
| 29 | /** Browser and terminal code: no server loop runs there. */ | |
| 30 | const SKIPPED_DIRS = new Set(["src/tui", "src/web/vendor"]); | |
| 31 | const isBrowserFile = (path: string): boolean => | |
| 32 | path.startsWith("src/web/") && /(^|[-_/])client\.ts$/.test(path); | |
| 33 | ||
| 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 | type Call = "setInterval" | "setTimeout"; | |
| 54 | ||
| 55 | interface Allowed { | |
| 56 | readonly file: string; | |
| 57 | readonly call: Call; | |
| 58 | /** How the callback begins, whitespace collapsed: it names one occurrence. */ | |
| 59 | readonly starts: string; | |
| 60 | readonly why: string; | |
| 61 | } | |
| 62 | ||
| 63 | /** | |
| 64 | * The job ring keeps 256 spans (`jobs.ts`). A timer that exists once per open | |
| 65 | * stream, session or fetch scales with load; after a stall every overdue one | |
| 66 | * fires at once and would evict the span of the job that stalled. | |
| 67 | */ | |
| 68 | const PER_CONNECTION = "one per open connection: wrapped, a burst of them evicts the real jobs from the 256-span ring"; | |
| 69 | ||
| 70 | 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}` }, | |
outdated
|
||
| 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" }, | |
| 81 | ]; | |
| 82 | ||
| 83 | function sources(dir: string, out: string[] = []): string[] { | |
| 84 | for (const name of readdirSync(dir)) { | |
| 85 | const path = join(dir, name); | |
| 86 | const rel = relative(ROOT, path); | |
| 87 | if (statSync(path).isDirectory()) { | |
| 88 | if (!SKIPPED_DIRS.has(rel)) sources(path, out); | |
| 89 | } else if (name.endsWith(".ts") && !isBrowserFile(rel)) out.push(rel); | |
| 90 | } | |
| 91 | return out; | |
| 92 | } | |
| 93 | ||
| 94 | /** 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 }[] { | |
| 96 | const source = ts.createSourceFile("x.ts", text, ts.ScriptTarget.Latest, true); | |
| 97 | const found: { callback: string; named: boolean }[] = []; | |
| 98 | const visit = (node: ts.Node): void => { | |
| 99 | if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === call) { | |
| 100 | const first = node.arguments[0]; | |
| 101 | if (first !== undefined) | |
| 102 | found.push({ callback: first.getText(source).replace(/\s+/g, " ").trim(), named: isNamed(first) }); | |
| 103 | } | |
| 104 | ts.forEachChild(node, visit); | |
| 105 | }; | |
| 106 | visit(source); | |
| 107 | return found; | |
| 108 | } | |
| 109 | ||
| 110 | /** An expression whose work is a `during` call: the call itself, or it behind `void`, `&&`, `.catch`, `.finally` or a `.then(() => during(…))`. */ | |
| 111 | function isDuring(expr: ts.Expression): boolean { | |
| 112 | if (ts.isParenthesizedExpression(expr) || ts.isVoidExpression(expr)) return isDuring(expr.expression); | |
| 113 | if (ts.isBinaryExpression(expr)) { | |
| 114 | const op = expr.operatorToken.kind; | |
| 115 | if (op === ts.SyntaxKind.AmpersandAmpersandToken || op === ts.SyntaxKind.BarBarToken || op === ts.SyntaxKind.EqualsToken) | |
| 116 | return isDuring(expr.right); | |
| 117 | return false; | |
| 118 | } | |
| 119 | if (!ts.isCallExpression(expr)) return false; | |
| 120 | const callee = expr.expression; | |
| 121 | if (ts.isIdentifier(callee)) return callee.text === "during"; | |
| 122 | if (!ts.isPropertyAccessExpression(callee)) return false; | |
| 123 | if (isDuring(callee.expression)) return true; | |
| 124 | return callee.name.text === "then" && expr.arguments.length > 0 && expr.arguments.every(isNamed); | |
| 125 | } | |
| 126 | ||
| 127 | /** A statement that does no work of its own: a guard that returns, or a flag set to a plain value. */ | |
| 128 | function isInert(st: ts.Statement): boolean { | |
| 129 | if (ts.isIfStatement(st)) | |
| 130 | return st.elseStatement === undefined && | |
| 131 | (ts.isReturnStatement(st.thenStatement) && st.thenStatement.expression === undefined || | |
| 132 | ts.isBlock(st.thenStatement) && st.thenStatement.statements.every((s) => ts.isReturnStatement(s) && s.expression === undefined)); | |
| 133 | if (!ts.isExpressionStatement(st) || !ts.isBinaryExpression(st.expression)) return false; | |
| 134 | const { operatorToken, right } = st.expression; | |
| 135 | return operatorToken.kind === ts.SyntaxKind.EqualsToken && (ts.isLiteralExpression(right) || right.kind === ts.SyntaxKind.TrueKeyword || right.kind === ts.SyntaxKind.FalseKeyword || ts.isIdentifier(right)); | |
| 136 | } | |
| 137 | ||
| 138 | /** Every statement is inert or a `during` (a `try` counts by its block), and at least one is a `during`. */ | |
| 139 | function blockNamed(statements: readonly ts.Statement[]): boolean { | |
| 140 | let any = false; | |
| 141 | for (const st of statements) { | |
| 142 | if (ts.isExpressionStatement(st) && isDuring(st.expression)) any = true; | |
| 143 | else if (ts.isTryStatement(st) && blockNamed(st.tryBlock.statements)) any = true; | |
| 144 | else if (ts.isIfStatement(st) && st.elseStatement === undefined && blockNamed(ts.isBlock(st.thenStatement) ? st.thenStatement.statements : [st.thenStatement])) any = true; | |
| 145 | else if (!isInert(st)) return false; | |
| 146 | } | |
| 147 | return any; | |
| 148 | } | |
| 149 | ||
| 150 | /** True when a timer callback's work all runs inside `during`, not merely mentions it. */ | |
| 151 | function isNamed(node: ts.Node): boolean { | |
| 152 | if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) | |
| 153 | return ts.isBlock(node.body) ? blockNamed(node.body.statements) : isDuring(node.body); | |
| 154 | return ts.isExpression(node) && isDuring(node); | |
| 155 | } | |
| 156 | ||
| 157 | /** Whether the callback of the one `setInterval` in `text` counts as named. */ | |
| 158 | export const isWrapped = (text: string): boolean => callbacks(text, "setInterval")[0]?.named ?? false; | |
| 159 | ||
| 160 | /** 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 }[] = []; | |
| 163 | for (const file of sources(SRC)) { | |
| 164 | const text = readFileSync(join(ROOT, file), "utf8"); | |
| 165 | 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 }); | |
| 168 | } | |
| 169 | return out; | |
| 170 | } | |
| 171 | ||
| 172 | const matches = (a: Allowed, t: { file: string; call: Call; callback: string }): boolean => | |
| 173 | a.file === t.file && a.call === t.call && t.callback.startsWith(a.starts); | |
| 174 | ||
| 175 | describe("background timers run inside during", () => { | |
| 176 | test("the scan reads each timer's first argument", () => { | |
| 177 | const read = callbacks(`setInterval(tick, 10); setInterval(() => {\n g(1, 2);\n}, 5)`, "setInterval"); | |
| 178 | expect(read.map((c) => c.callback)).toEqual(["tick", "() => { g(1, 2); }"]); | |
| 179 | }); | |
| 180 | ||
| 181 | test("only a callback whose work runs inside during counts as named", () => { | |
| 182 | const named = (cb: string) => isWrapped(`setInterval(${cb}, 10)`); | |
| 183 | expect(named(`() => during("a", f)`)).toBe(true); | |
| 184 | expect(named(`() => void during("a", () => f()).catch(() => {})`)).toBe(true); | |
| 185 | expect(named(`() => !stopped && during("a", f)`)).toBe(true); | |
| 186 | expect(named(`() => { if (stopped) return; running = true; void during("a", f).finally(() => (running = false)); }`)).toBe(true); | |
| 187 | expect(named(`() => { try { during("a", f); } catch (e) { log(e); } }`)).toBe(true); | |
| 188 | expect(named(`() => { queue = queue.then(() => during("a", f)); }`)).toBe(true); | |
| 189 | expect(named(`() => { f(); during("a", g); }`)).toBe(false); | |
| 190 | expect(named(`() => { /* during(x) */ f(); }`)).toBe(false); | |
| 191 | expect(named(`() => f(during)`)).toBe(false); | |
| 192 | expect(named(`() => { if (x) f(); else return; during("a", g); }`)).toBe(false); | |
| 193 | expect(named(`() => { if (!stopped) during("a", f); }`)).toBe(true); | |
| 194 | expect(named(`tick`)).toBe(false); | |
| 195 | }); | |
| 196 | ||
| 197 | test("a setInterval inside a template string is page text, not a timer", () => { | |
| 198 | expect(callbacks("const js = `setInterval(refresh, 1000)`; setInterval(tick, 5);", "setInterval").map((c) => c.callback)).toEqual(["tick"]); | |
| 199 | }); | |
| 200 | ||
| 201 | test("every server-side timer callback is wrapped in during or allowed by name", () => { | |
| 202 | const unnamed = timers() | |
| 203 | .filter((t) => !t.named && !ALLOWED.some((a) => matches(a, t))) | |
| 204 | .map((t) => `${t.file}: ${t.call}(${t.callback.slice(0, 60)}`); | |
| 205 | expect(unnamed).toEqual([]); | |
| 206 | }); | |
| 207 | ||
| 208 | test("each allowed entry names exactly one timer, and says why", () => { | |
| 209 | const all = timers(); | |
| 210 | for (const a of ALLOWED) { | |
| 211 | expect(a.why.length).toBeGreaterThan(10); | |
| 212 | expect({ ...a, found: all.filter((t) => matches(a, t)).length }).toEqual({ ...a, found: 1 }); | |
| 213 | } | |
| 214 | }); | |
| 215 | }); |
A whole-file exemption hides any later server-side
setIntervalin these files. Exempt only the template-string occurrence.