@primafuture/systemd-tasks
v1.1.0
Published
One-shot process tasks owned by the systemd user manager.
Readme
@primafuture/systemd-tasks
Run one-shot Linux processes owned by the systemd user manager, with literal arguments, binary file-descriptor I/O, manager-owned timeouts, and recoverable results after the Node client exits or crashes.
Version 1.1.0 requires Node 24.13.0+, Linux x64 with glibc 2.28+, systemd 257+, and cgroup v2. The verified environment is Node 24.13.0, systemd 257.9-0ubuntu2.4, Linux 6.17.0-14-generic, x86-64. Other versions satisfying those minimums are not independently verified.
Installation
npm install @primafuture/systemd-tasksThe native dependency @primafuture/[email protected] is installed under the npm
alias usocket and supplies both the D-Bus adapter and explicitly owned Unix
socket-pair primitives. It is pinned to the exact published release and ships a
prebuilt native binary, so ordinary installation needs no compiler, Python, install
scripts, or consumer override. Musl Linux and other architectures remain unsupported.
The JavaScript D-Bus stack
([email protected] and its dependencies) is bundled; licenses are recorded in
THIRD_PARTY_NOTICES.md.
An existing local user bus at /run/user/<uid>/bus, a reachable systemd user
manager, Unix FD negotiation, and /usr/bin/true are required. connect() checks
that the retention executable resolves to a regular executable file before opening
the bus, then verifies the manager environment. It fails explicitly; the library does not provision a manager,
enable linger, request elevated privileges, or fall back to child_process.
Importing either the ESM or CommonJS entrypoint does not connect or start a process.
Observed tasks additionally require /usr/bin/systemd-notify, /usr/bin/systemctl,
and the packaged guardian bundle; connectObserved() verifies them before opening
the bus.
Basic execution
import * as tasks from '@primafuture/systemd-tasks';
const intent = tasks.prepare({
executable: '/usr/bin/printf',
argv: ['%s\n', 'Literal $HOME, spaces, and Unicode: žluťoučký'],
cwd: '/tmp',
env: {},
runtimeLimitMs: 5000,
stopGraceMs: 500,
});
const client = await tasks.connect();
try {
await client.start(intent, {
...tasks.nullIO,
stdout: { type: 'file', path: '/tmp/task-output.log', mode: 'append' },
});
const completed = await client.wait(intent, { timeoutMs: 10000 });
console.log(completed.result);
if (completed.phase === 'completed') {
// Persist any required result before discarding manager retention.
await client.release(completed.reference);
}
} finally {
await client.disconnect();
}executable and cwd must be absolute; no PATH lookup or shell parsing is performed.
argv excludes argv[0]. Empty arguments, $, %, quotes, and Unicode remain
literal. Every task must explicitly choose runtimeLimitMs (positive integer
milliseconds or null) and stopGraceMs (positive integer milliseconds).
env is an explicit map. The library removes names from the manager's inherited
environment that are absent from this map, using a snapshot immediately before
submission. Systemd may add execution metadata such as INVOCATION_ID. Concurrent
external changes to manager configuration are outside this guarantee.
Observed execution
The separate @primafuture/systemd-tasks/observed entrypoint adds live binary
stdout/stderr delivery without wrapping the payload process:
import * as observed from '@primafuture/systemd-tasks/observed';
const intent = observed.prepareObserved({
executable: '/usr/bin/sort',
argv: [],
cwd: '/tmp',
env: {},
runtimeLimitMs: 5000,
stopGraceMs: 500,
stdin: 'second\nfirst\n',
});
const client = await observed.connectObserved();
try {
const started = await client.startObserved(intent, {
async onOutput(event) {
// Awaiting here applies bounded backpressure to both output channels.
await consume(event.stream, event.data);
},
});
await started.observation.completion;
const completed = await client.wait(intent, { timeoutMs: 10000 });
await client.release(completed.reference);
} finally {
await client.disconnect();
}stdin is mandatory. A string is snapshotted as UTF-8, a Uint8Array is copied,
an empty value remains an explicit empty data input, and null selects /dev/null.
The fixed limit is 512 KiB, chosen to remain below systemd's manager-state line
serialization boundary after base64 expansion. The canonical base64 snapshot is
part of the serialized intent and its fingerprint. Systemd owns StandardInputData after submission and
may retain those bytes in manager state until release, so treat the intent and
manager access as sensitive when stdin contains secrets.
Each observed task consists of payload, retention, and Type=notify guardian
services created by one StartTransientUnit transaction. The payload is ordered
after and BindsTo the guardian; both process services require successful
retention initialization. startObserved() returns only after manager
acceptance and guardian readiness, with a fixed five-second readiness budget. A
failure after manager acceptance reports mutationOutcome: 'accepted'; it is not
permission to retry.
Output callbacks are sequential and their returned promises provide real bounded
backpressure. Callback failure rejects observation.completion with
OBSERVATION_FAILED and detaches locally; it does not stop the payload. detach()
and disconnect() likewise close only the local observer. The guardian continues
to drain and discard both channels so the payload does not receive EPIPE merely
because the client left. There is deliberately no reconnect, replay, or adoption
of another client's live stream. Use ordinary durable file I/O instead when output
must survive observation loss.
An already-invoked callback is application code and cannot be cancelled. Explicit
detach closes the socket and settles completion as detached without waiting for
that callback promise; no later callback is invoked, and a later settlement of the
detached callback cannot change the observation outcome or block disconnect().
The guardian emits a private versioned binary protocol with separate stdout and
stderr frames. Payload bytes and callback causes are never included in diagnostics.
wait() reports payload and guardian results independently and reaches completed
only after both services are terminal with confirmed empty cgroups. stop() targets
the payload; systemd propagates that stop to the guardian in reverse dependency
order, so it drains bytes already written before completing. This also closes a
guardian whose payload start was cancelled before exec. release() refuses until
both process services are clean, then removes retention.
Lifecycle and recovery
| Operation | Contract |
| --- | --- |
| prepare(spec) | Pure local validation, random identity, frozen serializable intent. No I/O or execution. |
| connect(options?) | Explicit local connection, environment checks, immutable manager identity. |
| start(intent, io) | One creation attempt. A receipt means manager acceptance, not successful exec. |
| lookup(intentOrReference) | Observe existing work: found, not-found, or incomplete. Never starts anything. |
| inspect(reference) | Snapshot of the exact instance; missing evidence is an error. |
| wait(intentOrReference, options?) | Wait for terminal manager state and report cleanup evidence. |
| stop(reference, options?) | Request manager stop; wait: true additionally waits for cleanup. |
| release(reference, options?) | Refuse live tasks, then discard owned result retention. Never deletes logs. |
| disconnect() | Release local socket/listeners only; tasks keep running. Idempotent. |
Save the intent before calling start, then save an observed reference and final result as they become available. Intents contain the command and environment, possibly secrets. Use application-appropriate permissions and durable storage. A prepared intent does not prove that submission ever occurred.
A second process can deserialize an intent, connect, and call lookup or wait.
References bind the task UUID, specification fingerprint, manager scope, UID,
machine/boot/bus identity, manager bus owner, and both service InvocationIDs.
Missing InvocationIDs are explicitly null; an incomplete reference cannot
silently adopt a later invocation. Waiting on an intent pins IDs as they appear.
PIDs are diagnostic information, never ownership identity.
not-found means that no owned record is currently observable in the reachable
manager. It does not prove that the command never executed. incomplete means
only the retention unit remains and the main result is unavailable. A found
snapshot can itself have incomplete invocation evidence, for example after
retention initialization prevented execution.
Start errors expose TaskError.details.mutationOutcome:
| Outcome | Recovery meaning |
| --- | --- |
| not-submitted | No creation request was sent by this operation; file-opening effects may exist. |
| rejected | A manager error reply was received; inspect possible partial records. |
| unknown | Delivery or acceptance cannot be established. Reconnect and look up the saved identity. |
| accepted | Acceptance was confirmed; a later local stop wait can still fail or be cancelled. |
Never automatically retry a start with an unknown outcome, even when lookup
returns not-found. Local duplicate protection lasts only for that client; this
is not a durable exactly-once ledger. Creating another intent is a new execution
and requires an application decision. There is no automatic reconnect or retry.
References guard accidental collisions and stale instances. They are not a security boundary against equally privileged processes. Systemd has no conditional "stop only if InvocationID equals X" method; read/validate/mutate has a race if an external actor replaces a unit between those steps. Do not modify library-owned units concurrently through another controller.
Results, timeouts, and cleanup
TaskSnapshot.result is a normal result for application failures, signals, and
execution failures. It contains kind, exitCode, signal, execution,
systemdResult, and cleanup. The snapshot also preserves raw exit, timestamp,
job, and cgroup evidence. A program returning 203 differs from systemd failing to
exec with status 203; classification uses retained Type=exec activation evidence.
phase: 'completed' requires terminal manager state, no pending job, and confirmed
cgroup cleanup. Main-process exit alone is insufficient. wait() can return a
terminal snapshot with cleanup: 'incomplete' or 'unknown'; inspect that field
and do not treat it as successful completion or attempt release. Such a snapshot
remains in phase: 'cleaning'.
Runtime limits, SIGTERM, stop grace, SIGKILL escalation, and descendant cleanup are
owned by systemd. They continue after client death. Result=timeout can mean a
runtime deadline, stop escalation, or descendant cleanup escalation; the library
preserves it without claiming which occurred. Descendants remaining in the task
cgroup are covered, including ordinary setsid descendants. Privileged processes
that escape the cgroup are outside the guarantee.
wait/stop options signal and timeoutMs affect only that caller's local wait.
They do not stop a task, cancel an accepted stop, or cancel other waiters. Local
timeouts are positive integers up to 2147483647 ms. Stop cancellation while its
request is in flight reports unknown delivery; after an acknowledgment, the error
retains mutationOutcome: 'accepted'.
Binary I/O and ownership
All three channels are explicit. nullIO provides EOF stdin and discarded output.
| Channel value | Ownership and behavior |
| --- | --- |
| { type: 'null' } | Selects systemd's null channel mode; the client opens no descriptor. |
| { type: 'file', path } for stdin | Existing regular file, opened for reading before submission. |
| { type: 'file', path, mode: 'append' \| 'truncate' } for output | Regular file; parent directory must exist. Creation/truncation is not transactional. |
| { type: 'fd', fd } | Borrowed Unix FD; the caller must keep it open until start settles. Never closed by the library. |
The transport passes descriptors directly to systemd; the client never decodes or buffers process output. File output survives client death. Successful submission does not mean the task has finished using its manager-held descriptor.
Identical output paths and aliases of the same inode with matching modes share one open file description and offset. Conflicting modes are rejected. Opens and alias checks precede truncation; a later manager rejection or exec failure can still leave created or truncated files. Truncating an input/output alias is rejected. Existing symlinks are followed; this is not a sandbox for untrusted paths. Use borrowed FDs when your application needs stricter opening policies or non-regular files.
Borrowed FDs share underlying offsets and flags with the application. Unix pipes retain normal backpressure, EOF, and EPIPE behavior. Closing a reader can break task output. Reconnecting does not reconstruct lost pipe bytes or create stream history. The library does not own application streams and never waits for task-held pipe EOF when disconnecting. Regular files are the durable-output option. Release leaves those files untouched.
Retention and operational limits
Each task uses two transient services: the actual Type=exec service and a short
/usr/bin/true oneshot with RemainAfterExit=yes. Retention becomes active before
the user command runs. It keeps the completed service referenced without a resident
helper process. Both records consume manager memory until explicit release; failed
records are reset during release. Garbage collection may follow asynchronously,
which is reflected by ReleaseReceipt.collected.
release(reference, { timeoutMs, signal }) has its own total local deadline,
including time queued behind another mutation; it defaults to 5000 ms.
connect({ requestTimeoutMs }) independently limits each individual D-Bus request,
not the sum of release's reads and mutations. Increasing the release deadline does
not relax the per-request deadline. Cancellation does not undo manager mutations.
Release errors include acceptedCleanupMutations, the number of acknowledged
reset/stop calls during that attempt. If earlier cleanup was accepted but a later
step is explicitly rejected or not submitted, mutationOutcome remains accepted:
the manager has already acknowledged part of cleanup. If a later step has unknown delivery,
the outcome is unknown and the positive count still preserves the earlier facts.
Neither acceptance nor the count proves completion of the whole release sequence.
Reconcile the reference before continuing cleanup.
Retention is not persistent storage across manager restart, logout that stops the user manager, bus replacement, or reboot. Store final results yourself. External service manipulation can invalidate evidence. The library does not enable linger, recreate missing tasks, implement a daemon, or promise survival when the manager itself stops. Connection failure is distinct from missing units.
The only advanced properties allowed in 1.x are CPUWeight and IOWeight, each
with signature 't' and an integer value from 1 to 10000. Duplicates, unknown
properties, and lifecycle overrides are rejected before mutation. Kernel/controller
configuration determines the practical scheduling effect of those weights.
Optional connect({ onDiagnostic }) receives structured events correlated by
context.connection and context.task. No argv, environment, output, or raw error
cause is logged implicitly. Synchronous sink exceptions and rejected promises or
thenables do not change task outcomes; asynchronous sinks are never awaited.
TaskError.cause is explicitly accessible and may contain sensitive upstream text;
default error inspection and JSON formatting are redacted.
Development and verification
npm ci --prefix "$PWD"
npm run checkcheck runs strict typechecking, unit tests, real systemd integration tests, dual
ESM/CJS build, and tarball runtime/type smoke tests. prepublishOnly invokes the
same chain. Integration tests fail explicitly when prerequisites are missing.
Tests create unique identities and clean only their recorded task/retention pairs,
plus the exact guardian identity for observed tests.
They do not restart the user manager or change host configuration.
Runnable repository examples after npm run build:
npx tsx examples/basic.ts
npx tsx examples/durable-start.ts
# Use the printed record directory from the preceding command:
npx tsx examples/durable-reconnect.ts /absolute/path/to/record-directory
npx tsx examples/timeout-stop.tsThe two durable examples are independent processes. The first fsyncs its intent
before submission and disconnects; the second fsyncs the result before release.
Generated records stay under .artifacts/examples and are not published.
See implementation decisions and acceptance evidence for the feasibility experiments, T01–T18 mapping, exact versions, simulated failure boundaries, and unverified environments.
