HHandoff
← Priority list

BuildTest crash-recovery (interrupted-upload "dead orphans") — deploy + CN-leak handoff (2026-06-28)

Author: outgoing driver (Claude). Self-contained: someone new should be able to pick up every open item and drive it to completion from here. Work spanned 2026-06-28 → 2026-07-05.

hf-2026-06-28-buildtest-crash-recovery-upload-orphan-fix-and-cn-leak-handoff

Edit handoff

Add dependency

Complete this handoff

Moves it out of every priority list and into ArchiveArea.

Handoff document

Markdown

BuildTest crash-recovery (interrupted-upload "dead orphans") — deploy + CN-leak handoff (2026-06-28)

Author: outgoing driver (Claude). Self-contained: someone new should be able to pick up every open item and drive it to completion from here. Work spanned 2026-06-28 → 2026-07-05.


1. Original request

Two parts, in order:

  1. "Implement both crash-recovery fixes, ensure they both go green." The two fixes address interrupted chunked-upload "dead orphans" — a build whose workspace upload (createUploadSessionuploadChunk* → finalizeUpload) is interrupted by a service restart, leaving a PENDING run with chunks on disk but no assembled archive, that then sits PENDING until the 3-hour max-duration backstop. That violates the crash-recovery contract's requirement of prompt reconciliation (STATE_MANAGEMENT.md#crash-recovery).
  2. Later: "merge, then rebase (pull any changes in main), then build locally and deploy." Then, after the deploy spiraled into a production incident: "proceed with loose ends."

2. Bottom line (status at handoff)

  • Both crash-recovery fixes are implemented, MERGED, and DEPLOYED to production. An interrupted upload now either completes (client retries) or is promptly failed (buildtest watchdog reaps it in ~5 min instead of 3 h) — never stranded PENDING.
  • The deploy triggered / collided with a total ContainerNursery (CN) outage (CN's own JVM GC-death-spiralling — the 128 MB heap leak). Recovered via SSH twice; a bind race on CN's fixed P2P port :35000 was also fixed. All services verified back up.
  • The single most important open item is the CN heap leak (§5). It is the same leak the NetLab workstream handoff (2026-07-05) calls out — an unbounded libp2p/Netty outbound-buffer leak that GC-death-spirals CN roughly hourly and takes down all url:// services. I captured fresh evidence (a live class histogram + a 91 MB heap dump). The fix is a ContainerNursery source change + a risky CN redeploy, which I stopped short of doing pending sign-off.
  • All code changes are merged; nothing is uncommitted. The BuildTestServerService dep bump I had staged became moot (others advanced main past it) and was discarded. Repos /code/kotlin-build-ci, /code/BuildTestEmbedded, /code/BuildTestServerService are all clean.

3. What was delivered (merged + deployed)

Fix A — buildtest-side: watchdog reaps stalled interrupted uploads

BuildTestEmbedded #230 — MERGED (squash 20df733).

  • Adds Check 2.5 to BuildWatchdog: a PENDING run flagged as an in-progress chunked upload (WatchedBuild.uploadActivityAt non-null — chunks on disk, no assembled archive) with no upload activity for MAX_UPLOAD_STALL_MS (5 min) is reconciled to FAILED. uploadActivityAt is refreshed on createUploadSession/uploadChunk and reset to now on restart-recovery, so an actively-uploading session is never reaped however long the upload runs, while a truly abandoned one is failed in minutes not hours.
  • Files: src/buildtest/embedded/BuildWatchdog.kt (Check 2.5, WatchedBuild.uploadActivityAt, MAX_UPLOAD_STALL_MS), src/buildtest/embedded/BuildTestEmbeddedService.kt (uploadLastActivityMs map + getActiveRunsForWatchdog wiring). Test: tests/watchdogReapsStalledInterruptedUploadPromptly.kts.
  • Gotcha fixed during review: uploadLastActivityMs MUST be declared before the init block — loadFromDisk() (called from init) writes to it during construction, so a later declaration is still null → NPE. This regressed 3 existing restart tests; caught by them, fixed by moving the declaration up.
  • Scope note: originally also bundled a recovery-skip OOM fix, but #229 landed an identical .recovery-complete-v1 mechanism independently while this was in flight; #230 was rebased down to the upload-stall reap only.

Fix B — client-side: retry the upload across a restart

kotlin-build-ci #157 — MERGED (25987c5) + DEPLOYED.

  • submitBuildFromFile (src/main/kotlin/org/example/ci/BuildRunner.kt) now re-drives the whole chunk upload + finalize against the same session on a transient failure (default 5 attempts, backoff). createUploadSession is called once so the runId is never re-minted (no duplicate run); buildtest rehydrates the session from its persisted run + on-disk chunks across its own restart, so the re-upload completes. New params uploadMaxAttempts/uploadRetryBaseDelayMs for fast tests.
  • Test: tests/testSubmitBuildFromFileRetriesUploadAcrossTransientFailure.kts (retry recovers; a uploadMaxAttempts=1 sub-case proves the pre-fix failure mode).

Deploy (both live in production)

  • buildtest (url:buildtest: / buildtest-server-service.jar): the reap fix shipped in buildtest-embedded:0.0.204 (I published it — 0.0.203 collided with a stale artifact on the maven server). BuildTestServerService main has since advanced to 0.0.207 (#167/#168, verified to contain both the reap + recovery-skip) and that newer jar is deployed (SHA changed 07-01 05:26).
  • kotlin-build-ci (kotlin.build:443): rebuilt the 222 MB fat jar from latest main 08d8673 (so it also includes #156's resolver 0.0.560→0.0.572 bump), scp'd it (exceeds CN's 200 MB upload-jar limit), SHA-verified on host, backed up the old jar (kotlin-build-ci.jar.bak-predeploy-0.0.54), atomic-swapped, restarted via CN CLI. Verified: new pid stable, orchestrator actively resuming/polling in-flight builds with zero "run not found" errors (a live demonstration that the crash-recovery works end-to-end).

4. Incidents handled during the deploy (context for the next driver)

  1. CN total outage — GC death spiral (twice). All :443 services timed out; CN's own JVM was at ~186% CPU doing back-to-back full GCs (128 MB heap exhausted). Restored via SSH: kill -9 <cnpid> + cd /root/ContainerNursery && setsid nohup java -Xmx128m … -jar bin/container-nursery.jar config.json (relative config, CWD matters; do not raise -Xmx — hides the leak).
  2. :35000 bind race. The first restart raced the old process's socket and CN silently failed to bind its fixed P2P port :35000 — breaking all url:// resolution (buildtest couldn't reach digitalocean-droplets). A second clean restart (once :35000 was free) bound it. Verify with ss -tlnp | grep :35000 after any CN restart.
  3. Footgun: pkill -f "container-nursery-jar-" matches your own SSH shell (its cmdline contains the pattern) and kills the session mid-restart. Reap app containers with pgrep -x java (excluding the CN pid) or explicit PIDs.
  4. Merge-queue was stuck on an unrelated PR whose queue-branch build was dropped while CN was degrading. Unblocked by dequeuing it via the dequeuePullRequest GraphQL mutation. Admin merge cannot bypass the merge queue ("Changes must be made through the merge queue").

5. OPEN — the CN heap leak (highest priority)

This is the root cause of the recurring total outages and the thing most worth doing next. Same issue the NetLab handoff flags; a watchdog now auto-restarts CN roughly hourly as a band-aid.

Fresh evidence captured (2026-07-05):

  • Live class histogram (retained after GC) on a ~1-hour-old CN, dominated by:
    • Unbounded Netty/libp2p outbound write backlog16,670 io.netty.channel.ChannelOutboundBuffer$Entry + 17,323 AbstractChannelHandlerContext$WriteTask + ~25 MB of queued byte[], spread across 172 connections (69 on the P2P relay port :35000). CN queues writes to slow/dead peer connections without backpressure or dead-connection eviction. (Matches the NetLab handoff's "libp2p mplex outbound-buffer leak".)
    • Unbounded in-memory metrics/log retention — CN's own org.example.LogLine (11k), org.example.RouteMetrics (6.7k), org.example.SystemMetricsAggregate (2.7k) accumulate with no cap.
  • Full heap dump: /root/cn-heap-leak.hprof on 198.199.106.165 (91 MB, live dump). Analyze with handoffs/artifacts/hprof_histo.py (already in this repo) or Eclipse MAT dominator tree to nail the exact retention path.
  • Together they fill the 128 MB heap in ~1–10 h → GC death spiral → total outage.

Fix (ContainerNursery source, then republish + redeploy CN):

  1. Backpressure on the P2P relay / HTTP proxy writes: check channel.isWritable() before writing; bound the outbound buffer (high/low water marks); stop/drop when exceeded.
  2. Evict dead/slow connections whose outbound buffer grows without draining (the 69 :35000 relay conns are the prime suspects).
  3. Bound the in-memory metrics/log retention (LogLine/RouteMetrics/SystemMetricsAggregate — cap count / evict oldest).
  4. Publish new ContainerNursery + redeploy CN.

Why I stopped here: redeploying CN is the single riskiest action in this environment — it is exactly what caused the cascading outages above (the :35000 bind race, cold-start thundering herds spiking load to ~38, transient duplicate containers). It warrants explicit sign-off and a careful, low-traffic window. The next driver should coordinate with whoever owns the NetLab CN-leak thread (same root cause) to avoid duplicate work.

Do NOT: raise -Xmx (hides the leak), or treat "the host is overloaded" as the cause (it's a symptom of this leak).


6. Resolved loose ends (no action needed)

  • BuildTestServerService dep bump — MOOT. Main advanced to buildtest-embedded:0.0.207 (contains the reap + recovery-skip) and the newer jar is deployed. My staged local 0.0.204 bump was obsolete and discarded; all repos clean.
  • BuildTestEmbedded #232 (0.0.203→0.0.204 version-collision bump) — CLOSED as obsolete (main moved to 0.0.207; it had gone DIRTY).

7. Uncommitted / unsaved changes

None in the code repos — all changes are merged (#157, #230) or closed/discarded (#232, the BuildTestServerService bump). Verified git status clean on all three working copies.

The only unsaved artifact is the CN heap dump for the leak investigation: /root/cn-heap-leak.hprof on 198.199.106.165 (91 MB, too large to sensibly commit; retrieve via scp -P 23 root@198.199.106.165:/root/cn-heap-leak.hprof .). Analysis tooling already lives at handoffs/artifacts/hprof_histo.py.


8. Relevant PRs (all verified)

| PR | Purpose | Status | |---|---|---| | kotlin-build-ci #157 | Client upload retry across restart | MERGED + DEPLOYED | | BuildTestEmbedded #230 | Watchdog reaps stalled interrupted uploads | MERGED + DEPLOYED (via buildtest-embedded 0.0.204→0.0.207) | | BuildTestEmbedded #232 | Version-collision bump (stopgap) | CLOSED (obsolete) | | BuildTestEmbedded #229 | Recovery-skip OOM fix (landed independently) | MERGED (not mine; #230 rebased onto it) | | BuildTestServerService #168 | Re-point buildtest-embedded → 0.0.207 (both fixes) | MERGED (not mine) | | kotlin-build-ci #156 | Resolver 0.0.572 bump (folded into the deployed jar) | MERGED (not mine) |


9. Concrete next steps

  1. Fix the CN heap leak (§5). Analyze /root/cn-heap-leak.hprof, implement backpressure + dead-conn eviction + bounded metrics in ContainerNursery, republish, and redeploy CN in a low-traffic window with sign-off. Coordinate with the NetLab CN-leak thread (same root cause).
  2. (Optional) Confirm no duplicate buildtest-server-service instances persist — transient duplicates appeared during the CN cold-start churn; later restarts likely cleared them, but pgrep -f buildtest-server-service.jar | wc -l should be 1.
  3. Nothing else outstanding on the crash-recovery workstream — both fixes are live and verified.