From 3ae622862d24800b3fb0e5b6086ff04b304e5464 Mon Sep 17 00:00:00 2001 From: SevenX77 Date: Fri, 24 Apr 2026 11:21:19 +0000 Subject: [PATCH] fix(cli-parser): return empty on socket stdin to unblock Claude Code Bash tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CCB CLI `ask` hangs 100% under Claude Code's Bash tool because `_read_optional_stdin` checks only `isatty()` before blocking-reading stdin. Claude Code gives child processes a Unix socket stdin (not TTY, not pipe) that never closes — so the read never returns. Add `stat.S_ISSOCK` early-exit on fd 0. Other stdin types (FIFO, regular file, TTY) keep their original behavior. On fstat OSError, fall through to `read_stdin_text()` per AC5 (no regression). Refs: TD-007 --- lib/cli/parser.py | 8 ++++ test/test_cli_parser.py | 102 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 test/test_cli_parser.py diff --git a/lib/cli/parser.py b/lib/cli/parser.py index fae8e6a4b..59d585cbe 100644 --- a/lib/cli/parser.py +++ b/lib/cli/parser.py @@ -1,5 +1,7 @@ from __future__ import annotations +import os +import stat import sys from typing import Iterable @@ -84,6 +86,12 @@ def parse(self, argv: Iterable[str]) -> ParsedCommand: def _read_optional_stdin(self) -> str: if sys.stdin.isatty(): return '' + try: + mode = os.fstat(0).st_mode + if stat.S_ISSOCK(mode): + return '' # Unix socket stdin (e.g. Claude Code Bash tool) — never closes; don't block on read + except OSError: + pass # Fall through to read_stdin_text on fstat failure (AC5) try: return read_stdin_text() except OSError: diff --git a/test/test_cli_parser.py b/test/test_cli_parser.py new file mode 100644 index 000000000..60a649cd6 --- /dev/null +++ b/test/test_cli_parser.py @@ -0,0 +1,102 @@ +"""TD-007: CLI parser stdin detection tests.""" +from __future__ import annotations +import stat +import sys +from unittest.mock import MagicMock, patch +import pytest + +from cli.parser import CliParser + + +class TestReadOptionalStdin: + """Test _read_optional_stdin socket detection (TD-007).""" + + def test_stdin_socket_returns_empty(self, monkeypatch): + """AC1: Unix socket stdin should return empty without blocking.""" + parser = CliParser() + + # Mock isatty to return False (not TTY) + monkeypatch.setattr(sys.stdin, 'isatty', lambda: False) + + # Mock os.fstat to return socket mode + mock_stat = MagicMock() + mock_stat.st_mode = stat.S_IFSOCK | 0o600 + monkeypatch.setattr('os.fstat', lambda fd: mock_stat) + + # Mock read_stdin_text to verify it's NOT called + mock_read = MagicMock(return_value="should not be read") + monkeypatch.setattr('cli.parser.read_stdin_text', mock_read) + + result = parser._read_optional_stdin() + + assert result == '' + mock_read.assert_not_called() # Should not block on socket + + def test_stdin_fifo_reads_normally(self, monkeypatch): + """AC2: FIFO (pipe) stdin should read normally.""" + parser = CliParser() + + monkeypatch.setattr(sys.stdin, 'isatty', lambda: False) + + mock_stat = MagicMock() + mock_stat.st_mode = stat.S_IFIFO | 0o600 + monkeypatch.setattr('os.fstat', lambda fd: mock_stat) + + monkeypatch.setattr('cli.parser.read_stdin_text', lambda: "piped content") + + result = parser._read_optional_stdin() + + assert result == "piped content" + + def test_stdin_regular_file_reads_normally(self, monkeypatch): + """AC3: Regular file stdin should read normally.""" + parser = CliParser() + + monkeypatch.setattr(sys.stdin, 'isatty', lambda: False) + + mock_stat = MagicMock() + mock_stat.st_mode = stat.S_IFREG | 0o644 + monkeypatch.setattr('os.fstat', lambda fd: mock_stat) + + monkeypatch.setattr('cli.parser.read_stdin_text', lambda: "file content") + + result = parser._read_optional_stdin() + + assert result == "file content" + + def test_stdin_tty_returns_empty_without_checking_mode(self, monkeypatch): + """AC4: TTY stdin should return empty without calling fstat.""" + parser = CliParser() + + # Mock isatty to return True (TTY) + monkeypatch.setattr(sys.stdin, 'isatty', lambda: True) + + # Track if fstat is called + fstat_called = [] + def mock_fstat(fd): + fstat_called.append(True) + return MagicMock() + monkeypatch.setattr('os.fstat', mock_fstat) + + result = parser._read_optional_stdin() + + assert result == '' + assert len(fstat_called) == 0 # fstat should NOT be called for TTY + + def test_fstat_oserror_falls_back_to_read(self, monkeypatch): + """AC5: OSError from fstat should fall back to read_stdin_text.""" + parser = CliParser() + + monkeypatch.setattr(sys.stdin, 'isatty', lambda: False) + + # Mock fstat to raise OSError + def mock_fstat(fd): + raise OSError("fstat failed") + monkeypatch.setattr('os.fstat', mock_fstat) + + # read_stdin_text should still be called as fallback + monkeypatch.setattr('cli.parser.read_stdin_text', lambda: "fallback content") + + result = parser._read_optional_stdin() + + assert result == "fallback content"