Linux 17 min read

The Directive That Was Ignored

Suppose a change adds a restart policy to a service unit. Review passes, the pipeline runs systemd-analyze verify against the unit, the command exits zero, and the unit deploys. If the process later dies, its Restart= setting provides no automatic restart.

The change introduced Restart=on-failuer instead of Restart=on-failure. A gate that reads only the default exit status treats that verification as a success, while the warning goes to stderr. The directive is ignored twice: the loader discards the line, and the gate discards the warning. For a gate whose policy is to stop only on hard failures, the default status does that job: in these fixtures it returned nonzero for the missing ExecStart= executable, the missing Requires= target, and the forbidden directive combination. This piece works through what a passing systemd-analyze verify establishes beyond the absence of hard failures.

What the loader does with configuration it cannot use

Unit loading can discard an assignment without rejecting the unit. systemd.unit(5) documents this for unknown options: a warning, and loading continues. The fixtures below show the same nonfatal verification outcome for an unknown name, an invalid value for a known directive, and a valid directive in the wrong section.

For unknown names, that tolerance is useful. Unit files are shipped by packages and written by administrators against whatever systemd version happens to be installed, and a loader that refused every unrecognized directive could break any host carrying a unit written for a newer release. In my reading, leniency works as a compatibility mechanism for fleets where unit files and systemd versions advance on separate schedules. That case is weaker for a misspelled value or a misplaced line, which get the same treatment, and the cost is that the file's apparent intent and the effective configuration can diverge with only a warning to show for it.

Ten fixtures and the exit codes they earn

Every fixture below was executed on Ubuntu 24.04.4 LTS with systemd 255 (255.4-1ubuntu8.15). Each is a minimal service unit checked with systemd-analyze verify, which loads and verifies unit files in an in-process test manager instead of the running service manager. Socket, timer, and template units fall outside this set.

Fixture fileLine under testKindDefault exitResult and basis
typo-nameRestartSecc=5warning0observed: line ignored; documented: RestartSec= defaults to 100ms
typo-valueRestart=on-failuerwarning0observed: line ignored; documented: default no
wrong-sectionRestart=always in [Unit]warning0observed: line ignored
absent-execExecStart=/opt/absent/apphard failure1observed: verification fails
absent-depRequires=definitely-absent.servicehard failure1observed: verification fails
oneshot-restartType=oneshot with Restart=alwayshard failure1observed: verification fails
duplicateRestart=always, then Restart=nosilent risk0documented: later value wins
absent-workdirWorkingDirectory=/opt/definitely-absentsilent risk0documented: start failure
x-prefixX-Restart=alwaysintentional0documented: ignored by design
absent-manpageDocumentation=man:nosuchfile(1)inconclusive here0inconclusive: depends on the host's man

Each file is named <fixture>.service. An eleventh unit, clean.service, carries nothing under test. Exit statuses are observed. The last column labels each result by its basis; effective-property and start-time consequences come from the cited documentation, never from a running manager.

Under default verification, seven of the ten fixtures exit zero. Three, duplicate, absent-workdir, and x-prefix, produce no diagnostic output; the manual-page fixture's silence is environmental, as covered below. The misspelled-directive case shows the stream split:

systemd-analyze verify ./typo-name.service; echo "exit=${?}";
/tmp/sdtest/typo-name.service:5: Unknown key name 'RestartSecc' in section 'Service', ignoring.
exit=0

That diagnostic arrived on stderr and left stdout empty.

The value case and the section case

A misspelled directive name is at least visible to a text search. The value typo from the opening is harder to reason about. Restart=on-failuer uses a real directive with a value the parser cannot interpret:

/tmp/sdtest/typo-value.service:5: Failed to parse service restart specifier, ignoring: on-failuer

Exit zero again, yet grep finds the setting. Because this minimal fixture holds no other Restart= line, discarding the invalid value leaves the documented default in effect: systemd.service(5) specifies Restart=no, so the policy the change asked for yields no automatic restart. In a real deployment, a drop-in or another fragment could supply a different value.

The third warning fixture shows that the same behavior reaches beyond misspellings. Restart=always is a valid directive with a valid value, placed in the wrong section:

/tmp/sdtest/wrong-section.service:3: Unknown key name 'Restart' in section 'Unit', ignoring.

systemd.service(5) separates generic settings in [Unit] and [Install] from service-specific ones in [Service], so a correct directive in the wrong section resolves to an unknown key.

Asking verification to count warnings

A gate written around the command's exit status asks the tool whether it succeeded:

systemd-analyze verify ./typo-name.service && echo "PASS";

Across all eleven units, that gate reports PASS eight times. Three of the eight produced a warning the gate ignored, two are silent risks, one is the intentional X- line, one is the inconclusive manual-page fixture, and one is the clean unit.

Since systemd 250, systemd-analyze verify accepts --recursive-errors=, which systemd-analyze(1) documents as controlling whether warnings produce a nonzero exit status and for which units: no for the specified unit only, one for it and its immediate dependencies, yes for it and everything associated with it. Without the option, warnings leave the exit status at zero. Diagnostic severity and process exit status are separate dimensions: the option changes the exit-status policy, and the diagnostics in these fixtures stay the same.

systemd-analyze verify --recursive-errors=yes ./typo-value.service; echo "exit=${?}";
/tmp/sdtest/typo-value.service:5: Failed to parse service restart specifier, ignoring: on-failuer
exit=1

Every unit, under every mode:

Fixture fileDefaultnooneyes
typo-name0111
typo-value0111
wrong-section0111
absent-exec1111
absent-dep1011
oneshot-restart1111
duplicate0000
absent-workdir0000
x-prefix0000
absent-manpage0000
clean0000

In this matrix, each explicit mode fails the three warning cases while the X- fixture still passes. Treat the modes as verdict scopes, not as graph-depth settings. Referencing a unit and letting its diagnostics affect the exit status are separate behaviors.

Which units a verdict covers

The no mode drops the missing-dependency result to exit 0, as documented: no counts only the named unit. A second set of fixtures points a parent unit at other units, testing whether any common reference makes one promote the referenced file's warning, without implying the directives are equivalent:

ConstructionDefaultnooneyes
Parent references a unit whose file carries the value typo0001
Same, with the referenced unit also named0111
Parent Requires= a unit whose ExecStart= binary is absent0000
Same, with the referenced unit also named1111

The referenced-file warning result held for all seven relationship directives, from Wants= and Requires= to the ordering-only After=, at both one-hop and two-hop distances. A parse warning inside a unit that was only referenced reached the exit status only under yes, although the man page describes one as covering immediate dependencies. Yet in the absent-dep fixture, one promoted the missing unit.

The systemd 255 source for verify accounts for both results. It records the name of every unit that logs a parse warning during the run, referenced units included, and under yes any recorded name fails the run. Under one, the final test compares the recorded names with the base names of the files given on the command line. A unit that was only referenced can log its warning and still stay outside the verdict. Name that file on the command line as well, and the same warning fails the run, as the second construction row shows.

The missing dependency takes another path. It fails the named unit's start job (absent-dep.service: Failed to create absent-dep.service/start), an error rather than a recorded warning, and that error fails the run in every mode that loads dependencies. Under no, the manager runs with dependencies ignored, which is why absent-dep passes there. The code settles how systemd 255 computes the exit status; whether that computation is what the man page's "immediate dependencies" wording intends is a question for the systemd project.

The referenced-ExecStart= construction exposes a separate boundary. In these systemd 255 fixtures, executable validation applied only to units named on the command line, whatever the mode. The v255 implementation follows the same named-file boundary: verify builds its list of units from the file arguments and runs the executable, socket, and documentation checks on that list alone.

Name every unit file you own

Recursive warning scope is no substitute for enumerating the units a build is responsible for. A gate should pass every unit file it owns to systemd-analyze verify explicitly, then choose the mode according to whether warnings in referenced units should affect the verdict: yes if they should fail the build, no if warnings from vendor units outside its control should stay out of the verdict, accepting that the absent-dep fixture then passes. one offered no middle ground for a unit that was only referenced: it never promoted that unit's parse warning. Naming files one invocation at a time and naming the whole set in one invocation are different experiments, as the second row shows.

A minimal gate and its control

The gate below turns verifier results into a build verdict, keeping the output for diagnosis. FAIL means verification returned nonzero for that file, and PASS means only that it exited zero. It passes --man=no, putting documentation availability outside what it judges, and every nonzero verifier result becomes the same build failure, whether the unit was rejected or the verifier itself failed. It checks its arguments, then the systemd version, then a deliberately defective control fixture kept beside the script: designing for the failure first applied to the checker itself. Each named file then gets its own invocation. The control's two-state signature verifies the behavior the gate needs: typo-value must exit 0 under default verification and nonzero with warnings counted. A missing file, a clean unit, or a hard failure fails that check and stops the gate. The signature proves the required status transition.

#!/usr/bin/env bash
# Minimal gate: systemd 250 or newer, one verify call per named unit file.
# The control lives beside this script in controls/typo-value.service.
set -uo pipefail;
script_directory="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)";
control="${script_directory}/controls/typo-value.service";
if [ "${#}" -eq 0 ]; then
  printf 'usage: %s UNIT_FILE...\n' "${0}" >&2;
  exit 2;
fi;
version="$(systemd-analyze --version | awk 'NR == 1 { print $2 }')";
case "${version}" in
  ''|*[!0-9]*)
    printf 'ERROR: cannot read systemd version (%s)\n' "${version}" >&2;
    exit 2;;
esac;
if [ "${version}" -lt 250 ]; then
  printf 'ERROR: systemd %s predates --recursive-errors=\n' "${version}" >&2;
  exit 2;
fi;
systemd-analyze verify --man=no "${control}" >/dev/null 2>&1;
default_exit_status=${?};
systemd-analyze verify --man=no --recursive-errors=yes "${control}" >/dev/null 2>&1;
strict_exit_status=${?};
if [ "${default_exit_status}" -ne 0 ] || [ "${strict_exit_status}" -eq 0 ]; then
  printf 'ERROR: control %s gave default=%d strict=%d, expected 0 and nonzero\n' "${control}" "${default_exit_status}" "${strict_exit_status}" >&2;
  exit 2;
fi;
status=0;
for unit in "${@}"; do
  output="$(systemd-analyze verify --man=no --recursive-errors=yes -- "${unit}" 2>&1)";
  exit_status=${?};
  if [ "${exit_status}" -ne 0 ]; then
    printf 'FAIL %s (exit %d)\n' "${unit}" "${exit_status}";
    printf '%s\n' "${output}" | sed 's/^/     /';
    status=1;
  else
    printf 'PASS %s\n' "${unit}";
  fi;
done;
exit "${status}";

The capture takes both streams, so a diagnostic a later release prints on stdout still reaches the failure message. Executed against all eleven units:

FAIL ./typo-name.service (exit 1)
     /tmp/sdtest/typo-name.service:5: Unknown key name 'RestartSecc' in section 'Service', ignoring.
FAIL ./typo-value.service (exit 1)
     /tmp/sdtest/typo-value.service:5: Failed to parse service restart specifier, ignoring: on-failuer
FAIL ./wrong-section.service (exit 1)
     /tmp/sdtest/wrong-section.service:3: Unknown key name 'Restart' in section 'Unit', ignoring.
PASS ./duplicate.service
PASS ./absent-workdir.service
PASS ./x-prefix.service
PASS ./absent-manpage.service
FAIL ./absent-exec.service (exit 1)
     absent-exec.service: Command /opt/absent/app is not executable: No such file or directory
FAIL ./absent-dep.service (exit 1)
     absent-dep.service: Failed to create absent-dep.service/start: Unit definitely-absent.service not found.
FAIL ./oneshot-restart.service (exit 1)
     oneshot-restart.service: Service has Restart= set to either always or on-success, which isn't allowed for Type=oneshot services. Refusing.
     Unit oneshot-restart.service has a bad unit file setting.
PASS ./clean.service

That run exited 1; a clean unit alone exited 0, including from another directory and, with -- in place, under a leading-dash file name. The gate exited 2 with no arguments, with the control file replaced by the clean unit, deleted, or replaced by absent-exec, and under a stub reporting systemd 249.

In this fixture set, counting warnings turns three observed warning cases into failures: the misspelled name, the unparseable value, and the wrong section. It leaves the silent risks open, because verification prints nothing about them, and with --man=no it leaves the manual-page check out of scope by choice. A gate claiming more than that is overselling itself.

What warning promotion still misses on systemd 255

The duplicate fixture produced nothing under every mode. systemd.syntax(7) explains why: settings may legitimately repeat, and the interpretation depends on the setting. For Restart=, the later assignment wins, so a drop-in repeating a vendor-unit line with a different value leaves an unexpected final value and no trace. The defect there is intent rather than duplication, which this verifier cannot infer from the unit file alone.

WorkingDirectory=/opt/definitely-absent also verified clean. systemd.exec(5) documents a missing working directory as fatal unless the setting is prefixed with a hyphen; this fixture was verified and never started. In this systemd 255 run, WorkingDirectory= and ExecStart= both named missing paths, and only the missing executable failed verification. That matches systemd-analyze(1), which calls out four classes: unknown sections and directives, missing required dependencies, absent man pages named in Documentation=, and missing or non-executable ExecStart=-style commands. WorkingDirectory= falls outside them. What fails is the assumption that a clean verification proves host-side conditions verification never inspects.

One silent result is correct. X-Restart=always produces no diagnostic and exits zero, as systemd.unit(5) specifies for names prefixed with X-, which lets applications carry their own metadata inside unit files. Under default verification it reaches the same exit status as an accidental unknown key, and only stderr tells them apart. Once warnings count, the status separates them too.

The manual-page fixture also verified clean when --man=yes was passed explicitly, and the reason is local. In the drafting container, /usr/bin/man is a four-line shell script that Ubuntu's minimized images divert into place; it prints a notice about restoring documentation and exits 0 for any page. That check shells out to man, so the fixture supports no conclusion about systemd. For a CI image, pass --man=no if documentation availability is outside what the gate should judge, or add a separate control for the image's man behavior. A checker self-test should avoid an uncontrolled environmental helper.

The semantic rule that does fire

Verification performs checks beyond parsing, and the Type=oneshot fixture shows where the line sits:

oneshot-restart.service: Service has Restart= set to either always or on-success, which isn't allowed for Type=oneshot services. Refusing.
Unit oneshot-restart.service has a bad unit file setting.

Exit 1. Both directives are individually valid, both are in the right section, and the combination is rejected, a semantic check on directive interaction. The refusal also sits outside the four classes the man page lists, so that list describes part of what the loader validates.

What the host has to answer

Three questions remain after a strict gate passes. What value did the manager resolve? Does the target host have what the unit needs? Does the workload behave under the policy it was given?

For the first, read back the specific manager properties the deployment intends to change after daemon-reload, instead of trusting the file. That is the same declared-versus-actual comparison that separates configuring from scripting. "The manager reports Restart=on-failure" is evidence of the property currently in effect, whichever fragment supplied it; "the file says so" is only a declaration. systemctl show does the reading. On a test host where the fixture has been installed and the manager reloaded, the read-back to perform is:

# Not executed for this article.
systemctl show -p Restart typo-value.service;

No fixture run for this piece loaded typo-value into a running manager, so no output is quoted, and its Restart=no rests on the documented default.

For the second, assert paths on the host that will run the unit. Testing the WorkingDirectory= value with test -d is a handful of lines of shell, the same fail-before-start principle used by a preflight check, and it checks host state that verification cannot establish. The third question needs a service test on the real workload.

Appendix: reproduction script

This script is the minimal generator for the matrix above, on whichever release runs it. The companion repository, ignored-directive-reproductions, holds a fuller harness that asserts these results on this build and, on any other, records how many of its expectation-bearing checks match. This script refuses to run before systemd 250 and writes only beneath its own temporary directory, which it removes on exit. Each referenced fixture sits beside the unit that references it, per verify's documented lookup preference. It asserts nothing; compare its rows with the tables above.

#!/usr/bin/env bash
# Writes the fixtures to a scratch directory and prints exit status per mode.
set -uo pipefail;
version="$(systemd-analyze --version | awk 'NR == 1 { print $2 }')";
case "${version}" in
  ''|*[!0-9]*)
    printf 'cannot read systemd version (%s)\n' "${version}" >&2;
    exit 2;;
esac;
if [ "${version}" -lt 250 ]; then
  printf 'systemd 250 or newer is required for this matrix\n' >&2;
  exit 2;
fi;
if ! directory="$(mktemp -d)"; then
  printf 'cannot create temporary directory\n' >&2;
  exit 2;
fi;
trap 'rm -rf -- "${directory}"' EXIT;
unit() {
  printf '[Unit]\nDescription=fixture\n%s[Service]\nExecStart=/bin/sleep 1\n%s' "${2}" "${3}" > "${directory}/${1}.service";
}
unit typo-name       ''                                      $'RestartSecc=5\n';
unit typo-value      ''                                      $'Restart=on-failuer\n';
unit wrong-section   $'Restart=always\n'                     '';
unit duplicate       ''                                      $'Restart=always\nRestart=no\n';
unit absent-workdir  ''                                      $'WorkingDirectory=/opt/definitely-absent\n';
unit x-prefix        ''                                      $'X-Restart=always\n';
unit absent-manpage  $'Documentation=man:nosuchfile(1)\n'    '';
unit absent-dep      $'Requires=definitely-absent.service\n' '';
unit clean           ''                                      '';
printf '[Unit]\nDescription=fixture\n[Service]\nExecStart=/opt/absent/app\n' > "${directory}/absent-exec.service";
printf '[Unit]\nDescription=fixture\n[Service]\nType=oneshot\nExecStart=/bin/sleep 1\nRestart=always\n' > "${directory}/oneshot-restart.service";
unit ref-parent      $'Wants=typo-value.service\n'           '';
unit ref-exec-parent $'Requires=absent-exec.service\n'       '';
run() {
  local label="${1}"; shift;
  printf '%-38s' "${label}";
  printf ' %s' "$(systemd-analyze verify --man=yes "${@}" >/dev/null 2>&1; echo ${?})";
  for mode in no one yes; do
    printf ' %s' "$(systemd-analyze verify --man=yes --recursive-errors="${mode}" "${@}" >/dev/null 2>&1; echo ${?})";
  done;
  printf '\n';
}
cd "${directory}" || exit 1;
systemd-analyze --version | head -1;
printf '%-38s %s\n' 'unit' 'default no one yes';
for fixture_name in typo-name typo-value wrong-section duplicate absent-workdir x-prefix absent-manpage absent-exec absent-dep oneshot-restart clean; do
  run "${fixture_name}" "./${fixture_name}.service";
done;
run 'ref-parent (Wants= typo-value)' ./ref-parent.service;
run 'ref-parent + typo-value named' ./ref-parent.service ./typo-value.service;
run 'ref-exec-parent (Requires=)' ./ref-exec-parent.service;
run 'ref-exec-parent + absent-exec named' ./ref-exec-parent.service ./absent-exec.service;
# Each relationship directory holds its own typo-value.service, so the
# reference resolves from the directory of the unit being verified.
for relationship in Wants Requires Requisite BindsTo PartOf Upholds After; do
  mkdir "${directory}/${relationship}";
  cp "${directory}/typo-value.service" "${directory}/${relationship}/";
  printf '[Unit]\nDescription=mid\n%s=typo-value.service\n[Service]\nExecStart=/bin/sleep 1\n' "${relationship}" > "${directory}/${relationship}/mid.service";
  printf '[Unit]\nDescription=top\n%s=mid.service\n[Service]\nExecStart=/bin/sleep 1\n' "${relationship}" > "${directory}/${relationship}/top.service";
  run "${relationship}, one hop" "./${relationship}/mid.service";
  run "${relationship}, two hops" "./${relationship}/top.service";
done;

References

Sources verified 2026-09-10; systemd-analyze(1) and systemd.service(5) rechecked 2026-09-21; links pinned to the systemd 255 pages; analyze-verify-util.c read at the v255 tag 2026-09-24.

Linux Validation