Skip to content

Commit bff78bc

Browse files
ci(audience): run linux PlayMode under xvfb (SDK-317 / SDK-318)
- Adds playmode-linux job using docker + xvfb. game-ci/unity-test-runner@v4 hardcodes -nographics, so PlayMode tests came back inconclusive and silently passed. - Watchdog SIGTERMs Unity 30s after "Test run completed" so cells exit on suite finish; handles Unity 6's known shutdown hang. - Replaces cartesian + partial-include matrix with explicit per-cell entries; partial includes failed to merge the runner column and spawned zero tests. - Forces StandaloneLinux64 to OpenGLCore-only at build to skip Vulkan shader variants and runtime negotiation. - Suppresses in-app log pane on Unity 6 Linux to skip llvmpipe rasterising UI Toolkit triangles per frame. - Stamps CI provenance into Player.log on player startup; gated to CI runs only. - Mirrors SDK output and OnError fires to Debug.Log so failures land in Player.log. - Captures Unity profiler binary log when AUDIENCE_PLAYER_PROFILE_PATH is set. - DiskStore.ReadBatch treats missing queue dir as empty (matches existing guards). - Live-fire test SetUp ignores cleanup-time OnError fires so background flush cancellations do not fail unrelated tests. - Trims 30 unused packages from the sample-app manifest. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b3a13ba commit bff78bc

13 files changed

Lines changed: 437 additions & 49 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/bin/bash
2+
# Audience SDK PlayMode test runner for Linux.
3+
# Runs inside the unityci/editor:ubuntu-X-linux-il2cpp-3 container.
4+
# Workflow caller: .github/workflows/test-audience-sample-app.yml (playmode-linux job).
5+
6+
set -uo pipefail
7+
8+
LOG=/github/workspace/artifacts/playmode.log
9+
ACTIVATION_LOG=/github/workspace/artifacts/activation.log
10+
RESULTS=/github/workspace/artifacts/playmode-results.xml
11+
PROJECT=/github/workspace/examples/audience
12+
13+
test_rc=1
14+
15+
activate_license() {
16+
unity-editor -batchmode -nographics -quit \
17+
-username "$UNITY_EMAIL" \
18+
-password "$UNITY_PASSWORD" \
19+
-serial "$UNITY_SERIAL" \
20+
-logFile - 2>&1 | tee "$ACTIVATION_LOG" || true
21+
22+
if grep -qE "License activation has failed|\[Licensing::Client\] Error: Code [0-9]+" "$ACTIVATION_LOG"; then
23+
echo "::error::Unity license activation failed."
24+
exit 1
25+
fi
26+
if ! grep -qE "Successfully activated the entitlement license" "$ACTIVATION_LOG"; then
27+
echo "::error::Unity license activation: no success marker in log."
28+
exit 1
29+
fi
30+
}
31+
32+
run_tests_with_watchdog() {
33+
# xvfb-run gives Unity a virtual X display. UI Toolkit needs GLX + render;
34+
# llvmpipe in the image provides software OpenGL so no GPU is needed.
35+
# -force-glcore skips the Unity 6 Vulkan init and matches the Unity 2021.3 default path.
36+
xvfb-run -a --server-args="-ac +extension GLX +render -noreset" -- \
37+
unity-editor \
38+
-batchmode \
39+
-force-glcore \
40+
-screen-fullscreen 0 \
41+
-screen-width 320 \
42+
-screen-height 240 \
43+
-projectPath "$PROJECT" \
44+
-runTests \
45+
-testPlatform StandaloneLinux64 \
46+
-testResults "$RESULTS" \
47+
-logFile "$LOG" &
48+
local unity_pid=$!
49+
50+
# Mirror Unity log to job stdout while the editor is alive.
51+
tail --pid=$unity_pid -F "$LOG" 2>/dev/null &
52+
53+
# Watchdog (vs fixed timeout) because per-version run length varies wildly:
54+
# Unity 2021.3 cells finish in ~2 min, Unity 6 in ~22 min, and Unity 6 has a
55+
# known post-test shutdown hang. SIGTERM 30 s after "Test run completed" so
56+
# each cell exits as soon as its suite finishes. 40 min hard cap as fallback.
57+
local deadline=$((SECONDS + 2400))
58+
local flush_deadline=0
59+
local kill_reason=""
60+
while kill -0 "$unity_pid" 2>/dev/null; do
61+
if [ "$SECONDS" -ge "$deadline" ]; then
62+
kill_reason="hard-cap-40m"
63+
break
64+
fi
65+
if [ "$flush_deadline" -eq 0 ] && grep -q "Test run completed" "$LOG" 2>/dev/null; then
66+
flush_deadline=$((SECONDS + 30))
67+
echo "[watchdog] saw \"Test run completed\" at ${SECONDS}s; SIGTERM after 30s flush window"
68+
fi
69+
if [ "$flush_deadline" -gt 0 ] && [ "$SECONDS" -ge "$flush_deadline" ]; then
70+
kill_reason="flush-window-elapsed"
71+
break
72+
fi
73+
sleep 5
74+
done
75+
76+
if [ -n "$kill_reason" ]; then
77+
echo "[watchdog] sending SIGTERM to Unity (reason: $kill_reason)"
78+
kill -TERM "$unity_pid" 2>/dev/null || true
79+
# 15 s grace, then SIGKILL.
80+
for _ in 1 2 3; do
81+
kill -0 "$unity_pid" 2>/dev/null || break
82+
sleep 5
83+
done
84+
if kill -0 "$unity_pid" 2>/dev/null; then
85+
echo "[watchdog] SIGTERM not honored, sending SIGKILL"
86+
kill -KILL "$unity_pid" 2>/dev/null || true
87+
fi
88+
fi
89+
90+
wait "$unity_pid" 2>/dev/null
91+
test_rc=$?
92+
if [ "$kill_reason" = "hard-cap-40m" ]; then
93+
echo "::warning::Unity hit the 40 min hard cap without logging \"Test run completed\". Inspect Player.log."
94+
fi
95+
}
96+
97+
capture_player_log() {
98+
# Player runs in a separate process from the editor; copy its Player.log so
99+
# HTTP traces and OnError fires are captured. Glob across companies / products.
100+
find /root/.config/unity3d -name "Player.log" 2>/dev/null | while IFS= read -r f; do
101+
co=$(basename "$(dirname "$(dirname "$f")")")
102+
pr=$(basename "$(dirname "$f")")
103+
cp "$f" "/github/workspace/artifacts/Player-${co}-${pr}.log" 2>/dev/null || true
104+
done
105+
}
106+
107+
return_license() {
108+
# Always return the seat to keep the activation pool from exhausting on reruns.
109+
unity-editor -batchmode -nographics -quit -returnlicense -logFile - 2>&1 || true
110+
}
111+
112+
activate_license
113+
run_tests_with_watchdog
114+
capture_player_log
115+
return_license
116+
117+
# Unity exits 2 on test failure or inconclusive; propagate so the step fails.
118+
exit "$test_rc"

.github/workflows/test-audience-sample-app.yml

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ concurrency:
2525
group: ${{ github.workflow }}-${{ github.ref }}
2626
cancel-in-progress: true
2727

28+
# CI run id stamped into the player for CDP filtering. Per-cell id set on jobs below.
29+
env:
30+
AUDIENCE_TEST_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}
31+
2832
jobs:
2933
# Reduced matrix on pull_request, full matrix on schedule and
3034
# workflow_dispatch. The self-hosted Windows runner pool is small, so
@@ -60,6 +64,8 @@ jobs:
6064
# short releases the self-hosted runner sooner so queued cells can
6165
# progress instead of waiting 60 min on a stuck job.
6266
timeout-minutes: 30
67+
env:
68+
AUDIENCE_TEST_CELL_ID: ${{ matrix.target }}-${{ matrix.backend }}-${{ matrix.unity }}
6369

6470
steps:
6571
- name: Kill stale Unity processes (Windows pre-checkout)
@@ -412,6 +418,10 @@ jobs:
412418
|| github.event_name == 'workflow_dispatch'
413419
name: ${{ matrix.target }} / ${{ matrix.backend }} / Unity ${{ matrix.unity }}
414420
runs-on: ubuntu-latest-8-cores
421+
# 45 min cap covers the inner 40 min watchdog plus post-Unity steps.
422+
timeout-minutes: 45
423+
env:
424+
AUDIENCE_TEST_CELL_ID: ${{ matrix.target }}-${{ matrix.backend }}-${{ matrix.unity }}
415425
strategy:
416426
fail-fast: false
417427
matrix:
@@ -433,35 +443,101 @@ jobs:
433443
Library-${{ matrix.backend }}-${{ matrix.target }}-${{ matrix.unity }}-
434444
Library-${{ matrix.backend }}-${{ matrix.target }}-
435445
436-
- uses: game-ci/unity-test-runner@v4
437-
id: playmode
446+
- name: Run PlayMode tests under xvfb
447+
# Manual docker run because game-ci/unity-test-runner@v4 hardcodes -nographics.
448+
# Without a virtual display every PlayMode test comes back inconclusive,
449+
# and the action's USE_EXIT_CODE=false suppresses Unity exit 2, so cells went silently green.
450+
shell: bash
438451
env:
439452
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
440453
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
441454
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
442455
AUDIENCE_TEST_PUBLISHABLE_KEY: ${{ secrets.AUDIENCE_TEST_PUBLISHABLE_KEY }}
443456
AUDIENCE_SCRIPTING_BACKEND: ${{ matrix.backend }}
444-
with:
445-
unityVersion: ${{ matrix.unity }}
446-
targetPlatform: ${{ matrix.target }}
447-
projectPath: examples/audience
448-
testMode: playmode
449-
githubToken: ${{ secrets.GITHUB_TOKEN }}
457+
UNITY_VERSION: ${{ matrix.unity }}
458+
AUDIENCE_LINUX_GLCORE_ONLY: "1"
459+
AUDIENCE_PLAYER_PROFILE_PATH: "/github/workspace/artifacts/player-profile.raw"
460+
run: |
461+
set -uo pipefail
462+
mkdir -p artifacts
463+
docker run --rm \
464+
--workdir /github/workspace \
465+
--env UNITY_EMAIL --env UNITY_PASSWORD --env UNITY_SERIAL \
466+
--env AUDIENCE_TEST_PUBLISHABLE_KEY --env AUDIENCE_SCRIPTING_BACKEND \
467+
--env AUDIENCE_TEST_RUN_ID --env AUDIENCE_TEST_CELL_ID \
468+
--env AUDIENCE_LINUX_GLCORE_ONLY \
469+
--env AUDIENCE_PLAYER_PROFILE_PATH \
470+
--volume "$PWD":/github/workspace:z \
471+
--cpus=8 --memory=30487m \
472+
"unityci/editor:ubuntu-${UNITY_VERSION}-linux-il2cpp-3" \
473+
bash /github/workspace/.github/scripts/audience-playmode-linux.sh
474+
475+
- name: Fail if test results missing or inconclusive
476+
# NUnit marks tests "inconclusive" (not "failed") when the player never starts,
477+
# and dorny/test-reporter does not fail on inconclusive. Catch both here.
478+
if: always()
479+
shell: bash
480+
run: |
481+
xml="artifacts/playmode-results.xml"
482+
test -s "$xml" || { echo "::error::No test-results.xml at $xml."; exit 1; }
483+
if grep -qE 'inconclusive="[1-9]' "$xml"; then
484+
echo "::error::Tests came back inconclusive. Check xvfb and player launch."
485+
exit 1
486+
fi
487+
if ! grep -qE 'passed="[1-9]' "$xml" && ! grep -qE 'failed="[1-9]' "$xml"; then
488+
echo "::error::Zero tests passed and zero failed. The suite did not execute."
489+
exit 1
490+
fi
491+
492+
- name: Surface CI provenance to job summary
493+
# Greps the [CI] line from Player.log into the job summary so the buildGuid is copy-pasteable from the Actions UI.
494+
if: always()
495+
shell: bash
496+
run: |
497+
set -uo pipefail
498+
log=""
499+
for candidate in artifacts/Player-*.log; do
500+
[ -f "$candidate" ] && { log="$candidate"; break; }
501+
done
502+
{
503+
echo "## CI provenance"
504+
if [ -z "$log" ]; then
505+
echo "_No Player.log captured for this cell._"
506+
exit 0
507+
fi
508+
line=$(grep -m1 '\[CI\]' "$log" || true)
509+
if [ -z "$line" ]; then
510+
echo "_No [CI] line found in \`$log\`. The SampleApp's RuntimeInitializeOnLoad hook may not have fired._"
511+
exit 0
512+
fi
513+
echo
514+
echo '```'
515+
echo "$line"
516+
echo '```'
517+
echo
518+
echo "Filter CDP for events with this \`buildGuid\` to see the rows from this cell."
519+
} >> "$GITHUB_STEP_SUMMARY"
450520
451521
- name: Publish test report
452522
uses: dorny/test-reporter@v3
453523
if: always()
454524
with:
455525
name: PlayMode (${{ matrix.backend }} / ${{ matrix.target }})
456-
path: ${{ steps.playmode.outputs.artifactsPath }}/playmode-results.xml
526+
path: artifacts/playmode-results.xml
457527
reporter: dotnet-nunit
458528
fail-on-error: true
459529

460530
- uses: actions/upload-artifact@v4
461531
if: always()
462532
with:
463533
name: playmode-${{ matrix.backend }}-${{ matrix.target }}-${{ matrix.unity }}
464-
path: ${{ steps.playmode.outputs.artifactsPath }}
534+
path: |
535+
artifacts/playmode-results.xml
536+
artifacts/playmode.log
537+
artifacts/activation.log
538+
artifacts/Player-*.log
539+
artifacts/player-profile.raw
540+
examples/audience/Logs/**
465541
466542
# Mobile IL2CPP build validation — runs on GitHub-hosted Ubuntu via GameCI Docker
467543
# containers so self-hosted macOS/Windows machines are not occupied.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#nullable enable
2+
3+
using System;
4+
using UnityEditor;
5+
using UnityEditor.Build;
6+
using UnityEditor.Build.Reporting;
7+
using UnityEngine;
8+
using UnityEngine.Rendering;
9+
10+
namespace Immutable.Audience.Samples.SampleApp.Editor
11+
{
12+
// Forces StandaloneLinux64 player to OpenGLCore-only at build time.
13+
// Skips Vulkan shader variants the runtime never uses.
14+
internal sealed class GraphicsApisLinuxOverride : IPreprocessBuildWithReport
15+
{
16+
private const string EnvVar = "AUDIENCE_LINUX_GLCORE_ONLY";
17+
18+
public int callbackOrder => 1;
19+
20+
public void OnPreprocessBuild(BuildReport report)
21+
{
22+
if (report.summary.platform != BuildTarget.StandaloneLinux64) return;
23+
24+
var requested = Environment.GetEnvironmentVariable(EnvVar);
25+
if (string.IsNullOrEmpty(requested)) return;
26+
27+
var current = PlayerSettings.GetGraphicsAPIs(BuildTarget.StandaloneLinux64);
28+
if (current.Length == 1 && current[0] == GraphicsDeviceType.OpenGLCore)
29+
{
30+
Debug.Log($"[{nameof(GraphicsApisLinuxOverride)}] StandaloneLinux64 already at OpenGLCore only.");
31+
return;
32+
}
33+
34+
PlayerSettings.SetUseDefaultGraphicsAPIs(BuildTarget.StandaloneLinux64, false);
35+
PlayerSettings.SetGraphicsAPIs(BuildTarget.StandaloneLinux64, new[] { GraphicsDeviceType.OpenGLCore });
36+
Debug.Log($"[{nameof(GraphicsApisLinuxOverride)}] StandaloneLinux64 graphics APIs forced to OpenGLCore. Vulkan shader variants will be skipped.");
37+
}
38+
}
39+
}

examples/audience/Assets/Editor/GraphicsApisLinuxOverride.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,17 @@ public sealed partial class AudienceSample : MonoBehaviour
3838

3939
// ---- Lifecycle ----
4040

41+
// Stamps a CI provenance line into Player.log on player startup. CI-only.
42+
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
43+
private static void LogCiProvenance()
44+
{
45+
var runId = Environment.GetEnvironmentVariable("AUDIENCE_TEST_RUN_ID") ?? string.Empty;
46+
var cellId = Environment.GetEnvironmentVariable("AUDIENCE_TEST_CELL_ID") ?? string.Empty;
47+
if (string.IsNullOrEmpty(runId) && string.IsNullOrEmpty(cellId)) return;
48+
UnityEngine.Debug.Log(
49+
$"[CI] buildGuid={Application.buildGUID} runId={runId} cellId={cellId}");
50+
}
51+
4152
private void Awake()
4253
{
4354
// InitializeUi must precede the Log.Writer swap — _logView has
@@ -246,15 +257,19 @@ private void OnAlias() => RunAndLog("alias()", () =>
246257

247258
// Fires from background flush threads; AppendLog marshals to main.
248259
// Body is JSON for parity with handler "Copy" output.
249-
private void OnSdkError(AudienceError err) =>
250-
AppendLog("onError", Json.Serialize(new Dictionary<string, object>
260+
// Mirrors to Debug.LogError so failures land in Player.log, not just the in-app pane.
261+
private void OnSdkError(AudienceError err)
262+
{
263+
var body = Json.Serialize(new Dictionary<string, object>
251264
{
252265
["code"] = err.Code.ToString(),
253266
["message"] = err.Message,
254-
}, 2), LogLevel.Err, LogSource.Sdk);
267+
}, 2);
268+
UnityEngine.Debug.LogError($"[Audience.OnError] {body}");
269+
AppendLog("onError", body, LogLevel.Err, LogSource.Sdk);
270+
}
255271

256-
// SDK Log.Writer adapter. May fire from any thread; AppendLog handles
257-
// the main-thread marshal.
272+
// SDK Log.Writer adapter. Mirrors to Debug.Log so SDK output reaches Player.log.
258273
private void RouteSdkLogToPane(string msg)
259274
{
260275
const string warnTag = "[ImmutableAudience] WARN:";
@@ -265,10 +280,16 @@ private void RouteSdkLogToPane(string msg)
265280
{
266281
level = LogLevel.Warn;
267282
body = msg.Substring(warnTag.Length).TrimStart();
283+
UnityEngine.Debug.LogWarning($"[Audience] {body}");
268284
}
269285
else if (msg.StartsWith(prefix, StringComparison.Ordinal))
270286
{
271287
body = msg.Substring(prefix.Length).TrimStart();
288+
UnityEngine.Debug.Log($"[Audience] {body}");
289+
}
290+
else
291+
{
292+
UnityEngine.Debug.Log($"[Audience] {body}");
272293
}
273294
AppendLog("sdk", body, level, LogSource.Sdk);
274295
}

0 commit comments

Comments
 (0)