Service Contract
link-service-contract runs background services as lnk's plugins
run them, through one job and lifecycle API. Native machines use launchd
or systemd, while explicitly configured Linux guests use a durable
process supervisor.
A service
A Job is what a service runs: its argv, env, working folder and
log. A service is named by a launchd label and a systemd unit name, and
its file is a plist (plist_path_of) or a unit (unit_path_of).
What a job may not say
A job whose unit or plist could say more than it means is refused: a control character, such as a newline, in a systemd value would start a new directive.
LNK_SERVICES=off
With it, which tests set, neither launchd nor systemd is asked anything
(managed), and no model runtime is started. Both off and 0 also
skip guest marker selection and supervision. Installation and removal
keep their inert service-file behavior without changing guest intent.
A service's name doesn't depend on HOME, so a test's agent would
otherwise stop or replace yours.
What a unit is handed
setenv_args is systemd-run's --setenv for each variable of this
process a unit of Link's is started with: HOME, PATH, LANG,
XDG_CONFIG_HOME and Link's own (LNK_*), but one naming a secret (a
token, key, password). By name only, so systemd-run takes each value
from its own environment and none is on its command line for ps to
show.
Starting and stopping
start installs the service for its job and (re)starts it, and
start_all starts several, with one reload of systemd for all of them
on Linux. stop stops the service and removes it, so it doesn't start
at login.
stop_before_start and reload
stop_before_start stops one, leaving systemd's reload to the start
or start_all that follows, since each reload reloads every unit;
reload has systemd read the units again after ones with no start to
follow. There's nothing to reload with launchd.
stop_from_within
Stops a service from its own process, by its file: the file removed, so neither a restart nor the next login runs it, and its stop not waited for, since it ends this process. Best effort.
pause and resume
pause stops a service for now, its file kept, so resume starts it
again as it was: an agent stopped for being idle.
Another program's service
unload_other stops a service of another program's by its label or
unit, before its file is removed, and load_other starts it again
once its file is back. Both are best effort.
On macOS
Unloading waits, up to 10 seconds, until launchd has let the job go,
since bootout returns before the job is gone.
After logout
systemd stops a user's services when their last session ends, unless
lingering is on for the user: on a server reached over SSH, a service
would stop at logout. keep_after_logout turns it on, which systemd
lets users do for themselves on most systems, or else says how.
Nothing on macOS.
Linux guests
A prepared Linux guest without systemd selects process supervision
with /etc/lnk/service.toml. Missing native service tools never select
this mode, and the caller continues using the same Job, start, stop,
pause, resume and status functions.
Explicit selection
The closed configuration contains exactly version = 1 and
mode = "guest". The file and its ancestors must belong to root and
deny writes by other users, with trusted sticky directories allowed by
the shared file reader. The reader refuses links, nonregular files,
unknown fields, unsupported versions and configurations over 4 KiB.
Only an absent file in trusted folders selects native behavior.
Process entry
An executable using guest services calls guest_service_entry before
parsing its ordinary command line or starting runtime threads. A
recognized private invocation returns Some(Result) and the executable
exits with that result. The entry launches a controller or a worker
through the same executable, so a library never assumes an agent's
command names.
Lifecycle and readiness
Start waits for the worker to report the actual job process identity. This confirms execution, while application readiness remains the caller's check. The controller survives the initiating terminal, restarts an exited job after ten seconds and stays active while pause stops the worker. Resume explicitly restarts a paused job, including after controller loss. A guest reboot requires a new explicit start or resume, and makes old process identities stale.
Ownership and recovery
The controller owns an unreaped worker process-group leader through
OwnedChild.
Its main thread remains active during pause. Kernel parent-death
notification makes the worker stop its own group when the controller
dies. Persisted boot identity, process start ticks and generation fence
status and late completions. Recovery never signals a stored numeric
PID, and refuses replacement when an old group may still be live.
Durable intent
Private state under .lnk/services/<unit>/ records the job, desired
state, revision, controller generation, process identities and start
count. Short metadata transactions precede external work. Stop records
its intent before waiting for confirmed cleanup, while stop from within
the service records that intent without waiting on itself. A unit file
remains at unit_path_of for the existing installed-service checks.
Bounds and scope
Control waits and lock acquisition each have a ten-second deadline. Jobs have at most 256 arguments and a serialized size below 128 KiB, with absolute work and log paths. Each output pump uses an 8 KiB buffer and truncates its private log before a write would exceed 8 MiB, resetting the recorded start offset. Descendants must stay in the owned process group. This guest lifecycle adds no host confinement or resource guarantee, which belongs to the VM's host authority.
Debug configuration path
Debug builds accept LNK_TEST_SERVICE_CONFIG as a different marker
path, retaining all production root and ancestor checks. Release
builds always use /etc/lnk/service.toml. Unit fixtures inject owners
only in test code, and a path override never authorizes an untrusted
marker or substitutes for guest bootstrap verification.
Its log
open_log opens the log for a new start: a regular file, only the
user's, in a folder only the user's, which launchd or systemd (outside
the sandbox) then appends the service's output to, or a start in a
terminal does itself. It records where this start's lines begin, kept
next to the log (start_path), for lnk agent logs.
Its state
running says whether a service is installed and its process running,
and status gives its Status, from one launchctl print or
systemctl show. running_each and status_each ask for several at
once, in one systemctl or launchctl, rather than one per service.
Without a systemd
systemctl runs as the user, reaching the user's bus in
/run/user/<uid> from a service's environment, which may lack it.
systemd's error when this user has no systemd of their own (over SSH,
in WSL or a container) is turned into what to do about it.