xo-umbrella2/etc/gen-hostegl
Roland Conybeare 631ba7d8da
Some checks failed
cmake-docker / cmake-build (clang, clang++) (push) Failing after 13m21s
cmake-docker / cmake-build (gcc, g++) (push) Successful in 44m52s
CI / smoke-test (push) Successful in 36m15s
build: generate etc/{hostegl,hostubunut} symlinks [BUILD] [TIDY]
2026-09-25 09:03:05 -04:00

325 lines
12 KiB
Bash
Executable file

#!/usr/bin/env bash
# gen-hostegl -- build etc/host{egl,ubuntu}/, a curated directory of symlinks
# into the host's graphics libraries.
#
# WHY THIS EXISTS
#
# nixpkgs ships GPU libraries, but they are wired for nixos. Off nixos the
# driver half of the stack must come from the host, while libc, libstdc++,
# libX11 and the rest must keep coming from nix. Both live in the same "big
# swimming pool" (/usr/lib/x86_64-linux-gnu), so putting that directory on
# LD_LIBRARY_PATH would drag a stale libc along with the driver. A directory
# containing only the wanted names finesses that -- see README.md, "Nix + SDL2
# + Vulkan + ImGui".
#
# These symlinks used to be COMMITTED, which is wrong: they are host-specific,
# they name driver builds that apt and nvidia bump underneath them, and by
# 2026-09-25 twenty-nine of the seventy-eight in etc/hostegl/ were dangling.
# The basename lists are committed instead, and this script turns a list into
# symlinks against whatever the host running it actually has.
#
# MODES
#
# --mode=known (default) link every basename in the profile's .known list
# that this host can supply, and REPORT the ones it cannot.
# The lists were built by running apps and recording what
# failed to load, so they are evidence. This mode reproduces
# them and is the only policy known to work.
#
# --mode=derived seed from the host's glvnd and Vulkan ICD manifests, then
# walk ldd to a fixpoint and link whatever the loader cannot
# otherwise find. Self-adapting, and NOT yet trusted: it has
# to be checked against --mode=known on several platforms
# first. --compare is how you do that.
#
# Nothing here writes outside etc/host{egl,ubuntu}/.
set -o errexit
set -o nounset
set -o pipefail
self_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
profile=
mode=known
compare=0
dry_run=0
usage() {
cat <<EOF
usage: gen-hostegl [--profile=egl|ubuntu] [--mode=known|derived] [--compare] [-n]
--profile which tested configuration to build. Default: autodetected --
'egl' under WSL2, 'ubuntu' otherwise. egl -> etc/hostegl/,
ubuntu -> etc/hostubuntu/.
--mode see the header of this script. Default: known.
--compare write nothing; print what derived would add and drop relative to
known. This is the instrument for testing derived on a new
platform before anyone relies on it.
-n dry run: report what would be linked, create nothing.
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--profile=*) profile="${1#*=}" ;;
--mode=*) mode="${1#*=}" ;;
--compare) compare=1 ;;
-n|--dry-run) dry_run=1 ;;
-h|--help) usage; exit 0 ;;
*) echo "gen-hostegl: unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
shift
done
# ---------------------------------------------------------------- profile
if [ -z "$profile" ]; then
if grep -qi microsoft /proc/version 2>/dev/null; then
profile=egl
else
profile=ubuntu
fi
echo "gen-hostegl: autodetected profile '$profile' (override with --profile=)"
fi
case "$profile" in
egl) out_dir="$self_dir/hostegl"; known_file="$self_dir/hostegl.known" ;;
ubuntu) out_dir="$self_dir/hostubuntu"; known_file="$self_dir/hostubuntu.known" ;;
*) echo "gen-hostegl: unknown profile: $profile (want egl or ubuntu)" >&2; exit 2 ;;
esac
case "$mode" in
known|derived) ;;
*) echo "gen-hostegl: unknown mode: $mode (want known or derived)" >&2; exit 2 ;;
esac
# Where host libraries live. /usr/lib/wsl/lib carries the WSL2 GPU shims and
# is harmless to search elsewhere -- it simply will not exist.
host_dirs=(/usr/lib/x86_64-linux-gnu /lib/x86_64-linux-gnu /usr/lib/wsl/lib)
# --------------------------------------------------- LD_LIBRARY_PATH scrub
#
# LOAD-BEARING. A shell built by shells.nix already has an etc/host* directory
# on LD_LIBRARY_PATH -- possibly from a DIFFERENT worktree. Left in place,
# every ldd below resolves through the PREVIOUS generation, derived mode sees
# nothing missing, and the script emits an empty directory most confidently on
# a machine that was working fine. Measured 2026-09-25: ldd libGLX_nvidia.so.0
# reported all four nvidia deps satisfied, from ../xo-umbrella2-claude1.
clean_ld_path=$(printf '%s' "${LD_LIBRARY_PATH:-}" \
| tr ':' '\n' \
| grep -v '/etc/host' \
| paste -sd: -)
# resolve $1 (a basename, possibly with a glob) across the host dirs.
# Prints one "<basename> <host path>" per match. The path printed is the one
# NAMED, never its realpath: linking libdrm.so.2 to .../libdrm.so.2 follows
# apt's next bump, while linking it to .../libdrm.so.2.125.0 pins a version
# that will be gone. That distinction is why libdrm.so.2 still worked in the
# committed directory and libdrm.so.2.4.0 did not.
resolve_pattern() {
local pattern="$1" dir candidate found=1
for dir in "${host_dirs[@]}"; do
[ -d "$dir" ] || continue
for candidate in "$dir"/$pattern; do
[ -e "$candidate" ] || continue
printf '%s %s\n' "$(basename -- "$candidate")" "$candidate"
found=0
done
# first directory that has it wins, so /usr/lib/x86_64-linux-gnu
# shadows /usr/lib/wsl/lib rather than both contributing
[ $found -eq 0 ] && break
done
return $found
}
# ------------------------------------------------------------- known mode
#
# Prints "<name> <path>" for each entry this host can supply. Entries it
# cannot are recorded for the report -- never silently dropped, because a
# missing entry is either a stale name worth pruning or a real gap worth
# fixing, and only a human looking at the list can tell which.
# compute_known runs inside $(...), so it cannot report back through a shell
# variable -- the subshell's copy dies with it. Missing entries go to a file
# whose name the caller knows. (First cut used an array and silently reported
# nothing; the count was right and the report was empty.)
missing_file=$(mktemp)
trap 'rm -f "$missing_file"' EXIT
compute_known() {
local pattern
: > "$missing_file"
while read -r pattern; do
case "$pattern" in ''|'#'*) continue ;; esac
if ! resolve_pattern "$pattern"; then
printf '%s\n' "$pattern" >> "$missing_file"
fi
done < "$known_file"
}
# ----------------------------------------------------------- derived mode
#
# Seeds are the driver libraries the host's own loaders are told to use:
# glvnd's EGL vendor manifests and the Vulkan ICD manifests both carry a
# "library_path". Parsed with sed rather than jq to avoid the dependency.
derived_seeds() {
local f
for f in /usr/share/glvnd/egl_vendor.d/*.json /usr/share/vulkan/icd.d/*.json; do
[ -e "$f" ] || continue
sed -n 's/.*"library_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$f"
done
# glvnd loads libGLX_<vendor>.so.0 by name rather than through a manifest
local d c
for d in "${host_dirs[@]}"; do
[ -d "$d" ] || continue
for c in "$d"/libGLX_*.so.0 "$d"/libEGL_*.so.0; do
[ -e "$c" ] && basename -- "$c"
done
done
}
# Link what the loader cannot find, then look again: a dependency of a library
# that was itself missing is INVISIBLE to ldd until the first one resolves.
# Measured: with the output directory scrubbed, ldd libGLX_nvidia.so.0 reports
# glsi, tls and glcore missing but never mentions gpucomp, which is glcore's.
compute_derived() {
local staging round name path progress
staging=$(mktemp -d)
trap 'rm -rf "$staging"' RETURN
# The seeds themselves belong in the directory, not just their missing
# dependencies. glvnd and the Vulkan loader look these up by BARE NAME
# through LD_LIBRARY_PATH -- that is what the manifests' "library_path"
# is -- so a directory holding only the closure would leave the loader
# unable to find the driver it was pointed at. Caught by running
# --mode=derived and noticing libGLX_nvidia.so.0 absent from its own output.
while read -r name; do
[ -n "$name" ] || continue
while read -r resolved path; do
[ -e "$staging/$resolved" ] || ln -s "$path" "$staging/$resolved"
done < <(resolve_pattern "$name" || true)
done < <(derived_seeds | sort -u)
for round in 1 2 3 4 5 6 7 8; do
progress=0
while read -r name; do
[ -n "$name" ] || continue
[ -e "$staging/$name" ] && continue
if resolve_pattern "$name" > /dev/null; then
path=$(resolve_pattern "$name" | head -1 | cut -d' ' -f2-)
ln -s "$path" "$staging/$name"
progress=1
fi
done < <(derived_missing "$staging")
[ $progress -eq 0 ] && break
done
for name in "$staging"/*; do
[ -e "$name" ] || continue
printf '%s %s\n' "$(basename -- "$name")" "$(readlink -- "$name")"
done
}
# every soname ldd cannot resolve, across the seeds and everything linked so far
derived_missing() {
local staging="$1" lib
{
derived_seeds | while read -r s; do
resolve_pattern "$s" | cut -d' ' -f2- || true
done
find "$staging" -type l -exec readlink {} \; 2>/dev/null || true
} | sort -u | while read -r lib; do
[ -e "$lib" ] || continue
LD_LIBRARY_PATH="$staging${clean_ld_path:+:$clean_ld_path}" \
ldd "$lib" 2>/dev/null | sed -n 's/^[[:space:]]*\([^ ]*\) => not found.*/\1/p'
done | sort -u
}
# ------------------------------------------------------------------ apply
emit_report() {
local n_linked="$1"
echo
echo "gen-hostegl: profile=$profile mode=$mode -> $out_dir"
echo "gen-hostegl: linked $n_linked"
local n_missing
n_missing=$(grep -c . "$missing_file" 2>/dev/null || true)
if [ "${n_missing:-0}" -gt 0 ]; then
echo
echo "gen-hostegl: $n_missing entries in $(basename -- "$known_file")"
echo " that this host cannot supply. Each is either a stale name"
echo " worth pruning from the list, or a real gap worth fixing --"
echo " the script will not guess which:"
sed 's/^/ /' "$missing_file"
fi
}
if [ "$compare" -eq 1 ]; then
k=$(compute_known | cut -d' ' -f1 | sort -u)
d=$(compute_derived | cut -d' ' -f1 | sort -u)
echo "only in known (derived would DROP these):"
comm -23 <(printf '%s\n' "$k") <(printf '%s\n' "$d") | sed 's/^/ /'
echo "only in derived (derived would ADD these):"
comm -13 <(printf '%s\n' "$k") <(printf '%s\n' "$d") | sed 's/^/ /'
echo "in both: $(comm -12 <(printf '%s\n' "$k") <(printf '%s\n' "$d") | wc -l)"
exit 0
fi
if [ "$mode" = known ]; then
plan=$(compute_known)
else
plan=$(compute_derived)
fi
n=$(printf '%s' "$plan" | grep -c . || true)
if [ "$dry_run" -eq 1 ]; then
printf '%s\n' "$plan" | sed 's/^/ would link /'
emit_report "$n"
exit 0
fi
mkdir -p "$out_dir"
# Rebuild rather than accumulate, so a driver upgrade clears out the entries it
# stranded. Only symlinks are removed; anything else is left alone and
# reported, because this script did not put it there.
for existing in "$out_dir"/* "$out_dir"/.[!.]*; do
[ -e "$existing" ] || [ -L "$existing" ] || continue
if [ -L "$existing" ]; then
rm -f "$existing"
else
echo "gen-hostegl: leaving non-symlink alone: $existing" >&2
fi
done
while read -r name path; do
[ -n "$name" ] || continue
ln -sfn "$path" "$out_dir/$name"
done <<< "$plan"
emit_report "$n"
# end gen-hostegl