#!/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:-}" # 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 } # 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="$HOME/.pad/config.toml" [ -n "${PAD_DATA_DIR:-}" ] && path="$PAD_DATA_DIR/config.toml" [ -n "${PAD_DB_PATH:-}" ] && path="$(dirname "$PAD_DB_PATH")/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. # # 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 rev-parse --verify --quiet "${expect}^{commit}" 2>/dev/null || true)" b="$(git 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" cleanup() { [ -n "${STAGED:-}" ] && rm -f "$STAGED"; } 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 # --- 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 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)" # PORT resolution, in the server's own precedence order. # # 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}" # 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 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)"