Shut plugin hosting down at the end of main, not only from an exit handler

Testing on macOS showed that the exit-handler fallback comes too late for a
plugin that has been used: with HALion Sonic inserted on a track, its bundleExit
segfaults, and both the app (on quit) and soundcraft-cli (after a bounce) exited
with 139. A plain scan was fine. Exit handlers run in reverse order of
registration, so state the plugin set up while in use is presumably torn down
before the host's handler runs.

soundcraft_vst3_host::shutdown() runs the module exits up front, and
soundcraft_engine::shutdown_plugin_hosts() calls it. The app calls that once the
event loop has returned (the player and its plugin instances are gone by then),
and the CLI once its subcommand has. The exit handler stays as the fallback for
other exits, such as test binaries.

The fixture can now set up state lazily the way such plugins do
(VST3_FIXTURE_LATE_STATE), and module_exit_shutdown checks both sides in child
processes: with shutdown() the exit is clean, with only the exit handler the
fixture reports the late teardown. Contract violations now end the process with
status 3 instead of an abort, so that expected failure leaves no crash report.

Refs #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ebbicott 2026-10-08 14:28:27 -04:00
parent 527f555512
commit ccd1f995bf
8 changed files with 109 additions and 12 deletions

View File

@ -44,7 +44,7 @@ fn main() -> ExitCode {
return ExitCode::FAILURE;
};
let rest: Vec<String> = args.iter().skip(1).cloned().collect();
match cmd.as_str() {
let code = match cmd.as_str() {
"--version" | "-V" | "version" => {
outln!("SoundCraft CLI {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
@ -69,7 +69,10 @@ fn main() -> ExitCode {
ExitCode::SUCCESS
}
other => fail(format!("unknown subcommand `{other}`")),
}
};
// The subcommand's engine and its plugin instances are gone by now.
soundcraft_engine::shutdown_plugin_hosts();
code
}
fn info(args: &[String]) -> ExitCode {

View File

@ -168,7 +168,7 @@ fn main() -> eframe::Result {
if let Some(icon) = app_icon() {
options.viewport = options.viewport.with_icon(icon);
}
eframe::run_native(
let result = eframe::run_native(
"SoundCraft",
options,
Box::new(move |cc| {
@ -188,5 +188,8 @@ fn main() -> eframe::Result {
}
Ok(Box::new(App(app)))
}),
)
);
// The app, its player and their plugin instances are gone once the event loop returns.
soundcraft_engine::shutdown_plugin_hosts();
result
}

View File

@ -23,6 +23,13 @@ use std::sync::Arc;
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use soundcraft_model as model;
/// Shuts the third-party plugin hosts down, running the module exit of every plugin binary they
/// loaded. Call it at the end of `main`, once every plugin instance (mixer, player) is gone: a
/// plugin that has been used can crash when its module exit only runs from an exit handler.
pub fn shutdown_plugin_hosts() {
soundcraft_vst3_host::shutdown();
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum EngineError {
#[error("unknown command `{0}`")]

View File

@ -39,6 +39,10 @@ harness = false
name = "module_exit_worker_thread"
harness = false
[[test]]
name = "module_exit_shutdown"
harness = false
# An isolated unsafe helper crate (craftrules never-crash), like soundcraft-clap-host: this crate
# opts out of the workspace `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere
# and allows it solely on the FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`.

View File

@ -316,7 +316,7 @@ fn open_library(bundle: &Path, exe: &Path) -> Result<(libloading::Library, Optio
/// with a live instance or leaked objects keeps running and is not exited, and so is one entered
/// on the main thread when the process exits from another thread. Runs once; later loads are
/// refused.
fn exit_modules() {
pub(crate) fn exit_modules() {
if MODULES_EXITED.swap(true, Ordering::AcqRel) {
return;
}

View File

@ -135,6 +135,9 @@ mod api {
pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> {
registry::plugin_info(id)
}
pub fn shutdown() {
ffi::exit_modules();
}
}
#[cfg(target_arch = "wasm32")]
@ -162,6 +165,7 @@ mod api {
pub fn plugin_info(_id: &str) -> Option<&'static PluginInfo> {
None
}
pub fn shutdown() {}
}
/// Every plugin in the standard VST3 folders, `VST3_PATH` and folders added with
@ -187,6 +191,15 @@ pub fn scan_paths(dirs: &[PathBuf]) -> Vec<Vst3Descriptor> {
api::scan_paths(dirs)
}
/// Shuts plugin hosting down: runs the module exit of every loaded binary that has no live
/// instance left, each on the thread that loaded it; later loads fail. Call it on the main
/// thread once the plugin instances are gone, before the process exits. Without it the exits
/// run from an exit handler, by which time some plugins have already torn down state they need
/// (HALion Sonic crashes in its module exit after it has been used).
pub fn shutdown() {
api::shutdown()
}
/// The audio module classes in one `.vst3` bundle or file.
pub fn load_bundle(path: &Path) -> Result<Vec<Vst3Descriptor>, Vst3Error> {
api::load_bundle(path)

View File

@ -625,6 +625,7 @@ impl IPluginFactoryTrait for Factory {
kResultOk
}
unsafe fn createInstance(&self, cid: FIDString, iid: FIDString, obj: *mut *mut c_void) -> tresult {
set_up_late_state();
let cid = unsafe { *(cid as *const TUID) };
let unknown = if cid == GAIN_CID {
ComWrapper::new(GainProcessor {
@ -676,9 +677,10 @@ impl IPluginFactory2Trait for Factory {
//
// The contract real plugins rely on: the host runs the module exit once the factory and every
// plugin object are released, on the thread that ran the module entry, and before the process
// tears down the module's statics. Breaking it aborts the process, much as real plugins crash
// (HALion Sonic segfaults in its static destructors when the module exit never ran; Massive X in
// its module exit when that runs on another thread than the entry).
// tears down the module's statics. Breaking it ends the process with status 3, much as real
// plugins crash (HALion Sonic segfaults in its static destructors when the module exit never ran,
// and in its module exit when that runs from an exit handler after it was used; Massive X in its
// module exit when that runs on another thread than the entry).
/// Module entries minus module exits (they nest, as in the VST3 SDK).
static ENTERED: AtomicI64 = AtomicI64::new(0);
@ -689,6 +691,15 @@ static FACTORIES: AtomicI64 = AtomicI64::new(0);
/// Plugin objects alive: components, controllers, views.
static OBJECTS: AtomicI64 = AtomicI64::new(0);
static TEARDOWN_REGISTERED: AtomicBool = AtomicBool::new(false);
/// With `VST3_FIXTURE_LATE_STATE` set, the first plugin made sets up state that the process exit
/// tears down, like a static a real plugin initialises lazily; the module exit needs it.
static LATE_STATE_REGISTERED: AtomicBool = AtomicBool::new(false);
static LATE_STATE_GONE: AtomicBool = AtomicBool::new(false);
unsafe extern "C" {
fn atexit(f: extern "C" fn()) -> c_int;
fn _exit(status: c_int) -> !;
}
/// Counts itself in one of the counters above for as long as it exists.
struct Live(&'static AtomicI64);
@ -706,9 +717,10 @@ impl Drop for Live {
}
}
/// Ends the process at once with status 3 (no crash report: one test expects this).
fn violation(what: &str) -> ! {
eprintln!("vst3-fixture: host contract violation: {what}");
std::process::abort();
unsafe { _exit(3) }
}
/// The OS id of the calling thread (`std::thread::current` is gone in exit handlers).
@ -735,14 +747,22 @@ fn module_entry() -> bool {
ENTRY_THREAD.store(os_thread(), Ordering::SeqCst);
}
if !TEARDOWN_REGISTERED.swap(true, Ordering::SeqCst) {
unsafe extern "C" {
fn atexit(f: extern "C" fn()) -> c_int;
}
unsafe { atexit(static_teardown) };
}
true
}
/// Called whenever the factory makes a plugin object.
fn set_up_late_state() {
if std::env::var_os("VST3_FIXTURE_LATE_STATE").is_some() && !LATE_STATE_REGISTERED.swap(true, Ordering::SeqCst) {
unsafe { atexit(late_state_teardown) };
}
}
extern "C" fn late_state_teardown() {
LATE_STATE_GONE.store(true, Ordering::SeqCst);
}
fn module_exit() -> bool {
let left = ENTERED.fetch_sub(1, Ordering::SeqCst) - 1;
if left < 0 {
@ -755,6 +775,9 @@ fn module_exit() -> bool {
if left == 0 && os_thread() != ENTRY_THREAD.load(Ordering::SeqCst) {
violation("module exit on another thread than the module entry");
}
if LATE_STATE_GONE.load(Ordering::SeqCst) {
violation("module exit after the process exit tore down state the module set up while in use");
}
true
}

View File

@ -0,0 +1,44 @@
//! A plugin that sets up state while in use (HALion Sonic does) needs its module exit before the
//! process exit tears that state down: `shutdown()` runs the exits up front, while an exit
//! handler comes too late (`harness = false`, see `module_exit.rs`). Each case runs in a child
//! process with the fixture's late state on (`VST3_FIXTURE_LATE_STATE`); the fixture ends the
//! process with status 3 when its module exit comes after the teardown.
mod common;
use soundcraft_dsp::Plugin;
use std::process::{Command, Output};
/// Makes, uses and releases a plugin, then shuts plugin hosting down unless `exit_handler_only`.
fn child(exit_handler_only: bool) {
let dir = common::fixture_dir();
let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id;
let mut p = soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate");
p.prepare(48_000.0, 64, 2);
let mut io = vec![vec![0.5f32; 64]; 2];
p.process(&mut io, 64);
drop(p);
if !exit_handler_only {
soundcraft_vst3_host::shutdown();
assert!(soundcraft_vst3_host::instantiate_plugin(&gain).is_err(), "loads fail after shutdown");
}
}
fn run(case: &str) -> Output {
Command::new(std::env::current_exe().expect("test binary")).arg(case).env("VST3_FIXTURE_LATE_STATE", "1").output().expect("run the child")
}
fn main() {
match std::env::args().nth(1).as_deref() {
Some("shutdown") => return child(false),
Some("exit-handler") => return child(true),
_ => {}
}
let up_front = run("shutdown");
assert!(up_front.status.success(), "shutdown before exit: {:?}\n{}", up_front.status, String::from_utf8_lossy(&up_front.stderr));
let too_late = run("exit-handler");
let err = String::from_utf8_lossy(&too_late.stderr);
assert_eq!(too_late.status.code(), Some(3), "the exit handler alone comes too late: {err}");
assert!(err.contains("module exit after the process exit tore down state"), "{err}");
println!("module_exit_shutdown: ok");
}