Back to list
Skywire Development Update — September 10, 2026

Skywire Development Update — September 10, 2026

Thirty-five merges, and three threads carry almost all of them. The convergence onto a single js/wasm module finished: the desk host, the in-tab visor, the transport visualizer and the wallet cipher all run from one skywire.wasm now, which let the committed wasm-visor blobs, the SharedWorker core, the legacy boot page and two whole commands leave the tree — more than twenty thousand lines and seventeen megabytes of binary. Then a performance sweep driven entirely by profiles of the production hosts: an address resolver spending 65% of its time in the garbage collector to compress hundred-byte answers, a telemetry store holding 2.7 GB of page cache for objects it had already deleted, a transport discovery whose Redis hash reads missed 92% of the time, and a dmsg server emitting 67,000 log lines a minute and formatting each one twice. And the first steps toward folding a deployment dmsg server into the visor beside it under one identity.

Skywire: One Module, Every Role

4759 closed the last two consumers still carrying bundles of their own. The WebGL transport visualizer and the wallet’s cipher now boot from the module that hosts the desk and every command: execwasm gained a Serve that hands out the embedded copy or redirects to /skywire.wasm, and a LoaderJS(role) emitting Go’s wasm_exec.js plus a prelude that pins argv to skywire desk-host --role <role>. The native hypervisor’s wallet gets a cipher for the first time, and the orphaned standalone tpviz frontend is deleted outright. Verified live through hv serve, with the WebGL view ready in four seconds; the module costs 176.6 MB raw and 38.9 MB gzipped.

With nothing pointing at them, 4760 deletes the committed wasm-visor blobs and the apparatus for selecting among them — --variant, /wasm-visor.wasm, hv gen, the headless smoke rig, the TinyGo targets and the CI jobs behind them: 47 files, 3,905 lines and 17 MB of binary. 4761 takes the rest, including cmd/wasm-visor and cmd/dmsg-wasm, so no js-only package remains under cmd/ at all, plus the hv-boot.js page and the SharedWorker core packages: another 106 files and 16,930 lines. 4762 removes the implicit on-disk module fallback and the separate release asset, which existed only for hosts predating the embed, and fixes a deadlock the previous chunk introduced, where the startup line naming the chosen UI root took a mutex its own caller already held and hung every hypervisor without a command module before its HTTP server started.

Skywire: Ninety-Five Resolves a Second

The address resolver on the TPD host ran at about 190% CPU and the profile said almost none of it was work: 65% in the garbage collector, and 110 MB of a 187 MB live heap held in pooled flate writers from chi’s blanket Compress(5). Under the container’s 256 MiB GOMEMLIMIT with GOGC=50 that is a collection five times a second, 166,000 in nine hours. 4765 stops compressing resolve and bind answers, which are under a hundred bytes and gain nothing, and drops the four to six INFO lines per resolve — about 570 a second — to debug. The load was the other half: 95 requests a second from roughly 900 visors, a third of them visors resolving their own public key once a minute per transport type, feeding only the CLI’s self-registration display. 4766 refreshes that cache on demand instead, and has the autoconnect Connector — the source of the rest, re-dialing the same offline public visors every cycle — skip a failed target and transport type for five minutes, doubling per failure to a two-hour ceiling.

The compression finding generalized. Every deployment service compressed everything, so each one-entry answer and health line went through a flate writer holding some 800 KB of state for no wire saving; flate was 15% of dmsg-discovery’s CPU on prod02. 4772 adds httputil.CompressMin, which gzips only a body over a threshold, and switches nine services to it at 1 KiB. 4784 found service discovery, at 18% CPU on prod02, rebuilding its entire router and middleware stack on every incoming request and then discarding it; constructing it once, as every other service does, takes a GET /health from about 46 microseconds and 196 allocations to 15 and 36.

Skywire: Memory Held for Nothing

Every visor and every dmsg server inflated the embedded 30 MB gzipped GeoLite2 database at startup into roughly 60 MB of heap held for the life of the process — 59 MB of a 156 MB in-use heap on dmsg-server-7 — paid in full by fleet visors that never answer a geo query, and on a 1 GB nanode that is a quarter of the memory budget spent on a blob that never changes. 4767 opens it lazily behind a sync.Once, and 4775 inflates it once into a cache file named by the gzip’s CRC and memory-maps that: file-backed, shared between the visor and the server on one host, and invisible to the Go runtime, since the mapped open pins 32 KiB of heap. The same profiles named one more allocation of that size — on dmsg-server-6, io.Copy in the stream bridge allocated a fresh 32 KiB buffer per direction per stream, 120 MB live and 2.3 GB of churn in the sample — so 4768 copies through a pooled buffer instead.

The TPD host visor’s telemetry store was a heavier version of the same problem: 2.9 GB on disk and 2.7 GB of its 3.1 GB resident set. The startup scan reads the whole file and then declined to compact, because the publisher deletes dead objects at runtime so the store looks mostly live — but bbolt never returns freed pages, so the file keeps its high-water mark forever. 4769 rewrites when live and dead objects together occupy under half the file, measured from the scan itself rather than the freelist, which bbolt loads lazily and reports empty right after open; 4776 is what made that run at all, since the scan memoizes “nothing to do at this size” until the file grows 25%, and every existing store carried that memo.

Reading the store cost as much as holding it: every publish nudges the cleanup loop and each sweep walks the whole object store in one write transaction — a full walk every twelve seconds for the telemetry feed, 81% of the visor’s CPU in a twelve-second profile — so 4777 makes a nudge wait twenty times the previous sweep’s own duration, bounding sweeps to about 5% of wall time. On the subscriber side 4771 fixes a watchdog that re-dialed after sixty seconds without a Root, which an idle feed never sends because the publisher’s heartbeat only starts after its first real publish — so an empty pairing feed had every subscriber re-dialing it every ninety seconds forever. Silence-only reconnects now double the tolerated quiet to a fifteen-minute cap.

Skywire: The Transport Discovery’s Redis Bill

Redis on the TPD host sat at 64% CPU and transport-discovery at 108%, nearly all of it syscalls between the two. The metrics publisher SCANned a three-million-key keyspace once per day of its window — 35 passes, about 70 seconds of Redis CPU — then read every transport-by-day hash across that window: 8.4 million per cycle of which 92% missed, measured as 10.6 million HGETALLs in 203 seconds. 4774 builds a per-transport bitmask of the days that actually have a hash from one SCAN, and fetches only those days plus today and yesterday, the only two that can gain a hash after the scan; a cycle drops to about 680,000 hashes plus two probes per transport.

The write side was worse. Every visor republishes its whole transport list with each 45-second CXO heartbeat and both edges publish every transport, so the aggregator handed each live transport to the reconciler about every 20 seconds and it was re-registered every time — roughly 460,000 of Redis’s 640,000 commands a minute. 4778 remembers what was last written per transport and when, re-registering an unchanged entry once a third of the entry TTL has passed. 4770 fixes the publishing end, where a whole day of records — about 130 MB of JSON — was marshalled and then marshalled again per part whenever the gzip did not fit one CXO object, every minute; records stream into the gzip writer one at a time now.

Skywire: Log Lines Nobody Reads

An audit prompted by dmsg-server-6 emitting 67,000 lines a minute found the cost spread across five layers. 4779 starts at the top: every deployment service routed its request log through chi’s middleware.Logger, a stdlib logger writing to stdout rather than logrus, so it has no level and log_level silenced none of it — about 4,100 of dmsg-discovery’s 4,400 lines a minute on prod02, with the same middleware on nine other routers. They log through the service’s own logger now, with everything short of a slow request or a 5xx at debug. 4780 halves the server’s own volume, where seven debug lines per forwarded stream at 8,800 streams a minute made 61,700 of those 67,000 lines, 4% of the process’s CPU in the formatter and 9% of host CPU inside dockerd’s json-file driver. A correctness fix rides along: the close of a failed forward’s yamux stream had been written as the argument of its own log call, so removing that line would have silently stopped reclaiming the stream.

4781 takes three paths that built messages regardless of level or logged a peer’s mistakes as ours: the address resolver formatting string(data) for every inbound SUDPH datagram at the call site, httputil.GetLogger minting a fresh master logger on every context miss — the common case — whose entries then bypassed the process hooks and level entirely, and dmsg discovery logging every 4xx at warn, so any peer could fill the log by re-posting a stale entry. 4782 stops the double formatting underneath all of it, since the /debug/log ring hook and the visor’s log store hook both accepted every level and formatted each entry a second time in full — about 10 MB a minute poured into a 256 KiB ring that retained roughly 1.5 seconds of history in exchange. Both default to info now, so the log viewers return exactly what they did before.

4783 explains why so many processes were verbose at all: LevelFromString fell back to debug for any string it could not parse, including the empty one, so a missing config or a misspelled log_level ran the whole process at debug, which is what the dmsg server did. Both parsers fall back to info now, and each service gets its own level, where previously ar, rf, tpd, tps and stun applied theirs through the package global — so under skywire svc run whichever was constructed last decided the level for all of them. Finally 4786 rewrites the formatter itself, 4% of a production server’s CPU, which reached fmt at least seven times per entry. Appending into the entry’s buffer instead takes a colored three-field line from 3,664 ns and 40 allocations to 1,114 ns and none, with output pinned by 73 renderings captured from the old code.

Skywire: One Key, Two Roles

The goal is a deployment host running a visor and a dmsg server under one identity, and the blockers fell in turn. 4785 removes the second TCP port by making the server a third branch of the cmux already on the visor’s transport port: stcpr is matched positively instead of being the catch-all — exact, since its initiator’s first bytes are the literal handshake message — which frees the catch-all for dmsg, whose sessions open with a noise frame no fixed prefix can match. 4787 removes the registration blocker, where a discovery entry has always carried independent client and server sections but nothing could produce one with both: the server loop found no server section and gave up, the client loop discarded the entry and posted a rejected sequence-zero replacement, and whichever role registered first locked the other out permanently — silently, since only a retry loop ever saw those errors.

4792 makes the mode reachable from skywire.conf at all, where it existed in the visor config with nothing generating it, so setting it by hand survived until the next autoconfig run and then vanished, taking the host’s dmsg server with it. 4793 fixes a trap found while doing this for real: SK and --sk looked like they pinned the visor’s identity and silently did not, because the generator read them and then overwrote the value from the existing config on every regen. Giving a visor its co-resident server’s key ran cleanly and brought it back up under its old identity, with the server answering on the wrong one.

Three changes follow on the relay tier. 4788 adds routing.no_transit, so a visor hosting a dmsg server carries dmsg-over-skynet traffic without becoming a transit hop for other visors’ routes, enforced where the rule would be installed so it needs no cooperation from the route finder or any peer. 4789 raises the relay-slot cap of 256, sized for a desk’s worth of streams: one core dmsg server carries 771 client connections on production today, and a relay fronting a few hundred of those exhausts the cap long before it runs out of anything real.

4790 and 4791 make nomination work unconfigured, since a visor previously nominated only a relay it had pinned as a persistent transport and held a live direct transport to, and almost nobody sets pins. A hub is recognised from discovery instead, as an entry carrying both sections, and needs no direct transport, since the attach dials skynet://<pk>:70 and that resolves over a transport, a one-hop relay or a route. The pin was standing in for a trust judgement it does not need to make, since stream requests are signed by the source and traffic is encrypted end to end; what it really protected is stated directly instead — a visor never nominates its own hypervisors, since that list includes the desk tabs it serves.

4773 closes one more fault between the servers themselves, a peer-mesh flap in which every mutually configured pair reconnected several times a second: prod02’s dmsg-server-7 logged 1,726 started peer sessions, 408 failed announcements and 324 handshake overruns in ten minutes, most of the servers’ CPU and a large share of dmsg-discovery’s lookup load. The accepting side skipped a configured peer’s announcement, so the announce fell through to the stream-request parser, failed and closed the session, which the dialer immediately redialed; and the responder failed the handshake whenever the initiator’s first session frames arrived in the same read as its final handshake message, which a peering server does by design because it announces immediately. Both are fixed, and a session that lived under thirty seconds now backs off before redial.