Follow-up from #126 part C ("reported, unverified" in the original issue). This
is now confirmed reproduced with direct process/thread evidence. Deliberately
not fixed in this pass — the fix needs a small design decision plus a
nontrivial restructure, and deserves its own careful TDD cycle rather than a
guess bolted onto the #126 cleanup. Splitting it out per the original plan
(#126 was closed by #140 + #160 without this part).
Repro
- Start the interactive REPL.
sh -c 'sleep 3600' (a long-running foreground job).
- Ctrl-Z to stop it.
bg to resume it in the background.
exit.
Observed: the kaish process does not terminate — it stays alive,
blocked, for as long as the backgrounded job keeps running (I let it run 15s
in testing, versus the theorized full 3600s; nothing suggested it would ever
return early).
Evidence this is a real block, not just a busy/loaded machine
The test machine was under genuine heavy load while I did this (confirmed:
2GB free RAM, 42GB swap in use, load average 30-58 from ~8 concurrent build
swarms) — so I built in a control before trusting the result:
- Control (fresh PTY session,
exit immediately, no backgrounded job):
process exits in ~100ms, even under that same load.
- Repro case (steps above): kaish still had not exited after 15s
(5s + a further bounded 10s poll), while its sleep 3600-family child was
independently confirmed still alive and running (ps showed it in state
S, executing the sleep syscall normally — i.e. nothing killed it, it just
kept running exactly as a real backgrounded job should).
/proc/<kaish_pid>/task/*/{wchan,status} during the hang showed:
- one thread named
tokio-runtime-w[orker] in state S, wchan=do_wait
— do_wait is the kernel's internal name for the wait-queue function
backing wait4/waitpid-family syscalls. This is bg's reaper thread,
parked inside its blocking waitpid(pid, None) call.
- the other threads (the REPL's driver thread(s)) parked on
wchan=futex_do_wait — consistent with tokio::runtime::Runtime's
default Drop blocking the dropping thread until outstanding
blocking-pool work (block_in_place/spawn_blocking) completes.
- (
gdb -p <pid> -batch -ex "thread apply all bt" would have been more
definitive but ptrace is blocked in this sandbox — Operation not permitted. The /proc evidence above is consistent and specific enough
that I'm confident in it, but a full backtrace would be worth getting in
an environment where ptrace is available.)
Mechanism (source-level)
bg.rs's reaper (tokio::spawn(async move { tokio::task::block_in_place(|| loop { waitpid(pid, None) ... }) ... })) has its JoinHandle dropped
immediately — never awaited. Dropping a JoinHandle does not cancel the
task; it keeps running, tracked by the runtime, independent of the bg
command that spawned it.
fg.rs has a similar-looking block_in_place wait, but it's synchronously
awaited as part of fg's own execution — by the time you're back at a
prompt to type exit, that closure has already returned. This bug is
specific to bg's detached, outlives-the-command reaper.
kaish-repl's exit path (ProcessResult::Exit in lib.rs:process_line →
run_with_overlay/run return Ok(()) → the Repl struct, and its
runtime: tokio::runtime::Runtime field, drop normally at scope exit) never
calls shutdown_background() or shutdown_timeout() anywhere — it's a bare
Runtime::drop, which tokio documents as blocking the dropping thread until
outstanding blocking-pool work finishes (that's precisely why
shutdown_background() exists as an alternative).
Fix directions (not yet implemented — needs a decision + care)
Option 1 — make the reaper non-blocking (my lean): replace the
block_in_place + blocking waitpid(pid, None) loop with a plain async task
that polls waitpid(pid, WNOHANG) on a tokio::time::sleep interval. No
block_in_place, so nothing for Runtime::drop to wait on — a not-yet-
finished plain async task is simply dropped/cancelled at shutdown rather than
joined. Smallest-diff option; doesn't touch Repl's structure. Needs a sane
poll interval choice and a regression test.
Option 2 — bounded shutdown at REPL exit: call runtime.shutdown_timeout(D)
explicitly instead of relying on implicit Drop. Simpler in spirit, but
shutdown_timeout consumes self, so Repl.runtime: Runtime would need to
become Option<Runtime> (or similar) purely to support taking ownership at
exit — a wider, more mechanical diff across every self.runtime.block_on(...)
call site.
Design question either way: what should happen to a backgrounded job
when the shell exits? Real bash's default (huponexit off, the common
interactive default) is: the job is not signaled, becomes an orphan of
init, and keeps running independently. Both fix options above are compatible
with that (the job's OS process was never a child of any Rust object kaish
depends on to keep running — it keeps going either way); the only question is
whether kaish's reaper task gets to see it finish and log a completion
(probably not worth blocking exit for either way). Worth confirming this is
the desired behavior before implementing, rather than assuming.
Test plan for whoever picks this up
crates/kaish-repl/tests/pty_job_control.rs is the right home. A permanent
regression test doesn't need a full 3600s upper bound — background a
short-duration job (e.g. a couple seconds), send exit immediately, and
assert the kaish process itself exits promptly (well under the job's
duration), the way I did for this investigation's throwaway diagnostic tests
(not committed anywhere — this issue's repro steps + evidence above are the
record).
Follow-up from #126 part C ("reported, unverified" in the original issue). This
is now confirmed reproduced with direct process/thread evidence. Deliberately
not fixed in this pass — the fix needs a small design decision plus a
nontrivial restructure, and deserves its own careful TDD cycle rather than a
guess bolted onto the #126 cleanup. Splitting it out per the original plan
(#126 was closed by #140 + #160 without this part).
Repro
sh -c 'sleep 3600'(a long-running foreground job).bgto resume it in the background.exit.Observed: the kaish process does not terminate — it stays alive,
blocked, for as long as the backgrounded job keeps running (I let it run 15s
in testing, versus the theorized full 3600s; nothing suggested it would ever
return early).
Evidence this is a real block, not just a busy/loaded machine
The test machine was under genuine heavy load while I did this (confirmed:
2GB free RAM, 42GB swap in use, load average 30-58 from ~8 concurrent build
swarms) — so I built in a control before trusting the result:
exitimmediately, no backgrounded job):process exits in ~100ms, even under that same load.
(5s + a further bounded 10s poll), while its
sleep 3600-family child wasindependently confirmed still alive and running (
psshowed it in stateS, executing the sleep syscall normally — i.e. nothing killed it, it justkept running exactly as a real backgrounded job should).
/proc/<kaish_pid>/task/*/{wchan,status}during the hang showed:tokio-runtime-w[orker]in stateS,wchan=do_wait—
do_waitis the kernel's internal name for the wait-queue functionbacking
wait4/waitpid-family syscalls. This isbg's reaper thread,parked inside its blocking
waitpid(pid, None)call.wchan=futex_do_wait— consistent withtokio::runtime::Runtime'sdefault
Dropblocking the dropping thread until outstandingblocking-pool work (
block_in_place/spawn_blocking) completes.gdb -p <pid> -batch -ex "thread apply all bt"would have been moredefinitive but
ptraceis blocked in this sandbox —Operation not permitted. The/procevidence above is consistent and specific enoughthat I'm confident in it, but a full backtrace would be worth getting in
an environment where ptrace is available.)
Mechanism (source-level)
bg.rs's reaper (tokio::spawn(async move { tokio::task::block_in_place(|| loop { waitpid(pid, None) ... }) ... })) has itsJoinHandledroppedimmediately — never awaited. Dropping a
JoinHandledoes not cancel thetask; it keeps running, tracked by the runtime, independent of the
bgcommand that spawned it.
fg.rshas a similar-lookingblock_in_placewait, but it's synchronouslyawaited as part of
fg's own execution — by the time you're back at aprompt to type
exit, that closure has already returned. This bug isspecific to
bg's detached, outlives-the-command reaper.kaish-repl's exit path (ProcessResult::Exitinlib.rs:process_line→run_with_overlay/runreturnOk(())→ theReplstruct, and itsruntime: tokio::runtime::Runtimefield, drop normally at scope exit) nevercalls
shutdown_background()orshutdown_timeout()anywhere — it's a bareRuntime::drop, which tokio documents as blocking the dropping thread untiloutstanding blocking-pool work finishes (that's precisely why
shutdown_background()exists as an alternative).Fix directions (not yet implemented — needs a decision + care)
Option 1 — make the reaper non-blocking (my lean): replace the
block_in_place+ blockingwaitpid(pid, None)loop with a plain async taskthat polls
waitpid(pid, WNOHANG)on atokio::time::sleepinterval. Noblock_in_place, so nothing forRuntime::dropto wait on — a not-yet-finished plain async task is simply dropped/cancelled at shutdown rather than
joined. Smallest-diff option; doesn't touch
Repl's structure. Needs a sanepoll interval choice and a regression test.
Option 2 — bounded shutdown at REPL exit: call
runtime.shutdown_timeout(D)explicitly instead of relying on implicit
Drop. Simpler in spirit, butshutdown_timeoutconsumesself, soRepl.runtime: Runtimewould need tobecome
Option<Runtime>(or similar) purely to support taking ownership atexit — a wider, more mechanical diff across every
self.runtime.block_on(...)call site.
Design question either way: what should happen to a backgrounded job
when the shell exits? Real bash's default (
huponexitoff, the commoninteractive default) is: the job is not signaled, becomes an orphan of
init, and keeps running independently. Both fix options above are compatible
with that (the job's OS process was never a child of any Rust object kaish
depends on to keep running — it keeps going either way); the only question is
whether kaish's reaper task gets to see it finish and log a completion
(probably not worth blocking exit for either way). Worth confirming this is
the desired behavior before implementing, rather than assuming.
Test plan for whoever picks this up
crates/kaish-repl/tests/pty_job_control.rsis the right home. A permanentregression test doesn't need a full 3600s upper bound — background a
short-duration job (e.g. a couple seconds), send
exitimmediately, andassert the kaish process itself exits promptly (well under the job's
duration), the way I did for this investigation's throwaway diagnostic tests
(not committed anywhere — this issue's repro steps + evidence above are the
record).