Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions browser-app.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<CefCommandLine> command_line)
Expand Down
3 changes: 3 additions & 0 deletions browser-app.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ class BrowserApp : public CefApp, public CefRenderProcessHandler, public CefBrow
bool shared_texture_available;
CallbackMap callbackMap;
int callbackId;
std::function<void()> on_context_initialized;
#if !defined(__APPLE__) && !defined(_WIN32)
bool wayland;
#endif
Expand All @@ -84,6 +85,8 @@ class BrowserApp : public CefApp, public CefRenderProcessHandler, public CefBrow
{
}

void SetContextInitializedCallback(std::function<void()> callback) { on_context_initialized.swap(callback); }

virtual CefRefPtr<CefRenderProcessHandler> GetRenderProcessHandler() override;
virtual CefRefPtr<CefBrowserProcessHandler> GetBrowserProcessHandler() override;
virtual CefRefPtr<CefClient> GetDefaultClient() override;
Expand Down
50 changes: 37 additions & 13 deletions obs-browser-plugin.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -108,6 +110,10 @@ class BrowserTask : public CefTask {

bool QueueCEFTask(std::function<void()> task)
{
if (!cef_started_event || os_event_try(cef_started_event) != 0) {
return false;
}

return CefPostTask(TID_UI, CefRefPtr<BrowserTask>(new BrowserTask(task)));
}

Expand Down Expand Up @@ -269,7 +275,7 @@ static obs_missing_files_t *browser_source_missingfiles(void *data)

static CefRefPtr<BrowserApp> 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);
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -417,17 +426,20 @@ 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

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
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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");
}

Expand All @@ -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;
}
9 changes: 8 additions & 1 deletion obs-browser-source.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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; });
}

Expand Down Expand Up @@ -239,7 +245,8 @@ bool BrowserSource::CreateBrowser()
cefBrowserSettings.default_fixed_font_size = 16;

auto browser = CefBrowserHost::CreateBrowserSync(windowInfo, browserClient, url, cefBrowserSettings,
CefRefPtr<CefDictionaryValue>(), nullptr);
CefRefPtr<CefDictionaryValue>(),
CefRequestContext::GetGlobalContext());

SetBrowser(browser);

Expand Down
60 changes: 60 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# 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.

## 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.

## 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.
65 changes: 65 additions & 0 deletions tests/initialization_failure.py
Original file line number Diff line number Diff line change
@@ -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}")
14 changes: 14 additions & 0 deletions tests/queue-before-init.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#include <cstdio>
#include <functional>

bool QueueCEFTask(std::function<void()> 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;
}
40 changes: 40 additions & 0 deletions tests/queue_before_init.py
Original file line number Diff line number Diff line change
@@ -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}")
39 changes: 39 additions & 0 deletions tests/startup-shutdown.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/* 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 <obs.h>
#include <dlfcn.h>
#include <stdio.h>
#include <string.h>

int main(int argc, char **argv)
{
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);
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_private("browser_source", "Early browser", 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();
}
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;
}
Loading
Loading