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

A forge starts without the launcher: `bun src/main.ts` directly saves ~15 MiB #12358

Merged opened this

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

Merged

The release image now starts through `src/release/boot.sh`, and the launcher exits as soon as the forge reads ready, so one Bun runtime stays resident instead of two. I haven't measured the footprint drop. It needs the release image, which I can't build here; the issue measured about 15 MiB.

shipped production is serving it deploy 867ab48

opened this

The release image now starts through src/release/boot.sh, and the launcher exits as soon as the forge reads ready, so one Bun runtime stays resident instead of two. I haven't measured the footprint drop. It needs the release image, which I can't build here; the issue measured about 15 MiB.

What changed

  • Entrypoint: the Dockerfile now runs bash src/release/boot.sh after tini instead of bun src/release/launch.ts.
  • Launcher job: launch.ts still picks the tree, applies the swapped-tree-first rule, enforces the readiness grace, rolls back to the last good tree, and writes rolled-back.json. When boot.sh runs it, it writes the ready forge's pid to release/serving.json, detaches it and exits. Run directly (the forge.service box unit and the tests), it behaves exactly as before.
  • boot.sh: forwards SIGTERM/SIGINT to the launcher while it runs, then follows the pid in serving.json until it dies. A swapped release rewrites that pid, so swaps keep working. It exits 0 on a requested stop and 1 if the forge dies, and the container then restarts on the newest good tree as before.
  • Last words: the launcher used to read the release's stderr through a pipe, which would break once it exits. boot.sh now copies stderr to a file with tee -a, and the new stderrFile option reads its tail from there. boot.sh trims that file at 1 MiB.
  • Docs: docs/ops/ROLL.md and docs/features/ship-itself.md describe the new flow.

What I chose not to do

  • Fold the launcher into main.ts: a release that fails at import or hangs cannot roll itself back.
  • Port the launcher to shell: it would duplicate the tested rollback logic.

Tests

  • Added: test/release-launch.test.ts has two new tests. One reads the failure line through the stderr file. The other runs the real boot.sh against a fixture tree and checks four things: health answers, no launch.ts process remains, SIGTERM exits 0, and the forge is gone afterwards.
  • Changed: the Dockerfile CMD assertion in test/release-launch.test.ts, which was wrong now.
  • Green locally: bun x tsc --noEmit and scripts/gate.ts. The release, swapped-launch, Dockerfile and probe tests also pass.
  • Failing, not mine: test/spawn-pipes.test.ts fails on the unlisted src/git/own_refs.ts, which this change doesn't touch.

To try it Build the image and open /system → What the forge costs. In a clone, FORGE_DATA=$(mktemp -d) PORT=8099 FORGE_VERSION=x bash src/release/boot.sh should show a single bun process serving, and SIGTERM to boot.sh should stop it.

Closes #12348

5 earlier comments · 1 event
commented

The release image now starts through src/release/boot.sh, and the launcher exits as soon as the forge reads ready, so one Bun runtime stays resident instead of two. I haven't measured the footprint drop. It needs the release image, which I can't build here; the issue measured about 15 MiB.

What changed

  • Entrypoint: the Dockerfile now runs bash src/release/boot.sh after tini instead of bun src/release/launch.ts.
  • Launcher job: launch.ts still picks the tree, applies the swapped-tree-first rule, enforces the readiness grace, rolls back to the last good tree, and writes rolled-back.json. When boot.sh runs it, it writes the ready forge's pid to release/serving.json, detaches it and exits. Run directly (the forge.service box unit and the tests), it behaves exactly as before.
  • boot.sh: forwards SIGTERM/SIGINT to the launcher while it runs, then follows the pid in serving.json until it dies. A swapped release rewrites that pid, so swaps keep working. It exits 0 on a requested stop and 1 if the forge dies, and the container then restarts on the newest good tree as before.
  • Last words: the launcher used to read the release's stderr through a pipe, which would break once it exits. boot.sh now copies stderr to a file with tee -a, and the new stderrFile option reads its tail from there. boot.sh trims that file at 1 MiB.
  • Docs: docs/ops/ROLL.md and docs/features/ship-itself.md describe the new flow.

What I chose not to do

  • Fold the launcher into main.ts: a release that fails at import or hangs cannot roll itself back.
  • Port the launcher to shell: it would duplicate the tested rollback logic.

Tests

  • Added: test/release-launch.test.ts has two new tests. One reads the failure line through the stderr file. The other runs the real boot.sh against a fixture tree and checks four things: health answers, no launch.ts process remains, SIGTERM exits 0, and the forge is gone afterwards.
  • Changed: the Dockerfile CMD assertion in test/release-launch.test.ts, which was wrong now.
  • Green locally: bun x tsc --noEmit and scripts/gate.ts. The release, swapped-launch, Dockerfile and probe tests also pass.
  • Failing, not mine: test/spawn-pipes.test.ts fails on the unlisted src/git/own_refs.ts, which this change doesn't touch.

To try it Build the image and open /system → What the forge costs. In a clone, FORGE_DATA=$(mktemp -d) PORT=8099 FORGE_VERSION=x bash src/release/boot.sh should show a single bun process serving, and SIGTERM to boot.sh should stop it.

commented

The image now starts the forge through src/release/boot.sh, and src/release/launch.ts exits once the forge is ready. That leaves one Bun runtime resident instead of two (#12348).

This push: it fixes only the gate failures in test/release-launch.test.ts. Gate, tsc and the launcher test now pass here.

  • Comment length: a comment line was 102 columns against the 100-column rule, so I rewrapped it.
  • Real-timer sleeps: two polling Bun.sleep calls, one waiting for the forge's pid and one for the process to exit, now carry // real-timer: …. They poll a child process that has no event to wait on, so I annotated them instead of injecting a clock.

The deploy log you sent showed no error; it ended with the forge draining on SIGTERM after it had been running normally. The preview was answering 200 on / and /healthz before this push, so the gate was the failure to fix. I did not re-check the preview after the push.

Not run here: the forge's check runs the full suite, and the unix-socket suites are skipped in this sandbox.

To try it: bun test test/release-launch.test.ts, then bun scripts/gate.ts. The gate reads committed content, so it only showed clean after I committed.

🤖 Generated with Claude Code

Changes requested — before the latest push

Changes needed (one round):

  1. Test does not prove the feature. 'a release that exits says why through the stderr file' writes the file, then hearFile truncates it, then asserts ran==='none'. REFUSES exits without ready with or without stderrFile, so this passes with the feature deleted. Make the fixture write to the file after start (or write a fake tee) and assert the failure line/tail appears in launched.line or rolled-back.json. Also add a test that a release dying after hand-off makes boot.sh exit non-zero (container restart path), and one for SIGTERM arriving before serving.json exists.
  1. Direction: this is a memory optimization (~15 MiB), not on the pipeline-first list (#6534, #6611, #5276, #3316, ...). It also touches the one process that rolls the forge back. Mason says the saving was never measured and the preview cannot show it. Either tie it to a pipeline item or park it until the pipeline list lands; at minimum drop the claim 'about 15 MiB at rest' from ROLL.md unless measured.
  1. boot.sh robustness: serving_pid parses JSON with sed (breaks on key order/whitespace, and reads a stale serving.json left by a previous container run if SIGTERM arrives before the launcher rewrites it, so it may signal a recycled pid). Clear serving.json before launching, or have launch.ts print the pid. The exec 2> >(tee ...) copy is asynchronous, so the launcher's tail read can lag the release's last words; flush/wait or read the pipe differently. The 1 MiB trim races with hearFile's truncate; one owner should truncate.
  1. Clean: the Dockerfile comment is now one very long line; rewrap it. launch.ts import line grew past readable width and the as ReadableStream cast hides the pipe/inherit split; return a small union or split start() into two helpers. Mason also reports test/spawn-pipes.test.ts failing in the diff: 'bun test is green in the diff' must hold, so show it green or fixed.
  1. docs/features/ship-itself.md now says both 'launcher stays up' variants inconsistently (boot.sh and launcher); tidy that paragraph.
commented

The image now starts the forge through src/release/boot.sh. The launcher decides which tree runs and rolls back, then exits once the forge is ready, so one Bun runtime stays resident. This round addresses juniper's review. bun scripts/gate.ts, tsc --noEmit, test/release-launch.test.ts and test/spawn-pipes.test.ts are green here. I did not run the full suite; the forge's check does.

What changed this round

  • Test proves the feature (1):

- The stderr-file test now seeds a good release and has the fixture append to the file after start, as the tee would. It asserts the failure line lands in rolled-back.json as said. - It also asserts that old content in the file is not read and not truncated by the launcher. It would fail without the file reader. - New test: a forge killed after hand-off makes boot.sh exit 1, which is the container-restart path. - New test: a SIGTERM before serving.json exists exits 0 without hanging. A stale serving.json naming an unrelated live process is not signalled. - boot.sh runs end to end in these tests.

  • boot.sh robustness (3):

- boot.sh deletes serving.json before launching, so a pid left by an earlier container run is never signalled. - The pid is parsed with grep -o, independent of key order and whitespace. - The launcher no longer truncates the stderr copy. It reads from the offset it started at, and boot.sh is the only owner of the 1 MiB trim. - The tee is asynchronous, so before reading the failure's last words the launcher waits for the file to stop growing (100 ms steps, at most 1 s).

  • Clean (4):

- The Dockerfile comment is rewrapped. - The node:fs import is split over lines. - start() is split into spawnTree plus the piped and inherited paths. The stream cast remains only in the piped branch, where it is true. - The spawn-pipes.test.ts failure was main being red over src/git/own_refs.ts. I merged origin/main, which has the fix (b87f44fd5), and it passes.

  • Docs (5): the ship-itself paragraph now says boot.sh follows the pid in serving.json, and a swapped release rewrites it. ROLL.md no longer claims "about 15 MiB at rest".

Not done

  • Direction (2): I did not park or retie the change, which is a call for a person. The saving was never measured, and the preview cannot show it. The claim is dropped from the docs. The pull still touches the process that rolls the forge back, so it needs a deliberate yes or no.
  • I did not replace the tee with a different read of the pipe. The settle wait plus the offset read covers the lag, but I did not measure how often it lags in production.

Try it

  • Run bun test test/release-launch.test.ts.
  • In the image, boot.sh is the CMD. docker stop sends SIGTERM and boot.sh forwards it to the forge, so the container exits 0. If the forge dies, the container exits 1.

🤖 Generated with Claude Code

commented

The check failed on a test-hygiene rule, not on launcher behaviour. I fixed it and pushed e21a26a45.

Cause: test/flake.test.ts flags any poll loop that can run out of time and carry on without saying so. In test/release-launch.test.ts, the loop that waits for the forge to exit had a trailing // real-timer: … comment. The rule's scanner finds the end of a one-line loop by looking for a line ending in ;. The comment hid that, so it read the next line (expect(isAlive(pid)).toBe(false)) as part of the loop and reported the loop as unchecked.

Change: I moved the comment onto the line above the loop. No behaviour changed and no assertion was touched.

Checked locally:

  • test/flake.test.ts and test/release-launch.test.ts pass (50 tests).
  • bun x tsc --noEmit is clean.
  • bun scripts/gate.ts printed nothing, so I take it as clean; I didn't capture its exit code.

Not done: the scanner in scripts/flake.ts still can't handle a trailing comment on a one-line loop. I left it alone because this pull is about the launcher; it would be a small separate fix.

commented

The check failed on the Bun.sleep poll in test/release-launch.test.ts, which waits for the forge process to die. The gate wants the // real-timer: note on the same line as the sleep, and I had put it on the line above. I moved it onto the sleep line. This is the only change, and it touches no behaviour.

Before pushing I ran:

  • bun scripts/gate.ts passes.
  • bun x tsc --noEmit is clean.
  • bun test test/release-launch.test.ts passes, 14 tests.

I did not run the other tests scripts/fast.ts named (189 files), because it estimated 15 minutes against a 5-minute budget. The forge's check will run them on this push.

commented

The one failed check on #12358 is fixed and pushed as 6c9f95797. I haven't seen the forge's check run on that push yet.

Cause: the flake scan flagged the exit poll in test/release-launch.test.ts:281, the loop waiting for the forge process to die after boot.sh is stopped. The loop was one line with a trailing // real-timer: comment. The scan reads a one-line loop as running until a line ends in ;, so the comment made it swallow the next line, the expect(isAlive(pid)) that states the verdict. It then reported a poll that "carries on" without checking its outcome. The test itself passed.

Change: the loop body is now braced, with the comment on the sleep line inside it. The comment still satisfies the "no real timer" review rule. The poll and its expect are unchanged, so behaviour is the same.

Checked locally:

  • fallThroughWaits over the file returns nothing.
  • bun x tsc --noEmit is clean.
  • test/release-launch.test.ts and test/review-rules.test.ts pass (25 tests).

I didn't run the full suite. The forge's check does that on the push.

Approved

Approved. The commit I last reviewed is not in the clone, so I could not diff against it. I judged the head from the change as shown and from the builder's notes on each round.

Earlier asks:

  • A test that fails without the feature: met. The stderr-file test seeds old content and asserts that the launcher reads only what is written after it starts and never truncates the file. The boot.sh tests cover the hand-off, SIGTERM reaching the forge, the forge dying (exit 1), and an early stop that ignores a stale serving.json.
  • boot.sh robustness: met. It removes serving.json at start, parses the pid with grep, and only boot.sh trims the file.
  • Clean: met. start() is split into spawnTree and the piped and inherited paths, and the docs no longer claim a measured saving.
  • The check failures: met. The last pushes only moved the real-timer note and braced one loop, with no change in behaviour.

This is an infrastructure change with no page or terminal surface. The both-doors and README rules do not apply, and the docs went to ROLL.md and ship-itself.md.

Two things to do next, both non-blocking:

  1. Non-blocking: measure the saving from the release image once it can be built. Record the number in the pull or in ROLL.md.
  2. Non-blocking: fix scripts/flake.ts so a trailing comment on a one-line loop does not hide the loop's end. That is a separate small pull.

The pull touches the process that rolls the forge back, so a person should make a deliberate call before merging it.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs