Files
pad/scripts/install-refresh.sh
T
xarmian 9e6791d4f1 fix(scripts): install-refresh asks git in the caller's checkout, not the server's cwd (BUG-3403) (#1790)
* fix(scripts): install-refresh asks git in the caller's checkout, not the server's cwd (BUG-3403)

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

* fix(scripts): anchor the web/ pathspec at the repo root (codex r1 on BUG-3403)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-05 05:52:30 -04:00

942 lines
42 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# install-refresh.sh — the honest half of `make install` (BUG-2897, TASK-2787).
#
# The target used to: kill the running pad, cp the binary, then bring the
# server back BY SIDE EFFECT (`pad auth whoami` triggers an auto-start) and
# print "Server restarted." unconditionally. Three things were wrong with
# that, and the third is the one that makes the other two invisible:
#
# 1. The auto-start does not know the killed process's argv, so a server
# running `--host 0.0.0.0` came back bound to the default host alone.
# Measured: curl 127.0.0.1:7777 -> 000 while the LAN address -> 200,
# with the process count and the version both reading correct.
# 2. `cp` copies whatever is at the repo path, not what THIS invocation
# built. Two sessions sharing the checkout interleave, and the loser's
# build is installed by the winner with every exit code green.
# 3. "Server restarted." was printed after a command ending in `|| true`,
# with no probe of any kind. It is not that the wrong address went
# unverified — nothing was verified.
#
# So this script checks OUTCOMES, not steps: what got installed, and what is
# answering afterwards.
#
# Usage: install-refresh.sh <built-binary> <install-path> <expected-commit>
#
# It performs the stop, the install and the restart; the caller does the
# build. Ordering is load-bearing and matches CONVE-2687's day-74 clause:
# nothing irreversible happens until the artifact has been checked.
set -uo pipefail
BUILT="${1:?built binary path required}"
INSTALLED="${2:?install path required}"
EXPECT_COMMIT="${3:-}"
# SRC_DIR is the checkout this invocation runs from (`make install` runs in
# the repo), captured before anything changes directory. Every git question
# the script asks is about THIS checkout's objects, and is asked here: the
# restart below enters the running server's own cwd, which can be any
# directory at all (a pad auto-started from another project), and git
# asked from there cannot resolve these commits (BUG-3403).
SRC_DIR="$(pwd -P)"
# PORT and HOST are resolved AFTER the argv capture, from the same
# precedence the server itself uses: flag, then environment, then default.
# See the resolution block below the capture.
PORT=""
PROBE_TIMEOUT="${PAD_PROBE_TIMEOUT:-20}"
die() { printf 'install-refresh: %s\n' "$*" >&2; exit 1; }
note() { printf 'install-refresh: %s\n' "$*"; }
# flag_value prints the value of a long flag from the captured argv,
# accepting BOTH supported spellings: `--host 1.2.3.4` and `--host=1.2.3.4`.
#
# Codex round 2 (P1): the first version matched only the space-separated
# form. Cobra accepts both, so a server started `--port=8080` was restarted
# on 8080 and probed on 7777 — a healthy restart failed by this script's
# most confident message. A parser that reads a subset of the syntax it is
# quoting back is the same defect class as a probe that reads half an
# invocation.
flag_value() {
local want="$1"
shift
local i
for ((i = 0; i < $#; i++)); do
local a="${@:i+1:1}"
case "$a" in
"$want")
printf '%s' "${@:i+2:1}"
return 0
;;
"$want"=*)
printf '%s' "${a#*=}"
return 0
;;
esac
done
return 1
}
# data_dir prints the directory the server and CLI keep their state in, by
# config.Load's rule: PAD_DB_PATH's directory, else PAD_DATA_DIR, else ~/.pad
# (PAD_DB_PATH is applied second there, so it wins). The refresh marker
# (BUG-3215) must be found by every CLI on the box, so it lives here and not
# anywhere the cwd or the linked workspace could change.
data_dir() {
if [ -n "${PAD_DB_PATH:-}" ]; then
dirname "$PAD_DB_PATH"
elif [ -n "${PAD_DATA_DIR:-}" ]; then
printf '%s' "$PAD_DATA_DIR"
else
printf '%s' "$HOME/.pad"
fi
}
# config_value prints a top-level key from the pad config file, or nothing.
#
# The file is FLAT TOML (internal/config/config.go: Host, Port, Mode, ... are
# all top-level, no sections), so a line match is a correct reader for it
# rather than a hopeful one. Path resolution mirrors userConfigPath().
#
# Codex round 3 (P1): without this, a server whose host or port comes from
# the config file alone restarts correctly and is then probed on
# 127.0.0.1:7777 and reported as failed — on EVERY install, for that user.
# A stated boundary was the previous answer; it is a bad one when the cost of
# closing it is a grep and the cost of leaving it is a permanent false alarm.
config_value() {
local key="$1" path
path="$(data_dir)/config.toml"
[ -r "$path" ] || return 1
# Strip an inline comment before taking the value: `port = 8080 # dev`
# is valid TOML and the Go parser accepts it, so a regex that swallows
# the comment resolves the port to "8080 # dev" and probes nothing
# (codex round 4). A quoted value is unquoted first, so a `#` inside
# quotes survives.
sed -n "s/^[[:space:]]*$key[[:space:]]*=[[:space:]]*//p" "$path" |
sed -e 's/^"\([^"]*\)".*$/\1/' -e 's/[[:space:]]*#.*$//' -e 's/[[:space:]]*$//' |
head -1
}
# probe_url builds a health URL, bracketing IPv6 literals.
#
# Codex round 3 (P2): `::1` produced http://::1:7777/... — not a URL, so a
# healthy IPv6-bound server reads as unreachable. Accepts a value that is
# already bracketed, since that is how a user would write it in a flag.
probe_url() {
local host="$1" port="$2"
case "$host" in
\[*\]) : ;;
*:*) host="[$host]" ;;
esac
printf 'http://%s:%s/api/v1/health' "$host" "$port"
}
# commit_of prints the commit token a `--version` line carries, or nothing.
# Output shape: `pad version dev (a3a1d58 2026-09-07T12:33:21Z)`.
commit_of() {
printf '%s' "$1" | sed -n 's/.*(\([0-9a-f][0-9a-f]*\)[ )].*/\1/p'
}
# commit_matches compares an EXPECTED abbreviation against a FOUND one,
# tolerating different abbreviation LENGTHS.
#
# This is not defensive slack. `git rev-parse --short` returns the shortest
# UNAMBIGUOUS prefix, so its length grows as the object database does — the
# end-to-end run that found this had a binary embedding `a3a1d58` (7) while
# the Makefile, computing the same expression minutes later after a fetch,
# produced `a3a1d586` (8). An equality test fails there and reports "another
# session rebuilt it", which is a confident, specific and completely wrong
# diagnosis of a healthy build.
#
# The unit tests could not have caught it: their fixtures use fixed strings
# of equal length, so the two sides always agreed on width. Production does
# not have that property, and neither does any check written against it.
commit_matches() {
local expect="$1" found="$2"
[ -n "$expect" ] && [ -n "$found" ] || return 1
[ "$expect" = "$found" ] && return 0
# Resolve both to FULL object ids and compare those. This is the exact
# answer and it is available because `make install` runs in the repo,
# whose path is SRC_DIR wherever the script has moved since (BUG-3403).
#
# Codex round 5 (P1) on the previous prefix comparison: `a3a1d586`
# accepted `a3a1d58` even when those are DIFFERENT commits that happen
# to share seven characters. That is not hypothetical — it is the same
# mechanism that made the abbreviation grow in the first place, so the
# fix for the false alarm had introduced a false pass in the case the
# check exists for. Prefix matching cannot tell the two apart; full ids
# can.
local a b
a="$(git -C "$SRC_DIR" rev-parse --verify --quiet "${expect}^{commit}" 2>/dev/null || true)"
b="$(git -C "$SRC_DIR" rev-parse --verify --quiet "${found}^{commit}" 2>/dev/null || true)"
if [ -n "$a" ] && [ -n "$b" ]; then
[ "$a" = "$b" ]
return
fi
# FAIL CLOSED when an id cannot be resolved (codex round 9). A prefix
# comparison admits exactly the collision this function was rewritten to
# stop admitting, so keeping it as a fallback would reintroduce the
# defect on the path where verification is weakest.
#
# What this refuses: a binary whose embedded commit is not an object in
# THIS checkout — built from another history, or from a repository this
# one does not share. `make install` computes the expected id with `git
# rev-parse` in the same checkout it builds from, so the supported path
# always resolves; anything that does not is a case where the script
# genuinely cannot tell whether the artifact is the right one, and
# saying so is the whole point of this unit.
return 1
}
# --- 1. Capture the running server's argv BEFORE anything is killed ---------
#
# Identity, not count: other `pad` processes may exist (a sibling's CLI call,
# an e2e-spawned server from another worktree) and none of them is the thing
# being replaced. The server is the one with `server start` in its argv.
# Read from /proc so the exact tokens come back, including `--host`.
#
# Empty capture is a legitimate state — a box with no server running — and
# is handled at restart time rather than treated as an error here.
SERVER_ARGV=()
SERVER_PIDS=()
# The argv of every captured server, joined per pid on the ASCII unit
# separator (a bash string cannot hold the NULs /proc uses), so the target can
# be chosen after the port is known (BUG-3194). No argument pad accepts
# contains that byte.
US=$'\x1f'
declare -A SERVER_ARGV_OF=()
# Every element TERMINATED by the separator, not joined by it, and read back
# with mapfile: `read -a` drops trailing empty fields, and a trailing
# separator also keeps command substitution from stripping a final newline
# (codex round 1).
join_us() {
local a
for a in "$@"; do printf '%s%s' "$a" "$US"; done
}
# 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
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 ----
#
# BUG-3194. The target is the `server start` process LISTENING on the port the
# server itself would resolve with no flag (PAD_PORT, then the config file,
# then 7777), not "the only `server start` on the box". A sibling's e2e server
# on its own port is a different server; it used to make this script refuse
# (the old guard counted every `server start`), and the stop below used to be
# a system-wide `pkill -x` that would have killed it.
#
# Refused, still, where the answer is genuinely ambiguous:
# - the port is held by something that is not a captured `server start`;
# - more than one captured server holds it;
# - nothing holds it and more than one server is running elsewhere.
# With exactly one server, nothing on the port, and an explicit `--port` in its
# argv (a server deliberately started elsewhere), that server is the target.
# Stated limits: an argument containing the \x1f separator would be split
# (no pad flag takes one), and a pid can in principle be recycled between
# the last argv check and a signal.
#
# The listener is read with `ss` (Linux) or `lsof` (macOS). Where neither can
# see listeners, only the unambiguous one-server case proceeds.
numeric() { case "$1" in '' | *[!0-9]*) return 1 ;; *) return 0 ;; esac; }
TARGET_PORT=""
for candidate in "${PAD_PORT:-}" "$(config_value port || true)"; do
if numeric "$candidate"; then
TARGET_PORT="$candidate"
break
fi
done
TARGET_PORT="${TARGET_PORT:-7777}"
# listener_pids prints the pids listening on TCP port $1, one per line, and
# prints the word HIDDEN when a listener is visible but its owner is not (ss
# without process info, lsof unable to read another process). A hidden owner
# is refused below rather than read as "nothing is on the port" (codex
# round 1).
listener_pids() {
local lines pids
if command -v ss >/dev/null 2>&1; then
lines="$(ss -Hltnp "sport = :$1" 2>/dev/null)"
[ -n "$lines" ] || return 0
while IFS= read -r l; do
case "$l" in *pid=*) ;; *) echo HIDDEN ;; esac
done <<<"$lines"
grep -o 'pid=[0-9]*' <<<"$lines" | cut -d= -f2 | sort -u
elif command -v lsof >/dev/null 2>&1; then
lines="$(lsof -nP -iTCP:"$1" -sTCP:LISTEN 2>/dev/null | tail -n +2)"
[ -n "$lines" ] || return 0
pids="$(awk '{print $2}' <<<"$lines" | grep -E '^[0-9]+$' | sort -u)"
[ -n "$pids" ] || echo HIDDEN
printf '%s\n' "$pids"
fi
}
TARGET_PID=""
LISTENERS=()
HIDDEN=""
while read -r lp; do
case "$lp" in
HIDDEN) HIDDEN=1 ;;
'') ;;
*) LISTENERS+=("$lp") ;;
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
if [ -n "${SERVER_ARGV_OF[$lp]+x}" ]; then
matched+=("$lp")
else
foreign+=("$lp")
fi
done
# A listener whose owner cannot be read, or ANY non-captured listener on the
# port (even beside a captured one, on another address), makes the port
# ambiguous: refused before anything else is considered (codex round 1).
if [ -n "$HIDDEN" ]; then
die "something is listening on port $TARGET_PORT but its owning process cannot be read here.
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 \`server start\`.
Nothing was stopped or installed; find out what holds the port first."
fi
if [ "${#matched[@]}" -gt 1 ]; then
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]}"
elif [ "${#SERVER_PIDS[@]}" -eq 1 ]; then
# The only server, and nothing on the target port: this is the target
# ONLY if it was deliberately started elsewhere, with an explicit
# --port. Without that it is most likely a sibling's server (an e2e
# webServer takes its port from the environment) while this box's own
# server is down, and replacing it would restart someone else's server
# from this install (codex round 2).
only=()
mapfile -d "$US" -t only < <(printf '%s' "${SERVER_ARGV_OF[${SERVER_PIDS[0]}]}")
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 \`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 \`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
if [ -n "$TARGET_PID" ]; then
mapfile -d "$US" -t SERVER_ARGV < <(printf '%s' "${SERVER_ARGV_OF[$TARGET_PID]}")
OTHERS=()
for sp in "${SERVER_PIDS[@]}"; do
[ "$sp" != "$TARGET_PID" ] && OTHERS+=("$sp")
done
if [ "${#OTHERS[@]}" -gt 0 ]; then
note "other \`server start\` processes are left running: ${OTHERS[*]}"
fi
fi
if [ ${#SERVER_ARGV[@]} -gt 0 ]; then
note "captured server argv: ${SERVER_ARGV[*]}"
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
# path, and the check runs on that snapshot.
#
# Codex round 2 (P1) found the window this closes: checking $BUILT and then
# copying it later leaves room for another session to rebuild the shared repo
# binary in between, so the destination check catches the swap only AFTER the
# old server has been killed — turning a detectable race into an outage. A
# snapshot cannot change under us, so the expensive failure mode disappears
# rather than being detected later.
#
# The temp file lives in the DESTINATION directory so the final move is a
# rename within one filesystem, which is atomic: no window in which
# ~/.local/bin/pad is half-written.
mkdir -p "$(dirname "$INSTALLED")"
STAGED="$(mktemp "$(dirname "$INSTALLED")/.$(basename "$INSTALLED").XXXXXX")" ||
die "could not stage the install next to $INSTALLED"
# The marker is removed only while it is still OURS: a second refresh that
# started after this one owns the file now, and removing it would reopen the
# window it is holding.
cleanup() {
[ -n "${STAGED:-}" ] && rm -f "$STAGED"
if [ -n "${MARKER_WRITTEN:-}" ] &&
[ "$(sed -n 's/.*"pid": *\([0-9]*\).*/\1/p' "$REFRESH_MARKER" 2>/dev/null)" = "$$" ]; then
rm -f "$REFRESH_MARKER"
fi
return 0
}
trap cleanup EXIT
cp -f "$BUILT" "$STAGED" || die "cp failed"
chmod +x "$STAGED"
# The ARTIFACT check, on the snapshot, while the old server is still serving.
# Failing here costs nothing.
if [ -n "$EXPECT_COMMIT" ]; then
staged_version="$("$STAGED" --version 2>/dev/null || true)"
if ! commit_matches "$EXPECT_COMMIT" "$(commit_of "$staged_version")"; then
die "the binary at $BUILT is not this invocation's build; refusing to stop the server.
expected commit: $EXPECT_COMMIT
$BUILT reads: ${staged_version:-<no version output>}
Another session very likely rebuilt it between this build and now.
Nothing was stopped or installed; re-run the build and install together."
fi
fi
# PORT resolution, in the server's own precedence order. Resolved before the
# stop because the refresh marker names it (BUG-3215); the probe uses it too.
#
# Codex round 1: probing a fixed 7777 while faithfully restarting a server
# that was started with `--port 8080` fails a HEALTHY restart, and does it
# with this script's most confident message. The flag exists
# (cmd_server.go: `--port`, default 7777) so the case is reachable, and the
# whole point of this script is that the restart preserves the invocation —
# a probe that ignores half of that invocation is checking the wrong server.
configured_port="$(flag_value --port "${SERVER_ARGV[@]}" || true)"
# Each source is accepted only if it is NUMERIC, mirroring the application:
# config.go ignores a non-integer PAD_PORT and falls through to the next
# source. Without that, a mistyped or inherited PAD_PORT is probed
# literally and every refresh fails against a server that is running fine
# (codex round 7).
PORT=""
for candidate in "$configured_port" "${PAD_PORT:-}" "$(config_value port || true)"; do
if numeric "$candidate"; then
PORT="$candidate"
break
fi
if [ -n "$candidate" ]; then
note "ignoring non-numeric port value \"$candidate\" (as the server does)"
fi
done
PORT="${PORT:-7777}"
# --- 2b. Claim the stop->restart window (BUG-3215) ---------------------------
#
# Between the stop below and the restart answering, nothing serves this port,
# and any pad CLI call in that window used to AUTO-START a server of its own:
# the caller's binary (a worktree build, or the old inode before the rename),
# from the caller's cwd, with default flags. It then held the port against
# this script's restart. Measured on day 79: the lead's `pad item comment`
# took the port during a bounce, 127.0.0.1:7777 went dark, and the script
# exited 1.
#
# The marker hands the window to this script. EnsureServer
# (internal/cli/refresh_marker.go) reads it and waits instead of spawning
# while it is LIVE: the pid below alive AND the deadline not passed, for the
# port it names. Written by rename, so a reader never sees half of it; removed
# by the EXIT trap, on success and on every refusal after this point.
#
# The deadline is short on purpose: the 5s TERM wait, a 5s margin for the
# install and the spawn, and one probe timeout (PAD_PROBE_TIMEOUT, 20s by
# default), so 30s by default. A healthy bounce is over in seconds and the
# waiting CLI returns the moment the server answers; the deadline only
# decides how long a CLI waits on a refresh that is failing, after which it
# errors naming this pid rather than spawning.
REFRESH_MARKER="$(data_dir)/refresh.lock"
MARKER_WRITTEN=""
probe_budget="$PROBE_TIMEOUT"
numeric "$probe_budget" || probe_budget=20
mkdir -p "$(dirname "$REFRESH_MARKER")" || die "could not create $(dirname "$REFRESH_MARKER") for the refresh marker"
marker_tmp="$(mktemp "$REFRESH_MARKER.XXXXXX")" || die "could not stage the refresh marker next to $REFRESH_MARKER"
printf '{"pid": %d, "port": %d, "deadline": %d}\n' "$$" "$PORT" "$(($(date +%s) + 10 + probe_budget))" >"$marker_tmp" &&
mv -f "$marker_tmp" "$REFRESH_MARKER" ||
{
rm -f "$marker_tmp"
die "could not write the refresh marker $REFRESH_MARKER.
Nothing was stopped or installed."
}
MARKER_WRITTEN=1
# --- 3. Stop -----------------------------------------------------------------
#
# The TARGET only, by pid (BUG-3194). This used to be `pkill -x <name>`, which
# reached every process with the binary's name: a sibling's e2e server, and
# any `pad` CLI call in flight. The argv is re-read immediately before the
# kill, because the pid was captured before the artifact check and a pid can
# be recycled: a mismatch refuses rather than killing whatever now holds it.
# SIGTERM first so the graceful-shutdown path runs (BUG-1531).
BIN_NAME="$(basename "$BUILT")"
if [ -n "$TARGET_PID" ]; then
# still_target re-reads the pid's argv the way the capture read it and
# succeeds only if it is still the captured server. Asked before EACH
# signal: a pid that exits and is recycled during the TERM wait must not
# receive the KILL (codex round 1). A pid that is already gone is not
# signalled at all.
still_target() {
local now=()
if [ -z "${PAD_NO_PROC:-}" ] && [ -r "/proc/$TARGET_PID/cmdline" ]; then
mapfile -d '' -t now < "/proc/$TARGET_PID/cmdline" 2>/dev/null
else
read -r -a now <<<"$(ps -o args= -p "$TARGET_PID" 2>/dev/null || true)"
fi
[ ${#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."
fi
kill -TERM "$TARGET_PID" 2>/dev/null || true
for _ in 1 2 3 4 5; do
kill -0 "$TARGET_PID" 2>/dev/null || break
sleep 1
done
if kill -0 "$TARGET_PID" 2>/dev/null && still_target; then
kill -KILL "$TARGET_PID" 2>/dev/null || true
fi
fi
fi
# --- 4. Move into place, then check the OUTCOME -------------------------------
mv -f "$STAGED" "$INSTALLED" || die "could not move the staged binary into $INSTALLED"
STAGED=""
rm -f "$HOME/.pad/pad.pid"
# The check TASK-2787 asks for, and it is deliberately on the installed file
# rather than on any step: read what is now at the install path and require it
# to carry the commit this invocation built with. Distinct from step 2 — that
# asked "is the source right", this asks "is the DESTINATION right", and it
# is the leg that survives another writer racing the final path.
#
# WHAT THIS ANSWERS, stated because the check reads stronger than it is: it
# catches "the binary here was built at a DIFFERENT commit", which is the
# reversed-ordering case TASK-2787 names. It does NOT establish byte
# provenance: a sibling who built the SAME commit leaves a binary this check
# accepts. That case is benign by the task's own scope note, and pretending
# otherwise would be the kind of overclaimed instrument this repo keeps
# filing bugs about.
if [ -n "$EXPECT_COMMIT" ]; then
installed_version="$("$INSTALLED" --version 2>/dev/null || true)"
if ! commit_matches "$EXPECT_COMMIT" "$(commit_of "$installed_version")"; then
die "installed binary is not the one this invocation built.
expected commit: $EXPECT_COMMIT
installed reads: ${installed_version:-<no version output>}
Another session very likely wrote $INSTALLED between this move and this read.
Re-run the build and install together; do not trust the previous output."
fi
fi
note "installed $BIN_NAME to $INSTALLED ($EXPECT_COMMIT)"
# --- 5. Restart with the argv that was killed --------------------------------
#
# The whole point of BUG-2897: a replacement is only correct if it preserves
# what it replaced, and that includes the INVOCATION, not just the bytes.
if [ ${#SERVER_ARGV[@]} -gt 0 ]; then
restart=("$INSTALLED" "${SERVER_ARGV[@]:1}")
else
restart=("$INSTALLED" server start)
fi
# mkdir before the redirect: a missing ~/.pad makes the redirection fail and
# the server never starts, which the probe then reports as "did not answer" —
# true, but three steps downstream of the cause. Found by the probe-failure
# test on a fresh HOME, where the directory does not exist; on a developer's
# box it exists by luck of history, which is why nothing had caught it.
mkdir -p "$HOME/.pad"
# setsid where it exists, plain nohup where it does not. Codex round 2
# (P1): setsid is Linux-specific and absent from a default macOS
# environment, where an unconditional call would fail AFTER the old server
# had been stopped — the one ordering this script exists to avoid. nohup
# plus the background job is sufficient detachment on both.
# PAD_NO_SETSID forces the fallback branch. It exists because the branch is
# otherwise unreachable on the machines this is developed and tested on —
# every Linux box has setsid — and an untestable branch is how the macOS
# case would have shipped broken a second time. Test-only knob, documented
# rather than hidden.
# Re-read the installed binary IMMEDIATELY before executing it.
#
# Codex round 4 (P1): a concurrent `make install` can replace $INSTALLED
# between the outcome check above and this exec, so this invocation would
# launch a build it never verified and report success.
#
# RESIDUAL, stated rather than glossed: the window is narrowed, not closed —
# the complete answer is an exclusive lock across the whole
# check-install-restart sequence. Deliberately not added here: flock is
# Linux-only, and a half-portable lock that silently does nothing on macOS is
# worse than a named residual. Filed separately.
if [ -n "$EXPECT_COMMIT" ]; then
preexec_version="$("$INSTALLED" --version 2>/dev/null || true)"
if ! commit_matches "$EXPECT_COMMIT" "$(commit_of "$preexec_version")"; then
die "another session replaced $INSTALLED between the install check and the restart.
expected commit: $EXPECT_COMMIT
installed reads: ${preexec_version:-<no version output>}
Refusing to start a binary this invocation did not verify. No server is running."
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
# describe_pid prints "pid N (argv)" for a report. Best effort: a pid that has
# gone by the time it is read is named alone.
describe_pid() {
local args
args="$(ps -o args= -p "$1" 2>/dev/null || true)"
if [ -n "$args" ]; then
printf 'pid %s (%s)' "$1" "$args"
else
printf 'pid %s' "$1"
fi
}
# The old server is stopped, so anything holding the port NOW took it in the
# window (BUG-3215): a CLI auto-start from a binary that predates the marker,
# or a start by hand. The restart could not bind, so it is not spawned, and
# the holder is named instead of left for the probe to misread.
early=()
while read -r lp; do
case "$lp" in HIDDEN | '') ;; *) early+=("$lp") ;; esac
done < <(listener_pids "$PORT")
if [ "${#early[@]}" -gt 0 ]; then
held_by=()
for lp in "${early[@]}"; do held_by+=("$(describe_pid "$lp")"); done
die "port $PORT was taken after the old server stopped and before the restart, by: ${held_by[*]}.
The binary is installed; nothing was restarted, because the restart could not bind.
Most likely a pad CLI call auto-started a server in the window. Stop that process and re-run the refresh.
Restart argv would have been: ${restart[*]}"
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
nohup "${restart[@]}" >>"$HOME/.pad/server.log" 2>&1 </dev/null &
fi
RESTART_PID=$!
disown 2>/dev/null || true
# --- 6. Probe BOTH addresses before claiming anything -------------------------
#
# `--host 0.0.0.0` is a bind spec, not an address to curl, so the configured
# host resolves to the primary LAN address in that case. Loopback is always
# probed: it is the address the previous defect killed, and the one every
# local CLI call and browser tab uses.
configured_host="$(flag_value --host "${SERVER_ARGV[@]}" || true)"
# HOST likewise. When the argv carries no --host, fall back to PAD_HOST,
# which config.go reads for exactly this purpose.
#
# Precedence: flag, then environment, then the config file, then the
# default — the same order the server itself resolves them in. The config
# file is read because leaving it out meant a permanent false failure for
# anyone who configures the bind there (codex round 3).
if [ -z "$configured_host" ]; then
configured_host="${PAD_HOST:-$(config_value host || true)}"
fi
# The probe set is exactly what the BIND PROMISES, no more and no less.
#
# A wildcard bind promises loopback AND the LAN address, and the original
# defect was precisely a wildcard server coming back bound to one host with
# 127.0.0.1 dead — so both are required there. An EXPLICIT single host
# promises only itself: demanding 127.0.0.1 from a server deliberately bound
# to ::1 or to a LAN interface would fail a correct configuration, which is
# the same false alarm in the opposite direction.
#
# The first version required 127.0.0.1 unconditionally, and the IPv6 test
# caught it: a server bound ::1 restarted correctly and was reported dead
# because a v4 loopback it never promised did not answer.
probe_hosts=()
case "$configured_host" in
"" | "127.0.0.1")
probe_hosts=("127.0.0.1")
;;
"localhost")
# `localhost` may resolve to either loopback family, and which one the
# server binds is not knowable from here (codex round 8). Requiring both
# would fail a healthy v4-only or v6-only bind, so this is the one case
# where ANY member answering is the pass — recorded because it is a
# deliberate exception to "every probed address must answer".
probe_any=1
probe_hosts=("127.0.0.1" "::1")
;;
"0.0.0.0")
probe_hosts=("127.0.0.1")
lan="$(hostname -I 2>/dev/null | awk '{print $1}')"
if [ -z "$lan" ]; then
lan="$(ipconfig getifaddr en0 2>/dev/null || true)"
fi
if [ -n "$lan" ]; then
probe_hosts+=("$lan")
else
# FAIL CLOSED (codex rounds 5 and 8). A wildcard bind promises the
# external address, and being unable to check it is not the same as
# it being fine. The first version warned and exited 0, which is
# this unit's own defect wearing a different hat: a success line
# covering something that was not verified.
die "cannot determine a LAN address to verify the wildcard bind (tried \`hostname -I\` and \`ipconfig getifaddr en0\`).
The binary is installed and loopback answers, but the external half of
\`--host 0.0.0.0\` is unverified and this refuses to claim otherwise.
Start the server with an explicit --host, or set PAD_HOST, to make the
address checkable."
fi
;;
"::")
probe_hosts=("127.0.0.1" "::1")
;;
*)
probe_hosts=("$configured_host")
;;
esac
probe() {
local host="$1" deadline=$((SECONDS + PROBE_TIMEOUT))
while [ $SECONDS -lt $deadline ]; do
if curl -fsS -m 2 -o /dev/null "$(probe_url "$host" "$PORT")" 2>/dev/null; then
return 0
fi
sleep 1
done
return 1
}
failed=()
answered=0
for host in "${probe_hosts[@]}"; do
if probe "$host"; then
answered=$((answered + 1))
else
failed+=("$host")
fi
done
# Every probed address must answer, EXCEPT the `localhost` case above, where
# one loopback family answering is the whole of what can be required.
if [ "${probe_any:-0}" = "1" ] && [ "$answered" -gt 0 ]; then
failed=()
probe_hosts=("localhost")
fi
# WHO answered (BUG-3215). A health probe answers for whatever holds the
# address, so a server that took the port in the window passes it: on day 79
# the stray bound the LAN address with default flags, and had it bound
# loopback this script would have printed success over the wrong server. The
# listener must be the restart this script spawned (or its direct child,
# should `setsid` have had to fork). Checked on the failure path too, where a
# named holder is the diagnosis a bare "did not answer" is not.
owner_ok() {
[ "$1" = "$RESTART_PID" ] && return 0
[ "$(ps -o ppid= -p "$1" 2>/dev/null | tr -d ' ')" = "$RESTART_PID" ]
}
late=()
late_hidden=""
while read -r lp; do
case "$lp" in HIDDEN) late_hidden=1 ;; '') ;; *) late+=("$lp") ;; esac
done < <(listener_pids "$PORT")
foreign_late=()
for lp in "${late[@]}"; do
owner_ok "$lp" || foreign_late+=("$(describe_pid "$lp")")
done
if [ "${#foreign_late[@]}" -gt 0 ]; then
die "port $PORT is held by ${foreign_late[*]}, not by this refresh's restart (pid $RESTART_PID).
The binary is installed, but the server answering is not the one this refresh started.
Most likely a pad CLI call auto-started a server in the window. Stop that process and re-run the refresh.
Restart argv was: ${restart[*]}"
fi
if [ -n "$late_hidden" ]; then
die "a listener on port $PORT cannot be attributed to a process here, so this refresh cannot confirm the restart (pid $RESTART_PID) is what answers.
The binary is installed. Check what holds port $PORT."
fi
if [ "${#late[@]}" -eq 0 ] && [ ${#failed[@]} -eq 0 ]; then
# Neither ss nor lsof can see listeners here: the probe passed, but
# whose server answered is unverified, and the success line says so.
unattributed=1
fi
if [ ${#failed[@]} -gt 0 ]; then
die "server did not answer on: ${failed[*]} (port $PORT, waited ${PROBE_TIMEOUT}s each).
The binary is installed; the server is not serving those addresses.
Restart argv was: ${restart[*]}"
fi
note "server restarted and answering on: ${probe_hosts[*]} (port $PORT)"
if [ -n "${unattributed:-}" ]; then
note "listener ownership NOT verified: neither ss nor lsof can see listeners here, so the process answering was not confirmed to be the restart (pid $RESTART_PID)"
fi
# --- 7. Report WHICH web bundle is served, and warn when it is not a clean
# build of this commit (TASK-3233) ---------------------------------------
# The binary's commit stamp says nothing about web/build: it is generated,
# gitignored and embedded as it stands, so a bundle built from uncommitted
# edits (a negative-control build, BUG-3129) or left over from an older
# commit ships under a clean commit stamp. The server digests the bundle it
# embeds and reports it in /api/v1/health, with the source stamp `vite build`
# writes (commit, and entries `git status` listed under web/). This step only
# WARNS: the restart has already happened, and the fix is to rebuild web and
# refresh again. A server older than TASK-3233 reports no `web` member.
warn() { printf 'install-refresh: WARNING: %s\n' "$*" >&2; }
health_json="$(curl -fsS -m 2 "$(probe_url "${probe_hosts[0]}" "$PORT")" 2>/dev/null || true)"
web_json="$(printf '%s' "$health_json" | sed -n 's/.*"web":{\([^}]*\)}.*/\1/p')"
if [ -z "$web_json" ]; then
note "served web bundle: not reported (the server predates TASK-3233, or has no web UI)"
else
web_sha="$(printf '%s' "$web_json" | sed -n 's/.*"sha256":"\([0-9a-f]*\)".*/\1/p')"
web_commit="$(printf '%s' "$web_json" | sed -n 's/.*"source_commit":"\([0-9a-f]*\)".*/\1/p')"
web_dirty="$(printf '%s' "$web_json" | sed -n 's/.*"source_dirty":\([0-9]*\).*/\1/p')"
note "serving web bundle sha256 ${web_sha:-?} (built from ${web_commit:-unknown commit}, web/ dirty ${web_dirty:-unknown})"
if [ -z "$web_commit" ]; then
warn "the web bundle carries no source stamp, so where it was built from is unknown (built without git, or before TASK-3233)."
elif [ -n "$EXPECT_COMMIT" ] && ! commit_matches "$EXPECT_COMMIT" "$web_commit"; then
# A bundle stamped with an older commit is correct when web/ did not
# change in between (a backend-only commit on top). Warn only when it
# did, or when that cannot be established.
# :(top) anchors the pathspec at the repository root: SRC_DIR may
# be a subdirectory, where a bare web/ would name <subdir>/web/
# and find no change (codex r1 on BUG-3403).
if ! git -C "$SRC_DIR" diff --quiet "$web_commit" "$EXPECT_COMMIT" -- ':(top)web/' 2>/dev/null; then
warn "the web bundle was built at ${web_commit}, and web/ differs between that commit and ${EXPECT_COMMIT} (or that cannot be checked here): web/build is stale. Rebuild web (vite build) and refresh again."
fi
fi
if [ -n "$web_dirty" ] && [ "$web_dirty" != "0" ]; then
warn "the web bundle was built while web/ had ${web_dirty} uncommitted change(s): it is not a build of any commit. Rebuild web from a clean tree and refresh again."
fi
fi