Files
clawdmeter/daemon/tests/test_windows_reconnect.py
T
kvenanzi eb70456755 Add native Windows host daemon (bleak/WinRT)
Adds a Windows port of the host daemon so the Clawdmeter stays connected
on Windows independent of WSL. Mirrors the macOS daemon and speaks the
existing GATT data service unchanged — no firmware changes.

- claude_usage_daemon_windows.py: Windows-local OAuth token read +
  Anthropic poll + BLE scan/connect/write, with auto-reconnect
  (connect-retry wrapper, zombie-link break, split fast/slow backoff)
- tray_windows.py: pystray login-startup tray app (status icon + Quit)
- autostart_windows.py: winreg HKCU\Run autostart via pythonw.exe
- icon_assets.py: per-state tray icons composited from logo.h
- install-windows.ps1 + daemon/README-windows.md: turnkey setup
- pytest suite: token / poll / reconnect / tray / autostart / no-WSL guard
2026-06-02 16:39:07 -05:00

724 lines
29 KiB
Python

#!/usr/bin/env python3
"""Unit tests for connect_and_run reconnect hardening — BLE-03.
Covers:
D-01: connect-retry wrapper (post-wake WinRT failure modes)
D-03: zombie-link consecutive-failure break (stale is_connected)
Run: python -m pytest daemon/tests/test_windows_reconnect.py -x -q
"""
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from bleak.exc import BleakError
from daemon.claude_usage_daemon_windows import (
AuthError,
Session,
_wait_first,
connect_and_run,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _run(coro):
"""Run a coroutine synchronously for synchronous test functions."""
return asyncio.run(coro)
def _make_device(address="AA:BB:CC:DD:EE:FF"):
"""Build a minimal fake BLEDevice."""
device = MagicMock()
device.address = address
return device
async def _make_event(set_):
ev = asyncio.Event()
if set_:
ev.set()
return ev
# ---------------------------------------------------------------------------
# D-01: connect-retry wrapper tests
# ---------------------------------------------------------------------------
def test_connect_retry_exhaustion_on_bleak_error(monkeypatch, capsys):
"""BleakError on every connect attempt exhausts CONNECT_RETRIES then returns False."""
import daemon.claude_usage_daemon_windows as mod
device = _make_device()
stop_event = asyncio.run(_make_event(False))
mock_client = AsyncMock()
mock_client.connect = AsyncMock(side_effect=BleakError("Unreachable"))
mock_client.is_connected = False
mock_client.disconnect = AsyncMock()
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
patch("daemon.claude_usage_daemon_windows.asyncio.sleep", new=AsyncMock()):
result = _run(connect_and_run(device, stop_event))
assert result is False
assert mock_client.connect.call_count == mod.CONNECT_RETRIES
def test_connect_retry_exhaustion_on_timeout_error(monkeypatch, capsys):
"""asyncio.TimeoutError on every connect attempt is treated same as BleakError."""
import daemon.claude_usage_daemon_windows as mod
device = _make_device()
stop_event = asyncio.run(_make_event(False))
mock_client = AsyncMock()
mock_client.connect = AsyncMock(side_effect=asyncio.TimeoutError())
mock_client.is_connected = False
mock_client.disconnect = AsyncMock()
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
patch("daemon.claude_usage_daemon_windows.asyncio.sleep", new=AsyncMock()):
result = _run(connect_and_run(device, stop_event))
assert result is False
assert mock_client.connect.call_count == mod.CONNECT_RETRIES
def test_connect_retry_calls_disconnect_between_attempts(monkeypatch):
"""Guarded disconnect() is called between failed connect attempts."""
import daemon.claude_usage_daemon_windows as mod
device = _make_device()
stop_event = asyncio.run(_make_event(False))
mock_client = AsyncMock()
mock_client.connect = AsyncMock(side_effect=BleakError("Unreachable"))
mock_client.is_connected = False
mock_client.disconnect = AsyncMock()
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
patch("daemon.claude_usage_daemon_windows.asyncio.sleep", new=AsyncMock()):
_run(connect_and_run(device, stop_event))
# disconnect is called between attempts (at least CONNECT_RETRIES - 1 times)
assert mock_client.disconnect.call_count >= mod.CONNECT_RETRIES - 1
def test_connect_success_on_first_attempt_no_extra_retries(monkeypatch):
"""First-attempt success consumes exactly 1 connect call and proceeds past connect block."""
import daemon.claude_usage_daemon_windows as mod
device = _make_device()
# stop_event is set so the loop exits immediately after connecting
stop_event = asyncio.run(_make_event(True))
mock_client = AsyncMock()
mock_client.connect = AsyncMock(return_value=None) # success
mock_client.is_connected = True
mock_client.disconnect = AsyncMock()
mock_client.start_notify = AsyncMock()
mock_client.write_gatt_char = AsyncMock(return_value=None)
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
patch("daemon.claude_usage_daemon_windows.read_token", return_value="fake-token"), \
patch("daemon.claude_usage_daemon_windows.poll_api", new=AsyncMock(return_value={"ok": True})):
_run(connect_and_run(device, stop_event))
assert mock_client.connect.call_count == 1
def test_connect_retry_exhaustion_does_not_log_token(monkeypatch, capsys):
"""On exhaustion, no log line contains the patched token sentinel (T-03-01)."""
import daemon.claude_usage_daemon_windows as mod
TOKEN_SENTINEL = "sk-ant-SUPERSECRET-DO-NOT-LOG-12345"
device = _make_device()
stop_event = asyncio.run(_make_event(False))
mock_client = AsyncMock()
mock_client.connect = AsyncMock(side_effect=BleakError("Unreachable"))
mock_client.is_connected = False
mock_client.disconnect = AsyncMock()
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
patch("daemon.claude_usage_daemon_windows.read_token", return_value=TOKEN_SENTINEL), \
patch("daemon.claude_usage_daemon_windows.asyncio.sleep", new=AsyncMock()):
_run(connect_and_run(device, stop_event))
captured = capsys.readouterr()
assert TOKEN_SENTINEL not in captured.out, "Token sentinel leaked to stdout (T-03-01)"
assert TOKEN_SENTINEL not in captured.err, "Token sentinel leaked to stderr (T-03-01)"
# ---------------------------------------------------------------------------
# D-03: zombie-link consecutive-failure break tests
# ---------------------------------------------------------------------------
def _make_zombie_client():
"""Build a mock BleakClient that connects successfully but has is_connected stuck True."""
mock_client = AsyncMock()
mock_client.connect = AsyncMock(return_value=None)
mock_client.is_connected = True # stale flag — never goes False
mock_client.disconnect = AsyncMock()
mock_client.start_notify = AsyncMock()
return mock_client
def test_zombie_link_break_after_limit_consecutive_failures(monkeypatch):
"""Loop breaks after exactly ZOMBIE_BREAK_LIMIT consecutive False writes (default 1)."""
import daemon.claude_usage_daemon_windows as mod
device = _make_device()
stop_event = asyncio.run(_make_event(False))
mock_client = _make_zombie_client()
write_call_count = [0]
async def fake_write_payload(payload):
write_call_count[0] += 1
return False # always fail — zombie link
fake_session = AsyncMock()
fake_session.write_payload = fake_write_payload
fake_session.refresh_requested = MagicMock()
fake_session.refresh_requested.is_set = MagicMock(return_value=False)
fake_session.refresh_requested.clear = MagicMock()
fake_session.refresh_requested.wait = AsyncMock()
# Force elapsed >= POLL_INTERVAL immediately
monkeypatch.setattr(mod, "POLL_INTERVAL", 0)
async def fast_wait_for(coro, timeout):
raise asyncio.TimeoutError()
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
patch("daemon.claude_usage_daemon_windows.Session", return_value=fake_session), \
patch("daemon.claude_usage_daemon_windows.read_token", return_value="fake-token"), \
patch("daemon.claude_usage_daemon_windows.poll_api",
new=AsyncMock(return_value={"ok": True})), \
patch("daemon.claude_usage_daemon_windows.asyncio.wait_for",
side_effect=fast_wait_for):
result = _run(connect_and_run(device, stop_event))
# With ZOMBIE_BREAK_LIMIT=1, one False write should break the loop
assert write_call_count[0] == mod.ZOMBIE_BREAK_LIMIT
# Should return used_successfully=False (no successful write)
assert result is False
def test_zombie_counter_resets_on_success_with_raised_limit(monkeypatch):
"""A failed write followed by success resets counter (limit raised to 2 to exercise reset)."""
import daemon.claude_usage_daemon_windows as mod
device = _make_device()
stop_event = asyncio.run(_make_event(False))
mock_client = _make_zombie_client()
# Sequence: False (counter=1), True (counter reset to 0), False (counter=1 again), break
write_results = iter([False, True, False])
write_call_count = [0]
async def fake_write_payload(payload):
write_call_count[0] += 1
try:
return next(write_results)
except StopIteration:
return False
# After success, subsequent False write breaks at limit=2 (requires 2 consecutive)
# With limit=2: False (1), True (reset to 0), False (1), False (2 -> break)
# But we only have 3 items in write_results; after StopIteration returns False.
# Let's use a longer sequence to ensure reset-then-2-failures trip the break.
write_results2 = [False, True, False, False]
write_call_count2 = [0]
async def fake_write_payload2(payload):
write_call_count2[0] += 1
if write_call_count2[0] - 1 < len(write_results2):
return write_results2[write_call_count2[0] - 1]
return False
fake_session = AsyncMock()
fake_session.write_payload = fake_write_payload2
fake_session.refresh_requested = MagicMock()
fake_session.refresh_requested.is_set = MagicMock(return_value=False)
fake_session.refresh_requested.clear = MagicMock()
fake_session.refresh_requested.wait = AsyncMock()
monkeypatch.setattr(mod, "POLL_INTERVAL", 0)
monkeypatch.setattr(mod, "ZOMBIE_BREAK_LIMIT", 2) # raise limit to test reset logic
async def fast_wait_for(coro, timeout):
raise asyncio.TimeoutError()
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
patch("daemon.claude_usage_daemon_windows.Session", return_value=fake_session), \
patch("daemon.claude_usage_daemon_windows.read_token", return_value="fake-token"), \
patch("daemon.claude_usage_daemon_windows.poll_api",
new=AsyncMock(return_value={"ok": True})), \
patch("daemon.claude_usage_daemon_windows.asyncio.wait_for",
side_effect=fast_wait_for):
result = _run(connect_and_run(device, stop_event))
# With limit=2 and sequence [False, True, False, False]:
# cycle 1: False -> consecutive_failures=1 (no break, limit=2)
# cycle 2: True -> consecutive_failures=0 (reset)
# cycle 3: False -> consecutive_failures=1 (no break)
# cycle 4: False -> consecutive_failures=2 -> break
assert write_call_count2[0] == 4, (
f"Expected 4 write calls (reset-on-success logic), got {write_call_count2[0]}"
)
# used_successfully=True because cycle 2 succeeded
assert result is True
def test_zombie_break_disconnect_called_in_finally(monkeypatch):
"""The finally block calls client.disconnect() exactly once on the zombie-break path."""
import daemon.claude_usage_daemon_windows as mod
device = _make_device()
stop_event = asyncio.run(_make_event(False))
mock_client = _make_zombie_client()
async def fake_write_payload(payload):
return False # always fail
fake_session = AsyncMock()
fake_session.write_payload = fake_write_payload
fake_session.refresh_requested = MagicMock()
fake_session.refresh_requested.is_set = MagicMock(return_value=False)
fake_session.refresh_requested.clear = MagicMock()
fake_session.refresh_requested.wait = AsyncMock()
monkeypatch.setattr(mod, "POLL_INTERVAL", 0)
async def fast_wait_for(coro, timeout):
raise asyncio.TimeoutError()
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
patch("daemon.claude_usage_daemon_windows.Session", return_value=fake_session), \
patch("daemon.claude_usage_daemon_windows.read_token", return_value="fake-token"), \
patch("daemon.claude_usage_daemon_windows.poll_api",
new=AsyncMock(return_value={"ok": True})), \
patch("daemon.claude_usage_daemon_windows.asyncio.wait_for",
side_effect=fast_wait_for):
_run(connect_and_run(device, stop_event))
# The finally block calls disconnect() exactly once
assert mock_client.disconnect.call_count == 1
def test_zombie_break_returns_used_successfully_false(monkeypatch):
"""connect_and_run returns used_successfully=False after zombie break with no writes."""
import daemon.claude_usage_daemon_windows as mod
device = _make_device()
stop_event = asyncio.run(_make_event(False))
mock_client = _make_zombie_client()
async def fake_write_payload(payload):
return False
fake_session = AsyncMock()
fake_session.write_payload = fake_write_payload
fake_session.refresh_requested = MagicMock()
fake_session.refresh_requested.is_set = MagicMock(return_value=False)
fake_session.refresh_requested.clear = MagicMock()
fake_session.refresh_requested.wait = AsyncMock()
monkeypatch.setattr(mod, "POLL_INTERVAL", 0)
async def fast_wait_for(coro, timeout):
raise asyncio.TimeoutError()
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
patch("daemon.claude_usage_daemon_windows.Session", return_value=fake_session), \
patch("daemon.claude_usage_daemon_windows.read_token", return_value="fake-token"), \
patch("daemon.claude_usage_daemon_windows.poll_api",
new=AsyncMock(return_value={"ok": True})), \
patch("daemon.claude_usage_daemon_windows.asyncio.wait_for",
side_effect=fast_wait_for):
result = _run(connect_and_run(device, stop_event))
# main() uses this return value to route into reconnect branch
assert result is False
# ---------------------------------------------------------------------------
# D-05: split fast-reconnect vs slow-search backoff in main()
# ---------------------------------------------------------------------------
def test_next_backoff_slow_search_doubles_to_60():
"""_next_backoff doubles correctly and never exceeds 60 (slow-search cap)."""
import daemon.claude_usage_daemon_windows as mod
values = []
b = 1
for _ in range(10):
b = mod._next_backoff(b, 60)
values.append(b)
assert values == [2, 4, 8, 16, 32, 60, 60, 60, 60, 60]
assert max(values) <= 60
def test_next_backoff_fast_reconnect_doubles_to_cap():
"""_next_backoff doubles correctly and never exceeds RECONNECT_BACKOFF_CAP (default 8)."""
import daemon.claude_usage_daemon_windows as mod
cap = mod.RECONNECT_BACKOFF_CAP
assert cap < 60, "Fast cap must be strictly lower than search cap"
values = []
b = 1
for _ in range(8):
b = mod._next_backoff(b, cap)
values.append(b)
# Should double until hitting the cap, then stay there
assert max(values) <= cap
# Should reach the cap (not just stay at 1)
assert values[-1] == cap
def test_next_backoff_one_to_two():
"""_next_backoff(1, 60) == 2 (basic sanity)."""
import daemon.claude_usage_daemon_windows as mod
assert mod._next_backoff(1, 60) == 2
def test_next_backoff_at_cap_stays():
"""_next_backoff(cap, cap) == cap (does not overflow)."""
import daemon.claude_usage_daemon_windows as mod
assert mod._next_backoff(mod.RECONNECT_BACKOFF_CAP, mod.RECONNECT_BACKOFF_CAP) == mod.RECONNECT_BACKOFF_CAP
def test_main_scan_miss_uses_search_backoff():
"""When scan_for_device returns None, asyncio.wait_for receives search_backoff timeout values."""
import daemon.claude_usage_daemon_windows as mod
# Capture main()'s internal stop_event by intercepting asyncio.Event()
internal_stop_event = [None]
real_Event = asyncio.Event
def capturing_Event():
ev = real_Event()
internal_stop_event[0] = ev
return ev
recorded_timeouts = []
call_count = [0]
MAX_CALLS = 3
async def fake_scan():
return None # always miss -> slow-search regime
async def fake_wait_for(coro, timeout):
recorded_timeouts.append(timeout)
call_count[0] += 1
if call_count[0] >= MAX_CALLS and internal_stop_event[0] is not None:
internal_stop_event[0].set() # terminate main()'s outer while loop
raise asyncio.TimeoutError()
with patch("daemon.claude_usage_daemon_windows.asyncio.Event", side_effect=capturing_Event), \
patch("daemon.claude_usage_daemon_windows.scan_for_device", side_effect=fake_scan), \
patch("daemon.claude_usage_daemon_windows.asyncio.wait_for", side_effect=fake_wait_for):
_run(mod.main())
# Should have recorded timeouts from search_backoff sequence: 1, 2, 4 (then stop)
assert len(recorded_timeouts) >= 2
# Timeouts should be doubling (search_backoff sequence)
assert recorded_timeouts[0] == 1
assert recorded_timeouts[1] == 2
# None should exceed the search cap (60)
assert all(t <= 60 for t in recorded_timeouts)
def test_main_connect_fail_uses_reconnect_backoff():
"""When connect_and_run returns False, asyncio.wait_for receives reconnect_backoff timeouts (fast cap)."""
import daemon.claude_usage_daemon_windows as mod
# Capture main()'s internal stop_event
internal_stop_event = [None]
real_Event = asyncio.Event
def capturing_Event():
ev = real_Event()
internal_stop_event[0] = ev
return ev
fake_device = _make_device()
recorded_timeouts = []
call_count = [0]
MAX_CALLS = 3
async def fake_scan():
return fake_device # always finds device
async def fake_connect_and_run(device, event, tray_state=None):
return False # always fails -> fast-reconnect regime
async def fake_wait_for(coro, timeout):
recorded_timeouts.append(timeout)
call_count[0] += 1
if call_count[0] >= MAX_CALLS and internal_stop_event[0] is not None:
internal_stop_event[0].set()
raise asyncio.TimeoutError()
with patch("daemon.claude_usage_daemon_windows.asyncio.Event", side_effect=capturing_Event), \
patch("daemon.claude_usage_daemon_windows.scan_for_device", side_effect=fake_scan), \
patch("daemon.claude_usage_daemon_windows.connect_and_run", side_effect=fake_connect_and_run), \
patch("daemon.claude_usage_daemon_windows.asyncio.wait_for", side_effect=fake_wait_for):
_run(mod.main())
# Should have recorded timeouts from reconnect_backoff sequence: 1, 2, 4 (then stop)
assert len(recorded_timeouts) >= 2
assert recorded_timeouts[0] == 1
assert recorded_timeouts[1] == 2
# All timeouts must be at or below RECONNECT_BACKOFF_CAP (fast cap, < 60)
assert all(t <= mod.RECONNECT_BACKOFF_CAP for t in recorded_timeouts)
def test_main_reconnect_backoff_reset_on_success():
"""A successful connect_and_run (returns True) resets reconnect_backoff to 1."""
import daemon.claude_usage_daemon_windows as mod
# Capture main()'s internal stop_event
internal_stop_event = [None]
real_Event = asyncio.Event
def capturing_Event():
ev = real_Event()
internal_stop_event[0] = ev
return ev
fake_device = _make_device()
recorded_timeouts = []
call_count = [0]
# Sequence: fail (reconnect_backoff=1), succeed (reset), fail (reconnect_backoff=1 again)
connect_results = [False, True, False]
connect_idx = [0]
async def fake_scan():
return fake_device
async def fake_connect_and_run(device, event, tray_state=None):
idx = connect_idx[0]
connect_idx[0] += 1
if idx < len(connect_results):
return connect_results[idx]
return False
async def fake_wait_for(coro, timeout):
recorded_timeouts.append(timeout)
call_count[0] += 1
if call_count[0] >= 2 and internal_stop_event[0] is not None:
internal_stop_event[0].set() # stop after 2 waits (first fail + post-success fail)
raise asyncio.TimeoutError()
with patch("daemon.claude_usage_daemon_windows.asyncio.Event", side_effect=capturing_Event), \
patch("daemon.claude_usage_daemon_windows.scan_for_device", side_effect=fake_scan), \
patch("daemon.claude_usage_daemon_windows.connect_and_run", side_effect=fake_connect_and_run), \
patch("daemon.claude_usage_daemon_windows.asyncio.wait_for", side_effect=fake_wait_for):
_run(mod.main())
# First wait: reconnect_backoff=1 (initial failure)
# Second wait: reconnect_backoff=1 again (reset by success, then another failure)
assert len(recorded_timeouts) >= 2
assert recorded_timeouts[0] == 1, f"Expected 1 on first fail, got {recorded_timeouts[0]}"
assert recorded_timeouts[1] == 1, f"Expected 1 after success reset, got {recorded_timeouts[1]}"
def test_main_no_saved_addr_file_or_skip_addr():
"""main() does not reference SAVED_ADDR_FILE or skip_addr (Windows is stateless - D-04)."""
import inspect
import daemon.claude_usage_daemon_windows as mod
source = inspect.getsource(mod.main)
assert "SAVED_ADDR_FILE" not in source, "main() must not reference SAVED_ADDR_FILE (D-04)"
assert "skip_addr" not in source, "main() must not reference skip_addr (macOS-only)"
assert "retrieve_connected" not in source.lower(), \
"main() must not reference retrieve_connected (macOS HID path)"
def test_requirements_windows_contains_required_deps():
"""requirements-windows.txt must contain the expected deps.
Phase 3 (reconnect) added no new deps; Phase 4 (tray) adds pystray + Pillow.
This test asserts the final expected state: bleak, httpx, pystray, Pillow
must be present; winreg must NOT be listed (it is stdlib — no install needed).
"""
req_path = Path(__file__).parent.parent / "requirements-windows.txt"
content = req_path.read_text()
lines = {line.strip().lower() for line in content.splitlines()
if line.strip() and not line.strip().startswith("#")}
assert "bleak" in lines, "bleak must be in requirements-windows.txt"
assert "httpx" in lines, "httpx must be in requirements-windows.txt"
assert "pystray" in lines, "pystray must be in requirements-windows.txt (Phase 4)"
assert "pillow" in lines, "Pillow must be in requirements-windows.txt (Phase 4)"
assert "winreg" not in lines, "winreg is stdlib — must NOT be in requirements-windows.txt"
# ---------------------------------------------------------------------------
# G-03-01: start_notify() OSError must not crash the daemon (SC#3 power-cycle)
# ---------------------------------------------------------------------------
def test_start_notify_oserror_does_not_crash_connect_and_run():
"""G-03-01 regression: on post-power-cycle reconnect, WinRT's start_notify()
CCCD write can raise a raw OSError/WinError when the just-rebooted peer GATT
server is not yet ready. The optional refresh subscription must degrade
gracefully — connect_and_run must NOT propagate the OSError and must proceed
into the poll loop (returning normally), so the daemon never restarts (SC#3/SC#4).
"""
device = _make_device()
# stop_event set so the poll loop exits immediately after subscription setup
stop_event = asyncio.run(_make_event(True))
mock_client = AsyncMock()
mock_client.connect = AsyncMock(return_value=None) # connect succeeds
mock_client.is_connected = True
mock_client.disconnect = AsyncMock()
# The exact failure observed on hardware (SC#3, 2026-06-02):
# OSError: [WinError -2147023673] The operation was canceled by the user.
mock_client.start_notify = AsyncMock(
side_effect=OSError(-2147023673, "The operation was canceled by the user.")
)
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
patch("daemon.claude_usage_daemon_windows.read_token", return_value="fake-token"), \
patch("daemon.claude_usage_daemon_windows.poll_api", new=AsyncMock(return_value={"ok": True})):
# Must NOT raise OSError — graceful degradation into the poll loop.
result = _run(connect_and_run(device, stop_event))
# start_notify was actually attempted (and raised), but was swallowed.
assert mock_client.start_notify.call_count == 1
# Function returned normally instead of propagating the OSError.
assert result is False
# The link was cleaned up via the finally block.
assert mock_client.disconnect.call_count >= 1
# ---------------------------------------------------------------------------
# SC#2 field report: write_payload() OSError must not crash the daemon thread
# ---------------------------------------------------------------------------
def test_write_payload_oserror_returns_false_not_raises():
"""SC#2 regression: write_gatt_char can raise a raw OSError/WinError (NOT a
BleakError) when the peer GATT server goes transiently unavailable mid-write.
write_payload must catch it and return False — tripping the zombie-link break
for a clean reconnect — instead of propagating an uncaught exception that
silently kills the daemon=True background thread and freezes the tray.
"""
mock_client = AsyncMock()
mock_client.write_gatt_char = AsyncMock(
side_effect=OSError(-2147023673, "The operation was canceled by the user.")
)
session = Session(mock_client)
result = _run(session.write_payload({"ok": True}))
assert result is False # caught and reported, not raised
assert mock_client.write_gatt_char.call_count == 1
def test_write_payload_bleak_error_still_returns_false():
"""The pre-existing BleakError path must keep returning False (no regression
from widening the except to also cover OSError)."""
mock_client = AsyncMock()
mock_client.write_gatt_char = AsyncMock(side_effect=BleakError("disconnected"))
session = Session(mock_client)
assert _run(session.write_payload({"ok": True})) is False
# ---------------------------------------------------------------------------
# SC#3 graceful Quit: _wait_first wakes immediately on stop (clean disconnect)
# ---------------------------------------------------------------------------
def test_wait_first_returns_immediately_when_an_event_is_set():
"""The poll loop's TICK wait must break the instant stop_event is set, so the
finally: client.disconnect() runs before the process exits (SC#3). The outer
wait_for(2s) fails fast if _wait_first wrongly blocks for the full 30s timeout."""
async def go():
refresh = asyncio.Event()
stop = asyncio.Event()
stop.set() # stop signalled
await asyncio.wait_for(_wait_first(refresh, stop, timeout=30.0), timeout=2.0)
assert not refresh.is_set() # loser waiter drained, refresh untouched
_run(go())
def test_wait_first_returns_after_timeout_when_no_event_set():
"""With neither event set, _wait_first returns after `timeout` (the normal
poll-tick path) rather than hanging."""
async def go():
await asyncio.wait_for(
_wait_first(asyncio.Event(), asyncio.Event(), timeout=0.05), timeout=2.0
)
_run(go())
# ---------------------------------------------------------------------------
# SC#5: transient poll failure must NOT toast "token expired"; only a real
# 401/403 (AuthError) should. A boot-time DNS blip returns None, not AuthError.
# ---------------------------------------------------------------------------
def _connected_mock_client():
client = AsyncMock()
client.connect = AsyncMock(return_value=None)
client.is_connected = True
client.disconnect = AsyncMock()
client.start_notify = AsyncMock()
return client
def test_transient_poll_failure_does_not_set_error():
"""poll_api returning None (network/DNS, timeout, 5xx, 429) is transient and
must leave the tray state untouched — not flip it to 'token expired' (SC#5
field report: `getaddrinfo failed` at boot wrongly fired the toast)."""
device = _make_device()
stop_event = asyncio.run(_make_event(False))
tray_state = MagicMock()
client = _connected_mock_client()
async def fake_poll(_token):
stop_event.set() # end the loop after this single transient failure
return None
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=client), \
patch("daemon.claude_usage_daemon_windows.read_token", return_value="tok"), \
patch("daemon.claude_usage_daemon_windows.poll_api", new=fake_poll):
_run(connect_and_run(device, stop_event, tray_state))
tray_state.set_error.assert_not_called()
tray_state.set_connected.assert_not_called()
def test_auth_error_sets_token_expired():
"""A genuine 401/403 surfaces as AuthError and DOES flip the tray to the
actionable 'token expired — run claude login' error state."""
device = _make_device()
stop_event = asyncio.run(_make_event(False))
tray_state = MagicMock()
client = _connected_mock_client()
async def fake_poll(_token):
stop_event.set()
raise AuthError(401)
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=client), \
patch("daemon.claude_usage_daemon_windows.read_token", return_value="tok"), \
patch("daemon.claude_usage_daemon_windows.poll_api", new=fake_poll):
_run(connect_and_run(device, stop_event, tray_state))
tray_state.set_error.assert_called_once_with("token expired — run claude login")