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

test/apps-adversarial-teardown.test.ts runs under 10 s #3571

Merged opened this

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

Merged

scout/forge:scout/3561-test-apps-adversarial-teardown-test-ts-r → main 2 commits fork

  • Merged. scout/3561-test-apps-adversarial-teardown-test-ts-r was merged into main as 736267e.

Review

Sign in to review this change.
3 files changed +70 −41
scripts/test-times.json +1 −1
@@ -1,7 +1,7 @@
11 {
22 "test/apps-adversarial-grants.test.ts": 28.3,
33 "test/apps-adversarial-rules.test.ts": 29.7,
4 "test/apps-adversarial-teardown.test.ts": 36.4,
4 "test/apps-adversarial-teardown.test.ts": 3,
55 "test/apps-adversarial-test-step.test.ts": 33.5,
66 "test/apps-adversarial.test.ts": 18,
77 "test/apps-busy.test.ts": 26.4,
src/apps/runtime.ts +1 −1
@@ -360,7 +360,7 @@ const TOUCH_REASONS: readonly string[] = [
360360
361361 const RESTART_WINDOW_MS = 10 * 60_000;
362362 const RESTART_MAX = 3;
363 const RESTART_BACKOFF_MS = [2_000, 10_000, 30_000];
363 export const RESTART_BACKOFF_MS = [2_000, 10_000, 30_000];
364364
365365 const runtimes = new WeakMap<App, AppsRuntime>();
366366
test/apps-adversarial-teardown.test.ts +68 −39
@@ -5,13 +5,13 @@
55 * (#2038); each file of the split boots its own forge and runtime.
66 */
77
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
99 import { existsSync } from "node:fs";
1010 import { mkdtemp, rm } from "node:fs/promises";
1111 import { tmpdir } from "node:os";
1212 import { join } from "node:path";
1313 import { createApp, type App } from "../src/app.ts";
14 import { AppsRuntime } from "../src/apps/runtime.ts";
14 import { AppsRuntime, RESTART_BACKOFF_MS } from "../src/apps/runtime.ts";
1515 import { envDir } from "../src/apps/data.ts";
1616 import { newApp } from "../src/actions/apps.ts";
1717 import { mergePull, openPull } from "../src/actions/pulls.ts";
@@ -55,11 +55,32 @@ describe("apps adversarial: teardown races", () => {
5555 run.state === "cancelled")
5656 )
5757 return run;
58 await Bun.sleep(50);
58 await Bun.sleep(10);
5959 }
6060 throw new Error("run did not settle in time");
6161 }
6262
63 /** Every deploy run of one environment exists and is in a final state. */
64 async function quiet(repoId: string, env: string, timeoutMs = 60_000) {
65 const deadline = Date.now() + timeoutMs;
66 while (Date.now() < deadline) {
67 const runs = listRuns(app.db, repoId, { env, kind: "deploy", limit: 50 });
68 if (
69 runs.length > 0 &&
70 runs.every((r) => ["ok", "failed", "cancelled"].includes(r.state))
71 )
72 return;
73 await Bun.sleep(10);
74 }
75 throw new Error(`deploys of ${env} did not settle in time`);
76 }
77
78 async function gone(path: string, timeoutMs = 10_000): Promise<void> {
79 const deadline = Date.now() + timeoutMs;
80 while (existsSync(path) && Date.now() < deadline) await Bun.sleep(10);
81 expect(existsSync(path)).toBe(false);
82 }
83
6384 beforeAll(async () => {
6485 dataDir = await mkdtemp(join(tmpdir(), "forge-adv-"));
6586 work = await mkdtemp(join(tmpdir(), "forge-adv-work-"));
@@ -120,35 +141,48 @@ describe("apps adversarial: teardown races", () => {
120141
121142 // Wait for the preview to actually come up (live, not just queued).
122143 await settled(repo.id, (r) => r.env === `pull-${pull.number}`);
123 expect((await viaHost(`resurrect--${pull.number}.${DOMAIN}`)).status).toBe(
124 200,
125 );
144 // Arm a watch on the first restart backoff (RESTART_BACKOFF_MS[0], the
145 // delay of a fresh environment's first crash): a timer of that length set
146 // after the close is a restart being scheduled.
147 const armed: unknown[] = [];
148 const realSetTimeout = globalThis.setTimeout;
149 const spy = spyOn(globalThis, "setTimeout").mockImplementation(((
150 fn: () => void,
151 ms?: number,
152 ...rest: unknown[]
153 ) => {
154 if (ms === RESTART_BACKOFF_MS[0]) armed.push(fn);
155 return (realSetTimeout as (...a: unknown[]) => unknown)(fn, ms, ...rest);
156 }) as unknown as typeof setTimeout);
157 try {
158 expect(
159 (await viaHost(`resurrect--${pull.number}.${DOMAIN}`)).status,
160 ).toBe(200);
126161
127 const closed = await mergePull(
128 app,
129 alice,
130 repo,
131 getPull(app.db, repo.id, pull.number)!,
132 "merge",
133 );
134 expect(closed.ok).toBe(true);
162 const closed = await mergePull(
163 app,
164 alice,
165 repo,
166 getPull(app.db, repo.id, pull.number)!,
167 "merge",
168 );
169 expect(closed.ok).toBe(true);
135170
136 // The teardown itself must remove the directory promptly.
137 const goneDeadline = Date.now() + 10_000;
138 while (
139 existsSync(envDir(dataDir, record.id, `pull-${pull.number}`)) &&
140 Date.now() < goneDeadline
141 )
142 await Bun.sleep(50);
143 expect(existsSync(envDir(dataDir, record.id, `pull-${pull.number}`))).toBe(
144 false,
145 );
171 // The teardown removes the directory last, after the process has been
172 // stopped, so the directory going is the stop having settled.
173 await gone(envDir(dataDir, record.id, `pull-${pull.number}`));
146174
147 // The bug: `watch()`'s exit handler mistook the deliberate stop for a
148 // crash and scheduled a restart ~2s later (RESTART_BACKOFF_MS[0]). Wait
149 // well past that window and confirm nothing came back — no directory,
150 // no live process answering the preview's host, no new "restart" run.
151 await Bun.sleep(3_500);
175 // The bug: `watch()`'s exit handler mistook the deliberate stop for a
176 // crash and scheduled a restart ~2s later. The handler runs as a
177 // continuation of the exit, so one turn of the loop lets it run; the
178 // restart timer is then observed being armed (or not), no clock waited.
179 await new Promise((r) => setImmediate(r));
180 expect(armed).toEqual([]);
181 } finally {
182 spy.mockRestore();
183 }
184 // And nothing came back — no directory, no live process answering the
185 // preview's host, no new "restart" run.
152186 expect(existsSync(envDir(dataDir, record.id, `pull-${pull.number}`))).toBe(
153187 false,
154188 );
@@ -199,16 +233,11 @@ describe("apps adversarial: teardown races", () => {
199233 );
200234 expect(closedQuickly.ok).toBe(true);
201235
202 const deadline = Date.now() + 15_000;
203 while (
204 existsSync(envDir(dataDir, record.id, `pull-${pull.number}`)) &&
205 Date.now() < deadline
206 )
207 await Bun.sleep(50);
208 expect(existsSync(envDir(dataDir, record.id, `pull-${pull.number}`))).toBe(
209 false,
210 );
211 await Bun.sleep(3_500);
236 await gone(envDir(dataDir, record.id, `pull-${pull.number}`));
237 // The in-flight deploy must not put anything back as it winds down: wait
238 // for every deploy run of this pull to reach a final state (and for one
239 // to exist), rather than for the clock.
240 await quiet(repo.id, `pull-${pull.number}`);
212241 expect(existsSync(envDir(dataDir, record.id, `pull-${pull.number}`))).toBe(
213242 false,
214243 );

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs