lifecycle

package
v1.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 21, 2026 License: MIT Imports: 12 Imported by: 0

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

Constants

This section is empty.

Variables

View Source
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.

View Source
var ErrNoOccupier = errors.New("no process listening on port")

ErrNoOccupier signals that no process is listening on the queried port.

View Source
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

func AcquireSetupLock(stateDir string) (release func(), err error)

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

func IsStale(path string) (bool, error)

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

func KillOccupier(ctx context.Context, port int, logger *slog.Logger) error

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

func PIDPath(workspace string) string

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

func ReadPID(path string) (int, error)

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

func RemovePID(path string) error

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.

func WritePID

func WritePID(path string, pid int) error

WritePID writes pid to path atomically: write a sibling .tmp file, then rename over the destination. Parent directory is created with mode 0700 if absent. The PID file itself is mode 0600 because it is read by the owning user and contains information about a long-running process.

Types

type Handle

type Handle struct {
	PID       int
	LogPath   string
	StartedAt time.Time
}

Handle is returned by Supervisor.Start to identify the spawned process.

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:

  1. Write a per-user LaunchAgent plist to ~/Library/LaunchAgents/
  2. Call `launchctl bootstrap gui/<uid> <plist>` to register it
  3. 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

Start is not yet implemented; returns errors.ErrUnsupported.

func (*LaunchdSupervisor) Status

func (s *LaunchdSupervisor) Status(_ int) (Status, error)

Status is not yet implemented; returns errors.ErrUnsupported.

func (*LaunchdSupervisor) Stop

func (s *LaunchdSupervisor) Stop(_ context.Context, _ int) error

Stop 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

type Occupier struct {
	PID     int
	Command string // best-effort process name
}

Occupier describes the process currently listening on a TCP port.

func LSofTCPListen

func LSofTCPListen(ctx context.Context, port int) (*Occupier, error)

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 Status

type Status struct {
	Running bool
	PID     int
}

Status describes whether a previously-spawned process is still alive.

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).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL