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:
@@ -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()),
|
||||
¤t_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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user