Documentation
¶
Overview ¶
Package lifecycle provides process supervision primitives for the wbt CLI: spawn a server process in the background, write a PID file, query status, and stop it cleanly. It also offers port-occupier discovery + kill helpers so `wbt setup` can reclaim a bound TCP port from a stale predecessor.
Two Supervisor implementations are provided:
- NohupSupervisor (default, Unix): forks the binary with `setsid` so it becomes its own session leader and survives parent shutdown. Stdout + stderr go to a log file with mode 0600.
- LaunchdSupervisor (stub): returns errors.ErrUnsupported. A real implementation will arrive in a future phase that writes a LaunchAgent plist and uses launchctl bootstrap/bootout.
All implementations satisfy the Supervisor interface so callers (internal/cli/setup.go) can swap them without code changes.
Index ¶
- Variables
- func AcquireSetupLock(stateDir string) (release func(), err error)
- func IsStale(path string) (bool, error)
- func KillOccupier(ctx context.Context, port int, logger *slog.Logger) error
- func PIDPath(workspace string) string
- func ReadPID(path string) (int, error)
- func RemovePID(path string) error
- func StateDir() string
- func WritePID(path string, pid int) error
- type Handle
- type LaunchdSupervisor
- type NohupSupervisor
- type Occupier
- type StartOptions
- type Status
- type Supervisor
Constants ¶
This section is empty.
Variables ¶
var ErrLockHeld = errors.New("another wbt setup is already running")
ErrLockHeld is returned by AcquireSetupLock when another wbt setup is already running and holds the exclusive lock.
var ErrNoOccupier = errors.New("no process listening on port")
ErrNoOccupier signals that no process is listening on the queried port.
var ErrNoPIDFile = errors.New("pid file not found")
ErrNoPIDFile signals that the PID file does not exist on disk. Callers typically treat this as "server already stopped" rather than an error.
Functions ¶
func AcquireSetupLock ¶
AcquireSetupLock acquires an exclusive non-blocking flock on $stateDir/wayneblacktea/setup.lock. The kernel releases the lock automatically if the process is killed (SIGKILL, crash, etc.), so no cleanup is needed on abnormal exit.
On success it returns a release func that unlocks + closes the file. Callers MUST defer the returned func when err == nil.
Returns ErrLockHeld if another process holds the lock. Returns any other OS error (wrapped) so the caller can decide whether to proceed without a lock (e.g. errors.ErrUnsupported from the Windows stub).
func IsStale ¶
IsStale reports whether the PID file references a process that no longer exists. Returns:
- (true, nil) if the file is missing or holds a dead PID
- (false, nil) if the PID is alive
- (false, err) if the file is corrupt and the caller must decide
The liveness probe is the platform-specific processAlive helper (signal 0 on Unix; stub on Windows).
func KillOccupier ¶
KillOccupier performs the TERM → wait → KILL → re-verify sequence on the process listening on port. Logs progress + the killed PID/command to the provided logger (nil → slog.Default()). The function returns nil once the port has been freed; otherwise it returns an error describing why the port could not be reclaimed.
func PIDPath ¶
PIDPath resolves the canonical path of the server PID file:
$XDG_STATE_HOME/wayneblacktea/server.pid — if XDG_STATE_HOME is set $HOME/.local/state/wayneblacktea/server.pid — otherwise
workspace is a reserved future hook (per SA spec) for namespacing the PID file when multiple workspaces share one wbt installation. workspace="" — the only currently-supported value — yields the plain server.pid. A non-empty workspace would map to server.<workspace>.pid; until the caller side accepts a workspace flag we return the same plain file so nothing changes today, but the resolver is hardened against accidental path-traversal in the workspace label.
func ReadPID ¶
ReadPID reads and parses the PID file at path. Returns ErrNoPIDFile when the file does not exist; any other I/O or parse failure is returned wrapped so callers can decide whether to treat it as stale.
func RemovePID ¶
RemovePID removes the PID file if present. Missing file is not an error (idempotent — calling Stop twice should succeed).
func StateDir ¶
func StateDir() string
StateDir returns the XDG state home, with a $HOME/.local/state fallback. It is exported so callers outside this package (e.g. setup.go) can resolve the canonical state directory without duplicating the XDG logic.
Types ¶
type LaunchdSupervisor ¶
type LaunchdSupervisor struct{}
LaunchdSupervisor is a placeholder for the Phase 3 macOS LaunchAgent integration. Each method currently returns errors.ErrUnsupported so the CLI can degrade gracefully if a caller wires it up too early. The real implementation will:
- Write a per-user LaunchAgent plist to ~/Library/LaunchAgents/
- Call `launchctl bootstrap gui/<uid> <plist>` to register it
- Use `launchctl bootout` to unregister on Stop
Keeping the type compile-time present (rather than #ifdef-ing it out) lets cmd/wbt code reference it with no build-tag complexity.
func NewLaunchdSupervisor ¶
func NewLaunchdSupervisor() *LaunchdSupervisor
NewLaunchdSupervisor returns a LaunchdSupervisor stub.
func (*LaunchdSupervisor) Start ¶
func (s *LaunchdSupervisor) Start(_ context.Context, _ StartOptions) (*Handle, error)
Start is not yet implemented; returns errors.ErrUnsupported.
type NohupSupervisor ¶
type NohupSupervisor struct {
// TermGracePeriod is how long Stop() waits for the process to exit
// after SIGTERM before escalating to SIGKILL. Defaults to 5s when zero.
TermGracePeriod time.Duration
}
NohupSupervisor spawns a binary detached from the parent terminal so it survives the wbt CLI exiting. The detachment is achieved with syscall.SysProcAttr{Setsid: true}: the child becomes its own session leader, ignoring the controlling terminal's SIGHUP.
stdout + stderr are redirected to a log file with mode 0600. The log file's parent directory is created with mode 0700.
func NewNohupSupervisor ¶
func NewNohupSupervisor() *NohupSupervisor
NewNohupSupervisor returns a NohupSupervisor with sensible defaults.
func (*NohupSupervisor) Start ¶
func (s *NohupSupervisor) Start(ctx context.Context, opts StartOptions) (*Handle, error)
Start launches opts.BinaryPath in the background. On success the PID file is written atomically and a Handle is returned.
func (*NohupSupervisor) Status ¶
func (s *NohupSupervisor) Status(pid int) (Status, error)
Status reports liveness via the platform processAlive helper.
func (*NohupSupervisor) Stop ¶
func (s *NohupSupervisor) Stop(ctx context.Context, pid int) error
Stop sends SIGTERM, waits up to TermGracePeriod, then SIGKILL.
Implementation note: after each signal we attempt a non-blocking wait4 reap. In production wbt-stop is not the parent of the server process (the server is detached via setsid, then orphaned to init/launchd when the wbt CLI that spawned it exits) so wait4 is a no-op. But if Stop is called from the same process that did the Start — common in tests and in the future "restart in-place" flow — wait4 prevents a zombie.
type Occupier ¶
Occupier describes the process currently listening on a TCP port.
func LSofTCPListen ¶
LSofTCPListen identifies the process listening on the given TCP port by shelling out to `lsof -nP -iTCP:<port> -sTCP:LISTEN -F pcn` and parsing the structured output. On systems without lsof (some Linux containers) it falls back to `ss -lntp`. The context bounds the external command runtime; callers without a deadline can pass context.Background() and rely on the internal 3s safety timeout.
Returns:
- (*Occupier, nil) on success
- (nil, ErrNoOccupier) when no process is listening (clean port)
- (nil, err) on parse or command failure
The path to the lsof binary may be overridden with the LSOF_BIN env var for tests only — never set from user input.
type StartOptions ¶
type StartOptions struct {
// BinaryPath is the absolute path to the executable. The caller is
// responsible for resolving via exec.LookPath before calling Start; the
// supervisor does not search $PATH.
BinaryPath string
// LogPath is the absolute path where stdout + stderr are appended.
// Created with mode 0600 if absent; parent dir is created with 0700.
LogPath string
// PIDPath is the absolute path of the PID file. Written atomically
// (tmp file + rename) so concurrent readers never observe a partial
// write. Parent dir is created with 0700.
PIDPath string
// Args are the command-line arguments (NOT including argv[0]).
Args []string
// Env is the explicit environment for the child process. If empty the
// supervisor falls back to os.Environ() so test harnesses can inject
// XDG_DATA_HOME and similar without listing every parent variable.
Env []string
}
StartOptions configures a Supervisor.Start call.
type Supervisor ¶
type Supervisor interface {
// Start launches the configured binary in the background. On success it
// writes the PID file at opts.PIDPath and returns a Handle. The returned
// context error wrapping is implementation-defined; callers should treat
// any non-nil error as fatal (no PID file is written on failure).
Start(ctx context.Context, opts StartOptions) (*Handle, error)
// Stop terminates the process identified by pid. Implementations should
// send SIGTERM first, wait, then SIGKILL if the process is still alive.
// Removing the PID file is the caller's responsibility (see PIDFile.Remove).
Stop(ctx context.Context, pid int) error
// Status reports whether a process with the given pid is alive. An error
// indicates the status check itself failed (permission denied, etc.); a
// dead process is reported as Running=false with a nil error.
Status(pid int) (Status, error)
}
Supervisor manages a single long-running child process: spawning it, querying liveness, and stopping it. Implementations must be safe to call from a CLI binary (no shared mutable state across processes).