Chapter 13
Hot reload
Edit DSP or layout code, rebuild, hear the change in ~2 seconds.
No DAW restart. No plugin window close. Same source file, same
truce::plugin! macro — just a Cargo feature.
Experimental. Hot reload is a dev-loop convenience, not a shipping mechanism. It works well day-to-day for most plugins, but state-format migrations, GUI backend reloads, and certain host edge cases (Pro Tools / AU v3 sandboxing) can still break reload mid-session. If you hit one of those, restart the DAW. Always sanity-check final builds without
--shellbefore shipping.
#Setup
The default cargo truce new scaffold produces a single-crate
plugin with the shell feature + [profile.shell] pre-wired:
[features]
default = ["clap", "vst3"]
clap = ["dep:truce-clap", "dep:clap-sys"]
vst3 = ["dep:truce-vst3"]
# ... other format features ...
shell = ["truce/shell"] # ← the hot-reload feature
[profile.shell]
inherits = "release" # ← shell binaries land at target/shell/
# One-time: build and install the dynamic shell.
# Shell goes to target/shell/, logic dylib goes to target/release/.
cargo truce install --shell
# Iterate (release-quality DSP, slower compile):
cargo build --release -p my-plugin
# Or, for faster compile / debug-quality DSP:
cargo truce install --shell --debug
cargo build -p my-plugin
--shell flips the shell feature on, which makes truce::plugin!
expand into a dynamic shell that loads your PluginLogic impl
out of a separate dylib. The shell watches the dylib for content
changes and swaps in the new one while the plugin is live.
Logic profile defaults to release for closer-to-shipped DSP perf.
Pass --debug to flip the logic to debug profile (faster compile,
slower DSP) for tight iteration. The shell binary itself is always
built into target/shell/ via [profile.shell] and never collides
with your regular cargo build / cargo build --release outputs.
When you're done iterating, ship the release build:
cargo truce install # no --shell = static, zero overhead
Zero code changes between dev and release.
#What reloads
| What you edit | How fast? |
|---|---|
| DSP algorithm | ~2 s |
| MIDI handling | ~2 s |
| Widget layout (built-in GUI) | ~2 s |
| Meter logic | ~2 s |
Built-in GUI reloads too. Editing your editor() body and
rebuilding swaps the new layout into the running editor without
closing the window. The HotEditor wrapper delegates to the
renderer returned by editor() and spawns a background thread
watching the dylib. On change, the new BuiltinEditor is installed
via a shared mutex — no flicker.
Custom editors (egui, iced, Slint, Vizia) do not hot-reload the UI itself — they reload the DSP, but you still need to close and reopen the plugin window to see layout changes in the custom UI.
#What does not reload
| What | Why |
|---|---|
Parameter definitions (adding / removing a #[param]) |
The host caches parameter count + IDs + names at scan time. |
| Plugin name / IDs | Host caches metadata at scan. |
| Bus layout | Host configures at init. |
Changing any of these requires rebuilding the shell
(cargo truce install --shell) and having the host rescan. That's
rare — most iteration is on DSP and GUI layout.
#How it works
The truce::plugin! macro expands differently when the shell
feature is on:
- Without
shell:StaticShellembeds thePluginLogicdirectly. Zero overhead, ships in production. - With
shell:HotShellloads thePluginLogicfrom a separate dylib via native Rust ABI. A file watcher thread monitors that dylib.
#Reload sequence
- File watcher detects the dylib changed (mtime poll every 500 ms).
- CRC32 hash confirms content actually changed.
- Audio thread serializes the current plugin state.
- Old dylib handle is leaked — never
dlclose(a TLS destructor segfault on macOS, plus stale pointers in TLS on every OS). - New dylib copied to a versioned temp path to defeat macOS's dyld cache.
- On macOS the copy is ad-hoc codesigned (required by SIP).
dlopenloads the new dylib.- An ABI canary verifies type layouts match.
- A vtable probe verifies trait-method dispatch order.
truce_create()returns a newBox<dyn PluginLogic>.- The new instance is reset with the current sample rate, then state is restored.
The plugin instance is behind a parking_lot::Mutex. Audio thread
locks for process(), main thread locks for render(). Mouse
event handlers release the lock before calling host callbacks, to
avoid deadlocks.
#Troubleshooting
Plugin doesn't notice the new dylib.
The shell looks for target/<profile>/lib{crate_name}.{dylib,so,dll},
where <profile> is the one baked in at cargo truce install --shell
time (release by default; debug if --debug was passed). The path
is captured at compile time from OUT_DIR (which honors
CARGO_TARGET_DIR), so you don't need any runtime env to be set in
the DAW process — but it does mean the shell is tied to the
workspace it was installed from. Re-run cargo truce install --shell if you change CARGO_TARGET_DIR or move the workspace.
For ad-hoc overrides (point the shell at any dylib), set
TRUCE_LOGIC_PATH=/absolute/path/to/lib... in the env that
launches the host. Caveat: DAWs launched from Finder / Spotlight /
Start don't inherit terminal env, and AU v3 sandboxing strips most
vars; this override only works when the DAW is started from the
same shell or via open -a Foo --env TRUCE_LOGIC_PATH=... (macOS).
The shell skips reload if CRC32 hasn't changed.
macOS "code signature invalid."
The shell codesigns the dylib copy. Ensure Xcode CLI tools are
installed: xcode-select --install.
Audio glitch on reload. Expected — a 5–50 ms dropout while the swap happens. Hot reload is a development tool, not a live-performance feature.
"ABI mismatch" error.
The shell and logic were built with different Rust toolchains.
Both must use the same compiler. rust-toolchain.toml in the
workspace pins it.
State lost on reload.
save_state() / load_state() format changed between builds.
The plugin falls back to defaults.
#What's next
You've reached the end of the guide. Browse the reference when you need to look something up, or the formats pages for per-format gotchas.