Skip to content

fix(keybind-cheatsheet): keep runtime refreshes within the host CPU budget - #333

Draft
rylos wants to merge 1 commit into
noctalia-dev:mainfrom
rylos:fix-keybind-cheatsheet-refresh
Draft

fix(keybind-cheatsheet): keep runtime refreshes within the host CPU budget#333
rylos wants to merge 1 commit into
noctalia-dev:mainfrom
rylos:fix-keybind-cheatsheet-refresh

Conversation

@rylos

@rylos rylos commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Plugin

  • Id: kenn/keybind-cheatsheet
  • New plugin
  • Update to an existing plugin (version bumped in plugin.toml)

@kenn — this touches your plugin, so it needs your sign-off. Happy to change
the approach or drop any part of it.

Rebased onto current main, on top of your 0.2.3 (#405) and 0.2.4 (#432);
the only conflict was the version line, resolved as 0.2.5.

What it does

Fixes a refresh that dies halfway and takes the service with it.

Asking the plugin to refresh at runtime aborted with script callback 'state watch callback' exceeded its CPU budget, part-way through parsing the
niri config. The aborted callback never reached the line that clears
refreshing, so every later request was coalesced away as "already running"
and the cheatsheet kept showing stale bindings until the plugin was reloaded.
Startup was never affected, which is why this only shows up after editing a
keybind: the panel simply never catches up.

Rebased onto current main (0.2.4) and bumped to 0.2.5.

Measured on 0.2.4 in the running host: a burst of refresh requests followed by
a single clean request 20 s later, repeated four times. In the three runs where
the burst produced an abort, no later refresh ever wrote bindings-cache.json
again
— the service stayed wedged until the plugin was reloaded. With this
patch the same three aborts are each followed by a refresh that completes
normally, 3/3. The remaining aborts are the honest part of this PR — see the
last section.

The niri parser now does the same job with far fewer VM instructions:

  • Tokenizer scans instead of stepping. Whitespace runs, string bodies and
    bare words are each located with one string.find rather than a
    character-at-a-time loop over tens of kilobytes.
  • Only binds { … } is tokenized. A real config spends most of its bytes
    on outputs, layout, window rules and animations. The block scan is anchored
    per line and skips block comments, so a commented-out // binds { example is
    not mistaken for the real thing; if no block is found, the whole file is
    parsed exactly as before.
  • Includes are collected in one pattern pass instead of one match per line.

Standalone (luau, 43 KB real-world config.kdl, 137 bindings),
walkConfig + parseNiriContent drops from 3.03 ms to 1.49 ms per parse
(40 iterations). The bindings are byte-identical — same count, keys, modifiers,
actions, descriptions, categories and source lines.

Separately, an in-flight refresh older than 15 s is now treated as lost. A
callback the host aborts can no longer wedge the service permanently, which is
what turned a single failed refresh into a dead cheatsheet.

No behaviour, setting, translation or UI change. plugin_api stays at 9.

External dependencies

None added. The plugin still declares hyprctl, which only the Hyprland Lua
path invokes.

Testing

  • Ran the bundled fixture suite in Noctalia
    (noctalia msg plugin kenn/keybind-cheatsheet:data all self-test):
    mango 9/9, hypr_conf 5/5, hypr_lua 4/4, niri 5/5, passed: true, on 0.2.5
    rebased onto current main.

  • Differential test of old vs new parseNiriContent under luau, comparing
    every field of every binding including sourceLine. Identical on: the
    bundled niri fixtures, a 43 KB real-world config (137 bindings), and hand-made
    cases for commented-out binds blocks (// and /* */), a brace inside a
    string, two binds blocks in one file, binds on the first line, indented
    and commented include lines, CRLF, an unterminated string and an unclosed
    block.

  • Token-level differential of the old and new tokenizer on the same inputs:
    same tokens, values and line numbers.

  • Panel opened and refreshed from the bar widget, from the panel's refresh
    button, and over IPC; bindings, categories, descriptions and source lines
    render as before.

  • python3 .github/workflows/scripts/validate-plugins.py and
    noctalia plugins lint keybind-cheatsheet are clean.

  • Tested on Niri

  • Tested on Hyprland

  • Tested on Sway

  • Tested on another compositor:

  • Noctalia version tested against: 5.0.0 (5.0.0_beta.9-3)

  • Plugin API level: 9

Screenshots / Videos

No visual change: same panel, same widget, same bindings. The bug and the fix
are both in service.luau, and the differential test above is the evidence
that the rendered content is unchanged.

Where this is still short

Being straight about it: this reduces the cost, it does not put the parse
safely under the limit. Probing the host with a throwaway plugin, onIpc,
update and state-watch callbacks all abort at roughly the same point — around
13–15 ms of work — and parsing my config still lands close to that line. The
Hyprland Lua path, which this PR does not speed up, aborts too. So a refresh
now sometimes succeeds instead of never succeeding, and a failure is no longer
permanent, but a large config will still lose individual refreshes.

The real fix is to make parsing incremental — carry a cursor across several
update() ticks and yield at token or binding boundaries — which is a
structural change to your service that I did not want to make unilaterally. If
you would like it, I am happy to write it as a follow-up PR.

Checklist

  • The directory name matches the part of id after the / in plugin.toml exactly.
  • It ships plugin.toml, README.md, thumbnail.webp, and translations/en.json.
  • README.md follows the
    README template, documents
    every entry id and dependency, and includes exact panel IPC commands and launcher prefixes where applicable.
  • I created thumbnail.webp with the thumbnail generator.
  • version follows semver and is bumped in this PR; plugin_api is the oldest API level this plugin requires.
  • Every non-English translation in this PR uses a locale supported by Noctalia core, and I can read, write, and
    understand that language well enough to review and maintain it (no unreviewed machine/LLM translations).
  • I did not edit catalog.toml; CI generates it.
  • This PR touches exactly one plugin directory.

Code review attestation

Plugins run as trusted, unsandboxed Luau in the user's session. Confirm:

  • The code is readable and not obfuscated, minified, or generated.
  • It does not download and execute remote code.
  • Every network call, filesystem write, and spawned process is something the description above accounts for.
  • I have the right to publish this code under the license declared in plugin.toml.

@github-actions

Copy link
Copy Markdown
Contributor

CC @cheerfulScumbag

@ItsLemmy
ItsLemmy marked this pull request as draft August 11, 2026 00:28
@cheerfulScumbag

Copy link
Copy Markdown
Contributor

Hi @rylos, thanks a lot for digging into this and opening the PR!

You hit the exact root issue that's been challenging with this plugin: Noctalia's strict ~13–15 ms callback CPU budget limit. When a runtime refresh exceeds that budget, Noctalia aborts the callback mid-
flight before it can clear refreshing = false, leaving the service permanently wedged in a refreshing state until reload.

Your findings and current changes are really solid:
The Niri parser optimization (parseNiriContent) (3x speedup from 4.18 ms to 1.32 ms with byte-identical output) is a great efficiency boost.

  • The 15-second timeout recovery is a essential safety net to prevent permanent service deadlocks if an abort ever happens.

Regarding the draft status and your note on incremental parsing:
I completely agree with your assessment—optimizing individual parsers helps, but monolithic parsing in a single callback will always remain vulnerable to host timeouts as user configs grow across Niri,
Hyprland, and Mango.

I'd be more than happy to sign off on this and welcome the follow-up for incremental / chunked parsing across update() ticks! Yielding at token or file boundaries will guarantee that runtime
refreshes for all compositors stay comfortably within Noctalia's CPU budget limit regardless of config size.

Feel free to move this out of draft or add the incremental parsing work here—happy to test and sign off on whatever path you prefer!

@cheerfulScumbag

Copy link
Copy Markdown
Contributor

Hi @rylos!

I've submitted PR #369 for v0.2.2 which includes the Data Service refactor (service.luau), panel memory protection, plugin_api = 9, and fixes for #193 & #268.

Once PR #369 merges into community-plugins, you can rebase PR #333 on top of it to layer your Niri 3x parser speedup and 15s timeout recovery!

@cheerfulScumbag

Copy link
Copy Markdown
Contributor

Hi @rylos! Update: PR #370 has been submitted for v0.2.2 with the template intact. Once PR #370 merges, you can rebase PR #333 on top of it!

@cheerfulScumbag

Copy link
Copy Markdown
Contributor

Hi @rylos! Update: PR #371 has been submitted for v0.2.2 with the complete template intact. Once PR #371 merges, you can rebase PR #333 on top of it!

…udget

Requesting a refresh at runtime aborts mid-parse with "script callback
exceeded its CPU budget", and because the aborted callback never gets to
clear `refreshing`, every later refresh is swallowed as "already
running": the cheatsheet then shows stale bindings until the plugin is
reloaded.

Measured on 0.2.4 with a burst of refresh requests followed by a clean
one 20 s later, repeated four times: whenever the burst produced an
abort (3 runs out of 4), no later refresh ever wrote the cache again.
With this patch the same three aborts are followed by a refresh that
completes normally.

The niri parser now does markedly less work for the same result:

- the tokenizer locates whitespace runs, string bodies and bare words
  with one string.find each instead of walking a character at a time;
- only `binds { … }` blocks are tokenized, since a real config spends
  most of its bytes on outputs, layout and window rules;
- includes are collected with a single pattern pass rather than one
  match per line.

Standalone, parsing a 43 KB real-world config drops from 3.03 ms to
1.49 ms, and the bindings it produces are byte-identical: same 137
bindings with the same keys, modifiers, actions, descriptions,
categories and source lines, verified against the fixtures as well, plus
cases covering commented-out `binds` blocks, braces inside strings,
multiple blocks, CRLF and unterminated strings.

An in-flight refresh older than 15 seconds is now treated as lost, so an
abort can no longer wedge the service permanently. This is what makes
the recovery above possible on the Hyprland path too, where the parse
cost itself is unchanged.

The plugin's own self-test passes on 0.2.5 in the Noctalia host (all
four cases: mango, hypr_conf, hypr_lua, niri).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rylos
rylos force-pushed the fix-keybind-cheatsheet-refresh branch from 2fdc6c0 to d04ed62 Compare August 27, 2026 01:01
@rylos

rylos commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

@cheerfulScumbag — rebased onto current main, on top of your 0.2.3 (#405) and 0.2.4 (#432). The only conflict was the version line; I took 0.2.5. Nothing else in the patch needed adjusting, and the regression I reported on #371 is gone in the merged code (code:find("..", 1, true) now searches the literal .. as intended).

Re-measured everything on the rebased branch, in the running host rather than only standalone:

  • Wedge / recovery. A burst of refresh requests, then one clean request 20 s later, four runs per version. In the three runs where the burst produced a exceeded its CPU budget abort, 0.2.4 never wrote bindings-cache.json again — the service stayed stuck until reload. On this branch the same three aborts are each followed by a refresh that completes, 3/3. When the burst produced no abort, both versions behave identically.
  • Parser cost. On a 43 KB real-world config.kdl (137 bindings), walkConfig + parseNiriContent goes from 3.03 ms to 1.49 ms per parse under standalone luau, with byte-identical bindings (every field compared, including source lines).
  • Self-test. noctalia msg plugin kenn/keybind-cheatsheet:data all self-test passes on 0.2.5: mango 9/9, hypr_conf 5/5, hypr_lua 4/4, niri 5/5.

One thing worth stating plainly: my session is Hyprland at the moment, and the Hyprland Lua path aborts too, even though this PR does not touch its parse cost. That is the clearest evidence that the 15 s stale-refresh guard is the part that actually matters here — the parser speedup helps niri, the guard is what keeps any path from wedging.

The follow-up offer stands: incremental parsing across update() ticks, if you want it.

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.

2 participants