Files
pad/internal/cli/refresh_marker.go
T
xarmian d4ec587de9 fix(cli): a CLI call during an install-refresh bounce waits for the refresh instead of auto-starting a server over it (BUG-3215) (#1544)
install-refresh.sh stops the server, installs, and restarts it with the
captured argv. Any pad CLI call in between found nothing answering and
auto-started a server of its own: the caller's binary, cwd and default
flags. That server held the port against the refresh's restart (day 79:
127.0.0.1:7777 went dark and the script exited 1).

- The script writes <DataDir>/refresh.lock {pid, port, deadline} by rename
  before the stop and removes it in its EXIT trap, only while it is still
  its own. The deadline is 10s + PAD_PROBE_TIMEOUT (30s by default).
- EnsureServer defers to a live marker for its own port (writer pid alive
  AND deadline not passed): it polls health and never spawns. If the
  writer is still alive past the deadline it errors naming the pid. A dead
  writer, a passed deadline, another port or an unparseable file means
  the marker is ignored.
- The script names a holder that took the port before the restart spawns
  (and does not spawn it), and refuses success when the listener after
  the probe is not the restart it spawned. A health probe answers for
  whoever holds the address.
- The script-test dependency guard now reads every install_refresh*_test.go.

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-25 09:55:48 -04:00

131 lines
4.8 KiB
Go

package cli
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/PerpetualSoftware/pad/internal/config"
)
// RefreshMarkerName is the file scripts/install-refresh.sh writes, beside the
// PID file, for the window in which it has stopped the server and not yet seen
// its replacement answer (BUG-3215).
//
// That window used to belong to whichever process asked first. Any pad CLI
// call landing in it found no server and auto-started one with DEFAULT flags,
// running the CALLER's binary (a worktree build, or the old inode before the
// install's rename) from the caller's cwd. The refresh's own restart then
// could not bind, and the box was left serving the wrong argv and possibly the
// wrong commit. The marker hands the window back to the refresh: EnsureServer
// waits for it instead of spawning.
const RefreshMarkerName = "refresh.lock"
// refreshMarker is the marker's content. The script writes it with printf, so
// the shape is kept flat and every member is a plain integer.
type refreshMarker struct {
// PID of the refresh script. A marker whose writer is gone describes
// nothing: crash-only, so no one else removes it.
PID int `json:"pid"`
// Port the refresh will restart the server on. A CLI pointed at another
// port (a seat's private server, an e2e server) is not in this window.
Port int `json:"port"`
// Deadline, unix seconds. A pid alone can be reused, so liveness is the
// pid AND the deadline; a marker past it is not honoured by a new call.
Deadline int64 `json:"deadline"`
}
// RefreshMarkerPath is where the marker lives for this config: the DataDir
// the PID file is in. The script resolves the same directory by the same
// rule (PAD_DB_PATH's directory, else PAD_DATA_DIR, else ~/.pad); neither
// the cwd nor the linked workspace takes part, so every CLI on the box finds
// the one marker.
func RefreshMarkerPath(cfg *config.Config) string {
return filepath.Join(cfg.DataDir, RefreshMarkerName)
}
// refreshState is what a read of the marker says about THIS config's server.
type refreshState int
const (
// refreshNone: no marker, an unreadable one, another port's, a dead
// writer's, or one past its deadline. Nothing to wait for.
refreshNone refreshState = iota
// refreshLive: a refresh of this port is in progress.
refreshLive
// refreshExpired: the marker's writer is alive but its deadline has
// passed. Only meaningful to a caller that was already waiting.
refreshExpired
)
// Package-level so the tests can drive the clock and the pid probe. Neither
// is swapped outside tests.
var (
refreshNow = time.Now
refreshOwnerGone = processIsGone
)
func readRefreshMarker(cfg *config.Config) (refreshMarker, refreshState) {
data, err := os.ReadFile(RefreshMarkerPath(cfg))
if err != nil {
return refreshMarker{}, refreshNone
}
var m refreshMarker
// An unparseable marker is read as absent, not as live: it cannot name a
// writer whose liveness bounds it, so honouring it could block auto-start
// for ever. The script writes it by rename, so a half-written file is
// never observed.
if err := json.Unmarshal(data, &m); err != nil || m.PID <= 0 || m.Port <= 0 {
return refreshMarker{}, refreshNone
}
if m.Port != cfg.Port {
return m, refreshNone
}
if refreshOwnerGone(m.PID) {
return m, refreshNone
}
if refreshNow().Unix() >= m.Deadline {
return m, refreshExpired
}
return m, refreshLive
}
// refreshPoll is how often a waiting call re-reads the marker and the health
// probe.
var refreshPoll = 200 * time.Millisecond
// awaitRefresh defers to a refresh in progress on this config's port. It
// returns nil when there is nothing to wait for, or when the server answered
// during the wait; the caller re-probes health and spawns only if it still
// has to. It returns an error, and the caller must NOT spawn, when the
// refresh's deadline passes while its writer is still alive: the refresh is
// still acting on the port, and an auto-start there is the defect.
func awaitRefresh(cfg *config.Config) error {
if _, st := readRefreshMarker(cfg); st != refreshLive {
// An expired marker found by a call that was not waiting is not
// honoured: pid AND deadline is the liveness rule.
return nil
}
for {
if isServerHealthy(cfg.Host, cfg.Port) {
return nil
}
time.Sleep(refreshPoll)
cur, st := readRefreshMarker(cfg)
switch st {
case refreshNone:
// Removed, or its writer died: the refresh is over, and if
// nothing answers now an auto-start is legitimate again.
return nil
case refreshExpired:
return fmt.Errorf(
"a server refresh (pid %d) is in progress on port %d and the server has not answered by its deadline; "+
"not auto-starting over it. Check that refresh's output; if it is stuck, stop it (kill %d) and retry. "+
"Marker: %s",
cur.PID, cur.Port, cur.PID, RefreshMarkerPath(cfg))
}
}
}