Skip to content

Anyone network in the browser, a standards-based engine, and a fast network path - #589

Open
eKisNonos wants to merge 100 commits into
mainfrom
feat/anyone-browser
Open

eKisNonos wants to merge 100 commits into
mainfrom
feat/anyone-browser

Conversation

@eKisNonos

@eKisNonos eKisNonos commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This branch brings the Anyone network into nonos_browser next to Direct and Nym, rebuilds the browser engine on the web standards, and makes the network path under it fast. It also carries the kernel fixes the browser needed to run reliably.

Private networks

  • The settings panel offers Direct, Nym mixnet and Anyone network. The address bar always shows which network the next request uses.
  • A network that is chosen but not running refuses the request by name ("nothing was sent"). It never falls back to Direct.
  • net.anon (Anyone): directory fetches and circuit builds are resumable, so the serve loop never blocks. Relay cells are scoped to their circuit, stream SENDMEs are credited correctly, and the relay's fixed-length NETINFO is accepted.
  • net.socks5 resends a numbered answer whose reply was lost, so a slow exit can no longer drop part of a response.

Browser engine

  • HTML: a WHATWG tokenizer and tree builder. html5lib tree construction 1,588 of 1,592 (was 833), tokenizer 6,972 of 6,972, and 1.25 million fuzz inputs with no panic. Depth is capped at 256.
  • CSS selectors: Selectors Level 4 with bounded matching. Feature probes that disagree with Chromium: 132 to 2. A 2 MB hostile sheet: 7.9 s to 0.25 s.
  • HTTP and encodings: responses framed by RFC 9112, every WHATWG text encoding decoded.
  • Images: streamed PNG, GIF and BMP decoding, with paint clipped to the viewport.
  • Text:
    • glyph cache with integer blending;
    • text drawn at its true em size;
    • unicode-range subsets choose the Latin face;
    • any glyph a page font lacks falls back to the built-in face.
  • Layout:
    • starts from the root box;
    • calc, min, max and clamp work;
    • flex and grid lay out item by item, with real inline layout;
    • a large page's layout memory is bounded.
  • Fetching: several fetches in parallel with keep-alive. The TLS verdict is settled in one pass. A bad certificate chain is refused in under 5 s instead of timing out.

Network path and kernel

  • Reads: 32 KiB per read with a 64 KiB TCP window, end to end. The NIC hands over every waiting frame in one batch per poll.
  • Scheduling: NIC drivers and net.core run in the High band. A woken High process preempts at the next tick, but only when the tick interrupted user code.
  • net.sockets: answers stream connects when the handshake resolves, instead of holding its loop.
  • Process table: writes are done with interrupts off.
  • Foreign guests: they end when their supervisor exits, instead of spinning a CPU.

Measurements

All live measurements are QEMU with 4 CPUs under full emulation (TCG), except where a line says "host".

  • The 92 KB nonos.software page arrives in 0.14 s, about 620 KB/s (was 2.8 KB/s).
  • The whole page with its 39 resources loads in 15.2 s (was 44.5 s).
  • A 695 KB, 11,236-node page peaks at 41 MiB in layout (was 54 MiB, over the old 48 MiB heap). The browser heap is now 96 MiB.
  • Host: the browser proof crate passes 254 tests. The repository gates report 0 new stubs, allows, dark features, unreachable sites or syscall ABI gaps.

Known open items

  • One boot in seven stopped on a TLB shootdown timeout while the host was overloaded. It is not settled whether the 50 ms budget is too tight or an IPI was lost.
  • CSS mask-image icons draw as empty boxes, and WOFF2 fonts are not loaded yet (WOFF and TTF are).
  • Some files touched here are still longer than 75 lines, and one #[allow] remains in css/walk.rs. A cleanup follows.
  • Unused-import warnings in the fetch proof helpers are still to be fixed.
  • Anyone and Nym were not proven live end to end through their networks. The refusals and the routing were proven live.

Testing

  • cargo test --release in userland/capsule_browser_proofs: 254 passed.
  • Also run: capsule_browser_html_proofs, image_paint_proofs, tls_proofs, anon_ntor_proofs and capsule_socks5_proofs.
  • scripts/check_stubs.py, check_allows.py, check_dark_features.py, check_unreachable.py, check_syscall_abi.py: 0 new.
  • Built with make nonos-mk-smp-prod and make NONOS_DEV=1 nonos-mk-esp, booted in QEMU with 4 CPUs, and loaded real pages in a maximized browser window.

eKisNonos and others added 30 commits September 21, 2026 16:56
The directory half of the Anyone transport: authority certificates, the
microdescriptor consensus, quorum verification against the certs held, path
selection under the bandwidth weights and the network rule, and the ntor
handshake and cell layer the link will carry.

The consensus is hashed inside the capsule. The kernel's crypto_hash refuses
anything over a megabyte and a microdescriptor consensus signs about 1.7 MB,
so every consensus fetched failed its digest with errno 22 and was discarded
as unverifiable over a transport that was working.

The capsule is built but not spawned: nonos-capsule-net-anon is declared and
left out of every feature bundle, because the link stage does not complete
yet and a transport that cannot finish a connection should not be started.
Every module under a #[path] is the file the capsule compiles, not a copy.
Values come from the network's reference implementation and from the cipher
and hash standards.

The live consensus test reads a document from a path in the environment and
passes quietly when there is none: committed vectors pin field offsets and
malformed shapes, but only a real document answers whether the parsers
survive five thousand relays written by five thousand operators.
A clean boot sent four clearnet DNS queries for validator.nymtech.net:
the directory fetch resolved the Nym API host through net.dns before
every TLS request, naming the Nym API to the local resolver and anyone
on the path to it before a single packet entered the mixnet.

The fetch now connects to a pinned set of IPv4 addresses for the API
host, every A record it answered on 2026-09-27, tried in order. Only
address discovery changes: SNI and certificate validation stay bound to
the name validator.nymtech.net, so TLS authenticates the host exactly
as before and the directory stays live rather than a frozen node list.

It fails closed. A stale address that now belongs to someone else fails
the handshake before the request is written, and when every pinned
address fails the fetch returns an error; it never falls back to
net.dns. The resolve helper had no other caller and is removed with it.

Step 2, a signed directory snapshot refreshed through the mixnet so
that even the one boot-time fetch leaves the clearnet, is future work.
The directory fetch now dials a pinned address instead of resolving a
name, and nothing orders it after DHCP any more. On a boot the first
connect ran at serial line 1450 while the lease only bound at line 1632,
so all 25 attempts failed (codes 42 and 6) and no SYN ever reached the
wire. The old resolve() path blocked in net.dns until the lease existed,
which gave that ordering implicitly.

fetch_tls now polls net.dhcp.client OP_LEASE_STATUS until the state is
bound, bounded at 30 s, and fails with code 25 if no lease appears. It
never falls back to DNS.

Refs: PR #564
A mk_yield loop is not a sleep. On one CPU the waiting capsule kept the
processor, the DHCP client never ran, and no DISCOVER reached the wire:
the capture held zero DHCP packets and every poll ended in E_NO_LEASE.
mk_idle_ms parks the capsule for the poll interval so DHCP can bind.
What was wrong: parse_size, parse_auto_repeat, min_track and one_track
checked for calc(, repeat( and minmax( by slicing the str at byte 5 or
7. When that byte fell inside a multi-byte character the slice panicked,
and one style attribute on any page took the whole browser down.

How it showed: a mutation fuzzer over the host render pipeline, seeded
with 12 local HTML documents, hit 30 panics in 12000 inputs, all at
css/parse_size.rs:53. Crafted inputs then panicked at auto_repeat.rs:35
and one_track.rs:33 as well.

What is true now: the prefix is compared as bytes, which has no char
boundary. css_utf8_tests renders the three shapes; with the old code all
three fail at those three lines, with this change all three pass.
What was wrong: :first-child, :last-child, :only-child, :nth-child and
the of-type forms found an element's place by walking every child of its
parent, once per element per rule. A list of n items under one such rule
cost n squared sibling reads.

How it showed: on the host render pipeline a 50,000 item list under
li:nth-child(2n+1) took 12785.6 ms to cascade and lay out. The capsule
runs the same code under QEMU TCG, where the page does not come back.

What is true now: the cascade and select() build a Siblings table in one
pass over the tree and answer from it; the same page takes 128.1 ms.
matches() and closest(), which ask about one node per event, keep the
walk. sibling_tests checks the table against the walk for eleven
positional selectors on one page, and a run over 2048 fuzzed documents
compared 544942 matched elements with 0 differences.
What was wrong: FloatCtx searched for a float's row from the current
line downward, ignoring the floats already placed. A narrow float after
one that had been pushed down climbed back into the gap above it, which
CSS 2.1 section 9.5.1 rule 5 forbids, and every float repeated the row
search from the top through every float bottom, cubic in the float
count.

How it showed: float_tests places a 200px float, a 200px float that has
to drop below it, then a 50px float; the 50px float landed at y 0, above
the second at y 50. On the host pipeline 2000, 5000 and 20000 left
floats took 376.6, 4677.3 and 40398.0 ms.

What is true now: FloatCtx keeps the top of the lowest float placed and
starts every later search there. The 50px float lands at y 50, beside
the second, and the three pages take 23.5, 48.2 and 129.4 ms.
What was wrong: parse_path kept Z as the current command after running
it. Z reads no arguments, so when path data continued with a number
instead of a command letter, the loop ran Z again without consuming
anything and never ended. One SVG on a page, inline or as an image, hung
the browser.

How it showed: a mutation fuzzer over decode_svg and decode_webp stalled
all four of its workers; gdb put every one of them in parse_path and
Tok::skip_sep. svg_tests decodes "M1 1 L9 1 L9 9 Z 5", which did not
return within 10 s on the old code.

What is true now: closepath clears the current command, so only a
command letter may follow it, as the SVG path grammar says. The test
returns at once, a new subpath after Z still draws, and a rerun of 60000
fuzzed SVG and WebP inputs finished with 0 panics and a worst case of
63.5 ms.
What was wrong: the transmit token only handed a frame to the driver
while the 8 ms poll window was still open, and dropped it silently
otherwise. Egress runs after ingress in a smoltcp poll, and under QEMU
without KVM one driver round trip takes about the whole window, so the
window had always closed before any frame was offered. Nothing ever left
the machine.

How it showed: on the desktop image booted in QEMU TCG, net_core logged
"bind: interface up" and never a lease; a packet capture on the virtio
NIC held only the firmware's IPv6 frames, 0 from NONOS, over more than
two minutes. With counters on the transmit path the log read "tx
sent/fail/dropped 0 0 5": every DHCP discover dropped. Every consumer
then failed on its own: the browser said "connect failed" on the direct
network and no request reached the host.

What is true now: a poll may always send its first TX_FLOOR (4) frames,
and more while the window is open; driver traffic still happens only
inside a poll. The same boot logs "lease 10.0.2.15/24 gw 10.0.2.2", the
capture shows ARP, the DHCP reply and TCP SYNs leaving, and the browser
on the direct network loads a test page from the QEMU host, which logged
the GET 4.2 s after Enter.
What was wrong: the only network control was Nym on or off, and when Nym
was wanted but net.socks5 was not registered, route_mixnet simply left
the route off. Every request then went direct while the reader believed
it was hidden, with nothing on screen to say so beyond the panel's
status line.

How it showed: with the refusal taken out of this change and the Anyone
network chosen (its service, net.anon.socks5, is not in the image), the
desktop image booted in QEMU loaded a test page straight from the
QEMU host: the host logged the GET and the address bar said
"Network: Anyone network" above a page that came in direct.

What is true now: the settings panel offers Direct, Nym mixnet and
Anyone network, marks the one chosen, and the address bar always shows
"Network: ..." for the next request. The choice is read once per
navigation, so a switch takes effect on the next request. Each private
network is a SOCKS5-over-IPC service found by name (net.socks5,
net.anon.socks5); when it is not running the request fails with
"<network>: <service> is not running, so nothing was sent". Live on the
same image: Nym chose net.socks5 ("[SOCKS5] connect" to the host, then
"no session", host GETs unchanged), Anyone refused by name (0 SOCKS5
connects, host GETs unchanged), Direct rendered the page (host GET 4.2 s
after Enter). The manual SOCKS5 proxy still applies to Direct.
What this brings: capsule_net_anon (consensus fetch and quorum check
against the seven Anyone authorities, bandwidth-weighted path selection,
ntor and the cell layer), its kernel mirror, and the two proof crates,
from the anon/anyone-consensus branch of 2026-09-18.

What had to change to build on main: main retired the Ed25519 syscall
and its libc wrapper, so net_anon's crypto::digest failed to compile
with "unresolved import nonos_libc::crypto_ed25519_verify". It now
verifies with the userland nonos_ed25519 crate, as net.nym does.
verify.yml keeps both sides' proof-crate lists.

What is still not true: the capsule is in no feature bundle and is not
spawned, because its link stage does not complete. anon_link_proofs does
not compile on the source branch or here: vectors/certs_cell.bin, a
CERTS cell captured from a live relay, was matched by the *.bin rule in
.gitignore and never committed. anon_ntor_proofs does not compile as
merged. verification/evidence/EVIDENCE.json keeps main's version, whose
proof_crates count (55) is now stale; collect-evidence.sh gives 57.
What was wrong: the crate's root never declared extern crate alloc,
which its test files import from, and consensus_live_fixture named
Weights without importing it from crate::path.

How it showed: cargo test stopped with three "unresolved module or
unlinked crate alloc" errors and then "cannot find type Weights"; none
of the tests over the ntor handshake, the cell layer or path selection
could run.

What is true now: cargo test --release in anon_ntor_proofs runs 88
tests, 88 pass.
What was wrong: decode32 did not count leading '1' characters, which in
base58 stand for leading zero bytes. Empty text decoded to the all-zero
key, and a key with a '1' in front decoded to the same key as without
it. Every key the capsule reads from the directory and from requester
addresses goes through this function.

How it showed: a parser test over requester addresses accepted ".@" as a
complete address whose three keys were all zero.

What is true now: decode32 refuses empty text and requires the number's
leading zero bytes to equal the leading '1's, so each 32-byte key has
exactly one spelling. All 8,151 base58 keys in the validator's
described, entry-gateway, exit-gateway and mixnode answers of 2026-09-28
still decode to 32 bytes. nym_topology_proofs now includes the capsule's
JSON and API readers by path; base58_tests has 5 tests, 3 of which fail
on the old decoder, and all 15 tests in the crate pass.
What was wrong: find_exit asked each exit node for its network requester
over plain HTTP on the node's port 8080 and used whatever identity and
encryption key came back. Nothing checked them, although plain.rs said
every answer was checked against the directory: anyone on the path to
one exit could answer with a requester of their own and become the
client's exit, seeing every destination it asked for.

How it showed: reading exit.rs and find_exit.rs; the only key taken from
the directory was the gateway, passed in by the caller, and the
requester's own keys had no source but the unauthenticated answer.

What is true now: requesters come from the validator's
/api/v1/nym-nodes/described, fetched over the same verified TLS
connection as the node lists (capped at 4 MiB; it was 1,563,754 bytes
for 841 nodes on 2026-09-28), and one is kept only when the gateway its
address names is an exit gateway in the current node list. The plain
HTTP lookup and fetch_plain are gone. Over the full live answer, 841
addresses parse and all 179 on the 179 listed exit gateways are kept, in
23.6 ms on the host. requester_tests covers a trimmed slice of that
answer: three kept, one real node that is not an exit dropped, one
malformed address refused. 19 of 19 tests pass. Not yet proven: a fetch
through an exit chosen this way, which needs a machine that can reach
the mixnet.
What was wrong: drain() read every handshake cell as variable-length and
refused anything else. NETINFO (command 8) and PADDING (command 0) are
fixed-length cells in tor-spec section 3, and the cell reader correctly
parses them as such, so the handshake ended with LinkError::Protocol at
the moment the relay sent its NETINFO, right after "link identity
proved". No link could open, and nothing behind it (circuits, streams)
could run.

How it showed: the capsule's own commit said the link stage does not
complete; reading pump.rs against cell/read/frame.rs showed a fixed cell
can never match the variable-only pattern.

What is true now: link::step::classify decides each handshake cell from
its frame size, command and whether CERTS has been verified: CERTS must
be variable, NETINFO fixed and only after CERTS, padding and
AUTH_CHALLENGE are passed over, anything else refuses. drain() uses it
and names a timeout. link_step_tests has 6 tests; with the old
variable-only rule written into classify, 3 fail, and with this change
all 94 tests in anon_ntor_proofs pass. Not yet proven live: a link to a
relay.
What was wrong: circuit_tick drew a whole new path for every circuit,
including a guard of its own, and built it over state.link, which is
open to state.guard, drawn separately. CREATE2 went to the link's relay
carrying another relay's identity and ntor key, so the handshake could
not complete, the build failed and the link was dropped as lost.

How it showed: reading circuit_tick.rs against link_tick.rs and
path/build.rs; with more than one guard-capable relay in the consensus
the two draws are independent.

What is true now: path::through builds guard, middle, exit with the
guard fixed to the link's relay, taken first so the exit and middle
exclude it and its /16; circuit_tick waits for both a link and a guard.
The rule is in its own file with the dice passed in, so the host proofs
run it: path_through_tests checks 500 draws that the first hop is the
linked guard and no later hop repeats it or shares its /16. With the old
independent guard written into the same function, 2 of its 3 tests fail;
with this change all 97 tests in anon_ntor_proofs pass. Not yet proven:
a circuit through live relays.
What was wrong: a read over net.sockets takes the bytes out of the
socket before its reply is delivered, and the kernel drops a reply whose
caller has already timed out (replies are matched by per-call token).
The browser waits 200 ms per read, so under load every read that timed
out threw away up to 1,536 bytes of the page for good.

How it showed: booted in QEMU TCG, the browser loaded a local 89,386
byte page (5000 list items) and failed. The packet capture shows all
89,386 bytes delivered and acknowledged on one connection; with byte
counts added to the error page, the browser held 65,532 bytes and no
header: the first 23,854 bytes had been read out and never delivered.

What is true now: the browser sends a read number after the handle,
starting at 1 per socket and moving on only when a read is answered with
bytes; net.sockets keeps the last chunk it handed out per socket and
gives the same bytes again for the same number, releasing them when the
next number arrives or the socket closes. A reader that sends only a
handle is served as before. On the same image the page renders all 5000
items.
What was wrong: step() failed or finished every fetch 12 s after it
started (180 s on a mixnet), however steadily its bytes were arriving,
and handed a half-received plain response to the parser.

How it showed: in the capture of a QEMU TCG boot of the network switch,
the host server sent a 89,386 byte page at about 6 KB/s and the guest
closed the connection 12.4 s after the request, with 74,881 bytes
acknowledged; the page showed "bad response".

What is true now: the budget runs from the last bytes received
(Fetch::progress_ms, set whenever the body grows), and max_total_ms
bounds the whole fetch at 120 s direct and 900 s over a mixnet so a
trickling server cannot hold it open. The same page transfers
completely: the capture of the next boot shows all 89,386 bytes and the
server's FIN acknowledged before the guest closes.
What was wrong: every response the parser could not read showed
"Navigation failed: bad response", whether the body stopped short of its
Content-Length or the bytes held no HTTP header at all.

How it showed: a local 89,386 byte page failed with only "bad response",
which gave nothing to tell a slow transfer from lost data.

What is true now: the error names the case with numbers: "body N of M
bytes the headers declared", "N header bytes, M body bytes" when no
length was declared, or "N bytes and no complete header". On the QEMU
TCG image it read "bad response: 65532 bytes and no complete header",
which is what showed that the start of the page was being lost between
net.sockets and the browser.
What was wrong: close() handed the socket to smoltcp for a graceful FIN
and nothing ever removed it from the socket set, so every connection
made stayed there with its 16 KB of buffers. One closed with bytes still
unread kept advertising a zero window, and the peer kept probing it.

How it showed: in the capture of a QEMU TCG boot of the network switch,
after the browser closed a page it had read only partly, the guest
answered the host's probes with "win 0" from 19:32:08 until the capture
ended at 19:37:18, over five minutes.

What is true now: a socket closed with unread data is aborted (RST, as
RFC 2525 section 2.17 asks) and one without is closed gracefully; either
way it goes on an orphan list and is removed from the set after each
poll once it reaches Closed or TimeWait. The list is cleared when the
stack is rebuilt, since old handles would name other sockets. On the
image built with this change, the browser stopping an 8,320,026 byte
page at 589,597 bytes produced one RST from the guest (win 273, the
unread bytes) and no zero-window probing after it.
What was wrong: the manager is ticked with the time in whole seconds
(server::runner::seconds), but the directory retry waits were written in
milliseconds and added to it: RETRY_MS 750 after a failed anchor stage
and 5_000 after a failed consensus fetch.

How it showed: reading dir_tick.rs and dir_load.rs against runner.rs. A
single failed authority round at boot, with no lease yet or one
authority unreachable, held the whole directory for 750 s, and one
failed consensus attempt for 5000 s, while their comments promise under
a second and five seconds.

What is true now: RETRY_SECONDS is 1 in dir_tick and 5 in dir_load, in
the unit of the clock they are added to. Not yet proven live: the retry
against the authorities.
What was wrong: three things in the stream layer.
- Inbound DATA, CONNECTED and END found their stream by id alone. Stream
  ids are unique only within a circuit, so a cell arriving on one
  circuit could write payload into, or end, a stream carried by another:
  a hostile exit on one path could inject into a connection it never
  carried.
- Every RELAY_SENDME credited the circuit window. A stream-level SENDME
  (non-zero stream id, tor-spec 7.4) never reached the stream, whose own
  package window only went down, so any upload stopped at 500 cells.
- A circuit marked Dead (DESTROY, TRUNCATED, an unrecognised cell) left
  its streams as they were, and their readers waited out their own
  deadlines.

How it showed: reading manager/inbound and manager/out against the
protocol; stream_scope_tests reproduces the first two against the
capsule's own Stream table.

What is true now: inbound cells, and the send window, look streams up
with stream::find_on(circuit, id). A SENDME with stream id 0 credits the
circuit and any other credits that stream by STREAM_INCREMENT. A circuit
going Dead ends each of its open streams with REASON_DESTROY (5). New
stream ids are unique across all streams, because the id is also the
caller's handle. With id-only lookup and a no-op credit written back in,
2 of the 3 new tests fail; with this change all 100 tests in
anon_ntor_proofs pass. Not yet proven: streams through live relays.
What was wrong: flex_row sized each wrapped item with clamp(MIN_ITEM_W,
w), which panics when the line is narrower than 16px. The same
crossed-bounds shape sat in flex_row's basis (clamp(0, w) with a
negative w), border_box_w (clamp(0, avail)), an auto-fill grid with no
tracks (clamp(1, 0)), and grid_place, whose span was bounded by the
unclamped start column, so a line at or past the last column left 0 (or
wrapped the u8).

How it showed: the host renderer running the capsule's own layout over
the live wiki.archlinux.org installation guide with its stylesheets
panicked at flex_row.rs:211 ("min > max. min = 16, max = 1") at widths
800, 1024, 1280 and 1360. The capsule is built with panic=abort, so the
browser would die.

What is true now: an item is at least MIN_ITEM_W and at most the line,
and never wider than a line narrower than that; the other bounds are
floored so they cannot cross. The same page renders: 3483 fragments,
24.0 ms layout, 11.6 ms paint on the host. narrow_tests covers a 1px
wrapping flex line, every viewport from 1 to 40px over flex, auto-fill
grid and percent widths, and a grid item placed past the last line;
without this change 2 of them fail, and all 75 browser proofs pass with
it.
What was wrong: net.socks5 takes the exit's bytes out of its inbox to
build an answer, and waits up to POLL_MS (1 s) for the exit before
answering. The browser waits 60 ms on a poll, and the kernel drops a
reply whose caller has stopped waiting. Exit bytes that arrived between
60 ms and 1 s went into a reply nobody received and were gone from the
stream: on the mixnet, where every answer arrives late by design, a page
could lose any part of its response.

How it showed: reading server/relay.rs (collect waits POLL_MS 1_000 and
drains the inbox) against the browser's net/mixnet/call.rs (POLL_MS 60),
with the kernel's token-matched replies (ipc/call/sys_ipc_call.rs); the
same loss was shown live for direct reads over net.sockets.

What is true now: a request marked STREAM_NUMBERED (2) carries a u32
exchange number; net.socks5 keeps the last encoded answer per caller and
gives it again for the same number, releasing it on the next number or a
reset. The browser numbers every exchange on its route and moves on only
when one is answered, and now says explicitly which exchanges are polls
(the old one-byte test would have given a numbered poll the 15 s send
wait). Frames marked 0 and 1 are served as before. kept_tests drives the
server's own parser and store through a lost answer: with the store
disabled 2 of 4 fail, and all 65 socks5 proofs pass with it. Booted in
QEMU TCG, choosing Nym still reaches the proxy: "[SOCKS5] connect"
to the host, then "open refused: no session", with no mixnet session
up. Not yet proven: a page over a live mixnet session.
What was wrong: the serve loop ran its idle work (directory, link,
circuit build, reading cells off the link, paying SENDMEs) only when no
request arrived within IDLE_MS (200 ms). A caller polling a stream more
often than that, which is what a reader waiting for a page does, kept
the loop in its request branch, so the cells it was waiting for were
never read off the link and no SENDME was paid.

How it showed: reading server/runner.rs against server/idle.rs; the only
call to idle() was in the branch taken when mk_ipc_recv_from timed out.

What is true now: idle() runs whenever a receive times out and, under
traffic, at least every IDLE_MS between requests. Not yet proven: a
stream through live relays under a polling reader.
What was wrong: net.anon answered only its own framed API (open, send,
recv, close by stream id). The browser reaches an anonymous network the
one way it reaches Nym, by speaking RFC 1928 as bytes to a service over
IPC with the 0/1/2 marker frames and numbered exchanges, so choosing the
Anyone network had nothing to talk to and no request could go through
it.

How it showed: the Anyone row pointed at a service no capsule registers,
and every request under that choice was refused as "not running".

What is true now: frames on the net.anon service port whose first byte
is 0, 1 or 2 go to a SOCKS front; API requests open with the magic
(first byte 0x31) and are served as before. Each caller, keyed on the
pid the kernel attests, has one conversation: greeting, CONNECT, then
relay. The destination name travels to the exit in RELAY_BEGIN, so no
lookup leaves the machine. Success is answered only once the exit sends
CONNECTED; an exit END before that is answered with the SOCKS code for
its reason, and a network not yet ready (no directory, path or link)
with code 3, the log naming which part is missing. A numbered answer is
kept per caller and given again for the same number, including the one
that ends the conversation, so a reply the kernel dropped after a
caller's timeout loses no stream bytes. An ended stream hands back every
byte that arrived before it reports closed. Unsent caller bytes are
bounded at 256 KiB, conversations at 32, and a reset or an unknown frame
ends the stream with END.

Host proof: anon_ntor_proofs socks_*_tests, 18 tests against the real
sources (every file but the three that reach the manager); 118 of 118
pass. With the kept-answer lookup disabled the two replay tests fail.
The idle timing check moves from runner.rs into idle.rs unchanged, so
the serve loop stays within 75 lines.
What was wrong: net.anon's service endpoint was 4472, the port net_core
registers net.udp on. Whichever capsule registered second lost it.

How it showed: in a desktop image carrying net.anon, the serial log has
"[NET-CORE] register FAILED net.udp": net.anon spawns first and holds
4472, so net_core's UDP service never registered.

What is true now: net.anon serves on 4484 with its reply inbox on 4485,
two ports no other capsule or kernel mirror uses (4400 to 4483 were
checked across every Capsule.mk and spawn.rs).
What was wrong: net_anon imported LinkError in link_tick.rs without
using it, and six anon_ntor_proofs test files imported names they never
used; two of them also kept a flat() weights helper no test called.

How it showed: the net_anon capsule build printed one warning and the
proof crate's test build printed eight, then two more for the dead
helpers once the imports were gone.

What is true now: the imports and the two unused helpers are gone. The
capsule build and `cargo test --release` in anon_ntor_proofs print no
warnings, and 118 of 118 proofs still pass.
What was wrong: every directory fetch waited inside one idle turn, up to
8 s for the connection, 10 s to send and 30 s to read, and the
certificate sweep asked all seven authorities in that same turn. The
serve loop answers callers only between turns, so while the authorities
were unreachable it answered nothing for minutes at a time.

How it showed: with net.anon in the desktop image and no authority
reachable, choosing the Anyone network and loading https://example.com/
ended in "Navigation failed: socks hello failed": the browser's 15 s IPC
call for the SOCKS greeting timed out behind a blocked turn.

What is true now: a directory fetch is a Job that each idle turn
advances as far as it can without waiting: connect, then one look at the
socket state, then as much of the request as the socket takes, then up
to eight 16 KiB reads. The certificate, consensus and microdescriptor
stages each name one target per turn and move on when it is done or has
failed. The same deadlines apply, now measured across turns. A read that
drains a closed socket keeps what it drains; the old loop dropped those
bytes. Live, the same request is answered by net.anon's SOCKS front with
"anyone: not connected yet, no directory or circuit" 5 s after Enter,
and the log says "socks connect refused, not ready, errno 6"
(E_NO_DIRECTORY).
munmap freed every frame it unmapped. When the range held a surface
that another process had attached, or a surface the caller had attached
from another owner, the allocator could hand a frame out again while
the other process kept writing pixels into it.

munmap now collects the surface windows inside the range first
(pin::held). Frames of surfaces the caller attached from another owner
are unmapped but not freed. The caller's own surfaces in the range stop
being attachable: their frame list leaves the slot. If no other process
has one attached the frames are freed as before. If one does and the
window is unmapped whole, the frames become an orphan the registry
frees once no attach record for the handle is left; a window unmapped
only in part stays live and keeps its frames.

attach_surface refuses a slot whose frame list is empty, and holds
pin::gate from the slot check until the mapping is recorded, so an
attach cannot race a pin, unmap or free of the same frames. The release
syscall goes through pin::drop_attach, which removes the receiver's
PTEs for the surface before dropping its record and reference, then
frees orphans nobody maps. Process exit drops the orphans the process
owned without freeing frames still attached elsewhere.

The existing-attach early return and the frame mapping loop of
attach_surface move into share/existing.rs and share/map_frames.rs.
The toolkit kept one attached surface. Apps repaint in turn, so every
switch released one window's attach and attached and mapped the next
one again.

attached_surface now keeps up to four attaches, each stamped with the
last time it was painted into. A miss fills a free entry or evicts the
least recently used one, and only an evicted entry's attach is
released. The kernel keeps a surface's frames allocated while any
attach of it is live, so a closed or resized window's frames stay held
until its entry ages out.
Weight was only regular or bold, so a variable face drew every weight
at its default: headings set at 500 came out as light as body text. The
toolkit now builds ab_glyph with its variable-fonts feature. The CSS
weight rides in four bits of the font key, bolder and lighter stepping
from the parent's weight and a later font-family keeping it. The font
registry makes a copy of a variable face set to that weight on its wght
axis, over bytes of its own so the glyph cache keeps each weight apart,
at most four weights a face and 6 MiB of faces in all. A face that
carries the weight itself is not thickened again for bold.
Cr2::read panics when CR2 holds a non-canonical address, and QEMU TCG
loads CR2 on a non-canonical access too, so such a fault in a user
process panicked the kernel instead of being handled. The handler now
reads CR2 raw through fault_address, and the fault is handled (and the
process killed) as any other.
A task resumed as a kernel thread with no saved context was taken off
the run queue and set Sleeping with no deadline. That happens when it
was woken on another CPU before the CPU it runs on finished yielding
it, so the wake was already spent and the task never ran again. It is
now parked on a deadline 1 ms out (retry_unsaved.rs), and the sleep
sweep retries it once its context is saved.

sleep_until and sleep_until_unless_woken set the deadline, then the
Sleeping state, then left the run queue. A wake landing between the
state change and the dequeue left the task Ready but off the queue, and
a sweep that found the deadline before the state change spent it on a
task still running. Both now go through park, which leaves the run
queue, then sets Sleeping, then publishes the deadline.
The sleep sweep on the timer tick and the IRQ broker's dispatcher woke
tasks through wake_process, which spins on the task's state lock. They
run on top of whatever the CPU was doing, and init reads a capsule's
state with interrupts open to see if it is alive. A tick or IRQ landing
while init held that lock spun forever on it, on the same CPU.

try_wake_process takes the process table and the state lock with
try-lock only and returns false when either is busy, changing nothing.
The sweep then puts the spent deadline back so the next tick retries,
and the IRQ dispatcher puts the waiter back in its slot. The liveness
check also turns interrupts off while it holds the state lock.
Kerning came only from a legacy kern table, which most web fonts and the
built-in Noto faces do not carry, so text set in them ran about 3% wide.
The toolkit now reads the GPOS kern feature's pair lookups straight from
the face data (formats 1 and 2, through extension lookups) and applies
each lookup's first covering subtable between glyph pairs, in measure
and in every draw path; a face without them keeps its kern table. The
chrome pixel hash is updated: with GPOS kerning turned off it is the
same as before.
A hover change committed only the bubble's band at the bottom of the
page, but its repaint grows the band over boxes crossing it and falls
back to the whole page when fixed or sticky boxes are present. The
compositor was then told less than was drawn, and a pointer-sized area
it recomposited while that repaint ran kept the half-drawn page. The
committed rect now covers the grown band, or the page, from the same
band_rows computation the painter uses.
nibble() indexed a one element table and dropped the result, which
clippy reads as a statement with no effect. The index now feeds the
returned value: OR-ing in the table's only byte, zero, leaves the digit
as it was, and a byte that is not a hex digit still indexes one past the
end, so a bad fingerprint literal is still a build error at that line.

connect() returns its (host, port, bytes taken) tuple through a Request
alias instead of spelling the type inline. The SHA-256 padding test
hashes a stack array instead of a vec! it never grows.
Start::Ready carried a HandshakeState inline, so every Start was as
large as the keyed state even while waiting or refused. It now carries
Box<HandshakeState>, allocated once when the ServerHello keys the
handshake; whole(), session::start, the browser's flight judge and the
RFC 8448 proof take the state back out of the box.

AppReader and Transcript derive Default. The derived values are the
ones new() already built: zero cursor and sequence, an empty plaintext
buffer, not broken, and a fresh SHA-256 state.
The Wire trait's socket calls return Result<_, Refused> instead of
Result<_, ()>; NetWire maps net.sockets' unit error to Refused and the
callers that only test for an error are unchanged.

A gradient Shade holds its GradPaint in a Box, so a solid-colour Brush
is no longer sized for a gradient. Pool and the image Store derive
Default with the values new() built.

Zero-guarded matches on a number or a length match the literal 0.0,
closure-free then_some replaces then on a plain cast, rounding up by
halves uses div_ceil, and the vp8 predictor and chroma token loops walk
their edge arrays by iterator. Negated conjunctions are written as one
negated disjunction, color-mix tests for a sum that is not positive or
is NaN directly, the badge row test uses Range::contains, and shift
precedence is spelled out in the WOFF2 point and vp8 quantizer maths.

The clipPath and gradient readers loop on the next tag only while the
element has a body, which is the condition the old loops tested and
never changed. The proofs crate reaches chunked decoding through the
http module it already loads instead of loading that file twice.
cp() turned an entity table code point into a char and called panic! on
a value that is not a Unicode scalar value, which the production source
hygiene scan rejects. It now indexes a one element table with one for
such a value, as the authority fingerprint decoder does. Every caller is
a static table, so a bad row is still a build error naming this line,
and a valid row decodes to the same char as before.
collect-evidence.sh counts every userland/*_proofs directory. Five were
added since the manifest was last regenerated, so the committed
runnable.proof_crates read 55 against the 60 in the tree. Nothing else
in the regenerated manifest differs.
senseix21

This comment was marked as duplicate.

@senseix21 senseix21 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am checking the latest CI rerun and captured proof-vector dependency before making a merge-readiness call.

@senseix21 senseix21 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking the current CI rerun and missing captured proof vector before making a merge-readiness call.

@senseix21 senseix21 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking current CI and the missing captured proof vector before concluding merge readiness.

@senseix21 senseix21 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking current CI and the missing captured proof vector before concluding merge readiness.

@senseix21 senseix21 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking current CI and the missing captured proof vector before concluding merge readiness.

@senseix21 senseix21 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking current CI and the missing captured proof vector before concluding merge readiness.

@senseix21 senseix21 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking current CI and the missing captured proof vector before concluding merge readiness.

@senseix21 senseix21 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking current CI and the missing captured proof vector before concluding merge readiness.

@senseix21 senseix21 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking CI and the missing captured proof vector before concluding merge readiness.

@senseix21 senseix21 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking the latest CI rerun and captured certs_cell.bin dependency before making a merge-readiness call.

@senseix21 senseix21 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirming current PR status after the latest main fast-forward.

@senseix21

Copy link
Copy Markdown
Collaborator

Reviewed at 7b407e7c9 (not a draft, base main, 2474 files, +96368/−14097, 90 commits behind main, 100 ahead, last pushed 2026-10-01). Entirely userland: capsule_browser +49524/−11034, a new capsule_net_anon at +12261, and nine proof crates.

Verdict: Comment. One red lane, and it is a file that was never committed rather than code that is wrong. The substantive point is about #551, not about this diff.

Nearly a hundred thousand lines, and the gates hold

This is worth stating against the backdrop of the Linux lane, where every PR carries nine red lanes: here build, production-build, benchmark, boot-smoke, boot-proofs, hygiene, abi-contracts, evidence-manifest, crypto-proofs and runnable-proofs all pass. build passing means tcb-budget passed too, which it should for a userland-only change, but on a 2474-file diff it is not nothing that nobody leaked a line into ring 0.

And the machine comes up further than anywhere else in the queue. From nonos-benchmarks-36698822891-1/boot-log.json: zk_attest_ok: 47, zk_attest_fail: 0, fatal: 0, panic: 0. Forty-seven capsules attested, against 24 on the Linux lane — the browser, the anon network and everything they pull in, all attesting clean.

Nine new or extended proof crates come with it: anon_link_proofs, anon_ntor_proofs, browser_http_proofs, capsule_browser_html_proofs, capsule_browser_proofs, image_paint_proofs, nym_topology_proofs, tls_proofs, kernel_proofs.

Important

1. This supersedes #551, and the evidence is in the blobs.

#551 is a draft, 347 commits behind main, last pushed 2026-09-21, and both PRs add userland/capsule_net_anon from nothing — #551 at +10537, this one at +12261. That reads at first like two independent implementations of the same Anyone-network subsystem, which is the pattern that has caused real trouble in the Linux lane. It is not. Measured:

So this branch carries #551's code forward rather than re-deriving it. I checked the eight files present in #551 and absent here, because "superseded" and "quietly dropped" look the same from a file count:

gone from #551 where it went
src/circuit/build/{drive,extend,first,next_cell}.rs src/circuit/build/{answer,ask,error,reply,timing}.rs plus a new src/circuit/extend/{build,mod,reply,types}.rs
src/manager/{http,http_exchange,read_body}.rs src/manager/{body_closed,body_step}.rs
src/link/held.rs folded into the restructured link/

Circuit building and HTTP body handling are both still there, reorganised into smaller files. Nothing was removed.

The useful action is a bookkeeping one: say so on #551 and close it, or state what it still carries that this does not. Right now two open PRs claim the same subsystem, one of them 347 commits behind, and anyone reading the queue has to do the above to find out which is live. Given #551 is a draft and this is not, that is almost certainly just an un-closed tab — but it is also why anon_ntor_proofs appears to be added twice across the queue (+2931 there, +3685 here).

2. The only failing lane is a test vector that .gitignore swallowed.

proof-crates (anon_link_proofs) fails at compile time:

error: couldn't read `src/../vectors/certs_cell.bin`: No such file or directory (os error 2)
error: could not compile `anon_link_proofs` (lib) due to 1 previous error

The vectors/ directory does not exist under userland/anon_link_proofs at this head at all — the tree has Cargo.toml, src/lib.rs, src/shim/{cell,crypto,link}.rs and six test modules, and nothing else. The cause is .gitignore:10, which is a bare *.bin for the whole repository. There are deliberate exceptions at :12 (!nonos-bootloader/firmware/**/*.bin) and :53 (!nonos-data/trust/**/*.bin), and none for proof-crate vectors.

So the file exists on the machine where these tests were written, include_bytes! finds it there, and it was never added. This is the failure mode that global binary ignores always produce, and the fix is an exception plus the file:

!userland/**/vectors/*.bin

Worth doing as an exception rather than by generating the vector at build time, since a checked-in wire capture is the point of a certs_cell fixture — it is evidence of what a real CERTS cell looked like, not an input that should be re-derived by the code under test.

Minor

  1. 90 commits behind main. Less pressing than the Linux lane's 127 because every gate here is green, but main wired build-aarch64 and boot-aarch64 in that window, and linux: land the Linux lane on main; terminal linux command, signals to running threads, pipe waits, madvise, VFS store, Process Manager #593 has just shown what those lanes find on first contact — a foreign_absent.rs stub that had drifted. A userland-only change is unlikely to meet the same thing, but the rebase is cheaper now than after another 90.

Questions

  1. capsule_net_anon is new and capsule_net_nym is modified (+799/−400). Does the Anyone path replace the Nym path for browser traffic, run beside it, or get chosen per request? The title says "Anyone network in the browser, a standards-based engine, and a fast network path" — three things — and which of them owns egress by default is the part a reader most wants stated, because it determines what a user's traffic does when both are available.
  2. Nine proof crates is a lot of new verification surface. Is anon_link_proofs the only one with an external fixture, or are there other include_bytes! paths that happen to resolve locally today? A git grep -l include_bytes userland/*proofs against a clean clone would answer it in one command, and item 2 is the reason to run it.

Verified correct

  • The certificate-chain MITM bypass stays closed through a large TLS refactor. nonos_tls is +1207/−724 here, which is the kind of churn that quietly loses a check. chain_walk.rs:49-55 still refuses unless the signature verifies and the parent is valid now and cert_is_ca(parent) holds, and the comment names the attack it prevents: "Signature checks alone accept a chain in which an attacker's own leaf certificate signs a forgery." cert_is_ca.rs parses basicConstraints by OID (2.5.29.19) and treats the absent extension as not-a-CA, which is the correct reading of cA BOOLEAN DEFAULT FALSE. The check predates this PR; what I verified is that it survived.
  • Forty-seven capsules attest with zero failures. zk_attest_ok: 47, zk_attest_fail: 0 — so every new capsule in this diff is signed, enrolled and verifiable at boot, not just compiled.
  • No ring 0 growth. build passes, which includes tcb-budget against nonos-ci/baselines/tcb-x86_64-capsules.txt. A browser engine and an onion-routing stack landing with no kernel surface is the right shape for this architecture and it is worth confirming rather than assuming.
  • The one failure is honest. It is a compile error with an exact path in it, not a flaky test or a timeout. Everything else in the matrix — forty-odd proof-crates entries, the seven dark-features-compile variants, boot-smoke at 35 minutes — is green.
  • anon: fetch and verify an Anyone consensus #551's code was restructured, not discarded. Checked file by file, per item 1. The eight files that disappear from anon: fetch and verify an Anyone consensus #551 all have successors, so a reviewer closing anon: fetch and verify an Anyone consensus #551 is not losing work.

CI at this head

One real failure, proof-crates (anon_link_proofs), from a missing committed fixture. No cancelled-run noise on this head. Everything else passes, including every lane that is red across the whole Linux lane.

Item 2 is a one-line .gitignore exception and a git add. Item 1 is a PR to close.

@senseix21

Copy link
Copy Markdown
Collaborator

Correction to my review above: I described this PR as "entirely userland" and said the browser and anon stack land "with no kernel surface". That is wrong.

Measured against this branch's merge base with main (0080d463f), it changes 62 files under src/, +1574/−306 — including the kernel-side spawn wiring that makes the anon capsule run: src/userspace/capsule_net_anon/{embed,mod,spawn,state}.rs, src/userspace/init/spawn_plan/network/spawn_anon.rs, plus src/userspace/mod.rs (+60/−40), a new src/userspace/network.rs (+43) and spawn_plan/network/{mod,spawn}.rs. My area summary bucketed src/userspace below the cut and I read its absence from the top of the list as absence from the diff.

What this changes for a reviewer: the kernel side needs looking at, not skipping. What it does not change: build passes, so tcb-budget holds against nonos-ci/baselines/tcb-x86_64-capsules.txt — that conclusion came from the gate, not from my wrong premise about scope.

It also strengthens item 1. #551 adds the same five spawn-wiring files, and three of the four under src/userspace/capsule_net_anon/ are byte-identical here (spawn.rs is revised). So this branch carries #551's kernel wiring as well as its capsule, and the supersession is complete rather than partial.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants