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

A proxied app response names the forge's own time in Server-Timing, beside the app's phases #8179

Merged opened this

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

Merged

scout/forge:scout/8155-a-proxied-app-response-names-the-forge-s → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/8155-a-proxied-app-response-names-the-forge-s was merged into main as c5f927b.

Review

Sign in to review this change.
5 files changed +283 −24
README.md +4
@@ -18,6 +18,10 @@ green changes, and nothing waits on a person. [`docs/STUDIO.md`](docs/STUDIO.md)
1818 git underneath is one click away — the switch in the top bar, or `?mode=dev` on
1919 any URL — and it is the rest of this page.
2020
21 Slow app? Its response's `Server-Timing` keeps the app's entries and adds the
22 forge's `forge-route`, `forge-wake`, `forge-upstream` and `forge-rewrite`
23 (milliseconds only; see `docs/APPS.md`).
24
2125 A forge this one spawned (hosted) runs the apps it was given too: its `bakery`
2226 answers at `bakery--<child>--<this forge's host>`, in this forge's lanes, on the
2327 child's own data, and `/forges` says how many are running and why any is not. Each row also ends with the first step its person has not taken (`not claimed yet`, `no everyday account yet`, `no tell yet`, or `going: last tell <time>`; `has not reported yet` before the child's first beat), read from the child's heartbeat; the terminal's forges screen and the MCP `forges` tool (`nextStep`) say the same.
docs/APPS.md +14
@@ -254,6 +254,20 @@ read the repository sees only the time, never a sha. The read is the `serving`
254254 Server-Timing phase. The prefix was already the forge's, so an app's own
255255 `/__forge/` path is not reachable.
256256
257 **Reading a slow app's Server-Timing.** A response the proxy relays keeps the
258 app's own entries (`db`, `app`, …) and gains the forge's, each `forge-` named
259 and in milliseconds only: `forge-route` (finding the target and its grant),
260 `forge-wake` (waiting for a sleeping or starting environment; 0 when none),
261 `forge-upstream` (until the app's headers) and `forge-rewrite` (pill injection,
262 when it ran). Big `forge-wake` is a cold start, big `forge-upstream` is the
263 app; if all are small, the time went before the request reached the forge. The
264 "app stopped answering" page carries `route`, `wake` and `upstream` too; the
265 forge's own `/__forge/*` pages and the WebSocket upgrade name no forge- phases.
266 A refused answer (sign in, not found, not running) names none either: the wake
267 runs before the door is asked, so its time would tell a caller who may not read
268 the app whether it was asleep. The proxy has one writer for these
269 (`withPhases` over `notePhase`), which appends to the app's header.
270
257271 **Tell the crew from the pill.** Signed in as someone who may direct the crew,
258272 the panel also offers *Tell the crew* (`/__forge/crew` adds a `tell` link, only
259273 for them; signed out there is no action). It opens the forge's own
src/apps/proxy.ts +54 −24
@@ -47,7 +47,7 @@ import { clientBucket } from "../http/peer.ts";
4747 import { DOOR_PULL_LIMIT, doorPage, type DoorPullLine } from "./door.ts";
4848 import { checkWord } from "./check_word.ts";
4949 import { openPulls } from "./door_pulls.ts";
50 import { takePhases, timePhase } from "../core/route_timing.ts";
50 import { notePhase, startedAt, takePhases, timePhase } from "../core/route_timing.ts";
5151 import { readServing } from "./serving.ts";
5252 import { notFound, securityFloor } from "../http/respond.ts";
5353 import { appMeasure } from "./measure.ts";
@@ -390,17 +390,27 @@ function sealedPull(runtime: AppsRuntime, target: AppTarget): Issue | undefined
390390 return pull?.sealed === true ? pull : undefined;
391391 }
392392
393 /** The response with the phases the request named, as `Server-Timing`. */
393 /**
394 * The response with the phases the request named, as `Server-Timing`: the
395 * one writer for the proxy, on the forge's own `/__forge/*` pages and on a
396 * relayed app answer alike. It appends, so an app's own entries stay first;
397 * on an app answer the forge's phases are `forge-` named (`relayPhase`) so
398 * none can collide with the app's or with the server's `route`.
399 */
394400 function withPhases(request: Request, res: Response): Response {
395401 const phases = takePhases(request);
396 if (phases.length > 0)
397 res.headers.set(
398 "server-timing",
399 phases.map((p) => `${p.name};dur=${p.ms.toFixed(1)}`).join(", "),
400 );
402 if (phases.length === 0) return res;
403 const mine = phases.map((p) => `${p.name};dur=${p.ms.toFixed(1)}`).join(", ");
404 const theirs = res.headers.get("server-timing");
405 res.headers.set("server-timing", theirs === null || theirs === "" ? mine : `${theirs}, ${mine}`);
401406 return res;
402407 }
403408
409 /** Name one of the forge's phases of relaying an app answer. Durations only. */
410 function relayPhase(request: Request, name: "route" | "wake" | "upstream" | "rewrite", ms: number): void {
411 notePhase(request, `forge-${name}`, ms);
412 }
413
404414 /**
405415 * What the pill says: the app, the environment and, for a preview, the pull
406416 * and its title. The title goes only to a viewer who may read the pull.
@@ -1023,15 +1033,24 @@ export async function proxyApp(
10231033 }
10241034 // An environment asleep wakes for the request that wants it, and the
10251035 // request waits the second that takes rather than seeing a closed door.
1026 if (runtime.envStatus(target.app, target.env)?.state === "asleep")
1036 let wakeMs = 0;
1037 if (runtime.envStatus(target.app, target.env)?.state === "asleep") {
1038 const woke = performance.now();
10271039 await runtime.wake(target.app, target.env);
1040 wakeMs = performance.now() - woke;
1041 }
10281042 const admitted = admit(runtime, request, target);
1043 // A refused answer names no phases: wake runs before `admit`, so its time
1044 // would tell a caller who may not read the app whether it was asleep.
10291045 if (!admitted.ok)
10301046 return url.pathname.startsWith("/__forge/")
10311047 ? securityFloor(admitted.error)
10321048 : admitted.error;
10331049 const { status, port, grant: vouched } = admitted.value;
10341050 runtime.touch(target.app, target.env);
1051 const began = performance.now();
1052 relayPhase(request, "route", Math.max(0, began - (startedAt(request) ?? began) - wakeMs));
1053 relayPhase(request, "wake", wakeMs);
10351054
10361055 const upstream = new URL(url);
10371056 upstream.protocol = "http:";
@@ -1082,6 +1101,7 @@ export async function proxyApp(
10821101 portAnswered();
10831102 };
10841103 let response: Response;
1104 const sent = performance.now();
10851105 try {
10861106 response = await fetch(upstream, {
10871107 method: request.method,
@@ -1097,8 +1117,10 @@ export async function proxyApp(
10971117 } as RequestInit);
outdated

The unavailable() return above carries no forge phases; add them or document.

10981118 } catch {
10991119 answered();
1100 return unavailable(runtime, target, status, "the app stopped answering");
1120 relayPhase(request, "upstream", performance.now() - sent);
1121 return withPhases(request, unavailable(runtime, target, status, "the app stopped answering"));
11011122 }
1123 relayPhase(request, "upstream", performance.now() - sent);
11021124 const out = new Headers(response.headers);
11031125 for (const name of HOP_BY_HOP) out.delete(name);
11041126 // Which app answered: a probe from outside (`spawn/reach.ts`) tells this
@@ -1130,6 +1152,7 @@ export async function proxyApp(
11301152 (rewritable(out, response.status) ||
11311153 (!out.has("content-length") && htmlAnswer(out, response.status)))
11321154 ) {
1155 const rewriting = performance.now();
11331156 let bytes: Uint8Array<ArrayBuffer>;
11341157 try {
11351158 if (out.has("content-length")) bytes = new Uint8Array(await response.arrayBuffer());
@@ -1138,15 +1161,18 @@ export async function proxyApp(
11381161 // past that it goes on as it came, what was read first.
11391162 const read = await readPage(response.body);
11401163 if (!read.whole)
1141 return new Response(read.rest, {
1142 status: response.status,
1143 statusText: response.statusText,
1144 headers: out,
1145 });
1164 return withPhases(
1165 request,
1166 new Response(read.rest, {
1167 status: response.status,
1168 statusText: response.statusText,
1169 headers: out,
1170 }),
1171 );
11461172 bytes = read.bytes;
11471173 }
11481174 } catch {
1149 return unavailable(runtime, target, status, "the app stopped answering");
1175 return withPhases(request, unavailable(runtime, target, status, "the app stopped answering"));
11501176 }
11511177 // A signed-in person's page load gets the panel's one-use proof, and so
11521178 // is theirs alone: never stored or shared. A page the app's own script
@@ -1178,19 +1204,23 @@ export async function proxyApp(
11781204 out.set("content-length", String(sent.length));
11791205 out.delete("etag");
11801206 out.delete("content-md5");
1181 return new Response(sent, {
1182 status: response.status,
1183 statusText: response.statusText,
1184 headers: out,
1185 });
1207 relayPhase(request, "rewrite", performance.now() - rewriting);
1208 return withPhases(
1209 request,
1210 new Response(sent, {
1211 status: response.status,
1212 statusText: response.statusText,
1213 headers: out,
1214 }),
1215 );
11861216 }
1187 return new Response(
1188 body,
1189 {
1217 return withPhases(
1218 request,
1219 new Response(body, {
11901220 status: response.status,
11911221 statusText: response.statusText,
11921222 headers: out,
1193 },
1223 }),
11941224 );
11951225 }
11961226
src/core/route_timing.ts +5
@@ -296,6 +296,11 @@ export function noteStart(request: Request, at: number): void {
296296 startTimes.set(request, at);
297297 }
298298
299 /** When the server took the request (`noteStart`), if it did. */
300 export function startedAt(request: Request): number | undefined {
301 return startTimes.get(request);
302 }
303
299304 const framed = new WeakSet<Request>();
300305
301306 /**
test/apps-proxy-timing.test.ts added +206
@@ -0,0 +1,206 @@
1 /**
2 * A proxied answer keeps the app's Server-Timing and appends the forge's own
3 * phases, `forge-` named: where a slow request spent its time.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts";
11 import { newApp } from "../src/actions/apps.ts";
12 import { commitToBranch } from "../src/actions/repos.ts";
13 import type { Repo } from "../src/core/repos.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { deploySettled } from "./helpers/apps-forge.ts";
17
18 const DOMAIN = "apps.test";
19 const SLOW = `const server = Bun.serve({
20 port: Number(process.env.PORT),
21 hostname: "127.0.0.1",
22 async fetch(request) {
23 if (new URL(request.url).pathname === "/healthz") {
24 await Bun.sleep(300);
25 return new Response("ok", { headers: { "server-timing": "db;dur=0.1, app;dur=1.1" } });
26 }
27 return new Response("<html><body><h1>hello</h1></body></html>", { headers: { "content-type": "text/html" } });
28 },
29 });
30 `;
31
32 const dur = (header: string, name: string): number | undefined => {
33 const m = new RegExp(`(?:^|, )${name};dur=([0-9.]+)`).exec(header);
34 return m ? Number(m[1]) : undefined;
35 };
36
37 describe("a proxied answer's Server-Timing", () => {
38 let dataDir: string;
39 let app: App;
40 let servers: Servers;
41 let runtime: AppsRuntime;
42 let alice: User;
43 let repo: Repo;
44
45 const viaHost = (path = "/") =>
46 fetch(`${servers.url}${path}`, { headers: { host: `hello.${DOMAIN}`, accept: "text/html" }, redirect: "manual" });
47
48 beforeAll(async () => {
49 dataDir = await mkdtemp(join(tmpdir(), "forge-ptiming-"));
50 app = createApp({ dataDir, port: 0 });
51 servers = await serve(app);
52 runtime = new AppsRuntime(app, {
53 domain: DOMAIN,
54 scheme: "http",
55 publicPort: servers.port,
56 forgeUrl: servers.url,
57 });
58 await runtime.start();
59 const created = await createUser(app.db, { origin: "seed", trusted: true,
60 username: "alice",
61 email: "[email protected]",
62 password: "a-good-passphrase",
63 adminIfFirst: true,
64 });
65 if (!created.ok) throw new Error(created.error);
66 alice = created.value;
67 const made = await newApp(app, alice, { name: "hello", description: "d", visibility: "public" });
68 if (!made.ok) throw new Error(String(made.error));
69 repo = made.value.repo;
70 const first = await deploySettled(app, repo.id, (r) => r.env === "production");
71 expect(first.state).toBe("ok");
72 const committed = await commitToBranch(app, alice, repo, {
73 branch: repo.defaultBranch,
74 base: repo.defaultBranch,
75 message: "Slow healthz",
76 files: [
77 { path: "src/slow.ts", content: SLOW },
78 {
79 path: "forge.json",
80 content: JSON.stringify({ app: { run: "bun run src/slow.ts", health: "/" } }),
81 },
82 ],
83 });
84 expect(committed.ok).toBe(true);
85 const second = await deploySettled(app, repo.id, (r) => r.env === "production" && r.id !== first.id);
86 expect(second.state).toBe("ok");
87 }, 120_000);
88
89 afterAll(async () => {
90 await runtime.stop();
91 servers.stop();
92 app.close();
93 await rm(dataDir, { recursive: true, force: true });
94 });
95
96 test("a slow upstream shows in forge-upstream, beside the app's own entries", async () => {
97 const res = await viaHost("/healthz");
98 expect(res.status).toBe(200);
99 const header = res.headers.get("server-timing")!;
100 expect(header.startsWith("db;dur=0.1, app;dur=1.1, ")).toBe(true);
101 expect(dur(header, "forge-upstream")!).toBeGreaterThanOrEqual(250);
102 expect(dur(header, "forge-route")).toBeDefined();
103 expect(dur(header, "forge-wake")).toBe(0);
outdated

Flake risk: sleep(300) can measure 299.x ms. Loosen the bound.

104 // Durations only: nothing of the host or the app's name.
105 expect(header).toMatch(/^[a-z0-9.;=, -]+$/);
106 expect(header).not.toContain("hello");
107 });
108
109 test("a page the pill is written into names forge-rewrite", async () => {
110 const res = await viaHost("/");
111 const header = res.headers.get("server-timing")!;
112 expect(dur(header, "forge-rewrite")).toBeDefined();
113 });
114
115 /** Make the next request find the environment asleep, and its wake take `ms`. */
116 const asleepOnce = (ms: number, after: "awake" | "asleep") => {
117 const status = runtime.envStatus.bind(runtime);
118 const wake = runtime.wake.bind(runtime);
119 let woke = false;
outdated

Waits up to 20s on a real sleep sweeper; slow and timing-dependent. Prefer a direct test.

120 runtime.envStatus = (record, env) =>
121 woke && after === "awake" ? status(record, env) : ({ ...status(record, env)!, state: "asleep", port: undefined } as EnvStatus);
122 runtime.wake = async () => {
123 await Bun.sleep(ms);
124 woke = true;
125 return undefined;
126 };
127 return () => {
128 runtime.envStatus = status;
129 runtime.wake = wake;
130 };
131 };
132
133 test("a request that wakes the environment names the wait in forge-wake", async () => {
134 const restore = asleepOnce(150, "awake");
135 try {
136 const res = await viaHost("/healthz");
137 expect(res.status).toBe(200);
138 const header = res.headers.get("server-timing")!;
139 expect(dur(header, "forge-wake")!).toBeGreaterThanOrEqual(140);
140 expect(dur(header, "forge-upstream")!).toBeGreaterThanOrEqual(250);
141 } finally {
142 restore();
143 }
144 });
145
146 test("a refused answer after a wake names no phases: the wait would say it was asleep", async () => {
147 const restore = asleepOnce(50, "asleep");
148 try {
149 const res = await viaHost("/healthz");
150 expect(res.status).toBeGreaterThanOrEqual(400);
151 expect(res.headers.get("server-timing") ?? "").not.toContain("forge-");
152 } finally {
153 restore();
154 }
155 });
156
157 /**
158 * Point the next requests at a fake upstream on a raw socket that answers
159 * `reply` to whatever it is sent and closes.
160 */
161 const fakeUpstream = (reply: string) => {
162 const listener = Bun.listen({
163 hostname: "127.0.0.1",
164 port: 0,
165 socket: {
166 data(socket) {
167 if (reply !== "") socket.write(reply);
168 socket.end();
169 },
170 },
171 });
172 const status = runtime.envStatus.bind(runtime);
173 runtime.envStatus = (record, env) => ({ ...status(record, env)!, port: listener.port }) as EnvStatus;
174 return () => {
175 runtime.envStatus = status;
176 listener.stop(true);
177 };
178 };
179
180 const stoppedAnswering = async (res: Response) => {
181 expect(res.status).toBeGreaterThanOrEqual(500);
182 expect(await res.text()).toContain("stopped answering");
183 const header = res.headers.get("server-timing")!;
184 for (const name of ["forge-route", "forge-wake", "forge-upstream"]) expect(dur(header, name)).toBeDefined();
185 };
186
187 test("an upstream that closes without answering: 'stopped answering' with the forge's phases", async () => {
188 const restore = fakeUpstream("");
189 try {
190 await stoppedAnswering(await viaHost("/"));
191 } finally {
192 restore();
193 }
194 });
195
196 test("a page that breaks off mid-body: 'stopped answering' with the forge's phases", async () => {
197 const restore = fakeUpstream(
198 "HTTP/1.1 200 OK\r\ncontent-type: text/html\r\ncontent-length: 1000\r\n\r\n<html><body>",
199 );
200 try {
201 await stoppedAnswering(await viaHost("/"));
202 } finally {
203 restore();
204 }
205 });
206 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs