fix(garmin): startup tokenstore resume recovers after its 10s timeout

The background login thread now flips _auth_state to authenticated
itself when a slow tokenstore resume succeeds after the 10s wait --
previously its result landed in an abandoned private queue and the
docstring's promised late recovery never happened, leaving every
subsequent call failing until an explicit authenticate. Guarded to only
ever transition from unauthenticated, so an explicit authenticate/MFA
flow that took over meanwhile is never clobbered; the timeout branch no
longer re-writes the state (a redundant write that could race a success
at the boundary). Structured-log messages reworked alongside.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 18:17:24 +02:00
parent 0e6cf00dba
commit 78c494affb
2 changed files with 98 additions and 22 deletions

View File

@@ -1,4 +1,5 @@
import os import os
import time
import queue import queue
import threading import threading
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -302,3 +303,51 @@ def test_call_does_not_mark_other_errors_as_not_found():
assert resp["id"] == 12 assert resp["id"] == 12
assert resp["error"] == "rate limited" assert resp["error"] == "rate limited"
assert "not_found" not in resp assert "not_found" not in resp
def test_startup_login_late_success_flips_auth_state_for_later_calls():
"""A tokenstore resume that outlives the 10s wait must still recover
the subprocess: the background thread itself flips _auth_state on
success, so the next `call` command passes the auth check without an
explicit authenticate."""
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
release = threading.Event()
mock_client = MagicMock()
mock_client.login.side_effect = lambda **kwargs: release.wait(timeout=5)
wrapper._auth_state = "unauthenticated"
with patch.dict("os.environ", env):
with patch("wrapper.Garmin", return_value=mock_client):
with patch("wrapper.queue.Queue") as mock_queue_cls:
# Simulate the 10s timeout: the reader gives up immediately,
# while the (still-blocked) login thread keeps running.
mock_queue_cls.return_value.get.side_effect = queue.Empty
wrapper._startup_login()
assert wrapper._auth_state == "unauthenticated"
release.set() # the slow login now completes, after the timeout
for _ in range(100):
if wrapper._auth_state == "authenticated":
break
time.sleep(0.01)
assert wrapper._auth_state == "authenticated"
def test_startup_login_late_success_never_overrides_explicit_auth_flow():
"""If an explicit authenticate/MFA flow moved the state while the
startup resume was still in flight, the late success must not clobber
it -- the explicit flow owns the state once it has moved it."""
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
release = threading.Event()
mock_client = MagicMock()
mock_client.login.side_effect = lambda **kwargs: release.wait(timeout=5)
wrapper._auth_state = "unauthenticated"
with patch.dict("os.environ", env):
with patch("wrapper.Garmin", return_value=mock_client):
with patch("wrapper.queue.Queue") as mock_queue_cls:
mock_queue_cls.return_value.get.side_effect = queue.Empty
wrapper._startup_login()
wrapper._auth_state = "mfa_pending" # an explicit flow took over meanwhile
release.set()
time.sleep(0.2) # give the thread ample time to (wrongly) flip it
assert wrapper._auth_state == "mfa_pending"

View File

@@ -36,9 +36,9 @@ def _log(level, msg, **attrs):
def _prompt_mfa(): def _prompt_mfa():
_log("info", "garminconnect invoked prompt_mfa() -- this is a REAL MFA challenge from Garmin") _log("debug", "prompt_mfa(): invoked")
code = _mfa_input_queue.get(timeout=300) code = _mfa_input_queue.get(timeout=300)
_log("debug", "prompt_mfa() handing code back to garminconnect", code_length=len(code)) _log("debug", "prompt_mfa(): returning MFA code", code_length=len(code))
return code return code
@@ -61,10 +61,13 @@ def _startup_login():
Bounded to the same 10s timeout as _handle_authenticate: the actual Bounded to the same 10s timeout as _handle_authenticate: the actual
login runs on a background daemon thread, and this function waits up login runs on a background daemon thread, and this function waits up
to 10s for it before returning either way, so a slow/rate-limited to 10s for it before returning either way, so a slow/rate-limited
Garmin login can never block the caller indefinitely -- if it times Garmin login can never block the caller indefinitely. On timeout the
out, the thread keeps running and will update _auth_state whenever it triggering call fails ("Not authenticated"), but the thread keeps
eventually finishes (success or failure), just without wedging the running and, if the login eventually succeeds, flips _auth_state to
whole subprocess unresponsive in the meantime. "authenticated" itself -- only ever from "unauthenticated", never
overriding an explicit authenticate/MFA flow that ran in the
meantime -- so subsequent calls recover without user action. A late
failure changes nothing (the state is already "unauthenticated").
Deliberately uses its own private, function-local result queue rather Deliberately uses its own private, function-local result queue rather
than the module-level _login_result_queue that _handle_authenticate and than the module-level _login_result_queue that _handle_authenticate and
@@ -76,6 +79,7 @@ def _startup_login():
unrelated, later authenticate/complete_mfa call instead of that call's unrelated, later authenticate/complete_mfa call instead of that call's
own fresh result.""" own fresh result."""
global _client, _auth_state global _client, _auth_state
email = os.environ.get("GARMIN_EMAIL") email = os.environ.get("GARMIN_EMAIL")
password = os.environ.get("GARMIN_PASSWORD") password = os.environ.get("GARMIN_PASSWORD")
if not email or not password: if not email or not password:
@@ -85,28 +89,48 @@ def _startup_login():
result_queue = queue.Queue() # private to this call, never shared with authenticate/complete_mfa result_queue = queue.Queue() # private to this call, never shared with authenticate/complete_mfa
def _do_login(): def _do_login():
global _auth_state
_log("debug", "startup_login(): background thread starting _client.login()", tokenstore=TOKENSTORE)
try: try:
_client.login(tokenstore=TOKENSTORE) _client.login(tokenstore=TOKENSTORE)
_log("debug", "startup_login(): _client.login() returned successfully")
result_queue.put(("success", None)) result_queue.put(("success", None))
# A success arriving after the 10s timeout below would land in
# an abandoned queue -- flip the state here too, so later calls
# benefit from the resume. Only from "unauthenticated": a
# concurrent explicit authenticate/MFA flow owns the state once
# it has moved it anywhere else.
if _auth_state == "unauthenticated":
_auth_state = "authenticated"
except Exception as exc: except Exception as exc:
_log(
"error",
"startup_login(): _client.login() failed",
error=str(exc),
error_type=type(exc).__name__,
traceback=traceback.format_exc(),
)
result_queue.put(("error", str(exc))) result_queue.put(("error", str(exc)))
threading.Thread(target=_do_login, daemon=True).start() threading.Thread(target=_do_login, daemon=True).start()
try: try:
status, err = result_queue.get(timeout=10) status, err = result_queue.get(timeout=10)
_log("debug", "startup_login(): got result within 10s timeout", status=status)
if status == "success": if status == "success":
_auth_state = "authenticated" _auth_state = "authenticated"
else: else:
_log("warn", "startup tokenstore login failed", error=err) _log("error", "startup_login(): login failed", error=err)
_auth_state = "unauthenticated" _auth_state = "unauthenticated"
except queue.Empty: except queue.Empty:
# No assignment here: the state is already "unauthenticated", and
# writing it again could clobber a success the background thread
# records right around the timeout boundary (see _do_login).
_log( _log(
"warn", "warn",
"startup tokenstore login hit the 10s timeout -- still running in the " "startup_login(): hit the 10s timeout but still running in the "
"background and will update auth state whenever it finishes", "background and will update auth state if it eventually succeeds",
) )
_auth_state = "unauthenticated"
def _handle_authenticate(_params): def _handle_authenticate(_params):
@@ -126,15 +150,15 @@ def _handle_authenticate(_params):
} }
def _do_login(): def _do_login():
_log("debug", "background login thread starting _client.login()", tokenstore=TOKENSTORE) _log("debug", "handle_authenticate(): background thread starting _client.login()", tokenstore=TOKENSTORE)
try: try:
_client.login(tokenstore=TOKENSTORE) _client.login(tokenstore=TOKENSTORE)
_log("debug", "_client.login() returned successfully") _log("debug", "handle_authenticate(): _client.login() returned successfully")
_login_result_queue.put(("success", None)) _login_result_queue.put(("success", None))
except Exception as exc: except Exception as exc:
_log( _log(
"error", "error",
"_client.login() raised", "handle_authenticate(): _client.login() failed",
error=str(exc), error=str(exc),
error_type=type(exc).__name__, error_type=type(exc).__name__,
traceback=traceback.format_exc(), traceback=traceback.format_exc(),
@@ -147,18 +171,17 @@ def _handle_authenticate(_params):
try: try:
status, err = _login_result_queue.get(timeout=10) status, err = _login_result_queue.get(timeout=10)
_log("debug", "authenticate got result within 10s timeout", status=status) _log("debug", "handle_authenticate(): got result within 10s timeout", status=status)
if status == "success": if status == "success":
_auth_state = "authenticated" _auth_state = "authenticated"
return {"status": "success", "message": "Authenticated successfully."} return {"status": "success", "message": "Authenticated successfully."}
else:
_log("error", "handle_authenticate(): login failed", error=err)
return {"status": "failed", "message": f"Authentication failed: {err}"} return {"status": "failed", "message": f"Authentication failed: {err}"}
except queue.Empty: except queue.Empty:
_log( _log(
"warn", "warn",
"authenticate hit the 10s timeout with no result yet -- reporting mfa_required, " "handle_authenticate(): hit the 10s timeout with no result yet, reporting mfa_required",
"but this does NOT necessarily mean prompt_mfa() was actually invoked; check "
"whether the 'REAL MFA challenge' log line above appears to tell real MFA "
"apart from a merely slow login.",
) )
_auth_state = "mfa_pending" _auth_state = "mfa_pending"
return { return {
@@ -174,21 +197,25 @@ def _handle_complete_mfa(params):
return {"status": "failed", "message": "No MFA in progress. Call authenticate first."} return {"status": "failed", "message": "No MFA in progress. Call authenticate first."}
code = params["code"] code = params["code"]
_log("debug", "complete_mfa received a code, pushing to mfa queue", code_length=len(code)) _log("debug", "handle_complete_mfa(): received a code, pushing to mfa queue", code_length=len(code))
_mfa_input_queue.put(code) _mfa_input_queue.put(code)
try: try:
status, err = _login_result_queue.get(timeout=30) status, err = _login_result_queue.get(timeout=30)
_log("debug", "complete_mfa got result", status=status, error=err) _log("debug", "handle_complete_mfa(): got result", status=status, error=err)
if status == "success": if status == "success":
_auth_state = "authenticated" _auth_state = "authenticated"
return {"status": "success", "message": "MFA accepted. Authenticated successfully."} return {"status": "success", "message": "MFA accepted. Authenticated successfully."}
return {"status": "failed", "message": f"Authentication failed after MFA: {err}"} return {"status": "failed", "message": f"Authentication failed after MFA: {err}"}
except queue.Empty: except queue.Empty:
_log(
"error",
"handle_complete_mfa(): hit the 30s timeout with no result yet, reporting unauthenticated",
)
_auth_state = "unauthenticated" _auth_state = "unauthenticated"
return { return {
"status": "failed", "status": "failed",
"message": "Timed out waiting for authentication to complete. Call authenticate again.", "message": "Timed out waiting for authentication to complete.",
} }