Skip to content

Make the plugin watcher die with the shell - #10177

Open
olafura wants to merge 2 commits into
omacom:quattrofrom
olafura:fix/plugin-watcher-dies-with-shell
Open

Make the plugin watcher die with the shell#10177
olafura wants to merge 2 commits into
omacom:quattrofrom
olafura:fix/plugin-watcher-dies-with-shell

Conversation

@olafura

@olafura olafura commented Sep 4, 2026

Copy link
Copy Markdown

Problem

PluginRegistry runs inotifywait -m -r … ~/.config/omarchy/plugins for the life of the shell and restarts it whenever it exits, but nothing ends it when the shell itself goes. Quickshell's Process only reaps its child on an orderly QML teardown, and the shell often does not leave that way: when the Wayland connection drops Qt calls _exit(), and SIGTERM or SIGKILL are just as abrupt. Each such exit leaves one inotifywait reparented to systemd --user, immortal because the plugins dir is quiet. Every one holds an inotify instance, so after enough compositor restarts the user hits fs.inotify.max_user_instances (1024) and nothing on the desktop can watch a file any more (#7150). The machine this was written on had 978 of them before they were cleared by hand.

#7230 attaches the cleanup to Component.onDestruction, but that hook never runs on the exits that cause the leak, which the live measurement in its review bears out. That is why this takes a different route.

Fix

Spawn the watcher under setpriv --pdeathsig TERM, the same kernel-level lifetime binding the clipboard watchers (Clipboard.qml, 60e0a2a) and omarchy-voxtype-status already rely on. PR_SET_PDEATHSIG fires when the thread that forked the child exits, and Quickshell starts Process commands synchronously on the QML thread, so it covers _exit(), SIGTERM and SIGKILL alike. setpriv is util-linux, so it is on every Omarchy install already.

This also closes #7124: the contract and screenshot tests end their shell with SIGTERM, which now takes the watcher with it.

The second commit deals with what is already there. The pdeathsig only helps shells started after it lands: an install that has been crashing for weeks keeps every watcher those crashes orphaned, and the update that brings the fix restarts the old shell, which orphans one more on its way out. So before the registry starts its own watcher it now runs pkill -f against any inotifywait still watching its plugins dir, from the same init step that creates the dir, the way Clipboard.qml reaps stale wl-paste watchers. The match is anchored on the exact plugins dir, so watchers of other dirs (another user's, a test fixture's) are left alone, and the shell's own watcher only starts once the reaper has exited, so it is never the one being reaped. This also covers the one path the pdeathsig leaves half open: Quickshell's crash handler re-execves the same PID in place, the forking thread does not exit there, and the old image's watcher would otherwise keep running beside the new one until the next real exit. The new image reaps it at start.

Test

test/shell.d/plugin-watcher-test.sh plants a watcher of the fixture's plugins dir, then launches a shell that runs only the registry (fixtures/plugin-watcher/shell.qml, no windows), waits for the shell's own watcher on that dir, checks the planted one is gone, SIGKILLs the shell and checks that the shell's watcher is gone too. It skips without a compositor, like the other Quickshell tests, and it kills both watchers itself on the way out so a failing run never leaks one.

Verification

  • The new test passes on this branch with a real inotifywait under Quickshell 0.3.1. It fails on unfixed quattro with plugin watcher outlived the shell, and with the pdeathsig but without the reaper with stale plugin watcher survived shell start.
  • The reaper's match, run as the exact init command against four live watchers: it kills the two on the plugins dir (argv[0] inotifywait and /usr/bin/inotifywait, the form older shells leave behind) and leaves the ones on a sibling dir and a subdirectory alone. The init step exits 0 when it reaped something and 1 when there was nothing, and the shell starts its watcher either way.
  • Running the tests next to a live Omarchy shell leaves that shell's watcher untouched, since the fixtures watch a temp dir.
  • SIGTERM to the shell, the way the contract and screenshot tests end it: the watcher survives on quattro and dies with this change. The contract test now leaves the inotifywait count unchanged.
  • Under Quickshell the wrapped watcher keeps one PID for as long as the shell lives, so the death signal is bound to a thread that outlives the QML engine and there is no restart churn.
  • Mechanism check outside Quickshell: a plain child survives its parent's _exit(), SIGKILL and SIGTERM and lands under systemd --user; the same child under setpriv --pdeathsig TERM dies in all three cases, and also dies after the parent re-execves itself in place and then exits.
  • setpriv --pdeathsig TERM execs the target in place: same PID, stdout, environment, uid and capabilities as without it. inotifywait neither ignores nor catches SIGTERM.
  • plugin-registry-contract-test.sh and qml-text-format-test.sh still pass.

Validated on a live shell

On this machine the real Omarchy shell has been exiting through the Wayland _exit() path about 350 times a day (an HDMI hotplug storm; 20,000 connector disconnect events since boot), which is how 978 watchers piled up. The fixed shell was run as the real session shell (launched through omarchy-launch-shell with OMARCHY_PATH pointing at this branch) and killed with SIGKILL twice so the launcher relaunched it:

  • Every step of the way the real plugins dir had exactly one inotifywait, owned by the live shell: before, after the clean stop, with the fixed shell up, after each relaunch, and after switching back to the packaged shell.
  • The killed shell's watcher was gone 86 ms after the kill, well inside the launcher's one-second relaunch delay, so it is the death signal that removes it, not the reaper in the next shell.
  • The reaper was exercised separately: a shell running this registry against the real plugins dir removed the twelve stale watchers left by earlier relaunches within two seconds of starting, and left the running shell's watcher and an unrelated inotifywait on another dir alone.

Fixes #7150
Fixes #7124

🤖 Generated with Claude Code

https://claude.ai/code/session_012GMSgUsKyhyZMRSSaXkm3F

olafura and others added 2 commits September 4, 2026 17:22
The plugin watcher is an inotifywait the registry restarts whenever it
exits, and nothing ends it when the shell itself goes. Quickshell only
reaps a Process on an orderly QML teardown, and the shell often does not
leave that way: Qt calls _exit() when the Wayland connection drops, and
SIGTERM or SIGKILL are just as abrupt. Every such exit left one immortal
inotifywait under systemd --user, each holding an inotify instance,
until the user's max_user_instances quota ran out and nothing on the
desktop could watch a file any more.

Spawn it under setpriv --pdeathsig TERM, the kernel-level lifetime
binding the clipboard watchers already use, so it dies with the shell
however the shell exits. The new test SIGKILLs a shell that runs only
the registry and checks that its watcher is gone.

Fixes omacom#7150
Fixes omacom#7124

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GMSgUsKyhyZMRSSaXkm3F
The pdeathsig only helps shells started after it landed. An install that
has been crashing for weeks still carries every watcher those crashes
orphaned, each holding one of the user's 1024 inotify instances, and
nothing collects them: the update that brings the fix restarts the old
shell, which orphans one more on its way out.

So before the registry starts its own watcher it kills any leftover copy
of it. The reap runs from the init step that creates the plugins dir,
whether or not mkdir succeeded, and the shell's own watcher only starts
once that step has exited, so it is never the one being reaped. This is
what the clipboard plugin already does for its wl-paste watchers.

The watcher argv lives in one property that both the process and the
reap pattern are built from, so the two cannot drift apart. pkill -f
matches an extended regex against each process's argv, so every argument
is escaped and the pattern is anchored at the end: it reaps exactly this
command, with or without the setpriv prefix, and leaves any other
inotifywait alone, whether it watches another user's dir, a test
fixture's, or was started by hand. A home path holding a regex
metacharacter cannot silently turn the match off either.

It also closes the one path the pdeathsig leaves half open: when the
crash handler execs a fresh shell in place, the PID survives, so the
parent never dies and the old image's watcher keeps running beside the
new one. The new image now reaps it at start.

The test plants a watcher with the shell's exact argv before starting
the shell and checks that it died from the shell's SIGTERM by the time
the shell's own watcher is up.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GMSgUsKyhyZMRSSaXkm3F
@olafura
olafura force-pushed the fix/plugin-watcher-dies-with-shell branch from 97d5ced to 2931e83 Compare September 4, 2026 15:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant