Repository navigation
Conversation
…ig reads omarchy-menu-keybindings learns about Lua-only binds by running the user's hyprland.lua under a stub `hl` whose every lookup answers a truthy noop table. That stub breaks as soon as a config reads compositor state at load time, which real configs do: - `for _, m in ipairs(hl.get_monitors())` never terminates, because noop[1], noop[2], ... are all non-nil. The menu exits, the `lua` child keeps spinning at 100% CPU under systemd --user until the next reboot. One such orphan ran for 27 hours on a laptop after a single SUPER+K. - `monitor.scale <= 0` (default/hypr/qconsole.lua:64) raises "attempt to compare table with number", which aborts the pcall'd scan, so every bind declared after that file is silently missing from the menu. The stub now answers nil for numeric indexes (so ipairs/`#` terminate), reports length 0, and degrades comparisons, arithmetic, and concatenation to harmless values instead of raising. The scan is also wrapped in `timeout 10` so a config the stub still cannot satisfy can never outlive the menu. Adds a shell test that scans a fixture config doing exactly those load-time reads; without the fix it hangs for the full timeout.
There was a problem hiding this comment.
Pull request overview
Hardens Lua keybinding discovery against hangs and load-time type errors.
Changes:
- Time-boxes Lua scanning to 10 seconds.
- Adds safer proxy metamethods.
- Adds regression coverage for loops, comparisons, and arithmetic.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Reviewed changes
Copilot reviewed 1 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
bin/omarchy-menu-keybindings |
Hardens and time-boxes Lua bind scanning. |
test/shell.d/menu-keybindings-lua-scan-test.sh |
Tests load-time reads and timeout configuration. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Ran into this exact issue after upgrading to Quattro 4.0 Tested this fix locally; it resolves the infinite loop cleanly and makes the keybindings scan instantaneous. Hope to see this merged soon! |
|
Confirming on omarchy 4.0.3-1 (Hyprland 0.56.2, Lua 5.5.1): a personal module that |
|
Confirming another real-world manifestation of this on I have o.bind("SUPER + SHIFT + F", "File manager", { launch = 'flea --gui' })Pressing Root cause is this PR's diagnosis exactly: Because the crash happens before ...instead of the user's actual |
The cache key hashes only the version, the active keymap and `hyprctl binds`, so a records file written by the scan that aborted in qconsole.lua keeps being served after this fix lands, and the menu goes on dispatching the stale default in place of the user's override until their binds happen to change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Codex Medium <noreply@openai.com>
|
Reviewed and reproduced on a worker built from the Omarchy ISO, with the stock config plus a user
I pushed two commits to the branch. The first merges Six other open pull requests fix parts of the same bug: #8876, #12712, #12916, #13276, #12155 and #11733. This one fixes both the endless loop and the qconsole abort, and it has a test that fails without the fix. #12712, #12916 and #12155 do not get past qconsole. #8876 gets past it by returning nil monitors, which still breaks a config that reads a field off one. #13276 has no test. #11733 is a redesign that would need its own review. Second opinion from Codex Medium, which agreed with that choice and with the cache bump (its independence from this review is not guaranteed). It also raised two design points that I have not changed, because they are a call about how far the stub should pretend:
This links #7025, #12052 and #12124, which it fixes. The next step is the maintainer's. |
|
Merged this with #12557 locally and measured it, because the two edit the same scan and are textually compatible while being behaviourally incompatible. #12557 adds an In the other direction, this PR rewrites the heredoc to On the merge: Separately, as the author of one of the seven PRs touching this scan: this is the only one of them whose test fails on |
Pairing and sorting happen before a record reaches the cache, and the cache key hashes only the version, the active keymap and `hyprctl binds`. So on a machine whose keymap and binds have not moved, the menu goes on serving the rows the previous version laid out and none of the sorting above reaches the user until something unrelated happens to change their binds. The version skips v15 on purpose: omacom#7564 and omacom#8229 are both open and both take it, and a merge that agrees on the same literal from both sides resolves silently into no bump at all.
The scan folds a tab in a description to a space so the record it prints keeps its fields, but the menu looked the bind up by the description `hyprctl binds` reported, tab and all, so the two keys could never meet: the bind came back with no id and no dispatcher. The reported description reaches the tab delimited record too, where the menu reads the back half of a label as the dispatcher it would run -- a bind labelled "Tabbed\tkillactive" dispatches killactive when it is picked, on quattro as much as here. So fold the delimiters out of the reported description the same way, and cut the scan's copy at the first newline, which is where hyprctl's own output cuts the description it reports. Bump the cache version, or an install that already cached a broken record keeps serving it. The literal skips to v17 because v15 is taken by omacom#7564 and omacom#8229 and v16 by omacom#13888, all open: two branches setting that line to the same value merge with no conflict and leave no bump at all. A comma in a label still shifts the fields of the intermediate record, here and on quattro alike. That one is the record format itself, not this lookup.
The records are sorted before they are cached, and the cache key hashes the version literal, the active keymap and `hyprctl binds` -- none of which moves when this script's sort does. So an install that already has a cache keeps serving the order it was written with, and since en_US.UTF-8 is the default locale that is most installs: the pinned order would arrive later, at some unrelated moment when a bind or the keymap changed. Measured over one cache directory and the two chords that swap, the new script printed the old script's en_US order byte for byte. Bump the version so that cache is retired, and plant a record under the key the previous version would have written, so that changing the order without a bump fails here rather than in a user's menu. The literal skips to v18 because v15 is taken by omacom#7564 and omacom#8229, v16 by omacom#13888 and v17 by omacom#12557, all open: two branches setting that line to the same value merge with no conflict and leave no bump at all. omacom#13521 bumped v13 to v14 for a records change the same way.
…shipped trigger and a user-module trigger as separate arms
The KI is "one throwing module ends the scan and every later bind is lost";
the reproducer only covered the shipped trigger (qconsole's scale
comparison). Arm A keeps that case. Arm B guards the qconsole comparisons
and adds a user monitors.lua that formats a mode string with
string.format("%dx%d@%d", ...) from the active monitor: the library call
rejects the mock value, the scan ends before hypr.bindings, and the SSH bind
is lost. Each arm prints its own verdict; exit 0 while either arm shows the
defect. The cache maps are cleared before each scan (they are global), so
an arm cannot read binds cached by the previous one.
Measured on Linux (Lua 5.5.1): quattro-proof 1e43373 arm A and B present
(exit 0); omacom#7564 merged, arm A fixed and arm B present
(exit 0); omacom#7564 plus a per-module pcall around require, both fixed
(exit 1). The control arm caches the bind on all three.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…shipped trigger and a user-module trigger as separate arms
The KI is "one throwing module ends the scan and every later bind is lost";
the reproducer only covered the shipped trigger (qconsole's scale
comparison). Arm A keeps that case. Arm B guards the qconsole comparisons
and adds a user monitors.lua that formats a mode string with
string.format("%dx%d@%d", ...) from the active monitor: the library call
rejects the mock value, the scan ends before hypr.bindings, and the SSH bind
is lost. Each arm prints its own verdict; exit 0 while either arm shows the
defect. The cache maps are cleared before each scan (they are global), so
an arm cannot read binds cached by the previous one.
Measured on Linux (Lua 5.5.1): quattro-proof 1e43373 arm A and B present
(exit 0); omacom#7564 merged, arm A fixed and arm B present
(exit 0); omacom#7564 plus a per-module pcall around require, both fixed
(exit 1). The control arm caches the bind on all three.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e on this mirror (box, linux) Arm A (shipped config) no longer reproduces with omacom#7564; arm B (a user module that hands a stub value to string.format) still does, so the green tripwire passes and the KI is still reproduced here. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ro-proof 1e43373) Mirror of omacom#7564 at head 7b49d0b: the upstream change applied onto quattro-proof 1e43373, followed by the proof work for the review. This commit changes no file; it records the mirror at the branch tip, where the review pipeline reads it (Proof-* trailers). Proof-Change-Key: pr/7564 Proof-Upstream-Repo: github.com/omacom/omarchy Proof-Mirror-Of: refs/pull/7564/head Proof-Upstream-Head: 7b49d0b Proof-Head-Ref: fix/menu-keybindings-stub-scan Proof-Change-Base: de63134 Proof-Base-Ref: quattro Proof-Baseline: 1e43373 Proof-Baseline-Ref: quattro-proof
|
TL;DR: This fixes the reported scan failure. One optional follow-up for custom configs: a type error in Remaining aborts and proposed tests, with the shipped-config baselineI ran the real
This is our known issue KI-MENU-KEYBINDINGS-SCAN-DOFILE-ABORT. Its reproducer has two arms. The shipped-config arm flips with this head: the scan no longer fails, and the SSH bind is cached. The second arm, a user 1. Other reads can still abort the scan. The new stub absorbs operators (comparison, arithmetic, local m = hl.get_active_monitor()
string.format("%dx%d@%d", m.width, m.height, m.refresh_rate) -- bad argument #2 to 'format'
math.floor(m.width) -- bad argument #1 to 'floor'
if tonumber(m.scale) > 1 then end -- tonumber(stub) is nil
string.lower(m.name) -- bad argument #1 to 'lower'
for x = 0, m.width, 960 do end -- 'for' limit must be a number
local gaps = hl.get_config("general.gaps_out") * 2 -- the stub's get_config returns nilOn this head the scan survives 2. A direct local first = hl.get_monitors()[1]
hl.monitor({ output = first.name }) -- attempt to index a nil value (local 'first')
Suggestion: in the scan, load each module under its own -- Load each module under its own pcall, so a module that still cannot run
-- against the stub costs only its own binds, not every bind loaded after it
-- (the user's hypr.bindings is required after hypr.monitors and hypr.input).
local load_module = require
require = function(name)
local ok, result = pcall(load_module, name)
if ok then
return result
end
if os.getenv("DEBUG") == "1" then
io.stderr:write("[DEBUG] lua bind scan skipped " .. tostring(name) .. ": " .. tostring(result) .. "\n")
end
return noop
endIt goes just before Tests for
|
| Tree | Result |
|---|---|
| this head | exit 1: the first three checks pass, then not ok - a module the stub cannot run does not drop the binds loaded after it; run alone, the [1] check fails the same way |
| this head + the change | exit 0, 5/5 ok |
The time limit and #13276
- When the 10s limit fires, the binds printed before the hang are kept: Lua flushes stdout on every
print. A hang therefore costs the same as an abort, not the whole Lua cache. Noluais left running. - fix(keybindings): keep the Lua bind scan alive past qconsole.lua #13276 (@playGitboy) fixes the same abort with nearly the same stub. @omarchybot's reasons for carrying this PR forward still hold on
035ce29f. fix(keybindings): keep the Lua bind scan alive past qconsole.lua #13276 still writesv14, which isquattro's current cache version, so stale caches are kept. It also has no time limit: thewhilewalk above runs until killed there. v15is also the bump in other open PRs that edit this script (for example Render every modifier Hyprland can bind in the keybindings menu #12042 and List submap bindings under the chord that enters their mode in the keybindings menu #13461), and Dispatch Lua function keybindings from the menu #11851 moves tov19. Whichever lands second needs a fresh number, or caches written by the first are reused (thev14case @omarchybot found).
Thanks @tobi for the fix and the clear write-up. The worker run by @omarchybot and @linyiru's #12557 note here, and on #13276 @llstrk's review and @KewkLW's merge-order note, already cover the rest, so I haven't repeated them.
Independent verification: the evidence for this PR comes from a full proof audit of 7b49d0b merged onto quattro at 035ce29 (evaluated merge e1ff55c): test report 69 passed · 0 failed · 0 skipped. Rechecked on quattro b83d3df (2026-10-08): the scan script is unchanged but the shipped Hyprland Lua configuration changed, and rerunning this head merged onto that tip preserves the first table's results and the remaining custom-monitor aborts. Checked by running the scan from each tree; not a formal proof.
Problem
omarchy-menu-keybindingslearns about Lua-only binds bydofile-ing the user'shyprland.luaunder a stubhlwhose every lookup returns a truthynooptable. Real configs read compositor state at load time, and the stub cannot survive that:Runaway
luaat 100 % CPU.for _, m in ipairs(hl.get_monitors())never terminates, becausenoop[1],noop[2], … are all non-nil. The menu exits, but theluachild keeps spinning undersystemd --useruntil reboot. Found one on my laptop that had run for 27 hours after a singleSUPER+K(PIDsomarchy-menu-keybindings→lua, 60 % of a core,dofile(hyprland.lua)never returned).Silently dropped binds. On current quattro,
default/hypr/qconsole.lua:64doesmonitor.scale <= 0, which under the stub raises attempt to compare table with number. The scan ispcall'd, so it just stops there — every Lua bind declared after thatrequireis missing from the menu (DEBUG=1showslua bind scan failed: …qconsole.lua:64).Fix
noop.__indexanswersnilfor numeric keys (soipairs/numeric loops end),__lenis 0, and__lt/__le, arithmetic,__concat,__tostringdegrade to harmless values instead of raising. String-key lookups behave as before.timeout 10so a config the stub still cannot satisfy can never outlive the menu.Test
test/shell.d/menu-keybindings-lua-scan-test.shextracts the embedded scan and runs it against a fixturehyprland.luathat iterates monitors, comparesmonitor.scale, does arithmetic/concatenation on stub fields, and then declares two binds. Without the fix it hangs for the full 10 s timeout and fails; with it the scan returns in milliseconds and both binds are reported. Also verified on my own config: the scan now completes with noscan failedand lists the Lua binds that were previously lost afterqconsole.lua.Fixes #7025
Fixes #12052
Fixes #12124