Creating Services and Foreground Daemons
A background service has the same business logic as an interactive server, but a different relationship with the operating system. It is started without a terminal, monitored by a service manager, and expected to stop in a predictable way when the machine shuts down or an administrator requests it.
This page shows how one Erbsland Core executable can meet those expectations on Windows, Linux, and macOS while still remaining convenient to run from a terminal during development. You will build the lifecycle into the application, install the executable with each platform’s native service manager, and learn which status and stop requests are translated into the regular application lifecycle.
What Makes a Service Different?
A command-line program normally begins because a person launches it, inherits that person’s terminal and environment, and exits when its immediate task is complete. A service is instead owned by a supervisor. The supervisor decides when it starts, which identity and environment it receives, where its output goes, whether a failure should restart it, and how long shutdown may take.
That ownership has a useful consequence: the application does not need to turn itself into a traditional background daemon. It should stay in the foreground, keep its main process alive, and let the supervisor track that process. Forking into the background, writing a PID file, or detaching from the session would hide the process that a modern service manager is trying to manage.
The three supported platforms express this relationship differently:
Windows services register with the Service Control Manager (SCM), publish lifecycle states, and receive service controls such as stop and system shutdown.
Linux distributions commonly use systemd. A unit file describes a foreground process, and systemd sends signals to stop it and may collect its output in the journal.
macOS uses launchd. A property-list file describes a system-wide launch daemon or a per-user launch agent, and launchd owns the process lifetime.
The Application service lifecycle bridges the native stop mechanisms to the
same orderly shutdown path used by an interactive application.
It does not install the program, choose a service account, or define a restart policy; those deployment decisions stay
in the platform configuration where administrators expect to find them.
Preparing One Executable for Every Mode
Call enableServiceLifecycle() after constructing the
application and before run().
The call is intentionally made at the composition root, where it is easy to see that native lifecycle integration is
active.
Repeated calls have no additional effect, but enabling it after run() has begun is an error.
/// Enable native service integration before the application lifecycle starts.
///
/// The executable runs as a Windows service when the Service Control Manager launches it.
/// Everywhere else, it runs as an ordinary foreground process.
/// Both modes use the same initialization, main event loop, and orderly cleanup.
auto main(const int argc, char *argv[]) -> int {
auto app = CatalogMonitorApplication{argc, argv};
app.enableServiceLifecycle();
return app.run();
}
On Windows, run() first offers the executable to the SCM.
When the SCM launched it, the application enters service mode and publishes native state transitions.
When a developer starts the same file in PowerShell or a terminal, Windows reports that no service controller is
attached and the ordinary application lifecycle continues.
Linux and macOS always run the executable as a foreground process.
The lifecycle subscribes to the termination signals used by supervisors and translates them into
quit().
This means you can start the executable directly, inspect its output, and stop it with Ctrl+C without maintaining
a second development entry point.
The demo is a small catalog monitor that remains on the main event loop and refreshes its index periodically.
Run core/core_service_lifecycle --help to inspect it, or start core/core_service_lifecycle and stop it with
Ctrl+C.
A production service can use the same shape while replacing the sample initialization and timer callback with its own
configuration, storage, and server components.
Installing the Compiled Application
Build and test the executable before installing it, then copy the release artifact to a stable, absolute path.
Service definitions outlive individual build directories, and supervisors generally start with a small, controlled
environment.
Do not depend on the current directory, a developer shell’s PATH, or environment variables that are not declared in
the service definition.
Run under a dedicated, unprivileged account whenever the service does not need system privileges.
Give that account access only to the configuration, data, sockets, and log destinations it actually uses.
The examples below use CatalogMonitor consistently, but the service name, executable path, identity, restart policy,
and resource limits are deployment choices rather than library requirements.
Windows Service Control Manager
Open PowerShell as an administrator and copy the executable to its permanent location.
New-Service stores the complete command line in the SCM database, so the inner quotes around a path containing
spaces are significant.
$installDirectory = 'C:\Program Files\Erbsland\Catalog Monitor'
New-Item -ItemType Directory -Force -Path $installDirectory
Copy-Item '.\core_service_lifecycle.exe' "$installDirectory\catalog-monitor.exe"
$serviceParameters = @{
Name = 'CatalogMonitor'
BinaryPathName = '"C:\Program Files\Erbsland\Catalog Monitor\catalog-monitor.exe"'
DisplayName = 'Catalog Monitor'
Description = 'Periodically refreshes the deployment catalog index.'
StartupType = 'Automatic'
}
New-Service @serviceParameters
Start-Service -Name 'CatalogMonitor'
Get-Service -Name 'CatalogMonitor'
Without Credential, this example uses the SCM’s default LocalSystem identity.
For a deployed system, configure a less privileged, dedicated service account and grant it read and execute access to
the installation directory before the first start.
If the executable accepts options, include them after the quoted executable name in BinaryPathName.
Use the same service name for routine control and removal:
Stop-Service -Name 'CatalogMonitor'
Remove-Service -Name 'CatalogMonitor'
Stopping the service first gives the application a chance to clean up. When replacing the executable during an upgrade, stop it, atomically install the tested replacement, and start it again.
Linux with systemd
Install the executable at a stable path and create a dedicated account using the account-management policy of your
distribution.
The following /etc/systemd/system/catalog-monitor.service unit uses Type=simple because the application stays in
the foreground.
[Unit]
Description=Catalog Monitor
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/libexec/catalog-monitor
User=catalog-monitor
Group=catalog-monitor
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
Copy the binary, set conservative ownership and permissions, then load and start the unit:
$ sudo install -o root -g root -m 0755 core_service_lifecycle /usr/local/libexec/catalog-monitor
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now catalog-monitor.service
$ systemctl status catalog-monitor.service
$ journalctl -u catalog-monitor.service
By default, systemd sends SIGTERM when the unit stops.
The service lifecycle converts that signal into an orderly application shutdown, so no custom ExecStop command is
needed.
Choose TimeoutStopSec to accommodate the application’s worst-case cleanup time; after that limit, systemd may force
termination.
To stop the unit and prevent it from starting at the next boot, disable it before removing its definition:
$ sudo systemctl disable --now catalog-monitor.service
$ sudo rm /etc/systemd/system/catalog-monitor.service
$ sudo systemctl daemon-reload
macOS with launchd
A system-wide daemon belongs in /Library/LaunchDaemons and runs without a logged-in user.
A process that serves only the current graphical session is usually a launch agent instead, placed in
~/Library/LaunchAgents.
The following system daemon assumes that a dedicated _catalogmonitor account already exists.
Install the executable at its permanent path:
$ sudo install -o root -g wheel -m 0755 core_service_lifecycle /usr/local/libexec/catalog-monitor
Then save this definition as /Library/LaunchDaemons/dev.erbsland.demo.catalog-monitor.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>dev.erbsland.demo.catalog-monitor</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/libexec/catalog-monitor</string>
</array>
<key>UserName</key>
<string>_catalogmonitor</string>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/var/log/catalog-monitor.log</string>
<key>StandardErrorPath</key>
<string>/var/log/catalog-monitor-error.log</string>
</dict>
</plist>
ProgramArguments contains one array element per argument, with the absolute executable path first.
KeepAlive asks launchd to maintain the long-running process and also causes an initial launch.
For an on-demand service, declare the appropriate socket or Mach service instead of keeping the process alive
unconditionally.
Validate the file, set the ownership expected for a system daemon, and bootstrap it into the system domain:
$ plutil -lint dev.erbsland.demo.catalog-monitor.plist
$ sudo install -o root -g wheel -m 0644 dev.erbsland.demo.catalog-monitor.plist \
/Library/LaunchDaemons/dev.erbsland.demo.catalog-monitor.plist
$ sudo launchctl bootstrap system /Library/LaunchDaemons/dev.erbsland.demo.catalog-monitor.plist
$ sudo launchctl print system/dev.erbsland.demo.catalog-monitor
Use kickstart when you deliberately want to restart a loaded job.
Use bootout before deleting its definition:
$ sudo launchctl kickstart -k system/dev.erbsland.demo.catalog-monitor
$ sudo launchctl bootout system/dev.erbsland.demo.catalog-monitor
$ sudo rm /Library/LaunchDaemons/dev.erbsland.demo.catalog-monitor.plist
Reporting a Slow Windows Startup
Windows expects a service to keep the SCM informed while it starts. Erbsland Core normally handles this without application code: an application without parts becomes ready immediately before main work begins, while an application with automatically started parts remains pending until every required part has reached its running state.
Initialization outside the part manager sometimes takes longer—for example, validating a large catalog or opening a
remote data store.
Call reportStartupPending() before the lengthy phase,
update the positive estimate when meaningful progress is made, and call
reportStartupComplete() as soon as the service can do
useful work.
/// Tell the service manager about initialization work that may take a while.
///
/// The remaining time must be positive and should be a realistic upper estimate rather than a heartbeat interval.
/// Once `reportStartupPending()` is called, every successful startup path must eventually call
/// `reportStartupComplete()`.
void CatalogMonitorApplication::initialize() {
info().setApplicationName("Catalog Monitor"_el);
info().setApplicationVersion(el::Version{1, 0, 0});
// Publish an initial estimate before loading external configuration.
reportStartupPending(el::Seconds{30});
loadConfiguration();
// Refine the estimate before opening the catalog and starting periodic work.
reportStartupPending(el::Seconds{10});
openCatalog();
_refreshTimer = events()->createTimer([this]() -> void { refreshCatalog(); });
_refreshTimer->startFixedDelay(el::Seconds{30});
// Mark the service ready only after it can perform its advertised work.
reportStartupComplete();
}
void CatalogMonitorApplication::cleanup() noexcept {
_refreshTimer.reset();
el::io::printLine("Catalog monitor stopped."_el);
}
void CatalogMonitorApplication::loadConfiguration() {
el::io::printLine("Configuration loaded."_el);
}
void CatalogMonitorApplication::openCatalog() {
el::io::printLine("Catalog opened."_el);
}
void CatalogMonitorApplication::refreshCatalog() {
el::io::printLine("Catalog index refreshed."_el);
}
These reports affect a native service manager only when the executable is running as a Windows service. They have no external effect on Linux, macOS, or an interactive Windows run, which keeps platform conditionals out of the application; the framework still validates the call order and remaining duration on every platform.
An explicit pending report takes responsibility away from automatic readiness.
Every successful path after the first reportStartupPending() must therefore reach reportStartupComplete().
The completion call is idempotent, but returning to pending afterward is an error.
Reporting a zero or negative remaining duration is also an error, as is reporting state without first enabling the
service lifecycle.
The remaining duration is a wait hint, not a promise that startup may block indefinitely. Estimate the whole next phase generously and update it as work advances; do not call the method from a tight timer only to keep a stalled startup alive. If shutdown begins while initialization is still running, stop that work promptly rather than attempting to report the service as ready.
Windows Controls and Console Events
Once startup is complete, the Windows service accepts the SCM controls that have a direct application-lifecycle meaning. The following mapping is deliberately small:
Native request |
Application behavior |
|---|---|
SCM stop |
Requests |
SCM system shutdown |
Follows the same orderly shutdown path as stop. |
SCM interrogate |
Lets the SCM read the current native status; it does not call application code. |
Ctrl+C in an interactive console |
Requests the same orderly application shutdown. |
Ctrl+Break in an interactive console |
Requests the same orderly application shutdown. |
Pause, continue, parameter-change, session-change, power, and custom controls are not accepted by this lifecycle. If a service needs one of those protocols, model it through an application-level administrative interface instead of assuming that it reaches an event callback.
During an orderly stop, the framework publishes a pending state, stops application parts in reverse dependency order, finishes managed event loops, and finally reports the service stopped. A zero application result becomes a successful service stop; a nonzero result is exposed to the SCM as a service-specific failure.
Linux and macOS Signal Conventions
On POSIX systems, service integration focuses on graceful termination and leaves policy with the supervisor:
Signal |
Application behavior |
|---|---|
|
Requests |
|
Requests the same orderly shutdown, including when generated by Ctrl+C in a terminal. |
|
Is not mapped to reload; the operating system’s default handling remains in effect. |
|
Is not mapped to graceful shutdown; the operating system’s default handling remains in effect. |
In particular, do not configure SIGHUP as a reload command unless the application separately installs and owns that
behavior.
Signals that cannot be caught, such as SIGKILL, necessarily bypass application cleanup.
Set the supervisor’s shutdown timeout long enough for ordinary cleanup, but keep cleanup bounded so an administrator is
not forced to escalate to a hard kill.
The lifecycle does not fork, create a PID file, change users, redirect streams, or send systemd or launchd readiness notifications. This is intentional: systemd and launchd already own process creation and supervision, while the unit or property-list file provides a clear place for deployment policy.
A Predictable Process Boundary
Service support works best when it remains a thin boundary around an ordinary, well-structured application.
Enable it before run(), keep the process in the foreground, let application parts and event loops perform their
normal orderly cleanup, and describe identity and restart policy in the native service definition.
The result is one executable that is pleasant to debug interactively and behaves predictably when the operating system
owns its lifetime.