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 (SIGHUP) or on Ctrl+C.

The user’s shell.

Background job

shelfd & or Ctrl+Z then bg. Still in the shell’s session and still tied to the terminal.

The user’s shell, which sends SIGHUP when the terminal closes.

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.

sequenceDiagram participant U as User shell participant S as shelf (client) participant L as lock file participant D as shelfd (daemon) participant M as Service manager M->>D: start (systemd, launchd or SCM) D->>L: flock(LOCK_EX or LOCK_NB) L-->>D: acquired (single instance) D->>D: create socket or pipe, mode 0600 or current user only D-->>M: READY=1 (Type=notify) or SERVICE_RUNNING U->>S: shelf list S->>D: connect to shelfd.sock or the shelfd pipe D->>D: check peer credentials S->>D: LIST D-->>S: OK 2 and the titles S-->>U: print result, exit 0 M->>D: stop (SIGTERM or SCM stop) D->>D: finish requests, unlink socket D-->>M: exit 0
Aspect Unix domain socket (Linux, macOS) Named pipe (Windows)

Name

A path in the file system (shelfd.sock), or on Linux only an abstract name starting with a null byte.

\\.\pipe\shelfd

Name length

sun_path is 108 bytes on Linux; assume as little as 92 bytes to be portable.

Pipe names are short strings in the pipe namespace.

Access control

Directory permissions and the socket file’s mode (umask); on Linux connecting needs write permission on the socket file. The manual warns portable programs not to rely on these alone.

A security descriptor on the pipe. If the server service is running every named pipe is reachable remotely, so deny NT AUTHORITY\NETWORK or use local RPC.

Who is the peer?

SO_PEERCRED on Linux returns the peer’s pid, uid and gid for a connected stream socket (define _GNU_SOURCE).

Impersonate the pipe client, or create the pipe with .NET’s PipeOptions.CurrentUserOnly.

Stale name

bind fails with EADDRINUSE if the file exists, and the file must be removed with unlink.

The name disappears when the last instance closes.

Message framing

A SOCK_STREAM has no message boundaries, so the protocol needs its own framing (here, lines).

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 with EWOULDBLOCK if 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 survives fork (parent and child share it) and execve. It is advisory: processes that never call flock can still use the file. On NFS, Linux emulates flock with 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

Type=exec

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. Type=simple is similar but reports success earlier.

Type=notify

The service must send READY=1 with sd_notify when start-up is finished. Until then the unit is "starting" and units ordered after it wait. If the program never sends it, systemd kills it after the start timeout. notify-reload adds a reload protocol.

Type=forking, oneshot, dbus, idle

forking is discouraged in favour of notify, notify-reload or dbus; oneshot is for programs that exit after doing their work.

NotifyAccess=

Which processes may send notifications: none, main, exec or all. With Type=notify, Type=notify-reload or WatchdogSec=, an unset or none value is forced to main.

Restart=

no (default), on-success, on-failure, on-abnormal, on-watchdog, on-abort or always. The manual recommends on-failure for long-running services. A stop requested through systemd does not trigger a restart, and restarts are rate limited.

TimeoutStartSec=

How long to wait for start-up. A notify service can extend it by sending EXTEND_TIMEOUT_USEC=.

RuntimeDirectory=

Creates /run/shelfd before the service starts and removes it afterwards; documented in systemd.exec. It is a natural home for the socket and lock file.

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)

~/Library/LaunchAgents/, /Library/LaunchAgents/

In the user’s login session, while that user is logged in.

Launch daemon (system)

/Library/LaunchDaemons/

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.

journalctl -u shelfd -f

macOS

Unified logging (os_log, the Logger type in Swift)

Not captured unless the job sets StandardErrorPath (or StandardOutPath) to a file.

log stream --predicate 'subsystem == "com.example.shelfd"', Console.app

Windows

Event Log (Application log)

Not captured; a service has no console.

Event Viewer, or a logging provider such as .NET’s EventLogLoggerProvider

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; bind then fails with EADDRINUSE. Remove it only after you hold the single-instance lock, never blindly.

  • Socket path length. sun_path is 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 write result.

  • 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 CurrentUserOnly or 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 deny NT AUTHORITY\NETWORK.

  • Secrets in unit files and the environment. Unit files, property lists and sc.exe command 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.