Back to list
Skywire Development Update — September 7, 2026
Two audits and one relocation account for most of the day. The first went after bytes — a hypervisor node list shipping 8.8 megabytes on a ten-second poll, most of it transport detail nothing rendered, over a deployment whose JSON crossed dmsg uncompressed. The second went after work, surveying every loop a visor runs on a timer and every initializer a browser tab pays before the visor starts, and found self-probes that real inbound traffic already answered, HTTP reconciliation CXO had made redundant, and a 512-keypair pool sized for a server restart storm being filled on a browser’s single thread. The relocation was the desk’s wasm visor, which moved off the page main thread into a Worker and took the renderer from 86.5% of a core to 5.2%. Alongside them, a source review found a setup node logging its own secret key, and the router’s local-route memo turned out never to have hit at all.
Skywire: The Eight-Megabyte Poll
On a live nine-visor deployment the hypervisor’s tree summary returned 8,810,495 bytes, re-fetched every ten seconds by default. 4609 established where those bytes were: 8,670,341 of them were the transports array, 13,597 full records, three million of which were an endpoint sub-object whose address strings restate the two public keys the record already carries. Projecting it to the three fields the node list actually reads took the response to 995,407 bytes, and the same projection at the source keeps the full detail off dmsg entirely.
4608 compressed what was left, on a surface that had no compression middleware at all and is entirely JSON, taking that endpoint to 38,506 bytes. 4612 extends it to five deployment services left out when three others got compression months ago, all of which serve unbounded lists over dmsg, where every byte is relayed — transport discovery’s uptimes went from 110,968 bytes to 43,085 — and 4613 does the same for the transport visualizer, which served raw a three-megabyte snapshot the discovery itself caches pre-gzipped. 4610 takes the idea off HTTP, where CXO propagates object bytes verbatim: a visor’s transport-list leaf at 1,343 transports is 138,821 bytes against 42,488 gzipped, headroom that matters because the 16 MB object ceiling is a whole-feed kill rather than a dropped object.
Skywire: A Secret Key in the Log, and Writes Without a Session
4599 is four one-line fixes from a source review, and the first is the one to fix first: the setup node logged its whole config struct at Info on every startup, and that struct embeds the node’s secret key. The key type’s string method does not mask, so the gentler verb would have leaked it too, and the fix logs named non-secret fields instead. The session cookie’s HTTPOnly flag returned the negation of the TLS setting, so enabling TLS made the cookie readable from JavaScript; skywire-cli edit wrote configs world-readable; and two address-resolver log calls used attacker-controlled text as a format string.
4603 closes a larger hole in the same surface. The transport visualizer’s routes were registered on the hypervisor’s root router rather than inside the group where authorization is applied, so the add-transport and remove-transport endpoints carried no session check at all — and both set a wildcard CORS origin and answered preflight, so any page the operator visited could create or delete transports on their visor with a fetch(). They move into their own authorized group at identical paths, and the mutating handlers now refuse preflight and require a JSON content type, which is what stops a cross-origin simple POST landing its side effect unexamined.
Then the setup node’s statistics endpoint, which 4639 found readable by any visor that could dial the node, its public-key stripping ineffective anyway because failure events embed the same keys verbatim in their error strings. 4643 split it rather than gating the whole thing: rates, reasons and latency percentiles are public, destination rankings and recent failures whitelisted. The case for an ungated health surface is concrete — a production setup node was found failing 100% of route setups, 46 requests and 46 failures, for roughly a month. The same audit aggregated an address-resolver log line emitting 9,475 times a minute in production, all the same not-connected error, into one line a minute with a count and the five worst offenders.
Skywire: Work Nobody Was Asking For
An audit of the visor’s periodic work produced five fixes. 4630 retires a self-probe: the visor dialed its own public key through the dmsg server every sixty seconds to check that inbound streams still reach it. The recovery action it escalates to is worth keeping; dialing yourself to decide whether to take it is not, because a real remote arriving proves the same property over the same path. Arrivals are now stamped on the one accept path every remote-initiated stream funnels through, so a visor with live traffic never self-dials while a visor nobody is contacting still probes.
4632 fixes a busy-wait, a cleanup loop whose done-channel case had an empty body and no return, so from cancellation onward it spun as fast as the scheduler would run it, and 4636 stops a failed client re-initialization storing its nil result over a working network type. 4633 and 4634 then remove two five-minute loops that queried transport discovery over dmsg in opposite directions, one warning about transports it did not reflect and the other deleting entries the visor no longer held. Both are subsumed by the CXO snapshot leaf, which deregisters anything of that visor’s absent from the published list, so absence is deletion and a missed update self-heals on the next publish. 4635 stops two per-second goroutines per CXO container rolling counters into averages nothing can read, since they are reachable only through the node RPC and every in-visor construction leaves that address empty — fourteen goroutines a second per visor, which matters out of proportion on a single-threaded browser visor.
Skywire: What the Profiler Found
4627 came out of profiling the development hypervisor. The cache memoizing secp256k1’s on-curve validation was wired into the text unmarshaler only, so the gob path behind every hypervisor-to-visor RPC response re-validated every occurrence of every key. A thirty-second profile put that beneath the gob decoder at 21% of all visor CPU, since the hypervisor decodes the same thousand fleet keys several times a minute; decoding a 64-key fleet repeatedly went from 14,898 ns to 86.18 ns.
4644 and 4645 come from an audit of package initialization cost, and the larger find is not initialization at all, which is why it survived several rounds of looking at boot. A package-level variable in the noise package starts four goroutines pre-generating 512 keypairs — right for the herd after a deployment restart, wrong in a browser, where generation costs 444 microseconds against 144 native and a tab establishes one to three dmsg sessions rather than hundreds. Filling that pool is about 227 milliseconds of CPU competing with the boot path, while init tracing charges the package 1.17 ms, since init merely starts the goroutines. The companion makes two eager initializers lazy: the embedded 186 KB Mozilla CA bundle, 98.6% of the bytes of the largest init in the binary, and four JSON passes over the deployment config that a code-generated equivalent already replaced for TinyGo alone.
4640 is the day’s largest single find: an unbounded leak of about 2.5 MB a minute, linear with no plateau, reproduced on three visors. Two forced-GC heap profiles ten minutes apart put one decode site at 43.6% rising to 46.8% of in-use heap, with 28 objects ever freed out of ten thousand. That site was misleading; the cause is that the treestore’s subscriber never ran the object sweep its publisher has run for years, so it accumulated every version of every leaf it had ever filled, nothing reaching a zero reference count because nothing deleted the old root. With the sweep running, a test republishing a 2 MB body forty times went from 83.9 MB retained to 2.1.
Skywire: The Memo That Never Hit
A fresh profile of the in-tab wasm visor put local route calculation at 46% cumulative — the only application frame in it — under a wall of garbage-collection and scheduler frames, which on a single-threaded runtime is what heavy allocation looks like from a profiler. It was not a boot cost; bursts of 84 to 93% of a core recurred indefinitely. 4637 found the memo meant to prevent this had never hit, for two independent reasons: it was gated on a CXO snapshot version the cache leaves at zero on its time-to-live path, so on exactly the visor the memo exists for it was disabled outright, and its signature hashed transport IDs in map iteration order, so it changed nearly every call, which resets the memo. The memo now keys on a rebuild generation set on both paths, the signature is order-independent, misses are memoized, and the search moves to parent-linked arena nodes rather than copying a path per graph edge — a repeat dial goes from 3.00 ms to 724 nanoseconds.
4638 explains a live regression in the route setup node from 92% to 71% success, with 77 to 88% of the failures one error raised by the destination visor: a refusal to append a mux leg over a transport already in the group. The initiator enforced that one-leg-per-transport invariant against the forward direction on its own side, while the destination enforces it against the reverse, because the reverse route starts there. The path picker ranks the two directions independently by design, and a reverse with no intermediates contributes nothing to the disjointness exclusions, so nothing could reject it and it is the latency winner — every auxiliary leg therefore paired a correctly disjoint forward with a reverse over the destination’s primary link, a guaranteed refusal paid for with route-ID reservation on every hop. The far-end transport is now tracked and excluded at planning time, while the refusal stays an error, since answering otherwise would make the initiator stripe onto a leg the destination discarded.
Skywire: A Visor Off the Main Thread
Three measurements in a row said the desk’s problem was the Go runtime sharing a thread with the compositor. 4614 found the wasm-served desk booting three full instances of the skywire binary — the visor, a docs server, and a terminal whose entire purpose was to have printed a help screen once — with the renderer at 1.3 GB resident and a forced collect-and-purge returning only 36 MB, because wasm linear memory only grows and every instance’s peak becomes a permanent floor. The visor earns that cost and the other two become opt-in. 4615 fixes the ones you do start: a one-shot version command added 93 MB no collection would return, and deleting one crash-post-mortem registry entry by hand returned 114 MB, because that entry was a closure created inside the async spawn function and the registry lives for the life of the page. 4624 then hands the process layer to bottle, which already owned the other two legs of the composition.
4647 moves the visor into a Worker. Same origin, same identity, same binary but the change; 960 one-second samples of the renderer’s main thread each way gave a mean of 86.5% before, with 797 samples at or above 90%, and 5.2% after, with none. Low CPU is also what a broken visor looks like, so it was verified from outside the browser: a health request through the host visor’s resolving proxy into the tab’s Worker and back returned 200 reporting a js/wasm visor with seven skynet transports, and a transport dialed from host visor 0323272a60895f56aad82cb767fb5c413807adcf7c9fb0578b1b1c5807c7f29d4c established with both ends reporting the same identifier. The page forwards each connection to the worker byte for byte rather than as HTTP, since one of those ports carries gob RPC and another SOCKS5.
The instruments came with it. 4654 times many trivial evaluates on one already-open debugger socket, which the existing one-shot cannot do: on the main-thread desk, evaluating 1+1 took 11.8 seconds and then timed out at 45, while on the worker-hosted desk forty evaluates read a median of 0.8 ms. 4618 made the second browser usable for this work at all, since Waterfox 6.6 dropped the CDP remote agent and speaks WebDriver BiDi only — which mattered when a Brave tab wedged badly enough that CDP never answered.
Skywire: The Desk Learns to Drive the Host Visor
4607 gives the hypervisor’s API a websocket, without a second API: a frame names a method, a path and a body, and the handler replays it through the hypervisor’s own router, so routes, middleware, permission checks and responses are whatever the equivalent HTTP call produces. It sits outside the API group, whose timeout middleware wraps the response writer in something that cannot be hijacked, and the origin check stays on, since an upgrade is not subject to CORS. One trap is worth repeating for anything replaying a request through chi: the router reuses an existing routing context without resetting it, so a sub-request built from the upgrade handler’s own context resolved back to that handler every time — invisible to tests on the standard library mux, and caught by a live visor on the first request.
4642 uses that to give the desk a data path to the host native visor, the enabling piece of the wasm and native UI convergence. The desk’s whole contract is a vnet fetch, so nothing is rewritten: a vnet listener claims the visor’s ports and forwards to the socket, API paths riding it while everything else is one same-origin fetch. With no in-tab wasm visor at all, the Angular dashboard renders fully through the bridge and an about call returns in 5 ms. Around it, 4605 adds the config field that lets a visor-hosted wasm server produce a desk rather than the legacy page, 4604 routes the nine visualizer call sites that still fetched absolute paths through the configurable API base, and 4606 stops the top bar’s tab strip imposing a 1,660-pixel floor on page width.
The nested browser inside that desk rendered nothing while the page inside it loaded perfectly — an iframe 1000 pixels wide and zero tall, its document titled “Skywire Hypervisor” with 29 KB of live, changing body, which is precisely what a collapsed content area looks like. 4648 and 4650 read the cause as the host element’s display mode and put a guard at each of the two call sites. Both were wrong, instructively, and 4652 says so: the nested browser already sets flex display on its host unprompted, so both guards were redundant, which is why the symptom survived two code fixes and two blob rebuilds. The real writer runs later — the desk’s tab machinery sets block display on a pane view on every tab show — so the frame renders on first mount and collapses on every switch after, and the fix gives the browser its own child element to own.
Skywire: Stamps, Budgets and Specifications
The version stamp had been landing on nothing. 4623 found the Makefile passing the linker a symbol in a package flattened away years ago, and a link-time override of an unknown symbol is silently ignored — so every build and every develop-channel publish shipped binaries whose version, date and commit were unset; 4625 fixes the same dead path in the publish workflow. 4631 clears an android lane failing independently of any change, 4.7 MB over an 83,886,080-byte budget, where the fat was two embedded browser wasm-visor blobs that exist so a visor serving the browser page can hand out a visor with no external file — which a phone’s own visor is not. 4611 fixes a quieter loss: flushing the config re-marshaled the typed struct over the file, so any key the running binary has no field for was erased rather than ignored, and flush now re-attaches the subtrees with no counterpart field.
Three documentation changes correct things written down wrong. 4655 fixes five wire formats in the specs that would each yield a non-interoperable implementation, among them the authentication signature’s preimage and the route finder’s field names, which have no JSON tags, so a hop-constrained query silently returns unconstrained routes. 4656 corrects two config fields whose help text describes behaviour that does not exist: a transport limit that reads only its own sign because the counting was deliberately never implemented, and a maximum that is a distinct-peer count pausing registration rather than deregistering. 4646 sketches post-quantum signatures with no code and no wire change, reaching a deliberately different conclusion about urgency than its key-agreement companion: signatures have no harvest-now analogue, so nothing accrues while waiting, and the cost is size — 2,420 bytes against 65, so a visor publishing a thousand transports as one CXO leaf would add roughly 2.4 MB of signatures under a ceiling that has already taken down a feed in production. The decision is therefore scope rather than mechanism: dual-sign long-lived identity assertions, leave the high-volume short-lived structures classical.
The rest is housekeeping. 4601 fixes an intermittent end-to-end failure that was a real bug rather than the usual container flake: a failed discovery entry publish never advanced the last-update stamp, so the backoff timer found the entry not yet due, reset itself to the full interval and dropped the retry silently, leaving a client that lost the cold-start race unresolvable for up to thirty minutes. Around it, a host-side stub so the CodeQL job can build the command tree, an uptime test that counts transactions instead of milliseconds so a loaded Windows runner cannot fail it falsely, and the blob rebuilds that make each desk-host fix live.