@push.rocks/smartdaemon
v3.0.0
Published
Start scripts as long running daemons and manage them.
Maintainers
Readme
@push.rocks/smartdaemon 🚀
Turn your Node.js scripts into production-ready system daemons
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
Seamlessly manage long-running processes, background services, and system daemons with smart root detection, automatic privilege escalation, and systemd integration.
🎯 What is SmartDaemon?
@push.rocks/smartdaemon is your Swiss Army knife for turning Node.js applications into bulletproof system services. Whether you're deploying microservices, running scheduled tasks, or managing background workers, SmartDaemon handles the complex stuff so you can focus on your business logic.
✨ Key Features
- 🔐 Smart Privilege Management: Automatically detects root status and handles sudo operations seamlessly
- 👤 User/Group Control: Run services under specific users for enhanced security
- 🔄 Systemd Integration: Full Linux systemd support for production-grade service management
- 🛡️ Auto-Recovery: Built-in service restart on failure
- 📝 Declarative Service Definitions: Simple, clear service configuration
- 🔌 Passwordless or Password-Based Sudo: Works with passwordless sudo, or hands a configured sudo password to sudo on standard input (no terminal or PTY needed)
- 🎛️ Complete Lifecycle Management: Start, stop, enable, disable, and reload services with ease
📦 Installation
# Using npm
npm install @push.rocks/smartdaemon --save
# Using pnpm (recommended)
pnpm add @push.rocks/smartdaemon
# Using yarn
yarn add @push.rocks/smartdaemon🚀 Quick Start
Basic Usage
import { SmartDaemon } from '@push.rocks/smartdaemon';
// Initialize SmartDaemon
const daemon = new SmartDaemon();
// Create and enable a service
const myService = await daemon.addService({
name: 'my-api-server',
description: 'My awesome API server',
command: 'node server.js',
workingDir: '/opt/myapp',
version: '1.0.0'
});
// Enable and start the service
await myService.enable(); // Creates systemd service file
await myService.start(); // Starts the serviceRunning Services as Specific Users 👥
Security best practice: Never run services as root unless absolutely necessary!
const webService = await daemon.addService({
name: 'web-server',
description: 'Production web server',
command: 'node app.js',
workingDir: '/var/www/app',
version: '2.1.0',
user: 'www-data', // Run as www-data user
group: 'www-data' // Run under www-data group
});
await webService.enable();
await webService.start();🔐 Smart Privilege Escalation
SmartDaemon intelligently handles privilege escalation based on your execution context:
Automatic Root Detection
// SmartDaemon automatically detects if running as root
const daemon = new SmartDaemon();
// If running as regular user, sudo will be used automatically
// If running as root, commands execute directlyWith Sudo Password
If you're not running as root and don't have passwordless sudo configured:
const daemon = new SmartDaemon({
sudoPassword: 'your-sudo-password' // Will be used for privilege escalation
});
// All privileged operations will now use the provided password
const service = await daemon.addService({
name: 'privileged-service',
description: 'Service requiring root privileges',
command: 'node admin-task.js',
workingDir: '/opt/admin',
version: '1.0.0'
});
await service.enable(); // Automatically uses sudo with passwordEach privileged command runs as sudo -k -S -p '' -- <command>: sudo reads the password from
standard input, which is closed right after the password line, so a rejected password fails at
once instead of waiting for a retry. The password is never placed in argv, the environment or the
output, and no PTY (node-pty) is involved. -k makes sudo ask for the password every time and
leaves no cached credentials behind. If a sudoers rule lets the command run without a password
(NOPASSWD), sudo does not read standard input and the password line reaches the command's
standard input instead (systemctl, mv, chmod and rm never read it); leave sudoPassword
unset when sudo is passwordless.
A command that does not exit 0 rejects with SmartDaemonCommandError (command, exitCode,
signal, stderr), with or without a sudo password: a rejected password, a missing sudo
(exit code 127) or a failing systemctl call all surface this way. Besides systemctl, saving
and deleting a unit file as a non-root user runs mv, chmod and rm through sudo.
Passwordless Sudo (Recommended for Production)
For production environments, configure passwordless sudo for specific systemctl commands:
- Create a sudoers file:
/etc/sudoers.d/smartdaemon - Add the following content:
# Allow smartdaemon to manage services without password
yourusername ALL=(ALL) NOPASSWD: /usr/bin/systemctl start smartdaemon_*
yourusername ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop smartdaemon_*
yourusername ALL=(ALL) NOPASSWD: /usr/bin/systemctl enable smartdaemon_*
yourusername ALL=(ALL) NOPASSWD: /usr/bin/systemctl disable smartdaemon_*
yourusername ALL=(ALL) NOPASSWD: /usr/bin/systemctl daemon-reload📚 Complete API
SmartDaemon Class
interface ISmartDaemonOptions {
sudoPassword?: string; // Optional sudo password for privilege escalation
}
class SmartDaemon {
constructor(options?: ISmartDaemonOptions);
// Add a new service or update existing one
async addService(options: ISmartDaemonServiceOptions): Promise<SmartDaemonService>;
// Direct access to managers (advanced usage)
systemdManager: SmartDaemonSystemdManager;
templateManager: SmartDaemonTemplateManager;
}Inspecting and controlling an existing systemd unit
SystemdUnit works with an exact canonical .service name. Construction performs
no I/O. It does not create or rewrite a unit, enable it, remove a mask, or invoke
sudo. The caller must already have permission for lifecycle commands.
import { SystemdUnit } from '@push.rocks/smartdaemon';
const unit = new SystemdUnit({ unitName: 'smartdaemon_my-api-server.service' });
const state = await unit.inspect();
console.log(state.loadState, state.activeState, state.unitFileState);
// Address the calling user's manager when running as that user.
const userUnit = new SystemdUnit({ unitName: 'authswitch.service', scope: 'user' });
const userState = await userUnit.inspect();
// Application code decides whether stopping or restarting is appropriate.
// await unit.stop();
// await unit.start();Snapshots are immutable and also include subState, mainPid, controlPid,
controlGroup, killMode, job, statusText, result and fileDescriptorStoreCount. An empty job means no
systemd job was reported; otherwise it is the pending job's id. statusText is the
service's last STATUS= notification, verbatim (empty when it sent none), and result
is systemd's Result: success, or why the service last failed (timeout,
exit-code, signal, core-dump, watchdog, start-limit-hit, oom-kill,
resources, protocol, exec-condition; a newer manager's word is still reported).
fileDescriptorStoreCount is how many descriptors the manager holds in the service's file
descriptor store right now (NFileDescriptorStore): 0 for a service without a store and for a
missing or masked unit, and possibly above 0 for a stopped service whose store is preserved.
Missing and masked units remain distinct from inactive or disabled units. Aliases
resolving to a different canonical unit are rejected.
systemdUnitQuiescence(state) reads from a snapshot, without I/O, whether the unit is at rest.
It is the package's one notion of a stopped unit; decide on it instead of comparing subState
strings. On systemd 254 and later a cleanly stopped service whose preserved file descriptor store
(FileDescriptorStorePreserve=yes) still holds descriptors reads inactive/dead-resources-pinned,
not inactive/dead, and is just as stopped:
| Field | Meaning |
| --- | --- |
| rest | 'stopped' for inactive with SubState dead or dead-resources-pinned, 'failed' for failed/failed, each only without a main or control process and without a control group; null for every other state (starting, running, stopping, about to restart, an exited oneshot) |
| jobPending | a systemd job is queued for the unit; it does not change rest |
| quiet | rest is not null and no job is queued: nothing of the unit runs or is about to |
| resourcesPinned | fileDescriptorStoreCount is above 0, whatever the state; a failed unit with FileDescriptorStorePreserve=yes keeps its store too |
import { SystemdUnit, systemdUnitQuiescence } from '@push.rocks/smartdaemon';
const state = await unit.stop();
const { quiet, rest, resourcesPinned } = systemdUnitQuiescence(state);
if (quiet && rest === 'stopped') {
// Stopped. With resourcesPinned the manager still holds the store for the next start;
// cleanFileDescriptorStore() releases it.
}start() and stop() await the systemctl command and return a fresh snapshot.
They do not establish application readiness or prove database shutdown; callers
must apply the owning application's lifecycle contract. A command failure or
timeout rejects with SystemdUnitError and a local code, without returning raw
command diagnostics. The systemd job can continue after a client timeout, so a
failed command must not be treated as cancelled or successfully completed.
A Type=notify start returns only at READY=1, which can outlast commandTimeoutMs.
start({ wait: false }) and stop({ wait: false }) pass --no-block: systemd
verifies and enqueues the job, and the call returns the snapshot read right after,
typically activating or deactivating with the job pending. A refused job (a
missing or masked unit, a conflicting transaction) still rejects with
command_failed. The caller then polls inspect(): job empties when the job
finishes, statusText carries the service's progress, and result tells a start
timeout (timeout) from a crash (exit-code, signal, core-dump). Options other
than a boolean wait reject with invalid_options before any command runs.
const pending = await unit.start({ wait: false });
// Later, on the caller's own schedule:
const state = await unit.inspect();
if (state.job === '' && state.activeState === 'failed') {
console.log(state.result === 'timeout' ? 'start stalled' : `crashed: ${state.result}`);
} else {
console.log(state.activeState, state.statusText);
}Calls use an absolute systemctlPath (default /usr/bin/systemctl), a clean
environment, no shell or password prompt, and bounded output. Trusted owning code
can choose another absolute executable path and commandTimeoutMs (default
120000, maximum 600000). This timeout bounds the client process only.
The default scope: 'system' retains system-manager behavior. scope: 'user'
uses systemctl --user as the calling user. Every SystemdUnit operation contacts
the user manager, so each requires a real, owned, private XDG_RUNTIME_DIR (or an
explicit runtimeDirectory) and passes that directory with the user's home and any
set XDG_CONFIG_HOME and XDG_DATA_HOME to the command. Before each call, all of
them must be absolute, normalized paths without a trailing slash, and the runtime
directory is checked again on disk; a missing, malformed, replaced, linked or
group-accessible runtime directory, a malformed XDG root and a missing or malformed
home directory each fail with invalid_options before contacting systemd.
Construction validates only the options it is given: the unit name, systemctlPath,
busctlPath, commandTimeoutMs, scope and a supplied runtimeDirectory, which must be a
well-formed path (and is refused for system scope). It reads, but does not validate,
the process environment above, so a unit can be constructed without a login session
(cron, a plain ssh command) or with a malformed XDG variable; its calls then refuse.
An empty XDG variable counts as unset. The environment is captured at construction;
later changes to process.env do not affect an existing unit.
cleanFileDescriptorStore() releases the service's file descriptor store with
systemctl clean --what=fdstore, closing every descriptor the manager holds for it, and returns a
fresh snapshot; nothing else of the unit is cleaned. The manager refuses, and the call rejects with
command_failed, unless the unit is loaded, inactive (a failed unit needs systemctl
reset-failed first) and without a pending job, and its loaded FileDescriptorStoreMax is above
zero or the store still holds a descriptor. A stopped unit whose store was pinned
(dead-resources-pinned) reads dead after the release. Once the manager has accepted the clean, every outcome
states that the store was released: the call resolves with the snapshot read afterwards, or, when
that inspection fails, rejects with released_uninspected and the inspection's error as cause.
The snapshot reports the unit as it is when read: its fileDescriptorStoreCount is 0 unless the
unit was started right after the release and stored descriptors again, which a caller must not
read as a failed clean. A refused clean, or one that could not be run, rejects with
command_failed; so does a clean whose systemctl call exceeded commandTimeoutMs, whose outcome
is unknown because the manager may have released the store before the client was killed. After
command_failed a caller must inspect() the unit before treating the store as kept. Cleaning a
store needs systemd 254 or later: the call reads the manager's version first and refuses an older
manager with SystemdUnitManagerTooOldError (manager_too_old), an unreadable version with
version_unknown and an unanswered read with command_failed, each without running the clean.
inspectConfiguration() reports the selected fragmentPath, opaque dropInPaths
list and needsReload flag. reloadConfiguration() explicitly reloads the manager's
unit definitions globally and returns a fresh configuration snapshot. Neither
operation changes enablement or masks.
inspectServiceSettings() reports the loaded service settings a definition renders, drop-ins
included: type, notifyAccess, restart, killMode (systemd's own words; type is empty
for a unit systemd could not load), delegate, restartPreventExitStatus and
successExitStatus (each { statuses, signals }: exit statuses ascending, and signals by
systemd's name without SIG, such as TERM, which only a hand-written unit or drop-in sets),
startLimitBurst, startLimitIntervalMicroseconds (0 when the start rate limit is disabled),
restartMicroseconds, timeoutStopMicroseconds and timeoutStartMicroseconds (exact integers or 'infinity';
the start timeout is the loaded TimeoutStartSec=, without any EXTEND_TIMEOUT_USEC=
extension a running start requested), limitNoFile and limitNoFileSoft
(hard and soft), tasksMax (integers or 'infinity'), oomScoreAdjust, fileDescriptorStoreMax
(0 without a store), fileDescriptorStorePreserve (no, yes or restart) and environment, the
unit's own Environment= variables as a frozen name-to-value object (variables the manager gives
every unit, EnvironmentFile= and PassEnvironment= are not included). A missing, repeated,
foreign or malformed property rejects with invalid_state; this includes an Environment= value
holding a control character, which systemctl show prints as a C escape. inspectServiceSettings()
needs systemd 254 or later, the first to report FileDescriptorStorePreserve: it reads the manager's
version first and refuses an older manager with SystemdUnitManagerTooOldError (manager_too_old)
and an unreadable version with version_unknown, both before the unit is read (see The manager's
version below). Every environment a SystemdServiceDefinition accepts
reads back. Reload changed files first; the snapshot describes what the manager loaded.
execStart lists every ExecStart= command of the loaded unit, drop-ins included, in the order
the manager runs them, each as a frozen ISystemdExecCommand: path (the executable), argv
(the argument vector, argv[0] included, one string per argument exactly as loaded; %
specifiers resolved as systemd loaded the unit, $VARIABLES and $$ as written) and ignoreFailure (a - prefix). It is empty for a unit
that sets none or that the manager could not find. systemctl show joins argv with spaces, so
an argument holding a space could not be told apart there; this field is read instead from the
unit's D-Bus ExecStart property (a(sasbttttuii)) with busctl --json=short get-property,
through an absolute busctlPath (default /usr/bin/busctl, systemd 240 or newer) with the same
clean environment and timeout. The system scope reads the system bus; the user scope reads the
user's bus at $XDG_RUNTIME_DIR/bus, so a user manager without a user D-Bus fails
inspectServiceSettings() with command_failed. A failed bus read rejects with
command_failed, and a reply that is not exactly that signature with an absolute path, a
non-empty argv of strings and a boolean flag per command rejects with invalid_state.
const settings = await new SystemdUnit({ unitName: 'pallet-containerd.service' }).inspectServiceSettings();
const carriesContract = settings.type === 'notify' && settings.notifyAccess === 'all' &&
settings.killMode === 'process' && settings.delegate && settings.limitNoFile === 'infinity' &&
settings.limitNoFileSoft === 'infinity' && settings.tasksMax === 'infinity' &&
settings.oomScoreAdjust === -999 && settings.restart === 'always' &&
settings.restartMicroseconds === 2_000_000 && settings.timeoutStopMicroseconds === 60_000_000;
// The exact command line the manager runs, argument by argument.
const runsServe = settings.execStart.length === 1 &&
settings.execStart[0].argv.join('\0') === ['/opt/pallet/pallet-control', 'containerd-serve'].join('\0');enable() persistently enables the existing unit definition and returns a fresh
state snapshot. It does not rewrite the definition, start the unit, unmask it or
force conflicting links. Run it inside the owning installer's cross-process
installation scope, after any required offline commissioning. A failed or timed
out command can leave partial enablement; inspect and retry the exact operation
without assuming cancellation or undoing the application's retained protection.
disable() is its counterpart: it removes the enablement links the unit's [Install]
section names (systemctl disable) and returns a fresh snapshot. It does not stop a running
unit, mask, unmask or remove the definition, so a disabled service keeps running until it is
stopped. A failed or timed out command can leave partial enablement as well; inspect and retry.
Disable a unit before SystemdUnitFile.uninstall() removes its definition.
inspectRelationships() returns immutable sorted arrays for before, after,
requires, wants, conflicts, requiredBy and wantedBy from the loaded manager
graph. Literal systemd hex escapes are preserved. The bounded snapshot rejects
missing fields, foreign identities, duplicate relationships and malformed names.
unitFileState === 'enabled' proves only that some enablement links exist. For a
boot guard, verify that its requiredBy and before both include every required
target and the actual network provider; inspect the provider's requires and
after as well. Recheck the exact file digest, selected fragment, absence of
drop-ins and needsReload === false before permitting dependent activation.
The manager's version
inspectManagerVersion() reads the running manager's version through the unit's scope — the
system manager, or the calling user's manager for scope: 'user' — with systemctl show
--property=Version, the same systemctlPath, environment and timeout as every other call. It
resolves with a frozen ISystemdManagerVersion: major (255) and version, the manager's
Version property verbatim (255.4-1ubuntu8.17). This is the running manager's version, which after
a package upgrade differs from systemctl --version until the manager re-executes. A manager that
does not answer rejects with command_failed; an answer that is not a single Version= line with a
version starting with a major of one to four digits rejects with version_unknown.
requireManagerVersion(requiredMajor) resolves with that version when its major is at least
requiredMajor (an integer from 1 to 9999; anything else rejects with invalid_options before any
command) and otherwise rejects with a SystemdUnitManagerTooOldError: a SystemdUnitError with code
manager_too_old that carries the managerVersion it read and the requiredMajor. Calls that need a
newer manager use it and fail closed: inspectServiceSettings(), cleanFileDescriptorStore() and
installing a definition with a requiredManagerMajor (see SystemdUnitFile.install()), so none fails
late with a generic code. runInTransientScope() refuses an older user manager the same way with its
own SystemdTransientScopeManagerTooOldError.
import { SystemdUnit, SystemdUnitManagerTooOldError } from '@push.rocks/smartdaemon';
const unit = new SystemdUnit({ unitName: 'pallet-node.service' });
console.log((await unit.inspectManagerVersion()).major); // 255
try {
await unit.inspectServiceSettings();
} catch (error) {
if (error instanceof SystemdUnitManagerTooOldError) {
// e.g. 'systemd 253 (253.5-1) running, 254 required'
console.error(`systemd ${error.managerVersion.major} (${error.managerVersion.version}) running, ` +
`${error.requiredMajor} required`);
}
throw error;
}Installing a direct systemd service definition
SystemdServiceDefinition encodes a literal executable and argument list, with
explicit restart policy, kill mode and stop timeout. System-scope definitions require
user and group; user-scope definitions reject both directives because the user
manager already runs under the calling identity. It defaults to Type=exec,
journal output (see standardOutput and standardError below) and null stdin. It introduces no shell; percent specifiers and
argument environment expansion are escaped. Paths must be absolute and normalized.
Descriptions, paths and arguments reject control characters, lone surrogates and Unicode
noncharacters (U+FDD0–U+FDEF and every code point ending in FFFE or FFFF), which systemd
does not accept as UTF-8 clean and would ignore, leaving the unit unloadable. Descriptions and working directories cannot have
backslashes or surrounding whitespace; executable paths also reject quotes and
backslashes. Arguments may contain spaces, quotes, backslashes and empty strings.
import { SystemdServiceDefinition, SystemdUnitFile } from '@push.rocks/smartdaemon';
const definition = new SystemdServiceDefinition({
unitName: 'smartdaemon_my-api-server.service',
description: 'My API server',
executable: '/opt/my-api/bin/server',
args: ['serve'],
workingDirectory: '/opt/my-api',
user: 'root',
group: 'root',
restart: 'no',
killMode: 'mixed',
timeoutStopSeconds: 360,
});
const file = new SystemdUnitFile({ unitName: definition.unitName });
// Inside the privileged installer's existing cross-process ownership scope:
const previous = await file.inspect();
const installed = await file.install(definition, previous?.sha256 ?? null);
console.log(installed.changed, installed.masked, installed.record.sha256);For a user service, give the definition and installer the same scope:
const userDefinition = new SystemdServiceDefinition({
scope: 'user',
unitName: 'authswitch.service',
description: 'Account authority',
executable: '/opt/authswitch/bin/authswitch',
args: ['daemon'],
workingDirectory: '/opt/authswitch',
restart: 'on-failure',
killMode: 'mixed',
timeoutStopSeconds: 60,
});
const userFile = new SystemdUnitFile({ scope: 'user', unitName: userDefinition.unitName });
const previousUserFile = await userFile.inspect();
await userFile.install(userDefinition, previousUserFile?.sha256 ?? null);User definitions default to WantedBy=default.target and have no system-only
After=local-fs.target edge. User unit files default to
$XDG_DATA_HOME/systemd/user (or ~/.local/share/systemd/user), below the user's
config directory in the unit load path. Installation and enablement use the
current user manager and do not escalate privileges. The installer rejects unsafe
ownership or group-writable path components; prepare those directories explicitly
before installation. It never changes the permissions of existing directories.
A user-scope SystemdUnitFile validates each value where it is read:
- Construction validates the unit's options as
SystemdUnitdoes, a suppliedunitDirectoryandexpectedUid(which must equal the caller's uid). WithoutunitDirectory, it also derives the publicpathfromXDG_DATA_HOME, or from the home directory when that is unset, and rejects a missing or malformed source there withinvalid_options; no other process value is validated at construction. inspect()reads only the unit directory as that uid. It needs no user manager, no runtime directory and no XDG config root, so with an explicitunitDirectoryit works whateverXDG_CONFIG_HOME,XDG_DATA_HOME,XDG_RUNTIME_DIRand the home directory hold.install()admits, reloads and verifies through the manager and recognizes the user's persistent and runtime mask paths. It therefore needs everything the manager calls need, plus a well-formed config root (XDG_CONFIG_HOME, or~/.config), and rejects anything missing or malformed withinvalid_optionsbefore touching any directory or runningsystemctl.uninstall()needs the same and refuses the same way, before removing anything.
An owner can therefore ask whether its definition is installed from a context without a login session:
// Resolves to null when the file is absent, or { path, sha256 } for a protected definition.
const installed = await new SystemdUnitFile({ scope: 'user', unitName: 'authswitch.service' }).inspect();inspect() and install() check every component of the unit directory from /
down, then the unit file itself. A refusal is a SystemdUnitFileUnsafePathError, a
SystemdUnitFileError with code unsafe_path that names the offending path and a
reason of type TSystemdUnitFileUnsafePathReason, so the owner knows exactly what
to fix:
| reason | path | Refused because |
| --- | --- | --- |
| unsupported_platform | unit directory | the process does not run on Linux |
| foreign_process | unit directory | the process does not run as the expected uid |
| symlink | directory component or unit file | it is a symbolic link |
| not_directory | directory component | it is not a directory |
| foreign_owner | directory component or unit file | a directory is owned by neither root nor the expected uid, or the file not by the expected uid |
| group_or_world_writable | directory component | it is group- or world-writable (only a root-owned mode-1777 /tmp is admitted) |
| unreadable | unit file | it cannot be opened; the operating-system error is the cause |
| not_regular_file | unit file | it is not a regular file |
| hard_linked | unit file | it has more than one hard link |
| wrong_mode | unit file | its mode is not exactly 0644 |
| too_large | unit file | it is larger than 16384 bytes |
| replaced | unit directory or a file being written | it changed identity between two checks during install() |
import { SystemdUnitFileUnsafePathError } from '@push.rocks/smartdaemon';
try {
await file.inspect();
} catch (error) {
if (error instanceof SystemdUnitFileUnsafePathError) {
// e.g. 'group_or_world_writable' '/home/user/.local' after `chmod 0775 ~/.local`
console.error(error.reason, error.path);
}
throw error;
}For a boot prerequisite that must finish before dependent services start, supply
startup: { type: 'oneshot', remainAfterExit: true, timeoutStartSeconds: 120 }.
Systemd waits for successful process exit; RemainAfterExit keeps the completed
unit active. The start timeout is an integer from 1 to 600 seconds. Oneshot
services reject restart: 'always'; the default exec definition remains unchanged.
A long-running service that reports its own readiness uses
startup: { type: 'notify', notifyAccess: 'all', timeoutStartSeconds: 120 }, rendered as
Type=notify, NotifyAccess= and TimeoutStartSec=. Systemd counts the start complete when a
process admitted by notifyAccess sends READY=1: 'main' admits only the main process,
'exec' also the processes of the unit's Exec*= commands, 'all' every process in the unit's
cgroup, as a ready notification from a child of the main process needs. NotifyAccess=none is
not offered because it discards every notification. Notify services may use restart: 'always'.
These optional settings render after TimeoutStopSec= in this order, and nothing when omitted,
so existing definitions keep their exact bytes:
| Option | Directive | Accepted values |
| --- | --- | --- |
| restartSeconds | RestartSec= (after Restart=) | integer 1–600; refused with restart: 'no' |
| restartPreventExitStatus | RestartPreventExitStatus= (after RestartSec=) | distinct integers 0–255, at least one, rendered ascending; refused with restart: 'no' |
| successExitStatus | SuccessExitStatus= (after RestartPreventExitStatus=) | distinct integers 0–255, at least one, rendered ascending |
| startLimitIntervalSeconds | StartLimitIntervalSec= in [Unit], after the relations | integer 0–86400; 0 disables the start rate limit |
| startLimitBurst | StartLimitBurst= in [Unit], after StartLimitIntervalSec= | integer 1–1000; refused with startLimitIntervalSeconds: 0 |
| delegate | Delegate= | boolean |
| limitNoFile | LimitNOFILE= (soft and hard) | positive integer or 'infinity' |
| tasksMax | TasksMax= | positive integer or 'infinity' |
| oomScoreAdjust | OOMScoreAdjust= | integer −1000–1000 |
| environment | one Environment= line per variable, ordered by name | plain object of string values; see below |
const containerd = new SystemdServiceDefinition({
unitName: 'pallet-containerd.service',
description: 'Pallet containerd',
executable: '/opt/pallet/current/pallet-control',
args: ['containerd-serve'],
workingDirectory: '/',
user: 'root',
group: 'root',
restart: 'always',
restartSeconds: 2,
killMode: 'process',
timeoutStopSeconds: 60,
startup: { type: 'notify', notifyAccess: 'all', timeoutStartSeconds: 120 },
dependencies: {
defaultDependencies: true,
after: ['pallet-guard.service'],
before: ['pallet-node.service'],
requires: ['pallet-guard.service'],
wants: [],
conflicts: [],
},
delegate: true,
limitNoFile: 'infinity',
tasksMax: 'infinity',
oomScoreAdjust: -999,
});A service that restarts on failure but must not loop on a permanent refusal names the exit
statuses that end it for good, and disables the start rate limit so the manager's
DefaultStartLimitIntervalSec=/DefaultStartLimitBurst= cannot make it give up during a long
outage of something it depends on:
const node = new SystemdServiceDefinition({
unitName: 'spark.service',
description: 'serve.zone Spark node',
executable: '/opt/spark/current/spark',
args: ['runnode'],
workingDirectory: '/opt/spark',
user: 'root',
group: 'root',
restart: 'on-failure',
restartSeconds: 5,
killMode: 'mixed',
timeoutStopSeconds: 360,
restartPreventExitStatus: [78], // EX_CONFIG: a permanent refusal
startLimitIntervalSeconds: 0,
});renders StartLimitIntervalSec=0s as the last [Unit] line and
Restart=on-failure, RestartSec=5s, RestartPreventExitStatus=78 in [Service]. Systemd 230
and later read the start rate limit from [Unit] only; under systemd 255 systemd-analyze verify
loads such a unit without a warning.
Systemd applies these within what the manager itself may grant. Under systemd 255 in an
unprivileged container, this unit started with the manager's own open-file hard limit for
LimitNOFILE=infinity and without the lowered OOM score, and still reported active.
inspectServiceSettings() reports the configured values, not the running process's limits.
environment gives the service's processes literal variables, for example the installer's PATH
when the executable or the tools it starts live outside the manager's default search path (a user
manager's default PATH omits ~/.local/bin and nvm's Node directories):
const authority = new SystemdServiceDefinition({
scope: 'user',
unitName: 'authority.service',
description: 'Authority daemon',
executable: '/home/owner/.nvm/versions/node/v24.9.0/bin/node',
args: ['/home/owner/app/cli.js', 'serve'],
workingDirectory: '/home/owner',
restart: 'on-failure',
killMode: 'mixed',
timeoutStopSeconds: 30,
environment: { PATH: process.env.PATH!, CODEX_HOME: '/home/owner/.codex' },
});
// Environment="CODEX_HOME=/home/owner/.codex"
// Environment="PATH=/home/owner/.nvm/versions/node/v24.9.0/bin:/home/owner/.local/bin:/usr/bin:/bin"The value must be a plain object (not a proxy, class instance or null-prototype object) whose own
properties are enumerable data properties. Names match [A-Za-z_][A-Za-z0-9_]* and have at most
128 characters; values are strings of at most 4096 characters without control characters, lone surrogates or
Unicode noncharacters, and may be empty. At most 64 variables and 8192 UTF-8 bytes of NAME=value in total are
accepted, and the whole unit stays within 16384 bytes. Anything else throws
SystemdServiceDefinitionError. Each assignment is double-quoted with \ and " backslash-escaped
and % doubled, so systemd applies no specifier; Environment= never expands $, so values are
taken literally. An empty object renders nothing. Systemd merges these variables over its own
(HOME, USER, INVOCATION_ID, …), so a name such as NOTIFY_SOCKET overrides the manager's value.
standardOutput and standardError say where the service's output streams go
(StandardOutput=, StandardError=): 'journal', the default, 'null', which discards the
stream, or { append: path }, which appends it to a file (append:, systemd 240 and later).
Omitted, both render journal as before, so existing definitions keep their bytes.
const controller = new SystemdServiceDefinition({
scope: 'user',
unitName: 'app-controller.service',
description: 'App controller',
executable: '/home/owner/.local/share/pnpm/app',
args: ['serve'],
workingDirectory: '/home/owner',
restart: 'on-failure',
killMode: 'process',
timeoutStopSeconds: 90,
standardOutput: 'null',
standardError: { append: '/home/owner/.config/app/logs/controller.log' },
});
// StandardOutput=null
// StandardError=append:/home/owner/.config/app/logs/controller.logThe path must be absolute and normalized, without a backslash, surrounding whitespace or a
trailing slash; % is doubled, so systemd applies no specifier. The service manager opens the file
before it invokes the executable and before it switches to the service's User=, so with its own
privileges: as root under the system manager, as the user under a user manager. It creates a missing
file owned by that user with mode 0666 masked by the unit's umask (0644 under the usual umask 0022).
Its directory must already exist, or the service fails to start. A caller that needs another owner
or a private file creates it with the owner and mode it wants first; an existing file keeps both.
Systemd reads append: from version 240 and an older manager would ignore the line, so a definition
with an append output has requiredManagerMajor 240 (254 with fileDescriptorStorePreserve as
well) and SystemdUnitFile.install() refuses it on an older manager with manager_too_old. The manager reports only the kind of output (append) as a property, not the path, so
inspectServiceSettings() does not report it: verify an installed definition by its digest.
Notify access and the file descriptor store
A service that keeps a descriptor across its own restarts, such as a network namespace a child
process created, hands it to the manager with sd_notify FDSTORE=1 (optionally FDNAME=), and
the manager passes it back to the next main process through LISTEN_FDS/LISTEN_FDNAMES:
| Option | Directive | Accepted values |
| --- | --- | --- |
| notifyAccess | NotifyAccess=, right after the startup lines | 'none', 'main', 'exec' or 'all'; refused beside a notify startup, which names its own |
| fileDescriptorStoreMax | FileDescriptorStoreMax= (after OOMScoreAdjust=) | integer 1–65536 |
| fileDescriptorStorePreserve | FileDescriptorStorePreserve= (after FileDescriptorStoreMax=) | 'no', 'yes' or 'restart'; requires fileDescriptorStoreMax |
'main' admits notifications from the main process only and 'exec' from the main and control
processes the manager started; a descriptor a child of the main process stores needs 'all'.
Without notifyAccess systemd 255 admits the main process for a service with a store, and it
loads an explicit NotifyAccess=none beside a store as main, so 'none' is refused there.
fileDescriptorStorePreserve: 'no' releases the store when the service stops, 'restart'
(systemd's default) once it is neither active nor about to restart, and 'yes' only when the
unit leaves the manager's memory or SystemdUnit.cleanFileDescriptorStore() releases it. A reboot
empties the store; a systemctl soft-reboot keeps the running kernel, so a preserved store and the
descriptors in it pass on to the next userspace boot. Systemd reads FileDescriptorStorePreserve= from version 254
and an older manager would ignore the line, so a definition with fileDescriptorStorePreserve has
requiredManagerMajor 254 and SystemdUnitFile.install() refuses it on an older manager with
SystemdUnitManagerTooOldError (manager_too_old) before touching anything. requiredManagerMajor is
the highest major any rendered line needs (240 for an append output, see above); a definition with
neither has it null. Sending FDSTORE=1 needs an AF_UNIX socket, so a definition
with a store or a notify access other than 'none' that restricts its socket families must allow
AF_UNIX. Omitted, each option renders nothing.
const node = new SystemdServiceDefinition({
unitName: 'pallet-node.service',
description: 'serve.zone Pallet node',
executable: '/opt/pallet/current/pallet-control',
args: ['runtime-unit'],
workingDirectory: '/',
user: 'root',
group: 'root',
restart: 'on-failure',
restartSeconds: 5,
killMode: 'mixed',
timeoutStopSeconds: 360,
notifyAccess: 'all',
fileDescriptorStoreMax: 1,
fileDescriptorStorePreserve: 'yes',
});
// Type=exec, NotifyAccess=all, …, FileDescriptorStoreMax=1, FileDescriptorStorePreserve=yesUnder systemd 255 systemd-analyze verify loads such a unit without a warning.
Credentials, a dynamic user and the sandbox
A system service can receive secrets as systemd credentials instead of variables, run as a
transient account and be confined. Each option below renders nothing when omitted, so existing
definitions keep their exact bytes; false renders the directive as no. A user service refuses
all of them, since a user manager cannot grant them. DynamicUser= follows Group=; the others
follow the environment lines in this order:
| Option | Directive | Accepted values |
| --- | --- | --- |
| dynamicUser | DynamicUser= | boolean; with true, user and group name the allocated account |
| loadCredentials | one LoadCredential= line per credential, ordered by name | array of { name, source? } |
| loadCredentialsEncrypted | one LoadCredentialEncrypted= line per credential, ordered by name | array of { name, source? } |
| noNewPrivileges | NoNewPrivileges= | boolean |
| protectSystem | ProtectSystem= | boolean, 'full' or 'strict' |
| protectHome | ProtectHome= | boolean, 'read-only' or 'tmpfs' |
| privateTmp, privateDevices, protectKernelTunables, protectKernelModules, protectKernelLogs, protectControlGroups, protectClock, protectHostname, restrictNamespaces, restrictRealtime, restrictSUIDSGID, lockPersonality | the directive of the same name, in this order | boolean |
| capabilityBoundingSet | CapabilityBoundingSet= | distinct capabilities from systemdCapabilities, sorted; empty drops every capability |
| ambientCapabilities | AmbientCapabilities= | distinct capabilities, sorted; within capabilityBoundingSet when both are given |
| restrictAddressFamilies | RestrictAddressFamilies= | at least one distinct family of AF_INET, AF_INET6, AF_NETLINK, AF_PACKET, AF_UNIX, AF_VSOCK, sorted; includes AF_UNIX for a notify service |
const relay = new SystemdServiceDefinition({
unitName: 'relay.service',
description: 'Cluster relay',
executable: '/opt/relay/current/relay',
args: [],
workingDirectory: '/opt/relay/current',
user: 'relay',
group: 'relay',
dynamicUser: true,
restart: 'always',
restartSeconds: 2,
killMode: 'mixed',
timeoutStopSeconds: 60,
dependencies: {
defaultDependencies: true,
after: ['network-online.target'],
before: ['node.service'],
requires: [],
wants: ['network-online.target'],
conflicts: [],
},
environment: { RELAY_BIND_PORT: '8443' },
loadCredentials: [{ name: 'RELAY_AUTHORIZATION' }],
noNewPrivileges: true,
protectSystem: 'strict',
protectHome: true,
privateTmp: true,
privateDevices: true,
capabilityBoundingSet: [],
restrictAddressFamilies: ['AF_INET', 'AF_INET6', 'AF_UNIX'],
});
// DynamicUser=yes
// LoadCredential=RELAY_AUTHORIZATION
// CapabilityBoundingSet=
// RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIXA credential's value never appears in the unit, in the service's environment (so child processes
do not inherit it) or in systemctl show: the service reads it from
$CREDENTIALS_DIRECTORY/<name>, readable by the service's user only. name is 1–255 characters of
[A-Za-z0-9_.@-], not starting with .. source is either an absolute normalized path of
[A-Za-z0-9_.@/+-] characters without a trailing / (a file, a directory of files or an AF_UNIX
socket), or a credential name that systemd looks up among its own credentials and then in
/etc/credstore/, /run/credstore/ and /usr/lib/credstore/ (for encrypted credentials also the
credstore.encrypted directories). Without source, systemd looks up name itself, so
{ name: 'RELAY_AUTHORIZATION' } reads /etc/credstore/RELAY_AUTHORIZATION; a source equal to
name is refused, as it only spells the same lookup twice. These characters need no quoting and
hold no % specifier, : separator or escape, so systemd reads each line exactly as written.
Encrypted credentials are sealed with systemd-creds encrypt (host key or TPM2). Systemd keeps both
kinds in one table by name, so a name appears in at most one list; at most 64 per list. The
directory search needs systemd 250 or later; an absolute source works from systemd 247.
dynamicUser: true allocates the account when the service starts. Systemd would use a static
account of the same name instead, so user and group must not be root, and they have at most
31 characters, the longest name systemd allocates. For such a service systemd enforces
ProtectSystem=strict, PrivateTmp=yes, NoNewPrivileges=yes, RestrictSUIDSGID=yes and at least
ProtectHome=read-only; a definition that states any of them weaker is refused, so the unit never
reads as less confined than it is.
Each refusal throws SystemdServiceDefinitionError, whose option names the offending option (for
every option of a definition, not only these) and whose message is
Invalid direct systemd service definition: <option>.. option is 'options' for the definition
object itself: not a plain object, an unknown or accessor key, or a unit over 16384 bytes. No
refusal carries the offending value.
Optional dependencies describes the complete literal runtime relationships:
dependencies: {
defaultDependencies: false,
after: [],
before: ['network-pre.target', 'shutdown.target', 'systemd-networkd.service'],
requires: [],
wants: ['network-pre.target'],
conflicts: ['shutdown.target'],
},
install: { wantedBy: [], requiredBy: ['network-pre.target', 'systemd-networkd.service'] },System definitions always retain After=local-fs.target. Disable default dependencies only for
an intentionally early boot or late shutdown service. Every list is bounded to
32 distinct literal unit names, sorted deterministically; templates, specifiers,
self references and contradictory direct relationships are rejected. These local
checks do not prove the complete host dependency graph is free of cycles.
Consumers must declare both requires: ['the-bootstrap.service'] and
after: ['the-bootstrap.service'] for a failed bootstrap to prevent their start.
Ordering alone does not propagate failure. A prerequisite must actually fail its
start on failure; a skipped condition is not equivalent. Reverse stop ordering
keeps the prerequisite active until its ordered consumers stop.
The example assumes the host uses systemd-networkd.service: requiring the guard
only from network-pre.target does not block a network manager that merely orders
itself after that target. Select and verify the actual host network owner's edges.
install supplies only WantedBy/RequiredBy metadata, with at least one target.
The privileged installer must separately enable and verify the intended links;
rendering or installing the file does not activate them. A successful oneshot
proves only that its executable returned success, so that executable must finish
and verify all required work before exiting. See the upstream
service readiness
and dependency semantics.
test/native/qualify-systemd.py exercises the generated units under Ubuntu systemd
PID 1 across provisioning, successful cold boot and failed cold boot. It checks
actual networkd failure coupling and reverse shutdown order in a disposable QEMU
overlay without an external network backend. The fixture requires QEMU/KVM,
OVMF, xorriso, Python PyYAML and global tsx. Supply the unmodified
Ubuntu Noble 20260826 image
with SHA-256 d0fe84bb5f80853425fa6be28e2c106f30104c3cfe8611933f2e65c9b63f0e30:
python3 test/native/qualify-systemd.py --image /path/to/noble-server-cloudimg-amd64.img \
--work-root /tmp/smartdaemon-systemd-qualification-localTo exercise the public enablement and relationship APIs in the same guest, bundle
test/native/activate-systemd.ts as a self-contained Node ESM file and also supply
--node /path/to/node --activation-bundle /path/to/activate.mjs. Any library absent
from the minimal image can be supplied explicitly with repeatable
--node-library /path/to/libname.so.N. These hashed test inputs travel on a
read-only ISO. The API fixture checks enablement without implicit start, oneshot
readiness, unchanged definition/configuration, detection of a missing provider
link despite enabled state, exact enable retry and preservation of a mask.
Installation requires Linux, the relevant manager's user identity and a quiescent
exact unit with no process,
cgroup or job. It rejects effective drop-ins and shadowing fragments. Definitions
default to /usr/local/lib/systemd/system for system scope and the user's data
unit directory for user scope, below administrator or user configuration in the
load path, so existing persistent/runtime masks remain intact. Existing
enablement is preserved; an absent unit becomes disabled. It never enables,
unmasks, starts or stops a service, or invokes sudo.
A quiescent unit is one systemdUnitQuiescence() reads as quiet, so a stopped service whose
preserved file descriptor store holds descriptors (dead-resources-pinned) is admitted, and
installation keeps that store: the manager carries the descriptors across the daemon-reload and
hands them back to the reloaded unit, up to the reloaded definition's FileDescriptorStoreMax, and
closes any beyond it. Only SystemdUnit.cleanFileDescriptorStore() releases a store on purpose.
Masked system units may report /dev/null or their exact administrator mask path:
/etc/systemd/system/<unit> for a persistent mask, /run/systemd/system/<unit>
for a runtime mask. Installation preserves that exact manager-selected fragment
through the write and reload, and never replaces the masking fragment itself.
For user scope, the exact user config or runtime mask path and the global user-unit
mask paths are also recognized.
Pending manager reloads are rejected before writing; inspect and explicitly
reload an existing changed configuration before retrying installation.
Each of these manager checks refuses with a SystemdUnitFileUnsafeUnitError, a
SystemdUnitFileError with code unsafe_unit whose reason of type
TSystemdUnitFileUnsafeUnitReason says what to resolve. It also carries the
state snapshot (ISystemdUnitState) the check read and, for the configuration
reasons, the configuration snapshot (ISystemdUnitConfigurationState), otherwise
null:
| reason | configuration | Refused because |
| --- | --- | --- |
| active | null | the unit is not at rest (systemdUnitQuiescence()): it has a main or control process or a control group, or is neither inactive/dead, inactive/dead-resources-pinned nor failed/failed: stop it first |
| job_pending | null | a systemd job is queued for the otherwise quiet unit |
| unexpected_load_state | null | the load state is none of loaded, masked and not-found |
| unexpected_file_state | null | the unit-file state does not fit the load state (loaded: enabled, enabled-runtime or disabled; masked: masked or masked-runtime; not-found: none) |
| reload_pending | snapshot | the manager needs a daemon-reload for the unit |
| drop_ins | snapshot | the unit has effective drop-in files (dropInPaths) |
| unexpected_fragment | snapshot | the selected fragmentPath is not the unit path (none for an absent unit), or a masked unit's is neither /dev/null nor its matching mask path, or is the unit path itself |
import { SystemdUnitFileUnsafeUnitError } from '@push.rocks/smartdaemon';
try {
await file.install(definition, installed?.sha256 ?? null);
} catch (error) {
if (error instanceof SystemdUnitFileUnsafeUnitError && error.reason === 'active') {
// e.g. 'active' 'running' 4711: the service must be stopped before its definition is replaced
console.error(error.state.activeState, error.state.subState, error.state.mainPid);
}
throw error;
}The same checks rerun after the manager reload; a refusal there is reported as
reload_failed, because the new definition is already in place, and the
SystemdUnitFileUnsafeUnitError is its cause. Every other reload_failed also
keeps the underlying error as cause, such as the SystemdUnitError of a failed
systemctl daemon-reload.
The caller must already serialize privileged installation across processes;
SystemdUnitFile only excludes simultaneous operations on the same instance.
The expected previous SHA-256 is mandatory (null requires an absent file),
while already matching bytes are idempotent. Installation checks protected
directories and regular single-link mode-0644 files, retains changed definitions
as <unit>.previous-<sha256>, flushes them before atomic replacement, reloads
the manager and checks its selected fragment and final bytes. A reload failure
leaves the new file and retained previous definition in place; recovery requires
inspection, never an automatic rollback. A definition whose requiredManagerMajor is not null is
admitted only on a manager at least that new, read with SystemdUnit.requireManagerVersion() before
any directory, file or other systemctl effect; that read's refusal propagates unchanged as the
unit's SystemdUnitError: manager_too_old (a SystemdUnitManagerTooOldError), version_unknown,
or command_failed when the manager does not answer. Filesystem and definition errors use
SystemdUnitFileError with a local code; every unsafe_path refusal is a
SystemdUnitFileUnsafePathError naming the offending path and its reason, and
every unsafe_unit refusal is a SystemdUnitFileUnsafeUnitError naming its reason
with the manager snapshots it read. Trusted qualification code can set
unitDirectory, expectedUid and the inherited SystemdUnit command options;
user scope requires expectedUid to match the caller's actual uid. The manager's
selected fragment must match the installed file after reload, so an unrecognized
custom user unit path fails closed.
Removing an installed definition
uninstall(expectedSha256) removes what install() wrote: the unit file, when it holds exactly
the bytes expectedSha256 digests, and every previous definition retained beside it
(<unit>.previous-<sha256>), then reloads the manager and verifies its view.
const unit = new SystemdUnit({ scope: 'user', unitName: 'authswitch.service' });
const userFile = new SystemdUnitFile({ scope: 'user', unitName: 'authswitch.service' });
await unit.stop();
await unit.disable();
const installed = await userFile.inspect();
if (installed) {
const result = await userFile.uninstall(installed.sha256);
console.log(result.removed, result.masked, result.removedPaths);
}It admits the unit only at rest (systemdUnitQuiescence()) with no job queued, no pending reload
and no drop-ins, while the manager still selects this file, and refuses with the same
SystemdUnitFileUnsafeUnitError reasons as installation. Removal admits fewer unit-file states: a
loaded unit must be disabled, so no enablement link outlives its definition (an enabled one is
refused with unexpected_file_state; disable it first), a masked unit keeps its mask, and a unit the
manager does not find is admitted. A stopped service whose killMode: 'process' left processes
running still has a control group and is refused with active until they end.
Every file is checked as installation checks the unit file (regular, single link, mode 0644, owned
by the expected uid) and is removed only while its name still holds the file that was checked; a
retained definition whose bytes do not match the digest in its name, or a unit file with other bytes
than expected, refuses the removal with changed before anything is removed. After removal the
manager must report the unit not found (still masked, for a masked unit) and at rest, or the call
rejects with reload_failed, the files already removed and the cause attached. With nothing to
remove the call resolves { removed: false, … } without reloading the manager. removed reports
whether the unit file itself was removed and removedPaths every file that was. Removal never
stops, disables or unmasks the unit.
With killMode: 'mixed', the main process owns graceful child shutdown and must
remain alive until its children have joined. Systemd sends SIGTERM to the main
process first and can send SIGKILL to the whole cgroup when the main process exits
or the stop deadline expires. control-group sends the initial signal to the
whole group. process signals only the main process, on stop and on a stop timeout; every
other process in the unit survives, so the main process must own their shutdown or their
deliberate retention (as with Delegate=yes workloads). A successful systemd start still requires application readiness
verification, and a completed stop alone does not prove database drain.
The service side: notifications and passed descriptors (/systemd-runtime, Node.js 26)
The definitions above tell the manager what to expect; a Node.js main process answers it through
the @push.rocks/smartdaemon/systemd-runtime subpath: sd_notify(3) messages (with descriptors
for the store) and the descriptors the manager passes (LISTEN_FDS). Node.js has no AF_UNIX
datagram socket and no fcntl(2), so the subpath calls glibc through node:ffi:
- It requires Node.js 26 or later. Importing it on an older Node.js throws
SystemdRuntimeErrorwith coderuntime_unsupported; the main entry is unaffected and keeps its Node.js range. The package is ES modules: CommonJS canrequire()either entry only on a Node.js that loads ES modules throughrequire()by default (20.19+, 22.12+, 23+), where the subpath throws the sameruntime_unsupportedbelow 26; an older Node.js throwsERR_REQUIRE_ESM. - It runs on Linux x64 and arm64 with glibc (
libc.so.6). Elsewhere, and withnode:ffiunavailable (--no-experimental-ffi) or denied by the permission model, the first call that needs libc throwsruntime_unsupported. Under the permission model pass--allow-ffi. node:ffiis experimental: loading it prints oneExperimentalWarning. A single executable application suppresses it through itsexecArgv(--disable-warning=ExperimentalWarning).- libc is opened on first use. Outside systemd nothing is loaded: without
NOTIFY_SOCKETSystemdNotifier.fromEnvironment()returns null, and withoutLISTEN_*variablestakeSystemdListenDescriptors()returns an empty list.
import {
SystemdNotifier,
takeSystemdListenDescriptors,
watchdogIntervalUsec,
} from '@push.rocks/smartdaemon/systemd-runtime';
// Descriptors passed for this process, numbered from 3, already close-on-exec.
const passed = takeSystemdListenDescriptors(); // [{ descriptor: 3, name: 'pallet-router' }]
const namespaceDescriptor = passed.find(({ name }) => name === 'pallet-router')?.descriptor ?? createNamespace();
// null when no service manager listens; the caller decides what that means.
const notifier = SystemdNotifier.fromEnvironment({ take: true });
if (notifier) {
notifier.extendTimeout(60_000_000, 'replaying journal');
notifier.replaceDescriptors('pallet-router', [namespaceDescriptor], { poll: false });
notifier.ready('serving');
const interval = watchdogIntervalUsec();
if (interval !== null) setInterval(() => notifier.watchdog(), interval / 2000);
}| Call | Sends or does |
| --- | --- |
| ready(status?) | READY=1 (and STATUS=) |
| status(text) | STATUS= |
| stopping(status?) | STOPPING=1 |
| reloading(status?) | RELOADING=1 with MONOTONIC_USEC= (CLOCK_MONOTONIC); send ready() when done |
| watchdog() | WATCHDOG=1 |
| extendTimeout(usec, status?) | EXTEND_TIMEOUT_USEC= |
| storeDescriptors(name, fds, { poll? }) | FDSTORE=1, FDNAME=, FDPOLL=0 for poll: false; descriptors as SCM_RIGHTS |
| removeDescriptors(name) | FDSTOREREMOVE=1, FDNAME= |
| replaceDescriptors(name, fds, { poll? }) | remove, then store: two messages, handled in order |
| send(fields, fds?) | any NAME=value fields in insertion order |
| takeSystemdNotifySocket() | removes NOTIFY_SOCKET from the environment and returns it validated, or null |
| takeSystemdListenDescriptors() | removes LISTEN_PID/LISTEN_FDS/LISTEN_FDNAMES; for this process marks every passed descriptor close-on-exec and returns it with its name (unknown without names) |
| setCloseOnExec(fd) | fcntl(F_SETFD, FD_CLOEXEC), verified |
| watchdogIntervalUsec() | WATCHDOG_USEC for this process (WATCHDOG_PID), or null |
Every call is synchronous and either completes or throws SystemdRuntimeError (code, and
errno when a libc call failed); nothing is dropped silently. systemd ignores a message it cannot
parse without telling the sender, so the notifier refuses it first: field names are upper-case,
values carry no newline or NUL, a message is at most 4096 bytes (notify_field_invalid); a
descriptor name is 1–255 printable ASCII characters without : (descriptor_name_invalid); one
message carries 1–253 descriptors (descriptor_invalid). NOTIFY_SOCKET must be an absolute
path or an abstract name (@name) of at most 107 bytes; anything else, vsock: addresses
included, is notify_socket_invalid. A send the kernel refuses is notify_failed with its errno
(ENOENT for a missing path, ECONNREFUSED for an abstract name nobody holds). LISTEN_*
variables naming another process are removed and nothing else is touched; a partial or malformed
set (LISTEN_FDS not a positive integer, a names list of another length) is
listen_environment_invalid before any descriptor is touched, and a passed descriptor that is not
open is close_on_exec_failed.
LISTEN_FDS is also bounded by the process's hard RLIMIT_NOFILE before any descriptor is touched:
a count whose last descriptor (2 + count) is at or above that limit is
listen_environment_invalid. No descriptor systemd passed can lie at or above the hard limit,
because it moves them into place with fcntl(F_DUPFD) (which refuses a number at or above the soft
limit) after applying the unit's limits; a wrapper that lowers its hard limit below a passed
descriptor before starting Node.js is refused. The hard limit can be as large as fs.nr_open
(LimitNOFILE=infinity), so nothing is allocated per counted descriptor up front: like
sd_listen_fds(3), the descriptors are marked one by one from 3, the first that is not open throws
close_on_exec_failed (the ones before it keep the flag), and the list grows only with open
descriptors.
Sends are blocking sendmsg(2) calls on a fresh AF_UNIX datagram socket, the same as
sd_notify(3): when the manager's receive queue is full, the call waits for room and the event loop
stalls for that time.
node:ffi has no errno API: the subpath reads glibc's thread-local errno through
__errno_location() immediately after the failed call. Node.js itself calls libuv's
uv_disable_stdio_inheritance() at startup (InitializeNodeWithArgsInternal() in src/node.cc;
deps/uv/src/unix/core.c, Node.js 25.9.0 and 26.11.1), which marks descriptors 0–15 close-on-exec
and continues past 15 until the first descriptor that is not open. The subpath still sets and
verifies the flag on every passed descriptor, as sd_listen_fds(3) does.
test/test.systemdruntimeunit.node.ts proves the subpath under a real Type=notify unit of the
caller's user manager (systemd-run --user), and skips with the reason without Node.js 26 or a
user manager.
Starting a program in its own transient scope
A service that launches a long-lived program keeps that program in its own
control group, so stopping the service ends the program too, even when the
program detached itself with setsid. runInTransientScope() starts a program in
a new transient scope unit of the calling user's systemd manager instead
(systemd-run --user --scope): the program and every process it starts run in
that scope and outlive the caller's unit. The call waits for the program's direct
process and returns its result; a program that daemonizes and exits returns while
its daemon keeps the scope active, and the scope ends when its last process exits.
import { runInTransientScope, SystemdTransientScopeError } from '@push.rocks/smartdaemon';
try {
const result = await runInTransientScope({
scope: 'user',
description: 'Codex app-server daemon',
executable: '/home/owner/.local/bin/codex',
args: ['app-server', 'daemon', 'start'],
workingDirectory: '/home/owner',
environment: { HOME: '/home/owner', CODEX_HOME: '/home/owner/.codex', PATH: '/usr/bin:/bin' },
});
console.log(result.unitName, result.exitCode, result.stdout);
} catch (error) {
if (error instanceof SystemdTransientScopeError && error.code === 'user_manager_unavailable') {
// No user manager: the caller decides how to run the program without one.
}
throw error;
}The result holds the scope's unitName, the direct process's exitCode (or the
signal that ended it), and its stdout and stderr, UTF-8 decoded. A failure
status is a result, not an error
