launchd

package
v0.9.1 Latest Latest
Warning

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

Go to latest
Published: Sep 5, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

Documentation

Overview

Package launchd drives macOS launch agents for billet's local lifecycle commands, as internal/lifeops drives systemd for Linux.

THE TWO ARE SIBLINGS RATHER THAN ONE ABSTRACTION, deliberately. The ORDER the lifecycle commands impose is shared and is their entire safety content; the vocabulary underneath it is not, and the facts each service manager reports have almost no overlap. lifeops.ServiceFacts carries FragmentPath, DropInPaths, ReloadPending and ExecStartFlags because systemd's refusals are computed from them, and launchd has none of those while having a durable disabled-override database systemd has no equivalent for. What the two share — Refusal, Tristate, and the plan vocabulary — is imported from lifeops rather than copied.

Every launchd behaviour this package relies on was MEASURED on macOS 26 by running real agents, not read from launchd.plist(5), which is wrong about at least one of them. The measurements live in reallaunchd_test.go so they cannot rot.

Index

Constants

View Source
const DefaultStabilityWait = 8 * time.Second

DefaultStabilityWait is how long `up` watches a service after starting it.

The same reason as the systemd side's: a process that starts and then dies is restarted by KeepAlive, and at any single instant that looks like a service which is running. It has to be longer than ThrottleInterval (5s in both shipped agents) or the sample lands during launchd's own back-off, when there is no process to see and nothing has gone wrong.

View Source
const (
	DefaultStartWindow = 30 * time.Second
)

startPoll is how often billet asks whether launchd has given a service a process yet, and DefaultStartWindow bounds that wait.

The window is generous because what it waits for is launchd's own scheduling rather than billet's: an agent is bootstrapped, launchd accepts it, and the process appears afterwards. Too short reports a healthy service as broken — and on this manager that unwinds, which removes the agent just installed.

View Source
const DefaultTimeout = 30 * time.Second

DefaultTimeout bounds one launchctl call. It is not a bound on a STOP: the process being stopped is a node that drains for as long as its jobs take, and the poll that waits for it is bounded by the caller's context alone, exactly as the systemd side is.

Variables

This section is empty.

Functions

This section is empty.

Types

type Converger

type Converger struct {
	// contains filtered or unexported fields
}

Converger drives launchd for the shared lifecycle commands.

func New

func New(opts ...Option) *Converger

New builds a Converger for the GUI domain of the calling user.

THE CALLING USER, NEVER AN INFERRED ONE. A launch agent lives in a per-user GUI domain, and billet's agents must be in the domain of the person whose login keychain is unlocked and whose tart image store holds the images — which is why running these commands as root is refused elsewhere rather than silently retargeted here.

func (*Converger) AgentPath added in v0.6.0

func (c *Converger) AgentPath(label string) string

AgentPath is where a label's plist lives, for a caller that preserves or restores it.

func (*Converger) ApplyOwnership

func (c *Converger) ApplyOwnership(changes []lifeops.OwnershipChange, _, _ int) error

ApplyOwnership corrects the files `up` planned to correct.

ON macOS THERE IS USUALLY NOTHING TO CORRECT: the agent runs as the operator, so the files they created are already readable by the service. What this must NOT do is chown — these commands refuse to run as root, so a file owned by somebody else is a refusal the plan already made rather than something to fix here.

func (*Converger) CollateralNote

func (c *Converger) CollateralNote() string

CollateralNote explains how enabling one service could commit another.

launchd HAS NO SUCH MECHANISM, which is worth saying rather than leaving the sentence systemd-shaped. There is no `[Install] Also=` here: a label is its own entry in the override database and its own file. The shared command still compares both services before and after, because a check that costs nothing and can only report a real change is worth keeping even where the mechanism it was written for does not exist.

func (*Converger) Disable

func (c *Converger) Disable(ctx context.Context, label string) error

Disable stops a label starting at the next login, and undoes an Enable this run performed.

IT UNDOES WHAT THIS RUN DID, in the shape this run did it. If this run INSTALLED the agent, undoing means removing that file — and removing it is enough, because a label with no plist starts nothing. If the agent was already there, this run only cleared an override, so undoing means writing it back.

THE TWO ARE NOT INTERCHANGEABLE, and getting it wrong leaves the landmine this backend exists to avoid: a label disabled in the durable database with no plist to explain it, which makes a LATER install bootstrap a service launchd silently refuses to run.

func (*Converger) DisableCmd

func (c *Converger) DisableCmd(label string) string

DisableCmd renders how an operator disables a label themselves.

func (*Converger) Enable

func (c *Converger) Enable(ctx context.Context, label string) error

Enable makes a label start at the next login, and is what this backend has instead of `systemctl enable`.

TWO THINGS ARE ENABLEMENT HERE, and both are done together on purpose. A launch agent starts at login when its plist is in ~/Library/LaunchAgents AND its label is not disabled in launchd's durable override database. Splitting them across two steps of `up` would make the command's own "is it enabled yet" reading flip to yes half way through its transaction — so `up` would refuse the second half of the work it had already begun.

IT ALSO HAS TO HAPPEN BEFORE THE START, which no other backend needs: a label carrying a disabled override refuses to bootstrap at all. So on macOS the commitment precedes the proof, and what protects the host is Disable undoing exactly this — see UnitPlan.EnableBeforeStart.

func (*Converger) EnableScheduled added in v0.6.0

func (c *Converger) EnableScheduled(ctx context.Context, label string) error

EnableScheduled installs a oneshot agent, clears any disabled override, and loads it so its schedule is live now rather than at the next login.

NO PROOF, BECAUSE THERE IS NO PROCESS TO PROVE. StartAndProve's whole test is that one pid survived a settle window, and a oneshot that ran at load and exited is a success that test would call a crash. What is established instead is that launchd holds the job: `print` answers for it afterwards.

A LOADED JOB WITH A PROCESS IS LEFT ALONE. That process may be the upgrade transaction itself, midway through draining the node; booting it out to load a fresh copy of its own plist would kill the updater. A loaded job with no process is booted out and loaded again, so what launchd holds is what the plist on disk says — launchd reads a plist once, at bootstrap. A plist that differs from the one this build ships is refused by Enable before any of that, as the service agents' are: a changed schedule wants a person to move the old file aside, and `up` says so.

func (*Converger) EnabledNow

func (c *Converger) EnabledNow(ctx context.Context, label string) (lifeops.Enablement, error)

EnabledNow reports whether a label will start itself at the next login.

TWO FACTS, AND THE SECOND IS INVISIBLE. A launch agent starts at login when its plist is in ~/Library/LaunchAgents AND its label is not disabled in launchd's override database. That database is durable, keyed by LABEL, and measured, it survives both a bootout and the removal of the plist entirely — `launchctl print-disabled` on a real Mac lists labels belonging to software uninstalled years ago, and it accepts a label that has never existed.

So "the plist is there" is not enablement, and neither is "launchd knows the job". A disabled label with a perfectly good plist refuses to bootstrap at all, with the same `Bootstrap failed: 5: Input/output error` launchd gives for every other reason — which is why billet reads the database rather than interpreting that error.

func (*Converger) EnablementCmd

func (c *Converger) EnablementCmd(...string) string

EnablementCmd renders what an operator runs to see a label's enablement.

IT IS print-disabled RATHER THAN print, because enablement on launchd is not a property of the loaded job at all: it lives in a durable override database keyed by LABEL, which survives both a bootout and the removal of the plist. An operator sent to `launchctl print` would be told the service does not exist and learn nothing about why it will not start.

func (*Converger) Identity

func (c *Converger) Identity(lifeops.UpRequest) (int, int, error)

Identity is the account the launch agents run as: the one invoking this.

THERE IS NO SERVICE ACCOUNT ON macOS, and that is the whole shape of this backend. billet ships launch AGENTS rather than daemons because Virtualization.framework needs an unlocked login keychain and tart's image store is per-user, so the services run inside a person's GUI session as that person. Nothing is chowned to a `billet` account, because none exists.

ROOT IS REFUSED. `sudo billet local up` would target root's domain — no unlocked keychain, no tart images — and everything would then fail for reasons naming none of that.

func (*Converger) ManagerName

func (c *Converger) ManagerName() string

ManagerName is what billet calls this service manager in a sentence.

func (*Converger) Plan

Plan is what `up` would do to this host.

func (*Converger) ProveStable

func (c *Converger) ProveStable(ctx context.Context, label string) error

ProveStable establishes that a service is still the same process after a settle window.

STABILITY IS NOT READINESS, and the difference is stated wherever this is reported. launchd has no sd_notify: nothing here says the process finished initialising, registered with the control plane, or can serve work. What it says is that one process survived the window — which rules out a crash loop and nothing else.

func (*Converger) RepairPaths

func (c *Converger) RepairPaths(string, []lifeops.RepairTarget, int, int) ([]string, error)

RepairPaths has nothing to repair either, for the same reason: a restore run here writes as the operator, and the agent runs as the operator.

func (*Converger) RepairServerState

func (c *Converger) RepairServerState(string, int, int) ([]string, error)

RepairServerState has nothing to repair on macOS.

The Linux one exists because `billet check` opens the ledger as the invoking process — root, under sudo — and leaves files the unprivileged server cannot write. Here the preflight and the agent are the same account, so the files it creates are already the agent's.

func (*Converger) Revalidate

func (c *Converger) Revalidate(ctx context.Context, req lifeops.UpRequest,
	want lifeops.UnitPlan,
) error

Revalidate asks again, immediately before acting.

THE PLAN IS OLD BY THE TIME IT IS USED. `billet check` spends as long on the network as the network takes, and in that time an agent can be booted out, disabled, or replaced. Acting on the older answer is how a converge starts a service it did not check.

func (*Converger) Running

func (c *Converger) Running(ctx context.Context,
	req lifeops.UpRequest,
) ([]lifeops.RunningFacts, error)

Running reports what each wanted service is executing right now.

func (*Converger) RunningOne

func (c *Converger) RunningOne(ctx context.Context, label string) (lifeops.RunningFacts, error)

RunningOne answers what one label is executing.

func (*Converger) Scheduled added in v0.6.0

func (c *Converger) Scheduled() (string, string)

Scheduled names billet's two oneshot agents, the upgrade first.

NOT SERVICES. Neither holds a process between runs, so nothing here starts, proves or drains them; they are installed and enabled so launchd runs them on their schedule, and removed with the rest at uninstall.

func (*Converger) Services

func (c *Converger) Services() (string, string)

Services names billet's two launch agents, server first.

func (*Converger) Snapshot

func (c *Converger) Snapshot(ctx context.Context, label string) (string, error)

Snapshot renders what a service looks like now, for the before-and-after comparison that catches billet disturbing a service it did not start.

WHAT IT COMPARES IS OPAQUE ON PURPOSE. The caller only asks whether the string changed, so this can carry whatever this manager makes observable without the shared command learning any of it.

func (*Converger) StartAndProve

func (c *Converger) StartAndProve(ctx context.Context, label string) (string, error)

StartAndProve bootstraps a launch agent and establishes it stayed up.

A FAILED bootstrap IS NEVER INTERPRETED. Measured: `Bootstrap failed: 5: Input/output error` is what launchd returns for a label that is already loaded, one that is still draining, AND one carrying a disabled override — three different situations and one useless sentence. So a failure is diagnosed by RE-READING what launchd and the override database say, which is the only way to tell an operator something true.

WHAT IT PROVES IS RETURNED, because it is weaker here than on Linux and must not be reported in the same words. systemd's units are Type=notify, so a successful start means billet's own process reached READY=1. launchd has no such thing: all that can be established is that one process survived a window.

func (*Converger) StartTimer added in v0.6.0

func (c *Converger) StartTimer(_ context.Context, unit string) error

StartTimer refuses: launchd has no timer units. The scheduled maintenance a Mac runs is the two oneshot agents EnableScheduled installs, and `up` asks the manager's name before it reaches for this.

func (*Converger) StopAndProve

func (c *Converger) StopAndProve(ctx context.Context, label string) (lifeops.StopResult, error)

StopResult is lifeops's, so the command layer stays one piece of code.

StopAndProve stops a launch agent and PROVES its process is gone.

launchd's OWN ANSWER IS NOT THE PROOF, and this is measured rather than cautious. A plain `launchctl bootout` returns in ZERO seconds against an agent that is still draining: the service stays in the domain reporting `state = SIGTERMed` with its pid, the process keeps running, and the record only disappears when the process finally exits. So the command's return says nothing at all, and `state` is launchd's intent as much as its observation.

What billet does instead is capture the pid FIRST and then wait for two facts together: launchd no longer has the service, and that pid is no longer a live process. The second is what makes this independent of launchd's own bookkeeping — the same discipline the provider inventories follow, where an answer that shrank is never taken as proof on its own. Pid reuse can only make this answer "still running" about a pid that is somebody else's, which is the safe direction: it refuses to report a host down, rather than reporting one down while a job runs on it.

bootout RATHER THAN `launchctl kill TERM`, and the difference is not stylistic. Both agents carry KeepAlive{SuccessfulExit: false}, and measured, `kill TERM` on an agent that exits non-zero makes launchd START IT AGAIN — the service billet was asked to stop, restarted by the stop. bootout removes it from the domain, so KeepAlive has nothing left to act on.

func (*Converger) Uninstall

func (c *Converger) Uninstall(ctx context.Context, label string) error

Uninstall removes a label's agent and leaves nothing behind that would stop a later install.

THE ORDER IS THE WHOLE THING, and it is the opposite of what reads naturally. The plist is removed FIRST and that removal is flushed to disk BEFORE the override is cleared. Clearing first opens a window in which the label is enabled and its plist is still there — a login or a reboot inside it starts the node this command is uninstalling, and nothing is left watching it.

AND THE OVERRIDE MUST BE CLEARED AT ALL, which is the landmine this exists to remove. launchd's disabled-override database is durable, keyed by LABEL, and measured, it survives both the bootout and the removal of the plist entirely — `launchctl print-disabled` on a real Mac lists labels belonging to software uninstalled years ago. A label left disabled with no plist to explain it means a LATER install bootstraps a service launchd silently refuses to run, with the same `Bootstrap failed: 5: Input/output error` it gives for three other reasons. Nothing on the machine would say why.

A PLIST THAT IS NOT THE ONE BILLET SHIPS IS LEFT, for the same reason `up` refuses to replace one: it is somebody's edit, and this command was asked to remove billet rather than to remove their work.

type Job

type Job struct {
	Label string
	// Path is the plist launchd loaded this job from, which is not necessarily
	// the plist that is there now.
	Path string
	// State is launchd's own word: `running`, `spawn scheduled` (it intends to
	// start this again), `SIGTERMed` (it is stopping and the process is still
	// alive), and others this package does not enumerate — an unrecognised
	// state is never read as proof of anything.
	State string
	// PID is the process launchd currently associates with the job. Known says
	// whether launchd reported one at all, which is a different fact from zero:
	// a job that is between runs has no pid line, and so does one launchd
	// declined to answer about.
	PID      int
	PIDKnown bool
	// Runs is how many times launchd has started this job. It is the closest
	// thing to systemd's NRestarts, and with the pid it is the ONLY evidence
	// available for a stability check — launchctl reports no start timestamp.
	Runs      int
	RunsKnown bool
	// LastExit is the exit status of the previous run. Measured: an agent whose
	// program does not exist reports `last exit code = 78: EX_CONFIG`, which is
	// how a mistyped binary path surfaces.
	LastExit      int
	LastExitKnown bool
	// ExitTimeout is the grace launchd will allow between SIGTERM and SIGKILL,
	// as LOADED. billet's node answers SIGTERM by draining for as long as the
	// jobs take, so this being the value billet expects is a safety property
	// rather than a detail.
	ExitTimeout      int
	ExitTimeoutKnown bool

	Program     string
	Arguments   []string
	Environment map[string]string
}

Job is what launchd reports about a service it has LOADED.

THIS IS NOT WHAT THE PLIST ON DISK SAYS, and the difference is the single most dangerous thing about this service manager. launchd reads a plist ONCE, at bootstrap, and keeps what it read: replacing the file changes nothing about the running job. Measured — a loaded agent went on reporting `exit timeout = 20` and its original environment after its plist had been rewritten to say 99 and something else.

So a node can be running with a stale ExitTimeOut while its plist is byte-identical to the one billet ships, and comparing the file would certify it. At the next logout launchd SIGKILLs that node five seconds into a drain that was allowed 88200. These fields are what makes the loaded job checkable instead.

func (Job) Running

func (j Job) Running() bool

Running reports whether launchd has a live process for this job.

A PID IS THE ONLY EVIDENCE THAT COUNTS. `state` is launchd's intent as much as its observation — `spawn scheduled` means it means to start one, and `SIGTERMed` means a process is alive and stopping — so a caller asking "is something executing" is asking about the pid.

type Option

type Option func(*Converger)

Option configures a Converger.

func WithAgentsDir

func WithAgentsDir(dir string) Option

WithAgentsDir points at a different LaunchAgents directory, for a test.

func WithLaunchctl

func WithLaunchctl(path string) Option

WithLaunchctl points at a different launchctl, for a test.

func WithLogDir

func WithLogDir(dir string) Option

WithLogDir points at a different log directory, for a test.

Jump to

Keyboard shortcuts

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