Back to list
Skywire Development Update — September 8, 2026
Forty-four merges, nearly all serving one direction: there should be one desk, and the native hypervisor should serve it. By the end of the day the hypervisor page had retired its own desk and was serving the wasm desk at its root — a real browser in a window, tabs in the title bar, a terminal on the host visor’s pty, and the Angular dashboard rendering natively inside the nested browser instead of as a bare iframe. Underneath that, a browser tab learned to run a visor of its own attached to the host over a WebSocket back to the page origin, which immediately exposed a router bug that had been truncating every RPC reply larger than one packet. The rest went to making a browser visor cheaper to run, and to the dmsg sessions and streams several layers had been accumulating forever.
Skywire: The Permanent Cost of a Browser Tab
A wasm visor pays for its mistakes differently than a native one. Go’s wasm linear memory only ever grows — the runtime never returns pages to the host — so the heap’s peak is a permanent cost for the life of the tab, however small the live set later becomes. 4659 defaults GOGC to 50 there for that reason. The stock 100 sizes the heap at roughly twice the live set after each collection, and every new peak ratchets it up again: a tab 9.5 hours into its life held 319 MB live against 1370 MB of heap system memory, a ratio of 4.30 — a gigabyte reserved and unused, implying a peak live set near 685 MB it can never give back. A fresh boot at GOGC=50 read 20 MB against 30 MB, a ratio of 1.56 against the 1.5 the formula predicts; fifty rather than lower because GOGC trades CPU for memory, and the tab’s single thread is the scarcer resource.
Two other costs were pure waste. 4657 found a config arriving without log_level falling back to a hardcoded "debug" and running there forever, while every other layer agreed the default was info: over 50 seconds the desk visor emitted 11.6 lines per second, 438 of them DEBUG against three INFO, and afterwards 1.7 lines per second with no DEBUG at all — an 85% cut, the bulk of it the transport manager, autoconnect, WebRTC and stcpr. 4665 removed the WebRTC share at its source: a visor in a plain Web Worker has no peer connection available, so every autoconnect cycle queued a dial to every public visor and every one failed, at 6600-plus log lines. The gate is a runtime capability probe rather than a build tag, since the same binary does have a peer connection on a page main thread. 4666 stopped the server inflating the committed 12,904,560-byte gzipped blob on every page load — 59,431,278 bytes served raw, which the proxy in front then recompressed to 13,839,544. The committed bytes now go out as they are.
Skywire: Sessions and Streams That Only Ever Grew
4660 found the embedded transport-setup and route-setup nodes each running their own dmsg client with MinSessions at zero — a value that does two things, the second of which hurts: it means connect to every server in the deployment, and it also disables the idle-session reaper, which returns immediately at anything at or below zero. On a visor configured for two sessions the main client held six while the embedded transport-setup client held nine, one per server; the configured count had only ever bounded the main client. Both now default to the visor’s own count. Of 25 visors surveyed exactly one ran an embedded setup node; across the same 25, twelve on release v1.3.93 averaged 7.92 sessions and thirteen on develop averaged 5.38, neither reaching the configured two because sessions still carrying streams are correctly not reaped.
4661 exposed the number nobody could see — a streams field on the dmsg server entries in visor state --json, read from the count the reaper itself decides on — and the real fights became visible. 4670 caught the reaper and the redialer working against each other: reaping a surplus idle session ends its serve goroutine, and that exit path noted the server as lost, so the Serve loop’s prefer-the-server-we-just-lost redial dialed it straight back — the same server reaped and redialed inside the same second, every minute, the count never falling. 4671 found the other half in the dmsg-HTTP keep-alive pool, where an idle stream was evicted only on the transport’s next request, so a transport built for one request and then dropped left its stream reserved in the dmsg porter for the life of the process. Eight nodes on v1.3.93 held between 1,433 and 6,449 ephemeral-port dmsg streams and gained ten to twenty-one more each over seventeen minutes, against 47 to 209 on six develop nodes. Leaked streams mark every session busy, which is why those visors never trimmed below seven to ten sessions.
Skywire: A Proxy Exit That Verified Itself
Proxy-exit verification accepted any first byte read back through the skysocks listener. With every tunnel down the local skysocks-client answers a plaintext CONNECT in-process with the branded interstitial, and the probe targets port 80 — so the visor read a page it had just generated for itself, logged the exit as verified end to end, and stayed pinned to a dead exit for hours while its skysocks-client sat in starting. 4664 stamps a header on both synthesised responses and sniffs the two page bodies besides, so a newer verifier still recognises a page from an older client build; the probe now reads a bounded prefix and requires a real 2xx or 3xx status line that is not synthetic. A verified exit is re-probed every thirty minutes — slow on purpose, since the verifier already retries for ninety seconds internally and a route hiccup should not rotate a working exit.
Two skychat fixes correct the same drift between what happened and what was reported. 4680 found the outbound event carrying the network the caller named rather than the one the frame actually left on: the standalone app’s TCP-direct listener hands accepted conns to the cache and replies ride them, so one conversation was seen reporting tcp-direct inbound and skynet outbound for the same peer. 4673 inverts a knob that had made the two hosts differ — a plain send went out as bare text unless the host asked otherwise, and only the wasm visor did, so quoted replies, read ticks and delete-for-everyone worked in a browser tab and not natively. Every message now carries a stable id by default, with a raw-text opt-out that keeps such a send byte-identical on the wire.
Skywire: The Hypervisor Page Grows a Browser
The native hypervisor opened its dashboard as a bare iframe in a plain window while the wasm desk rendered the same UI as a tab in a nested browser, and the obstacle was structural: netscrape, the browser, is Go compiled to wasm and lives in the wasm-visor module, and the native origin served no wasm at all. 4667 serves the module there under a new browser role that installs netscrape and nothing else — the careful part, since the shell role would put two panels and two taskbars on a page that already mounts its own, and no role at all publishes the whole visor API on a page with a native visor behind it. The test invariant was narrowed rather than deleted: native mode must not start a visor, so the boot path stays 404.
Rendering it took two more passes, each a different way of handing a browser an address it could not use. 4669 found the dashboard URL must stay relative — through the vnet service worker the page lives under a prefix the server never sees, so an absolute path escapes onto a different visor entirely — while netscrape treats a scheme-less address as a host; resolving against the current location satisfies both. 4675 found the tab still reading “404 page not found”, because the loader that claims same-origin URLs for native rendering is installed by a path the browser role never runs, and gave the native desk a terminal window on the host visor’s pty besides — an insert anchored on a line that occurs twice in the boot script, which landed at the top of the file and left the page blank until 4677 moved it.
4658 fixed the address the user reads. The tab showed http://127.0.0.1:8443/vnet/8001/?embed=1#/?embed=1, which reads as one address when it is two networks — the leading loopback is the host’s, the port after /vnet/ is the tab’s own virtual loopback, and spelling the latter vnet is what keeps them apart. It now reads http://vnet:8001/?embed=1#/?embed=1 while the iframe renders out of the served origin. 4663 carried two upstream fixes diagnosed here: a tab activation that re-asserted display:block on every show, collapsing netscrape’s flex-column host to zero height and leaving a fully loaded page permanently invisible, and a vnet service worker that asked window clients in sequence, where a client with no responder never replies at all — three such iframes could spend 24 seconds on an 8-second budget while the server behind them answered in 8ms.
Skywire: One Desk
With a browser on the page the rest followed quickly. 4681 hoists netscrape’s tab strip into the desk window’s title bar, level with the window controls, on both the wasm desk and the native page; tabs drag to reorder, and the native terminal becomes a tabbed window where “+” opens another pty session — which 4683 repaired a merge later, when it rendered blank because opening a tabbed window replaced the window body’s absolute geometry. 4684 gave netscrape a network on the native page: without a fetch hook it asked for a same-origin proxy the hypervisor never had, and every foreign URL rendered “404 page not found”. Mesh hosts now route through the hypervisor’s browse API and everything else through its clearnet endpoint, under a persisted proxy setting.
4688 is the merge the rest was building toward. The native hypervisor retires its own desk — the engine-free panel, the browse launcher, the boot script’s native branch — and serves the one wasm desk at its root, booted as a shell over the host visor: no in-tab visor autostart, no help terminal or docs server since both need a command module this port does not serve, the dashboard tab on this origin’s own Angular UI, the terminal on the host’s pty. Verified against the running visor: desk up in about two seconds, with a browser window holding the dashboard rendered natively and the resolver and proxy-status tabs behind it.
Skywire: A Visor in the Tab, Attached to Its Host
4690 opens the next stage. The desk served on the hypervisor port can run a visor of the tab’s own, attached to the host by a single transport — a WebSocket back to the page origin at /tp/ws, addressed by the host public key the page already carries, with no address-resolver lookup and nothing about the tab published. The endpoint hands the WebSocket transport client’s own listener the upgrade, so the socket is exactly what the transport port would have accepted, Noise handshake included — which is why it sits outside the password gate. A new --ws-peer pk@url flag on config gen and autoconfig pins the peer.
Starting that visor took four corrections in the plumbing. 4691 found the console running the boot script’s autoconfig command launching the listed terminal app, which on a hypervisor-served page is the host’s pty — an iframe that cannot run a command at all. 4693 found that autoconfig edits the environment file and then runs config gen, which reads that file into its flag defaults at program start: in a subprocess that happens after the edit, but the browser build re-enters gen in the same process, so it happens first and every flag given to autoconfig reached the environment file and nothing else — the attached desk asked for --disable-public-autoconn --ws-peer … and got neither. Each edit is now passed to gen as a flag as well, with 4700 adding the missing log-level entry and 4697 appending ?loglvl= from the page URL. 4698 made it debuggable: a CLI command inside the tab’s visor could previously only be read by screenshot, because the console renders with WebGL, so a headless exec entry point now runs one command line with no terminal attached and resolves the text back.
4694 fixed two things the attached desk exposed on the host. The route-setup hook counted only stcpr, sudph and dmsg as an existing transport, so while holding a perfectly good WebSocket transport to the tab the router dialed a new stcpr to it, timed out against a tab with no address that answers, and fell back to dmsg; any transport to the peer is a one-hop route, and the hook now counts them all. The browser build had also been starting the stcpr client, which “listened” on a port of the page’s virtual network and published it in the address resolver — a tab advertising 70.121.13.123:11 as reachable, from a runtime with neither TCP nor UDP. Neither stcpr nor sudph starts on wasm now.
Skywire: The RPC Reply That Stalled at Four Kilobytes
Getting hypervisor RPC onto skynet took two more fixes and then uncovered a router bug latent since per-frame noise landed. 4695 found the RPC client only considering skynet at dial time: the host starts redialing the moment the tab unloads, lands on dmsg and stays there, while the transport the new tab opens a second later is never used — a conn served over dmsg now polls for a direct transport and closes once one exists. 4696 found the mirror problem on the accept side: the accept loop identified its peer from the conn’s remote address and handled only dmsg and direct app-dial shapes, so a conn arriving through route setup — taken under a mux or minimum-hop policy — reported a routing address and was rejected as unidentifiable.
With RPC finally on skynet, it stalled. 4701 traced it: a route group’s write put the whole payload into a single data frame, and a routing packet’s payload length is a uint16. Before per-frame noise the stream-noise wrapper chunked every write to 4 KiB so this could never be hit; with per-frame noise the group receives the application’s raw writes, and the RPC layer hands down a gob reply larger than 64 KiB in one call. The oversized frame was rejected after a sequence number had been taken, so the peer’s no-skip reorder buffer waited forever on a hole that would never be filled — a summary reply delivering 4096 plus 97 payload bytes and then nothing, with no reorder gap, three RPC timeouts and a closed conn. Writes are now segmented to what one frame carries, less the sequence number and the authentication tag, and an oversized frame is refused before a sequence is consumed.
The day’s last merge begins the stage after. 4703 lets a dmsg client session carry stream requests for another key — a server had bound every request on a client session to that session’s own key, while a visor forwarding a request on another key’s behalf, which is what dmsg over skynet through a host requires, needs the same allowance peer sessions already have. The request is still signed by and verified against its source key, so a relaying session cannot impersonate anyone; what it spends is the server’s relay capacity, charged to the relay slots like a peer bridge.
4678 rounds the day out: resolving the package graph for the host platform only means modules pulled in solely by the wasm build were never bumped, so the dependency sweep now repeats under wasm, Windows and macOS. Seven blob re-embeds, two lint passes, a skychat guide and one stale module entry in go.sum account for the remaining merges.