anthropics / anthropics/claude-agent-sdk-python

Bundled `claude` binary hangs when stdout is not a TTY (pipe / file / launchd / cron / Docker)

Offen
#926 3 Kommentare 0 Reaktionen 0 zugewiesene Personen Auf GitHub ansehen
bug
Vorherrschende Sprache
Python
Sterne
8.1k
Forks
1.3k
Ø Merge
2 T. 31 Min.
Gemergte PRs (30 T.)
1

Beschreibung

**Status:** workaround in place locally; would benefit from upstream fix.
**Reproduced on:** macOS 25.4.0 (Darwin), claude-agent-sdk 0.1.x, 2026-05-05.
**Affected use case:** anything that captures the SDK's output (orchestrators, CI runners, headless launchd / cron jobs, Docker without `-t`).

## Symptom

Any Python script that uses `claude_agent_sdk` hangs indefinitely when its stdout is a pipe or a regular file (i.e., not a terminal). Once the script hits the first `query()` call, the process goes to a sleeping state, makes no progress, and consumes near-zero CPU. No output is ever emitted. The hang persists past arbitrary timeouts.

The same script, run with stdout connected to a TTY, completes normally in expected time.

## Minimal repro

\`\`\`python
# tty_bug.py
import asyncio
from claude_agent_sdk import (
AssistantMessage, ClaudeAgentOptions, ResultMessage, TextBlock, query,
)

async def main():
options = ClaudeAgentOptions(
system_prompt=\"Reply with exactly one word.\",
allowed_tools=[],
thinking={\"type\": \"adaptive\"},
effort=\"low\",
permission_mode=\"bypassPermissions\",
model=\"claude-opus-4-7\",
setting_sources=None,
)
parts = []
async for msg in query(prompt=\"Say 'hello'.\", options=options):
if isinstance(msg, AssistantMessage):
for block in msg.content:
if isinstance(block, TextBlock):
parts.append(block.text)
elif isinstance(msg, ResultMessage):
pass
print(\"response:\", \"\".join(parts).strip())

asyncio.run(main())
\`\`\`

| Invocation | Result |
|---|---|
| \`python tty_bug.py\` | Completes in ~2s |
| \`python tty_bug.py > out.txt\` | **Hangs indefinitely.** Kill required. \`out.txt\` empty. |
| \`python tty_bug.py \\| cat\` | **Hangs indefinitely.** Kill required. |
| \`script -q /dev/null python tty_bug.py > out.txt\` | Completes; output written to file |

\`script -q\` allocates a real PTY, which is then forwarded to its own stdout. The bundled \`claude\` binary inside claude-agent-sdk sees a TTY and proceeds normally.

## Why this matters

This bug bites anyone running the SDK in:

* **An orchestrator / pipeline that captures step output** (subprocess.PIPE).
* **CI / GitHub Actions runners** — they pipe stdout to log aggregators.
* **launchd / cron jobs** — no controlling terminal, stdout redirected to a file via the agent's plist or crontab.
* **Docker containers** without \`-t\`.

In all four cases the symptom is identical: silent hang, no diagnostic output, no error.

A real-world impact data point: a launchd-driven nightly pipeline saw analyze steps time out at 1800s every single tick for 24+ hours. Diagnosis required adding custom logging to the SDK consumer to discover the stream was hanging silently after a \`RateLimitEvent\` was yielded but no further message arrived.

## Suspected mechanism

The bundled \`claude\` binary at \`/lib/python3.x/site-packages/claude_agent_sdk/_bundled/claude\` appears to do an \`isatty()\` check on stdout and behaves differently when not connected to a terminal. Diagnostic confirmation:

* \`script -q /dev/null\` (which gives a PTY) consistently fixes it.
* Setting \`PYTHONUNBUFFERED=1\` does NOT fix it (so it's not a Python-side stdout buffering issue — it's inside the bundled binary).
* Disconnecting stdin (\`< /dev/null\`) does NOT fix it.

Additional issue: when an Anthropic-side rate limit fires, the SDK yields a \`RateLimitEvent\` message and then **the stream hangs indefinitely** instead of erroring or auto-retrying. Combined with the TTY-required behavior, this means a launchd-style consumer sees no output, no error, no progress — just a 1800s timeout.

## Workaround

Wrap the entire SDK-using process in \`script -q /dev/null\`, OR detect \`RateLimitEvent\` in the iterator and abort with a retryable error so the caller's retry-with-backoff handles it.

For an orchestrator using \`asyncio.create_subprocess_exec\`, prepend the wrapper to the command:

\`\`\`python
cmd = [\"/usr/bin/script\", \"-q\", \"/dev/null\", *original_cmd]
proc = await asyncio.create_subprocess_exec(*cmd, ...)
\`\`\`

\`script\` syntax differs between macOS and Linux:
* macOS: \`script -q \`
* Linux: \`script -q -c \"\" \`

## Real fix (upstream)

Either:

1. Don't \`isatty()\` to switch behavior. The SDK should produce the same output regardless of terminal attachment.
2. If terminal-only behavior (progress spinners, ANSI colors) is genuinely useful when interactive, gate it on a \`--non-interactive\` flag, defaulting to non-interactive when \`not isatty(stdout)\`.
3. Surface \`RateLimitEvent\` as a retryable Python exception that terminates the iterator, rather than as a continuing stream that may hang.

Beitragsleitfaden

Für dieses Repository ist kein Beitragsleitfaden indexiert

Bewertung

Dieses Issue wurde noch nicht bewertet.

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.