fix(oauth,gateway): monotonic deadlines for polling/timeout loops

Widen PR #20314's fix to the other timeout-polling sites in the codebase
that share the same wall-clock-jump bug class. All of these measure elapsed
timeout duration, not civil time, so they belong on time.monotonic().

- hermes_cli/auth.py: auth-store file-lock timeout, Spotify OAuth callback
  wait, Nous portal device-auth token poll.
- hermes_cli/copilot_auth.py: Copilot OAuth device-flow token poll.
- hermes_cli/gateway.py: gateway systemd restart wait.
- hermes_cli/web_server.py: dashboard Codex device-auth user_code wait,
  dashboard Nous device-auth token poll. (sess["expires_at"] stays on
  time.time() — it's a persisted absolute timestamp, not a local
  deadline-polling variable.)
- agent/copilot_acp_client.py: Copilot ACP JSON-RPC request timeout.
This commit is contained in:
teknium1 2026-05-07 05:05:24 -07:00 committed by Teknium
parent 6e8f1e09a9
commit 2e00bcaaab
5 changed files with 16 additions and 16 deletions

View file

@ -477,8 +477,8 @@ class CopilotACPClient:
proc.stdin.write(json.dumps(payload) + "\n") proc.stdin.write(json.dumps(payload) + "\n")
proc.stdin.flush() proc.stdin.flush()
deadline = time.time() + timeout_seconds deadline = time.monotonic() + timeout_seconds
while time.time() < deadline: while time.monotonic() < deadline:
if proc.poll() is not None: if proc.poll() is not None:
break break
try: try:

View file

@ -894,7 +894,7 @@ def _file_lock(
lock_path.write_text(" ", encoding="utf-8") lock_path.write_text(" ", encoding="utf-8")
with lock_path.open("r+" if msvcrt else "a+") as lock_file: with lock_path.open("r+" if msvcrt else "a+") as lock_file:
deadline = time.time() + max(1.0, timeout_seconds) deadline = time.monotonic() + max(1.0, timeout_seconds)
while True: while True:
try: try:
if fcntl: if fcntl:
@ -904,7 +904,7 @@ def _file_lock(
msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1) msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
break break
except (BlockingIOError, OSError, PermissionError): except (BlockingIOError, OSError, PermissionError):
if time.time() >= deadline: if time.monotonic() >= deadline:
raise TimeoutError(timeout_message) raise TimeoutError(timeout_message)
time.sleep(0.05) time.sleep(0.05)
@ -1974,9 +1974,9 @@ def _spotify_wait_for_callback(
thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True) thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True)
thread.start() thread.start()
deadline = time.time() + max(5.0, timeout_seconds) deadline = time.monotonic() + max(5.0, timeout_seconds)
try: try:
while time.time() < deadline: while time.monotonic() < deadline:
if result["code"] or result["error"]: if result["code"] or result["error"]:
return result return result
time.sleep(0.1) time.sleep(0.1)
@ -2739,10 +2739,10 @@ def _poll_for_token(
poll_interval: int, poll_interval: int,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Poll the token endpoint until the user approves or the code expires.""" """Poll the token endpoint until the user approves or the code expires."""
deadline = time.time() + max(1, expires_in) deadline = time.monotonic() + max(1, expires_in)
current_interval = max(1, min(poll_interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS)) current_interval = max(1, min(poll_interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS))
while time.time() < deadline: while time.monotonic() < deadline:
response = client.post( response = client.post(
f"{portal_base_url}/api/oauth/token", f"{portal_base_url}/api/oauth/token",
data={ data={

View file

@ -212,9 +212,9 @@ def copilot_device_code_login(
print(" Waiting for authorization...", end="", flush=True) print(" Waiting for authorization...", end="", flush=True)
# Step 3: Poll for completion # Step 3: Poll for completion
deadline = time.time() + timeout_seconds deadline = time.monotonic() + timeout_seconds
while time.time() < deadline: while time.monotonic() < deadline:
time.sleep(interval + _DEVICE_CODE_POLL_SAFETY_MARGIN) time.sleep(interval + _DEVICE_CODE_POLL_SAFETY_MARGIN)
poll_data = urllib.parse.urlencode({ poll_data = urllib.parse.urlencode({

View file

@ -585,10 +585,10 @@ def _wait_for_systemd_service_restart(
svc = get_service_name() svc = get_service_name()
scope_label = _service_scope_label(system).capitalize() scope_label = _service_scope_label(system).capitalize()
deadline = time.time() + timeout deadline = time.monotonic() + timeout
printed_runtime_wait = False printed_runtime_wait = False
while time.time() < deadline: while time.monotonic() < deadline:
props = _read_systemd_unit_properties(system=system) props = _read_systemd_unit_properties(system=system)
active_state = props.get("ActiveState", "") active_state = props.get("ActiveState", "")
sub_state = props.get("SubState", "") sub_state = props.get("SubState", "")

View file

@ -1877,8 +1877,8 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
name=f"oauth-codex-{sid[:6]}", name=f"oauth-codex-{sid[:6]}",
).start() ).start()
# Block briefly until the worker has populated the user_code, OR error. # Block briefly until the worker has populated the user_code, OR error.
deadline = time.time() + 10 deadline = time.monotonic() + 10
while time.time() < deadline: while time.monotonic() < deadline:
with _oauth_sessions_lock: with _oauth_sessions_lock:
s = _oauth_sessions.get(sid) s = _oauth_sessions.get(sid)
if s and (s.get("user_code") or s["status"] != "pending"): if s and (s.get("user_code") or s["status"] != "pending"):
@ -2012,10 +2012,10 @@ def _codex_full_login_worker(session_id: str) -> None:
sess["expires_at"] = time.time() + sess["expires_in"] sess["expires_at"] = time.time() + sess["expires_in"]
# Step 2: poll until authorized # Step 2: poll until authorized
deadline = time.time() + sess["expires_in"] deadline = time.monotonic() + sess["expires_in"]
code_resp = None code_resp = None
with httpx.Client(timeout=httpx.Timeout(15.0)) as client: with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
while time.time() < deadline: while time.monotonic() < deadline:
time.sleep(poll_interval) time.sleep(poll_interval)
poll = client.post( poll = client.post(
f"{issuer}/api/accounts/deviceauth/token", f"{issuer}/api/accounts/deviceauth/token",