Public: anyone can read this; the crew can change it.
A forge is stopped for CPU only far past its limit for minutes, never at its limit and never in its first ten minutes #11590
Check · Review · Land · Live — No review yet.
torque/forge:torque/11558-a-forge-is-stopped-for-cpu-only-far-past → main 4 commits fork
4 files
changed +108
−7
src/apps/contain.ts +15 −4
| @@ -38,6 +38,7 @@ import { dirname, join } from "node:path"; | ||
| 38 | 38 | import { portInsteadOfSocket } from "../git/hook-address.ts"; |
| 39 | 39 | import { relay, startEgress, upstreamProxy, type Egress } from "./egress.ts"; |
| 40 | 40 | import { BACKGROUND_CPU_WEIGHT } from "./background.ts"; |
| 41 | import { CPU_RULE, cpuVerdict } from "./cpu_breach.ts"; | |
| 41 | 42 | import { |
| 42 | 43 | cgroupHost, |
| 43 | 44 | diskDevice, |
| @@ -856,7 +857,7 @@ function readMountinfo(): string { | ||
| 856 | 857 | // ---- the watch ---- |
| 857 | 858 | |
| 858 | 859 | /** How often the process table is read, and over how long CPU is averaged. */ |
| 859 | export const WATCH = { tickMs: 1_000, cpuWindowMs: 30_000, diskEveryTicks: 15 }; | |
| 860 | export const WATCH = { tickMs: 1_000, cpuWindowMs: 30_000, diskEveryTicks: 15, cpuRule: { ...CPU_RULE } }; | |
| 860 | 861 | /** A cell's process limit: the watch's every tick, and the kernel's `pids.max` where pids is delegated. */ |
| 861 | 862 | export const PROCESSES_MAX = 512; |
| 862 | 863 | const CLK_TCK = 100; |
| @@ -869,6 +870,9 @@ interface Watched { | ||
| 869 | 870 | readonly breach: (why: string) => void; |
| 870 | 871 | readonly samples: { at: number; ticks: number }[]; |
| 871 | 872 | ticks: number; |
| 873 | readonly bornAt: number; | |
| 874 | /** Since when the window has stayed over the CPU rule's line; null when not. */ | |
| 875 | overSince: number | null; | |
| 872 | 876 | readonly key: number; |
| 873 | 877 | readonly label: string | undefined; |
| 874 | 878 | /** The memory cap, and who holds it: the kernel (a cgroup) or this watch alone. */ |
| @@ -1051,6 +1055,8 @@ export function watchTree( | ||
| 1051 | 1055 | breach, |
| 1052 | 1056 | samples: [], |
| 1053 | 1057 | ticks: 0, |
| 1058 | bornAt: Date.now(), | |
| 1059 | overSince: null, | |
| 1054 | 1060 | key: nextKey++, |
| 1055 | 1061 | label, |
| 1056 | 1062 | cap, |
| @@ -1140,7 +1146,7 @@ export function busiest( | ||
| 1140 | 1146 | |
| 1141 | 1147 | /** |
| 1142 | 1148 | * Why a cell was stopped for CPU: cores = Δticks / CLK_TCK / Δseconds over |
| 1143 | * the window, against its limit, and the busiest process when there is | |
| 1149 | * the window, against twice its limit (`cpu_breach.ts`), and the busiest process when there is | |
| 1144 | 1150 | * one (its CPU seconds = ticks / CLK_TCK, over its life). |
| 1145 | 1151 | */ |
| 1146 | 1152 | export function cpuBreach( |
| @@ -1149,7 +1155,7 @@ export function cpuBreach( | ||
| 1149 | 1155 | cpus: number, |
| 1150 | 1156 | top: { pid: number; ticks: number; name: string } | undefined, |
| 1151 | 1157 | ): string { |
| 1152 | const why = `used ${cores.toFixed(1)} cores for ${Math.round(seconds)}s, over its limit of ${cpus}`; | |
| 1158 | const why = `used ${cores.toFixed(1)} cores for ${Math.round(seconds)}s, over twice its limit of ${cpus}`; | |
| 1153 | 1159 | if (top === undefined) return why; |
| 1154 | 1160 | const spent = Math.round(top.ticks / CLK_TCK); |
| 1155 | 1161 | return `${why}; the busiest process is ${top.name} (pid ${top.pid}, ${spent}s of CPU)`; |
| @@ -1246,7 +1252,12 @@ function tick(): void { | ||
| 1246 | 1252 | if (oldest !== undefined && now - oldest.at >= WATCH.cpuWindowMs) { |
| 1247 | 1253 | const cores = |
| 1248 | 1254 | (ticks - oldest.ticks) / CLK_TCK / ((now - oldest.at) / 1000); |
| 1249 | if (cores > limits.cpus) { | |
| 1255 | const verdict = cpuVerdict( | |
| 1256 | { cores, cpus: limits.cpus, overSince: entry.overSince, bornAt: entry.bornAt, now }, | |
| 1257 | WATCH.cpuRule, | |
| 1258 | ); | |
| 1259 | entry.overSince = verdict.overSince; | |
| 1260 | if (verdict.stop) { | |
| 1250 | 1261 | // Read only now, at the stop: no tick pays for naming a process. |
| 1251 | 1262 | const top = busiest(tree, (pid) => rows.get(pid)?.ticks ?? 0); |
| 1252 | 1263 | entry.breach( |
src/apps/cpu_breach.ts added +41
| @@ -0,0 +1,41 @@ | ||
| 1 | /** | |
| 2 | * When a cell is stopped for CPU (#11558): only when it held more than twice | |
| 3 | * its cores for five minutes straight, and never in its first ten minutes. | |
| 4 | * A cell at, or just over, its limit is kept: a sampled 1.04 of 1 is the | |
| 5 | * sampling, and a build's burst is not a runaway. | |
| 6 | * | |
| 7 | * Pure: plain data in, a verdict out. The watch keeps `overSince` per cell. | |
| 8 | */ | |
| 9 | ||
| 10 | /** The rule's numbers; `WATCH` in `contain.ts` holds a mutable copy for tests. */ | |
| 11 | export const CPU_RULE = { | |
| 12 | /** Stop only above this multiple of the cell's cores. */ | |
| 13 | factor: 2, | |
| 14 | /** … held this long, ms, without a tick at or under it. */ | |
| 15 | holdMs: 5 * 60_000, | |
| 16 | /** … and never while the cell is younger than this, ms. */ | |
| 17 | graceMs: 10 * 60_000, | |
| 18 | }; | |
| 19 | ||
| 20 | /** Cores as the stop line prints them: one decimal. The rule compares this, so the line never contradicts it. */ | |
| 21 | export const shownCores = (cores: number): number => Math.round(cores * 10) / 10; | |
| 22 | ||
| 23 | export interface CpuVerdict { | |
| 24 | /** When the cell first went over and has stayed over, ms; null when it is not over now. */ | |
| 25 | readonly overSince: number | null; | |
| 26 | readonly stop: boolean; | |
| 27 | } | |
| 28 | ||
| 29 | /** | |
| 30 | * One tick's verdict. `cores` is the window's average, `cpus` the limit, | |
| 31 | * `overSince` the last verdict's, `bornAt` and `now` epoch ms. | |
| 32 | */ | |
| 33 | export function cpuVerdict( | |
| 34 | input: { cores: number; cpus: number; overSince: number | null; bornAt: number; now: number }, | |
| 35 | rule: typeof CPU_RULE = CPU_RULE, | |
| 36 | ): CpuVerdict { | |
| 37 | const { cores, cpus, bornAt, now } = input; | |
| 38 | if (!(shownCores(cores) > rule.factor * cpus)) return { overSince: null, stop: false }; | |
| 39 | const overSince = input.overSince ?? now; | |
| 40 | return { overSince, stop: now - overSince >= rule.holdMs && now - bornAt >= rule.graceMs }; | |
| 41 | } |
test/contain.test.ts +2 −1
| @@ -793,6 +793,7 @@ describe.skipIf(noCells)("cells: limits stop a step, and say which", () => { | ||
| 793 | 793 | WATCH.tickMs = 200; |
| 794 | 794 | WATCH.cpuWindowMs = 3_000; |
| 795 | 795 | WATCH.diskEveryTicks = 1; |
| 796 | WATCH.cpuRule = { factor: 2, holdMs: 1_000, graceMs: 0 }; | |
| 796 | 797 | }); |
| 797 | 798 | |
| 798 | 799 | afterAll(async () => { |
| @@ -840,7 +841,7 @@ describe.skipIf(noCells)("cells: limits stop a step, and say which", () => { | ||
| 840 | 841 | expect(result.ok).toBe(false); |
| 841 | 842 | if (!result.ok) |
| 842 | 843 | 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\)$/, | |
| 844 | /^check stopped: it used [\d.]+ cores for \d+s, over twice its limit of 0\.02; the busiest process is \S+ \(pid \d+, \d+s of CPU\)$/, | |
| 844 | 845 | ); |
| 845 | 846 | }, 30_000); |
| 846 | 847 |
test/cpu-breach.test.ts +50 −2
| @@ -4,6 +4,9 @@ | ||
| 4 | 4 | */ |
| 5 | 5 | import { describe, expect, test } from "bun:test"; |
| 6 | 6 | import { busiest, cpuBreach } from "../src/apps/contain.ts"; |
| 7 | import { cpuVerdict, shownCores } from "../src/apps/cpu_breach.ts"; | |
| 8 | ||
| 9 | const MIN = 60_000; | |
| 7 | 10 | |
| 8 | 11 | describe("a cell stopped for CPU", () => { |
| 9 | 12 | test("the busiest process is the one with the most ticks in the tree", () => { |
| @@ -20,11 +23,56 @@ describe("a cell stopped for CPU", () => { | ||
| 20 | 23 | // 9_300 ticks over 30 s at 100 ticks a second is 3.1 cores. |
| 21 | 24 | const cores = 9_300 / 100 / 30; |
| 22 | 25 | 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)", | |
| 26 | "used 3.1 cores for 30s, over twice its limit of 1; the busiest process is chrome (pid 4242, 92s of CPU)", | |
| 24 | 27 | ); |
| 25 | 28 | }); |
| 26 | 29 | |
| 27 | 30 | 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"); | |
| 31 | expect(cpuBreach(3.1, 30, 1, undefined)).toBe("used 3.1 cores for 30s, over twice its limit of 1"); | |
| 32 | }); | |
| 33 | }); | |
| 34 | ||
| 35 | /** Run a cell at `cores` for `minutes`, from `from` minutes into its life, one tick a second; true when stopped. */ | |
| 36 | function run(cores: number, minutes: number, from = 0, cpus = 1): boolean { | |
| 37 | let overSince: number | null = null; | |
| 38 | for (let t = from * MIN; t <= (from + minutes) * MIN; t += 1000) { | |
| 39 | const v = cpuVerdict({ cores, cpus, overSince, bornAt: 0, now: t }); | |
| 40 | if (v.stop) return true; | |
| 41 | overSince = v.overSince; | |
| 42 | } | |
| 43 | return false; | |
| 44 | } | |
| 45 | ||
| 46 | describe("cpu breach rule", () => { | |
| 47 | test("at its limit: kept", () => expect(run(1.0, 30, 11)).toBe(false)); | |
| 48 | test("1.9x for 10 minutes: kept", () => expect(run(1.9, 10, 11)).toBe(false)); | |
| 49 | test("2.1x for 4 minutes: kept", () => expect(run(2.1, 4, 11)).toBe(false)); | |
| 50 | test("2.1x for 5 minutes: stopped", () => expect(run(2.1, 5, 11)).toBe(true)); | |
| 51 | test("3x at minute 9 of the cell's life: kept", () => expect(run(3, 4, 5)).toBe(false)); | |
| 52 | test("3x since minute 5 is stopped at minute 10, not before", () => { | |
| 53 | expect(run(3, 4.9, 5)).toBe(false); | |
| 54 | expect(run(3, 5, 5)).toBe(true); | |
| 55 | }); | |
| 56 | test("over since birth: the hold ends at minute 5, only the ten-minute grace keeps it", () => { | |
| 57 | expect(run(3, 9.9, 0)).toBe(false); | |
| 58 | expect(run(3, 10, 0)).toBe(true); | |
| 59 | }); | |
| 60 | test("a dip under the line starts the five minutes again", () => { | |
| 61 | let overSince: number | null = null; | |
| 62 | const at = (cores: number, now: number) => { | |
| 63 | const v = cpuVerdict({ cores, cpus: 1, overSince, bornAt: 0, now }); | |
| 64 | overSince = v.overSince; | |
| 65 | return v.stop; | |
| 66 | }; | |
| 67 | expect(at(3, 11 * MIN)).toBe(false); | |
| 68 | expect(at(1, 15 * MIN)).toBe(false); | |
| 69 | expect(at(3, 16 * MIN)).toBe(false); | |
| 70 | expect(at(3, 20 * MIN)).toBe(false); | |
| 71 | expect(at(3, 21 * MIN)).toBe(true); | |
| 72 | }); | |
| 73 | test("the line's rounding is the rule's: 2.04 prints 2.0 and is kept", () => { | |
| 74 | expect(shownCores(2.04)).toBe(2); | |
| 75 | expect(run(2.04, 10, 11)).toBe(false); | |
| 76 | expect(cpuBreach(2.1, 300, 1, undefined)).toBe("used 2.1 cores for 300s, over twice its limit of 1"); | |
| 29 | 77 | }); |
| 30 | 78 | }); |