diff --git a/README.md b/README.md index 209b3aa..87dadee 100644 --- a/README.md +++ b/README.md @@ -501,8 +501,10 @@ stops Home Manager's user services (reverting the UI patches), runs `cleanup --all`, runs `home-manager uninstall`, then removes Nix and the per-user Nix state (see [Set up by install.sh](#set-up-by-installsh)). If SteamVR is running, its key is restored when SteamVR stops (the closing -message says so). If Nix's own uninstaller fails (e.g. something still uses -`/nix`), the rest still runs (the `~/.config/home-manager` link goes) except +message says so). Programs started from the Nix store keep `/nix` busy: +before removing Nix, `uninstall` lists them and waits until you close them +(with `--yes`, it stops instead); close them or run it right after a reboot. +If Nix's own uninstaller fails anyway, the rest still runs (the `~/.config/home-manager` link goes) except the per-user Nix state, and the closing message says to reboot and run `uninstall` again. Your configuration, `*.hm-backup-*` files, app data and Flatpaks stay. diff --git a/docs/cleanup.md b/docs/cleanup.md index 7b0cc68..c94f9ed 100644 --- a/docs/cleanup.md +++ b/docs/cleanup.md @@ -44,3 +44,18 @@ What `install.sh install` sets up is listed in the README under `systemctl --user` against the outer session's user manager (the nested desktop can't reach it with its own environment) and uses the installed `home-manager` if there is one, else Home Manager's `master`. + +`uninstall` removes Nix with nix-installer, which fails when it can't +unmount `/nix` (`systemctl stop nix.mount`). Before that it scans `/proc` +for processes using `/nix`: their `exe`, `cwd`, `root` or an `fd` links into +`/nix`, or `maps` names a file there (e.g. an app started before the +uninstall that mapped Home Manager's `mime.cache`). Only the user's own +processes are readable; the Nix daemon is nix-installer's to stop. Skipped: +the script itself, its subshells (descendants) and its process group (the +`curl | bash` pipeline). Its ancestors are listed with a hint to run +`uninstall` from another terminal. Nothing is killed: it asks to close them +and re-checks on Enter, or (`--yes`, no terminal) stops before Nix. A bash +from the Nix store re-executes the script with `/usr/bin/bash` first, and +nix-installer runs from a copy outside `/nix`. The check uses +`STEAM_FRAME_NIX_PROC` as a fake `/proc` (section H of +`modules/cleanup/check.nix`). diff --git a/docs/development.md b/docs/development.md index 826fae0..b07951c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -49,7 +49,7 @@ modules/ cleanup.nix switch: `cleanup --orphans`; steam-frame-nix-cleanup on PATH cleanup/package.nix build: install.sh as a command (cleanup, steamvr-debugger-arm, restart-check) cleanup/check.nix test: install.sh cleanup and restart-check on fake home/runtime dirs, - install --clone against local bare repos + install --clone against local bare repos, uninstall on a fake /proc portal.nix Steam session portal config (session.portalFix) portal/check.nix test: KDE FileChooser on by default, absent when disabled applications-menu.nix applications.menu link for KDE apps (session.applicationsMenu) diff --git a/install.sh b/install.sh index 72137b6..84f0268 100755 --- a/install.sh +++ b/install.sh @@ -27,6 +27,7 @@ USER_UNIT_DIR="$CONFIG_HOME/systemd/user" USER_NAME="$(id -un)" USER_ID="$(id -u)" OUTER_RUNTIME_DIR="${STEAM_FRAME_NIX_RUNTIME_DIR:-/run/user/$USER_ID}" +SFN_PROC="${STEAM_FRAME_NIX_PROC:-/proc}" # tests: a fake /proc ASSUME_YES=0 RO_RELOCK=0 @@ -69,8 +70,9 @@ Commands: uninstall [--yes] [--keep-nix] Stop Home Manager's user services, run 'cleanup --all', uninstall Home Manager, uninstall Nix (unless --keep-nix) and remove per-user Nix - leftovers. Your configuration directory (also a --clone) is never - deleted. + leftovers. Before removing Nix it lists the programs started from the + Nix store that still use /nix and waits until they are closed. Your + configuration directory (also a --clone) is never deleted. cleanup [--dry-run] [--quiet] (--all | --orphans [--keep ]...) Remove what steam-frame-nix (any version) wrote outside the Nix store, @@ -232,11 +234,17 @@ install_nix() { } uninstall_nix() { - local rc=0 + local rc=0 bin need_sudo ro_unlock step "Uninstalling Nix" - sudo "$NIX_INSTALLER_BIN" uninstall --no-confirm || rc=$? + # From a copy: the uninstaller running from /nix would keep /nix busy. + bin="$(mktemp)" + if ! { cp "$NIX_INSTALLER_BIN" "$bin" && chmod +x "$bin" && "$bin" --version >/dev/null 2>&1; }; then + rm -f "$bin"; bin=$NIX_INSTALLER_BIN + fi + sudo "$bin" uninstall --no-confirm || rc=$? + [[ $bin == "$NIX_INSTALLER_BIN" ]] || rm -f "$bin" ro_relock return "$rc" } @@ -1018,7 +1026,6 @@ cmd_cleanup() { # runtime (gamescope sends its own keymap to Xwayland again, e.g. whenever # the VR keyboard types, and only a SteamVR start opens the port). -SFN_PROC="${STEAM_FRAME_NIX_PROC:-/proc}" SFN_CGROUP="${STEAM_FRAME_NIX_CGROUP:-/sys/fs/cgroup}" LAYOUT_DROPIN="$USER_UNIT_DIR/gamescope-session.service.d/keyboard.conf" DEBUGGER_HM_DROPIN="$USER_UNIT_DIR/steamvr.service.d/webhelper-debugger.conf" @@ -1139,39 +1146,70 @@ remove_hm() { fi } -# Processes still running from /nix/store would keep /nix busy. -stop_nix_processes() { - local skip=" " p=$$ pid exe pids=() - while [[ -n $p && $p -gt 1 ]]; do - skip+="$p " - p="$(awk '/^PPid:/ {print $2}' "/proc/$p/status" 2>/dev/null || true)" - done - for pid in /proc/[0-9]*; do - pid=${pid#/proc/} - [[ -O /proc/$pid && $skip != *" $pid "* ]] || continue - exe="$(readlink "/proc/$pid/exe" 2>/dev/null || true)" - [[ $exe == /nix/store/* ]] && pids+=("$pid") - done - (( ${#pids[@]} )) || return 0 +# --- /nix in use --- +# +# nix-installer can't unmount /nix while a process uses it: its program, a +# library, an open file or its working directory in /nix. Only this user's +# processes are readable (the uninstaller stops the Nix daemon itself). This +# script, its subshells and its curl | bash pipeline are skipped; the +# shells and apps it was started from are not. - step "Processes still running from /nix" - ps -o pid=,args= -p "$(IFS=,; echo "${pids[*]}")" | cut -c1-120 | sed 's/^/ /' || true - if confirm "Stop them (Nix can't be unmounted while they run)?"; then - kill -TERM "${pids[@]}" 2>/dev/null || true - sleep 2 - for pid in "${pids[@]}"; do # only if it's still the same /nix process - [[ $(readlink "/proc/$pid/exe" 2>/dev/null || true) == /nix/store/* ]] && kill -KILL "$pid" 2>/dev/null || true - done - else - warn "left running; the Nix uninstall may fail to unmount /nix" - fi - for p in $skip; do - [[ $(readlink "/proc/$p/exe" 2>/dev/null || true) == /nix/store/* ]] \ - && warn "process $p (a parent shell) runs from /nix; if the uninstall can't unmount /nix, re-run from a terminal whose shell is /usr/bin/bash" - done +proc_ppid() { # pid + local k v + { while read -r k v; do [[ $k == PPid: ]] && { echo "$v"; return 0; }; done <"$SFN_PROC/$1/status"; } 2>/dev/null return 0 } +proc_pgid() { # pid + local s + { read -r s <"$SFN_PROC/$1/stat"; } 2>/dev/null || return 0 + s=${s##*) } # after "pid (comm) "; comm may contain spaces + read -r _ _ s _ <<<"$s" # state ppid pgrp + echo "$s" +} + +# " (PID )" per process using /nix. +nix_users() { + local p pid name argv0 self=$$ pgid ancestors=" " + p=$self + while [[ $p =~ ^[0-9]+$ ]] && (( p > 1 )); do ancestors+="$p "; p="$(proc_ppid "$p")"; done + pgid="$(proc_pgid "$self")" + { find "$SFN_PROC"/[0-9]*/{exe,cwd,root} "$SFN_PROC"/[0-9]*/fd -maxdepth 1 -lname '/nix/*' 2>/dev/null || true + grep -ls '[[:space:]]/nix/' "$SFN_PROC"/[0-9]*/maps || true + } | while IFS= read -r p; do p=${p#"$SFN_PROC"/}; echo "${p%%/*}"; done | sort -un \ + | while read -r pid; do + [[ $pid != "$self" ]] && { read -r name <"$SFN_PROC/$pid/comm"; } 2>/dev/null || continue + # argv[0]'s name says more than comm (often a thread name) + argv0=''; { IFS= read -r -d '' argv0 <"$SFN_PROC/$pid/cmdline"; } 2>/dev/null || true + [[ ${argv0##*/} == '' || ${argv0##*/} == exe ]] || name=${argv0##*/} + if [[ $ancestors == *" $pid "* ]]; then + echo "$name (PID $pid, started this uninstall: close it and run uninstall from another terminal)" + continue + fi + [[ -n $pgid && $(proc_pgid "$pid") == "$pgid" ]] && continue # our pipeline + p=$pid + while [[ $p =~ ^[0-9]+$ && $p != "$self" ]] && (( p > 1 )); do p="$(proc_ppid "$p")"; done + [[ $p == "$self" ]] && continue # our subshells + echo "$name (PID $pid)" + done +} + +# Waits until no process uses /nix; fails if the user gives up (or can't be +# asked: --yes, no terminal). Nothing is stopped automatically. +nix_idle() { + local users=() reply + while :; do + mapfile -t users < <(nix_users) + (( ${#users[@]} )) || return 0 + warn "programs started from the Nix store still use /nix, so Nix can't be removed:" + printf ' - %s\n' "${users[@]}" >&2 + (( ! ASSUME_YES )) && have_tty || return 1 + printf 'Close them, then press Enter to check again (a: abort) ' >/dev/tty + read -r reply /dev/null 2>&1; then warn "Nix wasn't installed by nix-installer ($NIX_INSTALLER_BIN missing); not removing it" @@ -1266,14 +1305,17 @@ Intentionally left in place: - app data, e.g. ~/.local/share/docker, Firefox profiles, and Flatpak apps Log out or reboot so running sessions drop the removed tweaks. EOF - if (( ${#CLEAN_DEFERRED[@]} || nix_failed )); then + if (( ${#CLEAN_DEFERRED[@]} )) || [[ -n $nix_failed ]]; then printf '\nNot done yet:\n' (( ${#CLEAN_DEFERRED[@]} )) && printf ' - %s\n' "${CLEAN_DEFERRED[@]}" - (( nix_failed )) && printf '%s\n' \ - " - Nix: its uninstaller failed. Reboot (so nothing uses /nix), then run" \ - " uninstall again; it also removes ~/.nix-profile and ~/.local/state/nix." + case $nix_failed in + busy) printf '%s\n' " - Nix: still in use by the programs listed above. Close these or reboot," ;; + failed) printf '%s\n' " - Nix: its uninstaller failed. Reboot (so nothing uses /nix)," ;; + esac + [[ -n $nix_failed ]] && printf '%s\n' \ + " then run uninstall again; it also removes ~/.nix-profile and ~/.local/state/nix." fi - (( ! nix_failed )) + [[ -z $nix_failed ]] } # --- status ----------------------------------------------------------------- diff --git a/modules/cleanup/check.nix b/modules/cleanup/check.nix index e78719d..73f1a26 100644 --- a/modules/cleanup/check.nix +++ b/modules/cleanup/check.nix @@ -5,6 +5,8 @@ # Also `install.sh restart-check` (what waits for a session/SteamVR restart). # Also `install.sh install --clone` (argument parsing and the clone step), # against local bare repositories through a logging git, without network. +# Also `install.sh uninstall` stopping before Nix while programs from the +# Nix store run (a fake /proc). { pkgs }: let cleanup = pkgs.callPackage ./package.nix { }; @@ -385,5 +387,50 @@ pkgs.runCommand "cleanup-check" { nativeBuildInputs = [ cleanup pkgs.jq pkgs.git res=$(inst --clone https://example.org/missing.git --dir $HOME/m 2>&1) && fail "missing repo" has "$res" "gh auth login" echo "G ok" + + # --- H: uninstall: /nix in use --- + fresh h + export -f fail has hasnt gone + export root INSTALL + # In a process of its own: in the sandbox $$ is PID 1. + bash <<'SH' + set -euo pipefail + P=$root/proc + # pid name ppid pgid exe: a fake /proc entry + fake() { + mkdir -p $P/$1/fd; echo $2 > $P/$1/comm; printf '%s\0-x\0' $5 > $P/$1/cmdline + ln -sfn $5 $P/$1/exe; ln -sfn /home $P/$1/cwd + printf 'Name:\t%s\nPPid:\t%s\n' $2 $3 > $P/$1/status + echo "$1 ($2 x) S $3 $4 0" > $P/$1/stat + echo "00400000-00401000 r-xp 00000000 00:01 1 $5" > $P/$1/maps + } + fake $$ bash 30 $$ /usr/bin/bash # this uninstall + fake 30 zsh 1 30 /nix/store/a-zsh/bin/zsh # its terminal's shell + fake 31 curl 30 $$ /nix/store/b-curl/bin/curl # its pipeline: skipped + fake 32 sort $$ 32 /nix/store/c-coreutils/bin/sort # its subshell: skipped + fake 40 app 1 40 /usr/bin/app # a library from /nix + echo "7f00-7f01 r-xp 0 00:01 2 /nix/store/d-lib/lib/libx.so" >> $P/40/maps + fake 41 cwd 1 41 /usr/bin/cwd; ln -sfn /nix/store/e $P/41/cwd + fake 42 fd 1 42 /usr/bin/fd; ln -s /nix/store/f/file $P/42/fd/3 + fake 43 clean 1 43 /usr/bin/clean + printf '#!/bin/sh\n' > $root/nix-installer; chmod +x $root/nix-installer + mkdir -p $HOME/.config; ln -s $HOME/cfg $HOME/.config/home-manager + uninst() { + (STEAM_FRAME_NIX_PROC=$P; . $INSTALL; ASSUME_YES=1 NIX_INSTALLER_BIN=$root/nix-installer + uninstall_nix() { echo "nix-installer uninstall"; }; cmd_uninstall) 2>&1 + } + res=$(uninst) && fail "uninstall went on with /nix in use: $res" + echo "$res" + hasnt "$res" "nix-installer uninstall" + has "$res" "zsh (PID 30, started this uninstall" + for p in "app (PID 40)" "cwd (PID 41)" "fd (PID 42)" "Close these or reboot,"; do has "$res" "$p"; done + for p in "PID 31" "PID 32" "PID 43" "PID $$"; do hasnt "$res" "$p"; done + gone $HOME/.config/home-manager + # closed: Nix is removed + rm -r $P/30 $P/40 $P/41 $P/42; fake $$ bash 1 $$ /usr/bin/bash + res=$(uninst) || fail "uninstall failed: $res" + has "$res" "nix-installer uninstall" + SH + echo "H ok" touch $out ''