Public: anyone can read this; the crew can change it.
On a 1-CPU forge the crew run has the core: looks and checks wait for it, and one browser runs at a time #11574
scout/forge:scout/11561-on-a-1-cpu-forge-the-crew-run-has-the-co → main 3 commits fork
6 files
changed +212
−1
src/checks/service.ts +12
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | * is that command's business. |
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | import { heldForCrew, RECHECK_MS } from "../core/one_core.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"; |
| @@ -622,8 +623,18 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 622 | 623 | return next; |
| 623 | 624 | } |
| 624 | 625 | |
| 626 | let heldTimer: ReturnType<typeof setTimeout> | undefined; | |
| 625 | 627 | function schedule(): void { |
| 626 | 628 | if (stopped) return; |
| 629 | // One core: the crew's run has it; queued checks start after it ends. | |
| 630 | if (heldForCrew(app)) { | |
| 631 | heldTimer ??= setTimeout(() => { | |
| 632 | heldTimer = undefined; | |
| 633 | schedule(); | |
| 634 | }, RECHECK_MS); | |
| 635 | heldTimer.unref?.(); | |
| 636 | return; | |
| 637 | } | |
| 627 | 638 | const limit = capacity(); |
| 628 | 639 | if (options.gate === undefined) gate.resize(limit); |
| 629 | 640 | startVerify(); |
| @@ -1751,6 +1762,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1751 | 1762 | stop: async (stopOptions) => { |
| 1752 | 1763 | stopped = true; |
| 1753 | 1764 | clearInterval(timer); |
| 1765 | if (heldTimer !== undefined) clearTimeout(heldTimer); | |
| 1754 | 1766 | if (sweeper !== undefined) clearInterval(sweeper); |
| 1755 | 1767 | unsubscribe(); |
| 1756 | 1768 | unwatch(); |
src/core/one_core.ts added +40
| @@ -0,0 +1,40 @@ | ||
| 1 | /** | |
| 2 | * One core: on a forge whose size has one CPU (`spawn/sizes.ts`), the crew's | |
| 3 | * run has the core, and a look, a check or a snapshot starts after it, in | |
| 4 | * order (#11561). A forge with two or more behaves as it always did. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { App } from "../app.ts"; | |
| 8 | import { cpuLimit } from "./capacity.ts"; | |
| 9 | import { listActive } from "./runs.ts"; | |
| 10 | ||
| 11 | /** Whether this forge has a single CPU to share. */ | |
| 12 | export const oneCore = (): boolean => cpuLimit() <= 1; | |
| 13 | ||
| 14 | /** Whether a crew run is going now. */ | |
| 15 | export const crewRunning = (app: App): boolean => | |
| 16 | listActive(app.db, "agent").some((run) => run.state === "running"); | |
| 17 | ||
| 18 | /** Whether background work waits: one core, and the crew is using it. */ | |
| 19 | export const heldForCrew = (app: App): boolean => oneCore() && crewRunning(app); | |
| 20 | ||
| 21 | /** How often a held start looks again. */ | |
| 22 | export const RECHECK_MS = 1000; | |
| 23 | ||
| 24 | /** Resolves once nothing is held for the crew; at once on a forge with room. */ | |
| 25 | export async function crewIdle(app: App, pollMs: number = RECHECK_MS): Promise<void> { | |
| 26 | while (heldForCrew(app)) await Bun.sleep(pollMs); | |
| 27 | } | |
| 28 | ||
| 29 | let tail: Promise<unknown> = Promise.resolve(); | |
| 30 | ||
| 31 | /** | |
| 32 | * Runs `fn` alone on a one-core forge: a second browser waits for the first | |
| 33 | * to end. With more cores it runs at once. | |
| 34 | */ | |
| 35 | export function oneBrowser<T>(fn: () => Promise<T>): Promise<T> { | |
| 36 | if (!oneCore()) return fn(); | |
| 37 | const run = tail.catch(() => {}).then(fn); | |
| 38 | tail = run; | |
| 39 | return run; | |
| 40 | } |
src/looks/capture.ts +2 −1
| @@ -34,6 +34,7 @@ | ||
| 34 | 34 | * process starts, and a pod that holds two agent runs has room for one. |
| 35 | 35 | */ |
| 36 | 36 | |
| 37 | import { oneBrowser } from "../core/one_core.ts"; | |
| 37 | 38 | import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 38 | 39 | import { tmpdir } from "node:os"; |
| 39 | 40 | import { join } from "node:path"; |
| @@ -118,7 +119,7 @@ export async function takeLook( | ||
| 118 | 119 | return err(NO_BROWSER); |
| 119 | 120 | const release = await gate.acquire(); |
| 120 | 121 | try { |
| 121 | return await shoot(binary, input); | |
| 122 | return await oneBrowser(() => shoot(binary, input)); | |
| 122 | 123 | } finally { |
| 123 | 124 | release(); |
| 124 | 125 | } |
src/looks/service.ts +4
| @@ -73,6 +73,7 @@ import { keepAsMain } from "./main_looks.ts"; | ||
| 73 | 73 | import { findChromium, isNoBrowser, takeLook, type Capture } from "./capture.ts"; |
| 74 | 74 | import { backoff, type BackoffClock } from "./backoff.ts"; |
| 75 | 75 | import { during } from "../perf/jobs.ts"; |
| 76 | import { crewIdle } from "../core/one_core.ts"; | |
| 76 | 77 | |
| 77 | 78 | const log = createLog("looks"); |
| 78 | 79 | |
| @@ -365,6 +366,9 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 365 | 366 | await Bun.sleep(15_000); |
| 366 | 367 | continue; |
| 367 | 368 | } |
| 369 | // One core: the crew's run has it, and the look waits its end. | |
| 370 | await crewIdle(app); | |
| 371 | if (stopped) return; | |
| 368 | 372 | try { |
| 369 | 373 | await take(next); |
| 370 | 374 | } catch (error) { |
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 { crewIdle } from "../core/one_core.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"; |
| @@ -185,6 +186,8 @@ export function startSnapshots( | ||
| 185 | 186 | // very next snapshot's. |
| 186 | 187 | const store = current(); |
| 187 | 188 | if (store === undefined) throw new Nowhere(); |
| 189 | // One core: the crew's run has it, and the snapshot starts after. | |
| 190 | await crewIdle(app); | |
| 188 | 191 | offBox = isOffBox(store); |
| 189 | 192 | running = { objects: 0, bytes: 0, elapsedMs: 0 }; |
| 190 | 193 | startedAt = Date.now(); |
test/one-core.test.ts added +151
| @@ -0,0 +1,151 @@ | ||
| 1 | /** | |
| 2 | * One core (#11561): on a 1-CPU forge a crew run has the core, and a check, | |
| 3 | * a look and a snapshot start after it; a 2-CPU forge starts them at once. | |
| 4 | */ | |
| 5 | import { setDefaultTimeout } from "bun:test"; | |
| 6 | import { afterEach, beforeEach, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { startChecks, type ChecksHandle } from "../src/checks/service.ts"; | |
| 13 | import { provideCpusForTest } from "../src/core/capacity.ts"; | |
| 14 | import { crewIdle, heldForCrew, oneBrowser } from "../src/core/one_core.ts"; | |
| 15 | import { cancelRun, claimRun, createRun, listRuns, type Run } from "../src/core/runs.ts"; | |
| 16 | import type { Repo } from "../src/core/repos.ts"; | |
| 17 | import { createUser, type User } from "../src/core/users.ts"; | |
| 18 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 19 | import { SIZES } from "../src/spawn/sizes.ts"; | |
| 20 | ||
| 21 | let dataDir: string; | |
| 22 | let app: App; | |
| 23 | let servers: Servers; | |
| 24 | let checks: ChecksHandle | undefined; | |
| 25 | let user: User; | |
| 26 | let repo: Repo; | |
| 27 | ||
| 28 | setDefaultTimeout(45_000); | |
| 29 | ||
| 30 | beforeEach(async () => { | |
| 31 | dataDir = await mkdtemp(join(tmpdir(), "forge-onecore-")); | |
| 32 | app = createApp({ dataDir, port: 0 }); | |
| 33 | servers = await serve(app); | |
| 34 | const a = await createUser(app.db, { | |
| 35 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 36 | password: "a-good-passphrase", adminIfFirst: true, | |
| 37 | }); | |
| 38 | if (!a.ok) throw new Error(a.error); | |
| 39 | user = a.value; | |
| 40 | const made = await createRepo(app, user, { name: "lib", visibility: "public" }); | |
| 41 | if (!made.ok) throw new Error(String(made.error)); | |
| 42 | repo = made.value; | |
| 43 | }); | |
| 44 | afterEach(async () => { | |
| 45 | provideCpusForTest(undefined); | |
| 46 | await checks?.stop(); | |
| 47 | checks = undefined; | |
| 48 | servers.stop(); | |
| 49 | app.close(); | |
| 50 | await rm(dataDir, { recursive: true, force: true }); | |
| 51 | }); | |
| 52 | ||
| 53 | /** A crew run that is going: queued, then claimed. */ | |
| 54 | function crewRun(): Run { | |
| 55 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", reason: "pick" }); | |
| 56 | const claimed = claimRun(app.db, run.id); | |
| 57 | if (!claimed) throw new Error("not claimed"); | |
| 58 | return claimed; | |
| 59 | } | |
| 60 | ||
| 61 | const checkRuns = () => listRuns(app.db, repo.id, { kind: "check", limit: 10 }); | |
| 62 | ||
| 63 | async function until(ok: () => boolean, ms = 20_000): Promise<void> { | |
| 64 | const end = Date.now() + ms; | |
| 65 | while (!ok() && Date.now() < end) await Bun.sleep(20); | |
| 66 | } | |
| 67 | ||
| 68 | async function push(): Promise<void> { | |
| 69 | const c = await commitToBranch(app, user, repo, { | |
| 70 | message: "Declare a check", | |
| 71 | files: [{ path: "forge.json", content: '{"check": {"run": "exit 0", "install": ""}}\n' }], | |
| 72 | }); | |
| 73 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 74 | } | |
| 75 | ||
| 76 | test("the small size is the one with one CPU", () => { | |
| 77 | expect(SIZES.small.cpu).toBe(1); | |
| 78 | expect(SIZES.medium.cpu).toBeGreaterThanOrEqual(2); | |
| 79 | }); | |
| 80 | ||
| 81 | describe("one CPU", () => { | |
| 82 | test("a check is held while a crew run is active, then started when it ends", async () => { | |
| 83 | provideCpusForTest(SIZES.small.cpu); | |
| 84 | const crew = crewRun(); | |
| 85 | expect(heldForCrew(app)).toBe(true); | |
| 86 | checks = startChecks(app, { forgeUrl: servers.url, concurrency: 1 }); | |
| 87 | await push(); | |
| 88 | await until(() => checkRuns().length > 0); | |
| 89 | expect(checkRuns()[0]!.state).toBe("queued"); | |
| 90 | await Bun.sleep(1500); | |
| 91 | expect(checkRuns()[0]!.state).toBe("queued"); | |
| 92 | cancelRun(app.db, crew.id, "done"); | |
| 93 | expect(heldForCrew(app)).toBe(false); | |
| 94 | await until(() => checkRuns()[0]!.state !== "queued"); | |
| 95 | expect(checkRuns()[0]!.state).not.toBe("queued"); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a look (or snapshot) waits on crewIdle until the run ends", async () => { | |
| 99 | provideCpusForTest(SIZES.small.cpu); | |
| 100 | const crew = crewRun(); | |
| 101 | let started = false; | |
| 102 | const waiting = crewIdle(app, 10).then(() => (started = true)); | |
| 103 | await Bun.sleep(100); | |
| 104 | expect(started).toBe(false); | |
| 105 | cancelRun(app.db, crew.id, "done"); | |
| 106 | await waiting; | |
| 107 | expect(started).toBe(true); | |
| 108 | }); | |
| 109 | ||
| 110 | test("a second browser waits for the first", async () => { | |
| 111 | provideCpusForTest(SIZES.small.cpu); | |
| 112 | let open = 0; | |
| 113 | let most = 0; | |
| 114 | const one = async () => { | |
| 115 | open++; | |
| 116 | most = Math.max(most, open); | |
| 117 | await Bun.sleep(30); | |
| 118 | open--; | |
| 119 | }; | |
| 120 | await Promise.all([oneBrowser(one), oneBrowser(one), oneBrowser(one)]); | |
| 121 | expect(most).toBe(1); | |
| 122 | }); | |
| 123 | }); | |
| 124 | ||
| 125 | describe("two CPUs", () => { | |
| 126 | test("nothing is held, and browsers overlap", async () => { | |
| 127 | provideCpusForTest(SIZES.medium.cpu); | |
| 128 | crewRun(); | |
| 129 | expect(heldForCrew(app)).toBe(false); | |
| 130 | await crewIdle(app, 10); | |
| 131 | let open = 0; | |
| 132 | let most = 0; | |
| 133 | const one = async () => { | |
| 134 | open++; | |
| 135 | most = Math.max(most, open); | |
| 136 | await Bun.sleep(30); | |
| 137 | open--; | |
| 138 | }; | |
| 139 | await Promise.all([oneBrowser(one), oneBrowser(one)]); | |
| 140 | expect(most).toBe(2); | |
| 141 | }); | |
| 142 | ||
| 143 | test("a check starts at once beside a crew run", async () => { | |
| 144 | provideCpusForTest(SIZES.medium.cpu); | |
| 145 | crewRun(); | |
| 146 | checks = startChecks(app, { forgeUrl: servers.url, concurrency: 1 }); | |
| 147 | await push(); | |
| 148 | await until(() => checkRuns().some((r) => r.state !== "queued")); | |
| 149 | expect(checkRuns()[0]!.state).not.toBe("queued"); | |
| 150 | }); | |
| 151 | }); |