Skip to content

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

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

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

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.

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.

Measured on my 32 KB / 1100-line config.kdl (134 bindings), four refresh
requests spaced 18 s apart updated bindings-cache.json 0 times before
this change and 2 times after. The remaining failures 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, same config), parseNiriContent drops from 4.18 ms to
1.32 ms
. 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.

  • Differential test of old vs new parseNiriContent under luau, comparing
    every field of every binding including sourceLine. Identical on: the
    bundled niri fixtures, a 32 KB real-world config (134 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 (97917d9ca07e)

  • 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. So a
refresh now sometimes succeeds instead of never succeeding, and a failure is no
longer permanent, but a larger config will still lose some 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.

…udget

Requesting a refresh at runtime aborted mid-parse with "script callback
exceeded its CPU budget", and because the aborted callback never got to
clear `refreshing`, every later refresh was swallowed as "already
running": the cheatsheet then showed stale bindings until the plugin was
reloaded. Measured on a 32 KB niri config, four consecutive refresh
requests updated the cache 0 times.

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 that config drops from 4.18 ms to 1.32 ms, and the
bindings it produces are byte-identical: same count, keys, modifiers,
actions, descriptions, categories and source lines, verified against the
fixtures, a 32 KB real-world config, and 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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

CC @cheerfulScumbag

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.

1 participant