← Priority list

Handoff: ContainerNursery :443 CPU meltdown = a fleet-wide 401 dependency-fetch retry storm against kotlin.directory (root-caused via live profiling; fix not yet applied)

Original user request (verbatim intent): *"Determine why https://buildtest.kotlin.build/run?id=392091d0 claims to have taken 29 minutes of wall clock time and 1 minute of cpu time … Perform a comprehensive forensic analysis."*

hf-2026-07-15-cn-tls-handshake-401-retry-storm-kotlin-directory

Edit handoff

Add dependency

Complete this handoff

Moves it out of every priority list and into ArchiveArea.

Handoff document

Markdown

Handoff: ContainerNursery :443 CPU meltdown = a fleet-wide 401 dependency-fetch retry storm against kotlin.directory (root-caused via live profiling; fix not yet applied)

Written: 2026-07-15 ~22:45Z

⚠️ RE-VERIFY BANNER — everything below is a write-time snapshot. The incident, PR states, merge-queue positions, host load, and the 401 rate were all true only when written. Re-verify before acting. Authoritative re-checks:

  • CN box: ssh -p 23 root@198.199.106.165 then uptime + ps -eo pid,pcpu,args --sort=-pcpu | grep container-nursery.jar (SSH key ~/.ssh/id_ed25519).
  • Live 401 storm: on the box, timeout 5 tcpdump -i lo -n -A -s 0 'tcp' | grep -aoE 'HTTP/1\.1 (401|200)' | sort | uniq -c.
  • PRs: gh pr view <n> --repo <owner>/<repo> --json state,mergeStateStatus,mergedAt.

Mission summary

Original user request (verbatim intent): "Determine why https://buildtest.kotlin.build/run?id=392091d0 claims to have taken 29 minutes of wall clock time and 1 minute of cpu time … Perform a comprehensive forensic analysis."

It then expanded, in order:

  1. User fast-tracked UrlResolver PR #776 (believed to fix "the N² in UrlResolver") and asked to watch it to merged, then publish a new resolver, and bump + build + deploy ContainerNursery (and originally the "kotlin.directory maven server" — later dropped, see below). User pre-approved merging those deploy PRs.
  2. User pushed back hard on hand-wavy root-causing and demanded actual profiling, then demanded the true root cause of a ~2,300/sec request storm ("something must be stuck in an infinite loop or exploding").
  3. The investigation root-caused a live production incident (below). No fix has been applied yet. The deploy work (publish 0.0.638 + CN bump) is prepped but not started, and is now understood to be unrelated to the incident.

Two decisions already made with the user:

  • The kotlin.directory maven server (directory.kotlin.www.server) is OUT of scope for the deploy — it has no foundation.url:resolver dependency; user chose "Just CN; maven server needs no change."
  • The CN N²/#776 deploy is fine to land but does not fix this incident (proven below).

What was found and done (the chain) — VERIFIED with evidence unless flagged

1. Run 392091d0 symptom (from the run page)

Build Rules phase = 29m 25s wall / 1m 22s CPU (~4.7% util) = ~95% idle, threads blocked on network I/O resolving deps from kotlin.directory (Connection refused + Unexpected end of file + coursier ResolutionError). The "29 min / 1 min" the user saw is this one phase. Total run 44m. The failing rule buildMaven() only ran 82.8s — so ~28 min was unexplained by the failure itself → pointed at the resolve path, not the rule.

2. Initial hypotheses — CORRECTED, do not trust

I first blamed (a) yamux ByteBufQueue O(n²), (b) PeerRegistry O(N²) (#776), (c) relay JAR-bootstrap volume. Live profiling disproved all three as the CPU driver. Recorded here so the next person doesn't re-walk them.

3. Live profiling of the CN process (198.199.106.165), VERIFIED

  • Box saturated: load 13–15 on 8 cores; container-nursery.jar (PID was 1907532, -Xmx384m) at 498–511% CPU; co-resident maven-server.jar (kotlin.directory backend) at ~86–90%.
  • Hot threads (top -H + /proc/<pid>/task/<tid>/comm) = ForkJoinPool-1 virtual-thread carriers carrying HttpsFacade-handler-* vthreads; ~13.3M vthreads created in 98 min ≈ 2,300/sec.
  • JFR was the decisive instrument (jcmd <pid> JFR.start settings=profile duration=40s filename=/tmp/cn.jfr; then jfr print --events jdk.ExecutionSample). On-CPU profile: 3,884 of 4,224 samples (~92%) in org.example.facade.HttpsFacadeProvider.handleMixedModeTls, and the deep frames are TLS 1.3 handshake cryptoSecureRandom/HashDrbgXECOperations.generatePrivate (X25519 ephemeral keygen) → ServerHello production. GC was secondary (~7%, G1 running ~3.6 concurrent cycles/sec on the tiny 384M heap).

4. ROOT CAUSE — a 401 dependency-fetch retry storm (VERIFIED via tcpdump)

Capturing the plaintext, post-TLS side on the box (tcpdump -i lo -n -A -s 0 'tcp') — CN terminates TLS on :443 and proxies plaintext to backends on loopback:

  • ~5,000 new TCP accepts/sec on :443 (nstat TcpPassiveOpens), ~59k TIME-WAIT sockets — massive connect→handshake→close churn.
  • Backend traffic is overwhelmingly Host: kotlin.directory (~2,800 req/s), from 53+ distinct DigitalOcean droplet IPs (X-Forwarded-For = the buildtest/netlab fleet). (An earlier SNI guess of buildtest.kotlin.build was noise from an unreliable cross-packet grep — corrected. Build droplets talking to kotlin.directory for deps is architecturally correct; the 401s are the problem.)
  • ~85–91% of responses are HTTP 401 Unauthorized (WWW-Authenticate: Basic realm="Authentication Required"), e.g. 7,403× 401 vs 1,324× 200 in 5s at write time. Requests carry NO Authorization header.
  • The 401'd artifacts are ordinary public transitive POMs (okio-jvm, slf4j-parent, ow2 asm, commons-parent, kotlin-stdlib…), and the same ones repeat — not diverse resolution.
  • Per-droplet: ~47 req/s sustained, 195 distinct artifacts in 15s (busiest droplet 705 reqs) → each droplet is cold-resolving its whole classpath with no local cache, each artifact fetched 2–8×.

Mechanism (verified): fleet droplets cold-resolve their full classpath from kotlin.directory (no pre-warmed cache) → every fetch returns 401 → the resolve never completes → it is re-driven in a loop → each attempt opens a fresh TLS connection (no keep-alive) → ~5,000 TLS handshakes/sec → CN burns 5–6 cores on X25519 ephemeral-key crypto → starves the co-resident maven server and every legitimate HTTPS resolve on the box → the original 29-min idle build. Self-amplifying: a 401 returns instantly (no network wait, no backoff), so it hammers far harder than a timeout would.

5. Coursier is NOT the retry source (VERIFIED against coursier source)

Checked coursier ArtifactError.scala: coursier already maps 401 → a dedicated terminal ArtifactError.Unauthorized (with realm), distinct from the Recoverable set (Locked, ConcurrentDownload) and from InternalServerError/RetryableHttpError. FileCache.withRetry(n) only retries Recoverable errors. So coursier does not retry a 401 — no fork or error-mapping hook is needed. This corrects an earlier wrong claim ("coursier retries the 401"). The re-drive loop is ABOVE coursier — in kompile's unifiedResolve and/or the buildtest droplet build lifecycle re-invoking resolution on a terminal failure. (HYPOTHESIS — the exact re-drive site is not yet located; see Next steps #3.)

6. Where the 401 originates — KEY OPEN QUESTION (partially verified)

The kotlin.directory backend maven-server.jar --maven-repository /root/maven (PID was 1907696) runs with no auth flags on its cmdline, yet returns 401. grep of /root/ContainerNursery/config.json hits auth/credential strings. So the Basic-auth requirement is configured at the CN route/config level (or a maven-server config file), not the maven-server CLI. Whether requiring auth on public artifact GETs is intended (a regression?) is UNVERIFIED and is the crux of the acute fix. There is also a second maven-server instance (was PID 2074990) with --certificate/--key for maven.directory — a different domain; don't confuse the two.

7. #776 / N² relevance (VERIFIED)

The CPU is ~92% TLS-handshake crypto driven by the 401 storm — unrelated to PeerRegistry/yamux N². #776 is safe to land but will not fix this incident. The yamux O(n²) fix (jvm-libp2p #23 → snapshot-12) is already in CN main via ContainerNursery #522 and pinned directly at build.kts:279 (jvm-libp2p:1.3.0-codexcoder21-snapshot-12).


Relevant PRs / refs (write-time state)

| Ref | State @ write | Notes | |---|---|---| | UrlResolver #776 | OPEN / CLEAN, in merge queue ~48min, queue:bld-build pending, no buildtest run URL yet | PeerRegistry O(N²) fix; bumps foundation.url:protocol → 0.0.360. Merge may itself be blocked by this incident (buildtest can't run while kotlin.directory 401s). Head branch fix/peerregistry-serviceindex-pin-0.0.360, head SHA 2818468cbc612975b96502e9a0e27670ab99a72d. Watched by build-watchman (see below). | | jvm-libp2p #23 | MERGED 2026-07-12 | yamux ByteBufQueue O(n²) → ArrayDeque. Released as jvm-libp2p:1.3.0-codexcoder21-snapshot-12. | | ContainerNursery #522 | MERGED 2026-07-12 | Bumped CN to jvm-libp2p:snapshot-12. Already in CN main. | | ContainerNursery #524 / #494 / #504 | MERGED (recent) | HTTPS-facade fixes (HEAD parks, handler thread exhaustion, selective hang) — HttpsFacadeProvider is bug-prone + under active work. Relevant to the facade/keep-alive fix. | | latest published foundation.url:resolver | 0.0.637 (compiled vs protocol:0.0.337) | The N² fix is NOT in any published resolver yet; publishing post-#776-merge main HEAD would be 0.0.638 (first with protocol:0.0.360). |

No code changes were made in this session. ~/workspace/ContainerNursery is a clean clone (no edits). Deploy is fully prepped but not started.


Next steps (ordered) — RE-VERIFY LIVE STATE FIRST

  1. Re-verify the incident is still live (banner commands). If load is normal and 401 rate is low, the storm may have subsided (fleet drained / someone fixed auth) — re-scope before acting.

  2. ACUTE — stop the 401 storm (this is what's melting the box; prioritize over the #776 deploy). Find where kotlin.directory's Basic-auth requirement is configured:

    • Read /root/ContainerNursery/config.json for the kotlin.directory route's auth/credential block, and/or any maven-server auth config file. Decide intended vs regression — these are public artifacts every build must fetch, so requiring auth on their GETs is almost certainly wrong.
    • If unintended: remove/disable auth on kotlin.directory artifact GETs (auth belongs on uploads/PUT, not reads). Redeploy the route/maven-server. This unblocks the whole fleet at once. Deploy via HardwareControlFabric / container-nursery skills (NOT ad-hoc SSH edits); verify by re-running the 401-rate tcpdump.
    • If auth IS intended: the droplet provisioning must inject kotlin.directory coursier credentials — they're currently absent (no Authorization header on the wire). Fix in droplet image / build config.
  3. Find + fix the re-drive loop (owns the "don't retry a terminal 401" behavior). Trace kompile-core's unifiedResolve and the buildtest droplet build lifecycle (BuildTestEmbedded / community.kotlin.kompile.testrunner.bootstrap) to find what re-invokes resolution after coursier returns terminal ArtifactError.Unauthorized. Make it fail fast on Unauthorized instead of re-resolving in a loop. (The re-drive site is a hypothesis — locate it before coding. Reproduce against a 401'ing repo per the bug-fixing workflow.) Coursier itself needs NO change (401 already terminal).

  4. CHRONIC — kill the cold-resolve volume (user's insight). Give droplets a pre-warmed / shared dependency cache (bake the common classpath into the droplet image, or a shared read cache) so a fresh droplet doesn't cold-resolve ~195 public artifacts from kotlin.directory every run. This makes the system resilient to any future kotlin.directory blip.

  5. HARDENING. CN facade should support HTTP keep-alive (so N fetches ≠ N TLS handshakes — see HttpsFacadeProvider.kt, note the Connection: close in rejectAndCloseHttpRequest). Consider a per-client rate-limit / circuit-breaker on :443 as a storm backstop (user suggested ~1 req/10s/droplet — good as a breaker, NOT a cure).

  6. Resume the (separate, pre-approved) deploy once #776 is mergeable. build-watchman is tracking #776 (see below). Then: publish foundation.url:resolver 0.0.638 from post-merge main HEAD (publish-maven-artifact skill), then bump ContainerNursery (~/workspace/ContainerNursery, or re-clone):

    • build.kts: foundation.url:protocol 0.0.336 → 0.0.360 (line ~167), foundation.url:resolver 0.0.600 → 0.0.638 (line ~244).
    • tests/testFoundationUrlResolverProtocolBinaryCompatibility.kts: update its 4 pinned version strings (the two @file:WithArtifact pins + expectedResolverVersion/expectedProtocolVersion) — its own comment says pins MUST track build.kts.
    • The two 128MB churn stress tests (stressTestServerSurvivesMidNegotiationChurnDrop…, …SustainedSubstreamChurn…) pin resolver:0.0.595 / protocol:0.0.336 — bump so the shipping pair is stress-validated.
    • Build fatjar locally → deploy to CN prod → merge the bump PR (pre-approved). Remember: this does NOT fix the incident.

Reusable / operational knowledge

  • CN prod host: ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_ed25519 -p 23 root@198.199.106.165 (fabric daemon has been down before; SSH is the reliable fallback). JDK 21 (jcmd/jstack/jfr at /usr/bin). ~/workspace is writable, not /code/workspace.
  • Profiling recipe that worked (definitive, low-overhead):
    • Hot threads: top -H -b -n1 -p <pid> → map TID→name via cat /proc/<pid>/task/<tid>/comm (GC/carrier threads DON'T show in jstack; comm never lies).
    • JFR: jcmd <pid> JFR.start name=cn settings=profile duration=40s filename=/tmp/cn.jfr; then jfr summary /tmp/cn.jfr and jfr print --events jdk.ExecutionSample --stack-depth 60 /tmp/cn.jfr | grep 'org.example\|foundation' | ... | sort | uniq -c.
    • Post-TLS plaintext: tcpdump -i lo -n -A -s 0 'tcp' on the box shows CN→backend HTTP after TLS termination — Host:, X-Forwarded-For: (true client), request paths, and response codes (HTTP/1.1 401). This is how the 401 storm was proven.
    • New-conn rate: nstat -z; sleep 4; nstat | grep TcpPassiveOpens. Churn: ss -tan '( sport = :443 )' | awk 'NR>1{print $1}' | sort | uniq -c (huge TIME-WAIT = connect/close churn).
  • Key PIDs/processes (will change on restart): CN = bin/container-nursery.jar; kotlin.directory backend = maven-server.jar --maven-repository /root/maven (no cert); maven.directory backend = second maven-server.jar with --certificate/--key. CN log: /root/ContainerNursery/container-nursery.log; container backends' stdout under /root/ContainerNursery/container-logs/.
  • Coursier fact: ArtifactError.Unauthorized (401), Forbidden (403), NotFound (404) are terminal; .withRetry(n) only retries Recoverable. So a 401 storm is never coursier's own retry — look upstream (kompile/build orchestration).
  • build-watchman tracking #776 is running as a background job in this session (--to-merged, launched via coursier launch buildwatchman:build-watchman:0.0.12 -r https://kotlin.directory -- --repo CodexCoder21Organization/UrlResolver --pr 776 --to-merged). It EXITS at merge or failure and does not re-arm itself — relaunch it in the next session (it is not durable across sessions).
  • Related memory / prior incidents (same box, adjacent mechanisms): cn-tls-slowness-relay-cpu-starvation-2026-07-12, buildtest-slow-concurrency-coursier-resolve-on-cn-relay-starvation-2026-07-12, maven-central-429-mirror-seeding-remedy. Those blamed relay CPU / coursier resolve starvation; this handoff supersedes the CPU-attribution: the dominant driver is the 401 TLS-handshake storm, proven by JFR + tcpdump.