Summary
ghaf.hardware.passthrough.vhotplug writes its rules to /etc/vhotplug.conf, but
systemd.services.vhotplug declares no restartTriggers. NixOS restarts a unit when its unit
file changes, and rewriting a file under environment.etc does not change the unit file — so
after nixos-rebuild switch the running vhotplug keeps the rules it parsed at boot.
The result is a silent no-op. Devices continue to be routed by the previous rules while every
artefact you would think to check says the change is live: the generation moved,
/etc/vhotplug.conf has the new content, the store path resolves, nix flake check passes. Only
the process is stale.
Reproduce
- On a running laptop-x86 target, add a
prependUsbRules entry routing a specific USB device to an
app VM — anything that changes usbPassthrough.
nixos-rebuild switch.
- Confirm the deployed config is correct:
$ jq '.usbPassthrough[0]' /etc/vhotplug.conf
{ "description": "...", "targetVm": "<app-vm>", "allow": [ { "vendorId": "...", "productId": "..." } ] }
- Plug the device in.
Expected: attached to <app-vm>.
Actual: attached according to the previous rules — for a USB drive, gui-vm, via the default
interfaceClass 8 / interfaceSubclass 6 rule in modules/hardware/passthrough/usb-rules.nix.
The tell:
$ systemctl show vhotplug -p ExecMainStartTimestamp -p MainPID
ExecMainStartTimestamp=Thu 2026-08-06 23:30:52 +04
MainPID=933
$ stat -c '%y' /etc/vhotplug.conf
2026-08-06 23:59:25 +0400
The unit predates the config by 29 minutes and was never restarted.
systemctl restart vhotplug followed by a replug routes the device correctly on the first attempt.
Root cause
Three things compose badly:
-
vhotplug parses its config exactly once. vhotplug/vhotplug.py:171 builds
config = Config(args.config), and Config.__init__ (vhotplug/config.py:37) calls load(),
a single json.load at line 41. There is no re-read and no SIGHUP handler.
-
Its FileWatcher does not cover the config. vhotplug already uses inotify, but only on VM
socket paths — vhotplug/vhotplug.py:180 adds vm["socket"] for each VM, to detect VM
restarts so devices can be re-attached. The config file is never watched. (Worth stating
explicitly: the presence of a file watcher in the tree makes it look as though this is already
handled.)
-
The unit has no restartTriggers. modules/hardware/passthrough/vhotplug.nix:184 defines
systemd.services.vhotplug with ExecStart = "... -a -c /etc/vhotplug.conf" (line 194) and no
restart trigger for the file written at line 161.
Proposed fix
systemd.services.vhotplug.restartTriggers = [
config.environment.etc."vhotplug.conf".source
];
This embeds the config's store path into X-Restart-Triggers=, so the unit file changes whenever
the rules change and switch restarts the daemon.
Caveats worth deciding on before merging
-
A restart does not re-home already-attached devices. vhotplug runs with -a, but a device
already attached to a VM is skipped, so a device plugged in before the switch stays where the
old rules put it. Replugging re-routes it. This is probably the right behaviour — detaching a
disk from a running VM during activation would be worse — but the fix should not be described as
making switch alone sufficient.
-
Restarting mid-session appeared harmless in our testing: PCI devices logged
is already attached to the VM with id vhp-pci-N and nothing moved. That is a single
observation on one machine, not a guarantee, and it is the main reason to consider the
alternative below.
-
Alternative: reload instead of restart. vhotplug could watch its own config and re-read it,
which avoids restart side effects entirely and reuses the inotify plumbing already present. That
is a change to tiiuae/vhotplug rather than to ghaf. If that is preferred, this issue is really
"ghaf should restart it until vhotplug can reload itself".
Impact
Any change to device routing on a running machine: ghaf.hardware.passthrough.usb.guivmRules,
netvmRules, audiovmRules, an app VM's usbPassthrough, prependUsbRules / postpendUsbRules,
and the PCI, evdev and ACPI rule lists — all are written to the same file and all are affected.
The failure mode is the expensive kind: nothing errors, nothing logs, and the natural conclusion is
that the rule itself is wrong. We came close to concluding that prependUsbRules does not take
precedence over the default rules, and re-planning around a bug that does not exist. What actually
identified it was comparing ExecMainStartTimestamp against the config's mtime.
Environment
- ghaf
d9328b9 — line numbers in this report refer to that revision
- vhotplug
4a0f407b2f96fc169f3cc83cda1924c456c169da, as pinned by ghaf's flake.lock
- An x86 laptop target with
ghaf.profiles.laptop-x86 and hardware.passthrough.mode = "dynamic"
Summary
ghaf.hardware.passthrough.vhotplugwrites its rules to/etc/vhotplug.conf, butsystemd.services.vhotplugdeclares norestartTriggers. NixOS restarts a unit when its unitfile changes, and rewriting a file under
environment.etcdoes not change the unit file — soafter
nixos-rebuild switchthe running vhotplug keeps the rules it parsed at boot.The result is a silent no-op. Devices continue to be routed by the previous rules while every
artefact you would think to check says the change is live: the generation moved,
/etc/vhotplug.confhas the new content, the store path resolves,nix flake checkpasses. Onlythe process is stale.
Reproduce
prependUsbRulesentry routing a specific USB device to anapp VM — anything that changes
usbPassthrough.nixos-rebuild switch.Expected: attached to
<app-vm>.Actual: attached according to the previous rules — for a USB drive, gui-vm, via the default
interfaceClass 8 / interfaceSubclass 6rule inmodules/hardware/passthrough/usb-rules.nix.The tell:
The unit predates the config by 29 minutes and was never restarted.
systemctl restart vhotplugfollowed by a replug routes the device correctly on the first attempt.Root cause
Three things compose badly:
vhotplug parses its config exactly once.
vhotplug/vhotplug.py:171buildsconfig = Config(args.config), andConfig.__init__(vhotplug/config.py:37) callsload(),a single
json.loadat line 41. There is no re-read and noSIGHUPhandler.Its
FileWatcherdoes not cover the config. vhotplug already uses inotify, but only on VMsocket paths —
vhotplug/vhotplug.py:180addsvm["socket"]for each VM, to detect VMrestarts so devices can be re-attached. The config file is never watched. (Worth stating
explicitly: the presence of a file watcher in the tree makes it look as though this is already
handled.)
The unit has no
restartTriggers.modules/hardware/passthrough/vhotplug.nix:184definessystemd.services.vhotplugwithExecStart = "... -a -c /etc/vhotplug.conf"(line 194) and norestart trigger for the file written at line 161.
Proposed fix
This embeds the config's store path into
X-Restart-Triggers=, so the unit file changes wheneverthe rules change and
switchrestarts the daemon.Caveats worth deciding on before merging
A restart does not re-home already-attached devices. vhotplug runs with
-a, but a devicealready attached to a VM is skipped, so a device plugged in before the switch stays where the
old rules put it. Replugging re-routes it. This is probably the right behaviour — detaching a
disk from a running VM during activation would be worse — but the fix should not be described as
making
switchalone sufficient.Restarting mid-session appeared harmless in our testing: PCI devices logged
is already attached to the VM with id vhp-pci-Nand nothing moved. That is a singleobservation on one machine, not a guarantee, and it is the main reason to consider the
alternative below.
Alternative: reload instead of restart. vhotplug could watch its own config and re-read it,
which avoids restart side effects entirely and reuses the inotify plumbing already present. That
is a change to
tiiuae/vhotplugrather than to ghaf. If that is preferred, this issue is really"ghaf should restart it until vhotplug can reload itself".
Impact
Any change to device routing on a running machine:
ghaf.hardware.passthrough.usb.guivmRules,netvmRules,audiovmRules, an app VM'susbPassthrough,prependUsbRules/postpendUsbRules,and the PCI, evdev and ACPI rule lists — all are written to the same file and all are affected.
The failure mode is the expensive kind: nothing errors, nothing logs, and the natural conclusion is
that the rule itself is wrong. We came close to concluding that
prependUsbRulesdoes not takeprecedence over the default rules, and re-planning around a bug that does not exist. What actually
identified it was comparing
ExecMainStartTimestampagainst the config's mtime.Environment
d9328b9— line numbers in this report refer to that revision4a0f407b2f96fc169f3cc83cda1924c456c169da, as pinned by ghaf'sflake.lockghaf.profiles.laptop-x86andhardware.passthrough.mode = "dynamic"