fix(bridge): Ozone FX GUI embed crash license check — sleep truoc attach + retry 5 spawn

Root cause (minidump x3, cung offset iZOzone11Core.dll+0x18f6b92): crash
deterministic 1 instruction - memcmp std::string [rdi+0x100] vs "iLok"
trong license check, doc heap ptr garbage (use-after-free/corrupt trong
iZotope Product Engine license path, flaky theo timing).

Fixes:
- RenderFxJob.cpp: fxGuiLoadAttach giu retry 4 attempt moi instance moi
  (SEH-guarded dispose); them Sleep(2s) giua load va attach cho plugin
  iZotope/Ozone - Product Engine service het race, attempt 1 open ngay
  (E2E: 60-70s -> 10-22s, 8/8 run OK).
- plugins.py: _spawn_fx_gui_embed process-level respawn 3->5 lan + delay
  2s giua respawn (service can thoi gian hoi phuc sau crash); di chuyen
  BELOW_NORMAL/CREATE_NO_WINDOW sang _fx_gui_creationflags(); gui stdout
  bridge qua thread de khong block event loop (asyncio.to_thread).
- main_fx.cpp: FxCrashDumpHandler log MOI exception (khong chi lan dau),
  minidump van 1 lan/process.

Verified: E2E engine-from-source 8/8 run Ozone embed OK; app installed
(thu cai moi installer NSIS): OPEN 49.5s, frames 10/10 jpeg, spawn.log
khong con 0x7E/Fatal Error.
This commit is contained in:
2026-09-02 09:40:17 +07:00
parent 5b90bd43b5
commit d068927694
3 changed files with 189 additions and 78 deletions
+96 -40
View File
@@ -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=<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")
+89 -36
View File
@@ -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<Vst3FxState*>(state_);
if (s->component) {
@@ -964,6 +955,14 @@ public:
return state_ ? static_cast<Vst3FxState*>(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<Vst3Fx*>(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);
+4 -2
View File
@@ -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);