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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11520-a-look-waits-for-the-app-to-answer-its-h → main 1 commit fork
2 files
changed +101
−1
src/looks/service.ts +60
| @@ -90,8 +90,33 @@ export interface LooksOptions { | ||
| 90 | 90 | readonly pressure?: (() => number | undefined) | undefined; |
| 91 | 91 | /** The clock a failed look's backoff waits on: the real one unless a test says otherwise. */ |
| 92 | 92 | 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; | |
| 93 | 101 | } |
| 94 | 102 | |
| 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 | ||
| 95 | 120 | /** The share of the memory limit in use, read from cgroup v2, if this is a container. */ |
| 96 | 121 | export function cgroupMemoryPressure(): number | undefined { |
| 97 | 122 | try { |
| @@ -148,6 +173,9 @@ const SWEEP_MS = 10 * 60_000; | ||
| 148 | 173 | const FIRST_SWEEP_MS = 60_000; |
| 149 | 174 | /** The browser's whole allowance for one picture. */ |
| 150 | 175 | 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; | |
| 151 | 179 | /** How long the grant in the URL is good for — the browser uses it at once. */ |
| 152 | 180 | const GRANT_TTL_MS = 60_000; |
| 153 | 181 | /** |
| @@ -377,6 +405,19 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 377 | 405 | } |
| 378 | 406 | } |
| 379 | 407 | |
| 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 | ||
| 380 | 421 | async function take(item: Pending): Promise<void> { |
| 381 | 422 | const record = getAppById(app.db, item.appId); |
| 382 | 423 | const runtime = appsRuntime(app); |
| @@ -405,6 +446,25 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 405 | 446 | item.swept === true, |
| 406 | 447 | ); |
| 407 | 448 | 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 | } | |
| 408 | 468 | // The phone and desktop pictures are what the owner sees: the app |
| 409 | 469 | // renders for a signed-in admin, exactly as when they open it from the |
| 410 | 470 | // 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 { | ||
| 44 | 44 | type Capture, |
| 45 | 45 | type LookInput, |
| 46 | 46 | } 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"; | |
| 48 | 48 | import type { BackoffClock } from "../src/looks/backoff.ts"; |
| 49 | 49 | import { setLogLevel } from "../src/kernel/log.ts"; |
| 50 | 50 | |
| @@ -1089,6 +1089,46 @@ describe("the look", () => { | ||
| 1089 | 1089 | expect(existsSync(join(dataDir, path))).toBe(false); |
| 1090 | 1090 | }); |
| 1091 | 1091 | |
| 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 | ||
| 1092 | 1132 | test("the boot prune spares an owner-taken thumb, an attached one, and every thumb of a public app", async () => { |
| 1093 | 1133 | const put = (oid: string, extra: Partial<Parameters<typeof recordLook>[1]>) => { |
| 1094 | 1134 | const path = `looks/${record.id}/production/${oid}-thumb.png`; |
new above
offline