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

Merged opened this

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

Merged

torque/forge:torque/11598-on-a-1-cpu-forge-a-look-waits-while-a-cr → main 2 commits fork

  • Merged. torque/11598-on-a-1-cpu-forge-a-look-waits-while-a-cr was merged into main as ed487cf.

Review

Sign in to review this change.
3 files changed +135 −2
scripts/unreached-allow.json −1
@@ -1,5 +1,4 @@
11 [
2 "src/core/one_core.ts",
32 "src/perf/page_budget.ts",
43 "src/perf/page_budget_read.ts",
54 "src/spawn/pod.ts",
src/looks/service.ts +21 −1
@@ -58,7 +58,9 @@ import {
5858 type Look,
5959 type LookKind,
6060 } from "../core/looks.ts";
61 import { crewHoldsCore, forgeCpus } from "../core/one_core.ts";
6162 import { getPull } from "../core/pulls.ts";
63 import { listActive } from "../core/runs.ts";
6264 import { frontPicks } from "../core/front.ts";
6365 import { getRepoById } from "../core/repos.ts";
6466 import { getUserById } from "../core/users.ts";
@@ -98,6 +100,10 @@ export interface LooksOptions {
98100 /** How long a look waits for the app to answer, and how often it asks. */
99101 readonly answerWaitMs?: number | undefined;
100102 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;
101107 }
102108
103109 /** 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 {
382388 queue.splice(at, 1);
383389 }
384390 queue.push(item);
385 if (pumping) return;
391 kick();
392 }
393
394 function kick(): void {
395 if (pumping || stopped || queue.length === 0) return;
386396 pumping = true;
387397 pump = drain().finally(() => {
388398 pumping = false;
@@ -393,6 +403,15 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
393403 for (;;) {
394404 const next = queue.shift();
395405 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 }
396415 if (Date.now() - next.at > MAX_WAIT_MS) {
397416 log.warn("look dropped: waited too long", {
398417 app: next.appId,
@@ -677,6 +696,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
677696 /** Queue every running production whose head lacks a picture to keep. */
678697 function sweep(): void {
679698 if (stopped) return;
699 kick();
680700 const runtime = appsRuntime(app);
681701 if (runtime === undefined) return;
682702 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs