Public: anyone can read this; the crew can change it.

Pull #3151's check-cell memory line clears its five review comments and lands #3154

Merged opened this

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

Merged

scout/forge:scout/3153-pull-3151-s-check-cell-memory-line-clear → main 10 commits fork

  • Merged. scout/3153-pull-3151-s-check-cell-memory-line-clear was merged into main as ee9ff08.

Review

Sign in to review this change.
7 files changed +397 −8
README.md +2
@@ -221,6 +221,8 @@ The repository's settings page is one more door: a writer sees their proposal
221221 tokens one to a line (child, origin, expires, last used) with Revoke, and a
222222 form that mints one and shows its secret once; others see no section.
223223
224 The check cell's memory is on the same page under **Check cell**, and in the terminal on a repository's About card (`I`, for an admin): the limit the machine enforces, read from `forge.json` on the default branch. When `check.limits.memory` asks for more than the machine's ceiling it shows both numbers and where the cut comes from (`FORGE_CELL_MEMORY_MAX_MIB`, else a share of the pod's memory); otherwise one number. The ceiling is read once and kept (`src/apps/check-memory.ts`).
225
224226 - **MCP**: the `proposal_tokens` tool with `repo` and a `verb`: `mint`
225227 (`child`, `origin`, optionally `childRepo`), `list` (the ones you minted for
226228 the repository) or `revoke` (`id`).
src/apps/check-memory.ts added +94
@@ -0,0 +1,94 @@
1 /**
2 * The check cell's memory in words: what forge.json asks for and what the
3 * machine holds the cell to. One number when they agree; both, and where the
4 * cut comes from, when forge.json asks for more. The settings page and the
5 * terminal say the same sentence.
6 */
7
8 import { readFileAt } from "./checkout.ts";
9 import { memoryCeilingSource, POD_MEMORY_FILE, POD_MEMORY_SHARE, resolveLimits, type Limits } from "./contain.ts";
10 import { parseCheck } from "./manifest.ts";
11
12 interface Held {
13 readonly limits: Limits;
14 readonly from: "env" | "pod" | "default";
15 }
16
17 const held = new Map<string, Held>();
18
19 let podFile = POD_MEMORY_FILE;
20
21 /** Read the pod's memory limit from `path` (a test's fixture); the returned function puts the real file back. */
22 export function usePodMemoryFile(path: string): () => void {
23 podFile = path;
24 return () => {
25 podFile = POD_MEMORY_FILE;
26 };
27 }
28
29 /**
30 * The limits a check cell gets for a declared memory. `resolveLimits` reads
31 * the pod's cgroup file, so the answer is kept per (env, declaration) and the
32 * file is read once, not on every request.
33 */
34 function cellLimits(declared: number | undefined): Held {
35 const env = process.env["FORGE_CELL_MEMORY_MAX_MIB"];
36 const key = `${podFile}|${env ?? ""}|${declared ?? ""}`;
37 let kept = held.get(key);
38 if (!kept) {
39 kept = {
40 limits: resolveLimits("step", declared === undefined ? undefined : { memoryMiB: declared }, podFile),
41 from: memoryCeilingSource(env, podFile),
42 };
43 if (held.size >= 64) held.clear();
44 held.set(key, kept);
45 }
46 return kept;
47 }
48
49 /** Where the ceiling a cut was held to comes from, in words. */
50 export function ceilingWords(from: "env" | "pod" | "default"): string {
51 return from === "env"
52 ? "the machine's ceiling, FORGE_CELL_MEMORY_MAX_MIB"
53 : from === "pod"
54 ? `${Math.round(POD_MEMORY_SHARE * 100)}% of the pod's memory.max`
55 : "the machine's default ceiling; the pod states no memory limit";
56 }
57
58 /** The sentence for a declared memory; with none declared it says the default holds. Only validated numbers enter it. */
59 export function checkMemoryWords(declared: number | undefined): string {
60 const { limits: cell, from } = cellLimits(declared);
61 if (declared === undefined) return `The check cell holds ${cell.memoryMiB} MiB (the default; forge.json declares none).`;
62 if (cell.memorySource !== "ceiling") return `The check cell holds ${cell.memoryMiB} MiB.`;
63 return `forge.json asks ${declared} MiB; the cell holds ${cell.memoryMiB} MiB (${ceilingWords(from)}).`;
64 }
65
66 /** What forge.json declared at a commit: `null` unreadable, `undefined` no check, else its memory (or none). */
67 type Declared = null | undefined | { readonly memoryMiB: number | undefined };
68
69 const declaredAt = new Map<string, Declared>();
70
71 /**
72 * The sentence for the check forge.json declares at `oid`. `undefined` when
73 * it declares none; `null` when it declares one that cannot be read. A commit
74 * never changes, so what it declares is read from git once per (dir, oid).
75 */
76 export async function checkMemoryAt(
77 dir: string,
78 oid: string | undefined,
79 ): Promise<string | null | undefined> {
80 if (oid === undefined) return undefined;
81 const key = `${dir}|${oid}`;
82 let declared = declaredAt.get(key);
83 if (declared === undefined && !declaredAt.has(key)) {
84 const [forgeJson, packageJson] = await Promise.all([
85 readFileAt(dir, oid, "forge.json"),
86 readFileAt(dir, oid, "package.json"),
87 ]);
88 const check = parseCheck(forgeJson, packageJson);
89 declared = !check.ok ? null : check.value === undefined ? undefined : { memoryMiB: check.value.limits.memoryMiB };
90 if (declaredAt.size >= 256) declaredAt.clear();
91 declaredAt.set(key, declared);
92 }
93 return declared === null ? null : declared === undefined ? undefined : checkMemoryWords(declared.memoryMiB);
94 }
src/apps/contain.ts +26 −7
@@ -63,14 +63,17 @@ export const DEFAULT_LIMITS: Record<"app" | "step", Limits> = {
6363 };
6464
6565 /** The most any declaration gets on this machine. */
66 export function maxLimits(): Limits {
66 export function maxLimits(podFile = POD_MEMORY_FILE): Limits {
6767 return {
68 memoryMiB: memoryCeiling(process.env["FORGE_CELL_MEMORY_MAX_MIB"]),
68 memoryMiB: memoryCeiling(process.env["FORGE_CELL_MEMORY_MAX_MIB"], podFile),
6969 cpus: availableParallelism(),
7070 diskMiB: positive(process.env["FORGE_CELL_DISK_MAX_MIB"]) ?? 16384,
7171 };
7272 }
7373
74 /** The pod's own memory limit, where cgroup v2 states it. */
75 export const POD_MEMORY_FILE = "/sys/fs/cgroup/memory.max";
76
7477 /** The share of the pod's memory a single cell may be given. */
7578 export const POD_MEMORY_SHARE = 0.75;
7679
@@ -81,18 +84,33 @@ export const POD_MEMORY_SHARE = 0.75;
8184 */
8285 export function memoryCeiling(
8386 env: string | undefined,
84 podFile = "/sys/fs/cgroup/memory.max",
87 podFile = POD_MEMORY_FILE,
8588 ): number {
89 return ceiling(env, podFile).mib;
90 }
91
92 /** Where the ceiling comes from: the variable, the pod's `memory.max`, or the fixed default. */
93 export function memoryCeilingSource(
94 env: string | undefined,
95 podFile = POD_MEMORY_FILE,
96 ): "env" | "pod" | "default" {
97 return ceiling(env, podFile).from;
98 }
99
100 function ceiling(
101 env: string | undefined,
102 podFile: string,
103 ): { mib: number; from: "env" | "pod" | "default" } {
86104 const set = positive(env);
87 if (set !== undefined) return set;
105 if (set !== undefined) return { mib: set, from: "env" };
88106 try {
89107 const bytes = Number(readFileSync(podFile, "utf8").trim());
90108 if (Number.isFinite(bytes) && bytes > 0)
91 return Math.max(1, Math.floor((bytes * POD_MEMORY_SHARE) / 1048576));
109 return { mib: Math.max(1, Math.floor((bytes * POD_MEMORY_SHARE) / 1048576)), from: "pod" };
92110 } catch {
93111 // no cgroup v2 here, or "max" (unlimited): the fixed default below
94112 }
95 return 4096;
113 return { mib: 4096, from: "default" };
96114 }
97115
98116 /** The line saying a declared memory limit is cut to the machine's ceiling; none when it is not. */
@@ -122,9 +140,10 @@ function positive(raw: string | undefined): number | undefined {
122140 export function resolveLimits(
123141 kind: "app" | "step",
124142 declared: DeclaredLimits | undefined,
143 podFile = POD_MEMORY_FILE,
125144 ): Limits {
126145 const base = DEFAULT_LIMITS[kind];
127 const max = maxLimits();
146 const max = maxLimits(podFile);
128147 const want = declared?.memoryMiB;
129148 return {
130149 memoryMiB: Math.min(want ?? base.memoryMiB, max.memoryMiB),
src/tui/screens/about.ts +27 −1
@@ -19,13 +19,16 @@ import { audienceFor } from "../../core/audience.ts";
1919 import { isPinned } from "../../core/pins.ts";
2020 import { parentReleases, releasesSentence } from "../../core/parent_releases.ts";
2121 import { running } from "../../kernel/version.ts";
22 import { checkMemoryAt } from "../../apps/check-memory.ts";
23 import { repoDir } from "../../git/repos.ts";
24 import { listRefs } from "../../git/read.ts";
2225 import { updateRepoSettings } from "../../actions/repos.ts";
2326 import { canAdmin, getRepoById, type Repo } from "../../core/repos.ts";
2427 import type { RepoFacts, RepoSource } from "../../view/source.ts";
2528 import type { Ctx, Screen } from "../app/app.ts";
2629 import { binding } from "../app/keys.ts";
2730 import { style } from "../term/style.ts";
28 import { truncate } from "../term/width.ts";
31 import { truncate, wrap } from "../term/width.ts";
2932 import { badge, card } from "../ui/widgets.ts";
3033 import type { Session } from "../session.ts";
3134 import { PIN_MARK, ago, drawFacts, plural } from "./common.ts";
@@ -49,11 +52,34 @@ export function aboutScreen(
4952 let repo = localRepo(src);
5053 const may = (): boolean =>
5154 repo !== undefined && f.user !== undefined && canAdmin(f.db, repo, f.user);
55 let cell: [string, string][] = [];
5256 const factRows = (): [string, string][] => [
5357 ...archivedRow(repo),
5458 ...aboutFacts(f, src, facts, ref),
59 ...cell,
5560 ];
5661 let rows = factRows();
62 // An admin also sees the check cell's memory, read once from forge.json.
63 if (may() && repo !== undefined) {
64 const at = repo;
65 void (async () => {
66 const refs = await listRefs(repoDir(f.app.reposRoot, at.ownerName, at.name), "refs/heads/");
67 const tip = refs.ok
68 ? refs.value.find((r) => r.name === `refs/heads/${at.defaultBranch}`)?.oid
69 : undefined;
70 const words = await checkMemoryAt(repoDir(f.app.reposRoot, at.ownerName, at.name), tip);
71 if (words === undefined) return;
72 cell = wrap(words ?? "forge.json's check could not be read", 50).map(
73 (line, i): [string, string] => [i === 0 ? "check cell" : "", line],
74 );
75 rows = factRows();
76 ctx.invalidate();
77 })().catch(() => {
78 cell = [["check cell", "forge.json's check could not be read"]];
79 rows = factRows();
80 ctx.invalidate();
81 });
82 }
5783 const cloneUrl = rows.find(([label]) => label.startsWith("clone"))?.[1] ?? "";
5884 return {
5985 title: "About",
src/web/pages/settings.ts +12
@@ -105,6 +105,7 @@ import {
105105 import { cutWaveFor } from "../../actions/cut_wave.ts";
106106 import { proposalTokensSection } from "./proposal-tokens.ts";
107107 import { secretsSection } from "./secrets.ts";
108 import { checkMemoryAt } from "../../apps/check-memory.ts";
108109 import { saveGrants } from "./subscriptions.ts";
109110
110111 type Section =
@@ -231,6 +232,10 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
231232 const branches = refs.ok
232233 ? refs.value.map((r) => r.name.replace("refs/heads/", "")).sort()
233234 : [];
235 const tip = refs.ok
236 ? refs.value.find((r) => r.name === `refs/heads/${repo.defaultBranch}`)?.oid
237 : undefined;
238 const cellMemory = await checkMemoryAt(dir, tip);
234239 if (!branches.includes(repo.defaultBranch))
235240 branches.unshift(repo.defaultBranch);
236241
@@ -308,6 +313,13 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
308313 >
309314 </label>
310315 </div>
316 ${cellMemory === undefined
317 ? null
318 : html`<div class="field"><p class="hint" data-check-memory>${
319 cellMemory === null
320 ? "forge.json's check could not be read, so no limit is shown."
321 : cellMemory
322 }</p></div>`}
311323 <div class="form-actions">
312324 ${btn("Save changes", { variant: "primary" })}
313325 </div>
test/settings-check-memory.test.ts added +141
@@ -0,0 +1,141 @@
1 /** #3135: the settings page shows the check cell's memory the machine enforces, beside forge.json's. */
2
3 import { afterAll, beforeAll, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { commitToBranch } from "../src/actions/repos.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10 import { adminCookie } from "./helpers/admin.ts";
11 import { quietLogs } from "./helpers/globals.ts";
12
13 quietLogs();
14
15 let dir = "";
16 let app: App;
17 let servers: Servers;
18 let cookie = "";
19 const saved = process.env["FORGE_CELL_MEMORY_MAX_MIB"];
20
21 const check = (limits: string) =>
22 `{"check":{"run":"true","limits":${limits}}}`;
23
24 beforeAll(async () => {
25 process.env["FORGE_CELL_MEMORY_MAX_MIB"] = "4096";
26 dir = await mkdtemp(join(tmpdir(), "forge-cellmem-"));
27 app = createApp({ dataDir: dir, port: 0 });
28 servers = await serve(app);
29 cookie = await adminCookie(app, { username: "olive", email: "[email protected]", password: "settings-password" });
30 }, 60_000);
31
32 afterAll(async () => {
33 if (saved === undefined) delete process.env["FORGE_CELL_MEMORY_MAX_MIB"];
34 else process.env["FORGE_CELL_MEMORY_MAX_MIB"] = saved;
35 servers.stop();
36 app.close();
37 await rm(dir, { recursive: true, force: true });
38 });
39
40 async function repoWith(name: string, forgeJson: string): Promise<string> {
41 const res = await fetch(`${servers.url}/new`, {
42 method: "POST",
43 headers: { cookie },
44 body: new URLSearchParams({ name, visibility: "private", description: "x" }),
45 redirect: "manual",
46 });
47 expect(res.status).toBeLessThan(400);
48 const { getRepo } = await import("../src/core/repos.ts");
49 const { getUserByName } = await import("../src/core/users.ts");
50 const owner = getUserByName(app.db, "olive");
51 const repo = getRepo(app.db, "olive", name);
52 if (!owner || !repo) throw new Error("setup");
53 const c = await commitToBranch(app, owner, repo, {
54 message: "forge.json",
55 files: [{ path: "forge.json", content: forgeJson }],
56 });
57 expect(c.ok).toBe(true);
58 return await (await fetch(`${servers.url}/olive/${name}/settings`, { headers: { cookie } })).text();
59 }
60
61 test("a declared limit above the ceiling shows both numbers and the source", async () => {
62 const body = await repoWith("over", check('{"memory":8192}'));
63 expect(body).toContain("forge.json asks 8192 MiB");
64 expect(body).toContain("4096 MiB");
65 expect(body).toContain("FORGE_CELL_MEMORY_MAX_MIB");
66 });
67
68 test("a declared limit below the ceiling shows one number and no warning", async () => {
69 const body = await repoWith("under", check('{"memory":1024}'));
70 expect(body).toContain("1024 MiB");
71 expect(body).not.toContain("forge.json asks");
72 expect(body).not.toContain("FORGE_CELL_MEMORY_MAX_MIB");
73 });
74
75 test("with no ceiling set and nothing declared, the default is named", async () => {
76 delete process.env["FORGE_CELL_MEMORY_MAX_MIB"];
77 try {
78 const body = await repoWith("plain", '{"check":{"run":"true"}}');
79 expect(body).toContain("2048 MiB (the default; forge.json declares none)");
80 expect(body).not.toContain("forge.json asks");
81 } finally {
82 process.env["FORGE_CELL_MEMORY_MAX_MIB"] = "4096";
83 }
84 });
85
86 test("an unreadable check says so instead of vanishing", async () => {
87 const body = await repoWith("broken", '{"check":{"run":"true","limits":{"memory":"<b>x</b>"}}}');
88 expect(body).toContain("forge.json&#39;s check could not be read");
89 expect(body).not.toContain("<b>x</b>");
90 });
91
92 test("with no variable set, a cut is blamed on the pod, not on the variable", async () => {
93 delete process.env["FORGE_CELL_MEMORY_MAX_MIB"];
94 const { usePodMemoryFile } = await import("../src/apps/check-memory.ts");
95 const { writeFile } = await import("node:fs/promises");
96 const pod = join(dir, "pod-memory.max");
97 await writeFile(pod, String(8192 * 1048576));
98 const restore = usePodMemoryFile(pod);
99 try {
100 const body = await repoWith("pod", check('{"memory":65536}'));
101 expect(body).toContain("forge.json asks 65536 MiB");
102 expect(body).toContain("6144 MiB");
103 expect(body).toContain("75% of the pod&#39;s memory.max");
104 expect(body).not.toContain("FORGE_CELL_MEMORY_MAX_MIB");
105 } finally {
106 restore();
107 process.env["FORGE_CELL_MEMORY_MAX_MIB"] = "4096";
108 }
109 });
110
111 test("the ceiling's origin is named from the environment, then the pod file", async () => {
112 const { memoryCeilingSource } = await import("../src/apps/contain.ts");
113 const { writeFile } = await import("node:fs/promises");
114 const pod = join(dir, "memory.max");
115 await writeFile(pod, String(8192 * 1048576));
116 expect(memoryCeilingSource("2048", pod)).toBe("env");
117 expect(memoryCeilingSource(undefined, pod)).toBe("pod");
118 expect(memoryCeilingSource(undefined, join(dir, "absent"))).toBe("default");
119 });
120
121 test("each origin has its own words", async () => {
122 const { ceilingWords } = await import("../src/apps/check-memory.ts");
123 expect(ceilingWords("env")).toContain("FORGE_CELL_MEMORY_MAX_MIB");
124 expect(ceilingWords("pod")).toBe("75% of the pod's memory.max");
125 expect(ceilingWords("default")).not.toContain("FORGE_CELL_MEMORY_MAX_MIB");
126 });
127
128 test("what forge.json declares at a commit is read from git once", async () => {
129 const { checkMemoryAt } = await import("../src/apps/check-memory.ts");
130 const { repoDir } = await import("../src/git/repos.ts");
131 const { listRefs } = await import("../src/git/read.ts");
132 await repoWith("kept", check('{"memory":1024}'));
133 const at = repoDir(app.reposRoot, "olive", "kept");
134 const refs = await listRefs(at, "refs/heads/");
135 const oid = refs.ok ? refs.value.find((r) => r.name === "refs/heads/main")?.oid : undefined;
136 expect(oid).toBeDefined();
137 const first = await checkMemoryAt(at, oid);
138 expect(first).toContain("1024 MiB");
139 await rm(at, { recursive: true, force: true });
140 expect(await checkMemoryAt(at, oid)).toBe(first);
141 });
test/tui-check-memory.test.ts added +95
@@ -0,0 +1,95 @@
1 /** #3135: About, in the terminal, says the check cell's memory in the settings page's words. */
2
3 import { afterAll, beforeAll, expect, spyOn, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
8 import { createApp, type App as Forge } from "../src/app.ts";
9 import type { User } from "../src/core/users.ts";
10 import { createForgeApp } from "../src/tui/forge.ts";
11 import { headlessTerminal } from "../src/tui/term/terminal.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13 import { settleApp, settleUntil } from "./helpers/tui.ts";
14 import { seedAdmin } from "./helpers/admin.ts";
15
16 quietLogs();
17
18 let dataDir = "";
19 let forge: Forge;
20 let alice: User;
21 const saved = process.env["FORGE_CELL_MEMORY_MAX_MIB"];
22
23 beforeAll(async () => {
24 process.env["FORGE_CELL_MEMORY_MAX_MIB"] = "4096";
25 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-cellmem-"));
26 forge = createApp({ dataDir, port: 0 });
27 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
28 if (!a.ok) throw new Error("signup failed");
29 alice = a.value;
30 const made = await createRepo(forge, alice, { name: "beacon", visibility: "public" });
31 if (!made.ok) throw new Error("repo failed");
32 const c = await commitToBranch(forge, alice, made.value, {
33 message: "forge.json",
34 files: [{ path: "forge.json", content: '{"check":{"run":"true","limits":{"memory":8192}}}' }],
35 });
36 if (!c.ok) throw new Error("commit failed");
37 });
38
39 afterAll(async () => {
40 if (saved === undefined) delete process.env["FORGE_CELL_MEMORY_MAX_MIB"];
41 else process.env["FORGE_CELL_MEMORY_MAX_MIB"] = saved;
42 await rm(dataDir, { recursive: true, force: true });
43 });
44
45 test("About shows the cut and its source to an admin", async () => {
46 const terminal = headlessTerminal(160, 40);
47 const { app } = createForgeApp(
48 forge,
49 { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: alice },
50 { terminal, exitHooks: false },
51 );
52 app.start();
53 const press = async (...keys: string[]) => {
54 for (const key of keys) app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
55 await settleApp(app);
56 };
57 await settleApp(app);
58 await press("/");
59 for (const ch of "beacon") await press(ch);
60 await press("enter");
61 await press("enter");
62 await settleUntil(app, "alice/beacon");
63 await press("I");
64 await settleUntil(app, "forge.json asks 8192 MiB");
65 expect(app.screen.toText()).toContain("FORGE_CELL_MEMORY_MAX_MIB");
66 });
67
68 test("About says the check could not be read when reading it rejects", async () => {
69 const memory = await import("../src/apps/check-memory.ts");
70 const spy = spyOn(memory, "checkMemoryAt").mockRejectedValue(new Error("git is gone"));
71 try {
72 const terminal = headlessTerminal(160, 40);
73 const { app } = createForgeApp(
74 forge,
75 { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: alice },
76 { terminal, exitHooks: false },
77 );
78 app.start();
79 const press = async (...keys: string[]) => {
80 for (const key of keys) app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
81 await settleApp(app);
82 };
83 await settleApp(app);
84 await press("/");
85 for (const ch of "beacon") await press(ch);
86 await press("enter");
87 await press("enter");
88 await settleUntil(app, "alice/beacon");
89 await press("I");
90 await settleUntil(app, "could not be read");
91 expect(spy).toHaveBeenCalled();
92 } finally {
93 spy.mockRestore();
94 }
95 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs