Skip to content

fix(keybindings): keep the Lua bind scan alive past qconsole.lua - #13276

Open
playGitboy wants to merge 1 commit into
omacom:quattrofrom
playGitboy:fix/lua-bind-scan-stub
Open

playGitboy wants to merge 1 commit into
omacom:quattrofrom
playGitboy:fix/lua-bind-scan-stub

Conversation

@playGitboy

@playGitboy playGitboy commented Sep 25, 2026 •

Copy link
Copy Markdown

Problem

Bindings declared in a user's ~/.config/hypr/bindings.lua lose their dispatcher and argument, so the keybindings menu can render them but never dispatch them.

Because Hyprland's Lua config reports every bind as dispatcher=__lua with an opaque argument, omarchy-menu-keybindings reconstructs the real dispatcher by re-running the config in Lua against a stub (build_lua_bind_cache). That scan dies partway through:

$ DEBUG=1 omarchy menu keybindings --print
[DEBUG] lua bind scan failed: /usr/share/omarchy/default/hypr/qconsole.lua:123: attempt to compare number with table

qconsole.lua:97 evaluates mon.scale > 0, and the stub answers hl.get_active_monitor() with a table that has no __lt and no arithmetic metamethods. The load is wrapped in a bare pcall (:234) that only reports when DEBUG=1, so nothing surfaces in normal use.

The crash position is what makes it damaging. qconsole is required after all the default binds but before require("hypr.bindings") — so the scan dies immediately before the user's own config is reached. dynamic_bindings() then overwrites the __lua dispatcher/index Hyprland did report with the two empty strings it found in the (necessarily empty) source map:

if [[ $dispatcher == "__lua" && -n $description && -n $key ]]; then
  dispatcher="${LUA_BIND_DISPATCHER_MAP["$cache_key"]}"   # -> ""
  arg="${LUA_BIND_ARG_MAP["$cache_key"]}"                # -> ""
fi

Measured on Hyprland 0.56.2: 22 of 260 cached rows had an empty dispatcher and argument, and they were exactly the ones from bindings.lua — super+w, super+c, super+v, super+x, print, plus everything other plugins write into that file. The 239 Hyprland defaults were unaffected.

This also breaks downstream consumers that read ~/.cache/omarchy/keybindings-*.records to resolve a chord. The virtual keyboard at simonezpx3/omarchy-vkeyboard does this: it looks a combo up in the records, and falls back to wtype when the lookup comes back empty. Since Hyprland does not route Wayland virtual-keyboard-v1 events into its own bindings, that fallback is a silent dead end — so the user sees a modifier that appears to do nothing.

Fix

Teach the stub to survive being read: comparison, arithmetic and concatenation now return inert values rather than raising.

The non-obvious part, and the one that will bite first — __index must return nil for positive integer keys:

Lua 5.4+ ipairs() honours __index. A stub that answers t[1], t[2], … never yields nil, so any config that iterates an API result loops forever. My first attempt (plain __index → stub) hung the scan until I killed it.

Also bump the cache key v13 → v14 so already-poisoned keybindings-*.records files are regenerated instead of reused — otherwise the fix appears to do nothing on an affected machine.

Verification

before:  22 rows with empty dispatcher + argument
after:    6

The 16 that this fixes are every binding coming from a user's
bindings.lua. The 6 that remain are a separate, pre-existing limitation and
are not addressed by this PR — they pass a Lua closure as the dispatcher,
which cannot be serialised into a text row at all:

o.bind("SUPER + C", "Universal copy", universal_clipboard_shortcut("CTRL", "C", "CTRL", "Insert"))
o.bind("SUPER + CTRL + ALT + Z", "Reset zoom", function() hl.config({...}) end)

o.bind forwards a non-string dispatcher to hl.bind untouched
(helpers.lua:101), so the record ends up with an empty kind and arg by
design. super+c/super+v/super+x are still reachable from an on-screen
keyboard because that tool special-cases them itself, and 32 + c is a
Hyper-layer bind.

and the affected rows now resolve:

SUPER + W  -> Close window permanently     exec  /…/bin/reprieve close
SUPER + Z  -> Restore parked window        lua   hl.dsp.global("…:undo")
SUPER ALT + W -> Park window (Reprieve)    exec  /…/bin/reprieve park
PRINT      -> Screenshot (save fullscreen) exec  omarchy capture screenshot fullscreen save

super+w, super+return and the other affected chords then dispatch correctly.

Note the scan is inherently a best-effort replay: a config that depends on real runtime state can still fail, and the pcall will still swallow it. Worth considering a follow-up that surfaces the failure instead of hiding it, but that is a behaviour change and I have kept this PR to the actual defect.

`hyprctl binds` reports every Lua-config binding as dispatcher `__lua` with
an opaque argument, so omarchy-menu-keybindings reconstructs the real
dispatcher by re-running the user's config in Lua against a stub
(build_lua_bind_cache). That scan aborts early:

    $ DEBUG=1 omarchy menu keybindings --print
    [DEBUG] lua bind scan failed: .../qconsole.lua:123: attempt to compare number with table

qconsole.lua:97 evaluates `mon.scale > 0`, and the stub answers
`hl.get_active_monitor()` with a table defining neither __lt nor any
arithmetic metamethod. The whole load sits in a bare `pcall` (line 234) that
reports only when DEBUG=1, so the failure is invisible in normal use.

The crash lands in the worst possible place. qconsole is required after all
the default binds but before `require("hypr.bindings")`, so the scan dies
immediately before the user's own config is reached, and every binding
declared there loses its dispatcher and argument. dynamic_bindings() then
overwrites the __lua dispatcher/index that Hyprland did report with the two
empty strings it found in the necessarily empty source map:

    if [[ $dispatcher == "__lua" && -n $description && -n $key ]]; then
      dispatcher="${LUA_BIND_DISPATCHER_MAP["$cache_key"]}"   # -> ""
      arg="${LUA_BIND_ARG_MAP["$cache_key"]}"                # -> ""
    fi

On my machine that left 22 of 260 cached rows with an empty dispatcher and
argument, and they are exactly the ones coming from
~/.config/hypr/bindings.lua: super+w, super+c, super+v, super+x, print, and
whatever bindings other plugins write into that file. The keybindings menu
still renders them but cannot dispatch them, and any consumer that reads the
cache to resolve a chord -- for example an on-screen keyboard deciding
between a real dispatch and a synthetic key event -- sees an unresolvable
combo and takes the wrong branch.

Make the stub safe to keep reading: comparison, arithmetic and concatenation
now yield inert values instead of raising. Note that __index must return nil
for positive integer keys. Lua 5.4+ ipairs() honours __index, so a stub that
answers t[1], t[2], ... never yields nil and hangs any config that iterates
an API result -- my first attempt deadlocked the scan until it was killed.

Also bump the cache key from v13 to v14 so the already-poisoned
keybindings-*.records files get regenerated rather than reused.

With this, the 16 rows that come from a user's bindings.lua resolve again:

    SUPER + W  -> Close window permanently    exec  .../bin/reprieve close
    SUPER + Z  -> Restore parked window       lua   hl.dsp.global("...:undo")
    PRINT      -> Screenshot (save fullscreen) exec  omarchy capture screenshot fullscreen save

Six rows still come out empty, unchanged by this patch and unfixable here:
they pass a Lua closure as the dispatcher (super+c, super+v, super+x,
super+ctrl+alt+z, super+ctrl+z, and a Hyper-layer bind), and o.bind forwards a
non-string dispatcher to hl.bind untouched (helpers.lua:101), so there is
nothing to serialise. That is pre-existing and separate from the crash.
@llstrk

llstrk commented Sep 27, 2026

Copy link
Copy Markdown

Automated AI review

Community review: Independent automated community review, unaffiliated with the Omarchy team, intended to help prepare PRs for their review.

Verified: at head 14f7e95, the Lua bind scan now replays the whole shipped config instead of stopping at default/hypr/qconsole.lua:123. String-dispatcher binds declared in the user's bindings.lua get their dispatcher and argument back, and the v13 to v14 key bump replaces an existing broken cache. No regression was found for the shipped config. The notes below cover a missing regression test, several open PRs that change the same scan code, and some edge cases of the new stub.

Verified: dispatch columns of the cache records written by the real script (--print), and records from its extracted Lua scan, against a copy of the shipped config/hypr. The copy had synthetic user binds added (the Reprieve rows are synthetic binds modelled on the PR description) and a stubbed hyprctl, and ran on Lua 5.5.1 (the lua package that Hyprland 0.56.2 depends on):

Record Base 7b336b1 Head 14f7e95
DEBUG=1 scan output qconsole.lua:123: attempt to compare number with table no error
User bind SUPER SHIFT + R "SSH" empty dispatcher and argument exec alacritty -e ssh your-server
Reprieve "Park window" / "Restore parked window" empty exec reprieve park / lua hl.dsp.global("reprieve:undo")
User o.rebind of "File manager" Omarchy default exec omarchy-launch-nautilus the user's command
Closure binds (the six defaults SUPER+A/C/V/X, SUPER+CTRL+Z, SUPER+CTRL+ALT+Z, and a user closure bind), from the extracted scan empty empty (as the description says)
  • Rebinds: the "File manager" row shows one more effect of the old abort, beyond empty rows. A user rebind that kept the default description carried the default's command in its dispatch columns, so selecting it in the menu would run the default's command (dispatch itself was not run). This PR fixes that as well.
  • Default binds unchanged: the records for everything loaded before qconsole.lua are identical on base and head, on Lua 5.5.1 and 5.4.9.
  • Cache key: a cache written by base was reused when the head script's key was edited back to v13. With v14, the head script rebuilt it and removed the old file. No other file in the repository reads keybindings-*.records.

Several open PRs change the same scan code

The following open PRs also modify bin/omarchy-menu-keybindings:

PR What it changes in the scan
#7564 Same scan failure: comparison/arithmetic metamethods, __len, nil for numeric keys, a timeout around lua, and a new test/shell.d/menu-keybindings-lua-scan-test.sh
#8876 Stops the scan looping on mocked APIs (nil numeric keys; nil from hl.get_workspace/hl.get_active_monitor)
#12712 Returns nil for numeric keys in the same noop.__index, plus a test for iterating hl.get_loaded_plugins()
#12916 Contains the scan with time and resource limits
#12362 Adds a collation component to the cache key
#11733 Reads binds from the running compositor instead of replaying the config

These were compared by reading their diffs; none of them was run.

Impact: #7564, #8876 and #12712 edit the same noop block as this change, and #12362 also edits keybindings_cache_key(). A trial merge of each PR's head with this one conflicts in bin/omarchy-menu-keybindings for #7564, #8876, #12712, #12362 and #11733; #12916 merges cleanly.

Suggested change: reconcile with #7564 in particular, since it targets the same failure. If this PR and #12362 both land, keep v14 alongside #12362's collation input.

Edge cases of the new stub

These were probed with minimal synthetic configs on Lua 5.5.1, and some also on 5.4.9. The shipped configs do not use these shapes. For code in the user's hypr/* files, which load after qconsole.lua, none of them is a regression, since base already aborted there. Code a user places in hyprland.lua above require("default.hypr.omarchy") ran before qconsole.lua on base, so for that placement rows 3 and 4 are behaviour changes.

Config shape Base Head
"notify-send " .. mon.name in a bind abort bind recorded with an empty argument (__concat returns "" and drops the known operand; #7564 keeps it)
if mon.scale > 1.5 then bind A else bind B end abort always records B (__lt/__le are always false)
hl.get_monitors()[1].name stub abort: attempt to index a nil value
repeat ... until m.scale > 0 abort, caught by pcall the extracted scan ran until the probe's timeout; build_lua_bind_cache runs lua with no timeout, so --print and the menu would hang whenever the cache is rebuilt (inferred from the probe and the code, not run end to end)

Impact: each row is a hypothetical user config. In those configs the scan can record an empty or wrong command, or hang. The new code comment says a config that reads a monitor or workspace "cannot abort the scan", which the third row contradicts.

Suggested change:

Optional test improvement: test/shell.d/keybindings-menu-test.sh passes on both base and head (15/15). On base the scan still aborts at qconsole.lua:123 inside that test, silently because DEBUG is unset, but none of its assertions depends on a bind declared after qconsole.lua.

A test in the same style catches the regression. It fails on base with the qconsole.lua:123 error and passes on head:

  1. Copy config/hypr into a temporary HOME.
  2. Append o.bind("SUPER + SHIFT + R", "SSH", "alacritty -e ssh your-server") to its bindings.lua.
  3. Stub hyprctl binds to report that bind as __lua (modmask 65, key SUPER SHIFT + R).
  4. Run --print with DEBUG=1, XDG_CACHE_HOME inside the temporary directory and OMARCHY_PATH at the checkout, and assert that it writes nothing to stderr.
  5. Assert that the cache records contain → SSH\texec\talacritty -e ssh your-server.

Description corrections:

  • The mon.scale > 0 comparison is at qconsole.lua:123, which matches the quoted error. Line 97 is monitor.transform % 2 == 1.
  • In this tree, SUPER+C/V/X are Omarchy default closure binds (default/hypr/bindings/clipboard.lua), not binds from the user's bindings.lua. They stay empty before and after this change.
  • 22 + 239 is 261, not 260.

Review information

Test scope: sandboxed runs of the base and head script and extracted Lua program, using copies of the pinned config/hypr, synthetic user binds and a stubbed hyprctl. No running Hyprland, interactive menu or actual dispatch was exercised, so dispatch is inferred from the record columns. The author's 22/260/16/239 counts depend on their machine and were not reproduced. The overlapping PRs were read, not run.

AI process: Opus 5.5 Medium coordination and synthesis, Opus 5.5 Xhigh technical review and final fact check, GPT 6 Sol Xhigh search for related issues, Opus 5.5 Medium editorial check.

Opt out: To stop receiving these reviews, reply to this comment saying so.

@KewkLW

KewkLW commented Sep 29, 2026

Copy link
Copy Markdown

Another data point, and a note on merge order.

What we hit: a local config module walks monitors at load (for _, m in ipairs(hl.get_monitors() or {}) do table.insert(rest, m) end). Under the stub noop, that loop never ends, and because it collects what it walks, the lua process grows without bound. Five of these scans ended up running at once (22.6 + 12.2 + 10.6 + 3.6 + 2.2 GB ≈ 51 GB resident, plus swap) on a 64 GB machine. systemd-oomd then killed the terminal scope, and the kernel OOM killer fired inside the Hyprland session, which took the desktop down. That's the same failure as #12852.

Merge order: this PR makes the scan continue past qconsole.lua. On a stock install today, the qconsole error ends the scan before hypr.bindings and the rest of the user's config load. That early abort is currently hiding the #7564 / #12052 / #12124 runaway from anyone whose user config walks a getter. With this PR alone, those loops become reachable. It should land together with #7564 (numeric keys → nil on the stub) and ideally #12916's bound on the scan.

Here's what we verified on quattro:

  • stock config: DEBUG=1 omarchy-menu-keybindings --print → lua bind scan failed: .../qconsole.lua:123: attempt to compare number with table
  • the scan continuing past failing modules plus a user getter walk, with no numeric-nil: the scan runs out of memory (tested under ulimit -v 2097152)
  • all three changes together: the walk ends immediately and the scan completes. On a live session, --print took 0.17 s with the same bind list, versus 9 s and hitting the 2 GB cap unfixed.

@linyiru

linyiru commented Sep 29, 2026

Copy link
Copy Markdown

Two things for this PR: a regression test it can have, and a measurement showing it is a prerequisite for work already in flight.

A regression test

llstrk's review lists a missing regression test, and @manuaudio suggested on #13790 that a personal-bind repro would make a good check for whichever fix lands. This drops into the existing harness in test/shell.d/keybindings-menu-test.sh, which already copies the shipped config/hypr and stubs hyprctl, so it needs nothing new:

# The menu recovers a Lua bind's dispatcher by replaying the config under a stub,
# so a bind the replay never reaches has nothing to dispatch. The shipped config
# loads default.hypr.omarchy before hypr.bindings, so a bind the user declares in
# their own bindings.lua is exactly the bind that goes missing when the replay
# gives up partway through the defaults.
cat >>"$home/.config/hypr/bindings.lua" <<'LUA'
o.bind("SUPER + ALT + P", "Personal bind", "true")
LUA

stub_hyprctl <<BINDS
$(lua_bind 72 "SUPER ALT + P" "Personal bind")
BINDS

rm -rf "$tmpdir/cache"
keybindings >/dev/null
grep -qP '→ Personal bind\texec\ttrue$' "$tmpdir"/cache/omarchy/keybindings-*.records ||
  fail "a bind declared in the user's own bindings.lua keeps its dispatcher" \
    "$(cat "$tmpdir"/cache/omarchy/keybindings-*.records)"
pass "a bind declared in the user's own bindings.lua keeps its dispatcher"

It discriminates, which is the only thing that makes it worth adding. At 647b4a21:

SUPER ALT + P                       → Personal bind
not ok - a bind declared in the user's own bindings.lua keeps its dispatcher

That row is the whole bug in one line: display text intact, both dispatch columns empty. At this PR's head 14f7e955 the same assertion passes and the file exits 0. Take it, change it, or ignore it.

This fix gates the bind-id work

#12404 asks for binds to carry a stable id so the keybindings menu stops keying decisions on display text; #12557 adds the id, and linyiru:menu-sort-by-bind-id moves the row pairing and the sort tier onto it, so a user can translate a description without splitting a paired row and flattening the order. Every one of those ids is declared in Lua, so the whole benefit arrives through the scan this PR keeps alive.

Two chords declaring the same id = "Close window" with a translated description, in ~/.config/hypr/bindings.lua, shipped load order, nothing reordered. Arch, Lua 5.5.1, stubbed hyprctl, separate cache dirs:

tree rendered cached dispatch
647b4a21, before the id work two rows, SUPER ALT + Q above SUPER ALT + W dispatcher=[] arg=[]
plus menu-sort-by-bind-id two rows, unchanged dispatcher=[] arg=[]
plus this PR SUPER ALT + W / SUPER ALT + Q → 關閉視窗 dispatcher=[exec] arg=[true]

The middle row is the point: threading the id through the parser correctly buys a personal config nothing on its own, because the ids live in the file the scan never reaches. This PR is what turns it on.

They also merge cleanly, both directions, ort, no conflict. That is worth stating given llstrk found five of the six neighbouring PRs conflict with yours. For the record #12916 does conflict with my branch, in test/shell.d/keybindings-menu-test.sh rather than in the scan, so that one is a test-file merge for whoever is second.

One small thing on the cache key

Upstream quattro has moved to v14 on its own since you opened this, so the v13 to v14 bump in the diff is a no-op after a rebase rather than the invalidation the body describes. Nothing breaks, because upstream's own bump already retires every v13 record file, so an affected machine still rebuilds. It just means that line no longer does what it says, and a reviewer reading the body may pause on it.

For disclosure: I opened #13790 on this today before finding your PR. It was a duplicate and my mistake, and it is closed and pointed here.

🤖 Generated with Claude Code

@WokoFlipper WokoFlipper 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.

Community review: read the diff and ran it as part of the #12557 + #13276 + id-sort stack on a live translated (RU, 257-bind) config. The hardened noop stub keeps the scan alive past user configs that read monitors/workspaces mid-load (previously aborted before reaching ~/.config/hypr/bindings.lua); all 29 shell tests green, live menu render verified. Small, safe, well-scoped.

@omarchybot

Copy link
Copy Markdown
Collaborator

Reviewed at 14f7e955 by Claude Opus 5.5, with Codex Medium as the second opinion. The diagnosis is right: on quattro the scan still raises at default/hypr/qconsole.lua:123 before hypr.bindings loads, so binds declared in a user's bindings.lua lose their dispatcher. This pull request fixes that, but #7564 fixes the same failure the same way and is the one this queue is carrying forward. This one was not run on a worker for that reason.

Both stub the same comparison and arithmetic operators and return nil for integer keys. #7564 is preferred for three reasons, all checked against the source:

Of the other pull requests changing this scan, #12712 fixes only the ipairs loop and leaves the qconsole comparison raising, and #12916 limits how long and how much memory a runaway scan can use, which is a separate problem. One thing this branch has that #7564 lacks is the bitwise operators (__band, __shl and so on). No shipped config needs them today.

Codex Medium was asked to choose among the four without being told this review's answer. It picked #7564 for the same reasons. Independence isn't guaranteed, because it can read this session's transcript. Choosing between the two is the maintainer's call, so this is waiting on them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants