Supervise fcitx5 so XCompose sequences can't silently die

fcitx5 is what turns the CapsLock compose sequences in ~/.XCompose into
text for Wayland clients -- CapsLock m s for an emoji, CapsLock space n
for your name. It was launched fire-and-forget from Hyprland's autostart
via uwsm-app, so nothing supervised it, nothing restarted it, and it
logged nowhere. When it went away, every compose sequence stopped
working for the rest of the session with no visible symptom beyond
"emoji input is busted", and no record of why.

That is not hypothetical: it was found dead on a running machine with no
coredump, no OOM kill, and nothing in the journal to explain it.

Move it to a systemd user service:

- Restart=always, not on-failure. fcitx5 exits 0 when it finds another
  instance already owning org.fcitx.Fcitx5, and a clean exit still
  leaves the user with no input method.
- After=/PartOf=graphical-session.target. It needs WAYLAND_DISPLAY and
  DISPLAY, which uwsm imports into the user manager before reaching the
  target, and its wayland connection dies with the compositor.
- ConditionEnvironment=WAYLAND_DISPLAY. After= is ordering only and does
  not stop the unit from being started while the target is inactive. An
  update over SSH has a live user manager (pam_systemd) and no graphical
  session, and a fcitx5 started there comes up blind -- then stays
  active, so the later target activation won't pull in a working one,
  because Wants= does not restart what is already running. Skipping the
  start leaves the unit enabled and healthy for the next graphical login.

The migration hands over inside a live session only: it enables without
--now, and only when graphical-session.target is active does it drop the
autostart-launched process and start the unit. Because that kills a
fcitx5 that was working a moment ago, a failed start is reported instead
of leaving the session mute with the migration marked complete.

omarchy-restart-xcompose now drives the unit. It still clears any fcitx5
running outside it first: that process owns the bus name, so the unit's
instance would exit on arrival and the stale one would keep serving the
old table -- a restart that reports success and changes nothing.

Side benefit: fcitx5 now logs to the journal under its own unit, so the
next disappearance leaves a record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-07-27 09:49:15 -07:00
co-authored by Claude Opus 5
parent 03902f2460
commit 6e07fd0e82
8 changed files with 110 additions and 4 deletions
+6 -1
View File
@@ -2,4 +2,9 @@
# omarchy:summary=Restart the XCompose input method service (fcitx5) to apply new compose key settings.
omarchy-restart-app fcitx5 --disable notificationitem
# A fcitx5 running outside the unit owns org.fcitx.Fcitx5, so the unit's
# instance would exit on arrival and the stale process would keep serving the
# old ~/.XCompose -- a restart that reports success and changes nothing.
systemctl --user stop omarchy-fcitx5.service
pkill -x fcitx5 >/dev/null 2>&1 || true
systemctl --user start omarchy-fcitx5.service
-1
View File
@@ -4,7 +4,6 @@ hl.on("hyprland.start", function()
hl.exec_cmd("dbus-update-activation-environment --systemd --all")
hl.exec_cmd("quickshell -n -p $OMARCHY_PATH/shell")
hl.exec_cmd(o.launch("fcitx5 --disable notificationitem"))
hl.exec_cmd("omarchy-first-run")
hl.exec_cmd("omarchy-powerprofiles-init")
hl.exec_cmd(o.launch("omarchy-hyprland-monitor-watch"))
@@ -0,0 +1,31 @@
[Unit]
Description=Fcitx5 input method (XCompose sequences)
# fcitx5 turns the CapsLock compose sequences in ~/.XCompose into text for
# Wayland clients.
#
# Wait for the compositor: fcitx5 needs WAYLAND_DISPLAY and DISPLAY, which uwsm
# imports into the user manager before it reaches graphical-session.target.
After=graphical-session.target
# The wayland connection dies with the compositor, so follow the session rather
# than linger against a socket that is gone.
PartOf=graphical-session.target
# After= is ordering only -- it does not stop anything from starting this unit
# while the target is inactive. An `omarchy update` over SSH has a live user
# manager (pam_systemd) and no graphical session, and a fcitx5 started there
# comes up with no WAYLAND_DISPLAY and no way to reach any client. Worse, it
# stays active, so the later graphical-session.target activation won't pull in a
# working one -- Wants= does not restart what is already running. Skip the start
# instead; the unit stays enabled and starts for real at graphical login.
ConditionEnvironment=WAYLAND_DISPLAY
[Service]
Type=simple
# notificationitem duplicates the tray entry omarchy already renders itself.
ExecStart=/usr/bin/fcitx5 --disable notificationitem
# always, not on-failure: fcitx5 exits 0 when it detects another instance owning
# its bus name, and a clean exit still leaves the user with no input method.
Restart=always
RestartSec=2
[Install]
WantedBy=graphical-session.target
+2 -1
View File
@@ -228,7 +228,8 @@ systemd instance:
Voxtype post-update hook.
- `install/user/first-run/enable-user-units.sh``systemctl --user enable`
the shipped user units (`bt-agent`, `omarchy-sleep-lock`,
`omarchy-recover-internal-monitor`, `omarchy-migrate-notify.service`).
`omarchy-recover-internal-monitor`, `omarchy-migrate-notify.service`,
`omarchy-fcitx5.service`).
Done here, not at finalize, because
the user manager isn't reachable from the ISO chroot; `ConditionPath*`
in the unit files keeps services inert when they don't apply.
+2 -1
View File
@@ -16,4 +16,5 @@ systemctl --user enable --now \
bt-agent.service \
omarchy-recover-internal-monitor.service \
omarchy-sleep-lock.service \
omarchy-migrate-notify.service
omarchy-migrate-notify.service \
omarchy-fcitx5.service
+38
View File
@@ -0,0 +1,38 @@
echo "Supervise fcitx5 so CapsLock compose sequences can't silently die"
# fcitx5 was launched fire-and-forget from Hyprland's autostart. Nothing
# restarted it and nothing noticed when it went away, so a single lost start
# left every ~/.XCompose sequence (CapsLock m s -> emoji) dead for the rest of
# the session. It's a systemd user service with Restart=always now.
systemctl --user daemon-reload >/dev/null 2>&1 || true
# Enable without --now, and start by hand further down only when there is a
# session to start into. `systemctl enable` needs a live user manager, which an
# `omarchy update` from a TTY does not have, so fall back to writing exactly the
# symlink it would have written rather than silently leaving this unenabled.
if ! systemctl --user enable omarchy-fcitx5.service >/dev/null 2>&1; then
wants_dir="$HOME/.config/systemd/user/graphical-session.target.wants"
mkdir -p "$wants_dir"
ln -sfn /usr/lib/systemd/user/omarchy-fcitx5.service \
"$wants_dir/omarchy-fcitx5.service"
fi
# Outside a graphical session -- an update over SSH -- there is nothing to hand
# over: no autostart-launched fcitx5 to kill, and the unit's own
# ConditionEnvironment would skip the start anyway. The enablement above is the
# whole job; the next graphical login starts it.
if systemctl --user is-active --quiet graphical-session.target; then
# The service and the autostart-launched process can't coexist: a second
# fcitx5 sees the first owning org.fcitx.Fcitx5 and exits 0, which
# Restart=always would turn into a restart loop. Drop the stray first.
pkill -x fcitx5 >/dev/null 2>&1 || true
# Report what systemctl actually said. This kills a fcitx5 that was working a
# moment ago, so a start failure here has to be loud instead of leaving the
# session with no input method and a migration marked complete.
if ! error=$(systemctl --user start omarchy-fcitx5.service 2>&1); then
echo "Could not start omarchy-fcitx5.service: $error"
echo "Compose sequences (CapsLock m s) will not work until the next login."
fi
fi
+1
View File
@@ -139,6 +139,7 @@ package_defaults = [
("default/systemd/user/omarchy-recover-internal-monitor.service", "/usr/lib/systemd/user/omarchy-recover-internal-monitor.service", "systemd/user/omarchy-recover-internal-monitor.service"),
("default/systemd/user/omarchy-migrate-notify.service", "/usr/lib/systemd/user/omarchy-migrate-notify.service", "systemd/user/omarchy-migrate-notify.service"),
("default/systemd/user/omarchy-tailscale-receive.service", "/usr/lib/systemd/user/omarchy-tailscale-receive.service", "systemd/user/omarchy-tailscale-receive.service"),
("default/systemd/user/omarchy-fcitx5.service", "/usr/lib/systemd/user/omarchy-fcitx5.service", "systemd/user/omarchy-fcitx5.service"),
("default/systemd/zram-generator.conf.d/90-omarchy.conf", "/usr/lib/systemd/zram-generator.conf.d/90-omarchy.conf", "systemd/zram-generator.conf.d/90-omarchy.conf"),
("default/fonts/omarchy/omarchy.ttf", "/usr/share/fonts/omarchy/omarchy.ttf", "omarchy.ttf"),
("default/snapper/root", "/etc/snapper/config-templates/omarchy", "snapper/root"),
+30
View File
@@ -46,3 +46,33 @@ grep -F 'omarchy-migrate-notify.service' "$first_run_units" >/dev/null ||
grep -F 'omarchy-update-user-notify' "$first_run_units" >/dev/null &&
fail "first-run still enables the retired notifier units"
pass "first-run enables the login-only migration notifier"
fcitx_service="$ROOT/default/systemd/user/omarchy-fcitx5.service"
grep -Fx 'ExecStart=/usr/bin/fcitx5 --disable notificationitem' "$fcitx_service" >/dev/null
grep -Fx 'Restart=always' "$fcitx_service" >/dev/null ||
fail "fcitx5 exits 0 on a duplicate bus name, so on-failure would leave the user with no input method"
grep -Fx 'After=graphical-session.target' "$fcitx_service" >/dev/null ||
fail "fcitx5 needs WAYLAND_DISPLAY, which uwsm imports before it reaches graphical-session.target"
grep -Fx 'PartOf=graphical-session.target' "$fcitx_service" >/dev/null ||
fail "fcitx5 must stop with the compositor instead of lingering against a dead wayland socket"
grep -Fx 'WantedBy=graphical-session.target' "$fcitx_service" >/dev/null ||
fail "fcitx5 is never pulled in at login without a WantedBy"
grep -Fx 'ConditionEnvironment=WAYLAND_DISPLAY' "$fcitx_service" >/dev/null ||
fail "an update over SSH has a live user manager and no display; starting fcitx5 there wedges the unit active-but-blind, and Wants= will not replace it at graphical login"
fcitx_migration="$ROOT/migrations/1785167800.sh"
grep -F 'is-active --quiet graphical-session.target' "$fcitx_migration" >/dev/null ||
fail "migration kills fcitx5 and starts the unit outside a graphical session"
grep -F 'systemctl --user enable omarchy-fcitx5.service' "$fcitx_migration" >/dev/null ||
fail "migration must enable without --now; --now starts the unit before the session-gate check"
grep -F 'Could not start omarchy-fcitx5.service' "$fcitx_migration" >/dev/null ||
fail "migration pkills a working fcitx5, so a failed handover must be reported instead of marked complete"
grep -F 'pkill -x fcitx5' "$ROOT/bin/omarchy-restart-xcompose" >/dev/null ||
fail "restart-xcompose cannot reload a fcitx5 running outside the unit, so it silently keeps serving the old table"
grep -F 'omarchy-fcitx5.service' "$first_run_units" >/dev/null ||
fail "first-run does not enable the input method, so ~/.XCompose sequences never resolve"
grep -F 'fcitx5' "$ROOT/default/hypr/autostart.lua" >/dev/null &&
fail "fcitx5 is autostarted from Hyprland; an unsupervised launch dies silently and takes every compose sequence with it"
pass "fcitx5 runs supervised, so a lost input method comes back instead of killing XCompose until logout"