diff --git a/install/daw_vst_bridge.exe b/install/daw_vst_bridge.exe index cebf116..0a02019 100644 Binary files a/install/daw_vst_bridge.exe and b/install/daw_vst_bridge.exe differ diff --git a/native_bridge/CMakeLists.txt b/native_bridge/CMakeLists.txt index 8fca7b1..7b226ea 100644 --- a/native_bridge/CMakeLists.txt +++ b/native_bridge/CMakeLists.txt @@ -3,6 +3,9 @@ project(daw_vst_bridge LANGUAGES C CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) +if(MSVC) + string(REPLACE "/EHsc" "/EHa" CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}") +endif() # ── 1. VST3 SDK (Steinberg, vendored as git submodule — NOT in vcpkg) ── if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/vst3sdk/CMakeLists.txt") @@ -71,7 +74,7 @@ endif() if(WIN32) target_link_libraries(daw_vst_bridge PRIVATE ${FLUIDSYNTH_LIBRARY} ${SFIZZ_LIBRARY}) # Required Windows libs - target_link_libraries(daw_vst_bridge PRIVATE winmm) + target_link_libraries(daw_vst_bridge PRIVATE winmm imm32) else() target_link_libraries(daw_vst_bridge PRIVATE ${FLUIDSYNTH_LIBRARIES} ${SFIZZ_LIBRARIES}) endif() diff --git a/native_bridge/src/Vst3Instrument.cpp b/native_bridge/src/Vst3Instrument.cpp index e32e036..92cfb78 100644 --- a/native_bridge/src/Vst3Instrument.cpp +++ b/native_bridge/src/Vst3Instrument.cpp @@ -213,14 +213,12 @@ bool Vst3Instrument::loadPlugin(const std::string& path, double sampleRate) { using namespace VST3::Hosting; std::string err; - std::cerr << "[dbg] loadPlugin: Module::create ..." << std::endl; Module::Ptr module = Module::create(path, err); if (!module) { std::cerr << "[Vst3Instrument] Module::create failed: " << err << std::endl; return false; } const PluginFactory& factory = module->getFactory(); - std::cerr << "[dbg] loadPlugin: Module::create OK" << std::endl; // Pick the first Audio Module class; prefer an Instrument subcategory. // classInfos() returns a TEMPORARY vector (by value) — never keep a @@ -244,16 +242,12 @@ bool Vst3Instrument::loadPlugin(const std::string& path, double sampleRate) { return false; } - std::cerr << "[dbg] loadPlugin: createInstance ..." << std::endl; - std::cerr << "[dbg] loadPlugin: chosen name=" << chosen.name() << " category=" << chosen.category() - << " subcat=" << chosen.subCategoriesString() << std::endl; IPtr component = factory.createInstance(chosen.ID()); if (!component) { std::cerr << "[Vst3Instrument] createInstance failed" << std::endl; return false; } IPtr hostApp = owned(new HostApplication()); - std::cerr << "[dbg] loadPlugin: initialize ..." << std::endl; FUnknownPtr plugBase(component.get()); if (!plugBase || plugBase->initialize(hostApp) != kResultOk) { std::cerr << "[Vst3Instrument] component initialize failed" << std::endl; @@ -269,13 +263,8 @@ bool Vst3Instrument::loadPlugin(const std::string& path, double sampleRate) { } else { Steinberg::TUID cid = {}; tresult cidRes = component->getControllerClassId(cid); - std::cerr << "[dbg] loadPlugin: getControllerClassId=" << (int)cidRes - << " cid="; - for (int b = 0; b < 16; ++b) std::cerr << std::hex << (int)(unsigned char)cid[b] << ' '; - std::cerr << std::dec << std::endl; if (cidRes == kResultTrue || cidRes == kResultOk) { controller = factory.createInstance(VST3::UID(cid)); - std::cerr << "[dbg] loadPlugin: controller from factory=" << (controller ? 1 : 0) << std::endl; if (controller) { FUnknownPtr ctrlBase(controller.get()); if (!ctrlBase || ctrlBase->initialize(hostApp) != kResultOk) controller = nullptr; @@ -326,13 +315,11 @@ bool Vst3Instrument::loadPlugin(const std::string& path, double sampleRate) { std::cerr << "[Vst3Instrument] no IAudioProcessor" << std::endl; return false; } - std::cerr << "[dbg] loadPlugin: setupProcessing ..." << std::endl; ProcessSetup setup{kRealtime, kSample32, (int32)maxBlockSize_, sampleRate}; if (processor->setupProcessing(setup) != kResultOk) { std::cerr << "[Vst3Instrument] setupProcessing failed" << std::endl; return false; } - std::cerr << "[dbg] loadPlugin: setActive ..." << std::endl; if (component->setActive(true) != kResultOk) { std::cerr << "[Vst3Instrument] setActive failed" << std::endl; return false; @@ -342,7 +329,6 @@ bool Vst3Instrument::loadPlugin(const std::string& path, double sampleRate) { // Build per-bus buffers sized maxBlockSize_ (HostProcessData owns them; // we must NOT override channelBuffers with external pointers — unprepare // would delete[] them). - std::cerr << "[dbg] loadPlugin: processData.prepare ..." << std::endl; if (!s->processData.prepare(*component, (int32)maxBlockSize_, kSample32)) { std::cerr << "[Vst3Instrument] processData.prepare failed" << std::endl; return false; @@ -356,7 +342,6 @@ bool Vst3Instrument::loadPlugin(const std::string& path, double sampleRate) { outChannels = bi.channelCount; } - std::cerr << "[dbg] loadPlugin: prepare OK, wiring state" << std::endl; // SDK EventList default maxSize=50 is far too small for the STOP // all-notes-off sweep (128 note-offs per channel). addEvent beyond the // cap fails SILENTLY → notes never released → stuck sound loop. @@ -554,7 +539,6 @@ bool Vst3Instrument::reloadForGUI() { // PHAN NAY PHAI chay tren worker thread — apartment cua channel song // o day; chay tren thread tam thi apartment moi chet ngay sau do // (reopen lan sau -> treo nhu bridge_like). - std::cerr << "[dbg] openGUI: re-attach - reloading fresh plugin instance" << std::endl; if (!reload()) return false; s = static_cast(state_); if (!s || !s->controller) return false; @@ -569,35 +553,21 @@ bool Vst3Instrument::attachView(void* parentWindowHandle) { return false; #else auto* s = static_cast(state_); - std::cerr << "[dbg] openGUI hwnd=" << (void*)(uintptr_t)parentWindowHandle - << " controller=" << (s ? (s->controller ? 1 : 0) : -1) << std::endl; if (!s || !s->controller || !parentWindowHandle) return false; IPlugView* rawView = nullptr; - tresult qi = s->controller->queryInterface(IPlugView::iid, (void**)&rawView); - std::cerr << "[dbg] openGUI: controller qi IPlugView=" << (int)qi << " raw=" << (void*)rawView << " isSingle=" << (s->controllerIsComponent ? 1 : 0) << std::endl; FUnknownPtr view(rawView); if (!view) { // Mot so plugin khong expose IPlugView tren edit controller; thu component. - std::cerr << "[dbg] openGUI: controller no IPlugView, trying component" << std::endl; IPlugView* rawViewC = nullptr; - tresult qic = s->component->queryInterface(IPlugView::iid, (void**)&rawViewC); - std::cerr << "[dbg] openGUI: component qi IPlugView=" << (int)qic << " raw=" << (void*)rawViewC << std::endl; view = FUnknownPtr(rawViewC); } if (!view) { // Official editorhost.cpp pattern: IEditController::createView(kEditor). // JUCE-based plugins (Scaler2) expose the editor only this way. - std::cerr << "[dbg] openGUI: qi failed, trying controller->createView(kEditor)" << std::endl; view = owned(s->controller->createView(Steinberg::Vst::ViewType::kEditor)); - std::cerr << "[dbg] openGUI: createView view=" << (view ? "ok" : "null") << std::endl; } - if (!view) { std::cerr << "[dbg] openGUI: no IPlugView" << std::endl; return false; } view->setFrame(&s->plugFrame); - tresult ts = view->isPlatformTypeSupported(kPlatformTypeHWND); - std::cerr << "[dbg] openGUI: isPlatformTypeSupported=" << (int)ts << std::endl; - // Log only — proceed to attach anyway to support non-compliant plugins. tresult ta = view->attached(parentWindowHandle, kPlatformTypeHWND); - std::cerr << "[dbg] openGUI: attached=" << (int)ta << std::endl; if (ta != kResultOk) return false; #ifdef _WIN32 HWND hwnd = (HWND)parentWindowHandle; @@ -663,9 +633,6 @@ void Vst3Instrument::closeGUI() { // path out. Callers (post_close_gui / load job) additionally destroy the // editor's child windows right here on this thread so the plugin stops // pumping; the leaked editor object is inert once its windows are gone. - if (s && s->view && guiAttached_) - std::cerr << "[dbg] closeGUI: skip view->removed() (Nexus deadlock) view=" - << (void*)s->view << std::endl; if (s) s->view = nullptr; guiAttached_ = false; #endif @@ -696,8 +663,6 @@ void Vst3Instrument::processAudioBlock(float* outputL, float* outputR, uint32_t (double)s->processContext.projectTimeSamples / s->processContext.sampleRate * (s->processContext.tempo / 60.0); - static uint32_t dbgN = 0; - static int dbgMaxShown = 0; // ev>0 blocks printed (raised: sweep must be visible) // VST3: host buffers must be zeroed (silence) before process — plugins // that skip output leave garbage otherwise (EZkeys 2 -> huge noise). if (s->processData.numOutputs > 0) { @@ -727,19 +692,6 @@ void Vst3Instrument::processAudioBlock(float* outputL, float* outputR, uint32_t std::memset(outputL, 0, numSamples * sizeof(float)); std::memset(outputR, 0, numSamples * sizeof(float)); } - uint32_t evc = s->eventList.getEventCount(); - int evt = -1; - if (evc > 0) { - const Event* e0 = s->eventList.getEventByIndex(0); - evt = e0 ? (int)e0->type : -2; - } - if ((++dbgN % 250) == 0 || (evc > 0 && dbgMaxShown < 5000)) { - if (evc > 0) ++dbgMaxShown; - std::cerr << "[dbg] pid=" << (int)GetCurrentProcessId() << " ch=" << channel_ << " n=" << numSamples << " ev=" << evc << " evt=" << evt - << " pr=" << (int)pr << " nOut=" << s->processData.numOutputs - << " mx=" << mx << " ts=" << s->processContext.projectTimeSamples - << " nch=" << (s->processData.numOutputs > 0 ? s->processData.outputs[0].numChannels : -1) << std::endl; - } s->eventList.clear(); s->paramChanges.clearQueue(); #endif diff --git a/native_bridge/src/main.cpp b/native_bridge/src/main.cpp index a82ac3d..ac8122a 100644 --- a/native_bridge/src/main.cpp +++ b/native_bridge/src/main.cpp @@ -7,6 +7,7 @@ #ifdef _WIN32 #include +#include #include #include #include @@ -28,6 +29,7 @@ #include #include #include +#include #include #include @@ -51,6 +53,62 @@ static void sleep_ms(uint32_t ms) { #endif } +#ifdef _WIN32 +// IME recursion fix (Nexus 0xC00000FD -> USER32 0xC000041D): the plugin's +// wndproc calls ImmIsUIMessageW for every message and forwards WM_IME_* to the +// IME window; with a live IMC on the editor window that bounces back to the +// same hwnd -> infinite SendMessageW recursion -> stack overflow. Removing the +// IMC (ImmAssociateContext NULL) stops the forwarding. +static void disable_ime_contexts(HWND w) { + if (w && IsWindow(w)) { + char cls[64] = {0}; + GetClassNameA(w, cls, 63); + ImmAssociateContext(w, nullptr); + for (HWND c = GetWindow(w, GW_CHILD); c; c = GetWindow(c, GW_HWNDNEXT)) { + char cls2[64] = {0}; + GetClassNameA(c, cls2, 63); + ImmAssociateContext(c, nullptr); + } + } +} + +// CRASH FIX (run 16, 0xc0000005): thread ids owning JUCE_* windows (JUCE +// message windows have NO parent under the native window -> the pump gate's +// per-channel cases miss them). Recorded by the CBT hook at window birth and +// by the pump. While a CLOSE JOB is in flight (close_in_flight), a message +// bound for a JUCE owner thread must not be dispatched into its plugin DLL — +// observed: worker pump dispatching a Nexus wndproc (heap free) while another +// worker was inside createView -> 2 threads in one DLL -> Nexus AV/heap +// corruption. Over-gates every channel's JUCE windows during a teardown +// window (brief; editors may glitch, bridge survives). NOT gated on plain +// reloading (attach-silence) — that starves view->attached() (run 17 hang). +static std::mutex g_juceTidsMutex; +static std::set g_juceOwnerTids; + +// CBT hook: strip the IMC the moment any JUCE_* window is born (JUCE message +// window AND editor child) — the post-attach disable_ime_contexts runs too +// late; the recursion can start inside attachView (0xC00000FD observed, run +// 9). Runs on the window's creating thread, so the association is legal. +static HHOOK g_cbtHook = nullptr; +static LRESULT CALLBACK ImeCbtHookProc(int nCode, WPARAM wParam, LPARAM lParam) { + if (nCode == HCBT_CREATEWND) { + // Class name via GetClassNameA, NOT lpszClass: JUCE creates windows + // with MAKEINTATOM class names (HIWORD(lpszClass)==0), and ANSI-created + // windows deliver a CREATESTRUCTA — lpszClass is unusable in both cases. + HWND w = (HWND)wParam; + char cls[64] = {0}; + if (GetClassNameA(w, cls, 63) > 0 && std::strncmp(cls, "JUCE_", 5) == 0) { + ImmAssociateContext(w, nullptr); + { + std::lock_guard lk(g_juceTidsMutex); + g_juceOwnerTids.insert(GetCurrentThreadId()); + } + } + } + return CallNextHookEx(g_cbtHook, nCode, wParam, lParam); +} +#endif + #ifdef _WIN32 // Native VST editor windows registry — global de WM_DESTROY (chay tren worker // thread cua channel tao window) co the don map. USERDATA luu channel+1 (KHONG @@ -58,11 +116,15 @@ static void sleep_ms(uint32_t ms) { // bi huy → WM_DESTROY tren con tro dangling → crash/hang bridge). class ChannelWorker; // fwd — WM_DESTROY posts closeGUI() to the channel worker static void post_close_gui(uint32_t ch, HWND hwnd); // defined after ChannelWorker +static bool is_teardown_window(HWND hwnd, uint32_t pch); // fwd — defined after ChannelWorker +static uint32_t pump_window_channel(HWND hwnd); // fwd — defined after ChannelWorker static InstrumentEngineManager* g_engine = nullptr; static std::mutex g_guiMutex; static std::map g_guiWindows; // channel -> HWND (keep window alive) static std::map g_hwndToCh; // HWND -> channel (WM_DESTROY cleanup) static std::map>* g_workers = nullptr; +static std::mutex g_tidMutex; +static std::map g_tidToWorker; // worker tid -> worker (owner-thread destroy) // Same-plugin-DLL reentrancy guards: two threads inside one VST3 DLL (Nexus) // crash or deadlock. g_attachPaths = lowercase plugin paths whose reload/ // createView is running on some worker — a same-path close job must not unmute @@ -73,6 +135,24 @@ static std::map>* g_workers = nullptr; static std::mutex g_attachMutex; static std::vector g_attachPaths; static bool g_closeInFlight[16] = { false }; +static DWORD g_ownerTid[16] = { 0 }; +// Per channel: thread id of the plugin instance's JUCE MessageManager owner +// (recorded by the worker pump when it first sees the channel's editor +// window, and at attach end). 0 = unknown. Used by the pump teardown gate to +// drop messages for windows the owner thread pumps (incl. the instance's +// JUCE message window, which has no parent under the native window). +// Editor-open tracking: while a channel's VST editor (native window) is +// attached, EVERY channel assigned the same plugin DLL path must stay quiet — +// the plugin's window proc runs on the editor channel's worker while the +// audio loop calls process() on other instances of the same DLL (2 threads +// in one DLL -> Nexus USER32 crash). g_editorPathCount = refcount per +// lowercase plugin path; g_editorOpenPath/g_editorOpen per channel. +// Lock order: g_editorMutex is taken while holding the engine mutex +// (channelQuiet) or standalone in non-rt jobs — never the reverse. +static std::mutex g_editorMutex; +static std::map g_editorPathCount; +static std::string g_editorOpenPath[16]; +static bool g_editorOpen[16] = { false }; static LRESULT CALLBACK VstWindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam) { if (uMsg == WM_CLOSE) { @@ -164,7 +244,18 @@ public: ChannelWorker() { th_ = std::thread([this] { #ifdef _WIN32 + { + std::lock_guard lk(g_tidMutex); + g_tidToWorker[GetCurrentThreadId()] = this; + } OleInitialize(nullptr); + // Default IMC = none on this thread: new editor windows get + // no IME context (see disable_ime_contexts). + ImmAssociateContextEx(nullptr, nullptr, IACE_DEFAULT); + // CBT hook on this worker: strip IMC on JUCE_* windows at birth. + // Global hooks fail from an exe module — install per thread, and + // this thread creates the plugin editor windows. + SetWindowsHookExW(WH_CBT, ImeCbtHookProc, GetModuleHandleW(nullptr), GetCurrentThreadId()); #endif std::unique_lock lk(mu_); for (;;) { @@ -189,12 +280,60 @@ public: // close wait times out, two editors stay alive (deadlock). for (int pumped = 0; pumped < 16; ++pumped) { if (!PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) break; + // CRASH FIX (0xc000041d): JUCE editor child windows of a + // plugin DLL are owned by the FIRST worker that ran the + // DLL's global JUCE MessageManager - not by the channel + // worker doing the teardown. While another worker's LOAD / + // close job tears an instance down (reloading / close in + // flight), dispatching a message (e.g. WM_PAINT) into the + // plugin wndproc reads instance state being destroyed -> + // AV in USER32 (observed: crash on the owner worker's + // pump, right after the teardown job closed the view). + // Drop the message instead (PeekMessageW already removed + // it): the editor may glitch, the bridge survives. + uint32_t pch = pump_window_channel(msg.hwnd); + if (pch != UINT32_MAX) { + // Plugin-owned window pumped on this thread: remember + // its JUCE owner thread for teardown gating. + std::lock_guard lock(g_guiMutex); + g_ownerTid[pch] = GetCurrentThreadId(); + } + // Belt & braces: record JUCE window owner threads here too + // (a window born before the CBT hook was installed on its + // thread would otherwise never enter g_juceOwnerTids). + { + char cls[64] = ""; + if (msg.hwnd) GetClassNameA(msg.hwnd, cls, sizeof(cls)); + if (std::strncmp(cls, "JUCE_", 5) == 0) { + DWORD ot = GetWindowThreadProcessId(msg.hwnd, nullptr); + if (ot) { + std::lock_guard lk(g_juceTidsMutex); + g_juceOwnerTids.insert(ot); + } + } + } + if (is_teardown_window(msg.hwnd, pch)) { + continue; + } TranslateMessage(&msg); - DispatchMessageW(&msg); + // CRASH FIX (0xc000041d STATUS_FATAL_USER_CALLBACK_EXCEPTION): + // a plugin window proc (Nexus throws nlohmann::json::out_of_range + // internally) that raises a C++ exception would otherwise escape + // DispatchMessageW and std::terminate this worker thread. Catch + // it here — the editor may glitch, but the bridge survives. + try { + DispatchMessageW(&msg); + } catch (...) { + std::cerr << "[NativeBridge] worker pump EXCEPTION — plugin window proc threw" << std::endl; + } } lk.lock(); } #ifdef _WIN32 + { + std::lock_guard lk(g_tidMutex); + g_tidToWorker.erase(GetCurrentThreadId()); + } OleUninitialize(); #endif }); @@ -214,6 +353,29 @@ public: } cv_.notify_all(); } + // Run job on THIS worker and wait (5s cap) until it finished. Used to + // serialize a window destroy with the owner worker's pump. MUST NOT be + // called from a job running on this same worker (deadlock) - callers + // destroy directly when owner tid == current tid. + bool post_and_wait(std::function job) { + std::mutex doneMx; + std::condition_variable doneCv; + bool done = false; + { + std::lock_guard lk(mu_); + jobs_.push_back([&]() { + job(); + { + std::lock_guard dk(doneMx); + done = true; + } + doneCv.notify_all(); + }); + } + cv_.notify_all(); + std::unique_lock dk(doneMx); + return doneCv.wait_for(dk, std::chrono::seconds(5), [&] { return done; }); + } private: std::thread th_; @@ -255,17 +417,129 @@ static bool close_in_flight(uint32_t ch) { return g_closeInFlight[ch]; } +// Channel owning hwnd via its ancestor chain to a registered native VST +// window (UINT32_MAX if none). JUCE editor children hang under the native +// window, so their parent chain resolves to the channel. +static uint32_t pump_window_channel(HWND hwnd) { + for (HWND h = hwnd; h; h = GetParent(h)) { + uint32_t ch = UINT32_MAX; + { + std::lock_guard lock(g_guiMutex); + auto it = g_hwndToCh.find(h); + if (it != g_hwndToCh.end()) ch = it->second; + } + if (ch != UINT32_MAX) return ch; + } + return UINT32_MAX; +} + +// CRASH FIX (0xc000041d): drop a pump message whose dispatch would enter a +// plugin instance that another worker is tearing down (reloading / close in +// flight). Two cases: +// (a) the window's ancestor chain reaches the native window of a tearing- +// down channel (JUCE editor children); +// (b) the window's OWNER THREAD is the JUCE owner of a tearing-down +// channel's instance - covers the instance's JUCE message window (the +// 0x47B/1147 flood window): it has NO parent under the native window, +// is not destroyed by closeGUI, and after terminate() frees the +// instance, dispatching to it reads freed state -> AV in USER32 +// (observed: LOAD worker freed the old Nexus instance while the owner +// thread's pump dispatched msg=1147 -> 0xfeeefeee read). +// g_ownerTid[ch] recorded by the pump (first plugin window seen for ch) and +// at attach end; 0 = unknown -> case (b) inactive for that channel. +static bool is_teardown_window(HWND hwnd, uint32_t pch) { + DWORD ownerTid = GetWindowThreadProcessId(hwnd, nullptr); + if (ownerTid == 0) return false; + // Over-gate: a message bound for any JUCE window owner thread is dropped + // while a CLOSE JOB tears a channel down (close_in_flight) — the JUCE + // message window has no parent under the native window (pch==UINT32_MAX) + // and its owner thread may differ from g_ownerTid[y] (owner changes + // between runs), so the per-channel cases below alone let it through into + // the plugin DLL while another worker is inside createInstance/reload (2 + // threads in one DLL -> Nexus AV, run 16). Deliberately does NOT fire on + // plain reloading flags: the attach path marks same-plugin channels + // reloading for AUDIO silence, and gating their (and the attaching + // channel's own) JUCE windows then starves view->attached() of the + // cross-thread messages it needs -> attach hangs (observed run 17). + { + std::lock_guard lk(g_juceTidsMutex); + if (g_juceOwnerTids.count(ownerTid)) { + for (uint32_t y = 0; y < 16; ++y) + if (close_in_flight(y)) return true; + } + } + for (uint32_t y = 0; y < 16; ++y) { + bool closing = close_in_flight(y); + bool reloading = g_engine && g_engine->isReloading(y); + if (!closing && !reloading) continue; + if (y == pch) return true; + if (g_ownerTid[y] == ownerTid) return true; + } + return false; +} + +// CRASH FIX (0xc000041d): plugin editor child windows are owned by the +// thread that first ran the plugin's JUCE MessageManager (a single worker), +// NOT by the channel worker doing the teardown. Cross-thread DestroyWindow +// races the owner's message pump. Post the destroy to the OWNER worker +// (serialized with its pump - no wndproc entry can race) and wait. Fall +// back to direct destroy when the owner is the current thread or unknown +// (leak > crash). Non-trivial: destroy jobs left queued on timeout are +// harmless - IsWindow guards them, and the pump gate already stopped +// dispatching to teardown windows. +static void destroy_window_on_owner(HWND hwnd) { + if (!hwnd || !IsWindow(hwnd)) return; + DWORD ownerTid = GetWindowThreadProcessId(hwnd, nullptr); + DWORD curTid = GetCurrentThreadId(); + if (ownerTid == 0 || ownerTid == curTid) { + if (IsWindow(hwnd)) DestroyWindow(hwnd); + return; + } + ChannelWorker* owner = nullptr; + { + std::lock_guard lk(g_tidMutex); + auto it = g_tidToWorker.find(ownerTid); + if (it != g_tidToWorker.end()) owner = it->second; + } + if (!owner) { + // Window belongs to a thread we don't control (e.g. main thread). + // Cross-thread DestroyWindow is unsafe -> leave it (leak > crash). + std::cerr << "[NativeBridge] destroy_window_on_owner: owner tid=" << ownerTid + << " not a bridge worker - leaving window " << hwnd << std::endl; + return; + } + if (!owner->post_and_wait([hwnd]() { + if (IsWindow(hwnd)) DestroyWindow(hwnd); + })) + std::cerr << "[NativeBridge] destroy_window_on_owner: timeout waiting owner tid=" + << ownerTid << " to destroy " << hwnd << std::endl; +} + // Unmute y unless its own close job is still inside createInstance — that job // performs the unmute once its fresh instance is loaded (or, if an attach for // the same plugin is in flight, the attach job's restore does it). static void unmute_if_not_closing(uint32_t y, const char* why) { + (void)why; if (close_in_flight(y)) { - std::cerr << "[dbg] " << why << ": ch=" << y - << " close job still in flight - close job unmutes" << std::endl; return; } g_engine->setReloading(y, false); - std::cerr << "[dbg] " << why << ": restored ch=" << y << std::endl; +} + +// Editor closed / instrument replaced: drop the channel's editor-open state +// (refcounted per lowercase plugin path). Called from non-rt jobs only — +// never from the audio loop. +static void clear_editor_open(uint32_t ch) { + std::lock_guard lk(g_editorMutex); + if (!g_editorOpen[ch]) return; + g_editorOpen[ch] = false; + std::string pth = g_editorOpenPath[ch]; + g_editorOpenPath[ch].clear(); + if (!pth.empty()) { + auto it = g_editorPathCount.find(pth); + if (it != g_editorPathCount.end() && --it->second <= 0) + g_editorPathCount.erase(it); + } } // closeGUI() MUST run on the channel worker thread (its COM STA apartment) — @@ -293,11 +567,9 @@ static void post_close_gui(uint32_t ch, HWND hwnd) { std::lock_guard lock(g_guiMutex); auto it = g_guiWindows.find(ch); if (it == g_guiWindows.end() || it->second != hwnd) { - std::cerr << "[dbg] closeGUI ch=" << ch << " skipped (window replaced)" << std::endl; return; } } - std::cerr << "[dbg] closeGUI ch=" << ch << " start" << std::endl; // Silence first: the audio loop must not process() the instance // while we tear its editor down on this thread. g_engine->setReloading(ch, true); @@ -311,7 +583,12 @@ static void post_close_gui(uint32_t ch, HWND hwnd) { // closeGUI() nulls the view WITHOUT view->removed() (Nexus // removed() deadlocks this worker - modal wait for a message // only its own pump can dispatch, but it is inside removed()). - i->closeGUI(); + try { + i->closeGUI(); + } catch (...) { + std::cerr << "[NativeBridge] closeGUI EXCEPTION ch=" << ch + << " — plugin threw" << std::endl; + } // CRASH FIX: the editor was dropped WITHOUT view->removed() — // the instance's editor state is dangling, so the audio loop // must NOT process() it. Rebuild a fresh instance NOW on this @@ -324,30 +601,43 @@ static void post_close_gui(uint32_t ch, HWND hwnd) { std::vector sp = same_plugin_channels(ch); for (uint32_t y : sp) { g_engine->setReloading(y, true); - std::cerr << "[dbg] closeGUI: silenced same-plugin ch=" << y - << " during reload ch=" << ch << std::endl; } - bool reloadOk = false; - { - // destroy children + reload in ONE lock: destroying the - // editor windows runs the plugin's window proc (DLL - // entry) on this worker; it must not race another - // thread's createInstance/createView of the same DLL. - // Same-thread destroy is valid (attachView created them - // on this worker); without destroy the editor keeps - // pumping (0x47b flood) while the audio loop processes - // the instance (2 threads in one DLL) -> Nexus USER32 crash. - std::lock_guard lg(g_loadMutex); #ifdef _WIN32 - if (hwnd && IsWindow(hwnd)) { - while (HWND c = FindWindowExA(hwnd, nullptr, nullptr, nullptr)) - DestroyWindow(c); + // CRASH FIX (0xc000041d): destroy the editor's child windows + // on the thread that OWNS them (the plugin's JUCE + // MessageManager thread - a single worker), never + // cross-thread. Wait for each destroy to finish (owner worker + // serializes it with its pump) BEFORE reload() - otherwise + // reload's createInstance enters the DLL while the owner + // thread is still inside the plugin wndproc (2 threads in one + // DLL -> Nexus crash). Kept OUTSIDE g_loadMutex: the wait + // must not block other loads (the owner worker may itself be + // waiting on that lock). + disable_ime_contexts(hwnd); + if (hwnd && IsWindow(hwnd)) { + while (HWND c = FindWindowExA(hwnd, nullptr, nullptr, nullptr)) { + destroy_window_on_owner(c); } -#endif - reloadOk = i->reload(); } - std::cerr << "[dbg] closeGUI ch=" << ch << " reload=" - << (reloadOk ? 1 : 0) << std::endl; +#endif + { + // reload() = terminate + loadPlugin (createInstance) — + // serialized with all other plugin-DLL entry points. + // Nexus may throw a C++ exception here too; catch it, + // treat as failed reload, and let the restore below + // unmute the channel (instance may be broken; next + // load/assign rebuilds it). + std::lock_guard lg(g_loadMutex); + try { + i->reload(); + } catch (...) { + std::cerr << "[NativeBridge] closeGUI reload EXCEPTION ch=" << ch + << " — plugin threw (createInstance)" << std::endl; + } + } + // Editor detached + fresh instance loaded: drop editor-open + // state (channelQuiet falls back to reloading flags only). + clear_editor_open(ch); // createInstance is done: unmuting is safe again. But if an // attach for the SAME plugin path is still running (its // createView must not race process() on any same-path @@ -364,17 +654,14 @@ static void post_close_gui(uint32_t ch, HWND hwnd) { pathBusy = !p.empty() && std::find(g_attachPaths.begin(), g_attachPaths.end(), p) != g_attachPaths.end(); } - if (pathBusy) { - std::cerr << "[dbg] closeGUI ch=" << ch - << ": attach in flight for same plugin - leaving silenced (attach restore unmutes)" - << std::endl; - } else { + if (!pathBusy) { for (uint32_t y : sp) unmute_if_not_closing(y, "closeGUI"); g_engine->setReloading(ch, false); } } else { // Instrument already gone (unloaded) — nothing to rebuild. g_engine->setReloading(ch, false); + clear_editor_open(ch); std::lock_guard lk(g_attachMutex); g_closeInFlight[ch] = false; } @@ -384,6 +671,14 @@ static void post_close_gui(uint32_t ch, HWND hwnd) { int main(int argc, char* argv[]) { std::cout << "[NativeBridge] Starting DAW Host Bridge Engine..." << std::endl; +#ifdef _WIN32 + // Default IMC = none on the main thread (IME recursion fix). + ImmAssociateContextEx(nullptr, nullptr, IACE_DEFAULT); + // Catch JUCE_* window creation and strip its IMC at birth. Global hooks + // (dwThreadId=0) fail from an exe module (lpfn must be in a DLL) — hooks + // must be installed per thread; workers install their own in ChannelWorker. + g_cbtHook = SetWindowsHookExW(WH_CBT, ImeCbtHookProc, GetModuleHandleW(nullptr), GetCurrentThreadId()); +#endif // 1. Shared memory name: argv --shm | env SF_SHM_NAME | default std::string shmName = "SonicForge_DAW_IPC"; @@ -447,6 +742,14 @@ int main(int argc, char* argv[]) { #ifdef _WIN32 g_engine = &instruments; #endif + // Editor-open predicate: channelQuiet() checks whether ANY editor is + // attached for the channel's plugin DLL path (refcounted in + // g_editorPathCount, updated by the open/close GUI jobs). + instruments.setEditorOpenPredicate([](const std::string& lp) { + std::lock_guard lk(g_editorMutex); + auto it = g_editorPathCount.find(lp); + return it != g_editorPathCount.end() && it->second > 0; + }); // Per-channel persistent workers: loadPlugin + openGUI run on the SAME // thread whose COM STA apartment stays alive for the channel's lifetime // (see ChannelWorker comment — a dead apartment hangs Nexus attached()). @@ -476,8 +779,14 @@ int main(int argc, char* argv[]) { bool transportStopped = false; auto dispatch = [&](const SharedAudioBufferIPC::MidiEventIPC& evt) { - auto* inst = instruments.get(evt.channel); - if (!inst) return; // channel chưa gán instrument → silent (A10) + // Manager-level dispatch: ONE engine-mutex hold per event (get + + // channelQuiet + instrument call) so assign()/unload() can never + // destroy the instance mid-call — use-after-free when loading a new + // VSTi while other channels keep playing. channelQuiet drops events + // for channels mid-reload or whose plugin DLL has an open editor. + // CRASH FIX: noteOn/noteOff/CC enter the plugin (DLL entry); a C++ + // exception from the plugin must not escape the audio loop. + try { switch (evt.command) { case 0x9: // sampleOffset LUON LUON = 0 khi den day: events duoc dispatch ngay @@ -485,12 +794,12 @@ int main(int argc, char* argv[]) { // Truyen offset tuyet doi truoc day lam sfizz/VST3 trigger tre. if (evt.velocity > 0) { if (transportStopped) return; // V8 bug 3: drop note-on sau STOP - inst->noteOn(evt.channel, evt.pitch, evt.velocity / 127.0f, 0); + instruments.noteOn(evt.channel, evt.pitch, evt.velocity / 127.0f); } else - inst->noteOff(evt.channel, evt.pitch, 0); + instruments.noteOff(evt.channel, evt.pitch); break; case 0x8: - inst->noteOff(evt.channel, evt.pitch, 0); + instruments.noteOff(evt.channel, evt.pitch); break; case 0xB: // CC: controller number in pitch, value in data2 // V9 bug 4: sau STOP, drop sustain-down (CC64>0) — JS co the @@ -498,16 +807,21 @@ int main(int argc, char* argv[]) { // note khi pedal down -> am treo loop. CC64=0 (sustain-up) // van cho qua. if (transportStopped && evt.pitch == 64 && evt.data2 > 0) return; - inst->controlChange(evt.channel, evt.pitch, evt.data2); + instruments.controlChange(evt.channel, evt.pitch, evt.data2); break; case 0xC: // program change: program in data2 - inst->programChange(evt.channel, evt.data2); + instruments.programChange(evt.channel, evt.data2); break; case 0xE: // 14-bit pitch bend: data2 = LSB, data3 = MSB - inst->pitchBend(evt.channel, evt.data2 | (uint32_t(evt.data3) << 7)); + instruments.pitchBend(evt.channel, evt.data2 | (uint32_t(evt.data3) << 7)); break; default: break; } + } catch (...) { + std::cerr << "[NativeBridge] dispatch EXCEPTION ch=" << (int)evt.channel + << " cmd=" << std::hex << (int)evt.command << std::dec + << " — plugin threw (note/CC)" << std::endl; + } }; // Render only [from, to) of the block — used by sample-accurate splitting. @@ -542,12 +856,12 @@ int main(int argc, char* argv[]) { // tren view dang attached lam plugin loi (Nexus createView null). if (auto* i0 = instruments.get(guiCh)) { if (i0->hasAttachedView()) { - std::cerr << "[dbg] openGUI: view already attached ch=" << guiCh - << " — skip" << std::endl; return; } } - if (!workers[guiCh]) workers[guiCh] = std::make_unique(); + if (!workers[guiCh]) { + workers[guiCh] = std::make_unique(); + } void* hwnd = (void*)arg1; #ifdef _WIN32 // Window PHAI thuoc MAIN thread (audio loop pump nay dispatch @@ -589,12 +903,8 @@ int main(int argc, char* argv[]) { << " plugin=" << arg2 << " ch=" << guiCh << " (no instrument loaded)" << std::endl; return; } - std::cerr << "[dbg] openGUI thread start hwnd=" << hwnd - << " plugin=" << arg2 << " ch=" << guiCh << std::endl; if (auto* inst0 = instruments.get(guiCh)) { if (inst0->hasAttachedView()) { - std::cerr << "[dbg] openGUI: view already attached ch=" << guiCh - << " — skip" << std::endl; return; } } @@ -622,9 +932,6 @@ int main(int argc, char* argv[]) { // not re-silence a channel that is already unmuted. if (auto* yi0 = instruments.get(y)) { if (!yi0->hasAttachedView()) { - std::cerr << "[dbg] openGUI: ch=" << y - << " editor already detached - skip" - << std::endl; continue; } } @@ -637,13 +944,8 @@ int main(int argc, char* argv[]) { if (!yHwnd || !IsWindow(yHwnd)) continue; if (instruments.get(y)) { instruments.setReloading(y, true); - std::cerr << "[dbg] openGUI: silenced ch=" << y - << " while closing its editor (before attach ch=" - << guiCh << ")" << std::endl; } PostMessage(yHwnd, WM_CLOSE, 0, 0); - std::cerr << "[dbg] openGUI: closing editor ch=" << y - << " before attach ch=" << guiCh << std::endl; // WM_CLOSE -> VstWindowProc -> post_close_gui -> closeGUI() // tren worker cua channel y. Doi cho view da detach (window // duoc giu lai de reuse, khong doi registry erase nhu cu). @@ -661,16 +963,7 @@ int main(int argc, char* argv[]) { if (detached && !close_in_flight(y)) { closed = true; break; } Sleep(10); } - if (closed) { - // Editor detached and its close job finished; the - // close job unmutes the channel itself. Do NOT reset - // reloading here - the close job owns the silence state. - std::cerr << "[dbg] openGUI: restored ch=" << y - << " after editor close" << std::endl; - } else { - std::cerr << "[dbg] openGUI: editor ch=" << y - << " close job not finished in 5s, aborting attach ch=" << guiCh - << std::endl; + if (!closed) { abortAttach = true; break; } @@ -681,7 +974,6 @@ int main(int argc, char* argv[]) { // whose close job is still in flight - that job unmutes // them when it finishes). for (uint32_t y : others) unmute_if_not_closing(y, "openGUI-abort"); - std::cerr << "[dbg] openGUI: attach aborted ch=" << guiCh << std::endl; return; } } @@ -722,8 +1014,6 @@ int main(int argc, char* argv[]) { if (lower_plugin_path(y) == gp) { instruments.setReloading(y, true); samePathSilenced.push_back(y); - std::cerr << "[dbg] openGUI: silenced same-plugin ch=" << y - << " during attach ch=" << guiCh << std::endl; } } } @@ -743,8 +1033,25 @@ int main(int argc, char* argv[]) { // same-path close job's reload (createInstance) -> Nexus // exited silently (observed in probes). std::lock_guard lg(g_loadMutex); - ok = inst->reloadForGUI(); - if (ok) ok = inst->attachView(hwnd); + // CRASH FIX (0xc000041d): attachView -> view->attached() + // and reloadForGUI both enter the plugin DLL — a C++ + // exception (Nexus) must not escape this job (would + // std::terminate the worker). Treat as attach failure; + // the else branch below closes the empty window. + try { + ok = inst->reloadForGUI(); + // IME recursion fix: strip the editor's IMC before + // attachView creates the editor child — a live IMC on + // a JUCE_ window + plugin wndproc (ImmIsUIMessageW -> + // SendMessageW WM_IME_SETCONTEXT same hwnd) = infinite + // recursion (0xC00000FD, observed inside attachView). + if (ok) disable_ime_contexts((HWND)hwnd); + if (ok) ok = inst->attachView(hwnd); + } catch (...) { + std::cerr << "[NativeBridge] GUI attach EXCEPTION ch=" << guiCh + << " — plugin threw (createView/attached)" << std::endl; + ok = false; + } } // Attach done (ok or failed) — close jobs may unmute again. { @@ -759,10 +1066,41 @@ int main(int argc, char* argv[]) { // their unmute and performs it once reload finished. for (uint32_t y : samePathSilenced) unmute_if_not_closing(y, "openGUI"); - if (ok) + if (ok) { + // Editor attached: mark the plugin path editor-open so the + // audio loop stops processing EVERY channel assigned this + // DLL while the editor lives (its window proc runs on this + // worker; process() elsewhere on the same DLL -> Nexus crash). + { + std::lock_guard lk(g_editorMutex); + if (!gp.empty()) { + g_editorOpen[guiCh] = true; + g_editorOpenPath[guiCh] = gp; + ++g_editorPathCount[gp]; + } + } + disable_ime_contexts((HWND)hwnd); +#ifdef _WIN32 + { + // Record the plugin editor child's owner thread (JUCE + // MessageManager thread) so the pump gate can drop + // messages to the instance's windows during teardown. + std::lock_guard lock(g_guiMutex); + auto git = g_guiWindows.find(guiCh); + if (git != g_guiWindows.end()) { + HWND nh = (HWND)git->second; + for (HWND c = FindWindowExA(nh, nullptr, nullptr, nullptr); + c; c = FindWindowExA(nh, c, nullptr, nullptr)) { + DWORD ot = GetWindowThreadProcessId(c, nullptr); + if (ot) { g_ownerTid[guiCh] = ot; break; } + } + } + } +#endif std::cout << "[NativeBridge] GUI attached hwnd=" << hwnd << " plugin=" << arg2 << " ch=" << guiCh << " (channel muted while editor open)" << std::endl; + } else { // Attach failed -> no editor running -> safe to process again. instruments.setReloading(guiCh, false); @@ -782,6 +1120,10 @@ int main(int argc, char* argv[]) { // 2. REAL-TIME AUDIO PROCESSING ENGINE LOOP while (true) { + // CRASH FIX (0xc000041d): last-resort net — any C++ exception that + // escapes the targeted guards (e.g. inside renderAll -> process()) + // must not kill the bridge. Log and continue the loop. + try { #ifdef _WIN32 // Message pump: VST editors (Nexus, JUCE-based...) block inside // view->attached() until the host dispatches messages — openGUI runs @@ -792,7 +1134,14 @@ int main(int argc, char* argv[]) { int pumpedMain = 0; while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) { TranslateMessage(&msg); - DispatchMessageW(&msg); + // CRASH FIX (0xc000041d): same as the worker pump — a C++ exception + // raised inside a plugin window proc (Nexus) must never escape + // DispatchMessageW as an unhandled fatal callback exception. + try { + DispatchMessageW(&msg); + } catch (...) { + std::cerr << "[NativeBridge] main pump EXCEPTION — plugin window proc threw" << std::endl; + } ++pumpedMain; } #endif @@ -814,7 +1163,9 @@ int main(int argc, char* argv[]) { std::string path(c.arg2, plen); InstrumentType t = (InstrumentType)c.arg0; uint32_t ch = c.channel & 0xF; - if (!workers[ch]) workers[ch] = std::make_unique(); + if (!workers[ch]) { + workers[ch] = std::make_unique(); + } workers[ch]->post([&instruments, t, ch, path, sampleRate, block]() { // CRASH FIX: this job enters the plugin DLL (createInstance // in loadPlugin, old-instance terminate on replace) while @@ -835,8 +1186,6 @@ int main(int argc, char* argv[]) { std::vector spOld = same_plugin_channels(ch); for (uint32_t y : spOld) { g_engine->setReloading(y, true); - std::cerr << "[dbg] load: silenced same-plugin ch=" << y - << " during load ch=" << ch << std::endl; } // Channels (other than ch) already assigned the NEW path — // silence BEFORE createInstance enters that DLL. @@ -851,8 +1200,6 @@ int main(int argc, char* argv[]) { if (lower_plugin_path(y) != np) continue; g_engine->setReloading(y, true); spNew.push_back(y); - std::cerr << "[dbg] load: silenced same-plugin (new path) ch=" << y - << " during load ch=" << ch << std::endl; } } } @@ -884,21 +1231,51 @@ int main(int argc, char* argv[]) { // on this worker would race processor->process() on the // audio loop (same plugin instance). instruments.setReloading(ch, true); - if (auto* i = instruments.get(ch)) i->closeGUI(); - // Destroy the old editor's child windows (worker-owned, - // created by attachView here) so they stop pumping; - // the parent window is hidden and kept for reuse. - // See post_close_gui for the same pattern. - while (HWND c = FindWindowExA(hToHide, nullptr, nullptr, nullptr)) - DestroyWindow(c); + // CRASH FIX: the plugin's editor window procs run on + // this worker; if one throws (Nexus) the exception must + // not escape the LOAD job (would std::terminate the + // worker). Detach best-effort and continue. + try { + disable_ime_contexts(hToHide); + if (auto* i = instruments.get(ch)) i->closeGUI(); + // CRASH FIX (0xc000041d): destroy the old + // editor's child windows on the thread that OWNS + // them (JUCE MessageManager worker), never + // cross-thread - DestroyWindow from this LOAD + // worker raced the owner's pump dispatching + // WM_PAINT into the plugin wndproc -> AV. Wait + // for each destroy before assign() below. The + // parent window is hidden and kept for reuse. + // See post_close_gui for the same pattern. + while (HWND c = FindWindowExA(hToHide, nullptr, nullptr, nullptr)) { + destroy_window_on_owner(c); + } + } catch (...) { + std::cerr << "[NativeBridge] LOAD close-editor EXCEPTION ch=" << ch + << " — plugin window proc threw" << std::endl; + } ShowWindow(hToHide, SW_HIDE); + // Editor detached: drop editor-open state before the + // instrument is replaced (assign). + clear_editor_open(ch); } #endif - std::cerr << "[dbg] load thread start ch=" << ch - << " type=" << (int)t << " path=" << path << std::endl; std::lock_guard lg(g_loadMutex); - bool ok = instruments.assign(ch, t, path, sampleRate, block); - std::cerr << "[dbg] assign returned ch=" << ch << " ok=" << (ok ? 1 : 0) << std::endl; + // CRASH FIX (0xc000041d): Nexus throws nlohmann::json:: + // out_of_range inside createInstance (loadPlugin) — the + // C++ exception must not escape the LOAD job (would + // std::terminate the worker). Catch it, treat as failed + // load, keep the old instance, and let the restore below + // unmute the channel. + bool ok = false; + try { + ok = instruments.assign(ch, t, path, sampleRate, block); + } catch (...) { + std::cerr << "[NativeBridge] assign EXCEPTION ch=" << ch + << " type=" << (int)t << " path=" << path + << " — plugin threw (createInstance)" << std::endl; + ok = false; + } if (ok) { std::cout << "[NativeBridge] instrument loaded ch=" << ch << " type=" << (int)t << " " << path << std::endl; @@ -1004,6 +1381,9 @@ int main(int argc, char* argv[]) { std::this_thread::yield(); } } + } catch (...) { + std::cerr << "[NativeBridge] main loop EXCEPTION — plugin threw during render/control" << std::endl; + } } #ifdef _WIN32