Public: anyone can read this; the crew can change it.
During a rollout an app's host briefly serves forge pages (title forge, forge 404s) beside the app; app hosts must never fall through to the forge router #6729
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6726-during-a-rollout-an-app-s-host-briefly-s → main 3 commits fork 8 threads · 8 unresolved
scripts/smoke.ts +5 −1
| @@ -16,6 +16,8 @@ | ||
| 16 | 16 | * line per finding with its section (`--error-path` names a path that must answer |
| 17 | 17 | * 500), and exits 0: it reports, it does not gate. |
| 18 | 18 | * |
| 19 | * `--app-host <url>` also fetches `/` there and fails if it is the forge's page (`<title>forge</title>`). | |
| 20 | * | |
| 19 | 21 | * `--outsider` also signs up a fresh account and proves each thing a |
| 20 | 22 | * stranger must not do is refused over HTTP and MCP (`release/outsider.ts`, |
| 21 | 23 | * #663); the terminal door is proven by the gate. It makes one real account, so run it against a forge whose signup is open to it, with |
| @@ -42,12 +44,13 @@ if (import.meta.main) { | ||
| 42 | 44 | const standards = args.includes("--standards"); |
| 43 | 45 | if (standards) args.splice(args.indexOf("--standards"), 1); |
| 44 | 46 | const errorPath = flag("--error-path"); |
| 47 | const appHost = flag("--app-host"); | |
| 45 | 48 | const version = flag("--version"); |
| 46 | 49 | const wait = flag("--wait"); |
| 47 | 50 | const base = args[0]; |
| 48 | 51 | const seconds = wait === undefined ? 120 : Number(wait); |
| 49 | 52 | if (!base || !Number.isFinite(seconds) || seconds < 0 || args.length > 1) { |
| 50 | console.error("usage: bun scripts/smoke.ts <url> [--version <v>] [--wait <seconds>] [--outsider] [--standards [--error-path <path that answers 500>]] (--version needs FORGE_PROBE, the forge's probe)"); | |
| 53 | console.error("usage: bun scripts/smoke.ts <url> [--version <v>] [--wait <seconds>] [--outsider] [--app-host <url of an app>] [--standards [--error-path <path that answers 500>]] (--version needs FORGE_PROBE, the forge's probe)"); | |
| 51 | 54 | process.exit(2); |
| 52 | 55 | } |
| 53 | 56 | if (withOutsider && !process.env["FORGE_TOKEN"]) { |
| @@ -69,6 +72,7 @@ if (import.meta.main) { | ||
| 69 | 72 | const failures = await smoke(base, process.env["FORGE_TOKEN"] ?? "", { |
| 70 | 73 | version, |
| 71 | 74 | waitMs: seconds * 1000, |
| 75 | ...(appHost ? { appHost } : {}), | |
| 72 | 76 | // The forge tells only its probe its version (`src/kernel/probe.ts`). |
| 73 | 77 | ...(process.env["FORGE_PROBE"] ? { probe: { [PROBE_HEADER]: process.env["FORGE_PROBE"] } } : {}), |
| 74 | 78 | }); |
src/apps/holding.ts added +76
| @@ -0,0 +1,76 @@ | ||
| 1 | /** | |
| 2 | * An app host is never the forge's. The listener opens before the apps | |
| 3 | * runtime is registered, and in that window a request for | |
| 4 | * `hello.<domain>` has no runtime to resolve against and would fall through | |
| 5 | * to the forge's own router (#6726). While a forge holds its app hosts, a | |
| 6 | * request for one waits briefly for the load, then gets a small page with | |
| 7 | * `Retry-After`: never a forge page. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { App } from "../app.ts"; | |
| 11 | import { appsRuntime } from "./runtime.ts"; | |
| 12 | import { parseAppHost } from "./proxy.ts"; | |
| 13 | ||
| 14 | /** How long a request waits for the apps to load before the holding page. */ | |
| 15 | export const HOLD_WAIT_MS = 2000; | |
| 16 | ||
| 17 | interface Hold { | |
| 18 | domain: string; | |
| 19 | release: () => void; | |
| 20 | released: Promise<void>; | |
| 21 | } | |
| 22 | ||
| 23 | const holds = new WeakMap<App, Hold>(); | |
| 24 | ||
| 25 | /** | |
| 26 | * The domain this forge's app hosts live under, kept from boot so a request | |
| 27 | * can be told apart before any runtime is registered. | |
| 28 | */ | |
| 29 | export function holdAppHosts(app: App, domain: string): void { | |
| 30 | let release!: () => void; | |
| 31 | const released = new Promise<void>((resolve) => (release = resolve)); | |
| 32 | holds.set(app, { domain, release, released }); | |
| 33 | } | |
| 34 | ||
| 35 | /** The runtime is registered: waiting requests go on to the proxy. */ | |
| 36 | export function releaseAppHosts(app: App): void { | |
| 37 | holds.get(app)?.release(); | |
| 38 | } | |
| 39 | ||
| 40 | const PAGE = | |
| 41 | '<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">' + | |
| 42 | "<title>Coming back</title><body><p>This app is coming back. Try again in a moment.</p>"; | |
| 43 | ||
| 44 | /** | |
| 45 | * `undefined` when the request is not for an app host of this forge, or a | |
| 46 | * runtime is registered; the holding page otherwise. A request for an app | |
| 47 | * host never reaches the forge's router: with no runtime it waits (at most | |
| 48 | * `waitMs`) for one to be registered, then gets a 503. | |
| 49 | */ | |
| 50 | export async function holdAppHost( | |
| 51 | app: App, | |
| 52 | request: Request, | |
| 53 | waitMs = HOLD_WAIT_MS, | |
| 54 | ): Promise<Response | undefined> { | |
| 55 | if (appsRuntime(app) !== undefined) return undefined; | |
| 56 | const hold = holds.get(app); | |
| 57 | if (hold === undefined) return undefined; | |
| 58 | const host = request.headers.get("host"); | |
| 59 | if (host === null || parseAppHost(host, hold.domain) === undefined) | |
| 60 | return undefined; | |
| 61 | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 62 | const timeout = new Promise<void>((resolve) => { | |
| 63 | timer = setTimeout(resolve, waitMs); | |
| 64 | }); | |
| 65 | await Promise.race([hold.released, timeout]); | |
| 66 | clearTimeout(timer); | |
| 67 | if (appsRuntime(app) !== undefined) return undefined; | |
| 68 | return new Response(request.method === "HEAD" ? null : PAGE, { | |
| 69 | status: 503, | |
| 70 | headers: { | |
| 71 | "content-type": "text/html; charset=utf-8", | |
| 72 | "retry-after": "2", | |
| 73 | "cache-control": "no-store", | |
| 74 | }, | |
| 75 | }); | |
| 76 | } |
src/http/server.ts +3
| @@ -539,6 +539,7 @@ import { sseStream, viewerStream } from "./sse.ts"; | ||
| 539 | 539 | import { Router } from "./router.ts"; |
| 540 | 540 | import { getUserById } from "../core/users.ts"; |
| 541 | 541 | import { appsRuntime } from "../apps/runtime.ts"; |
| 542 | import { holdAppHost } from "../apps/holding.ts"; | |
| 542 | 543 | import { |
| 543 | 544 | appWebSocket, |
| 544 | 545 | type AppSocket, |
| @@ -2270,6 +2271,8 @@ export async function serve(app: App): Promise<Servers> { | ||
| 2270 | 2271 | noteStart(request, started); |
| 2271 | 2272 | // An app's hostname is answered before any route: `hello.<domain>` is |
| 2272 | 2273 | // the app, whatever its path, and the forge's own routes never apply. |
| 2274 | const holding = await holdAppHost(app, request); | |
This only covers requests while a hold is set. Once it is lifted, or when it was never set, an app-shaped Host with |
||
| 2275 | if (holding !== undefined) return securityFloor(holding); | |
| 2273 | 2276 | const runtime = appsRuntime(app); |
| 2274 | 2277 | // The parent's own app host wins: a child named like a pull number |
| 2275 | 2278 | // must not take `<app>--<n>--<parentHost>`. Only its `"unknown"` falls |
src/main.ts +29 −12
| @@ -31,6 +31,8 @@ import { checkDue } from "./actions/upstream_follow.ts"; | ||
| 31 | 31 | import { runSync } from "./origins/sync.ts"; |
| 32 | 32 | import { startForgeSsh } from "./tui/ssh.ts"; |
| 33 | 33 | import { AppsRuntime } from "./apps/runtime.ts"; |
| 34 | import { listApps } from "./core/apps.ts"; | |
| 35 | import { holdAppHosts, releaseAppHosts } from "./apps/holding.ts"; | |
| 34 | 36 | import { resolveAppDomain } from "./apps/host.ts"; |
| 35 | 37 | import { LAND_SETTLE_MS, requeueAbandonedChecks, startChecks } from "./checks/service.ts"; |
| 36 | 38 | import { RED_HOLD_MS } from "./checks/red_main.ts"; |
| @@ -280,6 +282,21 @@ await reconcile(app); | ||
| 280 | 282 | // Issues linked under a pillar's umbrella before pillars had labels. |
| 281 | 283 | const labelled = syncAllPillars(app); |
| 282 | 284 | if (labelled > 0) app.log.info("pillars labelled", { issues: labelled }); |
| 285 | // The host apps answer at, resolved once and before the listener opens: an | |
| 286 | // app host never reaches the forge's own router, so `serve` needs it from the | |
| 287 | // first connection (#6726). Only the hostname matters here, not the port. | |
| 288 | const appsOn = process.env.FORGE_APPS !== "0"; | |
outdated
This is a second copy of the |
||
| 289 | const appDomain = appsOn | |
| 290 | ? resolveAppDomain({ | |
| 291 | explicit: (process.env.FORGE_APP_DOMAIN ?? "").trim() || undefined, | |
| 292 | hosted, | |
| 293 | hostname: new URL( | |
| 294 | (hosted ? hostedAt : process.env.FORGE_URL)?.trim().replace(/\/+$/, "") || | |
| 295 | `http://${app.config.host ?? "localhost"}`, | |
| 296 | ).hostname, | |
| 297 | }) | |
| 298 | : undefined; | |
| 299 | if (appDomain?.ok === true) holdAppHosts(app, appDomain.domain); | |
| 283 | 300 | const servers = await serve(app); |
| 284 | 301 | const sshStarted = |
| 285 | 302 | sshPort > 0 ? await startForgeSsh(app, { port: sshPort }) : undefined; |
| @@ -300,18 +317,10 @@ const forgeUrl = | ||
| 300 | 317 | (hosted ? hostedAt : process.env.FORGE_URL)?.replace(/\/+$/, "") ?? |
| 301 | 318 | `http://${host}:${servers.port}`; |
| 302 | 319 | const publicUrl = new URL(forgeUrl); |
| 303 | const appsOn = process.env.FORGE_APPS !== "0"; | |
| 304 | // The host apps answer at is checked once, here: an IP-addressed forge | |
| 305 | // hands out a hostname no URL can hold once a slug attaches to it, and an | |
| 306 | // app that parses its own FORGE_APP_URL (the scaffold does) dies before it | |
| 307 | // answers — every deploy, forever. docs/APPS.md, "Hostnames and the proxy". | |
| 308 | const appDomain = appsOn | |
| 309 | ? resolveAppDomain({ | |
| 310 | explicit: (process.env.FORGE_APP_DOMAIN ?? "").trim() || undefined, | |
| 311 | hosted, | |
| 312 | hostname: publicUrl.hostname, | |
| 313 | }) | |
| 314 | : undefined; | |
| 320 | // An IP-addressed forge hands out a hostname no URL can hold once a slug | |
| 321 | // attaches to it, and an app that parses its own FORGE_APP_URL (the scaffold | |
| 322 | // does) dies before it answers — every deploy, forever. docs/APPS.md, | |
| 323 | // "Hostnames and the proxy". | |
| 315 | 324 | if (appDomain?.ok === false) app.log.error(appDomain.reason); |
| 316 | 325 | if (appDomain?.ok === true && appDomain.note !== undefined) |
| 317 | 326 | app.log.warn(appDomain.note); |
| @@ -338,6 +347,9 @@ const apps = appDomain?.ok | ||
| 338 | 347 | : undefined; |
| 339 | 348 | // Which other forge serves this data's apps, read before this one starts its own. |
| 340 | 349 | const presence = apps ? takePresence(app.config.dataDir) : undefined; |
| 350 | // The runtime is registered by its constructor: app hosts stop waiting now, | |
| 351 | // not at the end of `start()`. | |
| 352 | releaseAppHosts(app); | |
| 341 | 353 | await apps?.start(); |
| 342 | 354 | // In a rollout this forge takes no traffic until the apps it just started |
| 343 | 355 | // serve: the one it replaces keeps proxying to its own copies until then, |
| @@ -544,6 +556,11 @@ void proveRelease({ | ||
| 544 | 556 | version: running().version, |
| 545 | 557 | url: forgeUrl, |
| 546 | 558 | token: process.env["FORGE_TOKEN"], |
| 559 | // The first app's production host: it must never answer as the forge (#6726). | |
| 560 | appHost: (() => { | |
| 561 | const first = listApps(app.db)[0]; | |
| 562 | return apps && first ? apps.urlFor(first, "production") : undefined; | |
| 563 | })(), | |
| 547 | 564 | }).then((p) => p && app.log.info("release proof", { version: p.version, ok: String(p.ok), ...(p.failure ? { failure: p.failure } : {}) })); |
| 548 | 565 | // The release tags of the forge's own repository are its shipped releases (release/shipped.ts); |
| 549 | 566 | // production behind the newest for over an hour is a card (release/behind.ts). |
src/release/prove.ts +3 −1
| @@ -66,6 +66,8 @@ export interface ProveOptions { | ||
| 66 | 66 | readonly url: string; |
| 67 | 67 | /** Signs `/board` in; without it a redirect to sign-in passes. */ |
| 68 | 68 | readonly token?: string | undefined; |
| 69 | /** An app's URL on this forge: its `/` must never be the forge's page (#6726). */ | |
| 70 | readonly appHost?: string | undefined; | |
| 69 | 71 | readonly fetch?: typeof fetch; |
| 70 | 72 | readonly now?: () => number; |
| 71 | 73 | readonly sleep?: (ms: number) => Promise<void>; |
| @@ -102,7 +104,7 @@ export async function proveRelease(o: ProveOptions): Promise<Proof | undefined> | ||
| 102 | 104 | if (now() + POLL_MS > deadline) return finish(`the public URL never answered as ${o.version}`); |
| 103 | 105 | await sleep(POLL_MS); |
| 104 | 106 | } |
| 105 | const failures = await smoke(o.url, o.token ?? "", { fetch: doFetch, anonymousBoard: true }); | |
| 107 | const failures = await smoke(o.url, o.token ?? "", { fetch: doFetch, anonymousBoard: true, ...(o.appHost ? { appHost: o.appHost } : {}) }); | |
| 106 | 108 | return finish(failures[0] === undefined ? undefined : checkLine(failures[0])); |
| 107 | 109 | } catch (error) { |
| 108 | 110 | return finish(`the smoke checks could not run: ${String(error)}`); |
src/release/smoke.ts +12
| @@ -59,6 +59,8 @@ export interface Options { | ||
| 59 | 59 | fetch?: typeof fetch; |
| 60 | 60 | /** Accept a redirect from `/board` to sign-in as the sign-in it is, for a caller holding no token. */ |
| 61 | 61 | anonymousBoard?: boolean; |
| 62 | /** An app's URL on this forge: `GET /` there must never be a forge page (#6726). */ | |
| 63 | appHost?: string; | |
| 62 | 64 | } |
| 63 | 65 | |
| 64 | 66 | /** The redirect statuses a sign-in answers with. */ |
| @@ -169,5 +171,15 @@ export async function smoke(base: string, token = "", options: Options = {}): Pr | ||
| 169 | 171 | else if (/Response\s*\.\s*error\s*\(/.test(nav)) fail(sw.url, "desktop", sw.res, "a navigation can be answered with Response.error()"); |
| 170 | 172 | } |
| 171 | 173 | } |
| 174 | if (options.appHost) { | |
Nothing in the release path sets |
||
| 175 | const url = new URL("/", options.appHost).href; | |
| 176 | try { | |
| 177 | const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(10_000), headers: { "user-agent": AGENTS["desktop"]! } }); | |
| 178 | const body = await res.text(); | |
| 179 | if (/<title>\s*forge\s*<\/title>/i.test(body)) fail(url, "desktop", res, "an app host answered with the forge's page"); | |
| 180 | } catch (error) { | |
| 181 | failures.push({ url, agent: "desktop", status: "error", contentType: "", why: String(error) }); | |
| 182 | } | |
| 183 | } | |
| 172 | 184 | return failures; |
| 173 | 185 | } |
test/app-host-holding.test.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | /** | |
| 2 | * While the apps table is not loaded, an app's host is never the forge's | |
| 3 | * (#6726): it waits, then gets a 503 holding page with Retry-After; once | |
| 4 | * released, the host goes on to the app. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { newApp } from "../src/actions/apps.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 15 | import { holdAppHost, holdAppHosts, releaseAppHosts } from "../src/apps/holding.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | let dataDir: string; | |
| 22 | let app: App; | |
| 23 | let servers: Servers; | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | dataDir = await mkdtemp(join(tmpdir(), "forge-holding-")); | |
| 27 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 28 | servers = await serve(app); | |
| 29 | }); | |
| 30 | afterAll(async () => { | |
| 31 | servers.stop(); | |
| 32 | app.close(); | |
| 33 | await rm(dataDir, { recursive: true, force: true }); | |
| 34 | }); | |
| 35 | ||
| 36 | const get = (host: string) => fetch(`${servers.url}/`, { headers: { host } }); | |
| 37 | ||
| 38 | test("with no runtime, an app host is the holding page — held or not, never the forge", async () => { | |
| 39 | holdAppHosts(app, "apps.test"); | |
| 40 | const waiting = await holdAppHost(app, new Request("http://x/", { headers: { host: "notes.apps.test" } }), 20); | |
| 41 | expect(waiting?.status).toBe(503); | |
| 42 | expect(waiting?.headers.get("retry-after")).toBe("2"); | |
| 43 | expect(await waiting?.text()).not.toContain("<title>forge</title>"); | |
| 44 | ||
| 45 | // Released with no runtime (start threw, say): still the holding page. | |
| 46 | releaseAppHosts(app); | |
| 47 | const res = await get("notes.apps.test"); | |
| 48 | expect(res.status).toBe(503); | |
| 49 | expect(res.headers.get("retry-after")).toBe("2"); | |
| 50 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 51 | expect(await res.text()).not.toContain("<title>forge</title>"); | |
outdated
With no runtime, this response is the forge's own page, so the test passes while the bug is still there. Assert what the issue asks for: after release, an app host is never |
||
| 52 | }); | |
| 53 | ||
| 54 | test("the forge's own host is the forge's", async () => { | |
| 55 | const own = await get("forge.test"); | |
| 56 | expect(own.status).toBe(200); | |
| 57 | expect(await own.text()).toContain("<title>"); | |
| 58 | }); | |
| 59 | ||
| 60 | test("a request waiting for the runtime goes on once it is registered", async () => { | |
| 61 | const fresh = createApp({ dataDir: join(dataDir, "fresh"), port: 0, host: "forge.test" }); | |
| 62 | holdAppHosts(fresh, "apps.test"); | |
| 63 | const waiting = holdAppHost(fresh, new Request("http://x/", { headers: { host: "notes.apps.test" } }), 1500); | |
| 64 | new AppsRuntime(fresh, { domain: "apps.test", scheme: "http", publicPort: 80, forgeUrl: "http://forge.test", concurrency: 1 }); | |
| 65 | releaseAppHosts(fresh); | |
| 66 | expect(await waiting).toBeUndefined(); | |
| 67 | fresh.close(); | |
| 68 | }); | |
outdated
Done-when #3 asks for the app's page once loaded, and this only checks an unknown name. Register a runtime with an app that resolves, the way the proxy tests stub a running app, and assert its body comes through on its host. |
||
| 69 | ||
| 70 | test("the app's page once loaded: a request that waited is answered by the app side, not the forge", async () => { | |
| 71 | const dir = await mkdtemp(join(tmpdir(), "forge-holding-app-")); | |
| 72 | const fresh = createApp({ dataDir: dir, port: 0, host: "forge.test" }); | |
| 73 | holdAppHosts(fresh, "apps.test"); | |
| 74 | const up = await serve(fresh); | |
| 75 | const u = await createUser(fresh.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
outdated
This runs on |
||
| 76 | if (!u.ok) throw new Error(u.error); | |
| 77 | const made = await newApp(fresh, u.value, { name: "notes", description: "x", visibility: "public" }); | |
| 78 | if (!made.ok) throw new Error(String(made.error)); | |
| 79 | try { | |
| 80 | const pending = fetch(`${up.url}/__forge/`, { headers: { host: "notes.apps.test" } }); | |
| 81 | await Bun.sleep(30); | |
| 82 | new AppsRuntime(fresh, { domain: "apps.test", scheme: "http", publicPort: up.port, forgeUrl: up.url, concurrency: 1 }); | |
| 83 | releaseAppHosts(fresh); | |
| 84 | const res = await pending; | |
| 85 | expect(res.status).toBe(200); | |
| 86 | const html = await res.text(); | |
| 87 | expect(html).toContain("<h1>notes</h1>"); | |
| 88 | expect(html).not.toContain("<title>forge</title>"); | |
| 89 | // An unknown name is the app side's 404, never the forge's. | |
| 90 | const none = await fetch(`${up.url}/`, { headers: { host: "nothing.apps.test" } }); | |
| 91 | expect(none.status).toBe(404); | |
| 92 | expect(await none.text()).not.toContain("<title>forge</title>"); | |
| 93 | } finally { | |
| 94 | up.stop(); | |
| 95 | fresh.close(); | |
| 96 | await rm(dir, { recursive: true, force: true }); | |
| 97 | } | |
| 98 | }, 30_000); | |
| 99 | ||
| 100 | test("only app hosts are held: forge-shaped and unrelated hosts are not, on an app with no runtime", async () => { | |
| 101 | const fresh = createApp({ dataDir: join(dataDir, "bare"), port: 0, host: "forge.test" }); | |
| 102 | holdAppHosts(fresh, "apps.test"); | |
| 103 | const ask = (host: string) => holdAppHost(fresh, new Request("http://x/", { headers: { host } }), 10); | |
| 104 | for (const host of ["forge.test", "a.b.apps.test", "elsewhere.example"]) | |
| 105 | expect(await ask(host)).toBeUndefined(); | |
| 106 | expect((await ask("notes.apps.test"))?.status).toBe(503); | |
| 107 | fresh.close(); | |
| 108 | }); |
test/release-prove.test.ts +13
| @@ -51,6 +51,19 @@ describe("proving a release", () => { | ||
| 51 | 51 | expect(proofNeed(dir)).toBeUndefined(); |
| 52 | 52 | }); |
| 53 | 53 | |
| 54 | test("an app host that answers as the forge fails the proof (#6726)", async () => { | |
| 55 | const { fake: base } = site("1.2.6"); | |
| 56 | const fake = (async (input: string | URL, init?: RequestInit) => | |
| 57 | new URL(String(input)).host === "notes.apps.test" | |
| 58 | ? new Response("<title>forge</title>", { headers: { "content-type": "text/html" } }) | |
| 59 | : base(input, init)) as unknown as typeof fetch; | |
| 60 | const p = await run("1.2.6", fake, { appHost: "https://notes.apps.test" }); | |
| 61 | expect(p?.ok).toBe(false); | |
| 62 | expect(p?.failure).toContain("an app host answered with the forge's page"); | |
| 63 | const good = await run("1.2.7", site("1.2.7").fake, { appHost: "https://notes.apps.test" }); | |
| 64 | expect(good?.ok).toBe(true); | |
| 65 | }); | |
| 66 | ||
| 54 | 67 | test("a failure raises the cause once, naming the first failing check", async () => { |
| 55 | 68 | const { fake } = site("1.2.4", { "/a.css": () => new Response("x", { status: 500 }) }); |
| 56 | 69 | await run("1.2.4", fake); |
test/smoke.test.ts +14
| @@ -160,3 +160,17 @@ test("--outsider without FORGE_TOKEN exits 2 and prints no ok", async () => { | ||
| 160 | 160 | expect(await new Response(proc.stdout).text()).not.toContain("ok"); |
| 161 | 161 | expect(await new Response(proc.stderr).text()).toContain("FORGE_TOKEN"); |
| 162 | 162 | }); |
| 163 | ||
| 164 | test("--app-host fails when an app host answers with the forge's page", async () => { | |
| 165 | const forgePage = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("<title>forge</title>", { headers: { "content-type": "text/html" } }) }); | |
| 166 | const appPage = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("<title>notes</title>") }); | |
| 167 | try { | |
| 168 | const base = stub("text/css"); | |
| 169 | expect(await smoke(base, "t", { appHost: `http://127.0.0.1:${appPage.port}` })).toEqual([]); | |
| 170 | const failures = await smoke(base, "t", { appHost: `http://127.0.0.1:${forgePage.port}` }); | |
| 171 | expect(failures.map((f) => f.why)).toEqual(["an app host answered with the forge's page"]); | |
| 172 | } finally { | |
| 173 | forgePage.stop(true); | |
| 174 | appPage.stop(true); | |
| 175 | } | |
| 176 | }); |
Low: because the hold is never removed, this rule also covers the old pod after
AppsRuntime.stop()deletes its runtime while the listener still drains. That window is likely the minutes-long one in #6726. Worth saying so here, and worth a test that does stop() → 503, so a later 'clear the hold on release' refactor doesn't quietly reopen it.