diff --git a/cmd/link.go b/cmd/link.go index 1ccefee..da71515 100644 --- a/cmd/link.go +++ b/cmd/link.go @@ -8,7 +8,6 @@ import ( "github.com/prvious/pv/internal/automation" "github.com/prvious/pv/internal/automation/steps" "github.com/prvious/pv/internal/config" - "github.com/prvious/pv/internal/daemon" "github.com/prvious/pv/internal/detection" "github.com/prvious/pv/internal/laravel" "github.com/prvious/pv/internal/phpenv" @@ -140,20 +139,10 @@ pv link --name=myapp ~/Code/myapp`, fmt.Fprintf(os.Stderr, " %s %s\n", ui.Muted.Render("PHP"), ui.Green.Render(ctx.PHPVersion)) fmt.Fprintln(os.Stderr) - // Reload/restart server if needed. + // Signal the daemon to reconcile FrankenPHP instances. if server.IsRunning() { - needsRestart := phpVersion != "" && phpVersion != globalPHP - if needsRestart && daemon.IsLoaded() { - if err := daemon.Restart(); err != nil { - ui.Fail(fmt.Sprintf("Could not restart daemon: %v — run 'pv restart' manually", err)) - } - } else { - if err := server.ReconfigureServer(); err != nil { - ui.Fail(fmt.Sprintf("Could not reconfigure server: %v", err)) - } - if needsRestart { - ui.Subtle("Stop and restart the server to serve this project: pv stop && pv start") - } + if err := server.SignalDaemon(); err != nil { + ui.Subtle(fmt.Sprintf("Could not signal daemon: %v", err)) } } diff --git a/cmd/restart.go b/cmd/restart.go index 0a1cd6e..5adac5c 100644 --- a/cmd/restart.go +++ b/cmd/restart.go @@ -15,21 +15,20 @@ var restartCmd = &cobra.Command{ GroupID: "server", Short: "Restart or reload the pv server", RunE: func(cmd *cobra.Command, args []string) error { - // Daemon mode — delegate to daemon:restart. if daemon.IsLoaded() { return daemoncmds.RunRestart() } - // Foreground mode — reload config via admin API. if !server.IsRunning() { return fmt.Errorf("pv is not running") } - return ui.Step("Reloading server configuration...", func() (string, error) { - if err := server.ReconfigureServer(); err != nil { - return "", fmt.Errorf("reconfigure failed: %w", err) + // Foreground mode — signal reconcile via SIGHUP. + return ui.Step("Reconciling server...", func() (string, error) { + if err := server.SignalDaemon(); err != nil { + return "", fmt.Errorf("cannot signal server: %w", err) } - return "Configuration reloaded", nil + return "Server reconciled", nil }) }, } diff --git a/cmd/unlink.go b/cmd/unlink.go index c0b0a18..6181896 100644 --- a/cmd/unlink.go +++ b/cmd/unlink.go @@ -8,7 +8,6 @@ import ( "github.com/prvious/pv/internal/caddy" "github.com/prvious/pv/internal/certs" "github.com/prvious/pv/internal/config" - "github.com/prvious/pv/internal/daemon" "github.com/prvious/pv/internal/registry" "github.com/prvious/pv/internal/server" "github.com/prvious/pv/internal/ui" @@ -53,7 +52,6 @@ pv unlink`, return fmt.Errorf("project %q is not linked", name) } projectPath := project.Path - projectPHP := project.PHP if err := reg.Remove(name); err != nil { return err @@ -91,28 +89,10 @@ pv unlink`, fmt.Fprintln(os.Stderr) ui.Success(fmt.Sprintf("Unlinked %s", ui.Accent.Bold(true).Render(domain))) + // Signal the daemon to reconcile — it will stop orphaned secondaries. if server.IsRunning() { - // Check if unlinking this project orphans a secondary FrankenPHP - // (no remaining projects use its PHP version). - globalPHP := "" - if settings != nil { - globalPHP = settings.Defaults.PHP - } - hadSecondary := projectPHP != "" && projectPHP != globalPHP - versionOrphaned := hadSecondary && !caddy.ActiveVersions(reg.List(), globalPHP)[projectPHP] - - if versionOrphaned && daemon.IsLoaded() { - // Daemon mode: full process restart so the relaunched server no longer spawns the unneeded secondary. - if err := daemon.Restart(); err != nil { - ui.Fail(fmt.Sprintf("Could not restart daemon: %v — run 'pv restart' manually", err)) - } - } else { - if err := server.ReconfigureServer(); err != nil { - ui.Fail(fmt.Sprintf("Could not reconfigure server: %v", err)) - } - if versionOrphaned { - ui.Subtle("Stop and restart the server to clean up unused PHP processes: pv stop && pv start") - } + if err := server.SignalDaemon(); err != nil { + ui.Subtle(fmt.Sprintf("Could not signal daemon: %v", err)) } } diff --git a/docs/superpowers/plans/2026-03-27-server-reconcile.md b/docs/superpowers/plans/2026-03-27-server-reconcile.md new file mode 100644 index 0000000..90146eb --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-server-reconcile.md @@ -0,0 +1,493 @@ +# Server Reconcile Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement a reconcile-based server manager so that PHP version changes (via pv.yml, pv link, pv unlink) automatically start/stop secondary FrankenPHP instances without requiring daemon restarts. + +**Architecture:** A `ServerManager` struct in `internal/server/manager.go` owns all FrankenPHP instances and exposes a `Reconcile()` method that reads configs from disk, diffs running instances against needed instances, and starts/stops accordingly. CLI commands send SIGHUP to the daemon to trigger reconciliation. The daemon's event loop handles SIGHUP alongside SIGINT/SIGTERM. + +**Tech Stack:** Go, fsnotify (existing watcher), FrankenPHP/Caddy (existing), Unix signals. + +**Spec:** `docs/superpowers/specs/2026-03-27-server-reconcile-design.md` + +--- + +## Task 1: Create ServerManager with Reconcile() + +**Files:** +- Create: `internal/server/manager.go` + +This is the core. A new file with the `ServerManager` struct, `Reconcile()`, and `Shutdown()`. + +- [ ] **Step 1: Create `internal/server/manager.go`** + +```go +package server + +import ( + "fmt" + "os" + "sync" + + "github.com/prvious/pv/internal/caddy" + "github.com/prvious/pv/internal/config" + "github.com/prvious/pv/internal/registry" +) + +// ServerManager owns the main and secondary FrankenPHP instances. +// Reconcile() is the single entry point for syncing running instances +// against the current config state on disk. +type ServerManager struct { + mu sync.Mutex + main *FrankenPHP + secondaries map[string]*FrankenPHP // version -> instance +} + +// NewServerManager creates a manager with the given main FrankenPHP instance. +func NewServerManager(main *FrankenPHP) *ServerManager { + return &ServerManager{ + main: main, + secondaries: make(map[string]*FrankenPHP), + } +} + +// Reconcile reads configs from disk, regenerates Caddyfiles, diffs running +// secondary instances against what's needed, starts missing ones, stops +// unneeded ones, restarts crashed ones, and reloads the main FrankenPHP. +func (m *ServerManager) Reconcile() error { + m.mu.Lock() + defer m.mu.Unlock() + + settings, err := config.LoadSettings() + if err != nil { + return fmt.Errorf("reconcile: load settings: %w", err) + } + globalPHP := settings.Defaults.PHP + + reg, err := registry.Load() + if err != nil { + return fmt.Errorf("reconcile: load registry: %w", err) + } + + // Regenerate all Caddyfiles from current config state. + if err := caddy.GenerateAllConfigs(reg.List(), globalPHP); err != nil { + return fmt.Errorf("reconcile: generate configs: %w", err) + } + + // Compute which secondary versions are needed. + needed := caddy.ActiveVersions(reg.List(), globalPHP) + + // Stop secondaries that are no longer needed. + for version, fp := range m.secondaries { + if !needed[version] { + fmt.Fprintf(os.Stderr, "Reconcile: stopping FrankenPHP for PHP %s (no longer needed)\n", version) + fp.Stop() + delete(m.secondaries, version) + } + } + + // Start missing or crashed secondaries. + for version := range needed { + fp, exists := m.secondaries[version] + + // Check if existing instance has crashed. + if exists { + select { + case <-fp.Done(): + // Process exited — remove and re-create. + fmt.Fprintf(os.Stderr, "Reconcile: FrankenPHP for PHP %s crashed, restarting\n", version) + delete(m.secondaries, version) + exists = false + default: + // Still running, nothing to do. + } + } + + if !exists { + port := config.PortForVersion(version) + fmt.Fprintf(os.Stderr, "Reconcile: starting FrankenPHP for PHP %s on port %d\n", version, port) + newFP, err := StartVersionFrankenPHP(version) + if err != nil { + fmt.Fprintf(os.Stderr, "Reconcile: cannot start FrankenPHP for PHP %s: %v\n", version, err) + continue + } + m.secondaries[version] = newFP + } + } + + // Reload the main FrankenPHP to pick up new Caddyfile. + if err := Reload(); err != nil { + return fmt.Errorf("reconcile: reload main FrankenPHP: %w", err) + } + + return nil +} + +// Shutdown stops all secondary FrankenPHP instances. +// The main instance is stopped separately via its own defer in Start(). +func (m *ServerManager) Shutdown() { + m.mu.Lock() + defer m.mu.Unlock() + + for version, fp := range m.secondaries { + fmt.Fprintf(os.Stderr, "Stopping FrankenPHP for PHP %s\n", version) + fp.Stop() + delete(m.secondaries, version) + } +} + +// RunningVersions returns the set of PHP versions with active secondary instances. +// Used for testing and diagnostics. +func (m *ServerManager) RunningVersions() []string { + m.mu.Lock() + defer m.mu.Unlock() + + var versions []string + for v := range m.secondaries { + versions = append(versions, v) + } + return versions +} +``` + +- [ ] **Step 2: Build and verify** + +```bash +go build ./... +go vet ./... +``` + +- [ ] **Step 3: Commit** + +```bash +git add internal/server/manager.go +git commit -m "Add ServerManager with Reconcile for secondary FrankenPHP lifecycle + +ServerManager owns all FrankenPHP instances and provides a single +Reconcile() method that reads configs from disk, diffs running +instances against what's needed, and starts/stops accordingly. +Handles crashed instance detection and restart." +``` + +--- + +## Task 2: Add SignalDaemon() and SIGHUP handler + +**Files:** +- Modify: `internal/server/process.go` + +Add `SignalDaemon()` for CLI commands and refactor the event loop to handle SIGHUP. + +- [ ] **Step 1: Add `SignalDaemon()` function** + +Add to `internal/server/process.go`: + +```go +// SignalDaemon sends SIGHUP to the running daemon process, triggering a +// reconciliation of FrankenPHP instances. Safe to call when daemon is not +// running (returns nil). +func SignalDaemon() error { + pid, err := ReadPID() + if err != nil { + return nil // daemon not running + } + proc, err := os.FindProcess(pid) + if err != nil { + return nil + } + // Signal 0 first to check if process exists. + if proc.Signal(syscall.Signal(0)) != nil { + return nil // process doesn't exist + } + return proc.Signal(syscall.SIGHUP) +} +``` + +- [ ] **Step 2: Build and verify** + +```bash +go build ./... +go vet ./... +``` + +- [ ] **Step 3: Commit** + +```bash +git add internal/server/process.go +git commit -m "Add SignalDaemon() to send SIGHUP to running daemon" +``` + +--- + +## Task 3: Refactor Start() to use ServerManager + +**Files:** +- Modify: `internal/server/process.go` + +This is the biggest change. Replace inline secondary management with the manager, add SIGHUP to the event loop, and wire the watcher to call `Reconcile()`. + +- [ ] **Step 1: Add package-level manager variable** + +Add near the top of `process.go`, next to `activeWatcher`: + +```go +// manager holds the server manager for FrankenPHP instances. +// Set during Start(), used by the watcher and SIGHUP handler. +var manager *ServerManager +``` + +- [ ] **Step 2: Refactor `Start()` to use manager** + +Replace the inline secondary startup (lines 89-107) and the `waitForEvent` call with: + +1. After starting main FrankenPHP, create the manager: + ```go + manager = NewServerManager(mainFP) + defer manager.Shutdown() + ``` + +2. Call `Reconcile()` instead of inline secondary startup: + ```go + if err := manager.Reconcile(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: initial reconcile: %v\n", err) + } + ``` + +3. Remove the local `secondaries` variable and the `defer` that stops them. + +4. Remove the `caddy.GenerateAllConfigs()` call at the top of `Start()` — `Reconcile()` handles it now. + +5. Add SIGHUP to signal handler: + ```go + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + ``` + +- [ ] **Step 3: Simplify `waitForEvent()`** + +Replace the current `waitForEvent` that watches a static secondaries slice. The new version only watches main FrankenPHP, DNS, and signals: + +```go +func waitForEvent(sigCh chan os.Signal, dnsErr chan error, mainFP *FrankenPHP) error { + for { + select { + case sig := <-sigCh: + if sig == syscall.SIGHUP { + fmt.Fprintf(os.Stderr, "Received SIGHUP, reconciling...\n") + if err := manager.Reconcile(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: reconcile failed: %v\n", err) + } + continue + } + fmt.Fprintf(os.Stderr, "\nReceived %s, shutting down...\n", sig) + return nil + case err := <-dnsErr: + if err != nil { + return fmt.Errorf("DNS server failed: %w", err) + } + return fmt.Errorf("DNS server exited unexpectedly") + case err := <-mainFP.Done(): + if err != nil { + return fmt.Errorf("FrankenPHP exited unexpectedly: %w", err) + } + return fmt.Errorf("FrankenPHP exited unexpectedly") + } + } +} +``` + +- [ ] **Step 4: Update `handleWatcherEvents()` to call `Reconcile()`** + +Replace `ReconfigureServer()` call on line 298 with `manager.Reconcile()`. + +- [ ] **Step 5: Remove `ReconfigureServer()`** + +Delete the `ReconfigureServer()` function entirely (lines 193-213). It's replaced by `manager.Reconcile()` inside the daemon and `SignalDaemon()` from CLI. + +- [ ] **Step 6: Build and run all tests** + +```bash +go build ./... +go vet ./... +go test ./... +``` + +- [ ] **Step 7: Commit** + +```bash +git add internal/server/process.go +git commit -m "Refactor Start() to use ServerManager with SIGHUP reconcile + +Replace inline secondary management with ServerManager.Reconcile(). +Add SIGHUP handler to event loop — CLI commands send SIGHUP to +trigger reconciliation. Remove ReconfigureServer() — replaced by +Reconcile() inside daemon and SignalDaemon() from CLI." +``` + +--- + +## Task 4: Update CLI commands to use SignalDaemon() + +**Files:** +- Modify: `cmd/link.go` +- Modify: `cmd/unlink.go` +- Modify: `cmd/restart.go` +- Modify: `internal/commands/php/use.go` + +- [ ] **Step 1: Simplify `cmd/link.go`** + +Replace lines 143-158 (the restart/reconfigure dance) with: + +```go +// Signal the daemon to reconcile FrankenPHP instances. +if server.IsRunning() { + if err := server.SignalDaemon(); err != nil { + ui.Subtle(fmt.Sprintf("Could not signal daemon: %v", err)) + } +} +``` + +Remove the `daemon` import and `needsRestart` logic — no longer needed. + +- [ ] **Step 2: Simplify `cmd/unlink.go`** + +Replace lines 94-117 (the orphan-detection + restart dance) with: + +```go +// Signal the daemon to reconcile — it will stop orphaned secondaries. +if server.IsRunning() { + if err := server.SignalDaemon(); err != nil { + ui.Subtle(fmt.Sprintf("Could not signal daemon: %v", err)) + } +} +``` + +Remove the orphan-detection logic, `caddy.ActiveVersions` import, `globalPHP` lookup for the orphan check, etc. + +- [ ] **Step 3: Simplify `cmd/restart.go`** + +Replace the dual foreground/daemon logic with: always do a full restart. + +```go +RunE: func(cmd *cobra.Command, args []string) error { + if daemon.IsLoaded() { + return daemoncmds.RunRestart() + } + + if !server.IsRunning() { + return fmt.Errorf("pv is not running") + } + + return ui.Step("Restarting server...", func() (string, error) { + // Foreground mode: send SIGHUP for a reconcile. + // For a true restart, user should pv stop && pv start. + if err := server.SignalDaemon(); err != nil { + return "", fmt.Errorf("cannot signal server: %w", err) + } + return "Server configuration reconciled", nil + }) +}, +``` + +Remove the `server.ReconfigureServer` import reference. + +- [ ] **Step 4: Simplify `internal/commands/php/use.go`** + +Replace lines 49-60 with: always full daemon restart since the main binary changes. + +```go +// The global PHP binary changed — daemon needs full restart. +if oldV != version && server.IsRunning() { + if daemon.IsLoaded() { + if err := daemon.Restart(); err != nil { + ui.Fail(fmt.Sprintf("Could not restart daemon: %v — run 'pv restart' manually", err)) + } else { + ui.Success("Daemon restarted with new PHP version") + } + } else { + ui.Subtle("Server is running in foreground — restart required.") + ui.Subtle("Run: pv stop && pv start") + } +} +``` + +Remove the `daemon.SyncIfNeeded` / plist sync logic. + +- [ ] **Step 5: Build and run all tests** + +```bash +go build ./... +go vet ./... +go test ./... +``` + +- [ ] **Step 6: Commit** + +```bash +git add cmd/link.go cmd/unlink.go cmd/restart.go internal/commands/php/use.go +git commit -m "Simplify CLI commands to use SignalDaemon() for reconcile + +- pv link: 15 lines of restart logic → SignalDaemon() +- pv unlink: 23 lines of orphan detection → SignalDaemon() +- pv restart: always full restart in daemon mode, SIGHUP in foreground +- pv php:use: always full daemon restart (main binary changes)" +``` + +--- + +## Task 5: Add service commands SignalDaemon() for Caddy reload + +**Files:** +- Modify: `internal/commands/service/add.go` +- Modify: `internal/commands/service/remove.go` +- Modify: `internal/commands/service/destroy.go` + +Service commands generate service site configs but currently never tell FrankenPHP to reload. The daemon needs to pick up the new routes. + +- [ ] **Step 1: Add SignalDaemon() after service config generation** + +In each of the three files, after the `caddy.GenerateServiceSiteConfigs(reg)` call, add: + +```go +// Signal daemon to reload and pick up new service routes. +if server.IsRunning() { + _ = server.SignalDaemon() +} +``` + +Add import: `"github.com/prvious/pv/internal/server"` + +- [ ] **Step 2: Build and verify** + +```bash +go build ./... +go vet ./... +go test ./... +``` + +- [ ] **Step 3: Commit** + +```bash +git add internal/commands/service/add.go internal/commands/service/remove.go internal/commands/service/destroy.go +git commit -m "Signal daemon after service config changes for Caddy reload + +Service add/remove/destroy generate service site configs but never +told FrankenPHP to reload. Now send SIGHUP so the daemon reconciles +and the main FrankenPHP picks up new service routes." +``` + +--- + +## Parallelization Guide + +**Task 1** must come first (creates manager.go). + +**Task 2** must come after Task 1 (adds SignalDaemon, depends on same file). + +**Task 3** must come after Task 2 (refactors Start to use manager). + +**Tasks 4 and 5** can run in parallel after Task 3 (they touch different files). + +``` +Task 1 → Task 2 → Task 3 → Task 4 (parallel) + → Task 5 (parallel) +``` diff --git a/docs/superpowers/specs/2026-03-27-server-reconcile-design.md b/docs/superpowers/specs/2026-03-27-server-reconcile-design.md new file mode 100644 index 0000000..5b38369 --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-server-reconcile-design.md @@ -0,0 +1,194 @@ +# Server Reconcile Architecture + +## Problem + +When a `pv.yml` file changes to a non-global PHP version, the watcher updates the registry and regenerates Caddyfiles, but no secondary FrankenPHP instance is started for the new version — resulting in a 502 error. Secondary instances are only started at daemon boot and never managed afterward. The same problem affects `pv link` with a non-global PHP version. + +## Core Insight: Two Process Boundaries + +The CLI process (`pv link`, `pv unlink`, etc.) and the daemon are separate OS processes. The CLI owns user interaction, config generation, and disk writes. The daemon owns running FrankenPHP instances. They communicate through: + +1. **Shared disk state** — registry.json, Caddyfiles, settings (configs are the source of truth) +2. **SIGHUP signal** — CLI tells daemon "configs changed, reconcile yourself" +3. **FrankenPHP admin API** — `frankenphp reload` (already used today) + +## Design + +### New: `ServerManager` (inside daemon process) + +A package-level struct in `internal/server/` that owns all FrankenPHP instances: + +```go +type ServerManager struct { + mu sync.Mutex + main *FrankenPHP + secondaries map[string]*FrankenPHP // version -> instance +} +``` + +### `Reconcile()` — the daemon's single reconciliation function + +Called internally by the daemon when SIGHUP is received or on boot. NOT called from CLI processes. + +Steps: +1. Load settings + registry from disk (source of truth) +2. Regenerate all Caddyfiles from current state +3. Compute needed secondary versions via `caddy.ActiveVersions()` +4. Diff against running secondaries: + - **Missing versions** → `StartVersionFrankenPHP(version)`, add to map + - **Unneeded versions** → `fp.Stop()`, remove from map + - **Crashed instances** (in map but process dead) → restart +5. Reload main FrankenPHP (picks up new Caddyfile) + +### SIGHUP Handler + +The daemon's `Start()` adds SIGHUP to its signal listener. On SIGHUP, it calls `Reconcile()`: + +```go +signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + +// In event loop: +case sig := <-sigCh: + if sig == syscall.SIGHUP { + manager.Reconcile() + continue // don't exit, keep running + } + // SIGINT/SIGTERM → shutdown +``` + +### Helper: `SignalDaemon()` + +A new exported function that CLI commands call to send SIGHUP to the running daemon: + +```go +func SignalDaemon() error { + pid, err := ReadPID() + if err != nil { + return nil // daemon not running, nothing to signal + } + proc, err := os.FindProcess(pid) + if err != nil { + return nil + } + return proc.Signal(syscall.SIGHUP) +} +``` + +## Who triggers what + +| Trigger | What happens | Why | +|---------|-------------|-----| +| **`pv link`** | CLI: pipeline + save configs to disk. Then `SignalDaemon()` (SIGHUP). | Daemon reconciles — starts secondary if new PHP version needed. | +| **`pv unlink`** | CLI: remove site config, save registry. Then `SignalDaemon()`. | Daemon reconciles — stops orphaned secondary if no projects use that version. | +| **`pv restart`** | Full daemon restart (`launchctl kickstart -k` or stop+start). | Clean slate — main binary reloaded, all secondaries rebuilt from configs. | +| **`pv php:use`** | CLI: update settings. Full daemon restart. | Main FrankenPHP binary changes — need process restart, not just config reload. | +| **Watcher** (pv.yml change) | Direct `Reconcile()` inside daemon. | Already in daemon process — no signal needed. | +| **Daemon boot** | `Reconcile()` after main FrankenPHP starts. | Initial secondary startup from config state. | +| **`pv service:*`** | CLI: `caddy.GenerateServiceSiteConfigs()`. Then `SignalDaemon()`. | Service routes need main FrankenPHP reload. No secondary changes. | + +## Changes to existing commands + +### `cmd/link.go` (lines 143-158) + +Before: +```go +if server.IsRunning() { + needsRestart := phpVersion != "" && phpVersion != globalPHP + if needsRestart && daemon.IsLoaded() { + daemon.Restart() + } else { + server.ReconfigureServer() + if needsRestart { + ui.Subtle("restart required...") + } + } +} +``` + +After: +```go +if server.IsRunning() { + server.SignalDaemon() +} +``` + +### `cmd/unlink.go` (lines 94-117) + +Before: Complex orphan detection + conditional restart/reconfigure. + +After: +```go +if server.IsRunning() { + server.SignalDaemon() +} +``` + +### `cmd/restart.go` + +Before: Foreground mode calls `ReconfigureServer()`. Daemon mode calls `daemon:restart`. + +After: Always full restart — `daemon.Restart()` if daemon mode, `stop+start` if foreground. + +### `internal/commands/php/use.go` (lines 49-60) + +Before: Sync plist + restart daemon, or tell user to restart. + +After: Always full daemon restart (main binary changed). + +### `internal/server/process.go` — watcher handler + +Before: `ReconfigureServer()` (only reloads config, no secondary management). + +After: `manager.Reconcile()` (reloads config AND manages secondaries). + +### `internal/server/process.go` — `Start()` + +Before: Inline secondary startup with local `secondaries` variable. + +After: +``` +Start(): + write PID + load configs + start DNS + start main FrankenPHP → store in manager.main + manager.Reconcile() → generates Caddyfiles, starts needed secondaries + start watcher → on change: update registry, Reconcile() + register SIGHUP → on signal: Reconcile() + start Colima (background) + start package updater + event loop (SIGINT/SIGTERM → shutdown, SIGHUP → reconcile) +``` + +### `ReconfigureServer()` — removed + +Replaced by `Reconcile()` inside the daemon and `SignalDaemon()` from CLI. + +## Changes to `waitForEvent()` + +Currently watches a static slice of secondaries for crashes. With the manager: +- Main FrankenPHP crash → fatal, daemon exits (same as today) +- Secondary crash → detected by `Reconcile()` on next trigger (watcher, SIGHUP, or we add a goroutine that watches the Done() channels and triggers Reconcile) +- DNS error → fatal, daemon exits (same as today) + +## Files changed + +| File | Change | +|------|--------| +| `internal/server/manager.go` | NEW — `ServerManager`, `Reconcile()`, `Shutdown()` | +| `internal/server/process.go` | Refactor `Start()` to use manager. Add SIGHUP handler. Add `SignalDaemon()`. Remove `ReconfigureServer()`. Simplify `waitForEvent()`. | +| `cmd/link.go` | Replace restart/reconfigure dance with `SignalDaemon()` | +| `cmd/unlink.go` | Replace orphan-detection + restart dance with `SignalDaemon()` | +| `cmd/restart.go` | Always full restart | +| `internal/commands/php/use.go` | Always full daemon restart | +| `internal/server/frankenphp.go` | No changes — `StartVersionFrankenPHP()` stays as-is | + +## What does NOT change + +- DNS server lifecycle (started once in Start, stopped on shutdown) +- Main FrankenPHP startup (started once in Start) +- Watcher logic for detecting pv.yml changes +- Colima boot logic +- `caddy.GenerateAllConfigs()` / `caddy.ActiveVersions()` +- Service commands (only touch service Caddy configs) +- Automation pipeline in `pv link` (still runs in CLI process) diff --git a/internal/commands/php/use.go b/internal/commands/php/use.go index bd07f85..ff2dcf4 100644 --- a/internal/commands/php/use.go +++ b/internal/commands/php/use.go @@ -46,17 +46,18 @@ pv php:use 8.3`, ui.Success(fmt.Sprintf("Global PHP set to %s", ui.Green.Bold(true).Render(version))) } - // If daemon is running, sync the plist and restart. - if oldV != version && daemon.IsLoaded() { - cfg := daemon.DefaultPlistConfig() - if err := daemon.SyncIfNeeded(cfg); err != nil { - ui.Fail(fmt.Sprintf("Cannot sync daemon plist: %v", err)) + // The global PHP binary changed — daemon needs full restart. + if oldV != version && server.IsRunning() { + if daemon.IsLoaded() { + if err := daemon.Restart(); err != nil { + ui.Fail(fmt.Sprintf("Could not restart daemon: %v — run 'pv restart' manually", err)) + } else { + ui.Success("Daemon restarted with new PHP version") + } } else { - ui.Success("Daemon restarted with new PHP version") + ui.Subtle("Server is running in foreground — restart required.") + ui.Subtle("Run: pv stop && pv start") } - } else if oldV != version && server.IsRunning() { - ui.Subtle("Server is running — restart required for changes to take effect.") - ui.Subtle("Run: pv restart") } return nil diff --git a/internal/commands/service/add.go b/internal/commands/service/add.go index 2448ea5..744ff65 100644 --- a/internal/commands/service/add.go +++ b/internal/commands/service/add.go @@ -10,6 +10,7 @@ import ( "github.com/prvious/pv/internal/config" "github.com/prvious/pv/internal/container" "github.com/prvious/pv/internal/registry" + "github.com/prvious/pv/internal/server" "github.com/prvious/pv/internal/services" "github.com/prvious/pv/internal/ui" "github.com/spf13/cobra" @@ -135,6 +136,11 @@ pv service:add postgres 16`, if err := caddy.GenerateServiceSiteConfigs(reg); err != nil { ui.Subtle(fmt.Sprintf("Could not generate service site config: %v", err)) } + if server.IsRunning() { + if err := server.SignalDaemon(); err != nil { + ui.Subtle(fmt.Sprintf("Could not signal daemon: %v", err)) + } + } // Print connection details. port := svc.Port(version) diff --git a/internal/commands/service/destroy.go b/internal/commands/service/destroy.go index 0840c83..e2ed904 100644 --- a/internal/commands/service/destroy.go +++ b/internal/commands/service/destroy.go @@ -9,6 +9,7 @@ import ( "github.com/prvious/pv/internal/config" "github.com/prvious/pv/internal/container" "github.com/prvious/pv/internal/registry" + "github.com/prvious/pv/internal/server" "github.com/prvious/pv/internal/services" "github.com/prvious/pv/internal/ui" "github.com/spf13/cobra" @@ -89,6 +90,11 @@ var destroyCmd = &cobra.Command{ if err := caddy.GenerateServiceSiteConfigs(reg); err != nil { ui.Subtle(fmt.Sprintf("Could not regenerate service site configs: %v", err)) } + if server.IsRunning() { + if err := server.SignalDaemon(); err != nil { + ui.Subtle(fmt.Sprintf("Could not signal daemon: %v", err)) + } + } if len(projects) > 0 { fmt.Fprintf(os.Stderr, " %s Unbound from: %s\n", diff --git a/internal/commands/service/remove.go b/internal/commands/service/remove.go index 1bb5142..1c46a9a 100644 --- a/internal/commands/service/remove.go +++ b/internal/commands/service/remove.go @@ -7,6 +7,7 @@ import ( "github.com/prvious/pv/internal/config" "github.com/prvious/pv/internal/container" "github.com/prvious/pv/internal/registry" + "github.com/prvious/pv/internal/server" "github.com/prvious/pv/internal/services" "github.com/prvious/pv/internal/ui" "github.com/spf13/cobra" @@ -79,6 +80,11 @@ var removeCmd = &cobra.Command{ if err := caddy.GenerateServiceSiteConfigs(reg); err != nil { ui.Subtle(fmt.Sprintf("Could not regenerate service site configs: %v", err)) } + if server.IsRunning() { + if err := server.SignalDaemon(); err != nil { + ui.Subtle(fmt.Sprintf("Could not signal daemon: %v", err)) + } + } // Determine data path for the message. _, version := services.ParseServiceKey(key) diff --git a/internal/server/manager.go b/internal/server/manager.go new file mode 100644 index 0000000..da71659 --- /dev/null +++ b/internal/server/manager.go @@ -0,0 +1,131 @@ +package server + +import ( + "fmt" + "os" + "strings" + "sync" + + "github.com/prvious/pv/internal/caddy" + "github.com/prvious/pv/internal/config" + "github.com/prvious/pv/internal/registry" +) + +// ServerManager owns the main and secondary FrankenPHP instances. +// Reconcile() is the single entry point for syncing running instances +// against the current config state on disk. +type ServerManager struct { + mu sync.Mutex + main *FrankenPHP + secondaries map[string]*FrankenPHP // version -> instance +} + +// NewServerManager creates a manager with the given main FrankenPHP instance. +func NewServerManager(main *FrankenPHP) *ServerManager { + return &ServerManager{ + main: main, + secondaries: make(map[string]*FrankenPHP), + } +} + +// Reconcile reads configs from disk, regenerates Caddyfiles, diffs running +// secondary instances against what's needed, starts missing ones, stops +// unneeded ones, restarts crashed ones, and reloads the main FrankenPHP. +func (m *ServerManager) Reconcile() error { + m.mu.Lock() + defer m.mu.Unlock() + + settings, err := config.LoadSettings() + if err != nil { + return fmt.Errorf("reconcile: load settings: %w", err) + } + globalPHP := settings.Defaults.PHP + + reg, err := registry.Load() + if err != nil { + return fmt.Errorf("reconcile: load registry: %w", err) + } + + // Regenerate all Caddyfiles from current config state. + if err := caddy.GenerateAllConfigs(reg.List(), globalPHP); err != nil { + return fmt.Errorf("reconcile: generate configs: %w", err) + } + + // Compute which secondary versions are needed. + needed := caddy.ActiveVersions(reg.List(), globalPHP) + + // Stop secondaries that are no longer needed. + for version, fp := range m.secondaries { + if !needed[version] { + fmt.Fprintf(os.Stderr, "Reconcile: stopping FrankenPHP for PHP %s (no longer needed)\n", version) + fp.Stop() + delete(m.secondaries, version) + } + } + + // Start missing or crashed secondaries. + var startErrors []string + for version := range needed { + fp, exists := m.secondaries[version] + + // Check if existing instance has crashed. + if exists { + select { + case <-fp.Done(): + // Process exited — remove and re-create. + fmt.Fprintf(os.Stderr, "Reconcile: FrankenPHP for PHP %s crashed, restarting\n", version) + delete(m.secondaries, version) + exists = false + default: + // Still running, nothing to do. + } + } + + if !exists { + port := config.PortForVersion(version) + fmt.Fprintf(os.Stderr, "Reconcile: starting FrankenPHP for PHP %s on port %d\n", version, port) + newFP, err := StartVersionFrankenPHP(version) + if err != nil { + fmt.Fprintf(os.Stderr, "Reconcile: cannot start FrankenPHP for PHP %s: %v\n", version, err) + startErrors = append(startErrors, fmt.Sprintf("PHP %s: %v", version, err)) + continue + } + m.secondaries[version] = newFP + } + } + + // Reload the main FrankenPHP to pick up new Caddyfile. + if err := Reload(); err != nil { + return fmt.Errorf("reconcile: reload main FrankenPHP: %w", err) + } + + if len(startErrors) > 0 { + return fmt.Errorf("reconcile: %d secondary instance(s) failed: %s", len(startErrors), strings.Join(startErrors, "; ")) + } + return nil +} + +// Shutdown stops all secondary FrankenPHP instances. +// The main instance is stopped separately via its own defer in Start(). +func (m *ServerManager) Shutdown() { + m.mu.Lock() + defer m.mu.Unlock() + + for version, fp := range m.secondaries { + fmt.Fprintf(os.Stderr, "Stopping FrankenPHP for PHP %s\n", version) + fp.Stop() + delete(m.secondaries, version) + } +} + +// RunningVersions returns the set of PHP versions with active secondary instances. +func (m *ServerManager) RunningVersions() []string { + m.mu.Lock() + defer m.mu.Unlock() + + var versions []string + for v := range m.secondaries { + versions = append(versions, v) + } + return versions +} diff --git a/internal/server/process.go b/internal/server/process.go index e8656d2..ead3cb7 100644 --- a/internal/server/process.go +++ b/internal/server/process.go @@ -25,6 +25,10 @@ import ( // activeWatcher holds the file watcher for pv.yml changes in linked projects. var activeWatcher *watcher.Watcher +// manager holds the ServerManager for FrankenPHP instances. +// Set during Start(), used by the watcher and SIGHUP handler. +var manager *ServerManager + // Start is the supervisor entry point. It writes a PID file, starts the DNS // server, the main FrankenPHP, and any needed secondary FrankenPHP instances, // then blocks until an OS signal or child exit. @@ -38,19 +42,18 @@ func Start(tld string) error { } defer removePID() - // Regenerate all caddy configs before starting. settings, err := config.LoadSettings() if err != nil { return fmt.Errorf("cannot load settings: %w", err) } - globalPHP := settings.Defaults.PHP reg, err := registry.Load() if err != nil { return fmt.Errorf("cannot load registry: %w", err) } - if err := caddy.GenerateAllConfigs(reg.List(), globalPHP); err != nil { + // Generate initial Caddyfiles so main FrankenPHP can start. + if err := caddy.GenerateAllConfigs(reg.List(), settings.Defaults.PHP); err != nil { return fmt.Errorf("cannot generate caddy configs: %w", err) } @@ -86,26 +89,17 @@ func Start(tld string) error { fmt.Fprintln(os.Stderr, "FrankenPHP started") fmt.Fprintf(os.Stderr, "Serving .%s domains on https (port 443) and http (port 80)\n", tld) - // Start secondary FrankenPHP instances for non-global PHP versions. - activeVersions := caddy.ActiveVersions(reg.List(), globalPHP) - var secondaries []*FrankenPHP - for version := range activeVersions { - port := config.PortForVersion(version) - fmt.Fprintf(os.Stderr, "Starting FrankenPHP for PHP %s on port %d...\n", version, port) - fp, err := StartVersionFrankenPHP(version) - if err != nil { - fmt.Fprintf(os.Stderr, "Warning: cannot start FrankenPHP for PHP %s: %v\n", version, err) - continue - } - secondaries = append(secondaries, fp) - fmt.Fprintf(os.Stderr, "FrankenPHP (PHP %s) started on port %d\n", version, port) - } + // Create the server manager and reconcile secondary instances. + manager = NewServerManager(mainFP) defer func() { - for _, fp := range secondaries { - fp.Stop() - } + manager.Shutdown() + manager = nil }() + if err := manager.Reconcile(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: initial reconcile: %v\n", err) + } + // Start file watcher for pv.yml changes in linked projects. projectWatcher, watcherErr := watcher.New() if watcherErr != nil { @@ -122,7 +116,7 @@ func Start(tld string) error { projectWatcher.Close() }() - go handleWatcherEvents(projectWatcher, globalPHP) + go handleWatcherEvents(projectWatcher) } // Boot Colima and recover service containers in the background. @@ -140,77 +134,50 @@ func Start(tld string) error { // Wait for signals or child exit. sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) defer signal.Stop(sigCh) - return waitForEvent(sigCh, dnsErr, mainFP, secondaries) + return waitForEvent(sigCh, dnsErr, mainFP) } -// waitForEvent blocks until a signal, DNS error, or any FrankenPHP process exits. -func waitForEvent(sigCh chan os.Signal, dnsErr chan error, mainFP *FrankenPHP, secondaries []*FrankenPHP) error { - // Since Go doesn't support dynamic select, we merge secondary done channels - // into a single channel. - merged := make(chan string, 1) // version string of the exited secondary - done := make(chan struct{}) - defer close(done) - - // Watch secondaries. - for _, fp := range secondaries { - go func(f *FrankenPHP) { - select { - case err := <-f.Done(): - if err != nil { - fmt.Fprintf(os.Stderr, "FrankenPHP (PHP %s) exited: %v\n", f.Version(), err) - } - select { - case merged <- f.Version(): - case <-done: +// waitForEvent blocks until a shutdown signal, DNS error, or main FrankenPHP exit. +// SIGHUP triggers a reconcile and continues the loop. +func waitForEvent(sigCh chan os.Signal, dnsErr chan error, mainFP *FrankenPHP) error { + for { + select { + case sig := <-sigCh: + if sig == syscall.SIGHUP { + fmt.Fprintf(os.Stderr, "Received SIGHUP, reconciling...\n") + if manager != nil { + func() { + defer func() { + if r := recover(); r != nil { + fmt.Fprintf(os.Stderr, "CRITICAL: reconcile panicked: %v\n", r) + } + }() + if err := manager.Reconcile(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: reconcile failed: %v\n", err) + } + }() } - case <-done: + continue } - }(fp) - } - - select { - case sig := <-sigCh: - fmt.Fprintf(os.Stderr, "\nReceived %s, shutting down...\n", sig) - return nil - case err := <-dnsErr: - if err != nil { - return fmt.Errorf("DNS server failed: %w", err) - } - return fmt.Errorf("DNS server exited unexpectedly") - case err := <-mainFP.Done(): - if err != nil { - return fmt.Errorf("FrankenPHP exited unexpectedly: %w", err) + fmt.Fprintf(os.Stderr, "\nReceived %s, shutting down...\n", sig) + return nil + case err := <-dnsErr: + if err != nil { + return fmt.Errorf("DNS server failed: %w", err) + } + return fmt.Errorf("DNS server exited unexpectedly") + case err := <-mainFP.Done(): + if err != nil { + return fmt.Errorf("FrankenPHP exited unexpectedly: %w", err) + } + return fmt.Errorf("FrankenPHP exited unexpectedly") } - return fmt.Errorf("FrankenPHP exited unexpectedly") - case v := <-merged: - return fmt.Errorf("secondary FrankenPHP (PHP %s) exited unexpectedly", v) } } -// ReconfigureServer regenerates all caddy configs and reloads the main FrankenPHP via its admin API. -// Called after pv link, pv unlink, pv restart (foreground mode), and watcher-triggered config changes. -func ReconfigureServer() error { - settings, err := config.LoadSettings() - if err != nil { - return err - } - - reg, err := registry.Load() - if err != nil { - return err - } - - // Regenerate all site configs and Caddyfiles. - if err := caddy.GenerateAllConfigs(reg.List(), settings.Defaults.PHP); err != nil { - return err - } - - // Reload the main FrankenPHP. - return Reload() -} // IsRunning checks if a pv supervisor process is currently running. func IsRunning() bool { @@ -226,6 +193,24 @@ func IsRunning() bool { return proc.Signal(syscall.Signal(0)) == nil } +// SignalDaemon sends SIGHUP to the running daemon process, triggering a +// reconciliation of FrankenPHP instances. Safe to call when daemon is not +// running (returns nil). +func SignalDaemon() error { + pid, err := ReadPID() + if err != nil { + return nil // daemon not running + } + proc, err := os.FindProcess(pid) + if err != nil { + return nil + } + if proc.Signal(syscall.Signal(0)) != nil { + return nil // process doesn't exist + } + return proc.Signal(syscall.SIGHUP) +} + // ReadPID reads the PID from the PID file. func ReadPID() (int, error) { data, err := os.ReadFile(config.PidFilePath()) @@ -246,8 +231,8 @@ func removePID() { } // handleWatcherEvents processes pv.yml change events, re-resolves PHP versions, -// updates the registry, and reconfigures the server when needed. -func handleWatcherEvents(w *watcher.Watcher, globalPHP string) { +// updates the registry, and triggers a reconcile to start/stop secondaries. +func handleWatcherEvents(w *watcher.Watcher) { for event := range w.Events() { reg, err := registry.Load() if err != nil { @@ -259,6 +244,13 @@ func handleWatcherEvents(w *watcher.Watcher, globalPHP string) { continue } + settings, err := config.LoadSettings() + if err != nil { + fmt.Fprintf(os.Stderr, "Watcher: cannot load settings: %v\n", err) + continue + } + globalPHP := settings.Defaults.PHP + var newPHP string switch event.Type { case watcher.ConfigChanged, watcher.ConfigDeleted: @@ -295,8 +287,10 @@ func handleWatcherEvents(w *watcher.Watcher, globalPHP string) { fmt.Fprintf(os.Stderr, "Watcher: cannot save registry: %v\n", err) continue } - if err := ReconfigureServer(); err != nil { - fmt.Fprintf(os.Stderr, "Watcher: cannot reconfigure server: %v\n", err) + if manager != nil { + if err := manager.Reconcile(); err != nil { + fmt.Fprintf(os.Stderr, "Watcher: reconcile failed: %v\n", err) + } } } }