Skip to content

feat(template): async hook execution - #4102

Merged
ItsLemmy merged 8 commits into
noctalia-dev:mainfrom
oldirtty:feat/async-hooks
Aug 31, 2026
Merged

feat(template): async hook execution#4102
ItsLemmy merged 8 commits into
noctalia-dev:mainfrom
oldirtty:feat/async-hooks

Conversation

@oldirtty

@oldirtty oldirtty commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds asynchronous template hook execution via a thread pool (HookRunner) to eliminate the performance bottleneck where slow post-hooks (e.g., Spicetify's apply) block all subsequent templates from rendering.

Additionally, introduces request coalescing to avoid redundant work during rapid theme switches, and uses the existing index field to allow opt-out of async behavior for templates requiring deterministic sequencing.

Motivation

Applying a theme with all user templates enabled took ~9.5s because a single slow post_hook (Spicetify) blocked the entire template queue. Templates after Spicetify (starship, zathura, zed, zen) waited ~8s for it to finish, despite being completely independent.

The template engine itself is fast (~1s for 15 templates); the bottleneck was synchronous hook execution in a single-threaded worker loop.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Refactoring
  • Build / packaging

Related Issue

N/A

Testing

  • Enable all builtin plugins
  • Benchmark upstream build for comparision
  • Benchmark async hooks build

Benchmarks

Before: upstream-builtin2.log
After: async1-builtin2.log

Builtin templates (18 templates, 19 files):

Metric Before After Improvement
Slowest template (wezterm) 5.550s 1.282s 76.9% faster
Slowest template (sway) 5.183s 1.245s 76.0% faster
Slowest template (umbriel) 5.421s 1.261s 76.7% faster

Rapid theme switching (5x in 250ms):

Metric Before After Improvement
Processing time 20.005s (timeout) 0.942s 95.3% faster
Total time 21.358s 2.403s 88.7% faster

Benchmark scripts

I used LLM to make these benchmark scripts and revised it myself :
https://gist.github.com/oldirtty/338f2d0a4dae48b88c60748f86ec4068

  • builtin_template_benchmark.pyBenchmarks builtin templates (reads enabled IDs from ~/.local/state/noctalia/settings.toml)
  • test_coalescing.sh Tests rapid theme switching coalescing

Manual Coverage

  • Tested on Niri
  • Tested on Hyprland
  • Tested on Sway
  • Tested on another compositor: Umbriel
  • Tested with different bar positions and density settings
  • Tested at different interface scaling values
  • Tested with multiple monitors

Screenshots / Videos

BEFORE

b4.mp4

AFTER

after.mp4

Checklist

  • This PR is ready for review, or it is marked as Draft.
  • I read and followed the relevant guidance in CONTRIBUTING.md.
  • I ran just format with clang-format v22+ installed, or this PR has no code changes.
  • I ran the relevant build or test commands, or explained why they were not run.
  • I self-reviewed the changes.
  • I checked for new warnings or errors.
  • I will update end-user documentation after merge, or this PR does not change user-facing configuration or behavior.
  • I added or updated assets/translations/en.json, or this PR adds no new user-facing strings.
  • I did not edit non-English translation files unless this PR is explicitly for translation tooling, an import/export sync, or a maintainer-requested locale change.
  • I used the existing canonical names for config keys, IPC names, paths, and identifiers.

Additional Notes

Breaking Change

Hooks from templates with index = 0 (default) now execute asynchronously. The order of hook execution between different templates is no longer guaranteed.

To opt out and force synchronous execution, set index > 0 in the template configuration. Templates with index > 0 execute hooks synchronously in index order, providing deterministic sequencing when needed.

Benchmarking

  • Templates with output_path_dynamic (e.g., emacs, niri, hyprland) are not tracked in benchmarks

@oldirtty oldirtty changed the title Feat/async hooks feat(template): async hook execution Aug 26, 2026
@ItsLemmy

Copy link
Copy Markdown
Collaborator

P1 — pre_hook runs asynchronously before output generation

src/theme/template_engine.cpp:1655-1658

runHook() is used for both preHook and postHook. For index == 0, both are enqueued
and return immediately.

The caller invokes runHook(entry.preHook) before rendering outputs, then
immediately enters the output loop. Therefore:

  • A pre_hook that creates prerequisites, backs up a file, or stops a consumer can
    still be running while the output is written.
  • With multiple workers, the same template’s pre_hook and post_hook can run
    concurrently.

Only post-hooks should be dispatched asynchronously, or the pre-hook must be
awaited before rendering outputs.

P1 — Superseded generations leave uncancelled hooks running

src/theme/template_apply_service.cpp:330-334

Once a hook is enqueued, it has no generation identifier or cancellation token. If
a new theme request supersedes the current request, applyRequest() skips
waitIdle() and the next generation starts using the same HookRunner.

A slow hook from generation A can therefore finish after generation B and leave
stale state:

A post_hook: sleep 1; apply A
B post_hook: apply B

B may complete first, followed by A overwriting the final state. Disable/undo
flows are also vulnerable: a synchronous undo from B can be followed by an old
asynchronous apply hook from A.

The coalescing logic only coalesces ApplyRequests; it does not coalesce or cancel
already queued hooks.

P1 — Built-in/community-only applications complete before hooks finish

src/theme/template_apply_service.cpp:313-315, 330-334

The new waitIdle() call is after the user-template section. The earlier return for
configurations with no user templates or custom colors bypasses it entirely.

Built-in-only and community-only configurations still enqueue asynchronous hooks,
then:

  1. applyRequest() returns;
  2. m_inFlight is cleared;
  3. m_afterApplyCallback fires;
  4. the next request may begin;

while those hooks are still running.

This affects the normal built-in-template path and means ColorsChanged can be
emitted before template application is actually complete.

P2 — index > 0 is not a sequencing barrier

src/theme/template_engine.cpp:1655-1661

Entries are sorted by index, but a positive-index synchronous hook does not wait
for earlier zero-index hooks that were merely enqueued.

Thus an index = 1 hook can run before an earlier index = 0 hook completes. The
documented opt-out does not provide deterministic sequencing unless every
dependent entry—including the predecessor—is assigned a positive index and the
runner is drained between phases.

P2 — Shutdown drains the entire queued hook backlog

src/theme/hook_runner.cpp:64-68

Workers exit only after the queue is empty. During shutdown, all queued hooks
execute to completion before TemplateApplyService destruction finishes.

After rapid theme changes, this can make shell exit wait for every stale hook in
the backlog. Queued, not-yet-started hooks should be discarded on shutdown;
currently only the service request is cancelled.

@oldirtty

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed thorough review! I'll push the updated implementation shortly.

@ItsLemmy
ItsLemmy merged commit f840b01 into noctalia-dev:main Aug 31, 2026
2 checks passed
@ItsLemmy

Copy link
Copy Markdown
Collaborator

Thanks, tweaked it a bit to my liking and added 'hook_async'

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