Skip to content
Merged
4 changes: 3 additions & 1 deletion .github/workflows/macos-arm-live-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ name: macOS ARM Live Test
#
# The native macOS build/lint/test workflows no longer run on pull requests;
# they still run on main pushes and manual dispatch.
# #251 tracks moving the execution into a docker-mac-x64 guest on Linux.
# The execution stays on a hosted runner: in a docker-mac-x64 guest on a Linux
# runner there is no GPU, and both the viewer's WebKit and Safari report no
# WebGL, so a sketch cannot render (#251, macos-x64-guest-webkit-probe.yml).

on:
push:
Expand Down
132 changes: 132 additions & 0 deletions .github/workflows/macos-x64-guest-webkit-probe.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
name: macOS x64 Guest WebKit Probe

# Can a docker-mac-x64 guest on a Linux runner replace the hosted macOS runner
# for the live viewer render and the Safari smoke (#251)? This probe measures
# it, and the answer so far is no:
#
# The guest boots zackees/docker-mac-x64's macOS 13.0.1 Recovery system under
# QEMU/KVM with a plain VGA adapter and no GPU acceleration. There, both the
# shipped viewer's WKWebView and Safari 16.1 report neither WebGL2 nor WebGL1,
# while requestAnimationFrame runs normally (~30 fps) and pages load; that
# WebKit also has no OffscreenCanvas. The FastLED frontend requires WebGL2
# (bootstrap.ts), so a sketch cannot render in this guest. A newer macOS image
# would not supply a GPU: GitHub's Linux runners offer none that a macOS guest
# has a driver for.
#
# Recovery also lacks python3, `open`, `screencapture` and `safaridriver`.
# Safari is opened on a home page preference instead (ci/macos-x64/probe-guest.sh),
# and the page reports through the fastled server's /viewer-log route.
#
# macos-arm-live-test.yml therefore keeps the hosted runner for rendering and
# Safari. This workflow stays as the way to re-measure when the guest image or
# the GPU situation changes: it runs on demand, and on pull requests that touch
# the probe itself. The job summary reports what each WebKit client saw.

on:
workflow_dispatch:
pull_request:
paths:
- .github/workflows/macos-x64-guest-webkit-probe.yml
- ci/macos-x64/**

concurrency:
group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.run_id || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

permissions:
contents: read

jobs:
probe:
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4

# Same pin as macos-arm-live-test.yml: a Soldr with cross targets and the
# managed macOS SDK.
- uses: zackees/setup-soldr@bb28e96d2dc32c058242f56722297caf1efcbd90
env:
GITHUB_TOKEN: ${{ github.token }}
with:
cache-key-suffix: macos-x64-guest-cross
cross-targets: x86_64-apple-darwin
toolchain-file: rust-toolchain.toml
prebuild-deps-flags: ""

- name: Cross-build the Intel fastled binary
run: |
soldr cargo build --release --bin fastled --target x86_64-apple-darwin
# Must be an x86_64 Mach-O, not a host binary.
test "$(od -A n -t x1 -N 8 target/x86_64-apple-darwin/release/fastled | tr -d ' \n')" = "cffaedfe07000001"

- name: Stage the guest share directory
run: |
mkdir -p share
cp ci/macos-x64/webgl.html target/x86_64-apple-darwin/release/fastled share/

# Recovery boots one script and offers no per-command execution, so the
# guest half travels as a single string.
- name: Generate the guest script
id: guest_script
run: |
{
echo 'script<<GUEST_EOF'
cat ci/macos-x64/probe-guest.sh
echo 'GUEST_EOF'
} >> "$GITHUB_OUTPUT"
echo "started=$(date +%s)" >> "$GITHUB_OUTPUT"

- name: Probe WebKit in a macOS Recovery guest
id: macos
uses: zackees/docker-mac-x64@f173a047ba7fbe1db7d8162ff3ac8e276f50b153 # main
with:
share-dir: share
collect: /tmp/results
ram: "10"
cores: "2"
threads: "4"
boot-timeout: "1200"
run-timeout: "1200"
run: ${{ steps.guest_script.outputs.script }}

# The guest never exits non-zero, so the reports are the gate: each
# WebKit client must have answered, or the probe measured nothing.
- name: Summarize what each WebKit client reported
if: ${{ !cancelled() }}
env:
COLLECTED: ${{ steps.macos.outputs.workdir }}/collected
STARTED: ${{ steps.guest_script.outputs.started }}
run: |
set -euo pipefail
log="$COLLECTED/server.log"
test -f "$log" || { echo "::error::the guest returned no server log"; exit 1; }
{
echo "## macOS x64 guest WebKit probe"
echo
echo "Guest boot plus probe: $(( $(date +%s) - STARTED )) s"
echo
echo '```'
cat "$COLLECTED/guest.txt"
echo '```'
echo
echo "| client | report |"
echo "|---|---|"
sed -n 's/^\[viewer\] \([a-z-]*\) \(.*\)$/| \1 | \2 |/p' "$log"
} >> "$GITHUB_STEP_SUMMARY"
cat "$GITHUB_STEP_SUMMARY"
for client in viewer safari; do
grep -q "^\[viewer\] $client WEBGL2 " "$log" \
|| { echo "::error::$client never reported; the probe measured nothing"; exit 1; }
done

- name: Upload the guest results
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: macos-x64-guest-webkit-probe
path: |
${{ steps.macos.outputs.workdir }}/results/
${{ steps.macos.outputs.workdir }}/collected/
if-no-files-found: warn
retention-days: 7
55 changes: 55 additions & 0 deletions ci/macos-x64/probe-guest.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Runs as root in a docker-mac-x64 Recovery guest (bash 3.2, no python, no
# `open`, no safaridriver). Loads ci/macos-x64/webgl.html in the shipped viewer
# (`fastled --internal-viewer`, WKWebView) and in Safari, and records what each
# reports through the fastled server's /viewer-log route. See
# .github/workflows/macos-x64-guest-webkit-probe.yml for why this exists.
mkdir -p /tmp/results /tmp/page /tmp/home
R=/tmp/results
# Recovery's /var/root is read-only; fastled needs a writable ~/.fastled.
export HOME=/tmp/home
cd /tmp
{
sw_vers
uname -m
} > $R/guest.txt 2>&1
curl -sS -o /tmp/fastled http://10.0.2.2:8000/fastled && chmod +x /tmp/fastled
curl -sS -o /tmp/page/index.html http://10.0.2.2:8000/webgl.html
/tmp/fastled --version >> $R/guest.txt 2>&1

/tmp/fastled --internal-serve-dir-headless /tmp/page > $R/server.log 2>&1 &
URL=
for i in $(seq 60); do
URL=$(grep -Eo 'http://127\.0\.0\.1:[0-9]+' $R/server.log | head -1)
[ -n "$URL" ] && break
sleep 1
done
echo "url=$URL"

# The shipped viewer: the same WKWebView window `fastled <sketch>` opens.
/tmp/fastled --internal-viewer "$URL/?who=viewer" > $R/viewer.log 2>&1 &
sleep 45
kill $! 2>/dev/null

# Safari. Recovery has no `open`, a command-line argument is read as a
# sandboxed file path (Safari then asks for consent nothing here can grant), and
# LaunchServices brings Safari forward without loading the URL. A home page
# preference, written before Safari first runs so no cached preferences override
# it, makes a plain launch open its window on the page.
for prefs in /var/root/Library/Preferences \
/var/root/Library/Containers/com.apple.Safari/Data/Library/Preferences; do
mkdir -p "$prefs"
cat > "$prefs/com.apple.Safari.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>HomePage</key><string>$URL/?who=safari</string>
<key>NewWindowBehavior</key><integer>0</integer>
<key>NewTabBehavior</key><integer>0</integer>
</dict></plist>
PLIST
done
/Applications/Safari.app/Contents/MacOS/Safari > $R/safari.log 2>&1 &
sleep 45

grep '\[viewer\]' $R/server.log
echo PROBE_DONE
35 changes: 35 additions & 0 deletions ci/macos-x64/webgl.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!doctype html>
<meta charset="utf-8">
<title>WebGL2 probe</title>
<body style="font: 22px monospace; margin: 24px">
<canvas id="c" width="320" height="160" style="border: 2px solid #000"></canvas>
<pre id="out">script did not run</pre>
<script>
const who = new URLSearchParams(location.search).get('who') || 'unknown';
const out = document.getElementById('out');
const report = (text) => fetch('/viewer-log', {method: 'POST', body: who + ' ' + text}).catch(() => {});
const lines = ['UA ' + navigator.userAgent];
let gl = null;
try { gl = document.getElementById('c').getContext('webgl2'); } catch (e) { lines.push('WEBGL2_ERROR ' + e); }
lines.push('WEBGL2 ' + (gl ? 'YES' : 'NO'));
if (gl) {
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
lines.push('RENDERER ' + (dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)));
}
let gl1 = null;
try { gl1 = document.createElement('canvas').getContext('webgl'); } catch (e) {}
lines.push('WEBGL1 ' + (gl1 ? 'YES' : 'NO'));
let offscreen = 'absent';
try { offscreen = new OffscreenCanvas(4, 4).getContext('webgl2') ? 'webgl2' : 'no-webgl2'; } catch (e) { offscreen = 'error ' + e; }
lines.push('OFFSCREEN_WEBGL2 ' + offscreen);
out.textContent = lines.join('\n');
lines.forEach(report);
let frames = 0;
const tick = () => {
frames++;
if (gl) { gl.clearColor((frames % 60) / 60, 0.6, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT); }
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
[5, 15, 30].forEach(s => setTimeout(() => report('FRAMES_AT_' + s + 's ' + frames), s * 1000));
</script>
12 changes: 9 additions & 3 deletions ci/safari_terminal_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,22 @@
await new Promise((resolve, reject) => {
const ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
const timer = setTimeout(() => { ws.close(); reject(new Error('PTY ready timeout')); }, 15000);
const timer = setTimeout(() => { ws.close(); reject(new Error('PTY ready timeout')); }, 30000);
let output = '';
let blocked = false;
ws.onerror = () => { clearTimeout(timer); reject(new Error('upgrade rejected')); };
ws.onopen = () => ws.send(JSON.stringify({type: 'input', data:
"stty raw -echo; printf 'BLOCK%s\\\\n' READY240; sleep 30\\r"}));
let sent = false;
ws.onmessage = event => {
if (!(event.data instanceof ArrayBuffer)) return;
ws.send(JSON.stringify({type: 'ack'}));
output += new TextDecoder().decode(event.data);
// Type only once the shell has written something; a command sent on
// open can land before a slow login shell starts reading.
if (!sent) {
sent = true;
ws.send(JSON.stringify({type: 'input', data:
"stty raw -echo; printf 'BLOCK%s\\\\n' READY240; sleep 30\\r"}));
}
if (!blocked && output.includes('BLOCKREADY240')) {
blocked = true;
ws.send(JSON.stringify({type: 'input', data: 'x'.repeat(60000)}));
Expand Down
11 changes: 9 additions & 2 deletions tests/frontend/test_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,9 +306,9 @@ def wait_output(text: str) -> None:
const timer = setTimeout(() => {
ws.close();
reject(new Error('PTY ready timeout: ' + JSON.stringify(output.slice(-2000))));
}, 15000);
}, 30000);
ws.onerror = () => { clearTimeout(timer); reject(new Error('upgrade rejected')); };
ws.onopen = () => ws.send(JSON.stringify({type: 'input', data: command}));
let sent = false;
ws.onmessage = event => {
if (!(event.data instanceof ArrayBuffer)) return;
ws.send(JSON.stringify({type: 'ack'}));
Expand All @@ -319,6 +319,13 @@ def wait_output(text: str) -> None:
for (; answered < queries; answered++) {
ws.send(JSON.stringify({type: 'input', data: '\\x1b[1;1R'}));
}
// Type only once the shell has written something, as a user
// would; a command sent on open can land before a slow login
// shell starts reading its terminal.
if (!sent) {
sent = true;
ws.send(JSON.stringify({type: 'input', data: command}));
}
if (!blocked && output.includes('BLOCKREADY240')) {
blocked = true;
ws.send(JSON.stringify({type: 'input', data: 'x'.repeat(60000)}));
Expand Down
Loading