From 663a59e0b6d7575d7465ea0f03b6fa66f6298038 Mon Sep 17 00:00:00 2001 From: r-vdp Date: Sat, 23 May 2026 12:50:06 +0200 Subject: [PATCH] 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. --- nixos/modules/system/activation/activation-script.nix | 3 +++ nixos/tests/switch-test.nix | 7 ++++--- nixos/tests/user-activation-scripts.nix | 8 ++++++++ pkgs/by-name/sw/switch-to-configuration-ng/src/main.rs | 4 ++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/nixos/modules/system/activation/activation-script.nix b/nixos/modules/system/activation/activation-script.nix index 8fd274276a61..338fc1911c01 100644 --- a/nixos/modules/system/activation/activation-script.nix +++ b/nixos/modules/system/activation/activation-script.nix @@ -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" ]; }; diff --git a/nixos/tests/switch-test.nix b/nixos/tests/switch-test.nix index 4126645f59e0..256c25108593 100644 --- a/nixos/tests/switch-test.nix +++ b/nixos/tests/switch-test.nix @@ -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") diff --git a/nixos/tests/user-activation-scripts.nix b/nixos/tests/user-activation-scripts.nix index e8ea2d05c465..8e29a0ab3ae1 100644 --- a/nixos/tests/user-activation-scripts.nix +++ b/nixos/tests/user-activation-scripts.nix @@ -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'") ''; } diff --git a/pkgs/by-name/sw/switch-to-configuration-ng/src/main.rs b/pkgs/by-name/sw/switch-to-configuration-ng/src/main.rs index bebf3c46a4bf..20c80a50baa5 100644 --- a/pkgs/by-name/sw/switch-to-configuration-ng/src/main.rs +++ b/pkgs/by-name/sw/switch-to-configuration-ng/src/main.rs @@ -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| { 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");