Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/copilot_usage/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion src/copilot_usage/docs/implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
22 changes: 22 additions & 0 deletions tests/copilot_usage/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading