Skip to content

effective_path: the login-shell PATH refresh helper is never reaped, leaving one zombie per aft process #332

Description

@iceteaSA

Summary

On Unix, every aft process that starts with a warm login-shell PATH cache leaves one zombie child for its whole lifetime. spawn_cached_path_refresh in crates/aft/src/effective_path.rs starts the refresh helper and drops the Child handle without waiting on it:

// effective_path.rs, spawn_cached_path_refresh (~L711-729)
let mut command = Command::new(executable);          // current_exe()
command.arg("--probe-login-shell-path").arg(cache_path) /* ... */;
unsafe { command.pre_exec(|| { libc::setsid(); /* ... */ Ok(()) }); }
let _ = command.spawn();                              // Child dropped, never waited

The helper refreshes the cache and exits, but its parent never calls wait(), so it stays <defunct> until the aft process exits.

Observed

On a Linux daemon (aft-subc --subc, v0.56.2), before and after a daemon restart:

aft-subc zombie child
before restart pid 2764, starttime 1433 pid 2920, starttime 1437, unreaped for ~2.3 days
after restart pid 1925547, starttime 20162693 pid 1925570, starttime 20162694
  • In both runs there's exactly one zombie, created within a few clock ticks of process start. I predicted the second one before the restart.
  • It shows as Zs [aft-subc] <defunct>. The name is aft-subc because the helper is the same binary run again; s is because the helper calls setsid().
  • The exit status in /proc/<zombie>/stat is 0, and effective-path.json was rewritten at the helper's start time. So the refresh works, and only the reap is missing.

Impact

Low. It's bounded at one zombie per aft process: it only runs on a cache hit, and refresh_started stops it repeating. The cost is one process-table slot per process, plus a <defunct> line in ps that looks like a leak to anyone checking process health.

The comment says the helper runs detached so it can outlive the serving process. setsid() does detach it from the session, but it doesn't change who reaps it: while the parent is alive, the parent still has to call wait().

Suggested fix

Reap it on a background thread. That keeps the current behaviour, including letting the helper outlive the parent:

if let Ok(mut child) = command.spawn() {
    let _ = std::thread::Builder::new()
        .name("aft-path-refresh-reaper".into())
        .spawn(move || { let _ = child.wait(); });
}

Alternatively, fork twice so the helper is reparented to init straight away.

Unchanged on main at 4ea5d610 (let _ = command.spawn(); at L729).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions