#!/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 # # 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-, 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:-} 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 `, 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:-} 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:-} 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 >"$HOME/.pad/server.log" 2>&1 /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 /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