fix(scripts): install-refresh restores the server's own cwd and matches the running server by identity (BUG-3196) (#1512)

* fix(scripts): install-refresh restores the server's own cwd with its argv, refuses a deleted one, and matches the running server by identity rather than the built binary's name (BUG-3196)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* fix(scripts): one existence check refuses a deleted server cwd, naming the path without the kernel's suffix (BUG-3196)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* test(scripts): the no-/proc refresh test declares lsof, which now reads the server's cwd there (BUG-3196)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* test(scripts): the lsof dependency sits on the requireScriptDeps line the guard reads (BUG-3196)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* docs(make): the install target's guarantees include the restored cwd (BUG-3196)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* fix(scripts): the server's cwd is re-checked immediately before the stop, and the capture's limits are stated (BUG-3196)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* fix(scripts): a failed restart cd says what state it leaves, not that no server runs (BUG-3196)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* fix(scripts): an unreadable server cwd is refused naming what was read and its error, and the case is tested (BUG-3196)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
This commit is contained in:
xarmian
2026-09-24 10:38:16 -04:00
committed by GitHub
parent 7fcbcaabcf
commit b2bee3242f
4 changed files with 412 additions and 52 deletions
+145 -48
View File
@@ -193,50 +193,69 @@ join_us() {
local a
for a in "$@"; do printf '%s%s' "$a" "$US"; done
}
if command -v pgrep >/dev/null 2>&1; then
while read -r pid; do
[ -n "$pid" ] || continue
# /proc where it exists, `ps` where it does not.
#
# Codex round 5 (P1): macOS has no /proc, so this loop found
# nothing there, SERVER_ARGV stayed empty, and the restart fell
# back to defaults — the exact defect BUG-2897 is about, silently
# unfixed on the platform the setsid fallback had just been added
# for. A fix that is Linux-only while advertising portability is
# worse than one that admits its platform.
#
# /proc is preferred because it gives the argv NUL-separated and
# therefore exactly; `ps -o args=` returns one space-joined string,
# so an argument containing a space is split. Named rather than
# hidden: no `pad server start` flag takes such a value today.
# No `local` here: this loop runs at TOP LEVEL, and bash answers
# `local` outside a function with an error on stderr for every
# matching process (codex round 6). It kept working — the arrays
# were still assigned — while printing a spurious error on every
# normal refresh, which is precisely the class of noise this unit
# exists to remove.
argv=()
# PAD_NO_PROC forces the `ps` path. Same reasoning as PAD_NO_SETSID:
# the branch is unreachable on any machine with /proc, and an
# untestable branch is exactly how the macOS half of this fix would
# have shipped broken.
if [ -z "${PAD_NO_PROC:-}" ] && [ -r "/proc/$pid/cmdline" ]; then
mapfile -d '' -t argv < "/proc/$pid/cmdline"
else
line="$(ps -o args= -p "$pid" 2>/dev/null || true)"
[ -n "$line" ] || continue
read -r -a argv <<<"$line"
# capture_pid records $1 as a server if its argv carries the `server start`
# verb pair. Keyed by identity (the argv), not by the binary's file name
# (BUG-3196): the name of the binary being INSTALLED says nothing about the
# name of the one running.
#
# With a second argument `require-pad`, argv[0] must also name a pad binary
# (pad, pad-*, pad.*), checked before anything is recorded. The name-matched
# candidates do not need it; a listener found only by port does.
capture_pid() {
local pid="$1" require="${2:-}"
[ -n "$pid" ] || return 0
[ -n "${SERVER_ARGV_OF[$pid]+x}" ] && return 0
# /proc where it exists, `ps` where it does not.
#
# Codex round 5 (P1): macOS has no /proc, so this loop found
# nothing there, SERVER_ARGV stayed empty, and the restart fell
# back to defaults — the exact defect BUG-2897 is about, silently
# unfixed on the platform the setsid fallback had just been added
# for. A fix that is Linux-only while advertising portability is
# worse than one that admits its platform.
#
# /proc is preferred because it gives the argv NUL-separated and
# therefore exactly; `ps -o args=` returns one space-joined string,
# so an argument containing a space is split. Named rather than
# hidden: no `pad server start` flag takes such a value today.
# A function now (BUG-3196), so `local` is legal here; codex round 6 of
# BUG-3194 had removed it when this body ran at top level.
local argv=() line i
# PAD_NO_PROC forces the `ps` path. Same reasoning as PAD_NO_SETSID:
# the branch is unreachable on any machine with /proc, and an
# untestable branch is exactly how the macOS half of this fix would
# have shipped broken.
if [ -z "${PAD_NO_PROC:-}" ] && [ -r "/proc/$pid/cmdline" ]; then
mapfile -d '' -t argv < "/proc/$pid/cmdline"
else
line="$(ps -o args= -p "$pid" 2>/dev/null || true)"
[ -n "$line" ] || return 0
read -r -a argv <<<"$line"
fi
[ ${#argv[@]} -gt 0 ] || return 0
if [ "$require" = require-pad ]; then
case "$(basename "${argv[0]}")" in
pad | pad-* | pad.*) ;;
*) return 0 ;;
esac
fi
# argv[0] is the binary; look for the `server start` verb pair.
for ((i = 1; i < ${#argv[@]}; i++)); do
if [ "${argv[i]}" = "server" ] && [ "${argv[i + 1]:-}" = "start" ]; then
SERVER_PIDS+=("$pid")
SERVER_ARGV_OF[$pid]="$(join_us "${argv[@]}")"
break
fi
[ ${#argv[@]} -gt 0 ] || continue
# argv[0] is the binary; look for the `server start` verb pair.
for ((i = 1; i < ${#argv[@]}; i++)); do
if [ "${argv[i]}" = "server" ] && [ "${argv[i + 1]:-}" = "start" ]; then
SERVER_PIDS+=("$pid")
SERVER_ARGV_OF[$pid]="$(join_us "${argv[@]}")"
break
fi
done
done < <(pgrep -x "$(basename "$BUILT")" 2>/dev/null || true)
done
}
if command -v pgrep >/dev/null 2>&1; then
# Candidates by name: the running binary is normally the installed one,
# and the built one's name is kept for a server started from a build.
while read -r pid; do
capture_pid "$pid"
# A pid found under both names is captured once: capture_pid skips a pid
# it has already recorded.
done < <({ pgrep -x "$(basename "$INSTALLED")"; pgrep -x "$(basename "$BUILT")"; } 2>/dev/null || true)
fi
# --- 1b. Choose the TARGET: the server holding the port this refresh is for ----
@@ -304,6 +323,16 @@ while read -r lp; do
esac
done < <(listener_pids "$TARGET_PORT")
# A listener is judged by what it IS rather than by the name of the binary
# being installed (BUG-3196): a pad `server start` holding the port is a server
# even when neither candidate name matched it (a binary started as
# pad-<commit>, say). Its OWN argv[0] must still be a pad binary: without that
# any tool on this port whose argv happens to read `server start` would be
# stopped and replaced.
for lp in "${LISTENERS[@]}"; do
capture_pid "$lp" require-pad
done
matched=()
foreign=()
for lp in "${LISTENERS[@]}"; do
@@ -322,12 +351,12 @@ if [ -n "$HIDDEN" ]; then
Nothing was stopped or installed; this script will not guess which server that is."
fi
if [ "${#foreign[@]}" -gt 0 ]; then
die "port $TARGET_PORT is held by pid(s) ${foreign[*]}, which is not a running \`$(basename "$BUILT") server start\`.
die "port $TARGET_PORT is held by pid(s) ${foreign[*]}, which is not a running \`server start\`.
Nothing was stopped or installed; find out what holds the port first."
fi
if [ "${#matched[@]}" -gt 1 ]; then
die "more than one \`$(basename "$BUILT") server start\` is listening on port $TARGET_PORT (pids: ${matched[*]}).
die "more than one \`server start\` is listening on port $TARGET_PORT (pids: ${matched[*]}).
Nothing was stopped or installed. Use the manual sibling-safe refresh."
elif [ "${#matched[@]}" -eq 1 ]; then
TARGET_PID="${matched[0]}"
@@ -343,11 +372,11 @@ elif [ "${#SERVER_PIDS[@]}" -eq 1 ]; then
if flag_value --port "${only[@]}" >/dev/null; then
TARGET_PID="${SERVER_PIDS[0]}"
else
die "nothing is listening on port $TARGET_PORT, and the one \`$(basename "$BUILT") server start\` running (pid ${SERVER_PIDS[0]}) was not started with --port, so it is not this box's server on another port.
die "nothing is listening on port $TARGET_PORT, and the one \`server start\` running (pid ${SERVER_PIDS[0]}) was not started with --port, so it is not this box's server on another port.
Nothing was stopped or installed. Start the server, or use the manual refresh."
fi
elif [ "${#SERVER_PIDS[@]}" -gt 1 ]; then
die "more than one \`$(basename "$BUILT") server start\` process is running (pids: ${SERVER_PIDS[*]}) and none is listening on port $TARGET_PORT.
die "more than one \`server start\` process is running (pids: ${SERVER_PIDS[*]}) and none is listening on port $TARGET_PORT.
This script cannot tell which one to replace.
Nothing was stopped or installed. Use the manual sibling-safe refresh."
fi
@@ -359,7 +388,7 @@ if [ -n "$TARGET_PID" ]; then
[ "$sp" != "$TARGET_PID" ] && OTHERS+=("$sp")
done
if [ "${#OTHERS[@]}" -gt 0 ]; then
note "other \`$(basename "$BUILT") server start\` processes are left running: ${OTHERS[*]}"
note "other \`server start\` processes are left running: ${OTHERS[*]}"
fi
fi
@@ -369,6 +398,54 @@ else
note "no running server found; will start with defaults after install"
fi
# --- 1c. Capture the TARGET's working directory, with its argv ----------------
#
# BUG-3196. The restart used to inherit the INVOKING shell's cwd, so a refresh
# run from a worktree left the server in that worktree, and removing the
# worktree left it in a deleted directory. The server's cwd is part of how it
# was started, like its argv, so it is captured and restored the same way.
#
# Refused BEFORE anything is stopped:
# - the captured cwd no longer exists: the server cannot be put back where
# it was, and the refusal names the path so the operator can choose;
# - the cwd cannot be read at all: restoring both argv and cwd is then
# impossible, and falling back to the caller's cwd is this bug.
# With no server running there is nothing to restore; the caller's cwd is used,
# as before.
SERVER_CWD=""
if [ -n "$TARGET_PID" ]; then
# What was read and why it failed, for the refusal: the path asked and
# the tool's own error text, which carries the errno (e.g. "Permission
# denied"). Lead ruling on BUG-3196.
cwd_source="" cwd_err=""
if [ -z "${PAD_NO_PROC:-}" ] && [ -d /proc ]; then
cwd_source="/proc/$TARGET_PID/cwd"
cwd_err="$(readlink "$cwd_source" 2>&1 >/dev/null)" || true
SERVER_CWD="$(readlink "$cwd_source" 2>/dev/null || true)"
elif command -v lsof >/dev/null 2>&1; then
cwd_source="lsof -a -p $TARGET_PID -d cwd"
cwd_err="$(lsof -a -p "$TARGET_PID" -d cwd -Fn 2>&1 >/dev/null)" || true
SERVER_CWD="$(lsof -a -p "$TARGET_PID" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -1)"
else
cwd_source="(no /proc and no lsof)"
fi
if [ -z "$SERVER_CWD" ]; then
die "the working directory of the running server (pid $TARGET_PID) cannot be read: $cwd_source: ${cwd_err:-no output}
Nothing was stopped or installed; the restart could not put it back where it was."
fi
# Linux reads a removed directory back with a " (deleted)" suffix. Strip
# it so the refusal names the real path; the one existence check below
# then refuses it, since that path is gone.
SERVER_CWD="${SERVER_CWD% (deleted)}"
if [ ! -d "$SERVER_CWD" ]; then
die "the running server (pid $TARGET_PID) is in a directory that no longer exists: $SERVER_CWD
Nothing was stopped or installed. Restart it from the directory it should run in, then refresh."
fi
# Stated limit: command substitution strips trailing newlines, so a
# directory whose NAME ends in one is not captured exactly.
note "captured server cwd: $SERVER_CWD"
fi
# --- 2. SNAPSHOT the artifact, then check the snapshot ------------------------
#
# The copy happens BEFORE the stop, into a temporary file beside the final
@@ -431,6 +508,16 @@ if [ -n "$TARGET_PID" ]; then
[ ${#now[@]} -gt 0 ] && [ "$(join_us "${now[@]}")" = "${SERVER_ARGV_OF[$TARGET_PID]}" ]
}
if kill -0 "$TARGET_PID" 2>/dev/null; then
# The cwd is checked again at the last moment before the stop
# (BUG-3196, codex round 1): it was checked before the snapshot, and
# a directory removed since then would leave the server stopped with
# nowhere to restart it. RESIDUAL, stated: one removed between this
# check and the restart's `cd` still can, and the restart then fails
# loudly (naming the port to check) rather than starting elsewhere.
if [ -n "$SERVER_CWD" ] && [ ! -d "$SERVER_CWD" ]; then
die "the running server's directory no longer exists: $SERVER_CWD
Nothing was stopped or installed. Restart it from the directory it should run in, then refresh."
fi
if ! still_target; then
die "pid $TARGET_PID no longer runs the captured server.
Nothing was stopped or installed; re-run."
@@ -522,6 +609,16 @@ if [ -n "$EXPECT_COMMIT" ]; then
fi
fi
# In the captured cwd (BUG-3196). The binary path is made absolute first,
# since the install path may have been given relative to the caller's cwd.
case "${restart[0]}" in
/*) ;;
*) restart[0]="$(cd "$(dirname "${restart[0]}")" && pwd)/$(basename "${restart[0]}")" ;;
esac
if [ -n "$SERVER_CWD" ]; then
cd "$SERVER_CWD" || die "could not enter $SERVER_CWD to restart the server there.
The old server was signalled to stop and the new binary is installed; nothing was restarted. Check what is running on port $TARGET_PORT."
fi
if [ -z "${PAD_NO_SETSID:-}" ] && command -v setsid >/dev/null 2>&1; then
setsid nohup "${restart[@]}" >>"$HOME/.pad/server.log" 2>&1 </dev/null &
else