Skip to content

hyprexit fails with hl.dispatch: expected a dispatcher under Hyprland Lua config #31

Description

@jasonboukheir

Symptom

Pressing the logout binding (CTRL + SHIFT + ESCAPE, defined in
modules/omarchy/home-manager/hyprland/bindings.nix:46) runs the
hyprexit shellscript from modules/omarchy/packages.nix:45, which calls:

exec hyprctl dispatch exit

Hyprland responds with:

error: return hl.dispatch(exit):1: hl.dispatch: expected a dispatcher
(e.g. hl.dsp.window.close())

…and the compositor stays alive. Nothing reaches plasma-login-manager,
so no greeter ever comes back.

Root cause

The Hyprland config in this repo runs in Lua mode:

# modules/omarchy/home-manager/hyprland/default.nix
wayland.windowManager.hyprland = {
  enable = true;
  configType = \"lua\";
};

Under configType = \"lua\" (Hyprland 0.55+, the new Lua API), the
legacy hyprlang dispatcher syntax is no longer accepted. hyprctl dispatch <X> does not look up a dispatcher named <X> anymore;
instead, the daemon wraps the argument and runs it through the Lua VM
as:

return hl.dispatch(<X>)

So hyprctl dispatch exit becomes:

return hl.dispatch(exit)

…where exit is parsed as a bare Lua identifier, evaluates to
nil in the binding's scope, and hl.dispatch rejects nil with the
exact error shown above. The other hl.dsp.* callsites in
bindings.nix (e.g. hl.dsp.window.close(), hl.dsp.focus({...}))
work because they construct real dispatcher objects; only the exit
codepath, which is hidden behind hyprexithyprctl dispatch exit, slipped through.

This is the same class of breakage we already patched around in
modules/nixpkgs/overlays/waybar.nix for waybar (PR Alexays/Waybar#5013)
and that the upstream Hyprland discussions
#14255 and
#14282
document.

Background — correct dispatcher API under Lua mode

Three equivalent ways to spell "exit" from Lua mode:

  1. In a keybind handler (preferred): pass the dispatcher object
    directly to hl.bind:
    hl.bind(\"CTRL + SHIFT + ESCAPE\", hl.dsp.exit())
  2. Inside a Lua callback: explicitly invoke hl.dispatch:
    hl.bind(\"CTRL + SHIFT + ESCAPE\", function()
      hl.dispatch(hl.dsp.exit())
    end)
  3. From hyprctl / a shellscript: quote the Lua expression so
    hl.dispatch(...) gets a real call, not a bare identifier:
    hyprctl dispatch 'hl.dsp.exit()'

The thing that doesn't work anywhere on Lua mode is dispatch exit
without hl.dsp. — that's the legacy hyprlang spelling and is the
exact pattern emitting the error.

How to cleanly log out to plasma-login-manager

programs.hyprland.withUWSM is not enabled in this repo, so the
session model is:

plasma-login-greeter
  └── plasmalogin-helper (PAM session leader, lives in user scope)
       └── wayland-session = hyprland (the Hyprland binary itself,
                                       launched via the .desktop
                                       installed by programs.hyprland)

When Hyprland's exit dispatcher fires, the hyprland process
returns 0, the helper sees HELPER_SUCCESS, and PLM's
Display::slotHelperFinished runs Display::stopSeat::display StoppedSeat::createDisplay, which spawns a fresh greeter. This
is exactly the path the thebeast-dm-recovery test guards against
regressing in hosts/thebeast/tests/dm-recovery.nix.

So the right primitive for this repo's non-UWSM setup is unchanged:
make Hyprland exit cleanly via its own dispatcher. We just need
to spell the dispatcher in a form Lua mode accepts. We don't need
loginctl terminate-user (it trips HELPER_AUTH_ERROR per the existing
comment in packages.nix) and we don't need uwsm stop (UWSM isn't
running).

If we ever flip programs.hyprland.withUWSM = true, the logout
primitive changes to uwsm stop (and hl.dsp.exit() becomes
discouraged per the Hyprland wiki, because it skips UWSM's ordered
shutdown). That's a follow-up, not a fix for this bug. See open
questions below.

Proposed fix

Minimal, surgical change to keep the existing
hyprexit indirection (which the dm-recovery test pins to
hyprctl dispatch exit):

--- a/modules/omarchy/packages.nix
+++ b/modules/omarchy/packages.nix
@@
       (writeShellScriptBin \"hyprexit\" ''
-        exec \${hyprland}/bin/hyprctl dispatch exit
+        # Under configType=\"lua\" (set in
+        # modules/omarchy/home-manager/hyprland/default.nix) the legacy
+        # \`hyprctl dispatch exit\` lowers to \`hl.dispatch(exit)\` and
+        # Lua parses \`exit\` as a bare identifier (= nil), so
+        # hl.dispatch rejects it. The Lua-mode spelling is
+        # \`hl.dsp.exit()\`.
+        exec \${hyprland}/bin/hyprctl dispatch 'hl.dsp.exit()'
       '')

The dm-recovery test in hosts/thebeast/tests/dm-recovery.nix:86
currently asserts \"hyprctl dispatch exit\" in script. After the fix
the literal substring is still present (hyprctl dispatch 'hl.dsp.exit()' contains it), so the test stays green — but we should
tighten the assertion to the exact Lua-mode form so a future regression
back to legacy syntax is caught:

-        assert \"hyprctl dispatch exit\" in script, (
+        assert \"hyprctl dispatch 'hl.dsp.exit()'\" in script, (
             \"hyprexit must dispatch a clean hyprland exit so the \"
             ...
         )

Optional follow-up (nice but not required): drop the script
indirection and call the dispatcher directly from the keybind so we
stop round-tripping through hyprctl's string parser entirely:

--- a/modules/omarchy/home-manager/hyprland/bindings.nix
+++ b/modules/omarchy/home-manager/hyprland/bindings.nix
@@
-      (bind \"CTRL + SHIFT + ESCAPE\" (exec \"pkill -TERM steam; sleep 1; hyprexit\"))
+      (bind \"CTRL + SHIFT + ESCAPE\" (inline ''
+        function()
+          hl.dsp.exec_cmd(\"pkill -TERM steam\")()
+          hl.dispatch(hl.dsp.exit())
+        end
+      ''))

(Skipping that for now keeps the dm-recovery test's
"hyprexit is the single chokepoint" invariant intact.)

Open questions / links

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions