ZIO: closing a query or session hangs for ever in a container whose PID 1 does not reap #92
Labels
No labels
bug
documentation
duplicate
enhancement
good first issue
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
iterative-works/claude-code-query#92
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
In the ZIO API, the end of a query or a session can hang for ever when the process runs in a container whose PID 1 does not reap zombies (
docker runwithout--init, a Jenkins/act-style container that keeps itself alive withtail -f /dev/nullorcat, and similar). The hang is in a finalizer, so it is uninterruptible: a Stop does not end it, and the fiber that closes the scope never returns.zioonly, from v0.6.1 on (the first release with the tree kill).directandeffectfulkill the CLI alone.kyo'sCliProcess.killTree(kyo/src/works/iterative/claude/kyo/internal/cli/CliProcess.scala:78-90) kills the same tree but does not wait for the descendants, so it does not hang. That is a working example in this repo.ProcessManager.executeProcess(query) andSessionProcess.start(session) go throughProcessManager.spawnUnderKill(zio/src/works/iterative/claude/zio/internal/cli/ProcessManager.scala:62-69).sleep) that dies together with its parent. The parent then never reaps it, the system gives the zombie to PID 1 (or to the nearest subreaper), and that process never callswait. Whether the parent reaps first is a race, so the failure is intermittent on a host without a reaper, and never shows on a host with systemd/init.CI is unblocked by #91 (
container: init: true). That workaround hides this bug from CI. It does not fix the SDK.Root cause (proven)
spawnUnderKillregistersspawned.killTreeForcibly.ignoreas the scope's kill finalizer.zio/process/ProcessPlatformSpecific.scala:76-93: It blocks ononExit().getfor every descendant that is still "alive", with no timeout.wait. It polls liveness, and a zombie counts as alive: JDK-8284282 "ProcessHandle/OnExitTest times out when run in misconfigured container", Closed / Won't Fix: "the test will wait for them forever, sinceProcessHandle.isAlive()returns true for zombified processes". JDK-8284874 only added a comment to that test. The JDK will not change this.ProcessHandleImpl_linux.cos_getParentPidAndTimingsreads/proc/<pid>/statand skips the state field, so stateZis never looked at.Evidence
SessionCloseTest("a CLI that never exits is killed once the ceiling passes", sometimes also "a close that is an interruption kills the CLI without waiting") hit its 60 s timeout on the main publish run for807cc2c(run 1371), on PR #89/#90 (runs 1391, 1392), and on PR #86 commits (e.g. run 1291). It passed on some runs (e.g. 1296): the race.docker run … entrypoint=["tail" "-f" "/dev/null"](runner v13.1.0, imageghcr.io/catthehacker/ubuntu:act-22.04).--no-daemonis needed: otherwise the test JVM runs under the Mill daemon, outside the wrapper. Without the wrapper the same command passes 3 of 3 on a desktop host.psshowssleepzombies whose PPID is the wrapper.jstackof the test JVM shows both close threads atCompletableFuture.get←zio.process.ProcessPlatformSpecific.killTreeForcibly…(ProcessPlatformSpecific.scala:89).What we checked upstream
killTree/killTreeForcibly. Open #425 (linesStreamis not interruptible) is a different problem.CI facts learned (so nobody probes them again)
container:key the job container's PID 1 istail -f /dev/null.container:with onlyinit: true(noimage) keeps the image of theruns-onlabel (runnerRunContext.platformImage,act/runner/run_context.go:1379-1395) and starts/sbin/docker-init -- tail -f /dev/nullas PID 1. An orphan is then reaped.initmust be a literal.init: ${{ matrix.init }}is dropped silently: the runner decodes thecontainermapping (Init *bool,act/model/workflow.go:638) before it evaluates expressions, the decode fails, and the defaultfalseapplies. The job log shows the samedocker runline either way. Only PID 1 (ps -p 1) tells the difference.ci.yml'sitestjob already solves the same problem in another way:tini -s -- ./mill --no-daemon __.itest(tini as child subreaper).ci/init-probe(throwaway workflowinit-probe.yml), run 1417 (matrix: both stilltail) and the literal re-run (docker-init, 2 of 2 pass).Proposed fix
Replace
killTreeForciblyinProcessManager.spawnUnderKillwith our own tree kill:destroyForciblythem and the CLI. Same order askyo'skillTree.waitFor. It is our direct child, so the JVM reaps it and this wait always ends./proc/<pid>/statisZcounts as finished: SIGKILL has ended it, and only its process-table entry is left, which only its adopter can remove. Without this check every close in such a container takes the full bound. Elsewhere (macOS: no/proc), useisAlivealone.Points to keep: the kill stays the first finalizer registered (so it runs last), and stays uninterruptible as one step with the spawn (see the scaladoc of
spawnUnderKilland the 0.6.1 release commit0239d1c). The bound must be shorter thanSessionCloseTest'stook < 5.seconds.Also check the PID reuse question: between the kill and the check, a PID can in theory be reused.
ProcessHandlekeeps the start time and compares it. A/proc/<pid>/statread must compare the start time too (field 22), or keep theProcessHandleand read/proconly for the state.Test plan
ProcessHandlethat never exits (a JDK interface, not our code). Cases: a descendant that exits in time; one that never exits (returns after the bound, warns); one reported as zombie (returns at once).SessionCloseTestunder the non-reaping wrapper above, or a job with nocontainer:block. It must fail on main as it is today, and pass after the fix. Keep it after the fix, because #91 removed the only environment where this bug showed.prctlthrough the FFM API. FFM is final in Java 22 and preview in Java 21, and CI runs Java 21 (thekyomodule already runs on Java 25, so it is possible but is a larger change).Open questions
container: init: truein CI (#91), or remove it and let the no-init job be the default?