Back to list
Skywire Development Update — October 7, 2026

Skywire Development Update — October 7, 2026

The seventh of October went after cost on the packet path, much of it driven by the TinyGo build, where every goroutine is an operating system thread and every allocation weighs on a slower collector. Over a dozen changes removed the goroutines, timers and closures created per packet, batched KCP’s scheduler wakeups, and finally let sudph and WebRTC transports be read by a small shared pool of workers instead of one blocked goroutine each. Route setup changed shape too: two visors with a transport between them now set up a one-hop route themselves without a setup node, visors began serving their own signed transport lists to each other, and route calculation can now build three-hop routes from those lists. Transport discovery stopped publishing the whole transport graph, which had been most of its outbound traffic, after the rewards bandwidth days were found to have been saved empty all week. The status pages learned to show what each service’s own process and traffic cost, and a set of browser-tab leaks in dmsg and WebRTC were found with a new census of the streams still in memory.

Skywire: No Goroutine, Timer or Closure Per Packet

5464 found that every routed write started two goroutines and every received packet armed a 30-second timer, so a TinyGo visor created about thirty threads a second. A route group write with no deadline now runs on the caller’s goroutine, which was already waiting for the result; a transport write whose context can end goes to one writer goroutine per transport that exits after five idle seconds; receive paths try a plain send before arming a timer. The mux rig smoke was not run, since scheduling and leg picking are unchanged. 5467 moved kcp-go to a version without closures on its flush path, which TinyGo heap-allocates along with everything they capture; a TinyGo visor had spent about 3.5 GB there in fifty minutes.

5471 stopped every dialed sudph session running its own read goroutine and every KCP session its own post-processing goroutine, which on a TinyGo test visor had been 344 plus 96 goroutines, by running post-processing on a small worker pool and adding a push mode to a new 0magnet fork of the packet filter. 5473 extended that to the sudph listener and went after the timer scheduler: 94 percent of that visor’s CPU sat in ten threads, each KCP update passing through a feeder goroutine, an unbuffered channel and a timer reset. Updates now go straight onto locked shard heaps, batched within a millisecond, and a probe of 344 sessions updating every 100 ms went from 9.5 to 1.9 percent of a core under TinyGo. 5477 let a task run up to five milliseconds early, half of KCP’s shortest update interval, so one wakeup serves several sessions, taking the same probe from 3.6 to 1.2 percent. 5474 fixed a race on the packet filter’s reused batch slices that a test mock printing the filter exposed, 5475 replaced its sync.Pool buffers with a channel free list, cutting one packet’s receive from 29 allocations to 13 in its benchmark, and 5476 made a fake data channel in the WebRTC conn tests block until closed, as a real one does, so its read pump no longer raced the test.

5487 removed the per-packet allocations from framing. Every noise frame had been copied out of the read buffer and decrypted into a new slice, writes were copied twice, and each noise write first wrote an empty buffer to the connection, which over KCP ran a full flush per frame and over TCP a zero-length write syscall. Frames now decrypt in place and encrypt into a reused frame, taking a 1 KiB frame benchmark from 17.6 microseconds, 4,576 bytes and eight allocations to 9.6 microseconds, 81 bytes and four, with the wire format unchanged; both a TinyGo and a native test visor ran it against the live network. 5496 moved to a 0magnet fork of pion WebRTC that starts its media goroutines only once media is negotiated, and a DTLS fork that cancels reads and writes with context.AfterFunc rather than a goroutine per record, where each WebRTC peer had been running about twenty-one goroutines.

5502 then served sudph and WebRTC reads from shared workers under TinyGo. Each such transport registers with its connection instead of blocking a goroutine on it; KCP sessions and data channels report when data is ready, noise decrypts frames pushed to it in any size, and a worker runs one transport at a time so its packets keep their order. When a transport’s read channel is full the worker stops reading it and leaves the data in the connection, so flow control slows the sender. On the TinyGo test visor 272 transports were served this way, running 650 goroutines for 289 transports against 1,064 for 412 on the native one. Other builds keep the read goroutine.

Skywire: Routes Without a Setup Node, and Routes From Neighbors’ Lists

5465 let two visors with a transport between them set up a one-hop route on their own. About half the setups the setup nodes handle are one hop and need no coordination, yet every dial reaching route setup sent its one-hop route to a setup node, and the earlier app shortcut over an existing transport never covered tunnels, standby and mux legs, datagram routes or pings. A one-hop route is now built with the peer directly, the way a setup node would build it, and the peer serves this only to a visor it shares a transport with, only for a route ending at itself over that transport, and never installs transit rules for it. A peer that predates this is sent to a setup node for thirty minutes. It was tested in process between two routers, not yet between two live visors.

5488 had each visor answer GET /transports on its API with its own live transports signed by its key, served only to requests arriving over a skywire transport, so any visor can read any other’s list while a bare dmsg client gets 403, with an ETag so a re-check costs a few bytes. 5489 used those lists: when the two-hop oracle has no answer, route calculation reads the lists of up to 32 first-hop neighbors and the destination, checks every one against its owner’s key, and builds a three-hop route from them, capped at five seconds before falling through to the route finder. Live, no neighbor served lists yet, so both attempts fell through as expected.

5498 sent each phase’s forward and reverse cascades together, cutting a source-driven cascade from six sequential round trips to four; on a browser visor whose first hops carry 200 to 600 ms of latency, cascades went from 7 to 16 seconds down to between 1.3 and 7. 5493 found 30 of 70 cascades failing on that visor as its dmsg sessions were replaced and their streams closed under the setup node RPC; convergence now leaves a session with open streams for a later tick, and a cascade whose setup node connection closed returns its error so the retry opens a fresh one. 5494 made concurrent dials for the same peer and transport type wait for the one already in flight, after five WebRTC data channels to an exit were opened at once and all but one discarded. 5495 made crowded two-hop oracle dials rotate through the candidates instead of all taking the best intermediate, 5499 stopped closing a route group the peer never answered from waiting two seconds for a reply that would never come, and 5490 counted cascade and legacy route setups per path in visor state. 5491 made the CLI’s gRPC client work inside a browser tab, where it had resolved localhost over DNS and dialed Go’s js network stub; a bandwidth test from a tab now pumped 770 KiB over a three-hop route.

Skywire: Transport Discovery’s Empty Days and Its Largest Feed

5462 found the per-visor bandwidth that the second rewards pool pays from empty for every settled day from September 30 to October 6, with each saved day a gzipped empty list while the raw rows were all still in Redis. The day index scan ran on the context of whichever request first needed it, a cancelled scan’s partial index was cached for everyone, and the publisher saved those days empty and reused them on every start. The scan now runs to the end, a failed scan is an error, and an empty day is never saved. With that deployed the rebuild still found no transports, and 5466 found the reads behind it queued every lookup in one pipeline and ignored the pipeline’s error; they now run in batches of five thousand and a failed batch is retried. The PR notes this could not be reproduced locally and is not confirmed as the cause, though it makes the cause visible either way.

5468 fixed the online rule counting only stcpr and sudph transports, which showed 24 visors offline that held two or more of other types; every type but dmsg now counts, from the live set. 5481 did the same for transport uptime, which had never been recorded for squicr, swtr, swsr and WebRTC transports, about a fifth of the network. 5485 stopped transport discovery publishing the all-transports feed and serving the full list over HTTP, which now answers 410. Together they had been 88 percent of its outbound traffic, about 1.87 GiB per five minutes, and visors since v1.3.98 already route on the routing feed of changed shards. 5497 updated a resolver home page test that still expected that endpoint.

Skywire: Status Pages Show What Serving Costs

5469 let a dmsg client count streams and bytes per listening port, turned on for services but not visors, and gave every service’s status page charts of its own process (CPU, garbage collection, memory, goroutines, open files), its traffic per listener named for the HTTP API and each CXO feed, and its HTTP requests by route with median and 95th percentile time. 5472 spaced that test’s two samples, since Windows’ coarse clock read them as no time apart. 5478 had dmsg servers count the streams they relay and the bytes they carry, report them on /health from both a standalone server and one folded into a visor, and the dmsg discovery page chart them per server. 5470 replaced the route finder’s single mean answer time with median and 95th percentile route search time, 5461 and 5492 steadied and merged the pie legends so a chart’s height no longer moves as the cursor does, and 5479 and 5480 added a table of every visor’s transports by type to transport discovery’s status page. 5501 fixed the scheduled deployment health check, failing since the discovery hostnames began serving only the status page, by checking the dmsg servers built into the binary.

Skywire: Leaks in the Browser Visor and a Folded Server

5482 found a dmsg dial that failed after its stream opened leaving the stream open and its port reserved, because the failure handler closed a named return value that every error path had already set to nil; a long-running browser visor held 1,048 reserved ports against about 37 live streams. It was found with 5483, which keeps a weak pointer to every dmsg stream with its port and state so cli dmsg diag porter can show what is still in memory, and 5486 and 5500 fixed a race and two flaky tests in that census. 5484 found the browser visor’s WebRTC conn never releasing its JS event handlers, which kept every signaling stream it accepted in memory; after 41 hours a tab held about ten thousand of them, around 100 MB, and now closed signaling streams stay at two or fewer.

5463 fixed a visor running the dmsg server in process connecting to every other server twice under one key, once as a client and once as a peer link, with the other server filing both under that key so each replaced and closed the other. On one folded exit the peer links reconnected every few seconds and the setup node’s dials to the visor failed. A session from such a key is now classified by its first stream, and older servers that do not announce keep working.