OpenHands / OpenHands/software-agent-sdk

ACP subprocess leaf process orphaned on shutdown (POSIX) — process-group teardown gap, separate from #4409's Windows fix

Open
#4,901 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

acp bug priority:medium release-note-required
Dominant language
Python
Stars
1.1k
Forks
539
Avg merge
1d 19h
Merged PRs (30d)
137

Description

Summary

On POSIX (Linux), ACPAgent._shutdown_runtime's process.terminate()/.kill() (a single-PID os.kill, since _process is an asyncio.subprocess.Process with no process-group handle) reliably kills the top 1-2 levels of the spawned ACP command chain but leaves deeper descendants running indefinitely as orphans, reparented to init.

This is the same root cause #4409 addresses for Windows (no process-group equivalent for a bare PID kill), but #4409's fix (a Win32 Job Object) is Windows-only by design and does not help here — this is filed separately because the correct mechanism on POSIX is different (process groups + killpg, not a Job Object).

Repro

Command shape: ["npx", "-y", "@agentclientprotocol/claude-agent-acp@<version>"], spawned via _start_acp_server's asyncio.create_subprocess_exec (no start_new_session=True/preexec_fn=os.setsid), so the whole resulting tree shares the parent agent-server's own PGID/SID.

Resulting live tree (ps -eo pid,ppid,pgid,sid,cmd), all sharing one PGID/SID:

agent-server (python)
 └─ npm exec @agentclientprotocol/claude-agent-acp   <- process.pid (the only PID ever captured)
     └─ sh -c claude-agent-acp
         └─ node .../claude-agent-acp                <- survives
             └─ claude ...                            <- survives

When the conversation is torn down (idle-eviction or explicit delete → ACPAgent.close()_shutdown_runtime()), process.terminate() targets only the top PID. npm's own signal handling tears down its immediate child (sh -c) reliably, but the grandchild node process (and its own claude child) is never referenced anywhere in _shutdown_runtime/ACPAgent/the caller — nothing waits on it, signals it, or enumerates it. It's simply left running once its immediate parent exits.

Note: unlike the assumption in #4409's description ("On POSIX this is mostly masked, because children die on the broken stdio pipe when the parent goes"), that did not hold in this repro — the node leaf keeps running indefinitely (confirmed alive for hours across many real conversations in a production deployment), because the pipe's write end is held by the asyncio parent (the agent-server process itself), not by the immediately-killed child, so the leaf's inherited fd is never actually broken.

Proposed fix (POSIX-specific, small)

  1. At the spawn site (_start_acp_server's create_subprocess_exec call), add start_new_session=True so the whole spawned tree gets its own process group.
  2. At the two teardown sites in _shutdown_runtime (currently process.terminate() / process.kill()), target the process group instead of the bare PID: os.killpg(os.getpgid(process.pid), signal.SIGTERM) (falling back to SIGKILL the same way the existing code already falls back), guarded for ProcessLookupError/PermissionError in case the group is already gone.

Happy to open a PR for this if useful — wanted to file the issue first since #4409 already exists for the Windows side of the same underlying gap and I didn't want to duplicate that discussion, just note the POSIX side needs a different, separate fix.

Environment

  • openhands-sdk 1.44.0 and 1.45.0 (confirmed identical in the relevant code — the gap is present in both; checked via create_subprocess_exec/process.terminate() call sites, no start_new_session/killpg/process-group handling anywhere in acp_agent.py in either version).
  • Linux (WSL2), agent-server --import-modules canvas_ui_tool.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in acp_agent.py at _start_acp_server's asyncio.create_subprocess_exec call and follow _shutdown_runtime's two process termination paths. Verify the POSIX process-group behavior and existing fallback handling, then confirm that closing an ACPAgent terminates the spawned descendants rather than leaving the node and claude processes alive.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
operating-systems
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
76/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.