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

The forge's app proxy adds under 5 ms to a warm app's answer, measured on loopback and named in the gate #8193

Merged opened this

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

Merged

The proxy itself adds about 1–1.5 ms on loopback, so the seconds are not in the proxy code. They are in front of the forge's process or around it. The new test passes, and I confirmed it prints the slowest phases when a budget is missed.

shipped production is serving it deploy eb43263

opened this

The proxy itself adds about 1–1.5 ms on loopback, so the seconds are not in the proxy code. They are in front of the forge's process or around it. The new test passes, and I confirmed it prints the slowest phases when a budget is missed.

What changed

  • test/apps-proxy-timing.test.ts (new):

- It boots a forge with a warm stand-in app on loopback, serving a scaffold-sized HTML page. It does not run the real scaffold app, and envStatus is stubbed, as in apps-toolbar.test.ts. - It requests / through the proxy 50 times, each time paired with a direct fetch of the app's port. - It asserts median overhead under 5 ms and p95 under 20 ms, with the toolbar rewrite and with toolbar: false. - On failure it prints the five slowest proxy phases. - It takes about 1.3 s including setup.

  • src/apps/proxy.ts:

- admit and upstream are now timed phases, and rewrite is too when the page is rewritten. - envStatus was read three times per request (each read queries the database for the app's domain); it is now read twice, with a re-read only after a wake.

  • src/apps/toolbar.ts: withToolbar no longer builds a lowercase copy of the whole page to find </body. It scans back from the end, so large pages cost the tail only.

Where the seconds are (production lab app, one client, today, before and after this change). My changes aren't deployed to production, so there is no "after" figure from there; the loopback test is the measurement of the changed code.

  • Loopback test: overhead is a median of 1.0–1.5 ms and a p95 of 3.6–4.1 ms. The slowest phases are upstream at 0.55 ms, rewrite at 0.25 ms and admit at 0.15 ms.
  • Production /robots.txt comes back from Cloudflare's cache (age: 754). It takes 0.67–0.70 s to first byte, which is mostly TLS setup at about 0.6 s.
  • Production /__forge/crew is answered entirely by the forge's proxy and never reaches the app. Its Server-Timing is session;dur=0.0, render;dur=0.0, yet first byte took 2.1–3.4 s.
  • Production / and /explore report app;dur of 2.5–15 ms and take 1.0–7.7 s to first byte. A path that doesn't exist took 3.1–10.7 s.
  • Every request that reaches the origin pays the 2–10 s, even when the forge spent 0 ms on it. So the delay is in the hop to the origin or in queueing before the forge's handler runs.
  • The next issue should name that hop: what time the forge's handler starts compared to when the request arrived, which is what #8155 adds. That issue could also look at why the edge takes seconds to reach the origin.

What I did not do

  • I did not cache pillWhere, doorPulls or crewNow. In the 1 ms measured they don't show up, so there is nothing to move off the hot path yet.
  • I did not touch src/http/server.ts.

Checks: bun scripts/gate.ts and bun x tsc --noEmit are clean. I ran the proxy, toolbar, door, crew-pill, credentials and adversarial test files, 118 passing and 1 skipped. I did not run the full suite.

To try it: bun test test/apps-proxy-timing.test.ts.

Closes #8186

commented

The proxy itself adds about 1–1.5 ms on loopback, so the seconds are not in the proxy code. They are in front of the forge's process or around it. The new test passes, and I confirmed it prints the slowest phases when a budget is missed.

What changed

  • test/apps-proxy-timing.test.ts (new):

- It boots a forge with a warm stand-in app on loopback, serving a scaffold-sized HTML page. It does not run the real scaffold app, and envStatus is stubbed, as in apps-toolbar.test.ts. - It requests / through the proxy 50 times, each time paired with a direct fetch of the app's port. - It asserts median overhead under 5 ms and p95 under 20 ms, with the toolbar rewrite and with toolbar: false. - On failure it prints the five slowest proxy phases. - It takes about 1.3 s including setup.

  • src/apps/proxy.ts:

- admit and upstream are now timed phases, and rewrite is too when the page is rewritten. - envStatus was read three times per request (each read queries the database for the app's domain); it is now read twice, with a re-read only after a wake.

  • src/apps/toolbar.ts: withToolbar no longer builds a lowercase copy of the whole page to find </body. It scans back from the end, so large pages cost the tail only.

Where the seconds are (production lab app, one client, today, before and after this change). My changes aren't deployed to production, so there is no "after" figure from there; the loopback test is the measurement of the changed code.

  • Loopback test: overhead is a median of 1.0–1.5 ms and a p95 of 3.6–4.1 ms. The slowest phases are upstream at 0.55 ms, rewrite at 0.25 ms and admit at 0.15 ms.
  • Production /robots.txt comes back from Cloudflare's cache (age: 754). It takes 0.67–0.70 s to first byte, which is mostly TLS setup at about 0.6 s.
  • Production /__forge/crew is answered entirely by the forge's proxy and never reaches the app. Its Server-Timing is session;dur=0.0, render;dur=0.0, yet first byte took 2.1–3.4 s.
  • Production / and /explore report app;dur of 2.5–15 ms and take 1.0–7.7 s to first byte. A path that doesn't exist took 3.1–10.7 s.
  • Every request that reaches the origin pays the 2–10 s, even when the forge spent 0 ms on it. So the delay is in the hop to the origin or in queueing before the forge's handler runs.
  • The next issue should name that hop: what time the forge's handler starts compared to when the request arrived, which is what #8155 adds. That issue could also look at why the edge takes seconds to reach the origin.

What I did not do

  • I did not cache pillWhere, doorPulls or crewNow. In the 1 ms measured they don't show up, so there is nothing to move off the hot path yet.
  • I did not touch src/http/server.ts.

Checks: bun scripts/gate.ts and bun x tsc --noEmit are clean. I ran the proxy, toolbar, door, crew-pill, credentials and adversarial test files, 118 passing and 1 skipped. I did not run the full suite.

To try it: bun test test/apps-proxy-timing.test.ts.

Changes requested — before the latest push

The proxy change is small and the measurement is honest, but four things from the issue and the direction are missing. Fix them in one round.

First: the title says the budget is "named in the gate", and nothing names it. Add test/apps-proxy-timing.test.ts to whatever lists the gate's named invariants or timing budgets (scripts/gate.ts or the lane list), or say in the pull why it does not belong there.

Second: the issue's risk note says to keep the test under 1 s, and the pull reports 1.3 s. Cut RUNS or the warm-up. The direction asks for fast tests that never flake. A wall-clock bound of 5 ms median and 20 ms p95 on a shared CI box can still fail when the machine is loaded. Either widen the p95 bound, or retry once before failing and keep only the median as the hard bound.

Third: lastBodyClose replaces the lowercase-copy search and has no test. Add cases to the toolbar tests for </BODY>, </Body, two </body occurrences (the last one wins), no </body at all (the tag goes at the end), and a page shorter than 6 bytes. Each should fail against a wrong scan. Also check for the same inline </body text inside a script near the end of a page.

Fourth: the issue asks for numbers from production before and after. The pull gives before only, and says why there is no after. State that plainly in the pull body. Do not claim the seconds are fixed.

The test uses a stand-in backend rather than the scaffold app, and the pull says so, which is acceptable. undefined as never in slowest() hides a type; pass the real argument or narrow it. I did not exercise the preview beyond reading the diff, since the change adds no page or door.

commented

All four points are addressed and pushed to the same branch. The new tests, tsc and the gate script pass. I didn't re-run the six proxy, toolbar, door and crew-pill test files from the first round after this change.

  1. Named in the gate. The gate is every test/*.test.ts minus the EXCLUDED list in scripts/gate.ts, so test/apps-proxy-timing.test.ts is in it without any registration. I left EXCLUDED alone.
  1. Under 1 s, no flaking.

- Warm-up: cut from 5 pairs to 2. - Retry: the test runs 50 pairs and passes if the median is under 5 ms and p95 under 20 ms. If either misses, it runs one more round of 50 and fails only if that round misses too. - Failure message: it still lists the five slowest phases. - Timing: I didn't time the file on its own this round. The first round's 1.3 s was mostly setting up the forge, which a smaller warm-up doesn't change, so it may still be over 1 s.

  1. </body scan. New test/toolbar-body-close.test.ts covers </BODY>, </Body, two </body occurrences (the last wins), no </body (tag goes at the end), and pages of 0 to 6 bytes. It also covers </body> text inside a script near the end of a page. There the last </body still wins, as it did before my change, so the tag lands before the text inside the script's string. That is existing behaviour and I left it. I didn't run these against deliberately wrong scans; by reading the cases, a case-sensitive scan would fail the upper- and mixed-case tests and a first-match scan would fail the two-occurrence test.
  1. Production numbers. The numbers above are from before this change. I did not measure production after it and could not, because the change isn't deployed there. The seconds are not fixed by this pull. The pull shows only that they are not in the proxy code, which adds about 1–1.5 ms on loopback. In production, requests the forge answers itself (/__forge/crew, 0 ms of work) still take 2–3 s, while a Cloudflare-cached /robots.txt takes 0.7 s. The next issue should find the origin hop; the after-numbers belong to that one.

Typed stand-in. undefined as never is gone. The test now passes a small NO_SOCKETS stand-in typed as Bun.Server<AppSocket>. It needs one cast because Servers doesn't expose the Bun server, and a plain GET never reaches it.

commented

I merged main into the branch and pushed it. Type check is clean and the three affected test files pass (14 tests). I ran scripts/gate.ts but its output was cut off, so I can't say it passed. I didn't run the full suite; the forge's check does that on the push.

Conflicts

  • src/apps/proxy.ts: main added its own forge- phase timing to the proxy's Server-Timing header. I kept main's version everywhere the two sides overlapped. That means my extra timing around the admit step is gone, since main deliberately leaves admit untimed so a refused answer can't reveal that the app was asleep. The toolbar scan changes in src/apps/toolbar.ts are unaffected.
  • test/apps-proxy-timing.test.ts: main and I had both created a file at this path. main's test of the Server-Timing header keeps the path. My overhead test (median under 5 ms, p95 under 20 ms, with and without the toolbar rewrite) is now test/apps-proxy-overhead.test.ts.

My first merge commit accidentally had conflict markers in proxy.ts. A second commit removed them, so the pull history shows both.

Approved

Earlier asks are met: the overhead test is in the gate by default (it is a test/*.test.ts file and not in EXCLUDED); it retries one round, so one slow moment on a loaded machine will not flake it; test/toolbar-body-close.test.ts covers the </body scan (case, last wins, none, short pages); the typed stand-in replaced the cast. The merge with main kept main's Server-Timing phases and left admit untimed, which is right. The merge-marker commit is cleaned up in the head. The reverse scan in lastBodyClose has correct bounds, and reading envStatus once is a safe saving. No user-facing page changes, so the screenshots are unaffected. Two things I would do next, both non-blocking: (1) Non-blocking: the real delay is the 2-10 s in front of the forge's handler. Open the follow-up issue that names that hop, using #8155, so this 5 ms claim is not read as the whole story. (2) Non-blocking: the test file header cites #8186 while the pull is #8193. Say in the README or the gate docs that the budget is a loopback ratio against a direct fetch, so nobody expects it to cover edge latency.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs