Skip to content

update/doctor/init hang indefinitely in interactive terminals — timeout + claude plugin list triggers unrecoverable SIGTTIN stop #213

Description

@jmwest96

Summary:

clagentic-lite update (and any path through doctor/init that calls claude plugin list/install/update) hangs forever when run from a real interactive terminal (confirmed on WSL2 + VS Code integrated terminal, /bin/sh = dash). It reliably reproduces on every run.

Root cause:

Throughout bin/clagentic-lite, plugin subcommands are invoked as:

$DS_TIMEOUT_CMD "$_CLAUDE_PLUGIN_TIMEOUT" claude plugin list 2>/dev/null

(e.g. _doctor_check_plugin_collision, _migrate_legacy_router_plugin, plugin install/update logic around lines 1132, 1428–1507).

GNU timeout, without --foreground, puts the monitored command in a new process group so it can signal the whole group at expiry. claude touches the controlling terminal directly while running in that new, non-foreground process group — which makes the kernel deliver SIGTTIN/SIGTTOU and stop the entire process group (verified via ps -o pgid,tpgid: the timeout/claude pair's PGID never matches the terminal's TPGID).

Because the process is stopped, not merely blocked, it can no longer act on ordinary signals — including the SIGTERM that timeout sends when its 30s clock elapses. That signal just queues undelivered until something sends SIGCONT, so the timeout guard never actually fires and the script hangs indefinitely.

Reproduction:

$ clagentic-lite update
...
[clagentic-lite] checking prerequisites post-update...
...

ps -eo pid,ppid,pgid,tpgid,stat,cmd shows:

PID PPID PGID TPGID STAT CMD
4255 1135 4255 4255 S+ /bin/sh /home/jwest/.local/bin/clagentic-lite update
4298 4255 4298 4255 T timeout 30 claude plugin list
4300 4298 4298 4255 Tl claude plugin list

Note PGID 4298 vs TPGID 4255 — the mismatch that causes the stop.

Note on existing test coverage: scripts/test_doctor_init_claude_plugin_timeout.py (added in the same release that introduced this) drives the script with subprocess.run(..., stdin=subprocess.DEVNULL), which never reproduces this failure mode — with stdin already detached from a tty, claude never touches the controlling terminal, so SIGTTIN can't fire. The test only proves the timeout guard works in a non-interactive/CI-like context,
not in the real interactive-terminal case that's actually breaking.

Confirmed workaround: running with stdin redirected from /dev/null (clagentic-lite update </dev/null) avoids the hang entirely, for the same reason the test suite doesn't reproduce it.

Suggested fix: add --foreground to the timeout/gtimeout invocations wrapping every claude plugin ... call (keeps the child in the shell's own foreground process group), and/or explicitly redirect stdin from /dev/null on those specific calls for defense in depth. Also worth adding a regression test that drives the script with a real pty (e.g. via pty.fork()) rather than stdin=DEVNULL, since that's the only way to catch this class of
bug.

Environment: WSL2 (Linux 6.18.33.2-microsoft-standard-WSL2), /bin/sh = dash, claude CLI on PATH, clagentic-lite installed at ~/.clagentic/lite.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions