You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When agent-server spawns a claude --print headless task, the subprocess's stdout pipe is inherited by every child process claude spawns — most consequentially the long-lived MCP server processes (npm exec moltbook-mcp, npm exec @peng-shawn/mermaid-mcp-server, npm exec aistudio-mcp-server, etc.). When claude exits, those children keep the pipe's write end open, so the agent-server's stdout reader thread blocks on a read() waiting for an EOF that never arrives. The pgroup-kill / pipe-close unwind code logs 1 reader thread(s) leaked … continuing anyway, but the leaked thread never exits — it accumulates. After enough failed/empty-result/long-running tasks, the agent-server's HTTP-handling thread pool starves and stops responding to all requests (including /health), wedging the entire agent until restarted.
This is the underlying root cause of the failures classified by #516/#517 (signal-kill 504) and #520/#521 (clean-exit empty-result 502). Those PRs made the symptoms honest — return the right HTTP status, log the right message — but neither prevents the leak. Every cron tick that hits the failure path adds another stuck thread, and eventually the operator sees the UI hang for ~5 minutes (per-agent calls timing out) and has to restart the affected agent container.
P2 — workaround exists (restart agent container; documented in #517 and #521 follow-ups), but the leak is silent and recurring on every agent that uses MCP servers and runs heavy enough headless tasks. UI symptoms only appear after enough threads accumulate, which makes the connection between the leak and the wedge non-obvious for operators.
Error
In agent-server logs, repeated for every failed/empty-result task:
[Subprocess] Reader thread(s) stuck after process exit (pid=NNNNN, stuck_count=1)
— killing process group and closing pipes to unwind
[Subprocess] 1 reader thread(s) leaked for pid=NNNNN after close+killpg; continuing anyway
[Headless Task] Error reading stdout: I/O operation on closed file.
The thread is "closed" from the agent-server's POV (file handle replaced) but the OS-level read syscall is still blocked because the pipe's write end is still open in a different process (an MCP child). When the leak count crosses some threshold (observed: dozens of accumulated stuck threads over days), the agent-server's HTTP thread pool exhausts:
/health requests stop being served (5+ second timeout, returns no response)
Backend's services.agent_client opens its circuit breaker on the agent
Trinity UI requests that fan out to per-agent endpoints (timeline, dashboards, agent detail) hang waiting on the now-unreachable agent
Cumulative effect: UI loads take 5+ minutes per page, agent appears "down"
The agent-server process itself is alive and S (sleeping) on a futex — not crashed, just out of free threads
Location
File: docker/base-image/agent_server/services/claude_code.py — subprocess spawn site
POSIX file descriptors are inherited across fork() + execve() by default. When agent-server spawns claude via asyncio.create_subprocess_exec(stdout=PIPE, ...), Python wires the child end of the pipe into claude's stdout. claude then spawns its own subprocesses (Node MCP servers, sometimes shells) without explicitly closing inherited fds — meaning the same pipe write end is duplicated into every grandchild.
When claude exits cleanly (or is signal-killed), the kernel closes claude's copy of the pipe. But it does not close the copies held by the MCP grandchildren. The pipe's reference count remains > 0, so the read end never sees EOF. The agent-server's reader thread sits in a blocking read() on that fd indefinitely.
subprocess_pgroup's "killpg + close pipes" recovery path closes the file handle in the agent-server process, but the underlying kernel fd is still held by the read syscall, which won't return until either:
The MCP child also closes/exits (could be never — MCP servers are designed to be long-lived), or
The thread is forcibly killed (which Python can't do safely), or
The agent-server process itself dies
Hence the "leaked" log message and the slow accumulation.
Reproduction Steps
Configure an agent with at least one MCP server that spawns a long-lived process (the standard Trinity agents with the moltbook / mermaid / aistudio MCP servers all qualify).
Schedule a heavy headless task (anything with claude --print --output-format stream-json --verbose against a non-trivial skill).
Observe in agent-server logs: Reader thread(s) stuck after process exit … 1 reader thread(s) leaked.
Repeat the failing schedule a few dozen times (or just leave a broken cron in place for a day or two).
Observe: agent-server eventually stops responding to /health (timeout, no response). Backend's circuit breaker opens. Trinity UI views that depend on per-agent calls take 5+ minutes to render.
Confirm the agent process is alive but blocked: cat /proc/<pid>/wchan shows futex_wait_queue, status S, threads = (small number, but the thread pool internal to the HTTP server is exhausted).
Restart the agent container — everything recovers immediately. Repeat from step 3.
Suggested Fix
Two complementary changes; the first is the actual fix, the second is defense in depth.
Primary: prevent fd inheritance on the stdout pipe
Set FD_CLOEXEC on the agent-server's end of the stdout pipe before spawning claude (so Python's pipe still works in claude itself, but anything claude spawns via fork+exec won't inherit the duplicate write end). Pseudo-fix:
# In claude_code.py, around the asyncio.create_subprocess_exec call:importfcntl# Create the pipe ourselves so we control the flags.read_fd, write_fd=os.pipe()
# Set close-on-exec on the WRITE end — this is the one claude's grandchildren# would otherwise inherit. claude itself uses it via stdin/stdout dup,# which dup2 strips CLOEXEC — so claude's own stdout still works,# but its forked-then-exec'd children won't carry the fd into their address space.flags=fcntl.fcntl(write_fd, fcntl.F_GETFD)
fcntl.fcntl(write_fd, fcntl.F_SETFD, flags|fcntl.FD_CLOEXEC)
proc=awaitasyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.DEVNULL,
stdout=write_fd, # claude writes here (CLOEXEC stripped by dup2)stderr=asyncio.subprocess.PIPE,
...,
)
os.close(write_fd) # parent doesn't need it; only the read_fd matters now# Wrap read_fd as the asyncio reader as usual
Note: standard asyncio.create_subprocess_exec(stdout=PIPE) does not set CLOEXEC on the parent's pipe ends in a way that protects against this, because dup2 clears CLOEXEC during the child's stdout setup. The fix has to happen on the explicit pipe before the spawn.
Defense in depth: thread-pool sizing + observability
Bound the number of leaked reader threads agent-server tolerates before raising a startup-style health-check failure (so the wedge surfaces as a hard fail instead of silent UI hangs).
Expose a metric (agent_server_leaked_reader_threads) so operators can alert on accumulation before exhaustion.
Add a unit test: spawn a fake claude that itself spawns a child holding stdout open, then have the parent exit; assert that the agent-server reaper closes cleanly within N seconds and does not leak the reader thread.
Affected: every agent runtime that (a) uses MCP servers as long-lived child processes and (b) hits the failed-headless-task code path frequently. In practice: most production agents with non-trivial skills.
Summary
When
agent-serverspawns aclaude --printheadless task, the subprocess's stdout pipe is inherited by every child process claude spawns — most consequentially the long-lived MCP server processes (npm exec moltbook-mcp,npm exec @peng-shawn/mermaid-mcp-server,npm exec aistudio-mcp-server, etc.). When claude exits, those children keep the pipe's write end open, so the agent-server's stdout reader thread blocks on aread()waiting for an EOF that never arrives. The pgroup-kill / pipe-close unwind code logs1 reader thread(s) leaked … continuing anyway, but the leaked thread never exits — it accumulates. After enough failed/empty-result/long-running tasks, the agent-server's HTTP-handling thread pool starves and stops responding to all requests (including/health), wedging the entire agent until restarted.This is the underlying root cause of the failures classified by #516/#517 (signal-kill 504) and #520/#521 (clean-exit empty-result 502). Those PRs made the symptoms honest — return the right HTTP status, log the right message — but neither prevents the leak. Every cron tick that hits the failure path adds another stuck thread, and eventually the operator sees the UI hang for ~5 minutes (per-agent calls timing out) and has to restart the affected agent container.
Component
Agent Runtime (
agent_server.services.claude_codesubprocess spawn;agent_server.utils.subprocess_pgroupreader threads)Priority
P2 — workaround exists (restart agent container; documented in #517 and #521 follow-ups), but the leak is silent and recurring on every agent that uses MCP servers and runs heavy enough headless tasks. UI symptoms only appear after enough threads accumulate, which makes the connection between the leak and the wedge non-obvious for operators.
Error
In agent-server logs, repeated for every failed/empty-result task:
The thread is "closed" from the agent-server's POV (file handle replaced) but the OS-level read syscall is still blocked because the pipe's write end is still open in a different process (an MCP child). When the leak count crosses some threshold (observed: dozens of accumulated stuck threads over days), the agent-server's HTTP thread pool exhausts:
/healthrequests stop being served (5+ second timeout, returns no response)services.agent_clientopens its circuit breaker on the agentS (sleeping)on a futex — not crashed, just out of free threadsLocation
docker/base-image/agent_server/services/claude_code.py— subprocess spawn sitedocker/base-image/agent_server/utils/subprocess_pgroup.py— pgroup-kill + reader thread cleanup (where "1 reader thread(s) leaked … continuing anyway" originates)Root Cause
POSIX file descriptors are inherited across
fork()+execve()by default. Whenagent-serverspawnsclaudeviaasyncio.create_subprocess_exec(stdout=PIPE, ...), Python wires the child end of the pipe into claude's stdout. claude then spawns its own subprocesses (Node MCP servers, sometimes shells) without explicitly closing inherited fds — meaning the same pipe write end is duplicated into every grandchild.When claude exits cleanly (or is signal-killed), the kernel closes claude's copy of the pipe. But it does not close the copies held by the MCP grandchildren. The pipe's reference count remains > 0, so the read end never sees EOF. The agent-server's reader thread sits in a blocking
read()on that fd indefinitely.subprocess_pgroup's "killpg + close pipes" recovery path closes the file handle in the agent-server process, but the underlying kernel fd is still held by the read syscall, which won't return until either:Hence the "leaked" log message and the slow accumulation.
Reproduction Steps
claude --print --output-format stream-json --verboseagainst a non-trivial skill).Reader thread(s) stuck after process exit … 1 reader thread(s) leaked./health(timeout, no response). Backend's circuit breaker opens. Trinity UI views that depend on per-agent calls take 5+ minutes to render.cat /proc/<pid>/wchanshowsfutex_wait_queue, statusS, threads = (small number, but the thread pool internal to the HTTP server is exhausted).Suggested Fix
Two complementary changes; the first is the actual fix, the second is defense in depth.
Primary: prevent fd inheritance on the stdout pipe
Set
FD_CLOEXECon the agent-server's end of the stdout pipe before spawning claude (so Python's pipe still works in claude itself, but anything claude spawns via fork+exec won't inherit the duplicate write end). Pseudo-fix:Note: standard
asyncio.create_subprocess_exec(stdout=PIPE)does not set CLOEXEC on the parent's pipe ends in a way that protects against this, becausedup2clears CLOEXEC during the child's stdout setup. The fix has to happen on the explicit pipe before the spawn.Defense in depth: thread-pool sizing + observability
agent_server_leaked_reader_threads) so operators can alert on accumulation before exhaustion.Environment
0b3bb58(post fix(agent): classify signal-killed claude exits as 504, not fake auth failure (#516) #517 + fix(agent): classify clean-exit empty-result as 502, not silent success (#520) #521); the underlying bug pre-exists both fixes — they classify the symptom honestly but neither prevents the leak.Related
docker/base-image/agent_server/services/claude_code.pydocker/base-image/agent_server/utils/subprocess_pgroup.py