obs-browser: fix panel use-after-destruction due to data race - #536
Open
zavitax wants to merge 1 commit into
Open
obs-browser: fix panel use-after-destruction due to data race#536zavitax wants to merge 1 commit into
zavitax wants to merge 1 commit into
Conversation
zavitax
force-pushed
the
fix/panel-widget-lifetime
branch
from
September 5, 2026 20:31
c1ea3d0 to
7443ea1
Compare
…reation QCefWidgetInternal::Init() posts a CEF task that captures `this` raw. The task assigns cefBrowser and constructs a QCefBrowserClient holding a back-pointer to the widget. Nothing cancels or guards it, so a widget destroyed before that task runs is read and written after it is freed. closeBrowser() is the only place that clears the client's back-pointer, and it reaches the client through host->GetClient() -- which needs a live browser. So it sits after `if (!cefBrowser) return;`, and the one case that needs clearing, "the browser does not exist yet", is exactly the case that returns early. Closing OBS a few seconds after launch reproduces it: CreateBrowserSync faults on the freed widget. Symbolized from a live capture: BrowserManagerThread -> task_execute -> QCefWidgetInternal::Init'::<lambda_1>::_Do_call -> CefBrowserHost::CreateBrowserSync -> cef_browser_host_create_browser_sync -> KiUserExceptionDispatch The visible symptom is not always a crash. When the host's unhandled-exception filter puts a message box up it does so on this thread, nothing dismisses it, and obs_module_unload's Thrd_join then never returns -- the process hangs at shutdown with no window the user can find. The widget now holds a reference to the client from the moment it REQUESTS a browser rather than from the moment one exists, which is what makes the back-pointer clearable in that window: * browserClient is created on the Qt thread in Init() and stored on the widget. * closeBrowser() detaches through a qScopeGuard, so it happens on every path out including both early returns. * The queued task captures the refcounted client plus copies of url, rqc and size instead of `this`, and publishes its result with attachBrowser(), which closes the browser rather than leaking it if the widget went away meanwhile. * The double-init guard moves to the Qt thread. Guarding on the queue side also drops the assumption that two queued tasks run in an order that lets the second see the first one's result. `widget` becomes private behind a recursive_mutex so the compiler enforces that the eight callbacks using it go through the lock. Five of those call sites dereferenced it with no null check at all -- OnBeforePopup (twice), OnContextMenuCommand, OnJSDialog and OnPreKeyEvent -- which is already reachable today, since closeBrowser() nulls the pointer while the client stays alive. QSize is now read on the Qt thread on macOS too. It was calling QWidget::size() from a CEF thread. The nested event loop in closeBrowser() is deliberately left alone. Removing it was tried and it turned an intermittent shutdown hang into one that reproduced in four runs out of four; external_message_pump means CEF only advances while Qt keeps pumping, so that wait is load-bearing. Measured with a harness that launches OBS and posts WM_CLOSE as soon as a main window exists, 16 runs per configuration, every hang classified from its stack: this crash accounted for 3 hangs before the patch and 0 after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
zavitax
force-pushed
the
fix/panel-widget-lifetime
branch
from
September 5, 2026 20:36
7443ea1 to
2829061
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
The widget now holds a reference to the client from the moment it REQUESTS a browser rather than from the moment one exists, which is what makes the back-pointer clearable in that window:
this, and publishes its result with attachBrowser(), which closes the browser rather than leaking it if the widget went away meanwhile.widgetbecomes private behind a recursive_mutex so the compiler enforces that the eight callbacks using it go through the lock. Five of those call sites dereferenced it with no null check at all --OnBeforePopup(twice),OnContextMenuCommand,OnJSDialogandOnPreKeyEvent-- which is already reachable today, sincecloseBrowser()nulls the pointer while the client stays alive.QSizeis now read on the Qt thread on macOS too. It was callingQWidget::size()from a CEF thread.The nested event loop in
closeBrowser()is deliberately left alone. Removing it was tried and it turned an intermittent shutdown hang into one that reproduced in four runs out of four;external_message_pumpmeans CEF only advances while Qt keeps pumping, so that wait is load-bearing.Motivation and Context
QCefWidgetInternal::Init()posts a CEF task that capturesthisraw. The task assignscefBrowserand constructs aQCefBrowserClientholding a back-pointer to the widget. Nothing cancels or guards it, so a widget destroyed before that task runs is read and written after it is freed.closeBrowser()is the only place that clears the client's back-pointer, and it reaches the client throughhost->GetClient()-- which needs a live browser. So it sits afterif (!cefBrowser) return;, and the one case that needs clearing, "the browser does not exist yet", is exactly the case that returns early.Closing OBS a few seconds after launch reproduces it:
CreateBrowserSyncfaults on the freed widget. Symbolized from a live capture:The visible symptom is not always a crash. When the host's unhandled-exception filter puts a message box up it does so on this thread, nothing dismisses it, and
obs_module_unload's Thrdjointhen never returns -- the process hangs at shutdown with no window the user can find.How Has This Been Tested?
Measured with a harness that launches OBS and posts
WM_CLOSEas soon as a main window exists, 16 runs per configuration, every hang classified from its stack: this crash accounted for 3 hangs before the patch and 0 after.Types of changes
Checklist: