mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 14:00:40 +00:00
d4ec587de9
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
131 lines
4.8 KiB
Go
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))
|
|
}
|
|
}
|
|
}
|