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).
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_refreshincrates/aft/src/effective_path.rsstarts the refresh helper and drops theChildhandle without waiting on it: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:Zs [aft-subc] <defunct>. The name isaft-subcbecause the helper is the same binary run again;sis because the helper callssetsid()./proc/<zombie>/statis 0, andeffective-path.jsonwas 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_startedstops it repeating. The cost is one process-table slot per process, plus a<defunct>line inpsthat 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 callwait().Suggested fix
Reap it on a background thread. That keeps the current behaviour, including letting the helper outlive the parent:
Alternatively, fork twice so the helper is reparented to init straight away.
Unchanged on
mainat4ea5d610(let _ = command.spawn();at L729).