Back to list
Skywire Development Update — September 3, 2026

Skywire Development Update — September 3, 2026

Two threads ran through the day, and both were driven by measurement rather than by design. The wasm visor gained a real VPN client, with a gVisor userspace netstack standing in for the TUN device a browser tab cannot have. And a long run of skysocks and router fixes traced transfers that were spending two and three times the wire they needed: a 20MB upload torn down at twenty seconds by a write valve and again at 112 seconds by a keepalive that could not see uploads, a 20MB download putting 33.8MB of duplicate bytes on one leg against 14.7MB of payload. Alongside them the browser visor’s recurring five-hour death was root-caused to leaked peer connections, the resolving proxies gained status pages of their own, and the native hypervisor learned to serve the converged desk.

Skywire: A VPN Client With No TUN Device

4478 puts a gVisor userspace netstack behind the existing TUNDevice seam. The data plane never needed a kernel device, only something that emits one IP packet per Read and accepts one per Write — so in-tab dialers originate connections inside the stack, the unchanged serve loop copies packets to the vpn-server over the route group, and the server does the real clearnet egress; it cannot tell this client from a native one, and no privileges are involved anywhere. The netstack type is pure Go, so the loopback test — a TCP round trip between two stacks pumped through the seam — runs natively in CI, and the native client’s routing-table mutations become successful no-ops because the stack’s only route is the tunnel.

4479 builds the in-tab instance on it, dialing the vpn-server’s route group with the same DialRoutes machinery the skysocks-lite instances use so the packet-level mux and SACK dataplane carry the tunnel, running the standard handshake, and exposing vpnStart, vpnStop, vpnStatus and vpnFetch to the page. 4476 closes the hazard in the other direction: a wasm visor’s skysocks server had been registering itself as a clearnet exit, entering the fleet’s exit pool where every peer that picked it formed a route and then failed each clearnet request — the zombie-exit class the honest-probe machinery exists to weed out, better never entered at all. A per-build capability constant, false under GOOS=js, gates the exit registrations while the mesh-side server keeps serving in-mesh targets.

Skywire: Tunnels Retired While Still Carrying Data

Three defects conspired to tear down healthy skysocks tunnels under load. yamux’s default 10s write timeout fired on healthy sessions, because a bulk upload backs the session send queue up behind the mesh’s actual drain rate and the splice then tears the connection down — reproducible as a reset 19 to 20 seconds into every upload through a mux3 route — so 4472 raises that valve to 60s on both sides, on the grounds that the keepalive loop owns session liveness and a write valve should not be deciding death.

The keepalive loop then turned out to have the same blind spot twice: 4433 found the pong queuing behind a bulk transfer’s own frames on the shared conn, so the pong-only 45-second hard-dead window retired the very tunnel delivering the download, and 4474 found the mirror case, that a saturated upload has no inbound traffic at all and was retired 112 seconds into a 20MB upload with the whole tunnel set rebuilt after. Inbound bytes and accepted writes now both stamp the activity clock; the verdict is unchanged, no pong and no traffic either way, and a dead conn’s buffers fill within seconds so true silence still ages a tunnel out.

4436 handles the aftermath, appending --reconnect whenever config gen enables the skysocks-client’s autostart, since without it the app exits on total tunnel collapse and leaves the SOCKS port dead between restart cycles. Range-split had a matching honesty problem: a follow-up chunk that exhausted its retries simply ended the response at that boundary, observed in soak testing as 50MB downloads “succeeding” with exactly the first 4MiB chunk, so 4443 degrades to a sequential rescue that streams the remaining range through one ranged request, resumed from the delivered offset under a progress-refreshed idle window, and 4467 makes that loop spin-proof against a reader stuck returning zero bytes and no error.

Skywire: The Mux Amplifying Itself

A standby leg showing hundreds of KB/s of inbound traffic was unexplainable, because per-leg receive counters lumped striped payload, the peer’s spurious retransmits and FEC repairs into one number; 4439 decomposes it into duplicate and repair bytes, surfaced through the visor RPC, cli proxy mux info and an overhead cell beside each leg on the status page. With that instrument in place the rest of the day’s mux work is a sequence of measured amplifications. 4438 found the documented frontier-gap age threshold defined and never called, so a frame striped onto a slower leg was retransmitted at 25ms of age and then every fast-leg RTT — 12% duplicate bytes on a three-leg 10MB download, concentrated on the fastest leg.

4470 found the gate still wrong once it existed, because it judged every sequence against the fastest live leg: on a 60ms direct and 300ms multihop pair everything striped onto the slow leg looked stalled one fast RTT into ordinary flight, measured as 30MB of duplicates against 11MB of payload, the fast leg carrying the slow leg’s stripe twice and negating the aggregation outright. Each sequence is now gated on the RTT of the leg it was actually sent on. 4446 narrowed the demote-time flush from the entire in-flight window to the demoted legs’ sequences, and because that fix tagged entries by leg index — and indices shift when the leg slice is compacted after a drop — the fleet picked it up and within minutes legs were carrying 22 to 111 retransmits against near-zero payload with transfers crawling at 14KB/s, which 4452 fixes by tagging with the transport’s stable UUID.

Underneath all of it 4473 replaces the loss detector’s basis: under a saturated leg the queue rather than the wire dominates feedback delay, so a threshold built on the idle ping RTT declared every queued frame lost — 899 spurious retransmits across a roughly 1250-frame 20MB upload, 1.63x wire amplification, even with per-entry backoff. The send-to-ack delay of never-retransmitted frames is now sampled under Karn’s rule into an asymmetric estimator that rises quickly and decays slowly, and genuine loss is still detected at about 1.25x the real feedback delay, the earliest it can honestly be distinguished from queueing.

4447 restores the per-leg hop chains that mux info had been dropping through a mirror struct missing the field, and 4450 adds an operator pin for the unidirectional mux, coordinated on the wire so the two ends cannot desync into sending on the same leg class.

Skywire: The Desk Visor’s Recurring Death

4430 root-caused the browser visor’s death at roughly five hours: every failed WebRTC dial or accept leaked its peer connection, since only the established conn’s Close ever released one, and hours of attrition spent the page’s hard budget until the next construction threw “Cannot create so many PeerConnections” — which syscall/js turns into a Go panic killing the whole instance and every listener in it. Construction is now recover-guarded and both paths close on every non-established exit. 4431 fixes what the crash left behind, since the desk session had recorded the panic as up-then-down, which reads as an operator stop and suppressed the autostart on every later load; only a clean exit counts as a stop now.

The other pathology was a core spin that a CDP profile could not name, because a spinning wasm instance shows only Go scheduler frames, so 4471 gives wasm instances the diagnostic SIGQUIT gives a native process — a dump hook returning the full runtime stack as a string, callable from DevTools or the serve harness. With it, 4475 named the culprit: a persistently unreachable dmsg destination cost every periodic caller the full dial ladder each tick, up to sixteen existing-session stream attempts plus two fresh Noise handshakes, and a wedged path created roughly 12.6 goroutines per second for 44 minutes straight while pegging 92% of a browser core. Dials now fast-fail per destination inside a doubling backoff window from two to sixty seconds, cleared by any success, and a caller whose own context expired records nothing because its short deadline says nothing about the destination.

Skywire: Status Pages for Every Layer

4456 gives the two resolving proxies status pages of their own. Both already intercepted a reserved status host and had a provider wired in, but the snapshot they received was shaped for the skysocks tunnel — an empty route-group table and little else. status.dmsg now shows the dmsg_web layer’s listener, suffix, uptime and request counters, the dmsg client’s established sessions with full public keys, carrier, stream count and last ping, and the configured aliases; status.skynet shows the skynet_web layer, the skysocks-client it chains to, the ports forwarded over skynet and the forwarded conns open right now.

A chain target’s liveness is labeled from runtime or process state the visor already holds rather than from a dial probe, so the claim stays honest and no layer fabricates a leg it does not have. Building them surfaced two more: 4461 bounds each layer collector with a three-second budget against a scratch snapshot merged only on completion, because the pages render through the very proxy they describe and a blocked collector hangs the page forever — status.skynet never answered on the browser visor while the identical native path rendered in fifteen milliseconds — and 4459 fixes an init-order race where the launcher built its app list from the embedded proxy modules without depending on them, so on a native visor with skynet_web enabled the proxy bound nothing and app ls had no row for it.

4465 self-heals a cosmetic version of the same crowding, reloading the desk’s dashboard frame once if the Material icon face is still missing after 25 seconds, since its first-paint asset fetches land on a still-busy visor and fonts, unlike API calls, are never retried.

Skywire: A Hypervisor That Stops Hanging Up, and the Desk Goes Native

The hypervisor holds one persistent RPC conn per remote visor, shared by the background summary poll, hv ls and the sub-hypervisor probes, and it closed that conn on any single per-call deadline expiry — so under a fleet update ripple or the re-registration stampede after a restart, slow replies exceeded the 20-second deadline and half the fleet flapped “connection is shut down”, each poll round re-killing the freshly re-registered conn while remotes on skynet conns take up to ninety seconds to notice and redial. 4454 closes only after three consecutive timeouts with no completed call between them, and guards the summary eviction with a conn identity check so a peer that re-registers mid-poll does not lose its fresh conn; 4445 makes the listing degrade rather than go blank, falling back to the summary cache so a row keeps its last known version, IP and label, marked offline with the error and cache age alongside.

4463 brings the converged desk to the native hypervisor at /desk, sharing the shell factory with the wasm page but booting nothing wasm-shaped — the native visor is the visor and the desk is a shell over it. 4435 mirrors the in-tab visor’s stderr ring to the harness bridge so an operator can read a browser visor’s log with plain curl, leaving the page byte-identical without --harness. 4469 lands an experimental Go/wasm browser alongside netscrape’s JS engine, with its chrome, transcoding and navigation written in Go and only the network delegated — added as a launcher rather than a default, and built without a running visor to test against. 4441 condenses three dependabot lockfile bumps with a full Go dependency sweep including quic-go 0.62.0, webtransport-go 0.13.0 and x/crypto 0.56.0, and 4458 moves the 0magnet modules to current commits.