nixos/activation: run user nixos-activation.service exactly once per switch

stc-ng starts every active target (including default.target) and then
explicitly restarts nixos-activation.service. As a Type=oneshot without
RemainAfterExit the unit is inactive after login, so the default.target
start job re-runs it via Wants=, and the explicit restart runs it again
(or, depending on ordering, SIGTERMs the currently running script and re-runs it).

Set RemainAfterExit=yes so target starts are a no-op for an already-run
activation, and restartIfChanged=false so the unit-diff pass leaves it
alone when the script changes. The explicit restart in stc-ng remains
the single trigger per switch. Print that restart so it is visible in
the switch output, and drop it from the "NOT restarting" list.

Extend the user-activation-scripts test to assert the activation is
only run once, and never killed.
This commit is contained in:
r-vdp
2026-05-23 12:50:40 +02:00
parent 6ced06a1b3
commit 663a59e0b6
4 changed files with 19 additions and 3 deletions
@@ -322,6 +322,9 @@ in
description = "Run user-specific NixOS activation";
script = config.system.userActivationScripts.script;
unitConfig.ConditionUser = "!@system";
# switch-to-configuration restarts this explicitly on every switch.
restartIfChanged = false;
serviceConfig.RemainAfterExit = true;
serviceConfig.Type = "oneshot";
wantedBy = [ "default.target" ];
};
+4 -3
View File
@@ -1754,9 +1754,10 @@ in
out = switch_to_specialisation("${machine}", "simpleUserService")
user_systemctl("is-active usertest.service")
# No-op switch does nothing
# No-op switch leaves the test unit alone.
out = switch_to_specialisation("${machine}", "simpleUserService")
assert_lacks(out, "user units:")
assert_lacks(out, "usertest.service")
assert_contains(out, "restarting the following user units: nixos-activation.service")
# Modifying the unit stop-starts it (default stopIfChanged=true)
out = switch_to_specialisation("${machine}", "simpleUserServiceModified")
@@ -1773,7 +1774,7 @@ in
# reloadIfChanged=true reloads instead
out = switch_to_specialisation("${machine}", "simpleUserServiceReload")
assert_lacks(out, "stopping the following user units:")
assert_lacks(out, "restarting the following user units:")
assert_lacks(out, "restarting the following user units: usertest.service")
assert_contains(out, "reloading the following user units: usertest.service")
user_systemctl("is-active usertest.service")
+8
View File
@@ -13,6 +13,7 @@
isNormalUser = true;
};
systemd.user.tmpfiles.users.alice.rules = [ "r %h/file-to-remove" ];
specialisation.changed.configuration.system.userActivationScripts.bar = "true";
};
testScript = ''
@@ -36,5 +37,12 @@
machine.succeed("/run/current-system/bin/switch-to-configuration test")
verify_user_activation_run_count(2)
machine.succeed("[[ ! -f /home/alice/file-to-remove ]] || false")
# Activation must not be killed while running.
machine.fail("journalctl -b _SYSTEMD_USER_UNIT=nixos-activation.service | grep -q 'code=killed'")
# Changed activation script: still exactly one run.
machine.succeed("/run/current-system/specialisation/changed/bin/switch-to-configuration test")
verify_user_activation_run_count(3)
machine.fail("journalctl -b _SYSTEMD_USER_UNIT=nixos-activation.service | grep -q 'code=killed'")
'';
}
@@ -1486,6 +1486,9 @@ fn do_user_switch(parent_exe: String) -> anyhow::Result<()> {
&mut units_to_filter,
)?;
// Restarted unconditionally below; don't list it as skipped.
units_to_skip.remove("nixos-activation.service");
let print_units = |verb: &str, units: &HashMap<String, ()>| {
if units.is_empty() {
return;
@@ -1586,6 +1589,7 @@ fn do_user_switch(parent_exe: String) -> anyhow::Result<()> {
// Toplevels with system.activatable = false do not ship this unit; mirror
// the system scope's tolerance for a missing activate script.
if new_unit_dir.join("nixos-activation.service").exists() {
eprintln!("restarting the following user units: nixos-activation.service");
match systemd.restart_unit("nixos-activation.service", "replace") {
Ok(_) => {
log::debug!("waiting for nixos activation to finish");