diff --git a/.github/workflows/compliance.yml b/.github/workflows/compliance.yml new file mode 100644 index 0000000..0d0a3a5 --- /dev/null +++ b/.github/workflows/compliance.yml @@ -0,0 +1,270 @@ +# Pulsar compliance gates -- étage-0 merge-gate conformance. +# +# These are the conformance checks required by the org-wide merge gate +# (docs/rules/git.md §1 + docs/rules/security.md §Détection). The build +# pipeline (pipeline.yml) proves pulsar.exe builds and broadcasts; this +# workflow proves the change is mergeable per policy: no leaked secret, +# no high/critical dependency CVE, a lockfile that is in sync, and a +# valid CODEOWNERS. +# +# Kept in a SEPARATE workflow from pipeline.yml on purpose: +# - different concern (governance vs build), different runners (all +# ubuntu, cheap + fast -- no MSVC, no Windows), +# - independent failure surface : a red secret-scan must not be tangled +# with the C++ build graph, and vice versa. Each job is its own check +# in `gh pr checks` so the reviewer sees exactly which gate broke. +# +# HARD RULE : no error-suppression anywhere (the step-skip toggle is +# banned per docs/rules/git.md). Every job here is allowed -- and intended +# -- to turn the PR red and block the merge. +# +# Trigger parity with pipeline.yml : pull_request to main fires once per +# push (the dedup reason pipeline.yml documents), plus push to main and +# tags so post-merge / release refs are re-checked. paths-ignore mirrors +# pipeline.yml: a docs-only / changelog / licence change can't introduce +# a secret-in-code, a dep CVE, a lockfile drift or a CODEOWNERS break. + +name: compliance + +on: + push: + branches: [main] + tags: + - 'v*.*.*' + paths-ignore: + - '**/*.md' + - 'docs/**' + - 'CHANGELOG.md' + - '.gitignore' + - 'LICENSE' + pull_request: + branches: [main] + paths-ignore: + - '**/*.md' + - 'docs/**' + - 'CHANGELOG.md' + - '.gitignore' + - 'LICENSE' + +concurrency: + group: compliance-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + + # ── secret-scan : trufflehog (filesystem + git history) + detect-secrets ── + # Two complementary scanners : + # - trufflehog scans the full git history of the diff range (PR : base + # ..head ; push : the pushed range) AND the working tree, with + # --only-verified so a finding is a credential that actually + # authenticates somewhere -- not an entropy false-positive. + # - detect-secrets audits the working tree against .secrets.baseline. + # Any NEW finding not already in the baseline fails the job; this is + # the entropy/keyword net trufflehog's verified-only mode skips. + # Either scanner flagging a real/new secret turns the job red. A leaked + # secret => rotate + purge (security.md), never just revert. + secret-scan: + name: secret-scan + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout (full history) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: TruffleHog (history + filesystem, verified secrets) + uses: trufflesecurity/trufflehog@main + with: + # PR : scan base..head. push : the action infers the pushed + # range from the event. Defaulting both to the repo path also + # scans the checked-out working tree. + path: ./ + base: ${{ github.event.pull_request.base.sha || github.event.before }} + head: ${{ github.event.pull_request.head.sha || github.sha }} + extra_args: --only-verified + + - name: Setup Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install detect-secrets + run: python -m pip install --upgrade "detect-secrets==1.5.0" + + - name: detect-secrets audit against baseline + # Re-scan the tree against the committed baseline, then compare ONLY + # the `results` block (the actual findings) -- NOT the whole file -- + # against what is committed. `scan --baseline` rewrites the file in + # place and always refreshes the volatile `generated_at` timestamp + # (and may reorder plugin metadata), so a raw `git diff` on the file + # would fail on the timestamp alone with zero new secrets. By diffing + # the `results` map alone we ignore that noise and fail only when a + # finding appears that is not already in the audited baseline -- i.e. + # a genuinely new secret-shaped string. To add a legitimate new + # allowlist entry: `detect-secrets scan > .secrets.baseline`, audit, + # commit. A real secret => ROTATE + purge per security.md. + run: | + set -euo pipefail + # Snapshot the committed baseline OUTSIDE the repo tree so the + # re-scan below doesn't pick the copy up as a new file. + cp .secrets.baseline "${RUNNER_TEMP}/baseline.committed.json" + detect-secrets scan --baseline .secrets.baseline + python - < rotate+purge; false positive => re-baseline.") + for fn, h in sorted(before_keys - after_keys): + print(f"::warning::baseline finding no longer present in {fn} (hash {h[:12]}...). Re-baseline to prune.") + sys.exit(1) + print("detect-secrets: no findings beyond the audited baseline.") + PY + + # ── deps-audit : npm high/critical CVE gate ────────────────────────── + # Dependency scope = npm. The repo's runtime deps are the @clodocapeo/ + # pulsar-* JS packages (package.json + package-lock.json). The Python + # side is dev-only probe glue (`websockets`), not a shipped/pinned + # dependency surface, so there is no requirements lock to pip-audit -- + # the deps gate that matters for what ships is npm. --omit=dev so the + # gate reflects what consumers actually install (dev-only advisories, + # e.g. the vitest test toolchain, do not block a merge). --audit-level + # =high : moderate/low advise but do not block; high+critical block. + deps-audit: + name: deps-audit + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: npm audit (production deps, high+critical block) + run: npm audit --omit=dev --audit-level=high + + # ── lockfile-check : package-lock.json is in sync + committed ──────── + # `npm ci` refuses to run if package.json and package-lock.json are out + # of sync, so `npm ci --dry-run` is the canonical "lockfile drift" + # detector -- it errors on any divergence or unpinned dependency without + # touching node_modules. We then assert the working tree is still clean + # so a lockfile that gets rewritten on install (e.g. a non-deterministic + # or stale lock) is caught as an uncommitted change. + lockfile-check: + name: lockfile-check + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Assert no stray yarn.lock (npm is the lock of record) + run: | + set -euo pipefail + if [ -f yarn.lock ]; then + echo "::error::yarn.lock present but Pulsar uses npm (package-lock.json) as the lock of record. Remove yarn.lock or migrate intentionally." + exit 1 + fi + echo "no yarn.lock -- npm is the sole lockfile." + + - name: npm ci --dry-run (lockfile in sync, deps pinned) + # --force : @clodocapeo/pulsar-bundle declares os:["win32"] + # cpu:["x64"], so on the ubuntu runner npm aborts with + # EBADPLATFORM before it ever checks lockfile sync. --force + # bypasses the os/cpu gate (same reason npm-publish in + # pipeline.yml uses it) WITHOUT weakening the sync check: a real + # lockfile drift surfaces as a distinct EUSAGE/"can only install + # with an up to date package-lock" error, not EBADPLATFORM. + env: + PULSAR_BUNDLE_SKIP_POSTINSTALL: '1' + run: npm ci --dry-run --force + + - name: Assert lockfile unchanged after resolution + run: | + set -euo pipefail + if ! git diff --quiet -- package-lock.json; then + echo "::error::package-lock.json drifted during resolution -- regenerate with 'npm install' and commit." + git --no-pager diff -- package-lock.json + exit 1 + fi + echo "package-lock.json is in sync and committed." + + # ── codeowners-check : CODEOWNERS exists and is syntactically valid ── + # A merge gate that relies on CODEOWNERS for review routing is only as + # good as the file being present + parseable. We assert it exists, then + # lint it with the same parser semantics GitHub uses (pattern + at least + # one @owner / team / email per non-comment line). No external network + # call -- the validation is purely structural. + codeowners-check: + name: codeowners-check + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Locate + validate CODEOWNERS + run: | + set -euo pipefail + file="" + for cand in CODEOWNERS .github/CODEOWNERS docs/CODEOWNERS; do + if [ -f "$cand" ]; then file="$cand"; break; fi + done + if [ -z "$file" ]; then + echo "::error::No CODEOWNERS found (looked in /, /.github, /docs)." + exit 1 + fi + echo "Validating $file" + # disable pathname expansion : CODEOWNERS patterns like '*' or + # '/plugins/*' must be treated literally, not glob-expanded + # against the runner's working tree by the unquoted `set --`. + set -f + fail=0 + lineno=0 + while IFS= read -r line || [ -n "$line" ]; do + lineno=$((lineno+1)) + # strip leading/trailing whitespace + trimmed="$(echo "$line" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + # skip blank + comment lines + [ -z "$trimmed" ] && continue + case "$trimmed" in \#*) continue;; esac + # a rule line is: [...] + # every token after the first must be @user, @org/team, or an email + set -- $trimmed + pattern="$1"; shift + if [ "$#" -eq 0 ]; then + echo "::error::$file:$lineno: rule '$pattern' has no owner." + fail=1; continue + fi + for owner in "$@"; do + case "$owner" in + @*/*) : ;; # @org/team + @*) : ;; # @user + *@*.*) : ;; # email + *) + echo "::error::$file:$lineno: invalid owner token '$owner' (expected @user, @org/team, or email)." + fail=1 ;; + esac + done + done < "$file" + [ "$fail" -eq 0 ] || exit 1 + echo "CODEOWNERS is syntactically valid." diff --git a/.secrets.baseline b/.secrets.baseline new file mode 100644 index 0000000..13d5edc --- /dev/null +++ b/.secrets.baseline @@ -0,0 +1,228 @@ +{ + "filters_used": [ + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "filename": ".secrets.baseline", + "path": "detect_secrets.filters.common.is_baseline_file" + }, + { + "min_level": 2, + "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies" + }, + { + "path": "detect_secrets.filters.heuristic.is_indirect_reference" + }, + { + "path": "detect_secrets.filters.heuristic.is_likely_id_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_lock_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_potential_uuid" + }, + { + "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" + }, + { + "path": "detect_secrets.filters.heuristic.is_sequential_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_swagger_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_templated_secret" + } + ], + "generated_at": "2026-06-07T23:06:38Z", + "plugins_used": [ + { + "name": "ArtifactoryDetector" + }, + { + "name": "AWSKeyDetector" + }, + { + "name": "AzureStorageKeyDetector" + }, + { + "limit": 4.5, + "name": "Base64HighEntropyString" + }, + { + "name": "BasicAuthDetector" + }, + { + "name": "CloudantDetector" + }, + { + "name": "DiscordBotTokenDetector" + }, + { + "name": "GitHubTokenDetector" + }, + { + "name": "GitLabTokenDetector" + }, + { + "limit": 3, + "name": "HexHighEntropyString" + }, + { + "name": "IbmCloudIamDetector" + }, + { + "name": "IbmCosHmacDetector" + }, + { + "name": "IPPublicDetector" + }, + { + "name": "JwtTokenDetector" + }, + { + "keyword_exclude": "", + "name": "KeywordDetector" + }, + { + "name": "MailchimpDetector" + }, + { + "name": "NpmDetector" + }, + { + "name": "OpenAIDetector" + }, + { + "name": "PrivateKeyDetector" + }, + { + "name": "PypiTokenDetector" + }, + { + "name": "SendGridDetector" + }, + { + "name": "SlackDetector" + }, + { + "name": "SoftlayerDetector" + }, + { + "name": "SquareOAuthDetector" + }, + { + "name": "StripeDetector" + }, + { + "name": "TelegramBotTokenDetector" + }, + { + "name": "TwilioKeyDetector" + } + ], + "results": { + ".github/workflows/compliance.yml": [ + { + "filename": ".github/workflows/compliance.yml", + "hashed_secret": "2ca3d07abf093c5b73e5eedc2c8c75dc7aaf321d", + "is_verified": false, + "line_number": 110, + "type": "Secret Keyword" + } + ], + "docs/DEVELOPMENT.md": [ + { + "filename": "docs/DEVELOPMENT.md", + "hashed_secret": "8cb202358b433b002379b0a492443bec0adcee99", + "is_verified": false, + "line_number": 57, + "type": "Secret Keyword" + } + ], + "packages/pulsar-bundle-full/tests/fake-pulsar.mjs": [ + { + "filename": "packages/pulsar-bundle-full/tests/fake-pulsar.mjs", + "hashed_secret": "973229c027606bc8cf2cbfb6259f856f35e6196d", + "is_verified": false, + "line_number": 26, + "type": "Secret Keyword" + } + ], + "packages/pulsar-bundle/tests/fake-pulsar.mjs": [ + { + "filename": "packages/pulsar-bundle/tests/fake-pulsar.mjs", + "hashed_secret": "973229c027606bc8cf2cbfb6259f856f35e6196d", + "is_verified": false, + "line_number": 26, + "type": "Secret Keyword" + } + ], + "plugins/pulsar-websocket/data/locale/en-US.ini": [ + { + "filename": "plugins/pulsar-websocket/data/locale/en-US.ini", + "hashed_secret": "fc4ea6ee08fe3cbc533c57da9b8f1c596f0b0f84", + "is_verified": false, + "line_number": 13, + "type": "Secret Keyword" + }, + { + "filename": "plugins/pulsar-websocket/data/locale/en-US.ini", + "hashed_secret": "3aea0bafa7d88caff43878d346fa5ec72412ca19", + "is_verified": false, + "line_number": 14, + "type": "Secret Keyword" + }, + { + "filename": "plugins/pulsar-websocket/data/locale/en-US.ini", + "hashed_secret": "983aeee5c2f5de5d44cb57e65d5d6d852c9dcdd5", + "is_verified": false, + "line_number": 20, + "type": "Secret Keyword" + }, + { + "filename": "plugins/pulsar-websocket/data/locale/en-US.ini", + "hashed_secret": "cef6e514114dc92935cfcaa8d7142bc0b1e05de8", + "is_verified": false, + "line_number": 21, + "type": "Secret Keyword" + }, + { + "filename": "plugins/pulsar-websocket/data/locale/en-US.ini", + "hashed_secret": "d933709caa7e5640a32cde0489420bca7f382240", + "is_verified": false, + "line_number": 22, + "type": "Secret Keyword" + }, + { + "filename": "plugins/pulsar-websocket/data/locale/en-US.ini", + "hashed_secret": "cfc19ad2836f9d620796126dcca64a820a470a03", + "is_verified": false, + "line_number": 23, + "type": "Secret Keyword" + }, + { + "filename": "plugins/pulsar-websocket/data/locale/en-US.ini", + "hashed_secret": "c49b578fbe8c74b0a9e38fe19264715719135c7a", + "is_verified": false, + "line_number": 24, + "type": "Secret Keyword" + } + ], + "scripts/probe-m6-live.py": [ + { + "filename": "scripts/probe-m6-live.py", + "hashed_secret": "d3caf5e1635ee7aab32349299591eac56bcd9739", + "is_verified": false, + "line_number": 130, + "type": "Base64 High Entropy String" + } + ] + }, + "version": "1.5.0" +} diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 8ad8cc5..6962e06 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -9,7 +9,7 @@ hand for debugging. | | | |---|---| | OS | Windows 10/11 x64 | -| Compiler | Visual Studio 2022 Build Tools — workload "Desktop development with C++" + Windows 11 SDK | +| Compiler | Visual Studio 2022 Build Tools — workload "Desktop development with C++" + Windows 11 SDK. The optional "C++ ATL" component (`Microsoft.VisualStudio.Component.VC.ATL`) is **not** required for the headless path but enables `obs-qsv11` / `win-dshow` locally (see [ATL runbook](runbooks/atl-missing-build-failure.md)). | | CMake | 3.28+ | | Generator | Visual Studio 17 2022 (default) or Ninja | | Yarn | 4.x via Corepack (used by upstream's build scripts) | @@ -192,6 +192,15 @@ matching binary. Bump it, commit, tag. ## Troubleshooting +### `error C1083: Cannot open include file: 'atlbase.h'` (or `atlcomcli.h` / `atlstr.h`) + +ATL headers missing — the "C++ ATL" VS component is not installed on this machine. +`scripts/build-win.ps1` detects this automatically and skips the three affected +plugins (`obs-qsv11`, `win-dshow`, `virtualcam-module`) with `PULSAR_HAVE_ATL=OFF`. +If you are invoking CMake directly (bypassing the script) the build will fail. +Full diagnosis, gate mechanics, rollback, and optional ATL install instructions: +[docs/runbooks/atl-missing-build-failure.md](runbooks/atl-missing-build-failure.md). + ### `pulsar.exe did not signal ready within 30000ms` The most common cause is `cwd` being wrong — libobs cannot find diff --git a/docs/adr/001-build-atl-gate-and-ci-compliance.md b/docs/adr/001-build-atl-gate-and-ci-compliance.md new file mode 100644 index 0000000..24c8576 --- /dev/null +++ b/docs/adr/001-build-atl-gate-and-ci-compliance.md @@ -0,0 +1,191 @@ +# ADR 001 — ATL build-gate & CI compliance gates + +- **Status**: accepted +- **Date**: 2026-06-08 +- **Decided**: 2026-06-08 +- **Deciders**: @ClodoCapeo (maintainer) +- **Author**: Atlas (architect agent) +- **Supersedes**: — +- **Superseded by**: — + +--- + +## 1. Context + +Pulsar is a hard fork / patched build of OBS Studio that produces `pulsar.exe` +(see `scripts/build-win.ps1`, `patches/0001-*`, `patches/0002-*`). PR #43 +(`forge/pulsar-twitch-scene-switch`) brought the Windows build green on the +`windows-2022` CI runner, wired the org-wide étage-0 merge gate +(`docs/rules/git.md §1`, `docs/rules/security.md §Détection`) into the repo, and +shipped a 30-second live broadcast smoke test for Twitch scene switching. + +Three structural build/CI decisions were taken during that work and need to be +recorded. This is the **first ADR of the Pulsar repo** — it also sets the house +ADR format for the repo (numbered sections, `proposed` status set by Atlas, +flipped to `accepted` by Vigil). + +The decisions below are **recorded, not re-arbitrated**: they were made and +landed in PR #43. This ADR makes them traceable and carries the residual-risk +ledger that Bastion cleared. + +## 2. Decision drivers + +- **CI must stay green and unchanged for the canonical build.** The default path + (full plugin set, ATL present) must behave exactly as upstream OBS + Pulsar + patches did before PR #43. +- **A build on a machine without the VS2022 "C++ ATL" workload must not silently + produce a different binary.** Either it degrades explicitly, or it fails loud — + never an undetected partial build. +- **The étage-0 merge gate is non-negotiable.** `docs/rules/git.md §1` requires + secret scanning, dependency audit, lockfile check and CODEOWNERS check as + blocking CI, with no `continue-on-error`. +- **Single responsibility per failure surface.** Governance checks must not be + tangled with the C++ build graph so a reviewer reads `gh pr checks` cleanly. +- **The live smoke test must prove the broadcast path end-to-end** even where the + headless OBS build constrains what scene primitives are available. + +## 3. Decision + +### 3.1 Conditional ATL build-gate (`PULSAR_HAVE_ATL`, default ON) + +`scripts/build-win.ps1` detects ATL availability (via `vswhere` plus a probe of +the three `atlmfc/include` headers) and injects the CMake flag accordingly: + +| Condition | Flag injected | Plugin set | +|---|---|---| +| ATL present (CI `windows-2022`, canonical dev box) | `-DPULSAR_HAVE_ATL=ON` | full — identical to upstream/CI before PR #43 | +| ATL absent | `-DPULSAR_HAVE_ATL=OFF` | `obs-qsv11`, `win-dshow`, `virtualcam` excluded by `patches/0002-*` | + +The default is **ON**, so CI and the canonical dev environment build the full +plugin set unchanged. Only an ATL-less box takes the reduced path. + +**Hardening:** when running under CI (`$env:GITHUB_ACTIONS` / `$env:CI`), a +missing ATL is a `throw` (red build), **not** a warning. CI is contractually an +ATL-present environment; ATL absence there means the toolchain regressed and the +run must fail rather than silently ship a reduced binary. The operator-facing +diagnosis and recovery for the local (non-CI) failure is documented in +`docs/runbooks/atl-missing-build-failure.md`. + +### 3.2 CI compliance gates (`.github/workflows/compliance.yml`) + +A workflow **separate** from `pipeline.yml` (governance vs build — different +concern, cheaper ubuntu runners, independent failure surface) carries the +étage-0 merge gate as four blocking jobs, plus the ownership file: + +| Job | Check | Source rule | +|---|---|---| +| `secret-scan` | trufflehog (fs + git history) + detect-secrets against `.secrets.baseline` | `security.md §Détection` | +| `deps-audit` | `npm audit --omit=dev --audit-level=high` | `git.md §1`, `security.md §Détection` | +| `lockfile-check` | `package-lock.json` in sync | `git.md §1` | +| `codeowners-check` | `.github/CODEOWNERS` valid | `git.md §1` | + +Plus `.github/CODEOWNERS` itself (maintainer-only on governance/CI/licence paths, +catch-all elsewhere). **No `continue-on-error` anywhere** — every job is allowed +and intended to turn the PR red and block the merge, per `git.md`. + +### 3.3 Scene-switch live test uses URL-swap (not OBS multi-scene) + +The 30-second live broadcast smoke test switches "scenes" via a **URL-swap +fallback** (`scripts/live-test/scene-a.html` ↔ `scene-b.html`) rather than real +OBS multi-scene orchestration. The headless build **declines `CreateScene` +(returns code 204)** over obs-websocket, so true multi-scene OBS switching is not +available in the headless context. The URL-swap proves the broadcast path +(encode → ingest → Twitch) end-to-end without depending on a scene primitive the +headless build refuses. The underlying multi-scene limitation is a **deferred +architecture item** — see §5 / Deferred, not resolved here. + +## 4. Consequences + +- **Canonical build unchanged.** ATL present → full plugin set, byte-for-byte the + same flags as before PR #43; CI behaviour is unchanged. +- **Reduced builds are explicit and reproducible.** An ATL-less box gets a + documented, flagged subset (`obs-qsv11` / `win-dshow` / `virtualcam` excluded), + never a silent partial binary; CI can never accidentally ship that subset + (it `throw`s instead). +- **Merge gate is enforced in-repo.** Every PR to `main` runs the four compliance + jobs; a leaked secret, a `high`+ npm CVE, a lockfile drift or a broken + CODEOWNERS each independently blocks the merge. +- **Build vs governance failures are decoupled.** A red `secret-scan` does not + entangle the C++ build graph and vice versa; the reviewer sees exactly which + gate broke. +- **Live test is honest about its scope.** It proves the broadcast pipeline, not + OBS scene graph manipulation — which is correctly flagged as future work. + +## 5. Risks + +### Residual risks accepted (Bastion clearance) + +- **R2 — deps-audit is npm-only.** The Python probes (`websockets`) are dev-only, + not shipped, and have no pinned lockfile, so they sit outside `pip-audit` by + choice. **Guard:** as soon as a `requirements.txt` / `uv.lock` is committed to + this repo, a `pip-audit` job MUST be added to `compliance.yml`. Assumed blind + spot until then. +- **R3 — `trufflehog --only-verified` + baseline dependency.** Verified-only + trufflehog catches credentials it can validate against a live service; + non-verifiable secrets are only caught by detect-secrets against + `.secrets.baseline`. **Guard:** every future addition to `.secrets.baseline` + MUST be re-audited (Bastion clearance) before commit — the baseline is a + suppression surface and must not grow unreviewed. + +### Resolved during this work + +- **R1 — `ws@8.20.0`** (GHSA-58qx-3vcg-4xpx, moderate, CVSS 4.4, memory + info-disclosure). Below the `high` threshold of the `deps-audit` gate, so it + would not have blocked the merge. **Resolved** by bumping to `ws@8.20.1` + (parallel Forge commit, Refs #43); `package-lock.json` now pins `ws@8.20.1`. + +### Deferred (no decision taken here) + +- **Headless multi-scene limitation.** The headless OBS build declines + `CreateScene` (code 204), so there is no real OBS multi-scene switching in the + headless context — only the URL-swap fallback (§3.3). This is **traced, not + decided**: if/when true multi-scene OBS switching becomes a requirement, it + warrants its own ADR (root-cause the 204 — headless module load order, plugin + set, or a `PULSAR_HAVE_ATL=OFF` interaction — and choose a path). Not in scope + for ADR-001. + +> Security-classed risks are owned by Bastion. R2 and R3 above are accepted with +> their guards as written by Bastion; no further security risk is opened by this +> ADR. + +## 6. Resolution criteria + +1. `compliance.yml` runs on every PR to `main` with the four jobs + (`secret-scan`, `deps-audit`, `lockfile-check`, `codeowners-check`) and **no + `continue-on-error`**; each can independently red the PR. +2. The canonical build (ATL present, CI `windows-2022`) produces the full plugin + set, with flags identical to pre-PR-#43. +3. An ATL-absent build emits `-DPULSAR_HAVE_ATL=OFF`, excludes the three + ATL-dependent plugins via `patches/0002-*`, and — **under CI only** — `throw`s + instead of warning. +4. `package-lock.json` pins `ws@8.20.1` (R1 closed); `npm audit --omit=dev + --audit-level=high` is clean. +5. `.github/CODEOWNERS` exists and passes `codeowners-check`. +6. The deferred multi-scene item is recorded (this §5) and not silently treated + as done. + +## 7. Alternatives considered (rejected) + +- **No ATL gate — require the C++ ATL workload unconditionally.** Rejected: hard + toolchain dependency that breaks any contributor box lacking the workload, with + a cryptic C1083 instead of a flagged, documented degradation. +- **ATL absent = warning everywhere (incl. CI).** Rejected: CI would silently + ship a reduced binary on a toolchain regression. CI is contractually + ATL-present, so absence there is an error (`throw`), not a warning. +- **One CI workflow for build + governance.** Rejected: tangles a red secret-scan + with the C++ build graph, slows governance behind MSVC/Windows runners, and + muddies `gh pr checks`. Split into `pipeline.yml` (build) + `compliance.yml` + (governance). +- **Implement real OBS multi-scene for the live test.** Rejected for PR #43: the + headless build declines `CreateScene` (204); resolving it is out of scope and + deferred to a future ADR (§5). URL-swap proves the broadcast path today. + +## 8. References + +- PR #43 — `forge/pulsar-twitch-scene-switch`. +- `scripts/build-win.ps1` — ATL detection + flag injection. +- `patches/0002-build-gate-ATL-dependent-plugins-behind-PULSAR_HAVE_ATL.patch`. +- `.github/workflows/compliance.yml`, `.github/CODEOWNERS`, `.secrets.baseline`. +- `docs/runbooks/atl-missing-build-failure.md` — local ATL-missing recovery. +- `docs/rules/git.md §1` (merge gate), `docs/rules/security.md §Détection` + (secret scanning / deps audit). diff --git a/docs/runbooks/atl-missing-build-failure.md b/docs/runbooks/atl-missing-build-failure.md new file mode 100644 index 0000000..d306481 --- /dev/null +++ b/docs/runbooks/atl-missing-build-failure.md @@ -0,0 +1,157 @@ +# Runbook — Build failure: ATL headers missing (C1083) + +**Applies to:** local Windows build without the VS2022 "C++ ATL" workload. +**Fixed by:** commit `d634d35` (PR #43, `patches/0002`, `scripts/build-win.ps1`). + +--- + +## Symptom + +`cmake --build` fails on one or more of: + +``` +error C1083: Cannot open include file: 'atlbase.h': No such file or directory +error C1083: Cannot open include file: 'atlcomcli.h': No such file or directory +error C1083: Cannot open include file: 'atlstr.h': No such file or directory +``` + +Faulting plugins: `obs-qsv11`, `win-dshow`, `virtualcam-module` (a sub-target of `win-dshow`). +The error appears during the CMake build step, not during configure. + +--- + +## Diagnostic + +ATL headers live under the MSVC toolset, not the Windows SDK: + +``` +\VC\Tools\MSVC\\atlmfc\include\ +``` + +Check whether the directory exists and contains the three headers: + +```powershell +$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" ` + -products '*' -property installationPath | Select-Object -First 1 +$msvcRoot = Join-Path $vs "VC\Tools\MSVC" +Get-ChildItem $msvcRoot | ForEach-Object { + $atl = Join-Path $_.FullName "atlmfc\include\atlbase.h" + [PSCustomObject]@{ Toolset = $_.Name; AtlPresent = (Test-Path $atl) } +} +``` + +If every row shows `AtlPresent = False`, ATL is not installed. This is the root cause. + +`scripts/build-win.ps1` runs `Test-AtlAvailable` automatically and emits: + +``` +WARNING: ATL not found -> skipping qsv11/virtualcam/win-dshow ; headless browser_source path unaffected +``` + +If you see that warning and the build succeeded anyway, the gate fired correctly — you are in the OFF branch (see below). If you see the warning and the build *still* fails with C1083, the gate was not applied — confirm you are running `scripts/build-win.ps1` and not invoking CMake directly. + +--- + +## Root cause + +The MSVC "C++ ATL" component (`Microsoft.VisualStudio.Component.VC.ATL`) is **not** part of the default "Desktop development with C++" workload install. It is an optional component. The CI runner (`windows-2022`) has it; a freshly installed VS2022 Build Tools box typically does not. + +`obs-qsv11`, `win-dshow`, and `win-dshow`'s `virtualcam-module` include ATL headers unconditionally in upstream. Installing ATL requires an elevated UAC prompt (VS installer) that cannot be satisfied in a non-interactive build context. + +--- + +## Fix — the `PULSAR_HAVE_ATL` gate (commit `d634d35`) + +The fix is already in place. Two artifacts implement it: + +**`patches/0002-build-gate-ATL-dependent-plugins-behind-PULSAR_HAVE_ATL.patch`** +Wraps the three plugin registrations in `plugins/CMakeLists.txt` behind +`if(PULSAR_HAVE_ATL) ... else()`. The `else()` branch registers each plugin +as a disabled stub via `target_disable()` so CMake reports them in +`OBS_MODULES_DISABLED` instead of failing configure. The CMake option +defaults to `ON`. + +**`scripts/build-win.ps1` — `Test-AtlAvailable` function** +Runs before the configure step. Uses `vswhere` to enumerate installed VS +instances, then checks each MSVC toolset's `atlmfc\include\` for all three +headers. Falls back to a fixed list of common VS install paths if `vswhere` +is unavailable. Outcome: + +| Detection result | Flag injected | Effect | +|---|---|---| +| ATL headers found | `-DPULSAR_HAVE_ATL=ON` (or none — same as default) | All three plugins build normally | +| ATL headers absent | `-DPULSAR_HAVE_ATL=OFF` | Three plugins registered as disabled stubs; build continues | + +Because the option defaults `ON`, CI builds (`windows-2022`) never receive the +flag and build everything exactly as upstream — no coverage regression. + +--- + +## Verification (after the gate fires) + +After a local build with ATL absent: + +1. `scripts/build-win.ps1` exits 0. +2. `upstream/build_x64/rundir/RelWithDebInfo/obs-plugins/64bit/` contains + `pulsar-browser.dll` and the encoder/capture plugins but **not** + `obs-qsv11.dll`, `win-dshow.dll`, or `win-dshow-virtualcam.dll`. +3. `pulsar.exe` starts and prints the `PULSAR_READY` sentinel. +4. The offline probe suite passes — `obs-qsv11`, `win-dshow`, and + `virtualcam-module` are not exercised by any probe (none are on the + headless `browser_source` → x264/nvenc → CEF path). + +--- + +## Rollback + +To revert to unconditional build of all plugins (equivalent to upstream +before this fix), pass the flag explicitly: + +```powershell +.\scripts\build-win.ps1 -CMakeArgs @("-DPULSAR_HAVE_ATL=ON") +``` + +This forces the ON branch regardless of detection. If ATL is genuinely +absent the build will fail with C1083 — which is the correct signal that +the toolchain is incomplete for a full build. + +Alternatively, remove the `-DPULSAR_HAVE_ATL=OFF` injection from +`Test-AtlAvailable` in `scripts/build-win.ps1` to restore the old +unconditional behavior (not recommended — reintroduces the original breakage). + +--- + +## Restoring local ↔ CI parity (optional) + +To build all three plugins locally — matching CI exactly — install the ATL component: + +**Via VS Installer (GUI):** +Open "Visual Studio Installer" → Modify your VS2022 Build Tools installation → +Individual components → search "ATL" → check +"C++ ATL for latest v143 build tools (x86 & x64)" → Modify. + +**Via winget / `vs_buildtools.exe` (elevated PowerShell):** + +```powershell +winget install Microsoft.VisualStudio.2022.BuildTools --override "--add Microsoft.VisualStudio.Component.VC.ATL --quiet --wait" +``` + +After install, `Test-AtlAvailable` will return `$true` on the next build and the three plugins will compile. No code change needed. + +--- + +## Local ↔ CI asymmetry note + +| | Local (ATL absent) | CI (`windows-2022`) | +|---|---|---| +| `obs-qsv11` | Disabled stub | Built | +| `win-dshow` | Disabled stub | Built | +| `virtualcam-module` | Disabled stub | Built | +| `pulsar.exe` | Built, fully functional | Built, fully functional | +| Probe suite | Passes (those plugins untested) | Passes | +| Headless live path | Unaffected | Unaffected | + +The asymmetry is intentional and safe for Pulsar's use case. The three +skipped plugins are capture/encode peripherals not required by the headless +`browser_source` path. If a future feature requires QSV hardware encode or +DirectShow capture in local development, install ATL (see above). diff --git a/package-lock.json b/package-lock.json index 1588309..c6303b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1537,9 +1537,9 @@ } }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/patches/0002-build-gate-ATL-dependent-plugins-behind-PULSAR_HAVE_ATL.patch b/patches/0002-build-gate-ATL-dependent-plugins-behind-PULSAR_HAVE_ATL.patch new file mode 100644 index 0000000..737d739 --- /dev/null +++ b/patches/0002-build-gate-ATL-dependent-plugins-behind-PULSAR_HAVE_ATL.patch @@ -0,0 +1,92 @@ +From baec3cbb38db3aeb74741f90cb431e49e8bbe765 Mon Sep 17 00:00:00 2001 +From: Keeper +Date: Mon, 8 Jun 2026 00:05:20 +0200 +Subject: build: gate ATL-dependent plugins behind PULSAR_HAVE_ATL + +obs-qsv11, win-dshow, and win-dshow's virtualcam-module compile sources +that require the MSVC ATL component (atlbase.h / atlcomcli.h / atlstr.h). +That component is present on the CI windows-2022 runner but absent on +toolchains where the ATL workload was not installed. + +Replace the previous unconditional skip with a CMake option, +PULSAR_HAVE_ATL, that defaults ON. With the default (CI), all three +plugins build exactly as upstream does -- no coverage regression. The +Pulsar build script (scripts/build-win.ps1) probes the toolchain for the +ATL headers and passes -DPULSAR_HAVE_ATL=OFF only when they are missing, +in which case the three plugins register as disabled stubs +(OBS_MODULES_DISABLED) instead of failing configure. + +None of these three plugins are on the headless browser_source live path +(x264/nvenc encode + CEF browser source), so the OFF branch has zero +functional impact on the Pulsar headless probe. + +Pulsar-Patch: 0002 +Upstream-Candidate: no (toolchain-conditional build gate) +--- + plugins/CMakeLists.txt | 38 ++++++++++++++++++++++++++++++++------ + 1 file changed, 32 insertions(+), 6 deletions(-) + +diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt +index c12f015c8..24afeac44 100644 +--- a/plugins/CMakeLists.txt ++++ b/plugins/CMakeLists.txt +@@ -2,6 +2,18 @@ cmake_minimum_required(VERSION 3.28...3.30) + + option(ENABLE_PLUGINS "Enable building OBS plugins" ON) + ++# PULSAR: PULSAR_HAVE_ATL gates the three plugins that compile sources ++# requiring the MSVC ATL component (atlbase.h / atlcomcli.h / atlstr.h): ++# obs-qsv11, win-dshow and its virtualcam-module. Default ON so any build ++# with ATL present (the CI windows runner) compiles them exactly as ++# upstream does. scripts/build-win.ps1 probes the toolchain for the ATL ++# headers and passes -DPULSAR_HAVE_ATL=OFF only when they are absent ++# (local dev toolchain without the ATL component). None of these three ++# plugins are on the headless browser_source live path (x264/nvenc encode ++# + CEF browser source), so disabling them has zero functional impact ++# there. ++option(PULSAR_HAVE_ATL "Build ATL-dependent plugins (qsv11, win-dshow, virtualcam)" ON) ++ + if(NOT ENABLE_PLUGINS) + set_property(GLOBAL APPEND PROPERTY OBS_FEATURES_DISABLED "Plugin Support") + return() +@@ -63,11 +75,18 @@ add_obs_plugin(obs-filters) + add_obs_plugin(obs-libfdk) + add_obs_plugin(obs-nvenc PLATFORMS WINDOWS LINUX ARCHITECTURES x64 x86_64) + add_obs_plugin(obs-outputs) +-add_obs_plugin( +- obs-qsv11 +- PLATFORMS WINDOWS LINUX +- ARCHITECTURES x64 x86_64 +-) ++if(PULSAR_HAVE_ATL) ++ add_obs_plugin( ++ obs-qsv11 ++ PLATFORMS WINDOWS LINUX ++ ARCHITECTURES x64 x86_64 ++ ) ++else() ++ # PULSAR: ATL absent -- obs-qsv11 needs atlbase.h. Register a disabled ++ # stub so OBS_MODULES_DISABLED reports it instead of failing configure. ++ add_custom_target(obs-qsv11) ++ target_disable(obs-qsv11) ++endif() + add_obs_plugin(obs-text PLATFORMS WINDOWS) + add_obs_plugin(obs-transitions) + add_obs_plugin( +@@ -86,5 +105,12 @@ add_obs_plugin(sndio PLATFORMS LINUX FREEBSD OPENBSD) + add_obs_plugin(text-freetype2) + add_obs_plugin(vlc-video WITH_MESSAGE) + add_obs_plugin(win-capture PLATFORMS WINDOWS) +-add_obs_plugin(win-dshow PLATFORMS WINDOWS) ++if(PULSAR_HAVE_ATL) ++ add_obs_plugin(win-dshow PLATFORMS WINDOWS) ++else() ++ # PULSAR: ATL absent -- win-dshow pulls ATL via the Elgato ++ # capture-device-support sources and its virtualcam-module subdir. ++ add_custom_target(win-dshow) ++ target_disable(win-dshow) ++endif() + add_obs_plugin(win-wasapi PLATFORMS WINDOWS) +-- +2.54.0.windows.1 + diff --git a/scripts/build-win.ps1 b/scripts/build-win.ps1 index dbe67db..dfff313 100644 --- a/scripts/build-win.ps1 +++ b/scripts/build-win.ps1 @@ -98,6 +98,85 @@ Write-Host "Using cmake: $cmake" Write-Host "Preset: $preset" Write-Host "Source: $upstream" +# --- ATL detection --------------------------------------------------------- +# +# Three upstream plugins -- obs-qsv11, win-dshow and its virtualcam-module +# -- compile sources that require the MSVC ATL component (atlbase.h / +# atlcomcli.h / atlstr.h). ATL ships as a separate Visual Studio +# workload/component under the MSVC toolset (VC/Tools/MSVC//atlmfc/ +# include/), NOT with the Windows SDK. The CI windows-2022 runner has it; +# a dev box without the "C++ ATL" component does not. +# +# We probe every installed VS instance via vswhere, then check each MSVC +# toolset's atlmfc/include for the three headers. If found -> full build +# (PULSAR_HAVE_ATL=ON, identical to upstream / CI). If not -> we pass +# -DPULSAR_HAVE_ATL=OFF so patch 0002 registers those three plugins as +# disabled stubs instead of failing configure on the missing headers. +# +# None of the three are on the headless browser_source live path +# (x264/nvenc encode + CEF browser source), so the OFF branch has zero +# functional impact on the Pulsar probe. +function Test-AtlAvailable { + $atlHeaders = @('atlbase.h', 'atlcomcli.h', 'atlstr.h') + + # Candidate VS installation roots. Prefer vswhere (authoritative); + # fall back to common fixed paths if vswhere is unavailable. + $vsRoots = @() + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + if (Test-Path $vswhere) { + $found = & $vswhere -products '*' -property installationPath 2>$null + if ($LASTEXITCODE -eq 0 -and $found) { + $vsRoots += @($found | Where-Object { $_ -and (Test-Path $_) }) + } + } + if ($vsRoots.Count -eq 0) { + foreach ($p in @( + 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise', + 'C:\Program Files\Microsoft Visual Studio\2022\Professional', + 'C:\Program Files\Microsoft Visual Studio\2022\Community', + 'C:\Program Files\Microsoft Visual Studio\2022\BuildTools', + 'C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools' + )) { + if (Test-Path $p) { $vsRoots += $p } + } + } + + foreach ($vsRoot in $vsRoots) { + $msvcRoot = Join-Path $vsRoot 'VC\Tools\MSVC' + if (-not (Test-Path $msvcRoot)) { continue } + $toolsets = Get-ChildItem $msvcRoot -Directory -ErrorAction SilentlyContinue + foreach ($ts in $toolsets) { + $atlInclude = Join-Path $ts.FullName 'atlmfc\include' + if (-not (Test-Path $atlInclude)) { continue } + $allPresent = $true + foreach ($h in $atlHeaders) { + if (-not (Test-Path (Join-Path $atlInclude $h))) { $allPresent = $false; break } + } + if ($allPresent) { + Write-Host "ATL headers found: $atlInclude" + return $true + } + } + } + return $false +} + +$haveAtl = Test-AtlAvailable +if (-not $haveAtl) { + # CI must build the full plugin set (windows-2022 ships the C++ ATL + # component), so a missing-ATL result on a CI runner is NOT a benign + # "dev box without ATL" -- it is a detection false-negative or a + # broken runner image. Silently dropping to the OFF branch there would + # amputate qsv11/virtualcam/win-dshow from the build and ship a thinner + # binary while CI stays green. Fail loud instead: turn the CI red so the + # gap is visible rather than masked. Locally (no GITHUB_ACTIONS / CI), + # keep the warning + skip -- that path is the intended dev convenience. + if ($env:GITHUB_ACTIONS -or $env:CI) { + throw "ATL not found in a CI context (GITHUB_ACTIONS/CI set). CI runners must have the MSVC 'C++ ATL' component so qsv11/virtualcam/win-dshow build. This is a detection false-negative or a broken runner image, not an expected skip -- failing the build instead of silently dropping plugins. See docs/runbooks/atl-missing-build-failure.md." + } + Write-Warning "ATL not found -> skipping qsv11/virtualcam/win-dshow ; headless browser_source path unaffected" +} + # --- Apply Pulsar patches onto upstream/ ----------------------------------- # # Reset upstream/ to the SHA recorded by Pulsar's submodule pointer, then @@ -186,6 +265,15 @@ if ($Stage -in @('configure', 'all')) { # ENABLE_BROWSER - default OFF in obs-browser, but the windows-x64 # preset forces it ON in cacheVariables. Override. $extraArgs = @() + # ATL gate (see Test-AtlAvailable). Default ON in the patched + # CMakeLists, so we only ever need to force OFF; passing ON + # explicitly when ATL is present keeps the cache value unambiguous + # across re-configures. + if ($haveAtl) { + $extraArgs += '-DPULSAR_HAVE_ATL=ON' + } else { + $extraArgs += '-DPULSAR_HAVE_ATL=OFF' + } if (-not $GuiBuild) { $extraArgs += '-DENABLE_FRONTEND=OFF' $extraArgs += '-DENABLE_UI=OFF' diff --git a/scripts/live-test/scene-a.html b/scripts/live-test/scene-a.html new file mode 100644 index 0000000..dea3bd4 --- /dev/null +++ b/scripts/live-test/scene-a.html @@ -0,0 +1,59 @@ + + + + +Pulsar — SCENE A + + + + +
+
SCENE A
+
COBALT · PULSAR
+
LIVE
+
+ + diff --git a/scripts/live-test/scene-b.html b/scripts/live-test/scene-b.html new file mode 100644 index 0000000..fc4eec9 --- /dev/null +++ b/scripts/live-test/scene-b.html @@ -0,0 +1,55 @@ + + + + +Pulsar — SCENE B + + + + +
+
SCENE B
+
CRIMSON · PULSAR
+
LIVE
+
+ + diff --git a/scripts/probe-twitch-live.py b/scripts/probe-twitch-live.py index 51d0a83..3320bed 100644 --- a/scripts/probe-twitch-live.py +++ b/scripts/probe-twitch-live.py @@ -88,6 +88,21 @@ POLL_INTERVAL_SEC = 5.0 DESTINATION_NAME = "pulsar-live-test" +# StartDestination can race the engine boot : the frontend streaming +# output is wired asynchronously after pulsar.exe spawns, and a probe +# that reaches StartDestination within a few seconds of boot can hit a +# transient `frontend streaming output unavailable` before the output +# exists. This is a boot-ordering race, not a broadcast failure (same +# binary/key passes on retry). We poll StartDestination for a bounded +# budget, but ONLY while the error is exactly that transient string — +# any other error (bad key, RTMP reject, etc.) fails immediately, and +# exhausting the budget is a hard failure. No masking : a genuinely +# broken streaming path never produces this exact transient and would +# still fail. +START_DEST_BOOT_ERROR = "frontend streaming output unavailable" +START_DEST_RETRY_BUDGET = 20.0 # seconds to wait out the boot race +START_DEST_RETRY_DELAY = 1.0 # poll cadence between attempts + # Benign log substrings that do not constitute failure. BENIGN_LOG_SUBSTRINGS = [ "no target (set PULSAR_CAPTURE_WINDOW)", # frontend-stub default boot warning @@ -616,17 +631,40 @@ async def probe(stream_key: str, duration_sec: int, fps: int) -> int: return 1 print(f"[live-test] destination created : id={dest_id}") - # 3. StartDestination. - r = await vendor_call(ws, inbox, "start-dest", "pulsar", - "StartDestination", {"id": dest_id}) - dump_response("start-dest", r) - sd = vendor_response_data(r) - if not sd.get("started"): + # 3. StartDestination — poll out the boot race (see + # START_DEST_BOOT_ERROR note above). Only the exact + # transient boot error is retried ; everything else fails + # on the first attempt. + deadline = time.time() + START_DEST_RETRY_BUDGET + attempt = 0 + while True: + attempt += 1 + r = await vendor_call(ws, inbox, f"start-dest-{attempt}", + "pulsar", "StartDestination", {"id": dest_id}) + sd = vendor_response_data(r) + if sd.get("started"): + break + err = str(sd.get("error", "")) + transient = (err == START_DEST_BOOT_ERROR) + if transient and time.time() < deadline: + print(f"[live-test] start-dest attempt #{attempt} : " + f"streaming output not ready yet " + f"('{err}'), retrying in {START_DEST_RETRY_DELAY}s") + await asyncio.sleep(START_DEST_RETRY_DELAY) + continue + # Either a non-transient error, or the boot race never + # cleared within budget — both are hard failures. + dump_response("start-dest", r) status = vendor_request_status(r) + reason = ("boot race unresolved after " + f"{START_DEST_RETRY_BUDGET}s ({attempt} attempts)" + if transient else "not started") fail_log("start-dest", - f"not started ; requestStatus={status} responseData={sd}") + f"{reason} ; requestStatus={status} responseData={sd}") return 1 - print(f"[live-test] destination STARTED -- going live") + dump_response("start-dest", r) + print(f"[live-test] destination STARTED -- going live " + f"(attempt #{attempt})") # 3b. StartRecord -- record the broadcast locally so the CI # workflow can upload the MP4 as the live-test proof. Standard diff --git a/scripts/probe-twitch-scene-switch.py b/scripts/probe-twitch-scene-switch.py new file mode 100644 index 0000000..53c5bec --- /dev/null +++ b/scripts/probe-twitch-scene-switch.py @@ -0,0 +1,856 @@ +#!/usr/bin/env python3 +""" +Pulsar live Twitch SCENE-SWITCH probe -- a 30s broadcast with a real scene +change at mid-course (~t=15s) and ZERO PC sound. + +WHAT THIS PROVES + A live Twitch push that, at duration/2, performs a genuine scene change so a + reviewer scrubbing the VOD sees a hard cut (cobalt-blue "SCENE A" -> crimson + "SCENE B"). Two visually-distinct, dependency-free, SILENT HTML pages are + served locally (scripts/live-test/scene-a.html + scene-b.html) and rendered + by Pulsar's CEF browser_source. + +SWITCH IMPLEMENTATION -- (a) real OBS scene, with (b) auto-fallback + The headless frontend-stub (plugins/pulsar-frontend-stub) creates exactly ONE + program scene at boot ("Default") and its `scenes` vector is hard-coded to a + single entry. BUT: + * obs-websocket's CreateScene uses obs_canvas_scene_create on the MAIN + canvas, registering the scene in libobs's GLOBAL source table. + * SetCurrentProgramScene -> AcquireScene -> obs_get_source_by_name resolves + by libobs-global name (NOT via the stub's scenes vector), so a + CreateScene'd scene IS found. + * The stub's obs_frontend_set_current_scene rebinds obs_set_output_source(0, + scene) and emits SCENE_CHANGED -> the new scene actually composites. + So a TRUE two-scene switch is viable headless and is attempted FIRST (path a): + 1. CreateScene("pulsar-scene-b"). + 2. SetCurrentProgramScene("pulsar-scene-b"); SetCaptureSource(scene-b.html) + -- the pulsar-scene plugin installs the browser_source on whatever + obs_frontend_get_current_scene() returns, i.e. scene-b. + 3. SetCurrentProgramScene("Default" / scene-a); SetCaptureSource(scene-a.html) + -- scene-a now carries the cobalt page; we go live on it. + 4. At t=duration/2: SetCurrentProgramScene("pulsar-scene-b") -> hard cut to + crimson. Asserted via GetCurrentProgramScene before AND after. + If CreateScene or the program-scene flip is not honoured by this build + (older stub, single-canvas quirk), the probe AUTO-FALLS-BACK to path (b): + a single program scene whose displayed browser_source URL is swapped from + scene-a.html to scene-b.html via SetCaptureSource at t=duration/2. Path (b) + is still a real, eye-visible content change on the live wire; it is clearly + labelled in the run log + the run summary as a fallback. The chosen path is + reported so Vigil/Probe know which assertion ran. + +ZERO PC SOUND (impératif) + "No sound from the PC" is enforced on three fronts: + 1. Spawn env: PULSAR_MIC_DEVICE_ID popped (mic source never created) and + PULSAR_PROCESS_AUDIO_NAME left unset (process-loopback never created). + 2. The browser_source is created with reroute_audio=False -- the page audio + (there is none anyway; both scene pages are silent) is NOT routed into + the OBS mixer. + 3. The frontend-stub ALWAYS creates a desktop-audio loopback on mixer + channel 1 ("PulsarDesktopAudio", device_id=default) at boot -- THAT is + literally "the sound of the PC". The probe SetInputMute()s it right after + auth, verifies the mute via GetInputMute, then enumerates GetInputList + + GetInputMute over every audio input and ASSERTS none remains unmuted + before going live. If any unmuted audio input survives, the probe fails + closed and does NOT broadcast. + Net: the AAC track on the Twitch push is silence. + +SECRET HANDLING + TWITCH_STREAM_KEY is read from the environment ONLY (set by the caller from + the etage-1 secret file). It is NEVER printed/logged/written; any log line + that might echo it is redacted to (redact(), mirrored from + probe-m6-live.py). Missing key -> exit 2, no broadcast. + +LICENSE INVARIANT + Pure obs-websocket v5 over the process boundary (+ `pulsar`/`pulsar-scene` + vendor requests). No FFI, no native import. CEF lives entirely inside the + pulsar.exe process tree. + +Usage (from the repo root, against a built rundir): + pip install websockets + export TWITCH_STREAM_KEY=... # from etage-1 secret, NEVER committed + python scripts/probe-twitch-scene-switch.py + python scripts/probe-twitch-scene-switch.py --duration 30 + +Required env: + TWITCH_STREAM_KEY Twitch stream key (opaque; never logged) + +Optional env / flags: + PULSAR_EXE / --exe override pulsar.exe path + --duration broadcast seconds (default 30); switch fires at /2 + --fps encoder fps target (default 60) + --force-fallback skip path (a), go straight to the URL-swap fallback (b) + +Exit codes: + 0 pass (scene switch performed + asserted; zero unmuted audio confirmed) + 1 fail (switch not honoured / broadcast assertion failed) + 2 config error (no key, no exe, bad args) + 3 typed skip (browser_source not registered -- LIGHT build, needs -Full) +""" +from __future__ import annotations + +import argparse +import asyncio +import base64 +import functools +import hashlib +import http.server +import json +import os +import pathlib +import re +import secrets +import socket +import socketserver +import subprocess +import sys +import threading +import time +from typing import Callable, Optional + +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + except Exception: + pass + +try: + import websockets +except ImportError: + print("error: pip install websockets (pure WS client — no native deps)") + sys.exit(2) + + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +DEFAULT_EXE = ( + REPO_ROOT / "upstream" / "build_x64" / "rundir" / "RelWithDebInfo" + / "bin" / "64bit" / "pulsar.exe" +) +SCENE_DIR = REPO_ROOT / "scripts" / "live-test" +BUILD_DIR = REPO_ROOT / "build" +LIVE_VOD_DIR = BUILD_DIR / "scene-switch-vod" + +READY_TIMEOUT_S = 60.0 +SHUTDOWN_GRACE_S = 8.0 +EVENT_SUBSCRIPTION_ALL = 0x7FF + +CANVAS_W = 1920 +CANVAS_H = 1080 + +FRAME_DROP_RATIO_MAX = 0.05 +POLL_INTERVAL_SEC = 5.0 +DESTINATION_NAME = "pulsar-scene-switch" + +# The default program scene the frontend-stub creates at boot. +DEFAULT_SCENE_NAME = "Default" +# The second program scene we attempt to create for path (a). +SCENE_B_NAME = "pulsar-scene-b" +# Desktop-audio source the frontend-stub binds to mixer channel 1 at boot. +# This is the "sound of the PC" we must mute. Name from +# plugins/pulsar-frontend-stub/src/pulsar-frontend-stub.cpp. +DESKTOP_AUDIO_SOURCE = "PulsarDesktopAudio" + +BENIGN_LOG_SUBSTRINGS = [ + "no target (set PULSAR_CAPTURE_WINDOW)", + "Failed to find module 'win-mf'", +] + + +# -------------------------------------------------------------------------- +# Secret redaction. The stream key must never reach a log line. +# -------------------------------------------------------------------------- +def redact(text: str, key: str) -> str: + if key and key in text: + return text.replace(key, "") + return text + + +# -------------------------------------------------------------------------- +# Local HTTP server hosting the two scene pages. +# -------------------------------------------------------------------------- +def find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +class _QuietHandler(http.server.SimpleHTTPRequestHandler): + def log_message(self, *_args) -> None: # silence per-request stderr noise + pass + + +def start_scene_server(port: int) -> socketserver.ThreadingTCPServer: + handler = functools.partial(_QuietHandler, directory=str(SCENE_DIR)) + socketserver.ThreadingTCPServer.allow_reuse_address = True + httpd = socketserver.ThreadingTCPServer(("127.0.0.1", port), handler) + threading.Thread(target=httpd.serve_forever, name="scene-http", daemon=True).start() + return httpd + + +# -------------------------------------------------------------------------- +# Process management -- mirrors probe-m6-live.py PulsarProcess. +# -------------------------------------------------------------------------- +READY_RE = re.compile(r"^PULSAR_READY ws=(\S+) password=(\S+)$") + + +class PulsarProcess: + def __init__(self, exe: pathlib.Path, port: int, password: str, fps: int) -> None: + self.exe = exe + self.port = port + self.password = password + self.fps = fps + self.proc: Optional[subprocess.Popen] = None + self._lines: list[str] = [] + self._ready_event = threading.Event() + self._ready_match: Optional[re.Match[str]] = None + + def spawn(self) -> None: + env = dict(os.environ) + env["PULSAR_PORT"] = str(self.port) + env["PULSAR_PASSWORD"] = self.password + env["PULSAR_FPS"] = str(self.fps) + env["PULSAR_RESOLUTION"] = f"{CANVAS_W}x{CANVAS_H}" + env["PULSAR_VIDEO_BITRATE"] = "6000" + LIVE_VOD_DIR.mkdir(parents=True, exist_ok=True) + env["PULSAR_RECORD_DIR"] = str(LIVE_VOD_DIR) + + # ZERO PC SOUND, part 1: never wire a window capture, never wire mic, + # never wire process-loopback audio. The frontend-stub only creates the + # mic source if PULSAR_MIC_DEVICE_ID is set, and process audio only if + # PULSAR_PROCESS_AUDIO_NAME is set -- so popping/leaving them unset means + # those sources are never created. Desktop audio is still created + # unconditionally and is muted later over the wire (see ensure_silence). + env.pop("PULSAR_CAPTURE_WINDOW", None) + env.pop("PULSAR_MIC_DEVICE_ID", None) + env.pop("PULSAR_PROCESS_AUDIO_NAME", None) + + creationflags = 0 + if os.name == "nt": + creationflags = 0x08000000 # CREATE_NO_WINDOW + + # --disable-gpu / --no-sandbox: forwarded to CEF via GetCommandLineW(). + # SW rasterization is the canonical headless-CEF config (same as M4/M6). + self.proc = subprocess.Popen( + [str(self.exe), "--disable-gpu", "--no-sandbox"], + cwd=str(self.exe.parent), # libobs resolves data/ from cwd + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + bufsize=1, + text=True, + encoding="utf-8", + errors="replace", + creationflags=creationflags, + ) + threading.Thread(target=self._pump_stdout, daemon=True).start() + + def _pump_stdout(self) -> None: + assert self.proc is not None and self.proc.stdout is not None + for line in self.proc.stdout: + line = line.rstrip("\r\n") + self._lines.append(line) + m = READY_RE.match(line) + if m is not None and not self._ready_event.is_set(): + self._ready_match = m + self._ready_event.set() + + def wait_ready(self, timeout: float) -> tuple[str, str]: + deadline = time.monotonic() + timeout + while True: + if self._ready_event.wait(timeout=0.2): + m = self._ready_match + assert m is not None + return m.group(1), m.group(2) + assert self.proc is not None + if self.proc.poll() is not None: + raise RuntimeError( + f"pulsar.exe exited (code {self.proc.returncode}) before READY.\n" + + self._diag() + ) + if time.monotonic() >= deadline: + raise RuntimeError( + f"pulsar.exe did not signal READY within {timeout:.0f}s.\n" + + self._diag() + ) + + @property + def lines(self) -> list[str]: + return list(self._lines) + + def diag(self) -> str: + return self._diag() + + def _diag(self) -> str: + tail = self._lines[-40:] + body = "\n".join(f" | {ln}" for ln in tail) if tail else " | (no output)" + return f"--- pulsar stdout/stderr (last {len(tail)} lines) ---\n{body}" + + def shutdown(self, grace: float = SHUTDOWN_GRACE_S) -> None: + if self.proc is None or self.proc.poll() is not None: + return + try: + self.proc.terminate() + except Exception: + pass + try: + self.proc.wait(timeout=grace) + return + except Exception: + pass + try: + self.proc.kill() + self.proc.wait(timeout=grace) + except Exception: + pass + + +# -------------------------------------------------------------------------- +# obs-websocket v5 plumbing -- mirrors probe-m6-live.py. +# -------------------------------------------------------------------------- +def compute_auth(password: str, salt: str, challenge: str) -> str: + secret = base64.b64encode( + hashlib.sha256((password + salt).encode("utf-8")).digest() + ).decode("ascii") + return base64.b64encode( + hashlib.sha256((secret + challenge).encode("utf-8")).digest() + ).decode("ascii") + + +class Inbox: + def __init__(self) -> None: + self.events: list[dict] = [] + self.responses: list[dict] = [] + + async def pump(self, ws, until: Callable[["Inbox"], bool], timeout: float) -> None: + end = asyncio.get_event_loop().time() + timeout + while not until(self): + remaining = end - asyncio.get_event_loop().time() + if remaining <= 0: + raise asyncio.TimeoutError + raw = await asyncio.wait_for(ws.recv(), timeout=remaining) + msg = json.loads(raw) + op = msg.get("op") + if op == 5: + self.events.append(msg["d"]) + elif op == 7: + self.responses.append(msg["d"]) + + +async def request( + inbox: Inbox, ws, request_type: str, request_id: str, + data: dict | None = None, timeout: float = 30.0, +) -> dict: + body: dict = {"requestType": request_type, "requestId": request_id} + if data is not None: + body["requestData"] = data + await ws.send(json.dumps({"op": 6, "d": body})) + + def has_response(ix: Inbox) -> bool: + return any(r["requestId"] == request_id for r in ix.responses) + + await inbox.pump(ws, has_response, timeout) + for i, r in enumerate(inbox.responses): + if r["requestId"] == request_id: + return inbox.responses.pop(i) + raise RuntimeError("unreachable") + + +async def vendor_call( + inbox: Inbox, ws, request_id: str, vendor: str, vendor_type: str, + data: dict | None = None, timeout: float = 30.0, +) -> dict: + return await request(inbox, ws, "CallVendorRequest", request_id, { + "vendorName": vendor, + "requestType": vendor_type, + "requestData": data or {}, + }, timeout) + + +def vendor_response_data(resp: dict) -> dict: + rd = resp.get("responseData", {}) + inner = rd.get("responseData", {}) if isinstance(rd, dict) else {} + return inner if isinstance(inner, dict) else {} + + +def vendor_request_status(resp: dict) -> dict: + s = resp.get("requestStatus", {}) + return s if isinstance(s, dict) else {} + + +def req_ok(resp: dict) -> bool: + return bool(resp.get("requestStatus", {}).get("result")) + + +# -------------------------------------------------------------------------- +# ZERO PC SOUND: mute the desktop-audio loopback + assert no unmuted input. +# -------------------------------------------------------------------------- +async def ensure_silence(inbox: Inbox, ws) -> int: + """Mute the frontend-stub's desktop-audio source and assert that NO audio + input remains unmuted. Returns 0 on a fully-silent mixer, 1 otherwise. + + The frontend-stub binds PulsarDesktopAudio to mixer channel 1 at boot + regardless of env -- that is the literal 'sound of the PC'. Mic + process + audio are never created (env popped/unset at spawn). We mute the desktop + source explicitly, then enumerate inputs and verify silence.""" + # 1. Mute the known desktop-audio source. It is created by the stub + # (wasapi_output_capture); if a build omitted it, the mute call simply + # reports the source missing, which is also silence. + r = await request(inbox, ws, "SetInputMute", "mute-desktop", { + "inputName": DESKTOP_AUDIO_SOURCE, + "inputMuted": True, + }) + if req_ok(r): + print(f"-> muted desktop audio source '{DESKTOP_AUDIO_SOURCE}'") + else: + # Not fatal on its own -- the source may be absent on this build. + print(f" note: SetInputMute('{DESKTOP_AUDIO_SOURCE}') declined " + f"({r.get('requestStatus')}); will rely on the input enumeration") + + # 2. Enumerate every input and assert none of the audio ones is unmuted. + # GetInputMute fails for non-audio inputs (no volume interface); we + # treat a successful GetInputMute as 'this is an audio input'. + r = await request(inbox, ws, "GetInputList", "input-list", {}) + if not req_ok(r): + print(f"FAIL: GetInputList failed: {r.get('requestStatus')}") + return 1 + inputs = r["responseData"].get("inputs", []) or [] + audio_inputs: list[str] = [] + unmuted: list[str] = [] + for idx, inp in enumerate(inputs): + name = inp.get("inputName") + if not name: + continue + mr = await request(inbox, ws, f"get-mute-{idx}", "GetInputMute", + {"inputName": name}) + if not req_ok(mr): + # No volume interface -> not an audio input (e.g. browser_source + # with reroute_audio off, scenes). Skip. + continue + audio_inputs.append(name) + if not mr["responseData"].get("inputMuted", False): + unmuted.append(name) + + print(f"-> audio inputs present: {audio_inputs or '(none)'}") + if unmuted: + print(f"FAIL: audio input(s) NOT muted -> PC sound would broadcast: " + f"{unmuted}. Refusing to go live.") + return 1 + print("-> silence confirmed: every audio input is muted (or absent)") + return 0 + + +# -------------------------------------------------------------------------- +# Scene plumbing. +# -------------------------------------------------------------------------- +async def set_capture_browser(inbox: Inbox, ws, rid: str, url: str) -> dict: + """SetCaptureSource(browser_source, url) on the CURRENT program scene. + reroute_audio=False so no page audio reaches the OBS mixer (zero PC sound). + Returns the unwrapped vendor responseData.""" + r = await vendor_call(inbox, ws, rid, "pulsar-scene", "SetCaptureSource", { + "kind": "browser_source", + "url": url, + "width": CANVAS_W, + "height": CANVAS_H, + "fps": 60, + "reroute_audio": False, + }) + return vendor_response_data(r) + + +async def get_current_scene(inbox: Inbox, ws, rid: str) -> Optional[str]: + r = await request(inbox, ws, "GetCurrentProgramScene", rid, {}) + if not req_ok(r): + return None + rd = r["responseData"] + return rd.get("sceneName") or rd.get("currentProgramSceneName") + + +async def set_current_scene(inbox: Inbox, ws, rid: str, name: str) -> bool: + r = await request(inbox, ws, "SetCurrentProgramScene", rid, + {"sceneName": name}) + return req_ok(r) + + +async def try_setup_two_scenes(inbox: Inbox, ws, url_a: str, url_b: str) -> bool: + """Path (a): create a second program scene, give each scene its own + browser_source, leave scene-a current + live. Returns True if the real + two-scene model is established and verified, False to fall back to (b). + + The pulsar-scene plugin always installs the browser_source on whatever + obs_frontend_get_current_scene() returns, so we set scene-b current, paint + it, then set scene-a current and paint it -- that orders the two managed + sources onto two distinct scenes.""" + start_scene = await get_current_scene(inbox, ws, "scene-cur-0") + if not start_scene: + print(" path(a): GetCurrentProgramScene returned nothing; fallback") + return False + print(f" path(a): boot program scene = {start_scene!r}") + + # Create scene-b. ResourceAlreadyExists is fine (idempotent re-run). + r = await request(inbox, ws, "create-scene-b", "CreateScene", + {"sceneName": SCENE_B_NAME}) + if not req_ok(r): + code = r.get("requestStatus", {}).get("code") + # 601 == ResourceAlreadyExists in obs-websocket. Tolerate it. + if code != 601: + print(f" path(a): CreateScene declined ({r.get('requestStatus')}); " + f"fallback") + return False + + # Switch to scene-b and confirm the program scene actually flipped -- this + # is the live test of whether the headless stub honours program-scene + # changes for a CreateScene'd scene at all. + if not await set_current_scene(inbox, ws, "to-b-setup", SCENE_B_NAME): + print(" path(a): SetCurrentProgramScene(scene-b) declined; fallback") + return False + now = await get_current_scene(inbox, ws, "scene-cur-1") + if now != SCENE_B_NAME: + print(f" path(a): program scene did not flip to {SCENE_B_NAME!r} " + f"(got {now!r}); fallback") + return False + + # Paint scene-b crimson. + data_b = await set_capture_browser(inbox, ws, "cap-b", url_b) + if data_b.get("kind") != "browser_source": + print(f" path(a): SetCaptureSource on scene-b failed ({data_b}); " + f"fallback") + return False + + # Switch back to scene-a (the boot scene) and paint it cobalt. + if not await set_current_scene(inbox, ws, "to-a-setup", start_scene): + print(" path(a): could not return to scene-a; fallback") + return False + now = await get_current_scene(inbox, ws, "scene-cur-2") + if now != start_scene: + print(f" path(a): program scene did not return to {start_scene!r}; " + f"fallback") + return False + data_a = await set_capture_browser(inbox, ws, "cap-a", url_a) + if data_a.get("kind") != "browser_source": + print(f" path(a): SetCaptureSource on scene-a failed ({data_a}); " + f"fallback") + return False + + print(f" path(a): two scenes ready -- '{start_scene}' (cobalt) + " + f"'{SCENE_B_NAME}' (crimson); live on '{start_scene}'") + return True + + +# -------------------------------------------------------------------------- +# Broadcast + mid-course switch. +# -------------------------------------------------------------------------- +async def broadcast(inbox: Inbox, ws, stream_key: str, duration_sec: int, + use_real_scene: bool, scene_a: str, url_a: str, url_b: str, + pulsar: "PulsarProcess") -> int: + switch_at = duration_sec / 2.0 + + r = await vendor_call(inbox, ws, "create-dest", "pulsar", + "CreateDestination", { + "name": DESTINATION_NAME, "kind": "twitch", "key": stream_key, + }) + dest_data = vendor_response_data(r) + dest_id = dest_data.get("id") + if not dest_id: + status = vendor_request_status(r) + print(f"FAIL: CreateDestination returned no id; " + f"status={redact(json.dumps(status), stream_key)}") + return 1 + print(f"-> CreateDestination(twitch) id={dest_id}") + + r = await vendor_call(inbox, ws, "start-dest", "pulsar", + "StartDestination", {"id": dest_id}) + if not vendor_response_data(r).get("started"): + status = vendor_request_status(r) + print(f"FAIL: StartDestination not started; " + f"status={redact(json.dumps(status), stream_key)}") + await vendor_call(inbox, ws, "rm-dest", "pulsar", + "RemoveDestination", {"id": dest_id}) + return 1 + print("-> StartDestination started=true -- LIVE on Twitch") + + recording = False + r = await request(inbox, ws, "StartRecord", "start-rec") + if req_ok(r): + recording = True + print(f"-> StartRecord ok (writing under {LIVE_VOD_DIR})") + else: + print(f" warn: StartRecord declined: {r.get('requestStatus')}") + + # The switch must be observable: capture the program scene (path a) or the + # active capture URL (path b) BEFORE the switch so we can assert it changed. + if use_real_scene: + before = await get_current_scene(inbox, ws, "before-switch") + else: + gr = await vendor_call(inbox, ws, "get-cap-before", "pulsar-scene", + "GetCaptureSource", {}) + before = vendor_response_data(gr).get("url") + print(f" pre-switch state = {redact(str(before), stream_key)}") + + rc = 0 + switched = False + start_t = time.time() + poll = 0 + adaptive_seen = 0 + while time.time() - start_t < duration_sec: + await asyncio.sleep(POLL_INTERVAL_SEC) + poll += 1 + elapsed = time.time() - start_t + + # Mid-course SCENE SWITCH at ~duration/2. + if not switched and elapsed >= switch_at: + if use_real_scene: + ok = await set_current_scene(inbox, ws, "do-switch", SCENE_B_NAME) + now = await get_current_scene(inbox, ws, "after-switch") + if not ok or now != SCENE_B_NAME: + print(f"FAIL: scene switch to {SCENE_B_NAME!r} not honoured " + f"(ok={ok} now={now!r})") + rc = 1 + break + print(f"** SCENE SWITCH @ t={elapsed:.1f}s : " + f"program scene {scene_a!r} -> {SCENE_B_NAME!r} (crimson)") + else: + data = await set_capture_browser(inbox, ws, "do-switch", url_b) + gr = await vendor_call(inbox, ws, "get-cap-after", + "pulsar-scene", "GetCaptureSource", {}) + now = vendor_response_data(gr).get("url") + if data.get("kind") != "browser_source" or now != url_b: + print(f"FAIL: fallback URL swap not honoured " + f"(now={redact(str(now), stream_key)})") + rc = 1 + break + print(f"** SCENE SWITCH @ t={elapsed:.1f}s (fallback) : " + f"capture URL scene-a.html -> scene-b.html (crimson)") + switched = True + + r = await vendor_call(inbox, ws, f"get-dest-{poll}", "pulsar", + "GetDestinations", {}) + lst = vendor_response_data(r).get("destinations", []) + ours = next((d for d in lst if d.get("id") == dest_id), None) + if not ours or not ours.get("active"): + print(f"FAIL: destination not active at poll #{poll}: {ours}") + rc = 1 + break + + r = await vendor_call(inbox, ws, f"get-adapt-{poll}", "pulsar", + "GetAdaptiveState", {}) + adapt = vendor_response_data(r) + samples = int(adapt.get("samples", 0)) + adaptive_seen = max(adaptive_seen, samples) + drop_ratio = float(adapt.get("last_drop_ratio", 0.0)) + cur_kbps = adapt.get("current_kbps") + + sr = await request(inbox, ws, "GetStats", f"stats-{poll}") + stats = sr.get("responseData", {}) or {} + fps = stats.get("activeFps") + fps_str = f"{fps:.1f}" if isinstance(fps, (int, float)) else "—" + + print(f" poll #{poll} t={elapsed:.0f}s active=true samples={samples} " + f"drop_ratio={drop_ratio:.4f} bitrate={cur_kbps} fps={fps_str} " + f"switched={switched}") + + if drop_ratio > FRAME_DROP_RATIO_MAX: + print(f"FAIL: frame drop ratio {drop_ratio:.4f} > " + f"{FRAME_DROP_RATIO_MAX} at poll #{poll}") + rc = 1 + break + + if rc == 0 and not switched: + print("FAIL: broadcast ended before the mid-course switch fired") + rc = 1 + + # Stop cleanly (best-effort even on a failed poll). + if recording: + try: + r = await request(inbox, ws, "StopRecord", "stop-rec") + vod = (r.get("responseData", {}) or {}).get("outputPath") + if vod: + print(f"-> StopRecord finalised: {vod}") + print(f"LIVE_VOD_PATH={vod}") + except Exception as exc: # noqa: BLE001 + print(f" warn: StopRecord error: {exc}") + try: + await vendor_call(inbox, ws, "stop-dest", "pulsar", + "StopDestination", {"id": dest_id}) + print("-> StopDestination ok") + except Exception as exc: # noqa: BLE001 + print(f" warn: StopDestination error: {exc}") + try: + await vendor_call(inbox, ws, "rm-dest", "pulsar", + "RemoveDestination", {"id": dest_id}) + except Exception: + pass + + if rc == 0 and adaptive_seen <= 0: + print(f"FAIL: adaptive worker never reported samples (saw {adaptive_seen})") + rc = 1 + if rc == 0: + print(f"-> broadcast clean: switch performed, adaptive_samples={adaptive_seen}") + return rc + + +async def run(url: str, password: str, stream_key: str, duration_sec: int, + http_port: int, force_fallback: bool, pulsar: "PulsarProcess") -> int: + scene_a_url = f"http://127.0.0.1:{http_port}/scene-a.html" + scene_b_url = f"http://127.0.0.1:{http_port}/scene-b.html" + + print(f"connecting: {url}") + async with websockets.connect( + url, subprotocols=["obswebsocket.json"], max_size=2**24, + ping_interval=None, close_timeout=15, open_timeout=10, + ) as ws: + hello = json.loads(await asyncio.wait_for(ws.recv(), timeout=10)) + if hello.get("op") != 0: + print(f"error: expected Hello (op=0), got {hello}") + return 1 + identify_d: dict = { + "rpcVersion": hello["d"]["rpcVersion"], + "eventSubscriptions": EVENT_SUBSCRIPTION_ALL, + } + if "authentication" in hello["d"]: + a = hello["d"]["authentication"] + identify_d["authentication"] = compute_auth( + password, a["salt"], a["challenge"]) + await ws.send(json.dumps({"op": 1, "d": identify_d})) + ident = json.loads(await asyncio.wait_for(ws.recv(), timeout=10)) + if ident.get("op") != 2: + print(f"error: identify failed: {ident}") + return 1 + print("identified (v5 auth OK)") + + inbox = Inbox() + + # Guard: browser_source must be registered (full-variant build). + resp = await request(inbox, ws, "GetInputKindList", "kinds", {}) + kinds = set(resp["responseData"]["inputKinds"]) + if "browser_source" not in kinds: + print("SKIP: browser_source NOT registered -- LIGHT build (no CEF). " + "Needs a full build (scripts/build-win.ps1 -Full). " + "Typed skip, NOT a pass.") + return 3 + print(f"browser_source registered ({len(kinds)} input kinds total)") + + # ZERO PC SOUND: mute desktop audio + assert no unmuted audio input. + if await ensure_silence(inbox, ws) != 0: + return 1 + + # Decide the switch implementation: real scene (a) or URL swap (b). + scene_a = await get_current_scene(inbox, ws, "scene-a-name") or DEFAULT_SCENE_NAME + use_real_scene = False + if force_fallback: + print("-> --force-fallback set: using path (b) URL swap") + else: + print("-> attempting path (a): real two-scene OBS switch") + use_real_scene = await try_setup_two_scenes( + inbox, ws, scene_a_url, scene_b_url) + + if not use_real_scene: + # Path (b): single scene, paint scene-a; the switch later swaps URL. + print("-> path (b): single program scene, browser_source = scene-a.html " + "(switch will swap to scene-b.html at duration/2)") + data = await set_capture_browser(inbox, ws, "cap-fallback", scene_a_url) + if data.get("kind") != "browser_source": + print(f"FAIL: SetCaptureSource(scene-a) failed: {data}") + return 1 + + impl = "REAL SCENE SWITCH (path a)" if use_real_scene else "URL-SWAP FALLBACK (path b)" + print(f"\n[scene-switch] implementation = {impl}") + print(f"[scene-switch] going live to Twitch ({duration_sec}s, " + f"switch @ {duration_sec/2:.0f}s) ...\n") + return await broadcast( + inbox, ws, stream_key, duration_sec, use_real_scene, + scene_a, scene_a_url, scene_b_url, pulsar) + + +def main() -> int: + ap = argparse.ArgumentParser(description="Pulsar Twitch scene-switch probe") + ap.add_argument("--exe", type=pathlib.Path, + default=pathlib.Path(os.environ.get("PULSAR_EXE", str(DEFAULT_EXE))), + help="path to pulsar.exe (default: built rundir)") + ap.add_argument("--duration", type=int, + default=int(os.environ.get("LIVE_TEST_DURATION", "30")), + help="broadcast duration in seconds (default 30); switch at /2") + ap.add_argument("--fps", type=int, + default=int(os.environ.get("LIVE_TEST_FPS", "60")), + help="encoder fps target (default 60)") + ap.add_argument("--force-fallback", action="store_true", + help="skip path (a), use the URL-swap fallback (b)") + ap.add_argument("--ready-timeout", type=float, default=READY_TIMEOUT_S) + args = ap.parse_args() + + exe: pathlib.Path = args.exe + if not exe.exists(): + print(f"error: pulsar.exe not found at {exe}") + print("Build it first: scripts/build-win.ps1 -Full") + return 2 + + if not (SCENE_DIR / "scene-a.html").exists() or not (SCENE_DIR / "scene-b.html").exists(): + print(f"error: scene-a.html / scene-b.html missing under {SCENE_DIR}") + return 2 + + if args.duration < 4: + print("error: --duration must be >= 4s so the mid-course switch has room") + return 2 + + stream_key = os.environ.get("TWITCH_STREAM_KEY", "").strip() + if not stream_key: + print("error: TWITCH_STREAM_KEY env var is empty. Set it from the " + "etage-1 secret; never commit. Refusing to broadcast.") + return 2 + + http_port = find_free_port() + httpd = start_scene_server(http_port) + print(f"scene HTTP server: http://127.0.0.1:{http_port}/ " + f"(scene-a.html cobalt / scene-b.html crimson)") + + port = find_free_port() + password = secrets.token_urlsafe(16) + print(f"spawning: {exe}") + print(f" cwd={exe.parent}") + print(f" PULSAR_PORT={port} PULSAR_PASSWORD=") + print(f" TWITCH_STREAM_KEY: ") + + pulsar = PulsarProcess(exe, port, password, args.fps) + rc = 1 + try: + pulsar.spawn() + ws_url, sentinel_pw = pulsar.wait_ready(args.ready_timeout) + print(f"READY: {ws_url}") + rc = asyncio.run(run( + ws_url, sentinel_pw, stream_key, args.duration, + http_port, args.force_fallback, pulsar, + )) + except KeyboardInterrupt: + print("interrupted") + rc = 130 + except Exception as exc: # noqa: BLE001 — top-level probe diagnostic + print(f"FAIL: {redact(str(exc), stream_key)}") + if pulsar.proc is not None: + print(redact(pulsar.diag(), stream_key)) + rc = 1 + finally: + if rc != 0: + tail = pulsar.lines[-80:] + if tail: + print("\n---- pulsar stdout (last 80 lines, redacted) ----") + for ln in tail: + print(f" {redact(ln, stream_key)}") + print("---- end pulsar stdout ----\n") + pulsar.shutdown() + try: + httpd.shutdown() + httpd.server_close() + except Exception: + pass + if pulsar.proc is not None and pulsar.proc.poll() is None: + print("error: pulsar.exe still running after shutdown attempt") + rc = rc or 1 + else: + print("pulsar.exe reaped cleanly") + + print("PASS" if rc == 0 else (f"SKIPPED (exit {rc})" if rc == 3 + else f"FAILED (exit {rc})")) + return rc + + +if __name__ == "__main__": + sys.exit(main())