From 4593a3b8f2ff412847737a3abac517bd87726667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Wed, 4 Nov 2026 10:40:34 +0800 Subject: [PATCH] fix(ci): run desktop smoke headless on rpm roots without Xvfb (#16211) * fix(ci): give the RHEL 10 desktop smoke a headless display RHEL 10 removed xorg-x11-server-Xvfb from every repository: BaseOS, AppStream, CRB, extras and EPEL 10 contain no such build, so the rpm build-root dependency step failed one step after #16154 fixed the EPEL bootstrap (`No match for argument: xorg-x11-server-Xvfb`, run 41642580745, job 125940965630). Keep Xvfb for the Fedora 44 leg and give the RHEL 10 smoke a real display instead: a weston headless compositor with rootless Xwayland on top, since GTK3 has no headless backend and WebKitGTK always needs a display server. Export WAYLAND_DISPLAY before Xwayland is launched, wait for both sockets with a hard timeout, and pin the app to X11 so the bridge is actually used. Verified end to end on a Desktop Checks dispatch of this head (run 41652880267): the RHEL 10 rpm lane passes dependency installation and the packaged smoke, and the Fedora 44 lane keeps its xvfb-run path. The three workflow meta-tests that parse this file pass (161 passed, 1 skipped). * fix(ci): key the rpm display arm on the matrix and harden its meta-test Review round 2 findings on #16211: - R2-1: the display arm keyed on an artifact_suffix literal while the EPEL bootstrap it depends on keys on matrix.epel. Declare matrix.display ('xvfb' on Fedora 44, 'weston' on RHEL 10) and branch on that, so a leg added later picks a server instead of inheriting an arm by accident. - R2-2: the xvfb-run arm shipped a shell function through declare -f into a child bash without -e -u -o pipefail. Run the same npm command in both arms so both keep the step's error regime; assert no declare -f remains. - R2-3 / R1-4: bound the shared package list by the conditional that follows it instead of a prose comment, ignore comments when checking it, and assert the xvfb/weston arms, the weston socket name against WAYLAND_DISPLAY, and the Xwayland display number against DISPLAY and the X11 socket. * fix(ci): fail the rpm display arm closed and pin the smoke's wiring R2-1: the install arm keyed on `matrix.display` but defaulted every unknown value to the weston arm, which only works where the EPEL bootstrap above has run. Turn it into an explicit `case` with an error arm, and assert from the matrix that a non-Fedora root taking the weston arm has EPEL enabled, so a new leg with `display: 'weston'` and `epel: 'false'` turns the meta-test red instead of dying at `dnf install` in a release run. R1-4: restore the two pins the previous rewrite dropped (`weston --backend=headless-backend.so`, `XDG_RUNTIME_DIR`) and bind the rest to the step's structure rather than to bare substrings: the weston readiness wait must sit between the two launches, the helper must `return 1` on timeout, and the EXIT trap must reap both daemons. Each added assertion was checked against the mutation it names (all five turn red; the unmutated tree stays green). Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmw1dkg4zbx --------- Co-authored-by: Qwen-Coder --- .github/workflows/desktop-release.yml | 69 +++++++++++- scripts/tests/desktop-isolation.test.js | 133 ++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 2 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 6235b8e527a..fcad7d6e740 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -381,6 +381,7 @@ jobs: glibc_floor: '2.43' rpm: 'true' epel: 'false' + display: 'xvfb' legacy_arch: '' rust_target: 'x86_64-unknown-linux-gnu' tauri_config: 'src-tauri/rpm-fedora44.conf.json' @@ -394,6 +395,7 @@ jobs: glibc_floor: '2.39' rpm: 'true' epel: 'true' + display: 'weston' legacy_arch: '' rust_target: 'x86_64-unknown-linux-gnu' tauri_config: 'src-tauri/rpm-rhel10.conf.json' @@ -484,7 +486,29 @@ jobs: rpm cpio \ libappindicator-gtk3-devel librsvg2-devel \ gtk3 mesa-libgbm libdrm at-spi2-core dbus-x11 xdg-utils \ - xorg-x11-server-Xvfb webkit2gtk4.1-devel + webkit2gtk4.1-devel + # Fedora 44 still ships Xvfb; RHEL 10 removed it from every + # repository (BaseOS, AppStream, CRB, extras and EPEL 10 carry no + # such build). `matrix.display` names the server each rpm root can + # install, so a leg added later picks one instead of inheriting an + # arm by accident. An unknown value fails closed: the weston arm + # needs the EPEL bootstrap above, so guessing it would be wrong on + # every root that has no EPEL. + case '${{ matrix.display }}' in + xvfb) + dnf install -y --setopt=install_weak_deps=False xorg-x11-server-Xvfb + ;; + weston) + # weston (EPEL) provides a headless Wayland display and Xwayland + # (AppStream) bridges it to X11 for WebKitGTK. The smoke step + # below starts both. + dnf install -y --setopt=install_weak_deps=False weston xorg-x11-server-Xwayland + ;; + *) + echo "matrix.display must be 'xvfb' or 'weston', got '${{ matrix.display }}'" >&2 + exit 1 + ;; + esac - name: 'Install Qwen Code dependencies' shell: 'bash' @@ -813,7 +837,48 @@ jobs: shell: 'bash' run: | set -euo pipefail - xvfb-run -a npm run smoke:packaged -- src-tauri/target/${{ matrix.rust_target }}/release/qwen-code-desktop + executable='src-tauri/target/${{ matrix.rust_target }}/release/qwen-code-desktop' + # A timed-out wait must fail here: `[ -S ... ] && break` is exempt + # from `set -e`, so falling through would blame the app instead. + wait_for_socket() { + for _ in $(seq 1 50); do + [ -S "$1" ] && return 0 + sleep 0.2 + done + echo "::error::$2 never created $1 within 10s" >&2 + return 1 + } + # WebKitGTK needs a real display: GTK3 has no headless backend. + # Fedora keeps Xvfb; RHEL 10 removed it, so there the display is a + # headless weston compositor with Xwayland on top. + if command -v xvfb-run >/dev/null 2>&1; then + xvfb-run -a npm run smoke:packaged -- "$executable" + else + command -v weston >/dev/null 2>&1 || { + echo '::error::weston is not installed'; exit 1; + } + command -v Xwayland >/dev/null 2>&1 || { + echo '::error::Xwayland is not installed'; exit 1; + } + export XDG_RUNTIME_DIR="$RUNNER_TEMP/xdg-runtime" + mkdir -p "$XDG_RUNTIME_DIR" + chmod 700 "$XDG_RUNTIME_DIR" + # Xwayland reads its compositor from its own environment, so the + # socket name has to be exported before it is launched. + export WAYLAND_DISPLAY=qwen-smoke + weston --backend=headless-backend.so --socket=qwen-smoke --idle-time=0 & + weston_pid=$! + trap 'kill ${xwayland_pid:-} $weston_pid 2>/dev/null || true' EXIT + wait_for_socket "$XDG_RUNTIME_DIR/qwen-smoke" weston + Xwayland :33 -rootless -wm -1 & + xwayland_pid=$! + wait_for_socket /tmp/.X11-unix/X33 Xwayland + export DISPLAY=:33 + # GTK3 prefers Wayland when both are exported, which would leave + # the X11 bridge above unused; pin the app to that display. + export GDK_BACKEND=x11 + npm run smoke:packaged -- "$executable" + fi - name: 'Collect artifacts' shell: 'bash' diff --git a/scripts/tests/desktop-isolation.test.js b/scripts/tests/desktop-isolation.test.js index 56e9511bfb3..084c0067808 100644 --- a/scripts/tests/desktop-isolation.test.js +++ b/scripts/tests/desktop-isolation.test.js @@ -105,6 +105,139 @@ describe('desktop rpm build-root dependencies', () => { source.indexOf('sha256sum -c -'), ); }); + + it('keeps the smoke runnable on rpm roots without Xvfb', () => { + const install = release.jobs.build.steps.find( + (step) => step.name === 'Install rpm build-root dependencies', + ); + // The display server a leg can install is a matrix property: EPEL, which + // supplies weston, is only enabled on the leg that has no Xvfb. + const displays = Object.fromEntries( + release.jobs.build.strategy.matrix.include.map((leg) => [ + leg.name, + leg.display, + ]), + ); + expect(displays['Linux x64 (rpm, Fedora 44)']).toBe('xvfb'); + expect(displays['Linux x64 (rpm, RHEL 10)']).toBe('weston'); + + // Fedora 44 ships weston from its own updates repo, so "weston ⇒ EPEL" is + // not the rule; the rule is that a non-Fedora root taking the weston arm + // has the EPEL bootstrap above enabled. Read the coupling from the matrix + // so a new leg with `display: 'weston'` and `epel: 'false'` turns this red + // instead of dying at `dnf install` in a release run. + const containerLegs = release.jobs.build.strategy.matrix.include.filter( + (leg) => leg.container, + ); + expect(containerLegs.length).toBeGreaterThan(0); + for (const leg of containerLegs) { + expect(['xvfb', 'weston']).toContain(leg.display); + if ( + leg.display !== 'xvfb' && + !String(leg.artifact_suffix).includes('fedora') + ) { + expect(leg.epel).toBe('true'); + } + } + + // Bound the shared package list by the arm switch that follows it, not by + // a prose comment: rewording the comment must not move the bound. + const switchAt = install.run.indexOf("case '${{ matrix.display }}' in"); + expect(switchAt).toBeGreaterThan(-1); + const sharedInstalls = install.run + .slice(0, switchAt) + .split('\n') + .filter((line) => !line.trim().startsWith('#')) + .join('\n'); + expect(sharedInstalls).not.toContain('Xvfb'); + // Each arm is bounded by its own pattern and terminating `;;`, so another + // `case`/`if` above cannot re-point the slice. + const installArm = (pattern) => { + const start = install.run.indexOf(`\n ${pattern})`, switchAt); + expect(start).toBeGreaterThan(switchAt); + const end = install.run.indexOf(';;', start); + expect(end).toBeGreaterThan(start); + return install.run.slice(start, end); + }; + const xvfbArm = installArm('xvfb'); + const westonArm = installArm('weston'); + expect(xvfbArm).toContain('xorg-x11-server-Xvfb'); + expect(xvfbArm).not.toContain('weston'); + expect(westonArm).toContain('weston xorg-x11-server-Xwayland'); + expect(westonArm).not.toContain('xorg-x11-server-Xvfb'); + // An unrecognised `matrix.display` must fail closed: defaulting it to the + // weston arm installs packages the root may have no repository for. + const fallbackArm = installArm('*'); + expect(fallbackArm).toContain('exit 1'); + + const smoke = release.jobs.build.steps.find( + (step) => + step.name === 'Smoke packaged application' && + String(step.if).includes("runner.os == 'Linux'"), + ); + expect(smoke.run).toContain('command -v xvfb-run'); + expect(smoke.run).not.toContain('GDK_BACKEND=dummy'); + // Both arms must run the same smoke under the step's own -e -u -o pipefail; + // a function shipped through `declare -f` into a child shell drops them. + expect(smoke.run).not.toContain('declare -f'); + expect( + smoke.run.match(/npm run smoke:packaged -- "\$executable"/gu), + ).toHaveLength(2); + + // The compositor socket the step exports has to be the one weston opens, + // and the export has to precede the launch: Xwayland reads its own + // environment at startup. + const socket = /--socket=(\S+)/u.exec(smoke.run)?.[1]; + expect(socket).toBeTruthy(); + // Without this flag weston finds no DRM device in the container and the + // leg dies in a release run, where nothing else reads this step. + expect(smoke.run).toContain( + `weston --backend=headless-backend.so --socket=${socket}`, + ); + expect(smoke.run).toContain('XDG_RUNTIME_DIR'); + const waylandExport = `export WAYLAND_DISPLAY=${socket}`; + expect(smoke.run).toContain(waylandExport); + expect(smoke.run.indexOf(waylandExport)).toBeLessThan( + smoke.run.indexOf('Xwayland :'), + ); + + // Xwayland must not start before its compositor exists, so the weston + // readiness wait has to sit between the two launches. + const westonWait = `wait_for_socket "$XDG_RUNTIME_DIR/${socket}" weston`; + expect(smoke.run).toContain(westonWait); + expect(smoke.run.indexOf(westonWait)).toBeGreaterThan( + smoke.run.indexOf('weston_pid=$!'), + ); + expect(smoke.run.indexOf(westonWait)).toBeLessThan( + smoke.run.indexOf('Xwayland :'), + ); + + // Both daemons have to be reaped, or the runner hangs on the sockets. + expect(smoke.run).toContain("trap 'kill ${xwayland_pid:-} $weston_pid"); + + // A timed-out wait must fail the step: `[ -S ... ] && break` is exempt + // from `set -e`, so a helper that only prints would blame the app. + const helper = /wait_for_socket\(\) \{([\s\S]*?)\n\}/u.exec(smoke.run); + expect(helper).not.toBeNull(); + expect(helper[1]).toContain('return 1'); + + // The display Xwayland serves, the socket the step waits on and the + // DISPLAY the app receives must all name the same number. + const display = /Xwayland :(\d+)/u.exec(smoke.run)?.[1]; + expect(display).toBeTruthy(); + expect(smoke.run).toContain(`wait_for_socket /tmp/.X11-unix/X${display}`); + const displayExport = `export DISPLAY=:${display}`; + expect(smoke.run).toContain(displayExport); + expect(smoke.run.indexOf(displayExport)).toBeGreaterThan( + smoke.run.indexOf('Xwayland :'), + ); + + // With both displays exported GTK3 prefers Wayland, which would leave the + // X11 bridge above unused; the app has to be pinned to it. + expect(smoke.run.indexOf('export GDK_BACKEND=x11')).toBeGreaterThan( + smoke.run.indexOf('Xwayland :'), + ); + }); }); describe('desktop isolation guard — nativePrefixes coverage', () => {