Public: anyone can read this; the crew can change it.
A check that reaches its CPU limit is slowed, not failed: no check is stopped for using the cores it was given #11575
torque/forge:torque/11573-a-check-that-reaches-its-cpu-limit-is-sl → main 5 commits fork
14 files
changed +346
−26
docs/REFERENCE.md +1 −1
| @@ -214,7 +214,7 @@ Which repository is the forge's own source can also be set by an admin, without | ||
| 214 | 214 | |
| 215 | 215 | **Keeping a domain drill**: `POST /api/v1/drills` (a site admin person; an agent is refused with 403, and a session needs a same-origin request) takes `{domain, seen:{health, board}, backSeconds}`: a domain key from `docs/DOMAINS.md`, what the drill read from health and the board (text, at most 8000 characters each), and whole seconds until the forge was well again (0 to 31536000). The body is capped at 32000 bytes (413). It answers 201 `{id, domain, backSeconds, green, why}`: the verdict is judged against the domain's own words and kept whether green or red. An unknown domain, bad `seen` or bad `backSeconds` is 422. Kept drills are read back on **Settings → Backups**, in its **Failure domains** card (`docs/SNAPSHOT.md`). To run a drill by hand, `FORGE_TOKEN=… bun scripts/drills/run.ts <domain> --forge <url>` (plain `http` is refused unless the host is loopback or you pass `--insecure`) prompts for the fault, watches health and the board until the forge is back, and posts this for you (`docs/DOMAINS.md`). |
| 216 | 216 | |
| 217 | **Re-checking a shared red**: `POST /api/v1/repos/:owner/:name/checks/recheck` (a repository admin, `admin` token scope; anyone else gets 404, and a session needs a same-origin request) takes `{test}`: the name of one failing test exactly as the check log's failing names show it, 1 to 300 characters (otherwise 400). It asks again, through the ordinary check queue, for every open pull whose latest check on its current head failed on that test, and answers `{queued: [pull numbers]}`. At most 50 pulls are queued per call; when more were red, the answer adds `more` (how many were left) and a `message`, and a second call takes the rest. A pull waiting for a writer's approval, or whose head declares no check, is not in `queued`. | |
| 217 | **Re-checking a shared red**: `POST /api/v1/repos/:owner/:name/checks/recheck` (a repository admin, `admin` token scope; anyone else gets 404, and a session needs a same-origin request) takes `{test}`: the name of one failing test exactly as the check log's failing names show it, 1 to 300 characters (otherwise 400). It asks again, through the ordinary check queue, for every open pull whose latest check on its current head failed on that test, and answers `{queued: [pull numbers]}`. At most 50 pulls are queued per call; when more were red, the answer adds `more` (how many were left) and a `message`, and a second call takes the rest. A pull waiting for a writer's approval, or whose head declares no check, is not in `queued`. Sent `{cause: "cpu"}` in place of `{test}`, it asks again for every open pull whose latest check on its head went red only because the CPU watch stopped it (`stopped: used … cores …`, #11573); any other `cause` is a 400 `invalid_cause`. A check the CPU watch stops is itself queued again once, as infrastructure, before it can go red: the watch stops a cell only past 1.5× its cores held for two minutes, and never where the kernel holds its cores (`cpu.max`). | |
| 218 | 218 | |
| 219 | 219 | If your shell already has `FORGE_URL` set — a crew workspace sets it to the |
| 220 | 220 | forge you work for — this forge takes it as its own canonical address and |
docs/features/flow-clog.md +1 −1
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | The owner's `/flow` opens with these rows, no box (`src/web/pages/flow-clog.ts`, mounted by one line in `flow.ts`; read by `src/flow/clog_view.ts`): |
| 4 | 4 | |
| 5 | - **One sentence** from `clogSays`: the standing clog's reasons, approved pulls that conflict, the file most open pulls touch, and the dollars waiting to land. | |
| 5 | - **One sentence** from `clogSays`: the open pulls red only because the CPU watch stopped their check, first, when they are at least half the red and more than one (#11573); the standing clog's reasons, approved pulls that conflict, the file most open pulls touch, and the dollars waiting to land. | |
| 6 | 6 | - **One test, many pulls** from `sharedRed`: a test failing in the latest check of three or more open pulls is listed once, with each pull linked. |
| 7 | 7 | - **Pulls on the same files** from `conflictPairs`: at most 20 pairs, each linked to both pulls. |
| 8 | 8 |
src/apps/contain.ts +51 −3
| @@ -857,6 +857,35 @@ function readMountinfo(): string { | ||
| 857 | 857 | |
| 858 | 858 | /** How often the process table is read, and over how long CPU is averaged. */ |
| 859 | 859 | export const WATCH = { tickMs: 1_000, cpuWindowMs: 30_000, diskEveryTicks: 15 }; |
| 860 | /** | |
| 861 | * When the watch stops a cell for CPU, where the kernel does not hold its | |
| 862 | * cores (#11573): its windowed average past `factor` × its limit, held for | |
| 863 | * `sustainMs`. A cell using the cores it was given — 16.1 of 16, the | |
| 864 | * sampling's own jitter — is never over; a runaway at 1.5× for two minutes is. | |
| 865 | */ | |
| 866 | export const CPU_STOP = { factor: 1.5, sustainMs: 120_000 }; | |
| 867 | ||
| 868 | /** Whether `cores` (averaged over the window) is past the stop line for a limit of `cpus`: strictly over `factor` × `cpus`. */ | |
| 869 | export function cpuOver(cores: number, cpus: number): boolean { | |
| 870 | return cores > cpus * CPU_STOP.factor; | |
| 871 | } | |
| 872 | ||
| 873 | /** | |
| 874 | * One tick of the CPU watch: when the cell has been over the stop line since | |
| 875 | * (`since`, ms, or undefined when it is not over now) and whether that has | |
| 876 | * held for `CPU_STOP.sustainMs`. Plain data in and out, so a test can run | |
| 877 | * three minutes of ticks without a process. | |
| 878 | */ | |
| 879 | export function cpuTick( | |
| 880 | since: number | undefined, | |
| 881 | cores: number, | |
| 882 | cpus: number, | |
| 883 | now: number, | |
| 884 | ): { since: number | undefined; stop: boolean } { | |
| 885 | if (!cpuOver(cores, cpus)) return { since: undefined, stop: false }; | |
| 886 | const from = since ?? now; | |
| 887 | return { since: from, stop: now - from >= CPU_STOP.sustainMs }; | |
| 888 | } | |
| 860 | 889 | /** A cell's process limit: the watch's every tick, and the kernel's `pids.max` where pids is delegated. */ |
| 861 | 890 | export const PROCESSES_MAX = 512; |
| 862 | 891 | const CLK_TCK = 100; |
| @@ -875,6 +904,8 @@ interface Watched { | ||
| 875 | 904 | readonly cap: MemoryCap; |
| 876 | 905 | /** The tree's CPU ticks so far and resident KiB, at the last tick. */ |
| 877 | 906 | last?: { ticks: number; kib: number }; |
| 907 | /** Since when (ms) its windowed cores have been past the stop line (`cpuTick`). */ | |
| 908 | cpuOverSince?: number | undefined; | |
| 878 | 909 | } |
| 879 | 910 | |
| 880 | 911 | const watched = new Set<Watched>(); |
| @@ -1149,12 +1180,25 @@ export function cpuBreach( | ||
| 1149 | 1180 | cpus: number, |
| 1150 | 1181 | top: { pid: number; ticks: number; name: string } | undefined, |
| 1151 | 1182 | ): string { |
| 1152 | const why = `used ${cores.toFixed(1)} cores for ${Math.round(seconds)}s, over its limit of ${cpus}`; | |
| 1183 | const why = `used ${cores.toFixed(1)} cores for ${Math.round(seconds)}s, over ${CPU_STOP.factor}× its limit of ${cpus}`; | |
| 1153 | 1184 | if (top === undefined) return why; |
| 1154 | 1185 | const spent = Math.round(top.ticks / CLK_TCK); |
| 1155 | 1186 | return `${why}; the busiest process is ${top.name} (pid ${top.pid}, ${spent}s of CPU)`; |
| 1156 | 1187 | } |
| 1157 | 1188 | |
| 1189 | /** | |
| 1190 | * Whether a step's error is the watch's CPU stop: `runStep`'s "<step> | |
| 1191 | * stopped: it " and `cpuBreach`'s line. Anchored at the start, where only | |
| 1192 | * the runner writes — a step's own output is quoted after "exited with | |
| 1193 | * code", so no pull can print its way into a re-check. A stop for CPU is | |
| 1194 | * the machine's verdict on a sampled average, not the code's: a check | |
| 1195 | * reads it as infrastructure (#11573). | |
| 1196 | */ | |
| 1197 | export function cpuStopped(error: string): boolean { | |
| 1198 | return CPU_STOP_LINE.test(error); | |
| 1199 | } | |
| 1200 | const CPU_STOP_LINE = /^[a-z]+ stopped: it used \d+(?:\.\d+)? cores for \d+s, over /; | |
| 1201 | ||
| 1158 | 1202 | /** A process's command name, printable and short, or "?" once it is gone. */ |
| 1159 | 1203 | function commandName(pid: number): string { |
| 1160 | 1204 | try { |
| @@ -1246,11 +1290,15 @@ function tick(): void { | ||
| 1246 | 1290 | if (oldest !== undefined && now - oldest.at >= WATCH.cpuWindowMs) { |
| 1247 | 1291 | const cores = |
| 1248 | 1292 | (ticks - oldest.ticks) / CLK_TCK / ((now - oldest.at) / 1000); |
| 1249 | if (cores > limits.cpus) { | |
| 1293 | const verdict = cpuTick(entry.cpuOverSince, cores, limits.cpus, now); | |
| 1294 | entry.cpuOverSince = verdict.since; | |
| 1295 | if (verdict.stop) { | |
| 1250 | 1296 | // Read only now, at the stop: no tick pays for naming a process. |
| 1251 | 1297 | const top = busiest(tree, (pid) => rows.get(pid)?.ticks ?? 0); |
| 1298 | // Over since the window that first crossed the line began. | |
| 1299 | const seconds = (now - (verdict.since ?? now) + WATCH.cpuWindowMs) / 1000; | |
| 1252 | 1300 | entry.breach( |
| 1253 | cpuBreach(cores, (now - oldest.at) / 1000, limits.cpus, top === undefined ? undefined : { ...top, name: commandName(top.pid) }), | |
| 1301 | cpuBreach(cores, seconds, limits.cpus, top === undefined ? undefined : { ...top, name: commandName(top.pid) }), | |
| 1254 | 1302 | ); |
| 1255 | 1303 | continue; |
| 1256 | 1304 | } |
src/checks/recheck.ts +35 −5
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Every open pull whose latest check on its current head failed on one |
| 3 | 3 | * named test, asked for again (#6445): once a shared red is fixed on main, |
| 4 | * the pulls it held are handed back to the check queue in one call. | |
| 4 | * the pulls it held are handed back to the check queue in one call. And | |
| 5 | * every open pull red only because the CPU watch stopped it (#11573). | |
| 5 | 6 | */ |
| 6 | 7 | |
| 7 | 8 | import type { App } from "../app.ts"; |
| @@ -40,16 +41,45 @@ export async function recheckNamed( | ||
| 40 | 41 | test: string, |
| 41 | 42 | max = RECHECK_MAX, |
| 42 | 43 | ): Promise<Rechecked> { |
| 43 | const red = listOpenPulls(app.db, repo.id).filter((pull) => { | |
| 44 | return recheckWhere(app, user, repo, (run) => run.checkDetail?.names.includes(test) ?? false, max); | |
| 45 | } | |
| 46 | ||
| 47 | /** | |
| 48 | * A failed check's summary that is the CPU watch's stop, not a test's red | |
| 49 | * (#11573): "check failed: stopped: used 16.3 cores for 30s, over its limit | |
| 50 | * of 16; …" as the pulls of 2026-11-11 read, or a stop since. | |
| 51 | */ | |
| 52 | export function cpuStopSummary(summary: string): boolean { | |
| 53 | return CPU_STOP_SUMMARY_LINE.test(summary); | |
| 54 | } | |
| 55 | const CPU_STOP_SUMMARY_LINE = /^check failed: (?:[a-z]+ )?stopped: (?:it )?used \d+(?:\.\d+)? cores for \d+s, over /; | |
| 56 | ||
| 57 | /** | |
| 58 | * Every open pull whose latest check on its head went red only because the | |
| 59 | * CPU watch stopped it, asked for again (#11573): the pulls a stop at 16.1 | |
| 60 | * of 16 cores failed before the watch learned that is not over. | |
| 61 | */ | |
| 62 | export async function recheckCpuStopped(app: App, user: User, repo: Repo, max = RECHECK_MAX): Promise<Rechecked> { | |
| 63 | return recheckWhere(app, user, repo, (run) => cpuStopSummary(run.summary ?? ""), max); | |
| 64 | } | |
| 65 | ||
| 66 | async function recheckWhere( | |
| 67 | app: App, | |
| 68 | user: User, | |
| 69 | repo: Repo, | |
| 70 | red: (run: Run) => boolean, | |
| 71 | max: number, | |
| 72 | ): Promise<Rechecked> { | |
| 73 | const pulls = listOpenPulls(app.db, repo.id).filter((pull) => { | |
| 44 | 74 | if (pull.headOid === "") return false; |
| 45 | 75 | const run = latestCheck(app, pull); |
| 46 | return run?.state === "failed" && (run.checkDetail?.names.includes(test) ?? false); | |
| 76 | return run?.state === "failed" && red(run); | |
| 47 | 77 | }); |
| 48 | 78 | const queued: number[] = []; |
| 49 | for (const pull of red.slice(0, max)) { | |
| 79 | for (const pull of pulls.slice(0, max)) { | |
| 50 | 80 | // The ordinary request: the check queue's own slots and order apply. |
| 51 | 81 | const asked = await askCheck(app, user, repo, pull); |
| 52 | 82 | if (asked.ok) queued.push(pull.number); |
| 53 | 83 | } |
| 54 | return { queued, left: Math.max(0, red.length - max) }; | |
| 84 | return { queued, left: Math.max(0, pulls.length - max) }; | |
| 55 | 85 | } |
src/checks/service.ts +18 −1
| @@ -43,6 +43,8 @@ import { | ||
| 43 | 43 | VANISHED_SUMMARY, |
| 44 | 44 | infraBefore, |
| 45 | 45 | INFRA_SUMMARY, |
| 46 | cpuStopBefore, | |
| 47 | CPU_STOP_SUMMARY, | |
| 46 | 48 | requeueCheck, |
| 47 | 49 | REQUEUED_SUMMARY, |
| 48 | 50 | sweepAbandonedChecks, |
| @@ -106,6 +108,7 @@ import { | ||
| 106 | 108 | } from "../core/capacity.ts"; |
| 107 | 109 | import { |
| 108 | 110 | cellsAvailable, |
| 111 | cpuStopped, | |
| 109 | 112 | limitWarnings, |
| 110 | 113 | resolveLimits, |
| 111 | 114 | STEP_HOSTS, |
| @@ -862,6 +865,16 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 862 | 865 | return; |
| 863 | 866 | } |
| 864 | 867 | } |
| 868 | // The CPU watch stopped it: the machine's stop, not the pull's red. | |
| 869 | // Checked once more on its own, and its builder hears nothing (#11573). | |
| 870 | if (!outcome.ok && outcome.cpuStop === true && !cpuStopBefore(app.db, claimed)) { | |
| 871 | const moved = requeueCheck(app.db, run.id, undefined, CPU_STOP_SUMMARY); | |
| 872 | if (moved) { | |
| 873 | write(CPU_STOP_SUMMARY); | |
| 874 | announceRun(app, moved.again, run.actorId ?? undefined); | |
| 875 | return; | |
| 876 | } | |
| 877 | } | |
| 865 | 878 | const seconds = Math.round((Date.now() - started) / 1000); |
| 866 | 879 | const done = finishRun(app.db, run.id, { |
| 867 | 880 | state: outcome.ok ? "ok" : "failed", |
| @@ -905,7 +918,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 905 | 918 | signal: AbortSignal, |
| 906 | 919 | ): Promise< |
| 907 | 920 | | { ok: true; proven?: string; passed?: readonly string[] } |
| 908 | | { ok: false; error: string; detail?: CheckDetail | undefined; infrastructure?: boolean; passed?: readonly string[] } | |
| 921 | | { ok: false; error: string; detail?: CheckDetail | undefined; infrastructure?: boolean; cpuStop?: boolean; passed?: readonly string[] } | |
| 909 | 922 | > { |
| 910 | 923 | const bare = await runDir(app.db, app.reposRoot, repo, run.issueId); |
| 911 | 924 | // A pull's check is the fast tier, judged against where the pull left |
| @@ -991,6 +1004,8 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 991 | 1004 | /** A whole verification: the files its log ran to the end green (#9524). */ |
| 992 | 1005 | passed?: readonly string[]; |
| 993 | 1006 | infrastructure?: boolean; |
| 1007 | /** The CPU watch stopped the check step (#11573): queued once more, not red. */ | |
| 1008 | cpuStop?: boolean; | |
| 994 | 1009 | /** Where it stopped red (#5276); none is before any step ran. */ |
| 995 | 1010 | stage?: GateStage; |
| 996 | 1011 | /** The check step was ended by a signal from outside: no verdict (#6722). */ |
| @@ -1351,6 +1366,8 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1351 | 1366 | }); |
| 1352 | 1367 | } |
| 1353 | 1368 | if (!ran.ok && ran.error === JOB_VANISHED) return { ok: false, error: JOB_VANISHED }; |
| 1369 | // Stopped for CPU: no test failed, so nothing is re-run alone or filed. | |
| 1370 | if (!ran.ok && cpuStopped(ran.error)) return { ok: false, error: ran.error, cpuStop: true, stage: checkStage(ran.error) }; | |
| 1354 | 1371 | // A file the gate cleared because it passed alone is still a failure |
| 1355 | 1372 | // to track: the cell cannot reach the forge, so it is filed from here. |
| 1356 | 1373 | try { |
src/contract/openapi/routes/postApiV1ReposOwnerNameChecksRecheck.ts +2 −1
| @@ -3,12 +3,13 @@ import { REPO, type Route } from "../shared.ts"; | ||
| 3 | 3 | export const route: Route = { |
| 4 | 4 | method: "post", |
| 5 | 5 | path: `${REPO}/checks/recheck`, |
| 6 | summary: "Ask again for every open pull whose latest check on its head failed on the test named in the body { test } (admin scope, a repository admin's; at most 50 per call); answers { queued: [numbers] }, with more when some were left", | |
| 6 | summary: "Ask again for every open pull whose latest check on its head failed on the test named in the body { test }, or, with { cause: \"cpu\" }, was stopped by the CPU watch (admin scope, a repository admin's; at most 50 per call); answers { queued: [numbers] }, with more when some were left", | |
| 7 | 7 | auth: true, |
| 8 | 8 | role: "member", |
| 9 | 9 | status: 200, |
| 10 | 10 | reply: { queued: "array" }, |
| 11 | 11 | refusals: { |
| 12 | 12 | invalid_test: "test is a name of 1 to 300 characters (400)", |
| 13 | invalid_cause: 'cause is "cpu", the one cause named (400)', | |
| 13 | 14 | }, |
| 14 | 15 | }; |
src/core/runs.ts +12
| @@ -525,6 +525,18 @@ export const VANISHED_SUMMARY = "interrupted: its cluster job vanished; queued a | ||
| 525 | 525 | /** What a check whose install the machine failed twice says as it is queued again (#5093). */ |
| 526 | 526 | export const INFRA_SUMMARY = "failed: infrastructure — the install failed twice on a transient error; queued again"; |
| 527 | 527 | |
| 528 | /** | |
| 529 | * What a check the CPU watch stopped says as it is queued again (#11573): | |
| 530 | * the machine's stop, not the pull's red, so it is checked once more and | |
| 531 | * its builder hears nothing. | |
| 532 | */ | |
| 533 | export const CPU_STOP_SUMMARY = "failed: infrastructure — stopped for CPU by the watch, not by its code; queued again"; | |
| 534 | ||
| 535 | /** Whether the attempt just before this one was stopped for CPU: it is queued again once. */ | |
| 536 | export function cpuStopBefore(db: Database, run: Run): boolean { | |
| 537 | return cutOffBefore(db, run, CPU_STOP_SUMMARY); | |
| 538 | } | |
| 539 | ||
| 528 | 540 | /** |
| 529 | 541 | * How many times one check may be cut off by its forge going away and be |
| 530 | 542 | * queued again. Past it the check fails once, saying so: a check that kills |
src/flow/clog_view.ts +31 −4
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | * What clogs the lane (#11454), read once for /flow and its terminal screen: one sentence (`clogSays`), a test failing on three or more open | |
| 3 | * pulls shown once (`sharedRed`), and pairs of open pulls touching the same files (`conflictPairs`, at most 20). Read from the runs table and | |
| 4 | * the pull file lists `crew/hot_files.ts` keeps in memory; no git per request. Quiet is an empty sentence and empty lists. | |
| 2 | * What clogs the lane (#11454), read once for /flow and its terminal screen: one sentence (`clogSays`, naming the CPU watch's stops | |
| 3 | * when they are most of the red, #11573), a test failing on three or more open pulls shown once (`sharedRed`), and pairs of open pulls | |
| 4 | * touching the same files (`conflictPairs`, at most 20). Read from the runs table and the pull file lists `crew/hot_files.ts` keeps in | |
| 5 | * memory; no git per request. Quiet is an empty sentence and empty lists. | |
| 5 | 6 | */ |
| 6 | 7 | |
| 7 | 8 | import type { Database } from "bun:sqlite"; |
| @@ -14,6 +15,7 @@ import { conflictedPulls } from "./conflicted.ts"; | ||
| 14 | 15 | import { sharedRed } from "./shared_red.ts"; |
| 15 | 16 | import { readStranded } from "./stranded.ts"; |
| 16 | 17 | import { approvedRows } from "../web/pages/flow-waiting.ts"; |
| 18 | import { cpuStopSummary } from "../checks/recheck.ts"; | |
| 17 | 19 | |
| 18 | 20 | export interface ClogView { |
| 19 | 21 | readonly says: string; |
| @@ -39,6 +41,28 @@ function failingRows(db: Database): { repo: string; number: number; tests: strin | ||
| 39 | 41 | }); |
| 40 | 42 | } |
| 41 | 43 | |
| 44 | /** | |
| 45 | * How many open pulls' latest check failed, and how many of those only | |
| 46 | * because the CPU watch stopped it (#11573): the summaries of the latest | |
| 47 | * failed check of each open pull, read once. | |
| 48 | */ | |
| 49 | export function redCauses(db: Database): { failed: number; cpu: number } { | |
| 50 | const rows = db | |
| 51 | .query<{ summary: string | null }, []>( | |
| 52 | `SELECT u.summary AS summary | |
| 53 | FROM pull p JOIN issue i ON i.id = p.issue_id AND i.state = 'open' | |
| 54 | JOIN run u ON u.id = (SELECT n.id FROM run n WHERE n.issue_id = i.id AND n.kind = 'check' ORDER BY n.id DESC LIMIT 1) | |
| 55 | WHERE p.merged_at IS NULL AND u.state = 'failed'`, | |
| 56 | ) | |
| 57 | .all(); | |
| 58 | return { failed: rows.length, cpu: rows.filter((r) => cpuStopSummary(r.summary ?? "")).length }; | |
| 59 | } | |
| 60 | ||
| 61 | /** The CPU stop is the lane's main failure: at least half its red pulls, and more than one. */ | |
| 62 | export function cpuIsMain(causes: { failed: number; cpu: number }): boolean { | |
| 63 | return causes.cpu >= 2 && causes.cpu * 2 >= causes.failed; | |
| 64 | } | |
| 65 | ||
| 42 | 66 | export function readClogView(db: Database, now = new Date()): ClogView { |
| 43 | 67 | const failing = failingRows(db); |
| 44 | 68 | const key = (r: Ref): string => `${r.repo}#${r.number}`; |
| @@ -74,13 +98,16 @@ export function readClogView(db: Database, now = new Date()): ClogView { | ||
| 74 | 98 | const conflicted = conflictedPulls(db).filter((c) => approved.has(key(c))).length; |
| 75 | 99 | const hot = warmFiles().sort((a, b) => b.pulls - a.pulls || a.path.localeCompare(b.path))[0]; |
| 76 | 100 | const standing = readClog(db, now).standing; |
| 101 | const causes = redCauses(db); | |
| 102 | const cpu = cpuIsMain(causes) ? causes.cpu : 0; | |
| 77 | 103 | const says = clogSays({ |
| 78 | 104 | reasons: standing?.reasons ?? [], |
| 79 | 105 | conflictedApproved: conflicted, |
| 80 | 106 | hottest: hot, |
| 81 | 107 | strandedUsd: readStranded(db).usd, |
| 108 | cpuStopped: cpu, | |
| 82 | 109 | }); |
| 83 | return { says: shared.length > 0 || pairs.length > 0 || standing || conflicted > 0 ? says : "", shared, pairs: pairs.slice(0, 20) }; | |
| 110 | return { says: shared.length > 0 || pairs.length > 0 || standing || conflicted > 0 || cpu > 0 ? says : "", shared, pairs: pairs.slice(0, 20) }; | |
| 84 | 111 | } |
| 85 | 112 | |
| 86 | 113 |
src/flow/clog_words.ts +5
| @@ -5,6 +5,8 @@ export type ClogFacts = { | ||
| 5 | 5 | conflictedApproved?: number | null; |
| 6 | 6 | hottest?: { path: string; pulls: number } | undefined; |
| 7 | 7 | strandedUsd?: number | null; |
| 8 | /** Open pulls red only because the CPU watch stopped their check (#11573), given when that is the lane's main failure. */ | |
| 9 | cpuStopped?: number | null; | |
| 8 | 10 | }; |
| 9 | 11 | |
| 10 | 12 | const MAX = 160; |
| @@ -17,8 +19,11 @@ export function clogSays(f: ClogFacts): string { | ||
| 17 | 19 | const conflicts = count(f.conflictedApproved); |
| 18 | 20 | const hot = f.hottest && count(f.hottest.pulls) > 0 && f.hottest.path ? f.hottest : undefined; |
| 19 | 21 | const dollars = Math.round(f.strandedUsd ?? 0); |
| 22 | const cpu = count(f.cpuStopped); | |
| 20 | 23 | const build = (reasons: readonly string[], withHot: boolean): string => { |
| 21 | 24 | const parts = reasons.filter((r) => r.trim() !== "").map((r) => r.trim()); |
| 25 | // The lane's main failure leads: it is not the pulls' code (#11573). | |
| 26 | if (cpu) parts.unshift(`${pulls(cpu, "open")} went red only because the CPU watch stopped ${cpu === 1 ? "its check" : "their checks"}`); | |
| 22 | 27 | if (conflicts) parts.push(`${pulls(conflicts, "approved")} ${conflicts === 1 ? "conflicts" : "conflict"}`); |
| 23 | 28 | if (hot && withHot) parts.push(`${pulls(count(hot.pulls), "open")} touch ${hot.path}`); |
| 24 | 29 | const head = parts.length ? `Clogged: ${parts.join("; ")}.` : ""; |
src/http/checks_recheck.ts +14 −6
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | /** |
| 2 | * The admin's one call to re-check every open pull red on one named test. | |
| 2 | * The admin's one call to re-check every open pull red on one named test, | |
| 3 | * or (`{cause: "cpu"}`) every open pull the CPU watch stopped (#11573). | |
| 3 | 4 | */ |
| 4 | 5 | |
| 5 | import { RECHECK_MAX, recheckNamed, TEST_NAME_MAX } from "../checks/recheck.ts"; | |
| 6 | import { RECHECK_MAX, recheckCpuStopped, recheckNamed, TEST_NAME_MAX } from "../checks/recheck.ts"; | |
| 6 | 7 | import { canAdmin, getRepo } from "../core/repos.ts"; |
| 7 | 8 | import type { Handler } from "./context.ts"; |
| 8 | 9 | import { apiError, body, json, notFound, requireScope, str } from "./respond.ts"; |
| @@ -14,10 +15,17 @@ export function checksRecheckRoutes(router: Router<Handler>): void { | ||
| 14 | 15 | if (denied) return denied; |
| 15 | 16 | const repo = getRepo(ctx.app.db, ctx.params.owner!, ctx.params.name!); |
| 16 | 17 | if (!repo || !canAdmin(ctx.app.db, repo, ctx.auth.user)) return notFound(); |
| 17 | const test = str((await body(ctx.request)).test); | |
| 18 | if (test.length < 1 || test.length > TEST_NAME_MAX) | |
| 19 | return apiError("invalid_test", 400, `test is a name of 1 to ${TEST_NAME_MAX} characters`); | |
| 20 | const done = await recheckNamed(ctx.app, ctx.auth.user!, repo, test); | |
| 18 | const input = await body(ctx.request); | |
| 19 | let done; | |
| 20 | if (input.cause !== undefined) { | |
| 21 | if (input.cause !== "cpu") return apiError("invalid_cause", 400, 'cause is "cpu", the one cause named'); | |
| 22 | done = await recheckCpuStopped(ctx.app, ctx.auth.user!, repo); | |
| 23 | } else { | |
| 24 | const test = str(input.test); | |
| 25 | if (test.length < 1 || test.length > TEST_NAME_MAX) | |
| 26 | return apiError("invalid_test", 400, `test is a name of 1 to ${TEST_NAME_MAX} characters`); | |
| 27 | done = await recheckNamed(ctx.app, ctx.auth.user!, repo, test); | |
| 28 | } | |
| 21 | 29 | return json( |
| 22 | 30 | done.left > 0 |
| 23 | 31 | ? { queued: done.queued, more: done.left, message: `at most ${RECHECK_MAX} per call; ${done.left} left` } |
test/checks-recheck.test.ts +29
| @@ -114,3 +114,32 @@ test("past the cap, the rest are left and the answer says how many", async () => | ||
| 114 | 114 | const next = await recheckNamed(app, alice, repo, "capped", 1); |
| 115 | 115 | expect(next).toEqual({ queued: [pulls[1]!.number], left: 0 }); |
| 116 | 116 | }); |
| 117 | ||
| 118 | /** A red the CPU watch made, not a test (#11573): its summary as the pulls of 2026-11-11 read. */ | |
| 119 | function stoppedForCpu(pull: Pull, summary: string): void { | |
| 120 | const run = createRun(app.db, { repoId: repo.id, kind: "check", env: `pull-${pull.number}`, commitOid: pull.headOid, issueId: pull.id }); | |
| 121 | claimRun(app.db, run.id); | |
| 122 | finishRun(app.db, run.id, { state: "failed", summary }); | |
| 123 | } | |
| 124 | ||
| 125 | test("{cause: \"cpu\"} queues the pulls the CPU watch stopped, and not one red on a test", async () => { | |
| 126 | stoppedForCpu(pulls[0]!, "check failed: stopped: used 16.3 cores for 30s, over its limit of 16; the busiest process is sh (pid 52062, 462s of CPU)"); | |
| 127 | stoppedForCpu(pulls[1]!, "check failed: check stopped: it used 30.0 cores for 150s, over 1.5× its limit of 16"); | |
| 128 | redOn(pulls[2]!, "used 16.3 cores"); | |
| 129 | const before = asked().length; | |
| 130 | const res = await call({ cause: "cpu" }); | |
| 131 | expect(res.status).toBe(200); | |
| 132 | expect(await res.json()).toEqual({ queued: [pulls[0]!.number, pulls[1]!.number] }); | |
| 133 | expect(asked().length - before).toBe(2); | |
| 134 | }); | |
| 135 | ||
| 136 | test("any other cause is a 400 and queues nothing", async () => { | |
| 137 | const before = asked().length; | |
| 138 | expect((await call({ cause: "memory" })).status).toBe(400); | |
| 139 | expect((await call({ cause: 1 })).status).toBe(400); | |
| 140 | expect(asked().length - before).toBe(0); | |
| 141 | }); | |
| 142 | ||
| 143 | test("a non-admin asking by cause gets 404", async () => { | |
| 144 | expect((await call({ cause: "cpu" }, {}, bobCookie)).status).toBe(404); | |
| 145 | }); |
test/contain.test.ts +8 −1
| @@ -27,6 +27,8 @@ import { | ||
| 27 | 27 | mountMode, |
| 28 | 28 | secretMounts, |
| 29 | 29 | WATCH, |
| 30 | CPU_STOP, | |
| 31 | cpuStopped, | |
| 30 | 32 | type CellSpec, |
| 31 | 33 | } from "../src/apps/contain.ts"; |
| 32 | 34 | import { envDir } from "../src/apps/data.ts"; |
| @@ -786,10 +788,12 @@ describe.skipIf(noCells)("cells: limits stop a step, and say which", () => { | ||
| 786 | 788 | let dir: string; |
| 787 | 789 | let hidden: string; |
| 788 | 790 | const saved = { ...WATCH }; |
| 791 | const savedStop = { ...CPU_STOP }; | |
| 789 | 792 | |
| 790 | 793 | beforeAll(async () => { |
| 791 | 794 | dir = await mkdtemp(join(tmpdir(), "forge-cell-limits-")); |
| 792 | 795 | hidden = await mkdtemp(join(tmpdir(), "forge-cell-hidden-")); |
| 796 | CPU_STOP.sustainMs = 1_000; | |
| 793 | 797 | WATCH.tickMs = 200; |
| 794 | 798 | WATCH.cpuWindowMs = 3_000; |
| 795 | 799 | WATCH.diskEveryTicks = 1; |
| @@ -797,6 +801,7 @@ describe.skipIf(noCells)("cells: limits stop a step, and say which", () => { | ||
| 797 | 801 | |
| 798 | 802 | afterAll(async () => { |
| 799 | 803 | Object.assign(WATCH, saved); |
| 804 | Object.assign(CPU_STOP, savedStop); | |
| 800 | 805 | await rm(dir, { recursive: true, force: true }); |
| 801 | 806 | await rm(hidden, { recursive: true, force: true }); |
| 802 | 807 | }); |
| @@ -840,8 +845,10 @@ describe.skipIf(noCells)("cells: limits stop a step, and say which", () => { | ||
| 840 | 845 | expect(result.ok).toBe(false); |
| 841 | 846 | if (!result.ok) |
| 842 | 847 | expect(result.error).toMatch( |
| 843 | /^check stopped: it used [\d.]+ cores for \d+s, over its limit of 0\.02; the busiest process is \S+ \(pid \d+, \d+s of CPU\)$/, | |
| 848 | /^check stopped: it used [\d.]+ cores for \d+s, over 1\.5× its limit of 0\.02; the busiest process is \S+ \(pid \d+, \d+s of CPU\)$/, | |
| 844 | 849 | ); |
| 850 | // …and a check reads that stop as infrastructure, not as its red (#11573). | |
| 851 | if (!result.ok) expect(cpuStopped(result.error)).toBe(true); | |
| 845 | 852 | }, 30_000); |
| 846 | 853 | |
| 847 | 854 | test("over its disk limit", async () => { |
test/cpu-breach.test.ts +80 −3
| @@ -3,7 +3,8 @@ | ||
| 3 | 3 | * (#11509), and only when one has spent any. |
| 4 | 4 | */ |
| 5 | 5 | import { describe, expect, test } from "bun:test"; |
| 6 | import { busiest, cpuBreach } from "../src/apps/contain.ts"; | |
| 6 | import { cpuStopSummary } from "../src/checks/recheck.ts"; | |
| 7 | import { busiest, cpuBreach, cpuOver, cpuStopped, cpuTick, CPU_STOP } from "../src/apps/contain.ts"; | |
| 7 | 8 | |
| 8 | 9 | describe("a cell stopped for CPU", () => { |
| 9 | 10 | test("the busiest process is the one with the most ticks in the tree", () => { |
| @@ -20,11 +21,87 @@ describe("a cell stopped for CPU", () => { | ||
| 20 | 21 | // 9_300 ticks over 30 s at 100 ticks a second is 3.1 cores. |
| 21 | 22 | const cores = 9_300 / 100 / 30; |
| 22 | 23 | expect(cpuBreach(cores, 30.4, 1, { pid: 4242, ticks: 9_150, name: "chrome" })).toBe( |
| 23 | "used 3.1 cores for 30s, over its limit of 1; the busiest process is chrome (pid 4242, 92s of CPU)", | |
| 24 | "used 3.1 cores for 30s, over 1.5× its limit of 1; the busiest process is chrome (pid 4242, 92s of CPU)", | |
| 24 | 25 | ); |
| 25 | 26 | }); |
| 26 | 27 | |
| 27 | 28 | test("with no process to name, the line is the cores alone", () => { |
| 28 | expect(cpuBreach(3.1, 30, 1, undefined)).toBe("used 3.1 cores for 30s, over its limit of 1"); | |
| 29 | expect(cpuBreach(3.1, 30, 1, undefined)).toBe("used 3.1 cores for 30s, over 1.5× its limit of 1"); | |
| 30 | }); | |
| 31 | }); | |
| 32 | ||
| 33 | /** | |
| 34 | * A cell is stopped for CPU only past 1.5× its limit, held two minutes | |
| 35 | * (#11573): a check using the 16 cores it was given is never stopped. | |
| 36 | * Ticks are the watch's own, one a second (`WATCH.tickMs`). | |
| 37 | */ | |
| 38 | describe("when the watch stops a cell for CPU", () => { | |
| 39 | /** Run `seconds` of one-second ticks at `cores(s)`; the second it stops at, or undefined. */ | |
| 40 | const run = (cores: (s: number) => number, cpus: number, seconds: number): number | undefined => { | |
| 41 | let since: number | undefined; | |
| 42 | for (let s = 0; s <= seconds; s++) { | |
| 43 | const v = cpuTick(since, cores(s), cpus, s * 1_000); | |
| 44 | since = v.since; | |
| 45 | if (v.stop) return s; | |
| 46 | } | |
| 47 | return undefined; | |
| 48 | }; | |
| 49 | ||
| 50 | test("the stop line is 1.5× the limit, for two minutes", () => { | |
| 51 | expect(CPU_STOP).toEqual({ factor: 1.5, sustainMs: 120_000 }); | |
| 52 | }); | |
| 53 | ||
| 54 | test("using exactly the limit is not over it", () => { | |
| 55 | expect(cpuOver(16, 16)).toBe(false); | |
| 56 | expect(cpuTick(undefined, 16, 16, 0)).toEqual({ since: undefined, stop: false }); | |
| 57 | }); | |
| 58 | ||
| 59 | test("16.1 and 17.5 of 16 — the pulls #11546 and #11547 were stopped at — are not over", () => { | |
| 60 | expect(cpuOver(16.1, 16)).toBe(false); | |
| 61 | expect(cpuOver(17.5, 16)).toBe(false); | |
| 62 | // 1.5 × 16 = 24: at the line is not over it, a hair past is. | |
| 63 | expect(cpuOver(24, 16)).toBe(false); | |
| 64 | expect(cpuOver(24.01, 16)).toBe(true); | |
| 65 | }); | |
| 66 | ||
| 67 | test("a check using all of its 16 cores for three minutes is never stopped", () => { | |
| 68 | expect(run(() => 16, 16, 180)).toBeUndefined(); | |
| 69 | // Sampling jitter just above the limit, every tick, for three minutes. | |
| 70 | expect(run((s) => 16 + (s % 3) * 0.6, 16, 180)).toBeUndefined(); | |
| 71 | }); | |
| 72 | ||
| 73 | test("a runaway at twice its limit is stopped once it has held 120 s", () => { | |
| 74 | expect(run(() => 32, 16, 180)).toBe(120); | |
| 75 | }); | |
| 76 | ||
| 77 | test("a spike past the line that falls back starts the count again", () => { | |
| 78 | // Over for 100 s, back at the limit for 1 s, over again: 100 + 120. | |
| 79 | const cores = (s: number): number => (s === 100 ? 16 : 30); | |
| 80 | expect(run(cores, 16, 300)).toBe(221); | |
| 81 | }); | |
| 82 | }); | |
| 83 | ||
| 84 | describe("a check stopped for CPU reads as infrastructure (#11573)", () => { | |
| 85 | test("the runner's stop line is a CPU stop", () => { | |
| 86 | expect(cpuStopped(`check stopped: it ${cpuBreach(30, 150, 16, { pid: 7, ticks: 49_400, name: "sh" })}`)).toBe(true); | |
| 87 | expect(cpuStopped("check stopped: it used 3.1 cores for 150s, over 1.5× its limit of 1")).toBe(true); | |
| 88 | }); | |
| 89 | ||
| 90 | test("a memory or disk stop, a test's red, and a step printing the line are not", () => { | |
| 91 | expect(cpuStopped("check stopped: it used 300 MiB of memory, over its 128 MiB limit (the default)")).toBe(false); | |
| 92 | expect(cpuStopped("check stopped: it wrote 200 MiB to disk, over its 64 MiB limit")).toBe(false); | |
| 93 | expect(cpuStopped("check exited with code 1 — 2 fail")).toBe(false); | |
| 94 | expect( | |
| 95 | cpuStopped("check exited with code 1 — check stopped: it used 30.0 cores for 150s, over 1.5× its limit of 16"), | |
| 96 | ).toBe(false); | |
| 97 | }); | |
| 98 | }); | |
| 99 | ||
| 100 | describe("a check's summary that is a CPU stop (#11573)", () => { | |
| 101 | test("the 2026-11-11 summaries and a stop since are; a test's red is not", () => { | |
| 102 | expect(cpuStopSummary("check failed: stopped: used 16.1 cores for 31s, over its limit of 16 …")).toBe(true); | |
| 103 | expect(cpuStopSummary("check failed: check stopped: it used 30.0 cores for 150s, over 1.5× its limit of 16")).toBe(true); | |
| 104 | expect(cpuStopSummary("check failed: 2 tests failed")).toBe(false); | |
| 105 | expect(cpuStopSummary("check failed: stopped: used 3000 MiB of memory, over its 2048 MiB limit")).toBe(false); | |
| 29 | 106 | }); |
| 30 | 107 | }); |
test/flow-clog-cpu.test.ts added +59
| @@ -0,0 +1,59 @@ | ||
| 1 | /** | |
| 2 | * /flow's one sentence names the CPU watch's stops when they are the lane's | |
| 3 | * main failure (#11573): the pulls are red for the machine, not their code. | |
| 4 | */ | |
| 5 | import { beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import type { Database } from "bun:sqlite"; | |
| 7 | import { cpuIsMain, readClogView, redCauses } from "../src/flow/clog_view.ts"; | |
| 8 | import { clogSays } from "../src/flow/clog_words.ts"; | |
| 9 | import { migrations } from "../src/core/schema.ts"; | |
| 10 | import { openDb } from "../src/kernel/db.ts"; | |
| 11 | import { id } from "../src/kernel/id.ts"; | |
| 12 | ||
| 13 | const STOPPED = "check failed: stopped: used 16.3 cores for 30s, over its limit of 16; the busiest process is sh (pid 52062, 462s of CPU)"; | |
| 14 | ||
| 15 | let db: Database; | |
| 16 | let n = 0; | |
| 17 | /** An open pull whose latest check failed with `summary`. */ | |
| 18 | const redPull = (summary: string): void => { | |
| 19 | const issueId = id(); | |
| 20 | n += 1; | |
| 21 | db.query(`INSERT INTO issue (id, repo_id, number, kind, title, author_id, state) VALUES (?1, 'r1', ?2, 'pull', 'P', 'u1', 'open')`).run(issueId, n); | |
| 22 | db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id) VALUES (?1, 'main', ?2, '', ?3, 'r1')`).run(issueId, `b${n}`, `o${n}`); | |
| 23 | db.query(`INSERT INTO run (id, repo_id, kind, issue_id, state, summary) VALUES (?1, 'r1', 'check', ?2, 'failed', ?3)`).run(id(), issueId, summary); | |
| 24 | }; | |
| 25 | ||
| 26 | beforeAll(() => { | |
| 27 | db = openDb(":memory:", migrations); | |
| 28 | db.run("INSERT INTO user (id, username, email, password_hash) VALUES ('u1', 'trevato', 't@x', '')"); | |
| 29 | db.run("INSERT INTO repo (id, owner_id, name) VALUES ('r1', 'u1', 'forge')"); | |
| 30 | }); | |
| 31 | ||
| 32 | describe("the clog sentence and the CPU stop", () => { | |
| 33 | test("the sentence leads with the CPU stop when it is given", () => { | |
| 34 | expect(clogSays({ reasons: [], cpuStopped: 3 })).toBe("Clogged: 3 open pulls went red only because the CPU watch stopped their checks."); | |
| 35 | expect(clogSays({ reasons: [], cpuStopped: 1, conflictedApproved: 2 })).toBe( | |
| 36 | "Clogged: 1 open pull went red only because the CPU watch stopped its check; 2 approved pulls conflict.", | |
| 37 | ); | |
| 38 | expect(clogSays({ reasons: [] })).toBe(""); | |
| 39 | }); | |
| 40 | ||
| 41 | test("main means at least half the red, and more than one pull", () => { | |
| 42 | expect(cpuIsMain({ failed: 34, cpu: 24 })).toBe(true); | |
| 43 | expect(cpuIsMain({ failed: 4, cpu: 2 })).toBe(true); | |
| 44 | expect(cpuIsMain({ failed: 5, cpu: 2 })).toBe(false); | |
| 45 | expect(cpuIsMain({ failed: 1, cpu: 1 })).toBe(false); | |
| 46 | }); | |
| 47 | ||
| 48 | test("one stop among test reds is not named; when stops are most of the red, /flow says so", () => { | |
| 49 | redPull("check failed: 2 tests failed"); | |
| 50 | redPull("check failed: 1 test failed"); | |
| 51 | redPull(STOPPED); | |
| 52 | expect(redCauses(db)).toEqual({ failed: 3, cpu: 1 }); | |
| 53 | expect(readClogView(db).says).not.toContain("CPU watch"); | |
| 54 | redPull(STOPPED); | |
| 55 | redPull("check failed: stopped: used 17.5 cores for 31s, over its limit of 16"); | |
| 56 | expect(redCauses(db)).toEqual({ failed: 5, cpu: 3 }); | |
| 57 | expect(readClogView(db).says).toContain("3 open pulls went red only because the CPU watch stopped their checks"); | |
| 58 | }); | |
| 59 | }); |