133 lines
4.2 KiB
Go
133 lines
4.2 KiB
Go
// Package runfile manages running.json — the PID + port handshake the Electron
|
|
// main process uses to discover, health-check, and reap the daemon. The daemon
|
|
// writes it on startup and removes it on graceful shutdown. On startup the
|
|
// daemon also checks for a stale entry left by a crashed predecessor so it can
|
|
// fail fast instead of fighting over the port.
|
|
package runfile
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/processalive"
|
|
)
|
|
|
|
// Info is the on-disk handshake payload.
|
|
type Info struct {
|
|
// PID is the daemon process id.
|
|
PID int `json:"pid"`
|
|
// Port is the loopback port the daemon bound.
|
|
Port int `json:"port"`
|
|
// StartedAt is when the daemon came up (RFC 3339).
|
|
StartedAt time.Time `json:"startedAt"`
|
|
// Owner is "app" when the desktop Electron app spawned this daemon; empty
|
|
// for a headless `ao start` daemon. Used by the app to decide whether to
|
|
// hold a supervisor link on attach (app-owned: re-link; headless: skip so
|
|
// the daemon stays persistent across app quit).
|
|
Owner string `json:"owner,omitempty"`
|
|
}
|
|
|
|
// Write atomically writes running.json at path, creating parent directories
|
|
// as needed. It writes to a temp file in the same directory and then calls
|
|
// atomicReplace — POSIX rename(2) on Unix, MoveFileEx with
|
|
// MOVEFILE_REPLACE_EXISTING on Windows — so a reader never observes a
|
|
// partial file and a stale running.json from a crashed predecessor is
|
|
// overwritten without an intermediate "no file" window.
|
|
func Write(path string, info Info) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
|
return fmt.Errorf("create run-file dir: %w", err)
|
|
}
|
|
data, err := json.MarshalIndent(info, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal run-file: %w", err)
|
|
}
|
|
data = append(data, '\n')
|
|
|
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".running-*.json")
|
|
if err != nil {
|
|
return fmt.Errorf("create temp run-file: %w", err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
defer func() { _ = os.Remove(tmpName) }() // no-op once the rename succeeds
|
|
|
|
if _, err := tmp.Write(data); err != nil {
|
|
_ = tmp.Close()
|
|
return fmt.Errorf("write temp run-file: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return fmt.Errorf("close temp run-file: %w", err)
|
|
}
|
|
if err := atomicReplace(tmpName, path); err != nil {
|
|
return fmt.Errorf("replace run-file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Read loads running.json. A missing file returns (nil, nil) — that is the
|
|
// normal "no daemon recorded" state, not an error.
|
|
func Read(path string) (*Info, error) {
|
|
data, err := os.ReadFile(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read run-file: %w", err)
|
|
}
|
|
var info Info
|
|
if err := json.Unmarshal(data, &info); err != nil {
|
|
return nil, fmt.Errorf("parse run-file: %w", err)
|
|
}
|
|
return &info, nil
|
|
}
|
|
|
|
// Remove deletes running.json. A missing file is not an error — graceful
|
|
// shutdown should be idempotent.
|
|
func Remove(path string) error {
|
|
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("remove run-file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RemoveIfOwned deletes running.json only if it still belongs to ownerPID. This
|
|
// prevents a shutting-down daemon from removing a successor's freshly written
|
|
// handshake after an overlapping restart.
|
|
func RemoveIfOwned(path string, ownerPID int) error {
|
|
info, err := Read(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info == nil || info.PID != ownerPID {
|
|
return nil
|
|
}
|
|
return Remove(path)
|
|
}
|
|
|
|
// CheckStale inspects an existing run-file before the new daemon binds. It
|
|
// returns:
|
|
//
|
|
// - (nil, nil) no run-file, or one left by a dead process (safe to
|
|
// proceed; the caller should overwrite it);
|
|
// - (*Info, nil) a run-file whose recorded PID is still alive — a live
|
|
// daemon already owns the port, so the caller should fail fast.
|
|
//
|
|
// A run-file pointing at a dead PID is treated as stale and reported safe; the
|
|
// fresh Write will overwrite it.
|
|
func CheckStale(path string) (*Info, error) {
|
|
info, err := Read(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if info == nil || info.PID <= 0 {
|
|
return nil, nil
|
|
}
|
|
if processalive.Alive(info.PID) {
|
|
return info, nil
|
|
}
|
|
return nil, nil
|
|
}
|