From a6f88ee306036ff9a9abfd1547ecd30a103e204e Mon Sep 17 00:00:00 2001 From: Vladimir Sumarov Date: Wed, 14 Oct 2026 21:57:38 -0700 Subject: [PATCH 1/2] Align CEF main bundle when obs64 runs outside an .app on macOS Without an .app ancestor, obs64 and its CEF helpers resolve different outer bundles, so helpers fail Mach-port rendezvous and exit; browser sources never load. Passing --main-bundle-path makes them agree. Co-Authored-By: Claude Opus 5.5 --- obs-studio-server/source/main.cpp | 53 +++++ .../osn-tests/src/test_browser_source_load.ts | 197 ++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 tests/osn-tests/src/test_browser_source_load.ts diff --git a/obs-studio-server/source/main.cpp b/obs-studio-server/source/main.cpp index 4da5a8dbe..5df30e45c 100644 --- a/obs-studio-server/source/main.cpp +++ b/obs-studio-server/source/main.cpp @@ -89,6 +89,10 @@ #include #include #include +#include +#include +#include +#include #endif #define BUFFSIZE 512 @@ -130,6 +134,53 @@ static void Shutdown(void *data, const int64_t id, const std::vector } } // namespace System +#ifdef __APPLE__ +static bool HasAppBundleAncestor(const std::string &path) +{ + size_t start = 0; + while (start < path.size()) { + size_t end = path.find('/', start); + if (end == std::string::npos) + end = path.size(); + std::string_view component(path.data() + start, end - start); + if (component.size() > 4 && component.substr(component.size() - 4) == ".app") + return true; + start = end + 1; + } + return false; +} + +static void SetCefMainBundlePath(const char *argv0) +{ + uint32_t size = 0; + _NSGetExecutablePath(nullptr, &size); + std::string exePath(size, '\0'); + if (_NSGetExecutablePath(exePath.data(), &size) != 0) + return; + + char realExe[PATH_MAX]; + if (!realpath(exePath.c_str(), realExe)) + return; + std::string exeDir(realExe); + if (HasAppBundleAncestor(exeDir)) + return; + exeDir.erase(exeDir.rfind('/')); + + char realHelper[PATH_MAX]; + std::string helperPath = exeDir + "/../Frameworks/obs64 Helper.app"; + if (!realpath(helperPath.c_str(), realHelper)) + return; + struct stat sb; + if (stat(realHelper, &sb) != 0 || !S_ISDIR(sb.st_mode)) + return; + + // CEF children rendezvous only if they resolve the same outer bundle as obs64. + std::string bundleArg = std::string("--main-bundle-path=") + realHelper; + const char *args[] = {argv0, bundleArg.c_str()}; + obs_set_cmdline_args(2, args); +} +#endif + int main(int argc, char *argv[]) { #ifdef _WIN32 @@ -150,6 +201,8 @@ int main(int argc, char *argv[]) int out_err = open(slobsStdErrPath.data(), O_WRONLY | O_CREAT | O_DSYNC); } + SetCefMainBundlePath(argv[0]); + g_util_osx = new UtilInt(); g_util_osx->init(); #endif diff --git a/tests/osn-tests/src/test_browser_source_load.ts b/tests/osn-tests/src/test_browser_source_load.ts new file mode 100644 index 000000000..1b8267ad3 --- /dev/null +++ b/tests/osn-tests/src/test_browser_source_load.ts @@ -0,0 +1,197 @@ +import 'mocha'; +import { expect } from 'chai'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as path from 'path'; +import { execSync } from 'child_process'; +import { AddressInfo } from 'net'; +import * as osn from '../osn'; +import { logInfo, logEmptyLine } from '../util/logger'; +import { OBSHandler } from '../util/obs_handler'; +import { deleteConfigFiles, sleep } from '../util/general'; + +const testName = 'browser-source-load'; +const loadTimeoutMs = 30000; + +interface IRequestRecord { + path: string; + time: number; +} + +const pageHtml = ''; +const appJs = "fetch('/report?ok=1');"; + +describe(testName, () => { + let obs: OBSHandler; + let server: http.Server; + let pageUrl: string; + let requests: IRequestRecord[] = []; + let scene: osn.IScene; + let source: osn.IInput; + let hasTestFailed = false; + + const startTime = Date.now(); + + function count(requestPath: string): number { + return requests.filter((r) => r.path.split('?')[0] === requestPath).length; + } + + function describeRequests(): string { + if (requests.length === 0) { + return 'no requests seen'; + } + return requests.map((r) => `+${r.time - startTime}ms ${r.path}`).join(', '); + } + + async function waitFor(condition: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) { + return true; + } + await sleep(100); + } + return condition(); + } + + function runDiagnostics() { + if (process.platform !== 'darwin') { + return; + } + try { + const ps = execSync('ps -axo pid,ppid,command', { encoding: 'utf8' }); + const lines = ps.split('\n').filter((line) => line.includes('obs64')); + logInfo(testName, 'obs64 processes:\n' + (lines.join('\n') || '(none)')); + } catch (e) { + logInfo(testName, 'ps failed: ' + e); + } + + const frameworkDirs = [ + path.join(osn.wd, 'Frameworks'), + path.join(osn.wd, '..', '..', 'Contents', 'Frameworks'), + ]; + for (const dir of frameworkDirs) { + const resolved = path.resolve(dir); + if (fs.existsSync(resolved)) { + logInfo(testName, `${resolved}:\n` + fs.readdirSync(resolved).join('\n')); + } else { + logInfo(testName, `${resolved} does not exist`); + } + } + } + + function failWithStage(stage: string, reached: string): never { + runDiagnostics(); + throw new Error(`Browser source did not request ${stage}; reached stage: ${reached}. Requests: ${describeRequests()}`); + } + + async function expectLoadSequence(documentCount: number) { + if (!(await waitFor(() => count('/') >= documentCount, loadTimeoutMs))) { + failWithStage('the document', 'none'); + } + if (!(await waitFor(() => count('/app.js') >= documentCount, loadTimeoutMs))) { + failWithStage('/app.js', 'document'); + } + if (!(await waitFor(() => count('/report') >= documentCount, loadTimeoutMs))) { + failWithStage('/report', 'app.js'); + } + } + + before(async function () { + logInfo(testName, 'Starting ' + testName + ' tests'); + + server = http.createServer((req, res) => { + const reqPath = req.url || ''; + requests.push({ path: reqPath, time: Date.now() }); + const pathname = reqPath.split('?')[0]; + if (pathname === '/') { + res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' }); + res.end(pageHtml); + } else if (pathname === '/app.js') { + res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-store' }); + res.end(appJs); + } else { + res.writeHead(200, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' }); + res.end('ok'); + } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + pageUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/`; + logInfo(testName, 'Fixture server listening at ' + pageUrl); + + deleteConfigFiles(); + obs = new OBSHandler(testName); + }); + + after(async function () { + if (source) { + source.release(); + source = null; + } + if (scene) { + scene.getItems().forEach((item) => item.remove()); + osn.Global.setOutputSource(0, null as any); + scene.release(); + scene = null; + } + + obs.shutdown(); + + if (hasTestFailed === true) { + logInfo(testName, 'One or more test cases failed. Uploading cache'); + await obs.uploadTestCache(); + } + + await new Promise((resolve) => server.close(() => resolve())); + + obs = null; + deleteConfigFiles(); + logInfo(testName, 'Finished ' + testName + ' tests'); + logEmptyLine(); + }); + + afterEach(function () { + if (this.currentTest.state == 'failed') { + hasTestFailed = true; + } + }); + + it('Browser source loads its page and runs its script', async function () { + this.timeout(120000); + + // Mocha retries rerun this test without rerunning before(), so create only once. + if (!source) { + scene = osn.SceneFactory.create('browser-load-scene'); + source = osn.InputFactory.create('browser_source', 'browser-load-source', { + url: pageUrl, + width: 640, + height: 360, + }); + expect(source).to.not.equal(undefined, 'Failed to create browser_source'); + scene.add(source); + osn.Global.setOutputSource(0, scene); + } + + await expectLoadSequence(1); + }); + + it('Browser source reloads on refresh', async function () { + this.timeout(120000); + expect(source, 'Browser source was not created by the previous test').to.exist; + + const documentsBefore = count('/'); + const refreshButton: any = source.properties.get('refreshnocache'); + if (refreshButton && typeof refreshButton.buttonClicked === 'function') { + logInfo(testName, 'Refreshing via refreshnocache button'); + refreshButton.buttonClicked(source); + } else { + logInfo(testName, 'refreshnocache button not exposed, refreshing by changing url'); + source.update({ url: pageUrl + '?refresh=1' }); + } + + if (!(await waitFor(() => count('/') > documentsBefore, loadTimeoutMs))) { + runDiagnostics(); + throw new Error(`Browser source did not request the document again after refresh. Requests: ${describeRequests()}`); + } + }); +}); From 1c8409c47f1f4beab1d7dda4b1efb94e42b4b1f9 Mon Sep 17 00:00:00 2001 From: Vladimir Sumarov Date: Thu, 15 Oct 2026 09:03:39 -0700 Subject: [PATCH 2/2] Close the browser-source test fixture server even when teardown fails Setup failure leaves obs undefined and any teardown error previously skipped server.close(), leaking the listener. Co-Authored-By: Claude Opus 5.5 --- .../osn-tests/src/test_browser_source_load.ts | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/tests/osn-tests/src/test_browser_source_load.ts b/tests/osn-tests/src/test_browser_source_load.ts index 1b8267ad3..e344d6adc 100644 --- a/tests/osn-tests/src/test_browser_source_load.ts +++ b/tests/osn-tests/src/test_browser_source_load.ts @@ -124,26 +124,30 @@ describe(testName, () => { }); after(async function () { - if (source) { - source.release(); - source = null; - } - if (scene) { - scene.getItems().forEach((item) => item.remove()); - osn.Global.setOutputSource(0, null as any); - scene.release(); - scene = null; - } + try { + if (source) { + source.release(); + source = null; + } + if (scene) { + scene.getItems().forEach((item) => item.remove()); + osn.Global.setOutputSource(0, null as any); + scene.release(); + scene = null; + } - obs.shutdown(); + if (obs) { + obs.shutdown(); - if (hasTestFailed === true) { - logInfo(testName, 'One or more test cases failed. Uploading cache'); - await obs.uploadTestCache(); + if (hasTestFailed === true) { + logInfo(testName, 'One or more test cases failed. Uploading cache'); + await obs.uploadTestCache(); + } + } + } finally { + await new Promise((resolve) => server.close(() => resolve())); } - await new Promise((resolve) => server.close(() => resolve())); - obs = null; deleteConfigFiles(); logInfo(testName, 'Finished ' + testName + ' tests');