Skip to content

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

Closed

Description

@e-defix

What happened

omarchy-menu-keybindings (SUPER + K) never opens. The process hangs indefinitely — no menu, no error, no timeout. omarchy-menu-keybindings --print also hangs.

The hang is triggered by any user Lua config that iterates one of Hyprland's list getters (hl.get_monitors(), hl.get_windows(), hl.get_workspaces()) with ipairs at config-evaluation time.

What I expected

The keybindings menu opens and lists the bindings, or fails loudly.

Steps to reproduce

On Omarchy 4.0.0 (quattro), add a single line to ~/.config/hypr/monitors.lua:

for _, monitor in ipairs(hl.get_monitors()) do end

Then run:

omarchy-menu-keybindings --print   # hangs forever

Hyprland itself is fine — hyprctl reload and hyprctl configerrors are clean, and the config behaves correctly at runtime. Only the keybindings scanner hangs.

Root cause

build_lua_bind_cache() in omarchy-menu-keybindings re-evaluates ~/.config/hypr/hyprland.lua in a bare lua interpreter, with hl replaced by a catch-all stub (bin/omarchy-menu-keybindings, around lines 181-232):

local noop
noop = setmetatable({}, {
  __index = function() return noop end,
  __call  = function() return noop end,
})

Because __index answers every key with a truthy value, hl.get_monitors() returns noop, and ipairs(noop) reads index 1, 2, 3, … without ever hitting nil. Infinite loop inside the lua subprocess. The while read loop feeding off it in bash blocks forever, so the menu never renders.

The stub already special-cases one getter to return a real value:

get_config = function()
  return nil
end,

The list getters just were not covered.

Suggested fix

Stub the list getters with real empty tables, so iteration terminates:

get_monitors   = function() return {} end,
get_windows    = function() return {} end,
get_workspaces = function() return {} end,

A timeout around the lua subprocess would also turn this class of failure into a degraded menu instead of a silent hang.

Why this probably was not caught

No config under /usr/share/omarchy/default/hypr/ calls any hl.get_* getter at load time (grep -rn "get_monitors\|get_windows\|get_workspaces" /usr/share/omarchy/default/hypr/ returns nothing), so the stock configs never hit it.

Third-party Lua modules do. Unmodified upstream shezdy/hyprsplit init.lua iterates all three getters, so any user requiring it from their Hypr config gets the same hang.

My own trigger was dynamic monitor placement — computing the laptop's position from the live external monitor geometry, rather than hardcoding auto-right.

Workaround

Guard in ~/.config/hypr/hyprland.lua, right after the bootstrap dofile, detecting the stub (raw slot empty but __index conjures a value) and handing back real empty lists:

local probe = hl.get_monitors()
if type(probe) == "table" and rawget(probe, 1) == nil and probe[1] ~= nil then
  hl.get_monitors = function() return {} end
  hl.get_windows = function() return {} end
  hl.get_workspaces = function() return {} end
end

With that in place omarchy-menu-keybindings --print exits 0 and lists all 231 bindings.

System details

  • Omarchy 4.0.0-1 (stable channel)
  • Hyprland 0.56.2-1
  • Lua 5.5.1-1
  • CPU: AMD Ryzen AI 7 PRO 350 w/ Radeon 860M
  • GPU: AMD Radeon 840M / 860M Graphics (Krackan)

Activity

  1. SamuelYancy commented on Aug 15, 2026

    @SamuelYancy

    Same root cause, confirmed independently on different hardware — but this can escalate well past a hang. On my machine it took the whole system down.

    The difference is what sits inside the user's loop. The repro here uses an empty body:

    for _, monitor in ipairs(hl.get_monitors()) do end

    which spins CPU but allocates nothing. Real configs put something in the body. Mine appends to a table:

    for _, monitor in ipairs(hl.get_monitors()) do
      table.insert(DESK_SERIALS[monitor.serial] and desk or extra, monitor)
    end

    Because ipairs over the noop stub never terminates, that table.insert runs forever and each hung lua grows without bound.

    Amplification: since the menu never appears, you press SUPER + K again. Every press leaks another unbounded lua. I accumulated 63 of them.

    Result (Omarchy 4.0.0-1, 64 GB RAM / 60 GiB usable):

    • 63 lua processes holding ~56 GB of the ~57.5 GB total RSS; largest single process 6.6 GB
    • kernel global OOM, free below the min watermark, page cache fully evicted
    • Chromium's browser process self-terminated with FATAL:content/browser/gpu/gpu_data_manager_impl_private.cc:417] GPU process isn't usable. Goodbye. — recovered verbatim from its core dump — because its GPU process kept being OOM-killed and it hit the GPU-failure limit
    • the Hyprland session was OOM-killed and the machine rebooted

    From the kernel OOM report:

    Out of memory: Killed process 140127 (lua) ... anon-rss:6108268kB
    Node 0 Normal free:65248kB  min:65348kB
    active_anon:54918168kB  active_file:3864kB  all_unreclaimable? yes
    

    The OOM task table shows the signature clearly — a repeating triplet of two omarchy-menu-ke… shells followed by a multi-GB lua, 63 times over.

    Mechanism confirmed separately on Lua 5.5.1: ipairs over the stub ran 5,000,001 iterations without hitting nil, and rawlen(stub) is 0. Neutralising just that one loop in my config makes the scan complete cleanly and emit all 226 bindings, which isolates it to the same line you identified.

    Given that, I'd treat the suggested timeout around the lua subprocess as required rather than nice-to-have, and pair it with a memory cap (ulimit -v in the subshell). A timeout alone still lets a config that allocates inside the loop exhaust a 64 GB machine — mine got there in roughly two minutes, well inside any generous timeout. The proposed empty-table stubs for the list getters fix the trigger; the cap contains the whole class.

    Worth noting for anyone hitting this: omarchy-launch-browser runs the browser with --property=StandardError=null, so Chromium's GPU-failure output is discarded and nothing lands in the journal to explain the browser dying.

    System details, different from the original report: AMD Ryzen 5 9600X, AMD Radeon RX 7900 XT (Navi 31) desktop + Raphael iGPU, Omarchy 4.0.0-1, Hyprland 0.56.2-1, Lua 5.5.1-1, Chromium 151.0.7922.137-1, Linux 7.1.8-arch1-3.

    Filed by Claude Opus 5 via Claude Code.

  2. mmsami commented on Aug 27, 2026

    @mmsami

    Reproduced independently on 4.0.1-1, confirming this isn't specific to hl.get_monitors() in monitors.lua.

    My trigger: a personal ~/.config/hypr/monitor-fit.lua (required from hyprland.lua) that prunes stale window records at top-level load time (not inside an hl.on(...) handler):

    for _, w in ipairs(hl.get_windows()) do
      live[w.address] = true
    end

    Same symptom: omarchy menu keybindings / --print hangs forever, hyprctl reload and hyprctl configerrors stay clean, real runtime behavior is unaffected — only the static scanner hangs. Confirms the noop stub's __index/__call pattern is the general problem for any unimplemented hl.* getter, not just the ones already listed.

    Local workaround for anyone hitting this before it's fixed upstream: detect the placeholder by checking for a __call metamethod (real getters return plain tables, the stub's noop is also callable) and skip the loop when it's present:

    local windows = hl.get_windows()
    local mt = type(windows) == "table" and getmetatable(windows) or nil
    if type(windows) ~= "table" or (mt and mt.__call) then
      windows = {}
    end
    for _, w in ipairs(windows) do ... end
  3. omarchybot commented on Aug 29, 2026

    @omarchybot
    Collaborator

    Confirmed, and your root-cause analysis holds exactly as written. Reviewed on a disposable Omarchy VM, never on a real desktop. Two open PRs fix it, and there is one thing about reproducing it today that is worth recording here.

    The reproduction in this issue no longer hangs on current quattro, and that is masking rather than a fix. default/hypr/qconsole.lua landed on 18 August in 8398771, three days after you filed. It calls fit() at load time on line 79, and fit() reaches qconsole.lua:64, if not monitor or not monitor.scale or monitor.scale <= 0 then. Under the scan's stub, hl.get_active_monitor() and .scale both answer with the truthy noop table, so Lua raises attempt to compare table with number. That happens on the path from config/hypr/hyprland.lua:14 → default/hypr/omarchy.lua:18, five lines before hyprland.lua:19 loads ~/.config/hypr/monitors.lua, and pcall(dofile, config) at bin/omarchy-menu-keybindings:234 swallows the error. So the scan now exits in about a second instead of spinning, and anyone retrying your steps gets a clean-looking result. Put the same loop ahead of hyprland.lua:14 and it hangs exactly as you described. The defect is untouched; only the stock load order changed.

    That abort is itself a bug, and it costs users their bindings today. Measured on the worker with an ordinary o.bind(...) in ~/.config/hypr/bindings.lua (required at hyprland.lua:21, past the abort): the scan recovers 226 binds and none of them is the user's. Personal Lua-registered bindings are silently missing from the keybindings menu on every stock install, with or without a hanging loop in the config.

    On @mmsami's generalisation: correct — any un-stubbed hl.* getter behaves this way, not just the list getters, because the hl table's own catch-all __index at bin/omarchy-menu-keybindings:224 answers every un-stubbed member with the sentinel, and the sentinel's __index at :189 then answers every key with itself. One narrowing: pairs does not share it. The sentinel has no __pairs metamethod, so pairs falls back to next over its raw table, which is empty — 0 iterations, confirmed on Lua 5.5.1. It is ipairs, and hand-rolled numeric or while-truthiness loops, that never terminate.

    On @SamuelYancy's point that a timeout alone is not enough: the two PRs split on exactly that.

    • Stop keybinding scans from looping on mocked APIs #8876 (@yashranaway) makes the sentinel's __index return nil for numeric keys, and nothing else. Verified by mutation on the worker: with a loop placed ahead of the qconsole abort, the scan runs until it is killed with the change reverted and completes in under a second with it. This is the only metatable mechanism that works — __len returning 0 does not stop ipairs, and __ipairs is ignored from Lua 5.4 on. It does not fix the qconsole abort and adds no timeout.
    • Make the keybindings menu's Lua bind scan safe against load-time config reads #7564 (@tobi) is a strict superset: the same numeric-nil change, plus __len, __lt/__le, arithmetic, __concat and __tostring so load-time reads degrade instead of raising, plus timeout 10 around the whole scan. Run against a stock config on the worker it produces no abort and recovers 227 binds, the user's own among them. The timeout is the part that bounds the while hl.some_api() do shape neither metatable change closes.

    Neither carries a memory cap, so the escalation you documented is bounded by the timeout in #7564 rather than prevented. Which PR to take, or whether to take both, is the maintainer's decision and not one I will make here.

    #8819, filed on 28 August, is the same defect: same file, same build_lua_bind_cache(), same sentinel, same non-terminating ipairs. This issue is the earlier report and would normally be the one kept; consolidating the two is the maintainer's call.

    Reviewed by Claude Opus 5 with an independent second opinion from Codex at xhigh reasoning, which independently confirmed the duplicate call against both issue texts and the quattro source, verified the load order above, and contributed the pairs narrowing.

  4. mikebenner commented on Aug 29, 2026

    @mikebenner

    Confirmed on packaged Omarchy 4.0.1-1 (Hyprland 0.56, Lua 5.5). Super+K never opens the menu; omarchy-menu-keybindings --print hangs until killed.

    Trigger is load-time ipairs(hl.get_monitors()) in ~/.config/hypr/monitors.lua. This package does not ship default/hypr/qconsole.lua, so the scan actually reaches that loop instead of aborting earlier on monitor.scale <= 0. Each Super+K left a 100% CPU lua child reparented to systemd --user. hyprctl reload / hyprctl configerrors stayed clean.

    #7564 is the fix that covers this (numeric-nil on the stub so ipairs terminates, plus the timeout so a leftover scan cannot outlive the menu).

  5. omarchybot commented on Aug 30, 2026

    @omarchybot
    Collaborator

    @mikebenner's packaging detail is correct and it corrects something in my previous comment that matters for urgency. I said the reproduction here is masked on current quattro by the qconsole.lua abort. That is true of the git branch and not of what users are running: the packaged omarchy 4.0.1-1 ships no default/hypr/qconsole.lua at all, so there is nothing to abort the scan early and the loop is reached. Checked on a disposable VM running the stock 4.0.1-1 package — /usr/share/omarchy/default/hypr/ has no qconsole.lua, and omarchy-menu-keybindings --print under a ~/.config/hypr/monitors.lua containing the reported loop runs until it is killed, while the same command on the stock config returns in well under a second. Reproduced identically for @mmsami's ipairs(hl.get_windows()) shape. The defect is live for everyone on 4.0.1-1 today, not only for people who put the loop ahead of the defaults.

    On the escalation to a system-wide lockup. The mechanism is real and I can name it: it is unbounded memory growth inside each hung lua, and it is neither a fork bomb nor the compositor blocking on IPC. Nothing self-replicates — one keypress produces exactly one scanner, and I measured the process table staying flat at three lua after three presses rather than growing on its own. There is no IPC in the loop either: hl.get_monitors resolves locally through the catch-all __index at bin/omarchy-menu-keybindings:224 to the sentinel, whose __call at :192 returns itself, so the child Lua never talks to Hyprland, which is why hyprctl reload stays responsive throughout. What amplifies it is the cache: the hang happens inside refresh_keybindings_cache before the mv at :548, and there is no lock, so every press starts another full scan and nothing reaps the previous one. The per-press process signature is two omarchy-menu-ke shells and one lua at ~100% CPU, which I reproduced exactly.

    Severity depends entirely on what is inside the loop, and this is worth being precise about. Measured on 4.0.1-1 with Lua 5.5.1, each capped so nothing could actually exhaust the machine:

    loop body iterations Lua heap outcome
    empty (do end) 57,158,451 in 8s 24 KB spins one core, no growth
    live[w.address] = true 50,349,363 in 8s 24 KB spins one core, no growth
    table.insert(t, m) 33,554,433 in 5.3s 294 MB died at a 512 MB cap, still growing

    So an appending body grows at roughly 165 MB/s and does not stop. That establishes the mechanism by which a config could exhaust a large machine. But none of the three configs reported in this thread is that shape: the original repro and @mikebenner's have empty bodies, and @mmsami's writes live[w.address] = true, where w.address is the same sentinel table every iteration, so it rewrites one key and does not grow. I cannot verify a particular incident on someone else's machine from here, and I am not treating the specific process counts and totals as established. What I will say is the part I proved: the hang is confirmed on the shipping package for every trigger reported here, and memory exhaustion is a demonstrated consequence of the same defect for the ordinary case of a loop body that appends.

    The two open PRs, since both claim this area. #7564's production change is a strict superset of #8876's — byte-for-byte the same numeric-nil __index, plus __len, __lt/__le, arithmetic, __concat and __tostring, plus timeout 10 around the scan. Both fix every trigger actually reported here; #8876's production change is a real fix and not merely something that makes its own test pass, and its timeout 5 is test-only. They separate on loop shapes that never index numerically, run against the packaged defaults:

    config shape 4.0.1-1 #8876 #7564
    for _, m in ipairs(hl.get_monitors()) hangs returns <1s returns <1s
    for _, w in ipairs(hl.get_windows()) hangs returns <1s returns <1s
    while hl.get_monitors() do ... end hangs hangs killed by the timeout
    while cur do cur = cur.next end hangs hangs killed by the timeout

    Neither carries a memory cap, so neither prevents the exhaustion above — a timeout bounds how long each process has to allocate, not how fast, and at the rate measured a single scanner passes 1.5 GB in under two seconds, well inside ten.

    One thing to weigh against #7564's timeout, which I had not considered until the second reviewer raised it. When the timeout fires, the incomplete result is still cached. The success test at :526 is [[ -n $dynamic ]], which turns only on hyprctl binds and not on whether the Lua scan finished, so the temporary file is promoted by the mv. Verified against #7564 on the worker with a stubbed hyprctl: the scan is killed at ten seconds, no diagnostic is printed even under DEBUG=1 because pcall cannot report an error when the process is signalled externally, a personal hl.bind declared after the loop is missing from the output, and every later press serves that incomplete record set instantly from cache. The hang stops, which is the point — but it becomes a silent, sticky wrong answer rather than a visible failure. That is an argument for pairing the timeout with a diagnostic and with declining to cache a scan that did not finish, not an argument against the timeout.

    Which PR to take, whether to take the narrow change plus a separate hardening pass, and whether to consolidate #8819 into this issue, are all the maintainer's calls and I am not making them here.

    Reviewed by Claude Opus 5 in Claude Code, with an independent second opinion from Codex at xhigh reasoning. Codex reached the same mechanism separately — unbounded memory rather than a fork bomb or blocked IPC, including the point that the bare loop does not itself grow — and contributed the caching finding above, which I had not reasoned about and then confirmed on the worker. Where it and I merely agree, its independence is not guaranteed, since it can read this session's own working notes.

  6. settingzbot commented on Sep 3, 2026

    @settingzbot

    Same defect, hit on a real desktop — adding three things the thread doesn't have yet: a thermal escalation path that a memory cap would not contain, evidence that the "no longer reproduces" masking does not apply to the shipped release, and a trigger that involves no hand-edited config at all.

    1. The escalation is not always OOM. Mine was thermal.

    @SamuelYancy's runaway allocates inside the loop, so it ends in OOM. Mine allocates nothing:

    local function shinyLoaded()
      for _, p in ipairs(hl.get_loaded_plugins()) do
        if p.name == "hypr-shiny-border" then return true end
      end
      return false
    end

    p is the sentinel, p.name is the sentinel, the comparison against a string is false, and the loop body never allocates. RSS stayed flat. Instead:

    • 15 lua processes, one per SUPER + K press, 15 of 20 cores at ~99%, load average 15.4
    • CPU package pinned at its 100 °C critical limit for roughly 40 minutes
    • package_throttle_count = 193372, core_throttle_count = 22822

    i5-13600KF, Omarchy 4.0.2-1, Hyprland 0.56.2-1, Lua 5.5.1-1, kernel 7.1.9-arch1-2.

    Why this matters for the fix choice: the ulimit -v cap suggested above would not have caught this at all — there is nothing to cap. A non-allocating body is the more likely shape in the wild, since the common pattern is scanning a list for a match and returning. Of the two open PRs, only #7564's timeout 10 bounds it. I confirmed the placement works: timeout is lua's direct parent there, Lua installs no SIGTERM handler, and a spinning scan dies on the timeout leaving no stray process (exit=124, pgrep -x lua → 0). Worth noting that a timeout bounds each press but not the count of presses — 15 presses is still 15 × 10s of full-core spin, which is why a flock is worth having alongside it (cf. #9057).

    2. The qconsole masking is quattro-only. The release is fully exposed.

    The reviews on this issue and #8819 report that these reproductions no longer hang on current quattro, because default/hypr/qconsole.lua aborts the scan at hyprland.lua:14 before user files load. On the released 4.0.2-1 that users are actually running, that file does not exist:

    $ ls /usr/share/omarchy/default/hypr/qconsole.lua
    ls: cannot access ...: No such file or directory
    $ grep -n require /usr/share/omarchy/default/hypr/omarchy.lua | sed -n '8p'
    18:require("default.hypr.input")
    

    There is no abort. I instrumented every line of ~/.config/hypr/hyprland.lua with a sentinel write and the scan executes all 59 lines with DEBUG=1 reporting no error. Running the real binary against a faithful copy of my config (cold cache, timeout -k 2 14):

    config under scan result
    unmodified exit=0, 0s
    ipairs(hl.get_monitors()) inserted before line 14 exit=124 (hangs)
    ipairs(hl.get_loaded_plugins()) appended after line 14, at line 60 exit=124 (hangs)

    So the position-dependence is an artifact of the development branch. On the release, the reproduction in this issue hangs exactly as originally filed, in either position. I'd suggest not letting "cannot reproduce on quattro" lower the priority here.

    One methodology note for anyone re-testing: the records cache at ${XDG_CACHE_HOME:-$HOME/.cache}/omarchy/keybindings-<key>.records is keyed on hyprctl binds and the active keymap, not on config contents, so a warm cache skips the Lua scan entirely and a modified config looks fine. My first attempt produced three confident false passes that way. Clear the cache, or point XDG_CACHE_HOME somewhere empty, before concluding anything.

    3. Reachable without ever hand-editing a config.

    Every reproduction so far is a loop the user wrote. Mine was generated:

    -- Generated by wmfeht.border-fx (look-apply). Do not edit.
    -- Source of truth: ~/.config/omarchy/shell.json plugins[] entry id wmfeht.border-fx.

    A third-party plugin wrote ~/.config/hypr/border-fx.lua and added pcall(require, "hypr.border-fx") to hyprland.lua. Uninstalling the plugin removed neither. The pcall swallowed the failure, the file was inert under real Hyprland (its guard was never true), hyprctl configerrors stayed empty, and nothing on the desktop looked wrong — the file only did damage inside the scanner, months later. The user's own view of this was that their customizations had made the system unstable, which was not what happened.

    Worth weighing when choosing between the PRs: the scanner has to survive Lua that arrives in ~/.config/hypr/ without the user ever reading it.

    Local workaround for anyone stuck on the release

    A wrapper at /usr/local/bin/omarchy-menu-keybindings (which precedes /usr/bin in Hyprland's PATH; ~/.local/bin does not) that adds flock -n so repeat presses cannot stack, and a watchdog that kills only a run holding >60% of a core for 15s — so an idle open menu is never killed — using setsid plus kill -TERM -$pid, because the lua is a grandchild inside the < <(lua …) process substitution and does not die with its parent shell.

    Filed by Claude Opus 5 via Claude Code.

  7. schmunk42 commented on Sep 8, 2026

    @schmunk42

    Not a new root cause — the analysis above holds exactly. Three small additions after hitting this on 4.0.2-1 (previous confirmations in the thread are 4.0.0 / 4.0.1-1), and PR #7564 has been open 19 days.

    1. A dated forensic trail exists in the cache directory. Every failed refresh leaves a zero-byte ~/.cache/omarchy/keybindings.XXXXXX behind, because refresh_keybindings_cache() mktemps before calling the scan and only rms on the error path — which is never reached when the scan hangs instead of failing. They accumulate one per attempt and keep their mtime:

    $ ls -l ~/.cache/omarchy/keybindings.??????
    -rw------- 1 user user 0 2026-09-04 14:51 keybindings.CxcACJ
    -rw------- 1 user user 0 2026-09-06 23:22 keybindings.1CQ8vd
    -rw------- 1 user user 0 2026-09-07 23:02 keybindings.cy7qCk
    …  10 files
    

    That is useful diagnostically: the oldest one matched the mtime of the backup of the config file I had edited, which pinned the offending line to the minute. Worth knowing when someone reports "load is high and I don't know since when" — the answer is sitting in the cache directory, and ls -l ~/.cache/omarchy/keybindings.?????? is a one-line check for whether a machine is affected at all.

    2. The last .records file makes the failure quiet for a while. output_binding_records() serves the cache whenever the hash of hyprctl binds still matches, so the menu keeps working normally until the user's next binding change invalidates it. Between introducing the loop and the next binding edit, nothing is wrong from the user's point of view. In my case the two events were days apart, and what actually reached me was ambient CPU load with three orphaned lua processes (oldest 21 h) — the menu was never the suspect.

    3. __len on the stub does not fix it. Recording the dead end so nobody proposes it: ipairs does not consult __len; it counts up until __index returns nil. Verified on Lua 5.5.1 — with __len = function() return 0 end the stub reports #noop == 0 and the loop still runs forever. PR #7564's numeric-nil approach is the right shape.

  8. BigKelLearns commented on Sep 12, 2026

    @BigKelLearns

    Confirmed still live on 4.0.3-1 (Hyprland 0.56.2-2, Lua 5.5.1-1, kernel 7.2.3-arch1-3), so this is not a stale report. Adding three things the thread does not have yet.

    1. The trigger was a hand-written personal file, not a third-party module.

    The confirmations so far are a synthetic one-liner and plugin-authored configs (hyprsplit, a generated border-fx.lua). Mine was ordinary user config: ~/.config/hypr/looknfeel.lua doing a monitor check at load time.

    for _, monitor in ipairs(hl.get_monitors()) do
      local is_internal = monitor.name:match("^eDP%-") or monitor.name:match("^LVDS%-") or monitor.name:match("^DSI%-")
      if not is_internal then
        external_monitor_attached = true
        break
      end
    end

    Worth adding to the repro, because it is the shape a user actually writes from the monitor docs, and it shows the break cannot save you either: monitor.name:match(...) also returns the truthy sentinel, so the guard condition never fires. Anyone iterating a list getter is exposed, not just plugin authors.

    2. The failure mode between the two already reported.

    The thread has a CPU-spin-only repro and a full OOM/reboot. Mine ran in between for ~35 hours:

    • 4 leaked lua processes, oldest spinning ~35 h, each ~33% of a core (~1.3 cores total)
    • system load average 17.7 while they ran, dropping to 10.9 the moment they were killed (the remainder is unrelated user workload)
    • no OOM at all, because the loop body only did string matching and never allocated
    • the only user-visible symptom was "SUPER + K does nothing"

    That is the case for the timeout specifically: if the body does not allocate, the machine degrades quietly for days instead of crashing loudly.

    3. The keybindings.* cache-marker triage works.

    19 zero-byte markers in ~/.cache/omarchy/, oldest 2026-09-07 18:28, mtimes matching each leaked process start (keybindings.zvpKpy and keybindings.atuffp at 09:49/09:50, keybindings.YDCDhW at 19:48 on 09-10). The .records cache from 09-08 explains why the menu kept working until the binding hash changed. That is a genuinely useful one-line triage for "load is high and I do not know since when" and it deserves to stay in this thread.

    Workaround, verified in a different shape from the rawget probe. Bounding the iteration by count: # on the stub is 0, so the loop is skipped under the scan and behaves identically against the real API.

    local monitors = hl.get_monitors()
    local monitor_count = (type(monitors) == "table") and #monitors or 0
    for index = 1, monitor_count do ... end

    With that, omarchy-menu-keybindings --print exits 0 and emits all 213 bind records on this machine, and a standalone run of the scanner's inline Lua returns 0. luac -p is clean across all personal config files. Either fix works, but the timeout + memory cap requested above is what turns this class from a silent permanent leak into a degraded menu.

  9. victorhramos-dev commented on Sep 19, 2026

    @victorhramos-dev

    +1 — confirming same root cause on i9-13900HX laptop

    Trigger: SUPER+K (keybindings menu). Triggered it twice in quick succession (probably auto-repeat / debounce race). Two lua processes got stuck at 100% CPU each and never exited.

    Symptoms noticed: CPU package 96 °C / Core 20 97 °C reported by sensors and Mission Center. i9-13900HX with crit=100 °C — 4 °C from hardware shutdown. Initially misread as a sensor/widget bug; turned out the temp was real, two runaway lua processes were the cause.

    Stuck process tree (11 h uptime before I noticed):

     488464  99.7 % CPU  R  lua          ← stuck, no script arg, stdin=pipe (no writer)
     488453   0.0 %      S  bash /usr/share/omarchy/bin/omarchy-menu-keybindings
     488452   0.0 %      S  bash /usr/share/omarchy/bin/omarchy-menu-keybindings
    (489042 / 489031 / 489030 — identical trio from the second invocation)
    

    pipe:[85580661] and pipe:[85605819] were the lua stdin pipes — both had a reader but no writer: bash closed its end after the heredoc was consumed, but lua never saw EOF because it was spinning in ipairs over the noop table.

    Per-process fd count: ~70 fds each, all anon_inode:sync_file — one per require()d module. Consistent with dofile() having walked the whole config tree and getting stuck mid-iteration.

    What in my config triggers it: ~/.config/hypr/hyprland.lua does require("hypr.chatgpt_pet"), and chatgpt_pet.lua ends with:

    for _, w in ipairs(hl.get_windows({ class = "chatgpt" })) do
      recognize_pet(w)
    end

    In the menu script's stub, hl.get_windows resolves through the __index metamethod to noop (a table whose own __index returns noop, not nil). ipairs therefore never reaches the array-end sentinel and loops forever — matches #8819 / #9395 / #12124 root cause exactly.

    Confirmed workaround: kill -9 <lua_pids> <bash_ppids>. CPU dropped 96 °C → 65 °C in 5 s. A pgrep -af lua watch would catch it earlier next time.

    Suggestion: change the __index metamethod in the stub (around line 145 of bin/omarchy-menu-keybindings) from return noop to return nil, and additionally return a function-typed __index that errors loudly on any unexpected access during the scan so future regressions fail fast instead of pegging the CPU.

  10. omarchybot commented on Oct 8, 2026

    @omarchybot
    Collaborator

    Re-checked against current quattro (902fd8a): the defect is still there. The noop sentinel at bin/omarchy-menu-keybindings:187 still answers every key, numeric ones included, with itself, and nothing in the two later changes to that file (07fccef, f814639) bounds the scan. The thread's confirmations on 4.0.2-1 and 4.0.3-1 match that.

    #7564 is now the one open fix. #8876 was closed by its author in favour of it. #7564 already lists this issue as one it closes, so this one closes when it merges. It is mergeable against quattro and was verified on a disposable Omarchy VM as its own item: the ipairs loop over hl.get_monitors(), hl.get_windows() and hl.get_loaded_plugins() returns at once, a user binding declared after the loop is recovered, and the cache version is bumped to v15 so a cache written by the broken scan is rebuilt rather than served.

    What #7564 leaves open, as raised earlier in this thread: there is still no lock, so repeated presses each start their own scan, each now bounded to 10 seconds by the timeout. A scan the timeout kills is still cached as if it had finished, so bindings declared after a while-style loop go missing without a message until the cache is next invalidated.

    This is the earliest report of the defect. Later reports of the same defect are still open, among them #12052, #12124, #12852, #13979, #14243 and #14302. Which fix to take, and how to consolidate those reports, is the maintainer's decision.

    Checked by Claude Opus 5.5, with a second opinion from Codex at medium effort. Codex independently reproduced the hang on Lua 5.5.1 for all three getters, confirmed that #7564's diff ends each one, and confirmed that a timed-out scan still returns success and is cached. Where it only agrees with what this thread already said, its independence is not guaranteed.

  11. aholbreich commented on Oct 9, 2026

    @aholbreich

    I compared the open fixes for this hang on current quattro (c352b62d). Each PR was merged onto it and the real script was run against a stubbed hyprctl with six configs. There are two separate bugs, and only #7564 fixes both:

    1. The hang (ipairs over the stub never ends). It's live on every release, since none ships qconsole.lua yet.
    2. On quattro, the scan dies in default/hypr/qconsole.lua:123 (mon.scale > 0 on the stub). omarchy.lua requires it before the user's files, so every bind in hypr/bindings.lua is listed without a dispatcher, and that crash also hides bug 1 for configs that follow the template.
    shipped config + user bind ipairs in monitors.lua release-style ipairs config while true do end
    quattro no dispatcher no dispatcher hangs hangs
    #12712 no dispatcher no dispatcher ok hangs
    #14128 no dispatcher no dispatcher ok stopped
    #13276 ok ok ok hangs
    #7564 ok ok ok stopped at 10 s

    #13276 is the same stub hardening as #7564 without a timeout or a test. #14128 gets one line further in qconsole.lua and then fails on monitor.transform % 2. All new tests fail on quattro and pass on their branches.

    #12852 is a different cause (nested hl.dsp expressions doubling in size), and only #12916 handles it: a 40-level nest ran to my 4 GB limit in about 2 s on every other branch, while #12916 stopped it in 27 ms. #7564's timeout bounds time, not memory, so #12916's caps are worth keeping as a follow-up on top of #7564; the two conflict textually.

    So: #7564 for this issue and #12052, #12124, #13979, #14243, #14302, with #12916 rebased after it for #12852. @jandrusk, this is why I'd pick #7564 over #12712: #12712 fixes the hang but leaves the quattro dispatcher loss and plain runaway loops.

  12. omarchybot commented on Oct 11, 2026

    @omarchybot
    Collaborator

    A correction to my last comment, and a change since it. I re-checked against current quattro (9830142): the defect is still there. The noop stub at bin/omarchy-menu-keybindings:209 still answers numeric keys with itself, and the lua scan at :106 still has no timeout.

    #7564 is not the only open fix, as I said on 8 October. #12712 (@wulujia) was already open then and still is. It changes only the stub's __index so that numeric keys return nil, which ends the ipairs loop this issue reports, and it merges cleanly into quattro. #13276 makes a similar stub change and no longer merges cleanly. #12712 lists no issue it closes, so merging it would leave this one open. #7564 still lists this issue.

    #7564 no longer merges cleanly into quattro. e1b0e5e (#14431) changed the scan's invocation to lua -E - so that LUA_INIT and LUA_PATH stay out of it. #7564 changes the same line to timeout 10 lua. That is the only conflicting hunk, and keeping both gives timeout 10 lua -E -. Its test test/shell.d/menu-keybindings-lua-scan-test.sh matches the old invocation literally on lines 16 and 56, so it needs the same update or it fails after the merge.

    Which fix to take is the maintainer's decision. This issue is waiting on that.

    Checked by Claude Opus 5.5 from the source, with no code run, and a second opinion from Codex at medium effort. Codex agreed on the defect and the conflict, though its independence is not guaranteed. It also found that #7564's test would break, which I then confirmed.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions