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

A look waits for the app to answer its health check before the first picture #11653

Merged opened this

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

Merged

scout/forge:scout/11520-a-look-waits-for-the-app-to-answer-its-h → main 1 commit fork

  • Merged. scout/11520-a-look-waits-for-the-app-to-answer-its-h was merged into main as 4bd2f5e.

Review

Sign in to review this change.
2 files changed +101 −1
src/looks/service.ts +60
@@ -90,8 +90,33 @@ export interface LooksOptions {
9090 readonly pressure?: (() => number | undefined) | undefined;
9191 /** The clock a failed look's backoff waits on: the real one unless a test says otherwise. */
9292 readonly clock?: BackoffClock | undefined;
93 /**
94 * Whether the app answers at its port now: a real request unless a test
95 * says otherwise. A look waits for this before opening a browser (#11520).
96 */
97 readonly answers?: ((port: number) => Promise<boolean>) | undefined;
98 /** How long a look waits for the app to answer, and how often it asks. */
99 readonly answerWaitMs?: number | undefined;
100 readonly answerPollMs?: number | undefined;
93101 }
94102
103 /** The app answers when its page comes back with anything but a server error. */
104 async function appAnswers(port: number): Promise<boolean> {
105 try {
106 const res = await fetch(`http://127.0.0.1:${port}/`, {
107 redirect: "manual",
108 signal: AbortSignal.timeout(2_000),
109 });
110 await res.body?.cancel();
111 return res.status < 500;
112 } catch {
113 return false;
114 }
115 }
116
117 /** What a look records when the app never answered, so no browser was opened. */
118 export const NOT_ANSWERING = "the app did not answer its health check in time";
119
95120 /** The share of the memory limit in use, read from cgroup v2, if this is a container. */
96121 export function cgroupMemoryPressure(): number | undefined {
97122 try {
@@ -148,6 +173,9 @@ const SWEEP_MS = 10 * 60_000;
148173 const FIRST_SWEEP_MS = 60_000;
149174 /** The browser's whole allowance for one picture. */
150175 const CAPTURE_TIMEOUT_MS = 20_000;
176 /** How long a look waits for the app to answer before its first picture. */
177 const ANSWER_WAIT_MS = 5_000;
178 const ANSWER_POLL_MS = 250;
151179 /** How long the grant in the URL is good for — the browser uses it at once. */
152180 const GRANT_TTL_MS = 60_000;
153181 /**
@@ -377,6 +405,19 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
377405 }
378406 }
379407
408 /** Ask until the app answers or the wait is spent. */
409 async function answered(port: number): Promise<boolean> {
410 const ask = options.answers ?? appAnswers;
411 const waitMs = options.answerWaitMs ?? ANSWER_WAIT_MS;
412 const pollMs = options.answerPollMs ?? ANSWER_POLL_MS;
413 const until = Date.now() + waitMs;
414 for (;;) {
415 if (await ask(port)) return true;
416 if (stopped || Date.now() >= until) return false;
417 await Bun.sleep(pollMs);
418 }
419 }
420
380421 async function take(item: Pending): Promise<void> {
381422 const record = getAppById(app.db, item.appId);
382423 const runtime = appsRuntime(app);
@@ -405,6 +446,25 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
405446 item.swept === true,
406447 );
407448 if (wanted.length === 0) return;
449 // An app still warming up gets no browser: wait, bounded, until it
450 // answers; one that never does is recorded as not answering (#11520).
451 if (status.port !== undefined && !(await answered(status.port))) {
452 if (stopped) return;
453 log.warn("no picture: the app did not answer", { app: record.slug, env: item.env });
454 for (const { page, kind, theme } of wanted)
455 recordLook(app.db, {
456 appId: item.appId,
457 env: item.env,
458 commitOid: item.commitOid,
459 page,
460 theme,
461 kind,
462 path: "",
463 error: NOT_ANSWERING,
464 });
465 failed(item);
466 return;
467 }
408468 // The phone and desktop pictures are what the owner sees: the app
409469 // renders for a signed-in admin, exactly as when they open it from the
410470 // forge. The thumb is what a stranger sees — it is the picture on the
test/looks.test.ts +41 −1
@@ -44,7 +44,7 @@ import {
4444 type Capture,
4545 type LookInput,
4646 } from "../src/looks/capture.ts";
47 import { startLooks, type LooksHandle } from "../src/looks/service.ts";
47 import { NOT_ANSWERING, startLooks, type LooksHandle } from "../src/looks/service.ts";
4848 import type { BackoffClock } from "../src/looks/backoff.ts";
4949 import { setLogLevel } from "../src/kernel/log.ts";
5050
@@ -1089,6 +1089,46 @@ describe("the look", () => {
10891089 expect(existsSync(join(dataDir, path))).toBe(false);
10901090 });
10911091
1092 test("a look waits for the app to answer before its one capture; an app that never answers gets none and one recorded reason (#11520)", async () => {
1093 const status = runtime.envStatus(record, "production")!;
1094 const head = status.commitOid;
1095 const forgetPictures = () =>
1096 app.db.query("DELETE FROM look WHERE app_id = ? AND env = 'production'").run(record.id);
1097 const trial = async (answers: (port: number) => Promise<boolean>) => {
1098 forgetPictures();
1099 const seen: LookInput[] = [];
1100 const own = startLooks(app, {
1101 localUrl: servers.url,
1102 capture: async (input) => {
1103 seen.push(input);
1104 return fake(input);
1105 },
1106 answers,
1107 answerWaitMs: 2_000,
1108 answerPollMs: 5,
1109 });
1110 own.sweep();
1111 await own.idle();
1112 await own.stop();
1113 return { seen, rows: looksForEnv(app.db, record.id, "production", "any").filter((l) => l.commitOid === head) };
1114 };
1115 // Answers on the fourth ask: no capture before it, then the pictures.
1116 let asked = 0;
1117 const slow = await trial(async () => ++asked >= 4);
1118 expect(asked).toBe(4);
1119 expect(slow.seen.filter((c) => c.dark !== true).length).toBeGreaterThan(0);
1120 expect(slow.rows.every((l) => l.error === null)).toBe(true);
1121 // Never answers: no browser, and the reason on record.
1122 const mute = await trial(async () => false);
1123 expect(mute.seen).toEqual([]);
1124 expect(mute.rows.length).toBeGreaterThan(0);
1125 for (const look of mute.rows) {
1126 expect(look.error).toBe(NOT_ANSWERING);
1127 expect(look.path).toBe("");
1128 }
1129 forgetPictures();
1130 });
1131
10921132 test("the boot prune spares an owner-taken thumb, an attached one, and every thumb of a public app", async () => {
10931133 const put = (oid: string, extra: Partial<Parameters<typeof recordLook>[1]>) => {
10941134 const path = `looks/${record.id}/production/${oid}-thumb.png`;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs