diff --git a/app/api/v1/plugins.py b/app/api/v1/plugins.py index 790d4b0..3f98625 100644 --- a/app/api/v1/plugins.py +++ b/app/api/v1/plugins.py @@ -737,50 +737,24 @@ async def open_fx_gui(req: FxGuiRequest, current_user: dict = Depends(get_curren raise HTTPException(status_code=500, detail="Không ghi được job file cho bridge.") cmd = [exe, "--fx-gui" if req.embed else "--open-fx-gui", job_path] try: - # fx-gui spawn o BELOW_NORMAL: process nay load instance VST THU HAI - # (CPU spike vai giay) - neu o NORMAL se preempt realtime audio loop - # (fx_vst_bridge --realtime-fx, thread NORMAL) -> underrun -> am "bop - # nghet" ngay luc mo GUI roi moi hoi phuc. BELOW_NORMAL cho load GUI - # khong canh tranh thread audio. - _gui_creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 - if os.name == "nt": - _gui_creationflags |= getattr(subprocess, "BELOW_NORMAL_PRIORITY_CLASS", 0) if req.embed: - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, text=True, - close_fds=os.name != "nt", - env=dict(os.environ, SF_PARENT_PID=str(os.getpid())), - creationflags=_gui_creationflags) - # Bridge prints SF_FXGUI_PORT= after binding its HTTP server. - embed_url = None - try: - for line in proc.stdout: - line = (line or "").strip() - if line.startswith("SF_FXGUI_PORT="): - port = int(line.split("=", 1)[1]) - embed_url = f"http://127.0.0.1:{port}" - break - except Exception: - pass + # Spawn the fx-gui bridge and WAIT for the editor attach verdict; + # respawn a fresh process on failure (see _spawn_fx_gui_embed — an + # in-process retry cannot recover from a crashed core DLL state). + # Runs in a worker thread so the API event loop is not blocked for + # the (long) load+attach+retry window. + proc, embed_url = await asyncio.to_thread(_spawn_fx_gui_embed, cmd) if not embed_url: - proc.terminate() + _kill_fx_gui_proc(proc) try: - proc.wait(timeout=5) - except Exception: - proc.kill() - raise HTTPException(status_code=500, detail="Bridge không báo port fx-gui (mở GUI thất bại).") - # Keep draining stdout in a daemon thread: the bridge logs to - # stdout/stderr while running; if the pipe fills (64KB) the bridge - # blocks on any later write -> GUI freezes/breaks. - def _drain_fx_gui_stdout(pp): - try: - for ln in pp.stdout: - ln = (ln or "").strip() - if ln: - logger.info("[fx-gui] %s", ln) + os.remove(job_path) except Exception: pass - threading.Thread(target=_drain_fx_gui_stdout, args=(proc,), daemon=True).start() + raise HTTPException( + status_code=500, + detail="Không mở được GUI embed cho plugin — bridge thất bại " + "nhiều lần khi tải/mở editor (chi tiết trong log [fx-gui]).", + ) proc._sf_plugin_path = plugin_path proc._sf_embed_url = embed_url _register_fx_gui_process(proc) @@ -788,7 +762,7 @@ async def open_fx_gui(req: FxGuiRequest, current_user: dict = Depends(get_curren "embed_url": embed_url, "cmd": cmd} proc = subprocess.Popen(cmd, close_fds=os.name != "nt", env=dict(os.environ, SF_PARENT_PID=str(os.getpid())), - creationflags=_gui_creationflags) + creationflags=_fx_gui_creationflags()) proc._sf_plugin_path = plugin_path _register_fx_gui_process(proc) return {"success": True, "started": True, "already_running": False, "cmd": cmd} @@ -833,6 +807,88 @@ def _prune_fx_gui_processes(): global _FX_GUI_PROCESSES _FX_GUI_PROCESSES = [p for p in _FX_GUI_PROCESSES if p is not None and p.poll() is None] +def _fx_gui_creationflags(): + # fx-gui spawn o BELOW_NORMAL: process nay load instance VST THU HAI + # (CPU spike vai giay) - neu o NORMAL se preempt realtime audio loop + # (fx_vst_bridge --realtime-fx, thread NORMAL) -> underrun -> am "bop + # nghet" ngay luc mo GUI roi moi hoi phuc. BELOW_NORMAL cho load GUI + # khong canh tranh thread audio. + flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 + if os.name == "nt": + flags |= getattr(subprocess, "BELOW_NORMAL_PRIORITY_CLASS", 0) + return flags + +def _kill_fx_gui_proc(proc): + """Terminate a bridge process best-effort (already-exited = no-op).""" + if proc is None: + return + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except Exception: + proc.kill() + +def _spawn_fx_gui_embed(cmd): + """Spawn the fx-gui bridge and wait for the editor attach verdict. + + Process-level retry: an Ozone-class plugin can crash its GUI thread + mid-attach; a fresh instance in the SAME process still shares the crashed + core DLL state, so respawn the whole bridge (fresh DLL state per spawn). + Each spawn's in-process retry (4 attempts, bridge side) covers the flaky + first attach. Returns (proc, embed_url) — embed_url is None on total + failure (all spawns failed; proc is the last, already-dead/killed, one). + BLOCKS: call via asyncio.to_thread from the async endpoint. + """ + max_spawns = 5 + respawn_delay = 2.0 # Ozone license check: Product Engine service needs + # time between respawns — back-to-back spawns all fail while it is busy. + proc = None + for spawn_i in range(1, max_spawns + 1): + proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + close_fds=os.name != "nt", + env=dict(os.environ, SF_PARENT_PID=str(os.getpid())), + creationflags=_fx_gui_creationflags(), + ) + holder = {"port": None, "opened": False} + + def _watch(pp, h): + try: + for ln in pp.stdout: + ln = (ln or "").strip() + if not ln: + continue + logger.info("[fx-gui] %s", ln) + if ln.startswith("SF_FXGUI_PORT="): + h["port"] = int(ln.split("=", 1)[1]) + elif "editor open" in ln: + h["opened"] = True + except Exception: + pass + + threading.Thread(target=_watch, args=(proc, holder), daemon=True).start() + # Fast path: port prints as soon as the HTTP server binds. + deadline = _time.time() + 20 + while holder["port"] is None and proc.poll() is None and _time.time() < deadline: + _time.sleep(0.05) + if holder["port"] is None: + logger.warning("[fx-gui] spawn %d/%d: no port, respawn", spawn_i, max_spawns) + _kill_fx_gui_proc(proc) + _time.sleep(respawn_delay) + continue + # Verdict: "editor open" (success) or process exit (attach all-fail). + deadline = _time.time() + 70 + while not holder["opened"] and proc.poll() is None and _time.time() < deadline: + _time.sleep(0.05) + if holder["opened"]: + logger.info("[fx-gui] spawn %d/%d: editor open", spawn_i, max_spawns) + return proc, "http://127.0.0.1:%d" % holder["port"] + logger.warning("[fx-gui] spawn %d/%d: editor failed, respawn", spawn_i, max_spawns) + _kill_fx_gui_proc(proc) + _time.sleep(respawn_delay) + return proc, None + @router.post("/preview") diff --git a/native_bridge/src/RenderFxJob.cpp b/native_bridge/src/RenderFxJob.cpp index cf74bed..e3dc866 100644 --- a/native_bridge/src/RenderFxJob.cpp +++ b/native_bridge/src/RenderFxJob.cpp @@ -455,23 +455,6 @@ static void setPluginSearchPath(const std::string& path) { bool vst3FxLoadInner(Vst3FxState* s, const std::string& path, double sampleRate, int32 maxBlockSize, std::string& err) { using namespace VST3::Hosting; -#ifdef _WIN32 - // Ozone 11 / Ozone Pro (iZotope) tự __fastfail (c0000409, license/anti- - // tamper) khi load — KHÔNG bị SEH chặn → giết cả bridge process (scan + - // render + realtime). Không load: trả lỗi sạch cho chain. WER: Ozone - // Pro.vst3 (v9.11.0.1955) cũng crash c0000409 trên fx_vst_bridge. - // ponytail: muốn chạy Ozone → SandboxVst3Host (mỗi module 1 process). - { - std::string low = path; - for (auto& c : low) c = (char)std::tolower((unsigned char)c); - if (low.find("ozone 11") != std::string::npos || - low.find("ozone pro") != std::string::npos) { - err = "Ozone not loadable (license fastfail) - use another module"; - std::cerr << "[RenderFx] skip (Ozone fastfail): " << path << std::endl; - return false; - } - } -#endif #ifdef _WIN32 setPluginSearchPath(path); #endif @@ -599,8 +582,16 @@ bool vst3FxLoadInner(Vst3FxState* s, const std::string& path, double sampleRate, class Vst3Fx { public: Vst3Fx() = default; - ~Vst3Fx() { -#ifdef HAVE_VST3SDK + ~Vst3Fx() { dispose(); } + // Full teardown of a loaded plugin (component terminate + controller + // terminate + process data). Idempotent. Callers on the retry path wrap + // this in SEH (fxGuiDisposeSafe) — a plugin whose GUI attach crashed may + // be too broken to terminate cleanly; worst case it leaks instead of + // re-crashing the process. + void dispose() { +#ifndef HAVE_VST3SDK + return; +#else if (!state_) return; auto* s = static_cast(state_); if (s->component) { @@ -964,6 +955,14 @@ public: return state_ ? static_cast(state_)->latencySamples : 0; #endif } + // Retry path (fxGuiLoadAttach): take ownership of another instance's + // loaded plugin state. other->state_ nulled so its destructor is a no-op; + // the caller may then delete other safely. Only use on instances that + // NEVER attached successfully (their state is clean or leaked anyway). + void stealState(Vst3Fx* other) { + state_ = other->state_; + other->state_ = nullptr; + } private: void* state_ = nullptr; @@ -1604,6 +1603,32 @@ static bool fxGuiAttachSafe(void* fx, HWND hwnd) { return false; #endif } +// SEH-wrapped plugin load: a load crash (rare) becomes a clean failure instead +// of killing the bridge. locals in load() are leaked on crash — acceptable on +// the retry path (process exits after the session anyway). +static bool fxGuiLoadSafe(Vst3Fx* fx, const std::string& path) { +#ifdef HAVE_VST3SDK + __try { return fx->load(path, 44100.0, 512); } + __except (EXCEPTION_EXECUTE_HANDLER) { return false; } +#else + (void)fx; (void)path; + return false; +#endif +} +// SEH-guarded delete of a failed-attempt instance: dispose() (destructor) runs +// guarded so a plugin too broken to terminate cleanly leaks instead of +// re-crashing the process; the main goal is stopping the failed instance's +// component/worker threads so they cannot crash the process asynchronously +// later (a leaked crashed instance's workers kept killing the GUI session +// seconds after a successful attach). +static void fxGuiDisposeSafe(Vst3Fx* fx) { +#ifdef HAVE_VST3SDK + __try { delete fx; } + __except (EXCEPTION_EXECUTE_HANDLER) {} +#else + (void)fx; +#endif +} static void fxGuiDetachSafe(void* fx) { #ifdef HAVE_VST3SDK __try { static_cast(fx)->closeEditor(); } @@ -1831,32 +1856,60 @@ static int fxGuiCreateWindow(const std::string& name, bool offscreen, // 3. Load the plugin + attach the editor (SEH-guarded — plugin GUI code may // crash). Slow — runs on the pump thread in server mode, after HTTP is up. -// Returns 0 ok, 2 plugin load or editor attach failure. +// Ozone-class plugins crash intermittently INSIDE view->attached() +// (iZOzone11Core.dll, null-vtable read — GUI-worker race), so each attempt +// loads a FRESH instance; failed ones are torn down best-effort (guarded — +// see fxGuiDisposeSafe). Returns 0 ok, 2 plugin load or editor attach +// failure. static int fxGuiLoadAttach(Vst3Fx* fx, const std::string& path, const std::string& name, HWND hwnd, const std::string& presetB64) { - // Fixed 44.1k/512 — GUI only, no audio processed here. - if (!fx->load(path, 44100.0, 512)) { std::cerr << "[FxGui] plugin load failed: " << path << std::endl; return 2; } - // Khôi phục preset GUI capture cuối (hide/show mất settings bug): áp - // preset NGAY SAU load — trước attach editor để GUI hiện đúng cài đặt. - if (!presetB64.empty() && !fx->applyPreset(presetB64)) - std::cerr << "[FxGui] preset apply FAILED (default kept): " << path << std::endl; - else if (!presetB64.empty()) - std::cerr << "[FxGui] preset applied: " << path << std::endl; - if (!fxGuiAttachSafe(fx, hwnd)) { - std::cerr << "[FxGui] editor attach failed for " << path << std::endl; - DestroyWindow(hwnd); - return 2; + const int kMaxAttempts = 4; + for (int attempt = 1; attempt <= kMaxAttempts; ++attempt) { + Vst3Fx* fresh = new Vst3Fx(); + std::cerr << "[FxGui] attempt " << attempt << "/" << kMaxAttempts + << ": load " << path << std::endl; + // Fixed 44.1k/512 — GUI only, no audio processed here. + if (!fxGuiLoadSafe(fresh, path)) { + std::cerr << "[FxGui] plugin load failed: " << path << std::endl; + delete fresh; // clean failure — state_ never set, destructor no-op + DestroyWindow(hwnd); + return 2; + } + // Khôi phục preset GUI capture cuối (hide/show mất settings bug): áp + // preset NGAY SAU load — trước attach editor để GUI hiện đúng cài đặt. + if (!presetB64.empty() && !fresh->applyPreset(presetB64)) + std::cerr << "[FxGui] preset apply FAILED (default kept): " << path << std::endl; + else if (!presetB64.empty()) + std::cerr << "[FxGui] preset applied: " << path << std::endl; + // iZotope (Ozone-class): license check races the editor attach and + // intermittently crashes inside iZOzone11Core.dll — give the Product + // Engine license service a beat after load before attaching. + if (path.find("Ozone") != std::string::npos || + path.find("iZotope") != std::string::npos) + Sleep(2000); + if (fxGuiAttachSafe(fresh, hwnd)) { + fx->stealState(fresh); + delete fresh; + std::cout << "[FxGui] editor open: " << (name.empty() ? path : name) << std::endl; + return 0; + } + std::cerr << "[FxGui] editor attach FAILED (attempt " << attempt << "/" + << kMaxAttempts << ") — retrying fresh instance" << std::endl; + // Best-effort teardown of the failed instance (guarded): stop its + // component/worker threads so they cannot crash the process later. + // Worst case the instance leaks (unchanged from before). + fxGuiDisposeSafe(fresh); } - std::cout << "[FxGui] editor open: " << (name.empty() ? path : name) << std::endl; - return 0; + DestroyWindow(hwnd); + return 2; } -// Combined setup (legacy open mode): parse + load + window + attach, in order. +// Combined setup (legacy open mode): parse + window + attach (load happens +// inside fxGuiLoadAttach's retry loop), in order. static int fxGuiSetup(const std::string& jobPath, Vst3Fx* fx, HWND* hwndOut, bool offscreen, LONG_PTR userData, std::string& shmOut) { std::string path, name, presetB64; int rc = fxGuiParseJob(jobPath, path, name, shmOut, presetB64); if (rc) return rc; - if (!fx->load(path, 44100.0, 512)) { std::cerr << "[FxGui] plugin load failed: " << path << std::endl; return 2; } rc = fxGuiCreateWindow(name, offscreen, userData, hwndOut); if (rc) return rc; return fxGuiLoadAttach(fx, path, name, *hwndOut, presetB64); diff --git a/native_bridge/src/main_fx.cpp b/native_bridge/src/main_fx.cpp index 105a9ff..18f8bf1 100644 --- a/native_bridge/src/main_fx.cpp +++ b/native_bridge/src/main_fx.cpp @@ -28,13 +28,15 @@ #ifdef _WIN32 // Minidump on crash -> %TEMP%\sf_dumps (same convention as daw_vst_bridge). +// Every qualifying exception is LOGGED (diagnosing the retry path needs to see +// crashes after the first); only the minidump is written once per process. static LONG WINAPI FxCrashDumpHandler(EXCEPTION_POINTERS* ep) { - static LONG once = 0; - if (InterlockedCompareExchange(&once, 1, 0)) return EXCEPTION_CONTINUE_SEARCH; PEXCEPTION_RECORD er = ep ? ep->ExceptionRecord : nullptr; std::cerr << "[CRASH] fx_vst_bridge exception code=0x" << std::hex << (er ? er->ExceptionCode : 0) << " addr=0x" << (er ? (void*)er->ExceptionAddress : nullptr) << std::dec << std::endl; + static LONG once = 0; + if (InterlockedCompareExchange(&once, 1, 0)) return EXCEPTION_CONTINUE_SEARCH; char dumpDir[MAX_PATH]; if (GetTempPathA(MAX_PATH, dumpDir)) { std::strncat(dumpDir, "sf_dumps", MAX_PATH - std::strlen(dumpDir) - 1);