switch-to-configuration-ng: restart changed user units on switch

Previously the per-user child only reexecuted the user systemd instance
and restarted nixos-activation.service, leaving NixOS-managed user units
in /etc/systemd/user running stale binaries until the next login. Options
like systemd.user.services.<name>.restartTriggers were ineffective.

The user-scope child now:

  1. collects changes by comparing the pre-switch old_toplevel (captured
     before activation and passed via OLD_TOPLEVEL env) against the new
     toplevel
  2. stops obsolete units
  3. reexecutes + reloads the user systemd
  4. reloads/restarts/starts changed units
  5. runs nixos-activation.service (home-manager etc.)

Units are filtered by FragmentPath so those shadowed by
~/.config/systemd/user are left to home-manager to handle.

Resume-after-interrupt persistence uses $XDG_RUNTIME_DIR/nixos for the
user scope, mirroring /run/nixos for system.

Fixes #246611
This commit is contained in:
r-vdp
2026-04-21 15:43:44 +02:00
parent 5e22ad20da
commit 2ff37e40e0
@@ -148,6 +148,7 @@ impl From<&Action> for &'static str {
#[derive(Debug, Clone, Copy, PartialEq)]
enum UnitScope {
System,
User,
}
impl UnitScope {
@@ -156,6 +157,7 @@ impl UnitScope {
fn etc_dir(&self) -> &'static str {
match self {
UnitScope::System => "etc/systemd/system",
UnitScope::User => "etc/systemd/user",
}
}
@@ -163,14 +165,25 @@ impl UnitScope {
fn current_dir(&self) -> &'static Path {
Path::new(match self {
UnitScope::System => "/etc/systemd/system",
UnitScope::User => "/etc/systemd/user",
})
}
/// Directory where unit action lists are persisted for
/// resume-after-interrupt.
/// resume-after-interrupt. The user scope uses XDG_RUNTIME_DIR so the
/// unprivileged child can write to it.
fn list_dir(&self) -> PathBuf {
match self {
UnitScope::System => PathBuf::from("/run/nixos"),
UnitScope::User => {
// The parent always sets XDG_RUNTIME_DIR when spawning the
// user-scope child.
let runtime_dir = std::env::var("XDG_RUNTIME_DIR")
.ok()
.filter(|d| !d.is_empty())
.expect("XDG_RUNTIME_DIR must be set and non-empty for the user scope");
PathBuf::from(runtime_dir).join("nixos")
}
}
}
@@ -1167,10 +1180,37 @@ fn do_user_switch(parent_exe: String) -> anyhow::Result<()> {
die();
}
let toplevel = PathBuf::from(required_env("TOPLEVEL")?);
let old_toplevel = PathBuf::from(required_env("OLD_TOPLEVEL")?);
let action = Action::from_str(&required_env("NIXOS_ACTION")?)?;
let action = ACTION.get_or_init(|| action);
let dry_run = *action == Action::DryActivate;
let scope = UnitScope::User;
let list_dir = scope.list_dir();
std::fs::create_dir_all(&list_dir)
.with_context(|| format!("Failed to create {}", list_dir.display()))?;
let perms = std::fs::Permissions::from_mode(0o700);
std::fs::set_permissions(&list_dir, perms)
.with_context(|| format!("Failed to set permissions on {}", list_dir.display()))?;
let start_list = scope.start_list_file();
let restart_list = scope.restart_list_file();
let reload_list = scope.reload_list_file();
let dbus_conn = LocalConnection::new_session().context("Failed to open dbus connection")?;
let systemd = systemd1_proxy(&dbus_conn);
systemd
.subscribe()
.context("Failed to subscribe to systemd dbus messages")?;
let submitted_jobs = Rc::new(RefCell::new(HashMap::new()));
let finished_jobs: Rc<RefCell<HashMap<dbus::Path, (String, Job, String)>>> =
Rc::new(RefCell::new(HashMap::new()));
let nixos_activation_done = Rc::new(RefCell::new(false));
let _submitted_jobs = submitted_jobs.clone();
let _finished_jobs = finished_jobs.clone();
let _nixos_activation_done = nixos_activation_done.clone();
let jobs_token = systemd
.match_signal(
@@ -1180,32 +1220,190 @@ fn do_user_switch(parent_exe: String) -> anyhow::Result<()> {
if signal.unit.as_str() == "nixos-activation.service" {
*_nixos_activation_done.borrow_mut() = true;
}
if let Some(old) = _submitted_jobs.borrow_mut().remove(&signal.job) {
_finished_jobs
.borrow_mut()
.insert(signal.job, (signal.unit, old, signal.result));
}
true
},
)
.context("Failed to add signal match for systemd removed jobs")?;
// Plan the user unit changes before touching anything. By the time this
// child runs, /etc (and /run/current-system) have already been switched to
// the new configuration. The parent captured the old toplevel path before
// activation and passed it to us so we can still compare old vs new.
let mut units_to_stop = HashMap::new();
let mut units_to_skip = HashMap::new();
let mut units_to_filter = HashMap::new();
// Seed from any previous interrupted run so that we continue where we left
// off, like the system scope does.
let mut units_to_start = map_from_list_file(&start_list);
let mut units_to_restart = map_from_list_file(&restart_list);
let mut units_to_reload = map_from_list_file(&reload_list);
let current_active_units = get_active_units(&systemd)?;
collect_unit_changes(
&toplevel,
scope,
&old_toplevel.join(scope.etc_dir()),
&toplevel.join(scope.etc_dir()),
&current_active_units,
&mut units_to_stop,
&mut units_to_start,
&mut units_to_reload,
&mut units_to_restart,
&mut units_to_skip,
&mut units_to_filter,
)?;
let print_units = |verb: &str, units: &HashMap<String, ()>| {
if units.is_empty() {
return;
}
let mut names: Vec<&str> = units.keys().map(String::as_str).collect();
names.sort_by_key(|n| n.to_lowercase());
eprintln!("{verb} the following user units: {}", names.join(", "));
};
if dry_run {
print_units("would stop", &units_to_stop);
if !units_to_skip.is_empty() {
print_units("would NOT restart", &units_to_skip);
}
print_units("would reload", &units_to_reload);
print_units("would restart", &units_to_restart);
print_units(
"would start",
&filter_units(&units_to_filter, &units_to_start),
);
return Ok(());
}
let mut exit_code = 0;
// Stop units before reexec so that ExecStop runs with the old definition.
print_units("stopping", &units_to_stop);
for unit in units_to_stop.keys() {
if let Ok(job_path) = systemd.stop_unit(unit, "replace") {
submitted_jobs.borrow_mut().insert(job_path, Job::Stop);
}
}
block_on_jobs(&dbus_conn, &submitted_jobs);
if !units_to_skip.is_empty() {
print_units("NOT restarting", &units_to_skip);
}
// The systemd user session seems to not send a Reloaded signal, so we don't have anything to
// wait on here.
_ = systemd.reexecute();
systemd
.restart_unit("nixos-activation.service", "replace")
.context("Failed to restart nixos-activation.service")?;
// Reset failed and reload so that subsequent starts use the new unit files.
_ = systemd.reset_failed();
_ = systemd.reload();
log::debug!("waiting for nixos activation to finish");
while !*nixos_activation_done.borrow() {
_ = dbus_conn
.process(DBUS_PROCESS_TIME)
.context("Failed to process dbus messages")?;
print_units("reloading", &units_to_reload);
for unit in units_to_reload.keys() {
match systemd.reload_unit(unit, "replace") {
Ok(job_path) => {
submitted_jobs.borrow_mut().insert(job_path, Job::Reload);
}
Err(err) => {
eprintln!("Failed to reload user unit {unit}: {err}");
exit_code = 4;
}
}
}
block_on_jobs(&dbus_conn, &submitted_jobs);
remove_file_if_exists(&reload_list)
.with_context(|| format!("Failed to remove {}", reload_list.display()))?;
print_units("restarting", &units_to_restart);
for unit in units_to_restart.keys() {
match systemd.restart_unit(unit, "replace") {
Ok(job_path) => {
submitted_jobs.borrow_mut().insert(job_path, Job::Restart);
}
Err(err) => {
eprintln!("Failed to restart user unit {unit}: {err}");
exit_code = 4;
}
}
}
block_on_jobs(&dbus_conn, &submitted_jobs);
remove_file_if_exists(&restart_list)
.with_context(|| format!("Failed to remove {}", restart_list.display()))?;
let start_filtered = filter_units(&units_to_filter, &units_to_start);
print_units("starting", &start_filtered);
for unit in units_to_start.keys() {
match systemd.start_unit(unit, "replace") {
Ok(job_path) => {
submitted_jobs.borrow_mut().insert(job_path, Job::Start);
}
Err(err) => {
eprintln!("Failed to start user unit {unit}: {err}");
exit_code = 4;
}
}
}
block_on_jobs(&dbus_conn, &submitted_jobs);
remove_file_if_exists(&start_list)
.with_context(|| format!("Failed to remove {}", start_list.display()))?;
// Run per-user activation (home-manager etc.) after NixOS-level user units
// have been brought up to date. This matches the system → user layering.
// Toplevels with system.activatable = false do not ship this unit; mirror
// the system scope's tolerance for a missing activate script.
if toplevel
.join(scope.etc_dir())
.join("nixos-activation.service")
.exists()
{
match systemd.restart_unit("nixos-activation.service", "replace") {
Ok(_) => {
log::debug!("waiting for nixos activation to finish");
while !*nixos_activation_done.borrow() {
_ = dbus_conn
.process(DBUS_PROCESS_TIME)
.context("Failed to process dbus messages")?;
}
}
Err(err) => {
eprintln!("Failed to restart nixos-activation.service: {err}");
exit_code = 4;
}
}
}
let finished = finished_jobs.borrow();
let mut failed_units = Vec::new();
for (unit, job, result) in finished.values() {
if matches!(result.as_str(), "timeout" | "failed" | "dependency") {
eprintln!("Failed to {job} user unit {unit}");
failed_units.push(unit.as_str());
exit_code = 4;
}
}
if !failed_units.is_empty() {
failed_units.sort_by_key(|name| name.to_lowercase());
eprintln!(
"warning: the following user units failed: {}\n\
See `systemctl --user status {}` for details.",
failed_units.join(", "),
failed_units.join(" "),
);
}
dbus_conn
.remove_match(jobs_token)
.context("Failed to remove jobs token")?;
Ok(())
std::process::exit(exit_code);
}
fn usage(argv0: &str) -> ! {
@@ -1227,6 +1425,12 @@ fn do_system_switch(action: Action) -> anyhow::Result<()> {
let out = PathBuf::from(required_env("OUT")?);
let toplevel = PathBuf::from(required_env("TOPLEVEL")?);
// Capture the old toplevel before the activation script updates
// /run/current-system. We pass this to the per-user switch child so it can
// compare old vs new user unit files after /etc has already been switched.
let old_toplevel = Path::new("/run/current-system")
.canonicalize()
.unwrap_or_else(|_| PathBuf::from("/run/current-system"));
let distro_id = required_env("DISTRO_ID")?;
let pre_switch_check = required_env("PRE_SWITCH_CHECK")?;
let install_bootloader = required_env("INSTALL_BOOTLOADER")?;
@@ -1870,16 +2074,23 @@ won't take effect until you reboot the system.
.context("Failed to get full path to /proc/self/exe")?;
log::debug!("Performing user switch for {name}");
std::process::Command::new(&myself)
let status = std::process::Command::new(&myself)
.uid(uid)
.gid(gid)
.env_clear()
.env("XDG_RUNTIME_DIR", runtime_path)
.env("__NIXOS_SWITCH_TO_CONFIGURATION_PARENT_EXE", &myself)
.env("TOPLEVEL", &toplevel)
.env("OLD_TOPLEVEL", &old_toplevel)
.env("NIXOS_ACTION", Into::<&'static str>::into(action))
.spawn()
.with_context(|| format!("Failed to spawn user activation for {name}"))?
.wait()
.with_context(|| format!("Failed to run user activation for {name}"))?;
if !status.success() {
eprintln!("warning: user activation for {name} failed");
exit_code = 4;
}
}
}
}