Back to list
Jul 19 2026

Development Update — July 19

Today’s work was all plumbing, down in the layer where dmsg carries HTTP and CXO fans roots out to subscribers. A production profile of dmsg-discovery told the story that started it: the service was sitting at ~84% CPU with garbage collection burning most of it, and nearly three-quarters of every allocation traced to noise handshakes — because the net/http-free HTTP client dialed a brand-new dmsg stream on every single call and told the server to close it. That got fixed. So did a stall on the CXO broadcast path where a well-meant optimization had quietly serialized every publisher behind one encode, a build-breaking piece of dead code that kept the darwin and windows CI jobs permanently red, and a flaky test that had been miscounting an async register.

Skywire: A Keep-Alive Pool for the dmsg HTTP Path

3515 fix(dmsgclient): keep-alive stream pool for the net/http-free HTTP path addresses the root of dmsg-discovery’s runaway GC. Production profiles taken over dmsgpty showed the service pinned at ~84% CPU with runtime.scanObject accounting for 57% of it, ~1 TB allocated over 21 hours of uptime, and 73% of all allocation landing under ClientSession.handshakeResponder — the noise handshake, made substantially heavier by the ML-KEM768 post-quantum hybrid. The cause was that the net/http-free HTTP client sent Connection: close and dialed a fresh dmsg stream per request, so every periodic call — TPD registration twice per 90 seconds per visor, discovery re-POSTs, dial-path lookups — forced the server through a full noise + PQ handshake. The net/http twin had been fixed exactly this way back in #3097, which also raised the server’s IdleTimeout to 115s so a client-side pool could span refreshes; the net/http-free version never got the same treatment and kept forcing teardowns against an already-fixed server. This PR sends Connection: keep-alive and adds a per-dmsg.Client, per-destination idle stream pool with a 90s cap safely below the server’s 115s. The bufio.Reader travels with the pooled stream — a kept-alive stream may already hold bytes pulled off the wire, and pairing it with a fresh reader would silently drop them. Reuse only happens when the response is explicitly framed (Content-Length or chunked) and the peer didn’t ask to close; a failure on a reused stream retries once on a freshly-dialed one that never draws from the pool, which is what makes keep-alive safe against peers that close early. It stays net/http-free, so the TinyGo / js-wasm build is intact, and a server that closes anyway — including pre-v1.3.80 peers with shorter idle timeouts — just costs one retry.

Skywire: Unstalling the CXO Broadcast Path

3516 fix(cxo): unstall the nodeFeeds broadcast path (encode off the event loop + conn closeq guard) undoes a regression from #3514 and hardens against a related leak. #3514 correctly hoisted the root encode out of the per-conn goroutines so a multi-MB body is built once and shared — the right call for total CPU and RSS — but it ran that encode inline in nodeFeed.broadcastRoot, which executes on the single nodeFeeds event loop that is the sole drainer of the unbuffered brorq channel. So while the loop encoded, every producer blocked in broadcastRoot stayed blocked: N parallel encodes off the critical path had become one encode on it. A production goroutine dump on transport-discovery showed 87 goroutines parked on that send with the head-filler pipeline backed up behind them. The fix keeps the encode-once win but drops the serialization — it snapshots the subscriber set on the event loop (cheap, and n.cs is actor-owned so it can only be read there), then does both the encode and the fan-out in a goroutine, which is safe because nextSeq is atomic. The second half guards broadcastRoot’s park on the originating connection’s closeq as well as the node’s, matching what receivedRoot already did; the connection channel is nil for locally-published roots, so a nil channel simply blocks forever in the select and degrades to the old two-way behavior rather than firing spuriously.

Skywire: Two CI Unblockers

3517 fix(vpn): move prefixLen to router_linux.go (unblocks darwin/windows CI) clears a piece of dead code that had been failing the darwin and windows jobs on every PR against develop. RouterConfig.prefixLen was declared in router.go, which builds on every platform, but its only callers live in the linux-only router_linux.go — so on darwin and windows it was genuinely unused and the unused linter failed the build. Those two checks had been permanently red across unrelated PRs, which meant they no longer signaled anything and every merge had to walk past them. Relocating the method next to its callers, with no behavior change, makes all three platforms build and lint clean again. 3518 test(appdisc): fix PauseResume race against the async initial register fixes TestServiceUpdater_PauseResume, which had been failing reproducibly on unmodified develop. #3511 correctly moved the initial client.Register into a goroutine — a synchronous register retries with backoff and, on the visor init path, had been blocking init for minutes and stalling the hypervisor — but that made the initial POST land at an arbitrary moment. The test sampled its counter immediately after Pause(), so when the initial POST arrived during the 60ms paused window it was miscounted as a heartbeat. The heartbeat loop does honor pause, which is the property the test means to verify; the fix simply waits for the initial registration to land before sampling, pinning down the starting count without changing any production behavior.