From 37bc20b2be8c260700e4e51bb628dd001daf88c7 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 17 Oct 2026 18:14:25 -0700 Subject: [PATCH 1/5] test: reproduce CEF task submission before initialization --- tests/README.md | 15 ++++++++++++++ tests/queue-before-init.cpp | 14 +++++++++++++ tests/queue_before_init.py | 40 +++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 tests/README.md create mode 100644 tests/queue-before-init.cpp create mode 100644 tests/queue_before_init.py diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..213cda2 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,15 @@ +# CEF startup regression + +On Linux, build `obs-browser` with CMake and Ninja, then run: + +```sh +uv run --no-project python tests/queue_before_init.py --build-dir /path/to/build --output-dir /path/to/output +``` + +Provide the same CEF/OBS runtime library paths used by that build. The test +relinks its actual browser objects into a small executable; it does not stub +CEF or rewrite the plugin implementation. It must reject a queued task before +CEF initialization without invoking CEF. With the CEF 7871 SDK, the original +implementation aborts with `CefTask_0_CToCpp called with invalid version -1`. +This focused check does not establish browser rendering or frontend parity. +Artifacts are retained in the requested output directory. diff --git a/tests/queue-before-init.cpp b/tests/queue-before-init.cpp new file mode 100644 index 0000000..0953dfe --- /dev/null +++ b/tests/queue-before-init.cpp @@ -0,0 +1,14 @@ +#include +#include + +bool QueueCEFTask(std::function task); + +int main() +{ + if (QueueCEFTask([] {})) { + std::fputs("task accepted before CEF initialization\n", stderr); + return 1; + } + std::puts("task rejected safely before CEF initialization"); + return 0; +} diff --git a/tests/queue_before_init.py b/tests/queue_before_init.py new file mode 100644 index 0000000..4e08696 --- /dev/null +++ b/tests/queue_before_init.py @@ -0,0 +1,40 @@ +#!/usr/bin/env -S uv run --script +"""Link the real browser objects and reject a task before CEF initialization.""" +import argparse +from pathlib import Path +import shlex +import subprocess + +parser = argparse.ArgumentParser() +parser.add_argument("--build-dir", type=Path, required=True) +parser.add_argument("--output-dir", type=Path, required=True) +args = parser.parse_args() +build = args.build_dir.resolve() +output = args.output_dir.resolve() +output.mkdir(parents=True, exist_ok=True) +commands = subprocess.check_output(["ninja", "-C", str(build), "-t", "commands", "obs-browser"], text=True) +link = None +for line in commands.splitlines(): + parts = shlex.split(line) + if "-shared" not in parts or "-o" not in parts: + continue + position = parts.index("-o") + if Path(parts[position + 1]).name != "obs-browser.so": + continue + start = max((i + 1 for i, part in enumerate(parts[:position]) if part == "&&"), default=0) + parts = parts[start:] + if "&&" in parts: + parts = parts[:parts.index("&&")] + link = [part.replace("\\$", "$") for part in parts] +if link is None: + raise SystemExit("no Linux browser module link command found; build obs-browser with CMake/Ninja first") +link.remove("-shared") +executable = output / "queue-before-init" +link[link.index("-o") + 1] = str(executable) +link.insert(1, str(Path(__file__).with_name("queue-before-init.cpp").resolve())) +subprocess.run(link, cwd=build, check=True) +result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=10) +print(result.stdout, end="") +print(result.stderr, end="") +if result.returncode: + raise SystemExit(f"pre-initialization task check failed: exit {result.returncode}") From 722370d92941856a2c0acdabb630c2778b8c6245 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 17 Oct 2026 18:21:51 -0700 Subject: [PATCH 2/5] test: exercise browser unload during CEF startup --- tests/README.md | 22 ++++++++++++++++++++++ tests/startup-shutdown.c | 34 ++++++++++++++++++++++++++++++++++ tests/startup_shutdown.py | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 tests/startup-shutdown.c create mode 100644 tests/startup_shutdown.py diff --git a/tests/README.md b/tests/README.md index 213cda2..f038ab2 100644 --- a/tests/README.md +++ b/tests/README.md @@ -13,3 +13,25 @@ CEF initialization without invoking CEF. With the CEF 7871 SDK, the original implementation aborts with `CefTask_0_CToCpp called with invalid version -1`. This focused check does not establish browser rendering or frontend parity. Artifacts are retained in the requested output directory. + +## Immediate unload + +`startup_shutdown.py` compiles a C harness against the actual libobs and loads +the requested browser module. One process initializes CEF then immediately +shuts down OBS; another creates and releases a browser source before shutdown. +The original CEF 7871 module aborts in both modes with API version `-1`. +Provide an X display (for example an owned Xvfb), the module's staged CEF helper +and resources, and its runtime library search paths. Example: + +```sh +uv run --no-project python tests/startup_shutdown.py \ + --include-dir /path/to/obs/libobs --include-dir /path/to/generated/config \ + --include-dir /path/to/simde/include --library-dir /path/to/libobs \ + --module /path/to/staged/obs-browser.so --data /path/to/obs-browser/data \ + --output-dir /path/to/new-output +``` + +Each output directory must be fresh; logs and profiles are retained. This +covers real successful initialization concurrent with unload. It does not +inject a CEF initialization failure or prove absence of leaks, and it does not +replace full frontend/rendering tests. diff --git a/tests/startup-shutdown.c b/tests/startup-shutdown.c new file mode 100644 index 0000000..20c2f9f --- /dev/null +++ b/tests/startup-shutdown.c @@ -0,0 +1,34 @@ +/* Exercise the real module's startup/unload boundary, including a source + * released before CEF is ready. Run each mode in a fresh process/profile. */ +#include +#include +#include +#include + +int main(int argc, char **argv) +{ + if (argc != 5) { + fprintf(stderr, "usage: %s MODULE DATA CONFIG initialize|source\n", argv[0]); + return 2; + } + obs_set_cmdline_args(argc, (const char *const *)argv); + if (!obs_startup("en-US", argv[3], NULL)) + return 3; + obs_module_t *module = NULL; + if (obs_open_module(&module, argv[1], argv[2]) != MODULE_SUCCESS || !obs_init_module(module)) + return 4; + if (strcmp(argv[4], "source") == 0) { + obs_source_t *source = obs_source_create("browser_source", "Early browser", NULL, NULL); + if (!source) + return 5; + obs_source_release(source); + } else { + void (*initialize)(void) = dlsym(obs_get_module_lib(module), "obs_browser_initialize"); + if (!initialize) + return 6; + initialize(); + } + obs_shutdown(); + puts("browser startup and immediate shutdown completed"); + return 0; +} diff --git a/tests/startup_shutdown.py b/tests/startup_shutdown.py new file mode 100644 index 0000000..d2814c5 --- /dev/null +++ b/tests/startup_shutdown.py @@ -0,0 +1,39 @@ +#!/usr/bin/env -S uv run --script +"""Run immediate shutdown against a real Linux OBS/CEF module, without stubs.""" +import argparse +from pathlib import Path +import subprocess + +parser = argparse.ArgumentParser() +parser.add_argument("--include-dir", type=Path, action="append", required=True) +parser.add_argument("--library-dir", type=Path, action="append", required=True) +parser.add_argument("--module", type=Path, required=True) +parser.add_argument("--data", type=Path, required=True) +parser.add_argument("--output-dir", type=Path, required=True) +parser.add_argument("--compiler", default="cc") +args = parser.parse_args() +output = args.output_dir.resolve() +output.mkdir(parents=True, exist_ok=True) +executable = output / "startup-shutdown" +command = [args.compiler, "-DHAVE_OBSCONFIG_H"] +for directory in args.include_dir: + command.append("-I" + str(directory.resolve())) +command.append(str(Path(__file__).with_name("startup-shutdown.c").resolve())) +for directory in args.library_dir: + command.extend(["-L" + str(directory.resolve()), "-Wl,-rpath," + str(directory.resolve())]) +command.extend(["-lobs", "-ldl", "-o", str(executable)]) +subprocess.run(command, check=True) +for mode in ["initialize", "source"]: + config = output / (mode + "-config") + config.mkdir() # Each process needs a fresh CEF profile. + command = [str(executable), str(args.module.resolve()), str(args.data.resolve()), str(config), mode] + try: + result = subprocess.run(command, capture_output=True, text=True, timeout=30) + except subprocess.TimeoutExpired as error: + log = (error.stdout or b"") + (error.stderr or b"") + (output / (mode + ".log")).write_bytes(log) + raise SystemExit(f"{mode}: startup/shutdown timed out after 30 seconds") from error + (output / (mode + ".log")).write_text(result.stdout + result.stderr) + if result.returncode: + raise SystemExit(f"{mode}: startup/shutdown failed: exit {result.returncode}") + print(mode + ": real browser startup and immediate shutdown completed") From a9a56fd37dbf21b699097e2a78d38ab2f4f915ee Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 17 Oct 2026 18:23:11 -0700 Subject: [PATCH 3/5] test: release private browser source without canvas ownership --- tests/startup-shutdown.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/startup-shutdown.c b/tests/startup-shutdown.c index 20c2f9f..00b2013 100644 --- a/tests/startup-shutdown.c +++ b/tests/startup-shutdown.c @@ -18,7 +18,7 @@ int main(int argc, char **argv) if (obs_open_module(&module, argv[1], argv[2]) != MODULE_SUCCESS || !obs_init_module(module)) return 4; if (strcmp(argv[4], "source") == 0) { - obs_source_t *source = obs_source_create("browser_source", "Early browser", NULL, NULL); + obs_source_t *source = obs_source_create_private("browser_source", "Early browser", NULL); if (!source) return 5; obs_source_release(source); From 0c3b7000d40e89c19734f9f7f3288015f6134082 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 17 Oct 2026 18:24:30 -0700 Subject: [PATCH 4/5] fix: coordinate CEF readiness with browser tasks and shutdown --- browser-app.cpp | 4 ++++ browser-app.hpp | 3 +++ obs-browser-plugin.cpp | 50 +++++++++++++++++++++++++++++++----------- obs-browser-source.cpp | 9 +++++++- 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/browser-app.cpp b/browser-app.cpp index 3d4f257..9aa2e90 100644 --- a/browser-app.cpp +++ b/browser-app.cpp @@ -108,6 +108,10 @@ void BrowserApp::OnContextInitialized() for (std::string_view feature : kBrowserFeaturesToEnable) { requestContext->SetPreference(feature.data(), optionValue.get(), errorMessage); } + + if (on_context_initialized) { + on_context_initialized(); + } } void BrowserApp::OnBeforeChildProcessLaunch(CefRefPtr command_line) diff --git a/browser-app.hpp b/browser-app.hpp index 0694d0c..793b0f3 100644 --- a/browser-app.hpp +++ b/browser-app.hpp @@ -69,6 +69,7 @@ class BrowserApp : public CefApp, public CefRenderProcessHandler, public CefBrow bool shared_texture_available; CallbackMap callbackMap; int callbackId; + std::function on_context_initialized; #if !defined(__APPLE__) && !defined(_WIN32) bool wayland; #endif @@ -84,6 +85,8 @@ class BrowserApp : public CefApp, public CefRenderProcessHandler, public CefBrow { } + void SetContextInitializedCallback(std::function callback) { on_context_initialized.swap(callback); } + virtual CefRefPtr GetRenderProcessHandler() override; virtual CefRefPtr GetBrowserProcessHandler() override; virtual CefRefPtr GetDefaultClient() override; diff --git a/obs-browser-plugin.cpp b/obs-browser-plugin.cpp index 0496eb8..a26112f 100644 --- a/obs-browser-plugin.cpp +++ b/obs-browser-plugin.cpp @@ -71,6 +71,8 @@ using namespace std; static thread manager_thread; static bool manager_initialized = false; os_event_t *cef_started_event = nullptr; +static os_event_t *cef_initialized_event = nullptr; +static bool browser_initialized = false; #if defined(_WIN32) static int adapterCount = 0; @@ -108,6 +110,10 @@ class BrowserTask : public CefTask { bool QueueCEFTask(std::function task) { + if (!cef_started_event || os_event_try(cef_started_event) != 0) { + return false; + } + return CefPostTask(TID_UI, CefRefPtr(new BrowserTask(task))); } @@ -269,7 +275,7 @@ static obs_missing_files_t *browser_source_missingfiles(void *data) static CefRefPtr app; -static void BrowserInit(void) +static bool BrowserInit(void) { string path = obs_get_module_binary_path(obs_current_module()); path = path.substr(0, path.find_last_of('/') + 1); @@ -374,6 +380,12 @@ static void BrowserInit(void) app = new BrowserApp(tex_sharing_avail, obs_get_nix_platform() == OBS_NIX_PLATFORM_WAYLAND); #endif + app->SetContextInitializedCallback([] { + // The global request context is ready when this callback runs. + CefRegisterSchemeHandlerFactory("http", "absolute", new BrowserSchemeHandlerFactory()); + os_event_signal(cef_started_event); + }); + #ifdef _WIN32 CefExecuteProcess(args, app, nullptr); #endif @@ -392,13 +404,10 @@ static void BrowserInit(void) #else blog(LOG_ERROR, "[obs-browser]: CEF failed to initialize."); #endif - return; + app = nullptr; + return false; } - - // Register custom scheme handler for local browser sources - CefRegisterSchemeHandlerFactory("http", "absolute", new BrowserSchemeHandlerFactory()); - - os_event_signal(cef_started_event); + return true; } static void BrowserShutdown(void) @@ -417,9 +426,12 @@ static void BrowserShutdown(void) #ifndef ENABLE_BROWSER_QT_LOOP static void BrowserManagerThread(void) { - BrowserInit(); - CefRunMessageLoop(); - BrowserShutdown(); + browser_initialized = BrowserInit(); + os_event_signal(cef_initialized_event); + if (browser_initialized) { + CefRunMessageLoop(); + BrowserShutdown(); + } } #endif @@ -427,7 +439,7 @@ extern "C" EXPORT void obs_browser_initialize(void) { if (!os_atomic_set_bool(&manager_initialized, true)) { #ifdef ENABLE_BROWSER_QT_LOOP - BrowserInit(); + browser_initialized = BrowserInit(); #else manager_thread = thread(BrowserManagerThread); #endif @@ -852,6 +864,7 @@ bool obs_module_load(void) #endif os_event_init(&cef_started_event, OS_EVENT_TYPE_MANUAL); + os_event_init(&cef_initialized_event, OS_EVENT_TYPE_MANUAL); #if defined(_WIN32) && CHROME_VERSION_BUILD < 5615 /* CefEnableHighDPISupport doesn't do anything on OS other than Windows. Would also crash macOS at this point as CEF is not directly linked */ @@ -915,10 +928,16 @@ void obs_module_post_load(void) void obs_module_unload(void) { #ifdef ENABLE_BROWSER_QT_LOOP - BrowserShutdown(); + if (browser_initialized) { + BrowserShutdown(); + } #else if (manager_thread.joinable()) { - if (!QueueCEFTask([]() { CefQuitMessageLoop(); })) { + // Initialization can finish before the context-ready callback runs. + // Wait for success or failure before using the CEF API; the quit task + // must also be accepted while the context is still starting. + os_event_wait(cef_initialized_event); + if (browser_initialized && !CefPostTask(TID_UI, new BrowserTask([]() { CefQuitMessageLoop(); }))) { blog(LOG_DEBUG, "[obs-browser]: Failed to post CefQuit task to loop"); } @@ -927,4 +946,9 @@ void obs_module_unload(void) #endif os_event_destroy(cef_started_event); + os_event_destroy(cef_initialized_event); + cef_started_event = nullptr; + cef_initialized_event = nullptr; + browser_initialized = false; + manager_initialized = false; } diff --git a/obs-browser-source.cpp b/obs-browser-source.cpp index bb43839..efed81a 100644 --- a/obs-browser-source.cpp +++ b/obs-browser-source.cpp @@ -148,6 +148,12 @@ void BrowserSource::Destroy() } *p_prev_next = next; + // No creation task can have been accepted before context readiness. + // There is no CEF browser to close on its UI thread in this case. + if (!cef_started_event || os_event_try(cef_started_event) != 0) { + delete this; + return; + } QueueCEFTask([this]() { delete this; }); } @@ -239,7 +245,8 @@ bool BrowserSource::CreateBrowser() cefBrowserSettings.default_fixed_font_size = 16; auto browser = CefBrowserHost::CreateBrowserSync(windowInfo, browserClient, url, cefBrowserSettings, - CefRefPtr(), nullptr); + CefRefPtr(), + CefRequestContext::GetGlobalContext()); SetBrowser(browser); From 4f26bdacd0dfecb72e06a9ac40d53fc9b058530f Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 17 Oct 2026 18:43:21 -0700 Subject: [PATCH 5/5] test: characterize real CEF initialization failure and unload --- tests/README.md | 23 ++++++++++++ tests/initialization_failure.py | 65 +++++++++++++++++++++++++++++++++ tests/startup-shutdown.c | 9 ++++- 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 tests/initialization_failure.py diff --git a/tests/README.md b/tests/README.md index f038ab2..4e90d56 100644 --- a/tests/README.md +++ b/tests/README.md @@ -35,3 +35,26 @@ Each output directory must be fresh; logs and profiles are retained. This covers real successful initialization concurrent with unload. It does not inject a CEF initialization failure or prove absence of leaks, and it does not replace full frontend/rendering tests. + +## Real initialization failure + +After compiling the lifecycle harness above, run the Linux CEF singleton case: + +```sh +uv run --no-project python tests/initialization_failure.py \ + --executable /path/to/output/startup-shutdown \ + --module /path/to/staged/obs-browser.so --data /path/to/obs-browser/data \ + --output-dir /path/to/new-failure-output +``` + +It holds a fresh CEF profile in one real module process, then starts another +against that locked profile. It requires CEF itself to report initialization +failure and the second process to complete OBS shutdown. It releases the holder +through stdin and bounds both process lifetimes. No CEF API is stubbed. Basic +password storage is selected only for these fresh disposable test profiles. + +This case requires a Chrome-runtime CEF profile singleton (validated with CEF +7871). The second process waits for the initialization-failure log before stdin +releases shutdown, separating this path from the pre-initialization task race. +It passes against both the original and fixed modules: this is characterization +coverage for preserved failure behavior, not an additional unique RED case. diff --git a/tests/initialization_failure.py b/tests/initialization_failure.py new file mode 100644 index 0000000..cfa526b --- /dev/null +++ b/tests/initialization_failure.py @@ -0,0 +1,65 @@ +#!/usr/bin/env -S uv run --script +"""Force real CefInitialize failure by holding its profile in another process.""" +import argparse +from pathlib import Path +import subprocess +import time + +parser = argparse.ArgumentParser() +parser.add_argument("--executable", type=Path, required=True) +parser.add_argument("--module", type=Path, required=True) +parser.add_argument("--data", type=Path, required=True) +parser.add_argument("--output-dir", type=Path, required=True) +args = parser.parse_args() +output = args.output_dir.resolve() +output.mkdir(parents=True, exist_ok=True) +config = output / "shared-config" +config.mkdir() +command = [str(args.executable.resolve()), str(args.module.resolve()), str(args.data.resolve()), str(config)] +with (output / "holder.log").open("w") as log: + holder = subprocess.Popen(command + ["hold", "--password-store=basic"], stdin=subprocess.PIPE, + stdout=log, stderr=subprocess.STDOUT, text=True) + try: + deadline = time.monotonic() + 15 + while not any(config.rglob("SingletonLock")): + if holder.poll() is not None: + raise SystemExit(f"profile holder exited: {holder.returncode}; see {output / 'holder.log'}") + if time.monotonic() >= deadline: + raise SystemExit("CEF did not acquire its real profile singleton lock") + time.sleep(0.05) + with (output / "failure.log").open("w") as failure_log: + failed = subprocess.Popen(command + ["hold", "--password-store=basic"], stdin=subprocess.PIPE, + stdout=failure_log, stderr=subprocess.STDOUT, text=True) + try: + deadline = time.monotonic() + 20 + while "CEF failed to initialize" not in (output / "failure.log").read_text(): + if failed.poll() is not None: + raise SystemExit(f"second process exited before observing initialization failure: {failed.returncode}") + if time.monotonic() >= deadline: + raise SystemExit("second process did not exercise the actual CEF initialization-failure branch") + time.sleep(0.05) + # Release shutdown only after observing the real failure. This + # avoids confusing it with the separate pre-initialization race. + try: + failed.communicate("\n", timeout=20) + except subprocess.TimeoutExpired as error: + raise SystemExit("unload hung after real CEF initialization failure") from error + if failed.returncode: + raise SystemExit(f"initialization-failure process exited {failed.returncode}") + finally: + if failed.poll() is None: + failed.kill() + failed.wait(timeout=5) + evidence = (output / "failure.log").read_text() + if "browser startup and immediate shutdown completed" not in evidence: + raise SystemExit("second process did not complete OBS shutdown") + print("real CEF initialization failure followed by OBS shutdown completed") + finally: + if holder.poll() is None: + try: + holder.communicate("\n", timeout=20) + except subprocess.TimeoutExpired: + holder.kill() + holder.wait(timeout=5) + if holder.returncode: + raise SystemExit(f"profile holder failed to shut down: {holder.returncode}") diff --git a/tests/startup-shutdown.c b/tests/startup-shutdown.c index 00b2013..e9887a5 100644 --- a/tests/startup-shutdown.c +++ b/tests/startup-shutdown.c @@ -7,8 +7,8 @@ int main(int argc, char **argv) { - if (argc != 5) { - fprintf(stderr, "usage: %s MODULE DATA CONFIG initialize|source\n", argv[0]); + if (argc < 5) { + fprintf(stderr, "usage: %s MODULE DATA CONFIG initialize|source|hold [CEF flags]\n", argv[0]); return 2; } obs_set_cmdline_args(argc, (const char *const *)argv); @@ -28,6 +28,11 @@ int main(int argc, char **argv) return 6; initialize(); } + if (strcmp(argv[4], "hold") == 0) { + puts("holding browser startup until stdin is released"); + fflush(stdout); + getchar(); + } obs_shutdown(); puts("browser startup and immediate shutdown completed"); return 0;