Public: anyone can read this; the crew can change it.
P0: 0.7.278 blocked its HTTP loop for minutes right after boot (not /boards); find the startup stall and add an event-loop lag gauge #6961
Check · Review · Land · Live done — Landed and live in production.
The 0.7.278 stall reproduces locally as SQLite lock contention between two forges. bun:sqlite waits for the write lock on the same thread that serves HTTP, for up to `busy_timeout` (15 s) per statement. So a forge booting beside one still draining on the same database freezes behind the old one's writes. This fits trevato's two-writers hypothesis. A minimal boot beside a process that holds the wri…
shipped production is serving it deploy 8c46a8d
The 0.7.278 stall reproduces locally as SQLite lock contention between two forges. bun:sqlite waits for the write lock on the same thread that serves HTTP, for up to busy_timeout (15 s) per statement. So a forge booting beside one still draining on the same database freezes behind the old one's writes. This fits trevato's two-writers hypothesis. A minimal boot beside a process that holds the write lock reproduces the readiness timeouts, and this branch fixes it in two parts plus a gauge.
The new forge holds its work (src/runs/peer.ts, wired in src/main.ts). After the listener and apps are up, the boot checks for running runs held by another process whose lease was renewed in the last 30 s. While any exist, it waits before starting checks, the merge queue, the crew and the ticks. It stops waiting when they are gone, or after 720 s (the pod's grace period). Old forges already renew run leases, so this works with 0.7.276 as the draining pod. A signal during the wait stops the apps and exits cleanly. heldRuns in src/runs/lease.ts tells this process's own leases apart from the other forge's.
Telemetry never waits on the lock (withoutWaiting in src/kernel/db.ts). Every /healthz probe is counted, and those counts are flushed as database writes. Behind the other forge's lock, each flush waited up to 15 s and held up the next probe. Holding the forge's work alone does not fix that. Route timings (src/core/route_timing.ts), usage counts (src/core/usage_count.ts) and machine samples (src/boards/ring.ts) now try the lock once. If it is taken they keep their rows and retry on the next flush, which they already did when a write failed.
The next stall names itself:
src/kernel/loop_lag.tslogsevent loop stalled lateMs=…whenever the loop wakes more than a second late. It is on from the first line of the boot, and while draining.src/kernel/slow_sql.tslogs any statement on the forge's database that takes 200 ms or more, with the start of its SQL and its callers, at most ten lines a minute. This is how the boot test found the usage-count flush blocking the loop.
Tests:
test/boot-beside-peer.test.tsbootssrc/main.tsbeside a process that holds the write lock for 2.5 s out of every 2.65 s and keeps 8 running check leases fresh. Every/healthzprobe must answer within 1 s. The forge must log the wait, and start its work once that process finishes.
- It passes with both fixes. - With both reverted, 7 probes time out at 1 s. - With only the hold removed, the probes stay fast, but the forge starts its work beside the other process.
test/loop-stall.test.tscovers the stall warning, the slow-statement log (including a lock held by a real second process), lease counting and the wait's three outcomes.- The type check and
scripts/gate.tsare clean. test/ssh-port-taken.test.tstimes out in this sandbox onmaintoo; this change does not cause it.
Not done:
- I could not boot on a copy of the production database: the sandbox blocks
/data/forge.db. The two log lines above are the first thing to look for on the next roll. - The test's probe window is 20 s, not the brief's 2 minutes, to fit the check's 60 s per-test timeout.
busy_timeoutstays at 15 s for request writes. A person's write during the overlap can still wait on the old forge's lock, but probes no longer do.docs/ops/ROLL.mdgets a new section on all this; its opening line still saysmaxSurge: 1, which belongs to #6958.
To try it: bun test test/boot-beside-peer.test.ts test/loop-stall.test.ts.
🤖 Generated with Claude Code
Approved. The preview answers /healthz and / with 200 (first requests about 1 s, likely cold). The fix is the right shape: the listener answers while the new forge holds its work behind a peer-lease check, telemetry writes stop waiting on the lock, and a stall warning plus a slow-statement log cover the next one. I checked the diff against the security and tests items in the review list and found no miss. The change adds no page, so the phone and desktop looks were not read, and it adds no tty or MCP surface, so there is no door to mirror. I did not run the tests myself. Two things to do next: (1) test/boot-beside-peer.test.ts is a 20 s wall-clock test that asserts 1 s probe latency and that no "event loop stalled" line appears. That can flake on a loaded check machine, and it works against the #1242 per-test budget and the never-flake rule. Give it an explicit budget, or assert on counts of slow probes with a tolerance, and consider moving it to the slow lane. (2) Open an issue for busy_timeout on request writes. The pull leaves them at 15 s, so a person's write during the overlap can still freeze the loop. A single-digit-second cap with a friendly retry message would finish the job.
The 0.7.278 stall reproduces locally as SQLite lock contention between two forges. bun:sqlite waits for the write lock on the same thread that serves HTTP, for up to
busy_timeout(15 s) per statement. So a forge booting beside one still draining on the same database freezes behind the old one's writes. This fits trevato's two-writers hypothesis. A minimal boot beside a process that holds the write lock reproduces the readiness timeouts, and this branch fixes it in two parts plus a gauge.The new forge holds its work (
src/runs/peer.ts, wired insrc/main.ts). After the listener and apps are up, the boot checks for running runs held by another process whose lease was renewed in the last 30 s. While any exist, it waits before starting checks, the merge queue, the crew and the ticks. It stops waiting when they are gone, or after 720 s (the pod's grace period). Old forges already renew run leases, so this works with 0.7.276 as the draining pod. A signal during the wait stops the apps and exits cleanly.heldRunsinsrc/runs/lease.tstells this process's own leases apart from the other forge's.Telemetry never waits on the lock (
withoutWaitinginsrc/kernel/db.ts). Every/healthzprobe is counted, and those counts are flushed as database writes. Behind the other forge's lock, each flush waited up to 15 s and held up the next probe. Holding the forge's work alone does not fix that. Route timings (src/core/route_timing.ts), usage counts (src/core/usage_count.ts) and machine samples (src/boards/ring.ts) now try the lock once. If it is taken they keep their rows and retry on the next flush, which they already did when a write failed.The next stall names itself:
src/kernel/loop_lag.tslogsevent loop stalled lateMs=…whenever the loop wakes more than a second late. It is on from the first line of the boot, and while draining.src/kernel/slow_sql.tslogs any statement on the forge's database that takes 200 ms or more, with the start of its SQL and its callers, at most ten lines a minute. This is how the boot test found the usage-count flush blocking the loop.Tests:
test/boot-beside-peer.test.tsbootssrc/main.tsbeside a process that holds the write lock for 2.5 s out of every 2.65 s and keeps 8 running check leases fresh. Every/healthzprobe must answer within 1 s. The forge must log the wait, and start its work once that process finishes.- It passes with both fixes. - With both reverted, 7 probes time out at 1 s. - With only the hold removed, the probes stay fast, but the forge starts its work beside the other process.
test/loop-stall.test.tscovers the stall warning, the slow-statement log (including a lock held by a real second process), lease counting and the wait's three outcomes.scripts/gate.tsare clean.test/ssh-port-taken.test.tstimes out in this sandbox onmaintoo; this change does not cause it.Not done:
/data/forge.db. The two log lines above are the first thing to look for on the next roll.busy_timeoutstays at 15 s for request writes. A person's write during the overlap can still wait on the old forge's lock, but probes no longer do.docs/ops/ROLL.mdgets a new section on all this; its opening line still saysmaxSurge: 1, which belongs to #6958.To try it:
bun test test/boot-beside-peer.test.ts test/loop-stall.test.ts.🤖 Generated with Claude Code
Closes #6957