buildtest.kotlin.build emergency lane — outage recovered, one root cause found, fixes in flight
Written 2026-07-31 ~04:10 UTC.
RE-VERIFY BEFORE ACTING. Everything below is a write-time snapshot. PR states, CI results, droplet counts, disk usage and queue depth all change continuously, and several other agent lanes were operating on the same host during this session. Authoritative re-checks:
- Service:
curl -s -o /dev/null -w '%{http_code} %{time_total}\n' https://buildtest.kotlin.build/
- PRs:
gh pr view <n> -R CodexCoder21Organization/<repo> --json state,mergeStateStatus,statusCheckRollup
- Host:
ssh -o BatchMode=yes -i ~/.ssh/id_ed25519_new -p 23 root@198.199.106.165 then df -h /, uptime, free -m
- Cross-session coordination log:
/root/cn-watchdog.audit on that host. Read its tail before ANY mutating action. Multiple lanes append INTENT/ACTION/CORRECTION entries there.
Mission
Keep https://buildtest.kotlin.build/ (the organization's CI) healthy: page loads fast, pending builds start promptly, continuous forward progress. Emergency lane — authorized to build locally and deploy to unbreak production, skipping CI and adversarial review while production is broken, then land the changes properly once it is stable.
Verified state at write time
buildtest.kotlin.build HTTP 200 in 0.10s
queue pending=9 active=2 testing=5
host 198.199.106.165 load ~4-9 MemAvailable 5.1 GB disk 87% (6.4 GB free)
DigitalOcean account 89 droplets, 88 of them buildtest
8 duplicate shard droplets (see "leak" below)
Compared with the start of the session: page load 20.5 s then a full outage with port 443 refusing connections; load average peaked at 212; disk hit 98%; MemAvailable bottomed at 705 MB; admission granted zero builds for ~35 minutes.
The unifying root cause (the most important thing here)
Five symptoms that look independent share one mechanism, confirmed in code and in production logs:
One shared, never-reopened SJVM sandbox connection to url://digitalocean-droplets/. Any RPC that exceeds its client budget is cancelled mid-flight, which invalidates that connection for every concurrent caller. Every path that would reconcile the coordinator's model back to reality runs over that same connection, so one stalled RPC converts "I could not observe" into "I observed absence" in four subsystems at once.
Caught in the act (run 7e23637f, coordinator log url_buildtest_.stdout.log):
03:26:31Z Creating droplet: name=buildtest-7e23637f-s1..s9, region=sfo3
03:27:06Z WARNING: Failed to check for orphaned droplets named 'buildtest-7e23637f-s1':
SandboxException: the sandboxed connection was invalidated (a prior call
'DropletServiceClientImpl.renewDroplet(J)' timed out after 30s).
03:27:06Z Creating droplet: name=buildtest-7e23637f-s1, region=nyc3 <- the duplicate
A lease-renewal RPC timing out destroyed the create-recovery listing. recoverCreatedDropletAfterFailure returns null for BOTH "listed fine, nothing there" AND "could not list", so the for (region in regions) fallback loop inside a single createBuildDroplet call proceeds to make a second droplet for the same shard slot. Both stay active and both count against the 100-droplet cap.
Fleet-wide since the last coordinator start: Creating droplet: 1520 vs Droplet created: 1401 — 119 creates with no success line. Failed to check for orphaned droplets 27 times. Region split sfo3 1477 / nyc3 43 (the fallbacks).
The invariant to build to: the coordinator may only act on an observation it actually made; a failed observation is Unknown, never Absent, and no duplicating or destructive action may be taken on Unknown.
The fix already exists in-org and is a port, not a design. NetLabManagerServer/src/netlabmanager/DropletClient.kt on main has all three pieces BuildTestServerService lacks: discardProxy + closeSandboxedConnection + reopen; retrySafe=false so createDropletAsync is never replayed; and a per-RPC providerOperationLock. Its kdoc names this exact defect. BuildTestServerService/src/buildtest/server/RemoteDropletService.kt:44 is still a bare by lazy with no discard and no reopen.
Structural companion defect: BuildTestEmbedded.callDropletServiceBounded bounds at 30 s and interrupts inside BoundedDropletService's longer budget, so the layer that does not own the connection times out first and triggers the cancel that poisons it. Two deadline owners, one connection. Removing the inner duplicate is not a timeout increase.
Deployed to production and verified (not yet merged to main)
Each was verified with a counterfactual or direct measurement, not asserted:
- ContainerNursery 15%-swap-discount memory-pressure eviction (
c13224d). Availability was MemAvailable + SwapFree, counting 100% of free swap, so reclamation never fired while the host thrashed. Production log at 15:47:40Z shows the fixed arithmetic flipping a real decision: projected −1501 MB (evict, and it evicted 24 containers) where the old formula would have computed +1110 MB (start a 4 GB container onto a full box). Swap growth rate fell ~80x.
- Gossip-retention fix via pin bump (
d0c69d1, resolver 0.0.670→0.0.701, protocol 0.0.401→0.0.409). Full GC now reclaims: old gen 83.42% → 44.57%, full-GC share of wall clock 28% → 0.41% (~68x). A restart gives a low heap but cannot make a pinned heap collectable, which is what makes this valid.
startupTimeoutSeconds actually governs container readiness (6378d7b). Two hardcoded 60 s deadlines existed (ContainerNursery.kt:456 START_TIMEOUT_MS and DangerJarFileBackedContainer.kt:1071 waitUntilTcpReady) and startupTimeoutSeconds appeared zero times in the repo — route config is an untyped JSONObject, so the key was accepted and read by nothing. Coordinator now survives 645 s where it was force-killed at 60 s.
ReopenableSandboxProxy in BuildTestServerService. Pre-fix: 1 listDroplets call issued, 2 stalls (the first stall killed the connection permanently). Post-fix: 17 calls, 2 stalled, 15 succeeded.
- NetLab absolute lifetime cap covers provisioning states (
79088d3) plus ENV_MAX_LIFETIME_MINUTES=50 on the CN route. Registry 376 → 75. Stuck-provisioning environments were previously exempt from every reclamation path.
- kotlin-build-ci queue-anchor (
1e370ee) — the build budget was anchored at submission, so queue wait was charged against execution time. Worse than a cosmetic false red: the resume path cancelled the healthy live run before failing it. Covers all three submission-anchored timers (BuildRunner.kt:2201, CheckBuildsServlet.kt:33-41, resume path :736-748) and installs the 180-minute backstop everywhere (single-phase builds previously had none).
MissingCheckRunReconciler per-pass cap (57e30f2, default 10 via RECONCILE_MAX_PER_PASS).
- Disk reclaimed 98% → 77% (~10.5 GB) and 22 droplets whose parent runs were terminal destroyed after per-droplet proof.
Open PRs — all re-verified at write time
- ContainerNursery swap fix — https://github.com/CodexCoder21Organization/ContainerNursery/pull/492 — OPEN, mergeState CLEAN, branch
fix/memory-pressure-discount-swap. Deployed in production. Merge-queue run previously failed on testGetOrCreateContainer_shutdownAbandonsUncooperativeCreation.kts; analysis says that is a race in the test (hasContainer reports quarantined records; the kill latch releases inside kill() before the quarantine record is removed), not a defect in the change.
- BuildTestServerService sandbox reopen — https://github.com/CodexCoder21Organization/BuildTestServerService/pull/268 — OPEN, CLEAN, branch
fix/reopen-stalled-droplet-sandbox-20260730. Deployed. Note PR 267 does NOT contain the deployed implementation and should be closed.
- BuildTestRunner transported-failure reporting — https://github.com/CodexCoder21Organization/BuildTestRunner/pull/57 — OPEN, CLEAN, branch
fix/report-build-script-failures. Head check passed in 8m35s. Evicted from the merge queue twice (00:26→00:36, 01:08→01:18) because the queue-ref run genuinely failed 2 of 58 tests, both timeouts stuck in Kotlin compiler internals. A rewritten cheaper test exists on wip/pr57-queuefix-handoff-20260731 — verified to still fail on the old dependency set, so it remains a valid regression detector.
- BuildTestEmbedded memory-admission fix — https://github.com/CodexCoder21Organization/BuildTestEmbedded/pull/451 — OPEN, mergeState BLOCKED, branch
fix-memory-admission-starvation-20260731. Not deployed.
- BuildTestEmbedded false-green fix — https://github.com/CodexCoder21Organization/BuildTestEmbedded/pull/454 — OPEN, BLOCKED, branch
fix-false-green-truncated-journal-20260731. Not deployed. Ship this before any further coordinator restart.
- UrlResolver prune-connection-snapshot — https://github.com/CodexCoder21Organization/UrlResolver/pull/902 — OPEN, BLOCKED, branch
fix/prune-connection-snapshot-20260730.
- Related issues: https://github.com/CodexCoder21Organization/BuildTestEmbedded/issues/452 (unknown-prediction default), https://github.com/CodexCoder21Organization/BuildTestEmbedded/issues/453 (false green).
Branches pushed for this handoff — all verified against remote SHAs
Every one was local-only before this handoff; all are pushed and the remote head was confirmed equal to the local head. They are in-flight snapshots and may not build.
| Repo | Branch | Remote head | Contents |
|---|---|---|---|
| BuildTestRunner | wip/pr57-queuefix-handoff-20260731 | 8b96e016 | Cheaper rewrite of the PR-57 test that was timing out in the merge queue |
| BuildTestRunner | wip/pr57-negative-control-handoff-20260731 | 1d2d100e | Negative-control checkout proving the test fails without the fix |
| ContainerNursery | wip/cn-https-stdout-pump-handoff-20260731 | 92d618f6 | HTTPS stdout-pump work (lock-convoy fix) |
| ContainerNursery | wip/cn-readiness-handoff-20260731 | 6378d7b1 | Readiness-deadline lane checkout; matches the deployed jar |
| ContainerNursery | wip/cn-eventloop-handoff-20260731 | 07925068 | EventLoopGroup investigation; 11 lifecycle scenarios pass on current code, so the reported leak did not reproduce |
| BuildTestServerService | wip/btss-admission-diagnostics-handoff-20260731 | debdf80f | Admission-diagnostics build (the admissionDecision log line now in production) |
| BuildTestEmbedded | wip/bte-staleness-control-handoff-20260731 | 675deb22 | Staleness-window control test — does not discriminate; kept as evidence, not as a fix |
| kotlin-build-ci | wip/kbci-retrystorm-handoff-20260731 | 57e30f2d | Queue-anchor + reconciler cap; matches the deployed jar |
Not pushed, deliberately: build outputs and fat jars, and the session scratchpad findings files (they are summarized here).
Next steps, in priority order
- Land the connection-ownership fix (highest value). Port
NetLabManagerServer/DropletClient.kt's discardProxy + reopen + retrySafe=false + per-RPC lock into BuildTestServerService/RemoteDropletService.kt:44. Collapse the double deadline (callDropletServiceBounded's inner 30 s inside BoundedDropletService's longer budget). Make recoverCreatedDropletAfterFailure three-valued so only ProvenAbsent advances to the next region. Also fix DropletManager.ownsBuildDroplet (lines 271-282): it treats an empty dropletIds array as "owned by name", so once a later attempt records its id, the earlier untracked droplet becomes owned=false permanently and no sweep can reclaim it. Reproducer shape: a FakeDropletService connection that creates the droplet, throws "connection was invalidated", then fails every later call until reopened — assert exactly one droplet exists with that name.
- Fix
DeletionWorker throughput (biggest lever on idle machines). Measured drain: ~1 droplet per 35-60 s against a 57-deep backlog. The duplicate reaper works correctly — it emits decision=delete:duplicate-name with the right keepId every cycle — but deletion execution cannot keep up, and shard progress is coupled to global queue depth: shards 1,2,3,6,7,8 of run 7e23637f sat on Waiting for failed droplet <id> deletion from 03:28:17 onward. Of the 17 droplets that run held, 3 were doing work. Fix: back off per failed intent only, process independent intents concurrently, decouple shard progress from queue depth.
- Ship BuildTestEmbedded PR 454 before any further coordinator restart.
finalizeIncompleteTests reconciles results against test_discovered events read back out of the same journal — so damage that removes a test's results also removes the evidence it existed. The reconciliation source is the artifact whose damage it would need to detect. PR 454 reconciles against shardTestSpecs in run.json instead, at all four finalization sites.
- Ship BuildTestEmbedded PR 451. The memory-history lookup key embeds the commit SHA (
discoveredTestEventProject, BuildTestEmbeddedService.kt:10616, falls back for a package-less test to the checkout directory named <Org>-<repo>-<40-hex-sha>), so it can never match a previous run and every test falls back to DEFAULT_UNKNOWN_TEST_PEAK_MEMORY_BYTES = exactly 2 GiB (LeaseScheduler.kt:136). Real peaks across 540,409 recorded executions: p50 78 MB, p95 169 MB, p99 268 MB. Note the memory_skip storm has two causes — some BuildTestEmbedded tests genuinely predict 3.05 GiB against a 3 GiB budget and legitimately serialize; that part is issue 452's territory.
- Merge campaign for the deployed-but-unmerged changes (PRs 492, 268, 57). Production is running code that is not in
main; that gap is the standing risk.
- Lower priority: UrlResolver PR 902 (prune loop walks the whole connection set per peer — the authors already fixed this exact pattern at
UrlResolver.kt:13901-13903 and did not apply it at :11563, :14256, :14261, :14726); CN reclamation threshold scaling (max(100 MB, 6% of RAM), derived from the worst observed non-evicting sample); the queue-vs-head shard asymmetry (merge-queue runs got 2 shards where the PR-head run got more — if systematic, it makes queue evictions mysterious org-wide).
Runs whose verdicts are NOT trustworthy
4ece6301, 79f3c702, and 251d47e4 — all CI for https://github.com/CodexCoder21Organization/kotlin-build-ci/pull/199. Their test-events.jsonl was truncated to reclaim disk, and they finalized COMPLETED with 0 failures over 118/383, 61/384 and a partial suite respectively. Treat as indeterminate, not as passes. Of the 20 runs that finalized (resumed after restart) in that window, the other 18 are fine (100.6-105.9% coverage) — surviving a restart is not the trigger; journal loss is.
Reusable and operational knowledge
Do not repeat these mistakes (all made this session):
- Restarting the buildtest coordinator or the droplet gateway discards cached service bytecode that cannot be refetched from cold (the
digitalocean-droplets bytecode is 2,686,436 bytes and its transfer truncates on a 256 KiB chunk boundary on both direct and relay paths). Two such restarts contributed to a 35-minute total admission outage.
- Reclaim disk from
build.log first, re-measure, and only touch test-events.jsonl if that is insufficient AND the run is already doomed. Truncating a journal destroys the run's authoritative record and (until PR 454 ships) produces a false green.
- Do not install scripts on the production host. A timed reclaimer was installed and removed as a mistake — unversioned, undocumented, deleting production resources on a timer, invisible to other operators, competing with the real reaper. Fixes belong in the repo with tests.
main-emergency already exists with other lanes' unmerged work in BuildTestEmbedded, NetLabManagerServer, HardwareControlFabricDaemon and kotlin-build-ci. Four lanes hit this. Never force-push it; pick a distinct name.
- Never put a
pkill -f / pgrep -f pattern in a command where the pattern appears in your own command line — it matches your own shell's argv and kills your session. Five lanes lost commands to this. Use bracket patterns like "[j]ava".
Diagnostic traps that produce wrong conclusions:
jstat's M column is used/committed, not used/max. A reading of M: 99.07 looks like metaspace exhaustion and is normal; actual use was 28.6 MB of a 512 MB cap. This produced a completely wrong "metaspace leak" diagnosis.
- ContainerNursery logs to
data/logs/container-nursery.log via its own bounded rotation. The file that a launcher redirect points at receives only the first few lines and then stays frozen forever, so /proc/<pid>/fd/1 looks like "logging is dead". This produced a wrong regression call and nearly a needless revert.
- CN prints
dmesg OOM-kill lines undated on every restart, so multi-day-old kernel events read as current. Convert with dmesg -T before believing them.
cn-watchdog.sh does not kill a young CN — it retries /health/live across two 20 s sleeps and only kills when process age exceeds 600 s, and starts CN when none exists. Do not suppress it by holding /tmp/cn-watchdog.lock; that removes the only thing that restarts CN if it dies.
- The WUI
active counter only covers PROVISIONING/INSTALLING/BUILDING and reads 0 while runs sit in TESTING with shards working. Do not infer a stall from it — check the coordinator stdout for DropletManager/BuildDriver activity.
~/.aibuildcaches memoizes buildMaven(). A step reporting "succeeded in Nms" with a single-digit number is a stale cache hit; it produced false negatives twice, including one test that "passed" without the fix applied. Build in a fresh path to confirm fail-first.
- Verify artifact versions by content, never by ordering. Resolver 0.0.670 lacks the byte-budget methods and
WeakReference that 0.0.701 has. An orphan buildtest-embedded:0.0.61309 was published from an unmerged branch.
- Do not merge UrlResolver PR #866 — it introduced the retention defect it appears to fix (its
indexedMessages deque stores the map key inside the map value, making WeakHashMap entries permanently unclearable), and it was already rolled back in production. PR #870 is the real fix, and both gossip fixes were already in resolver main via the #887 batch — the gap was the deployed pin.
kompile buildFatJar bundles only explicitly-named artifacts, not the transitive closure, so a jar can build green and fail at startup. Always run a jar you build.
kotlin.directory (the Maven repo) is served by the same ContainerNursery on the same host, so when CN is degraded you cannot build the fix for CN. scripts/build.bash will also permanently cache a launcher built from an empty coursier classpath in that state, producing a misleading later ClassNotFoundException.
Method that worked. Eleven hypotheses formed ahead of measurement were refuted during this session, several of them mine, and two of those wrong hypotheses led to restarts that made production worse. What resolved things was: direct census data over inference; pre-registering pass/fail criteria before seeing results (this killed a planned 15-service restart campaign whose premise turned out to be false); and delegates that contradicted the brief rather than confirming it. Brief future lanes to verify the premise and to say plainly when the evidence does not support it.