From adc209cedcedcb41691e7031e0bb5a3af9cdadfe Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 8 May 2026 10:39:17 +1000 Subject: [PATCH 01/19] ci(audience): run Linux PlayMode under xvfb so tests actually execute (SDK-317) - Replaces game-ci/unity-test-runner@v4 with docker run of unityci/editor:ubuntu-${unity}-linux-il2cpp-3, wrapped in xvfb-run. - Without an X server every [UnityTest] returned inconclusive (passed=0, failed=0). The action's USE_EXIT_CODE=false hid Unity's exit code 2, dorny/test-reporter does not flag inconclusive, so each Linux cell silently went green for ~3 min without executing a single test. macOS by comparison ran 39 tests in ~25 s with all 39 passing. - xvfb-run gives Unity a software-rendered virtual display via mesa-llvmpipe; no GPU required. - One image tag covers both backends (verified against the Mono cell log from run 25492697422 which pulled the il2cpp tag). - Per-run license activation + return mirrors the dropped self-hosted Linux job (commit 2658686c). - Adds a post-step that parses playmode-results.xml via xmllint and fails the cell when inconclusive > 0 or passed+failed == 0; stops any future regression from silently re-greening the matrix. - timeout-minutes: 30 since cells now run ~5-10 min of real work. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workflows/test-audience-sample-app.yml | 130 ++++++++++++++++-- 1 file changed, 120 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test-audience-sample-app.yml b/.github/workflows/test-audience-sample-app.yml index 104f7d4dd..6775fa93b 100644 --- a/.github/workflows/test-audience-sample-app.yml +++ b/.github/workflows/test-audience-sample-app.yml @@ -412,6 +412,11 @@ jobs: || github.event_name == 'workflow_dispatch' name: ${{ matrix.target }} / ${{ matrix.backend }} / Unity ${{ matrix.unity }} runs-on: ubuntu-latest-8-cores + # Tests now actually execute under xvfb instead of returning instantly + # as inconclusive, so cells take ~5-10 min. The 30 min cap leaves + # headroom for cold caches and the first image pull without leaving + # a stuck job sitting on the runner for the default 6 hours. + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -433,27 +438,128 @@ jobs: Library-${{ matrix.backend }}-${{ matrix.target }}-${{ matrix.unity }}- Library-${{ matrix.backend }}-${{ matrix.target }}- - - uses: game-ci/unity-test-runner@v4 - id: playmode + - name: Run PlayMode tests under xvfb + # Manual `docker run` instead of game-ci/unity-test-runner@v4: the + # action hardcodes `-batchmode -nographics` and never starts a + # virtual display, so every [UnityTest] in SampleAppLiveFireTests + # came back "inconclusive" (passed=0, failed=0). NUnit does not + # treat inconclusive as failure and the action's USE_EXIT_CODE=false + # suppresses Unity's exit code 2, so the cells silently went green + # without executing a single test. See SDK-318 for the diagnosis. + shell: bash env: UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} AUDIENCE_TEST_PUBLISHABLE_KEY: ${{ secrets.AUDIENCE_TEST_PUBLISHABLE_KEY }} AUDIENCE_SCRIPTING_BACKEND: ${{ matrix.backend }} - with: - unityVersion: ${{ matrix.unity }} - targetPlatform: ${{ matrix.target }} - projectPath: examples/audience - testMode: playmode - githubToken: ${{ secrets.GITHUB_TOKEN }} + UNITY_VERSION: ${{ matrix.unity }} + run: | + set -uo pipefail + mkdir -p artifacts + + # The unityci/editor:ubuntu-...-linux-il2cpp-3 image ships both + # the Mono and IL2CPP playback engines plus xvfb, so the same + # tag works for both backends. The previous GameCI runs proved + # this: the StandaloneLinux64/Mono2x cell pulled the il2cpp tag. + image="unityci/editor:ubuntu-${UNITY_VERSION}-linux-il2cpp-3" + + docker run --rm \ + --workdir /github/workspace \ + --env UNITY_EMAIL --env UNITY_PASSWORD --env UNITY_SERIAL \ + --env AUDIENCE_TEST_PUBLISHABLE_KEY --env AUDIENCE_SCRIPTING_BACKEND \ + --volume "$PWD":/github/workspace:z \ + --cpus=8 --memory=30487m \ + "$image" \ + /bin/bash -c ' + set -uo pipefail + + # Per-run license activation. Unity occasionally exits non-zero + # on a successful activation (warnings about prior cached state + # in /root), so swallow the rc and assert on the success marker + # in the log instead. Same approach as the dropped self-hosted + # Linux job from SDK-255. + unity-editor -batchmode -nographics -quit \ + -username "$UNITY_EMAIL" \ + -password "$UNITY_PASSWORD" \ + -serial "$UNITY_SERIAL" \ + -logFile - 2>&1 | tee /github/workspace/artifacts/activation.log || true + if grep -qE "License activation has failed|\[Licensing::Client\] Error: Code [0-9]+" \ + /github/workspace/artifacts/activation.log; then + echo "::error::Unity license activation failed." + exit 1 + fi + if ! grep -qE "Successfully activated the entitlement license" \ + /github/workspace/artifacts/activation.log; then + echo "::error::Unity license activation: no success marker in log." + exit 1 + fi + + # xvfb-run gives Unity a virtual X display so PlayMode tests + # that load scenes and exercise UI Toolkit can actually launch + # the player. GLX + render are required for UIElements; the + # image already bundles mesa-llvmpipe for software OpenGL, so + # no GPU is needed. -noreset keeps the X server up across + # Unity client reconnects (the editor opens / closes / reopens + # connections during scene load). + xvfb-run -a --server-args="-screen 0 1280x720x24 -ac +extension GLX +render -noreset" -- \ + unity-editor \ + -batchmode \ + -projectPath /github/workspace/examples/audience \ + -runTests \ + -testPlatform StandaloneLinux64 \ + -testResults /github/workspace/artifacts/playmode-results.xml \ + -logFile - 2>&1 | tee /github/workspace/artifacts/playmode.log + test_rc=${PIPESTATUS[0]} + + # Always return the seat so reruns and parallel cells do not + # exhaust the activation pool. Tolerate non-zero in case the + # editor process is still mid-shutdown. + unity-editor -batchmode -nographics -quit -returnlicense -logFile - 2>&1 || true + + # Unity exits 2 when any test fails or comes back inconclusive. + # Propagating the rc here means the step fails on real failures + # without needing the USE_EXIT_CODE=false hack the GameCI + # action applies. + exit $test_rc + ' + + - name: Fail when no tests actually executed + # Defense in depth: catches the silent-pass case if a future change + # accidentally re-disables the display, breaks the player launch, + # or restores USE_EXIT_CODE=false on the docker invocation. NUnit + # marks tests "inconclusive" rather than "failed" when the player + # never starts, and dorny/test-reporter does not flag inconclusive + # as failure, so the suite has to be inspected directly. + if: always() + shell: bash + run: | + set -euo pipefail + xml="artifacts/playmode-results.xml" + if [ ! -f "$xml" ]; then + echo "::error::No test-results.xml at $xml. Unity did not produce results." + exit 1 + fi + # xmllint is preinstalled on ubuntu-latest via libxml2-utils. + passed=$(xmllint --xpath 'string(/test-run/@passed)' "$xml") + failed=$(xmllint --xpath 'string(/test-run/@failed)' "$xml") + inconclusive=$(xmllint --xpath 'string(/test-run/@inconclusive)' "$xml") + echo "passed=$passed failed=$failed inconclusive=$inconclusive" + if [ "${inconclusive:-0}" -gt 0 ]; then + echo "::error::$inconclusive test(s) came back inconclusive. Unity could not actually execute them. Check that xvfb is running and the player launches." + exit 1 + fi + if [ "${passed:-0}" -eq 0 ] && [ "${failed:-0}" -eq 0 ]; then + echo "::error::Zero tests passed and zero failed. The suite did not execute." + exit 1 + fi - name: Publish test report uses: dorny/test-reporter@v3 if: always() with: name: PlayMode (${{ matrix.backend }} / ${{ matrix.target }}) - path: ${{ steps.playmode.outputs.artifactsPath }}/playmode-results.xml + path: artifacts/playmode-results.xml reporter: dotnet-nunit fail-on-error: true @@ -461,7 +567,11 @@ jobs: if: always() with: name: playmode-${{ matrix.backend }}-${{ matrix.target }}-${{ matrix.unity }} - path: ${{ steps.playmode.outputs.artifactsPath }} + path: | + artifacts/playmode-results.xml + artifacts/playmode.log + artifacts/activation.log + examples/audience/Logs/** # Mobile IL2CPP build validation — runs on GitHub-hosted Ubuntu via GameCI Docker # containers so self-hosted macOS/Windows machines are not occupied. From 354b52d708bc93b8176ae2c32a3d4d733a3ac638 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 8 May 2026 10:52:26 +1000 Subject: [PATCH 02/19] ci(audience): swap xmllint guard for grep parse (SDK-317) - xmllint is not actually preinstalled on ubuntu-latest-8-cores; the previous guard step failed with "command not found" while the underlying xvfb + Unity invocation had executed all 39 tests successfully (passed=39, failed=0, inconclusive=0). See run 25530011079. - Parses passed/failed/inconclusive off the root line via grep instead. Unity's NUnit writer keeps the summary attributes on one line, so the parse is reliable and adds no apt deps. - Validated locally against the playmode-results.xml from the failing cell: extracts passed=39 failed=0 inconclusive=0 as expected. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workflows/test-audience-sample-app.yml | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test-audience-sample-app.yml b/.github/workflows/test-audience-sample-app.yml index 6775fa93b..f3c39408d 100644 --- a/.github/workflows/test-audience-sample-app.yml +++ b/.github/workflows/test-audience-sample-app.yml @@ -540,11 +540,23 @@ jobs: echo "::error::No test-results.xml at $xml. Unity did not produce results." exit 1 fi - # xmllint is preinstalled on ubuntu-latest via libxml2-utils. - passed=$(xmllint --xpath 'string(/test-run/@passed)' "$xml") - failed=$(xmllint --xpath 'string(/test-run/@failed)' "$xml") - inconclusive=$(xmllint --xpath 'string(/test-run/@inconclusive)' "$xml") - echo "passed=$passed failed=$failed inconclusive=$inconclusive" + # Unity's NUnit writer puts the root summary attributes on a + # single line, so a grep parse is enough and + # avoids depending on libxml2-utils (which is not preinstalled + # on ubuntu-latest-8-cores; the previous xmllint guard failed + # with "command not found" while tests had actually passed). + line=$(grep -m1 ' element in $xml. The XML is malformed." + exit 1 + fi + extract() { + printf %s "$1" | grep -oE " $2=\"[0-9]+\"" | head -1 | grep -oE '[0-9]+' + } + passed=$(extract "$line" passed) + failed=$(extract "$line" failed) + inconclusive=$(extract "$line" inconclusive) + echo "passed=${passed:-?} failed=${failed:-?} inconclusive=${inconclusive:-?}" if [ "${inconclusive:-0}" -gt 0 ]; then echo "::error::$inconclusive test(s) came back inconclusive. Unity could not actually execute them. Check that xvfb is running and the player launches." exit 1 From 5806341367da9c3b736650b9b1dd67923ecaa3b2 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 8 May 2026 11:59:47 +1000 Subject: [PATCH 03/19] ci(audience): capture standalone test player log on Linux (SDK-317) - PlayMode tests on StandaloneLinux64 build a separate player binary that runs the suite. The editor stdout the workflow tees never sees the player's HTTP traces, OnError callbacks, or cert failures. - Without this capture, "39 passed" only tells us FlushAsync returned without throwing. Whether events reached CDP cannot be verified, and any silent OnError-class failure is invisible. - Adds a find + cp inside the docker bash that copies every Player.log under /root/.config/unity3d/// into artifacts/Player--.log before the container exits. - upload-artifact now includes artifacts/Player-*.log alongside playmode.log and activation.log. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test-audience-sample-app.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/test-audience-sample-app.yml b/.github/workflows/test-audience-sample-app.yml index f3c39408d..4bfca1e40 100644 --- a/.github/workflows/test-audience-sample-app.yml +++ b/.github/workflows/test-audience-sample-app.yml @@ -512,6 +512,22 @@ jobs: -logFile - 2>&1 | tee /github/workspace/artifacts/playmode.log test_rc=${PIPESTATUS[0]} + # Capture the standalone test player log. PlayMode tests on + # StandaloneLinux64 build a player binary that runs the suite + # in its own process; the editor stdout we tee above never + # sees the player'\''s HTTP traces, OnError callbacks, or cert + # failures. Without this, "39 passed" tells us FlushAsync + # returned without throwing but says nothing about whether + # events actually reached CDP. + # Linux convention: ~/.config/unity3d///Player.log. + # Glob across companies / products so the capture survives any + # rename of the sample app. + find /root/.config/unity3d -name "Player.log" 2>/dev/null | while IFS= read -r f; do + co=$(basename "$(dirname "$(dirname "$f")")") + pr=$(basename "$(dirname "$f")") + cp "$f" "/github/workspace/artifacts/Player-${co}-${pr}.log" 2>/dev/null || true + done + # Always return the seat so reruns and parallel cells do not # exhaust the activation pool. Tolerate non-zero in case the # editor process is still mid-shutdown. @@ -583,6 +599,7 @@ jobs: artifacts/playmode-results.xml artifacts/playmode.log artifacts/activation.log + artifacts/Player-*.log examples/audience/Logs/** # Mobile IL2CPP build validation — runs on GitHub-hosted Ubuntu via GameCI Docker From 2380f5d8ff1db0709dbe66001d4b45a7846c783d Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 8 May 2026 14:15:45 +1000 Subject: [PATCH 04/19] feat(audience-sample): mirror SDK output to Debug.Log (SDK-317) - OnSdkError now also calls Debug.LogError; SDK Log.Writer adapter now also calls Debug.Log / Debug.LogWarning. The in-app log pane keeps its existing behavior. - Without the mirror, OnError fires (HTTP / TLS cert / 4xx / 5xx) were visible only in the in-app pane, which disappears with the player process and is not captured by NUnit's test-results.xml. The StandaloneLinux64 cells passed all 39 tests but emitted zero SDK output to Player.log, so we cannot tell whether events actually reached CDP or whether OnError silently fired for every flush. - Mirroring to Debug.Log surfaces those entries in Player.log, which the workflow now uploads as artifacts/Player-Immutable-audience.log. - Sample app users also benefit: SDK warnings and onError now show up in the Unity console instead of being trapped in the UI panel. - Calls to Debug.Log are thread-safe in Unity, so the existing off-main-thread behavior of OnSdkError and RouteSdkLogToPane is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SampleApp/Scripts/AudienceSample.cs | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs index 7764d8dc5..d43bca30c 100644 --- a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs +++ b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs @@ -246,15 +246,26 @@ private void OnAlias() => RunAndLog("alias()", () => // Fires from background flush threads; AppendLog marshals to main. // Body is JSON for parity with handler "Copy" output. - private void OnSdkError(AudienceError err) => - AppendLog("onError", Json.Serialize(new Dictionary + // Also forwards to Debug.LogError so the entry lands in Player.log + // / Editor console. Without this, OnError fires would be visible + // only in the in-app log pane, which disappears with the player + // process and is not captured by NUnit's test-results.xml. That + // gap let HTTP / cert / 4xx-5xx failures pass under "39 passed" + // for the StandaloneLinux64 cells. + private void OnSdkError(AudienceError err) + { + var body = Json.Serialize(new Dictionary { ["code"] = err.Code.ToString(), ["message"] = err.Message, - }, 2), LogLevel.Err, LogSource.Sdk); + }, 2); + UnityEngine.Debug.LogError($"[Audience.OnError] {body}"); + AppendLog("onError", body, LogLevel.Err, LogSource.Sdk); + } // SDK Log.Writer adapter. May fire from any thread; AppendLog handles - // the main-thread marshal. + // the main-thread marshal. Also mirrors to Unity's Debug.Log so the + // SDK's warnings reach Player.log alongside the in-app pane. private void RouteSdkLogToPane(string msg) { const string warnTag = "[ImmutableAudience] WARN:"; @@ -265,10 +276,16 @@ private void RouteSdkLogToPane(string msg) { level = LogLevel.Warn; body = msg.Substring(warnTag.Length).TrimStart(); + UnityEngine.Debug.LogWarning($"[Audience] {body}"); } else if (msg.StartsWith(prefix, StringComparison.Ordinal)) { body = msg.Substring(prefix.Length).TrimStart(); + UnityEngine.Debug.Log($"[Audience] {body}"); + } + else + { + UnityEngine.Debug.Log($"[Audience] {body}"); } AppendLog("sdk", body, level, LogSource.Sdk); } From ca2b367e74d75dd52379b1675dc0c2269a13c4a1 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 8 May 2026 14:41:56 +1000 Subject: [PATCH 05/19] ci(audience): cap Unity invocation at 22 min so shutdown hang cannot stall the cell (SDK-317) - Wraps `xvfb-run unity-editor ...` in `timeout --signal=TERM --kill-after=10 1320` inside the docker bash. SIGTERM at 22 min, hard SIGKILL 10s later if the editor still has not exited. - Unity 6 on Linux has a known shutdown hang: tests run, the runner writes playmode-results.xml, then the editor begins `Application is shutting down...` and never fully terminates - likely a leftover thread or a player process xvfb-run is still tied to. Without the cap, the cell sits idle until GitHub's 30-min job timeout and the upload-artifact step never runs, so the results XML and Player.log are lost. - 22 min covers Unity 6 IL2CPP build (~5 min) + 39-test suite (~10-15 min) with a 2-min buffer. The 8-min slack to the cell timeout lets the post-Unity steps (license return, Player.log capture, artifact upload) all complete. - Annotates exit codes 124 and 137 (timeout and post-grace SIGKILL) so the artifact reader can distinguish a real test failure from a shutdown-hang kill. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workflows/test-audience-sample-app.yml | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test-audience-sample-app.yml b/.github/workflows/test-audience-sample-app.yml index 4bfca1e40..1e84d39c3 100644 --- a/.github/workflows/test-audience-sample-app.yml +++ b/.github/workflows/test-audience-sample-app.yml @@ -502,15 +502,39 @@ jobs: # no GPU is needed. -noreset keeps the X server up across # Unity client reconnects (the editor opens / closes / reopens # connections during scene load). - xvfb-run -a --server-args="-screen 0 1280x720x24 -ac +extension GLX +render -noreset" -- \ - unity-editor \ - -batchmode \ - -projectPath /github/workspace/examples/audience \ - -runTests \ - -testPlatform StandaloneLinux64 \ - -testResults /github/workspace/artifacts/playmode-results.xml \ - -logFile - 2>&1 | tee /github/workspace/artifacts/playmode.log + # + # `timeout` wraps the whole xvfb-run + unity-editor pipeline so + # the editor cannot stall the cell past 22 minutes. Unity 6 on + # Linux has a known shutdown hang: tests complete, the runner + # writes playmode-results.xml, then the editor begins + # `Application is shutting down...` and never fully exits + # (likely a leftover thread or a player process xvfb-run is + # still tied to). Without the wrapper, the cell sits idle until + # the GitHub 30-min cap and the upload step never gets to run. + # 22 min covers Unity 6 IL2CPP build (~5 min) + 39-test suite + # execution (~10-15 min) with a 2-min buffer. SIGTERM first via + # --signal=TERM so Unity can flush; --kill-after=10 forces + # SIGKILL 10 s later if it is still alive. The 8 min slack to + # the cell timeout lets the post-steps (license return, Player + # log capture, artifact upload) all run. + set +e + timeout --signal=TERM --kill-after=10 1320 \ + xvfb-run -a --server-args="-screen 0 1280x720x24 -ac +extension GLX +render -noreset" -- \ + unity-editor \ + -batchmode \ + -projectPath /github/workspace/examples/audience \ + -runTests \ + -testPlatform StandaloneLinux64 \ + -testResults /github/workspace/artifacts/playmode-results.xml \ + -logFile - 2>&1 | tee /github/workspace/artifacts/playmode.log test_rc=${PIPESTATUS[0]} + set -uo pipefail + # exit 124 = timeout fired, 137 = SIGKILL via --kill-after. + # Either way, log it explicitly so the artifact reader can tell + # a real test failure from a shutdown-hang kill. + if [ "$test_rc" = "124" ] || [ "$test_rc" = "137" ]; then + echo "::warning::Unity exceeded the 22-min budget and was killed (rc=$test_rc). Tests likely completed; the editor failed to shut down." + fi # Capture the standalone test player log. PlayMode tests on # StandaloneLinux64 build a player binary that runs the suite From 9106a748822ad2dd65f1ab69328c5a090d8956ad Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 8 May 2026 14:43:59 +1000 Subject: [PATCH 06/19] ci(audience): stamp CI run + cell + buildGuid into Player.log and job summary (SDK-317) - Adds a `RuntimeInitializeOnLoadMethod` in AudienceSample that prints `[CI] buildGuid=... runId=... cellId=...` to Debug.Log once per player startup. The line lands in Player.log, which the workflow already uploads as an artifact. - Workflow passes `AUDIENCE_TEST_RUN_ID` (github.run_id + run_attempt) and `AUDIENCE_TEST_CELL_ID` (target + backend + unity) as env vars through the docker container, where Application reads them via Environment.GetEnvironmentVariable. - Adds a post-step that greps the `[CI]` line from Player.log and writes it into `$GITHUB_STEP_SUMMARY`. The summary is visible from the Actions UI without downloading the artifact. - buildGuid is per-build and already lands on every game_launch event the SDK emits via DeviceCollector. Pairing the [CI] line with the same buildGuid on CDP gives a one-to-one match between a CI cell and the events it produced - removes the ambiguity left by the EPYC + Mesa llvmpipe + LinuxPlayer fingerprint, which can be shared by other Linux PR runs in the same window. - Local / production runs leave the env vars unset; the printed values are empty. No production behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workflows/test-audience-sample-app.yml | 41 +++++++++++++++++++ .../SampleApp/Scripts/AudienceSample.cs | 17 ++++++++ 2 files changed, 58 insertions(+) diff --git a/.github/workflows/test-audience-sample-app.yml b/.github/workflows/test-audience-sample-app.yml index 1e84d39c3..8c8c477ac 100644 --- a/.github/workflows/test-audience-sample-app.yml +++ b/.github/workflows/test-audience-sample-app.yml @@ -454,6 +454,12 @@ jobs: AUDIENCE_TEST_PUBLISHABLE_KEY: ${{ secrets.AUDIENCE_TEST_PUBLISHABLE_KEY }} AUDIENCE_SCRIPTING_BACKEND: ${{ matrix.backend }} UNITY_VERSION: ${{ matrix.unity }} + # Provenance markers passed through to the player. The SampleApp + # logs `[CI] buildGuid=... runId=... cellId=...` at startup, and + # the post-step writes the line into the job summary so events + # on CDP can be matched 1:1 to this cell via buildGuid. + AUDIENCE_TEST_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }} + AUDIENCE_TEST_CELL_ID: ${{ matrix.target }}-${{ matrix.backend }}-${{ matrix.unity }} run: | set -uo pipefail mkdir -p artifacts @@ -468,6 +474,7 @@ jobs: --workdir /github/workspace \ --env UNITY_EMAIL --env UNITY_PASSWORD --env UNITY_SERIAL \ --env AUDIENCE_TEST_PUBLISHABLE_KEY --env AUDIENCE_SCRIPTING_BACKEND \ + --env AUDIENCE_TEST_RUN_ID --env AUDIENCE_TEST_CELL_ID \ --volume "$PWD":/github/workspace:z \ --cpus=8 --memory=30487m \ "$image" \ @@ -606,6 +613,40 @@ jobs: exit 1 fi + - name: Surface CI provenance to job summary + # The SampleApp prints `[CI] buildGuid=... runId=... cellId=...` + # to Player.log on player startup. buildGuid is per-build and + # already lands on every game_launch event the SDK emits, so a + # one-line grep here gives a 1:1 match between the CI cell and + # CDP rows. The job summary makes it copy-pasteable from the + # Actions UI without downloading the artifact. + if: always() + shell: bash + run: | + set -uo pipefail + log="" + for candidate in artifacts/Player-*.log; do + [ -f "$candidate" ] && { log="$candidate"; break; } + done + { + echo "## CI provenance" + if [ -z "$log" ]; then + echo "_No Player.log captured for this cell._" + exit 0 + fi + line=$(grep -m1 '\[CI\]' "$log" || true) + if [ -z "$line" ]; then + echo "_No [CI] line found in \`$log\`. The SampleApp's RuntimeInitializeOnLoad hook may not have fired._" + exit 0 + fi + echo + echo '```' + echo "$line" + echo '```' + echo + echo "Filter CDP for events with this \`buildGuid\` to see the rows from this cell." + } >> "$GITHUB_STEP_SUMMARY" + - name: Publish test report uses: dorny/test-reporter@v3 if: always() diff --git a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs index d43bca30c..a4434a72d 100644 --- a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs +++ b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs @@ -38,6 +38,23 @@ public sealed partial class AudienceSample : MonoBehaviour // ---- Lifecycle ---- + // Stamps a single, unique CI provenance line into Player.log on + // player startup. buildGuid is per-build and already lands on every + // game_launch event the SDK emits, so matching a CI artifact to the + // CDP rows is a one-line grep on either side. AUDIENCE_TEST_RUN_ID + // and AUDIENCE_TEST_CELL_ID are env vars the CI workflow injects; + // local / production runs leave them unset and the printed values + // are empty. The line lands before any test runs because the + // RuntimeInitialize hook fires before the first scene loads. + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + private static void LogCiProvenance() + { + var runId = Environment.GetEnvironmentVariable("AUDIENCE_TEST_RUN_ID") ?? string.Empty; + var cellId = Environment.GetEnvironmentVariable("AUDIENCE_TEST_CELL_ID") ?? string.Empty; + UnityEngine.Debug.Log( + $"[CI] buildGuid={Application.buildGUID} runId={runId} cellId={cellId}"); + } + private void Awake() { // InitializeUi must precede the Log.Writer swap — _logView has From 54cbf4aea08f60dd830b234d5a92fb68d79661fa Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 8 May 2026 15:39:07 +1000 Subject: [PATCH 07/19] ci(audience): replace fixed timeout with log-driven watchdog (SDK-317) - Drops the `timeout 1320` wrapper. Replaces it with a watchdog loop inside the docker bash that polls artifacts/playmode.log every 5 s and signals Unity 30 s after "Test run completed" first appears. - 40 min hard cap as a fallback for the case where Unity never logs "Test run completed" (player hang, etc.); 15 s SIGKILL grace after SIGTERM if the editor refuses to exit. - Bumps cell timeout-minutes from 30 to 45 to cover the inner 40 min cap plus post-Unity steps (license return, Player.log copy, artifact upload, dorny/test-reporter). - Why: a fixed timeout that fits Unity 2021.3 (~5-7 min cells) cuts Unity 6 off mid-run; a fixed timeout sized for Unity 6 makes 2021.3 cells wait up to 30+ min on a shutdown hang they would not have hit. The previous 22-min cap killed Unity 6 cells before tests could finish writing playmode-results.xml. The watchdog adapts to whatever the actual test runtime is, then catches the Unity 6 Linux shutdown hang ("Application is shutting down..." that never completes) without waiting on it. - Also captures `tail -F` of the log to job stdout while Unity is alive, so the live build / test progress streams to the GitHub Actions log as before. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workflows/test-audience-sample-app.yml | 119 ++++++++++++------ 1 file changed, 81 insertions(+), 38 deletions(-) diff --git a/.github/workflows/test-audience-sample-app.yml b/.github/workflows/test-audience-sample-app.yml index 8c8c477ac..61ccc9059 100644 --- a/.github/workflows/test-audience-sample-app.yml +++ b/.github/workflows/test-audience-sample-app.yml @@ -412,11 +412,13 @@ jobs: || github.event_name == 'workflow_dispatch' name: ${{ matrix.target }} / ${{ matrix.backend }} / Unity ${{ matrix.unity }} runs-on: ubuntu-latest-8-cores - # Tests now actually execute under xvfb instead of returning instantly - # as inconclusive, so cells take ~5-10 min. The 30 min cap leaves - # headroom for cold caches and the first image pull without leaving - # a stuck job sitting on the runner for the default 6 hours. - timeout-minutes: 30 + # Cells settle to ~5-7 min for Unity 2021.3 (both backends) and + # ~22-25 min for Unity 6000.4 (Mono). Unity 6 IL2CPP on Mesa + # software OpenGL is the slow path. The 45 min cap covers the inner + # 40 min watchdog cap plus post-Unity steps (license return, + # Player.log copy, artifact upload, dorny/test-reporter) without + # leaving a stuck job sitting on the runner for the default 6 hours. + timeout-minutes: 45 strategy: fail-fast: false matrix: @@ -507,40 +509,81 @@ jobs: # the player. GLX + render are required for UIElements; the # image already bundles mesa-llvmpipe for software OpenGL, so # no GPU is needed. -noreset keeps the X server up across - # Unity client reconnects (the editor opens / closes / reopens - # connections during scene load). + # Unity client reconnects. # - # `timeout` wraps the whole xvfb-run + unity-editor pipeline so - # the editor cannot stall the cell past 22 minutes. Unity 6 on - # Linux has a known shutdown hang: tests complete, the runner - # writes playmode-results.xml, then the editor begins - # `Application is shutting down...` and never fully exits - # (likely a leftover thread or a player process xvfb-run is - # still tied to). Without the wrapper, the cell sits idle until - # the GitHub 30-min cap and the upload step never gets to run. - # 22 min covers Unity 6 IL2CPP build (~5 min) + 39-test suite - # execution (~10-15 min) with a 2-min buffer. SIGTERM first via - # --signal=TERM so Unity can flush; --kill-after=10 forces - # SIGKILL 10 s later if it is still alive. The 8 min slack to - # the cell timeout lets the post-steps (license return, Player - # log capture, artifact upload) all run. - set +e - timeout --signal=TERM --kill-after=10 1320 \ - xvfb-run -a --server-args="-screen 0 1280x720x24 -ac +extension GLX +render -noreset" -- \ - unity-editor \ - -batchmode \ - -projectPath /github/workspace/examples/audience \ - -runTests \ - -testPlatform StandaloneLinux64 \ - -testResults /github/workspace/artifacts/playmode-results.xml \ - -logFile - 2>&1 | tee /github/workspace/artifacts/playmode.log - test_rc=${PIPESTATUS[0]} - set -uo pipefail - # exit 124 = timeout fired, 137 = SIGKILL via --kill-after. - # Either way, log it explicitly so the artifact reader can tell - # a real test failure from a shutdown-hang kill. - if [ "$test_rc" = "124" ] || [ "$test_rc" = "137" ]; then - echo "::warning::Unity exceeded the 22-min budget and was killed (rc=$test_rc). Tests likely completed; the editor failed to shut down." + # Why the watchdog instead of a simple `timeout` wrapper: + # - Unity 6 on Linux has a known shutdown hang. After + # "Test run completed", the editor begins + # `Application is shutting down...` and never fully exits. + # Without intervention the cell sits idle until the cell + # timeout fires before the post-Unity steps can run. + # - Tests on Unity 6 + Mesa software OpenGL take ~22 min for + # Mono and longer for IL2CPP, vs ~2 min on Unity 2021.3. + # A fixed timeout that fits 2021.3 cuts Unity 6 off mid-run; + # a fixed timeout sized for Unity 6 makes 2021.3 cells wait + # up to 30+ min on a shutdown hang they would not have hit. + # - The watchdog adapts: it scans the log, sees + # "Test run completed" the moment Unity finishes the suite, + # gives the editor 30 s to flush playmode-results.xml, then + # sends SIGTERM. SIGKILL follows 15 s later if the editor + # refuses to exit. Cells finish as soon as their tests do, + # regardless of how slow the underlying Unity version is or + # what shutdown bug it happens to hit. + # - 40 min hard cap as a fallback for the case where + # "Test run completed" never appears (player hang, etc.). + log=/github/workspace/artifacts/playmode.log + + xvfb-run -a --server-args="-screen 0 1280x720x24 -ac +extension GLX +render -noreset" -- \ + unity-editor \ + -batchmode \ + -projectPath /github/workspace/examples/audience \ + -runTests \ + -testPlatform StandaloneLinux64 \ + -testResults /github/workspace/artifacts/playmode-results.xml \ + -logFile "$log" & + unity_pid=$! + + # Stream the log to job stdout for live visibility while the + # editor is alive. tail --pid exits when unity_pid does. + tail --pid=$unity_pid -F "$log" 2>/dev/null & + + deadline=$((SECONDS + 2400)) # 40 min hard cap + flush_deadline=0 + kill_reason="" + while kill -0 $unity_pid 2>/dev/null; do + if [ "$SECONDS" -ge "$deadline" ]; then + kill_reason="hard-cap-40m" + break + fi + if [ "$flush_deadline" -eq 0 ] && grep -q "Test run completed" "$log" 2>/dev/null; then + flush_deadline=$((SECONDS + 30)) + echo "[watchdog] saw \"Test run completed\" at ${SECONDS}s; SIGTERM after 30s flush window" + fi + if [ "$flush_deadline" -gt 0 ] && [ "$SECONDS" -ge "$flush_deadline" ]; then + kill_reason="flush-window-elapsed" + break + fi + sleep 5 + done + + if [ -n "$kill_reason" ]; then + echo "[watchdog] sending SIGTERM to Unity (reason: $kill_reason)" + kill -TERM $unity_pid 2>/dev/null || true + # 15 s grace, then SIGKILL if still alive. + for _ in 1 2 3; do + kill -0 $unity_pid 2>/dev/null || break + sleep 5 + done + if kill -0 $unity_pid 2>/dev/null; then + echo "[watchdog] SIGTERM not honored, sending SIGKILL" + kill -KILL $unity_pid 2>/dev/null || true + fi + fi + + wait $unity_pid 2>/dev/null + test_rc=$? + if [ "$kill_reason" = "hard-cap-40m" ]; then + echo "::warning::Unity hit the 40 min hard cap without logging \"Test run completed\". The player may have hung mid-suite. Inspect Player.log to see how far it got." fi # Capture the standalone test player log. PlayMode tests on From 5c2a9c5011dff7cc5d29252fe1234cd26b0a732d Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 8 May 2026 16:31:55 +1000 Subject: [PATCH 08/19] fix(audience): treat missing queue dir as empty queue in ReadBatch (SDK-341) - DiskStore.ReadBatch was the one path that called Directory.GetFiles(_queueDir) without the DirectoryNotFoundException guard the rest of the methods (DeleteAll, ApplyAnonymousDowngrade, TryDelete) already use. - Linux Mono / IL2CPP test cells run a SetUp that deletes the SDK persistent dir between every test. A background flush timer started by the prior test can fire after the delete; the resulting GetFiles raised DirectoryNotFoundException which propagated through HttpTransport.SendBatchAsync to OnError, made the SampleApp log "flush() Err" instead of "flush() Ok", and failed the test. - Same shape was hitting Shutdown's flush path: the AggregateException the caller saw was wrapping the same DirectoryNotFoundException. - Catch + return Array.Empty(). Empty result is the correct semantics: a deleted queue dir has no events to send. Surfaced by run 25539153233 once SDK-317 (PR #754) landed real Linux PlayMode coverage. Affected 9 cells (2 Mono, 7 IL2CPP) on Unity 6. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Audience/Runtime/Transport/DiskStore.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Packages/Audience/Runtime/Transport/DiskStore.cs b/src/Packages/Audience/Runtime/Transport/DiskStore.cs index b407a7f37..901a0a186 100644 --- a/src/Packages/Audience/Runtime/Transport/DiskStore.cs +++ b/src/Packages/Audience/Runtime/Transport/DiskStore.cs @@ -65,9 +65,18 @@ internal IReadOnlyList ReadBatch(int maxSize) var result = new List(); - // Sort by filename (ticks prefix) → oldest first - var files = Directory.GetFiles(_queueDir, "*.json") - .OrderBy(f => Path.GetFileName(f), StringComparer.Ordinal); + // Sort by filename (ticks prefix) → oldest first. + // The queue dir can disappear out from under us when an external + // process (test SetUp, manual cleanup, persistentDataPath wipe) + // deletes it between SDK Init and the next flush tick. Treat that + // as an empty queue rather than letting DirectoryNotFoundException + // bubble up through SendBatchAsync to the caller's OnError. + // Matches the existing guard in DeleteAll / ApplyAnonymousDowngrade. + string[] paths; + try { paths = Directory.GetFiles(_queueDir, "*.json"); } + catch (DirectoryNotFoundException) { return Array.Empty(); } + + var files = paths.OrderBy(f => Path.GetFileName(f), StringComparer.Ordinal); foreach (var path in files) { From 356d18dc6ea2de8b46b179347e3772bcacfb95ac Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Fri, 8 May 2026 21:03:52 +1000 Subject: [PATCH 09/19] test(audience): add ci marker test that stamps run metadata on CDP (SDK-317) - New [UnityTest] AudienceCiTestMarker_EmitsRunMetadata. Skips outside CI (AUDIENCE_TEST_RUN_ID and AUDIENCE_TEST_CELL_ID both unset). Inside CI, calls LoadAndInit then emits one ImmutableAudience.Track("audience_ci_test_marker", { source: "ci", ciRunId, ciCellId }). - Lets dashboards filter test traffic in / out: - select event_name = "audience_ci_test_marker" lists every CI run that hit CDP, with run / cell metadata directly on the row. - properties.buildGuid on the marker matches the buildGuid the SDK auto-emits on every game_launch event from the same cell binary, so a follow-up filter pulls the rest of the cell's events. - Standalone test rather than a hook in LoadAndInit so the marker stays explicit and opt-in. Other tests do not silently emit a side-event; the SampleApp and SDK carry no CI / QA-specific knowledge. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Tests/Runtime/SampleAppLiveFireTests.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs b/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs index aef4b65be..8e2660b73 100644 --- a/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs +++ b/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs @@ -128,6 +128,30 @@ private IEnumerator SetConsentVia(string consentButtonName) // ---- Tests ---- + // Marks this as coming from CI. + [UnityTest] + public IEnumerator AudienceCiTestMarker_EmitsRunMetadata() + { + var runId = Environment.GetEnvironmentVariable("AUDIENCE_TEST_RUN_ID"); + var cellId = Environment.GetEnvironmentVariable("AUDIENCE_TEST_CELL_ID"); + if (string.IsNullOrEmpty(runId) && string.IsNullOrEmpty(cellId)) + { + Assert.Ignore("Not running in CI."); + yield break; + } + + yield return LoadAndInit(); + + ImmutableAudience.Track("audience_ci_test_marker", new System.Collections.Generic.Dictionary + { + ["source"] = "ci", + ["ciRunId"] = runId ?? string.Empty, + ["ciCellId"] = cellId ?? string.Empty, + }); + + yield return null; + } + [UnityTest] public IEnumerator InitTrackFlush_AgainstSandbox_FlushReportsOk() { From 3420c1239c2c9e6b158174876a315bdf0f2f28e8 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 9 May 2026 10:48:18 +1000 Subject: [PATCH 10/19] ci(audience): lift ci marker env vars to workflow / job scope (SDK-317) - AUDIENCE_TEST_RUN_ID moves to workflow-level env (set once per workflow run, no matrix dependency). - AUDIENCE_TEST_CELL_ID moves to job-level env on the playmode (Windows + macOS self-hosted) and playmode-linux (GitHub-hosted) jobs. Job scope so matrix.target / matrix.backend / matrix.unity resolve. Both test jobs now share the same definition. - Drops the duplicated step-level env entries inside the Linux Run PlayMode tests step. Step env now only carries credentials and matrix.backend / matrix.unity passthroughs. - The Linux docker invocation already passes the vars through with --env AUDIENCE_TEST_RUN_ID --env AUDIENCE_TEST_CELL_ID; that line reads the values from the inherited shell env regardless of where they were originally set. - Result: every CI cell on every platform (Linux + Windows + macOS) emits AudienceCiTestMarker_EmitsRunMetadata with the correct ciRunId / ciCellId, defined in two places instead of three. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test-audience-sample-app.yml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test-audience-sample-app.yml b/.github/workflows/test-audience-sample-app.yml index 61ccc9059..eba60c694 100644 --- a/.github/workflows/test-audience-sample-app.yml +++ b/.github/workflows/test-audience-sample-app.yml @@ -25,6 +25,13 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +# CI run identifier injected into the player so events on CDP can be +# filtered as test traffic. Run id is workflow-wide; matrix-aware cell +# id is set on the test jobs below. The marker test reads these env +# vars and Assert.Ignores when both are unset. +env: + AUDIENCE_TEST_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }} + jobs: # Reduced matrix on pull_request, full matrix on schedule and # workflow_dispatch. The self-hosted Windows runner pool is small, so @@ -60,6 +67,8 @@ jobs: # short releases the self-hosted runner sooner so queued cells can # progress instead of waiting 60 min on a stuck job. timeout-minutes: 30 + env: + AUDIENCE_TEST_CELL_ID: ${{ matrix.target }}-${{ matrix.backend }}-${{ matrix.unity }} steps: - name: Kill stale Unity processes (Windows pre-checkout) @@ -419,6 +428,8 @@ jobs: # Player.log copy, artifact upload, dorny/test-reporter) without # leaving a stuck job sitting on the runner for the default 6 hours. timeout-minutes: 45 + env: + AUDIENCE_TEST_CELL_ID: ${{ matrix.target }}-${{ matrix.backend }}-${{ matrix.unity }} strategy: fail-fast: false matrix: @@ -456,12 +467,6 @@ jobs: AUDIENCE_TEST_PUBLISHABLE_KEY: ${{ secrets.AUDIENCE_TEST_PUBLISHABLE_KEY }} AUDIENCE_SCRIPTING_BACKEND: ${{ matrix.backend }} UNITY_VERSION: ${{ matrix.unity }} - # Provenance markers passed through to the player. The SampleApp - # logs `[CI] buildGuid=... runId=... cellId=...` at startup, and - # the post-step writes the line into the job summary so events - # on CDP can be matched 1:1 to this cell via buildGuid. - AUDIENCE_TEST_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }} - AUDIENCE_TEST_CELL_ID: ${{ matrix.target }}-${{ matrix.backend }}-${{ matrix.unity }} run: | set -uo pipefail mkdir -p artifacts From 923cc69defe40ad8ee0e34e0cd0d9e7edaba8e31 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 9 May 2026 11:56:59 +1000 Subject: [PATCH 11/19] chore(audience): log every DiskStore counter mutation (SDK-341 trace) Temporary instrumentation. Logs to Player.log: - ctor initial count + dir + instance hash - every BumpCount delta + new value + stack trace + instance hash Goal: identify which call sites fire BumpCount(-1) without matching BumpCount(+1) in the StatusBar_QueueSizeIncrementsAfterTrack failing on Linux Unity 6 cells. Revert before merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Packages/Audience/Runtime/Transport/DiskStore.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Packages/Audience/Runtime/Transport/DiskStore.cs b/src/Packages/Audience/Runtime/Transport/DiskStore.cs index 901a0a186..14db77c29 100644 --- a/src/Packages/Audience/Runtime/Transport/DiskStore.cs +++ b/src/Packages/Audience/Runtime/Transport/DiskStore.cs @@ -26,6 +26,8 @@ internal DiskStore(string persistentDataPath) _queueDir = Path.Combine(persistentDataPath, "imtbl_audience", "queue"); Directory.CreateDirectory(_queueDir); _cachedCount = Directory.GetFiles(_queueDir, "*.json").Length; + // DEBUG SDK-341: trace counter init. + Console.WriteLine($"[DiskStore] ctor initial count={_cachedCount} dir={_queueDir} hash={GetHashCode()}"); } // Atomically writes json as a new event file. @@ -113,7 +115,13 @@ internal void Delete(IEnumerable paths) // count seeded at construction; mutating ops maintain it. internal int Count() => Volatile.Read(ref _cachedCount); - private void BumpCount(int delta) => Interlocked.Add(ref _cachedCount, delta); + // DEBUG SDK-341: log every counter mutation with stack so we can see + // who decrements without a matching increment. Revert before merge. + private void BumpCount(int delta) + { + var newValue = Interlocked.Add(ref _cachedCount, delta); + Console.WriteLine($"[DiskStore] BumpCount({delta:+#;-#;0}) -> {newValue} hash={GetHashCode()}\n{System.Environment.StackTrace}"); + } private static bool TryDelete(string path) { From 8bdb0b0fa2dd69c5a81482c259bea41e93a486a2 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 9 May 2026 16:12:18 +1000 Subject: [PATCH 12/19] ci(audience): shrink xvfb screen and force GLcore for Unity 6 perf (SDK-340) - Drops xvfb screen from 1280x720 to 320x240 (12x fewer pixels per frame). Per-frame fill on mesa-llvmpipe is the dominant cost on Unity 6 Linux; smaller render target attacks the bottleneck directly. Tests never assert on pixel content, only on UI Toolkit log-row presence via VisualElement tree, so layout still works. - Adds -force-glcore -screen-fullscreen 0 -screen-width 320 -screen-height 240 to the Unity args. Unity 6 prefers Vulkan on Linux and falls back to OpenGL after a per-frame negotiation overhead. -force-glcore skips that, mirroring the path Unity 2021.3 takes by default. Explicit -screen-* matches the xvfb size so the player does not request a resize. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workflows/test-audience-sample-app.yml | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-audience-sample-app.yml b/.github/workflows/test-audience-sample-app.yml index eba60c694..1e0be9918 100644 --- a/.github/workflows/test-audience-sample-app.yml +++ b/.github/workflows/test-audience-sample-app.yml @@ -538,9 +538,27 @@ jobs: # "Test run completed" never appears (player hang, etc.). log=/github/workspace/artifacts/playmode.log - xvfb-run -a --server-args="-screen 0 1280x720x24 -ac +extension GLX +render -noreset" -- \ + # Why a tiny screen: the per-frame fragment-shader cost on + # mesa-llvmpipe scales with pixel count. 1280x720 = 0.92M + # px per frame; 320x240 = 0.08M px - a 12x reduction in + # software-rendered fill work, which is the dominant cost + # on Unity 6 Linux. UI Toolkit lays out fine on this size + # because the test never asserts on rendered pixel content, + # only on UI Toolkit log-row presence (queried via the + # VisualElement tree, not via screen capture). + # + # Why -force-glcore: Unity 6 prefers Vulkan on Linux and + # falls back to OpenGL when Vulkan init fails. Each frame + # carries the negotiation overhead. -force-glcore tells the + # player to skip Vulkan entirely and open a GLX context + # directly, the same path Unity 2021.3 takes by default. + xvfb-run -a --server-args="-screen 0 320x240x24 -ac +extension GLX +render -noreset" -- \ unity-editor \ -batchmode \ + -force-glcore \ + -screen-fullscreen 0 \ + -screen-width 320 \ + -screen-height 240 \ -projectPath /github/workspace/examples/audience \ -runTests \ -testPlatform StandaloneLinux64 \ From aaf44cb4222664e6134d40ceaa05610b3c7aea77 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sun, 10 May 2026 06:48:40 +1000 Subject: [PATCH 13/19] test(audience-sample): poll at 50ms wall-clock instead of per-frame WaitForCondition and WaitForLogEntry yielded once per Unity frame, which on Linux + Mesa llvmpipe under Unity 6 ran at 1 to 2 fps. Each predicate check therefore happened only once per second and a 30 second budget hit the cap routinely, dragging the 39-test suite from a few minutes into the half-hour band. Switching the yield to WaitForSecondsRealtime(0.05f) decouples polling from frame pacing. Tests check 20 times per second regardless of render rate, exit as soon as the condition holds, and frame-rate-bound players no longer artificially extend the wall time. --- .../Tests/Runtime/SampleAppTestHelpers.cs | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppTestHelpers.cs b/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppTestHelpers.cs index 2da909fb6..a6ee5233c 100644 --- a/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppTestHelpers.cs +++ b/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppTestHelpers.cs @@ -15,35 +15,46 @@ namespace Immutable.Audience.Samples.SampleApp.Tests // the log pane via the userData stash on each log row. internal static class SampleAppTestHelpers { - // Polls predicate once per frame until it returns true or the deadline - // elapses. Calls Assert.Fail with description when the deadline is hit. - // Use this instead of WaitForSecondsRealtime when a test is waiting - // "at most N seconds for X to become true" — the polling exits as soon - // as the condition is satisfied rather than burning the full N seconds. + // Wall-clock interval between predicate checks for WaitForCondition + // and WaitForLogEntry. Decoupled from frame pacing so tests poll at a + // fixed cadence regardless of how fast the player renders. On Linux + // PlayMode under llvmpipe Unity 6 runs at 1 to 2 fps; per-frame + // polling there made the polling interval one full second, so a + // suite that should finish in seconds dragged into tens of minutes. + private const float PollIntervalSeconds = 0.05f; + + // Polls predicate at PollIntervalSeconds wall-clock cadence until it + // returns true or the deadline elapses. Calls Assert.Fail with + // description when the deadline is hit. Use this instead of + // WaitForSecondsRealtime when a test is waiting "at most N seconds + // for X to become true": the polling exits as soon as the condition + // is satisfied rather than burning the full N seconds. internal static IEnumerator WaitForCondition( Func predicate, float timeoutSeconds, string description) { var deadline = Time.realtimeSinceStartup + timeoutSeconds; + var poll = new WaitForSecondsRealtime(PollIntervalSeconds); while (Time.realtimeSinceStartup < deadline) { if (predicate()) yield break; - yield return null; + yield return poll; } Assert.Fail($"Timed out after {timeoutSeconds:F1}s waiting for: {description}"); } // Wait until the log pane contains an entry whose label matches `label` - // and whose level matches `level`. Yields one frame per check. + // and whose level matches `level`. Polls at PollIntervalSeconds. // Throws TimeoutException on deadline. internal static IEnumerator WaitForLogEntry( VisualElement root, string label, int level, float timeoutSec) { var deadline = Time.realtimeSinceStartup + timeoutSec; + var poll = new WaitForSecondsRealtime(PollIntervalSeconds); while (Time.realtimeSinceStartup < deadline) { if (HasLogEntry(root, label, level)) yield break; - yield return null; + yield return poll; } throw new TimeoutException( $"Log entry not found within {timeoutSec}s. " + From 799ae238d529cbab544bfcf0dcb96037e4aac40b Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sun, 10 May 2026 06:50:18 +1000 Subject: [PATCH 14/19] ci(audience): force StandaloneLinux64 player to OpenGLCore only at build time Adds GraphicsApisLinuxOverride, an editor build hook gated on the AUDIENCE_LINUX_GLCORE_ONLY env var. When the env is set and the build target is StandaloneLinux64, it pins PlayerSettings graphics APIs to OpenGLCore only, dropping Vulkan from the active list. The Linux container has no GPU and the player runs on Mesa llvmpipe via -force-glcore. Vulkan was active in the build only because Unity's default Standalone API list includes it; the shader compiler emitted both glcore and vulkan variants for every shader. On the Unity 6 cell this was 213 of 413 compiles wasted on a code path the player never hit. Wires the env var through the playmode-linux job's docker run so the override fires on Linux PlayMode CI builds. Local builds and other targets see no change because the env var is unset and the hook short circuits on non-Linux build targets. --- .../workflows/test-audience-sample-app.yml | 2 + .../Editor/GraphicsApisLinuxOverride.cs | 56 +++++++++++++++++++ .../Editor/GraphicsApisLinuxOverride.cs.meta | 11 ++++ 3 files changed, 69 insertions(+) create mode 100644 examples/audience/Assets/Editor/GraphicsApisLinuxOverride.cs create mode 100644 examples/audience/Assets/Editor/GraphicsApisLinuxOverride.cs.meta diff --git a/.github/workflows/test-audience-sample-app.yml b/.github/workflows/test-audience-sample-app.yml index 1e0be9918..c304d6a4d 100644 --- a/.github/workflows/test-audience-sample-app.yml +++ b/.github/workflows/test-audience-sample-app.yml @@ -467,6 +467,7 @@ jobs: AUDIENCE_TEST_PUBLISHABLE_KEY: ${{ secrets.AUDIENCE_TEST_PUBLISHABLE_KEY }} AUDIENCE_SCRIPTING_BACKEND: ${{ matrix.backend }} UNITY_VERSION: ${{ matrix.unity }} + AUDIENCE_LINUX_GLCORE_ONLY: "1" run: | set -uo pipefail mkdir -p artifacts @@ -482,6 +483,7 @@ jobs: --env UNITY_EMAIL --env UNITY_PASSWORD --env UNITY_SERIAL \ --env AUDIENCE_TEST_PUBLISHABLE_KEY --env AUDIENCE_SCRIPTING_BACKEND \ --env AUDIENCE_TEST_RUN_ID --env AUDIENCE_TEST_CELL_ID \ + --env AUDIENCE_LINUX_GLCORE_ONLY \ --volume "$PWD":/github/workspace:z \ --cpus=8 --memory=30487m \ "$image" \ diff --git a/examples/audience/Assets/Editor/GraphicsApisLinuxOverride.cs b/examples/audience/Assets/Editor/GraphicsApisLinuxOverride.cs new file mode 100644 index 000000000..681d0406a --- /dev/null +++ b/examples/audience/Assets/Editor/GraphicsApisLinuxOverride.cs @@ -0,0 +1,56 @@ +#nullable enable + +using System; +using UnityEditor; +using UnityEditor.Build; +using UnityEditor.Build.Reporting; +using UnityEngine; +using UnityEngine.Rendering; + +namespace Immutable.Audience.Samples.SampleApp.Editor +{ + // Build pre-process hook that restricts the StandaloneLinux64 player to + // OpenGLCore when AUDIENCE_LINUX_GLCORE_ONLY is set. + // + // Why: the unityci/editor Linux container has no GPU. Unity falls back + // to Mesa software OpenGL via llvmpipe. The runtime -force-glcore flag + // picks OpenGL when the player launches, but the build still ships + // every active graphics API's shader variants. With Vulkan also active + // by default, the shader compiler emits both glcore and vulkan + // variants. Roughly half of the 413 shader compiles measured on Unity + // 6 Linux were wasted on Vulkan variants the player never used. + // + // The hook runs only when the env flag is set, only for the + // StandaloneLinux64 build target, and only modifies in-memory + // PlayerSettings during the build. Other Standalone targets (Win, Mac) + // and other CI workflows are unaffected. Local builds without the env + // var see no change. + // + // Usage: + // AUDIENCE_LINUX_GLCORE_ONLY=1 Unity -batchmode -buildTarget StandaloneLinux64 -runTests ... + internal sealed class GraphicsApisLinuxOverride : IPreprocessBuildWithReport + { + private const string EnvVar = "AUDIENCE_LINUX_GLCORE_ONLY"; + + public int callbackOrder => 1; + + public void OnPreprocessBuild(BuildReport report) + { + if (report.summary.platform != BuildTarget.StandaloneLinux64) return; + + var requested = Environment.GetEnvironmentVariable(EnvVar); + if (string.IsNullOrEmpty(requested)) return; + + var current = PlayerSettings.GetGraphicsAPIs(BuildTarget.StandaloneLinux64); + if (current.Length == 1 && current[0] == GraphicsDeviceType.OpenGLCore) + { + Debug.Log($"[{nameof(GraphicsApisLinuxOverride)}] StandaloneLinux64 already at OpenGLCore only."); + return; + } + + PlayerSettings.SetUseDefaultGraphicsAPIs(BuildTarget.StandaloneLinux64, false); + PlayerSettings.SetGraphicsAPIs(BuildTarget.StandaloneLinux64, new[] { GraphicsDeviceType.OpenGLCore }); + Debug.Log($"[{nameof(GraphicsApisLinuxOverride)}] StandaloneLinux64 graphics APIs forced to OpenGLCore. Vulkan shader variants will be skipped."); + } + } +} diff --git a/examples/audience/Assets/Editor/GraphicsApisLinuxOverride.cs.meta b/examples/audience/Assets/Editor/GraphicsApisLinuxOverride.cs.meta new file mode 100644 index 000000000..dc4f709e2 --- /dev/null +++ b/examples/audience/Assets/Editor/GraphicsApisLinuxOverride.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5b2e8a9c4d3f7c5e9b1a2c3d4e5f6a7b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From e7718f664af54f9f5c136177079d540fc7a3e34b Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sun, 10 May 2026 06:51:44 +1000 Subject: [PATCH 15/19] ci(audience): drop redundant xvfb -screen flag from server-args The previous xvfb-run line passed -screen 0 320x240x24 in --server-args intending to shrink the virtual desktop to cut llvmpipe fragment-fill work. The player log on PR #764 (the deep profile capture) shows the xvfb desktop is 1280x1024 anyway: xvfb-run with -a auto-picks a display number and its own default screen geometry, and the supplied -screen override does not always take effect. The flag did not change behaviour. Unity opens its own GL context at -screen-width by -screen-height (320 by 240, set on the editor command line below), so per-frame fragment fill is already at that resolution regardless of the xvfb desktop size. Drops the redundant -screen flag while keeping the X11 extension flags that do matter (-ac, +extension GLX, +render, -noreset). Updates the adjacent comment block to credit Unity's own -screen-width/-screen-height as the actual fill-rate control. --- .../workflows/test-audience-sample-app.yml | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test-audience-sample-app.yml b/.github/workflows/test-audience-sample-app.yml index c304d6a4d..1aaec04e2 100644 --- a/.github/workflows/test-audience-sample-app.yml +++ b/.github/workflows/test-audience-sample-app.yml @@ -540,21 +540,24 @@ jobs: # "Test run completed" never appears (player hang, etc.). log=/github/workspace/artifacts/playmode.log - # Why a tiny screen: the per-frame fragment-shader cost on - # mesa-llvmpipe scales with pixel count. 1280x720 = 0.92M - # px per frame; 320x240 = 0.08M px - a 12x reduction in - # software-rendered fill work, which is the dominant cost - # on Unity 6 Linux. UI Toolkit lays out fine on this size - # because the test never asserts on rendered pixel content, - # only on UI Toolkit log-row presence (queried via the - # VisualElement tree, not via screen capture). + # The player renders at 320x240 (Unity -screen-width and + # -screen-height below). xvfb's own screen size does not + # affect fragment fill because Unity creates its own GL + # context at the requested window size. xvfb-run with -a + # picks an unused display number and uses its default + # screen geometry; the X11 extension flags below are the + # only --server-args we actually need. Earlier comments + # in this file claimed the xvfb -screen flag was the + # source of the per-frame fill reduction; it was not. + # UI Toolkit lays out fine because tests assert on the + # VisualElement tree, not on rendered pixels. # # Why -force-glcore: Unity 6 prefers Vulkan on Linux and # falls back to OpenGL when Vulkan init fails. Each frame # carries the negotiation overhead. -force-glcore tells the # player to skip Vulkan entirely and open a GLX context # directly, the same path Unity 2021.3 takes by default. - xvfb-run -a --server-args="-screen 0 320x240x24 -ac +extension GLX +render -noreset" -- \ + xvfb-run -a --server-args="-ac +extension GLX +render -noreset" -- \ unity-editor \ -batchmode \ -force-glcore \ From 2d03ea7c213635fa0b0cf82029a3a55127567cab Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sun, 10 May 2026 07:04:28 +1000 Subject: [PATCH 16/19] ci(audience): fix apostrophe in docker bash heredoc comment Commit e7718f66 added a comment block above the xvfb-run line that contained 'xvfb's own screen size'. The apostrophe terminated the outer bash -c '...' single-quoted heredoc, the rest of the inner script became outer-shell tokens, and every Linux PlayMode cell on PR #765 exited 1 right after license activation (cell time 90 seconds). Rewords the comment without the apostrophe. No semantic change to the docker invocation or the Unity command line. --- .github/workflows/test-audience-sample-app.yml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test-audience-sample-app.yml b/.github/workflows/test-audience-sample-app.yml index 1aaec04e2..5aeb786bd 100644 --- a/.github/workflows/test-audience-sample-app.yml +++ b/.github/workflows/test-audience-sample-app.yml @@ -541,14 +541,15 @@ jobs: log=/github/workspace/artifacts/playmode.log # The player renders at 320x240 (Unity -screen-width and - # -screen-height below). xvfb's own screen size does not - # affect fragment fill because Unity creates its own GL - # context at the requested window size. xvfb-run with -a - # picks an unused display number and uses its default - # screen geometry; the X11 extension flags below are the - # only --server-args we actually need. Earlier comments - # in this file claimed the xvfb -screen flag was the - # source of the per-frame fill reduction; it was not. + # -screen-height below). The xvfb desktop size does not + # affect fragment fill because Unity creates a GL context + # at the requested window size, regardless of how big the + # underlying X desktop is. xvfb-run with -a picks an + # unused display number and uses a default screen + # geometry; the X11 extension flags below are the only + # --server-args we actually need. Earlier comments in + # this file claimed the xvfb -screen flag was the source + # of the per-frame fill reduction; it was not. # UI Toolkit lays out fine because tests assert on the # VisualElement tree, not on rendered pixels. # From 5c1040c42b6c4b948e43202815b3f56f17bec9a1 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sun, 10 May 2026 08:09:26 +1000 Subject: [PATCH 17/19] test(audience-sample): capture player-side profile on Linux PlayMode runs Adds PlayerProfilerLogger gated on UNITY_STANDALONE_LINUX and the AUDIENCE_PLAYER_PROFILE_PATH env var. At BeforeSceneLoad inside the test player process, points UnityEngine.Profiling.Profiler at the configured path and starts a binary log of every captured frame. PR #764 captured a deep profile of the editor process. The actual test loop runs in a separate PlayerWithTests subprocess that the editor launches and waits on. The editor profile only showed roughly 86 sec of editor activity over a 27 min cell, the rest being editor idle waiting on the player. This hook plugs that gap. Wires the env var through the playmode-linux job's docker run and adds player-profile.raw to the upload-artifact path so the capture is downloadable for offline analysis in Unity Editor. Note: enables regular profiling, not deep. Deep profiling on the player would need -deepprofiling on the player command line, which Unity Test Framework does not expose for editor-launched test players. Regular profiling still surfaces per-frame CPU and the function hot list, which is what we need to identify what is eating roughly 37 sec per test. --- .../workflows/test-audience-sample-app.yml | 3 + .../Tests/Runtime/PlayerProfilerLogger.cs | 65 +++++++++++++++++++ .../Runtime/PlayerProfilerLogger.cs.meta | 11 ++++ 3 files changed, 79 insertions(+) create mode 100644 examples/audience/Assets/SampleApp/Tests/Runtime/PlayerProfilerLogger.cs create mode 100644 examples/audience/Assets/SampleApp/Tests/Runtime/PlayerProfilerLogger.cs.meta diff --git a/.github/workflows/test-audience-sample-app.yml b/.github/workflows/test-audience-sample-app.yml index 5aeb786bd..452aab382 100644 --- a/.github/workflows/test-audience-sample-app.yml +++ b/.github/workflows/test-audience-sample-app.yml @@ -468,6 +468,7 @@ jobs: AUDIENCE_SCRIPTING_BACKEND: ${{ matrix.backend }} UNITY_VERSION: ${{ matrix.unity }} AUDIENCE_LINUX_GLCORE_ONLY: "1" + AUDIENCE_PLAYER_PROFILE_PATH: "/github/workspace/artifacts/player-profile.raw" run: | set -uo pipefail mkdir -p artifacts @@ -484,6 +485,7 @@ jobs: --env AUDIENCE_TEST_PUBLISHABLE_KEY --env AUDIENCE_SCRIPTING_BACKEND \ --env AUDIENCE_TEST_RUN_ID --env AUDIENCE_TEST_CELL_ID \ --env AUDIENCE_LINUX_GLCORE_ONLY \ + --env AUDIENCE_PLAYER_PROFILE_PATH \ --volume "$PWD":/github/workspace:z \ --cpus=8 --memory=30487m \ "$image" \ @@ -737,6 +739,7 @@ jobs: artifacts/playmode.log artifacts/activation.log artifacts/Player-*.log + artifacts/player-profile.raw examples/audience/Logs/** # Mobile IL2CPP build validation — runs on GitHub-hosted Ubuntu via GameCI Docker diff --git a/examples/audience/Assets/SampleApp/Tests/Runtime/PlayerProfilerLogger.cs b/examples/audience/Assets/SampleApp/Tests/Runtime/PlayerProfilerLogger.cs new file mode 100644 index 000000000..8ab9f80a6 --- /dev/null +++ b/examples/audience/Assets/SampleApp/Tests/Runtime/PlayerProfilerLogger.cs @@ -0,0 +1,65 @@ +#nullable enable + +#if UNITY_STANDALONE_LINUX +using System; +using System.IO; +using UnityEngine; +using UnityEngine.Profiling; + +namespace Immutable.Audience.Samples.SampleApp.Tests +{ + // Linux PlayMode test player profiler hook. + // + // The editor profile we captured on PR #764 only covered the editor + // process. The actual test loop runs in a separate PlayerWithTests + // subprocess, which never had a profiler attached. This hook plugs + // that gap from inside the player itself. + // + // Behaviour: at BeforeSceneLoad, reads AUDIENCE_PLAYER_PROFILE_PATH + // from the player process env. When set, points + // UnityEngine.Profiling.Profiler at that path and starts a binary + // log of every captured frame. Output can be loaded into Unity + // Editor: Window > Analysis > Profiler > Load Profile. + // + // Engages only on StandaloneLinux64 builds (gated by the #if) and + // only when the env var is set (gated at runtime). Local dev builds + // and other-platform CI runs are unaffected. + // + // Note: this enables regular profiling, not deep profiling. Deep + // profiling is set at editor build time via the -deepprofiling + // command line flag and cannot be toggled from runtime code. Regular + // profiling still surfaces per-frame CPU breakdowns and the function + // hot list, which is what we need to identify the per-frame UI + // Toolkit cost (or whatever else is eating ~37 sec per test). + public static class PlayerProfilerLogger + { + private const string PathEnvVar = "AUDIENCE_PLAYER_PROFILE_PATH"; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + public static void Init() + { + var path = Environment.GetEnvironmentVariable(PathEnvVar); + if (string.IsNullOrEmpty(path)) return; + + try + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + Profiler.logFile = path; + Profiler.enableBinaryLog = true; + Profiler.enabled = true; + + Debug.Log($"[PlayerProfilerLogger] Profiler binary log enabled. Output: {path}"); + } + catch (Exception ex) + { + Debug.LogWarning($"[PlayerProfilerLogger] Failed to enable profiler at {path}: {ex.Message}"); + } + } + } +} +#endif diff --git a/examples/audience/Assets/SampleApp/Tests/Runtime/PlayerProfilerLogger.cs.meta b/examples/audience/Assets/SampleApp/Tests/Runtime/PlayerProfilerLogger.cs.meta new file mode 100644 index 000000000..6bacc2716 --- /dev/null +++ b/examples/audience/Assets/SampleApp/Tests/Runtime/PlayerProfilerLogger.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8e3b1a4c5d6f7a8b9c0d1e2f3a4b5c6d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From f2f7037b1dd1eb18ea38f999698ac503a805abc0 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sun, 10 May 2026 09:04:34 +1000 Subject: [PATCH 18/19] test(audience-sample): hide log pane on Linux PlayMode to skip llvmpipe rasterisation PR 765 captured a player profile that showed the Unity 6 Linux cells spending roughly 4.5 seconds per frame in Gfx.PresentFrame self time on the render thread. Camera.Render was 2 ms and UI.RenderOverlays 1.45 ms per frame; the 4.5 sec is llvmpipe walking the deferred command buffer and rasterising approximately 2920 batches and 7520 triangles per frame, almost all of which are UI Toolkit log rows generated by the SampleApp's accumulating log pane. LinuxLogPaneSuppression registers a SceneManager.sceneLoaded handler that sets the log ScrollView to display:none after each scene load. display:none skips layout and render but keeps elements in the visual tree, so HasLogEntry (which walks contentContainer.Children() and inspects userData by reference) still observes log rows correctly. Engages only on StandaloneLinux64 builds via #if UNITY_STANDALONE_LINUX. Mac and Windows PlayMode runs are unaffected. --- .../Tests/Runtime/LinuxLogPaneSuppression.cs | 68 +++++++++++++++++++ .../Runtime/LinuxLogPaneSuppression.cs.meta | 11 +++ 2 files changed, 79 insertions(+) create mode 100644 examples/audience/Assets/SampleApp/Tests/Runtime/LinuxLogPaneSuppression.cs create mode 100644 examples/audience/Assets/SampleApp/Tests/Runtime/LinuxLogPaneSuppression.cs.meta diff --git a/examples/audience/Assets/SampleApp/Tests/Runtime/LinuxLogPaneSuppression.cs b/examples/audience/Assets/SampleApp/Tests/Runtime/LinuxLogPaneSuppression.cs new file mode 100644 index 000000000..0a8ffff38 --- /dev/null +++ b/examples/audience/Assets/SampleApp/Tests/Runtime/LinuxLogPaneSuppression.cs @@ -0,0 +1,68 @@ +#nullable enable + +#if UNITY_STANDALONE_LINUX +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.UIElements; + +namespace Immutable.Audience.Samples.SampleApp.Tests +{ + // Linux PlayMode test optimisation: hide the SampleApp log pane so UI + // Toolkit does not generate triangles for its rows during test runs. + // + // The player profile captured on PR 765 showed Render Thread spending + // roughly 4.5 seconds per frame in Gfx.PresentFrame self time on Unity + // 6 Linux cells, with ~2920 batches and ~7520 triangles per frame. + // Camera.Render is 2 ms, UI.RenderOverlays 1.45 ms; the rest is + // llvmpipe rasterising the deferred command buffer at present time. + // The bulk of those triangles come from the log pane, which + // accumulates one row per logged event over the course of a session. + // + // display:none keeps elements in the visual tree (so VisualElement.Q + // and contentContainer.Children() still find them) but skips layout + // and render entirely. Tests assert on log entries via userData on + // each row, which is reference-based, not layout-based, so the + // assertions stay correct. + // + // Engages only on StandaloneLinux64 builds (gated by the #if). Mac + // and Windows PlayMode runs are unaffected. + [SetUpFixture] + public sealed class LinuxLogPaneSuppression + { + [OneTimeSetUp] + public void RegisterSceneHook() + { + SceneManager.sceneLoaded += HideLogPane; + } + + [OneTimeTearDown] + public void DeregisterSceneHook() + { + SceneManager.sceneLoaded -= HideLogPane; + } + + // Re-fires for every scene load. The SampleApp's UIDocument runs + // its UI initialisation in Awake, so by the time sceneLoaded + // fires the log ScrollView is in the tree and ready to be + // styled. The hook is idempotent across loads. + private static void HideLogPane(Scene scene, LoadSceneMode mode) + { + var sample = Object.FindFirstObjectByType(FindObjectsInactive.Include); + if (sample == null) return; + + var doc = sample.GetComponent(); + if (doc == null) return; + + var root = doc.rootVisualElement; + if (root == null) return; + + var log = root.Q(SampleAppUi.LogScrollView); + if (log == null) return; + + log.style.display = new StyleEnum(DisplayStyle.None); + Debug.Log("[LinuxLogPaneSuppression] log pane hidden for Linux PlayMode test run."); + } + } +} +#endif diff --git a/examples/audience/Assets/SampleApp/Tests/Runtime/LinuxLogPaneSuppression.cs.meta b/examples/audience/Assets/SampleApp/Tests/Runtime/LinuxLogPaneSuppression.cs.meta new file mode 100644 index 000000000..375feaffe --- /dev/null +++ b/examples/audience/Assets/SampleApp/Tests/Runtime/LinuxLogPaneSuppression.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3a7e9d4b8c1f5a6e2b8d9c0e1f2a3b4c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 1834c9b2120c4ba1839dc7f1e41bdb84b07e09db Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sun, 10 May 2026 09:29:37 +1000 Subject: [PATCH 19/19] chore(audience-sample): trim unused packages and engine modules from SampleApp Drops 30 of 39 manifest.json entries that the SampleApp does not use. Verified by grepping the SampleApp source, the SampleApp scene, and the Audience SDK runtime for any reference to each removed module or package. Removed packages: - com.unity.textmeshpro: no TMP_Text or TMPro references - com.unity.timeline: no PlayableDirector or TimelineAsset references - com.unity.ugui: SampleApp uses UI Toolkit, no Canvas references - com.unity.visualscripting: no ScriptMachine or graph references Removed engine modules: - ai, animation, assetbundle, cloth, director, imageconversion, particlesystem, physics, physics2d, screencapture, terrain, terrainphysics, tilemap, ui, umbra, unityanalytics, vehicles, video, vr, wind, xr: no runtime references in SampleApp or audience SDK - unitywebrequestassetbundle, unitywebrequestaudio, unitywebrequesttexture, unitywebrequestwww: SDK uses System.Net.Http.HttpClient, not UnityWebRequest variants Kept (verified used or required): - com.immutable.audience: the SDK - com.unity.test-framework: test runner - com.unity.modules.androidjni: Android plugin compatibility - com.unity.modules.audio: AudioListener present in SampleApp scene - com.unity.modules.imgui: test runner overlay - com.unity.modules.jsonserialize: defensive - com.unity.modules.uielements: UI Toolkit, primary UI surface - com.unity.modules.unitywebrequest: defensive (test framework) Expected impact: - Smaller IL2CPP and Mono player binaries. - Faster IL2CPP compile and Bee/Tundra build phases on every cell. - Faster project init (less to load, fewer InitializeOnLoad hooks). - Per-frame UI Toolkit cost on llvmpipe is unchanged; that is targeted separately by PR #765's log-pane suppression. Risk: a build link error on a platform that needs a removed module shows immediately in CI; revert is one-line. --- examples/audience/Packages/manifest.json | 31 +----------------------- 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/examples/audience/Packages/manifest.json b/examples/audience/Packages/manifest.json index 22e45b697..44c46867e 100644 --- a/examples/audience/Packages/manifest.json +++ b/examples/audience/Packages/manifest.json @@ -2,40 +2,11 @@ "dependencies": { "com.immutable.audience": "file:../../../src/Packages/Audience", "com.unity.test-framework": "1.4.5", - "com.unity.textmeshpro": "3.0.6", - "com.unity.timeline": "1.6.5", - "com.unity.ugui": "1.0.0", - "com.unity.visualscripting": "1.9.4", - "com.unity.modules.ai": "1.0.0", "com.unity.modules.androidjni": "1.0.0", - "com.unity.modules.animation": "1.0.0", - "com.unity.modules.assetbundle": "1.0.0", "com.unity.modules.audio": "1.0.0", - "com.unity.modules.cloth": "1.0.0", - "com.unity.modules.director": "1.0.0", - "com.unity.modules.imageconversion": "1.0.0", "com.unity.modules.imgui": "1.0.0", "com.unity.modules.jsonserialize": "1.0.0", - "com.unity.modules.particlesystem": "1.0.0", - "com.unity.modules.physics": "1.0.0", - "com.unity.modules.physics2d": "1.0.0", - "com.unity.modules.screencapture": "1.0.0", - "com.unity.modules.terrain": "1.0.0", - "com.unity.modules.terrainphysics": "1.0.0", - "com.unity.modules.tilemap": "1.0.0", - "com.unity.modules.ui": "1.0.0", "com.unity.modules.uielements": "1.0.0", - "com.unity.modules.umbra": "1.0.0", - "com.unity.modules.unityanalytics": "1.0.0", - "com.unity.modules.unitywebrequest": "1.0.0", - "com.unity.modules.unitywebrequestassetbundle": "1.0.0", - "com.unity.modules.unitywebrequestaudio": "1.0.0", - "com.unity.modules.unitywebrequesttexture": "1.0.0", - "com.unity.modules.unitywebrequestwww": "1.0.0", - "com.unity.modules.vehicles": "1.0.0", - "com.unity.modules.video": "1.0.0", - "com.unity.modules.vr": "1.0.0", - "com.unity.modules.wind": "1.0.0", - "com.unity.modules.xr": "1.0.0" + "com.unity.modules.unitywebrequest": "1.0.0" } }