From 396765572febeb14c0d2c750cd58bc8321624e10 Mon Sep 17 00:00:00 2001 From: Charlie Knudsen Date: Mon, 10 Aug 2026 02:04:13 -0500 Subject: [PATCH 1/2] fix(deb): measure Depends: on Wayland too, and check the closure there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .deb's `Depends:` was measured under X11 only (plan 022 used `strace` under `xvfb-run`) and the closure check is X11-only too, so the gap was structurally invisible: the X11 EGL platform never calls `wl_egl_window_create`, so no amount of X11 testing can notice a missing Wayland library. Wayland is the primary Linux target. ## Depends: += libegl1, libwayland-egl1 Measured over SEVEN configurations, not the four the plan called for: {Wayland, X11} x {Vulkan ICD, none} x {libEGL reachable, not}. The third axis is the whole finding. With neither a Vulkan ICD nor libEGL, wgpu enumerates zero adapters and iced quietly renders on the CPU via tiny-skia — so the GLES path is never entered and `libwayland-egl.so.1` is never even probed. All four cells of the planned matrix pass, none of them loads it, and their union would have kept the gap open. The failure, once libEGL is reachable (which on a real desktop it always is): roost does not degrade, it SIGABRTs (exit 134) inside `Surface::configure` after eight ENOENT probes for `libwayland-egl.so.1`. iced's fallback compositor covers compositor *creation*, not surface configure. Installing `libwayland-egl1` into that same container is the positive control — it comes up on the GL backend having opened the library. So the two entries travel together: adding `libegl1` alone would CREATE that crash on Wayland rather than prevent it. Nothing was removed; all eleven existing entries were re-confirmed by trace. `libvulkan1` stays in Recommends — measured, the app starts, stays up and renders a real frame with no loader and no ICD. `libdecor` is confirmed NOT needed (zero hits in all seven traces): sctk-adwaita draws decorations in-process. ## The closure check runs both display servers, compositor OUT of container `--display x11|wayland|both`, defaulting to both, so the existing CI and release callers pick up the Wayland leg with no workflow change. Ubuntu's `weston` itself depends on `libwayland-egl1`, so installing it beside the package under test would satisfy the very dependency under test and the negative control would pass on a broken package. The compositor therefore runs in its own container and only its socket is shared; the package container installs NOTHING but the .deb. The X11 leg gets the same treatment — Xvfb with `-ac` so no `xauth` is needed either. Two assertions were added, and the first is the load-bearing one: * every dlopened soname must resolve via `ldconfig -p`. This is the detector. A launch-only check cannot catch this bug at all, because in a `--no-install-recommends` container the app starts and renders without either library. * liveness: the app must still be alive and answering `identify` after a further 8s, and exit with an expected status. `App::bootstrap` binds the IPC socket before iced creates the window, so a single `identify` only proves bootstrap got that far. Verified in a Linux VM (aarch64): both legs pass in 54s; the negative control — `libwayland-egl1` stripped from `Depends:`, `libegl1` kept — reds the Wayland leg with `::MISSING:: libwayland-egl.so.1` and leaves X11 green, which is the asymmetry the X11-only check could not express. The `dpkg` state proves it: 136 packages with the dependency, 135 without, `diff` of the sorted lists exactly one line. ## Review findings Fixed during review (four correctness bugs in the harness, all found by running it rather than reading it): * three shell-quoting/`set -e` defects meant the harness could never pass — the payload's own apostrophes closed the `-c` quote, a `${binary:Package}` format string expanded on the wrong side, and `wait` under `set -e` exited 143 on every successful shutdown. * `LAUNCH_TIMEOUT` wrapped `apt-get` as well as the launch, so a slow mirror would have been reported as a Depends: diagnosis. Install and launch are now separate phases. * the remapped inner-timeout code collided with docker's own exit 125, letting a daemon error masquerade as an app-launch timeout. It is 122 now, and 125/126/127 are reported as harness failures. * the soname check was an unanchored `grep` with unescaped dots, so `libEGL.so.10` would have satisfied `libEGL.so.1`. It is an exact first-field match now. Known limit: measured on aarch64. The dlopen sonames come from Rust source constants that are not `cfg(target_arch)`-gated, so the list should be arch-invariant, but a confirming amd64 run is cheap and CI will do it on this PR. Closes #325. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgxiEBQTqgNjPswKqcs12d --- linux/scripts/verify-deb-closure.sh | 399 +++++++++++++++++++++++----- packaging/nfpm.yaml | 21 ++ 2 files changed, 354 insertions(+), 66 deletions(-) diff --git a/linux/scripts/verify-deb-closure.sh b/linux/scripts/verify-deb-closure.sh index 8b4eb010..fc009f1c 100755 --- a/linux/scripts/verify-deb-closure.sh +++ b/linux/scripts/verify-deb-closure.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Verify the Roost .deb's dependency closure by installing and launching it in -# a clean container. +# a clean container, under BOTH display servers. # # smoke-deb.sh extracts the .deb, so it validates the payload (file list, exec # bits, destinations) but NOT the dependency closure: a build/CI runner already @@ -11,26 +11,59 @@ # container with --no-install-recommends, which also proves the # Recommends-vs-Depends split (no Vulkan loader in there at all). # -# Prerequisites: docker (the image is pulled on first run). +# Why the display server is now a parameter, and why the compositor runs in a +# SEPARATE container (issue #325): +# +# * The old check was X11-only, and wgpu's GLES backend reaches +# `wl_egl_window_create` only on the Wayland EGL platform — the X11 EGL +# platform never needs libwayland-egl. So an X11-only check is structurally +# incapable of noticing a missing Wayland dependency, which is exactly how +# libwayland-egl1 went missing from Depends:. +# * Ubuntu's `weston` depends on libwayland-egl1, and `xvfb`/`xauth` drag in +# X libraries. Installing either beside the package under test satisfies the +# very dependency being tested, and the negative control would pass on a +# broken package. So the compositor lives in its own container and only its +# socket is shared. The package container installs NOTHING but the .deb. +# +# Why the ldconfig assertions, and not just "did it launch" (#325, measured): +# +# In a --no-install-recommends container there is no Vulkan ICD, so wgpu +# enumerates zero adapters and iced falls back to its tiny-skia CPU +# compositor — which needs neither libEGL nor libwayland-egl. The app +# therefore STARTS AND RENDERS with those dependencies missing, and a launch- +# only check stays green on this bug forever. The crash (SIGABRT inside +# `Surface::configure`) only appears once libEGL is reachable, which on a real +# desktop it always is and in this container it is not. The resolvability +# assertions below are the load-bearing part of the Wayland leg; the launch is +# the backstop, not the detector. +# +# Prerequisites: docker (images are pulled on first run). # # Usage: # ./linux/scripts/verify-deb-closure.sh out/roost_0.0.18_amd64.deb +# ./linux/scripts/verify-deb-closure.sh --display wayland out/roost.deb set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=linux/scripts/_common.sh . "${SCRIPT_DIR}/_common.sh" -USAGE="usage: $(basename "$0") " +USAGE="usage: $(basename "$0") [--display x11|wayland|both] " usage() { printf '%s\n' "${USAGE}"; } deb="" +display="both" while [ "$#" -gt 0 ]; do case "$1" in -h|--help) usage exit 0 ;; + --display) + [ "$#" -ge 2 ] || { usage >&2; die "--display requires a value"; } + display="$2" + shift 2 + ;; -*) usage >&2 die "unknown flag: $1" @@ -43,83 +76,317 @@ while [ "$#" -gt 0 ]; do esac done +case "${display}" in + x11|wayland|both) ;; + *) usage >&2; die "--display must be x11, wayland or both" ;; +esac + [ -n "${deb}" ] || { usage >&2; die "missing "; } [ -f "${deb}" ] || die "no such .deb: ${deb}" -require_tools docker +require_tools docker timeout deb="$(abspath "${deb}")" # Every wait is bounded. An unbounded version of this ran for 31 minutes in a # shed before it was killed by hand — a release-gating step that can hang is a # defect, because `release.yml`'s only backstop is the job's 90-minute timeout, -# burned after `create-release` has already published the Release. `timeout` -# also runs xvfb-run in its own process group, which is what stops the -# backgrounded UI from keeping the container's streams open after the inner -# shell is done. +# burned after `create-release` has already published the Release. LAUNCH_TIMEOUT="${ROOST_CLOSURE_LAUNCH_TIMEOUT:-120}" DOCKER_TIMEOUT="${ROOST_CLOSURE_DOCKER_TIMEOUT:-900}" -require_tools timeout - -set +e -# shellcheck disable=SC2016 # $LAUNCH_TIMEOUT/$XDG_RUNTIME_DIR are expanded by -# the shell INSIDE the container (the value arrives via `-e`), not out here. -timeout "${DOCKER_TIMEOUT}" docker run --rm \ - -e "LAUNCH_TIMEOUT=${LAUNCH_TIMEOUT}" \ - -v "${deb}:/tmp/roost.deb:ro" ubuntu:24.04 bash -eu -c ' +COMPOSITOR_TIMEOUT="${ROOST_CLOSURE_COMPOSITOR_TIMEOUT:-300}" +IMAGE="${ROOST_CLOSURE_IMAGE:-ubuntu:24.04}" + +# How long the app must still be alive and answering AFTER its first successful +# identify. `App::bootstrap` binds the IPC socket before iced creates the +# window, so a renderer or EGL failure that kills the process a moment later can +# still answer one identify inside the poll window. Without this second look the +# check cannot tell "launched" from "bound the socket and then died". +LIVENESS_SECONDS="${ROOST_CLOSURE_LIVENESS_SECONDS:-8}" + +run_id="roost-closure-$$" +compositor_cid="" +share_dir="" + +cleanup() { + if [ -n "${compositor_cid}" ]; then + docker rm -f "${compositor_cid}" >/dev/null 2>&1 || true + compositor_cid="" + fi + if [ -n "${share_dir}" ] && [ -d "${share_dir}" ]; then + # The compositor container runs as root, so its socket is root-owned and a + # plain rm leaves the directory behind on any non-root host. Borrow root + # from a throwaway container to empty it; best-effort either way. + rm -rf "${share_dir}" 2>/dev/null || { + docker run --rm -v "${share_dir}:/share" "${IMAGE}" \ + find /share -mindepth 1 -delete >/dev/null 2>&1 || true + rmdir "${share_dir}" 2>/dev/null || true + } + share_dir="" + fi +} +trap cleanup EXIT + +# Start the display server in its own container and wait for its socket to show +# up in the shared directory. `$1` is the leg name; sets `compositor_cid`. +start_compositor() { + local leg="$1" name sock + name="${run_id}-${leg}-compositor" + + case "${leg}" in + wayland) + # weston's headless backend needs no GPU and no seat. `--idle-time=0` so + # it never suspends the output under a long apt-get in the other + # container. + compositor_cid="$(timeout "${COMPOSITOR_TIMEOUT}" docker run -d --rm --name "${name}" \ + -v "${share_dir}:/share" \ + -e XDG_RUNTIME_DIR=/share \ + "${IMAGE}" bash -eu -c ' + apt-get update -qq + apt-get install -y -qq --no-install-recommends weston + chmod 700 /share + exec weston --backend=headless-backend.so --socket=wayland-closure \ + --width=1280 --height=800 --idle-time=0 + ')" + sock="${share_dir}/wayland-closure" + ;; + x11) + # `-ac` disables access control so the package container needs no xauth — + # xauth is an X client library dependency we must not install beside the + # package under test. + compositor_cid="$(timeout "${COMPOSITOR_TIMEOUT}" docker run -d --rm --name "${name}" \ + -v "${share_dir}:/tmp/.X11-unix" \ + "${IMAGE}" bash -eu -c ' + apt-get update -qq + apt-get install -y -qq --no-install-recommends xvfb + exec Xvfb :99 -screen 0 1280x800x24 -ac -nolisten tcp + ')" + sock="${share_dir}/X99" + ;; + esac + + # `docker run -d` returns as soon as the container is created, but an image + # pull happens first and is unbounded without this — the readiness loop below + # would never even start, leaving the CI job timeout as the only backstop. + [ -n "${compositor_cid}" ] || die "could not start the ${leg} compositor container within ${COMPOSITOR_TIMEOUT}s" + + local waited=0 + while [ "${waited}" -lt "${COMPOSITOR_TIMEOUT}" ]; do + [ -S "${sock}" ] && return 0 + if ! docker inspect -f '{{.State.Running}}' "${compositor_cid}" 2>/dev/null | grep -q true; then + echo "--- ${leg} compositor container log ---" >&2 + docker logs "${compositor_cid}" 2>&1 | tail -40 >&2 || true + die "the ${leg} compositor container exited before its socket appeared" + fi + sleep 1 + waited=$((waited + 1)) + done + echo "--- ${leg} compositor container log ---" >&2 + docker logs "${compositor_cid}" 2>&1 | tail -40 >&2 || true + die "the ${leg} compositor socket never appeared within ${COMPOSITOR_TIMEOUT}s" +} + +# The body that runs inside the package container. Identical for both legs; the +# display environment is the only difference and arrives via `-e`. +# +# Split into two phases on purpose. `LAUNCH_TIMEOUT` must bound the LAUNCH and +# nothing else: when it also covered `apt-get`, a slow mirror timed out and got +# reported as "the package installed but did not come up", which is a +# diagnosis about the Depends: list drawn from an apt failure. +# +# shellcheck disable=SC2016 # every $VAR here is expanded INSIDE the container. +INSTALL_SCRIPT=' apt-get update -qq - # --no-install-recommends is the strict case: only what Depends - # actually names gets installed. + # --no-install-recommends is the strict case: only what Depends actually + # names gets installed. apt-get install -y -qq --no-install-recommends /tmp/roost.deb - # xvfb/xauth are the harness, not the package under test. - apt-get install -y -qq --no-install-recommends xvfb xauth - export XDG_RUNTIME_DIR=/tmp/rt - mkdir -p "$XDG_RUNTIME_DIR"; chmod 700 "$XDG_RUNTIME_DIR" - timeout "${LAUNCH_TIMEOUT}" xvfb-run -a --server-args="-screen 0 1280x800x24" bash -c " - /usr/bin/roost >/tmp/app.log 2>&1 & - ui=\$! - ok=no - for _ in \$(seq 1 60); do - if /usr/bin/roostctl identify >/dev/null 2>&1; then ok=yes; break; fi - sleep 0.5 - done - if [ \"\$ok\" != yes ]; then - echo \"the installed package never answered roostctl identify within 30s\" - tail -40 /tmp/app.log || true - kill \"\$ui\" 2>/dev/null || true - exit 1 - fi - /usr/bin/roostctl identify - # Reap the UI rather than leaving it to the container teardown: a live - # background process still holding the streams of the container is what - # turns a finished check into a hung one. - kill \"\$ui\" 2>/dev/null || true - wait \"\$ui\" 2>/dev/null || true - " || { - inner=$? - # Re-map the inner timeout off 124. Both timeouts exit 124, and the inner - # one propagates out as the container status, so leaving it would make the - # launch timeout report itself as the outer docker budget — the same - # misdiagnosis this script was rewritten to stop making, one layer down. - [ "$inner" -eq 124 ] && { tail -40 /tmp/app.log || true; exit 125; } - exit "$inner" - } + + # Snapshot immediately after the package under test and BEFORE anything else, + # so "the harness supplied it" is a provable claim rather than an assumption. + # Nothing else is installed in this container at all — the display server runs + # in a separate one — so this snapshot IS the final state. + dpkg-query -W -f "\${binary:Package}\n" | sort > /tmp/pkgs-after-deb.txt + echo "packages present after installing the deb: $(wc -l < /tmp/pkgs-after-deb.txt)" + + # Every soname the binary dlopens must be resolvable from the .deb'"'"'s own + # closure. Nothing but the .deb is installed in this container, so a miss here + # is a Depends: gap with no other candidate. + # + # libwayland-egl.so.1 is asserted on the WAYLAND leg only, because that is the + # truth: the X11 EGL platform never calls wl_egl_window_create. Stripping + # libwayland-egl1 from Depends: therefore reds the Wayland leg and leaves the + # X11 leg green — which is the whole point, and precisely what the previous + # X11-only check could not express. + sonames="libxkbcommon.so.0 libxkbcommon-x11.so.0 libwayland-client.so.0 + libX11.so.6 libX11-xcb.so.1 libxcb.so.1 libXcursor.so.1 libXi.so.6 + libEGL.so.1" + if [ "${ROOST_LEG}" = wayland ]; then + sonames="${sonames} libwayland-egl.so.1" + fi + + # Exact first-field match, not `grep`: in `ldconfig -p` output the soname is + # the first field, and an unanchored grep with unescaped dots would let + # libEGL.so.10 satisfy a libEGL.so.1 check. + missing="" + for soname in ${sonames}; do + ldconfig -p | awk -v s="${soname}" '"'"'$1 == s { hit = 1 } END { exit !hit }'"'"' \ + || missing="${missing} ${soname}" + done + if [ -n "${missing}" ]; then + echo "::MISSING::${missing}" + exit 3 + fi ' -rc=$? -set -e - -# Do NOT assert a cause. The original inline version reported every failure as -# "its Depends: list is incomplete", which misdiagnosed a hang as a dependency -# bug and would have sent someone hunting a package that was actually fine. -case "${rc}" in - 0) ;; - 124) - die "the closure check exceeded its overall ${DOCKER_TIMEOUT}s budget before the UI launch was reached (docker pull or apt wedged). This is a harness/environment failure, NOT evidence about the Depends: list." - ;; - 125) - die "the installed package did not answer roostctl identify within ${LAUNCH_TIMEOUT}s and the launch was killed. Distinct from the ${DOCKER_TIMEOUT}s budget above: the package DID install, so this points at the app or its runtime dependencies, not at docker or apt." + +# shellcheck disable=SC2016 # every $VAR here is expanded INSIDE the container. +LAUNCH_SCRIPT=' + /usr/bin/roost >/tmp/app.log 2>&1 & + ui=$! + + ok=no + for _ in $(seq 1 60); do + if /usr/bin/roostctl identify >/dev/null 2>&1; then ok=yes; break; fi + # If the process is already gone there is nothing left to wait for. + if ! kill -0 "$ui" 2>/dev/null; then break; fi + sleep 0.5 + done + if [ "$ok" != yes ]; then + echo "the installed package never answered roostctl identify within 30s" + tail -60 /tmp/app.log || true + kill "$ui" 2>/dev/null || true + exit 1 + fi + /usr/bin/roostctl identify + + # Liveness. The socket is bound before the window exists, so one successful + # identify only proves bootstrap got that far. + sleep "${LIVENESS_SECONDS}" + if ! kill -0 "$ui" 2>/dev/null; then + # `set -e` would abort on a non-zero `wait`, taking the diagnostic with it. + status=0 + wait "$ui" 2>/dev/null || status=$? + echo "the UI answered identify and then exited (status ${status}) — it bound the socket but did not stay up" + tail -60 /tmp/app.log || true + exit 4 + fi + if ! /usr/bin/roostctl identify >/dev/null 2>&1; then + echo "the UI is still running but stopped answering identify after ${LIVENESS_SECONDS}s" + tail -60 /tmp/app.log || true + kill "$ui" 2>/dev/null || true + exit 4 + fi + + # Reap the UI rather than leaving it to the container teardown: a live + # background process still holding the container'"'"'s streams is what turns a + # finished check into a hung one. SIGTERM is expected, so 143 is the success + # status here; anything else means it was already dying. + kill "$ui" 2>/dev/null || true + status=0 + wait "$ui" 2>/dev/null || status=$? + case "${status}" in + 0|143) ;; + *) + echo "the UI exited with an unexpected status (${status}) on shutdown" + tail -60 /tmp/app.log || true + exit 4 + ;; + esac + + echo "leg ok: ${ROOST_LEG}" +' + +run_leg() { + local leg="$1" + echo "==> closure leg: ${leg}" + + share_dir="$(mktemp -d)" + chmod 700 "${share_dir}" + start_compositor "${leg}" + + local -a display_env + case "${leg}" in + wayland) + # No DISPLAY at all, so winit cannot silently fall back to X11 and quietly + # turn this into a second X11 leg. + display_env=( + -v "${share_dir}:/share" + -e XDG_RUNTIME_DIR=/share + -e WAYLAND_DISPLAY=wayland-closure + -e WINIT_UNIX_BACKEND=wayland + ) + ;; + x11) + display_env=( + -v "${share_dir}:/tmp/.X11-unix" + -e DISPLAY=:99 + -e WINIT_UNIX_BACKEND=x11 + ) + ;; + esac + + set +e + timeout "${DOCKER_TIMEOUT}" docker run --rm \ + --name "${run_id}-${leg}-package" \ + "${display_env[@]}" \ + -e "ROOST_LEG=${leg}" \ + -e "LIVENESS_SECONDS=${LIVENESS_SECONDS}" \ + -v "${deb}:/tmp/roost.deb:ro" \ + "${IMAGE}" bash -eu -c " + export XDG_RUNTIME_DIR=\${XDG_RUNTIME_DIR:-/tmp/rt} + mkdir -p \"\$XDG_RUNTIME_DIR\"; chmod 700 \"\$XDG_RUNTIME_DIR\" + # The two scripts arrive as \$1 and \$2, separate argv words. Interpolating + # them into a quoted command string instead lets any apostrophe in their + # own comments close that quote and spill the remainder into this shell. + bash -eu -c \"\$1\" install-phase + timeout ${LAUNCH_TIMEOUT} bash -eu -c \"\$2\" launch-phase || { + inner=\$? + # Re-map the inner timeout off 124: both timeouts exit 124 and the inner + # status propagates out as the container status, so leaving it would + # make the launch timeout report itself as the outer docker budget. + # 122 rather than 125 because docker reserves 125/126/127 for its own + # failures, and reusing one would let a docker daemon error masquerade + # as an app-launch timeout. + [ \"\$inner\" -eq 124 ] && exit 122 + exit \"\$inner\" + } + " _ "${INSTALL_SCRIPT}" "${LAUNCH_SCRIPT}" + local rc=$? + set -e + + cleanup + + # Do NOT assert a cause for the generic failures. The original inline version + # reported every failure as "its Depends: list is incomplete", which + # misdiagnosed a hang as a dependency bug. + case "${rc}" in + 0) ;; + 3) + die "[${leg}] a library the app dlopens is not present after installing the .deb with --no-install-recommends. Nothing but the .deb was installed in that container — the display server runs in its own — so this is a Depends: gap, not harness contamination." + ;; + 4) + die "[${leg}] the package installed and answered roostctl identify, then failed the liveness re-check. The IPC socket is bound before the window exists, so this is the shape a renderer/EGL failure takes: bootstrap succeeds, the window does not." + ;; + 122) + die "[${leg}] the installed package did not come up within ${LAUNCH_TIMEOUT}s and the launch was killed. Distinct from the ${DOCKER_TIMEOUT}s budget below: the package DID install and its dlopen closure DID resolve, so this points at the app, not at docker or apt." + ;; + 124) + die "[${leg}] the closure check exceeded its overall ${DOCKER_TIMEOUT}s budget before the UI launch was reached (docker pull or apt wedged). This is a harness/environment failure, NOT evidence about the Depends: list." + ;; + 125|126|127) + die "[${leg}] docker itself failed (exit ${rc}: container could not be created, the command could not be invoked, or it was not found). This is a harness/environment failure, NOT evidence about the Depends: list." + ;; + *) + die "[${leg}] the .deb installed but did not come up in a clean container (exit ${rc}). The most likely cause is an incomplete Depends: list — the container has nothing preinstalled — but check the log above before concluding that: a docker or apt-mirror failure lands here too." + ;; + esac +} + +case "${display}" in + both) + run_leg x11 + run_leg wayland ;; *) - die "the .deb installed but did not come up in a clean container (exit ${rc}). The most likely cause is an incomplete Depends: list — the container has nothing preinstalled — but check the log above before concluding that: a docker or apt-mirror failure lands here too." + run_leg "${display}" ;; esac -echo "closure ok: ${deb}" +echo "closure ok: ${deb} (${display})" diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml index d8f13386..e7902436 100644 --- a/packaging/nfpm.yaml +++ b/packaging/nfpm.yaml @@ -33,6 +33,14 @@ description: | # actually opened, mapped through `dpkg -S` and reduced to the libraries the # application itself loads (their own Depends pull the rest of the closure — # Mesa alone accounts for ~30 more packages that must NOT be pinned here). +# +# Measured over SEVEN configurations, not one (#325): {Wayland, X11} × +# {Vulkan ICD, none} × {libEGL reachable, not}. The first six axes exist +# because a single measurement is systematically blind: the original list was +# taken under Xvfb only, and X11 never needs `libwayland-egl`. The libEGL axis +# exists because with neither a Vulkan ICD nor libEGL, wgpu enumerates zero +# adapters and iced quietly renders on the CPU via tiny-skia — so the whole +# GLES path, and every library it loads, stays untouched and unmeasured. depends: # Measured, not inherited: `objdump -T` on the release binary shows # non-weak GLIBC_2.35 imports (hypot/hypotf) and GLIBC_2.34 (pthread_*, @@ -58,6 +66,19 @@ depends: - libxcb1 - libxcursor1 - libxi6 + # wgpu's GLES backend, reached whenever no Vulkan ICD is usable — which both + # `--no-install-recommends` and any GPU without an ICD produce. `libegl1` is + # the vendor-neutral loader the app dlopens by soname; `libwayland-egl1` + # supplies the `wl_egl_window_create` the same backend needs to build a + # surface on a Wayland compositor. + # + # These two travel TOGETHER. Measured: with libEGL present and + # libwayland-egl1 absent, roost does not degrade — it SIGABRTs (exit 134) + # inside `Surface::configure`, because iced's tiny-skia fallback covers + # compositor *creation* and not surface configure. Adding `libegl1` alone + # would therefore create that crash rather than prevent it. + - libegl1 + - libwayland-egl1 # Vulkan is deliberately NOT a hard dependency. Measured: with # /usr/share/vulkan/icd.d removed entirely the app still opens a window — # wgpu falls back to the software renderer that is compiled in alongside it. From 3dc5f3a6699a20bc722d6bcddec45aa453e6a999 Mon Sep 17 00:00:00 2001 From: Charlie Knudsen Date: Mon, 10 Aug 2026 02:16:24 -0500 Subject: [PATCH 2/2] fix(deb): three CodeRabbit findings on the closure harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * `ROOST_CLOSURE_LIVENESS_SECONDS` is spent inside the phase bounded by `LAUNCH_TIMEOUT`, and the two are independent knobs. Raising the first past the second made the launch phase time out and report "the package did not come up" — the opposite of what happened. The combination is now rejected up front. * `timeout` around `docker run` kills the docker CLIENT; the daemon keeps the container and `--rm` never fires. The orphan outlived the script AND kept the shared directory bind-mounted, so the directory cleanup silently failed too. `cleanup` now removes it by its deterministic name. * The nfpm comment said "the first six axes" for a three-axis, eight-cell matrix. Chasing the second one surfaced a third leak the reviewer could not have seen from the diff: **Xvfb chowns its socket directory to root and sets the sticky bit**, so after the X11 leg the host cannot even rmdir the emptied directory. The shared path is now two levels — containers mount only the inner one, so the outer stays ours and is removable once a throwaway container has emptied it as root. Verified in the Linux VM: both legs still pass, and a run now leaves zero temp directories and zero containers behind (previously one directory per X11 leg). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgxiEBQTqgNjPswKqcs12d --- linux/scripts/verify-deb-closure.sh | 48 +++++++++++++++++++++++------ packaging/nfpm.yaml | 14 ++++----- 2 files changed, 45 insertions(+), 17 deletions(-) diff --git a/linux/scripts/verify-deb-closure.sh b/linux/scripts/verify-deb-closure.sh index fc009f1c..2f44320a 100755 --- a/linux/scripts/verify-deb-closure.sh +++ b/linux/scripts/verify-deb-closure.sh @@ -102,8 +102,20 @@ IMAGE="${ROOST_CLOSURE_IMAGE:-ubuntu:24.04}" # check cannot tell "launched" from "bound the socket and then died". LIVENESS_SECONDS="${ROOST_CLOSURE_LIVENESS_SECONDS:-8}" +# The liveness sleep happens INSIDE the phase bounded by LAUNCH_TIMEOUT, and +# the two are independent env knobs. Raising the first past the second makes +# the launch phase time out and report "the package did not come up" — the +# opposite of what happened. Reject the combination rather than misdiagnose it. +# 30s is the identify poll (60 x 0.5s) that precedes the sleep; 20s is margin +# for apt-free startup and the second probe. +if [ "$((LIVENESS_SECONDS + 50))" -gt "${LAUNCH_TIMEOUT}" ]; then + die "ROOST_CLOSURE_LIVENESS_SECONDS=${LIVENESS_SECONDS} does not fit inside ROOST_CLOSURE_LAUNCH_TIMEOUT=${LAUNCH_TIMEOUT} (needs at least $((LIVENESS_SECONDS + 50))): the launch phase would time out and report itself as a startup failure." +fi + run_id="roost-closure-$$" compositor_cid="" +package_container="" +share_root="" share_dir="" cleanup() { @@ -111,15 +123,28 @@ cleanup() { docker rm -f "${compositor_cid}" >/dev/null 2>&1 || true compositor_cid="" fi - if [ -n "${share_dir}" ] && [ -d "${share_dir}" ]; then - # The compositor container runs as root, so its socket is root-owned and a - # plain rm leaves the directory behind on any non-root host. Borrow root - # from a throwaway container to empty it; best-effort either way. - rm -rf "${share_dir}" 2>/dev/null || { - docker run --rm -v "${share_dir}:/share" "${IMAGE}" \ - find /share -mindepth 1 -delete >/dev/null 2>&1 || true - rmdir "${share_dir}" 2>/dev/null || true + # `timeout` around `docker run` kills the docker CLIENT, not the container: + # the daemon keeps running it and `--rm` never fires. Left alone it outlives + # this script AND keeps share_dir bind-mounted, so the directory cleanup + # below silently fails too. The name is deterministic, so no extra state is + # needed to find it. + if [ -n "${package_container}" ]; then + docker rm -f "${package_container}" >/dev/null 2>&1 || true + package_container="" + fi + if [ -n "${share_root}" ] && [ -d "${share_root}" ]; then + # Everything the containers create in here is root-owned, and the X11 leg + # is worse than that: Xvfb chowns its socket directory to root and sets + # the sticky bit, so a non-root host cannot even rmdir the empty shell + # afterwards. Hence the two levels — the containers only ever mount + # `share_dir`, so `share_root` stays ours and is removable once a + # throwaway container has emptied it as root. + rm -rf "${share_root}" 2>/dev/null || { + docker run --rm -v "${share_root}:/share_root" "${IMAGE}" \ + find /share_root -mindepth 1 -delete >/dev/null 2>&1 || true + rmdir "${share_root}" 2>/dev/null || true } + share_root="" share_dir="" fi } @@ -297,8 +322,11 @@ run_leg() { local leg="$1" echo "==> closure leg: ${leg}" - share_dir="$(mktemp -d)" + share_root="$(mktemp -d)" + share_dir="${share_root}/xdg" + mkdir -p "${share_dir}" chmod 700 "${share_dir}" + package_container="${run_id}-${leg}-package" start_compositor "${leg}" local -a display_env @@ -324,7 +352,7 @@ run_leg() { set +e timeout "${DOCKER_TIMEOUT}" docker run --rm \ - --name "${run_id}-${leg}-package" \ + --name "${package_container}" \ "${display_env[@]}" \ -e "ROOST_LEG=${leg}" \ -e "LIVENESS_SECONDS=${LIVENESS_SECONDS}" \ diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml index e7902436..5e4fcc8d 100644 --- a/packaging/nfpm.yaml +++ b/packaging/nfpm.yaml @@ -34,13 +34,13 @@ description: | # application itself loads (their own Depends pull the rest of the closure — # Mesa alone accounts for ~30 more packages that must NOT be pinned here). # -# Measured over SEVEN configurations, not one (#325): {Wayland, X11} × -# {Vulkan ICD, none} × {libEGL reachable, not}. The first six axes exist -# because a single measurement is systematically blind: the original list was -# taken under Xvfb only, and X11 never needs `libwayland-egl`. The libEGL axis -# exists because with neither a Vulkan ICD nor libEGL, wgpu enumerates zero -# adapters and iced quietly renders on the CPU via tiny-skia — so the whole -# GLES path, and every library it loads, stays untouched and unmeasured. +# Measured over SEVEN of the eight cells of {Wayland, X11} x {Vulkan ICD, +# none} x {libEGL reachable, not} (#325). The first two axes exist because a +# single measurement is systematically blind: the original list was taken +# under Xvfb only, and X11 never needs `libwayland-egl`. The third axis exists +# because with neither a Vulkan ICD nor libEGL, wgpu enumerates zero adapters +# and iced quietly renders on the CPU via tiny-skia — so the whole GLES path, +# and every library it loads, stays untouched and unmeasured. depends: # Measured, not inherited: `objdump -T` on the release binary shows # non-weak GLIBC_2.35 imports (hypot/hypotf) and GLIBC_2.34 (pthread_*,