Skip to content
Merged
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
33 changes: 16 additions & 17 deletions src/copilot_usage/docs/implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,41 +248,40 @@ def _read_line_nonblocking(timeout: float = 0.5) -> str | None:

This is **Unix only** — `select()` on stdin doesn't work on Windows. The 500ms timeout allows the main loop to check for file-change events between input polls.

### Fallback to blocking `input()`
### Fallback to threaded `_start_input_reader_thread()`

If `select()` raises `ValueError` or `OSError` (e.g. stdin is piped, not a real TTY, or during testing), the loop falls back to blocking `input()` (in `cli.py`):
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`:

```python
except (ValueError, OSError):
try:
line = input().strip()
except (EOFError, KeyboardInterrupt):
break
fallback_queue = _start_input_reader_thread()
line = None
```

The daemon thread calls `input()` in a loop, placing stripped lines on the queue. When stdin is exhausted or an unrecoverable error occurs, it posts the `_FALLBACK_EOF` sentinel and exits. The main loop then reads from `fallback_queue.get(timeout=0.5)` instead of `_read_line_nonblocking`, so that `change_event` auto-refresh keeps working during the fallback. The queue is created lazily on first `ValueError`/`OSError` and is local to that `_interactive_loop` call. The reader thread is also started lazily, but because it is a daemon thread that can block in `input()`, there is no explicit teardown path here: it may remain alive after `_interactive_loop` returns until stdin reaches EOF/errors or the process exits. Being a daemon means it does not prevent process shutdown.

### Watchdog file observer

A `watchdog.Observer` watches `~/.copilot/session-state/` recursively for **any** filesystem change — new session directories, lockfile creation/deletion, `events.jsonl` writes, etc. (in `cli.py`):
A `watchdog.Observer` watches `~/.copilot/session-state/` recursively for **any** filesystem change — new session directories, lockfile creation/deletion, `events.jsonl` writes, etc. The observer is created and started by `start_observer()` in `interactive.py`:

```python
observer = Observer()
observer.schedule(handler, str(session_path), recursive=True)
observer.daemon = True
observer.start()
observer = start_observer(session_path, change_event)
```

The observer watches the session-state directory; if the directory doesn't exist at startup, no observer is created and auto-refresh is simply skipped.
`start_observer()` returns a `Stoppable` handle (or `None` when the observer cannot be started, e.g. inotify watch limit exhausted). The corresponding `stop_observer()` tears it down in a `finally` block. If the session-state directory doesn't exist at startup, no observer is created and auto-refresh is simply skipped.

### `_FileChangeHandler` with 2-second debounce
### `FileChangeHandler` with `WATCHDOG_DEBOUNCE_SECS` debounce

`_FileChangeHandler` (in `cli.py`) triggers on any filesystem event in the session-state tree and enforces a 2-second debounce using `time.monotonic()`:
`FileChangeHandler` (public, in `interactive.py`) triggers on any filesystem event in the session-state tree and enforces a debounce using `time.monotonic()` and the `WATCHDOG_DEBOUNCE_SECS` constant:

```python
def dispatch(self, event):
def dispatch(self, event: object) -> None:
now = time.monotonic()
if now - self._last_trigger > 2.0:
with self._lock:
if now - self._last_trigger <= WATCHDOG_DEBOUNCE_SECS:
return
self._last_trigger = now
self._change_event.set()
self._change_event.set()
```

Each trigger causes a full `get_all_sessions()` re-read, picking up new sessions, closed sessions, and updated event data. The debounce prevents rapid redraws during high-frequency event writes (e.g. tool execution loops producing many events per second). Manual refresh (`r`) is still available as a fallback.
Expand Down
51 changes: 51 additions & 0 deletions tests/test_docs.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import importlib
import re
from pathlib import Path

Expand Down Expand Up @@ -225,3 +226,53 @@ def test_architecture_first_pass_mentions_resume_detection() -> None:
assert "resume" in description.lower(), (
"_first_pass() description in architecture.md must mention resume detection"
)


# --- Symbol-existence checks for implementation.md ---


_IMPL_SYMBOL_EXPECTATIONS: list[tuple[str, str, bool]] = [
# (symbol_name, module_path, is_public)
("FileChangeHandler", "copilot_usage.interactive", True),
("_start_input_reader_thread", "copilot_usage.cli", False),
("start_observer", "copilot_usage.interactive", True),
("stop_observer", "copilot_usage.interactive", True),
("WATCHDOG_DEBOUNCE_SECS", "copilot_usage.interactive", True),
]


def test_implementation_md_symbols_exist_in_expected_modules() -> None:
"""Symbol names referenced in implementation.md must exist in the
expected modules — prevents future renames from silently drifting."""
for symbol, module_path, is_public in _IMPL_SYMBOL_EXPECTATIONS:
# Verify the symbol is mentioned in implementation.md
assert symbol in _IMPL_MD, (
f"implementation.md does not mention '{symbol}' — "
f"expected a reference to {module_path}.{symbol}"
)
# Verify the doc attributes the symbol to the correct module.
# The doc uses short filenames (e.g. ``cli.py``, ``interactive.py``),
# so derive the expected filename from the dotted module path.
expected_file = module_path.rsplit(".", 1)[-1] + ".py"
idx = _IMPL_MD.index(symbol)
window_start = max(0, idx - 500)
window_end = min(len(_IMPL_MD), idx + 500)
window = _IMPL_MD[window_start:window_end]
assert expected_file in window, (
f"implementation.md mentions '{symbol}' but does not attribute it "
f"to '{expected_file}' nearby — expected the module reference "
f"within ~500 chars of the symbol"
)
# Verify the symbol actually exists in the stated module
mod = importlib.import_module(module_path)
assert hasattr(mod, symbol), (
f"implementation.md references '{symbol}' in {module_path}, "
f"but the symbol does not exist in that module"
)
Comment thread
microsasa marked this conversation as resolved.
# Public symbols must be listed in __all__
if is_public:
mod_all = getattr(mod, "__all__", [])
assert symbol in mod_all, (
f"implementation.md references '{symbol}' as a public "
f"export of {module_path}, but it is not in __all__"
)
Loading