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

A second machine can boot a read-only forge from the latest snapshot, with one script and a test #9665

Merged opened this

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

Merged

mason/forge:mason/9657-a-second-machine-can-boot-a-read-only-fo → main 3 commits fork 2 threads · 2 unresolved

  • Merged. mason/9657-a-second-machine-can-boot-a-read-only-fo was merged into main as 753aaa6.

Review

Sign in to review this change.
8 files changed +359 −4
docs/features/maintenance.md +24 −3
@@ -34,10 +34,31 @@ return the same HTML with status 503 and `Retry-After`.
3434 apps 13 GB) plus one snapshot and headroom, so 64 GB; 4 GB of memory free for the
3535 forge alone, 8 GB with apps running. The 16 GB droplet cannot hold it.
3636
37 **Take over reads (#9657).** On a second machine of that size, with the backup
38 store's variables (`S3_*`, or `FORGE_SNAPSHOT_DIR`) and `FORGE_SNAPSHOT_PASSPHRASE`:
39
40 bun scripts/standby-restore.ts latest /srv/standby # or a snapshot name; the directory must be empty
41 FORGE_READ_ONLY=1 FORGE_DATA=/srv/standby PORT=3000 bun run src/main.ts
42
43 The script restores the snapshot (`src/snapshot/restore.ts`), runs SQLite's
44 integrity check and confirms every repository the database lists is on disk;
45 it exits 1 and names what is wrong otherwise. With `FORGE_READ_ONLY=1` the forge
46 serves pages, the API's reads and git clone/fetch from that directory. One guard
47 at the front of the request path (`src/http/read_only.ts`) answers every other
48 method, a WebSocket upgrade and the push door (`git-receive-pack`) with a plain
49 503 "read-only standby", so a route added later is covered. Nothing past the
50 listener starts: no terminal, apps, crew, checks, releases, mirrors, snapshots
51 or nightly jobs, and not even the boot's reconcile or any timer. The
52 standby knows no one: a session or token of the main forge is read as signed
53 out, so it shows public pages only, and sign-in (a POST) is refused. Opening
54 the database still migrates it forward, on the restored copy only. Point the tunnel's origin at it for the move.
55 Test: `test/standby-restore.test.ts`.
56
3757 ## Not done
38 - No standby takeover: nothing replicates data to a second machine, so the
39 droplet can only show the page. That needs the snapshot mirror restored onto a
40 machine of the size above, and is a separate issue.
58 - Writes on the standby, and switching back: the standby is a copy as of its
59 snapshot, so anything written on the main forge after it is not there, and
60 nothing carries standby-time changes back (there are none; it takes no writes).
61 Nothing replicates continuously; re-restore into a new directory to catch up.
4162 - The page is not pushed to Cloudflare automatically; `standby.ts` is started by hand.
4263 - The admin is told on the page, event and status; no mail or push.
4364 - Where the kernel gives no boot id, any restart ends maintenance.
scripts/standby-restore.ts added +54
@@ -0,0 +1,54 @@
1 /**
2 * Make a second machine a read-only standby (#9657): restore a snapshot the
3 * forge's backups made into an empty data directory, and verify it.
4 *
5 * bun scripts/standby-restore.ts <snapshot|latest> <data-dir>
6 *
7 * The store is the one the forge backs up to: the `S3_*` variables, or
8 * `FORGE_SNAPSHOT_DIR`; `FORGE_SNAPSHOT_PASSPHRASE` opens sealed values.
9 * Then serve reads from it:
10 *
11 * FORGE_READ_ONLY=1 FORGE_DATA=<data-dir> PORT=3000 bun run src/main.ts
12 *
13 * Exits 0 when the database's integrity check passes and every repository
14 * is on disk, 1 when it does not, 2 on a bad argument. See
15 * docs/features/maintenance.md.
16 */
17 import { resolve } from "node:path";
18 import { resolveDestination } from "../src/snapshot/destination.ts";
19 import { restoreStandby } from "../src/snapshot/standby.ts";
20
21 async function main(args: readonly string[]): Promise<number> {
22 const [snapshot, dir] = args;
23 // CLI output below is for the person at the machine.
24 if (args.length !== 2 || !snapshot || !dir || dir.startsWith("-")) {
25 console.error("usage: bun scripts/standby-restore.ts <snapshot|latest> <data-dir>");
26 return 2;
27 }
28 const dataDir = resolve(dir);
29 if (dataDir === "/data") {
30 console.error("/data is the production data directory; restore into the standby's own");
31 return 2;
32 }
33 const store = resolveDestination()?.store;
34 if (store === undefined) {
35 console.error("no store: export the S3_* variables, or FORGE_SNAPSHOT_DIR");
36 return 2;
37 }
38 try {
39 const done = await restoreStandby(store, snapshot, dataDir, process.env["FORGE_SNAPSHOT_PASSPHRASE"] || undefined);
40 console.log(`restored ${done.name}: ${done.restored.files} files, ${done.repos} repositories, key ${done.restored.key}`);
41 if (done.problems.length > 0) {
42 for (const p of done.problems) console.error(`standby-restore: ${p}`);
43 return 1;
44 }
45 console.log("verified: database integrity ok, every repository on disk");
46 console.log(`serve reads: FORGE_READ_ONLY=1 FORGE_DATA=${dataDir} PORT=3000 bun run src/main.ts`);
47 return 0;
48 } catch (error) {
49 console.error(`standby-restore: ${error instanceof Error ? error.message : String(error)}`);
50 return 1;
51 }
52 }
53
54 if (import.meta.main) process.exit(await main(process.argv.slice(2)));
src/app.ts +2
@@ -205,6 +205,8 @@ export interface ForgeConfig {
205205 * The listener is bound with SO_REUSEPORT so the new process can share it.
206206 */
207207 selfSwap?: boolean;
208 /** A standby: reads are answered, every mutation is a 503 (`FORGE_READ_ONLY=1`; `http/read_only.ts`, #9657). */
209 readOnly?: boolean;
208210 /** How long a swapped-in release has to read healthy (`FORGE_SWAP_BUDGET_S`). @default 60000 */
209211 swapBudgetMs?: number;
210212 }
src/http/read_only.ts added +50
@@ -0,0 +1,50 @@
1 /**
2 * The read-only standby's one guard (#9657). A forge started with
3 * `FORGE_READ_ONLY=1` answers reads and refuses everything that could
4 * change state, at the front of the request path: one check on the method,
5 * not a check per route, so a route added next year is covered.
6 *
7 * Allowed: GET, HEAD and OPTIONS (a WebSocket upgrade is a GET and is
8 * refused, since a shell is a write), and the one POST that only reads,
9 * git's `git-upload-pack` (clone and fetch). The push advertisement
10 * (`info/refs?service=git-receive-pack`) is refused too, so a client is
11 * told before it sends a pack.
12 */
13
14 import { MAINTENANCE_HEADERS } from "../maintenance/page.ts";
15
16 /** True when the environment asks for a read-only standby (`FORGE_READ_ONLY=1`). */
17 export const readOnlyFrom = (env: Record<string, string | undefined>): boolean =>
18 (env["FORGE_READ_ONLY"] ?? "").trim() === "1";
19
20 const READS = new Set(["GET", "HEAD", "OPTIONS"]);
21 const UPLOAD_PACK = /^\/[^/]+\/[^/]+\.git\/git-upload-pack$/;
22
23 /** Whether a request only reads: by method and path, nothing else. */
24 export function isRead(request: Request): boolean {
25 const url = new URL(request.url);
26 if (request.method === "POST" && UPLOAD_PACK.test(url.pathname)) return true;
27 if (!READS.has(request.method)) return false;
28 if (request.headers.get("upgrade") !== null) return false;
29 return url.searchParams.get("service") !== "git-receive-pack";
30 }
31
32 const WORDS = "read-only standby";
33
34 /** The plain 503 a mutation gets on a standby; undefined for a read. */
35 export function readOnlyRefusal(request: Request): Response | undefined {
36 if (isRead(request)) return undefined;
37 const wantsJson =
38 new URL(request.url).pathname.startsWith("/api/") ||
39 (request.headers.get("accept") ?? "").includes("application/json");
40 const headers = { ...MAINTENANCE_HEADERS };
41 if (wantsJson) {
42 headers["content-type"] = "application/json; charset=utf-8";
43 return new Response(JSON.stringify({ error: "read_only", message: WORDS }), { status: 503, headers });
44 }
45 headers["content-type"] = "text/plain; charset=utf-8";
46 return new Response(`${WORDS}: this forge is serving reads while the main one is away; nothing can be changed here.\n`, {
47 status: 503,
48 headers,
49 });
50 }
src/http/server.ts +9
@@ -80,6 +80,7 @@ import { activeEdgeToken, edgeIdentity } from "./edge.ts";
8080 import { shieldState } from "../actions/shield.ts";
8181 import { crawlRefused, familyOf, finishedStrangerDeep, isCrawler, isDeepPath, isShieldedPath, noteCrawler, noteDeepStranger, noteShielded, shieldRefused, takeCrawl, wasCrawler, wasDeepStranger, wasShielded } from "./crawl.ts";
8282 import { liveResponse, readyResponse } from "./health.ts";
83 import { readOnlyRefusal } from "./read_only.ts";
8384 import { running } from "../kernel/version.ts";
8485 import { runGit } from "../git/env.ts";
8586 import { noteRefusedInternal } from "../kernel/internal_refused.ts";
@@ -571,6 +572,9 @@ const SHELL_IDLE_MS = 30 * 60_000;
571572 const NO_AUTH: Auth = { user: undefined, scope: undefined, via: "none" };
572573
573574 async function resolveAuth(app: App, request: Request): Promise<Auth> {
575 // A read-only standby knows no one: a session or token of the main forge
576 // must not show its private pages on a copy (#9657).
577 if (app.config.readOnly === true) return NO_AUTH;
574578 // The platform edge, when there is one, has already signed this person
575579 // in; its word outranks a cookie of ours.
576580 const login = edgeIdentity(request, activeEdgeToken(app));
@@ -2416,6 +2420,11 @@ export async function serve(app: App): Promise<Servers> {
24162420 ): Promise<Response | undefined> => {
24172421 const started = performance.now();
24182422 noteStart(request, started);
2423 // A read-only standby refuses every mutation here, before any route.
2424 if (app.config.readOnly === true) {
2425 const refused = readOnlyRefusal(request);
2426 if (refused !== undefined) return securityFloor(refused);
2427 }
24192428 // An app's hostname is answered before any route: `hello.<domain>` is
24202429 // the app, whatever its path, and the forge's own routes never apply.
24212430 const holding = await holdAppHost(app, request);
src/main.ts +19 −1
@@ -17,6 +17,7 @@ import { drainOnHandover, handover } from "./release/swap.ts";
1717 import { parentUrlProblem } from "./core/parent_releases.ts";
1818 import { releaseEnv } from "./release/env.ts";
1919 import { createApp } from "./app.ts";
20 import { readOnlyFrom } from "./http/read_only.ts";
2021 import { warmFlow } from "./system/flow_cache.ts";
2122 import { backfillCarried } from "./core/carried.ts";
2223 import { loadTrustedHosts } from "./origins/trusted-hosts.ts";
@@ -169,7 +170,10 @@ const hosted = hostedAt !== "";
169170
170171 // SSH is on by default: the terminal UI is a first-class surface, not an
171172 // add-on. `FORGE_SSH_PORT=0` turns the listener off entirely.
172 const sshPort = Number(process.env.FORGE_SSH_PORT ?? (hosted ? 0 : 2222));
173 // A read-only standby (FORGE_READ_ONLY=1, #9657) serves reads from a restored
174 // directory: no terminal, no apps, no crew, no jobs.
175 const readOnly = readOnlyFrom(process.env);
176 const sshPort = readOnly ? 0 : Number(process.env.FORGE_SSH_PORT ?? (hosted ? 0 : 2222));
173177
174178 // FORGE_URL is the address people reach the forge at when a proxy in front
175179 // terminates TLS. Parsed once here so a typo fails the boot, not a request.
@@ -235,6 +239,7 @@ const previewKey = previewIdentityKey(process.env);
235239
236240 const app = createApp({
237241 dataDir,
242 ...(readOnly ? { readOnly } : {}),
238243 // A new forge starts with signup = invite (FORGE_SIGNUP below still wins).
239244 seedSignup: true,
240245 ...(parentUrl === "" ? {} : { parentUrl }),
@@ -323,6 +328,19 @@ const app = createApp({
323328 timeStatements(app.db, createLog("sql"));
324329 loadFlags(app.db);
325330 loadTrustedHosts(app.db);
331 if (readOnly) {
332 const servers = await serve(app);
333 // Only the listener runs: no reconcile, perf watcher, timers, apps, crew,
334 // checks, releases, mirrors, snapshots or nightly jobs. Reads are answered until a signal.
335 app.log.info("forge is up, read-only standby", { url: servers.url, data: app.config.dataDir });
336 const stop = (): void => {
337 servers.stop();
338 app.close();
339 process.exit(0);
340 };
341 for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.once(signal, stop);
342 await new Promise<never>(() => {});
343 }
326344 // FORGE_SIGNUP (checked in configProblem) sets who may sign up; kept like any change.
327345 const bootSignup = signupFromEnv(process.env.FORGE_SIGNUP);
328346 if (bootSignup.ok && bootSignup.value !== null)
src/snapshot/standby.ts added +65
@@ -0,0 +1,65 @@
1 /**
2 * A snapshot restored for a read-only standby (#9657): the restore of
3 * `restore.ts` into an empty directory, then checked — the database passes
4 * SQLite's integrity check and every repository the database lists is a
5 * directory on disk. Plain data out; the script prints it.
6 */
7
8 import { existsSync } from "node:fs";
9 import { join } from "node:path";
10 import { repoDir } from "../git/repos.ts";
11 import { restoreSnapshot, type Restored } from "./restore.ts";
12 import { listSnapshots } from "./snapshot.ts";
13 import type { Store } from "./store.ts";
14
15 export interface Standby {
16 readonly name: string;
17 readonly restored: Restored;
18 /** Repositories the database lists. */
19 readonly repos: number;
20 /** What failed the verification, one line each; empty is verified. */
21 readonly problems: readonly string[];
22 }
23
24 /** The database's integrity and its repositories, checked in a restored directory. */
25 export async function verifyRestored(dataDir: string): Promise<{ repos: number; problems: string[] }> {
26 const { Database } = await import("bun:sqlite");
27 const file = join(dataDir, "forge.db");
28 if (!existsSync(file)) return { repos: 0, problems: ["forge.db is missing"] };
29 const db = new Database(file, { readonly: true });
30 try {
31 const problems: string[] = [];
32 const rows = db.query<{ integrity_check: string }, []>("PRAGMA integrity_check").all();
33 if (rows.length !== 1 || rows[0]?.integrity_check !== "ok")
34 problems.push(`integrity check: ${rows.map((r) => r.integrity_check).slice(0, 3).join("; ")}`);
35 const repos = db
36 .query<{ owner: string; name: string }, []>(
37 "SELECT u.username AS owner, r.name AS name FROM repo r JOIN user u ON u.id = r.owner_id",
38 )
39 .all();
40 const missing = repos.filter((r) => !existsSync(repoDir(join(dataDir, "repos"), r.owner, r.name)));
41 for (const r of missing.slice(0, 5)) problems.push(`repository ${r.owner}/${r.name} is not on disk`);
42 if (missing.length > 5) problems.push(`and ${missing.length - 5} more repositories not on disk`);
43 return { repos: repos.length, problems };
44 } finally {
45 db.close();
46 }
47 }
48
49 /** `name` is a snapshot name or `latest`; the directory must be empty. */
50 export async function restoreStandby(
51 store: Store,
52 name: string,
53 dataDir: string,
54 passphrase?: string,
55 ): Promise<Standby> {
56 let chosen = name;
57 if (name === "latest") {
58 const newest = (await listSnapshots(store))[0];
59 if (newest === undefined) throw new Error(`no snapshot in ${store.where}`);
60 chosen = newest.name;
61 }
62 const restored = await restoreSnapshot(store, chosen, dataDir, { passphrase });
63 const checked = await verifyRestored(dataDir);
64 return { name: chosen, restored, repos: checked.repos, problems: checked.problems };
65 }
test/standby-restore.test.ts added +136
@@ -0,0 +1,136 @@
1 /**
2 * The read-only standby (#9657): a small snapshot restored by
3 * `scripts/standby-restore.ts`, the restored directory booted with
4 * FORGE_READ_ONLY=1, `/` answering and git reading, and every mutation a
5 * plain 503. The guard is also checked as plain data over the methods.
6 */
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { existsSync, mkdtempSync } from "node:fs";
9 import { rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
14 import { createUser } from "../src/core/users.ts";
15 import { createSession } from "../src/core/sessions.ts";
16 import { isRead, readOnlyFrom, readOnlyRefusal } from "../src/http/read_only.ts";
17 import { lostPort, onFreshPort } from "../src/kernel/port.ts";
18 import { dirStore } from "../src/snapshot/store.ts";
19 import { takeSnapshot } from "../src/snapshot/snapshot.ts";
20
21 const TREE = join(import.meta.dir, "..");
22 let root: string;
23 let app: App;
24 let session = "";
25
26 beforeAll(async () => {
27 root = mkdtempSync(join(tmpdir(), "forge-standby-"));
28 app = createApp({ dataDir: join(root, "live"), port: 0 });
29 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
30 if (!u.ok) throw new Error(u.error);
31 const repo = await createRepo(app, u.value, { name: "notes", visibility: "public" });
32 if (!repo.ok) throw new Error(String(repo.error));
33 const c = await commitToBranch(app, u.value, repo.value, { message: "first", files: [{ path: "README.md", content: "# notes\n" }] });
34 if (!c.ok) throw new Error(JSON.stringify(c.error));
35 const secret = await createRepo(app, u.value, { name: "diary", visibility: "private" });
36 if (!secret.ok) throw new Error(String(secret.error));
37 const d = await commitToBranch(app, u.value, secret.value, { message: "first", files: [{ path: "README.md", content: "# diary\n" }] });
38 if (!d.ok) throw new Error(JSON.stringify(d.error));
39 session = createSession(app.db, u.value.id).token;
40 await takeSnapshot(
41 { db: app.db, dataDir: app.config.dataDir, reposRoot: app.reposRoot, version: "t" },
42 dirStore(join(root, "store")),
43 new Date("2026-11-01T08:00:00Z"),
44 );
45 });
46
47 afterAll(async () => {
48 app.close();
49 await rm(root, { recursive: true, force: true });
50 });
51
52 const run = (args: string[], env: Record<string, string>) =>
53 Bun.spawn([process.execPath, ...args], {
54 cwd: TREE,
55 env: { PATH: process.env["PATH"] ?? "", HOME: root, ...(process.env["FORGE_TEST_SOCKET"] ? { FORGE_TEST_SOCKET: process.env["FORGE_TEST_SOCKET"] } : {}), ...env },
56 stdout: "pipe",
57 stderr: "pipe",
58 });
59
60 test("the guard lets reads through and refuses every other method and the push door", () => {
61 const req = (method: string, path: string, headers: Record<string, string> = {}) =>
62 new Request(`http://forge.test${path}`, { method, headers });
63 expect(isRead(req("GET", "/"))).toBe(true);
64 expect(isRead(req("HEAD", "/alice/notes"))).toBe(true);
65 expect(isRead(req("POST", "/alice/notes.git/git-upload-pack"))).toBe(true);
66 expect(isRead(req("POST", "/alice/notes.git/git-receive-pack"))).toBe(false);
67 expect(isRead(req("GET", "/alice/notes.git/info/refs?service=git-receive-pack"))).toBe(false);
68 expect(isRead(req("GET", "/alice/notes.git/info/refs?service=git-upload-pack"))).toBe(true);
69 expect(isRead(req("GET", "/x/shell", { upgrade: "websocket" }))).toBe(false);
70 for (const m of ["POST", "PUT", "PATCH", "DELETE"]) expect(readOnlyRefusal(req(m, "/anything"))?.status).toBe(503);
71 expect(readOnlyFrom({ FORGE_READ_ONLY: "1" })).toBe(true);
72 expect(readOnlyFrom({})).toBe(false);
73 });
74
75 test("a restored snapshot boots read-only: / answers, git reads, a POST is a 503", async () => {
76 const data = join(root, "standby");
77 const restore = run(["scripts/standby-restore.ts", "latest", data], { FORGE_SNAPSHOT_DIR: join(root, "store") });
78 const [out, code] = [await new Response(restore.stdout).text(), await restore.exited];
79 expect({ code, out }).toMatchObject({ code: 0 });
80 expect(out).toContain("verified");
81
82 // Not empty now: a second restore is refused.
83 const again = run(["scripts/standby-restore.ts", "latest", data], { FORGE_SNAPSHOT_DIR: join(root, "store") });
84 const [refusal, againCode] = [await new Response(again.stderr).text(), await again.exited];
85 expect(againCode).toBe(1);
86 expect(refusal).toContain(`${data} is not empty`);
87
88 await onFreshPort(async (port) => {
outdated

Only the ref advertisement is checked. Add a real clone through git-upload-pack.

89 const child = run(["src/main.ts"], { FORGE_READ_ONLY: "1", FORGE_DATA: data, PORT: String(port), FORGE_SSH_PORT: "2222" });
90 let stderr = "";
91 let logged = "";
92 void (async () => { for await (const c of child.stderr) stderr = (stderr + new TextDecoder().decode(c)).slice(-4096); })();
93 void (async () => { for await (const c of child.stdout) logged += new TextDecoder().decode(c); })();
94 const base = `http://127.0.0.1:${port}`;
95 try {
96 const until = Date.now() + 45_000;
97 let up = false;
98 while (Date.now() < until && child.exitCode === null && !up) {
99 up = await fetch(`${base}/`, { signal: AbortSignal.timeout(2000) }).then((r) => r.status === 200, () => false);
100 if (!up) await Bun.sleep(200);
101 }
102 expect({ up, stderr: up ? "" : stderr }).toEqual({ up: true, stderr: "" });
103 const home = await fetch(`${base}/`);
104 expect(home.status).toBe(200);
105 expect((await fetch(`${base}/alice/notes`)).status).toBe(200);
106 const refs = await fetch(`${base}/alice/notes.git/info/refs?service=git-upload-pack`);
107 expect(refs.status).toBe(200);
108 // A real clone, through git-upload-pack.
109 const clone = join(root, "clone");
110 const cloned = Bun.spawn(["git", "clone", "--quiet", `${base}/alice/notes.git`, clone], { env: { PATH: process.env["PATH"] ?? "", HOME: root }, stdout: "pipe", stderr: "pipe" });
111 const cloneErr = await new Response(cloned.stderr).text();
112 expect({ code: await cloned.exited, cloneErr }).toEqual({ code: 0, cloneErr: "" });
113 expect(await Bun.file(join(clone, "README.md")).text()).toBe("# notes\n");
114 // A session of the main forge is not an identity here: the private repository stays private.
115 const cookie = { cookie: `forge_session=${session}` };
116 expect((await fetch(`${base}/alice/diary`, { headers: cookie, redirect: "manual" })).status).not.toBe(200);
117 expect((await fetch(`${base}/alice/diary`, { redirect: "manual" })).status).not.toBe(200);
118 // Only the listener started: no boot record, no timers' log lines.
119 expect(logged).toContain("read-only standby");
120 expect(logged).not.toMatch(/processes|apps are up|crew is|snapshot|nightly|reconcil/i);
121 expect(existsSync(join(data, "boots.json"))).toBe(false);
122 for (const [method, path] of [["POST", "/signin"], ["POST", "/new"], ["POST", "/alice/notes.git/git-receive-pack"], ["DELETE", "/alice/notes"]] as const) {
123 const res = await fetch(`${base}${path}`, { method, headers: { origin: base }, body: method === "DELETE" ? null : "x=1" });
124 expect({ path, status: res.status }).toEqual({ path, status: 503 });
125 expect(await res.text()).toContain("read-only standby");
126 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
127 }
128 expect((await fetch(`${base}/alice/notes.git/info/refs?service=git-receive-pack`)).status).toBe(503);
129 expect((await fetch(`${base}/api/v1/repos`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" })).status).toBe(503);
130 } finally {
131 child.kill();
132 await child.exited;
133 }
134 return { result: undefined, portLost: lostPort(stderr) };
135 });
136 }, 90_000);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs