fix(keybind-cheatsheet): keep runtime refreshes within the host CPU budget - #333
fix(keybind-cheatsheet): keep runtime refreshes within the host CPU budget#333rylos wants to merge 1 commit into
Conversation
|
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- Your findings and current changes are really solid:
Regarding the draft status and your note on incremental parsing: I'd be more than happy to sign off on this and welcome the follow-up for incremental / chunked parsing across 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! |
|
Hi @rylos! I've submitted PR #369 for v0.2.2 which includes the Data Service refactor ( Once PR #369 merges into |
…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>
2fdc6c0 to
d04ed62
Compare
|
@cheerfulScumbag — rebased onto current Re-measured everything on the rebased branch, in the running host rather than only standalone:
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 |
Plugin
kenn/keybind-cheatsheetplugin.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 theniri 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.jsonagain — 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:
bare words are each located with one
string.findrather than acharacter-at-a-time loop over tens of kilobytes.
binds { … }is tokenized. A real config spends most of its byteson outputs, layout, window rules and animations. The block scan is anchored
per line and skips block comments, so a commented-out
// binds {example isnot mistaken for the real thing; if no block is found, the whole file is
parsed exactly as before.
Standalone (
luau, 43 KB real-worldconfig.kdl, 137 bindings),walkConfig+parseNiriContentdrops 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_apistays at 9.External dependencies
None added. The plugin still declares
hyprctl, which only the Hyprland Luapath 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.5rebased onto current
main.Differential test of old vs new
parseNiriContentunderluau, comparingevery field of every binding including
sourceLine. Identical on: thebundled niri fixtures, a 43 KB real-world config (137 bindings), and hand-made
cases for commented-out
bindsblocks (//and/* */), a brace inside astring, two
bindsblocks in one file,bindson the first line, indentedand commented
includelines, CRLF, an unterminated string and an unclosedblock.
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.pyandnoctalia plugins lint keybind-cheatsheetare 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 evidencethat 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,updateand state-watch callbacks all abort at roughly the same point — around13–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 astructural 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
idafter the/inplugin.tomlexactly.plugin.toml,README.md,thumbnail.webp, andtranslations/en.json.README.mdfollows theREADME template, documents
every entry id and dependency, and includes exact panel IPC commands and launcher prefixes where applicable.
thumbnail.webpwith the thumbnail generator.versionfollows semver and is bumped in this PR;plugin_apiis the oldest API level this plugin requires.understand that language well enough to review and maintain it (no unreviewed machine/LLM translations).
catalog.toml; CI generates it.Code review attestation
Plugins run as trusted, unsandboxed Luau in the user's session. Confirm:
licensedeclared inplugin.toml.