Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48a74e8c67 | ||
|
|
ab455a9cfe | ||
|
|
e763d61ed3 | ||
|
|
17540c75b0 | ||
|
|
b23644fa3e | ||
|
|
8888e57d08 | ||
|
|
5a39881af8 | ||
|
|
d9f73926e4 | ||
|
|
ee44fb6a73 | ||
|
|
abd4dc9aca | ||
|
|
1571d7ecea | ||
|
|
bfc38f45ca |
@@ -1,9 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Creates a (draft) Gitea Release for the tag that triggered
|
||||
# Creates a published Gitea Release for the tag that triggered
|
||||
# .gitea/workflows/release.yml, and uploads every archive in $DIST_DIR as a
|
||||
# release asset. Draft because nobody has run this plugin in the OBS GUI on
|
||||
# any platform yet -- a human still opens it and clicks Publish once that's
|
||||
# no longer true (or once they're satisfied regardless).
|
||||
# release asset.
|
||||
#
|
||||
# This used to create a DRAFT, on the grounds that nobody had run the plugin
|
||||
# in the OBS GUI on any platform. That stopped being true on 2026-09-09, when
|
||||
# the v0.1.0 Windows artifact loaded into OBS 32.2.2 on Windows 11 -- so the
|
||||
# release publishes directly and the per-platform table below carries the
|
||||
# remaining caveats instead. Assets upload AFTER the release row is created
|
||||
# either way, so a release is briefly visible with no files attached; that is
|
||||
# the tradeoff for not needing a human click.
|
||||
#
|
||||
# Required env: GITEA_TOKEN, SERVER, OWNER, REPO, TAG, SHA, DIST_DIR
|
||||
# Optional env: MACOS_BUNDLE_FOUND ("true"/"false", default "false")
|
||||
@@ -19,7 +25,7 @@ set -euo pipefail
|
||||
MACOS_BUNDLE_FOUND="${MACOS_BUNDLE_FOUND:-false}"
|
||||
|
||||
if [ "${MACOS_BUNDLE_FOUND}" = "true" ]; then
|
||||
MACOS_NOTE="This archive contains a \`.plugin\` bundle."
|
||||
MACOS_NOTE="This archive contains a \`.plugin\` bundle (verified on v0.1.0: MH_BUNDLE + Info.plist, libobs via \`@rpath\` + \`@executable_path/../Frameworks\`, LiveKit dylibs bundled, all three binaries code-signed). **arm64 only -- no Intel slice**, macOS 13+. Never yet loaded in OBS.app by a human."
|
||||
else
|
||||
MACOS_NOTE="This archive is packaged as a bare \`streamer-tools-camera.so\` (the layout \`build/package/\` currently produces on macOS), **not** an OBS.app-loadable \`.plugin\` bundle. It will not load in the OBS GUI as-is -- see the \"macOS packaging gap\" section of \`README.md\`."
|
||||
fi
|
||||
@@ -30,26 +36,48 @@ cat > "${NOTES_FILE}" <<EOF
|
||||
|
||||
Built from commit \`${SHA}\`.
|
||||
|
||||
**Nobody has yet run this plugin in the OBS GUI, on any platform.** See "What
|
||||
is verified, and how" in \`README.md\` for exactly what has and has not been
|
||||
checked, including which claims are backed by automated tests versus a human
|
||||
watching OBS. This is why the release is a draft -- open it and click Publish
|
||||
once you're satisfied.
|
||||
**Confirmed working on Linux and Windows, including a live show.** The plugin
|
||||
carried a real broadcast on 2026-09-07. Video and audio both arrive and hold
|
||||
up across a session: Linux verified by the project owner, Windows by two
|
||||
directors independently.
|
||||
|
||||
Still unverified: **macOS in the OBS GUI** (nobody has opened it -- see the
|
||||
table), **measured** A/V sync and end-to-end latency against the existing
|
||||
egress path (no drift reported, but nothing measured), and whether a publisher
|
||||
restarting mid-show recovers cleanly on screen. See "What is verified, and
|
||||
how" in \`README.md\` for what is backed by automated tests versus a human
|
||||
watching OBS.
|
||||
|
||||
| Platform | Archive | Notes |
|
||||
|---|---|---|
|
||||
| Linux (x64) | \`streamer-tools-camera-${TAG}-linux-x64.zip\` | Functionally complete and verified end to end against a real LiveKit server and a real libobs (see README); OBS GUI itself still unverified |
|
||||
| Windows (x64) | \`streamer-tools-camera-${TAG}-windows-x64.zip\` | Built and tested by this workflow's Windows job; the WinHTTP backend has never been exercised against a real streamer-tools server, only a loopback test server -- see README's Windows CI section |
|
||||
| Linux (x64) | \`streamer-tools-camera-${TAG}-linux-x64.zip\` | Functionally complete, verified end to end against a real LiveKit server and a real libobs (see README), and confirmed working in the OBS GUI |
|
||||
| Windows (x64) | \`streamer-tools-camera-${TAG}-windows-x64.zip\` | Built and tested by this workflow's Windows job, and confirmed working in the OBS GUI by two directors independently (first load: OBS 32.2.2 / Windows 11) -- which also exercises the WinHTTP backend against a real streamer-tools server |
|
||||
| macOS | \`streamer-tools-camera-${TAG}-macos.zip\` | Built and tested by this workflow's macOS job. ${MACOS_NOTE} |
|
||||
|
||||
## Installing
|
||||
|
||||
Extract the archive into your OBS plugins folder for your platform (the
|
||||
default locations are easy to find online -- typically
|
||||
\`~/.config/obs-studio/plugins/\` on Linux, \`%APPDATA%\\obs-studio\\plugins\\\`
|
||||
on Windows, \`~/Library/Application Support/obs-studio/plugins/\` on macOS).
|
||||
Each archive's top-level folder already matches the shape OBS expects there,
|
||||
so extracting is the whole install step. Then in OBS: Sources -> \`+\` ->
|
||||
Extract the archive into your OBS plugins folder. **The directory is not the
|
||||
same shape on every platform, and picking the wrong one fails silently -- OBS
|
||||
logs nothing at all for a plugin it never finds:**
|
||||
|
||||
| Platform | Extract into |
|
||||
|---|---|
|
||||
| Windows | \`C:\\ProgramData\\obs-studio\\plugins\\\` -- **not** \`%APPDATA%\\obs-studio\\\`, which is where OBS keeps its config and is never scanned for plugins |
|
||||
| macOS | \`~/Library/Application Support/obs-studio/plugins/\` |
|
||||
| Linux | \`~/.config/obs-studio/plugins/\` |
|
||||
|
||||
Each archive's top-level folder already matches the shape OBS expects, so
|
||||
extracting is the whole install step -- but check the result is exactly one
|
||||
folder deep. Windows Explorer's "Extract All..." adds a folder named after the
|
||||
zip unless you clear it from the destination box, which nests it one level too
|
||||
far and is equally silent. On Windows the finished path must be:
|
||||
|
||||
\`\`\`
|
||||
C:\\ProgramData\\obs-studio\\plugins\\streamer-tools-camera\\bin\\64bit\\streamer-tools-camera.dll
|
||||
\`\`\`
|
||||
|
||||
To confirm it loaded, restart OBS and check Help -> Log Files -> View Current
|
||||
Log for \`streamer-tools-camera\` under "Loaded Modules". Then in OBS: Sources -> \`+\` ->
|
||||
"streamer-tools Camera" -> fill in the server URL, room slug and read key
|
||||
from the room's settings page -> "Refresh camera list" -> pick a camera.
|
||||
|
||||
@@ -73,7 +101,7 @@ print(json.dumps({
|
||||
"tag_name": tag,
|
||||
"name": tag,
|
||||
"body": notes,
|
||||
"draft": True,
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}))
|
||||
PYEOF
|
||||
@@ -87,7 +115,7 @@ RESP="$(curl -sS -f -X POST \
|
||||
"${SERVER}/api/v1/repos/${OWNER}/${REPO}/releases")"
|
||||
|
||||
RELEASE_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"${RESP}")"
|
||||
echo "Created release id ${RELEASE_ID} (draft)."
|
||||
echo "Created release id ${RELEASE_ID} (published; assets upload next)."
|
||||
|
||||
shopt -s nullglob
|
||||
ASSETS=("${DIST_DIR}"/*)
|
||||
@@ -106,4 +134,4 @@ for f in "${ASSETS[@]}"; do
|
||||
> /dev/null
|
||||
done
|
||||
|
||||
echo "Done. Draft release: ${SERVER}/${OWNER}/${REPO}/releases/${RELEASE_ID}"
|
||||
echo "Done. Release: ${SERVER}/${OWNER}/${REPO}/releases/${RELEASE_ID}"
|
||||
|
||||
@@ -31,7 +31,29 @@ on:
|
||||
# job is ordinary commits.
|
||||
branches:
|
||||
- "**"
|
||||
# Documentation-only changes cannot break a build, and this workflow is a
|
||||
# full three-platform build (Windows included) behind a runner with
|
||||
# capacity:1. Six of these fired for one afternoon of README/release-notes
|
||||
# edits on 2026-09-09. Anything that feeds a build or a test is absent
|
||||
# from this list on purpose -- release.yml and publish-release.sh only run
|
||||
# on a `v*` tag, via release.yml's own trigger.
|
||||
#
|
||||
# Tradeoff: a docs-only push now shows NO status at all on the branch,
|
||||
# rather than a green one. If a required-status check is ever added, these
|
||||
# paths have to be reconsidered.
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "LICENSE"
|
||||
- "NOTICE"
|
||||
- ".gitea/workflows/release.yml"
|
||||
- ".gitea/scripts/publish-release.sh"
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "LICENSE"
|
||||
- "NOTICE"
|
||||
- ".gitea/workflows/release.yml"
|
||||
- ".gitea/scripts/publish-release.sh"
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
name: Release
|
||||
|
||||
# Packages a build of each platform into a downloadable archive and creates
|
||||
# a (draft) Gitea Release for it, so the project owner and other directors
|
||||
# a published Gitea Release for it, so the project owner and other directors
|
||||
# can grab a ready-to-use build instead of compiling from source.
|
||||
#
|
||||
# Runs only on a pushed version tag (see `on.push.tags` below) -- never on an
|
||||
# ordinary push or PR, unlike build.yml. The release it creates is a DRAFT:
|
||||
# it stays invisible to anyone without write access until a human explicitly
|
||||
# opens it and clicks Publish, since nobody has run this plugin in the OBS
|
||||
# GUI on any platform yet.
|
||||
# ordinary push or PR, unlike build.yml. The release it creates is PUBLISHED
|
||||
# immediately. It used to be a draft, gated on a human clicking Publish
|
||||
# because nobody had run the plugin in the OBS GUI on any platform; the first
|
||||
# confirmed GUI load (Windows, 2026-09-09) retired that. The caveats that
|
||||
# remain live in the generated release notes, not in the draft flag -- see
|
||||
# .gitea/scripts/publish-release.sh.
|
||||
#
|
||||
# The actual per-platform build commands live in .gitea/scripts/ and are the
|
||||
# same scripts .gitea/workflows/build.yml uses, so this workflow can't drift
|
||||
@@ -183,7 +185,7 @@ jobs:
|
||||
path: dist
|
||||
|
||||
release:
|
||||
name: Create Gitea Release (draft)
|
||||
name: Create Gitea Release
|
||||
needs: [linux, macos, windows]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
@@ -214,7 +216,7 @@ jobs:
|
||||
name: release-archive-windows-x64
|
||||
path: dist
|
||||
|
||||
- name: Create draft release and upload assets
|
||||
- name: Create release and upload assets
|
||||
run: .gitea/scripts/publish-release.sh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
@@ -14,18 +14,38 @@ match the vendored LiveKit binaries, which are also Apache-2.0 — see
|
||||
|
||||
`.gitea/workflows/build.yml` builds, tests, and uploads CI-internal build
|
||||
artifacts on every push. `.gitea/workflows/release.yml` packages a tagged
|
||||
build (`v*`) into a **draft** Gitea Release — draft because nobody has run
|
||||
this in the OBS GUI yet (see below), not because of anything else; a human
|
||||
still needs to open it and click Publish.
|
||||
build (`v*`) into a **published** Gitea Release. It created drafts until
|
||||
2026-09-09, gated on a human clicking Publish because nobody had run the
|
||||
plugin in the OBS GUI; the first confirmed GUI load retired that gate, and the
|
||||
remaining caveats live in the generated release notes instead.
|
||||
|
||||
The plugin is **functionally complete on Linux and verified end to end there**
|
||||
(module loads into real libobs, connects to a real LiveKit server through the
|
||||
real streamer-tools API shape, and pushes decoded frames into
|
||||
`obs_source_output_video`/`_audio`).
|
||||
|
||||
It has **not been run in the OBS GUI on any platform.** macOS builds the real
|
||||
module in CI but its artifact is not yet loadable (see the macOS packaging gap
|
||||
under CI).
|
||||
**Confirmed working in the OBS GUI on Linux and Windows, including a live
|
||||
show.** The plugin carried a real broadcast on 2026-09-07 and was reported to
|
||||
work well. Video and audio both arrive and hold up across a session: Linux
|
||||
verified by the project owner, Windows by two directors independently
|
||||
(2026-09-09/10; the first Windows load was OBS 32.2.2 on Windows 11 build
|
||||
26200, from
|
||||
`C:\ProgramData\obs-studio\plugins\streamer-tools-camera\bin\64bit\`).
|
||||
Because listing cameras requires an API call, that also retires "the WinHTTP
|
||||
backend has never run against a real streamer-tools server".
|
||||
|
||||
What that does **not** cover: measured A/V sync and end-to-end latency against
|
||||
the existing egress path (no drift reported over a session, but nothing was
|
||||
measured), mid-show publisher restart, and **macOS in the GUI — still never
|
||||
opened by anyone**, though its artifact is now known to be correctly packaged
|
||||
(see macOS packaging below). See "Not verified anywhere" for the current list.
|
||||
|
||||
⚠️ **The install directory is not the same on every platform, and getting it
|
||||
wrong fails silently.** On Windows it is
|
||||
`C:\ProgramData\obs-studio\plugins\` (`GetProgramDataPath` →
|
||||
`CSIDL_COMMON_APPDATA`), **not** `%APPDATA%\obs-studio\` — see the packaging
|
||||
section. That mistake cost the director above an evening: OBS logs nothing at
|
||||
all for a plugin it never finds.
|
||||
|
||||
**Windows CI is now green.** The run at `f27b1c0` is the first completed
|
||||
green Windows job on this repository: the from-source libobs bootstrap
|
||||
@@ -35,8 +55,8 @@ suites pass, and `build\package\bin\64bit\streamer-tools-camera.dll`
|
||||
out of the job's own log body, not inferred from the job status. That also
|
||||
retires three previously-unproven items in one go: the `-A x64` argument fix,
|
||||
the PowerShell rewrite of the Windows steps, and the `add_subdirectory`
|
||||
patch for `OBS::w32-pthreads`. Windows is still **unverified in the OBS GUI**,
|
||||
exactly like the other two platforms. See "Where the Windows bootstrap got
|
||||
patch for `OBS::w32-pthreads`. Windows has since been **loaded in the real OBS
|
||||
GUI** (see above); Linux and macOS have not. See "Where the Windows bootstrap got
|
||||
to" under CI below for the whole trace, and check current CI status rather
|
||||
than trusting this paragraph's age.
|
||||
|
||||
@@ -66,7 +86,7 @@ scripts/livekit-dev-room.py - mints tokens for the integration test
|
||||
third_party/livekit/ - redistribution notices for the LiveKit binaries
|
||||
.gitea/scripts/ - the actual per-platform build commands, shared by build.yml and release.yml
|
||||
.gitea/workflows/build.yml - 3-platform CI matrix (every push/PR; never publishes)
|
||||
.gitea/workflows/release.yml - packages + creates a draft Gitea Release (only on a `v*` tag push; see Status above)
|
||||
.gitea/workflows/release.yml - packages + publishes a Gitea Release (only on a `v*` tag push; see Status above)
|
||||
```
|
||||
|
||||
## How it works
|
||||
@@ -142,17 +162,28 @@ build/package/licenses/...
|
||||
```
|
||||
|
||||
That is exactly the layout OBS searches on Linux and Windows —
|
||||
`<config>/obs-studio/plugins/<name>/bin/64bit` plus a sibling `data/`, per
|
||||
`AddExtraModulePaths()` in obs-studio's `UI/window-basic-main.cpp` — so
|
||||
`build/package/` is a straight drop-in. The module resolves the LiveKit
|
||||
libraries from `$ORIGIN` (verified: `ldd` on the staged copy resolves both
|
||||
to `bin/64bit/`), not from the build tree. macOS is not this shape; see the
|
||||
macOS packaging gap under CI.
|
||||
`<base>/obs-studio/plugins/<name>/bin/64bit` plus a sibling `data/`, per
|
||||
`AddExtraModulePaths()` in obs-studio (`UI/window-basic-main.cpp` in 30.x,
|
||||
`frontend/widgets/OBSBasic.cpp` in 32.x) — so `build/package/` is a straight
|
||||
drop-in. **`<base>` is NOT the same directory on every platform**, and getting
|
||||
this wrong is silent: OBS logs nothing at all for a plugin it never finds.
|
||||
Linux uses the user config dir (`GetAppConfigPath` → `~/.config`), but Windows
|
||||
uses `GetProgramDataPath` (`CSIDL_COMMON_APPDATA`) — i.e.
|
||||
`C:\ProgramData\obs-studio\plugins\`, **not** `%APPDATA%\obs-studio\`
|
||||
(`CSIDL_APPDATA`), which on Windows holds OBS's config and is never scanned for
|
||||
plugins. This bit a director on 2026-09-09: a correctly-shaped install under
|
||||
`AppData\Roaming` produced a log with zero mention of the module.
|
||||
|
||||
The module resolves the LiveKit libraries from `$ORIGIN` (verified: `ldd` on the staged copy resolves both
|
||||
to `bin/64bit/`), not from the build tree. macOS is not this shape — it ships
|
||||
a `.plugin` bundle; see macOS packaging under CI.
|
||||
|
||||
## Testing this by hand
|
||||
|
||||
**Nobody has yet run this in the OBS GUI. That test is still outstanding on
|
||||
all three platforms.** To do it on Linux:
|
||||
**Linux and Windows are confirmed working in the GUI — video and audio over a
|
||||
real session, Linux by the project owner and Windows by two directors
|
||||
independently (2026-09-09/10). macOS has never been opened in the GUI by
|
||||
anyone.** To repeat the Linux run:
|
||||
|
||||
```
|
||||
mkdir -p ~/.config/obs-studio/plugins/streamer-tools-camera
|
||||
@@ -208,17 +239,17 @@ livekit-server 1.13.6 in dev mode):
|
||||
| Two sources in one OBS process | same harness with a second source added: both connect with distinct nonce identities, both receive frames, both tear down cleanly |
|
||||
|
||||
**Not verified anywhere:**
|
||||
- The OBS GUI, on any platform. No human has looked at this in OBS.
|
||||
- macOS beyond "CI builds and links the real module and the core tests pass".
|
||||
Its artifact is a bare `.so` with a relative libobs install name and will
|
||||
not load in OBS.app — see the macOS packaging gap under CI.
|
||||
- Windows beyond "the core library and the WinHTTP backend compile and their
|
||||
tests pass", from runs predating the current fixes. The WinHTTP backend has
|
||||
never run against a real streamer-tools server, only against the loopback
|
||||
test server in `test_api_client`.
|
||||
- A/V sync and end-to-end latency against the existing egress path.
|
||||
- Behaviour against the real production streamer-tools server (only against a
|
||||
stand-in serving the same shapes).
|
||||
- **macOS in the OBS GUI.** Nobody has opened it. Its artifact is now known to
|
||||
be a correctly-formed, correctly-linked, code-signed `.plugin` bundle
|
||||
(verified by inspecting the shipped v0.1.0 zip — see macOS packaging under
|
||||
CI), and it is arm64-only, so Intel Macs are out regardless. "The bundle is
|
||||
well formed" is not "OBS loaded it".
|
||||
- **Measured** A/V sync and end-to-end latency against the existing egress
|
||||
path. A live show and several sessions on Linux and Windows produced no
|
||||
reported drift, which is not the same as a measurement — and the timestamp
|
||||
caveat above is the reason to want real numbers.
|
||||
- Whether a publisher restarting mid-show recovers cleanly on screen.
|
||||
- Token expiry across a session longer than an hour (see below).
|
||||
- Token expiry after an hour. Expiry is handled *reactively*: a fatal
|
||||
disconnect makes the worker mint a fresh token and reconnect. The design
|
||||
doc's "proactively refreshed before expiry" is **not** implemented —
|
||||
@@ -232,8 +263,8 @@ runners available to this repo under the `CyberCoveLLC` org.
|
||||
| Job | `runs-on` | Runner | State |
|
||||
|---|---|---|---|
|
||||
| `linux` | `ubuntu-24.04` | `localhost.localdomain` | **Green.** Builds the real adapter against Ubuntu's libobs-dev 30.0.2, runs all six test suites, uploads `build/package` as an artifact |
|
||||
| `macos` | `macos-latest` | `home-mac` (Global) | **Green.** Builds libobs 30.0.2 from source, then the real adapter; 6/6 tests; artifact uploaded. But see the macOS packaging gap below |
|
||||
| `windows` | `windows-latest` | `winvm-builder` (org-scoped) | **Failing, fix pushed and awaiting a completed run.** Every completed run so far has failed; the latest got as far as building libobs and stopped on an OBS-side `OBS::w32-pthreads` target that its own modern CMake path never defines. A bootstrap patch for that gap has been pushed but not yet confirmed by a green run; see below |
|
||||
| `macos` | `macos-latest` | `home-mac` (Global) | **Green.** Builds libobs 30.0.2 from source, then the real adapter; 6/6 tests; artifact uploaded as a `.plugin` bundle. Never loaded in OBS.app, and arm64-only — see macOS packaging below |
|
||||
| `windows` | `windows-latest` | `winvm-builder` (org-scoped) | **Green.** Builds libobs 30.0.2 from source, then the real adapter; 6/6 tests; artifact staged. Was red twice more after the bootstrap was fixed, both times on `test_api_client`'s timeout probe — see "WinHTTP timeouts are not deadlines" below |
|
||||
|
||||
The Linux job is pinned to `ubuntu-24.04` rather than `ubuntu-latest`: this
|
||||
instance's two Linux runners answer `ubuntu-latest` with different releases,
|
||||
@@ -327,26 +358,36 @@ obs-studio, builds libobs from source, builds and links the real adapter,
|
||||
passes 6/6 tests, and uploads its artifact. `otool -L` on the result shows it
|
||||
linked against libobs and `@rpath/liblivekit.dylib`.
|
||||
|
||||
### macOS packaging gap (known, unfixed)
|
||||
### macOS packaging (was described here as broken; it is not)
|
||||
|
||||
**The macOS artifact will not load in OBS.app as it stands.** Two reasons,
|
||||
neither of which CI can catch, because CI only proves it compiles and links:
|
||||
**This section used to claim the macOS artifact was a bare
|
||||
`streamer-tools-camera.so` with a relative libobs install name that "will not
|
||||
load in OBS.app as it stands". That is wrong, and it contradicted the release
|
||||
notes for the same build.** Corrected 2026-09-10 by inspecting the shipped
|
||||
`streamer-tools-camera-v0.1.0-macos.zip` itself:
|
||||
|
||||
1. It is a bare `streamer-tools-camera.so`. OBS on macOS loads plugins as
|
||||
`<name>.plugin` bundles (`Contents/MacOS/<name>`, `Contents/Resources/`,
|
||||
an `Info.plist`), which is what obs-plugintemplate's
|
||||
`cmake/macos/helpers.cmake` builds and which this project deliberately did
|
||||
not vendor.
|
||||
2. `otool -L` shows the libobs dependency recorded as the relative path
|
||||
`libobs/libobs.framework/Versions/A/libobs`, inherited from the
|
||||
from-source libobs's own install name. A real plugin needs
|
||||
`@rpath/libobs.framework/Versions/A/libobs` plus an `LC_RPATH` pointing at
|
||||
`OBS.app/Contents/Frameworks`.
|
||||
- It is a proper bundle: `streamer-tools-camera.plugin/Contents/MacOS/streamer-tools-camera`
|
||||
(Mach-O **`MH_BUNDLE`**, which is what OBS loads), plus `Info.plist`
|
||||
(`CFBundlePackageType BNDL`, `CFBundleExecutable streamer-tools-camera`),
|
||||
`Contents/Resources/locale/en-US.ini`, and both LiveKit dylibs under
|
||||
`Contents/Frameworks/`.
|
||||
- The install names are right, which was the specific doubt. The module loads
|
||||
`@rpath/libobs.framework/Versions/A/libobs` and carries
|
||||
`LC_RPATH @executable_path/../Frameworks` — inside OBS.app that resolves to
|
||||
`OBS.app/Contents/Frameworks`, where libobs lives. `@rpath/liblivekit.dylib`
|
||||
resolves through `LC_RPATH @loader_path/../Frameworks` to the bundle's own
|
||||
copy, and `liblivekit.dylib` finds `liblivekit_ffi.dylib` through its own
|
||||
`LC_RPATH @loader_path`. Nothing points into a build tree.
|
||||
- All three binaries carry an `LC_CODE_SIGNATURE` (superblob `0xfade0cc0`),
|
||||
which is not optional: arm64 macOS refuses to load unsigned code at all.
|
||||
|
||||
Fixing this means either vendoring the template's macOS bundle helpers or
|
||||
adding an `install_name_tool` pass and a bundle layout — bounded work, but
|
||||
work that has to be done and checked on an actual Mac. It is deliberately not
|
||||
attempted here rather than guessed at.
|
||||
**The real macOS limitation is different: the bundle is arm64-only.** There is
|
||||
no x86_64 slice, so Intel Macs cannot load it, and `LSMinimumSystemVersion` is
|
||||
`13.0`. Shipping a universal binary would mean building both slices and
|
||||
`lipo`-ing them, on a Mac.
|
||||
|
||||
Everything above is static inspection of the artifact. **Nobody has yet opened
|
||||
it in OBS.app** — well-formed and signed is a strong prior, not a load.
|
||||
|
||||
### Where the Windows bootstrap got to
|
||||
|
||||
|
||||
+147
-23
@@ -19,8 +19,13 @@ You may obtain a copy of the License at
|
||||
#include <windows.h>
|
||||
#include <winhttp.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstddef>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace stplugin {
|
||||
@@ -72,6 +77,79 @@ private:
|
||||
HINTERNET h_ = nullptr;
|
||||
};
|
||||
|
||||
/// Hard deadline for one WinHTTP exchange, enforced by cancelling it.
|
||||
///
|
||||
/// Neither receive timeout is a guaranteed deadline: Microsoft documents both
|
||||
/// as "checked only when data is received from the socket", so an expired
|
||||
/// timeout is not surfaced until the peer finally sends something. Measured on
|
||||
/// the Windows CI runner against a server that accepts and then stalls 5s: a
|
||||
/// 700ms budget returned after 1490, 1529, 2485, 3493 and 4506ms across five
|
||||
/// attempts -- always cancelled, never on time.
|
||||
///
|
||||
/// That overshoot matters because `fetchSlots` is called synchronously on the
|
||||
/// OBS UI thread behind the properties dialog's "Refresh camera list" button
|
||||
/// (obs-adapter/src/plugin-main.cpp), with a 5s budget. At the ratio above
|
||||
/// that is a frozen dialog for half a minute.
|
||||
///
|
||||
/// The documented way to force cancellation is to close the handle from
|
||||
/// another thread; the pending call then fails with
|
||||
/// ERROR_WINHTTP_OPERATION_CANCELLED. This owns the request handle so that
|
||||
/// exactly one of the two threads ever closes it: `handle_.exchange(nullptr)`
|
||||
/// hands the close to whichever gets there first.
|
||||
///
|
||||
/// Known, accepted race: the caller may load the handle and have the watchdog
|
||||
/// close it before the WinHttp* call reads it, in which case the call fails
|
||||
/// with ERROR_INVALID_HANDLE instead. Both outcomes are "the deadline
|
||||
/// expired", which is what the caller is told either way.
|
||||
class RequestDeadline {
|
||||
public:
|
||||
RequestDeadline(HINTERNET request, DWORD after_ms) : handle_(request)
|
||||
{
|
||||
watchdog_ = std::thread([this, after_ms] {
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
if (cv_.wait_for(lock, std::chrono::milliseconds(after_ms), [this] { return finished_; }))
|
||||
return; // exchange finished inside the deadline
|
||||
if (closeOnce())
|
||||
expired_.store(true);
|
||||
});
|
||||
}
|
||||
|
||||
~RequestDeadline()
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
finished_ = true;
|
||||
}
|
||||
cv_.notify_all();
|
||||
if (watchdog_.joinable())
|
||||
watchdog_.join();
|
||||
closeOnce(); // no-op if the watchdog got there first
|
||||
}
|
||||
|
||||
RequestDeadline(const RequestDeadline &) = delete;
|
||||
RequestDeadline &operator=(const RequestDeadline &) = delete;
|
||||
|
||||
HINTERNET get() const { return handle_.load(); }
|
||||
bool expired() const { return expired_.load(); }
|
||||
|
||||
private:
|
||||
bool closeOnce()
|
||||
{
|
||||
HINTERNET h = handle_.exchange(nullptr);
|
||||
if (!h)
|
||||
return false;
|
||||
WinHttpCloseHandle(h);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::atomic<HINTERNET> handle_;
|
||||
std::atomic<bool> expired_{false};
|
||||
std::mutex mutex_;
|
||||
std::condition_variable cv_;
|
||||
bool finished_ = false;
|
||||
std::thread watchdog_;
|
||||
};
|
||||
|
||||
class WinHttpClient : public HttpClient {
|
||||
public:
|
||||
HttpResponse send(const HttpRequest &request) override
|
||||
@@ -116,6 +194,39 @@ public:
|
||||
WinHttpSetTimeouts(session.get(), static_cast<int>(timeout), static_cast<int>(timeout),
|
||||
static_cast<int>(timeout), static_cast<int>(timeout));
|
||||
|
||||
// WinHttpSetTimeouts' receive parameter maps to
|
||||
// WINHTTP_OPTION_RECEIVE_TIMEOUT, which Microsoft documents as a
|
||||
// PER-PACKET Winsock-layer read timeout ("applies to fetching each
|
||||
// packet of data off the socket"), not a deadline on the response.
|
||||
// The wait for the response HEADERS is a *separate* option,
|
||||
// WINHTTP_OPTION_RECEIVE_RESPONSE_TIMEOUT ("to wait to receive all
|
||||
// response headers to a request"), which WinHttpSetTimeouts does not
|
||||
// touch and which defaults to 90 SECONDS. Without this call a server
|
||||
// that accepts, reads the request and then stalls can hold this
|
||||
// thread for a minute and a half regardless of request.timeout_ms --
|
||||
// exactly the "blocking an OBS thread indefinitely" failure
|
||||
// testPlatformBackendTimeout exists to prevent, and the likely
|
||||
// mechanism behind that test's intermittent Windows failures.
|
||||
//
|
||||
// Caveat, also documented: this timeout "is checked only when data is
|
||||
// received from the socket", so it bounds the wait but does not
|
||||
// guarantee a hard deadline. A guaranteed deadline needs a watchdog
|
||||
// thread calling WinHttpCloseHandle; not done here.
|
||||
//
|
||||
// Guarded because the constant postdates some Windows SDK headers; a
|
||||
// toolchain without it keeps the previous (90s default) behaviour
|
||||
// rather than failing to build.
|
||||
#ifdef WINHTTP_OPTION_RECEIVE_RESPONSE_TIMEOUT
|
||||
DWORD response_timeout = timeout;
|
||||
// Return value deliberately unchecked: a rejected option leaves the
|
||||
// documented default in place, which is degraded but still correct
|
||||
// behaviour, and there is no logging sink in this layer to report it
|
||||
// to. The timeout probe in test_api_client.cpp is what would catch a
|
||||
// regression here.
|
||||
WinHttpSetOption(session.get(), WINHTTP_OPTION_RECEIVE_RESPONSE_TIMEOUT, &response_timeout,
|
||||
sizeof(response_timeout));
|
||||
#endif
|
||||
|
||||
Handle connect(WinHttpConnect(session.get(), host, parts.nPort, 0));
|
||||
if (!connect) {
|
||||
response.network_error = lastErrorMessage("WinHttpConnect");
|
||||
@@ -126,13 +237,36 @@ public:
|
||||
target += extra;
|
||||
|
||||
const DWORD flags = (parts.nScheme == INTERNET_SCHEME_HTTPS) ? WINHTTP_FLAG_SECURE : 0u;
|
||||
Handle req(WinHttpOpenRequest(connect.get(), widen(request.method).c_str(), target.c_str(), nullptr,
|
||||
WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, flags));
|
||||
if (!req) {
|
||||
HINTERNET raw_req = WinHttpOpenRequest(connect.get(), widen(request.method).c_str(), target.c_str(),
|
||||
nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES,
|
||||
flags);
|
||||
if (!raw_req) {
|
||||
response.network_error = lastErrorMessage("WinHttpOpenRequest");
|
||||
return response;
|
||||
}
|
||||
|
||||
// Ceiling at twice the caller's budget: each of the four
|
||||
// WinHttpSetTimeouts phases (resolve, connect, send, receive) is
|
||||
// allowed `timeout` on its own, so a slow-but-progressing exchange can
|
||||
// legitimately exceed one budget, and this must not cancel those. The
|
||||
// floor keeps a very small timeout_ms from producing a deadline the
|
||||
// exchange cannot meet on a cold connection.
|
||||
const DWORD deadline_ms = (timeout > 500u) ? (timeout * 2u) : 1000u;
|
||||
RequestDeadline req(raw_req, deadline_ms);
|
||||
|
||||
// From here on, `req.get()` can be closed underneath us by the
|
||||
// watchdog; every WinHttp* failure below is therefore checked against
|
||||
// req.expired() before its GetLastError text is reported, so an
|
||||
// expired deadline reads as a timeout rather than as
|
||||
// "WinHttpReceiveResponse failed (GetLastError=12017)".
|
||||
const auto fail = [&](const char *what) -> HttpResponse {
|
||||
if (req.expired())
|
||||
response.network_error = "timed out after " + std::to_string(deadline_ms) + " ms";
|
||||
else
|
||||
response.network_error = lastErrorMessage(what);
|
||||
return response;
|
||||
};
|
||||
|
||||
std::wstring headers;
|
||||
if (!request.content_type.empty())
|
||||
headers = L"Content-Type: " + widen(request.content_type) + L"\r\n";
|
||||
@@ -144,31 +278,23 @@ public:
|
||||
: const_cast<char *>(request.body.data());
|
||||
const DWORD body_len = static_cast<DWORD>(request.body.size());
|
||||
|
||||
if (!WinHttpSendRequest(req.get(), header_ptr, header_len, body_ptr, body_len, body_len, 0)) {
|
||||
response.network_error = lastErrorMessage("WinHttpSendRequest");
|
||||
return response;
|
||||
}
|
||||
if (!WinHttpReceiveResponse(req.get(), nullptr)) {
|
||||
response.network_error = lastErrorMessage("WinHttpReceiveResponse");
|
||||
return response;
|
||||
}
|
||||
if (!WinHttpSendRequest(req.get(), header_ptr, header_len, body_ptr, body_len, body_len, 0))
|
||||
return fail("WinHttpSendRequest");
|
||||
if (!WinHttpReceiveResponse(req.get(), nullptr))
|
||||
return fail("WinHttpReceiveResponse");
|
||||
|
||||
DWORD status = 0;
|
||||
DWORD status_size = sizeof(status);
|
||||
if (!WinHttpQueryHeaders(req.get(), WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
|
||||
WINHTTP_HEADER_NAME_BY_INDEX, &status, &status_size, WINHTTP_NO_HEADER_INDEX)) {
|
||||
response.network_error = lastErrorMessage("WinHttpQueryHeaders");
|
||||
return response;
|
||||
}
|
||||
WINHTTP_HEADER_NAME_BY_INDEX, &status, &status_size, WINHTTP_NO_HEADER_INDEX))
|
||||
return fail("WinHttpQueryHeaders");
|
||||
response.status = static_cast<long>(status);
|
||||
|
||||
std::string body;
|
||||
for (;;) {
|
||||
DWORD available = 0;
|
||||
if (!WinHttpQueryDataAvailable(req.get(), &available)) {
|
||||
response.network_error = lastErrorMessage("WinHttpQueryDataAvailable");
|
||||
return response;
|
||||
}
|
||||
if (!WinHttpQueryDataAvailable(req.get(), &available))
|
||||
return fail("WinHttpQueryDataAvailable");
|
||||
if (available == 0)
|
||||
break;
|
||||
if (body.size() + available > kMaxResponseBytes) {
|
||||
@@ -177,10 +303,8 @@ public:
|
||||
}
|
||||
std::vector<char> chunk(available);
|
||||
DWORD read = 0;
|
||||
if (!WinHttpReadData(req.get(), chunk.data(), available, &read)) {
|
||||
response.network_error = lastErrorMessage("WinHttpReadData");
|
||||
return response;
|
||||
}
|
||||
if (!WinHttpReadData(req.get(), chunk.data(), available, &read))
|
||||
return fail("WinHttpReadData");
|
||||
if (read == 0)
|
||||
break;
|
||||
body.append(chunk.data(), read);
|
||||
|
||||
@@ -16,6 +16,7 @@ You may obtain a copy of the License at
|
||||
// three runners rather than assumed to work.
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
@@ -422,8 +423,32 @@ void testPlatformBackendTimeout()
|
||||
{
|
||||
// A server that accepts and then stalls. The plugin must give up on its
|
||||
// own timeout rather than blocking an OBS thread indefinitely.
|
||||
sttest::LoopbackServer server([](const std::string &) {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(5));
|
||||
//
|
||||
// INSTRUMENTED (2026-09-09) while chasing an intermittent Windows-only
|
||||
// failure: on roughly 2 of 6 CI runs both assertions below fail together,
|
||||
// meaning the request waited out the full 5s stall and returned 200 --
|
||||
// the timeout did not fire at all. Same failure seen on 2026-09-07
|
||||
// (job 5834) and 2026-09-09 (job 5911), on identical code that passed on
|
||||
// other runs, so it is not a code change that caused it.
|
||||
//
|
||||
// The probe runs kProbes times and prints one line per attempt so a
|
||||
// single CI run yields a failure RATE and the WinHTTP error code, rather
|
||||
// than one bit. `ST_ASSERT` records and continues, so every attempt is
|
||||
// reported even when one fails. Remove the loop and this comment once the
|
||||
// mechanism is understood and fixed.
|
||||
constexpr int kProbes = 5;
|
||||
constexpr long long kStallMs = 5000;
|
||||
constexpr long kTimeoutMs = 700;
|
||||
|
||||
int timed_out = 0;
|
||||
for (int i = 0; i < kProbes; ++i) {
|
||||
// A FRESH server per attempt, deliberately. `LoopbackServer` accepts
|
||||
// and handles one connection at a time on a single thread, so reusing
|
||||
// one server across attempts would leave attempts 2..n sitting in the
|
||||
// accept backlog -- a different scenario (never accepted) from the one
|
||||
// that fails on Windows (accepted, request read, then stalled).
|
||||
sttest::LoopbackServer server([kStallMs](const std::string &) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(kStallMs));
|
||||
return sttest::httpResponse(200, "OK", R"({"slots":[]})");
|
||||
});
|
||||
ST_ASSERT(server.valid());
|
||||
@@ -431,13 +456,44 @@ void testPlatformBackendTimeout()
|
||||
std::shared_ptr<HttpClient> http(createPlatformHttpClient());
|
||||
HttpRequest request;
|
||||
request.url = server.baseUrl() + "/api/obs/main-room/slots?key=k";
|
||||
request.timeout_ms = 700;
|
||||
request.timeout_ms = kTimeoutMs;
|
||||
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
const HttpResponse response = http->send(request);
|
||||
const auto elapsed = std::chrono::steady_clock::now() - start;
|
||||
const long long ms = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count();
|
||||
|
||||
// 4000 was the old bound, chosen when nothing bounded the wait. The
|
||||
// code now promises a hard ceiling of 2x the caller's budget
|
||||
// (RequestDeadline in http_winhttp.cpp), so assert THAT -- 1400ms
|
||||
// here, plus slack for a loaded runner. This is also the only signal
|
||||
// that survives a green run: CTest prints nothing on success, so if
|
||||
// WinHTTP's own erratic cancellation (measured at 1490-4506ms for
|
||||
// this same 700ms budget) were doing the work instead of the
|
||||
// watchdog, roughly half the attempts would land above this bound and
|
||||
// say so, instead of quietly passing under a 4s ceiling.
|
||||
constexpr long long kCeilingMs = 2500;
|
||||
const bool gave_up = !response.ok() && ms < kCeilingMs;
|
||||
if (gave_up)
|
||||
++timed_out;
|
||||
|
||||
// Always printed, pass or fail: elapsed time and the backend's own
|
||||
// error string (which carries GetLastError on Windows) are the
|
||||
// evidence. requests_seen separates "the client never reached the
|
||||
// server" (0) from "the server read the request and the client then
|
||||
// waited it out" (1).
|
||||
std::fprintf(stderr,
|
||||
" [timeout-probe %d/%d] elapsed=%lldms ok=%d status=%ld "
|
||||
"requests_seen=%d network_error='%s' -> %s\n",
|
||||
i + 1, kProbes, ms, response.ok() ? 1 : 0, response.status,
|
||||
server.requestCount(), response.network_error.c_str(),
|
||||
gave_up ? "gave up (expected)" : "WAITED OUT THE STALL");
|
||||
|
||||
ST_ASSERT(!response.ok());
|
||||
ST_ASSERT(std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count() < 4000);
|
||||
ST_ASSERT(ms < kCeilingMs);
|
||||
}
|
||||
std::fprintf(stderr, " [timeout-probe] %d/%d attempts honoured the %ldms timeout\n",
|
||||
timed_out, kProbes, kTimeoutMs);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Reference in New Issue
Block a user