Skip to content

emacs-os/el-init

Repository files navigation

https://img.shields.io/badge/License-GPLv3-purple.svg https://github.com/emacs-os/el-init/actions/workflows/ci.yml/badge.svg https://img.shields.io/endpoint.svg?url=https://gist.githubusercontent.com/el-sloppo/e686727a6d88c17c557003e73a9c020c/raw/elinit-tests.json https://img.shields.io/badge/mirror-GitLab-orange.svg https://img.shields.io/badge/mirror-Codeberg-2185D0.svg

el-init

A statically compiled Emacs init (PID 1) patchset, Emacs Lisp-based service supervisor and core component of Emacs-OS.

assets/Screencast_20260324_122402.gif

el-init is an Emacs Lisp-based, systemd-inspired init and service-management solution. Whether you are building a full-send Emacs-OS, or simply want to orchestrate your desktop session services from within EXWM, or keep project daemons running while you work, el-init has you covered. Use M-x elinit for the interactive dashboard, or elinitctl from the terminal.

assets/elinit-branding-full-send-50.png

Features

  • Optional PID1 patchset – static build instruction files (Arch PKGBUILD and Nix) and Emacs patches adding zombie reaping and PID1 lifecycle hooks (static-builds/).
  • Dependency graph – five relationship keywords give fine-grained control over startup order and runtime interaction:
    • :after / :before – pure ordering (start A after B, or B before A).
    • :requires – ordering plus hard dependency (if the required unit fails, the dependent is not started).
    • :wants – ordering plus soft dependency (best-effort, failure tolerated).
    • :conflicts – mutual exclusion (starting one unit stops the other, and vice versa).

    Topological sort with cycle detection. Targets (default.target, multi-user.target, graphical.target) group services into activation stages, just like systemd.

  • Event-driven process monitoring – uses Emacs’s built-in set-process-sentinel for zero-overhead exit detection. No polling. The hot path is a sentinel lambda that does a few hash lookups and optionally schedules a run-at-time restart.
  • Crash loop detection – configurable restart window (default: 3 restarts within 60 seconds marks a service as FAILED). Per-unit :restart-sec for individual restart delays.
  • Lifecycle FSM – proper state machine with validated transitions: pending -> waiting-on-deps -> delayed -> started -> failed-to-spawn.
  • Structured event system – hook-based events for process-exit, process-started, target-reached, target-degraded, timer-trigger, and more. Full programmatic integration via elinit-event-hook.
  • Service types – long-running simple daemons with restart policies and run-once oneshot tasks with configurable blocking and timeout.
  • Graceful shutdown – SIGTERM with configurable timeout, then SIGKILL. Sentinel-driven completion tracking (no polling).
  • 3-tier authority model/usr/lib/elinit.el/ (vendor), /etc/elinit.el/ (admin), ~/.config/elinit.el/ (user). Higher tiers override lower tiers completely. Daemon-reload support.
  • Persistent overrides – enable/disable, mask/unmask, restart-policy, and logging overrides persist across restarts.
  • Timers – calendar and relative timers for oneshot, simple, and target units with persistent catch-up semantics after downtime.
  • DashboardM-x elinit for a live view with lifecycle actions, policy changes, dependency inspection, and log viewing.
  • CLIsbin/elinitctl for scripting and remote use (human and JSON output).
  • Logging – external log writer helpers with rotation and pruning for bounded growth (configurable per-file and total size caps). Optional structured log formats (human-readable text or compact binary with nanosecond timestamps) for machine-parseable records.
  • Resource limits – per-service ulimit-style limits (:limit-nofile, :limit-nproc, :limit-core, :limit-fsize, :limit-as) via a small C helper that calls setrlimit(2) before exec.
  • Sandbox – optional bubblewrap integration with curated profiles for process isolation (Linux only).
  • systemd importsbin/elinit-import converts systemd .service files to elinit unit plists.
  • Comprehensive test suite – ~33,000 lines of tests across Elisp ERT, C (acutest), and POSIX shell frameworks.

Requirements

  • Emacs 28.1 or later
  • For CLI: emacsclient, base64, and sed in your PATH
  • For bundled libexec helpers: a C compiler in PATH (typically cc, clang, or gcc)

AI Dev Disclosure (RETROSPECTIVE)

assets/you-really-should-read-this-small.png

See RETROSPECTIVE.md for the full disclosure and the human author’s unfiltered opinions on AI-driven development. Some of those opinions may surprise you.

Handbook Overview

This document is the complete reference. Run M-x elinit-handbook to open it in Emacs at any time.

From Lisp Machines to GNU Emacs provides historical context. The Quick Start below gets you running immediately. The rest of the handbook provides detailed reference material:

From Lisp Machines to GNU Emacs

assets/elinit-branding-lisp-machine-small.png

In 1958 John McCarthy sat down to design a language. He based it on lambda calculus. He called it Lisp. The paper came out in 1960. It was called “Recursive Functions of Symbolic Expressions and Their Computation by Machine.” The core idea was simple. Lists were data. Lists were also code. The language could rewrite itself.

FORTRAN crunched numbers. COBOL kept records. Lisp did whatever you told it to. Its primitives were so small you could rebuild the whole language from seven of them. McCarthy even wrote a Lisp interpreter in Lisp. He put it in the paper almost as an afterthought. It was the first time a language could run itself.

At the MIT AI Lab in the 1960s and 1970s, people took that idea and ran with it. They did not just write programs in Lisp. They built whole machines out of it. The Lisp Machine was hardware designed from the microcode up to run Lisp natively. Symbolics built them. Lisp Machines Inc. built them. Texas Instruments built them.

On a Symbolics 3600, everything was Lisp. The editor. The debugger. The window system. The file system. The operating system itself. You could click on any part of the running machine, read the source, change it, and watch the change take effect. No reboot. No recompile. No permission needed. There was no line between using the machine and programming it. That was the whole point.

The hackers at the AI Lab shared everything. Code lived on the machines and anyone could read it. Anyone could fix it. If you found a bug you just patched it. People walked down the hall and talked about what they changed. Sometimes they carried tapes. Later they carried floppies. The code moved freely because nobody thought to stop it.

Then the money showed up. In the early 1980s, Symbolics and LMI spun out of the AI Lab and took the code with them. They locked it up. They put lawyers on it. The shared codebase that had sustained a generation of hackers was cut in half and sold. The community fell apart.

Richard Stallman had been at the Lab since 1971. He watched it happen. He did not build another Lisp Machine. He wrote a manifesto. Software had to be free. Free to study. Free to modify. Free to share. He announced the GNU Project in 1983. He published the GNU Manifesto in 1985. The first real piece of GNU software was an editor.

GNU Emacs was not the first Emacs. Stallman and Guy Steele wrote the original EMACS at MIT in 1976. It was macros on top of TECO. James Gosling made Gosmacs for Unix in 1981. But GNU Emacs was the one that mattered. It had a real Lisp inside it. Emacs Lisp. Every buffer ran through it. Every mode. Every keystroke. The editor was a Lisp runtime that happened to show you text.

That is not a small thing. A Lisp Machine was an operating system built on a Lisp evaluator with direct access to hardware. GNU Emacs is a Lisp evaluator with direct access to an operating system. It spawns processes. It opens network sockets. It talks to the filesystem. It renders windows. It handles signals.

The Lisp Machine ran Lisp on bare metal. Emacs runs Lisp on a portable C core. That is why it survived. Four decades. Every Unix. Every window system. VAXen to Apple Silicon. The Lisp Machines sit in museums now. GNU Emacs kept going. It carried the same idea forward. The user should be able to take apart every layer of the system and put it back together differently.

Quick Start

(add-to-list 'load-path "~/repos/el-init")
(require 'elinit)

;; Use elinit-stop-now for immediate SIGKILL - ensures clean X session exit
(add-hook 'kill-emacs-hook #'elinit-stop-now)

;; Don't ask about running processes on exit - elinit handles cleanup
(setq confirm-kill-processes nil)

;; Start elinit and config watcher
(elinit-mode 1)

Services are defined as individual unit files in your authority roots (default ~/.config/elinit.el/). For example:

;; ~/.config/elinit.el/xhost.el
(:id "xhost"
 :command "xhost +SI:localuser:$USER"
 :type oneshot
 :required-by ("basic.target")
 :tags (x-setup))
;; ~/.config/elinit.el/nm-applet.el
(:id "nm-applet"
 :command "nm-applet"
 :type simple
 :wanted-by ("graphical.target")
 :delay 3
 :restart t
 :tags (applet tray))

With the above configuration running, sbin/elinitctl status shows:

$ sbin/elinitctl status
ID               TYPE     TARGET               ENABLED  STATUS     RESTART  LOG   PID     REASON
----------------------------------------------------------------------------------------------------
xhost            oneshot  graphical.target     yes      done       n/a      yes   -       -
nm-applet        simple   graphical.target     yes      running    always   yes   1396    -

M-x elinit opens an interactive dashboard for managing services. In the following example, services and oneshots for setting up an EXWM environment.

assets/elinit-dashboard-exwm.png

Press ? to open the options menu.

assets/elinit-dashboard-options.png

Three transient submenus – magit-style – expose additional options for managing service lifecycles and timers, policies and inspecting state.

assets/elinit-dashboard-lifecycle-options.png

assets/elinit-dashboard-policy-options.png

assets/elinit-dashboard-inspect-options.png

assets/elinit-dashboard-timers-options.png

Each service gets its own .el file in an authority root. See Unit Files for the full authority-root model, precedence rules, and all available keywords.

Understanding the Dashboard View

When you first run M-x elinit, you will see a mix of target statuses that might look surprising. Some targets show reached, others show unreachable, and you might wonder if something is broken. It is not – this is how the target system works. The following image shows an unconfigured elinit running as pid2 in an Alpine Linux container.

assets/elinit-dashboard-default.png

Pressing v and V toggle these .target views in the Service sections, hidden by default to keep the signal-to-noise ratio down.

Why some targets are “reached”

At startup, elinit picks a root target (by default graphical.target) and walks its dependency chain to figure out what needs to start. The built-in target hierarchy is:

basic.target
  ^  (required by)
multi-user.target
  ^  (required by)
graphical.target   <-- startup root

All three are in the startup path, so they activate, their member services start, and the targets converge to reached. default.target also shows reached because it resolves to graphical.target under the hood.

Why some targets are “unreachable”

Targets like rescue.target, shutdown.target, poweroff.target, and reboot.target are not in graphical.target’s dependency chain. Nothing pulls them in during a normal boot, so they sit outside the activation closure.

Elinit shows these as unreachable – meaning “not part of the current startup transaction.” This is expected. These targets only activate when you explicitly request them with isolate or init.

Why runlevel aliases follow their canonical target

Each runlevelN.target is an alias that resolves to a canonical target before any status lookup:

AliasResolves toIn normal boot?Status
runlevel2.targetmulti-user.targetyesreached
runlevel3.targetmulti-user.targetyesreached
runlevel4.targetmulti-user.targetyesreached
runlevel5.targetgraphical.targetyesreached
runlevel0.targetpoweroff.targetnounreachable
runlevel1.targetrescue.targetnounreachable
runlevel6.targetreboot.targetnounreachable

Runlevels 2 through 5 show reached because their canonical targets (multi-user.target and graphical.target) are in the startup path and converged. Runlevels 0, 1, and 6 show unreachable because their canonical targets (poweroff.target, rescue.target, reboot.target) were never pulled into the startup transaction.

Quick reference for target statuses

StatusMeaning
reachedTarget’s required members all started successfully.
degradedTarget converged, but one or more members failed.
convergingTarget is still waiting for members to start.
pendingTarget is in the startup path but has not begun converging yet.
unreachableTarget is not part of the current startup transaction.
maskedTarget was explicitly masked by the operator.

Service Definition (Unit-File Keywords)

Each unit file is a single plist expression with :id required. :command is required for non-target units and invalid for :type target.

Entry Keywords

KeywordTypeDefaultNotes
:idstring(required)Unit ID (non-empty string)
:commandstring or nil(required for non-target types)Command to execute; must be omitted for :type target
:typesymbolsimplesimple, oneshot, or target
:delaynon-negative number0Delays spawn in seconds
:afterstring or list of stringsnilOrdering dependency
:requiresstring or list of stringsnilRequirement + ordering dependency
:enabledbooleantEnable/disable service
:disabledbooleannilInverse form of :enabled
:restartboolean or policy symbolalwaysFor simple only; accepts t/nil or always/no/on-success/on-failure
:no-restartbooleannilInverse form of :restart (t means policy no)
:loggingbooleantPer-process log capture
:stdout-log-filestring or nilnilOptional stdout log path override
:stderr-log-filestring or nilnilOptional stderr log path override; defaults to stdout target
:oneshot-blockingbooleanelinit-oneshot-default-blockingFor oneshot only
:oneshot-asyncbooleannilInverse of :oneshot-blocking
:oneshot-timeoutnumber or nilelinit-oneshot-timeoutFor oneshot only
:tagssymbol, string, or listnilDashboard tag filtering
:working-directorystringnilProcess working directory
:environmentalist of (KEY . VALUE)nilEnvironment variables
:environment-filestring or list of stringsnilEnvironment file path(s)
:exec-stopstring or list of stringsnilCustom stop command(s), simple only
:exec-reloadstring or list of stringsnilCustom reload command(s), simple only
:restart-secnon-negative numbernilPer-unit restart delay, simple only
:descriptionstringnilHuman-readable description, metadata only
:documentationstring or list of stringsnilDocumentation URIs/paths, metadata only
:beforestring or list of stringsnilInverse ordering, see below
:wantsstring or list of stringsnilSoft dependency, see below
:conflictsstring or list of stringsnilMutual exclusion, see below
:kill-signalsymbol or stringSIGTERMGraceful stop signal for this unit
:kill-modesymbol or stringprocessprocess or mixed, see Stop Semantics
:remain-after-exitbooleanniloneshot only: latch active on success
:success-exit-statusint, signal symbol/string, or listnilsimple only: extra clean exit criteria
:userstring, integer, or nilnilRun-as user (requires root, trusted unit source)
:groupstring, integer, or nilnilRun-as group (requires root, trusted unit source)
:wanted-bystring or list of stringsnilSoft membership in target units
:required-bystring or list of stringsnilRequired membership in target units
:sandbox-profilesymbolnilnone, strict, service, or desktop (Linux only)
:sandbox-networksymbolprofile defaultshared or isolated
:sandbox-ro-bindlist of absolute path stringsnilRead-only bind mounts inside sandbox
:sandbox-rw-bindlist of absolute path stringsnilRead-write bind mounts inside sandbox
:sandbox-tmpfslist of absolute path stringsnilTmpfs mounts inside sandbox
:sandbox-raw-argslist of stringsnilRaw bwrap arguments (expert gate required)
:log-formatsymbolnilStructured log format: text or binary
:limit-nofileinteger, string, or infinitynilMax open file descriptors (RLIMIT_NOFILE)
:limit-nprocinteger, string, or infinitynilMax user processes (RLIMIT_NPROC)
:limit-coreinteger, string, or infinitynilMax core dump size in bytes (RLIMIT_CORE)
:limit-fsizeinteger, string, or infinitynilMax file size in bytes (RLIMIT_FSIZE)
:limit-asinteger, string, or infinitynilMax address space in bytes (RLIMIT_AS)

Validation Rules

Validation is performed by unit-file validation (elinit--validate-unit-file-plist) plus elinit--validate-entry during plan building.

  • Malformed plist structures are rejected (non-proper lists, odd number of elements).
  • Unknown keywords are rejected.
  • Duplicate plist keys are rejected.
  • Unit files require :id. :command is required for non-target entries and rejected for :type target.
  • :id must be a non-empty string containing only A-Z, a-z, 0-9, ., _, :, @, and -.
  • :command, when present, must be a non-empty, non-whitespace-only string.
  • :type must be symbol simple, oneshot, or target.
  • :stage is removed and rejected. Use :wanted-by and :required-by for target membership.
  • :delay must be non-negative number.
  • :restart, when provided, must be t/nil or one of always/no/on-success/on-failure.
  • :oneshot-timeout must be a positive number or nil.
  • Boolean flag keys (:enabled, :disabled, :logging, :no-restart, :oneshot-blocking, :oneshot-async) must be exactly t or nil.
  • :stdout-log-file and :stderr-log-file must be non-empty strings or nil.
  • Mutually exclusive pairs are rejected: :enabled with :disabled, :restart with :no-restart, and :oneshot-blocking with :oneshot-async.
  • :restart-sec with a disabled restart policy (:no-restart t, :restart no, or :restart nil) is rejected as contradictory.
  • Type restrictions are enforced: oneshot rejects :restart and :no-restart; simple rejects :oneshot-blocking, :oneshot-async, and :oneshot-timeout. oneshot rejects :exec-stop, :exec-reload, and :restart-sec (simple-only keys).
  • :tags must be a symbol, string, or proper list of symbols/strings. Empty strings and nil elements within the list are rejected.
  • :after, :requires, :before, :wants, and :conflicts must be string or proper list of strings. Empty or whitespace-only dependency IDs are rejected. Self-referencing the entry’s own ID is rejected.
  • :wanted-by and :required-by must be string or proper list of strings. Referenced IDs must exist and be :type target.
  • :working-directory must be a string or nil.
  • :environment must be an alist of (KEY . VALUE) string pairs. Keys must match [A-Za-z_][A-Za-z0-9_]*. Duplicate keys are rejected.
  • :environment-file must be a string, proper list of strings, or nil.
  • :exec-stop and :exec-reload must be a string, proper list of strings, or nil. Empty or whitespace-only command strings within are rejected.
  • :restart-sec must be a non-negative number or nil.
  • :description must be a string or nil.
  • :documentation must be a string, proper list of strings, or nil.
  • :kill-signal must be a recognized signal name (e.g., SIGTERM, SIGINT).
  • :kill-mode must be symbol process or mixed.
  • :remain-after-exit must be boolean; rejected for simple type.
  • :success-exit-status items must be integers (0–255) or recognized signal names; rejected for oneshot type.
  • :user and :group must be a string, integer, or nil. Identity requirements (root privileges, trust gate) are enforced at launch time, not during validation. See Security for details.
  • :sandbox-profile must be one of none, strict, service, or desktop.
  • :sandbox-network must be shared or isolated.
  • :sandbox-ro-bind, :sandbox-rw-bind, and :sandbox-tmpfs must be strings or proper lists of absolute path strings. Empty paths and forbidden destinations (/proc, /dev) are rejected. Duplicate paths are deduplicated (first occurrence wins). Bind sources (:sandbox-ro-bind and :sandbox-rw-bind) must exist on disk; :sandbox-tmpfs destinations do not require existence (bwrap creates them).
  • :sandbox-raw-args must be a list of strings. Rejected unless elinit-sandbox-allow-raw-bwrap is non-nil. Raw args that conflict with the effective network mode or duplicate profile-managed setup are rejected (see Expert Raw Mode).
  • Sandbox keys are rejected for :type target.
  • Units requesting sandbox (any sandbox key present or profile not none) on non-Linux hosts are rejected.
  • Units requesting sandbox without bwrap (bubblewrap) installed are rejected.
  • :log-format must be text or binary. binary is rejected unless elinit-log-format-binary-enable is non-nil. :log-format is rejected for :type target.
  • :limit-nofile, :limit-nproc, :limit-core, :limit-fsize, :limit-as each accept a non-negative integer (sets both soft and hard), the symbol infinity (sets both to unlimited), or a string "SOFT:HARD" where each component is a non-negative integer or infinity. Floats, negative integers, and malformed strings are rejected. Limit keys are rejected for :type target.

Invalid entries are skipped at start and shown as invalid in dashboard and CLI status/validate output.

ID Resolution and Duplicate Handling

ID resolution for string entries uses the basename of the first command token (after split-string-and-unquote).

Duplicate IDs are deterministic:

  • First valid occurrence wins.
  • Later duplicates are skipped with warning.

Dependency Semantics: :after, :requires, :before, :wants

  • :after: ordering only.
  • :requires: pull-in + ordering.
  • :before: inverse ordering (A :before B is equivalent to B :after A).
  • :wants: soft dependency with ordering preference.

Planner rules:

  • Dependencies are global.
  • :before is inverted into :after edges before sorting.
  • Missing :after and :before refs are dropped with warning.
  • Missing :wants refs are dropped.
  • Missing :requires refs are dropped for non-target units; for target units, missing :requires makes the target invalid.
  • Topological sort uses stable source order as tie-break.
  • Cycle fallback clears :after, :requires, and :wants edges for cycle participants and falls back to deterministic source order.

:wants soft dependency semantics:

  • A wanted unit that is missing, disabled, masked, or fails to start does not block the wanting unit.
  • :wants does not force-start disabled units.

Conflict Semantics: :conflicts

  • :conflicts: mutual exclusion. Starting a unit stops active conflicting units.
  • Conflicts are symmetric at runtime: if A declares :conflicts "B", starting A stops B and starting B stops A.
  • Conflict-stopped units do not auto-restart until explicitly started.
  • Missing conflict targets are dropped with warning (same as :after).
  • Targets may declare :conflicts.

Example – two audio servers that cannot coexist:

;; pipewire.el
(:id "pipewire"
 :command "pipewire"
 :conflicts "pulseaudio"
 :wanted-by ("graphical.target"))

;; pulseaudio.el
(:id "pulseaudio"
 :command "pulseaudio --start"
 :conflicts "pipewire"
 :wanted-by ("graphical.target")
 :disabled t)

Starting pipewire stops pulseaudio if it is running, and vice versa. The stopped unit does not auto-restart until explicitly started via the dashboard or CLI.

Internal Normalized Shape

Valid entries are normalized to schema v1 tuple form:

(id cmd delay enabled-p restart-policy logging-p
    stdout-log-file stderr-log-file
    type after
    oneshot-blocking oneshot-timeout tags requires
    working-directory environment environment-file
    exec-stop exec-reload restart-sec
    description documentation before wants
    kill-signal kill-mode remain-after-exit success-exit-status
    user group wanted-by required-by
    sandbox-profile sandbox-network sandbox-ro-bind sandbox-rw-bind
    sandbox-tmpfs sandbox-raw-args
    log-format
    limit-nofile limit-nproc limit-core limit-fsize limit-as
    conflicts)

Accessor functions (elinit-entry-id, elinit-entry-command, etc.) are the canonical way to read entry fields.

Validation and Dry-Run Commands

M-x elinit-verify:

  • validates services
  • validates timers when timer module is available
  • populates invalid hashes for dashboard/CLI visibility
  • opens *elinit-verify* report buffer

M-x elinit-dry-run:

  • builds plan without starting processes
  • prints resolved target closure and dependency metadata
  • prints timer validation summary when timers are configured
  • opens *elinit-dry-run* report buffer

Unit Files (Modular Configuration)

Services are defined as individual unit files. Each unit file is a single .el file containing one plist expression.

Authority Roots (Cascading Resolution)

Unit files are loaded from a configurable list of authority roots, searched in order from lowest to highest precedence. When the same unit ID exists in multiple roots, the highest-precedence root wins completely (no key-level merge). Non-existent roots are silently skipped.

(setq elinit-unit-authority-path
      '("/usr/lib/elinit.el/"    ; Tier 1: vendor (lowest precedence)
        "/etc/elinit.el/"        ; Tier 2: system admin
        "~/.config/elinit.el/")) ; Tier 3: user (highest precedence)

The default three-tier layout mirrors systemd’s resolution order. You can add, remove, or reorder roots to suit your environment.

Unit files found across all active roots are resolved at startup using the precedence rules described below.

Unit File Format

Each file contains a single plist:

;; ~/.config/elinit.el/nm-applet.el
(:id "nm-applet"
 :command "nm-applet"
 :type simple
 :wanted-by ("graphical.target")
 :restart t)

Minimum valid keys (schema):

  • :id (non-empty string)
  • :command (string, for non-target units)

For operator-managed services, prefer an explicit baseline instead of relying on defaults:

  • :id
  • :command
  • :type
  • :wanted-by or :required-by
  • :restart

This package defaults :type to simple, :enabled to t, and :restart to always when omitted. See Service Definition for the full key list, defaults, and constraints.

Unit File Best Practices (Data-Only Declarations)

Unit files are Lisp syntax, but they should be treated as static data declarations, not mini programs, so validation stays reliable, behavior stays reproducible, and unit loading stays deterministic.

Recommended:

  • Keep values literal and explicit.
  • Put conditional logic in wrapper scripts or the service program itself.
  • Keep runtime behavior reproducible across machines and restarts.

Discouraged:

  • Embedding logic in unit declarations (if, when, filesystem checks, environment-dependent branching, or read-time evaluation tricks).

Good example (declarative):

(:id "backup"
 :command "/usr/local/bin/backup-runner --mode=incremental"
 :type oneshot
 :wanted-by ("multi-user.target")
 :logging t)

Bad example (logic in declaration):

;; Discouraged: dynamic branching in unit data.
;; Keep this kind of logic in a script/program, not the unit file.
(:id "backup"
 :command (if (file-exists-p "/mnt/backup")
              "/usr/local/bin/backup-runner --mode=incremental"
            "/usr/local/bin/backup-runner --mode=local")
 :type oneshot
 :wanted-by ("multi-user.target"))

Precedence and Merge Semantics

Within each authority root, unit files (*.el) are scanned in alphabetical order. Roots are resolved in the order listed in elinit-unit-authority-path (low to high precedence). When the same unit ID appears in multiple roots, the highest-precedence root wins completely – no per-key merge is performed.

  • Same root, duplicate IDs: first file wins, later duplicates are skipped with warning.
  • Cross-root, same ID: highest-precedence root wins, lower-precedence entries are shadowed.
  • ID absent from all roots: not loaded (use edit ID to create a new unit), except default log maintenance IDs (logrotate, log-prune) when elinit-seed-default-maintenance-units is enabled.

Override Example

A vendor root provides a default service:

;; /usr/lib/elinit.el/polkit.el  (Tier 1, vendor)
(:id "polkit"
 :command "/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1"
 :type simple
 :wanted-by ("multi-user.target"))

A user root overrides it with different target membership and logging:

;; ~/.config/elinit.el/polkit.el  (Tier 3, user)
(:id "polkit"
 :command "/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1"
 :type simple
 :wanted-by ("graphical.target")
 :logging t)

The user definition wins entirely. The vendor definition is shadowed.

Invalid Authority Root Example

If an authority root does not exist, it is silently skipped. This means a misconfigured path does not cause an error – it simply contributes no units:

(setq elinit-unit-authority-path
      '("/nonexistent/root/"          ; Silently skipped (does not exist)
        "/etc/elinit.el/"      ; Active if exists
        "~/.config/elinit.el/")) ; Active if exists

Invalid Winner Blocks Fallback Example

If the highest-tier unit file for an ID is invalid, it blocks fallback to a valid lower-tier unit for the same ID. This prevents surprising behavior where a broken override silently reverts to the vendor default:

;; /usr/lib/elinit.el/backup.el  (Tier 1, vendor -- valid)
(:id "backup"
 :command "/usr/bin/backup-agent"
 :type simple
 :wanted-by ("multi-user.target"))
;; ~/.config/elinit.el/backup.el  (Tier 3, user -- INVALID: missing :command)
(:id "backup"
 :type simple)

Result: backup appears as invalid in the dashboard and CLI. The vendor definition is not used as a fallback – the invalid higher-tier winner blocks it. Fix the user unit file or delete it to unblock the vendor definition.

Validation

Unit files are validated at load time:

  • Unknown keywords are rejected.
  • Missing :id is rejected.
  • :command is required for non-target entries and rejected for target entries.
  • :id must be a non-empty string; :command must be a string when present.
  • File path is included in validation error messages.
  • Invalid unit files are skipped and logged.
  • Invalid unit files appear in the dashboard and CLI with invalid status.

Editing Unit Files

From the dashboard, press i e on any service row to open its unit file for editing. If the file does not exist, a scaffold template is created with the service ID and common configuration keys commented out.

On save, the unit file is validated automatically and results are reported in the minibuffer. A minor mode (elinit-edit-mode) is activated with convenient return bindings: press q to return to the *elinit* dashboard (prompts to save if modified), or C-c C-q to save and return unconditionally. Killing the buffer also returns to the dashboard.

From the CLI:

elinitctl edit nm-applet

This resolves the unit file path and launches $VISUAL or $EDITOR. If the file does not exist, a scaffold is created first. If neither $VISUAL nor $EDITOR is set, an error is returned with the unit file path for manual editing.

Viewing Unit Files

From the dashboard, press c on a service row to view its unit file in read-only mode (view-mode). Press q to return.

From the CLI:

elinitctl cat nm-applet

This outputs the raw unit file content. Returns an error if the file does not exist. With --json, returns {"path": "...", "content": "..."}.

Adding a New Service

Step 1: Create the unit file

Create a file in your highest-precedence authority root (default ~/.config/elinit.el/). The filename should be ID.el (e.g., nm-applet.el):

;; ~/.config/elinit.el/nm-applet.el
(:id "nm-applet"
 :command "nm-applet"
 :type simple
 :wanted-by ("graphical.target")
 :restart t)

Or press i e in the dashboard to scaffold a unit file interactively.

Step 2: Reload

Run elinitctl daemon-reload to pick up the new unit file.

Startup and Lifecycle Model

Activation Root and Closure

Startup begins from a resolved root target:

  • elinit-default-target selects the startup root.
  • If it is default.target, elinit resolves elinit-default-target-link first.
  • Alias targets (for example runlevel5.target) are resolved to canonical targets before activation.

The root must exist and be :type target. Elinit then computes an activation closure:

  • Target units pull in their own :requires and :wants.
  • Target units also pull in inverse membership edges contributed by service :required-by and :wanted-by declarations.
  • Service units pull in their own :requires and :wants.

Only units in this closure are activated during startup. Units outside the closure – including both service and target units – are shown as unreachable in status surfaces. This applies to all unit types:

  • Service and oneshot units outside the closure show unreachable.
  • Target units outside the closure show unreachable (not pending).
  • Alias targets (e.g., runlevel3.target) resolve to their canonical target before checking closure membership, so an alias shows reached when its canonical target has converged, and unreachable when the canonical target is not in the closure.

In a normal graphical.target boot, transition targets such as rescue.target, shutdown.target, poweroff.target, and reboot.target are not in the activation closure and display as unreachable. This is expected – these targets activate only when explicitly requested via isolate or init.

Async DAG Scheduler Semantics

Scheduling is a global DAG over the activation closure.

  • In-degree 0 entries are eligible to start.
  • Dependents unlock when prerequisites become ready.
  • Disabled entries are marked ready immediately.
  • Start failures mark ready immediately (do not block graph).
  • Optional elinit-max-concurrent-starts limits active spawn attempts.

Ready semantics:

  • simple: ready when process spawns.
  • oneshot: ready on exit (success/failure) or timeout.
  • target: ready when required members converge (reached/degraded).

Startup completion requires all of:

  • all entries started/skipped/failed-to-spawn,
  • no pending delay timers,
  • no pending blocking oneshots,
  • no targets still converging.

Lifecycle State Machine

Per-entry runtime state is tracked in elinit--entry-state.

States:

  • pending
  • waiting-on-deps
  • delayed
  • disabled
  • started
  • failed-to-spawn
  • startup-timeout

invalid is a surfaced status (from validation hash tables), not a lifecycle FSM state in elinit--entry-state.

Invalid transitions signal errors unless forced by internal maintenance paths.

Process Spawn and Command Execution

Processes are created via make-process.

  • Command arguments are parsed by split-string-and-unquote.
  • No implicit shell is used.
  • Use explicit shell (sh -c ...) when shell semantics are required (for example pipelines cmd1 | cmd2, redirects > file, or shell expansion like $HOME).

Restart and Crash Loop Policy (simple)

Restart behavior is controlled by effective restart policy (config + runtime override). The four restart policies are:

PolicyBehavior
noNever auto-restart
on-successRestart only on clean exit (exit 0 or clean signal)
on-failureRestart only on non-clean exit
alwaysRestart regardless of exit type

Clean exit means exit code 0, or a signal in SIGHUP, SIGINT, SIGPIPE, SIGTERM. The :success-exit-status keyword extends this set with additional numeric exit codes and/or signal names per unit. For example, :success-exit-status (42 SIGUSR1) treats exit code 42 and SIGUSR1 as clean exits for restart-policy evaluation.

Set the policy at runtime from the dashboard policy menu (p r, explicit selection) or via CLI: elinitctl restart-policy always myservice.

Crash-loop protection:

  • Delay: elinit-restart-delay (overridden per-unit by :restart-sec)
  • Window: elinit-restart-window seconds
  • Threshold: elinit-max-restarts
  • On crash-loop threshold, service is marked failed (dead) and restart stops.

oneshot services are not auto-restarted.

Per-Unit Restart Delay (:restart-sec)

Set :restart-sec to override elinit-restart-delay for a single unit. A value of 0 means immediate retry. If nil (the default), the global delay applies. simple only.

Working Directory (:working-directory)

Set :working-directory to a path string. The process starts with that directory as its working directory.

  • "~" and ~/... are expanded to the home directory.
  • Relative paths are resolved against the directory containing the authoritative unit file for that unit.
  • If the resolved directory does not exist, the process fails to start.

Environment Variables (:environment, :environment-file)

Effective environment build order:

  1. Start from inherited process-environment.
  2. Apply :environment-file entries in list order.
  3. Apply :environment pairs in list order.
  4. Later assignment for the same key overrides earlier assignment.

:environment is an alist of (KEY . VALUE) string pairs:

:environment (("APP_ENV" . "prod")
              ("LOG_LEVEL" . "info"))

:environment-file is a path (or list of paths) to files containing KEY=VALUE lines:

  • Blank lines and lines starting with # or ; are ignored.
  • Optional ~export ~ prefix is accepted and stripped.
  • Key must match [A-Za-z_][A-Za-z0-9_]*.
  • Invalid lines produce logged warnings with file:line context (non-fatal).
  • Relative paths are resolved against the authoritative unit file directory.
  • Leading - in a path means a missing file is silently ignored; without it, a missing file is an error that prevents the process from starting.

Reload Semantics (:exec-reload)

When a running simple unit has :exec-reload commands, reloading it (elinitctl reload ID, dashboard l u) runs the reload commands sequentially without stopping or restarting the process. Each command has a per-command timeout of elinit-shutdown-timeout seconds.

  • If reload commands succeed, the unit reports reloaded.
  • If any reload command fails, the unit reports an error and the process continues running.
  • If :exec-reload is nil, reload falls back to the default behavior (stop the process, start with new config).

Oneshot Exit Encoding

For oneshot completion tracking (elinit--oneshot-completed):

  • normal exit stores exit code (0, 1, …)
  • signal death stores negative signal number (SIGKILL -> -9)

This is used by status/reporting and timer retry eligibility.

Comparison with systemd Oneshot Semantics

Elinit oneshot services are modeled after systemd’s Type=oneshot services, but the mapping is not one-to-one.

ElinitsystemdNotes
:type oneshotType=oneshotRun-to-completion services
:oneshot-blocking tType=oneshot (inherent)Blocking is inherent to Type=oneshot; ordering deps wait for exit
:oneshot-async tNo direct equivalentProcess runs without blocking dependency progression
:oneshot-timeout 30TimeoutStartSec=30Kill the process if it hasn’t exited in time
No restart for oneshotRestart= partially validsystemd allows Restart=on-failure etc. for oneshot; elinit forbids all restart for oneshot
:working-directoryWorkingDirectory=Process working directory
:environmentEnvironment=Key-value pairs as alist
:environment-fileEnvironmentFile=Paths to env files; - prefix = optional
:exec-stopExecStop=Custom stop commands (simple only)
:exec-reloadExecReload=Custom reload commands (simple only)
:restart-secRestartSec=Per-unit restart delay (simple only)
:descriptionDescription=Human-readable description
:documentationDocumentation=Documentation URIs/paths
:beforeBefore=Inverse ordering dependency
:wantsWants=Soft dependency
:kill-signalKillSignal=Graceful stop signal
:kill-modeKillMode=process or mixed (no control-group or none)
:remain-after-exitRemainAfterExit=Latch active status on success (oneshot only)
:success-exit-statusSuccessExitStatus=Extra clean exit criteria (simple only)

Key differences:

  • Blocking is the default. Elinit oneshots block dependency progression by default (elinit-oneshot-default-blocking is t). In systemd, blocking is inherent to Type=oneshot: ordered dependencies wait for the oneshot process to exit before starting. RemainAfterExit= controls whether the unit stays in “active” state after exit, not whether it blocks ordering.
  • Restart policy. Elinit forbids all restart policies for oneshot services. systemd permits Restart=on-failure, on-abnormal, on-abort, and on-watchdog for Type=oneshot (restarting on non-clean exit), but disallows always and on-success.
  • Timeout default. Elinit defaults to elinit-oneshot-timeout (30 seconds). systemd disables the startup timeout for oneshot by default (TimeoutStartSec=infinity), relying on the administrator to set explicit limits.
  • RemainAfterExit. Elinit supports :remain-after-exit t for oneshot units. If the process exits with code 0, the unit status latches to active until explicitly stopped. Non-zero exit still results in failed status. stop on an active latched unit transitions it to stopped. start on an active unit is a no-op. restart re-runs the oneshot.
  • Target model and closure. Both systems sequence oneshots with dependency ordering. Elinit activation is root-target closure based (:required-by~/:wanted-by~ + :requires~/:wants~), then globally topologically ordered.

Stop Semantics

Per-unit stop (elinitctl stop ID, dashboard l t):

  1. If the unit has :exec-stop commands, they run sequentially with the unit’s effective working directory and environment. Each command has a per-command timeout of elinit-shutdown-timeout seconds.
  2. After stop commands complete (or fail), the process is terminated via signal. The signal used is the unit’s :kill-signal (default SIGTERM).
  3. Stop commands failing does not abort the shutdown path.

elinit-stop (async graceful):

  1. Runs :exec-stop command chains for applicable simple units.
  2. Sends each unit’s :kill-signal (default SIGTERM) to remaining live processes.
  3. After elinit-shutdown-timeout, sends SIGKILL to survivors. For units with :kill-mode mixed, SIGKILL is also sent to discovered descendant processes of the main process.

elinit-stop-now (sync hard stop):

  • sends immediate SIGKILL
  • waits up to 0.5s for process death
  • does not run :exec-stop commands
  • intended for kill-emacs-hook

:kill-signal:

  • Overrides the default graceful stop signal (SIGTERM) for this unit.
  • Accepts signal name symbols (e.g., SIGQUIT, SIGUSR1).
  • Short forms like QUIT are normalized to SIGQUIT.
  • Applies to stop, restart, and shutdown paths.

:kill-mode:

  • process (default): signal only the main managed process.
  • mixed: send the graceful :kill-signal to the main process first; on timeout, send SIGKILL to both the main process and its discovered descendants.
  • Descendant discovery uses list-system-processes and process-attributes (PID-tree traversal). If OS/process metadata is unavailable, a warning is logged and behavior falls back to process mode.

Runtime Overrides and Reconciliation

Runtime Overrides

Four override tables are supported:

  • mask override (elinit--mask-override)
  • enabled override (elinit--enabled-override)
  • restart override (elinit--restart-override)
  • logging override (elinit--logging)

Effective value resolution for enabled state:

  • if masked, always disabled (highest precedence),
  • else explicit enabled override if present,
  • otherwise config default.

Enable/disable semantics follow the systemctl model:

  • enable ID: unit should start on next elinit-start. Persisted as override.
  • disable ID: unit should NOT start automatically. Persisted.
  • start ID on a disabled unit: starts it this session only. Does not change enabled state. Only mask blocks manual start.

Persistence of Overrides

Overrides are persisted in elinit-overrides-file.

  • Saved with atomic write (temp file + rename)
  • Loaded on elinit-start
  • Corrupt file is logged and preserved

Interactive helpers:

  • M-x elinit-overrides-load
  • M-x elinit-overrides-save
  • M-x elinit-overrides-clear

All dashboard policy commands (enable, disable, mask, unmask, set-restart-policy, set-logging) persist overrides immediately via elinit--save-overrides, matching the CLI behaviour.

Target System

Elinit uses systemd-style targets for startup ordering and runtime isolation. Targets are dependency-only units (no command) whose IDs end in .target.

Built-in Targets

Canonical built-in targets (lowest authority, user overrides win):

TargetRequiresDescription
basic.target(none)Basic system initialization
multi-user.targetbasic.targetMulti-user services
graphical.targetmulti-user.targetGraphical session
default.target(none)Startup root (alias resolved via elinit-default-target-link)
rescue.targetbasic.targetSingle-user rescue mode
shutdown.target(none)Shutdown synchronization barrier
poweroff.targetshutdown.targetPower-off target
reboot.targetshutdown.targetReboot target

SysV Init Runlevel Compatibility

Elinit maps SysV numeric runlevels to targets using systemd mapping semantics. The mapping is fixed and not configurable:

RunlevelTargetDescription
0poweroff.targetHalt/power off
1rescue.targetSingle-user / rescue
2multi-user.targetMulti-user (no NFS)
3multi-user.targetMulti-user (full)
4multi-user.targetReserved / multi-user
5graphical.targetGraphical session
6reboot.targetReboot

Use init N or telinit N to switch runlevels at runtime. Both commands map the numeric runlevel to the corresponding target and execute an isolate transaction (stops entries not in the new target closure, starts entries in the closure).

These commands manage elinit target state only. They do not directly issue kernel poweroff or reboot syscalls.

Runlevels 0 and 6 are destructive transitions and require confirmation. In interactive Emacs, a y-or-n-p prompt is shown. In batch mode or JSON output, --yes is required. Runlevels 1-5 do not require confirmation.

init N is transaction-scoped and does NOT persist default target changes. Use set-default TARGET to persist a default target override.

Alias Targets

Alias targets provide alternate names for canonical targets. They resolve to the canonical target before graph expansion. The built-in runlevel aliases are:

AliasResolves to
runlevel0.targetpoweroff.target
runlevel1.targetrescue.target
runlevel2.targetmulti-user.target
runlevel3.targetmulti-user.target
runlevel4.targetmulti-user.target
runlevel5.targetgraphical.target
runlevel6.targetreboot.target

Alias resolution is immutable. User units cannot redefine runlevelN.target alias mappings.

set-default accepts alias targets but always persists the resolved canonical target ID. list-targets shows the KIND column (canonical or alias) and resolved link for alias targets.

Timer Denylist for Init-Transition Targets

Timer targets that represent init transitions are not timer-eligible. The timer validation denylist includes:

  • rescue.target, shutdown.target, poweroff.target, reboot.target
  • runlevel0.target through runlevel6.target

Timers targeting these IDs are rejected during validation with a reason of the form :target 'ID' is an init-transition target and is not timer-eligible and excluded from the scheduler.

Timer Subsystem

Timer subsystem lives in elinit-timer.el and is enabled by default. Both elinit-mode and elinit-timer-subsystem-mode must be active for timers to run.

To disable:

(elinit-timer-subsystem-mode -1)

Disabling the timer subsystem disables all timer-driven scheduling, including logrotate-daily and log-prune-daily. It does not disable normal unit startup.

Timer Configuration (elinit-timers)

Each timer is a plist.

Required keys:

  • :id (non-empty string)
  • :target (non-empty string; must resolve to a oneshot, simple, or target unit)

Trigger keys (at least one required):

  • :on-calendar
  • :on-startup-sec
  • :on-unit-active-sec

Optional keys:

  • :enabled (boolean, default t)
  • :persistent (boolean, default t)
(setq elinit-timers
      '((:id "daily-backup"
         :target "backup-oneshot"       ;; oneshot target
         :on-calendar (:hour 3 :minute 0)
         :persistent t)
        (:id "warm-cache"
         :target "cache-prime"          ;; simple service
         :on-startup-sec 45)
        (:id "sync-loop"
         :target "sync-oneshot"         ;; oneshot target
         :on-unit-active-sec 300)
        (:id "nightly-converge"
         :target "multi-user.target"    ;; target unit
         :on-calendar (:hour 2 :minute 30))))

Calendar Trigger Format

:on-calendar accepts either:

  • single plist, or
  • list of plists (earliest next match wins)

Allowed fields:

  • :minute (0..59)
  • :hour (0..23)
  • :day-of-month (1..31)
  • :month (1..12)
  • :day-of-week (0..6)

Field values:

  • integer
  • non-empty list of integers
  • * wildcard

Semantics:

  • next run is computed strictly after current time
  • day-by-day search with bounded horizon (28-year coverage for full leap-day + weekday combinations)
  • DST gaps are handled by validating encoded/decoded wall clock fields

Trigger Semantics

:on-startup-sec:

  • positive integer seconds after scheduler startup
  • fires once per elinit session

:on-unit-active-sec:

  • positive integer seconds after last successful target execution
  • anchor is type-specific:
    • oneshot: anchored at last successful completion timestamp
    • simple: anchored at last successful activation timestamp (spawn success or already-active no-op)
    • target: anchored at last successful convergence timestamp (reached or already-reached no-op)

Combined timers:

  • scheduler chooses earliest due trigger among configured trigger types

Overlap, Disable, and Missing-Target Behavior

When due:

  • Disabled timer -> skipped, miss reason disabled
  • Active target oneshot -> skipped, miss reason overlap
  • Already-running simple service -> success no-op, reason already-active
  • Converging target unit -> skipped, miss reason target-converging
  • Disabled target unit -> skipped, miss reason disabled-target
  • Masked target unit -> skipped, miss reason masked-target
  • Missing target entry -> failure recorded, reason target-not-found

Miss metadata is stored in timer state.

Retry Policy

Configured by elinit-timer-retry-intervals (default '(30 120 600)).

  • Retryable failures for oneshot targets: positive exit codes only
  • Retryable failures for simple targets: spawn failures
  • Retryable failures for target units: degraded convergence outcome
  • Non-retryable: signal deaths (negative stored exit codes), nil, zero, overlap skips
  • Retry budget resets on fresh scheduled runs

Catch-up Policy

Configured by:

  • :persistent per timer (default t)
  • elinit-timer-catch-up-limit in seconds (default 24h)

On scheduler start, persistent timers may trigger catch-up runs for missed schedules within the configured window.

Timer Persistence

Timer runtime state file: elinit-timer-state-file.

  • Atomic writes (temp + rename)
  • Schema versioned
  • Newer incompatible schema versions are rejected
  • Stale timer IDs are pruned when scheduler starts

Persisted keys include:

  • :last-run-at
  • :last-success-at
  • :last-failure-at
  • :last-exit
  • :last-missed-at
  • :last-miss-reason
  • :last-result (success, failure, or skip)
  • :last-result-reason (type-specific reason symbol or nil; e.g. already-active, already-reached, overlap, spawn-failed, target-reached, target-degraded, target-not-converged, convergence-unknown, target-converging, target-not-found, disabled, disabled-target, masked-target)
  • :last-target-type (oneshot, simple, or target)

Transient keys (for example :next-run-at, retry bookkeeping, startup-consumed state) are recomputed each session.

State is saved on every trigger, completion, and scheduler stop.

Timer Scheduler Lifecycle

  • Started after startup completion
  • Stopped on elinit-stop, elinit-stop-now, and when timer mode is disabled
  • Uses run-at-time scheduling (no polling loop)

Timer Visibility Surfaces

Dashboard:

  • Timer section is shown when elinit-dashboard-show-timers is non-nil.
  • Target and tag filters apply only to the service section; the timer section is always visible when elinit-dashboard-show-timers is non-nil.
  • When the timer subsystem is disabled, the timer section renders with an explicit “(disabled)” label and no interactive timer actions.
  • When no timers are configured, the section shows “no timers configured”.

CLI:

  • elinitctl list-timers shows timer runtime/invalid definitions.
  • If subsystem is gated off, command returns explicit disabled status.

Dashboard (M-x elinit)

Dashboard buffer: *elinit*, major mode elinit-dashboard-mode (derived from tabulated-list-mode).

Service-First Default View

The dashboard defaults to a service-first view that hides target-type entries. This reduces noise for operators who use elinit as a service manager rather than a PID1 init system.

Press v to toggle target visibility. When targets are shown, init-transition targets (rescue, shutdown, poweroff, reboot, runlevel0, runlevel1, runlevel6) remain hidden by default. Press V to include init-transition targets.

The header line always shows a bracket indicator for the current visibility state: [services] in the default view, [services+targets] when targets are shown, [services+targets+init] when init-transition targets are also included.

When a target filter is active (via f), the filtered target and its members are always shown regardless of the visibility toggle state.

Service Columns

  • ID
  • Type
  • Target
  • Enabled
  • Status
  • Restart
  • Log
  • PID
  • Reason

Service Status Values

  • running
  • active (oneshot with :remain-after-exit exited successfully)
  • done
  • failed
  • dead
  • pending
  • stopped
  • masked
  • invalid

active means a :remain-after-exit oneshot exited with code 0 and is latched active until explicitly stopped. masked means the entry has been explicitly masked and is always disabled. invalid means configuration/validation failure, not a runtime FSM state.

Service Reason Values

  • masked
  • disabled
  • delayed
  • waiting-on-deps
  • failed-to-spawn
  • startup-timeout
  • crash-loop

Timer Rows in Dashboard

Timer rows appear in a dedicated section below services with their own column layout: ID, TARGET, ENABLED, LAST-RUN, NEXT-RUN, EXIT, REASON, TYPE, RESULT. This matches the columns of elinitctl list-timers.

Timer rows support interactive timer actions via the y submenu. Service-only commands (lifecycle, policy, inspect) reject timer rows with the message: “Not available for timer rows: use timer actions (y or ? -> Timers)”.

The first body row is a services section header: ── Services TYPE TARGET ENABLED STATUS RESTART LOG PID REASON. Service rows follow under this section.

Service counters (run/done/pend/fail/inv) are shown in the Emacs header line. These counters are service-only and do not include timer rows.

Timer rows do not use service STATUS~/~REASON semantics. They show timer-specific runtime fields:

  • LAST-RUN: relative time since the most recent trigger (or -)
  • NEXT-RUN: relative time until next scheduled trigger (or -)
  • EXIT: last target exit code (or -)
  • REASON: last result reason symbol (or -)
  • TYPE: target type (oneshot, simple, target, or -)
  • RESULT: last execution result (success, failure, skip, or -)

Dashboard Keymap

The dashboard uses a Magit-style nested menu model. Top-level keys handle navigation, filtering, and system controls. Service actions are grouped into three submenus (lifecycle, policy, inspect) accessed by a prefix key followed by a second key. Timer actions use the y prefix.

Top-Level Keys

KeyFunction
fCycle target filter (all -> targetA -> targetB -> …)
FCycle tag filter
vToggle target visibility (service-first default)
VToggle init-transition targets (rescue/shutdown/poweroff/reboot/runlevel0/1/6)
gRefresh dashboard
GToggle auto-refresh (live monitoring)
tOpen proced (system process list)
TToggle proced auto-update mode
lOpen Lifecycle submenu (service rows only)
pOpen Policy submenu (service rows only)
iOpen Inspect submenu (service rows only)
yOpen Timers submenu (timer rows only)
?Open transient action menu
hOpen dashboard help buffer
qQuit dashboard

Lifecycle (l)

KeyFunction
sStart process
tStop process (graceful, suppresses restart)
rRestart process (stop + start)
kKill process (send signal, restart policy unchanged)
uReload unit (re-read config and restart)
fReset failed state

Policy (p)

Policy actions are explicit verbs (not blind toggles).

KeyFunction
eEnable entry
dDisable entry
mMask entry (always disabled)
uUnmask entry
rSet restart policy (via selection)
lSet logging (via selection)

Inspect (i)

All inspect actions are read-only.

KeyFunction
iShow entry details (C-u for status legend)
dShow dependencies for entry
gShow dependency graph
bBlame: startup timing sorted by duration
lView log file
cView unit file (read-only)
eEdit unit file (create scaffold if missing)
mShow target members (target rows only)

Timers (y)

Timer actions operate on timer rows only. Service rows reject these actions.

KeyFunction
tTrigger timer now (manual reason)
iShow timer details (schedule, state, retry info)
jJump to target service row
rReset timer runtime state and recompute next run
gRefresh timer section

Timer actions are also available via the ? transient menu under the “Timers” group.

Limitations in this version:

  • cat and edit are not available for timer definitions (timers do not have unit files).
  • Timer enable/disable policy overrides are not yet supported.

Notes

  • Separator rows reject service actions.
  • Timer rows reject service-only commands (lifecycle, policy, inspect).
  • Service rows reject timer-only commands.
  • ? requires the transient package.
  • Full ID is echoed in minibuffer when current row ID exceeds table width.
  • Stop vs Kill: l t (stop) gracefully terminates and suppresses auto-restart; l k (kill) sends a signal without changing restart policy. Use l s to start a stopped service again.
  • Restart: l r performs a stop-then-start cycle for service rows.

Mode Interaction and Buffer-Local State

  • elinit-mode is the parent global mode; enabling it runs elinit-start, disabling it runs elinit-stop.
  • elinit-timer-subsystem-mode is a global gate for the timer subsystem and only becomes active when elinit-mode is also enabled.
  • elinit-dashboard-mode is the major mode for *elinit* and can be opened independently of whether elinit is currently running.
  • Dashboard filters are buffer-local: elinit--dashboard-target-filter and elinit--dashboard-tag-filter.
  • Dashboard auto-refresh timer (elinit--auto-refresh-timer) is buffer-local and defaults to off until toggled with G / M-x elinit-dashboard-toggle-auto-refresh.

Dashboard Faces

All dashboard faces are in customization group elinit:

FaceUsed for
elinit-status-runningStatus running
elinit-status-doneStatus done
elinit-status-failedStatus failed
elinit-status-deadStatus dead
elinit-status-invalidStatus invalid
elinit-status-pendingStatus pending
elinit-status-stoppedStatus stopped
elinit-type-simpleType simple
elinit-type-oneshotType oneshot
elinit-type-timerType timer rows
elinit-enabled-yesEnabled column (yes)
elinit-enabled-noEnabled column (no)
elinit-reasonReason column values
elinit-section-separatorSection header/separator rows

CLI (sbin/elinitctl)

The shell wrapper is transport-only; behavior is implemented in elinit-cli.el dispatchers.

Requires the Emacs server to be running:

(require 'server)
(unless (server-running-p) (server-start))

Wrapper Syntax

sbin/elinitctl [WRAPPER-OPTIONS] COMMAND [COMMAND-ARGS]

Wrapper Options

OptionNotes
--help, -hShow wrapper help
--jsonRequest JSON output from CLI dispatcher
--socket NAME, --socket-name NAME, -s NAMEUse specific local socket
--server-file PATH, -f PATHUse server file transport
--timeout N, -t NPass wait timeout to emacsclient -w

Wrapper transport rules:

  • --socket and --server-file are mutually exclusive.
  • --server-file emits a TCP transport warning.

Wrapper/dispatcher usage behavior:

  • Wrapper --help prints wrapper option help.
  • Calling elinitctl with no command prints dispatcher usage text and command list.

CLI Commands

Systemctl-compatible commands:

  • status [ID...] (detail with IDs, overview without)
  • list-units [ID...] (overview table)
  • show ID (all properties of a unit)
  • start [--target TARGET] [-- ID...]
  • stop [-- ID...]
  • restart [-- ID...]
  • enable [--] ID...
  • disable [--] ID...
  • mask [--] ID... (always disabled, overrides enable)
  • unmask [--] ID...
  • kill [--signal SIG] [--] ID
  • is-active ID (exit 0 if running or latched active, 3 if not, 4 if unknown)
  • is-enabled ID (exit 0 if enabled, 1 if disabled/masked, 4 if unknown)
  • is-failed ID (exit 0 if failed/dead, 1 if not failed, 4 if unknown)
  • daemon-reload (reload unit definitions from disk)
  • reload [--] ID... (hot-reload specific units)
  • cat ID (display raw unit file content)
  • edit ID (edit unit file; creates scaffold if missing)
  • list-dependencies [ID]
  • list-timers

Target commands:

  • list-targets (list all target units with convergence state and alias/canonical kind)
  • target-status TARGET (show convergence, member lists, alias resolution)
  • explain-target TARGET (root-cause chain for target state)
  • isolate --yes TARGET (switch to target, transaction-scoped, does not persist)
  • get-default (show effective default target)
  • set-default TARGET (persist default target; aliases resolved to canonical)
  • init [--yes] N (switch to runlevel 0-6; maps to systemd-style target)
  • telinit [--yes] N (alias for init)

Elinit-specific commands:

  • verify
  • reset-failed [--] [ID...] (with IDs: reset those; without: reset all)
  • restart-policy (no|on-success|on-failure|always) [--] ID...
  • logging (on|off) [--] ID...
  • blame
  • logs [--tail N] [--] ID
  • journal (-u ID | --unit ID | -fu ID) [-n N] [-p (err|info)] [-f|--follow] [--since TS] [--until TS] [--json]
  • ping
  • version

Use -- before IDs that start with - for commands that accept positional IDs.

Command Notes

status:

  • with IDs: detailed per-unit output for valid units, invalid detail for misconfigured units, and “could not be found” for truly missing IDs; non-zero exit only when IDs are truly missing (not just invalid)
  • without IDs: overview table (delegates to list-units)

verify:

  • returns exit code 4 if service or timer validation errors exist
  • returns both service and timer invalid sets

reset-failed:

  • with IDs: reset failed state for those entries
  • without IDs: reset all failed entries
  • clears crash-loop tracking so entries can be restarted

start, stop, restart:

  • with IDs: operate on those IDs
  • with no IDs: operate on whole elinit (start, stop, stop+start)
  • start --target TARGET: start full elinit from TARGET for that invocation only (cannot be combined with specific IDs)
  • start on a disabled unit: succeeds (session-only, no override change). Only masked units are blocked from manual start.

list-dependencies:

  • no ID: full edge list
  • with ID: after/requires/blocks for that ID

list-timers:

  • explicit disabled response when elinit-timer-subsystem-mode is off
  • works even when elinit-mode is off (shows configured timer definitions)

cat:

  • requires exactly one ID argument
  • outputs raw file content from the authoritative unit file (resolved via elinit-unit-authority-path)
  • error if file does not exist
  • JSON: {"path": "...", "content": "..."}

edit:

  • requires exactly one ID argument
  • resolves path via authority roots; new files are created in the highest-precedence active root
  • creates scaffold template if unit file does not exist
  • reports the authority root and tier in human output and JSON
  • non-interactive: launches $VISUAL or $EDITOR
  • JSON: {"path": "...", "root": "...", "tier": N, "created": true/false}

daemon-reload:

  • no arguments
  • re-reads unit files from all authority roots and rebuilds the internal plan
  • does NOT start, stop, or restart anything; runtime state is untouched
  • after daemon-reload, the next start or reload operates on the refreshed plan
  • JSON: {"reloaded": true, "entries": N, "invalid": N}
  • available in dashboard transient menu under “System” group (X)

reload:

  • requires at least one ID argument
  • hot-reloads specific units: re-reads config and applies changes per unit
  • running simple process: stop gracefully, then start with new definition (action: reloaded)
  • not running: update stored definition only; next start uses new config (action: updated)
  • masked unit: skip with warning (action: skipped (masked))
  • unknown ID: error (action: error: not found)
  • does NOT affect other units (operates only on the specified IDs)
  • exit 0 if all units succeed, exit 1 if any unit has an error
  • human output: one line per ID showing ID: action
  • JSON: {"results": [{"id": "x", "action": "reloaded"}, ...]}
  • available in dashboard transient menu under “System” group (u)

is-active, is-enabled, is-failed:

  • require exactly one ID argument
  • use strict systemctl-compatible exit codes (not boolean 0/1)
  • human output prints the status/state string followed by newline
  • is-active: exit 0 if running or latched active, exit 3 if not active, exit 4 if no such unit
  • is-enabled: exit 0 if enabled, exit 1 if disabled or masked, exit 4 if no such unit; output distinguishes “enabled”, “disabled”, and “masked” states
  • is-failed: exit 0 if status is “dead” or “failed”, exit 1 if not failed, exit 4 if no such unit
  • JSON: {"id": "...", "<predicate>": true/false, "status": "..."} (is-enabled uses "state" instead of "status")

Output Formats

Human format is default.

--json returns stable object structures per command (for example status, verify, list-dependencies, list-timers).

Status JSON top-level keys (status without IDs / list-units):

  • entries (array)
  • invalid (array)

Status JSON top-level keys (status ID...):

  • entries (array, valid unit detail)
  • invalid (array, invalid configured units with reason)
  • not_found (array, truly missing IDs)

Per-entry status JSON object keys (entries array):

  • id, type, enabled, status, restart, logging, pid, reason, delay, after, requires
  • start_time, ready_time, duration, unit_file, authority_tier
  • working_directory, environment, environment_file, exec_stop, exec_reload, restart_sec, user, group
  • sandbox_enabled, sandbox_profile, sandbox_network
  • uptime, restart_count, last_exit, next_restart_eta, metrics, process_tree
  • description, documentation, log_tail

Sandbox JSON field notes:

  • sandbox_enabled is true when sandbox is requested, otherwise false.
  • sandbox_profile is always present as one of "none", "strict", "service", or "desktop".
  • sandbox_network is always present as effective mode ("shared" or "isolated"), including profile defaults when no explicit network override is set.

Timer JSON top-level keys:

  • timers (array)
  • invalid (array)

Per-timer JSON object keys:

  • id, target, enabled, persistent
  • on_calendar, on_startup_sec, on_unit_active_sec
  • last_run_at, last_success_at, last_failure_at
  • last_exit, next_run_at, retry_at
  • last_miss_at, miss_reason
  • last_result ("success", "failure", "skip", or null)
  • last_result_reason (reason symbol string or null)
  • target_type ("oneshot", "simple", "target", or null)

Validation JSON top-level keys:

  • services with valid, invalid, errors
  • timers with valid, invalid, errors

Error JSON shape (for argument/runtime errors):

  • error (boolean)
  • message (string)
  • exitcode (integer)

Empty collections are encoded as arrays (not null).

Exit Codes

CodeMeaning
0Success
1Runtime failure (also: is-enabled disabled/masked, is-failed not failed)
2Invalid arguments
3is-active: unit exists but not active (systemctl parity)
4is-*: no such unit; also: verify validation failed
69Emacs server unavailable (EX_UNAVAILABLE)

Events and Hooks

Unified Event Hook

elinit-event-hook receives one plist per event:

  • :type (symbol)
  • :ts (float timestamp)
  • :id (string or nil)
  • :data (plist)

Event types:

  • startup-begin
  • startup-complete
  • process-started
  • process-ready
  • process-exit
  • process-failed
  • cleanup
  • timer-trigger
  • timer-overlap
  • timer-success
  • timer-failure
  • target-reached
  • target-degraded

startup-begin and startup-complete are currently defined event symbols but are not emitted by runtime paths in this release.

Minibuffer Notifications

By default, elinit shows warnings and errors in the minibuffer for process failures, non-zero oneshot exits, and crash-looping services. These come from the core logger (elinit--log), which always displays warning and error level messages. Only info level messages are gated behind elinit-verbose.

For more granular control, use elinit-event-hook to react to structured events:

(add-hook 'elinit-event-hook
          (lambda (event)
            (pcase (plist-get event :type)
              ('process-failed
               (message "Elinit: FAILED to start %s" (plist-get event :id)))
              ('process-exit
               (let ((code (plist-get (plist-get event :data) :code)))
                 (unless (eq code 0)
                   (message "Elinit: %s exited with code %s"
                            (plist-get event :id) code)))))))

You can also check the dashboard or logs.

Persistence and Files

Log Files

  • Elinit-level log file (optional):
  • <elinit-log-directory>/elinit.log (controlled by elinit-log-to-file)
  • Per-process logs:
  • <elinit-log-directory>/log-<id>.log by default (controlled per service logging policy)

By default, each unit merges stdout and stderr into the same per-process log file. Units can override stream targets with :stdout-log-file and :stderr-log-file. If both resolve to the same path, streams remain merged. If they differ, elinit writes stdout and stderr via separate logd writers. These stream overrides are literal file paths; use writable paths and ensure the target directories exist.

Example split stream configuration:

(:id "example-svc"
 :command "/usr/bin/example"
 :stdout-log-file "~/.config/elinit/log/example-svc.out.log"
 :stderr-log-file "~/.config/elinit/log/example-svc.err.log")

Log files are managed by external elinit-logd writer processes. Each writer enforces a per-file size cap (elinit-logd-max-file-size) and rotates locally when the limit is reached.

The default elinit-log-directory is user-local ((expand-file-name "elinit" user-emacs-directory)), so userland setups work without requiring write access to /var/log.

If elinit-log-directory is configured to an unwritable location, elinit automatically falls back to the same user-local default path. If neither location is writable, file logging is skipped and services still run.

Bundled helpers can be built from Emacs with M-x elinit-build-libexec-helpers. Startup can prompt or auto-build missing/stale helpers via elinit-libexec-build-on-startup.

elinit-logd and elinit-runas are deliberately narrow helper executables. elinit-logd handles continuous append/rotate I/O outside the Emacs Lisp scheduler loop, and elinit-runas performs privileged identity transitions (setuid/setgid/initgroups) when units request :user / :group.

Scheduled Rotation

The sbin/elinit-logrotate script provides scheduled rotation and pruning of log files. It rotates active log files by renaming them with a timestamp suffix (YYYYMMDD-HHMMSS), optionally signals logd writers to reopen their files, and prunes old rotated files by age.

sbin/elinit-logrotate --log-dir /path/to/logs --keep-days 14

Options:

  • --log-dir DIR (required) – the log directory to operate on
  • --keep-days N (default: 14) – prune rotated files older than N days
  • --signal-reopen – send SIGHUP to logd writers after rotation
  • --pid-dir DIR (default: same as --log-dir) – directory for writer PID file discovery
  • --dry-run – print actions without executing
  • --help – show usage

Script output behavior:

  • progress/action lines are written to stdout
  • warnings and errors are written to stderr

A typical cron entry for daily rotation:

0 3 * * * /path/to/elinit-logrotate --log-dir /path/to/elinit-log-directory --signal-reopen

Global Pruning

The sbin/elinit-log-prune script enforces a hard cap on the total size of the log directory. It deletes the oldest rotated files first until the directory is at or below the cap. Active log files (log-<id>.log, elinit.log) are never deleted.

sbin/elinit-log-prune --log-dir /path/to/logs --max-total-bytes 1073741824

Options:

  • --log-dir DIR (required) – the log directory to operate on
  • --max-total-bytes N (default: 1073741824, i.e. 1 GiB) – hard cap on total directory size in bytes
  • --lock-file PATH (default: <log-dir>/.prune.lock) – exclusive lock file path to prevent concurrent prune races
  • --protect-id ID (repeatable) – never delete log-<ID>.log; use this for services whose ID contains a timestamp pattern (e.g. svc.20250101-120000) to prevent their active log from being misidentified as rotated
  • --dry-run – print actions without executing
  • --help – show usage

Script output behavior:

  • progress/action lines are written to stdout
  • warnings and errors are written to stderr

The script uses flock for exclusive locking. If another prune instance is already running, the script prints a lock-busy skip message and exits 0 without taking action.

Active logs whose service ID contains a timestamp pattern (e.g. svc.20250101-120000) are unconditionally protected. A file matching the rotated-file naming pattern is only deleted when the script can confirm it is genuinely rotated. Confirmation requires at least one of:

  1. Parent-exists guard – the parent active log exists in the directory (e.g. log-svc.log confirms log-svc.20250101-120000.log is a rotated child of service svc).
  2. Sibling guard – multiple rotated files share the same parent name, confirming they are rotated children of a now-removed service (e.g. log-svc.20240101-120000.log and log-svc.20240201-120000.log both map to parent log-svc.log, confirming each other).
  3. Children guard – the file has its own rotated children, confirming it as an active parent (protects the file from deletion).
  4. Open-file guard – when fuser is available, files currently held open by a process (e.g. the logd writer) are never deleted.

A lone file with no parent, no siblings, and no children is preserved because it could be an active log for a timestamp-like service ID. The --protect-id flag provides additional explicit protection.

A typical cron entry for periodic pruning (e.g. hourly):

0 * * * * /path/to/elinit-log-prune --log-dir /path/to/elinit-log-directory --max-total-bytes 1073741824

Integrated Maintenance

Three paths keep log growth bounded end-to-end:

  1. logd path – each logd writer rotates its own file when the per-file size cap is reached, then triggers the prune script (throttled by elinit-logd-prune-min-interval seconds) to keep the directory within the total size cap.
  2. Daily automatic path – a built-in logrotate oneshot unit and a built-in log-prune oneshot unit are scheduled by separate timers: logrotate-daily at 03:00 and log-prune-daily at 03:05. This runs automatically when the timer subsystem is enabled (the default). The rotate unit uses --signal-reopen, so log writers reopen after rotation.
  3. Manual pathM-x elinit-run-log-maintenance runs the full maintenance sequence asynchronously: rotate all active logs, signal writers to reopen, then prune.

Built-in logrotate and log-prune units are inert fallback definitions – they do not declare :wanted-by and are not activated during startup. They exist solely so that the built-in timers logrotate-daily and log-prune-daily can find the target entry at runtime. When elinit-seed-default-maintenance-units is enabled (the default), the first startup seeds unit files for these IDs into the highest-precedence authority root.

A user-created unit file with :id "logrotate" overrides the built-in entry, and a user-created unit file with :id "log-prune" overrides that built-in entry. Similarly, user timers with IDs logrotate-daily or log-prune-daily in elinit-timers override the built-in timer schedules.

Seeded unit files are write-once: once created, they are never overwritten by elinit. This means improvements to default commands will not propagate to already-seeded units. To pick up updated defaults, delete the seeded unit file and call elinit-daemon-reload (or restart elinit) – the seeder will create a fresh file with current defaults.

Seeding respects runtime overrides: if a maintenance unit is masked (via elinitctl mask) or disabled (via elinitctl disable), its unit file will not be re-seeded after deletion.

To disable automatic daily maintenance, either disable the timer subsystem entirely:

(elinit-timer-subsystem-mode -1)

Or override the built-in timer with a disabled one:

(setq elinit-timers
      '((:id "logrotate-daily" :target "logrotate"
         :on-calendar (:hour 3 :minute 0) :enabled nil)
        (:id "log-prune-daily" :target "log-prune"
         :on-calendar (:hour 3 :minute 5) :enabled nil)))

All paths use the same lock file (<log-dir>/.prune.lock) for prune coordination, so concurrent invocations are safe.

Relevant customization variables:

VariableDefaultPurpose
elinit-logd-max-file-size50 MiBPer-file size cap before logd rotates locally
elinit-log-prune-max-total-bytes1 GiBHard cap on total log directory size
elinit-logd-prune-min-interval60 sMinimum seconds between logd-triggered prune calls
elinit-logrotate-keep-days14Days to keep rotated files (scheduled path)

Structured Logging

The :log-format unit option enables structured log records. When set, the logd writer produces machine-parseable records instead of raw byte passthrough. Two formats are available:

  • text – one key=value line per record, human-readable.
  • binary – compact binary framing with nanosecond timestamps.

Text is the default when :log-format is set. Binary requires the gate variable elinit-log-format-binary-enable to be non-nil; units requesting :log-format binary without the gate enabled are rejected during validation.

Example unit file:

(:id "my-service"
 :command "/usr/bin/my-service"
 :log-format text)

Text Record Format

Each log line has the form:

ts=<TIMESTAMP> unit=<UNIT> pid=<PID> stream=<STREAM> event=<EVENT> status=<STATUS> code=<CODE> payload=<PAYLOAD>

Fields:

FieldValuesDescription
tsRFC3339Nano UTCRecord timestamp (e.g. 2026-02-16T12:34:56.123456789Z)
unitstringService unit ID
pidintegerProcess PID
streamstdout, stderr, metaOutput stream origin
eventoutput, exitRecord type
status-, exited, signaled, spawn-failedExit status (output events use -)
codeinteger or -Exit code (output events use -)
payloadescaped string or -Output data with escaping

Payload escaping rules: \\ becomes \\\\, newline becomes \\n, carriage return becomes \\r, tab becomes \\t, bytes outside 0x20-0x7E become \\xNN.

Binary Record Format

Binary files begin with a 4-byte magic header SLG1. Each record is:

u32be record_len       (bytes after this field)
u8    version          (1)
u8    event            (1=output, 2=exit)
u8    stream           (1=stdout, 2=stderr, 3=meta)
u8    reserved         (0)
u64be timestamp_ns     (nanoseconds since epoch)
u32be pid
u16be unit_len
i32be exit_code
u8    exit_status      (0=none, 1=exited, 2=signaled, 3=spawn-failed)
u8[3] reserved         (0)
u32be payload_len
<unit_len bytes>       unit_id (UTF-8)
<payload_len bytes>    payload

The Elisp decoder handles truncated trailing records gracefully: it returns all valid records parsed before the truncation point and includes a warning. Unknown version or event values are rejected as hard errors.

elinitctl journal Command

The journal command reads and displays structured log records:

elinitctl journal -u my-service
elinitctl journal -u my-service -n 20
elinitctl journal -u my-service -p err
elinitctl journal -u my-service --since 1708098896 --until 1708099000
elinitctl journal -fu my-service
elinitctl journal --json -u my-service

Flags:

FlagDescription
-u ID / --unit IDUnit ID (required)
-n NLimit to last N records (without -n, full decoded history is returned)
-p err / -p infoFilter by priority
--since TSShow records from timestamp (epoch integer or RFC3339 UTC with trailing Z)
--until TSShow records up to timestamp (epoch integer or RFC3339 UTC with trailing Z)
-f / --followStream new records as they arrive (requires -u/--unit)
-fu IDCombined follow + unit short form
--jsonJSON object output in non-follow mode; NDJSON stream in follow mode

Priority classification: stderr output and non-clean exits (non-zero exit code or signal death) are err; all other records are info.

Filter semantics for records missing timestamp metadata (for example legacy fallback records): they are included even when --since or --until is set, and -p still applies.

Auto-detection: the decoder reads the first bytes of the log file to determine the format (SLG1 magic for binary, ts= prefix for text, otherwise legacy raw lines). Legacy files are wrapped as unstructured output records.

The dashboard (elinit-dashboard-view-log) also auto-detects binary format and decodes records for display instead of showing raw binary data.

With --json:

  • non-follow mode returns one JSON object with metadata and a records array
  • follow mode emits NDJSON (one JSON object per line) suitable for stream consumers

Compression and Vacuum

The logrotate script optionally compresses rotated files with tar. When tar is available on PATH, each newly rotated log file is compressed into a .tar.gz archive. If compression fails, the uncompressed file is preserved and a warning is emitted.

The prune script accepts additional aliases for consistency with journalctl vocabulary:

FlagDescription
--vacuumAccepted as no-op alias
--vacuum-max-total-bytes NAlias of --max-total-bytes N
--format-hint VALUEAccepted for forward compatibility

Both scripts handle .log.tar.gz files in their rotated-file patterns.

Example vacuum-style invocation:

sbin/elinit-log-prune --log-dir /path/to/logs --vacuum --vacuum-max-total-bytes 1073741824

Equivalent canonical invocation:

sbin/elinit-log-prune --log-dir /path/to/logs --max-total-bytes 1073741824

Logging Semantics

elinit--log emits levels:

  • error and warning are always shown in minibuffer/log output.
  • info is shown when elinit-verbose is non-nil.
  • When elinit-log-to-file is non-nil, all levels are written to elinit.log regardless of elinit-verbose.

Overrides File

  • Path: elinit-overrides-file
  • Default: ${XDG_CONFIG_HOME}/elinit/overrides.eld if XDG_CONFIG_HOME is set, otherwise ~/.config/elinit/overrides.eld
  • Format: schema-versioned Elisp data

Timer State File

  • Path: elinit-timer-state-file
  • Default: ${XDG_STATE_HOME}/elinit/timer-state.eld if XDG_STATE_HOME is set, otherwise ~/.local/state/elinit/timer-state.eld
  • Active only when timer subsystem is active

Customization Reference

All user options (defcustom) are listed below.

Core Options

VariableDefaultPurpose
elinit-timersnilTimer definition list
elinit-log-directory(expand-file-name "elinit" user-emacs-directory)Log directory
elinit-restart-delay2Restart delay (seconds)
elinit-max-restarts3Crash-loop threshold
elinit-restart-window60Crash-loop time window (seconds)
elinit-shutdown-timeout3Graceful shutdown timeout
elinit-oneshot-default-blockingtDefault oneshot blocking behavior
elinit-oneshot-timeout30Default oneshot timeout
elinit-startup-timeoutnilStartup timeout before force-complete (nil means disabled)
elinit-max-concurrent-startsnilMax concurrent startup spawn attempts
elinit-default-target"default.target"Startup target to activate
elinit-default-target-link"graphical.target"Alias target that default.target resolves to
elinit-verbosenilShow info-level messages
elinit-log-to-filenilWrite elinit events to file
elinit-watch-confignilConfig file watch and auto-reload
elinit-overrides-file(XDG_CONFIG_HOME or ~/.config)/elinit/overrides.eldOverride persistence path
elinit-logd-commandlibexec/elinit-logd (relative to package)Per-service log writer helper path
elinit-logrotate-commandsbin/elinit-logrotate (relative to package)Log rotation script path
elinit-log-prune-commandsbin/elinit-log-prune (relative to package)Global log prune script path
elinit-logrotate-keep-days14Days to keep rotated log files
elinit-logd-max-file-size52428800 (50 MiB)Per-file size cap for log writer
elinit-log-prune-max-total-bytes1073741824 (1 GiB)Total log directory size cap
elinit-logd-prune-min-interval60Throttle interval for logd-triggered prune (seconds)
elinit-log-follow-interval1.0Seconds between journal follow poll cycles
elinit-log-default-max-bytes(* 5 1024 1024) (5 MiB)Default tail byte cap when log decoding has no explicit record count
elinit-logd-pid-directorynil (falls back to effective log directory)Writer PID file directory
elinit-libexec-build-on-startuppromptBuild bundled helper binaries on startup (prompt/automatic/never)
elinit-libexec-compiler-candidates("cc" "clang" "gcc")Candidate compiler commands, checked in order
elinit-libexec-cflags("-Wall" "-Wextra" "-Werror" "-pedantic" "-std=c99" "-O2")C flags used when building bundled helpers
elinit-sandbox-allow-raw-bwrapnilEnable raw bwrap arguments (:sandbox-raw-args)
elinit-log-format-binary-enablenilGate variable: allow :log-format binary in unit files

Unit-File Options

VariableDefaultPurpose
elinit-unit-authority-path'("/usr/lib/elinit.el/" "/etc/elinit.el/" "~/.config/elinit.el/")Authority roots (low to high precedence)
elinit-unit-directory(XDG_CONFIG_HOME or ~/.config)/elinit/units/Legacy unit directory (deprecated; use elinit-unit-authority-path)
elinit-seed-default-maintenance-unitstAuto-seed default logrotate / log-prune unit files when missing

Timer Options

VariableDefaultPurpose
elinit-timer-state-file(XDG_STATE_HOME or ~/.local/state)/elinit/timer-state.eldTimer state persistence path
elinit-timer-retry-intervals'(30 120 600)Retry schedule
elinit-timer-catch-up-limit(* 24 60 60)Catch-up lookback window

Dashboard Options

VariableDefaultPurpose
elinit-dashboard-show-header-hintsnilReserved compatibility option (currently no effect); use h for key help
elinit-dashboard-show-timerstShow timer section
elinit-dashboard-log-view-record-limit1000Maximum decoded records shown by elinit-dashboard-view-log
elinit-auto-refresh-interval2Auto-refresh cadence

PID1 Options

VariableDefaultPurpose
elinit-pid1-mode-enabledauto-detectedNon-nil means PID1 integration is active (auto-detected from pid1-mode and PID)

elinit-pid1-mode-enabled defaults to non-nil only when Emacs was started with --pid1 and the process is actually PID 1. In all other contexts it defaults to nil and the PID1 module is inert.

PID1 lifecycle in elinit is single-path:

  • boot hook calls elinit-start
  • shutdown hooks call elinit-stop-now

Built-in PID1 script autoloading (rc.boot.el / rc.shutdown.el) has been removed. If you need early boot actions when initramfs does not provide them, define an explicit early oneshot unit and place it early in the target graph (for example :required-by ("basic.target") with :oneshot-blocking t).

You can still use pure Elisp for those actions: repurpose the example scripts in static-builds/scripts/rc.boot.el.example and static-builds/scripts/rc.shutdown.el.example, then invoke your script through an explicit oneshot unit command.

When you need an early boot script:

  1. You usually do not need one if your initramfs already handles core early boot responsibilities before handing off to Emacs PID1 (mount, dev, fsck, networking bootstrap, runtime dirs).
  2. You do need one when those responsibilities are not handled before handoff and must be done deterministically before normal services.
  3. In that case, define an explicit early oneshot unit and wire it near the start of the target graph (for example required by basic.target and blocking).

Minimal early oneshot pattern:

;; /etc/elinit.el/early-boot.el
(:id "early-boot"
 :command "/usr/bin/emacs --batch -Q -l /usr/local/lib/elinit/early-boot.el"
 :type oneshot
 :oneshot-blocking t
 :enabled t
 :required-by ("basic.target"))

This keeps boot sequencing explicit, testable, and visible through the same supervisor model as every other unit.

When you need shutdown or reboot scripts:

  1. You usually do not need one if stopping managed units is enough. In PID1 mode, signal-driven shutdown/reboot hooks already call elinit-stop-now.
  2. You do need one if you require deterministic pre-transition tasks under supervisor control (for example cleanup or state export).
  3. In that case, attach blocking oneshot units to shutdown.target, poweroff.target, and reboot.target, then trigger transition targets explicitly with elinitctl init --yes 0 (poweroff path) or elinitctl init --yes 6 (reboot path).

Minimal shutdown/reboot oneshot pattern:

;; /etc/elinit.el/pre-shutdown.el
(:id "pre-shutdown"
 :command "/usr/bin/emacs --batch -Q -l /usr/local/lib/elinit/pre-shutdown.el"
 :type oneshot
 :oneshot-blocking t
 :enabled t
 :required-by ("shutdown.target"))

;; /etc/elinit.el/pre-poweroff.el
(:id "pre-poweroff"
 :command "/usr/bin/emacs --batch -Q -l /usr/local/lib/elinit/pre-poweroff.el"
 :type oneshot
 :oneshot-blocking t
 :enabled t
 :required-by ("poweroff.target"))

;; /etc/elinit.el/pre-reboot.el
(:id "pre-reboot"
 :command "/usr/bin/emacs --batch -Q -l /usr/local/lib/elinit/pre-reboot.el"
 :type oneshot
 :oneshot-blocking t
 :enabled t
 :required-by ("reboot.target"))

CLI Options

VariableDefaultPurpose
elinit-cli-follow-max-age3600Seconds before orphaned journal follow sessions are cleaned up

Command Reference

Interactive Emacs Commands

CommandPurpose
M-x elinit-modeGlobal elinit mode (start/stop + file watch)
M-x elinit-timer-subsystem-modeToggle timer subsystem gate
M-x elinit-startBuild plan and start target-closure DAG scheduler
M-x elinit-stopAsync graceful stop
M-x elinit-stop-nowSync hard stop
M-x elinit-daemon-reloadReload unit definitions from disk into memory without runtime changes
M-x elinit-run-log-maintenanceRun rotate, reopen, and prune immediately
M-x elinit-build-libexec-helpersCompile missing/stale bundled libexec helpers
M-x elinit-verifyVerify config without start
M-x elinit-dry-runShow execution plan without start
M-x elinit-migrate-configEmit canonical schema v1 config
M-x elinit-overrides-loadLoad overrides from disk
M-x elinit-overrides-saveSave overrides to disk
M-x elinit-overrides-clearClear overrides in memory + file
M-x elinitOpen dashboard
M-x elinit-handbookOpen README.org handbook (read-only)

Dashboard Interactive Commands

CommandPurpose
M-x elinit-dashboard-lifecycleOpen lifecycle submenu
M-x elinit-dashboard-policyOpen policy submenu
M-x elinit-dashboard-inspectOpen inspect submenu
M-x elinit-dashboard-refreshRefresh dashboard buffer
M-x elinit-dashboard-cycle-filterCycle target filter
M-x elinit-dashboard-cycle-tag-filterCycle tag filter
M-x elinit-dashboard-toggle-targetsToggle visibility of target entries
M-x elinit-dashboard-toggle-init-targetsToggle visibility of init-transition targets (rescue/shutdown/etc)
M-x elinit-dashboard-toggle-auto-refreshToggle auto-refresh
M-x elinit-dashboard-toggle-proced-auto-updateToggle Proced auto-update mode
M-x elinit-dashboard-quitQuit dashboard
M-x elinit-dashboard-startStart selected service
M-x elinit-dashboard-stopStop selected service (graceful, suppresses restart)
M-x elinit-dashboard-restartRestart selected service (stop + start)
M-x elinit-dashboard-killKill selected service (send signal, restart unchanged)
M-x elinit-dashboard-kill-forceKill selected service (no confirm)
M-x elinit-dashboard-reset-failedReset failed state
M-x elinit-dashboard-reload-unitHot-reload unit at point
M-x elinit-dashboard-daemon-reloadReload all unit definitions from disk and refresh dashboard
M-x elinit-dashboard-enableEnable entry (explicit)
M-x elinit-dashboard-disableDisable entry (explicit)
M-x elinit-dashboard-maskMask entry (always disabled)
M-x elinit-dashboard-unmaskUnmask entry
M-x elinit-dashboard-set-restart-policySet restart policy (selection)
M-x elinit-dashboard-set-loggingSet logging (selection)
M-x elinit-dashboard-describe-entryDescribe selected row
M-x elinit-dashboard-show-depsShow selected service deps
M-x elinit-dashboard-show-graphShow full dependency graph
M-x elinit-dashboard-blameShow startup timing view
M-x elinit-dashboard-target-membersShow required/wanted members for target at point
M-x elinit-dashboard-view-logOpen selected service log
M-x elinit-dashboard-catView unit file (read-only)
M-x elinit-dashboard-editEdit unit file (scaffold if missing)
M-x elinit-dashboard-timer-actionsOpen timer submenu
M-x elinit-dashboard-timer-triggerTrigger selected timer now
M-x elinit-dashboard-timer-infoShow selected timer details
M-x elinit-dashboard-timer-jumpJump to selected timer target service
M-x elinit-dashboard-timer-resetReset selected timer runtime state
M-x elinit-dashboard-timer-refreshRefresh timer section
M-x elinit-dashboard-helpOpen dashboard help
M-x elinit-dashboard-menu-openOpen transient menu

Unit Edit Buffer Commands

CommandPurpose
M-x elinit-edit-modeUnit edit minor mode (normally enabled automatically by elinit-dashboard-edit)
M-x elinit-edit-finishSave unit file and return to dashboard
M-x elinit-edit-quitPrompt-save if modified and return to dashboard

Security

Manager Targeting

elinitctl uses emacsclient --eval. Which manager instance it reaches is determined entirely by the standard emacsclient server-discovery mechanism, which in turn depends on the caller’s OS identity:

  • Each user’s Emacs server listens on a socket in that user’s runtime directory (e.g., /run/user/UID/emacs).
  • emacsclient finds the socket belonging to the caller’s uid.
  • There is no elinit-level routing: manager selection = caller identity.

Operational patterns:

GoalInvocation
Manage your own serviceselinitctl status
Manage a root-owned managersudo elinitctl status
Manage another user’s managersudo -u alice elinitctl status

The --socket and --server-file wrapper options override discovery when multiple servers coexist under one uid.

:user and :group on a unit affect the spawned process identity, not which manager is targeted.

Explicit --system / --user scope flags (analogous to systemctl --user) are deferred to future planning; the current model relies on caller identity for manager selection.

Transport Security

elinitctl uses emacsclient --eval. Any principal that can connect to your Emacs server can execute code as your Emacs user.

Security boundary: your Emacs server socket/server-file access controls.

Practical guidance:

  • Prefer local socket transport.
  • Use --server-file only when you intentionally operate server-file/TCP mode.
  • Keep server auth/socket directories private (owner-only permissions).

Privilege-Drop Trust Gate

When a unit specifies :user or :group, two runtime checks are enforced at launch time (both initial startup and reload/restart):

  1. Root requirement. The manager must be running as root (euid 0). A non-root manager that encounters :user or :group will fail the spawn with reason identity change requires root (user=... group=...).
  2. Trust gate. When running as root, the unit file must pass ownership and permission checks before identity change is allowed:
    • The unit must originate from a file on disk (not inline legacy config).
    • The file must be owned by root (uid 0).
    • The file must not be world-writable.

    Failure produces reason unit source not trusted (user=... group=...).

These are runtime spawn rejections, not validation errors. The unit passes validation normally; the service appears with status stopped and a failed-to-spawn reason containing identity context. This prevents unprivileged users from escalating through writable unit files.

Process Sandbox (bubblewrap)

elinit provides optional process sandboxing via bubblewrap (bwrap). Sandboxing is Linux-only, opt-in, and disabled by default.

Overview

Sandbox is configured per-unit via :sandbox-profile and optional knob overrides. The feature model is profile-first:

  1. Select a curated built-in profile.
  2. Optionally override network mode and bind mounts.
  3. For advanced use, enable the global expert gate for raw arguments.

bubblewrap is an optional dependency. Units without sandbox settings launch exactly as before. Units requesting sandbox without bwrap installed are rejected at validation time with an explicit reason – they never fall back to unsandboxed execution.

Profiles

ProfileNamespacesFilesystemNetworkUse case
none(no sandbox)(no sandbox)(no sandbox)Default – no wrapper
strictAll (–unshare-all)Read-only root, tmpfs /tmpIsolatedBatch jobs, build tasks
servicePID, IPC, UTSRead-only root, tmpfs /tmpSharedNetwork daemons
desktopPID, IPC, UTSRead-only root, tmpfs /tmp, XDG_RUNTIME_DIR, X11 socketSharedDesktop userland apps

Safe Knobs

These per-unit overrides adjust the effective sandbox without bypassing the profile model:

  • :sandbox-network – Force shared or isolated network mode, overriding the profile default.
  • :sandbox-ro-bind – Add read-only bind mounts inside the sandbox.
  • :sandbox-rw-bind – Add read-write bind mounts inside the sandbox.
  • :sandbox-tmpfs – Add tmpfs mounts inside the sandbox.

Path validation rules:

  • All paths must be absolute.
  • Empty paths are rejected.
  • /proc and /dev are forbidden destinations (profiles handle these). Equivalent spellings (trailing slashes, dot segments) are canonicalized before the check.
  • :sandbox-ro-bind and :sandbox-rw-bind sources must exist on disk. :sandbox-tmpfs destinations do not require existence (bwrap creates them).
  • Duplicate paths are deduplicated (first occurrence wins).

Expert Raw Mode

:sandbox-raw-args allows passing arbitrary bwrap arguments. This key is rejected at validation time unless the global gate is enabled:

(setq elinit-sandbox-allow-raw-bwrap t)

When enabled, the raw arguments are appended after profile and knob arguments. Expert mode is unsupported on non-Linux and rejected.

Even with the gate enabled, conflicting or unsafe raw arguments are rejected:

  • --share-net is rejected when the effective network mode is isolated (explicit :sandbox-network isolated or profile default such as strict).
  • --unshare-net is rejected when the effective network mode is shared (explicit :sandbox-network shared or profile default such as service).
  • --unshare-all, --die-with-parent, --proc, and --dev are rejected because all profiles already emit these arguments and duplication causes undefined bwrap behavior.

Status Surfaces

Sandbox status is surfaced in CLI and dashboard detail views:

  • CLI human detail (status ID / show ID) includes:
    • Sandbox: PROFILE (network MODE) (only when sandbox is requested)
  • CLI JSON entry objects include:
    • sandbox_enabled (boolean)
    • sandbox_profile ("none", "strict", "service", or "desktop")
    • sandbox_network ("shared" or "isolated")
  • Dashboard inspect detail (i i) includes:
    • Sandbox: PROFILE (network MODE) (only when sandbox is requested)

Launch Order

When identity drop (:user / :group), sandbox, and resource limits are configured, the launch wrapper ordering is fixed:

elinit-rlimits -> elinit-runas -> bwrap -> service executable

The rlimits helper applies resource limits first (outermost), then runas drops to the target identity, then bwrap creates the sandbox namespace, then the service runs inside. If only some wrappers are needed, unused layers are omitted.

Unit File Example

;; ~/.config/elinit.el/myapp.el
(:id "myapp"
 :command "/usr/bin/myapp --serve"
 :sandbox-profile service
 :sandbox-network shared
 :sandbox-rw-bind ("/var/lib/myapp")
 :description "My application (sandboxed)")

Troubleshooting

  • “sandbox is only supported on GNU/Linux” – bubblewrap uses Linux namespaces. Non-Linux hosts cannot use sandbox features.
  • “sandbox requires bwrap (bubblewrap) but bwrap is not found in PATH” – Install bubblewrap (e.g., apt install bubblewrap).
  • “:sandbox-raw-args requires elinit-sandbox-allow-raw-bwrap” – Set the global gate variable before loading units.

Resource Limits

Overview

Per-service resource limits (ulimit-style) allow restricting how many file descriptors, processes, or how much memory a managed service may use. Limits are applied via a small C helper (elinit-rlimits) that calls setrlimit(2) before exec-ing the service command. Linux and other POSIX systems with setrlimit are supported.

Supported Limits

KeyRLIMIT constantTypical use
:limit-nofileRLIMIT_NOFILECap open file descriptors
:limit-nprocRLIMIT_NPROCCap child processes
:limit-coreRLIMIT_COREDisable or cap core dumps
:limit-fsizeRLIMIT_FSIZECap file write size
:limit-asRLIMIT_ASCap virtual memory

Value Syntax

Each limit key accepts one of three forms:

  • Integer – sets both soft and hard limit to the same value. Example: :limit-nofile 1024
  • Symbol ~infinity~ – sets both soft and hard to unlimited. Example: :limit-core infinity
  • String ~”SOFT:HARD”~ – sets soft and hard independently. Each component is a non-negative integer or the word infinity. Example: :limit-nproc "256:512"

Status Surfaces

  • CLI elinitctl show ID includes a “Resource limits” section listing each non-nil limit.
  • CLI JSON output (--json) includes limit_nofile, limit_nproc, limit_core, limit_fsize, limit_as fields.
  • Dashboard inspect detail (i i) includes a “Resource limits” section when any limit is set.

Unit File Example

;; ~/.config/elinit.el/mydb.el
(:id "mydb"
 :command "/usr/bin/mydb --datadir /var/lib/mydb"
 :limit-nofile 65536
 :limit-core 0
 :limit-as "2147483648:4294967296"
 :description "Database server with resource limits")

Portability

  • The elinit-rlimits C helper is compiled as part of make -C libexec check. If the helper binary is missing at launch time (e.g., compilation failed on an exotic platform), the service fails to start with an “executable not found” error rather than a validation error.
  • Setting RLIMIT_NOFILE or RLIMIT_NPROC to infinity may require root privileges. Other limits such as RLIMIT_CORE accept infinity as an unprivileged user because the kernel caps it at the current hard limit.
  • If setrlimit fails at runtime, the helper exits with code 112 and the service is not started.

PID1 - for the init hackers, tinkerers and adventurers among us

All documentation related to this crazy idea is in ./static-builds.

Service Management Systems Comparison

Feature Support Matrix

Where behavior diverges across systems, parenthetical notes explain.

Featuresystemdrunits6elinit
Init System (PID 1)
Can run as PID 1yes (systemd is PID 1)yes (runit-init, 3-stage boot)yes (s6-linux-init, separate package)yes (with --pid1 patched Emacs; also runs as PID 2+)
Process Supervision
Restart crashed daemonsyesyesyesyes
Restart policies (always/on-failure/on-success/no)yes (6 modes)no (always or never)no (always or never)yes (4 modes)
Restart backoff / graduated delayyes (v254 RestartSteps)nonopartial (fixed restart-sec delay; no graduated steps)
Crash-loop detectionyes (StartLimitBurst)nonoyes (elinit–failed)
Configurable kill signalyes (KillSignal)yes (control/ scripts)yes (down-signal file)yes (kill-signal)
Kill mode (cgroup/process/mixed)yes (KillMode)no (process only)no (process only)yes (kill-mode, process/mixed)
Exec-stop custom shutdownyes (ExecStop)yes (finish script)yes (finish script)yes (exec-stop)
Exec-reload custom reloadyes (ExecReload)nonoyes (exec-reload)
Service Types
Long-running daemonsyes (Type=simple)yesyes (longruns)yes (type simple)
Oneshot run-to-completionyes (Type=oneshot)partial (sv once, no dedicated type)yes (s6-rc oneshot)yes (type oneshot)
Forking/double-fork PID trackingyes (Type=forking)nonono
D-Bus readiness typeyes (Type=dbus)nonono
Notify readiness protocolyes (Type=notify, sd_notify)noyes (notification-fd, no library needed)no
Remain-after-exityes (RemainAfterExit)nonoyes (remain-after-exit)
Success-exit-status overrideyes (SuccessExitStatus)nonoyes (success-exit-status)
Dependencies and Ordering
Explicit dependency declarationsyes (Requires/Wants/BindsTo)noyes (s6-rc dependencies)yes (requires/wants)
Ordering declarationsyes (Before/After)no (manual in run scripts)yes (s6-rc ordering)yes (before/after)
Topological sort with cycle detectionyesnoyesyes (DAG with cycle fallback)
Parallel startup respecting orderingyesyes (all parallel, no ordering)yesyes (DAG in-degree scheduling)
Conflict declarationsyes (Conflicts)nonoyes (conflicts)
Conditional activationyes (ConditionXxx)nonono
Targets / Runlevels
Named synchronization barriersyes (targets)partial (runlevels as dirs)yes (s6-rc bundles)yes (targets)
Runlevel/target switching at runtimeyes (isolate)yes (runsvchdir)yes (s6-rc -u/-d bundles)yes (isolate, init, telinit)
Activation Mechanisms
Socket activationyes (centralized, fd passing)nopartial (s6-ipcserver/s6-tcpserver, composable not centralized)no
Timer activationyes (OnCalendar, monotonic)nonoyes (on-calendar, on-startup-sec, on-unit-active-sec)
Path activation (inotify)yes (.path units)nono (ftrig is FIFO-based, not inotify)no
D-Bus activationyesnonono
Device activationyes (.device from udev)nonono
Logging
Integrated logging daemonyes (journald, structured binary)yes (svlogd, plain text)yes (s6-log, plain text)yes (elinit-logd, text or binary)
Per-service log captureyes (journal tags by unit)yes (service/log/run pipe)yes (servicedir/log pipe)yes (per-id log files)
Log rotationyes (journal size/time)yes (svlogd built-in)yes (s6-log built-in)yes (elinit-logrotate)
Log pruningyes (journald vacuum)no (manual)no (manual)yes (elinit-log-prune)
Structured/indexed log queriesyes (journalctl field filtering)nonopartial (journal command with per-unit filtering; no global index)
Network log shippingyes (journal-remote/upload)partial (svlogd UDP)nono
Resource Control
Cgroup integrationyes (native, per-unit cgroup)nonono
CPU/memory/IO limitsyes (CPUQuota, MemoryMax, etc.)nonono
Task limitsyes (TasksMax)nonono
Resource accountingyes (CPUAccounting, etc.)nonono
Resource limits (ulimit-style)yes (LimitNOFILE, etc.)yes (chpst)yes (s6-softlimit)yes (:limit-nofile, etc.)
Sandboxing
Namespace isolationyes (PrivateTmp, PrivateNetwork, etc.)nonoyes (bubblewrap profiles)
Seccomp syscall filteringyes (SystemCallFilter)nonono
Capability restrictionyes (CapabilityBoundingSet)nonono
Filesystem protectionyes (ProtectSystem, ReadOnlyPaths)nonoyes (sandbox-ro-bind, sandbox-rw-bind)
Dynamic user allocationyes (DynamicUser)nonono
Security audit scoringyes (systemd-analyze security)nonono
Sandbox profilespartial (systemd-analyze security)nonoyes (none, strict, service, desktop)
Environment and Execution Context
Environment variablesyes (Environment, EnvironmentFile)partial (chpst -e envdir)yes (s6-envdir)yes (environment, environment-file)
Working directoryyes (WorkingDirectory)yes (set in run script)yes (set in run script)yes (working-directory)
User/group executionyes (User, Group)yes (chpst -u)yes (s6-setuidgid)yes (user, group fields)
Supplementary groupsyes (SupplementaryGroups)yes (chpst -u uid:gid:gid)yes (s6-applyuidgid)no
Enable / Disable / Mask
Enable/disable servicesyes (systemctl enable/disable)yes (symlink in/out of rundir)yes (s6-rc enable/disable)yes (enabled/disabled, runtime overrides)
Mask (force-prevent start)yes (systemctl mask)nonoyes (mask-override)
Persistent runtime overridespartial (enable/disable persist, runtime overrides do not)yes (symlinks persist)yes (compiled db persists)yes (overrides.eld atomic file)
Preset defaultsyes (systemd.preset files)nonono
Drop-in override fragmentsyes (unit.d/*.conf)nonono
Template / Instance Services
Template units with instancesyes (unit@.service, %i specifier)noyes (s6-instance-maker)no
Watchdog
Service heartbeat watchdogyes (WatchdogSec, sd_notify WATCHDOG=1)nonono
Hardware watchdogyes (RuntimeWatchdogSec)nonono
UI and Observability
Interactive dashboardno (third-party cockpit)nonoyes (elinit-dashboard-mode)
CLI status/controlyes (systemctl)yes (sv)yes (s6-rc, s6-svc)yes (elinitctl)
JSON output modeyes (systemctl –output=json)nonoyes (elinitctl –json)
Boot Loader
UEFI boot manageryes (systemd-boot)nonono
Unified Kernel Imagesyes (ukify, sd-stub)nonono
TPM2 measured bootyes (pcrlock, pcrextend, measure)nonono
Networking
Network configurationyes (networkd)nonono
DNS resolver/cacheyes (resolved)nonono
NTP clientyes (timesyncd)nonono
Container / VM Management
OS containersyes (nspawn)nonono
VM spawningyes (vmspawn)nonono
Machine registrationyes (machined)nonono
Portable servicesyes (portabled)nonono
System extensionsyes (sysext, confext)nonono
Home / User / Identity
Encrypted home dirsyes (homed)nonono
Declarative system usersyes (sysusers)nonono
User record multiplexeryes (userdb)nonono
Hostname/locale daemonsyes (hostnamed, localed)nonono
Storage / Filesystem
LUKS/dm-crypt setupyes (cryptsetup, cryptenroll)nonono
dm-verity/dm-integrityyes (veritysetup, integritysetup)nonono
Declarative partitioningyes (repart)nonono
Disk image inspectionyes (dissect)nonono
Credentials / Secrets
Encrypted service credentialsyes (systemd-creds, TPM2-sealed)nonono
OOM / Core Dumps
Userspace OOM killeryes (oomd, PSI-based)nonono
Core dump capture/managementyes (coredump, coredumpctl)nonono
Power Management
Suspend/hibernate/hybridyes (systemd-sleep)nonono
Inhibitor locksyes (systemd-inhibit)nonono
Soft reboot (userspace only)yes (systemd-soft-reboot)nonono
Misc
Transient units from CLIyes (systemd-run)nonono
Boot performance analysisyes (systemd-analyze blame/plot/critical-chain)nonoyes (elinitctl blame)
Virtualization detectionyes (systemd-detect-virt)nonono
sudo replacementyes (run0, v256+)nonono
Config override delta viewyes (systemd-delta)nonono
Unit file validationyes (systemd-analyze verify)nonoyes (entry whitelist validation)

Example Conversions: systemd to elinit

The examples below are strict do-the-same-thing translations. Each elinit unit includes :command in the plist, because unit files in these examples are non-target units (where :command is required).

Translation Rules Used in Examples

systemd directiveelinit keyNotes
Description=:descriptionSame intent
Type=simple:type simpleSame process model
Type=oneshot:type oneshotSame run-to-completion model
ExecStart=...:command "..."Required in elinit unit files
Restart=...:restart ...Same policy names for supported values
RestartSec=...:restart-sec ...Restart delay
WorkingDirectory=...:working-directory ...Same intent
After=...:after (...)Use elinit IDs (for example, database not database.service)
Requires=...:requires (...)Hard dependency + ordering
Environment=K=V:environment ((\"K\" . \"V\"))Alist form
ExecStop=...:exec-stop "..."Stop command
ExecReload=...:exec-reload "..."Reload command (simple units)
KillSignal=...:kill-signal ...Signal symbol
Conflicts=...:conflicts (...)Mutual exclusion set
RemainAfterExit=yes:remain-after-exit tOneshot active latch
User=~/~Group=:user / :groupRequires root manager + trusted unit file
LimitNOFILE=:limit-nofileInteger, infinity, or "SOFT:HARD"
LimitNPROC=:limit-nprocInteger, infinity, or "SOFT:HARD"
LimitCORE=:limit-coreInteger, infinity, or "SOFT:HARD"
LimitFSIZE=:limit-fsizeInteger, infinity, or "SOFT:HARD"
LimitAS=:limit-asInteger, infinity, or "SOFT:HARD"
WantedBy=multi-user.target:wanted-by ("multi-user.target")Target membership

Example 1: Simple Daemon

systemd unit:

[Unit]
Description=My Background Service
After=network-ready.service

[Service]
Type=simple
ExecStart=/usr/bin/my-daemon --config /etc/my-daemon.conf
Restart=on-failure
RestartSec=5
WorkingDirectory=/var/lib/my-daemon

[Install]
WantedBy=multi-user.target

elinit unit file (my-daemon.el):

(:id "my-daemon"
 :command "/usr/bin/my-daemon --config /etc/my-daemon.conf"
 :description "My Background Service"
 :type simple
 :wanted-by ("multi-user.target")
 :after ("network-ready")
 :restart on-failure
 :restart-sec 5
 :working-directory "/var/lib/my-daemon")

Example 2: Oneshot Setup Script

systemd unit:

[Unit]
Description=Create runtime directories

[Service]
Type=oneshot
ExecStart=/usr/bin/mkdir -p /run/myapp
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target

elinit unit file (setup-dirs.el):

(:id "setup-dirs"
 :command "/usr/bin/mkdir -p /run/myapp"
 :description "Create runtime directories"
 :type oneshot
 :wanted-by ("multi-user.target")
 :remain-after-exit t)

Example 3: Service with Dependencies and Environment

systemd unit:

[Unit]
Description=Web Application
After=database.service
Requires=database.service

[Service]
Type=simple
ExecStart=/usr/bin/node /opt/webapp/server.js
Environment=NODE_ENV=production
Environment=PORT=3000
Restart=always
ExecStop=/usr/bin/pkill -TERM -f /opt/webapp/server.js
KillSignal=SIGTERM

[Install]
WantedBy=multi-user.target

elinit unit file (webapp.el):

(:id "webapp"
 :command "/usr/bin/node /opt/webapp/server.js"
 :description "Web Application"
 :type simple
 :wanted-by ("multi-user.target")
 :after ("database")
 :requires ("database")
 :environment (("NODE_ENV" . "production")
               ("PORT" . "3000"))
 :restart always
 :exec-stop "/usr/bin/pkill -TERM -f /opt/webapp/server.js"
 :kill-signal SIGTERM)

Example 4: Service with Privilege Drop

systemd unit:

[Unit]
Description=Web server

[Service]
Type=simple
ExecStart=/usr/bin/nginx -g "daemon off;"
User=www-data
Group=www-data
Restart=on-failure

[Install]
WantedBy=multi-user.target

elinit unit file (nginx.el), placed in a root-owned unit directory:

(:id "nginx"
 :command "/usr/bin/nginx -g \"daemon off;\""
 :description "Web server"
 :type simple
 :wanted-by ("multi-user.target")
 :restart on-failure
 :user "www-data"
 :group "www-data")

Requirements: the Emacs manager must run as root, and the unit file must be owned by root and not world-writable.

Automated Import (sbin/elinit-import)

The sbin/elinit-import script automates the translation shown above. It reads a systemd .service file and prints the equivalent elinit plist to stdout.

sbin/elinit-import /etc/systemd/system/foo.service

Redirect to save as a unit file:

sbin/elinit-import /etc/systemd/system/foo.service > ~/.config/elinit.el/foo.el

Options

FlagDescription
--output-dir DIRWrite output to DIR/<id>.el instead of stdout
--dry-runPrint what would be written without writing
--help, -hShow usage information

Supported Directives

All directives from the translation table are supported: Description, Type (simple, oneshot), ExecStart, Restart, RestartSec, WorkingDirectory, After, Requires, Wants, Before, Environment, EnvironmentFile, ExecStop, ExecReload, KillSignal, KillMode, RemainAfterExit, SuccessExitStatus, User, Group, LimitNOFILE, LimitNPROC, LimitCORE, LimitFSIZE, LimitAS, WantedBy, and RequiredBy.

Dependency IDs are normalized: .service suffixes are stripped, .target suffixes are preserved, and unsupported unit types (.socket, .timer, etc.) produce a warning and are skipped.

Unsupported systemd directives (such as CapabilityBoundingSet or ProtectSystem) produce a warning on stderr and are skipped. Unsupported Type values (forking, notify, dbus, idle) produce a warning and default to simple.

Systemd exec prefixes (-, +, !, !!) on ExecStart are stripped automatically. Quoted Environment values (Environment="KEY=VALUE") are handled correctly.

Worked Example

Given /etc/systemd/system/webapp.service:

[Unit]
Description=Web Application
After=database.service
Requires=database.service

[Service]
Type=simple
ExecStart=/usr/bin/node /opt/webapp/server.js
Environment=NODE_ENV=production
Environment=PORT=3000
Restart=always
RestartSec=5s
ExecStop=/usr/bin/pkill -TERM -f /opt/webapp/server.js
KillSignal=SIGTERM
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target

Running:

$ sbin/elinit-import /etc/systemd/system/webapp.service

Produces on stdout:

(:id "webapp"
 :command "/usr/bin/node /opt/webapp/server.js"
 :description "Web Application"
 :type simple
 :wanted-by ("multi-user.target")
 :after ("database")
 :requires ("database")
 :restart always
 :restart-sec 5
 :environment (("NODE_ENV" . "production")
               ("PORT" . "3000"))
 :exec-stop "/usr/bin/pkill -TERM -f /opt/webapp/server.js"
 :kill-signal SIGTERM
 :limit-nofile 65536)

No warnings are produced because all directives have elinit equivalents. When unsupported directives are present, the skipped-directive summary on stderr lists every line that had no elinit equivalent.

Exit Codes

CodeMeaning
0Success
1Runtime error (file not found, missing ExecStart)
2Invalid arguments

Privilege-Drop Troubleshooting

“identity change requires root (user=… group=…)”

The manager is not running as root. :user and :group require the Emacs process to have euid 0 so that the elinit-runas helper can call setuid/setgid.

Resolution: start Emacs (and its server) as root, or remove the :user / :group keys from the unit file.

“unit source not trusted (user=… group=…)”

The manager is root but the unit file failed the trust gate. Common causes:

  • The unit file is not owned by root. Fix: sudo chown root:root unit.el.
  • The unit file is world-writable. Fix: sudo chmod o-w unit.el.
  • The unit was defined inline in elinit-programs rather than in a unit file. Inline definitions have no verifiable file ownership; move the definition to a root-owned unit file.

“executable not found”

When :user or :group is set, the command is launched through the elinit-runas helper (at libexec/elinit-runas). This error means either the helper itself or the target executable could not be found. Verify that elinit-runas-command points to the compiled helper and that the target program path is absolute (the helper uses execv without PATH search).

Mutator Inventory and Classification

Every state-mutating entry point across all modules, classified by semantic category. Classification key:

  • Explicit verb: user-invoked command that performs exactly one named operation on a named target.
  • Unit-definition mutation: re-reads or modifies the on-disk unit definition; state changes are a side-effect of the new definition.
  • Implicit (sentinel): triggered automatically by a process exit event; not directly user-invoked.
  • Implicit (scheduler): triggered automatically by the staging engine during startup sequencing; not directly user-invoked.
  • UI-local state: mutates dashboard-local or display state only (buffer-local variables, timers); does not change service runtime state.

No toggle/cycle mutators for service policy remain. Legacy toggle commands (toggle-restart, toggle-enabled, toggle-mask, toggle-logging) were removed in favour of explicit verb replacements.

Core Engine (elinit-core.el)

Public entry points that change runtime state:

FunctionCategoryState mutated
elinit-startExplicit verbClears all runtime state, loads overrides, builds plan, starts target-closure DAG scheduler
elinit-stopExplicit verbSends graceful stop to all processes (async)
elinit-stop-nowExplicit verbSends hard stop to all processes (sync, for kill-emacs-hook)
elinit-daemon-reloadUnit-definition mutationRe-reads unit files, refreshes invalid hash
elinit-overrides-loadExplicit verbReads override hash tables from elinit-overrides-file
elinit-overrides-saveExplicit verbWrites override hash tables to elinit-overrides-file
elinit-overrides-clearExplicit verbClears all override hash tables and deletes file

Private helpers called by CLI and dashboard (not direct entry points):

FunctionCategoryState mutated
elinit--manual-startExplicit verbClears failed/stopped flags, spawns process, records manually-started
elinit--manual-stopExplicit verbStops process, records manually-stopped, clears remain-active latch
elinit--manual-killExplicit verbSends signal to process, restart policy unchanged
elinit--reset-failedExplicit verbClears failed, oneshot-completed, restart-times for target
elinit--reload-unitUnit-definition mutationRe-reads unit file, stops/starts if running, clears stale state
elinit--policy-enableExplicit verbSets/clears enabled-override with config normalization
elinit--policy-disableExplicit verbSets/clears enabled-override with config normalization
elinit--policy-maskExplicit verbSets mask-override
elinit--policy-unmaskExplicit verbClears mask-override
elinit--policy-set-restartExplicit verbSets/clears restart-override with config normalization, cancels timer on no
elinit--policy-set-loggingExplicit verbSets/clears logging with config normalization
elinit--save-overridesImplicit (scheduler)Writes override hash tables to elinit-overrides-file (called by policy commands)
elinit--check-crash-loopImplicit (sentinel)Records restart timestamp; marks failed if threshold exceeded
elinit--make-process-sentinelImplicit (sentinel)On exit: removes from processes, records last-exit-info, handles oneshot completion, schedules restart
elinit--start-processImplicit (scheduler)Inserts into processes, clears manually-stopped, records start-times
elinit--handle-oneshot-exitImplicit (sentinel)Records oneshot-completed exit code, sets remain-active latch
elinit--transition-stateImplicit (scheduler)Updates entry-state FSM
elinit--reconcileImplicit (daemon-reload)Diffs running vs desired state, stops/starts as needed

CLI (elinit-cli.el)

All CLI subcommand handlers. Each dispatches to a core helper.

SubcommandFunctionCategoryCore callPersists
startelinit--cli-cmd-startExplicit verbelinit--manual-startNo
stopelinit--cli-cmd-stopExplicit verbelinit--manual-stop / elinit-stopNo
restartelinit--cli-cmd-restartExplicit verbelinit--manual-stop + elinit--manual-startNo
killelinit--cli-cmd-killExplicit verbelinit--manual-killNo
enableelinit--cli-cmd-enableExplicit verbelinit--policy-enable + save-overridesYes
disableelinit--cli-cmd-disableExplicit verbelinit--policy-disable + save-overridesYes
maskelinit--cli-cmd-maskExplicit verbelinit--policy-mask + save-overridesYes
unmaskelinit--cli-cmd-unmaskExplicit verbelinit--policy-unmask + save-overridesYes
restart-policyelinit--cli-cmd-restart-policyExplicit verbelinit--policy-set-restart + save-overridesYes
loggingelinit--cli-cmd-loggingExplicit verbelinit--policy-set-logging + save-overridesYes
reset-failedelinit--cli-cmd-reset-failedExplicit verbelinit--reset-failedNo
reloadelinit--cli-cmd-reloadUnit-definition mutationelinit--reload-unitNo
daemon-reloadelinit--cli-cmd-daemon-reloadUnit-definition mutationelinit-daemon-reloadNo

Dashboard (elinit-dashboard.el)

All interactive dashboard commands that change state.

CommandCategoryCore callPersists
elinit-dashboard-startExplicit verbelinit--manual-startNo
elinit-dashboard-stopExplicit verbelinit--manual-stopNo
elinit-dashboard-restartExplicit verbelinit--manual-stop + elinit--manual-startNo
elinit-dashboard-killExplicit verbelinit--manual-killNo
elinit-dashboard-kill-forceExplicit verbelinit--manual-kill (no confirm)No
elinit-dashboard-reset-failedExplicit verbelinit--reset-failedNo
elinit-dashboard-enableExplicit verbelinit--policy-enable + save-overridesYes
elinit-dashboard-disableExplicit verbelinit--policy-disable + save-overridesYes
elinit-dashboard-maskExplicit verbelinit--policy-mask + save-overridesYes
elinit-dashboard-unmaskExplicit verbelinit--policy-unmask + save-overridesYes
elinit-dashboard-set-restart-policyExplicit verbelinit--policy-set-restart + save-overridesYes
elinit-dashboard-set-loggingExplicit verbelinit--policy-set-logging + save-overridesYes
elinit-dashboard-reload-unitUnit-definition mutationelinit--reload-unitNo
elinit-dashboard-daemon-reloadUnit-definition mutationelinit-daemon-reloadNo
elinit-dashboard-cycle-filterUI-local stateSets buffer-local elinit--dashboard-target-filterNo
elinit-dashboard-cycle-tag-filterUI-local stateSets buffer-local elinit--dashboard-tag-filterNo
elinit-dashboard-toggle-auto-refreshUI-local stateCreates/cancels buffer-local auto-refresh timerNo
elinit-dashboard-toggle-proced-auto-updateUI-local stateToggles proced-auto-update-flagNo

CLI / Dashboard Parity

Every CLI mutator has a dashboard counterpart with identical semantics.

OperationCLIDashboardSame core path
Startstart IDl selinit--manual-start
Stopstop IDl telinit--manual-stop
Restartrestart IDl rstop + start
Killkill IDl kelinit--manual-kill
Reset failedreset-failed IDl felinit--reset-failed
Enableenable IDp eelinit--policy-enable
Disabledisable IDp delinit--policy-disable
Maskmask IDp melinit--policy-mask
Unmaskunmask IDp uelinit--policy-unmask
Restart policyrestart-policy ID POLICYp relinit--policy-set-restart
Logginglogging ID on\vert{}offp lelinit--policy-set-logging
Reload unitreload IDl uelinit--reload-unit
Daemon reloaddaemon-reloadX (transient)elinit-daemon-reload

All policy mutations (enable/disable/mask/unmask/restart-policy/logging) route through shared core policy mutators (elinit--policy-*) that validate entry existence, reject invalid entries, normalize overrides against config defaults, and return a status plist. Both CLI and dashboard call elinit--save-overrides after the core mutator applies the change.

Development

make check
make lint
make test
make test-one TEST=elinit-test-parse-string-entry
make pid1-check ELINIT_PID1_EMACS=/path/to/patched/emacs

pid1-check is optional and skips when prerequisites are unavailable (patched Emacs binary with --pid1, unshare, user namespaces).

Interactive load:

emacs -Q -l elinit.el

Indexes

This is a topical index for targeted lookup of less-obvious behavior.

Topical Index

License

GPL-3.0-or-later. See LICENSE.

assets/elinit-branding-logo-small.png

About

A statically compiled Emacs init (PID 1) patchset, Emacs Lisp-based service supervisor and core component of Emacs-OS.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors