Files
OwnCord/Server/restart.go
T
J3vbandClaude Fable 5 6a26f2a839 fix(server): drain fully before the self-update/restore restart handoff (#1380)
* feat(server): supervisor detection and server.restart_mode config key

RunningUnderSupervisor detects systemd (INVOCATION_ID) and, best-effort,
NSSM (NSSM_SERVICE_NAME — 2.24 does not set it, so NSSM deployments set
the mode explicitly). server.restart_mode (auto|spawn|supervised, default
auto, env OWNCORD_SERVER_RESTART_MODE) selects how a self-restart hands
off after the server drains: exit for the supervisor to relaunch, or
spawn the replacement directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ngzj2Rx9UGC35uLHAfErMp

* fix(server): make the self-restart handoff drain fully before starting the successor

The update/restore/wizard restart previously spawned the replacement
while the old server was still serving, then SIGTERMed itself and
hard-exited after 10s. That design failed in every documented deployment
mode: under the shipped systemd unit the spawned child (same cgroup) was
killed when the old main process exited and Restart=on-failure never
relaunched a clean exit; on Windows the self-SIGTERM is unsupported and
silently dropped, so graceful shutdown never ran — hub.GracefulStop (the
only caller of LiveKitProcess.Stop) was skipped, orphaning livekit-server
on TCP 7880/UDP 50000-60000 and dropping queued event/audit batches; and
NSSM's relaunch raced the self-spawned replacement for the database lock.

Admin handlers now perform only the on-disk swap and request a restart
through an injected hook (admin.SetRestartHandoff). The main package's
restart coordinator cancels the parent of run()'s signal.NotifyContext —
the exact drain a SIGTERM triggers, on every platform — and after run()
has fully torn down (listeners closed, hub and LiveKit stopped, queues
flushed, DB closed and its lock released) main() performs the handoff:
spawn the replacement in spawn mode, or exit 0 for the supervisor in
supervised mode. A 90s backstop force-exits a wedged teardown; the
DB-lock and bind retries demote to safety nets.

A three-state guard (idle/busy/restart-pending) serializes update apply,
backup restore, and setup-wizard restarts against each other: concurrent
applies no longer race the same staged .new file or broadcast a spurious
update_aborted, and conflicting requests get 409 UPDATE_IN_PROGRESS /
RESTART_PENDING. The swap being free of process side effects also makes
the apply success path unit-testable for the first time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ngzj2Rx9UGC35uLHAfErMp

* fix(server): errno-based bind-conflict detection, ACME bind retry, LiveKit Pdeathsig

isAddrInUse now unwraps to the platform errno (EADDRINUSE; WSAEADDRINUSE
10048 on Windows) with the English strings kept only as fallback — the
string-only match never fired on localized Windows, silently disabling
the bind retry. The retry loop is extracted into serveWithBindRetry and
now also covers the ACME :80 challenge server, which previously gave up
on first conflict and stayed dead (breaking HTTP-01 renewals) until the
next restart. The .old-binary boot cleanup retries briefly for the
window where a spawn-mode predecessor has not fully exited. The
companion livekit-server gets Pdeathsig SIGKILL on Linux so a parent
killed without teardown (kill -9, OOM, backstop exit) cannot orphan it
with the voice ports held.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ngzj2Rx9UGC35uLHAfErMp

* docs(deploy): Restart=always unit and per-supervisor restart-mode guidance

Restart=always is what lets the deliberate clean exit after a
self-update/restore relaunch under systemd (systemctl stop is never
auto-restarted; failure exits behave as before). Deployment docs gain
the required NSSM AppEnvironmentExtra line, the Task Scheduler and
Docker restart-policy notes, and the new drain-then-handoff update flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ngzj2Rx9UGC35uLHAfErMp

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-16 08:25:40 +02:00

190 lines
6.8 KiB
Go

// Restart coordination for self-restarts (update apply, backup restore,
// setup wizard).
//
// The admin package never stops or spawns processes; it requests a restart
// through admin.SetRestartHandoff, which lands in restartCoordinator.Request
// here. Request cancels the parent context of run()'s signal.NotifyContext,
// so the process drains through the exact same graceful path a SIGTERM takes
// — including on Windows, where a process cannot deliver a signal to itself.
// Only after run() has fully torn down (HTTP listeners closed, hub and
// LiveKit stopped, queues flushed, database closed and its process lock
// released) does main() perform the handoff: spawn the replacement binary
// when self-managed, or just exit and let the process supervisor relaunch
// the service. The old process being completely gone before the successor
// starts is what makes the handoff deterministic — the DB-lock and bind
// retries in db/ and main.go survive only as safety nets.
package main
import (
"context"
"log/slog"
"os"
"path/filepath"
"time"
"github.com/owncord/server/syncutil"
"github.com/owncord/server/updater"
)
const (
restartModeSpawn = "spawn"
restartModeSupervised = "supervised"
// restartBackstopDelay bounds how long a requested restart may drain
// before the process force-exits (performing the handoff first). run()'s
// worst-case legitimate teardown is ≈55s — the 30s shutdown budget plus
// its sequential bounded defers — so 90s only ever fires on a genuinely
// wedged teardown. The successor's lock/bind retries absorb whatever a
// backstop exit leaves unreleased.
restartBackstopDelay = 90 * time.Second
)
// restartCoordinator owns the lifecycle of one restart request. It is
// created in main(), threaded into run() (as a parameter, so tests drive
// run() with their own instance), and consulted by main() again after run()
// returns.
type restartCoordinator struct {
ctx context.Context
cancel context.CancelFunc
backstopDelay time.Duration
onBackstop func()
mu syncutil.Mutex
requested bool
reason string
mode string
backstop *time.Timer
}
// newRestartCoordinator builds a coordinator whose Context() is the parent
// for run()'s signal.NotifyContext. onBackstop runs once if a requested
// restart's drain exceeds backstopDelay; production passes handoff+os.Exit,
// tests pass a recorder.
func newRestartCoordinator(backstopDelay time.Duration, onBackstop func()) *restartCoordinator {
ctx, cancel := context.WithCancel(context.Background())
return &restartCoordinator{
ctx: ctx,
cancel: cancel,
backstopDelay: backstopDelay,
onBackstop: onBackstop,
}
}
// Context is the parent context for run()'s signal handling: cancelling it
// (Request) is indistinguishable from a shutdown signal to everything
// downstream. A Request issued before run() reaches NotifyContext is safe —
// NotifyContext over an already-cancelled parent starts out done, and run()
// falls straight through to graceful teardown.
func (rc *restartCoordinator) Context() context.Context { return rc.ctx }
// SetMode records the resolved restart mode ("spawn"/"supervised") once
// config is loaded; Mode reads it back for the handoff.
func (rc *restartCoordinator) SetMode(mode string) {
rc.mu.Lock()
rc.mode = mode
rc.mu.Unlock()
}
func (rc *restartCoordinator) Mode() string {
rc.mu.Lock()
defer rc.mu.Unlock()
return rc.mode
}
// Request records a restart request and starts the drain. Idempotent: the
// first reason wins, duplicates are logged and dropped. Arms the backstop
// timer before cancelling so a wedged teardown can never outlive it.
func (rc *restartCoordinator) Request(reason string) {
rc.mu.Lock()
if rc.requested {
pending := rc.reason
rc.mu.Unlock()
slog.Info("restart already pending — duplicate request dropped",
"reason", reason, "pending_reason", pending)
return
}
rc.requested = true
rc.reason = reason
mode := rc.mode
if rc.onBackstop != nil {
rc.backstop = time.AfterFunc(rc.backstopDelay, rc.onBackstop)
}
rc.mu.Unlock()
slog.Info("restart requested — draining for handoff", "reason", reason, "mode", mode)
rc.cancel()
}
// Requested reports whether a restart was requested, and its reason.
func (rc *restartCoordinator) Requested() (reason string, ok bool) {
rc.mu.Lock()
defer rc.mu.Unlock()
return rc.reason, rc.requested
}
// disarm stops the backstop timer. main() calls it the moment run() returns:
// from there the handoff is in main()'s hands and a delayed force-exit would
// only race it.
func (rc *restartCoordinator) disarm() {
rc.mu.Lock()
if rc.backstop != nil {
rc.backstop.Stop()
rc.backstop = nil
}
rc.mu.Unlock()
}
// resolveRestartMode turns cfg.Server.RestartMode into the effective handoff
// mode. Explicit "spawn"/"supervised" win; "auto" (or empty, or an unknown
// value after a warning) detects: containers and supervised services exit
// for their supervisor/engine to relaunch, everything else spawns its own
// replacement.
func resolveRestartMode(cfgVal string, log *slog.Logger) string {
switch cfgVal {
case restartModeSpawn, restartModeSupervised:
return cfgVal
case "", "auto":
default:
log.Warn("unknown server.restart_mode, using auto detection",
"value", cfgVal, "valid", "auto|spawn|supervised")
}
if updater.RunningInContainer() || updater.RunningUnderSupervisor() {
return restartModeSupervised
}
return restartModeSpawn
}
// spawnReplacement is the replacement-process spawner, swappable in tests
// (which must not start real processes).
var spawnReplacement = updater.SpawnDetached
// performRestartHandoff completes a requested restart after run() has fully
// drained. In supervised mode the handoff IS the exit — the supervisor
// (systemd Restart=, NSSM AppExit, Docker restart policy) relaunches the
// service, now running the swapped binary. In spawn mode the replacement is
// started directly; every resource is already released, so the successor
// boots with no lock or port contention. A failed spawn leaves the server
// down — loudly logged; there is no hub left to notify clients through.
func performRestartHandoff(reason, mode string, log *slog.Logger) {
if mode == restartModeSupervised {
log.Info("restart: exiting for the supervisor to relaunch", "reason", reason, "mode", mode)
return
}
exePath, err := os.Executable()
if err != nil {
log.Error("restart: cannot determine executable path — manual restart required",
"reason", reason, "error", err)
return
}
if resolved, symErr := filepath.EvalSymlinks(exePath); symErr == nil {
exePath = resolved
}
if err := spawnReplacement(exePath, os.Args[1:]); err != nil {
log.Error("restart: spawning the replacement process FAILED — manual restart required",
"reason", reason, "error", err)
return
}
log.Info("restart: replacement process spawned", "reason", reason, "path", exePath)
}