Skip to content

Read keybindings from the running compositor instead of replaying configuration - #11733

Open
jkarmel wants to merge 3 commits into
omacom:quattrofrom
jkarmel:fix/live-keybindings-registry
Open

jkarmel wants to merge 3 commits into
omacom:quattrofrom
jkarmel:fix/live-keybindings-registry

Conversation

@jkarmel

@jkarmel jkarmel commented Sep 13, 2026

Copy link
Copy Markdown

Opening the keybindings menu with Super+K can leave a Lua process running forever. The menu never appears, but its background process keeps using an entire CPU core. Pressing Super+K again can leave another process running.

On the machine that prompted this PR, 14 stuck processes saturated all 12 logical CPUs. Thirteen came from repeated menu launches; another came from omarchy-menu-keybindings --print and had been running for about 43 hours. Another user reported 63 processes consuming about 56 GB of RAM before their desktop session was killed.

This PR makes the menu read shortcuts from the running Hyprland process. Hyprland already knows what each shortcut does. The menu shows the shortcuts, then tells Hyprland which one the user selected. Hyprland runs that shortcut's original action.

Fixes #7025. AI helped diagnose and implement this change.

Why the menu gets stuck

The current menu runs the user's configuration again in a separate Lua process. It uses fake Hyprland objects to discover shortcuts and reconstruct the commands to run when someone selects them.

Those objects behave differently from Hyprland. This loop is enough to make the keybindings menu freeze:

for _, workspace in ipairs(hl.get_workspaces()) do end

The loop goes through the workspaces one at a time. In Hyprland, it reaches the end of the list and stops.

The menu runs the same code using a fake workspace list. Whenever the loop asks for the next workspace, that list returns another placeholder—even when it should say there are no more. Specifically, the catch-all placeholder returns itself for every numeric index, so ipairs never reaches nil. The loop never ends, so the menu never opens and its background process keeps using CPU.

On the affected machine, the loop was in a user module that called hl.get_workspaces() to calculate terminal margins. Hyprland loaded that configuration normally, with no configuration errors. A normal loop over monitors can trigger the same problem.

The old scanner exists because Hyprland's bindings query did not expose all the original key spellings and actions the menu needed. Returning empty tables for individual list getters would stop this loop, but the scanner would still run user configuration against an incomplete imitation of Hyprland. Maintaining it requires following what the fake objects return, how they reconstruct commands, and which results the disk cache serves.

How the replacement works

A small collector loads inside Hyprland before the default and user bindings are registered. It keeps a lookup table: binding ID → real binding and original action.

  1. Record each binding when it is registered. The collector wraps the real hl.bind call and returns the original native handle—the binding object supplied by Hyprland. It keeps the original action, including Lua functions and the variables they captured when created.
  2. Read the current list. The menu requests one JSON snapshot through hyprctl repl, with a five-second client timeout. The binding objects supply the original key spellings, descriptions, submaps, and whether each binding is enabled. New bindings and enable/disable changes appear without a disk cache.
  3. Run the selected action. The menu sends back the binding ID. The collector checks that the binding can still be used, then calls hl.dispatch with its original action.

The menu also sends an identifier for the configuration load it read. Suppose someone opens the menu, then reloads the configuration while the menu is still open. The shortcuts may have changed, so the menu's list could be out of date. If the identifier no longer matches, the selection is rejected. Restarting Hyprland also invalidates old selections.

Removed and disabled bindings cannot run. When the collector scans an expired binding, it releases the action it had kept for it.

The menu preserves shortcut ordering, keycode display, browser-extension shortcuts, and groups of alternative key combinations for the same action. Those default aliases now share the same action object. Separate actions with identical labels remain independently selectable.

This removes the second Lua interpreter, fake Hyprland API, reconstructed callback expressions, and disk cache. It does not need access to Lua's private registry. Keeping the original functions also addresses bindings that appear in the menu but do nothing when selected.

Upgrade and error handling

The collector must load before bindings are registered. Omarchy's shipped configuration already loads the required bootstrap file in that order.

The upgrade migration attempts to reload Hyprland. A stale connection identifier for a compositor that has already exited does not block the remaining migrations.

For custom configurations that omit the bootstrap file, the migration and menu provide the exact dofile line to add and explain where to put it, before bindings and imports. They leave the user's Lua configuration unchanged. The suggested line matches the shipped configuration, including the /usr/share/omarchy fallback when OMARCHY_PATH is unset.

Failures to list or run a shortcut appear as desktop notifications. This includes Lua errors returned by hyprctl even when the command exits with status zero. The bootstrap notification stays visible for 30 seconds and opens the full recovery instructions in a terminal, so the notification's three-line limit does not cut them off. In print mode, errors go only to stderr.

Hyprland compatibility

Hyprland 0.56.2 can crash if is_enabled() is called on an expired native keybinding handle. This was reproduced without the collector: create a binding, remove it, then call is_enabled() on its handle.

The collector first checks the handle's safe tostring result for HL.Keybind(expired) before accessing properties or methods. This check is isolated and covered by a focused test and VM tests that remove and unbind real bindings.

Testing

The latest follow-up, 901a57d0, passed all 25 keybindings acceptance checks, both focused shell tests, and the CLI suite in an isolated Omarchy 4.0.3 / Hyprland 0.56.2 VM. The approving reviewer also confirmed that the original hang was fixed and all 25 acceptance checks passed in their VM.

The most recent full shell run passed 238 of 239 test files. The remaining network-qr-test.sh failure also occurs on the unchanged PR base: the isolated VM has no default route, so its unstubbed ip route get 1.1.1.1 exits 2. No unrelated network code was changed. The full shell suite was not rerun after the final follow-up.

Detailed test coverage, earlier results, and timing measurements

Initial testing

Testing used a disposable QEMU VM running Omarchy 4.0.3-1 and Hyprland 0.56.2-2, with two CPUs and 2 GiB RAM. The working checkout was installed with omarchy-dev-link.

  • Original bug: With the one-line configuration above and a fresh cache, the old menu ran until a two-second timeout stopped it (exit 124).
  • Initial full suite: ./test/all passed the CLI tests and all 239 shell test files. The repository's package and ISO test dependencies were provided. Inherited display, locale, and NO_COLOR overrides were removed, and Python bytecode generation was disabled for the run.
  • Focused menu and collector tests: Covered JSON escaping, punctuation and control characters, duplicate labels, keycodes and modifier aliases, whether actions are the same object, functions with captured variables, disabled and expired handles, repeated installation, selections from an earlier configuration load, invalid IPC responses, and migration behavior with and without a running Hyprland session.
  • External Lua check: The focused test records any attempt to start an external Lua interpreter, even if its failure is ignored. During review, this replaced an assertion whose fake interpreter exited before the loop could run. Actual loops over live lists are covered by the VM acceptance test.
  • VM acceptance test: test/acceptance.d/keybindings-test.sh passed. It covered all three live list getters; twelve simultaneous listings without repeating configuration side effects; native dispatchers; functions with captured variables; submaps; disabled, unbound, and removed bindings; re-enabling bindings; rejecting outdated selections; and graphical menu search and selection.
  • Manual keyboard and UI checks: Keyboard input sent through QEMU's QMP interface opened a real terminal and the keybindings menu using global shortcuts. A menu selection closed the terminal through its native dispatcher. Functions with captured variables and separate actions with the same label ran independently. Escape ran nothing. Saved screenshots were checked for alignment and clipping. These QMP checks were manual and are not automated by the committed acceptance test.
  • Reload and upgrade checks: Twenty consecutive reloads each produced a distinct configuration-load identifier. Hyprland stayed running with no configuration errors. A simulated upgrade from a session without the collector verified the reload message and successful activation by the migration. The final acceptance rerun passed, and the VM's test configuration was restored.

September 14 review follow-up

The updated checkout was tested in a QEMU VM running Omarchy 4.0.3 and Hyprland 0.56.2.

  • All 25 acceptance checks passed. They included actual menu selections rejected after disabling a binding or reloading; visible notifications and the action that opens recovery instructions; migrations with no running compositor, a stale compositor connection, and a live compositor; a real custom configuration that omitted the bootstrap; and restoring the menu by adding the documented bootstrap line. Notification and instruction screenshots were inspected.
  • The CLI suite and 238 of 239 shell test files passed after restoring Node's install path and a UTF-8 locale for reruns affected by the environment. The remaining network failure is described above.

Final follow-up: 901a57d0

  • All 25 acceptance checks, both focused shell tests, and the CLI suite passed. The full shell suite was not rerun.
  • Tests execute the bootstrap recovery line with OMARCHY_PATH unset and with a custom install path.
  • The acceptance test checks that notifications invokeLast returns ok, so an expired notification produces a direct diagnostic.
  • A separate check waited 12 seconds before invoking the notification and confirmed that it was still visible and opened the complete instructions. Screenshots and the timing capture were inspected.

Timing

Across twenty samples in the initial VM:

  • Live JSON snapshot: median 6.26 ms.
  • Complete --print: median 36.84 ms, maximum 41.89 ms.

These are VM measurements, not a direct speedup comparison against the affected host.

Architecture and repeatable test commands are documented in docs/keybindings-menu.md.

Related reports and PRs

Eight reports describe the same scanner loop: #7025, #8819, #9052, #9395, #10214, #10737, #10972, #11094.

Eight other PRs propose fixes: #7564, #8876, #10212, #10388, #10562, #11027, #11095, #11867.

Related work covers repeated cache rebuilds (#8140 / #8229) and Lua function bindings that appear in the menu but do nothing when selected (#11851). This PR addresses those problems by removing the cache and keeping the original functions.

@chadmandoo chadmandoo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested head 964bfe1 on quattro b679363: the suites on this box, and a QEMU guest running Omarchy 4.0.3 with the tree dev-linked so Hyprland loads the collector.

Two things to change in the migration:

  1. hyprctl reload runs bare, and omarchy-migrate runs each file with bash -euo pipefail. A HYPRLAND_INSTANCE_SIGNATURE that names a compositor that is gone (a tmux session or a shell that outlived a restart) makes hyprctl exit 4, the migration stays pending and the queue would stop behind it. 1784767406.sh and 1787618700.sh guard the same call with >/dev/null 2>&1 || true; the update hook already forces a reload before migrations run, so the guard costs nothing.
  2. The collector is installed only by bootstrap.lua, and the migration assumes every entrypoint loads it. 1781043107.sh exists because some hand-edited hyprland.lua files never dofile bootstrap and set package.path themselves; on those, omarchy_keybindings never exists and SUPER + K fails on every open, with nothing on screen. Either insert the dofile line when grep -F '/default/hypr/bootstrap.lua' misses, or say which line is missing.

What holds: #7025 reproduces on quattro, where the one-line ipairs reproducer in a fake home makes the old --print hang until killed (10 s, exit 124). In the guest on this branch --print lists 224 bindings in 40 ms, and still does after the reproducer is appended to the real hyprland.lua and reloaded, with no lua process left behind. Your test/acceptance.d/keybindings-test.sh passes in the guest, all 11 assertions; ./test/all gains 7 assertions and loses none, with locate-test.sh failing the same way on both trees.

Three smaller things inside the change: the "Reload Hyprland" message goes to stderr while SUPER + K launches the menu without a terminal, and a rejected invoke (stale generation, disabled binding) is discarded the same way, so both look like nothing happened; omarchy-notification-send would show them. keybindings-menu-test.sh writes the while true config and stubs lua with exit 99 alongside it, so that assertion does not exercise a loop. docs/keybindings-menu.md says QMP keyboard checks verify global shortcuts; nothing in the tree does that.

Testing performed by Claude Fable 5.1 via Claude Code; reviewed by me before posting.

@jkarmel

jkarmel commented Sep 14, 2026

Copy link
Copy Markdown
Author

Addressed in 2222babc: guarded reload failures, added explicit bootstrap recovery instructions, and made menu failures visible through notifications. Tightened the interpreter test and clarified which QMP checks were manual.

All 25 VM acceptance checks pass, including custom-entrypoint recovery and stale/disabled selections. CLI and 238/239 shell tests pass; the remaining Wi-Fi test fails identically on the unchanged base because the isolated VM lacks a default route.

Ready for another look—thanks for the thorough review.

@chadmandoo chadmandoo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retested head 2222bab on quattro b679363: the suites on this box, and the same QEMU guest running Omarchy 4.0.3 with the tree dev-linked.

Both changes hold. The migration under bash -euo pipefail, with a real hyprctl and a signature that names no compositor, exits 0 (964bfe1 exited 4); with a live signature it reloads once per run, without one it never calls hyprctl. A hyprland.lua that sets package.path itself and never loads bootstrap gets the dofile line printed on every run and stays byte for byte the same. Your keybindings-test.sh passes in the guest, all 25 assertions: the toast after a disabled binding and after a reload, the offline, expired and live migration runs against the custom entrypoint, the bootstrap toast, the click that opens the full text in a terminal, and the collector coming back once the documented line is added. On this box, whose compositor has no collector, --print writes the recovery text to stderr and sends nothing, and the interactive path sends one notification with the two-line message as a single argument and the terminal command on --exec. ./test/all gains 10 assertions on this box and in the guest and loses none; the two tightened shell tests fail on 964bfe1 at exactly their new assertions and pass on 2222bab. With the #7025 reproducer in the guest's real hyprland.lua, --print still lists 224 bindings in 43 ms. docs/keybindings-menu.md now calls the QMP checks manual.

One improvement inside the change. The recovery line at migrations/1789328800.sh:8 and bin/omarchy-menu-keybindings:92 is dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua"). The shipped config/hypr/hyprland.lua:4 and default/hypr/bootstrap.lua:37 write (os.getenv("OMARCHY_PATH") or "/usr/share/omarchy"), paths.lua falls back the same way, and migration 1781063758.sh inserted the guarded form. uwsm exports the variable before Hyprland starts, so the line works in a normal session. Where it is missing, the line raises at load and nothing after it runs, so the user has no bindings at all. Suggested: dofile((os.getenv("OMARCHY_PATH") or "/usr/share/omarchy") .. "/default/hypr/bootstrap.lua"), the line hyprland.lua ships. keybindings-menu-test.sh:285, keybindings-registry-test.sh:46 and acceptance.d/keybindings-test.sh:170 grep for the exact string and :182 applies it, so the change is those three tests plus the two sources.

One thing inside your test. keybindings-test.sh:174-178 needs the bootstrap toast still on screen when omarchy-shell notifications invokeLast runs, and a normal-urgency popup expires after 8 s (Service.qml normalPopupDuration). screen_contains captures at 2x and runs tesseract, so one missed pass on a slow guest lands after the popup is gone; invokeLast then prints "none" with exit 0, the output is discarded, and the file fails at "notification opens complete setup instructions". It passed here, the whole file in 37 s. Checking that invokeLast printed ok would fail with the right message, and a longer -t on that one notification would give a user more than 8 s to click as well.

Not run: omarchy-migrate end to end in the guest. The dev-linked tree carries fifteen migrations newer than the installed 4.0.3, so the queue stops before this one; the direct runs above stand in for it.

Testing performed by Claude Fable 5.1 via Claude Code; reviewed by me before posting.

@jkarmel

jkarmel commented Sep 14, 2026

Copy link
Copy Markdown
Author

Addressed both follow-ups in 901a57d0.

  • The migration and menu now print the same guarded bootstrap line as the shipped configuration, including the /usr/share/omarchy fallback. Updated all three affected tests and added a check that executes the recovery line with OMARCHY_PATH unset and with a custom install path.
  • The bootstrap notification now stays up for 30 seconds. The acceptance test checks that notifications invokeLast returns ok, so an expired notification fails with a direct diagnostic.

Validated the updated checkout in the isolated Omarchy 4.0.3 / Hyprland 0.56.2 VM: all 25 keybindings acceptance checks, both focused shell tests, and the CLI suite passed. A separate delayed-invocation check confirmed that the notification remains visible and opens the complete instructions after waiting 12 seconds; screenshots and the timing capture were inspected. The full shell suite was not rerun for this follow-up.

Ready for another look—thanks for catching these.

@chadmandoo chadmandoo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Head 901a57d on quattro 86a2e58. The #7025 hang reproduces on quattro and is gone on this head. The live listing, the migration and the recovery path ran in a QEMU guest running Omarchy 4.0.3 with the tree dev-linked, through all 25 of the PR's acceptance assertions. Both rounds of review notes are closed with nothing left to change. Details in my comments above.

Testing performed by Claude Fable 5.1 via Claude Code; reviewed by me before posting.

@jkarmel

jkarmel commented Sep 15, 2026

Copy link
Copy Markdown
Author

I want to make the stakes and the reason for this approach clearer.

Opening the keybindings menu can leave the whole machine struggling. When the scan hangs, the menu never appears, but its Lua process keeps running. Pressing Super+K again can leave another process behind. On the machine that prompted this PR, 14 stuck processes saturated all 12 logical CPUs; one had been running for about 43 hours. In #7025, another user reported 63 processes consuming about 56 GB of RAM, followed by the desktop session being killed.

The confusing part is that the user's configuration works normally in Hyprland. The old menu runs that same configuration again in a separate Lua process, where Hyprland is replaced by fake objects. Some pretend to be commands and build up text describing what to run later. Others stand in for almost everything else: asking for a monitor, one of its properties, or the next item in a list can all return the same placeholder object.

That means the same configuration has two different meanings. In Hyprland, “loop over the monitors” walks a real list and finishes. In the menu's imitation, every request for the next item gets another placeholder, so the loop never ends. Code that expects a number can receive that same object and fail instead. Those errors are normally hidden, so the scan can silently skip the bindings that come afterward.

To understand or change the old menu, you have to follow several steps: what the fake objects return, how fake commands become text, how those results are matched back to Hyprland's binding list, and which result the cache serves. The menu is maintaining a second interpretation of the configuration, and every behavior the imitation misses can create another mismatch. Plain Lua functions are another gap: the scanner cannot reconstruct their actions, so they can appear in the menu without working when selected.

The replacement is a small lookup table inside the running Hyprland process: binding ID → real binding and original action.

  1. When Hyprland registers a binding, we give it an ID and keep a reference to its real binding and action. For example, binding 42 might be “Reset zoom,” pointing to the actual function already loaded in Hyprland.
  2. The menu gets the binding's ID, keys, and description. It displays “Reset zoom” without needing to understand or recreate that function.
  3. When the user selects it, the menu sends back 42. The collector looks up that entry, checks that the binding is still enabled, and asks Hyprland to run the original action. The function keeps the variables it captured when it was created.

The menu also sends back an identifier for the configuration load it read. If Hyprland has reloaded, an old selection is rejected, so yesterday's 42 cannot accidentally run a new binding assigned that number. Removed or disabled bindings are rejected too. Two actions with the same display name still have separate binding IDs.

The menu no longer needs to rerun the configuration, imitate Hyprland, reconstruct commands, or maintain a disk cache. It needs to know which binding the user selected; Hyprland already has the action.

I found at least eight issue reports and eight other PRs about the same scanner loop:

There is related work on repeated cache rebuilds (#8140 / #8229) and function bindings that do nothing when selected (#11851). Removing the cache and retaining the original actions addresses those problems as well.

How this differs from the other fixes

The eight other PRs addressing the hang keep the separate Lua scanner. They change the fake objects so list iteration stops; some also teach those objects to handle comparisons and arithmetic. #7564 and #11027 add a timeout, and #11027 also reports interrupted scans and prevents their incomplete results from being cached. Those changes address real failures in the existing approach.

This PR removes the need for that imitation. Hyprland loads the configuration, and we keep a lookup table from binding IDs to the real bindings and their original actions. The menu reads the list and sends back the selected ID. It no longer needs to execute user configuration in a second environment or reconstruct what a shortcut should do.

That also covers problems beyond the loop: the cache rebuild issue in #8140 / #8229 disappears because there is no disk cache, and function actions covered by #11851 work because we retain the original functions. The tradeoff is a broader change: it adds a collector inside Hyprland that must load before bindings are registered. The smaller fixes can stay within the existing scanner.

The latest review reproduced the original hang, confirmed it was gone with this change, and passed all 25 acceptance checks in an Omarchy VM.

Next steps for merging

For the maintainers: what is the process for getting this merged, and what else do you need from me? The PR now has an approving review, with all 25 acceptance checks passing in the reviewer’s VM. Given the reports of runaway processes exhausting memory and killing the Omarchy desktop session, I’d like to help get a fix to users soon. Is there another review, test, or change needed before this can merge?

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.

omarchy-menu-keybindings hangs forever when a user Lua config iterates hl.get_monitors()

2 participants