Public: anyone can read this; the crew can change it.
On a 1-CPU forge a look waits while a crew run is working, and is taken after it ends #11724
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11598-on-a-1-cpu-forge-a-look-waits-while-a-cr → main 2 commits fork
3 files
changed +135
−2
scripts/unreached-allow.json −1
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | [ |
| 2 | "src/core/one_core.ts", | |
| 3 | 2 | "src/perf/page_budget.ts", |
| 4 | 3 | "src/perf/page_budget_read.ts", |
| 5 | 4 | "src/spawn/pod.ts", |
src/looks/service.ts +21 −1
| @@ -58,7 +58,9 @@ import { | ||
| 58 | 58 | type Look, |
| 59 | 59 | type LookKind, |
| 60 | 60 | } from "../core/looks.ts"; |
| 61 | import { crewHoldsCore, forgeCpus } from "../core/one_core.ts"; | |
| 61 | 62 | import { getPull } from "../core/pulls.ts"; |
| 63 | import { listActive } from "../core/runs.ts"; | |
| 62 | 64 | import { frontPicks } from "../core/front.ts"; |
| 63 | 65 | import { getRepoById } from "../core/repos.ts"; |
| 64 | 66 | import { getUserById } from "../core/users.ts"; |
| @@ -98,6 +100,10 @@ export interface LooksOptions { | ||
| 98 | 100 | /** How long a look waits for the app to answer, and how often it asks. */ |
| 99 | 101 | readonly answerWaitMs?: number | undefined; |
| 100 | 102 | readonly answerPollMs?: number | undefined; |
| 103 | /** The forge's CPU count: the machine's unless a test says otherwise. */ | |
| 104 | readonly cpus?: (() => number) | undefined; | |
| 105 | /** Crew runs queued or running: the database's unless a test says otherwise. */ | |
| 106 | readonly activeCrewRuns?: (() => number) | undefined; | |
| 101 | 107 | } |
| 102 | 108 | |
| 103 | 109 | /** The app answers when its page comes back with anything but a server error. */ |
| @@ -382,7 +388,11 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 382 | 388 | queue.splice(at, 1); |
| 383 | 389 | } |
| 384 | 390 | queue.push(item); |
| 385 | if (pumping) return; | |
| 391 | kick(); | |
| 392 | } | |
| 393 | ||
| 394 | function kick(): void { | |
| 395 | if (pumping || stopped || queue.length === 0) return; | |
| 386 | 396 | pumping = true; |
| 387 | 397 | pump = drain().finally(() => { |
| 388 | 398 | pumping = false; |
| @@ -393,6 +403,15 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 393 | 403 | for (;;) { |
| 394 | 404 | const next = queue.shift(); |
| 395 | 405 | if (next === undefined || stopped) return; |
| 406 | // One core: a crew run has it. The look keeps its place and the next | |
| 407 | // sweep takes it once the run has ended (src/core/one_core.ts). | |
| 408 | const cpus = options.cpus ?? forgeCpus; | |
| 409 | const active = | |
| 410 | options.activeCrewRuns ?? (() => listActive(app.db, "agent").length); | |
| 411 | if (crewHoldsCore(cpus(), active())) { | |
| 412 | queue.unshift(next); | |
| 413 | return; | |
| 414 | } | |
| 396 | 415 | if (Date.now() - next.at > MAX_WAIT_MS) { |
| 397 | 416 | log.warn("look dropped: waited too long", { |
| 398 | 417 | app: next.appId, |
| @@ -677,6 +696,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 677 | 696 | /** Queue every running production whose head lacks a picture to keep. */ |
| 678 | 697 | function sweep(): void { |
| 679 | 698 | if (stopped) return; |
| 699 | kick(); | |
| 680 | 700 | const runtime = appsRuntime(app); |
| 681 | 701 | if (runtime === undefined) return; |
| 682 | 702 | try { |
test/looks-one-core.test.ts added +114
| @@ -0,0 +1,114 @@ | ||
| 1 | /** | |
| 2 | * One core (#11598): on a 1-CPU forge a look waits while a crew run is | |
| 3 | * working and is taken on the first sweep after it ends; with a second | |
| 4 | * core it is taken at once. The CPU count and the run count are injected. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { writeFileSync } from "node:fs"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { newApp } from "../src/actions/apps.ts"; | |
| 14 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 15 | import type { AppRecord } from "../src/core/apps.ts"; | |
| 16 | import { createUser } from "../src/core/users.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | import { ok } from "../src/kernel/result.ts"; | |
| 19 | import type { Capture, LookInput } from "../src/looks/capture.ts"; | |
| 20 | import { startLooks } from "../src/looks/service.ts"; | |
| 21 | import { quietLogs } from "./helpers/globals.ts"; | |
| 22 | ||
| 23 | quietLogs(); | |
| 24 | ||
| 25 | const PNG = Buffer.from( | |
| 26 | "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", | |
| 27 | "base64", | |
| 28 | ); | |
| 29 | ||
| 30 | describe("looks on one core", () => { | |
| 31 | let dataDir: string; | |
| 32 | let app: App; | |
| 33 | let servers: Servers; | |
| 34 | let record: AppRecord; | |
| 35 | ||
| 36 | beforeAll(async () => { | |
| 37 | dataDir = await mkdtemp(join(tmpdir(), "forge-looks-core-")); | |
| 38 | app = createApp({ dataDir, port: 0 }); | |
| 39 | servers = await serve(app); | |
| 40 | const runtime = new AppsRuntime(app, { | |
| 41 | domain: "apps.test", | |
| 42 | scheme: "http", | |
| 43 | publicPort: servers.port, | |
| 44 | forgeUrl: servers.url, | |
| 45 | concurrency: 2, | |
| 46 | }); | |
| 47 | await runtime.start(); | |
| 48 | const a = await createUser(app.db, { | |
| 49 | origin: "seed", | |
| 50 | trusted: true, | |
| 51 | username: "alice", | |
| 52 | email: "[email protected]", | |
| 53 | password: "a-good-passphrase", | |
| 54 | adminIfFirst: true, | |
| 55 | }); | |
| 56 | if (!a.ok) throw new Error(a.error); | |
| 57 | const made = await newApp(app, a.value, { name: "hello", visibility: "private" }); | |
| 58 | if (!made.ok) throw new Error(String(made.error)); | |
| 59 | record = made.value.app; | |
| 60 | const until = Date.now() + 30_000; | |
| 61 | while (runtime.envStatus(record, "production")?.state !== "running") { | |
| 62 | if (Date.now() > until) throw new Error("production never ran"); | |
| 63 | await Bun.sleep(25); | |
| 64 | } | |
| 65 | }); | |
| 66 | ||
| 67 | afterAll(async () => { | |
| 68 | await servers.stop?.(); | |
| 69 | await rm(dataDir, { recursive: true, force: true }); | |
| 70 | }); | |
| 71 | ||
| 72 | const trial = (cpus: number) => { | |
| 73 | app.db.query("DELETE FROM look WHERE app_id = ?").run(record.id); | |
| 74 | const seen: LookInput[] = []; | |
| 75 | const capture: Capture = async (input) => { | |
| 76 | seen.push(input); | |
| 77 | writeFileSync(input.out, PNG); | |
| 78 | return ok({ entries: {} }); | |
| 79 | }; | |
| 80 | let active = 1; | |
| 81 | const looks = startLooks(app, { | |
| 82 | localUrl: servers.url, | |
| 83 | capture, | |
| 84 | cpus: () => cpus, | |
| 85 | activeCrewRuns: () => active, | |
| 86 | answers: async () => true, | |
| 87 | answerWaitMs: 1_000, | |
| 88 | answerPollMs: 5, | |
| 89 | }); | |
| 90 | return { looks, seen, end: () => (active = 0) }; | |
| 91 | }; | |
| 92 | ||
| 93 | test("1 CPU with a crew run working takes no look; once the run is gone the next sweep takes it", async () => { | |
| 94 | const { looks, seen, end } = trial(1); | |
| 95 | looks.sweep(); | |
| 96 | await looks.idle(); | |
| 97 | looks.sweep(); | |
| 98 | await looks.idle(); | |
| 99 | expect(seen).toHaveLength(0); | |
| 100 | end(); | |
| 101 | looks.sweep(); | |
| 102 | await looks.idle(); | |
| 103 | expect(seen.length).toBeGreaterThan(0); | |
| 104 | await looks.stop(); | |
| 105 | }); | |
| 106 | ||
| 107 | test("2 CPUs with a crew run working takes the look at once", async () => { | |
| 108 | const { looks, seen } = trial(2); | |
| 109 | looks.sweep(); | |
| 110 | await looks.idle(); | |
| 111 | expect(seen.length).toBeGreaterThan(0); | |
| 112 | await looks.stop(); | |
| 113 | }); | |
| 114 | }); |
new above
offline