Public: anyone can read this; the crew can change it.
A forge that reaches its CPU limit is slowed, not stopped: a crew run on a 1-CPU forge finishes #11540
Check · Review · Land · Live — Changes were asked on this head.
torque/forge:torque/11539-a-forge-that-reaches-its-cpu-limit-is-sl → main 1 commit fork 6 threads · 6 unresolved
docs/features/cpu-throttle.md added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | # A forge at its CPU limit is slowed, not stopped | |
| 2 | ||
| 3 | A child forge is held to its size's cores (small is 1). Where the kernel's `cpu.max` is ours to write, the cell's cgroup holds it. Where it is not (`apps/cgroup.ts`, `cpuCapped` false), the watch (`watchTree`, `apps/contain.ts`) slows the cell itself: `apps/throttle.ts` pauses the whole process tree (SIGSTOP) for a share of each tick and lets it go (SIGCONT), so over a few seconds it gets `limit` cores. Using exactly the limit is not over it. | |
| 4 | ||
| 5 | - **Stopping** for CPU is the last resort: more than twice the limit for five minutes, in a cell at least ten minutes old (`throttleFailed`). Memory, disk and process-count stops are unchanged. | |
| 6 | - **The owner is told**: a stop by the host writes `host-stopped.json` in the forge's data directory (`spawn/stop_note.ts`); the forge's home page says what happened and why for a day. | |
| 7 | - **Work fits the size** (`spawn/fit.ts`): on a 1-core forge, while a crew run is running, checks are not started, scheduled snapshots are not taken, and looks wait (their age restarts while they wait). One browser at a time: pictures and the accessibility check share `looks/browser_gate.ts`. | |
| 8 | - **A run cut off by a stop** is queued again in the same clone and session (`requeue`, `crew/dispatch.ts`). Cut off twice in a row (`crew/interrupt_loop.ts`) the issue is parked with a plain note instead of looping. | |
| 9 | ||
| 10 | Not done here: the sizes' measured first-change times (the plan lines say what each size is for, not a number), the production probe, and the real-1-CPU-cell three-minute test, which needs a cell the sandbox cannot make. |
src/apps/contain.ts +45 −2
| @@ -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 { pauseMs, signalAll, THROTTLE_WINDOW_MS, throttleFailed, throttleFailure, type OverState } from "./throttle.ts"; | |
| 41 | 42 | import { |
| 42 | 43 | cgroupHost, |
| 43 | 44 | diskDevice, |
| @@ -717,7 +718,7 @@ export function openCell( | ||
| 717 | 718 | breach = why; |
| 718 | 719 | log(`stopped: ${why}`); |
| 719 | 720 | stop(why); |
| 720 | }); | |
| 721 | }, { throttle: spec.cpuQuota === true && cgroup?.cpuCapped !== true }); | |
| 721 | 722 | }; |
| 722 | 723 | return { |
| 723 | 724 | argv, |
| @@ -875,6 +876,21 @@ interface Watched { | ||
| 875 | 876 | readonly cap: MemoryCap; |
| 876 | 877 | /** The tree's CPU ticks so far and resident KiB, at the last tick. */ |
| 877 | 878 | last?: { ticks: number; kib: number }; |
| 879 | /** Slowed (SIGSTOP/SIGCONT, `apps/throttle.ts`) rather than stopped when over its cores. */ | |
| 880 | readonly throttle: boolean; | |
| 881 | readonly born: number; | |
| 882 | readonly over: OverState; | |
| 883 | /** The pids paused right now, and the timer that lets them go. */ | |
| 884 | paused: number[]; | |
| 885 | resume?: ReturnType<typeof setTimeout> | undefined; | |
| 886 | } | |
| 887 | ||
| 888 | /** Let a throttled tree run again: always, before it is stopped or unwatched. */ | |
| 889 | function release(entry: Watched): void { | |
| 890 | if (entry.resume !== undefined) clearTimeout(entry.resume); | |
| 891 | entry.resume = undefined; | |
| 892 | if (entry.paused.length > 0) signalAll(entry.paused, "SIGCONT"); | |
| 893 | entry.paused = []; | |
| 878 | 894 | } |
| 879 | 895 | |
| 880 | 896 | const watched = new Set<Watched>(); |
| @@ -1043,8 +1059,13 @@ export function watchTree( | ||
| 1043 | 1059 | writable: readonly string[], |
| 1044 | 1060 | label: string | undefined, |
| 1045 | 1061 | breach: (why: string) => void, |
| 1062 | options: { readonly throttle?: boolean | undefined } = {}, | |
| 1046 | 1063 | ): () => void { |
| 1047 | 1064 | const entry: Watched = { |
| 1065 | throttle: options.throttle === true, | |
| 1066 | born: Date.now(), | |
| 1067 | over: {}, | |
| 1068 | paused: [], | |
| 1048 | 1069 | root, |
| 1049 | 1070 | limits, |
| 1050 | 1071 | writable, |
| @@ -1058,6 +1079,7 @@ export function watchTree( | ||
| 1058 | 1079 | watched.add(entry); |
| 1059 | 1080 | timer ??= setInterval(() => during("cell-watch", tick), WATCH.tickMs); |
| 1060 | 1081 | return () => { |
| 1082 | release(entry); | |
| 1061 | 1083 | watched.delete(entry); |
| 1062 | 1084 | if (watched.size === 0 && timer !== undefined) { |
| 1063 | 1085 | clearInterval(timer); |
| @@ -1243,7 +1265,28 @@ function tick(): void { | ||
| 1243 | 1265 | ) |
| 1244 | 1266 | entry.samples.shift(); |
| 1245 | 1267 | const oldest = entry.samples[0]; |
| 1246 | if (oldest !== undefined && now - oldest.at >= WATCH.cpuWindowMs) { | |
| 1268 | if (entry.throttle) { | |
| 1269 | // Slow it, never stop it for being busy: pause the tree for a share of the next tick. | |
| 1270 | release(entry); | |
| 1271 | const base = [...entry.samples].reverse().find((s) => now - s.at >= THROTTLE_WINDOW_MS); | |
| 1272 | if (base !== undefined) { | |
| 1273 | const cores = (ticks - base.ticks) / CLK_TCK / ((now - base.at) / 1000); | |
| 1274 | const ms = pauseMs(cores, limits.cpus, WATCH.tickMs); | |
| 1275 | if (ms > 0) { | |
| 1276 | entry.paused = tree; | |
| 1277 | signalAll(tree, "SIGSTOP"); | |
| 1278 | entry.resume = setTimeout(() => release(entry), ms); | |
| 1279 | } | |
| 1280 | } | |
| 1281 | if (oldest !== undefined && now - oldest.at >= WATCH.cpuWindowMs) { | |
| 1282 | const cores = (ticks - oldest.ticks) / CLK_TCK / ((now - oldest.at) / 1000); | |
| 1283 | if (throttleFailed(entry.over, cores, limits.cpus, entry.born, now)) { | |
| 1284 | release(entry); | |
| 1285 | entry.breach(throttleFailure(cores, limits.cpus)); | |
| 1286 | continue; | |
| 1287 | } | |
| 1288 | } | |
| 1289 | } else if (oldest !== undefined && now - oldest.at >= WATCH.cpuWindowMs) { | |
| 1247 | 1290 | const cores = |
| 1248 | 1291 | (ticks - oldest.ticks) / CLK_TCK / ((now - oldest.at) / 1000); |
| 1249 | 1292 | if (cores > limits.cpus) { |
src/apps/throttle.ts added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | /** | |
| 2 | * Holding a cell to its cores by slowing it, where the kernel's `cpu.max` | |
| 3 | * is not ours to write (#11539). A forge at its limit runs slower; nothing | |
| 4 | * is killed for being busy. The cell's whole process tree is paused | |
| 5 | * (SIGSTOP) for a share of each watch tick and let go (SIGCONT), so that | |
| 6 | * over a few seconds it gets `limit` cores: duty = limit / cores. | |
| 7 | * | |
| 8 | * Stopping the cell for CPU remains only as a last resort, for a tree the | |
| 9 | * pause cannot hold: over twice its limit for five minutes, and never in | |
| 10 | * the cell's first ten minutes. | |
| 11 | */ | |
| 12 | ||
| 13 | /** The window the pause is sized from, ms: short, so a burst is slowed within seconds. */ | |
| 14 | export const THROTTLE_WINDOW_MS = 5_000; | |
| 15 | /** A pause shorter than this share of a tick is not worth the signals; none is longer than the most share. */ | |
| 16 | export const PAUSE_MIN_SHARE = 0.05; | |
| 17 | export const PAUSE_MAX_SHARE = 0.9; | |
| 18 | /** Last resort: over `LAST_RESORT_FACTOR` × limit, for this long, in a cell at least this old. */ | |
| 19 | export const LAST_RESORT_FACTOR = 2; | |
| 20 | export const LAST_RESORT_MS = 5 * 60_000; | |
| 21 | export const LAST_RESORT_MIN_AGE_MS = 10 * 60_000; | |
| 22 | ||
| 23 | /** | |
| 24 | * How long to pause the tree over the next tick, ms (0: let it run). | |
| 25 | * It used `cores` over the throttle window against `limit`; running a | |
| 26 | * share `limit / cores` of each tick averages `limit`. Using exactly the | |
| 27 | * limit is not over it. | |
| 28 | */ | |
| 29 | export function pauseMs(cores: number, limit: number, tickMs: number): number { | |
| 30 | if (!(cores > limit) || !(limit > 0) || !Number.isFinite(limit)) return 0; | |
| 31 | const ms = Math.round(tickMs * (1 - limit / cores)); | |
| 32 | return ms < tickMs * PAUSE_MIN_SHARE ? 0 : Math.min(ms, Math.floor(tickMs * PAUSE_MAX_SHARE)); | |
| 33 | } | |
| 34 | ||
| 35 | /** What the last-resort rule remembers between ticks: since when the tree has been over twice its limit. */ | |
| 36 | export interface OverState { | |
| 37 | since?: number | undefined; | |
| 38 | } | |
| 39 | ||
| 40 | /** | |
| 41 | * Whether the throttle has failed: `cores` (over the 30 s CPU window) above | |
| 42 | * `LAST_RESORT_FACTOR` × `limit` since `LAST_RESORT_MS` ago, in a cell born | |
| 43 | * `LAST_RESORT_MIN_AGE_MS` ago. Dropping under the line forgets the clock. | |
| 44 | */ | |
| 45 | export function throttleFailed(state: OverState, cores: number, limit: number, born: number, now: number): boolean { | |
| 46 | if (!(cores > limit * LAST_RESORT_FACTOR)) { | |
| 47 | state.since = undefined; | |
| 48 | return false; | |
| 49 | } | |
| 50 | state.since ??= now; | |
| 51 | return now - state.since >= LAST_RESORT_MS && now - born >= LAST_RESORT_MIN_AGE_MS; | |
| 52 | } | |
| 53 | ||
| 54 | /** The line a cell is stopped with when the throttle could not hold it. */ | |
| 55 | export function throttleFailure(cores: number, limit: number): string { | |
| 56 | return `used ${cores.toFixed(1)} cores for ${LAST_RESORT_MS / 60_000} minutes while slowed, over twice its limit of ${limit}`; | |
| 57 | } | |
| 58 | ||
| 59 | /** Signal `pids`, ignoring the ones already gone or not ours. */ | |
| 60 | export function signalAll(pids: readonly number[], signal: "SIGSTOP" | "SIGCONT"): void { | |
| 61 | for (const pid of pids) { | |
| 62 | try { | |
| 63 | process.kill(pid, signal); | |
| 64 | } catch { | |
| 65 | // gone, or not ours to signal | |
| 66 | } | |
| 67 | } | |
| 68 | } |
src/checks/service.ts +3
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | * is that command's business. |
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | import { crewHasTheCore, crewRunActive } from "../spawn/fit.ts"; | |
| 14 | 15 | import { pullDir, runDir } from "../core/pulls.ts"; |
| 15 | 16 | import { cellHides } from "../spawn/hosted-mark.ts"; |
| 16 | 17 | import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync } from "node:fs"; |
| @@ -626,6 +627,8 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 626 | 627 | if (stopped) return; |
| 627 | 628 | const limit = capacity(); |
| 628 | 629 | if (options.gate === undefined) gate.resize(limit); |
| 630 | // On one core the crew run has it: a check waits until no run is active (`spawn/fit.ts`). | |
| 631 | if (crewHasTheCore(() => crewRunActive(app.db))) return; | |
Unbounded wait: checks never start while a crew run is active on a 1-core forge. Add a maximum wait so the queue cannot starve, with a test. |
||
| 629 | 632 | startVerify(); |
| 630 | 633 | // Every merge-queue gate in flight holds a slot, a line's first too (#6376). |
| 631 | 634 | while (slotted() < limit - gatesInFlight()) { |
src/crew/autonomy.ts +2 −1
| @@ -112,6 +112,7 @@ import { crewWeekFor, weekFor, weekScope } from "./week_read.ts"; | ||
| 112 | 112 | import { announceGovernor, governorFor } from "./governed.ts"; |
| 113 | 113 | import { BREAKER_NOTE, breakerTripped } from "./breaker.ts"; |
| 114 | 114 | import { REPEAT_NOTE } from "./repeat_note.ts"; |
| 115 | import { INTERRUPTED_TWICE_NOTE } from "./interrupt_loop.ts"; | |
| 115 | 116 | import { given, pendingReplans } from "../core/limb_replan.ts"; |
| 116 | 117 | import { handedOverPulls, SKIP_SUMMARY, skipsReplan } from "./guard.ts"; |
| 117 | 118 | import { atRunCap, type Held, heldBy, heldWords, replannable } from "./held.ts"; |
| @@ -990,7 +991,7 @@ export function parkForBreaker( | ||
| 990 | 991 | const last = listComments(app.db, issue.id).at(-1); |
| 991 | 992 | if (last?.body === note) return false; |
| 992 | 993 | addComment(app, author, repo, issue, note); |
| 993 | log.info(note === BREAKER_NOTE ? "parked by the breaker" : note === REPEAT_NOTE ? "parked by the repeat stop" : "parked at the run cap", { | |
| 994 | log.info(note === BREAKER_NOTE ? "parked by the breaker" : note === REPEAT_NOTE ? "parked by the repeat stop" : note === INTERRUPTED_TWICE_NOTE ? "parked: cut off twice in a row" : "parked at the run cap", { | |
| 994 | 995 | repo: `${repo.ownerName}/${repo.name}`, |
| 995 | 996 | issue: issue.number, |
| 996 | 997 | }); |
src/crew/dispatch.ts +7
| @@ -80,6 +80,7 @@ import { detectClaude } from "./claude.ts"; | ||
| 80 | 80 | import { followDivergence, settleAll, settleOnPush } from "./converge.ts"; |
| 81 | 81 | import { breakerTripped } from "./breaker.ts"; |
| 82 | 82 | import { REPEAT_NOTE, repeatTripped } from "./repeat.ts"; |
| 83 | import { INTERRUPTED_TWICE_NOTE, INTERRUPTIONS_IN_A_ROW, interruptedInARow } from "./interrupt_loop.ts"; | |
| 83 | 84 | import { unstickOwed } from "./stalls.ts"; |
| 84 | 85 | import { |
| 85 | 86 | CAP_NOTE, |
| @@ -1385,6 +1386,12 @@ function requeue(app: App, run: Run, why: string): void { | ||
| 1385 | 1386 | cancelRun(app.db, run.id, why); |
| 1386 | 1387 | const task = taskOf(app, run); |
| 1387 | 1388 | if (task === undefined) return; |
| 1389 | // Cut off twice in a row: the forge says so plainly, and does not loop (#11539). | |
| 1390 | if (run.issueId !== null && interruptedInARow(app.db, run.issueId) >= INTERRUPTIONS_IN_A_ROW) { | |
| 1391 | parkForBreaker(app, task.repo, task.issue, task.agent, INTERRUPTED_TWICE_NOTE); | |
| 1392 | log.info("run not queued again: cut off twice in a row", { run: run.id }); | |
| 1393 | return; | |
| 1394 | } | |
| 1388 | 1395 | // The run that starts carries on in the clone this one left, in the |
| 1389 | 1396 | // same session — an interruption is a pause, not a fresh start. |
| 1390 | 1397 | queueAgentRun( |
src/crew/interrupt_loop.ts added +39
| @@ -0,0 +1,39 @@ | ||
| 1 | /** | |
| 2 | * A run cut off by its forge being stopped goes back on the queue and | |
| 3 | * carries on in the same clone and session. Cut off twice in a row, the | |
| 4 | * forge says so plainly rather than looping (#11539): the cycle of stop, | |
| 5 | * queue again, stop again is what spent a forge's credit. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | ||
| 10 | /** The words a restart's cancel is recorded with (`requeue` in `dispatch.ts`). */ | |
| 11 | export const RESTART_CANCEL = "interrupted by a restart"; | |
| 12 | ||
| 13 | /** How many restarts in a row end the carrying on. */ | |
| 14 | export const INTERRUPTIONS_IN_A_ROW = 2; | |
| 15 | ||
| 16 | /** What the issue is parked with, once. */ | |
| 17 | export const INTERRUPTED_TWICE_NOTE = | |
| 18 | "Parked by the crew: this forge was stopped twice in a row while the crew worked here, so the run is not started again by itself. What the crew had done is kept in its clone; say go and it carries on from there."; | |
| 19 | ||
| 20 | /** | |
| 21 | * How many of the issue's latest ended agent runs, newest first, were cut | |
| 22 | * off by a restart, without a run that ended another way between them. | |
| 23 | */ | |
| 24 | export function interruptedInARow(db: Database, issueId: string): number { | |
| 25 | const rows = db | |
| 26 | .query<{ summary: string }, [string, number]>( | |
| 27 | `SELECT summary FROM run | |
| 28 | WHERE issue_id = ?1 AND kind = 'agent' | |
| 29 | AND state IN ('ok', 'failed', 'cancelled') | |
| 30 | ORDER BY id DESC LIMIT ?2`, | |
| 31 | ) | |
| 32 | .all(issueId, INTERRUPTIONS_IN_A_ROW + 1); | |
| 33 | let n = 0; | |
| 34 | for (const row of rows) { | |
| 35 | if (!row.summary.includes(RESTART_CANCEL)) break; | |
| 36 | n++; | |
| 37 | } | |
| 38 | return n; | |
| 39 | } |
src/looks/browser_gate.ts added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | /** | |
| 2 | * At most one browser at a time in this forge (#11539): a picture and an | |
| 3 | * accessibility check share it, so two Chromiums never compete for a core. | |
| 4 | */ | |
| 5 | import { Gate } from "../git/env.ts"; | |
| 6 | ||
| 7 | export const browserGate = new Gate(1); |
src/looks/capture.ts +2 −2
| @@ -38,7 +38,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no | ||
| 38 | 38 | import { tmpdir } from "node:os"; |
| 39 | 39 | import { join } from "node:path"; |
| 40 | 40 | import { childEnv } from "../apps/process.ts"; |
| 41 | import { Gate } from "../git/env.ts"; | |
| 41 | import { browserGate } from "./browser_gate.ts"; | |
| 42 | 42 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 43 | 43 | import type { Entries } from "../release/webvitals.ts"; |
| 44 | 44 | |
| @@ -107,7 +107,7 @@ export function findChromium(): string | undefined { | ||
| 107 | 107 | return undefined; |
| 108 | 108 | } |
| 109 | 109 | |
| 110 | const gate = new Gate(1); | |
| 110 | const gate = browserGate; | |
| 111 | 111 | |
| 112 | 112 | /** Take one picture. Serialised: the second caller waits for the first. */ |
| 113 | 113 | export async function takeLook( |
src/looks/service.ts +19 −1
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | * environment does — a preview's go when its pull closes. |
| 16 | 16 | */ |
| 17 | 17 | |
| 18 | import { crewHasTheCore, crewRunActive } from "../spawn/fit.ts"; | |
| 18 | 19 | import { sealedPreview } from "../core/sealed.ts"; |
| 19 | 20 | import { |
| 20 | 21 | closeSync, |
| @@ -88,6 +89,11 @@ export interface LooksOptions { | ||
| 88 | 89 | * while two agent runs are at their peak rather than being the straw. |
| 89 | 90 | */ |
| 90 | 91 | readonly pressure?: (() => number | undefined) | undefined; |
| 92 | /** | |
| 93 | * Whether the crew has the core: on a forge of one CPU a look waits while | |
| 94 | * a run is active (`spawn/fit.ts`). Read from the forge's size unless a test says otherwise. | |
| 95 | */ | |
| 96 | readonly crewBusy?: (() => boolean) | undefined; | |
| 91 | 97 | /** The clock a failed look's backoff waits on: the real one unless a test says otherwise. */ |
| 92 | 98 | readonly clock?: BackoffClock | undefined; |
| 93 | 99 | } |
| @@ -140,8 +146,13 @@ export interface LooksHandle { | ||
| 140 | 146 | stop(): Promise<void>; |
| 141 | 147 | } |
| 142 | 148 | |
| 143 | /** A look that has waited longer than this is stale, and is dropped. */ | |
| 149 | /** | |
| 150 | * A look that has waited longer than this is stale, and is dropped. A look | |
| 151 | * held for the crew keeps its place: its age is counted from the run's end. | |
| 152 | */ | |
| 144 | 153 | const MAX_WAIT_MS = 10 * 60_000; |
| 154 | /** How long a look waits before it asks again whether the crew is still at work. */ | |
| 155 | const CREW_WAIT_MS = 5_000; | |
| 145 | 156 | /** How often production is checked for a picture it lacks. */ |
| 146 | 157 | const SWEEP_MS = 10 * 60_000; |
| 147 | 158 | /** The first check, once a booted forge's apps have had a moment. */ |
| @@ -349,6 +360,13 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 349 | 360 | for (;;) { |
| 350 | 361 | const next = queue.shift(); |
| 351 | 362 | if (next === undefined || stopped) return; |
| 363 | // On one core the crew run has it: the look waits, and its age restarts when the run ends. | |
| 364 | if ((options.crewBusy ?? (() => crewHasTheCore(() => crewRunActive(app.db))))()) { | |
| 365 | if (stopped) return; | |
| 366 | queue.unshift({ ...next, at: Date.now() }); | |
| 367 | await Bun.sleep(CREW_WAIT_MS); | |
| 368 | continue; | |
| 369 | } | |
| 352 | 370 | if (Date.now() - next.at > MAX_WAIT_MS) { |
| 353 | 371 | log.warn("look dropped: waited too long", { |
| 354 | 372 | app: next.appId, |
src/release/devtools.ts +4
| @@ -6,11 +6,14 @@ | ||
| 6 | 6 | import { mkdtempSync, rmSync } from "node:fs"; |
| 7 | 7 | import { tmpdir } from "node:os"; |
| 8 | 8 | import { join } from "node:path"; |
| 9 | import { browserGate } from "../looks/browser_gate.ts"; | |
| 9 | 10 | |
| 10 | 11 | const STEP_MS = 15000; |
| 11 | 12 | |
| 12 | 13 | /** A DevTools connection to one page of a private headless Chromium. */ |
| 13 | 14 | export async function withPage<T>(binary: string, use: (send: (method: string, params?: object) => Promise<any>, once: (event: string) => Promise<void>) => Promise<T>): Promise<T> { |
| 15 | // One browser at a time: a picture being taken has it first (`looks/browser_gate.ts`). | |
| 16 | const release = await browserGate.acquire(); | |
High: the gate is acquired at line 10 of the function, but spawn (and mkdtemp) are outside the try. A throw leaks the gate and blocks every later browser use. Acquire inside the try or wrap the spawn. |
||
| 14 | 17 | const profile = mkdtempSync(join(tmpdir(), "forge-axe-")); |
| 15 | 18 | const proc = Bun.spawn( |
| 16 | 19 | [binary, "--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", "--disable-background-networking", "--no-first-run", `--user-data-dir=${profile}`, "--remote-debugging-port=0", "about:blank"], |
| @@ -87,5 +90,6 @@ export async function withPage<T>(binary: string, use: (send: (method: string, p | ||
| 87 | 90 | proc.kill("SIGKILL"); |
| 88 | 91 | await proc.exited.catch(() => {}); |
| 89 | 92 | rmSync(profile, { recursive: true, force: true }); |
| 93 | release(); | |
| 90 | 94 | } |
| 91 | 95 | } |
src/snapshot/service.ts +3
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | * is within the hour, not the next night. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { crewHasTheCore, crewRunActive } from "../spawn/fit.ts"; | |
| 13 | 14 | import type { App } from "../app.ts"; |
| 14 | 15 | import { createLog } from "../kernel/log.ts"; |
| 15 | 16 | import { firstSeen, reportVerdict, seedFirstSeen } from "./verdict.ts"; |
| @@ -267,6 +268,8 @@ export function startSnapshots( | ||
| 267 | 268 | // The manifest is written last, so a snapshot cut short is not one. |
| 268 | 269 | const takeIfDue = async (): Promise<boolean> => { |
| 269 | 270 | if (stopped || active > 0) return false; |
| 271 | // On one core the crew run has it: the scheduled snapshot waits for the next pass (`spawn/fit.ts`). | |
| 272 | if (crewHasTheCore(() => crewRunActive(app.db))) return false; | |
| 270 | 273 | const store = current(); |
| 271 | 274 | if (store === undefined) return false; |
| 272 | 275 | let newest: Manifest | undefined; |
src/spawn/cell.ts +6 −1
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * (`spawn/placement.ts` `fallbackPlacement`), and the ledger says which. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { noteStop } from "./stop_note.ts"; | |
| 11 | 12 | import { Database } from "bun:sqlite"; |
| 12 | 13 | import { existsSync } from "node:fs"; |
| 13 | 14 | import { tmpdir } from "node:os"; |
| @@ -356,7 +357,11 @@ export function makeRegistry(parent: App, launcher?: Launcher): CellChildren { | ||
| 356 | 357 | }); |
| 357 | 358 | const exited = proc.exited.then((code) => { |
| 358 | 359 | const why = cell.breach(); |
| 359 | if (why !== undefined) parent.log.warn("child forge stopped", { name: input.name, why }); | |
| 360 | if (why !== undefined) { | |
| 361 | parent.log.warn("child forge stopped", { name: input.name, why }); | |
| 362 | // The owner reads it on their home page (`spawn/stop_note.ts`). | |
| 363 | noteStop(dir, why); | |
| 364 | } | |
| 360 | 365 | cell.close(); |
| 361 | 366 | door.close(); |
| 362 | 367 | release(); |
src/spawn/fit.ts added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | /** | |
| 2 | * Work scheduled to fit the forge's size (#11539). On one CPU a crew run is | |
| 3 | * the product and has the core: looks, checks and snapshots wait until no | |
| 4 | * run is active, so nothing competes with it for the time the host gives. | |
| 5 | * A forge that is not a cell child (no size in its environment) and one of | |
| 6 | * two or more cores are not held: they have the room. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { isSize, SIZES } from "./sizes.ts"; | |
| 11 | import { SIZE_ENV } from "./usage.ts"; | |
| 12 | ||
| 13 | /** The cores this forge is held to, or undefined where it is not a sized cell child. */ | |
| 14 | export function forgeCores(env: Readonly<Record<string, string | undefined>> = process.env): number | undefined { | |
| 15 | const size = env[SIZE_ENV]; | |
| 16 | return isSize(size) ? SIZES[size].cpu : undefined; | |
| 17 | } | |
| 18 | ||
| 19 | /** Whether a crew run is working now: one `running` agent run. */ | |
| 20 | export function crewRunActive(db: Database): boolean { | |
| 21 | return db.query("SELECT 1 FROM run WHERE kind = 'agent' AND state = 'running' LIMIT 1").get() !== null; | |
| 22 | } | |
| 23 | ||
| 24 | /** | |
| 25 | * Whether the other work (a look, a check, a snapshot) waits for the crew: | |
| 26 | * on a forge of one core, while a run is active. `active` is read only when | |
| 27 | * the forge has one core, so a larger forge pays no query. | |
| 28 | */ | |
| 29 | export function crewHasTheCore( | |
| 30 | active: () => boolean, | |
| 31 | env: Readonly<Record<string, string | undefined>> = process.env, | |
| 32 | ): boolean { | |
| 33 | const cores = forgeCores(env); | |
| 34 | return cores !== undefined && cores <= 1 && active(); | |
| 35 | } |
src/spawn/sizes.ts +2 −2
| @@ -55,8 +55,8 @@ export const isSize = (v: unknown): v is Size => typeof v === "string" && Object | ||
| 55 | 55 | |
| 56 | 56 | // What each size is for, never how big: the figures live only in SIZES. |
| 57 | 57 | const SAYS: Readonly<Record<Size, string>> = { |
| 58 | small: "For a notes app, a small tool, a place to try things.", | |
| 59 | medium: "For a busy app, or a few apps side by side.", | |
| 58 | small: "For a notes app, a small tool, a place to try things. The crew has its only core while it builds, so a change takes longer and checks and pictures wait until the crew is idle.", | |
| 59 | medium: "For a busy app, or a few apps side by side; checks and pictures run beside the crew.", | |
Principle 1: one confident sentence per size. This line now explains scheduling twice over. |
||
| 60 | 60 | large: "For a crew's working forge, with room to build.", |
| 61 | 61 | }; |
| 62 | 62 |
src/spawn/stop_note.ts added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | /** | |
| 2 | * What a forge tells its owner when the host stopped it (#11539): the host | |
| 3 | * writes one small file in the forge's own data directory, the forge reads | |
| 4 | * it on its home page for a day. Plain data in, plain data out. | |
| 5 | */ | |
| 6 | import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; | |
| 7 | import { join } from "node:path"; | |
| 8 | ||
| 9 | const FILE = "host-stopped.json"; | |
| 10 | /** How long the note stays on the home page, ms. */ | |
| 11 | export const NOTE_MS = 24 * 60 * 60_000; | |
| 12 | ||
| 13 | export interface StopNote { | |
| 14 | readonly at: string; | |
| 15 | readonly why: string; | |
| 16 | } | |
| 17 | ||
| 18 | /** Record that the host stopped the forge whose data is `dataDir`, and why. Best effort: a note never stops a stop. */ | |
| 19 | export function noteStop(dataDir: string, why: string, now = new Date()): void { | |
| 20 | try { | |
| 21 | mkdirSync(dataDir, { recursive: true }); | |
| 22 | const tmp = join(dataDir, `${FILE}.tmp`); | |
| 23 | writeFileSync(tmp, JSON.stringify({ at: now.toISOString(), why: why.slice(0, 400) })); | |
| 24 | renameSync(tmp, join(dataDir, FILE)); | |
| 25 | } catch { | |
| 26 | // the stop stands without its note | |
| 27 | } | |
| 28 | } | |
| 29 | ||
| 30 | /** The note, while it is a day old at most; undefined when there is none or it is not well formed. */ | |
| 31 | export function readStop(dataDir: string, now = Date.now()): StopNote | undefined { | |
| 32 | try { | |
| 33 | const raw: unknown = JSON.parse(readFileSync(join(dataDir, FILE), "utf8")); | |
| 34 | if (typeof raw !== "object" || raw === null) return undefined; | |
| 35 | const { at, why } = raw as Record<string, unknown>; | |
| 36 | if (typeof at !== "string" || typeof why !== "string") return undefined; | |
| 37 | const when = Date.parse(at); | |
| 38 | if (!Number.isFinite(when) || now - when > NOTE_MS || when > now + 60_000) return undefined; | |
| 39 | return { at, why: why.slice(0, 400) }; | |
| 40 | } catch { | |
| 41 | return undefined; | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | /** The line on the owner's home page. */ | |
| 46 | export function stopWords(note: StopNote): string { | |
| 47 | const time = note.at.slice(11, 16); | |
| 48 | return `This forge was stopped by its host at ${time} UTC: ${note.why}. It started again, and the crew carries on where it stopped.`; | |
| 49 | } |
src/web/studio/home.ts +5 −1
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * best of what is public (`actions/front.ts`). |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { readStop, stopWords } from "../../spawn/stop_note.ts"; | |
| 9 | 10 | import { UPSTREAM_REPO } from "../../core/upstream.ts"; |
| 10 | 11 | import { foundryHref } from "../../actions/foundry_door.ts"; |
| 11 | 12 | import { forgeCeiling } from "../../flow/ceiling.ts"; |
| @@ -368,7 +369,10 @@ export async function studioHome( | ||
| 368 | 369 | // The first page after Allow says so, once. |
| 369 | 370 | const allowed = untoldAnswer(ctx.app.db, user.id) === "allowed"; |
| 370 | 371 | if (allowed) markTold(ctx.app.db, user.id, new Date().toISOString()); |
| 371 | const needsYou = html`${requests}${waiting}${held}${alarms}`; | |
| 372 | // A stop by the host is said to the owner, plainly, for a day (`spawn/stop_note.ts`, #11539). | |
| 373 | const stopped = user.isAdmin ? readStop(ctx.app.config.dataDir) : undefined; | |
| 374 | const stopLine = stopped === undefined ? null : html`<p class="small" id="host-stopped" role="status">${stopWords(stopped)}</p>`; | |
Both doors: the TUI home has no equivalent of this line. Also 'host' and 'slowed' are not plain words (principle 8). |
||
| 375 | const needsYou = html`${requests}${waiting}${held}${alarms}${stopLine}`; | |
| 372 | 376 | const mine = writableAppsOf(ctx, user).repos; |
| 373 | 377 | const body = html`<h1 class="sr-only">Home</h1> |
| 374 | 378 | ${allowed ? html`<div class="toast" data-tone="success" role="status" id="youre-in"><strong>${YOURE_IN_MESSAGE}</strong> Your account is open: tell the crew what you want to build.</div>` : null} |
test/cpu-throttle.test.ts added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | /** | |
| 2 | * A cell at its CPU limit is slowed, not stopped (#11539): paused for a | |
| 3 | * share of each tick so it averages its cores, and stopped only when the | |
| 4 | * pause cannot hold it, after five minutes over twice the limit, in a cell | |
| 5 | * ten minutes old. | |
| 6 | */ | |
| 7 | import { describe, expect, test } from "bun:test"; | |
| 8 | import { readFileSync } from "node:fs"; | |
| 9 | import { watchTree, WATCH, type Limits } from "../src/apps/contain.ts"; | |
| 10 | import { LAST_RESORT_MIN_AGE_MS, LAST_RESORT_MS, pauseMs, throttleFailed } from "../src/apps/throttle.ts"; | |
| 11 | ||
| 12 | describe("the pause", () => { | |
| 13 | test("at or under the limit nothing is paused: using exactly the limit is not over it", () => { | |
| 14 | expect(pauseMs(1, 1, 1000)).toBe(0); | |
| 15 | expect(pauseMs(0.4, 1, 1000)).toBe(0); | |
| 16 | expect(pauseMs(1.02, 1, 1000)).toBe(0); | |
| 17 | }); | |
| 18 | test("over it the tree runs limit/cores of each tick (hand calculation: 2 cores on 1 is half)", () => { | |
| 19 | expect(pauseMs(2, 1, 1000)).toBe(500); | |
| 20 | expect(pauseMs(4, 1, 1000)).toBe(750); | |
| 21 | expect(pauseMs(3, 2, 1000)).toBe(333); | |
| 22 | }); | |
| 23 | test("never the whole tick, whatever the demand", () => { | |
| 24 | expect(pauseMs(1000, 1, 1000)).toBe(900); | |
| 25 | }); | |
| 26 | }); | |
| 27 | ||
| 28 | describe("the last resort", () => { | |
| 29 | const born = 0; | |
| 30 | test("five minutes over twice the limit, in a cell past ten minutes, is a failed throttle", () => { | |
| 31 | const s = {}; | |
| 32 | const t0 = LAST_RESORT_MIN_AGE_MS; | |
| 33 | expect(throttleFailed(s, 2.5, 1, born, t0)).toBe(false); | |
| 34 | expect(throttleFailed(s, 2.5, 1, born, t0 + LAST_RESORT_MS - 1)).toBe(false); | |
| 35 | expect(throttleFailed(s, 2.5, 1, born, t0 + LAST_RESORT_MS)).toBe(true); | |
| 36 | }); | |
| 37 | test("at or near the limit it never is, however long", () => { | |
| 38 | const s = {}; | |
| 39 | for (let at = 0; at < 60 * 60_000; at += 10_000) expect(throttleFailed(s, 2, 1, born, at + LAST_RESORT_MIN_AGE_MS)).toBe(false); | |
| 40 | }); | |
| 41 | test("a cell younger than ten minutes is never stopped for CPU", () => { | |
| 42 | const s = {}; | |
| 43 | expect(throttleFailed(s, 9, 1, 0, 0)).toBe(false); | |
| 44 | expect(throttleFailed(s, 9, 1, 0, LAST_RESORT_MS + 1)).toBe(false); | |
| 45 | expect(throttleFailed(s, 9, 1, 0, LAST_RESORT_MIN_AGE_MS - 1)).toBe(false); | |
| 46 | }); | |
| 47 | test("dropping under the line forgets the clock", () => { | |
| 48 | const s = {}; | |
| 49 | const t0 = LAST_RESORT_MIN_AGE_MS; | |
| 50 | throttleFailed(s, 3, 1, born, t0); | |
| 51 | throttleFailed(s, 1, 1, born, t0 + 60_000); | |
| 52 | expect(throttleFailed(s, 3, 1, born, t0 + LAST_RESORT_MS)).toBe(false); | |
| 53 | }); | |
| 54 | }); | |
| 55 | ||
| 56 | const cpuSeconds = (pid: number): number => { | |
| 57 | const f = readFileSync(`/proc/${pid}/stat`, "utf8").split(") ")[1]!.split(" "); | |
| 58 | return (Number(f[11]) + Number(f[12])) / 100; | |
| 59 | }; | |
| 60 | ||
| 61 | test("a CPU-bound process under a throttle runs near its quota and is not stopped", async () => { | |
| 62 | const saved = { ...WATCH }; | |
A 16 s test measuring wall-clock CPU share will flake on a loaded CI machine. Inject the clock and the signal function and test tick behaviour deterministically. |
||
| 63 | WATCH.tickMs = 100; | |
| 64 | const hog = Bun.spawn(["bun", "-e", "const end = Date.now() + 30000; while (Date.now() < end) {}"], { stdout: "ignore", stderr: "ignore" }); | |
| 65 | let stopped: string | undefined; | |
| 66 | const limits: Limits = { memoryMiB: 4096, cpus: 0.5, diskMiB: 1 << 20 }; | |
| 67 | const unwatch = watchTree(hog.pid, limits, { bytes: 4096 << 20, by: "watch" }, [], undefined, (why) => (stopped = why), { throttle: true }); | |
| 68 | try { | |
| 69 | await Bun.sleep(10_000); // the window fills and the pause settles | |
| 70 | const from = cpuSeconds(hog.pid); | |
| 71 | const t0 = Date.now(); | |
| 72 | await Bun.sleep(6_000); | |
| 73 | const cores = (cpuSeconds(hog.pid) - from) / ((Date.now() - t0) / 1000); | |
| 74 | expect(stopped).toBeUndefined(); | |
| 75 | expect(cores).toBeGreaterThan(0.2); | |
| 76 | expect(cores).toBeLessThan(0.8); // unthrottled it is 1.0 | |
| 77 | expect(hog.killed).toBe(false); | |
| 78 | } finally { | |
| 79 | unwatch(); | |
| 80 | hog.kill("SIGCONT"); | |
| 81 | hog.kill(); | |
| 82 | Object.assign(WATCH, saved); | |
| 83 | } | |
| 84 | }, 40_000); |
test/crew-has-the-core.test.ts added +43
| @@ -0,0 +1,43 @@ | ||
| 1 | /** On a forge of one core the crew run has it; looks, checks and snapshots wait (#11539). */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { Database } from "bun:sqlite"; | |
| 4 | import { crewHasTheCore, crewRunActive, forgeCores } from "../src/spawn/fit.ts"; | |
| 5 | import { browserGate } from "../src/looks/browser_gate.ts"; | |
| 6 | import { withPage } from "../src/release/devtools.ts"; | |
| 7 | ||
| 8 | test("a small forge holds other work while a run is active; larger and unsized forges do not", () => { | |
| 9 | expect(forgeCores({ FORGE_CELL_SIZE: "small" })).toBe(1); | |
| 10 | expect(forgeCores({ FORGE_CELL_SIZE: "large" })).toBe(4); | |
| 11 | expect(forgeCores({})).toBeUndefined(); | |
| 12 | expect(crewHasTheCore(() => true, { FORGE_CELL_SIZE: "small" })).toBe(true); | |
| 13 | expect(crewHasTheCore(() => false, { FORGE_CELL_SIZE: "small" })).toBe(false); | |
| 14 | expect(crewHasTheCore(() => true, { FORGE_CELL_SIZE: "medium" })).toBe(false); | |
| 15 | expect(crewHasTheCore(() => true, {})).toBe(false); | |
| 16 | }); | |
| 17 | ||
| 18 | test("a larger forge never asks whether a run is active", () => { | |
| 19 | let asked = 0; | |
| 20 | crewHasTheCore(() => (asked++, true), { FORGE_CELL_SIZE: "large" }); | |
| 21 | expect(asked).toBe(0); | |
| 22 | }); | |
| 23 | ||
| 24 | test("a run is active only while one is running", () => { | |
| 25 | const db = new Database(":memory:"); | |
| 26 | db.run("CREATE TABLE run (kind TEXT, state TEXT)"); | |
| 27 | expect(crewRunActive(db)).toBe(false); | |
| 28 | db.run("INSERT INTO run VALUES ('agent','queued'), ('check','running')"); | |
| 29 | expect(crewRunActive(db)).toBe(false); | |
| 30 | db.run("INSERT INTO run VALUES ('agent','running')"); | |
| 31 | expect(crewRunActive(db)).toBe(true); | |
| 32 | }); | |
| 33 | ||
| 34 | test("one browser at a time: a page waits while a picture holds the gate", async () => { | |
| 35 | const held = await browserGate.acquire(); | |
| 36 | let started = false; | |
| 37 | const waiting = withPage("/nonexistent/chromium", async () => 1).catch(() => (started = true)); | |
| 38 | await Bun.sleep(100); | |
| 39 | expect(started).toBe(false); | |
| 40 | held(); | |
| 41 | await waiting; | |
| 42 | expect(started).toBe(true); | |
| 43 | }); |
test/crew-interrupted-twice.test.ts added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | /** | |
| 2 | * A run cut off by a forge stop carries on once; cut off twice in a row the | |
| 3 | * forge says so on the issue and does not queue it again (#11539). | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 10 | import { openIssue } from "../src/actions/issues.ts"; | |
| 11 | import { createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { claimRun, createRun, getRun, listQueued } from "../src/core/runs.ts"; | |
| 14 | import { listComments } from "../src/core/issues.ts"; | |
| 15 | import { requeueInterrupted } from "../src/crew/dispatch.ts"; | |
| 16 | import { INTERRUPTED_TWICE_NOTE, interruptedInARow } from "../src/crew/interrupt_loop.ts"; | |
| 17 | import { STALE_AFTER_MS } from "../src/runs/lease.ts"; | |
| 18 | import { createUser } from "../src/core/users.ts"; | |
| 19 | ||
| 20 | let dataDir = ""; | |
| 21 | let app: App; | |
| 22 | ||
| 23 | beforeAll(async () => { | |
| 24 | dataDir = await mkdtemp(join(tmpdir(), "forge-twice-")); | |
| 25 | app = createApp({ dataDir, port: 0 }); | |
| 26 | }); | |
| 27 | afterAll(async () => { | |
| 28 | app.close(); | |
| 29 | await rm(dataDir, { recursive: true, force: true }); | |
| 30 | }); | |
| 31 | ||
| 32 | test("the first stop queues the run again; the second says so and does not", async () => { | |
| 33 | const made = await createUser(app.db, { | |
| 34 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 35 | password: "a-good-passphrase", adminIfFirst: true, | |
| 36 | }); | |
| 37 | if (!made.ok) throw new Error(made.error); | |
| 38 | const alice = made.value; | |
| 39 | const bot = await createAgent(app, alice, { username: "bot" }); | |
| 40 | if (!bot.ok) throw new Error(String(bot.error)); | |
| 41 | const created = await createRepo(app, alice, { name: "loop", visibility: "private" }); | |
| 42 | if (!created.ok) throw new Error(String(created.error)); | |
| 43 | const repo = created.value; | |
| 44 | expect(addToCrew(app, alice, repo, "bot").ok).toBe(true); | |
| 45 | const issued = openIssue(app, alice, repo, { title: "Build it", body: "" }); | |
| 46 | if (!issued.ok) throw new Error(String(issued.error)); | |
| 47 | const issue = issued.value; | |
| 48 | ||
| 49 | const lapse = (id: string) => | |
| 50 | app.db.query("UPDATE run SET heartbeat_at = ?2 WHERE id = ?1").run(id, new Date(Date.now() - STALE_AFTER_MS - 1_000).toISOString()); | |
| 51 | const cutOff = (id: string) => { | |
| 52 | claimRun(app.db, id); | |
| 53 | lapse(id); | |
| 54 | requeueInterrupted(app); | |
| 55 | }; | |
| 56 | ||
| 57 | const first = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: issue.id, actorId: bot.value.user.id, reason: "assigned" }); | |
| 58 | cutOff(first.id); | |
| 59 | expect(getRun(app.db, first.id)!.state).toBe("cancelled"); | |
| 60 | const again = listQueued(app.db, "agent").filter((r) => r.issueId === issue.id && r.state === "queued"); | |
| 61 | expect(again).toHaveLength(1); | |
| 62 | expect(interruptedInARow(app.db, issue.id)).toBe(1); | |
| 63 | ||
| 64 | cutOff(again[0]!.id); | |
| 65 | expect(interruptedInARow(app.db, issue.id)).toBe(2); | |
| 66 | expect(listQueued(app.db, "agent").filter((r) => r.issueId === issue.id && r.state === "queued")).toHaveLength(0); | |
| 67 | expect(listComments(app.db, issue.id).some((c) => c.body === INTERRUPTED_TWICE_NOTE)).toBe(true); | |
| 68 | }); | |
| 69 | ||
| 70 | test("a run that ended another way between two stops breaks the row", () => { | |
| 71 | const rows = [{ summary: "interrupted by a restart; queued again" }, { summary: "built it" }, { summary: "interrupted by a restart; queued again" }]; | |
| 72 | const fake = { query: () => ({ all: () => rows }) } as never; | |
| 73 | expect(interruptedInARow(fake, "x")).toBe(1); | |
| 74 | }); |
test/host-stop-note.test.ts added +42
| @@ -0,0 +1,42 @@ | ||
| 1 | /** The owner is told on their home page that the host stopped their forge, and why (#11539). */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { NOTE_MS, noteStop, readStop, stopWords } from "../src/spawn/stop_note.ts"; | |
| 7 | ||
| 8 | test("a stop is noted, read back for a day, and then forgotten", () => { | |
| 9 | const dir = mkdtempSync(join(tmpdir(), "forge-stopnote-")); | |
| 10 | try { | |
| 11 | expect(readStop(dir)).toBeUndefined(); | |
| 12 | const at = new Date("2026-11-11T23:36:00Z"); | |
| 13 | noteStop(dir, "used 2.9 cores for 5 minutes while slowed, over twice its limit of 1", at); | |
| 14 | expect(readStop(dir, at.getTime() + 1000)?.why).toContain("over twice its limit of 1"); | |
| 15 | expect(readStop(dir, at.getTime() + NOTE_MS + 1)).toBeUndefined(); | |
| 16 | } finally { | |
| 17 | rmSync(dir, { recursive: true, force: true }); | |
| 18 | } | |
| 19 | }); | |
| 20 | ||
| 21 | test("the words give the time and the reason", () => { | |
| 22 | expect(stopWords({ at: "2026-11-11T23:36:00.000Z", why: "used too much" })).toBe( | |
| 23 | "This forge was stopped by its host at 23:36 UTC: used too much. It started again, and the crew carries on where it stopped.", | |
| 24 | ); | |
| 25 | }); | |
| 26 | ||
| 27 | test("the owner's home page says so; a forge never stopped says nothing", async () => { | |
| 28 | const { startAppsForge, signedInCookie, stopAppsForge } = await import("./helpers/apps-forge.ts"); | |
| 29 | const forge = await startAppsForge(); | |
| 30 | try { | |
| 31 | const cookie = await signedInCookie(forge.servers); | |
| 32 | const read = async () => (await fetch(`${forge.servers.url}/`, { headers: { cookie } })).text(); | |
| 33 | expect(await read()).not.toContain("host-stopped"); | |
| 34 | noteStop(forge.app.config.dataDir, "used 2.9 cores for 5 minutes while slowed, over twice its limit of 1"); | |
| 35 | const page = await read(); | |
| 36 | expect(page).toContain('id="host-stopped"'); | |
| 37 | expect(page).toContain("This forge was stopped by its host"); | |
| 38 | expect(page).toContain("over twice its limit of 1"); | |
| 39 | } finally { | |
| 40 | await stopAppsForge(forge); | |
| 41 | } | |
| 42 | }, 30_000); |
SIGSTOPped pids are resumed by a timer only. A host crash leaves the tree frozen, and the pids may be reused by the time the timer fires. Re-check before signalling and resume on boot.