diff --git a/src/copilot_usage/cli.py b/src/copilot_usage/cli.py index 858eb06..290f048 100644 --- a/src/copilot_usage/cli.py +++ b/src/copilot_usage/cli.py @@ -215,13 +215,15 @@ def _read_line_nonblocking(timeout: float = 0.5) -> str | None: """Return a line from stdin if available within *timeout*, else ``None``. Uses ``select.select`` to poll stdin. Raises :class:`ValueError` or - :class:`OSError` when stdin is not selectable (e.g. Windows, or a - detached stdin buffer in tests); callers should fall back to a threaded - reader in that case. + :class:`OSError` when stdin is not selectable (e.g. Windows, detached + stdin buffer in tests, or ``sys.stdin is None``); callers should fall + back to a threaded reader in that case. Raises :class:`EOFError` when stdin is closed (``readline()`` returns an empty string), preventing an infinite polling loop. """ + if sys.stdin is None: + raise ValueError("stdin is None") ready, _, _ = select.select([sys.stdin], [], [], timeout) if ready: line = sys.stdin.readline() diff --git a/src/copilot_usage/docs/implementation.md b/src/copilot_usage/docs/implementation.md index 8ec2642..7acd6d1 100644 --- a/src/copilot_usage/docs/implementation.md +++ b/src/copilot_usage/docs/implementation.md @@ -250,7 +250,7 @@ This is **Unix only** — `select()` on stdin doesn't work on Windows. The 500ms ### Fallback to threaded `_start_input_reader_thread()` -If `select()` raises `ValueError` or `OSError` (e.g. stdin is not selectable, notably on Windows, or stdin is detached during testing), the loop starts a daemon thread via `_start_input_reader_thread()` (in `cli.py`) that feeds lines into a `queue.SimpleQueue`: +If `select()` raises `ValueError` or `OSError` (e.g. stdin is not selectable, notably on Windows, stdin is detached during testing, or `sys.stdin is None`), the loop starts a daemon thread via `_start_input_reader_thread()` (in `cli.py`) that feeds lines into a `queue.SimpleQueue`: ```python except (ValueError, OSError): diff --git a/tests/copilot_usage/test_cli.py b/tests/copilot_usage/test_cli.py index 81a3abc..215d5e0 100644 --- a/tests/copilot_usage/test_cli.py +++ b/tests/copilot_usage/test_cli.py @@ -2168,12 +2168,34 @@ def test_returns_stripped_line(self) -> None: r_file.close() os.close(w_fd) + def test_none_stdin_raises_value_error(self) -> None: + """When sys.stdin is None, _read_line_nonblocking raises ValueError.""" + with patch("copilot_usage.cli.sys.stdin", None): + with pytest.raises(ValueError, match="stdin is None"): + _read_line_nonblocking(timeout=0.05) + # --------------------------------------------------------------------------- # Gap 3 — _interactive_loop stdin fallback (issue #258) # --------------------------------------------------------------------------- +def test_interactive_loop_none_stdin_falls_back_to_input( + tmp_path: Path, monkeypatch: Any +) -> None: + """When sys.stdin is None, _interactive_loop must fall back to threaded input().""" + _write_session(tmp_path, "fb_none0-0000-0000-0000-000000000000", name="NoneStdin") + + import copilot_usage.cli as cli_mod + + monkeypatch.setattr(cli_mod.sys, "stdin", None) + monkeypatch.setattr("builtins.input", lambda *_: "q") + + runner = CliRunner() + result = runner.invoke(main, ["--path", str(tmp_path)]) + assert result.exit_code == 0 + + def test_interactive_loop_select_value_error_falls_back_to_input( tmp_path: Path, monkeypatch: Any ) -> None: