Repository navigation
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
…failed An activation in which systemd refused a job, userborn.service failed or the services phase ended early (a job timeout, a D-Bus error) logged the error, wrote the state and exited 0. A caller judging the switch, a person or an unattended upgrade, then took a half-applied profile for an applied one. The control flow is unchanged: every unit is still attempted and the state file still records what was applied. Only the result changes: - exit 1 (ActivationFailure::Incomplete) when part of the profile wasn't applied, naming each part: a job systemd refused (the TODO in for_each_unit), including for sysinit-reactivation.target; a failed userborn.service (a oneshot's job finishes whether or not it succeeded, so its result is checked); a services phase that returned a partial result; - exit 4 (ActivationFailure::UnitsFailed) when everything was applied and managed units are in the failed state afterwards, naming them, as NixOS' switch-to-configuration does, so a caller can tell the two apart and judge the second against what was failing before. Stopping a unit systemd no longer has loaded (one that had exited, whose file the new profile dropped) fails with NoSuchUnit. That isn't a refusal, there is nothing to stop, so it is now logged at debug instead of as an error. /etc is left as it is: numtide#551 covers it.
There was a problem hiding this comment.
🟡 Changes recommended
Wait-error paths discard concurrently collected refused-unit diagnostics.
1 open finding
What changed in this PR
Ensures activation returns meaningful non-zero statuses when systemd only partially applies a profile or managed units fail.
Changes:
- Adds activation failure types and exit codes 1 and 4.
- Detects refused jobs, failed userborn, and failed managed units.
- Adds regression tests and release notes.
| File | Description |
|---|---|
crates/system-manager-engine/src/activate.rs |
Aggregates activation failures and checks failed units. |
crates/system-manager-engine/src/activate/services.rs |
Detects refused jobs and failed systemd units. |
crates/system-manager-engine/src/main.rs |
Maps activation failures to exit statuses. |
testFlake/container-tests/activation-exit-codes.nix |
Tests activation exit-code behavior. |
CHANGELOG.md |
Documents the visible behavior change. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7b95a75333
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
A job systemd accepted was taken as done once it was removed, whatever its result. Removing a managed mount that can't be unmounted, for instance: the stop job fails, the mount stays, and as the profile no longer lists it, the failed-units check doesn't look at it either, so the activation exited 0. The job monitor now records each job's result from JobRemoved, and matches jobs by object path rather than unit name, so that another job on the same unit (one ours replaced, reported as canceled) isn't read as ours. `done` completed a job, and so did `skipped` (the job didn't apply to the unit's state: nothing to do). Any other result (`failed`, `dependency`, `timeout`, `canceled`, ...) means the unit isn't where the job was to put it. Which exit that gives follows from what it means for the profile: - a stop of a unit the profile drops or masks: exit 1, "<unit> not stopped (job <result>)". The old unit may still run, and nothing else reports it; - a restart of sysinit-reactivation.target or userborn.service: exit 1, as their refusals already are. For userborn this replaces the failed-state check: a oneshot's job fails with it, and the result also covers a dependency that failed or a job that timed out; - a (re)start or reload of a unit the profile keeps: exit 4, alongside the failed units, "<unit> (job <result>)". The unit was applied and didn't come up; that is what 4 already means, and NixOS' switch-to-configuration also exits 4 for a timeout, failed or dependency job result. Exit 1 here would double count: a unit whose start fails is also in the failed state, and a pre-existing failure would turn every later activation that touches it into an incomplete one. A unit both failed and with a failed job is named once. This also reports what the failed-state check can't see: a reload that fails leaves the unit active on its old configuration, and a start a dependency failed leaves it inactive. When a wait is cut short (a job timeout, a D-Bus error), the error returned now names what wasn't applied before it, refusals and stops that didn't complete, rather than only the timeout. The container test adds a reload that fails (exit 4) and a dropped mount kept busy (exit 1).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4da6670502
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
CI here has been red since I opened this, and I didn't notice until now. Sorry for the noise. Every nixbot failure I've read has the same cause. The container tests' activation exits 4 because I'm finding out whether the unit fails on real Ubuntu and Debian hosts or only inside the test containers, and I'll report back here. If it's only the containers, I'd suggest splitting this PR:
I'm happy to go whichever way you prefer. |
When a unit the profile drops didn't stop (its stop job failed, or systemd refused it), the activation exited 1, but the state it wrote was the new profile's services, without that unit. Activating the same profile again then had nothing to stop and nothing to check: the unit still ran, and the activation exited 0. Deactivating didn't stop it either. The state now keeps the old entry of each unit the profile drops whose stop didn't complete, so that: - activating the profile again retries the stop, and exits 1 again until it completes; - deactivating stops the unit; - a rollback to a profile that has the unit finds the same entry, and leaves the unit as it is rather than restarting it. When the wait is cut short (a job timeout, a D-Bus error), which stops completed isn't known, so every unit the profile drops keeps its entry: retrying a stop that already completed does nothing. A unit the profile masks keeps its new, masked entry; masked units are stopped on every activation anyway. The container test now activates the profile again after the failed stop (exit 1 again), rolls back to the profile with the mount (exit 0, the mount untouched), and, once the mount is free, activates again, which unmounts it.
…tainers
The container tests run systemd-nspawn inside the Nix build sandbox, and
the sandbox's seccomp filter (Nix's filter-syscalls, on by default)
refuses setuid and setgid bits, and file capabilities, to every process
under it. The container's root is no exception, with every capability:
chmod: changing permissions of '/run/wrappers/wrappers.LIVKfoT1vC/mount': Operation not permitted
suid-sgid-wrappers.service: Failed with result 'exit-code'.
so suid-sgid-wrappers.service has failed on every activation in every
container test, empty-config included. Nothing looked: activation exits
0 whatever the units do, and no container test checks the wrappers.
Outside the sandbox the unit works: the VM tests' security-wrappers
check asserts it succeeds, on every image.
The driver now puts a drop-in in each container's /etc that skips the
unit in a container, so that a container test's activation reflects the
profile rather than the sandbox. It's in the driver, not in each test's
configuration, because several tests build their own profiles, and so
do projects that use the driver to test theirs. It's written when the
container is prepared rather than baked into the rootfs, which would
rebuild every distribution's image for one file.
… needs
The VM tests' newConfig, and the system-path container test's, declare
a sops secret decrypted with /run/age-keys.txt. The example VM test
copies the key there first; the prepopulate VM test and the system-path
container test didn't, so activating newConfig there failed
sops-install-secrets.service, and with it the restart of
sysinit-reactivation.target:
sops-install-secrets: cannot read keyfile '/run/age-keys.txt': open /run/age-keys.txt: no such file or directory
The activation's exit status doesn't report a job that didn't complete,
and neither test looked for the secret, so both passed. They now copy
the key as the example test does, and check that the secret is there.
Every activation restarts userborn.service twice: through
sysinit-reactivation.target, which requires it, and then on its own
before tmpfiles. systemd's default start limit is 5 starts in 10
seconds, so the third activation within ten seconds or so finds it
reached, and systemd refuses the restart:
userborn.service: Start request repeated too quickly.
userborn.service: Failed with result 'start-limit-hit'.
userborn doesn't run, so the users and groups of that profile aren't
applied, and userborn.service is left failed. The VM tests' example and
prepopulate scenarios activate that fast, and the activation exited 0
all the same: a restart job's result isn't read.
Activation now resets the failed state of each unit the profile manages
before restarting any of them, which also resets its start limit, as
NixOS' switch-to-configuration does (for every unit there). Only the
profile's units: on a host that isn't NixOS, the other units' failed
states are the host's, and someone may be watching them.
|
Following up on the red CI. #582 doesn't cause any of the three failures behind it. All three exist on
With those three and the state fix for the open thread (04f7e65), every x86_64 check is green on my fork's hosted runners. That covers all VM tests and the containers on Debian 13 and Ubuntu 24.04 and 26.04. The exception is Ubuntu 22.04: its containers never finish booting on GitHub's hosted runners, on any branch, So I'm not splitting this after all. Splitting wouldn't help: failures 2 and 3 come through the exit-1 path, not exit 4. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 91c472893c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| return e | ||
| .downcast_ref::<system_manager_engine::activate::ActivationFailure>() | ||
| .map_or(ExitCode::FAILURE, |failure| { | ||
| ExitCode::from(failure.exit_code()) |
There was a problem hiding this comment.
Propagate exit code 4 through the public CLI
When activation is invoked through the normal system-manager switch/activate path (locally or over SSH), this new exit code is consumed by crates/system-manager/src/main.rs:731-746, which turns every nonzero engine status into a generic anyhow error, and that binary's handler at lines 913-918 always returns exit 1. Consequently only the low-level engine/profile script exposes 4, while callers of the public CLI still cannot distinguish failed units from an incomplete activation; preserve the child status through the wrapper.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Not doing this here. It's true that system-manager switch turns any non-zero engine status into its own exit 1, but that is deliberate in this PR (see Compatibility in the description): passing the code through changes the CLI's local and SSH paths, and what system-manager-upgrade.service sees, and is easier to review on its own. Happy to open it as a follow-up if the maintainers want it.
…restart Two ways an activation still exited 0 with part of the profile not applied. A new unit is started only through system-manager.target, and StartUnit returns only the target's own job. When a unit the profile doesn't manage, and that a managed unit requires, fails, the managed unit's start job ends with "dependency": the unit is inactive, not failed, and the target's job completes all the same. Activation now starts the target with EnqueueUnitJob, which also returns the jobs queued in the same transaction, and waits for those of managed units. One that doesn't complete is reported with the failed units (exit 4), as a restart that doesn't complete already is. A changed unit that systemd refuses to restart, or whose restart or reload doesn't complete, still runs its old configuration, but the state recorded its new entry. Activating the same profile again found nothing changed, didn't retry, and exited 0. Such a unit now keeps its old entry, as a dropped unit whose stop doesn't complete does, so that the next activation retries it. When the wait is cut short, which restarts completed isn't known, and every changed unit keeps its old entry. The activation-exit-codes container test covers both: activating the bad-setting profile a second time exits 1 again, and a unit whose unmanaged dependency fails exits 4 and is named.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 847ff7d936
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
… applied The state recorded a unit's new entry unless the activation noticed that its stop or restart didn't complete, and each early return had to know which units to hold back. The stop phase's didn't for changed units: when the stop wait timed out, a changed unit was recorded on its new entry before its restart was even queued, and activating the profile again found nothing to restart. The rule is now the other way round. A unit the profile drops or changes is recorded on its old entry until systemd confirms its stop or restart, and only then on the profile's. Whatever cuts the activation short, the state holds what was applied, and the next activation retries the rest. That also covers a restart or reload that ran and didn't complete without making the activation incomplete (exit 4): the unit keeps its old entry and is retried, which the container test now checks.
|
nixbot's build of 1d42883 (build 79) failed every VM test, 20 of 20, with |

system-manager-engine activate(and a profile'sbin/activate) exits 0 when systemd didn't apply the profile's units, so a caller can't tell an applied profile from a half-applied one. We hit this running system-manager unattended: an upgrade loop switches, checks health and rolls back on a regression. On exit 0 it records the new generation as applied while the old unit is still running, or the new one has failed.What fails today
All of these log the error, write the state file and exit 0:
TODOinfor_each_unit.userborn.servicethat ran and failed. A oneshot's job completes either way, sorestart_userborn_if_existsreturnedOk, and users the profile declares may not exist.WithPartialResult, from a job timeout or a D-Bus error.A managed unit that fails to start after an otherwise complete activation also exits 0.
Minimal repro:
What changes
The control flow is unchanged. Every unit is still attempted, and the state file still records what was applied, so activating another profile (a rollback) starts from the truth. Only the result changes:
ActivationFailure::Incomplete) when part of the profile wasn't applied. The error names each part: the refused jobs (for_each_unitnow collects them, forsysinit-reactivation.targettoo), a failed userborn, a partial services phase.ActivationFailure::UnitsFailed) when everything was applied but managed (non-masked) units are in thefailedstate afterwards. The error names the units.mainfinds the failure throughanyhow'sdowncast_ref. Every other error still exits 1, as before.One case had to be told apart from a refusal. When a profile drops a unit that has already exited, such as a oneshot without
RemainAfterExit, its file is gone by the time it's stopped, and systemd answers the stop withNoSuchUnit("not loaded"). There's nothing to stop. On main that was logged as an error; now it's logged at debug and doesn't count.Exit codes
4 is the code NixOS'
switch-to-configurationuses for the same case. It lets a caller judge the failed units against what was already failing before the switch, instead of treating them like a switch that didn't happen. One difference:switch-to-configurationreports every failed unit on the system; this reports only the profile's own.Compatibility
bin/activateor the engine and relied on exit 0 after a refused job or a failed unit will now see 1 or 4. That's the point, but it's a visible change, hence the CHANGELOG entry.system-managerCLI is unchanged:invoke_engineturns any non-zero engine status into its own exit 1 ("Engine command exited with status 4"). Sosystem-manager switchexits 1 in both cases, and the 4 reaches only callers ofbin/activateor the engine. Passing the engine's code through seems right to me, but I left it out to keep this small.system-manager-upgrade.service(system.autoUpgrade) runssystem-manager switch, so an upgrade that leaves a unit failed now fails the upgrade unit too.Tests
activate::tests::failed_units_exit_like_nixos_switch,the_exit_code_survives_anyhow(the code survives the trip throughanyhowtomain), andservices::tests::a_unit_systemd_has_not_loaded_needs_no_stop.activation-exit-codes:ExecStart=lines) exits 1 and names the unit, while the profile's other units keep running;cargo test --workspace,cargo clippy --all-targets(no warnings),cargo fmt --checkandnixfmt --checkpass locally.register::tests::test_try_nix_evalneedsnixonPATH, and passes with it. I couldn't run the container test here, since my Nix can't provideuid-range. It evaluates, its profiles build, and its script passes the derivation's mypy and pyflakes steps. I checked the systemd behaviour it relies on by hand, on systemd 261: a changed unit with twoExecStart=lines loads asbad-setting, andReloadOrRestartUnitis refused while the old process keeps running; stopping an exited unit whose file is gone getsNoSuchUnit. CI will be the judge of the rest./etc
This PR leaves
/etcalone. An/etcentry that can't be written, and an/etcphase that fails outright, still exit 0 on this branch. #551 fixes both, and I'd rather not compete with it. The two touch different code. Merged together they conflict in one place, where both add amod teststo the end ofactivate.rs. #551's error exits 1 throughmainunchanged.Things you may want differently
wait_for_jobs. A start job that runs longer, such as a slow oneshot, already ended the services phase early; now that also exits 1. A configurable timeout, or telling "still running" apart from "failed", may suit you better.matchinActivationFailure::exit_code.prepopulatestill exits 0 when registering services returns a partial result. It starts nothing, so I left it out; it's two lines if you want it.Fixes #581