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

Merged opened this

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

Merged

scout/forge:scout/11694-settings-says-what-a-sized-forge-s-cores → main 5 commits fork

  • Merged. scout/11694-settings-says-what-a-sized-forge-s-cores was merged into main as eebb066.

Review

Sign in to review this change.
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";
5252 import { entry as invites } from "./invites.ts";
5353 import { entry as billingLinks } from "./billinglinks.ts";
5454 import { entry as planRow } from "./planrow.ts";
55 import { entry as cores } from "./cores.ts";
5556 import { entry as limits } from "./limits.ts";
5657 import { entry as plans } from "./plans.ts";
5758 import { entry as economics } from "./economics.ts";
@@ -170,6 +171,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
170171 invites,
171172 billingLinks,
172173 planRow,
174 cores,
173175 claimStart,
174176 requests,
175177 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";
6363 import { field, readForm } from "../form.ts";
6464 import { html, when, type Html } from "../html.ts";
6565 import { planRow } from "../plan_row.ts";
66 import { coresRow } from "../cores_row.ts";
67 import { coresLine } from "../../spawn/cores.ts";
6668 import { flashTo, page } from "../layout.ts";
6769 import { readFlash } from "../flash.ts";
6870 import {
@@ -516,11 +518,13 @@ export function accountPage(ctx: Ctx): Response {
516518 const parent = timePhase(ctx.request, "parent", () => parentLinkView(ctx));
517519 const notify = timePhase(ctx.request, "notify", () => notifyView(ctx));
518520 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()));
519523 const response = accountShell(
520524 ctx,
521525 "profile",
522526 "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}`,
524528 200,
525529 // The everyday form makes its account with a passkey (#1890).
526530 pair ? passkeyJs : undefined,
src/web/plan_row.ts +7 −4
@@ -39,11 +39,14 @@ export function planLine(dataDir: string): string | undefined {
3939 return `${n.name}${size}. ${statusWords(n)}.${credit}`;
4040 }
4141
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 {
4447 if (!admin) return null;
4548 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>`;
4750 const n = noticeOf(dataDir);
4851 const portal = n?.portalUrl;
4952 // 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
5558 }${
5659 // One link per place: when Pay already goes to the host's pricing page, Change size would repeat it.
5760 parentOrigin === undefined || pay === `${parentOrigin}/pricing` ? "" : html` <a href="${parentOrigin}/pricing">Change size</a>`
58 }</dd></div></dl>`;
61 }</dd></div>${more}</dl>`;
5962 }
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs