diff --git a/crates/pi-natives/src/shell.rs b/crates/pi-natives/src/shell.rs index a573fbf7f..3958fac07 100644 --- a/crates/pi-natives/src/shell.rs +++ b/crates/pi-natives/src/shell.rs @@ -245,6 +245,15 @@ impl Shell { self.inner.abort().await; Ok(()) } + + /// Count live background jobs (`&`/`nohup` children still running) on this + /// session. Completed jobs are reaped first. The host uses this to retain a + /// per-call shell whose background processes are still running instead of + /// dropping it (which would SIGKILL them via kill-on-drop). + #[napi] + pub async fn live_background_job_count(&self) -> u32 { + self.inner.live_background_job_count().await + } } /// Execute a brush shell command. diff --git a/crates/pi-shell/src/shell.rs b/crates/pi-shell/src/shell.rs index 9ac8a7ff8..c3943920c 100644 --- a/crates/pi-shell/src/shell.rs +++ b/crates/pi-shell/src/shell.rs @@ -174,6 +174,35 @@ impl Shell { pub async fn abort(&self) { self.abort_state.abort().await; } + + /// Number of live background jobs (running `&`/`nohup` children) tracked by + /// the persistent session. Completed jobs are reaped first via a silent + /// `JobManager::poll()` (no job-control notifications), so the count + /// reflects only processes still alive. Returns 0 when no session core is + /// materialized. The host uses this to decide whether to retain a per-call + /// shell whose background children are still running instead of dropping it + /// (which would SIGKILL them on kill-on-drop). + pub async fn live_background_job_count(&self) -> u32 { + let mut guard = self.session.lock().await; + let Some(core) = guard.as_mut() else { + return 0; + }; + let jobs = core.shell.jobs_mut(); + // Fail closed: a poll error leaves the job table in an unknown state, so + // report 0 (drop the shell) rather than pin a retained session forever on + // stale `representative_pid()` entries. + if jobs.poll().is_err() { + return 0; + } + u32::try_from( + jobs + .jobs + .iter() + .filter(|job| job.representative_pid().is_some()) + .count(), + ) + .unwrap_or(u32::MAX) + } } pub async fn execute_shell( @@ -2090,6 +2119,48 @@ replace = [{ pattern = "hello", replacement = "HI" }] } } + /// `live_background_job_count` reports 0 when the session has no live + /// external background jobs and 1 while one is running. The host relies on + /// this to retain a per-call shell whose `&`/`nohup` child is still alive + /// instead of dropping it (which would SIGKILL the child via kill-on-drop). + /// Path-qualified `/bin/sleep` is used so it spawns a real external process + /// (the bare `sleep` builtin runs in-process and is intentionally not + /// counted). + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread")] + async fn live_background_job_count_tracks_external_background_jobs() { + let _guard = shell_test_lock().lock().await; + let shell = Shell::new(None); + + // No session core materialized yet. + assert_eq!(shell.live_background_job_count().await, 0); + + // A foreground-only command leaves nothing in the background. + shell + .run( + ShellRunOptions { command: "true".into(), ..Default::default() }, + None, + CancelToken::default(), + ) + .await + .expect("run true"); + assert_eq!(shell.live_background_job_count().await, 0); + + // An external background process is tracked while it runs. + shell + .run( + ShellRunOptions { command: "/bin/sleep 30 &".into(), ..Default::default() }, + None, + CancelToken::default(), + ) + .await + .expect("run sleep"); + assert_eq!(shell.live_background_job_count().await, 1); + + // Dropping the shell at scope end reaps the child via kill-on-drop. + shell.abort().await; + } + #[cfg(unix)] #[tokio::test(flavor = "multi_thread")] async fn segmented_false_and_printf_skips_second_and_returns_nonzero() { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2d0cf6d78..103bc848d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,10 @@ - Changed `/share` to upload the encrypted session blob to the share server by default instead of a secret GitHub gist. Gist-hosted shares are fetched through the unauthenticated GitHub gist API from the viewer's browser, which GitHub rate-limits to 60 requests/hour per IP, so shared NATs and repeated reloads surfaced `Failed to load session: Gist fetch failed: HTTP 403` in the viewer. Opt back into gist hosting with `share.store: "gist"`. +### Fixed + +- Fixed `nohup`/`&` background processes started inside an auto-backgrounded (`:async:`) bash turn being killed when that turn's shell was torn down. The per-job shell is now retained while a background process is still running (and reaped once it exits), so backgrounded commands survive across turns while still dying with the harness process. + ## [16.1.14] - 2026-06-22 ### Added diff --git a/packages/coding-agent/src/exec/bash-executor.ts b/packages/coding-agent/src/exec/bash-executor.ts index 81a5afc82..365d553b3 100644 --- a/packages/coding-agent/src/exec/bash-executor.ts +++ b/packages/coding-agent/src/exec/bash-executor.ts @@ -59,6 +59,42 @@ const shellSessionQuarantines = new Map>(); /** Session keys with a command currently in flight on the persistent Shell. */ const shellSessionsInUse = new Set(); +/** + * Shells retained past their turn because a background (`nohup`/`&`) job is + * still running. A per-call `:async:` Shell is normally dropped at teardown, + * which SIGKILLs its children via kill-on-drop. Keeping the reference alive lets + * the process survive across turns; the Shell is dropped once its last + * background job exits (reaped by the poll loop below). Children stay + * kill-on-drop, so they still die when the harness tears the Shell down on exit. + */ +const retainedShells = new Set(); +const RETAIN_REAP_INTERVAL_MS = 5_000; + +async function retainShellWithLiveBackgroundJobs(shell: Shell): Promise { + let live: number; + try { + live = await shell.liveBackgroundJobCount(); + } catch { + return; + } + if (live <= 0) return; + retainedShells.add(shell); + const interval = setInterval(() => { + void shell + .liveBackgroundJobCount() + .then(remaining => { + if (remaining > 0) return; + clearInterval(interval); + retainedShells.delete(shell); + }) + .catch(() => { + clearInterval(interval); + retainedShells.delete(shell); + }); + }, RETAIN_REAP_INTERVAL_MS); + interval.unref?.(); +} + function quarantineShellSession( sessionKey: string, runPromise: Promise, @@ -411,6 +447,14 @@ export async function executeBash(command: string, options?: BashExecutorOptions // `:async:` keys are per-job (jobId is unique), so the Shell would // otherwise stay in the process-global map forever after completion. shellSessions.delete(sessionKey); + // Dropping the only reference to a per-call `:async:` Shell SIGKILLs + // any `nohup`/`&` children (kill-on-drop). If the command left a live + // background job, retain the Shell so the process survives across + // turns; it is reaped once its last job exits and still dies with the + // harness. Skip on resetSession (cancel/error) — those tear down. + if (!resetSession && shellSession) { + await retainShellWithLiveBackgroundJobs(shellSession); + } } } } diff --git a/packages/coding-agent/test/bash-executor.test.ts b/packages/coding-agent/test/bash-executor.test.ts index 7d02a23ee..6252952fe 100644 --- a/packages/coding-agent/test/bash-executor.test.ts +++ b/packages/coding-agent/test/bash-executor.test.ts @@ -910,3 +910,59 @@ exit 64 await expectMarkerNeverWritten(marker, release); }); }); + +describe("executeBash :async: background retention", () => { + let tmp: string; + + beforeEach(async () => { + tmp = makeTempDir(); + resetSettingsForTest(); + await Settings.init({ inMemory: true, cwd: tmp }); + }); + + afterEach(() => { + resetSettingsForTest(); + vi.restoreAllMocks(); + if (fs.existsSync(tmp)) removeSyncWithRetries(tmp); + }); + + it.skipIf(process.platform === "win32")( + "keeps a per-job :async: shell's background process alive across turns", + async () => { + const pidFile = path.join(tmp, "pid"); + const sleepBin = fs.existsSync("/bin/sleep") ? "/bin/sleep" : "sleep"; + let pid: number | undefined; + try { + // A per-job `:async:` key: its shell is removed from the reuse map at + // teardown, which would SIGKILL the backgrounded child (kill-on-drop). + // The retain logic keeps the shell alive while a background process is + // still running. `$!` is the external child's pid (nohup is a + // transparent background wrapper). + const res = await executeBash(`nohup ${sleepBin} 30 >/dev/null 2>&1 & echo $! > ${shellQuote(pidFile)}`, { + sessionKey: "retain-probe:async:job1", + cwd: tmp, + }); + expect(res.cancelled).toBe(false); + pid = Number.parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10); + expect(Number.isInteger(pid)).toBe(true); + + // A later turn on a different per-job shell must not have killed it. + await executeBash("true", { sessionKey: "retain-probe:async:job2", cwd: tmp }); + + let alive = true; + try { + process.kill(pid, 0); + } catch { + alive = false; + } + expect(alive).toBe(true); + } finally { + if (pid !== undefined) { + try { + process.kill(pid, "SIGKILL"); + } catch {} + } + } + }, + ); +}); diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index f885ab6d2..f0b249187 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `Shell.liveBackgroundJobCount()` reporting the number of live external background jobs (`&`/`nohup` children) on a persistent session, reaping completed jobs first via a silent `poll()`. Lets the host retain a shell whose background process is still running instead of dropping it (which would SIGKILL the child via kill-on-drop). + ## [16.1.14] - 2026-06-22 ### Fixed diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index a9254317f..299d48db0 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -116,6 +116,13 @@ export declare class Shell { * Returns `Ok(())` even when no commands are running. */ abort(): Promise + /** + * Count live background jobs (`&`/`nohup` children still running) on this + * session. Completed jobs are reaped first. The host uses this to retain a + * per-call shell whose background processes are still running instead of + * dropping it (which would SIGKILL them via kill-on-drop). + */ + liveBackgroundJobCount(): Promise } /**