Skip to content

Initialize CEF on the Qt main thread on macOS - #10

Merged
zackees merged 2 commits into
obs-rust:masterfrom
vpacher:fix/2387-macos-init-main-thread
Oct 25, 2026
Merged

zackees merged 2 commits into
obs-rust:masterfrom
vpacher:fix/2387-macos-init-main-thread

Conversation

@vpacher

@vpacher vpacher commented Oct 25, 2026

Copy link
Copy Markdown
Member

Refs obs-rust/obs-studio#2387

What and why

On macOS obs-browser runs CEF on Qt's main loop (ENABLE_BROWSER_QT_LOOP), and obs_browser_initialize() ran BrowserInit() on the calling thread: CefInitialize() makes that thread CEF's UI thread. The first browser source initializes CEF from its create callback, so when obs-websocket created it (CreateInput, on its IO thread), CEF's UI thread was one that never pumps. Only the GPU process started, and no browser source rendered for the rest of the session, also ones recreated in the UI later.

obs_browser_initialize() now runs BrowserInit() on the Qt main thread and waits for it (QMetaObject::invokeMethod(..., Qt::BlockingQueuedConnection)) when it is called from another thread. On the main thread, and without a Qt application, it calls it directly as before. Linux and Windows (their own CEF thread) are unchanged.

RED → GREEN

tests/first_source_off_main_thread.py (new, macOS, local like the other real-CEF tests) starts the built OBS-Studio-Rust.app with a fresh, seeded configuration and obs-websocket on a test port, and requires a browser source with first-source.html (a page that turns green) to render. Scenarios: websocket-first (obs-websocket creates the first source) and collection (the scene collection has it, so OBS creates it on the main thread; the control).

AI note: Claude Code (Claude Opus 5.5) wrote the change, the test and this text under the submitter's direction, and ran the tests above on the submitter's machine.

vpacher and others added 2 commits October 25, 2026 13:01
On macOS obs-browser runs CEF on Qt's main loop, and the first browser
source initializes CEF from its create callback, on the calling thread.
When obs-websocket creates that source, the call comes from its IO
thread, CEF's UI thread is one that never pumps, and no browser source
renders for the rest of the session (obs-rust/obs-studio#2387).

first_source_off_main_thread.py starts the built OBS-Studio-Rust.app with
a fresh, seeded configuration and obs-websocket on a test port, and
requires a browser source with first-source.html to render:
websocket-first (obs-websocket creates it) fails, collection (OBS creates
it on the main thread while the scene collection loads; the control)
passes. macOS only, local like the other real-CEF tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the Qt loop (macOS), obs_browser_initialize() ran BrowserInit() on
the calling thread, and CefInitialize() makes that thread CEF's UI
thread. The first browser source initializes CEF from its create
callback, so when obs-websocket created it (CreateInput, on its IO
thread), CEF's UI thread was one that never pumps: no browser rendered
for the rest of the session (obs-rust/obs-studio#2387).

obs_browser_initialize() now runs BrowserInit() on the Qt main thread
and waits for it (a blocking queued call) when it is called from another
thread. On the main thread, and without a Qt application, it calls it
directly as before. tests/first_source_off_main_thread.py: websocket-first
renders now, and the collection control still does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@zackees zackees left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coordinator review: approved.

  • The cause and the fix match: with ENABLE_BROWSER_QT_LOOP, CefInitialize() makes the calling thread CEF's UI thread, so the first source created from obs-websocket's IO thread left CEF on a thread that never pumps. Handing BrowserInit() to the Qt main thread when called from elsewhere, and calling it directly on the main thread or without a QCoreApplication, keeps every existing path the same. Linux and Windows are untouched.
  • RED → GREEN in the real app (macOS 26.7.1, CEF 7871): websocket-first was black and now renders 3 of 3 times, and the collection control passes in both. The test refuses to touch an existing config folder, which is the right call for a test that seeds ~/Library/Application Support.

Note (non-blocking): BlockingQueuedConnection deadlocks if the main thread is waiting on the calling thread at that moment, or isn't running its event loop. Today obs_source_create calls create without an OBS-global lock, and obs-websocket's request thread isn't something the UI thread waits on, so I don't see a path. A first browser source created off the main thread during shutdown (after the Qt loop has stopped) would hang, though. A guard like if (QCoreApplication::closingDown()) return false; before the blocking call would cover that.

@zackees
zackees merged commit 9aa4a60 into obs-rust:master Oct 25, 2026
1 check passed
vpacher added a commit to vpacher/obs-studio that referenced this pull request Oct 25, 2026
…hread)

Moves plugins/obs-browser from the head of obs-rust/obs-browser#9
(e513629, the same tree as its merge 1412f26) to master 9aa4a60, which
adds obs-rust/obs-browser#10: with the Qt loop (macOS),
obs_browser_initialize() runs BrowserInit() on the Qt main thread when it
is called from another thread, so a first browser source created by
obs-websocket renders (obs-rust#2387).

9aa4a60 has the tree of the branch tested on macOS 26.7.1 (first source
through obs-websocket renders 3 of 3, the scene-collection control too).
docs/features.md gets the obs-rust#2387 line (fixed upstream bugs, sources) and
the PR links for both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zackees pushed a commit that referenced this pull request Oct 25, 2026
* test: RED hand-over to the Qt main thread while OBS quits

obs_browser_initialize() hands BrowserInit() to the Qt main thread with a
blocking queued call when it is called from another thread (#10). Once
Qt's main loop has finished, nothing runs that call, so a first browser
source created off the main thread while OBS quits would hang it
(obs-rust/obs-studio#2391, from the review of #10).

The hand-over moves unchanged into RunOnQtMainThread()
(browser-qt-main-thread.hpp), with WatchQtMainLoop() called from
obs_module_load() (a no-op for now). qt-main-thread.cpp, run by
qt_main_thread.py against Qt Core only: while the loop runs, a worker
thread's call runs on the main thread (passes); after the loop has
finished, the call must return within 2 s without running (fails: it
waits forever).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Do not hand over to the Qt main thread once its loop has stopped

RunOnQtMainThread() waited on a blocking queued call; once Qt's main
loop has finished (OBS quits), nothing runs it, so a first browser
source created off the main thread then hung OBS on quit
(obs-rust/obs-studio#2391, from the review of #10).

WatchQtMainLoop(), called from obs_module_load() on the main thread, sets
a flag on QCoreApplication::aboutToQuit. RunOnQtMainThread() no longer
hands over when that flag or QCoreApplication::closingDown() is set: it
queues the call without blocking and waits for it in 50 ms steps, and if
the loop stops before the call has started, it cancels the call (a
queued call that runs later does nothing) and returns false. The caller
then leaves CEF uninitialized.

tests/qt_main_thread.py: both cases pass, 20 of 20 runs.
tests/first_source_off_main_thread.py on macOS: both scenarios render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@vpacher
vpacher deleted the fix/2387-macos-init-main-thread branch October 25, 2026 12:50
MCPVOT pushed a commit to MCPVOT/obs-studio that referenced this pull request Oct 25, 2026
…thread (obs-rust#2386, obs-rust#2387) (obs-rust#2390)

* ci: RED the macOS browser helpers follow the app identity (obs-rust#2386)

Chromium finds its helper apps by the main executable's name ("<app>
Helper", "(GPU)", "(Plugin)", "(Renderer)"). obs-browser names them "OBS
Helper" while the Rust brand's app is "OBS-Studio-Rust", so the GPU
process fails to launch (error_code=1003) and adding a browser source
crashes OBS.

test_macos_browser_helpers.py checks that obs-browser's macOS CMake
resolves the helpers to "<OBS_MACOS_BUNDLE_NAME> Helper" and
"<OBS_MACOS_BUNDLE_ID>.helper<suffix>" for both brands (upstream keeps
"OBS Helper" and com.obsproject.obs-studio.helper), and that
app_identity_check.py's bundle check, which the release workflow runs on
every macOS app, rejects helpers named otherwise.

Two of four fail: the pinned obs-browser hard-codes the upstream names,
and the bundle check does not look at the helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* macos: Name the browser helpers after the app (obs-rust#2386)

app_identity_check.py's bundle check, which the release workflow runs on
every macOS app, now checks the browser source's helper apps: when the
app has any, they must be "<CFBundleExecutable> Helper", "(GPU)",
"(Plugin)" and "(Renderer)", with the bundle IDs "<app id>.helper",
".gpu", ".plugin" and ".renderer". The 2026-10-23 release fails it with
its "OBS Helper" apps.

plugins/obs-browser moves to obs-rust/obs-browser e513629, which names
the helpers and their bundle IDs after obs-studio's app identity
(OBS_MACOS_BUNDLE_NAME, OBS_MACOS_BUNDLE_ID). With it, Chromium finds the
GPU helper and adding a browser source no longer crashes OBS on macOS.
The source still stays black until obs-rust#2387 is fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: Skip the obs-browser source checks without the submodule (obs-rust#2386)

The Format job checks out no submodules, so test_macos_browser_helpers.py
failed there with FileNotFoundError on plugins/obs-browser/cmake/
os-macos.cmake. The two checks that read that file now skip with the
reason when the submodule is not checked out. The two bundle checks
still run there, and the release workflow runs app_identity_check.py on
every built macOS app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* obs-browser: Update to 9aa4a60 (macOS helper names, CEF on the main thread)

Moves plugins/obs-browser from the head of obs-rust/obs-browser#9
(e513629, the same tree as its merge 1412f26) to master 9aa4a60, which
adds obs-rust/obs-browser#10: with the Qt loop (macOS),
obs_browser_initialize() runs BrowserInit() on the Qt main thread when it
is called from another thread, so a first browser source created by
obs-websocket renders (obs-rust#2387).

9aa4a60 has the tree of the branch tested on macOS 26.7.1 (first source
through obs-websocket renders 3 of 3, the scene-collection control too).
docs/features.md gets the obs-rust#2387 line (fixed upstream bugs, sources) and
the PR links for both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants