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

P0: 0.7.278 blocked its HTTP loop for minutes right after boot (not /boards); find the startup stall and add an event-loop lag gauge #6961

Merged opened this in answer to

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

Merged

mason/forge:mason/6957-p0-0-7-278-blocked-its-http-loop-for-min → main 2 commits fork 2 threads · 2 unresolved

  • Merged. mason/6957-p0-0-7-278-blocked-its-http-loop-for-min was merged into main as 3089b61.

Review

Sign in to review this change.
12 files changed +583 −11
docs/ops/ROLL.md +31
@@ -234,3 +234,34 @@ in `src/release/`:
234234 version that was ready, kept on the volume under `release/good/`. The forge
235235 that comes up shows a `release:rolled-back` card and pages the admins once.
236236 The next version that reads ready clears it.
237
238 ## A forge beside one still draining (#6957)
239
240 bun:sqlite waits for the write lock on the one thread the forge serves from,
241 up to `busy_timeout` (15 s) a statement. 0.7.278 booted while 0.7.276 still
242 ran its gates and crew runs on the same database. It started its own work
243 beside them, and `/healthz` timed out for minutes behind the old pod's writes.
244 Three things keep that from happening again:
245
246 - **The new forge holds its work** (`src/runs/peer.ts`). Its listener
247 answers at once. Its checks, merge queue, crew and ticks start only once
248 no running run that another process holds has renewed its lease within
249 30 s, or after 720 s (the pod's grace). The log says
250 `another forge still works this database` when the wait starts and
251 `the other forge let go of this database` when it ends.
252 - **Telemetry never waits on the lock** (`withoutWaiting` in
253 `src/kernel/db.ts`). Route timings, usage counts and machine samples try
254 the lock once, keep their rows and try again on the next flush. Every
255 probe is counted, so without this a probe's own count would hold the next
256 probe up.
257 - **The next stall names itself.** `event loop stalled lateMs=…` is a
258 warning whenever the loop wakes more than a second late
259 (`src/kernel/loop_lag.ts`, on from the first line of the boot). `slow
260 statement held the thread ms=… sql=… stack=…` names any statement on the
261 forge's handle that took 200 ms or more, with its callers
262 (`src/kernel/slow_sql.ts`), at most ten lines a minute.
263
264 `test/boot-beside-peer.test.ts` boots `src/main.ts` beside a process that
265 holds the write lock 2.5 s of every 2.65 s and keeps a batch of 8 check
266 leases fresh. Every `/healthz` probe must answer within a second, and the
267 forge's work must start once that process lets go.
src/boards/ring.ts +7 −3
@@ -6,6 +6,7 @@
66 * 24 h / 15 s + 30 d / 5 min = 5,760 + 8,640 rows per target.
77 */
88 import type { Database } from "bun:sqlite";
9 import { withoutWaiting } from "../kernel/db.ts";
910
1011 export const FINE_MS = 15_000;
1112 export const COARSE_MS = 300_000;
@@ -92,7 +93,10 @@ type FineRow = {
9293 lag_ms: number | null; checks: number | null; agents: number | null;
9394 };
9495
95 /** Keep `points` (taken at `now`), roll up the windows that closed, drop what is past its window. One transaction. */
96 /**
97 * Keep `points` (taken at `now`), roll up the windows that closed, drop what is past its window. One transaction,
98 * never waiting on another process's write lock: behind one it throws at once and the sampler's next tick tries again (#6957).
99 */
96100 export function record(db: Database, points: readonly Point[], now: number): void {
97101 const put = db.query(
98102 `INSERT OR REPLACE INTO machine_sample
@@ -100,7 +104,7 @@ export function record(db: Database, points: readonly Point[], now: number): voi
100104 lag_ms, lag_ms_max, checks, agents)
101105 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
102106 );
103 db.transaction(() => {
107 withoutWaiting(db, db.transaction(() => {
104108 for (const p of points) {
105109 const c = p.celsius ?? null;
106110 const lag = p.lagMs ?? null;
@@ -133,5 +137,5 @@ export function record(db: Database, points: readonly Point[], now: number): voi
133137 }
134138 db.query("DELETE FROM machine_sample WHERE step = 15 AND at < ?").run(iso(now - FINE_KEEP_MS));
135139 db.query("DELETE FROM machine_sample WHERE step = 300 AND at < ?").run(iso(now - COARSE_KEEP_MS));
136 })();
140 }));
137141 }
src/core/route_timing.ts +6 −3
@@ -5,7 +5,8 @@
55 * key in one transaction as an upsert that adds to `n` and `sum_us`; it runs
66 * by itself about a second after the first pending record, once 500 keys are
77 * pending, and on `stop()`. The timer is `unref`'d. A failed write loses
8 * nothing: the records go back and the timer retries. Rows older than
8 * nothing: the records go back and the timer retries. A flush never waits on

The header comment now wraps awkwardly and runs past the line width. Re-flow it.

9 * another process's write lock (`withoutWaiting`): it fails and retries. Rows older than
910 * `KEEP_DAYS` are deleted, checked once per hour at flush.
1011 *
1112 * `record` also adds to totals kept in memory per database for the life of
@@ -18,6 +19,7 @@
1819 */
1920
2021 import type { Database } from "bun:sqlite";
22 import { withoutWaiting } from "../kernel/db.ts";
2123
2224 /** Upper edges in ms; bucket `i` holds durations `<= EDGES_MS[i]`, the last is everything slower. */
2325 export const EDGES_MS = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500] as const;
@@ -126,7 +128,8 @@ export function createRouteTiming(
126128 DO UPDATE SET n = n + excluded.n, sum_us = sum_us + excluded.sum_us`,
127129 );
128130 const hourNo = Math.floor(now() / HOUR_MS);
129 db.transaction(() => {
131 // Behind another process's lock this throws at once and the records go back (#6957).
132 withoutWaiting(db, db.transaction(() => {
130133 for (const [key, v] of batch) {
131134 const [h, pattern, b] = key.split(SEP) as [string, string, string];
132135 upsert.run(hourText(Number(h)), pattern, Number(b), v.n, v.us);
@@ -137,7 +140,7 @@ export function createRouteTiming(
137140 );
138141 prunedHour = hourNo;
139142 }
140 })();
143 }));
141144 } catch (err) {
142145 prunedHour = -1;
143146 for (const [key, v] of batch) {
src/core/usage_count.ts +4 −2
@@ -14,6 +14,7 @@
1414 */
1515
1616 import type { Database } from "bun:sqlite";
17 import { withoutWaiting } from "../kernel/db.ts";
1718
1819 export const ROLES = ["anon", "user", "admin", "agent"] as const;
1920 export const SURFACES = ["api", "mcp", "web"] as const;
@@ -123,14 +124,15 @@ export function createUsageCounter(
123124 ON CONFLICT (hour, pattern, method, status, role, surface)
124125 DO UPDATE SET n = n + excluded.n`,
125126 );
126 db.transaction(() => {
127 // Behind another process's lock this throws at once and the counts go back (#6957).
128 withoutWaiting(db, db.transaction(() => {
127129 for (const [key, n] of batch) {
128130 const [hour, pattern, method, status, role, surface] = key.split(
129131 SEP,
130132 ) as [string, string, string, string, string, string];
131133 upsert.run(hour, pattern, method, status, role, surface, n);
132134 }
133 })();
135 }));
134136 } catch (err) {
135137 // Put the counts back so a failed write loses nothing.
136138 for (const [key, n] of batch)
src/kernel/db.ts +17
@@ -171,6 +171,23 @@ export function transact<T>(db: Database, work: () => T): T {
171171 return withRetry(() => tx.immediate());
172172 }
173173
174 /**
175 * A write not worth the thread (#6957): telemetry that keeps its rows and
176 * tries again. `busy_timeout` waits on the one thread every request is
177 * served from, so a write behind another process's lock (a draining forge
178 * on the same data) stops /healthz with it. Here the lock is tried once and
179 * SQLITE_BUSY comes back at once; the handle's own timeout is put back after.
180 */
181 export function withoutWaiting<T>(db: Database, work: () => T): T {
182 const was = db.query<{ timeout: number }, []>("PRAGMA busy_timeout").get()?.timeout ?? BUSY_TIMEOUT_MS;
183 db.exec("PRAGMA busy_timeout = 0");
184 try {
185 return work();
186 } finally {
187 db.exec(`PRAGMA busy_timeout = ${Math.max(0, Math.floor(was))}`);
188 }
189 }
190
174191 export function isBusy(error: unknown): boolean {
175192 if (error === null || typeof error !== "object") return false;
176193 const code = (error as { code?: unknown }).code;
src/kernel/loop_lag.ts +31 −3
@@ -9,6 +9,8 @@
99 * take, in ms, and starts a new window; null when no timer fired in it.
1010 */
1111
12 import type { Log } from "./log.ts";
13
1214 export type LoopLag = { take(): number | null; stop(): void };
1315
1416 /** How often the timer asks to wake, ms: 20 wakes a second, no measurable cost. */
@@ -29,15 +31,21 @@ export function nearestRank(xs: readonly number[], p: number): number | null {
2931 return sorted[rank - 1]!;
3032 }
3133
32 /** Start measuring; `now` is a clock in ms (`performance.now` in production). */
33 export function startLoopLag(everyMs = LAG_EVERY_MS, now: () => number = () => performance.now()): LoopLag {
34 /** Start measuring; `now` is a clock in ms (`performance.now` in production); `onLate` hears every wake's lateness. */
35 export function startLoopLag(
36 everyMs = LAG_EVERY_MS,
37 now: () => number = () => performance.now(),
38 onLate?: (lateMs: number) => void,
39 ): LoopLag {
3440 let samples: number[] = [];
3541 let timer: ReturnType<typeof setTimeout> | undefined;
3642 const arm = (): void => {
3743 const due = now() + everyMs;
3844 timer = setTimeout(() => {
3945 // Early by a fraction of a ms is timer rounding, not a fast loop.
40 if (samples.length < MAX_SAMPLES) samples.push(Math.max(0, now() - due));
46 const late = Math.max(0, now() - due);
47 if (samples.length < MAX_SAMPLES) samples.push(late);
48 onLate?.(late);
4149 arm();
4250 }, everyMs);
4351 timer.unref?.();
@@ -52,3 +60,23 @@ export function startLoopLag(everyMs = LAG_EVERY_MS, now: () => number = () => p
5260 stop: () => clearTimeout(timer),
5361 };
5462 }
63
64 /** A wake this late is a stall worth a warning (#6957): a readiness probe gives up at 1 s. */
65 export const STALL_WARN_MS = 1_000;
66
67 /**
68 * The always-on gauge (#6957): from boot, every wake later than `warnMs` is
69 * one warning saying how late, so a stall that keeps /healthz from answering
70 * names its minute in the log. Returns the stop.
71 */
72 export function watchStalls(
73 log: Pick<Log, "warn">,
74 warnMs = STALL_WARN_MS,
75 everyMs = LAG_EVERY_MS,
76 now: () => number = () => performance.now(),
77 ): () => void {
78 const lag = startLoopLag(everyMs, now, (late) => {
79 if (late >= warnMs) log.warn("event loop stalled", { lateMs: Math.round(late) });
80 });
81 return lag.stop;
82 }
src/kernel/slow_sql.ts added +83
@@ -0,0 +1,83 @@
1 /**
2 * Statements that hold the thread (#6957). bun:sqlite is synchronous: a
3 * statement waiting on another process's write lock (`busy_timeout`, 15 s)
4 * or walking a big table stops every request, `/healthz` too, for as long
5 * as it takes. `timeStatements` wraps one handle so a statement or exec
6 * that takes `overMs` or more is one warning with the first words of its
7 * SQL and the stack that called it: the stall names itself.
8 *
9 * SQL text is the code's own (values are bound, never spliced in), and the
10 * log redacts anything credential-shaped besides. At most `PER_MINUTE`
11 * lines a minute, so a lock held for minutes is a few lines, not thousands.
12 */
13
14 import type { Database } from "bun:sqlite";
15 import type { Log } from "./log.ts";
16
17 /** A statement this slow is a stall a person waits through. */
18 export const SLOW_SQL_MS = 200;
19 const PER_MINUTE = 10;
20 const METHODS = ["run", "get", "all", "values"] as const;
21
22 /** The SQL's first 100 characters on one line. */
23 export function sqlHead(sql: unknown): string {
24 return String(sql).replace(/\s+/g, " ").trim().slice(0, 100);
25 }
26
27 /** The callers above this module, nearest first, on one line. */
28 function callers(): string {
29 return (new Error().stack ?? "")
30 .split("\n")
31 .slice(1)
32 .map((l) => l.trim().replace(/^at /, ""))
33 .filter((l) => l !== "" && !l.includes("slow_sql.ts"))
34 .slice(0, 6)
35 .join(" < ");
36 }
37
38 /** Wrap `db`'s query, prepare, run and exec; `now` is a clock in ms. */
39 export function timeStatements(
40 db: Database,
41 log: Pick<Log, "warn">,
42 overMs = SLOW_SQL_MS,
43 now: () => number = () => performance.now(),
44 ): void {
45 let minute = 0;
46 let told = 0;
47 const report = (sql: unknown, ms: number): void => {
48 const at = Math.floor(Date.now() / 60_000);
49 if (at !== minute) [minute, told] = [at, 0];
50 if (++told > PER_MINUTE) return;
51 log.warn("slow statement held the thread", { ms: Math.round(ms), sql: sqlHead(sql), stack: callers() });
52 };
53 const timed = <A extends unknown[], R>(sql: unknown, fn: (...args: A) => R) =>
54 (...args: A): R => {
55 const start = now();
56 try {
57 return fn(...args);
58 } finally {
59 const ms = now() - start;
60 if (ms >= overMs) report(sql, ms);
61 }
62 };
63 const wrapped = new WeakSet<object>();
64 const wrap = <S extends object>(stmt: S, sql: unknown): S => {
65 if (wrapped.has(stmt)) return stmt;
66 wrapped.add(stmt);
67 const methods = stmt as unknown as Record<string, unknown>;
68 for (const m of METHODS) {
69 const fn = methods[m];
70 if (typeof fn === "function") methods[m] = timed(sql, (fn as (...a: unknown[]) => unknown).bind(stmt));
71 }
72 return stmt;
73 };
74 const handle = db as unknown as Record<string, (...args: unknown[]) => unknown>;
75 for (const name of ["query", "prepare"] as const) {
76 const make = handle[name]!.bind(db);
77 handle[name] = (sql: unknown, ...rest: unknown[]) => wrap(make(sql, ...rest) as object, sql);
78 }
79 for (const name of ["run", "exec"] as const) {
80 const exec = handle[name]!.bind(db);
81 handle[name] = (sql: unknown, ...rest: unknown[]) => timed(sql, exec)(sql, ...rest);
82 }
83 }
src/main.ts +29
@@ -72,6 +72,10 @@ import { bootCounts, recordBoot, recordCleanExit } from "./kernel/boots.ts";
7272 import { census, censusWords, processRows } from "./kernel/census.ts";
7373 import { reapLeftoverBridges } from "./crew/leftovers.ts";
7474 import { crewRoot } from "./crew/workdir.ts";
75 import { createLog } from "./kernel/log.ts";
76 import { watchStalls } from "./kernel/loop_lag.ts";
77 import { timeStatements } from "./kernel/slow_sql.ts";
78 import { waitForPeer } from "./runs/peer.ts";
7579
7680 // `forge hook <name>` re-enters here from a git hook stub. It relays to the
7781 // running forge over its socket and exits — it must never boot a server.
@@ -191,6 +195,11 @@ if (parentProblem !== null) {
191195 process.exit(1);
192196 }
193197
198 // The loop's lateness, from the first line of the boot: a stall over a
199 // second is a warning that names its minute (kernel/loop_lag.ts, #6957),
200 // draining included.
201 watchStalls(createLog("loop"));
202
194203 const app = createApp({
195204 dataDir,
196205 // A new forge starts with signup = invite (FORGE_SIGNUP below still wins).
@@ -272,6 +281,8 @@ const app = createApp({
272281 }),
273282 });
274283
284 // A statement that holds the thread past 200 ms names its caller (#6957).
285 timeStatements(app.db, createLog("sql"));
275286 loadFlags(app.db);
276287 loadTrustedHosts(app.db);
277288 // FORGE_SIGNUP (checked in configProblem) sets who may sign up; kept like any change.
@@ -361,6 +372,24 @@ if (apps && presence) warmBehind(app.log, presence, () => apps.startingCount());
361372 void backfillCarried(app.db, app.reposRoot).catch((error) =>
362373 app.log.error("could not backfill what deploys carried", { error: String(error) }),
363374 );
375 // A forge still draining on this database keeps writing for its gates and
376 // runs; starting ours beside it stalls the listener behind its write lock
377 // (#6957). The listener answers meanwhile; a signal now stops what is up.
378 const leaveEarly = (): void => {
379 void Promise.resolve(apps?.stop())
380 .catch(() => {})
381 .finally(() => {
382 presence?.stop();
383 ssh?.stop();
384 servers.stop();
385 app.close();
386 process.exit(0);
387 });
388 };
389 const earlySignals = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
390 for (const signal of earlySignals) process.once(signal, leaveEarly);
391 await waitForPeer(app.db, app.log);
392 for (const signal of earlySignals) process.off(signal, leaveEarly);
364393 // A repository's own check, on every push a pull or the default branch takes.
365394 const checks =
366395 process.env.FORGE_CHECKS === "0"
src/runs/lease.ts +5
@@ -60,3 +60,8 @@ export function holdLease(db: Database, runId: string): () => void {
6060 }
6161 };
6262 }
63
64 /** The runs this process holds the lease of on `db`: whatever else is renewed is another forge's. */
65 export function heldRuns(db: Database): ReadonlySet<string> {
66 return held.get(db) ?? new Set();
67 }
src/runs/peer.ts added +69
@@ -0,0 +1,69 @@
1 /**
2 * Another forge still working this database (#6957). A roll stops the old
3 * pod and starts the new one on the same data, and since #6732 the old one
4 * keeps its gates and crew runs going through its drain: it keeps writing.
5 * bun:sqlite waits for the write lock on the one thread, up to
6 * `busy_timeout` a statement, so a new forge that starts its own gates and
7 * ticks beside it stalls every request behind the old one's writes: 0.7.278
8 * failed readiness for minutes that way.
9 *
10 * The old forge says it is there the way it always has: it renews the leases
11 * of the runs it holds (`lease.ts`). A running run renewed within
12 * `PEER_FRESH_MS` that this process does not hold is that forge's, whatever
13 * its version. `waitForPeer` lets the listener answer and holds the rest of
14 * the boot until no such run is left, or `PEER_WAIT_MAX_MS` has passed (the
15 * platform has stopped the old pod by then).
16 */
17
18 import type { Database } from "bun:sqlite";
19 import type { Log } from "../kernel/log.ts";
20 import { HEARTBEAT_MS, heldRuns } from "./lease.ts";
21
22 /** Two renewals missed: the lease is not being kept. */
23 export const PEER_FRESH_MS = 2 * HEARTBEAT_MS;
24 /** The pod's grace (charts/app, 720 s): an old pod is gone by then. */
25 export const PEER_WAIT_MAX_MS = 720_000;
26 const POLL_MS = 5_000;
27
28 /** Running runs whose lease another process renewed within `PEER_FRESH_MS` of `now`. */
29 export function peerRuns(db: Database, now = Date.now()): number {
30 const here = heldRuns(db);
31 const rows = db
32 .query<{ id: string }, [string]>("SELECT id FROM run WHERE state = 'running' AND heartbeat_at >= ?1")
33 .all(new Date(now - PEER_FRESH_MS).toISOString());
34 return rows.filter((r) => !here.has(r.id)).length;
35 }
36
37 export interface PeerWait {
38 readonly pollMs?: number;
39 readonly maxMs?: number;
40 readonly now?: () => number;
41 readonly sleep?: (ms: number) => Promise<unknown>;
42 }
43
44 /** How the wait ended: nobody was there, they finished, or the ceiling passed. */
45 export type PeerOutcome = "alone" | "gone" | "waited out";
46
47 /** Resolve once no other forge's run is live on `db`; said in the log when it waits at all. */
48 export async function waitForPeer(db: Database, log: Log, opts: PeerWait = {}): Promise<PeerOutcome> {
49 const now = opts.now ?? Date.now;
50 const sleep = opts.sleep ?? Bun.sleep;
51 const maxMs = opts.maxMs ?? PEER_WAIT_MAX_MS;
52 const started = now();
53 let live = peerRuns(db, started);
54 if (live === 0) return "alone";
55 log.warn("another forge still works this database: this one serves, and starts its own work once it lets go", {
56 runs: live,
57 maxSeconds: Math.round(maxMs / 1000),
58 });
59 while (live > 0) {
60 if (now() - started >= maxMs) {
61 log.warn("another forge still works this database; starting beside it", { runs: live });
62 return "waited out";
63 }
64 await sleep(opts.pollMs ?? POLL_MS);
65 live = peerRuns(db, now());
66 }
67 log.info("the other forge let go of this database; starting work", { waitedSeconds: Math.round((now() - started) / 1000) });
68 return "gone";
69 }
test/boot-beside-peer.test.ts added +116
@@ -0,0 +1,116 @@
1 /**
2 * A forge booting beside one still draining on the same database answers
3 * /healthz within a second throughout (#6957). 0.7.278 came up while
4 * 0.7.276 finished a batch of gates on the shared data, started its own
5 * work, and waited on the old pod's write lock on the one thread it serves
6 * from: readiness timed out for minutes.
7 *
8 * The peer here is that old pod in miniature: a process that keeps the
9 * leases of a batch of 8 running checks fresh and holds the write lock
10 * for most of every second, then finishes and lets go.
11 */
12
13 import { expect, test } from "bun:test";
14 import { mkdtemp, rm, writeFile } from "node:fs/promises";
15 import { tmpdir } from "node:os";
16 import { join } from "node:path";
17 import { createApp } from "../src/app.ts";
18
19 const PEER_MS = 20_000;
20 const HOLD_MS = 2_500;
21 const GAP_MS = 150;
22
23 /** Holds the write lock `HOLD_MS` of every `HOLD_MS + GAP_MS`, renewing the 8 leases each time; then lands them and exits. */
24 const peerScript = `
25 const { Database } = require("bun:sqlite");
26 const db = new Database(process.argv[2]);
27 db.run("PRAGMA busy_timeout = 15000");
28 const ids = ${JSON.stringify(Array.from({ length: 8 }, (_, n) => `gate-${n}`))};
29 const until = Date.now() + ${PEER_MS};
30 const spin = (ms) => { const end = Date.now() + ms; while (Date.now() < end) {} };
31 console.log("peer up");
32 const tick = () => {
33 db.run("BEGIN IMMEDIATE");
34 db.run("UPDATE run SET heartbeat_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id IN (" + ids.map((i) => "'" + i + "'").join(",") + ")");
35 spin(${HOLD_MS});
36 db.run("COMMIT");
37 if (Date.now() < until) return void setTimeout(tick, ${GAP_MS});
38 db.run("UPDATE run SET state = 'ok' WHERE state = 'running'");
39 console.log("peer done");
40 process.exit(0);
41 };
42 tick();
43 `;
44
45 test("healthz answers within 1 s throughout a boot beside a draining forge's batch of 8, and work starts once it lets go", async () => {
46 const dataDir = await mkdtemp(join(tmpdir(), "forge-bootpeer-"));
47 const seed = createApp({ dataDir, port: 0 });
48 seed.db.run("PRAGMA foreign_keys = OFF");
49 const now = new Date().toISOString();
50 for (let n = 0; n < 8; n++)
51 seed.db
52 .query("INSERT INTO run (id, repo_id, kind, state, started_at, heartbeat_at) VALUES (?1, 'r', 'check', 'running', ?2, ?2)")
53 .run(`gate-${n}`, now);
54 seed.close();
55 const script = join(dataDir, "..", `${dataDir.split("/").pop()}-peer.js`);
56 await writeFile(script, peerScript);
57
58 const peer = Bun.spawn(["bun", script, join(dataDir, "forge.db")], { stdout: "pipe", stderr: "inherit" });
59 await peer.stdout.getReader().read();
60
61 const web = 20000 + Math.floor(Math.random() * 20000);
62 const env: Record<string, string> = {
63 ...(process.env as Record<string, string>),
64 FORGE_DATA: dataDir,
65 PORT: String(web),
66 FORGE_SSH_PORT: "0",
67 FORGE_APPS: "0",
68 };
69 delete env.FORGE_URL;
70 const forge = Bun.spawn(["bun", "run", "src/main.ts"], { env, stdout: "pipe", stderr: "pipe" });
71 let out = "";
72 const drain = async (stream: ReadableStream<Uint8Array>) => {
73 for await (const chunk of stream) out += new TextDecoder().decode(chunk);
74 };
75 void drain(forge.stdout);
76 void drain(forge.stderr);
77 try {
78 // Up: the first answer, however long the boot behind the lock took.
79 let up = false;
80 for (let i = 0; i < 300 && !up; i++) {
81 try {
82 up = (await fetch(`http://127.0.0.1:${web}/healthz`, { signal: AbortSignal.timeout(1_000) })).ok;
83 } catch {
84 await Bun.sleep(100);
85 }
86 }
87 expect(up).toBe(true);
88 // Then every probe answers within a second while the peer holds on, and after.
89 const slow: number[] = [];
90 const end = performance.now() + PEER_MS;
91 while (performance.now() < end) {
92 const t = performance.now();
93 try {
94 const res = await fetch(`http://127.0.0.1:${web}/healthz`, { signal: AbortSignal.timeout(1_000) });
95 if (!res.ok) slow.push(-res.status);
96 } catch {
97 slow.push(Math.round(performance.now() - t));
98 }
99 await Bun.sleep(200);
100 }
101 // A probe that timed out: the forge's own log names what held it.
102 if (slow.length > 0) console.log(out);
103 expect(slow).toEqual([]);
104 expect(out).toContain("another forge still works this database");
105 await peer.exited;
106 for (let i = 0; i < 100 && !out.includes("the other forge let go of this database"); i++) await Bun.sleep(100);
107 expect(out).toContain("the other forge let go of this database");
108 expect(out).not.toContain("event loop stalled");
109 } finally {
110 peer.kill();
111 forge.kill();
112 await Promise.all([peer.exited, forge.exited]);

Wall-clock assertion that no stall line appears is the flake risk on a busy check machine. Prefer a tolerance, or assert only the probe latency.

113 await rm(dataDir, { recursive: true, force: true });
114 await rm(script, { force: true });
115 }
116 }, 60_000);
test/loop-stall.test.ts added +185
@@ -0,0 +1,185 @@
1 /**
2 * The next stall names itself (#6957): the always-on gauge warns when the
3 * loop wakes over a second late, a statement that holds the thread past
4 * 200 ms is logged with the stack that called it, and a forge booting
5 * beside another one's live runs holds its own work until they are gone.
6 */
7
8 import { describe, expect, test } from "bun:test";
9 import { Database } from "bun:sqlite";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp } from "../src/app.ts";
14 import { watchStalls } from "../src/kernel/loop_lag.ts";
15 import { sqlHead, timeStatements } from "../src/kernel/slow_sql.ts";
16 import { holdLease } from "../src/runs/lease.ts";
17 import { PEER_FRESH_MS, peerRuns, waitForPeer } from "../src/runs/peer.ts";
18 import type { LogFields } from "../src/kernel/log.ts";
19
20 type Line = { message: string; fields?: LogFields };
21 const recorder = () => {
22 const lines: Line[] = [];
23 const push = (message: string, fields?: LogFields) => void lines.push({ message, ...(fields ? { fields } : {}) });
24 return { lines, log: { debug: push, info: push, warn: push, error: push } };
25 };
26
27 describe("the stall gauge", () => {
28 test("a wake later than the floor is one warning saying how late; an on-time loop says nothing", async () => {
29 const { lines, log } = recorder();
30 const stop = watchStalls(log, 150, 10);
31 try {
32 await Bun.sleep(100);
33 expect(lines).toEqual([]);
34 const until = performance.now() + 300;
35 while (performance.now() < until) {
36 /* hold the thread */
37 }
38 await Bun.sleep(50);
39 } finally {
40 stop();
41 }
42 expect(lines).toHaveLength(1);
43 expect(lines[0]!.message).toBe("event loop stalled");
44 expect(Number(lines[0]!.fields?.lateMs)).toBeGreaterThanOrEqual(150);
45 });
46 });
47
48 describe("slow statements", () => {
49 test("a statement over the floor is logged with its SQL and the caller's stack; a fast one is not", () => {
50 const db = new Database(":memory:");
51 const { lines, log } = recorder();
52 // A clock that says every call took 250 ms, until it is told otherwise.
53 let step = 250;
54 let t = 0;
55 timeStatements(db, log, 200, () => (t += step));
56 db.run("CREATE TABLE t (x INTEGER)");
57 db.query("INSERT INTO t VALUES (?1)").run(1);
58 db.prepare("SELECT x\n FROM t").all();
59 db.transaction(() => db.query("SELECT 1").get()).immediate();
60 step = 0;
61 db.query("SELECT x FROM t").all();
62 db.exec("SELECT 1");
63 const sqls = lines.map((l) => l.fields?.sql);
64 expect(lines.every((l) => l.message === "slow statement held the thread")).toBe(true);
65 expect(sqls).toContain("CREATE TABLE t (x INTEGER)");
66 expect(sqls).toContain("INSERT INTO t VALUES (?1)");
67 expect(sqls).toContain("SELECT x FROM t");
68 expect(sqls).toContain("BEGIN IMMEDIATE");
69 expect(lines.every((l) => String(l.fields?.stack).includes("loop-stall.test.ts"))).toBe(true);
70 expect(lines.some((l) => String(l.fields?.stack).includes("slow_sql.ts"))).toBe(false);
71 expect(lines).toHaveLength(6);
72 db.close();
73 });
74
75 test("a lock held by another process is the slow statement, named", async () => {
76 const dir = await mkdtemp(join(tmpdir(), "forge-slowsql-"));
77 const file = join(dir, "db.sqlite");
78 const db = new Database(file, { create: true });
79 db.run("PRAGMA journal_mode = WAL");
80 db.run("PRAGMA busy_timeout = 5000");
81 db.run("CREATE TABLE t (x INTEGER)");
82 const holder = Bun.spawn(
83 ["bun", "-e", `const { Database } = require("bun:sqlite"); const d = new Database(${JSON.stringify(file)}); d.run("BEGIN IMMEDIATE"); console.log("held"); setTimeout(() => { d.run("COMMIT"); process.exit(0); }, 400);`],
84 { stdout: "pipe" },
85 );
86 try {
87 const reader = holder.stdout.getReader();
88 await reader.read();
89 const { lines, log } = recorder();
90 timeStatements(db, log);
91 db.query("INSERT INTO t VALUES (1)").run();
92 expect(lines).toHaveLength(1);
93 expect(lines[0]!.fields?.sql).toBe("INSERT INTO t VALUES (1)");
94 expect(Number(lines[0]!.fields?.ms)).toBeGreaterThanOrEqual(200);
95 } finally {
96 await holder.exited;
97 db.close();
98 await rm(dir, { recursive: true, force: true });
99 }
100 });
101
102 test("the SQL is one line, at most 100 characters", () => {
103 expect(sqlHead("SELECT a,\n b FROM c")).toBe("SELECT a, b FROM c");
104 expect(sqlHead(`SELECT ${"x".repeat(200)}`)).toHaveLength(100);
105 });
106 });
107
108 describe("another forge's live runs", () => {
109 const withApp = async (body: (db: Database) => Promise<void> | void) => {
110 const dataDir = await mkdtemp(join(tmpdir(), "forge-peer-"));
111 const app = createApp({ dataDir, port: 0 });
112 app.db.run("PRAGMA foreign_keys = OFF");
113 try {
114 await body(app.db);
115 } finally {
116 app.close();
117 await rm(dataDir, { recursive: true, force: true });
118 }
119 };
120 const run = (db: Database, id: string, state: string, heartbeatAt: string | null) =>
121 db.query("INSERT INTO run (id, repo_id, kind, state, heartbeat_at) VALUES (?1, 'r', 'check', ?2, ?3)").run(id, state, heartbeatAt);
122
123 test("a running run renewed lately counts; a lapsed one, a finished one and one this process holds do not", () =>
124 withApp((db) => {
125 const now = Date.parse("2026-10-27T11:37:30.000Z");
126 const at = (ago: number) => new Date(now - ago).toISOString();
127 run(db, "fresh", "running", at(1_000));
128 run(db, "edge", "running", at(PEER_FRESH_MS - 1));
129 run(db, "lapsed", "running", at(PEER_FRESH_MS + 1));
130 run(db, "never", "running", null);
131 run(db, "done", "ok", at(1_000));
132 run(db, "mine", "running", at(1_000));
133 expect(peerRuns(db, now)).toBe(3);
134 const release = holdLease(db, "mine");
135 // Holding renews it now, which is after `now`: still fresh, and still not a peer's.
136 expect(peerRuns(db, now)).toBe(2);
137 release();
138 }));
139
140 test("nobody there: no wait, no line", () =>
141 withApp(async (db) => {
142 const { lines, log } = recorder();
143 expect(await waitForPeer(db, log, { sleep: async () => {} })).toBe("alone");
144 expect(lines).toEqual([]);
145 }));
146
147 test("the wait says so once, polls until the peer's runs finish, and says it let go", () =>
148 withApp(async (db) => {
149 run(db, "a", "running", new Date().toISOString());
150 run(db, "b", "running", new Date().toISOString());
151 const { lines, log } = recorder();
152 let polls = 0;
153 const outcome = await waitForPeer(db, log, {
154 sleep: async () => {
155 polls++;
156 if (polls === 1) db.run("UPDATE run SET state = 'ok' WHERE id = 'a'");
157 if (polls === 2) db.run("UPDATE run SET state = 'ok' WHERE id = 'b'");
158 },
159 });
160 expect(outcome).toBe("gone");
161 expect(polls).toBe(2);
162 expect(lines.map((l) => l.message)).toEqual([
163 "another forge still works this database: this one serves, and starts its own work once it lets go",
164 "the other forge let go of this database; starting work",
165 ]);
166 expect(lines[0]!.fields?.runs).toBe(2);
167 }));
168
169 test("a peer that never lets go is waited out at the ceiling", () =>
170 withApp(async (db) => {
171 const { lines, log } = recorder();
172 let clock = Date.now();
173 run(db, "a", "running", new Date(clock).toISOString());
174 const outcome = await waitForPeer(db, log, {
175 maxMs: 10_000,
176 now: () => clock,
177 sleep: async (ms) => {
178 clock += ms;
179 db.run("UPDATE run SET heartbeat_at = ?1 WHERE id = 'a'", [new Date(clock).toISOString()]);
180 },
181 });
182 expect(outcome).toBe("waited out");
183 expect(lines.at(-1)?.message).toBe("another forge still works this database; starting beside it");
184 }));
185 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs