acdream/.github/workflows/headless-portability.yml
Erik 777f60708d ci(render): make V9's first CI run green on both operating systems
The lavapipe job did the thing it was built to do on its first attempt.
It accepted a Cpu device at API 1.4, created a device and read pixels
back, captured a real frame, and exited 4 when a feature was forced
unsupported. Three other things were red, and none of them were the
Vulkan backend.

The shader-freshness step aborted for two separate Linux faults in the
compiler tool. Disposing the Silk.NET API container unloads the native
module, and dlclose-ing libshaderc_shared.so leaves glslang's
process-level teardown running against unmapped code. Bisected with a
four-mode probe on Ubuntu 24.04: GetApi, CompilerInitialize and
CompilerRelease each exit 0, and adding only the container Dispose turns
the exit into SIGSEGV. That is the 134 CI reported. shaderc's own handles
are still released; the container is not, because the module's lifetime
is the process's and the process is one statement from returning.
Separately, a portable dotnet build leaves the native under
runtimes/linux-x64/native/ and makes reaching it Silk.NET's probing
problem, which it solved on a local Ubuntu 24.04 and did not solve on the
runner. The script now publishes the tool for the host RID, so the native
sits beside the assembly where AppContext.BaseDirectory finds it, and
checks for it by name so a regression says which file is missing rather
than which names failed.

With both fixed, the question section 5.5.20 left open has an answer:
Linux shaderc and Windows shaderc agree byte-for-byte at the pinned Silk
2.23.0. Eighteen of eighteen .spv identical, manifest identical. The byte
comparison stays a byte comparison.

The Windows leg of portable-headless was running sudo apt-get. That step
is older than this campaign - it is red in the 2026-07-27 main run too -
and it was misplaced rather than mis-conditioned. Nothing in that job
opens a display or links GL, and the graphical jobs that do call xvfb-run
take it from the runner image, so the step is deleted rather than
guarded. Every remaining step in the two-operating-system matrix is pwsh;
every bash step now lives in an ubuntu-only job.

The last failure was ours in a quieter way. WaitForCharacterLogOff-
Confirmation expressed its deadline only as a CancellationTokenSource,
whose timeout is published from a thread-pool timer callback, so on a
saturated pool the token stays unsignalled past the deadline while the
loop keeps draining items that are already queued. That is the case the
method exists to bound. Reproduced by pinning the suite to two CPUs on
Linux, which failed 2 of 6 where four CPUs and sixteen were clean, and
where CI failed 3 of 3. The drain now reads the deadline off the
monotonic clock as well; the token still bounds the asynchronous wait.
Ten of ten clean under the same pin. The test is untouched. Filed as

Release build green. App tests 4,152 / 3 skipped against the same 4,152 /
3 measured at base 32f9bcfa. Core.Net 600 / 600.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:52:53 +02:00

460 lines
20 KiB
YAML

name: Headless portability
on:
pull_request:
paths:
- ".github/workflows/headless-portability.yml"
- "AcDream.slnx"
- "src/AcDream.Core/**"
- "src/AcDream.Core.Net/**"
- "src/AcDream.Content/**"
- "src/AcDream.Plugin.Abstractions/**"
- "src/AcDream.Runtime/**"
- "src/AcDream.Headless/**"
- "src/AcDream.App/**"
- "src/AcDream.UI.Abstractions/**"
- "src/AcDream.UI.ImGui/**"
- "tests/AcDream.Core.Tests/**"
- "tests/AcDream.Core.Net.Tests/**"
- "tests/AcDream.Content.Tests/**"
- "tests/AcDream.Runtime.Tests/**"
- "tests/AcDream.Headless.Tests/**"
- "tests/AcDream.App.Tests/**"
- "tests/AcDream.UI.Abstractions.Tests/**"
- "tools/ShaderCompiler/**"
- "tools/compile-shaders.ps1"
push:
paths:
- ".github/workflows/headless-portability.yml"
- "AcDream.slnx"
- "src/AcDream.Core/**"
- "src/AcDream.Core.Net/**"
- "src/AcDream.Content/**"
- "src/AcDream.Plugin.Abstractions/**"
- "src/AcDream.Runtime/**"
- "src/AcDream.Headless/**"
- "src/AcDream.App/**"
- "src/AcDream.UI.Abstractions/**"
- "src/AcDream.UI.ImGui/**"
- "tests/AcDream.Core.Tests/**"
- "tests/AcDream.Core.Net.Tests/**"
- "tests/AcDream.Content.Tests/**"
- "tests/AcDream.Runtime.Tests/**"
- "tests/AcDream.Headless.Tests/**"
- "tests/AcDream.App.Tests/**"
- "tests/AcDream.UI.Abstractions.Tests/**"
- "tools/ShaderCompiler/**"
- "tools/compile-shaders.ps1"
workflow_dispatch:
permissions:
contents: read
jobs:
portable-headless:
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
# No apt step here on purpose. This job's whole claim is that the closure
# below is presentation-free: it builds Plugin.Abstractions, Core,
# Core.Net, Content, Runtime and Headless, runs their tests, and invokes
# the Headless CLI. Nothing in it opens a display, links GL, or calls
# xvfb-run, so an "install the graphical smoke dependencies" step here was
# both unnecessary and, being unconditional `sudo apt-get` in a
# two-operating-system matrix, fatal on the windows-latest leg (exit 127,
# `sudo: command not found`). The graphical jobs that do use xvfb-run and
# jq run only on ubuntu-latest and take both from the runner image.
- name: Build presentation-free closure
shell: pwsh
run: |
$projects = @(
"src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj",
"src/AcDream.Core/AcDream.Core.csproj",
"src/AcDream.Core.Net/AcDream.Core.Net.csproj",
"src/AcDream.Content/AcDream.Content.csproj",
"src/AcDream.Runtime/AcDream.Runtime.csproj",
"src/AcDream.Headless/AcDream.Headless.csproj"
)
foreach ($project in $projects) {
dotnet build $project -c Release
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
# AcDream.Core.Tests still contains historical App integration fixtures,
# so it is deliberately not a member of this no-App restore lane. Core is
# built directly above and exercised through every portable downstream
# test project below; the complete solution lane retains Core.Tests.
- name: Test presentation-free closure
shell: pwsh
run: |
$projects = @(
"tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj",
"tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj",
"tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj",
"tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj"
)
foreach ($project in $projects) {
dotnet test $project -c Release
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
- name: Verify CLI without connecting
shell: pwsh
run: |
dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- --help
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Set-Content -LiteralPath headless-k0.json -Value '{"version":1,"sessions":[]}'
dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- validate --config headless-k0.json
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
linux-graphical:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Build and publish Linux graphical client
shell: pwsh
run: |
dotnet build src/AcDream.App/AcDream.App.csproj -c Release -r linux-x64
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
dotnet publish src/AcDream.App/AcDream.App.csproj `
-c Release `
-r linux-x64 `
--self-contained false `
-o artifacts/acdream-linux-x64
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Test portable graphical boundary
shell: pwsh
run: |
dotnet test `
tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj `
-c Release
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
dotnet test `
tests/AcDream.App.Tests/AcDream.App.Tests.csproj `
-c Release `
--filter "FullyQualifiedName~LinuxMonotonicFramePacingWaiterTests|FullyQualifiedName~LinuxPlatformBoundaryTests|FullyQualifiedName~GraphicalHostPlatformServicesTests|FullyQualifiedName~GraphicalLegacyConfigurationMigratorTests|FullyQualifiedName~GraphicalWindowBackendSelectionTests|FullyQualifiedName~GraphicalCapabilityRequirementsTests|FullyQualifiedName~StudioWindowTests"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Verify Linux package contract
shell: bash
run: |
set -euo pipefail
root=artifacts/acdream-linux-x64
test -x "$root/AcDream.App"
test -f "$root/AcDream.App.dll"
test -f "$root/libglfw.so.3"
test -f "$root/libopenal.so"
test -f "$root/libcimgui.so"
test -f "$root/Rendering/Shaders/mesh_modern.vert"
test -f "$root/plugins/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.dll"
test -f "$root/plugins/AcDream.Plugins.Smoke/plugin.json"
test "$(grep -RIl --include='*.cs' 'LibraryImport(\"kernel32.dll\"' \
src/AcDream.App | wc -l)" -eq 1
grep -q 'WindowsHighResolutionFramePacingWaiter.cs' < <(
grep -RIl --include='*.cs' 'LibraryImport(\"kernel32.dll\"' \
src/AcDream.App)
! grep -RIn --include='*.cs' 'LocalApplicationData' \
src/AcDream.App src/AcDream.UI.Abstractions
! grep -RIn --include='*.cs' \
'WindowsHighResolutionFramePacingWaiter.Create()' \
src/AcDream.App \
--exclude='FramePacingWaiterFactory.cs' \
--exclude='WindowsHighResolutionFramePacingWaiter.cs'
- name: Verify actionable unsupported-driver gate
shell: bash
run: |
set -euo pipefail
root=artifacts/acdream-linux-x64
report=artifacts/linux-x11-capabilities.json
set +e
ACDREAM_DISPLAY_PROTOCOL=x11 \
xvfb-run -a "$root/AcDream.App" \
ui-studio /tmp/not-needed \
--mockup \
--capability-report "$report"
code=$?
set -e
test "$code" -eq 4
test "$(jq -r '.ActiveDisplayProtocol' "$report")" = X11
test "$(jq -r '.Lifecycle.ShutdownComplete' "$report")" = true
test "$(jq -r '.Lifecycle.OwnedWindowCount' "$report")" -eq 0
test "$(jq -r '.Lifecycle.OwnedGlApiCount' "$report")" -eq 0
test "$(jq -r '.Lifecycle.OwnedInputContextCount' "$report")" -eq 0
jq -e '.SupportFailures | length > 0' "$report"
# Campaign V slice V9. The GL job above proves the unsupported-driver gate
# fires on Mesa's software OpenGL; this one proves the Vulkan backend does the
# opposite on Mesa's software Vulkan. lavapipe passes the capability gate that
# llvmpipe-GL cannot, because mandatory GL_ARB_bindless_texture has no llvmpipe
# implementation while every Vulkan feature acdream requires is core 1.3 or a
# descriptor-indexing feature lavapipe implements. That makes this the first CI
# job in the project's history that renders a frame.
#
# NOT DONE HERE, deliberately: a GL-versus-Vulkan pixel comparison. Two
# independent reasons, either of which alone is disqualifying. First, the GL
# job never produces a frame at all: it asserts exit code 4, so there is no
# left-hand side. Second, even if llvmpipe-GL could run, the probe harness
# renders synthetic verification scenes rather than the world, and the world
# needs retail DATs that CI does not have and cannot be given. The real
# GL-versus-Vulkan differential is V7's, on the developer machine, against the
# DATs, with both clocks pinned. See plan section 5.5.20.
linux-vulkan:
runs-on: ubuntu-latest
# Redirect the portable per-user roots into the workspace so the capability
# report, which the app writes to its own diagnostics directory rather than
# to a path a caller chooses, lands somewhere collectable. Slice L0 made
# these XDG-driven precisely so a host could place them.
env:
XDG_CONFIG_HOME: ${{ github.workspace }}/artifacts/xdg/config
XDG_DATA_HOME: ${{ github.workspace }}/artifacts/xdg/data
XDG_CACHE_HOME: ${{ github.workspace }}/artifacts/xdg/cache
VULKAN_REPORT: ${{ github.workspace }}/artifacts/xdg/cache/acdream/diagnostics/graphical-capabilities-vulkan.json
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Install lavapipe, the Vulkan loader and Xvfb
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
jq \
libvulkan1 \
mesa-vulkan-drivers \
vulkan-tools \
xauth \
xvfb
- name: Record the software Vulkan device
shell: bash
run: |
set -euo pipefail
mkdir -p artifacts
# Evidence, not configuration: no ICD is forced, because on a runner
# with no GPU lavapipe is the only one the loader can find. If that
# ever stops being true the DeviceType assertion below turns red
# rather than silently measuring different hardware, which is the
# outcome this row wants; its whole point is that the gate passes on
# the weakest conformant device in existence.
ls -l /usr/share/vulkan/icd.d/ || true
vulkaninfo --summary 2>&1 | tee artifacts/vulkaninfo-summary.txt
- name: Build and publish the Linux graphical client
shell: pwsh
run: |
dotnet publish src/AcDream.App/AcDream.App.csproj `
-c Release `
-r linux-x64 `
--self-contained false `
-o artifacts/acdream-linux-x64
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Test the Vulkan backend's platform-independent decisions
shell: pwsh
run: |
dotnet test `
tests/AcDream.App.Tests/AcDream.App.Tests.csproj `
-c Release `
--filter "FullyQualifiedName~AcDream.App.Tests.Rendering.Gpu.Vk"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# (a) + (b): one run, two gates. The harness opens a real window, runs the
# capability gate against a real device, and presents the V6c/V6d
# verification scenes through the real RHI. ACDREAM_VULKAN_PROBE_FRAMES is
# what makes it terminate: nothing in CI ever closes a window.
- name: Probe the Vulkan capability gate on lavapipe
shell: bash
run: |
set -euo pipefail
root=artifacts/acdream-linux-x64
out="$PWD/artifacts/vulkan-probe"
mkdir -p "$out"
# 24-bit depth explicitly: xvfb-run's default screen is 8-bit, which
# leaves the X11 WSI without a usable visual.
ACDREAM_RENDER_BACKEND=vulkan \
ACDREAM_VULKAN_PROBE=1 \
ACDREAM_VULKAN_PROBE_FRAMES=30 \
ACDREAM_AUTOMATION_ARTIFACT_DIR="$out" \
ACDREAM_DISPLAY_PROTOCOL=x11 \
ACDREAM_NO_AUDIO=1 \
xvfb-run -a -s "-screen 0 1920x1080x24" \
"$root/AcDream.App" /tmp/not-needed 2>&1 | tee artifacts/vulkan-probe.log
test -f "$VULKAN_REPORT"
cp "$VULKAN_REPORT" artifacts/vulkan-capabilities-pass.json
jq -r '"device: \(.DeviceName) (\(.DeviceType)), \(.DeviceApiVersion), \(.DriverInfo)"' \
"$VULKAN_REPORT"
# The gate accepted the device outright.
jq -e '.SupportFailures | length == 0' "$VULKAN_REPORT"
test "$(jq -r '.ActiveDisplayProtocol' "$VULKAN_REPORT")" = X11
# A software device, which is the whole point of this row.
test "$(jq -r '.DeviceType' "$VULKAN_REPORT")" = Cpu
# Vulkan 1.3 floor, unpacked from VK_MAKE_API_VERSION: major is bits
# 22+, minor is bits 12-21. Evaluate() already rejects anything lower,
# so this asserts the report agrees with the verdict rather than
# re-deriving it.
jq -e '
((.DeviceApiVersionPacked / 4194304) | floor) as $major
| (((.DeviceApiVersionPacked % 4194304) / 4096) | floor) as $minor
| $major > 1 or ($major == 1 and $minor >= 3)' "$VULKAN_REPORT"
# Advertisement is not evidence: the active probe created the device,
# built the descriptor layouts and a pipeline from committed .spv, drew
# an offscreen triangle and read the pixels back.
jq -e '.FunctionProbe.Failures | length == 0' "$VULKAN_REPORT"
jq -e '.FunctionProbe.DeviceCreation and .FunctionProbe.OffscreenReadback' \
"$VULKAN_REPORT"
- name: Assert the offline render produced a real frame
shell: bash
run: |
set -euo pipefail
png=artifacts/vulkan-probe/vulkan-bringup.png
test -f "$png"
# PNG IHDR carries the dimensions at bytes 16..23, big-endian. Reading
# them proves the capture path returned a full backbuffer rather than a
# stub, without depending on a rasterizer's pixel values.
width=$(od -An -tu4 -j16 -N4 --endian=big "$png" | tr -d ' ')
height=$(od -An -tu4 -j20 -N4 --endian=big "$png" | tr -d ' ')
bytes=$(stat -c%s "$png")
echo "captured ${width}x${height}, ${bytes} bytes"
test "$width" -ge 640
test "$height" -ge 360
# A uniform-colour frame at this size encodes to a few kilobytes. This
# threshold is the "the scene actually drew" line, and is deliberately
# a byte count rather than a pixel baseline: lavapipe and any other
# rasterizer are free to disagree about shading, and nothing in CI has
# a reference frame to disagree with.
test "$bytes" -gt 8192
# (c) The gate's failure path, exercised on a device that actually
# supports everything, so the exit-code-4 contract is proven rather than
# assumed. Same knob slice V5 built for exactly this.
- name: Verify the forced-unsupported gate exits 4
shell: bash
run: |
set -euo pipefail
root=artifacts/acdream-linux-x64
out="$PWD/artifacts/vulkan-forced"
mkdir -p "$out"
set +e
ACDREAM_RENDER_BACKEND=vulkan \
ACDREAM_VULKAN_PROBE=1 \
ACDREAM_VULKAN_PROBE_FRAMES=30 \
ACDREAM_VULKAN_FORCE_UNSUPPORTED=timelineSemaphore \
ACDREAM_AUTOMATION_ARTIFACT_DIR="$out" \
ACDREAM_DISPLAY_PROTOCOL=x11 \
ACDREAM_NO_AUDIO=1 \
xvfb-run -a -s "-screen 0 1920x1080x24" \
"$root/AcDream.App" /tmp/not-needed \
> artifacts/vulkan-forced.log 2>&1
code=$?
set -e
cat artifacts/vulkan-forced.log
test -f "$VULKAN_REPORT"
cp "$VULKAN_REPORT" artifacts/vulkan-capabilities-forced.json
test "$code" -eq 4
test "$(jq -r '.ForcedUnsupportedFeature' "$VULKAN_REPORT")" = timelineSemaphore
test "$(jq -r '.Features.TimelineSemaphore' "$VULKAN_REPORT")" = false
jq -e '.SupportFailures | length > 0' "$VULKAN_REPORT"
jq -e '.SupportFailures | any(test("timelineSemaphore"))' "$VULKAN_REPORT"
# The refusal must be actionable: the operator is told where the full
# report is, not merely that something was unsupported.
grep -q 'graphical-capabilities-vulkan.json' artifacts/vulkan-forced.log
# (d) The committed .spv are the only shaders the Vulkan backend ever
# loads. An App test already re-hashes the GLSL sources against the
# manifest, which catches "edited a shader, forgot to recompile". Nothing
# until now tied the committed BINARIES to those sources, so a stale or
# hand-edited .spv would have shipped silently. Recompiling here closes
# that, and does it on a second operating system.
- name: Verify the committed SPIR-V is fresh
shell: bash
run: |
set -euo pipefail
committed=src/AcDream.App/Rendering/Shaders/spv
fresh="$PWD/artifacts/spv-fresh"
rm -rf "$fresh"
mkdir -p "$fresh"
pwsh tools/compile-shaders.ps1 -OutputDirectory "$fresh"
# The file SET first: a .spv present in one tree and not the other is
# drift the per-file compare would never visit.
diff <(cd "$committed" && ls -1 | sort) <(cd "$fresh" && ls -1 | sort)
drift=0
for path in "$committed"/*.spv; do
name=$(basename "$path")
if ! cmp -s "$path" "$fresh/$name"; then
echo "DRIFT: $name differs from a fresh compile"
echo " committed $(sha256sum "$path" | cut -d' ' -f1) $(stat -c%s "$path") bytes"
echo " fresh $(sha256sum "$fresh/$name" | cut -d' ' -f1) $(stat -c%s "$fresh/$name") bytes"
drift=$((drift + 1))
fi
done
# The manifest is compared as JSON rather than as bytes: it is written
# with Environment.NewLine, so a byte compare would report drift for
# the operating system rather than for the shaders.
diff <(jq -S . "$committed/shaders.manifest.json") \
<(jq -S . "$fresh/shaders.manifest.json")
if [ "$drift" -ne 0 ]; then
echo "$drift .spv artifact(s) are stale."
echo "Run tools/compile-shaders.ps1 and commit the result."
exit 1
fi
echo "all committed .spv match a fresh compile"
- name: Upload Vulkan evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: linux-vulkan-evidence
if-no-files-found: warn
path: |
artifacts/vulkaninfo-summary.txt
artifacts/vulkan-probe.log
artifacts/vulkan-forced.log
artifacts/vulkan-capabilities-pass.json
artifacts/vulkan-capabilities-forced.json
artifacts/vulkan-probe/*.png