Files
pad/scripts/install-refresh.sh
T
xarmian 0a32eff274 fix(scripts): install-refresh removes pad.pid only when it is provably not a live sibling's (BUG-4068) (#2311)
* fix(scripts): install-refresh removes pad.pid only when it is provably not a live sibling's (BUG-4068)

The refresh removed <data dir>/pad.pid unconditionally after the install,
whether or not it stopped anything and whoever the file named. A second
server sharing the data dir lost its record, so `pad server stop` and
`pad server status` could no longer find it.

Now the file is removed when it names the pid this refresh stopped, or a pid
that is provably dead (kill -0 fails and ps finds no such process, so EPERM
is not read as dead). It is kept when it names any live process, when its
liveness is unknown, and when it does not parse as a pid record (JSON or the
legacy bare integer). A kept stale file is the safe failure: server stop
refuses a record it cannot prove (BUG-2969). The BUG-4064 tests, which
clear a dead-pid file with nothing stopped, stand unchanged.

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

* test(buildtools): pin install-refresh's pad.pid removal rule (BUG-4068)

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

* fix(scripts,buildtools): pad.pid is read no more loosely than pad writes it, and only ESRCH is dead (BUG-4068 r1)

r1 items, as ruled:
1, 3: pid_file_pid accepts only MarshalIndent's record (one "pid", at the
   two-space top-level indent) or a bare integer with surrounding whitespace,
   at most 10 digits. Duplicate or nested pid keys, compact JSON, "1 2" and
   huge values no longer parse, so the file is kept.
2: pid_liveness reads the builtin kill's own message under LC_ALL=C. Only
   "No such process" is dead; EPERM and anything else are unknown. ps is not
   asked, so a missing, busybox or hidepid-restricted ps cannot call a live
   sibling dead.
4: STOPPED_PID is recorded only when kill -TERM is delivered.
5: the decision is pid_file_removable, tested on its own, so the stopped-pid
   leg (a zombie or a quick reuse) has a row of its own.
6: the dead-pid row uses a reaped pid, not 999999.

Also: the harness guard's testTempScope cuts the test name to 64 bytes, as
Go's makeTempDir does. A top-level test name of 64 bytes or more was refused
by its own temp dir; this unit's long test names found it.

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

* fix(scripts): pad.pid's reader checks the whole record's line shape and caps the pid (BUG-4068 r2)

r2 items, as ruled:
1: pid_file_pid now checks the record's line SHAPE end to end, not only the
   pid line: first non-blank line exactly "{", last exactly "}", every line
   between a two-space-indent key line, one of them the pid. A "\r" is
   dropped only at a line end. This is still a shape check, not full JSON
   validation. The r1 commit's "accepts only MarshalIndent's record" said
   more than r1 did; this commit is what makes the shape claim true.
2: a pid above 4194304 (Linux's largest pid_max) does not parse, so it can
   never wrap as a pid_t.
3: pid_liveness states that ESRCH is per pid namespace.
5: the stopped-pid test comment is corrected: the e2e pre-server is a zombie
   until cleanup reaps it, so the e2e stopped-server rows reach that leg too.

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

* docs(scripts,buildtools): pid_file_pid's comments state the inner-line check exactly (BUG-4068 r3)

Comment-only. pid_file_pid checks that each line between the braces STARTS
like a two-space-indent quoted key; the rest of such a line is unchecked.
The comments said "every line between a two-space-indent key line", and
"read no more loosely than the SHAPE", which claimed more than the pattern
checks (r3, ruled a decline: the code stays as it is after the close).

Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
2026-10-25 06:26:02 -04:00

1146 lines
51 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
}
# anchored_data_dir is data_dir with a RELATIVE value anchored where the
# restarted server resolves it: config.Load takes the value verbatim, so the
# server reads it against its own cwd. The restart runs in SERVER_CWD (the cd
# in section 5) or, before one is captured or when none is, here
# (BUG-4064 r2 for the log and the pid file; BUG-4066 for the refresh marker
# and config_value, so every path the script touches is resolved one way).
anchored_data_dir() {
local dir
dir="$(data_dir)"
case "$dir" in
/*) printf '%s' "$dir" ;;
*) printf '%s' "${SERVER_CWD:-$PWD}/$dir" ;;
esac
}
# normalize_path collapses "." and ".." segments of an absolute path in pure
# shell (no realpath -m, which macOS lacks; no arrays, for bash 3.2), so the
# warning names a clean path for "./x/../data".
normalize_path() {
local rest="$1/" seg out=""
while [ -n "$rest" ]; do
seg="${rest%%/*}"
rest="${rest#*/}"
case "$seg" in
"" | .) ;;
..) out="${out%/*}" ;;
*) out="$out/$seg" ;;
esac
done
printf '%s' "${out:-/}"
}
# warn_relative_data_dir prints config.Load's BUG-4066 warning for a relative
# PAD_DATA_DIR or PAD_DB_PATH (the value still resolves; a future release
# refuses it, TASK-4072). It names the DATA DIR the server and this refresh
# actually use (the directory of a PAD_DB_PATH, not the db file), anchored
# like anchored_data_dir, so it runs once SERVER_CWD is captured (BUG-4066
# r1), and still before the marker or the stop.
warn_relative_data_dir() {
local name value dir what base
if [ -n "${PAD_DB_PATH:-}" ]; then
name=PAD_DB_PATH value="$PAD_DB_PATH" dir="$(dirname "$PAD_DB_PATH")" what="the data dir"
elif [ -n "${PAD_DATA_DIR:-}" ]; then
name=PAD_DATA_DIR value="$PAD_DATA_DIR" dir="$PAD_DATA_DIR" what="it"
else
return 0
fi
case "$dir" in /*) return 0 ;; esac
base="${SERVER_CWD:-$PWD}"
# ONE line, like config.Load's: phase 1's contract is one warning per
# process, so the shell's-directory note and the ~ hint ride in it
# (BUG-4066 r4 item 1).
local msg resolved
resolved="$(normalize_path "$base/$dir")"
if [ -n "${SERVER_CWD:-}" ] && [ "$SERVER_CWD" != "$PWD" ]; then
resolved="$resolved (the server's directory; from this shell's directory it would be $(normalize_path "$PWD/$dir"))"
fi
msg="Warning: $name is relative ($value); this process resolves $what to $resolved. A CLI and a server in different directories will use different data. Set an absolute path; relative values will be refused in a future release."
case "$value" in "~"*) msg="$msg The leading ~ was not expanded by your shell, so it names a directory literally called ~." ;; esac
echo "$msg" >&2
}
# 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="$(anchored_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=""
STOPPED_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
# BUG-4066: after the capture, so the warning names the anchored path; still
# before the marker and the stop.
warn_relative_data_dir
# --- 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}"
# --- 2a. The server log, proven writable before anything is stopped ----------
#
# The restarted server logs where `pad server info` says the log is:
# cfg.LogFile(), <data dir>/logs/server.log (BUG-4064). This used to be
# $HOME/.pad/server.log, a second file nothing else reads, and one that also
# ignored PAD_DATA_DIR. The file is opened for append HERE, before the stop:
# a redirect that cannot open its target never runs the command, so an
# unwritable log found after the stop would leave no server at all. A missing
# parent (a fresh HOME), a read-only mount, a file another user owns (for a
# non-root caller) or a directory in its place all refuse now, with the
# running server untouched.
#
# A RELATIVE data dir is anchored where the restarted server will resolve it
# (BUG-4064 r2). config.Load takes PAD_DATA_DIR verbatim, or PAD_DB_PATH's
# dirname, with no filepath.Abs (config.go), so the server resolves it against
# its own cwd. The restart runs in SERVER_CWD (the cd in section 5) or, when
# none was captured, here. Proving the file relative to THIS cwd instead
# would check one file and then open another after the stop.
DATA_DIR="$(anchored_data_dir)"
LOG_FILE="$DATA_DIR/logs/server.log"
mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null &&
{ : >>"$LOG_FILE"; } 2>/dev/null ||
die "cannot append to the server log $LOG_FILE. Nothing was stopped; the running server is untouched. Fix the path's permissions and re-run."
LEGACY_LOG="$HOME/.pad/server.log"
# --- 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.
# Anchored like the log (BUG-4066): written here and removed by cleanup()
# after the cd into SERVER_CWD, so both must name the same file.
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
# The pid this refresh signalled: the only pid whose pad.pid record
# section 4 may treat as its own (BUG-4068). Recorded only when the
# signal was delivered, so a pid that exited and was reused after
# still_target is never claimed.
if kill -TERM "$TARGET_PID" 2>/dev/null; then
STOPPED_PID="$TARGET_PID"
fi
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=""
# cfg.PIDFile() is <data dir>/pad.pid (config.go), claimed by `pad server
# start` (cmd/pad/cmd_server.go); not $HOME/.pad under PAD_DATA_DIR (BUG-4064).
# DATA_DIR is section 2a's, anchored like the log.
#
# The file is removed only when it is provably not someone else's (BUG-4068):
# it names the pid this refresh stopped, or a pid that is provably DEAD. It is
# kept when it names any live process, a sibling server sharing the data dir
# or a reused pid, and when its liveness is unknown (EPERM) or it does not
# parse. A kept stale file is the safe failure: `pad server stop` refuses a
# record it cannot prove (BUG-2969).
pid_file_pid() {
# The pid in pad.pid, read by checking each line against the SHAPE that
# internal/cli/pidfile.go writes (BUG-4068 r1, r2). This is a line-shape
# check, not full JSON validation:
# - the MarshalIndent record: first non-blank line exactly "{", last
# exactly "}", and each line between must START like a two-space-indent
# quoted key (' "' and a key character); the rest of such a line is
# unchecked. Exactly one of them is " \"pid\": N" (with or without a
# trailing comma), and "pid" appears nowhere else in the file;
# - or the legacy bare integer: one non-blank line, surrounding space only.
# A "\r" is dropped only at a line end. The pid is at most 10 digits,
# checked before any arithmetic, and at most 4194304 (Linux's largest
# pid_max; macOS's is 99999), so it never wraps as a pid_t. Anything else
# does not parse, and the file is kept.
local raw line pid="" n=0 lines=()
raw="$(cat "$1" 2>/dev/null)" || return 1
while IFS= read -r line || [ -n "$line" ]; do
line="${line%$'\r'}"
case "$line" in *[![:space:]]*) lines+=("$line") ;; esac
done <<<"$raw"
[ ${#lines[@]} -gt 0 ] || return 1
case "$raw" in *'"pid"'*'"pid"'*) return 1 ;; esac
if [ "${lines[0]}" = "{" ]; then
[ ${#lines[@]} -ge 3 ] && [ "${lines[${#lines[@]} - 1]}" = "}" ] || return 1
local i
for ((i = 1; i < ${#lines[@]} - 1; i++)); do
line="${lines[i]}"
case "$line" in
' "pid": '*)
pid="${line#' "pid": '}"
pid="${pid%,}"
n=$((n + 1))
;;
' "'[!\ \"]*) ;;
*) return 1 ;;
esac
done
[ "$n" -eq 1 ] || return 1
else
[ ${#lines[@]} -eq 1 ] || return 1
pid="${lines[0]}"
while case "$pid" in [[:space:]]*) true ;; *) false ;; esac; do pid="${pid#?}"; done
while case "$pid" in *[[:space:]]) true ;; *) false ;; esac; do pid="${pid%?}"; done
fi
numeric "$pid" && [ ${#pid} -le 10 ] || return 1
pid=$((10#$pid))
[ "$pid" -gt 0 ] && [ "$pid" -le 4194304 ] || return 1
printf '%s' "$pid"
}
pid_liveness() {
# alive, dead or unknown. DEAD is ESRCH only: kill -0 also fails with EPERM
# for a live process this user may not signal (BUG-4068 r1), so the
# builtin's own message is read under LC_ALL=C. ps is not asked: a missing,
# busybox or hidepid-restricted ps would read a live process as gone.
# ESRCH is per pid namespace: a sibling in another container that shares
# the data dir reads as dead here (ruled inherent, f8009dad).
local out
if out="$(LC_ALL=C kill -0 "$1" 2>&1)"; then
echo alive
return
fi
case "$out" in
*'No such process'*) echo dead ;;
*) echo unknown ;;
esac
}
pid_file_removable() {
# pid_file_removable <pad.pid> <stopped pid, or empty>: succeeds when the
# file is provably not a live sibling's, the pid this refresh stopped or a
# dead one. Otherwise it prints why the file is kept, and fails.
local file_pid
if ! file_pid="$(pid_file_pid "$1")"; then
echo "it does not parse as a pid record"
return 1
fi
[ -n "$2" ] && [ "$file_pid" = "$2" ] && return 0
case "$(pid_liveness "$file_pid")" in
dead) return 0 ;;
alive) echo "it names pid $file_pid, which is alive and is not the server this refresh stopped" ;;
*) echo "it names pid $file_pid, whose liveness is unknown" ;;
esac
return 1
}
pid_file="$DATA_DIR/pad.pid"
if [ -e "$pid_file" ]; then
if kept_why="$(pid_file_removable "$pid_file" "$STOPPED_PID")"; then
rm -f "$pid_file"
else
note "kept $pid_file: $kept_why"
fi
fi
# 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
# The redirect target exists and is writable: 2a created and opened it
# before the stop. A missing directory there would make the redirection fail
# and the server never start, three steps upstream of the probe that reports
# it (found on a fresh HOME by the probe-failure test).
# 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[@]}" >>"$LOG_FILE" 2>&1 </dev/null &
else
nohup "${restart[@]}" >>"$LOG_FILE" 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[*]}
Server log: $LOG_FILE"
fi
note "server restarted and answering on: ${probe_hosts[*]} (port $PORT)"
note "server log: $LOG_FILE"
# The old destination is left as it is: it is the operator's file, and an
# investigation in flight may still be counting from it.
if [ "$LEGACY_LOG" != "$LOG_FILE" ] && [ -f "$LEGACY_LOG" ]; then
note "older refreshes logged to $LEGACY_LOG; it is no longer written, and this server logs to $LOG_FILE"
fi
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