diff --git a/src/audio/VSTHost.cpp b/src/audio/VSTHost.cpp index 933cb57..5f32424 100644 --- a/src/audio/VSTHost.cpp +++ b/src/audio/VSTHost.cpp @@ -1,6 +1,6 @@ #include "VSTHost.h" #include "VSTTrace.h" -#include "addon/LifecycleExecutor.h" +#include "addon/PluginModulePin.h" // The out-of-process scan path is compiled only into the audio addon // (SLOPSMITH_AUDIO_ADDON, set in src/audio/CMakeLists.txt). slopsmith-vst-host diff --git a/src/audio/addon/LifecycleExecutor.cpp b/src/audio/addon/LifecycleExecutor.cpp index 8a1f6af..cc652dd 100644 --- a/src/audio/addon/LifecycleExecutor.cpp +++ b/src/audio/addon/LifecycleExecutor.cpp @@ -7,14 +7,10 @@ #include #include +#include #include #include -#if JUCE_WINDOWS - #define WIN32_LEAN_AND_MEAN - #include -#endif - namespace slopsmith::addon { static std::atomic engineGeneration{1}; @@ -50,9 +46,17 @@ LifecycleOpResult runLifecycleOp(const char* name, return LifecycleOpResult::neverRan; } - // Already on the message thread (engine init path, macOS inline mode): - // dispatch-and-wait on ourselves would deadlock. Run inline — we are by - // definition serialized with every queued op. + // Already on the message thread (engine init path, macOS inline mode, + // or a lifecycle op submitting a nested op): dispatch-and-wait on + // ourselves would deadlock, so run inline. + // + // Ordering note (CodeRabbit #120): an inline op runs as part of the + // CURRENT message-thread turn, i.e. ahead of ops still queued behind + // it. That is intentional, not a FIFO leak: a nested submission is + // semantically part of the outer op and must complete inside it, and a + // message-thread caller can never interleave with a *running* op — the + // queue drains on this very thread. The FIFO contract is between + // off-thread submitters, which all take the queued path below. if (mm->isThisTheMessageThread()) { if (stamped != currentEngineGeneration()) @@ -60,7 +64,13 @@ LifecycleOpResult runLifecycleOp(const char* name, fprintf(stderr, "[lifecycle] op '%s': stale generation (inline); skipped\n", name); return LifecycleOpResult::stale; } - func(); + try { func(); } + catch (const std::exception& e) { + fprintf(stderr, "[lifecycle] op '%s' (inline) threw: %s\n", name, e.what()); + } + catch (...) { + fprintf(stderr, "[lifecycle] op '%s' (inline) threw (unknown)\n", name); + } return LifecycleOpResult::completed; } @@ -86,7 +96,16 @@ LifecycleOpResult runLifecycleOp(const char* name, } else { - func(); + // Always reach the signal below: a throwing op would + // otherwise leave its unbounded caller waiting forever + // (CodeRabbit #120). + try { func(); } + catch (const std::exception& e) { + fprintf(stderr, "[lifecycle] op '%s' threw: %s\n", name, e.what()); + } + catch (...) { + fprintf(stderr, "[lifecycle] op '%s' threw (unknown)\n", name); + } } shared->done.signal(); }); @@ -133,35 +152,4 @@ LifecycleOpResult runLifecycleOp(const char* name, : LifecycleOpResult::completed; } -void pinPluginModuleForever(const char* fileOrIdentifierUtf8) -{ -#if JUCE_WINDOWS - // JUCE loads the inner /Contents/x86_64-win/.vst3 (or a - // flat .vst3/.dll); either way the loaded module's base name is the - // path's final component. Pin by that name so the loader never unloads - // it, even when JUCE's module refcount hits zero. - const juce::String path = juce::String::fromUTF8(fileOrIdentifierUtf8); - const juce::String base = path.replaceCharacter('\\', '/') - .fromLastOccurrenceOf("/", false, false); - if (base.isEmpty()) - return; - - HMODULE h = nullptr; - if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_PIN, - base.toWideCharPointer(), &h)) - { - fprintf(stderr, "[lifecycle] pinned plugin module '%s' for process " - "lifetime\n", base.toRawUTF8()); - } - // Not currently loaded under that name (sandboxed plugin, or the inner - // file is named differently): nothing to pin — the sandbox child owns - // its own modules, and an in-process module we can't resolve here was - // loaded under some other base name and will be pinned on a later load - // if it ever resolves. Silent by design; this is a belt on JUCE's - // refcount braces, not a correctness gate. -#else - (void) fileOrIdentifierUtf8; -#endif -} - } // namespace slopsmith::addon diff --git a/src/audio/addon/LifecycleExecutor.h b/src/audio/addon/LifecycleExecutor.h index df9921b..98bfceb 100644 --- a/src/audio/addon/LifecycleExecutor.h +++ b/src/audio/addon/LifecycleExecutor.h @@ -70,14 +70,9 @@ inline bool runLifecycleOpOk(const char* name, std::function func, == LifecycleOpResult::completed; } -// Pin a plugin module so the OS never unloads it for the life of the -// process (guide §12 P0 item 4). JUCE refcounts VST3 modules and unloads -// them when the last instance dies; a window/timer message already queued -// to that module's code then fires into unmapped or reused pages — the CFG -// fail-fast (0xc0000409 / FAST_FAIL_GUARD_ICALL_CHECK_FAILURE) in the -// 2026-07-17 dumps. The pin is by module base name (the inner -// x86_64-win/*.vst3 file JUCE actually loads shares the bundle's base -// name). No-op off Windows and for modules that are not currently loaded. -void pinPluginModuleForever(const char* fileOrIdentifierUtf8); +// Plugin-module pinning (guide §12 P0 item 4) lives in PluginModulePin.h — +// header-only, because VSTHost.cpp is compiled into targets across three +// CMake projects and an out-of-line definition broke the ones that don't +// build this executor (PR #120 CI). } // namespace slopsmith::addon diff --git a/src/audio/addon/PluginModulePin.h b/src/audio/addon/PluginModulePin.h new file mode 100644 index 0000000..f2e84df --- /dev/null +++ b/src/audio/addon/PluginModulePin.h @@ -0,0 +1,99 @@ +#pragma once + +// Plugin-module pinning — P0 item 4 of the audio architecture guide (§12). +// +// JUCE refcounts VST3 modules and unloads them when the last instance dies; +// a window/timer message already queued to that module's code then fires +// into unmapped or reused pages — the CFG fail-fast +// (0xc0000409 / FAST_FAIL_GUARD_ICALL_CHECK_FAILURE) in the 2026-07-17 +// field dumps. Pinning tells the loader to never unload the module for the +// life of the process. +// +// Header-only ON PURPOSE: VSTHost.cpp is compiled into four different +// targets across three CMake projects (the addon, slopsmith-vst-host in +// two projects, slopsmith-vst-scan on macOS); an out-of-line definition +// broke every link that didn't add the extra .cpp (PR #120 CI). +// +// The pin matches loaded modules by PATH, not base name: the identifier we +// get is usually the bundle directory (…\Foo.vst3\), while the loader knows +// the inner …\Contents\x86_64-win\Foo.vst3 file — and two plugins may share +// a base name. Enumerate loaded modules and pin every one whose full path +// starts with the normalized identifier (K32* exports so no psapi.lib). +// No-op off Windows and for modules that are not currently loaded (e.g. +// sandboxed plugins — the child process owns those). + +#include + +#if defined(_WIN32) + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + #ifndef NOMINMAX + #define NOMINMAX + #endif + #include + // PSAPI_VERSION 2 maps EnumProcessModules/GetModuleFileNameExW onto the + // kernel32-exported K32* variants — no psapi.lib link needed (matters for + // the extra targets this header serves; see the header-only note above). + #ifndef PSAPI_VERSION + #define PSAPI_VERSION 2 + #endif + #include + #include + #include +#endif + +#include + +namespace slopsmith::addon { + +inline void pinPluginModuleForever(const char* fileOrIdentifierUtf8) +{ +#if defined(_WIN32) + const juce::String wanted = + juce::String::fromUTF8(fileOrIdentifierUtf8).replaceCharacter('/', '\\'); + if (wanted.isEmpty()) + return; + + auto startsWithIgnoreCase = [](const wchar_t* full, const wchar_t* prefix) { + while (*prefix != 0) + { + if (*full == 0 + || std::towlower(static_cast(*full)) + != std::towlower(static_cast(*prefix))) + return false; + ++full; + ++prefix; + } + return true; + }; + + std::vector modules(1024); + DWORD needed = 0; + if (!EnumProcessModules(GetCurrentProcess(), modules.data(), + static_cast(modules.size() * sizeof(HMODULE)), + &needed)) + return; + modules.resize(needed / sizeof(HMODULE)); + + for (HMODULE mod : modules) + { + wchar_t modPath[MAX_PATH * 2] = {}; + if (GetModuleFileNameExW(GetCurrentProcess(), mod, modPath, + static_cast(sizeof(modPath) / sizeof(modPath[0]))) == 0) + continue; + if (!startsWithIgnoreCase(modPath, wanted.toWideCharPointer())) + continue; + + HMODULE pinned = nullptr; + if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_PIN, modPath, &pinned)) + fprintf(stderr, "[lifecycle] pinned plugin module '%s' for process " + "lifetime\n", + juce::String(modPath).toRawUTF8()); + } +#else + (void) fileOrIdentifierUtf8; +#endif +} + +} // namespace slopsmith::addon diff --git a/src/vst-host/CMakeLists.txt b/src/vst-host/CMakeLists.txt index a516a64..a6462f0 100644 --- a/src/vst-host/CMakeLists.txt +++ b/src/vst-host/CMakeLists.txt @@ -63,7 +63,6 @@ endfunction() set(VST_HOST_SOURCES main.cpp ../audio/VSTHost.cpp - ../audio/addon/LifecycleExecutor.cpp ../audio/Sandbox/Protocol.cpp ../audio/Sandbox/ControlChannel_shared.cpp ../audio/Sandbox/AudioChannel_shared.cpp