Public: anyone can read this; the crew can change it.
Settings says what a sized forge's cores mean for builds (Compute, both doors) #11733
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11694-settings-says-what-a-sized-forge-s-cores → main 5 commits fork
8 files
changed +266
−5
docs/features/cores-row.md added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | # What a sized forge's cores mean for builds (#11694) | |
| 2 | ||
| 3 | A child forge's admins see one `Cores` row on `/settings`, next to the Plan row: `1 CPU: builds run, slower.` and how the cores are held. The terminal's palette entry *Cores* (`src/tui/screens/cores.ts`) prints the same sentence (`coresLine`, `src/spawn/cores.ts`). | |
| 4 | ||
| 5 | The forge reads how it is held from itself, never from a promise: | |
| 6 | ||
| 7 | - **quota**: its cgroup's `cpu.max` names a quota. The kernel slows a busy build; nothing stops the forge. | |
| 8 | - **affinity**: its allowed CPUs (`/proc/self/status`) are no more than its cores (`taskset`). Slowed the same way. | |
| 9 | - **watch**: nothing holds it; a build above twice its cores for 5 minutes is stopped, the first 10 minutes are safe (`src/apps/cpu_rule.ts`, numbers read from it). | |
| 10 | ||
| 11 | A forge that is no cell child (no `FORGE_CELL_SIZE`) shows no row and is not offered the entry. Members who are not admins see neither. |
src/spawn/cores.ts added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | /** | |
| 2 | * What a sized forge's cores mean for its builds (#11694), said once for the | |
| 3 | * Settings row and the terminal screen. A child in a cell is held to its | |
| 4 | * size's cores one of three ways, and the words differ: the kernel's quota | |
| 5 | * (`cpu.max`), CPU affinity (`taskset`, `apps/contain.ts`), or only the watch | |
| 6 | * (`apps/cpu_rule.ts`). The child reads which from its own cgroup and its own | |
| 7 | * allowed CPUs; nothing here changes a limit. | |
| 8 | */ | |
| 9 | ||
| 10 | import { readFileSync } from "node:fs"; | |
| 11 | import { availableParallelism } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { CPU_STOP_FACTOR, CPU_STOP_GRACE_MS, CPU_STOP_HOLD_MS } from "../apps/cpu_rule.ts"; | |
| 14 | import { CGROUP_DIR_ENV, SIZE_ENV } from "./usage.ts"; | |
| 15 | import { isSize, SIZES } from "./sizes.ts"; | |
| 16 | ||
| 17 | export type CoresHold = "quota" | "affinity" | "watch"; | |
| 18 | ||
| 19 | export interface Cores { | |
| 20 | readonly cpus: number; | |
| 21 | readonly hold: CoresHold; | |
| 22 | } | |
| 23 | ||
| 24 | export interface CoresSource { | |
| 25 | readonly env: Readonly<Record<string, string | undefined>>; | |
| 26 | readonly read: (path: string) => string | undefined; | |
| 27 | readonly machine: number; | |
| 28 | } | |
| 29 | ||
| 30 | /** `cpu.max` held to a number of cores: "200000 100000" is a quota, "max 100000" none. */ | |
| 31 | export const hasQuota = (cpuMax: string | undefined): boolean => /^\d+\s/.test(cpuMax?.trim() ?? ""); | |
| 32 | ||
| 33 | /** How many CPUs a `Cpus_allowed_list` ("0-3,8") names; undefined when unreadable. */ | |
| 34 | export function allowedCpus(status: string | undefined): number | undefined { | |
| 35 | const m = status === undefined ? null : /^Cpus_allowed_list:\s*(\S+)/m.exec(status); | |
| 36 | if (m === null) return undefined; | |
| 37 | let n = 0; | |
| 38 | for (const part of m[1]!.split(",")) { | |
| 39 | const r = /^(\d+)(?:-(\d+))?$/.exec(part); | |
| 40 | if (r === null) return undefined; | |
| 41 | n += (r[2] === undefined ? 0 : Number(r[2]) - Number(r[1])) + 1; | |
| 42 | } | |
| 43 | return n; | |
| 44 | } | |
| 45 | ||
| 46 | const readOr = (path: string): string | undefined => { | |
| 47 | try { | |
| 48 | return readFileSync(path, "utf8"); | |
| 49 | } catch { | |
| 50 | return undefined; | |
| 51 | } | |
| 52 | }; | |
| 53 | ||
| 54 | export const realCores = (): CoresSource => ({ env: process.env, read: readOr, machine: availableParallelism() }); | |
| 55 | ||
| 56 | /** This forge's cores and how they are held; null when it is no cell child (no size in its environment). */ | |
| 57 | export function coresOf(s: CoresSource = realCores()): Cores | null { | |
| 58 | const size = s.env[SIZE_ENV]; | |
| 59 | if (!isSize(size)) return null; | |
| 60 | const cpus = SIZES[size].cpu; | |
| 61 | const dir = s.env[CGROUP_DIR_ENV]; | |
| 62 | if (dir !== undefined && dir.startsWith("/") && hasQuota(s.read(join(dir, "cpu.max")))) return { cpus, hold: "quota" }; | |
| 63 | const allowed = allowedCpus(s.read("/proc/self/status")); | |
| 64 | if (allowed !== undefined && allowed < s.machine && allowed <= cpus) return { cpus, hold: "affinity" }; | |
| 65 | return { cpus, hold: "watch" }; | |
| 66 | } | |
| 67 | ||
| 68 | const coreWord = (n: number): string => `${n} ${n === 1 ? "CPU" : "CPUs"}`; | |
| 69 | const minutes = (ms: number): string => `${ms / 60_000} minutes`; | |
| 70 | ||
| 71 | /** One sentence for the Settings row and the terminal: what the cores mean for a build. */ | |
| 72 | export function coresWords(c: Cores): string { | |
| 73 | const head = `${coreWord(c.cpus)}: builds run${c.cpus === 1 ? ", slower" : ""}.`; | |
| 74 | if (c.hold === "quota") return `${head} The kernel holds this forge to its cores, so a busy build is slowed, never stopped.`; | |
| 75 | if (c.hold === "affinity") return `${head} This forge is pinned to its cores, so a busy build is slowed, never stopped.`; | |
| 76 | return `${head} Nothing limits this forge to its cores. A build that stays above ${CPU_STOP_FACTOR === 2 ? "twice" : `${CPU_STOP_FACTOR}×`} its cores for ${minutes(CPU_STOP_HOLD_MS)} is stopped; the first ${minutes(CPU_STOP_GRACE_MS)} are safe.`; | |
| 77 | } | |
| 78 | ||
| 79 | /** The line, or undefined on a forge that is no cell child. */ | |
| 80 | export function coresLine(s: CoresSource = realCores()): string | undefined { | |
| 81 | const c = coresOf(s); | |
| 82 | return c === null ? undefined : coresWords(c); | |
| 83 | } |
src/tui/screens/cores.ts added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | /** What this forge's cores mean for builds, in the terminal (#11694): the Settings row's own words (`coresLine`). Admins only, as the row is. */ | |
| 2 | ||
| 3 | import { coresLine } from "../../spawn/cores.ts"; | |
| 4 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 5 | import { style } from "../term/style.ts"; | |
| 6 | import { wrap } from "../term/width.ts"; | |
| 7 | import type { Session } from "../session.ts"; | |
| 8 | import type { PaletteEntry } from "./entries.ts"; | |
| 9 | ||
| 10 | export function coresScreen(f: Session, ctx: Ctx, line: string | undefined = coresLine()): Screen { | |
| 11 | const allowed = f.user?.isAdmin === true; | |
| 12 | return { | |
| 13 | title: "Cores", | |
| 14 | keys: [], | |
| 15 | render(area, s) { | |
| 16 | const c = ctx.theme.c; | |
| 17 | const text = !allowed ? "Only admins see this." : (line ?? "This forge is no sized child: its cores are the machine's."); | |
| 18 | wrap(text, Math.max(20, area.w - 2)).forEach((l, i) => { | |
| 19 | if (i < area.h) s.text(area.x + 1, area.y + i, l, style(c.fg, c.bg), area.x + area.w); | |
| 20 | }); | |
| 21 | }, | |
| 22 | key: () => false, | |
| 23 | }; | |
| 24 | } | |
| 25 | ||
| 26 | export const entry: PaletteEntry = { | |
| 27 | id: "cores", | |
| 28 | title: "Cores", | |
| 29 | hint: "what this forge's cores mean for builds", | |
| 30 | after: ["plan-row", "limits"], | |
| 31 | offered: (session) => session.user?.isAdmin === true && coresLine() !== undefined, | |
| 32 | run: (session, ctx) => { | |
| 33 | if (session.user?.isAdmin === true) ctx.push(coresScreen(session, ctx)); | |
| 34 | }, | |
| 35 | }; |
src/tui/screens/entries.ts +2
| @@ -52,6 +52,7 @@ import { entry as claimStart } from "./claim_start.ts"; | ||
| 52 | 52 | import { entry as invites } from "./invites.ts"; |
| 53 | 53 | import { entry as billingLinks } from "./billinglinks.ts"; |
| 54 | 54 | import { entry as planRow } from "./planrow.ts"; |
| 55 | import { entry as cores } from "./cores.ts"; | |
| 55 | 56 | import { entry as limits } from "./limits.ts"; |
| 56 | 57 | import { entry as plans } from "./plans.ts"; |
| 57 | 58 | import { entry as economics } from "./economics.ts"; |
| @@ -170,6 +171,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 170 | 171 | invites, |
| 171 | 172 | billingLinks, |
| 172 | 173 | planRow, |
| 174 | cores, | |
| 173 | 175 | claimStart, |
| 174 | 176 | requests, |
| 175 | 177 | bulk, |
src/web/cores_row.ts added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | /** | |
| 2 | * The Cores row on a sized forge's /settings (#11694): one `div` row for its admins, placed in the Plan row's `dl`; | |
| 3 | * null for anyone else or a forge no cell holds. | |
| 4 | */ | |
| 5 | ||
| 6 | import { html, type Html } from "./html.ts"; | |
| 7 | ||
| 8 | export function coresRow(admin: boolean, line: string | undefined): Html | null { | |
| 9 | if (!admin || line === undefined) return null; | |
| 10 | return html`<div class="status-row" data-cores><dt>Cores</dt><dd>${line}</dd></div>`; | |
| 11 | } |
src/web/pages/account.ts +5 −1
| @@ -63,6 +63,8 @@ import { accountSections } from "./origins.ts"; | ||
| 63 | 63 | import { field, readForm } from "../form.ts"; |
| 64 | 64 | import { html, when, type Html } from "../html.ts"; |
| 65 | 65 | import { planRow } from "../plan_row.ts"; |
| 66 | import { coresRow } from "../cores_row.ts"; | |
| 67 | import { coresLine } from "../../spawn/cores.ts"; | |
| 66 | 68 | import { flashTo, page } from "../layout.ts"; |
| 67 | 69 | import { readFlash } from "../flash.ts"; |
| 68 | 70 | import { |
| @@ -516,11 +518,13 @@ export function accountPage(ctx: Ctx): Response { | ||
| 516 | 518 | const parent = timePhase(ctx.request, "parent", () => parentLinkView(ctx)); |
| 517 | 519 | const notify = timePhase(ctx.request, "notify", () => notifyView(ctx)); |
| 518 | 520 | const rendering = performance.now(); |
| 521 | const admin = user.isAdmin === true; | |
| 522 | const plan = planRow(ctx.app.config.dataDir, parentIssuerOf(ctx.app.config), admin, coresRow(admin, coresLine())); | |
| 519 | 523 | const response = accountShell( |
| 520 | 524 | ctx, |
| 521 | 525 | "profile", |
| 522 | 526 | "Settings", |
| 523 | html`${planRow(ctx.app.config.dataDir, parentIssuerOf(ctx.app.config), user.isAdmin === true)}${pair}${profile}${edge}${parent}${notify}`, | |
| 527 | html`${plan}${pair}${profile}${edge}${parent}${notify}`, | |
| 524 | 528 | 200, |
| 525 | 529 | // The everyday form makes its account with a passkey (#1890). |
| 526 | 530 | pair ? passkeyJs : undefined, |
src/web/plan_row.ts +7 −4
| @@ -39,11 +39,14 @@ export function planLine(dataDir: string): string | undefined { | ||
| 39 | 39 | return `${n.name}${size}. ${statusWords(n)}.${credit}`; |
| 40 | 40 | } |
| 41 | 41 | |
| 42 | /** One `dl` row for a forge's admins; null for anyone else or a forge told nothing. */ | |
| 43 | export function planRow(dataDir: string, parentOrigin: string | undefined, admin: boolean): Html | null { | |
| 42 | /** | |
| 43 | * One `dl` row for a forge's admins; null for anyone else or a forge told nothing. `more` is a row to share the same `dl` | |
| 44 | * (the Cores row), so the facts read as one list. | |
| 45 | */ | |
| 46 | export function planRow(dataDir: string, parentOrigin: string | undefined, admin: boolean, more: Html | null = null): Html | null { | |
| 44 | 47 | if (!admin) return null; |
| 45 | 48 | const line = planLine(dataDir); |
| 46 | if (line === undefined) return null; | |
| 49 | if (line === undefined) return more === null ? null : html`<dl class="status-rows">${more}</dl>`; | |
| 47 | 50 | const n = noticeOf(dataDir); |
| 48 | 51 | const portal = n?.portalUrl; |
| 49 | 52 | // In grace the one link that matters is Pay: the provider's portal, else the host's pricing page. |
| @@ -55,5 +58,5 @@ export function planRow(dataDir: string, parentOrigin: string | undefined, admin | ||
| 55 | 58 | }${ |
| 56 | 59 | // One link per place: when Pay already goes to the host's pricing page, Change size would repeat it. |
| 57 | 60 | parentOrigin === undefined || pay === `${parentOrigin}/pricing` ? "" : html` <a href="${parentOrigin}/pricing">Change size</a>` |
| 58 | }</dd></div></dl>`; | |
| 61 | }</dd></div>${more}</dl>`; | |
| 59 | 62 | } |
test/cores-row.test.ts added +112
| @@ -0,0 +1,112 @@ | ||
| 1 | /** #11694: Settings and the terminal say what a sized forge's cores mean for builds, in the same words. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 8 | import { allowedCpus, coresLine, coresOf, coresWords, type CoresSource } from "../src/spawn/cores.ts"; | |
| 9 | import { coresRow } from "../src/web/cores_row.ts"; | |
| 10 | import { coresScreen, entry } from "../src/tui/screens/cores.ts"; | |
| 11 | import { adminCookie } from "./helpers/admin.ts"; | |
| 12 | import { quietLogs } from "./helpers/globals.ts"; | |
| 13 | ||
| 14 | quietLogs(); | |
| 15 | ||
| 16 | const src = (files: Record<string, string>, env: Record<string, string> = { FORGE_CELL_SIZE: "small", FORGE_CELL_CGROUP_DIR: "/cg" }, machine = 8): CoresSource => ({ | |
| 17 | env, | |
| 18 | read: (p) => files[p], | |
| 19 | machine, | |
| 20 | }); | |
| 21 | ||
| 22 | test("allowedCpus counts a list", () => { | |
| 23 | expect(allowedCpus("Cpus_allowed_list:\t0-3,8\n")).toBe(5); | |
| 24 | expect(allowedCpus("Cpus_allowed_list: 2\n")).toBe(1); | |
| 25 | expect(allowedCpus("nothing")).toBeUndefined(); | |
| 26 | }); | |
| 27 | ||
| 28 | test("how the cores are held is read, not assumed", () => { | |
| 29 | expect(coresOf(src({ "/cg/cpu.max": "100000 100000\n" }))).toEqual({ cpus: 1, hold: "quota" }); | |
| 30 | expect(coresOf(src({ "/cg/cpu.max": "max 100000", "/proc/self/status": "Cpus_allowed_list: 3\n" }))).toEqual({ cpus: 1, hold: "affinity" }); | |
| 31 | expect(coresOf(src({ "/proc/self/status": "Cpus_allowed_list: 0-7\n" }))).toEqual({ cpus: 1, hold: "watch" }); | |
| 32 | expect(coresOf(src({}, {}))).toBeNull(); | |
| 33 | expect(coresOf(src({}, { FORGE_CELL_SIZE: "huge" }))).toBeNull(); | |
| 34 | }); | |
| 35 | ||
| 36 | test("one CPU says builds run, slower; the watch's cost is stated", () => { | |
| 37 | expect(coresWords({ cpus: 1, hold: "quota" })).toContain("1 CPU: builds run, slower."); | |
| 38 | expect(coresWords({ cpus: 2, hold: "affinity" })).toStartWith("2 CPUs: builds run."); | |
| 39 | const w = coresWords({ cpus: 1, hold: "watch" }); | |
| 40 | expect(w).toContain("Nothing limits this forge to its cores. A build that stays above twice its cores for 5 minutes is stopped; the first 10 minutes are safe."); | |
| 41 | }); | |
| 42 | ||
| 43 | test("the row is for admins of a sized forge only", () => { | |
| 44 | const line = "1 CPU: builds run, slower."; | |
| 45 | expect(coresRow(true, line)?.toString()).toContain(`<dt>Cores</dt><dd>${line}</dd>`); | |
| 46 | expect(coresRow(false, line)).toBeNull(); | |
| 47 | expect(coresRow(true, undefined)).toBeNull(); | |
| 48 | }); | |
| 49 | ||
| 50 | test("the terminal refuses a non-admin: the screen says nothing and run pushes nothing", () => { | |
| 51 | const ctx = { theme: { c: new Proxy({}, { get: () => 0 }) } } as never; | |
| 52 | const texts: string[] = []; | |
| 53 | const member = { user: { isAdmin: false } } as never; | |
| 54 | coresScreen(member, ctx, "1 CPU: builds run.").render({ x: 0, y: 0, w: 100, h: 5 }, { text: (_x: number, _y: number, t: string) => texts.push(t) } as never); | |
| 55 | expect(texts.join(" ")).not.toContain("CPU"); | |
| 56 | let pushed = 0; | |
| 57 | entry.run(member, { push: () => pushed++ } as never); | |
| 58 | expect(pushed).toBe(0); | |
| 59 | entry.run({ user: { isAdmin: true } } as never, { push: () => pushed++, theme: { c: {} } } as never); | |
| 60 | expect(pushed).toBe(1); | |
| 61 | }); | |
| 62 | ||
| 63 | test("the terminal screen prints the row's line", () => { | |
| 64 | const line = coresWords({ cpus: 1, hold: "quota" }); | |
| 65 | expect(coresLine(src({ "/cg/cpu.max": "100000 100000" }))).toBe(line); | |
| 66 | const screen = coresScreen({ user: { isAdmin: true } } as never, { theme: { c: new Proxy({}, { get: () => 0 }) } } as never, line); | |
| 67 | const texts: string[] = []; | |
| 68 | screen.render({ x: 0, y: 0, w: 100, h: 5 }, { text: (_x: number, _y: number, t: string) => texts.push(t) } as never); | |
| 69 | expect(texts.join(" ")).toContain("1 CPU: builds run, slower."); | |
| 70 | }); | |
| 71 | ||
| 72 | let dir = ""; | |
| 73 | let app: App; | |
| 74 | let servers: Servers; | |
| 75 | let cookie = ""; | |
| 76 | const saved = process.env["FORGE_CELL_SIZE"]; | |
| 77 | ||
| 78 | beforeAll(async () => { | |
| 79 | process.env["FORGE_CELL_SIZE"] = "small"; | |
| 80 | dir = await mkdtemp(join(tmpdir(), "forge-cores-")); | |
| 81 | app = createApp({ dataDir: dir, port: 0 }); | |
| 82 | servers = await serve(app); | |
| 83 | cookie = await adminCookie(app, { username: "olive", email: "[email protected]", password: "settings-password" }); | |
| 84 | }, 60_000); | |
| 85 | ||
| 86 | afterAll(async () => { | |
| 87 | if (saved === undefined) delete process.env["FORGE_CELL_SIZE"]; | |
| 88 | else process.env["FORGE_CELL_SIZE"] = saved; | |
| 89 | servers.stop(); | |
| 90 | app.close(); | |
| 91 | await rm(dir, { recursive: true, force: true }); | |
| 92 | }); | |
| 93 | ||
| 94 | const settings = async (): Promise<string> => (await fetch(`${servers.url}/settings`, { headers: { cookie } })).text(); | |
| 95 | ||
| 96 | test("/settings shows the Cores row for the quota hold and for the watch, in the one dl", async () => { | |
| 97 | const cg = join(dir, "cg"); | |
| 98 | await mkdir(cg, { recursive: true }); | |
| 99 | process.env["FORGE_CELL_CGROUP_DIR"] = cg; | |
| 100 | await writeFile(join(cg, "cpu.max"), "100000 100000\n"); | |
| 101 | const quota = await settings(); | |
| 102 | expect(quota).toContain("<dt>Cores</dt>"); | |
| 103 | expect(quota).toContain("The kernel holds this forge to its cores"); | |
| 104 | await writeFile(join(cg, "cpu.max"), "max 100000\n"); | |
| 105 | const open = await settings(); | |
| 106 | expect(open).toMatch(/Nothing limits this forge to its cores|pinned to its cores/); | |
| 107 | // One list: the Cores row sits in the dl that holds no second hairline of its own. | |
| 108 | expect(open.match(/<dl class="status-rows"><div class="status-row" data-cores>/g)?.length ?? 0).toBe(1); | |
| 109 | delete process.env["FORGE_CELL_CGROUP_DIR"]; | |
| 110 | delete process.env["FORGE_CELL_SIZE"]; | |
| 111 | expect(await settings()).not.toContain("<dt>Cores</dt>"); | |
| 112 | }); |
new above
offline