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

A child forge in a cell serves its apps: their hosts reach the cell (client forge Starck answered 404 while saying running) #7956

Merged opened this

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

Merged

steward/cell-app-hosts → main 2 commits 1 thread · 1 unresolved

  • Merged. steward/cell-app-hosts was merged into main as 742fa77.

Review

Sign in to review this change.
4 files changed +91 −23
src/http/server.ts +23 −20
@@ -2348,14 +2348,28 @@ export async function serve(app: App): Promise<Servers> {
23482348 }
23492349 // A hosted child's app (`<app>--<child>--<parentHost>`) is the child's
23502350 // own runtime's, as the parent's apps are the parent's.
2351 if (runtime === undefined || ownUnknown) {
2352 const hit = await resolveHostedChildApp(app, request);
2353 if (hit === "unknown")
2354 return securityFloor(new Response("not found", { status: 404 }));
2355 if (hit !== undefined) {
2356 notePeer(request, server.requestIP(request)?.address, trust);
2357 return proxyApp(hit.runtime, request, hit.target, server);
2358 }
2351 // A child in its own cell answers through this one door, for its pages and its apps.
2352 const fromCell = async (name?: string): Promise<Response> => {
2353 // The child sees only this forge's relay, so it is told who the client is.
2354 notePeer(request, server.requestIP(request)?.address, trust);
2355 armClose(request, () => server.timeout(request, 1));
2356 const response = await answered(app, request, () => answerFromCell(app, request, name));
2357 counted(app, request, response, started);
2358 logged(app.config.dataDir, request, response, started);
outdated

This block duplicates the child === "cell" branch a few lines below (armClose/answered/counted/logged/settleBody/close). Extract one local helper like carryToCell(request, server, started, name?) and use it in both, so keeping the two in step is not a manual job.

2359 await settleBody(request);
2360 if (!mustClose(request)) return response;
2361 server.timeout(request, 1);
2362 return withHeader(response, "connection", "close");
2363 };
2364 // Asked whatever the parent's own apps said: their domain need not be the
2365 // one children hang from, and then it does not claim this host at all.
2366 const hit = await resolveHostedChildApp(app, request);
2367 if (hit === "unknown")
2368 return securityFloor(new Response("not found", { status: 404 }));
2369 if (hit !== undefined) {
2370 if ("cell" in hit) return fromCell(hit.cell);
2371 notePeer(request, server.requestIP(request)?.address, trust);
2372 return proxyApp(hit.runtime, request, hit.target, server);
23592373 }
23602374 // A hosted child's Host (`<name>--<parentHost>`) is answered by that
23612375 // child's own App before the parent's pages. Ingress routes the label
@@ -2364,18 +2378,7 @@ export async function serve(app: App): Promise<Servers> {
23642378 const child = await resolveHostedChild(app, request);
23652379 if (child === "unknown")
23662380 return securityFloor(new Response("not found", { status: 404 }));
2367 if (child === "cell") {
2368 // The child sees only this forge's relay, so it is told who the client is.
2369 notePeer(request, server.requestIP(request)?.address, trust);
2370 armClose(request, () => server.timeout(request, 1));
2371 const response = await answered(app, request, () => answerFromCell(app, request));
2372 counted(app, request, response, started);
2373 logged(app.config.dataDir, request, response, started);
2374 await settleBody(request);
2375 if (!mustClose(request)) return response;
2376 server.timeout(request, 1);
2377 return withHeader(response, "connection", "close");
2378 }
2381 if (child === "cell") return fromCell();
23792382 if (child !== undefined) {
23802383 const key = child.config.dataDir;
23812384 let http = childHttps.get(key);
src/spawn/host.ts +9 −3
@@ -233,6 +233,11 @@ export interface ChildAppHit {
233233 readonly target: AppTarget;
234234 }
235235
236 /** A child app in a child's own cell: its runtime is in that process, so the request is carried there. */
237 export interface CellAppHit {
238 readonly cell: string;
239 }
240
236241 /**
237242 * The child app a Host names, `"unknown"` when it is shaped like one of a
238243 * child that exists but names no app of it, undefined when it is no child
@@ -241,7 +246,7 @@ export interface ChildAppHit {
241246 export async function resolveHostedChildApp(
242247 parent: App,
243248 request: Request,
244 ): Promise<ChildAppHit | "unknown" | undefined> {
249 ): Promise<ChildAppHit | CellAppHit | "unknown" | undefined> {
245250 const host = request.headers.get("host");
246251 if (host === null) return undefined;
247252 const label = parseHostedChildAppLabel(host, childHost(parent));
@@ -257,6 +262,7 @@ export async function resolveHostedChildApp(
257262 if (!CHILD_NAME.test(name)) continue;
258263 const ledger = readLedgerSync(parent, name);
259264 if (ledger === undefined || !ledger.done) continue;
265 if (ledger.placement === "cell") return { cell: name };
260266 const child = await openOnce(parent, ledger);
261267 const runtime = appsRuntime(child);
262268 if (runtime === undefined) return "unknown";
@@ -275,8 +281,8 @@ export const hostedChildApps = childAppsSummary;
275281 * is up, and when it is not (just spawned, a parent that restarted, a
276282 * child that stopped) start it and answer 503 at once.
277283 */
278 export async function answerFromCell(parent: App, request: Request): Promise<Response> {
279 const name = parseHostedChildName(request.headers.get("host") ?? "", childHost(parent));
284 export async function answerFromCell(parent: App, request: Request, named?: string): Promise<Response> {
285 const name = named ?? parseHostedChildName(request.headers.get("host") ?? "", childHost(parent));
280286 const ledger = name === undefined ? undefined : readLedgerSync(parent, name);
281287 if (ledger === undefined || ledger.placement !== "cell") return new Response("not found", { status: 404 });
282288 const child = cellChildren(parent).get(ledger.bodyId);
test/spawn-cell-app-hosts.test.ts added +58
@@ -0,0 +1,58 @@
1 /**
2 * A child forge in a cell said its apps were running while every address of them answered 404 (Starck,
3 * 2026-11-01): an app host of a child was only looked up when the parent's own app domain claimed the name,
4 * and the lookup opened the child in-process, where a cell's child has no apps. The host reaches the cell now.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { AppsRuntime } from "../src/apps/runtime.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { cellChildren } from "../src/spawn/cell.ts";
14 import { resolveHostedChildApp } from "../src/spawn/host.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18
19 let parent: App;
20 let servers: Servers;
21 let cell: ReturnType<typeof Bun.serve>;
22
23 beforeAll(async () => {
24 const dataDir = mkdtempSync(join(tmpdir(), "cell-app-hosts-"));
25 parent = createApp({ dataDir, url: "https://forge.test", port: 0 });
26 // As in production: the parent's own apps hang from another domain than its children do.
27 await new AppsRuntime(parent, { domain: "--apps.test", scheme: "https", forgeUrl: "https://forge.test", concurrency: 1 }).start();
28 mkdirSync(join(dataDir, "spawn"), { recursive: true });
29 writeFileSync(join(dataDir, "spawn", "kid.json"), JSON.stringify({ name: "kid", bodyId: "cell-k", done: true, placement: "cell", size: "small" }));
30 cell = Bun.serve({ port: 0, fetch: (r) => new Response(`cell saw ${r.headers.get("host")}`) });
31 const port = cell.port;
32 (cellChildren(parent) as { get: unknown }).get = (id: string) => (id === "cell-k" ? { port } : undefined);
33 servers = await serve(parent);
34 });
35
36 afterAll(() => {
37 servers.stop();
38 void cell.stop(true);
39 parent.close();
40 });
41
42 const ask = (host: string) => fetch(`${servers.url}/`, { headers: { host }, redirect: "manual" });
43
44 test("a cell child's app host names the cell, and opens nothing here", async () => {
45 const hit = await resolveHostedChildApp(parent, new Request("http://x/", { headers: { host: "deal--kid--forge.test" } }));
46 expect(hit).toEqual({ cell: "kid" });
47 });
48
49 test("through the server, the app's request reaches the cell with its own Host", async () => {
50 const res = await ask("deal--kid--forge.test");
51 expect(res.status).toBe(200);
52 expect(await res.text()).toBe("cell saw deal--kid--forge.test");
53 });
54
55 test("the child's own host still reaches the cell, and a child that does not exist is not found", async () => {
56 expect(await (await ask("kid--forge.test")).text()).toBe("cell saw kid--forge.test");
57 expect((await ask("deal--nobody--forge.test")).status).toBe(404);
58 });
test/spawn-child-runs-apps.test.ts +1
@@ -43,6 +43,7 @@ describe("a hosted child's apps run in the parent", () => {
4343 const hit = await resolveHostedChildApp(parent, request);
4444 if (hit === undefined) return new Response("not an app host", { status: 418 });
4545 if (hit === "unknown") return new Response("not found", { status: 404 });
46 if ("cell" in hit) throw new Error("this child is hosted");
4647 // The server is only for a websocket upgrade, which these requests are not.
4748 return (await proxyApp(hit.runtime, request, hit.target, {} as never))!;
4849 };

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs