Public: anyone can read this; the crew can change it.
A hosted app with children still working is never put to sleep by the sweep #350
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/332-a-hosted-app-with-children-still-working → main 3 commits fork
9 files
changed +473
−10
docs/APPS.md +9
| @@ -690,6 +690,15 @@ and every preview comes back asleep until someone looks. A restart used to | ||
| 690 | 690 | mean thirty installs and test suites at once; now it means starting what |
| 691 | 691 | people are using. |
| 692 | 692 | |
| 693 | Quiet is not the same as idle. Before it sleeps an environment the sweep | |
| 694 | asks whether it is _busy_ (`apps/busy.ts`): a child process of the app's | |
| 695 | still alive — a build, a crew run inside a hosted forge — or a websocket or | |
| 696 | event stream the proxy holds open to it. A busy environment is left awake, | |
| 697 | the app page and the Now card say _busy_ and why, and its deploy log says | |
| 698 | it was kept awake. Busy buys one crew run's length past the idle window | |
| 699 | (`FORGE_CREW_TIMEOUT_MIN`, 45 minutes by default) and no more, so an app | |
| 700 | that keeps a child forever still rests. | |
| 701 | ||
| 693 | 702 | ### Subscriptions |
| 694 | 703 | |
| 695 | 704 | One token in the environment is one account for the whole forge. An admin |
forge.json +1 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | "ready": 90 |
| 7 | 7 | }, |
| 8 | 8 | "check": { |
| 9 | "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts", | |
| 9 | "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts", | |
| 10 | 10 | "timeout": 1800 |
| 11 | 11 | } |
| 12 | 12 | } |
src/apps/busy.ts added +52
| @@ -0,0 +1,52 @@ | ||
| 1 | /** | |
| 2 | * Whether a running environment is in the middle of something, asked of | |
| 3 | * the process itself rather than of the clock: a child it started still | |
| 4 | * working (a build, a crew run inside a hosted forge), or a stream the | |
| 5 | * proxy holds open to it (a websocket, an event stream). The sleep sweep | |
| 6 | * leaves a busy environment awake — for a while, never forever | |
| 7 | * (`apps/runtime.ts`). | |
| 8 | * | |
| 9 | * Children are read from `/proc/<pid>/task/<tid>/children`. Where there is | |
| 10 | * no `/proc` the answer is "no children", so an environment sleeps on | |
| 11 | * time alone exactly as it did before this was asked. | |
| 12 | */ | |
| 13 | ||
| 14 | import { readdirSync, readFileSync } from "node:fs"; | |
| 15 | ||
| 16 | export type BusyReason = "children" | "stream"; | |
| 17 | ||
| 18 | /** Busy, in the words a page says it with. */ | |
| 19 | export const BUSY_WORDS: Record<BusyReason, string> = { | |
| 20 | children: "busy — its child processes are still working", | |
| 21 | stream: "busy — a stream to it is still open", | |
| 22 | }; | |
| 23 | ||
| 24 | /** Whether the process `pid` has a child process alive right now. */ | |
| 25 | export function hasChildren(pid: number): boolean { | |
| 26 | if (!Number.isSafeInteger(pid) || pid <= 0) return false; | |
| 27 | let tasks: string[]; | |
| 28 | try { | |
| 29 | tasks = readdirSync(`/proc/${pid}/task`); | |
| 30 | } catch { | |
| 31 | return false; | |
| 32 | } | |
| 33 | for (const tid of tasks) { | |
| 34 | try { | |
| 35 | if (readFileSync(`/proc/${pid}/task/${tid}/children`, "utf8").trim()) | |
| 36 | return true; | |
| 37 | } catch { | |
| 38 | // The thread ended between the listing and the read. | |
| 39 | } | |
| 40 | } | |
| 41 | return false; | |
| 42 | } | |
| 43 | ||
| 44 | /** Why an environment is busy, or undefined when it is not. */ | |
| 45 | export function busyReason( | |
| 46 | pid: number, | |
| 47 | openStreams: number, | |
| 48 | ): BusyReason | undefined { | |
| 49 | if (openStreams > 0) return "stream"; | |
| 50 | if (hasChildren(pid)) return "children"; | |
| 51 | return undefined; | |
| 52 | } |
src/apps/proxy.ts +50 −4
| @@ -465,10 +465,50 @@ export async function proxyApp( | ||
| 465 | 465 | } |
| 466 | 466 | const out = new Headers(response.headers); |
| 467 | 467 | for (const name of HOP_BY_HOP) out.delete(name); |
| 468 | return new Response(response.body, { | |
| 469 | status: response.status, | |
| 470 | statusText: response.statusText, | |
| 471 | headers: out, | |
| 468 | const eventStream = | |
| 469 | response.body !== null && | |
| 470 | (response.headers.get("content-type") ?? "") | |
| 471 | .toLowerCase() | |
| 472 | .startsWith("text/event-stream"); | |
| 473 | return new Response( | |
| 474 | eventStream ? held(runtime, target, response.body!) : response.body, | |
| 475 | { | |
| 476 | status: response.status, | |
| 477 | statusText: response.statusText, | |
| 478 | headers: out, | |
| 479 | }, | |
| 480 | ); | |
| 481 | } | |
| 482 | ||
| 483 | /** | |
| 484 | * An event stream, passed through untouched while it keeps the environment | |
| 485 | * busy: held from its first byte until it ends, fails, or the browser | |
| 486 | * goes away. | |
| 487 | */ | |
| 488 | function held( | |
| 489 | runtime: AppsRuntime, | |
| 490 | target: AppTarget, | |
| 491 | body: ReadableStream<Uint8Array>, | |
| 492 | ): ReadableStream<Uint8Array> { | |
| 493 | const release = runtime.holdStream(target.app, target.env); | |
| 494 | const reader = body.getReader(); | |
| 495 | return new ReadableStream<Uint8Array>({ | |
| 496 | async pull(controller) { | |
| 497 | try { | |
| 498 | const { done, value } = await reader.read(); | |
| 499 | if (done) { | |
| 500 | release(); | |
| 501 | controller.close(); | |
| 502 | } else controller.enqueue(value); | |
| 503 | } catch (error) { | |
| 504 | release(); | |
| 505 | controller.error(error); | |
| 506 | } | |
| 507 | }, | |
| 508 | cancel(reason) { | |
| 509 | release(); | |
| 510 | return reader.cancel(reason); | |
| 511 | }, | |
| 472 | 512 | }); |
| 473 | 513 | } |
| 474 | 514 | |
| @@ -543,6 +583,12 @@ async function proxyWebSocket( | ||
| 543 | 583 | return unavailable(runtime, target, status, opened.error); |
| 544 | 584 | } |
| 545 | 585 | const upstream = opened.value; |
| 586 | // Open, the socket keeps the environment busy; its close — from either | |
| 587 | // side, or a failed upgrade below — lets it rest again. | |
| 588 | upstream.addEventListener( | |
| 589 | "close", | |
| 590 | runtime.holdStream(target.app, target.env), | |
| 591 | ); | |
| 546 | 592 | const upgraded = server.upgrade(request, { |
| 547 | 593 | data: { upstream }, |
| 548 | 594 | // The app's choice of subprotocol is what the browser hears; without |
src/apps/runtime.ts +67 −3
| @@ -44,6 +44,7 @@ import type { Result } from "../kernel/result.ts"; | ||
| 44 | 44 | import { holdLease, STALE_AFTER_MS } from "../runs/lease.ts"; |
| 45 | 45 | import { appendRunLog } from "../runs/log.ts"; |
| 46 | 46 | import { primaryDomain } from "../core/domains.ts"; |
| 47 | import { BUSY_WORDS, busyReason, type BusyReason } from "./busy.ts"; | |
| 47 | 48 | import { exportTree, readFileAt } from "./checkout.ts"; |
| 48 | 49 | import type { DomainResolver } from "./domains.ts"; |
| 49 | 50 | import { |
| @@ -91,6 +92,12 @@ export interface RuntimeOptions { | ||
| 91 | 92 | | undefined; |
| 92 | 93 | /** How often the runtime looks for environments to put to sleep. */ |
| 93 | 94 | readonly sweepEveryMs?: number | undefined; |
| 95 | /** | |
| 96 | * How long past its idle window a busy environment (`apps/busy.ts`) is | |
| 97 | * left awake before it sleeps anyway — one crew run's length, so an app | |
| 98 | * that only claims to be busy cannot stay up forever. | |
| 99 | */ | |
| 100 | readonly busyGraceMs?: number | undefined; | |
| 94 | 101 | /** |
| 95 | 102 | * What a domain check asks: the system resolver unless a test hands in |
| 96 | 103 | * its own, so proving `app.example.com` needs no network in a test. |
| @@ -107,6 +114,7 @@ export type EnvState = | ||
| 107 | 114 | |
| 108 | 115 | const SLEEP_AFTER_MS = { production: 24 * 3_600_000, preview: 30 * 60_000 }; |
| 109 | 116 | const SWEEP_EVERY_MS = 60_000; |
| 117 | const BUSY_GRACE_MS = 45 * 60_000; | |
| 110 | 118 | |
| 111 | 119 | export interface EnvStatus { |
| 112 | 120 | readonly env: EnvName; |
| @@ -163,6 +171,11 @@ export interface EnvStatus { | ||
| 163 | 171 | readonly error: string; |
| 164 | 172 | } |
| 165 | 173 | | undefined; |
| 174 | /** | |
| 175 | * Why a running environment is still awake past its idle window, in a | |
| 176 | * person's words (`BUSY_WORDS`) — set only while the sweep is sparing it. | |
| 177 | */ | |
| 178 | readonly busy?: string | undefined; | |
| 166 | 179 | } |
| 167 | 180 | |
| 168 | 181 | interface Live { |
| @@ -246,6 +259,10 @@ interface Environment { | ||
| 246 | 259 | waking?: Promise<void> | undefined; |
| 247 | 260 | /** When something last came through the proxy for this environment. */ |
| 248 | 261 | lastRequestAt?: number | undefined; |
| 262 | /** Streams the proxy holds open to it: websockets, event streams. */ | |
| 263 | openStreams: number; | |
| 264 | /** Why the last sweep spared it, while it still is. */ | |
| 265 | keptAwake?: BusyReason | undefined; | |
| 249 | 266 | /** The deploy in flight, if one is. */ |
| 250 | 267 | deploying?: { run: Run; abort: AbortController } | undefined; |
| 251 | 268 | /** The next deploy, waiting its turn; replaced by a newer request. */ |
| @@ -416,6 +433,9 @@ export class AppsRuntime { | ||
| 416 | 433 | ? {} |
| 417 | 434 | : { error: `serving · tests failed: ${state.live.testsFailed}` }), |
| 418 | 435 | ...(failedDeploy === undefined ? {} : { failedDeploy }), |
| 436 | ...(state.keptAwake === undefined | |
| 437 | ? {} | |
| 438 | : { busy: BUSY_WORDS[state.keptAwake] }), | |
| 419 | 439 | }; |
| 420 | 440 | } |
| 421 | 441 | // Nothing is live and nothing is in flight yet, but a deploy already |
| @@ -758,7 +778,13 @@ export class AppsRuntime { | ||
| 758 | 778 | const k = key(record.id, env); |
| 759 | 779 | let state = this.envs.get(k); |
| 760 | 780 | if (state === undefined) { |
| 761 | state = { app: record, env, chain: Promise.resolve(), restarts: [] }; | |
| 781 | state = { | |
| 782 | app: record, | |
| 783 | env, | |
| 784 | chain: Promise.resolve(), | |
| 785 | restarts: [], | |
| 786 | openStreams: 0, | |
| 787 | }; | |
| 762 | 788 | this.envs.set(k, state); |
| 763 | 789 | } |
| 764 | 790 | return state; |
| @@ -1094,7 +1120,26 @@ export class AppsRuntime { | ||
| 1094 | 1120 | /** Something came through the proxy: the environment is wanted. */ |
| 1095 | 1121 | touch(record: AppRecord, env: EnvName): void { |
| 1096 | 1122 | const state = this.envs.get(key(record.id, env)); |
| 1097 | if (state !== undefined) state.lastRequestAt = Date.now(); | |
| 1123 | if (state === undefined) return; | |
| 1124 | state.lastRequestAt = Date.now(); | |
| 1125 | state.keptAwake = undefined; | |
| 1126 | } | |
| 1127 | ||
| 1128 | /** | |
| 1129 | * A stream through the proxy opened: the environment is busy until the | |
| 1130 | * returned release is called — once; a second call does nothing. | |
| 1131 | */ | |
| 1132 | holdStream(record: AppRecord, env: EnvName): () => void { | |
| 1133 | const state = this.envs.get(key(record.id, env)); | |
| 1134 | if (state === undefined) return () => {}; | |
| 1135 | state.openStreams++; | |
| 1136 | let held = true; | |
| 1137 | return () => { | |
| 1138 | if (!held) return; | |
| 1139 | held = false; | |
| 1140 | state.openStreams--; | |
| 1141 | state.lastRequestAt = Date.now(); | |
| 1142 | }; | |
| 1098 | 1143 | } |
| 1099 | 1144 | |
| 1100 | 1145 | /** |
| @@ -1223,9 +1268,12 @@ export class AppsRuntime { | ||
| 1223 | 1268 | private async sweep(): Promise<void> { |
| 1224 | 1269 | if (this.stopped) return; |
| 1225 | 1270 | const after = this.options.sleepAfterMs ?? SLEEP_AFTER_MS; |
| 1271 | const grace = this.options.busyGraceMs ?? BUSY_GRACE_MS; | |
| 1226 | 1272 | const now = Date.now(); |
| 1227 | 1273 | for (const state of this.envs.values()) { |
| 1228 | 1274 | const live = state.live; |
| 1275 | const spared = state.keptAwake; | |
| 1276 | state.keptAwake = undefined; | |
| 1229 | 1277 | if ( |
| 1230 | 1278 | live === undefined || |
| 1231 | 1279 | state.deploying !== undefined || |
| @@ -1237,6 +1285,20 @@ export class AppsRuntime { | ||
| 1237 | 1285 | state.env === "production" ? after.production : after.preview; |
| 1238 | 1286 | const last = state.lastRequestAt ?? Date.parse(live.since); |
| 1239 | 1287 | if (now - last < limit) continue; |
| 1288 | // Idle by the clock; asked whether it is mid-work, and spared while | |
| 1289 | // it is — but only for one grace past the window, never forever. | |
| 1290 | const busy = busyReason(live.handle.pid, state.openStreams); | |
| 1291 | const overdue = now - last - limit >= grace; | |
| 1292 | if (busy !== undefined && !overdue) { | |
| 1293 | if (spared !== busy && live.runId !== "") | |
| 1294 | appendRunLog( | |
| 1295 | this.app.config.dataDir, | |
| 1296 | live.runId, | |
| 1297 | `kept awake: ${BUSY_WORDS[busy]}`, | |
| 1298 | ); | |
| 1299 | state.keptAwake = busy; | |
| 1300 | continue; | |
| 1301 | } | |
| 1240 | 1302 | state.live = undefined; |
| 1241 | 1303 | state.asleep = { |
| 1242 | 1304 | commitOid: live.commitOid, |
| @@ -1249,7 +1311,9 @@ export class AppsRuntime { | ||
| 1249 | 1311 | appendRunLog( |
| 1250 | 1312 | this.app.config.dataDir, |
| 1251 | 1313 | live.runId, |
| 1252 | `asleep: nothing asked for it in ${Math.round(limit / 60_000)} minutes; the next request wakes it`, | |
| 1314 | busy === undefined | |
| 1315 | ? `asleep: nothing asked for it in ${Math.round(limit / 60_000)} minutes; the next request wakes it` | |
| 1316 | : `asleep: still busy ${Math.round(grace / 60_000)} minutes past its idle window, which is as long as busy keeps an app awake; the next request wakes it`, | |
| 1253 | 1317 | ); |
| 1254 | 1318 | log.info("asleep", { app: state.app.slug, env: state.env }); |
| 1255 | 1319 | await live.handle.stop(); |
src/main.ts +5 −1
| @@ -124,6 +124,9 @@ const appDomain = appsOn | ||
| 124 | 124 | if (appDomain?.ok === false) app.log.error(appDomain.reason); |
| 125 | 125 | if (appDomain?.ok === true && appDomain.note !== undefined) |
| 126 | 126 | app.log.warn(appDomain.note); |
| 127 | // One crew run's length: what a run takes, and how long past its idle | |
| 128 | // window an app busy with one is left awake. | |
| 129 | const crewRunMs = positive(process.env.FORGE_CREW_TIMEOUT_MIN, 45) * 60_000; | |
| 127 | 130 | const apps = appDomain?.ok |
| 128 | 131 | ? new AppsRuntime(app, { |
| 129 | 132 | domain: appDomain.domain, |
| @@ -136,6 +139,7 @@ const apps = appDomain?.ok | ||
| 136 | 139 | : Number(publicUrl.port), |
| 137 | 140 | forgeUrl, |
| 138 | 141 | concurrency: positive(process.env.FORGE_APPS_CONCURRENCY, 2), |
| 142 | busyGraceMs: crewRunMs, | |
| 139 | 143 | }) |
| 140 | 144 | : undefined; |
| 141 | 145 | await apps?.start(); |
| @@ -172,7 +176,7 @@ const crew = appsOn | ||
| 172 | 176 | binary: process.env.FORGE_CLAUDE ?? "claude", |
| 173 | 177 | concurrency: positive(process.env.FORGE_CREW_CONCURRENCY, hosted ? 1 : 2), |
| 174 | 178 | budgetUsd: Number(process.env.FORGE_CREW_BUDGET_USD) || 5, |
| 175 | runTimeoutMs: positive(process.env.FORGE_CREW_TIMEOUT_MIN, 45) * 60_000, | |
| 179 | runTimeoutMs: crewRunMs, | |
| 176 | 180 | allowHosts: (process.env.FORGE_CREW_ALLOW_HOSTS ?? "") |
| 177 | 181 | .split(",") |
| 178 | 182 | .map((h) => h.trim()) |
src/web/studio/now.ts +20 −1
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * a repaint (`layout.fragment`, `client.liveRegionsJs`). |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { envPull } from "../../apps/data.ts"; | |
| 10 | 11 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 11 | 12 | import { listCrewRoles } from "../../core/agents.ts"; |
| 12 | 13 | import { getAppByRepo, type AppRecord } from "../../core/apps.ts"; |
| @@ -21,7 +22,7 @@ import { readRunLogTail } from "../../runs/log.ts"; | ||
| 21 | 22 | import { loadRepo, repoBase } from "../chrome.ts"; |
| 22 | 23 | import { html, type Html } from "../html.ts"; |
| 23 | 24 | import { fragment } from "../layout.ts"; |
| 24 | import { avatar, btn, card, icon, listCard, row, time } from "../ui.ts"; | |
| 25 | import { avatar, badge, btn, card, icon, listCard, row, time } from "../ui.ts"; | |
| 25 | 26 | import { changeHref, tellHref } from "./nouns.ts"; |
| 26 | 27 | import { LOG_TAIL_CAP, logBox } from "./log.ts"; |
| 27 | 28 | import { appPanels } from "./previews.ts"; |
| @@ -204,7 +205,25 @@ export async function nowCard( | ||
| 204 | 205 | runRows.push(html`<li class="log-tail-row">${activeTail}</li>`); |
| 205 | 206 | } |
| 206 | 207 | |
| 208 | // An environment the sleep sweep is sparing because it is mid-work: | |
| 209 | // that is something going on in the app, and the reason it is awake. | |
| 210 | const busyRows = (wordCtx.runtime?.status(record) ?? []).flatMap((env) => { | |
| 211 | if (env.busy === undefined) return []; | |
| 212 | const number = envPull(env.env); | |
| 213 | return [ | |
| 214 | row({ | |
| 215 | href: `${base}/app`, | |
| 216 | nav: true, | |
| 217 | lead: icon(number === undefined ? "rocket" : "pull"), | |
| 218 | title: number === undefined ? "production" : `preview of #${number}`, | |
| 219 | meta: html`${env.busy}; kept awake past its idle window`, | |
| 220 | trail: badge("busy", { icon: "activity", tone: "attn" }), | |
| 221 | }), | |
| 222 | ]; | |
| 223 | }); | |
| 224 | ||
| 207 | 225 | const nowRows: Html[] = [ |
| 226 | ...busyRows, | |
| 208 | 227 | ...(await Promise.all( |
| 209 | 228 | openPulls.map(async (p) => { |
| 210 | 229 | const { word, note } = await pullWord(wordCtx, p); |
src/web/ui.ts +7
| @@ -380,6 +380,13 @@ export function envFacts( | ||
| 380 | 380 | </p>`, |
| 381 | 381 | )} |
| 382 | 382 | ${when(env.note, (note) => html`<p class="muted small">${note}</p>`)} |
| 383 | ${when( | |
| 384 | env.busy, | |
| 385 | (busy) => | |
| 386 | html`<p class="muted small" data-busy> | |
| 387 | ${busy}; kept awake past its idle window | |
| 388 | </p>`, | |
| 389 | )} | |
| 383 | 390 | ${when(env.error, (error) => html`<p class="env-error">${error}</p>`)} |
| 384 | 391 | ${when( |
| 385 | 392 | env.failedDeploy, |
test/apps-busy.test.ts added +262
| @@ -0,0 +1,262 @@ | ||
| 1 | /** | |
| 2 | * An environment past its idle window is asked whether it is busy before | |
| 3 | * it is put to sleep: a child process still working, or a stream still | |
| 4 | * open through the proxy, keeps it awake — for one grace past the window, | |
| 5 | * never forever — and the app page and the Now card say *busy* while that | |
| 6 | * is why it is up. An idle one still sleeps. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { existsSync } from "node:fs"; | |
| 10 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; | |
| 15 | import { newApp } from "../src/actions/apps.ts"; | |
| 16 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 17 | import type { AppRecord } from "../src/core/apps.ts"; | |
| 18 | import type { Repo } from "../src/core/repos.ts"; | |
| 19 | import { listRuns, type Run } from "../src/core/runs.ts"; | |
| 20 | import { createUser, type User } from "../src/core/users.ts"; | |
| 21 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 22 | import { readRunLog } from "../src/runs/log.ts"; | |
| 23 | ||
| 24 | /** | |
| 25 | * The app under test: `/work?s=N` starts a child that works for N seconds | |
| 26 | * and answers at once; `/stream` is an event stream that stays open until | |
| 27 | * the browser goes away. | |
| 28 | */ | |
| 29 | const BUSY_SERVER = `const port = Number(process.env.PORT ?? "0"); | |
| 30 | export const server = Bun.serve({ | |
| 31 | port, | |
| 32 | fetch(request) { | |
| 33 | const url = new URL(request.url); | |
| 34 | if (url.pathname === "/work") { | |
| 35 | Bun.spawn(["sleep", String(Number(url.searchParams.get("s") ?? "1"))], { | |
| 36 | stdio: ["ignore", "ignore", "ignore"], | |
| 37 | }); | |
| 38 | return new Response("working"); | |
| 39 | } | |
| 40 | if (url.pathname === "/stream") { | |
| 41 | let timer: ReturnType<typeof setInterval> | undefined; | |
| 42 | const body = new ReadableStream({ | |
| 43 | start(controller) { | |
| 44 | controller.enqueue(new TextEncoder().encode("data: hello\\n\\n")); | |
| 45 | timer = setInterval( | |
| 46 | () => controller.enqueue(new TextEncoder().encode(": tick\\n\\n")), | |
| 47 | 100, | |
| 48 | ); | |
| 49 | }, | |
| 50 | cancel() { | |
| 51 | clearInterval(timer); | |
| 52 | }, | |
| 53 | }); | |
| 54 | return new Response(body, { | |
| 55 | headers: { "content-type": "text/event-stream" }, | |
| 56 | }); | |
| 57 | } | |
| 58 | return new Response("busy app", { status: url.pathname === "/" ? 200 : 404 }); | |
| 59 | }, | |
| 60 | }); | |
| 61 | `; | |
| 62 | ||
| 63 | const BUSY_TEST = `import { expect, test } from "bun:test"; | |
| 64 | process.env.PORT = "0"; | |
| 65 | const { server } = await import("./server.ts"); | |
| 66 | test("serves the page", async () => { | |
| 67 | const response = await fetch(\`http://\${server.hostname}:\${server.port}/\`); | |
| 68 | expect(response.status).toBe(200); | |
| 69 | }); | |
| 70 | `; | |
| 71 | ||
| 72 | const noProc = !existsSync("/proc/self/task"); | |
| 73 | ||
| 74 | describe("apps that are busy", () => { | |
| 75 | let dataDir: string; | |
| 76 | let app: App; | |
| 77 | let servers: Servers; | |
| 78 | let runtime: AppsRuntime; | |
| 79 | let alice: User; | |
| 80 | let repo: Repo; | |
| 81 | let record: AppRecord; | |
| 82 | const DOMAIN = "apps.test"; | |
| 83 | const HOST = `busy.${DOMAIN}`; | |
| 84 | const WINDOW = 700; | |
| 85 | const GRACE = 4_000; | |
| 86 | ||
| 87 | const viaHost = (path = "/", init: RequestInit = {}) => | |
| 88 | fetch(`${servers.url}${path}`, { | |
| 89 | ...init, | |
| 90 | headers: { host: HOST }, | |
| 91 | redirect: "manual", | |
| 92 | }); | |
| 93 | ||
| 94 | const production = (): EnvStatus => | |
| 95 | runtime.status(record).find((e) => e.env === "production")!; | |
| 96 | ||
| 97 | async function until<T>(read: () => T | undefined, ms = 60_000): Promise<T> { | |
| 98 | const deadline = Date.now() + ms; | |
| 99 | while (Date.now() < deadline) { | |
| 100 | const value = read(); | |
| 101 | if (value !== undefined) return value; | |
| 102 | await Bun.sleep(50); | |
| 103 | } | |
| 104 | throw new Error("did not happen in time"); | |
| 105 | } | |
| 106 | const settled = (where: (run: Run) => boolean) => | |
| 107 | until(() => | |
| 108 | listRuns(app.db, repo.id, { kind: "deploy", limit: 50 }).find( | |
| 109 | (r) => where(r) && (r.state === "ok" || r.state === "failed"), | |
| 110 | ), | |
| 111 | ); | |
| 112 | const asleep = () => | |
| 113 | until(() => (production().state === "asleep" ? true : undefined), 20_000); | |
| 114 | const log = (): string => | |
| 115 | readRunLog(dataDir, production().runId!, 64 * 1024).lines.join("\n"); | |
| 116 | ||
| 117 | /** Wake production and wait for it to fall asleep, so each test starts rested. */ | |
| 118 | async function freshlyAwake(): Promise<void> { | |
| 119 | if (production().state !== "asleep") await asleep(); | |
| 120 | expect((await viaHost("/")).status).toBe(200); | |
| 121 | expect(production().state).toBe("running"); | |
| 122 | } | |
| 123 | ||
| 124 | beforeAll(async () => { | |
| 125 | dataDir = await mkdtemp(join(tmpdir(), "forge-busy-")); | |
| 126 | app = createApp({ dataDir, port: 0 }); | |
| 127 | servers = await serve(app); | |
| 128 | runtime = new AppsRuntime(app, { | |
| 129 | domain: DOMAIN, | |
| 130 | scheme: "http", | |
| 131 | publicPort: servers.port, | |
| 132 | forgeUrl: servers.url, | |
| 133 | // Long enough for the scaffold and the busy app to deploy and settle. | |
| 134 | sleepAfterMs: { production: 3_600_000, preview: 3_600_000 }, | |
| 135 | sweepEveryMs: 100, | |
| 136 | busyGraceMs: GRACE, | |
| 137 | }); | |
| 138 | await runtime.start(); | |
| 139 | const created = await createUser(app.db, { | |
| 140 | username: "alice", | |
| 141 | email: "[email protected]", | |
| 142 | password: "a-good-passphrase", | |
| 143 | adminIfFirst: true, | |
| 144 | }); | |
| 145 | if (!created.ok) throw new Error(created.error); | |
| 146 | alice = created.value; | |
| 147 | const made = await newApp(app, alice, { | |
| 148 | name: "busy", | |
| 149 | description: "An app with work of its own", | |
| 150 | visibility: "public", | |
| 151 | }); | |
| 152 | if (!made.ok) throw new Error(String(made.error)); | |
| 153 | repo = made.value.repo; | |
| 154 | record = made.value.app; | |
| 155 | await settled((r) => r.env === "production"); | |
| 156 | const committed = await commitToBranch(app, alice, repo, { | |
| 157 | message: "Work in children, and stream", | |
| 158 | files: [ | |
| 159 | { path: "src/server.ts", content: BUSY_SERVER }, | |
| 160 | { path: "src/server.test.ts", content: BUSY_TEST }, | |
| 161 | // The scaffold's suites that boot its real server go with it. | |
| 162 | { path: "src/audit.test.ts", content: null }, | |
| 163 | { path: "src/openapi.test.ts", content: null }, | |
| 164 | { path: "src/security.test.ts", content: null }, | |
| 165 | ], | |
| 166 | }); | |
| 167 | if (!committed.ok) throw new Error(String(committed.error)); | |
| 168 | const run = await settled( | |
| 169 | (r) => | |
| 170 | r.env === "production" && r.commitOid === committed.value.commitOid, | |
| 171 | ); | |
| 172 | if (run.state !== "ok") throw new Error(`deploy failed: ${run.summary}`); | |
| 173 | // The runtime the rest of the suite judges: the same data, a short window. | |
| 174 | await runtime.stop(); | |
| 175 | runtime = new AppsRuntime(app, { | |
| 176 | domain: DOMAIN, | |
| 177 | scheme: "http", | |
| 178 | publicPort: servers.port, | |
| 179 | forgeUrl: servers.url, | |
| 180 | sleepAfterMs: { production: WINDOW, preview: WINDOW }, | |
| 181 | sweepEveryMs: 100, | |
| 182 | busyGraceMs: GRACE, | |
| 183 | }); | |
| 184 | await runtime.start(); | |
| 185 | await until(() => (production().state === "running" ? true : undefined)); | |
| 186 | }, 180_000); | |
| 187 | ||
| 188 | afterAll(async () => { | |
| 189 | await runtime.stop(); | |
| 190 | servers.stop(); | |
| 191 | app.close(); | |
| 192 | await rm(dataDir, { recursive: true, force: true }); | |
| 193 | }); | |
| 194 | ||
| 195 | test("an idle app still sleeps, and says nothing about being busy", async () => { | |
| 196 | await freshlyAwake(); | |
| 197 | await asleep(); | |
| 198 | expect(production().busy).toBeUndefined(); | |
| 199 | expect(log()).toContain("asleep: nothing asked for it"); | |
| 200 | }, 30_000); | |
| 201 | ||
| 202 | test.skipIf(noProc)( | |
| 203 | "an app whose child is still working survives the sweep, then sleeps once it is done", | |
| 204 | async () => { | |
| 205 | await freshlyAwake(); | |
| 206 | expect(await (await viaHost("/work?s=2")).text()).toBe("working"); | |
| 207 | // Well past the idle window, and still up: it was asked, and it is busy. | |
| 208 | await Bun.sleep(WINDOW + 700); | |
| 209 | expect(production().state).toBe("running"); | |
| 210 | expect(production().busy).toContain("child processes"); | |
| 211 | expect(log()).toContain("kept awake: busy"); | |
| 212 | ||
| 213 | // The app page and the Now card both say why it is up. | |
| 214 | const page = await (await fetch(`${servers.url}/alice/busy/app`)).text(); | |
| 215 | expect(page).toContain("busy — its child processes are still working"); | |
| 216 | const now = await ( | |
| 217 | await fetch(`${servers.url}/alice/busy/app/now`) | |
| 218 | ).text(); | |
| 219 | expect(now).toContain(">busy<"); | |
| 220 | expect(now).toContain("its child processes are still working"); | |
| 221 | ||
| 222 | // The child finishes; the next sweep finds nothing to wait for. | |
| 223 | await asleep(); | |
| 224 | expect(production().busy).toBeUndefined(); | |
| 225 | }, | |
| 226 | 30_000, | |
| 227 | ); | |
| 228 | ||
| 229 | test("an open stream keeps the app awake until it closes", async () => { | |
| 230 | await freshlyAwake(); | |
| 231 | const abort = new AbortController(); | |
| 232 | const response = await viaHost("/stream", { signal: abort.signal }); | |
| 233 | expect(response.headers.get("content-type")).toContain("text/event-stream"); | |
| 234 | const reader = response.body!.getReader(); | |
| 235 | const first = await reader.read(); | |
| 236 | expect(new TextDecoder().decode(first.value)).toContain("data: hello"); | |
| 237 | await Bun.sleep(WINDOW + 700); | |
| 238 | expect(production().state).toBe("running"); | |
| 239 | expect(production().busy).toContain("a stream to it is still open"); | |
| 240 | ||
| 241 | abort.abort(); | |
| 242 | await reader.cancel().catch(() => {}); | |
| 243 | await asleep(); | |
| 244 | }, 30_000); | |
| 245 | ||
| 246 | test.skipIf(noProc)( | |
| 247 | "an app that stays busy sleeps anyway once the grace is spent", | |
| 248 | async () => { | |
| 249 | await freshlyAwake(); | |
| 250 | const started = Date.now(); | |
| 251 | // Works far longer than the window and the grace together. | |
| 252 | await viaHost("/work?s=12"); | |
| 253 | await Bun.sleep(WINDOW + 700); | |
| 254 | expect(production().state).toBe("running"); | |
| 255 | await asleep(); | |
| 256 | expect(Date.now() - started).toBeGreaterThanOrEqual(WINDOW + GRACE); | |
| 257 | expect(Date.now() - started).toBeLessThan(10_000); | |
| 258 | expect(log()).toContain("asleep: still busy"); | |
| 259 | }, | |
| 260 | 30_000, | |
| 261 | ); | |
| 262 | }); |
new above
offline