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

Merged opened this in answer to

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

Merged

mason/forge:mason/332-a-hosted-app-with-children-still-working → main 3 commits fork

  • Merged. mason/332-a-hosted-app-with-children-still-working was merged into main as 907652b.

Review

Sign in to review this change.
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
690690 mean thirty installs and test suites at once; now it means starting what
691691 people are using.
692692
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
693702 ### Subscriptions
694703
695704 One token in the environment is one account for the whole forge. An admin
forge.json +1 −1
@@ -6,7 +6,7 @@
66 "ready": 90
77 },
88 "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",
1010 "timeout": 1800
1111 }
1212 }
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(
465465 }
466466 const out = new Headers(response.headers);
467467 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 },
472512 });
473513 }
474514
@@ -543,6 +583,12 @@ async function proxyWebSocket(
543583 return unavailable(runtime, target, status, opened.error);
544584 }
545585 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 );
546592 const upgraded = server.upgrade(request, {
547593 data: { upstream },
548594 // 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";
4444 import { holdLease, STALE_AFTER_MS } from "../runs/lease.ts";
4545 import { appendRunLog } from "../runs/log.ts";
4646 import { primaryDomain } from "../core/domains.ts";
47 import { BUSY_WORDS, busyReason, type BusyReason } from "./busy.ts";
4748 import { exportTree, readFileAt } from "./checkout.ts";
4849 import type { DomainResolver } from "./domains.ts";
4950 import {
@@ -91,6 +92,12 @@ export interface RuntimeOptions {
9192 | undefined;
9293 /** How often the runtime looks for environments to put to sleep. */
9394 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;
94101 /**
95102 * What a domain check asks: the system resolver unless a test hands in
96103 * its own, so proving `app.example.com` needs no network in a test.
@@ -107,6 +114,7 @@ export type EnvState =
107114
108115 const SLEEP_AFTER_MS = { production: 24 * 3_600_000, preview: 30 * 60_000 };
109116 const SWEEP_EVERY_MS = 60_000;
117 const BUSY_GRACE_MS = 45 * 60_000;
110118
111119 export interface EnvStatus {
112120 readonly env: EnvName;
@@ -163,6 +171,11 @@ export interface EnvStatus {
163171 readonly error: string;
164172 }
165173 | 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;
166179 }
167180
168181 interface Live {
@@ -246,6 +259,10 @@ interface Environment {
246259 waking?: Promise<void> | undefined;
247260 /** When something last came through the proxy for this environment. */
248261 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;
249266 /** The deploy in flight, if one is. */
250267 deploying?: { run: Run; abort: AbortController } | undefined;
251268 /** The next deploy, waiting its turn; replaced by a newer request. */
@@ -416,6 +433,9 @@ export class AppsRuntime {
416433 ? {}
417434 : { error: `serving · tests failed: ${state.live.testsFailed}` }),
418435 ...(failedDeploy === undefined ? {} : { failedDeploy }),
436 ...(state.keptAwake === undefined
437 ? {}
438 : { busy: BUSY_WORDS[state.keptAwake] }),
419439 };
420440 }
421441 // Nothing is live and nothing is in flight yet, but a deploy already
@@ -758,7 +778,13 @@ export class AppsRuntime {
758778 const k = key(record.id, env);
759779 let state = this.envs.get(k);
760780 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 };
762788 this.envs.set(k, state);
763789 }
764790 return state;
@@ -1094,7 +1120,26 @@ export class AppsRuntime {
10941120 /** Something came through the proxy: the environment is wanted. */
10951121 touch(record: AppRecord, env: EnvName): void {
10961122 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 };
10981143 }
10991144
11001145 /**
@@ -1223,9 +1268,12 @@ export class AppsRuntime {
12231268 private async sweep(): Promise<void> {
12241269 if (this.stopped) return;
12251270 const after = this.options.sleepAfterMs ?? SLEEP_AFTER_MS;
1271 const grace = this.options.busyGraceMs ?? BUSY_GRACE_MS;
12261272 const now = Date.now();
12271273 for (const state of this.envs.values()) {
12281274 const live = state.live;
1275 const spared = state.keptAwake;
1276 state.keptAwake = undefined;
12291277 if (
12301278 live === undefined ||
12311279 state.deploying !== undefined ||
@@ -1237,6 +1285,20 @@ export class AppsRuntime {
12371285 state.env === "production" ? after.production : after.preview;
12381286 const last = state.lastRequestAt ?? Date.parse(live.since);
12391287 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 }
12401302 state.live = undefined;
12411303 state.asleep = {
12421304 commitOid: live.commitOid,
@@ -1249,7 +1311,9 @@ export class AppsRuntime {
12491311 appendRunLog(
12501312 this.app.config.dataDir,
12511313 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`,
12531317 );
12541318 log.info("asleep", { app: state.app.slug, env: state.env });
12551319 await live.handle.stop();
src/main.ts +5 −1
@@ -124,6 +124,9 @@ const appDomain = appsOn
124124 if (appDomain?.ok === false) app.log.error(appDomain.reason);
125125 if (appDomain?.ok === true && appDomain.note !== undefined)
126126 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;
127130 const apps = appDomain?.ok
128131 ? new AppsRuntime(app, {
129132 domain: appDomain.domain,
@@ -136,6 +139,7 @@ const apps = appDomain?.ok
136139 : Number(publicUrl.port),
137140 forgeUrl,
138141 concurrency: positive(process.env.FORGE_APPS_CONCURRENCY, 2),
142 busyGraceMs: crewRunMs,
139143 })
140144 : undefined;
141145 await apps?.start();
@@ -172,7 +176,7 @@ const crew = appsOn
172176 binary: process.env.FORGE_CLAUDE ?? "claude",
173177 concurrency: positive(process.env.FORGE_CREW_CONCURRENCY, hosted ? 1 : 2),
174178 budgetUsd: Number(process.env.FORGE_CREW_BUDGET_USD) || 5,
175 runTimeoutMs: positive(process.env.FORGE_CREW_TIMEOUT_MIN, 45) * 60_000,
179 runTimeoutMs: crewRunMs,
176180 allowHosts: (process.env.FORGE_CREW_ALLOW_HOSTS ?? "")
177181 .split(",")
178182 .map((h) => h.trim())
src/web/studio/now.ts +20 −1
@@ -7,6 +7,7 @@
77 * a repaint (`layout.fragment`, `client.liveRegionsJs`).
88 */
99
10 import { envPull } from "../../apps/data.ts";
1011 import { appsRuntime } from "../../apps/runtime.ts";
1112 import { listCrewRoles } from "../../core/agents.ts";
1213 import { getAppByRepo, type AppRecord } from "../../core/apps.ts";
@@ -21,7 +22,7 @@ import { readRunLogTail } from "../../runs/log.ts";
2122 import { loadRepo, repoBase } from "../chrome.ts";
2223 import { html, type Html } from "../html.ts";
2324 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";
2526 import { changeHref, tellHref } from "./nouns.ts";
2627 import { LOG_TAIL_CAP, logBox } from "./log.ts";
2728 import { appPanels } from "./previews.ts";
@@ -204,7 +205,25 @@ export async function nowCard(
204205 runRows.push(html`<li class="log-tail-row">${activeTail}</li>`);
205206 }
206207
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
207225 const nowRows: Html[] = [
226 ...busyRows,
208227 ...(await Promise.all(
209228 openPulls.map(async (p) => {
210229 const { word, note } = await pullWord(wordCtx, p);
src/web/ui.ts +7
@@ -380,6 +380,13 @@ export function envFacts(
380380 </p>`,
381381 )}
382382 ${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 )}
383390 ${when(env.error, (error) => html`<p class="env-error">${error}</p>`)}
384391 ${when(
385392 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs