Long-Running Processes & Services
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
Most command-line programs start, do their work and exit. Some must stay alive: a catalogue indexer that keeps the
Bookshelf data warm, a watcher that sends loan reminders, a local server that several shelf invocations talk to.
This page covers what changes when a process is meant to run for hours or months: how each operating system supervises
it, how to start only one copy, where its log lines go, and how a short-lived shelf client reaches a long-lived
shelfd daemon.
The running scenario is shelfd, a background service that owns the book catalogue and answers a local socket, and the
shelf command, which is a thin client of it. The page was written against systemd (the latest manual pages),
launchd as documented in Apple’s archived system start-up guide, Windows services with .NET 10 (the
Microsoft documentation targets net10.0-windows) and the windows-service crate 0.8.1. The C program for
shelfd and its client were compiled and run on macOS with Apple clang 21; the systemd unit, the property list, the
PowerShell session, the C# and the Rust snippets were not run while the page was written, because no Linux or Windows
machine was available, and they follow the cited documentation. Process, signal and descriptor mechanics are on
POSIX Processes, Signals & I/O, terminals and job control on
Terminals, TTYs & Pseudo-Terminals, and the Windows process model on
Windows Console & Processes. For the design rules of programs that
agents drive, see Building CLIs for AI Agents; the "client of a daemon" shape is one
of the program kinds in The CLI Landscape & Choosing a Stack.
Foreground, background and daemon
Three situations are easy to confuse.
| Mode | What it is | Who owns the process |
|---|---|---|
Foreground |
The program runs in the terminal’s foreground process group, reads the terminal and dies with it ( |
The user’s shell. |
Background job |
|
The user’s shell, which sends |
Service (daemon) |
Started by the operating system’s service manager, with no controlling terminal, no inherited user environment and its own log destination. |
systemd, launchd or the Windows Service Control Manager. |
The traditional UNIX "daemonize yourself" recipe (fork, setsid, fork again, close the descriptors) is what programs
did before service managers existed. Today every mainstream manager prefers a program that stays in the foreground and
lets the manager do the supervision. systemd’s manual recommends Type=exec for long-running services and
discourages Type=forking. Apple’s guide says outright that a launchd job must not call daemon() or
fork followed by exit, because launchd would see the original process end, conclude that the job died, and relaunch it
until it gives up with a "respawning too fast" error. A Windows service is a normal process that connects to the Service
Control Manager (SCM) and does not fork at all.
So the design rule for shelfd is the same on every platform: run in the foreground, write diagnostics to standard
error (or the platform logger), handle the platform’s stop request by shutting down cleanly, and let the manager
restart it.
Local IPC between shelf and shelfd
A client and a daemon on one machine need a local transport. The usual choices are a Unix domain socket on Linux and macOS and a named pipe on Windows. Both are addressed by a name, both support many clients at once, and both stay off the network.
| Aspect | Unix domain socket (Linux, macOS) | Named pipe (Windows) |
|---|---|---|
Name |
A path in the file system ( |
|
Name length |
|
Pipe names are short strings in the pipe namespace. |
Access control |
Directory permissions and the socket file’s mode ( |
A security descriptor on the pipe. If the server service is running every named pipe is reachable remotely, so deny |
Who is the peer? |
|
Impersonate the pipe client, or create the pipe with .NET’s |
Stale name |
|
The name disappears when the last instance closes. |
Message framing |
A |
Byte or message mode is chosen when the pipe is created. |
macOS has no SO_PEERCRED; the local peer credentials are obtained through other socket options such as
LOCAL_PEERCRED and getpeereid, which this page does not document because the pages consulted do not cover them. The
design consequence is still portable: keep the socket in a directory only the user (or the service account) can enter,
create it with mode 0600, and treat the permissions as the access control.
Single instance: lock files and PID files
Starting shelfd twice must not leave two programs fighting over one catalogue. There are three common ways to
enforce it.
-
The service manager does it. systemd, launchd and the SCM each run at most one instance of a unit, label or service name. This is the simplest and the only one that needs no code.
-
An advisory lock on a file.
flock(LOCK_EX | LOCK_NB)fails immediately withEWOULDBLOCKif another process holds the lock. The lock belongs to the open file description, so it is released when every descriptor for it is closed, which includes the death of the process. It survivesfork(parent and child share it) andexecve. It is advisory: processes that never callflockcan still use the file. On NFS, Linux emulatesflockwith byte-range locks from 2.6.12, so keep the lock file on a local file system. -
A PID file alone. Writing the process id to a file and checking whether it is alive is racy and leaves stale files after a crash (the pid may even be reused by an unrelated program). Use it only to tell humans or scripts which process to signal, and combine it with a lock, as the program below does.
On Windows, the equivalents are the SCM’s own uniqueness for a service, a named mutex, or creating the first instance of
a named pipe (PipeOptions.FirstPipeInstance in .NET; the enum documentation gives it no description, and its Win32
counterpart is the FILE_FLAG_FIRST_PIPE_INSTANCE flag of CreateNamedPipe).
A complete shelfd and shelf client in C
The daemon below stays in the foreground, takes the lock, removes the stale socket that the lock proves is not in use,
listens on a 0600 socket and uses the self-pipe trick from
POSIX Processes, Signals & I/O so that SIGTERM and SIGINT wake
the poll loop for a clean shutdown. It ignores SIGPIPE so that a client that disappears produces an error from write
rather than killing the daemon. Its exit code 75 (EX_TEMPFAIL) tells a script that another copy is running.
#include <errno.h>
#include <fcntl.h>
#include <poll.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/file.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/un.h>
#include <unistd.h>
static int wake_pipe[2]; /* self-pipe: signal handler -> poll loop */
static void on_signal(int signo)
{
int saved = errno;
unsigned char b = (unsigned char)signo;
(void)!write(wake_pipe[1], &b, 1); /* async-signal-safe */
errno = saved;
}
static void serve_client(int fd)
{
char line[128];
ssize_t n = read(fd, line, sizeof line - 1);
if (n <= 0) { return; }
line[n] = '\0';
const char *reply = strncmp(line, "LIST", 4) == 0
? "OK 2\nThe Left Hand of Darkness\nDune\n"
: "ERR unknown command\n";
(void)!write(fd, reply, strlen(reply));
}
int main(int argc, char *argv[])
{
const char *dir = argc > 1 ? argv[1] : "/tmp/shelfd-demo";
char lock_path[256], sock_path[256];
snprintf(lock_path, sizeof lock_path, "%s/shelfd.lock", dir);
snprintf(sock_path, sizeof sock_path, "%s/shelfd.sock", dir);
mkdir(dir, 0700);
/* Single instance: an advisory lock the kernel drops when we exit or crash. */
int lock_fd = open(lock_path, O_RDWR | O_CREAT | O_CLOEXEC, 0600);
if (lock_fd == -1 || flock(lock_fd, LOCK_EX | LOCK_NB) == -1) {
fprintf(stderr, "shelfd: already running (%s)\n", lock_path);
return 75; /* EX_TEMPFAIL */
}
dprintf(lock_fd, "%d\n", (int)getpid());
if (pipe(wake_pipe) == -1) { perror("pipe"); return 1; }
struct sigaction sa = { .sa_handler = on_signal };
sigemptyset(&sa.sa_mask);
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGINT, &sa, NULL);
signal(SIGPIPE, SIG_IGN);
/* We hold the lock, so any socket file left behind is stale. */
unlink(sock_path);
int srv = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr = { .sun_family = AF_UNIX };
if (strlen(sock_path) >= sizeof addr.sun_path) {
fprintf(stderr, "shelfd: socket path too long\n");
return 1;
}
strcpy(addr.sun_path, sock_path);
mode_t old = umask(0177);
if (bind(srv, (struct sockaddr *)&addr, sizeof addr) == -1 || listen(srv, 16) == -1) {
perror("bind/listen");
return 1;
}
umask(old);
fprintf(stderr, "shelfd: listening on %s\n", sock_path);
for (;;) {
struct pollfd fds[2] = { { srv, POLLIN, 0 }, { wake_pipe[0], POLLIN, 0 } };
if (poll(fds, 2, -1) == -1 && errno != EINTR) { perror("poll"); break; }
if (fds[1].revents & POLLIN) { break; }
if (fds[0].revents & POLLIN) {
int c = accept(srv, NULL, NULL);
if (c != -1) { serve_client(c); close(c); }
}
}
fprintf(stderr, "shelfd: shutting down\n");
unlink(sock_path);
return 0;
}
The client is the other half of the split. When the daemon is not running, it reports that on standard error and exits
with 69 (EX_UNAVAILABLE), so scripts and agents can tell "no daemon" from "daemon said no".
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
const char *path = argc > 1 ? argv[1] : "/tmp/shelfd-demo/shelfd.sock";
int fd = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr = { .sun_family = AF_UNIX };
strncpy(addr.sun_path, path, sizeof addr.sun_path - 1);
if (connect(fd, (struct sockaddr *)&addr, sizeof addr) == -1) {
perror("shelf: cannot reach shelfd");
return 69; /* EX_UNAVAILABLE */
}
(void)!write(fd, "LIST\n", 5);
char buf[512];
ssize_t n;
while ((n = read(fd, buf, sizeof buf)) > 0) { fwrite(buf, 1, (size_t)n, stdout); }
close(fd);
return 0;
}
$ cc -Wall -Wextra -o shelfd shelfd.c && cc -Wall -Wextra -o shelf shelfctl.c
$ ./shelfd &
shelfd: listening on /tmp/shelfd-demo/shelfd.sock
$ ./shelf
OK 2
The Left Hand of Darkness
Dune
$ ./shelfd; echo "exit=$?"
shelfd: already running (/tmp/shelfd-demo/shelfd.lock)
exit=75
$ ls -l /tmp/shelfd-demo
-rw------- 1 alice wheel 6 Oct 25 15:06 shelfd.lock
srw------- 1 alice wheel 0 Oct 25 15:06 shelfd.sock
$ kill -TERM %1
shelfd: shutting down
$ ./shelf; echo "exit=$?"
shelf: cannot reach shelfd: No such file or directory
exit=69
Source: unix(7), flock(2)
and POSIX — sigaction. The exit codes follow
the BSD sysexits.h convention discussed on Streams & Exit Codes.
Linux: systemd service units
systemd runs shelfd from a unit file. A system service lives in /etc/systemd/system/ and starts at boot; a user
service lives in ~/.config/systemd/user/ and runs for one user, managed with systemctl --user.
# /etc/systemd/system/shelfd.service
[Unit]
Description=Bookshelf catalogue daemon
After=network.target
[Service]
Type=notify
ExecStart=/usr/local/bin/shelfd /run/shelfd
Restart=on-failure
RestartSec=2
User=shelfd
RuntimeDirectory=shelfd
RuntimeDirectoryMode=0750
[Install]
WantedBy=multi-user.target
The settings that matter for a CLI-style daemon:
| Setting | Meaning |
|---|---|
|
The manager considers the service started once the binary has been executed successfully. The manual recommends it for long-running services because a missing binary or user is reported as a start failure. |
|
The service must send |
|
|
|
Which processes may send notifications: |
|
|
|
How long to wait for start-up. A |
|
Creates |
sd_notify(int unset_environment, const char *state) sends newline-separated assignments to the socket named by
NOTIFY_SOCKET; it does nothing and returns 0 when that variable is unset, so the same binary runs unchanged in a
terminal. The well-known assignments are READY=1, STOPPING=1 and a one-line STATUS=. libsystemd is not required:
the protocol is a single datagram on an AF_UNIX SOCK_DGRAM socket (an abstract address when the variable starts with
@), which the manual illustrates with a dependency-free C example. With libsystemd, shelfd would call
sd_notify(0, "READY=1\nSTATUS=Listening on /run/shelfd/shelfd.sock") right after listen.
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now shelfd.service
$ systemctl status shelfd.service
$ journalctl -u shelfd.service -f
Source: systemd.service and sd_notify. The unit above and the commands were not run while the page was written.
Everything a systemd service writes to standard output and standard error is captured by the journal, so a daemon needs
no log file: print lines to stderr and read them with journalctl -u shelfd. A line prefix such as <3> (a syslog
priority) is the journal’s convention for levels in plain text output, documented under sd-daemon; this page does not
reproduce that table. Because the service manager owns the process, systemctl stop sends SIGTERM, which shelfd
already turns into a clean shutdown.
macOS: launchd agents and daemons
launchd is macOS’s service manager and the first process the kernel starts. A job is described by a property list whose directory decides its kind.
| Kind | Location | Runs |
|---|---|---|
Launch agent (per user) |
|
In the user’s login session, while that user is logged in. |
Launch daemon (system) |
|
At boot, for all users, with no GUI session. |
The two keys the guide marks as required are Label (a unique string) and ProgramArguments. KeepAlive set to true keeps the
job running always, while Apple recommends designing daemons to launch on demand. StandardOutPath and
StandardErrorPath replace redirecting stdio yourself, UserName and GroupName replace calling setuid, and
WorkingDirectory replaces chdir. Globally installed property lists must be owned by root and not be writable by group
or others.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.shelfd</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/shelfd</string>
<string>/Users/alice/Library/Application Support/shelfd</string>
</array>
<key>KeepAlive</key>
<true/>
<key>StandardErrorPath</key>
<string>/Users/alice/Library/Logs/shelfd.log</string>
</dict>
</plist>
Source: Apple — Creating Launch Daemons and Agents. The property list was not loaded into launchd while the page was written.
The archived guide predates the current launchctl sub-commands, and Apple’s web documentation does not describe
launchctl bootstrap; the command comes from the launchctl manual page (man launchctl) on the Mac itself, which this
page could not consult. In outline, an agent for the logged-in user is loaded into the gui/<uid> domain and a daemon into the
system domain:
$ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.shelfd.plist
$ launchctl print gui/$(id -u)/com.example.shelfd
$ launchctl bootout gui/$(id -u)/com.example.shelfd
Check the exact syntax with man launchctl before scripting it.
Two rules from the guide shape shelfd. Never daemonize: launchd supervises the process it started, and a program that
forks away looks dead to it. And do not exit within roughly ten seconds of launch, or launchd may treat the job as crashed and
stop relaunching it, which matters for a daemon that fails fast on a missing configuration file and is better off waiting.
The C program above already runs in the foreground, so it works unchanged. Because launchd has KeepAlive and socket
activation of its own, the flock-based single-instance check is a safety net rather than the primary guard on macOS.
Windows services
A Windows service is a process that follows the Service Control Manager’s rules. It can start at boot, with no one logged
on, and is controlled with the Services console, sc.exe or the service APIs. Its entry point does three things: connect to the
SCM, report status changes (start pending, running, stop pending, stopped) and handle control requests such as stop.
A console program that skips these steps starts, but the SCM reports that it did not respond in time and ends it.
.NET Worker Service
In .NET the plumbing is hidden behind the Generic Host. Create the project with dotnet new worker, add the
Microsoft.Extensions.Hosting.WindowsServices package, and call AddWindowsService. The host then reports the SCM states,
maps the stop request to cancellation of the stopping token and sends logs to the Windows Event Log. The published
tutorial uses Microsoft.NET.Sdk.Worker with a net10.0-windows target.
using Microsoft.Extensions.Logging.Configuration;
using Microsoft.Extensions.Logging.EventLog;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddWindowsService(options =>
{
options.ServiceName = "Bookshelf Daemon";
});
LoggerProviderOptions.RegisterProviderOptions<
EventLogSettings, EventLogLoggerProvider>(builder.Services);
builder.Services.AddHostedService<ShelfdService>();
IHost host = builder.Build();
host.Run();
public sealed class ShelfdService(ILogger<ShelfdService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
logger.LogWarning("shelfd started");
while (!stoppingToken.IsCancellationRequested)
{
// Serve clients here, for example with NamedPipeServerStream (next section).
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
}
}
catch (OperationCanceledException)
{
// A stop request cancels the token: expected, exit code 0.
}
catch (Exception ex)
{
logger.LogError(ex, "shelfd failed");
// A non-zero exit code is what lets the SCM recovery actions restart the service.
Environment.Exit(1);
}
}
}
Source: Create Windows Service using BackgroundService.
The code follows that tutorial and was not compiled on Windows. The tutorial’s example sets the EventLog severity
through appsettings.json, because the default Event Log level is Warning, which is why it logs at that level.
The tutorial’s exception-handling note is important for a daemon: since .NET 6 an unhandled exception in a
BackgroundService stops the host cleanly with exit code 0, so the SCM does not consider it a failure and will not apply
recovery actions. Terminating with a non-zero exit code, as above, is what makes the recovery settings work. Publish a
self-contained single-file executable (see .NET Publishing Options
for the choices) and register it with the SCM from an administrator PowerShell.
PS> sc.exe create "Bookshelf Daemon" binpath= "C:\Program Files\Bookshelf\shelfd.exe"
[SC] CreateService SUCCESS
PS> sc.exe failure "Bookshelf Daemon" reset= 0 actions= restart/60000/restart/60000/run/1000
[SC] ChangeServiceConfig2 SUCCESS
PS> sc.exe start "Bookshelf Daemon"
PS> sc.exe stop "Bookshelf Daemon"
PS> sc.exe delete "Bookshelf Daemon"
sc.exe needs the space after binpath=, reset= and actions=; without it the command fails. Regular users cannot
manage services, so run these from an elevated shell. A service that is not stopped is only marked for deletion by
sc.exe delete. If you publish a DLL rather than an executable, the tutorial registers dotnet.exe with the DLL path as
binpath. Source: the same tutorial, and Microsoft — Services.
Rust: the windows-service crate
Rust has no standard service support. The community windows-service crate (version 0.8.1 at the time of writing)
wraps the SCM API: define_windows_service! generates the low-level entry point, service_dispatcher::start is called
from main and blocks until the service stops, service_control_handler::register registers the handler for stop and
other control events and returns a ServiceStatusHandle, and set_service_status reports state changes.
#[macro_use]
extern crate windows_service;
use std::ffi::OsString;
use windows_service::{service_dispatcher, Result};
define_windows_service!(ffi_service_main, shelfd_service_main);
fn shelfd_service_main(_arguments: Vec<OsString>) {
// Runs on a background thread once the dispatcher has started the service:
// register a control handler, report Running, serve clients until stop is requested.
}
fn main() -> Result<()> {
service_dispatcher::start("shelfd", ffi_service_main)?;
Ok(())
}
Source: windows-service. This skeleton is the crate’s documented
structure; it was not compiled, and the handler and status reporting that fill shelfd_service_main are shown in the
crate’s own examples rather than reproduced here.
Named pipes as the Windows IPC channel
On Windows shelf talks to shelfd over a named pipe. All instances of a pipe name share the name, but each instance has
its own buffers and handle, so a server creates a new instance for every client it expects. The Win32 functions are
CreateNamedPipe, ConnectNamedPipe (server) and CreateFile (client). In .NET, NamedPipeServerStream and
NamedPipeClientStream wrap them, and PipeOptions.CurrentUserOnly makes the pipe connectable only by a client of the same
user; on Windows it checks both the user account and the elevation level.
using System.IO.Pipes;
// shelfd side: one instance per client.
await using var server = new NamedPipeServerStream(
"shelfd", PipeDirection.InOut, NamedPipeServerStream.MaxAllowedServerInstances,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
await server.WaitForConnectionAsync(stoppingToken);
using var reader = new StreamReader(server);
await using var writer = new StreamWriter(server) { AutoFlush = true };
if (await reader.ReadLineAsync(stoppingToken) == "LIST")
{
await writer.WriteLineAsync("OK 2\nThe Left Hand of Darkness\nDune");
}
// shelf side:
await using var client = new NamedPipeClientStream(
".", "shelfd", PipeDirection.InOut, PipeOptions.CurrentUserOnly);
await client.ConnectAsync(2000);
Source: Microsoft — Named Pipes and
PipeOptions. The constructor arguments were
not checked against the API reference beyond the PipeOptions members, and the snippet was not compiled.
Logging for a service
A service has no terminal, so its output has to go somewhere a person can find it. The sensible default is to write plain lines to standard error and let the platform route them, and to use the native logger only when you need its filtering.
| Platform | Native destination | How a plain stderr line is handled |
Reading it |
|---|---|---|---|
Linux |
The systemd journal |
Captured automatically for a systemd service. |
|
macOS |
Unified logging ( |
Not captured unless the job sets |
|
Windows |
Event Log (Application log) |
Not captured; a service has no console. |
Event Viewer, or a logging provider such as .NET’s |
On macOS, os_log messages carry a subsystem and a category (use a reverse-DNS subsystem such as the bundle or job
label), and dynamic values are redacted by default in the log, so mark values .public only when they are not
sensitive. Debug and info levels are kept in memory unless logging is configured to persist them. On Windows, a Warning
default level in the .NET Event Log provider hides Information messages until you configure the filter. Cross-platform
logging libraries (Rust tracing, Go log/slog, .NET Microsoft.Extensions.Logging) ship adapters for these
destinations; this page documents only the platform side. Keep user-facing results on stdout and diagnostics on stderr
(see Streams & Exit Codes), and rotate any log file you write yourself.
Pitfalls and security
-
Daemonizing under a service manager.
fork-and-exit makes launchd and systemd (Type=simple,exec) lose track of the process. Stay in the foreground. -
Stale sockets and PID files. After a crash the socket file remains;
bindthen fails withEADDRINUSE. Remove it only after you hold the single-instance lock, never blindly. -
Socket path length.
sun_pathis 108 bytes on Linux and may be near 92 elsewhere; deep home directories and per-user temporary directories can overflow it, so check the length as the example does. -
Ignoring SIGPIPE. A client that disconnects during a reply otherwise kills the daemon; ignore the signal and check the
writeresult. -
Exiting 0 on failure under the SCM. A .NET host stops cleanly after an unhandled exception, so the SCM never applies its recovery actions. Exit non-zero.
-
Unprivileged users and local access. Anything that can connect to the socket or pipe can send commands. Restrict the directory and mode (Unix), use
CurrentUserOnlyor a security descriptor (Windows), and authenticate the peer when the daemon acts with more privilege than the client. Remote access to named pipes is possible when the Server service runs, so denyNT AUTHORITY\NETWORK. -
Secrets in unit files and the environment. Unit files, property lists and
sc.execommand lines are readable by other users or processes. Keep secrets in files with restricted permissions or the platform’s credential store, as described in Security and Configuration & Authentication. -
Different environment. A service does not inherit the user’s shell environment (
PATH,HOME, locale). Pass configuration explicitly; the same applies to scheduled jobs, covered on Shells, Completions & Shell Integration.
What the official documentation does not provide: Apple’s web documentation does not describe the current launchctl
syntax, and Microsoft’s FirstPipeInstance enum page has no description. This page therefore states those two points as
outlines to verify, not as specifications.
Packaging the unit file, property list or service registration with the program is part of Native Packages & Installers; testing a daemon through its socket is covered on Testing Terminal Apps & TUIs. The agent-facing side of a client and daemon pair is on Wrapping a CLI as an Agent Tool.