fix: preserved shells with running background jobs

- Added `Shell.liveBackgroundJobCount` to query active background processes.
- Retained per-call `:async:` shells if background jobs are still running upon turn completion.
- Reaped shells automatically once their last background process exits to prevent lingering processes.
This commit is contained in:
can1357
2026-06-22 17:25:19 +02:00
parent 26c72689c2
commit 0fbcb63539
7 changed files with 195 additions and 0 deletions
+9
View File
@@ -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.
+71
View File
@@ -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() {
+4
View File
@@ -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
@@ -59,6 +59,42 @@ const shellSessionQuarantines = new Map<string, Promise<unknown>>();
/** Session keys with a command currently in flight on the persistent Shell. */
const shellSessionsInUse = new Set<string>();
/**
* 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<Shell>();
const RETAIN_REAP_INTERVAL_MS = 5_000;
async function retainShellWithLiveBackgroundJobs(shell: Shell): Promise<void> {
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<ShellRunResult>,
@@ -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);
}
}
}
}
@@ -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 {}
}
}
},
);
});
+4
View File
@@ -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
+7
View File
@@ -116,6 +116,13 @@ export declare class Shell {
* Returns `Ok(())` even when no commands are running.
*/
abort(): Promise<void>
/**
* 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<number>
}
/**