Skip to content

podcasts 0.1.2: keep RSS parsing inside the Luau CPU budget - #1133

Merged
ItsLemmy merged 2 commits into
noctalia-dev:mainfrom
abuamar142:fix/podcasts-luau-cpu-budget
Oct 24, 2026
Merged

ItsLemmy merged 2 commits into
noctalia-dev:mainfrom
abuamar142:fix/podcasts-luau-cpu-budget

Conversation

@abuamar142

Copy link
Copy Markdown
Contributor

Plugin

  • Id: fel/podcasts
  • New plugin
  • Update to an existing plugin (version bumped in plugin.toml)

What it does

Fixes the episode list never appearing for feeds above a certain size. Users
with a large subscription see the panel open with the subscription listed, no
episodes, and no error message — and after a few attempts the panel is disabled
for the rest of the session.

Noctalia runs plugin code in a Luau sandbox with a CPU budget per callback.
lib/rss.luau spends that budget on pattern work per episode, so the async
noctalia.http callback that calls rss.parseFeed is killed part-way through:

[ERR] [luau] plugin fel/podcasts:browser: call to 'async http callback' failed:
      lib/rss.luau:34: script callback 'async http callback' exceeded its CPU budget
[WRN] [plugin-panel] plugin panel 'fel/podcasts:browser' disabled after repeated timeouts

Because the callback dies before epCache[feed] = eps runs, episodes stays
empty and errorMsg stays empty too, so the panel renders as if the feed had
no episodes.

Measurements

Feed: 666 KB, 557 items, 30 show-note descriptions per page load. The number of
episodes parsed per feed is search_limit (clamped 3–30, default 10).

search_limit stock this PR
7 21–25 ms, intermittently killed ok
10 (default) killed 11 ms
12 killed ok
20 killed ok
30 killed 24 ms

The margin is thin because the budget is per callback, not per parse: the same
input alternates between passing and failing run to run. That is why the
default value of 10 already fails for this feed.

Change

Four helpers now return early when their pattern cannot match:

  • stripCdata — plain find for <![CDATA[ before the anchored match, which
    otherwise scans the whole string with a pattern on every call
  • unescape — plain find for & before the eight gsub passes
  • cleanText — run %s+ only when a run of whitespace is actually present
  • stripTags — run <[^>]+> only when the string contains a tag

Every early return yields exactly the string the pattern would have returned
unchanged, so parsed output is identical. No API, setting, or file format
changes.

External dependencies

None added. Existing: mpv (playback), socat (mpv IPC).

Testing

  • Tested on Niri
  • Tested on Hyprland
  • Tested on Sway
  • Tested on another compositor:
  • Noctalia version tested against: 5.3.0
  • Plugin API level: 22

Exercised:

  • Panel opened via noctalia msg panel-toggle fel/podcasts:browser at
    search_limit 10, 20 and 30; episode rows render with title, date and
    duration.
  • Repeated open/close cycles at 30 episodes: no exceeded its CPU budget
    entries, panel not disabled.
  • noctalia plugins lint ./podcasts — 0 errors, 0 warnings.
  • python3 .github/workflows/scripts/validate-plugins.py — 280 manifests valid.
  • Playback unchanged: noctalia msg plugin fel/podcasts:player all play still
    starts the episode (the player path does not touch rss.luau).

Screenshots / Videos

Episode list rendering with search_limit = 20 after the change:

episode list

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.
  • thumbnail.webp is present and relevant; for a new plugin I created it with the thumbnail generator, and for an update I regenerated it with the generator if the visual identity or user-facing appearance changed. (Visual identity unchanged.)
  • 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). (No translation changes.)
  • I did not edit catalog.toml; CI generates it.
  • This PR touches exactly one plugin directory.

Code review attestation

  • 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.

The browser panel never listed episodes for large feeds. Noctalia runs plugin
code in a Luau sandbox with a per-callback CPU budget, and lib/rss.luau spends
that budget on pattern work for every episode, so the async http callback is
killed before it can fill the list. The panel then gets disabled after repeated
timeouts.

Measured on a 666 KB feed with 557 items (search_limit = episodes per feed):

  stock   limit 7  -> 21-25 ms, intermittently killed
  stock   limit 10 -> killed (default setting)
  stock   limit 12 -> killed
  patched limit 10 -> 11 ms
  patched limit 30 -> 24 ms

Each helper now returns early when its pattern cannot match:

- stripCdata: plain find for '<![CDATA[' before the anchored match
- unescape: plain find for '&' before the eight gsub passes
- cleanText: collapse '%s+' only when a run of whitespace exists
- stripTags: run '<[^>]+>' only when a tag is present

No behaviour change: every early return is the same string the pattern would
have returned unchanged.
@github-actions

Copy link
Copy Markdown
Contributor

CC @Fel-2: this pull request was automatically moved to draft until you have had a chance to look at it. It will be marked ready for review automatically once you reply here.

@github-actions
github-actions Bot marked this pull request as draft October 24, 2026 03:18
@Fel-2

Fel-2 commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

Looks good thanks.

@github-actions
github-actions Bot marked this pull request as ready for review October 24, 2026 09:05

@Fel-2 Fel-2 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not blocking — one clarification for future readers.

needsCollapse is the only guard here whose correctness isn't obvious from its name: it is exhaustive only because %s is exactly space \t \n \r \f \v in Luau. Two lines above the function listing those classes (and a "keep in sync if %s ever changes") would make that invariant explicit in the file rather than only in this thread. The other three guards (stripCdata, unescape, stripTags) are self-evident and read well as they are.

Also worth one line in the description on how the "parsed output is identical" claim was checked (stock vs patched on the same feed at a few maxItems values). The timing side is well documented with the measurements table; the equivalence side is currently an assertion, and that is the half a reviewer cannot re-derive from the table.

For the record, I checked the diff at both ends: the four guards are behaviour-preserving on a hand-built corpus of CDATA/entity/tag/whitespace edge cases, on 20k fuzz inputs, and the full parseFeed output is byte-identical on three real feeds (12 KB / 1.35 MB / 6.3 MB) at maxItems 7/10/20/30, while parsing 20–43 % faster. Diagnosis also confirms: the host's budget is ≈26 ms per callback (measured), which matches your 21–25 ms → killed and 11 ms → passes numbers.

Everything else is clear as-is. Nice work.

%s is exactly space, tab, LF, CR, FF and VT in Luau, so the six
plain finds cover every character gsub("%s+") would touch. Says so in
the file now, next to the guard.
@abuamar142

Copy link
Copy Markdown
Contributor Author

Fair point — added the invariant to the file itself, right above the guard:

-- The list below is exhaustive because `%s` is exactly space \t \n \r \f \v in
-- Luau. Keep it in sync if `%s` ever changes.
local function needsCollapse(s)

Commit 6533180. Comment-only, no behaviour change; validate is green.

For the record, I checked the class empirically rather than trusting memory: iterating string.char(0..255) against %s yields exactly 9,10,11,12,13,32 — the six the guard covers. (That was plain Lua 5.4, not Luau, but Luau inherits the same C-locale isspace set.)

@ItsLemmy
ItsLemmy merged commit e6e9047 into noctalia-dev:main Oct 24, 2026
1 check passed
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.

3 participants