Skip to content

fix(agent-server): resolve #728 drain-deadlock — sweep orphans before pipe close, bound all close paths - #1718

Open
nyasour wants to merge 11 commits into
Abilityai:devfrom
44-pixels:fix/drain-deadlock-cgroup-sweep-before-close
Open

fix(agent-server): resolve #728 drain-deadlock — sweep orphans before pipe close, bound all close paths#1718
nyasour wants to merge 11 commits into
Abilityai:devfrom
44-pixels:fix/drain-deadlock-cgroup-sweep-before-close

Conversation

@nyasour

@nyasour nyasour commented Jul 21, 2026

Copy link
Copy Markdown

Problem

When an agent has a persistent MCP connection (a remote streamable-HTTP MCP server, native type:http or via an npx mcp-remote stdio bridge), claude --print completes its work — the MCP tool calls succeed (the remote returns HTTP 200s) — but a setsid'd grandchild (or the live connection) keeps claude's stdout write-end open past SIGKILL. Every such execution then hangs the drain, gets SIGKILLed after the 90s budget, discards the model's already-produced output (empty result), and trips the agent circuit breaker. With an empty .mcp.json it works fine, which is what pins the trigger to a lingering pipe holder.

Observed signature (base image 0.8.0):

[Subprocess] Reader thread(s) still busy after process exit ... killing process group
[Subprocess] Drain budget (90s) exceeded — safe_close_pipes may have deadlocked with reader thread's TextIOWrapper lock ... Issue #728.
[Subprocess] #1502: SIGKILLed the process group ...
[METRIC] drain_outcome ... outcome=leaked ... leaked_count=1

Root cause

A lock-ordering deadlock in drain_reader_threads:

  1. The stdout reader thread is parked in a blocking readline(), holding CPython's TextIOWrapper buffer lock.
  2. safe_close_pipes()TextIOWrapper.close() was called synchronously in the async coroutine; close() needs that same lock → deadlock.
  3. kill_cgroup_orphans() — the only thing that can SIGKILL the pipe-holder so the reader hits EOF and releases the lock — ran in a finally after the deadlocked close, so it never fired.

Fix

  • drain_reader_threads: run the cgroup orphan sweep before the force-close (holder dies → reader EOFs → lock released → close is a fast no-op), and bound the close itself via _bounded_safe_close_pipes (asyncio.wait_for + asyncio.to_thread). The finally backstop sweep and the bug: drain_reader_threads closes stdout pipe before reader can drain backlog — silently loses final result line on long agentic tasks #531 natural-drain-before-kill ordering are preserved.
  • HeadlessRunContext.terminate() (second deadlock-exposed site): switched from asyncio.run(_bounded_safe_close_pipes(...)) to a new sync _bounded_safe_close_pipes_sync (daemon thread + join(timeout)). asyncio.run()'s loop teardown joins the leaked non-daemon to_thread worker for up to THREAD_JOIN_TIMEOUT (300s on CPython 3.13), silently defeating the 5s bound and pinning a shared executor thread — a daemon thread is never joined at teardown, so the true bound holds.
  • claude_code.py / codex_runtime.py: their await loop.run_in_executor(None, _safe_close_pipes, process) calls were fully unbounded (never resolve if the escapee is alive). Routed through the bounded async helper (5s).

Tests

tests/unit/test_subprocess_pgroup.py:

  • A fork-based test where a setsid'd grandchild holds stdout open for 20s (past post_kill_grace) — the exact condition existing tests never covered. FAILS on pre-fix code (drain took 20.01s), PASSES post-fix (~1s).
  • TestBoundedSafeClosePipes — the sync + async bounded helpers each return within their timeout under a genuinely-wedged safe_close_pipes.

pytest tests/unit/test_subprocess_pgroup.py tests/unit/test_drain_bounded.py tests/unit/test_headless_executor_pipe_drop.py tests/unit/test_headless_executor_970_timeout.py tests/unit/test_codex_runtime.py -q167 passed, 1 skipped (pre-existing Linux-only skip; ran on macOS).

Context

Closes the gap behind the #1502 band-aid; addresses the deadlock tracked as #728 and reported in discussion #1696.

Deferred follow-ups (kept out to stay focused)

  • Add the cgroup sweep at the claude_code/codex_runtime close sites (they currently only pgid-kill before closing).
  • A JSONL-recovery safety net so output is never lost even on a pathological drain.

🤖 Generated with Claude Code

Eugene Vyborov and others added 11 commits July 11, 2026 10:21
…p (f5d69be)

Public main's enterprise gitlink referenced 630cca9 — the tip of the
feature/78-portal-history-hardening branch (portal voice mode, ElevenLabs
TTS + Scribe STT), which was added AFTER the Abilityai#106 umbrella squash-merged to
enterprise main and was never itself landed on the enterprise trunk.

That left main pointing at a dangling, off-trunk enterprise commit and
inverted vs dev (dev=f5d69be is on enterprise main; main=630cca9 was ahead).
This reverts main to f5d69be — the enterprise-main tip that already carries
the full Client Portal (Abilityai#79 exposure seam, Abilityai#104 roster/sessions/chat, Abilityai#106
history hardening + chat sessions + cross-chat search) minus voice mode.

Voice mode stays on its feature branch, unshipped, per decision to hold it.
dev already points at f5d69be, so this restores dev == main on a valid
on-trunk pointer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pages

One page per topic (getting started, agents, chat/sessions, credentials,
scheduling, collaboration, channels, MCP/API, operations, sharing,
deployment, security, advanced, troubleshooting) plus a generated
question index. Answers derived from user docs and verified against
code; .claude pointer picks up the generate-user-docs FAQ step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… authentication

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-submodule-pointer

fix(enterprise): align main's submodule pointer to on-trunk f5d69be (revert dangling 630cca9)
Create research/ as the home for Ability AI's open research work:
- Quantum-Steered Cognition (qrng-agent-consciousness-whitepaper, 2026-07-14)
- A Measurably Self-Improving Multi-Agent Forecasting System
  (closed-loop-forecasting, 2026-07-11)

Each paper lives in its own subfolder as a dated PDF of the final
manuscript; README.md carries the index and naming conventions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n-papers-main

docs(research): add open research folder with first two papers (main)
Links the new animated architecture explainer (https://youtu.be/XDLOq1crF9w)
in the README "Watch more" list and at the top of the Platform Overview
section of docs/user-docs/videos.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
docs: add llms.txt for agent-native discovery
…plainer-video-main

docs: add architecture explainer video to README and video library (main)
…ns before pipe close, bound all close paths

When an agent has a persistent MCP connection, `claude --print` completes its
work but a setsid'd grandchild (or the connection) keeps its stdout write-end
open past SIGKILL. The stdout reader thread is parked in a blocking readline()
holding CPython's TextIOWrapper buffer lock, so a synchronous safe_close_pipes()
-> TextIOWrapper.close() deadlocks on that same lock. Worse, kill_cgroup_orphans()
— the only thing that can kill the pipe-holder and let the reader hit EOF — ran
in a finally block AFTER the deadlocked close, so it never executed. Result: the
drain coroutine never returned, the 90s budget was exhausted, the process was
SIGKILLed, the reader thread leaked, and the model's already-produced output was
discarded (empty result + circuit-breaker trip), even though the MCP tool calls
had succeeded.

Fixes:
- drain_reader_threads: run the cgroup orphan sweep BEFORE the force-close (so
  the pipe-holder dies, the reader EOFs and releases the lock, and the close is a
  fast no-op), and bound the close itself via _bounded_safe_close_pipes
  (asyncio.wait_for + asyncio.to_thread). Keeps the finally backstop sweep and the
  Abilityai#531 natural-drain-before-kill ordering.
- HeadlessRunContext.terminate(): the second deadlock-exposed call site. Use a new
  sync _bounded_safe_close_pipes_sync (daemon thread + join(timeout)) instead of
  asyncio.run(_bounded_safe_close_pipes(...)) — the latter's loop teardown joins
  the leaked non-daemon to_thread worker for up to THREAD_JOIN_TIMEOUT (300s on
  CPython 3.13), silently defeating the 5s bound and pinning a shared executor
  thread.
- claude_code.py / codex_runtime.py: their `await loop.run_in_executor(None,
  _safe_close_pipes, process)` calls were fully unbounded (never resolve if the
  escapee is alive). Route through the bounded async helper (5s).

Tests (tests/unit/test_subprocess_pgroup.py): a fork-based test where a setsid'd
grandchild holds stdout open for 20s (past post_kill_grace) — FAILS on pre-fix
code ("drain took 20.01s"), PASSES post-fix (~1s); plus TestBoundedSafeClosePipes
covering the sync + async bounded helpers under a genuinely-wedged close.

Related: closes the gap behind the band-aid in Abilityai#1502; addresses the deadlock
tracked as Abilityai#728 (and reported in discussion Abilityai#1696).

Follow-ups (not in this PR, to keep it focused): add the cgroup sweep at the
claude_code/codex_runtime close sites too; a JSONL-recovery safety net so output
is never lost even on a pathological drain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vybe
vybe changed the base branch from main to dev July 22, 2026 08:23
@vybe

vybe commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Retargeted `main` → `dev` (per SDLC, feature/fix PRs land on `dev`; `main` is release-cut only).

That retarget surfaces a conflict, but only from branch history — the branch was cut from `main`, which has diverged from `dev` (124 commits ahead / 11 behind). The change itself applies cleanly:

Could you rebase onto `dev` and force-push? I could not do it for you — the fork is org-owned, so GitHub's maintainer-push does not apply and I get a 403.

```bash
git fetch upstream dev && git rebase upstream/dev && git push --force-with-lease
```

Everything else validated: required checks green, security scan clean, closing keyword present (`resolve #728` → auto-promotes on merge), tests cover the non-happy paths.

@github-actions

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

@AndriiPasternak31 AndriiPasternak31 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validated via /validate-pr. The engineering here is the strongest thing I've reviewed this week — the root-cause analysis is correct and the test is a real reproduction, not a mock. Requesting changes on process only; I have no objection to the fix itself.

What holds up

The lock-ordering diagnosis is right. safe_close_pipes() needs the same TextIOWrapper buffer lock the reader thread holds while parked in a blocking read, so calling it synchronously while the pipe-holder is still alive deadlocks the coroutine — and a deadlocked coroutine never reaches the trailing finally, which is why the #817 sweep that could have freed the lock never fired. Moving _sweep_cgroup_orphans() ahead of the force-close in subprocess_pgroup.py attacks the actual causal chain rather than widening a timeout.

The asyncio.run() catch in HeadlessRunContext.terminate() is the kind of thing that normally ships undetected: loop.shutdown_default_executor() joins the leaked non-daemon to_thread worker for up to THREAD_JOIN_TIMEOUT (300s on CPython 3.13), so the "5s bound" would have been a 5-minute bound in exactly the pathological case it exists for. Using a daemon thread in _bounded_safe_close_pipes_sync is the correct escape.

test_drained_via_sweep_before_close_when_grandchild_survives_entire_window is the test the previous fixes in this lineage were missing — a real setsid()'d grandchild outliving the entire drain window, failing pre-fix at 20.01s and passing post-fix in ~1s, with the sweep stubbed only to stay cross-platform while still performing a real SIGKILL of the real holder. The #1502 and #1661 follow-ups exist because the earlier tests couldn't reach this branch.

Blocking

1. The PR closes no open issue. The closing keyword targets #728, which is closed and is a different bug (agent-server.py spins at 90% CPU on OAuth token auth failure). The whole lineage is closed — #728#1502#1661 — and #1661 is titled "#728 fix incomplete", which is what this PR is actually resolving. As written the keyword is a no-op and the work traces to nothing open. Please reopen #1661 (or file a fresh bug for this deadlock) and re-point the reference at it, so the status automation and the release notes pick it up.

2. No CI has run. gh pr checks reports no checks at all on this branch — this is a fork PR, so the workflows need a maintainer to approve the run. A change to the agent base-image runtime can't merge on a local test run alone. A maintainer will need to approve the workflow; given docker/base-image/** is touched, this also wants a /verify-local pass with the agent stage (base image rebuild + real agent boot), not just the backend stages.

Non-blocking

3. The async helper still pins a shared pool thread. _bounded_safe_close_pipes uses asyncio.to_thread, so on timeout it abandons a non-daemon worker from the loop's default ThreadPoolExecutor — the exact hazard you correctly avoided in the sync variant. The inline comment argues it's harmless because the app loop is long-lived and never torn down, and that's true for the bound; the cost is different. That pool is capped at min(32, cpu_count + 4) and is shared process-wide with every other to_thread/run_in_executor(None, ...) caller in the agent server — the git auto-sync maintenance cycle (#1595) among them. So each wedge permanently consumes a slot from a small shared pool, and the failure mode isn't a leaked thread, it's unrelated subsystems stalling once the pool is exhausted. A leaked reader thread is a plain thread and costs nothing shared; this one isn't. Routing both helpers through the same daemon-thread primitive would make the async path leak as cheaply as the sync one.

4. Diff display is misleading. GitHub renders this as 32 files / +2035, including the .claude and src/backend/enterprise submodule pointers and two research PDFs. That's a stale merge-base artifact from the branch being cut off main before those commits reached dev — verified locally, the true delta against current dev is 7 files / +482. A rebase onto dev would make the diff reviewable at a glance and is worth doing before this gets more eyes.

Happy to re-review as soon as the issue reference points somewhere open and CI has actually run.

AndriiPasternak31 pushed a commit that referenced this pull request Aug 2, 2026
…r as the error cause (#1849)

Mirror the CLI's own consumer: drop `[ede_diagnostic]` entries from
`result.errors`, join the rest. Applied at BOTH `errors[0]` reads — the
pre-existing max_turns branch (#361) reads the same array from the same
emitter and has the same defect shape.

When a result carries no real cause at all, the diagnostic's key=value
payload is kept as clearly-labelled context (minus the `[ede_diagnostic]`
token, which is unsearchable against this repo and misdirects investigators)
rather than dropped — nothing else on this path is recorded. It also stays in
the existing WARNING log; not DEBUG, since the agent server is pinned to INFO
and DEBUG would emit nothing.

The `#1673:` comment block is EXTENDED, not replaced — a sibling test asserts
only `"#1673" in src`.

Also fixes four latent defects in the same two expressions:
  - a malformed `errors` shape (a dict, an int, a bool) made `errors[0]` RAISE
    inside the parser, and the swallowed raise fell through to the #160
    `context: fork` placeholder — HTTP 200, status=success;
  - a bare-string `errors` was indexed character-by-character ("boom" -> "b");
  - a blank/None entry could join to "" and re-introduce the empty
    error_message #1673 fixed;
  - a leading newline slipped a marker past the prefix test.

No new imports; no change to headless_executor / jsonl_recovery / claude_code
(#1870 / #1853 / PR #1718 territory) or to the backend predicate, which is the
acceptance criterion rather than the fix site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vybe added a commit that referenced this pull request Aug 6, 2026
…r as the error cause (#1849) (#1938)

* test(agent-server): failing regression tests for the [ede_diagnostic] header (#1849)

TDD red. 24 cases (19 test functions, one parametrized 5 ways) driving the
real parser, the real 502 finalizer, and the REAL backend predicate
(routers/sessions.py::_is_resume_not_found, loaded via spec_from_file_location
rather than `from routers.sessions import ...` — the package import raises
ImportError under CI's randomized seeds).

18 fail against unmodified source, all behaviourally:
  - errors[0] is Claude Code's own [ede_diagnostic] header, so the marker wins
    over the real cause at errors[1..];
  - the acceptance test proves impact #3 (resume self-healing defeated) is
    real, not inferred: _is_resume_not_found(detail) is False today;
  - a malformed `errors` shape RAISES inside the parser (KeyError: 0 for a
    dict, TypeError for an int/bool) — the swallowed raise lands as HTTP 200
    success;
  - a bare-string errors is indexed character-by-character ("boom" -> "b").

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(agent-server): never surface Claude Code's [ede_diagnostic] header as the error cause (#1849)

Mirror the CLI's own consumer: drop `[ede_diagnostic]` entries from
`result.errors`, join the rest. Applied at BOTH `errors[0]` reads — the
pre-existing max_turns branch (#361) reads the same array from the same
emitter and has the same defect shape.

When a result carries no real cause at all, the diagnostic's key=value
payload is kept as clearly-labelled context (minus the `[ede_diagnostic]`
token, which is unsearchable against this repo and misdirects investigators)
rather than dropped — nothing else on this path is recorded. It also stays in
the existing WARNING log; not DEBUG, since the agent server is pinned to INFO
and DEBUG would emit nothing.

The `#1673:` comment block is EXTENDED, not replaced — a sibling test asserts
only `"#1673" in src`.

Also fixes four latent defects in the same two expressions:
  - a malformed `errors` shape (a dict, an int, a bool) made `errors[0]` RAISE
    inside the parser, and the swallowed raise fell through to the #160
    `context: fork` placeholder — HTTP 200, status=success;
  - a bare-string `errors` was indexed character-by-character ("boom" -> "b");
  - a blank/None entry could join to "" and re-introduce the empty
    error_message #1673 fixed;
  - a leading newline slipped a marker past the prefix test.

No new imports; no change to headless_executor / jsonl_recovery / claude_code
(#1870 / #1853 / PR #1718 territory) or to the backend predicate, which is the
acceptance criterion rather than the fix site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(agent-server): record the [ede_diagnostic] fix and its bug class (#1849)

- tests/registry.json: entry for tests/unit/test_1849_ede_diagnostic_filtered.py.
- learnings.md: new entry for the durable class — a cross-surface contract
  carried by third-party FREE TEXT has no schema and breaks silently when the
  vendor reformats. Broken twice in six weeks (#1673 -> #1849), green CI both
  times, because no test drove producer -> transforms -> the real consumer
  predicate. Also covers the "raise inside a swallowing parser is a false
  success" and "mirror a vendor's own consumer-side filter" corollaries.
- parallel-headless-execution.md: revision-history row, AND a fix to the stale
  "How Errors Are Classified -> Source 1" section, which quoted pre-#1673 code
  and attributed it to claude_code.py — a file that has not owned this since
  the #122 extraction. Appending a row above a wrong snippet would leave the
  doc self-contradictory, and that snippet is exactly what a future contributor
  reads before "simplifying" the helper.
- session-tab.md: hazard clause on the resume-fallback step. The contract is
  restored, not changed — but it was documented without noting that it depends
  on an agent-side free-text string surviving filter -> join -> sanitize ->
  [:300], which is why it has silently broken twice.

No requirements/ or architecture.md change: this is a bug fix (Rule #4), and
architecture.md documents nothing about result.errors parsing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct the #1849 test count (19 tests / 23 cases, not 24)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(docs,tests): correct the AUTH_INDICATORS UUID false-positive rate to ~1.73% (#1849)

The plan cited 1.09% "measured over 200,000 UUIDs". Re-measured here over
500,000 `uuid4()`s: 1.727% contain a bare "401" or "403" (0.879% / 0.854%
individually) — ~1 in 58, not ~1 in 92. uuid4 pins '4' at string index 14,
which lifts the rate above the naive 30-position estimate. The fixed prose
around the UUID ("Execution error: No conversation found with session ID: ")
contributes no match, so the UUID is the only source.

Material because this number is the whole cost argument for shipping #1849
before the `\b401\b` word-boundary hardening — it belongs in the PR body and
release notes correct.

Also: assert the WHOLE diagnostic payload survives, not the "stop_reason=null"
fragment (uses the previously-unreferenced _MARKER_PAYLOAD constant).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* review: correct the max_turns SUB-003 disclosure and two known-bad pins (#1849)

Four review corrections, no behaviour change.

1. Disclosure defect (the substantive one). The plan justified joining at the
   max_turns branch with "verified safe: max_turns text lands only in an
   informational 422 and is substring-matched by nothing". That is wrong:
   `classify_switch_failure` returns "auth" for ANY >=400 body that trips
   `is_auth_failure`, and the `except httpx.HTTPError` handler calls
   `is_auth_failure(error_msg)` independently of status code — so the 422
   detail is matched at the same two sites as the 502. The R1 exposure
   therefore covers BOTH branches. Practically inert (a max_turns result with
   real `errors[]` entries is unobserved; marker-only/absent still yields the
   fixed "Task stopped after N turns"), but a spurious switch there re-runs a
   turn that legitimately hit its limit, so the misfire costs tokens rather
   than a fast-failing duplicate. Recorded in the feature-flow row, since the
   whole R1 disposition rests on the exposure being measured and disclosed.

2. `errors[0]` did NOT raise on all four malformed shapes. Verified against
   the pre-fix expression: dict/int/bool raise; an explicit `null` falls
   through the `if errors` guard to the old bare literal, and a nested list
   assigns a raw `list` into the str-typed `error_message` field. The commit
   message and the flow row already said "a dict, an int, a bool"; the test
   file's docstrings said "a nested list" and "on a non-sequence raises".
   Corrected so the artifact matches what was measured.

3. The casefold/fixed-length-slice interaction is now stated as an invariant
   instead of left to be re-derived: full case folding never shortens a
   string, so the 16 folded chars came from at most 16 original ones and the
   slice starts at or after the real payload — the token cannot leak. Worst
   case, confirmed with a "st"-ligature marker, is one dropped payload
   character on input Claude Code cannot emit.

4. `test_multi_error_truncation_limit_is_known` pins a KNOWN-BAD residual, and
   its failure message read as if the failure were the regression. It now says
   outright that a failure means truncation was FIXED, names the flip, and
   asserts the marker really is truncated away so the pin can't pass
   vacuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: trinity-ability <trinity@ability.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants