Chapter 10
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 watch -x "build --release -p my-plugin"
# Or, for faster compile / debug-quality DSP:
cargo truce install --shell --debug
cargo watch -x "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 layout() and rebuilding
swaps the new layout into the running editor without closing the
window. The HotEditor wrapper delegates to GpuEditor for
rendering 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) 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.