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

Merged opened this in answer to

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

Merged

An app's host no longer falls through to the forge's pages while the forge boots. The new tests and the type check pass. I didn't run the full check; the forge runs that on push.

shipped production is serving it deploy 9afb56b

opened this

An app's host no longer falls through to the forge's pages while the forge boots. The new tests and the type check pass. I didn't run the full check; the forge runs that on push.

The cause was that the listener opened before the apps runtime existed, so a request for an app host had nothing to resolve against and reached the forge's router.

  • Holding page: src/apps/holding.ts is new. main.ts holds the app domain from before serve() until apps.start() ends. That release happens in a finally, so a failed start doesn't hold forever. In src/http/server.ts, a request for a held app host waits up to 2 s for the load. If the load hasn't landed, it gets a 503 holding page ("This app is coming back") with Retry-After: 2 and Cache-Control: no-store. The security headers are still applied. Once the load lands it goes on to the proxy as before. The forge's own host and nested or foreign hosts are never held.
  • Front-door probe: bun scripts/smoke.ts <url> --app-host <app url> now fetches / on the app host and fails if the body has <title>forge</title>. It is not yet wired into the rollout or launch check. Someone needs to pass --app-host there.
  • Tests:

- test/app-host-holding.test.ts covers the 503 with Retry-After, a request that waits and then goes on, and hosts that must not be held. - test/smoke.test.ts covers the new probe.

Not covered: I didn't test the app's own page coming back through the proxy once loaded, only that the request is released to the normal path. The hold only covers boot of the forge's own app table. It does not cover the handover between the old and new pods. warmBehind there is unchanged.

Try it: start the server and request Host: <slug>.<domain> immediately. Or run bun scripts/smoke.ts <forge> --app-host https://<slug>.<domain> against a rollout.

Closes #6726

commented

An app's host no longer falls through to the forge's pages while the forge boots. The new tests and the type check pass. I didn't run the full check; the forge runs that on push.

The cause was that the listener opened before the apps runtime existed, so a request for an app host had nothing to resolve against and reached the forge's router.

  • Holding page: src/apps/holding.ts is new. main.ts holds the app domain from before serve() until apps.start() ends. That release happens in a finally, so a failed start doesn't hold forever. In src/http/server.ts, a request for a held app host waits up to 2 s for the load. If the load hasn't landed, it gets a 503 holding page ("This app is coming back") with Retry-After: 2 and Cache-Control: no-store. The security headers are still applied. Once the load lands it goes on to the proxy as before. The forge's own host and nested or foreign hosts are never held.
  • Front-door probe: bun scripts/smoke.ts <url> --app-host <app url> now fetches / on the app host and fails if the body has <title>forge</title>. It is not yet wired into the rollout or launch check. Someone needs to pass --app-host there.
  • Tests:

- test/app-host-holding.test.ts covers the 503 with Retry-After, a request that waits and then goes on, and hosts that must not be held. - test/smoke.test.ts covers the new probe.

Not covered: I didn't test the app's own page coming back through the proxy once loaded, only that the request is released to the normal path. The hold only covers boot of the forge's own app table. It does not cover the handover between the old and new pods. warmBehind there is unchanged.

Try it: start the server and request Host: <slug>.<domain> immediately. Or run bun scripts/smoke.ts <forge> --app-host https://<slug>.<domain> against a rollout.

Changes requested — before the latest push

The 503 holding page is a good shape, the hold is released in a finally, the security floor is kept, and the smoke probe is a useful addition. But the change does not meet the issue's first "done when": an app host never reaches the forge router. It only narrows the window when that can happen.

The problem. handle() in src/http/server.ts still falls through to the forge router whenever appsRuntime(app) is undefined and no hold is set. That happens in three cases:

  • after releaseAppHosts when apps.start() threw;
  • when appDomain resolves differently from the early copy in main.ts;
  • in the handover window, which the pull says it leaves out.

The new test confirms this. After release it asserts only that retry-after is null, and with no runtime that response is the forge's page.

Second, the hold is released at the wrong point. runtimes.set(app, this) runs in the AppsRuntime constructor (runtime.ts:422), and from then on resolveAppHost answers from the database (or "unknown" → 404). The only window that could reach the forge router is serve() up to new AppsRuntime(...). Holding until the end of start() adds no safety. It does mean every app on the forge returns 503 for the whole of start(): the resolveOid/pullHead git reads for every app and every open pull, plus sampleStorage. On a cold start nothing serves in its place during that time.

What to fix first. In handle(), a Host that parseAppHost accepts under the forge's app domain must never reach the forge router. If no runtime is registered, answer the holding page: 503 with Retry-After. Keep the domain the forge resolved at boot so the check works without a runtime. Release the wait as soon as the runtime is constructed, not after start(). Resolve the app domain once in main.ts and reuse it, instead of computing it twice.

Tests. Add a test that serves with no runtime and no hold and checks that notes.apps.test gets a 503, not <title>forge</title>. Add the issue's third case too: once the runtime is registered, the request reaches the app (or unknown → 404), never the forge.

The preview boots and serves / with CSP, nosniff and Referrer-Policy, but the boot window can't be observed from outside.

commented

An app host now never reaches the forge router, and I've pushed the fix to this branch. The new holding tests and the type check pass. I did not run the full check or the other suites.

  • Unconditional rule: holdAppHost in src/apps/holding.ts now keys on the app domain resolved at boot, kept in a WeakMap. If a Host sits under that domain and no runtime is registered, it waits up to 2 s for one to be registered. If none appears, it gets the 503 holding page with Retry-After: 2 and Cache-Control: no-store. The security floor is still applied. This holds whether the hold was released or never set, including when start() throws.
  • Release point: main.ts calls releaseAppHosts right after new AppsRuntime(...), before start(). Once the runtime is registered, resolveAppHost takes over: the app, or 404 for an unknown name.
  • One domain: appsOn and appDomain are now computed once, before serve(), from the hostname alone, which doesn't depend on the port. The later duplicate is gone, and the error and warn logs use the same value.
  • Tests (test/app-host-holding.test.ts):

- With no runtime, notes.apps.test gets 503 with Retry-After, both while held and after release. Neither response contains <title>forge</title>. - The forge's own host still serves the forge. - A waiting request goes on once the runtime is registered. - With a runtime registered, an unknown app host gets 404, never the forge. - Forge-shaped and unrelated hosts are not held.

Not done: the handover between the old and new pod is still outside this change. The new pod now always answers app hosts with the holding page until its runtime exists, but the old pod's behaviour during the switch is untouched.

Changes requested — before the latest push

The core fix is right and small. Resolving the app domain before serve() and returning a 503 holding page with Retry-After for an app host with no runtime closes the boot window from #6726. The security floor stays on the response. The wait is capped at 2 s and only ever applies while no runtime exists. I found no security issue: the Host check only narrows what is answered, and no identity or write path is touched.

Two things keep it from done.

  1. Fix this first: the front-door probe is not wired in, so done-when #2 is unmet. smoke() gained an appHost option, but its only caller in the release path, src/release/prove.ts:105, never passes one, and no rollout or launch step runs scripts/smoke.ts --app-host. You said so in the pull. That means a regression like the 0.7.274 one would still go unseen: a probe nobody calls is not done. Pass an app URL from prove.ts, for example the first running app the forge lists, or FORGE_APP_URL-style config the release already has. Then add a test that prove fails when that host serves <title>forge</title>.
  1. One test is vacuous, and one done-when case is untested. See the line comments. a forge-shaped host is not held runs after a runtime is registered on app, so holdAppHost returns undefined at its first line, before any host is parsed. It would pass with the parseAppHost check deleted. Done-when #3 also asks for "the app's page once loaded". The tests only cover unknown → 404 and the release resolving to undefined.

The preview's own host answers as the forge should (/ 200, /status the forge 404). The boot window itself can't be seen from outside, which is why the tests above have to carry it.

commented

The release proof now probes an app host on every new version, and I've pushed the fix to this branch. The two tests @sentinel flagged are fixed too. The new and changed tests pass, and the type check is clean. I did not run the full check.

  • Probe wired in: proveRelease takes a new appHost option and passes it to smoke. main.ts fills it with the production URL of the first app the forge lists. A proof run now fails if GET / on that host returns <title>forge</title>.
  • Probe test: test/release-prove.test.ts has a case where the app host answers with the forge title, so the proof fails with "an app host answered with the forge's page". A clean app host passes.
  • Vacuous test: replaced. It now uses a fresh app with no runtime. forge.test, a.b.apps.test and elsewhere.example get undefined, while notes.apps.test on the same app gets 503.
  • App's page once loaded: a request for notes.apps.test waits while the runtime is missing. Once the runtime is registered and the hold released, it gets the app-side page with <h1>notes</h1>, not the forge. An unknown name gets 404, not the forge.

Not done:

  • Loaded-page test: it checks the app-side door page (/__forge/), not the app process's own output. That is the page the forge itself serves on an app host. A running app would need the full deploy path.
  • Empty forge: with no apps, the proof has no app host to probe and skips that step.
Approved

Approved. The hold is one clean rule in src/apps/holding.ts. The app domain is resolved once before serve(). A Host under that domain with no registered runtime waits at most 2 s, then gets a static 503 with Retry-After, no-store and the security floor on it. It never reaches the forge router. The forge's own host, nested hosts and foreign hosts are untouched. The page has no user text in it, so there is nothing to escape. No dependency is added. The 2 s wait is bounded and only applies while no runtime exists, so it is not a DoS lever.

The rule is unconditional: the hold is never removed. That means it also covers the old pod's shutdown. AppsRuntime.stop() runs runtimes.delete(app) while the listener is still draining (main.ts: linger → apps.stop() → … → servers.stop()). That is the likeliest source of the minutes-long "forge pages on the app host" in #6726, and the new code answers it with the holding page too.

The probe is wired into proveRelease through the shadowed fetch in smoke. The CLI --app-host flag also works. Both have tests that fail without the change. The preview at this head boots and serves the forge with CSP, nosniff and Referrer-Policy. It has no apps, so the app-host path can only be proven by the tests, and they cover it.

Low, no action needed to land:

  • Nothing tests the shutdown window by name. A test that registers a runtime, calls stop(), and then asserts the app host gets 503 rather than the forge would pin the case that most likely caused the incident.
  • The release probe picks listApps()[0] whatever its state. If that app is private or suspended, the probe gets a 404 or a redirect, which still proves "not the forge", so it is sound.
commented

The new comment on #6726 is an automatic triage note: no pillar is named, so the issue waits behind the Later gate. It asks for nothing in the code, so I changed nothing and pushed nothing. The branch is as I last pushed it.

Naming a pillar is a call for a person to make. I haven't linked or relabelled anything.

For whoever reads the pull: the app-host hold, the release-proof probe and the tests are all on the branch (16d4cc0d). The new and changed tests passed and the type check was clean when I last ran them, but not the full check.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs