Headless `claude -p` hangs indefinitely at startup (no transcript, no output) — suspected untimed bg-daemon spare-worker handshake under memory pressure

Open 💬 0 comments Opened Jul 8, 2026 by ignatovv

Summary

Headless claude -p launched from a launchd agent at 02:00 hung at process startup for 4 hours on two consecutive nights (until an external watchdog killed it). The process produced zero stdout/stderr, never created a session transcript in ~/.claude/projects/<project>/, never started any MCP server, and wrote no file under ~/.claude at all. A healthy run of the same command writes its transcript within ~8 seconds.

Strong circumstantial evidence points at the background daemon's spare-worker handshake blocking with no timeout when the daemon is under memory pressure.

Environment

  • Claude Code 2.1.202 (first hang night) and 2.1.203 (second hang night); native build at ~/.local/share/claude/versions/<v>, launched via the ~/.local/bin/claude symlink
  • macOS, Apple Silicon, Darwin 25.5.0
  • Subscription (OAuth) auth, credentials in the macOS Keychain
  • Invocation (from a launchd agent, ProcessType: Background):
export CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0
claude -p "<prompt>" --model opus --permission-mode auto --settings .claude/settings.nightly.json

What happened

Nightly unattended maintenance job (launchd StartCalendarInterval 02:00). On 2026-07-07 and 2026-07-08:

  • launchd started the wrapper on time; the wrapper's own logging shows normal progress up to spawning claude -p.
  • claude stayed alive for the full 4h budget with zero output, then was SIGTERM'd by the wrapper's cap (rc=143).
  • No session .jsonl was created for either run, no MCP logs were written, and find ~/.claude -newermt <window> shows the process touched nothing. So it blocked in the first seconds of startup, before session creation.

Why I suspect the daemon spare-claim

~/.claude/daemon.log for the first hang night:

[2026-07-07T01:00:59Z] [bg] bg settled 5cbc7b79 (done)
[2026-07-07T01:01:59Z] [bg] bg: low memory persists after shedding non-pinned — retiring pinned settled workers as a last resort
... (the same line every 60s, all night) ...
[2026-07-07T05:00:59Z] [bg] bg: low memory persists after shedding non-pinned — retiring pinned settled workers as a last resort

The hung claude -p was launched at 01:00:11Z and killed at 05:01:01Z. The daemon's low-memory shedding loop starts ~2 minutes after the launch and stops the minute the process is killed - it brackets the hung process exactly. My read: the -p process asked the daemon for a worker, the daemon was refusing/retiring workers due to memory pressure, and the client side waited on the handshake indefinitely.

The daemon itself was otherwise healthy both nights (it completed its proactive OAuth refresh minutes before each hang).

What I ruled out

  • Not the keychain: login keychain has no timeout/lock-on-sleep, and the daemon (same binary version) read credentials fine minutes earlier.
  • Not signing/Gatekeeper: binary is Developer ID signed (Team Q6L2SF6YDW) and the daemon had been executing the same binary for hours.
  • Not the launchd context per se: the identical command run under a one-shot launchd agent (same minimal PATH, same ProcessType: Background) completes in ~6s during the day.
  • Not the settings/permission flags: same flags pass from a terminal on the same versions.

I could not reproduce in daytime conditions (memory pressure had cleared). I've added a client-side watchdog (kill + retry if no transcript appears within 3 minutes) and CLAUDE_CODE_DAEMON_COLD_START=1 to the nightly job; if it fires again it will capture a sample(1) stack of the hung process, which I can attach here.

Expected

claude -p should apply a timeout to any startup IPC with the shared daemon (and fall back to a cold start), so a headless run can never hang indefinitely before doing any work. An unattended -p invocation has no user watching it; silent infinite waits are worst-case there.

View original on GitHub ↗