Skip to content

support clients that close and reconnect their vif (fixes #230) - #232

Open
arkenoi wants to merge 6 commits into
mirage:mainfrom
arkenoi:vif-reconnect
Open

support clients that close and reconnect their vif (fixes #230)#232
arkenoi wants to merge 6 commits into
mirage:mainfrom
arkenoi:vif-reconnect

Conversation

@arkenoi

@arkenoi arkenoi commented Aug 24, 2026

Copy link
Copy Markdown

With the mirage-net-xen changes, a client's connection and its vif no longer have the same
lifetime: a vif lives from domain build to detach, while a connection over it can close and reopen
any number of times. A Windows guest does exactly that — it closes the device at PnP start and on
every driver restart.

1. client_eth: remove only our own IP entry, and make admission cancellable.
remove_client removed by IP without checking identity, so cleanup for a client that was never
admitted evicted the live one holding that IP; the assert then fired on the next removal, and
Cleanup.cleanup is a bare List.iter, so that exception truncated the remaining handlers. An
HVM reaches this routinely: its two vifs carry the same IP, and Dao.VifMap.iter cleans the lower
domid first.

2. dispatcher: serve a vif across many connections.
Per-connection state now lives in its own Cleanup.t, released the moment the connection closes
so the IP frees immediately, and the vif is served again. The outer cleanup keeps the vif's real
lifetime.

3. dispatcher: never let a client's failure escape into Lwt.async.
An escaping exception reaches async_exception_hook and takes down the whole firewall, not one
client — and reconnection makes three such paths routine: the re-serve loop (a guest shutdown
raises Xs_protocol.Error), conf_vif's listener (re-raises anything not Lwt.Canceled,
including the Netif error thrown when the ring goes away), and wait_clients' fallback arm
(re-raised by construction). The latter two are pre-existing; reconnect merely made them reachable
on every guest reboot.

4. dispatcher: log which vif a client is.
An HVM's two vifs are otherwise indistinguishable in the log — same IP, differing only by domid —
so a routine handover reads like two clients fighting over one address. Reports the backend's
type verbatim rather than translating it: the guest's own vif is the one marked vif_ioemu
(it is the one with an emulated counterpart), and a PV-only guest's would also be a plain vif,
so any friendlier label would be an inference in a log line. Drop this commit if you would rather
not carry it.

5. dispatcher: retry a failed reconnect instead of abandoning a live vif.
A connection attempt can fail transiently — a guest resetting mid-handshake leaves a revoked grant,
a stale event-channel port, or half-torn-down ring keys, none of which are Xs_protocol.Error.
Treating those as terminal abandoned a vif that still exists, and wait_clients only re-adds a vif
whose directory is absent, so that client had no network until detach/reattach. Also distinguishes
the two spellings of "gone": a vanished frontend directory surfaces as Xs_protocol.Error, a
backend directory removed first as a raw Xs_protocol.Enoent.

6. Pin and hashsum.

This PR cannot go green on its own — please read before running CI

config.ml pins mirage-net-xen ~min:"2.2.0", the release that carries the backend fixes. Until
that release exists in opam, make depend cannot resolve and CI fails at the build step, not at
the hash check. The mirage-net-xen PR has to land and be released first.

The qubes-firewall.sha256 in the last commit is a placeholder: it is the hash of a build made
here against the patched library source, not against a released 2.2.0 tarball, so it will not match
what CI produces even once the release exists. It needs regenerating (./build-with.sh docker,
copy sha256sum dist/qubes-firewall.xen) once the pin resolves against the real release — happy to
push that as a follow-up commit, or drop commit 6 entirely and let a maintainer do the pin.

Testing

Windows 10 22H2 HVM on Qubes OS 4.3, plus ~28 Linux client qubes on the same firewall.

The Windows guest attaches and reconnects across 20+ cold boots, and the reconnect path is also
driven directly by detaching and re-attaching its netvm while it runs: the vif disappears and a new
one arrives, and the firewall serves it again without a restart. Switching the guest to a different
netvm works too — it picks up the new gateway with no special handling.

Windows guest   10 MB transfers 15-16 MB/s, complete every time
Linux clients   15.5-17 MB/s, unchanged; none disturbed by the Windows guest coming and going
ICMP            0/40 loss, p50 48 ms - the same as the Linux peer sees

The firewall has stayed up throughout: repeated guest reboots, guest resets mid-handshake, and live
vif removal are all routine now, where each of them previously took the whole unikernel down.

Two defects on the client-handover path. An HVM reaches both routinely: its two
vifs carry one IP, the stubdomain's is admitted first, and the guest's own vif
is still parked in add_client when the PV driver arms the emulated-NIC unplug
and forces a reboot.

1. remove_client removed by IP without checking identity, so cleanup for a vif
   that was never admitted evicted the live client holding that IP. The assert
   guarding it then fired on the NEXT removal - and Cleanup.cleanup is a bare
   List.iter, so that exception aborted every remaining cleanup handler.
   Dao.VifMap.iter walks in key order, so the waiting guest vif (lower domid) is
   cleaned before the stubdomain's: exactly the bad order.

2. A parked admission outlived the vif it was admitting. When the IP was finally
   freed it registered an interface whose device no longer existed, and the next
   client for that IP then waited behind a dead one for ever. Admission is now
   cancelled from cleanup; handlers run last-registered first, so the cancel
   precedes the removal.

Neither is the mirage#230 handshake bug, but both sit on the path the fix for it makes
routine.
A client's connection and its vif have different lifetimes. The vif lives from
domain build to detach; a connection over it can close and reopen any number of
times - a Windows guest does exactly that. The only teardown trigger used to be
the vif's directory disappearing, so a closed connection freed nothing (the IP
stayed held) and was never remade.

Per-connection state now lives in its own Cleanup.t, released the moment the
connection closes, and the vif is served again immediately. The outer
cleanup_tasks keeps the vif's real lifetime and stops the loop when the
directory is gone for good.
An exception escaping Lwt.async reaches Lwt's async_exception_hook and takes the
whole firewall down - it is not confined to the client that raised it. Three
paths could do that, and reconnection made all three reachable in ordinary use:

- the re-serve loop: when a guest shuts down its frontend directory disappears
  and the next handshake raises Xs_protocol.Error, so an unguarded re-serve
  turned every ordinary guest reboot into a firewall crash;
- conf_vif's listener: it re-raises anything that is not Lwt.Canceled,
  including the Netif error or_raise throws when the ring goes away - which now
  happens whenever a guest closes its device, not only at domain death;
- wait_clients: its fallback arm re-raised into Lwt.async by construction.

All three now log and stop serving that client. wait_clients still handles the
vif's real disappearance.
An HVM has two vifs that are indistinguishable in the log: both carry the same
IP and differ only by domid, so a routine handover reads like two clients
fighting over one address:

  add client vif {domid=2682} with IP 10.137.0.72
  Client 2682:0 (IP: 10.137.0.72) ready
  add client vif {domid=2681} with IP 10.137.0.72

That ambiguity cost real debugging time - it is what made the same-IP collision
look like a plausible cause of the Windows hang, which it was not.

Report the backend's type key alongside. Verbatim, not translated: the naming
runs the opposite way to the obvious reading - the GUEST's own vif is the one
marked vif_ioemu, because it is the one with an emulated counterpart, while the
device model's is a plain vif - and a PV-only guest's vif is also a plain vif,
so any friendlier label would be an inference rather than a fact.

Logging only. Nothing routes on the type, and an unreadable key reports '?'
rather than failing the client.
The re-serve handler treated every exception it did not recognise as terminal.
That was wrong twice over.

It abandoned vifs that still exist. A connection attempt can fail transiently -
a guest resetting mid-handshake leaves a revoked grant (Import.map_exn raises),
a stale event-channel port (bind_interdomain raises), or half-torn-down ring
keys (Enoent from the tx-ring-ref/rx-ring-ref/event-channel reads). None of
those are Xs_protocol.Error, so all landed in the terminal arm. wait_clients
only re-adds a vif whose directory is ABSENT from its map, so a vif that still
exists is never re-offered: that client had no network until it was detached and
reattached, or the firewall restarted, announced by a single warn line.

And it misclassified the benign case. wait_frontend_ready reports a vanished
FRONTEND directory as Xs_protocol.Error, but a BACKEND directory removed first
surfaces as a raw Xs_protocol.Enoent - a different constructor - so an ordinary
guest death was logged as 'giving up' rather than recognised as gone.

Now: both spellings of gone are terminal, anything else is retried up to ten
times, and the loop is damped. The delay is not politeness - a crash-looping
driver, or a client cycling its guest-writable state key, would otherwise drive
back-to-back handshake+teardown cycles (~15 xenstore round trips, two ring
map/unmaps and an event-channel bind each) at xenstored speed, against dom0,
from a 32 MB unikernel.
… hashsum

config.ml pinned ~min:"2.1.8", whose make_backend signature is
  domid:int -> device_id:int -> t Lwt.t
while dispatcher.ml now calls it as
  ?on_closed:(unit -> unit Lwt.t) -> domid:int -> device_id:int -> unit -> t Lwt.t
and S.CONFIGURATION gains wait_frontend_ready. Both are compile-breaking, so the
tree only built here because of an untracked duniverse copy: resolved from opam
against 2.1.8 it is a FTBFS.

The library changes must therefore be released before this lands - the two PRs
are ordered, library first.

qubes-firewall.sha256 still held the hash of the UNPATCHED build, which the
repo's own CI compares verbatim (.github/workflows/docker.yml) and exits 42 on
mismatch, so the PR would have failed its first run.
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.

1 participant