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")