From 392d13d9aada7457e4856cd72c0cb5b57082def3 Mon Sep 17 00:00:00 2001 From: Lilly Luo Date: Wed, 16 Sep 2026 20:39:36 +0000 Subject: [PATCH 1/6] Add custom OAuth lock hang reproduction --- scripts/repro_custom_oauth_lock_hang.py | 141 ++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 scripts/repro_custom_oauth_lock_hang.py diff --git a/scripts/repro_custom_oauth_lock_hang.py b/scripts/repro_custom_oauth_lock_hang.py new file mode 100644 index 00000000..5baafcb2 --- /dev/null +++ b/scripts/repro_custom_oauth_lock_hang.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Reproduce a hung UG custom-OAuth helper blocking another helper. + +This is entirely offline: it takes the same file lock introduced by PR #620, +but does not open a browser, read credentials, or contact a workspace. +""" + +import argparse +import fcntl +import multiprocessing +import os +import signal +from pathlib import Path + +DEFAULT_LOCK = Path.home() / ".config/databricks-sdk-py/oauth/ug-oauth-8020.lock" + + +def acquire_and_wait(lock_path, acquired, release): + lock_path.parent.mkdir(parents=True, exist_ok=True) + with lock_path.open("a+b") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX) + acquired.set() + release.wait() + fcntl.flock(lock_file, fcntl.LOCK_UN) + + +def acquire_and_report(lock_path, acquired): + with lock_path.open("a+b") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX) + acquired.set() + fcntl.flock(lock_file, fcntl.LOCK_UN) + + +def ensure_lock_is_free(lock_path): + lock_path.parent.mkdir(parents=True, exist_ok=True) + with lock_path.open("a+b") as lock_file: + try: + fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise SystemExit( + f"The lock is already held: {lock_path}\n" + f"Find its owner with: fuser -v {lock_path}" + ) from None + fcntl.flock(lock_file, fcntl.LOCK_UN) + + +def automatic_repro(lock_path, blocked_seconds): + ensure_lock_is_free(lock_path) + context = multiprocessing.get_context("spawn") + holder_acquired = context.Event() + release_holder = context.Event() + waiter_acquired = context.Event() + + holder = context.Process( + target=acquire_and_wait, + args=(lock_path, holder_acquired, release_holder), + name="hung-auth-token", + ) + waiter = context.Process( + target=acquire_and_report, + args=(lock_path, waiter_acquired), + name="second-auth-token", + ) + + try: + holder.start() + if not holder_acquired.wait(5): + raise SystemExit("The simulated hung helper could not acquire the lock") + print(f"Hung auth helper PID {holder.pid} holds {lock_path}") + + waiter.start() + if waiter_acquired.wait(blocked_seconds): + raise SystemExit("Reproduction failed: the second helper unexpectedly acquired the lock") + + print( + f"REPRODUCED: second auth helper PID {waiter.pid} remained blocked for " + f"{blocked_seconds:g}s" + ) + print(f"While blocked, the owner is visible with: fuser -v {lock_path}") + + release_holder.set() + holder.join(5) + waiter.join(5) + if holder.is_alive() or waiter.is_alive() or not waiter_acquired.is_set(): + raise SystemExit("Cleanup failed: a child process did not exit normally") + print("Released the holder; the second helper acquired the lock and exited.") + finally: + release_holder.set() + for process in (holder, waiter): + if process.pid is not None: + process.join(1) + if process.is_alive(): + process.terminate() + process.join(5) + + +def manual_repro(lock_path): + ensure_lock_is_free(lock_path) + stopping = multiprocessing.Event() + + def stop(_signum, _frame): + stopping.set() + + signal.signal(signal.SIGINT, stop) + signal.signal(signal.SIGTERM, stop) + with lock_path.open("a+b") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX) + print(f"PID {os.getpid()} holds {lock_path}", flush=True) + print("Start Claude Code or Codex through UG now; press Ctrl-C here to release.", flush=True) + while not stopping.wait(0.2): + pass + fcntl.flock(lock_file, fcntl.LOCK_UN) + print("Lock released.") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--lock-path", type=Path, default=DEFAULT_LOCK) + parser.add_argument( + "--blocked-seconds", + type=float, + default=2, + help="how long the automatic repro must observe the second helper blocked", + ) + parser.add_argument( + "--manual", + action="store_true", + help="hold the lock until Ctrl-C so a real UG launch can be tested", + ) + args = parser.parse_args() + if args.blocked_seconds <= 0: + parser.error("--blocked-seconds must be positive") + lock_path = args.lock_path.expanduser().resolve() + if args.manual: + manual_repro(lock_path) + else: + automatic_repro(lock_path, args.blocked_seconds) + + +if __name__ == "__main__": + main() From 5505bacded693ae783af15e2139215b76f4773e4 Mon Sep 17 00:00:00 2001 From: Lilly Luo Date: Wed, 16 Sep 2026 20:45:45 +0000 Subject: [PATCH 2/6] Bound custom OAuth lock waits --- src/ucode/custom_oauth.py | 43 +++++++++++++++++++++++++++++++++++--- tests/test_custom_oauth.py | 14 ++++++++++++- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/ucode/custom_oauth.py b/src/ucode/custom_oauth.py index c6b8ac7e..1eef0e69 100644 --- a/src/ucode/custom_oauth.py +++ b/src/ucode/custom_oauth.py @@ -5,8 +5,10 @@ import platform import shlex import subprocess +import time from collections.abc import Iterator, Sequence from contextlib import contextmanager +from os import getpid from pathlib import Path from typing import TypedDict from urllib.parse import urlparse @@ -20,6 +22,13 @@ DEFAULT_REDIRECT_URL = f"http://{LOCALHOST}:8020" # Custom OAuth may need a human to finish browser consent, not just a token fetch. CUSTOM_OAUTH_TIMEOUT_MS = 180_000 +# A waiter must never proceed without the lock: doing so would reopen the browser-storm race. Keep +# this shorter than the harness auth timeout so a blocked helper can report the owning PID. +CUSTOM_OAUTH_LOCK_TIMEOUT_SECONDS = 30.0 + + +class CustomOAuthLockTimeout(RuntimeError): + """Another custom-OAuth helper held the shared callback-port lock for too long.""" class CustomOAuthConfig(TypedDict): @@ -94,7 +103,12 @@ def build_custom_auth_shell_command(workspace: str, config: CustomOAuthConfig) - @contextmanager -def _custom_oauth_lock(cache_dir: Path, redirect_url: str) -> Iterator[None]: +def _custom_oauth_lock( + cache_dir: Path, + redirect_url: str, + *, + timeout_seconds: float = CUSTOM_OAUTH_LOCK_TIMEOUT_SECONDS, +) -> Iterator[None]: """Serialize helpers sharing a callback port with a POSIX file lock. Keep the lock file in place: unlinking it could let waiters lock different @@ -104,8 +118,29 @@ def _custom_oauth_lock(cache_dir: Path, redirect_url: str) -> Iterator[None]: cache_dir.mkdir(parents=True, exist_ok=True) port = urlparse(redirect_url).port - with (cache_dir / f"ug-oauth-{port}.lock").open("a+b") as lock_file: - fcntl.flock(lock_file, fcntl.LOCK_EX) + lock_path = cache_dir / f"ug-oauth-{port}.lock" + with lock_path.open("a+b") as lock_file: + deadline = time.monotonic() + timeout_seconds + while True: + try: + fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + remaining = deadline - time.monotonic() + if remaining <= 0: + lock_file.seek(0) + holder = lock_file.read().decode(errors="replace").strip() + holder_detail = f" PID {holder}" if holder.isdigit() else " an unknown process" + raise CustomOAuthLockTimeout( + f"Timed out after {timeout_seconds:g}s waiting for custom OAuth lock " + f"{lock_path}, held by{holder_detail}. If that process is no longer " + "authenticating, inspect it before terminating it." + ) from None + time.sleep(min(0.1, remaining)) + lock_file.seek(0) + lock_file.truncate() + lock_file.write(f"{getpid()}\n".encode()) + lock_file.flush() try: yield finally: @@ -168,6 +203,8 @@ def get_custom_client_token( raise ValueError("OAuth returned no access token") cache.save(credentials) return token + except CustomOAuthLockTimeout: + raise except Exception as exc: raise RuntimeError( "Custom-client OAuth failed. Check the workspace, client ID, and registered " diff --git a/tests/test_custom_oauth.py b/tests/test_custom_oauth.py index ed0d945a..211fb136 100644 --- a/tests/test_custom_oauth.py +++ b/tests/test_custom_oauth.py @@ -15,7 +15,7 @@ import ucode.cli as cli_mod import ucode.databricks as db_mod from ucode.cli import app -from ucode.custom_oauth import _custom_oauth_lock, get_custom_client_token +from ucode.custom_oauth import CustomOAuthLockTimeout, _custom_oauth_lock, get_custom_client_token WS = "https://example.databricks.com" TEST_SCOPES = ("offline_access", "catalog.catalogs:read") @@ -30,6 +30,18 @@ def test_releases_lock_when_login_fails(self, tmp_path): with _custom_oauth_lock(tmp_path, "http://127.0.0.1:8020/other-callback"): assert len(list(tmp_path.glob("*.lock"))) == 1 + def test_times_out_with_holder_pid_without_entering(self, tmp_path): + entered = False + with _custom_oauth_lock(tmp_path, "http://localhost:8020/callback"): + with pytest.raises(CustomOAuthLockTimeout, match=r"held by PID \d+"): + with _custom_oauth_lock( + tmp_path, + "http://localhost:8020/callback", + timeout_seconds=0.01, + ): + entered = True + assert entered is False + class TestCustomClientToken: @pytest.fixture(autouse=True) From 090a12fdc084a6bf62936baed915ec247957f2f3 Mon Sep 17 00:00:00 2001 From: Lilly Luo Date: Wed, 16 Sep 2026 20:46:57 +0000 Subject: [PATCH 3/6] Require explicit OAuth lock timeout --- src/ucode/custom_oauth.py | 8 ++++++-- tests/test_custom_oauth.py | 10 +++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/ucode/custom_oauth.py b/src/ucode/custom_oauth.py index 1eef0e69..84e6a272 100644 --- a/src/ucode/custom_oauth.py +++ b/src/ucode/custom_oauth.py @@ -107,7 +107,7 @@ def _custom_oauth_lock( cache_dir: Path, redirect_url: str, *, - timeout_seconds: float = CUSTOM_OAUTH_LOCK_TIMEOUT_SECONDS, + timeout_seconds: float, ) -> Iterator[None]: """Serialize helpers sharing a callback port with a POSIX file lock. @@ -167,7 +167,11 @@ def get_custom_client_token( redirect_url=config["redirect_url"], scopes=config["scopes"], ) - with _custom_oauth_lock(Path(cache.filename).parent, config["redirect_url"]): + with _custom_oauth_lock( + Path(cache.filename).parent, + config["redirect_url"], + timeout_seconds=CUSTOM_OAUTH_LOCK_TIMEOUT_SECONDS, + ): # Read only after acquiring the lock: another helper may have just # completed login or rotated the refresh token while we waited. credentials = cache.load() diff --git a/tests/test_custom_oauth.py b/tests/test_custom_oauth.py index 211fb136..3e8a89a4 100644 --- a/tests/test_custom_oauth.py +++ b/tests/test_custom_oauth.py @@ -25,14 +25,18 @@ class TestCustomOAuthLock: def test_releases_lock_when_login_fails(self, tmp_path): with pytest.raises(ValueError, match="login failed"): - with _custom_oauth_lock(tmp_path, "http://localhost:8020/callback"): + with _custom_oauth_lock( + tmp_path, "http://localhost:8020/callback", timeout_seconds=1 + ): raise ValueError("login failed") - with _custom_oauth_lock(tmp_path, "http://127.0.0.1:8020/other-callback"): + with _custom_oauth_lock( + tmp_path, "http://127.0.0.1:8020/other-callback", timeout_seconds=1 + ): assert len(list(tmp_path.glob("*.lock"))) == 1 def test_times_out_with_holder_pid_without_entering(self, tmp_path): entered = False - with _custom_oauth_lock(tmp_path, "http://localhost:8020/callback"): + with _custom_oauth_lock(tmp_path, "http://localhost:8020/callback", timeout_seconds=1): with pytest.raises(CustomOAuthLockTimeout, match=r"held by PID \d+"): with _custom_oauth_lock( tmp_path, From beef8c26e179289ba8040939aec21cd52304afda Mon Sep 17 00:00:00 2001 From: Lilly Luo Date: Wed, 16 Sep 2026 20:48:22 +0000 Subject: [PATCH 4/6] Verify bounded OAuth lock behavior in repro --- scripts/repro_custom_oauth_lock_hang.py | 80 +++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/scripts/repro_custom_oauth_lock_hang.py b/scripts/repro_custom_oauth_lock_hang.py index 5baafcb2..79d0b48c 100644 --- a/scripts/repro_custom_oauth_lock_hang.py +++ b/scripts/repro_custom_oauth_lock_hang.py @@ -11,6 +11,7 @@ import os import signal from pathlib import Path +from queue import Empty DEFAULT_LOCK = Path.home() / ".config/databricks-sdk-py/oauth/ug-oauth-8020.lock" @@ -19,6 +20,10 @@ def acquire_and_wait(lock_path, acquired, release): lock_path.parent.mkdir(parents=True, exist_ok=True) with lock_path.open("a+b") as lock_file: fcntl.flock(lock_file, fcntl.LOCK_EX) + lock_file.seek(0) + lock_file.truncate() + lock_file.write(f"{os.getpid()}\n".encode()) + lock_file.flush() acquired.set() release.wait() fcntl.flock(lock_file, fcntl.LOCK_UN) @@ -31,6 +36,25 @@ def acquire_and_report(lock_path, acquired): fcntl.flock(lock_file, fcntl.LOCK_UN) +def acquire_with_ug_timeout(lock_path, timeout_seconds, result): + from ucode.custom_oauth import CustomOAuthLockTimeout, _custom_oauth_lock + + prefix = "ug-oauth-" + if not lock_path.stem.startswith(prefix): + result.put(("error", f"lock filename must look like {prefix}.lock")) + return + port = int(lock_path.stem.removeprefix(prefix)) + try: + with _custom_oauth_lock( + lock_path.parent, + f"http://localhost:{port}/callback", + timeout_seconds=timeout_seconds, + ): + result.put(("entered", "waiter entered the protected OAuth section")) + except CustomOAuthLockTimeout as exc: + result.put(("timeout", str(exc))) + + def ensure_lock_is_free(lock_path): lock_path.parent.mkdir(parents=True, exist_ok=True) with lock_path.open("a+b") as lock_file: @@ -94,6 +118,50 @@ def automatic_repro(lock_path, blocked_seconds): process.join(5) +def verify_fix(lock_path, timeout_seconds): + ensure_lock_is_free(lock_path) + context = multiprocessing.get_context("spawn") + holder_acquired = context.Event() + release_holder = context.Event() + result = context.Queue() + holder = context.Process( + target=acquire_and_wait, + args=(lock_path, holder_acquired, release_holder), + name="hung-auth-token", + ) + waiter = context.Process( + target=acquire_with_ug_timeout, + args=(lock_path, timeout_seconds, result), + name="bounded-auth-token", + ) + try: + holder.start() + if not holder_acquired.wait(5): + raise SystemExit("The simulated hung helper could not acquire the lock") + waiter.start() + waiter.join(timeout_seconds + 5) + if waiter.is_alive(): + raise SystemExit("FIX FAILED: UG's waiter remained blocked beyond its deadline") + try: + outcome, detail = result.get(timeout=1) + except Empty: + raise SystemExit("FIX FAILED: UG's waiter exited without reporting an outcome") from None + if outcome != "timeout": + raise SystemExit(f"FIX FAILED: {detail}") + print(f"FIX VERIFIED: {detail}") + print("The waiter exited without entering OAuth; no second browser flow was started.") + finally: + release_holder.set() + for process in (holder, waiter): + if process.pid is not None: + process.join(1) + if process.is_alive(): + process.terminate() + process.join(5) + result.close() + result.join_thread() + + def manual_repro(lock_path): ensure_lock_is_free(lock_path) stopping = multiprocessing.Event() @@ -127,12 +195,24 @@ def main(): action="store_true", help="hold the lock until Ctrl-C so a real UG launch can be tested", ) + parser.add_argument( + "--verify-fix", + metavar="SECONDS", + type=float, + help="use UG's real bounded lock helper and require it to time out after SECONDS", + ) args = parser.parse_args() if args.blocked_seconds <= 0: parser.error("--blocked-seconds must be positive") lock_path = args.lock_path.expanduser().resolve() + if args.verify_fix is not None and args.verify_fix <= 0: + parser.error("--verify-fix must be positive") + if args.manual and args.verify_fix is not None: + parser.error("--manual and --verify-fix cannot be used together") if args.manual: manual_repro(lock_path) + elif args.verify_fix is not None: + verify_fix(lock_path, args.verify_fix) else: automatic_repro(lock_path, args.blocked_seconds) From b53ff2b5b54260b5ec1d2c4470858117b384988f Mon Sep 17 00:00:00 2001 From: Lilly Luo Date: Wed, 16 Sep 2026 21:01:40 +0000 Subject: [PATCH 5/6] Expire stalled custom OAuth lock owners --- scripts/repro_custom_oauth_lock_hang.py | 84 ++++++++++++++++--------- src/ucode/custom_oauth.py | 56 ++++++++++++++--- tests/test_agent_codex.py | 2 +- tests/test_custom_oauth.py | 55 ++++++++++++++-- tests/test_state.py | 2 +- 5 files changed, 154 insertions(+), 45 deletions(-) diff --git a/scripts/repro_custom_oauth_lock_hang.py b/scripts/repro_custom_oauth_lock_hang.py index 79d0b48c..6a10c071 100644 --- a/scripts/repro_custom_oauth_lock_hang.py +++ b/scripts/repro_custom_oauth_lock_hang.py @@ -10,6 +10,7 @@ import multiprocessing import os import signal +import time from pathlib import Path from queue import Empty @@ -36,23 +37,41 @@ def acquire_and_report(lock_path, acquired): fcntl.flock(lock_file, fcntl.LOCK_UN) -def acquire_with_ug_timeout(lock_path, timeout_seconds, result): - from ucode.custom_oauth import CustomOAuthLockTimeout, _custom_oauth_lock +def hold_with_ug_lease(lock_path, lease_seconds, acquired, result): + from ucode.custom_oauth import ( + CustomOAuthFlowTimeout, + _custom_oauth_lock, + ) - prefix = "ug-oauth-" - if not lock_path.stem.startswith(prefix): - result.put(("error", f"lock filename must look like {prefix}.lock")) - return - port = int(lock_path.stem.removeprefix(prefix)) + port = int(lock_path.stem.removeprefix("ug-oauth-")) try: with _custom_oauth_lock( lock_path.parent, f"http://localhost:{port}/callback", - timeout_seconds=timeout_seconds, + timeout_seconds=1, + lease_seconds=lease_seconds, ): - result.put(("entered", "waiter entered the protected OAuth section")) - except CustomOAuthLockTimeout as exc: - result.put(("timeout", str(exc))) + acquired.set() + time.sleep(lease_seconds + 60) + except CustomOAuthFlowTimeout as exc: + result.put(("expired", str(exc))) + + +def acquire_after_owner(lock_path, wait_seconds, result): + from ucode.custom_oauth import _custom_oauth_lock + + prefix = "ug-oauth-" + if not lock_path.stem.startswith(prefix): + result.put(("error", f"lock filename must look like {prefix}.lock")) + return + port = int(lock_path.stem.removeprefix(prefix)) + with _custom_oauth_lock( + lock_path.parent, + f"http://localhost:{port}/callback", + timeout_seconds=wait_seconds, + lease_seconds=wait_seconds, + ): + result.put(("acquired", "waiter acquired the lock after the owner's lease expired")) def ensure_lock_is_free(lock_path): @@ -118,20 +137,20 @@ def automatic_repro(lock_path, blocked_seconds): process.join(5) -def verify_fix(lock_path, timeout_seconds): +def verify_fix(lock_path, lease_seconds): ensure_lock_is_free(lock_path) context = multiprocessing.get_context("spawn") holder_acquired = context.Event() - release_holder = context.Event() - result = context.Queue() + holder_result = context.Queue() + waiter_result = context.Queue() holder = context.Process( - target=acquire_and_wait, - args=(lock_path, holder_acquired, release_holder), + target=hold_with_ug_lease, + args=(lock_path, lease_seconds, holder_acquired, holder_result), name="hung-auth-token", ) waiter = context.Process( - target=acquire_with_ug_timeout, - args=(lock_path, timeout_seconds, result), + target=acquire_after_owner, + args=(lock_path, lease_seconds + 5, waiter_result), name="bounded-auth-token", ) try: @@ -139,27 +158,30 @@ def verify_fix(lock_path, timeout_seconds): if not holder_acquired.wait(5): raise SystemExit("The simulated hung helper could not acquire the lock") waiter.start() - waiter.join(timeout_seconds + 5) - if waiter.is_alive(): - raise SystemExit("FIX FAILED: UG's waiter remained blocked beyond its deadline") + holder.join(lease_seconds + 5) + waiter.join(lease_seconds + 10) + if holder.is_alive() or waiter.is_alive(): + raise SystemExit("FIX FAILED: a helper remained blocked beyond the owner's lease") try: - outcome, detail = result.get(timeout=1) + holder_outcome, holder_detail = holder_result.get(timeout=1) + waiter_outcome, waiter_detail = waiter_result.get(timeout=1) except Empty: - raise SystemExit("FIX FAILED: UG's waiter exited without reporting an outcome") from None - if outcome != "timeout": - raise SystemExit(f"FIX FAILED: {detail}") - print(f"FIX VERIFIED: {detail}") - print("The waiter exited without entering OAuth; no second browser flow was started.") + raise SystemExit("FIX FAILED: a helper exited without reporting an outcome") from None + if holder_outcome != "expired" or waiter_outcome != "acquired": + raise SystemExit(f"FIX FAILED: {holder_detail}; {waiter_detail}") + print(f"OWNER EVICTED: {holder_detail}") + print(f"FIX VERIFIED: {waiter_detail}.") + print("Only the lock owner can enter OAuth at any time, so browser flows remain serialized.") finally: - release_holder.set() for process in (holder, waiter): if process.pid is not None: process.join(1) if process.is_alive(): process.terminate() process.join(5) - result.close() - result.join_thread() + for result in (holder_result, waiter_result): + result.close() + result.join_thread() def manual_repro(lock_path): @@ -199,7 +221,7 @@ def main(): "--verify-fix", metavar="SECONDS", type=float, - help="use UG's real bounded lock helper and require it to time out after SECONDS", + help="use UG's real lock helper and verify an owner is evicted after SECONDS", ) args = parser.parse_args() if args.blocked_seconds <= 0: diff --git a/src/ucode/custom_oauth.py b/src/ucode/custom_oauth.py index 84e6a272..4683e2f4 100644 --- a/src/ucode/custom_oauth.py +++ b/src/ucode/custom_oauth.py @@ -3,7 +3,9 @@ from __future__ import annotations import platform +import random import shlex +import signal import subprocess import time from collections.abc import Iterator, Sequence @@ -20,17 +22,22 @@ from ucode.ui import err_console, normalize_workspace_url, print_warning_err DEFAULT_REDIRECT_URL = f"http://{LOCALHOST}:8020" -# Custom OAuth may need a human to finish browser consent, not just a token fetch. -CUSTOM_OAUTH_TIMEOUT_MS = 180_000 -# A waiter must never proceed without the lock: doing so would reopen the browser-storm race. Keep -# this shorter than the harness auth timeout so a blocked helper can report the owning PID. -CUSTOM_OAUTH_LOCK_TIMEOUT_SECONDS = 30.0 +# Custom OAuth may need a human to finish browser consent. An owner gets three minutes; a waiter gets +# a little longer so it can inherit the lock after that lease expires. Codex's outer process timeout +# covers both a full wait and a fresh owner's full browser flow. +CUSTOM_OAUTH_FLOW_TIMEOUT_SECONDS = 180.0 +CUSTOM_OAUTH_LOCK_TIMEOUT_SECONDS = 185.0 +CUSTOM_OAUTH_TIMEOUT_MS = 370_000 class CustomOAuthLockTimeout(RuntimeError): """Another custom-OAuth helper held the shared callback-port lock for too long.""" +class CustomOAuthFlowTimeout(RuntimeError): + """The custom-OAuth lock owner exceeded its authentication lease.""" + + class CustomOAuthConfig(TypedDict): client_id: str redirect_url: str @@ -108,6 +115,7 @@ def _custom_oauth_lock( redirect_url: str, *, timeout_seconds: float, + lease_seconds: float, ) -> Iterator[None]: """Serialize helpers sharing a callback port with a POSIX file lock. @@ -121,6 +129,7 @@ def _custom_oauth_lock( lock_path = cache_dir / f"ug-oauth-{port}.lock" with lock_path.open("a+b") as lock_file: deadline = time.monotonic() + timeout_seconds + delay = 0.1 while True: try: fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) @@ -136,17 +145,47 @@ def _custom_oauth_lock( f"{lock_path}, held by{holder_detail}. If that process is no longer " "authenticating, inspect it before terminating it." ) from None - time.sleep(min(0.1, remaining)) + time.sleep(min(random.uniform(delay / 2, delay), remaining)) + delay = min(delay * 2, 5.0) lock_file.seek(0) lock_file.truncate() lock_file.write(f"{getpid()}\n".encode()) lock_file.flush() try: - yield + with _custom_oauth_flow_deadline(lease_seconds): + yield finally: fcntl.flock(lock_file, fcntl.LOCK_UN) +@contextmanager +def _custom_oauth_flow_deadline(timeout_seconds: float) -> Iterator[None]: + """Interrupt a lock owner's OAuth work so its advisory lock cannot live forever.""" + + def expire(_signum: int, _frame: object) -> None: + raise CustomOAuthFlowTimeout( + f"Custom OAuth did not finish within {timeout_seconds:g}s; its lock was released. Retry " + "the coding agent to start a new authentication attempt." + ) + + previous_handler = signal.signal(signal.SIGALRM, expire) + previous_timer = signal.setitimer(signal.ITIMER_REAL, timeout_seconds) + started = time.monotonic() + try: + yield + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, previous_handler) + previous_remaining, previous_interval = previous_timer + if previous_remaining > 0: + elapsed = time.monotonic() - started + signal.setitimer( + signal.ITIMER_REAL, + max(previous_remaining - elapsed, 1e-6), + previous_interval, + ) + + def get_custom_client_token( workspace: str, client_id: str, @@ -171,6 +210,7 @@ def get_custom_client_token( Path(cache.filename).parent, config["redirect_url"], timeout_seconds=CUSTOM_OAUTH_LOCK_TIMEOUT_SECONDS, + lease_seconds=CUSTOM_OAUTH_FLOW_TIMEOUT_SECONDS, ): # Read only after acquiring the lock: another helper may have just # completed login or rotated the refresh token while we waited. @@ -207,7 +247,7 @@ def get_custom_client_token( raise ValueError("OAuth returned no access token") cache.save(credentials) return token - except CustomOAuthLockTimeout: + except (CustomOAuthFlowTimeout, CustomOAuthLockTimeout): raise except Exception as exc: raise RuntimeError( diff --git a/tests/test_agent_codex.py b/tests/test_agent_codex.py index a95a1fe6..4e960176 100644 --- a/tests/test_agent_codex.py +++ b/tests/test_agent_codex.py @@ -145,7 +145,7 @@ def test_auth_timeout_allows_custom_browser_login_only(self, render, custom): ) overlay = render(WS, custom_oauth=config) auth = overlay["model_providers"][codex.CODEX_MODEL_PROVIDER_NAME]["auth"] - assert auth["timeout_ms"] == (180_000 if custom else 5000) + assert auth["timeout_ms"] == (370_000 if custom else 5000) def test_provider_adds_routing_header(self): overlay = codex.render_overlay(WS, provider="main.aarushi.aarushi-openai") diff --git a/tests/test_custom_oauth.py b/tests/test_custom_oauth.py index 3e8a89a4..79820d62 100644 --- a/tests/test_custom_oauth.py +++ b/tests/test_custom_oauth.py @@ -3,6 +3,8 @@ from __future__ import annotations import json +import signal +import time from datetime import UTC, datetime, timedelta from pathlib import Path from unittest.mock import Mock, patch @@ -15,7 +17,13 @@ import ucode.cli as cli_mod import ucode.databricks as db_mod from ucode.cli import app -from ucode.custom_oauth import CustomOAuthLockTimeout, _custom_oauth_lock, get_custom_client_token +from ucode.custom_oauth import ( + CustomOAuthFlowTimeout, + CustomOAuthLockTimeout, + _custom_oauth_flow_deadline, + _custom_oauth_lock, + get_custom_client_token, +) WS = "https://example.databricks.com" TEST_SCOPES = ("offline_access", "catalog.catalogs:read") @@ -26,26 +34,65 @@ class TestCustomOAuthLock: def test_releases_lock_when_login_fails(self, tmp_path): with pytest.raises(ValueError, match="login failed"): with _custom_oauth_lock( - tmp_path, "http://localhost:8020/callback", timeout_seconds=1 + tmp_path, + "http://localhost:8020/callback", + timeout_seconds=1, + lease_seconds=1, ): raise ValueError("login failed") with _custom_oauth_lock( - tmp_path, "http://127.0.0.1:8020/other-callback", timeout_seconds=1 + tmp_path, + "http://127.0.0.1:8020/other-callback", + timeout_seconds=1, + lease_seconds=1, ): assert len(list(tmp_path.glob("*.lock"))) == 1 def test_times_out_with_holder_pid_without_entering(self, tmp_path): entered = False - with _custom_oauth_lock(tmp_path, "http://localhost:8020/callback", timeout_seconds=1): + with _custom_oauth_lock( + tmp_path, + "http://localhost:8020/callback", + timeout_seconds=1, + lease_seconds=1, + ): with pytest.raises(CustomOAuthLockTimeout, match=r"held by PID \d+"): with _custom_oauth_lock( tmp_path, "http://localhost:8020/callback", timeout_seconds=0.01, + lease_seconds=1, ): entered = True assert entered is False + def test_owner_lease_interrupts_work_and_releases_lock(self, tmp_path): + with pytest.raises(CustomOAuthFlowTimeout, match="its lock was released"): + with _custom_oauth_lock( + tmp_path, + "http://localhost:8020/callback", + timeout_seconds=1, + lease_seconds=0.01, + ): + time.sleep(1) + with _custom_oauth_lock( + tmp_path, + "http://localhost:8020/callback", + timeout_seconds=1, + lease_seconds=1, + ): + pass + + def test_flow_deadline_restores_an_existing_timer(self): + signal.setitimer(signal.ITIMER_REAL, 10) + try: + with _custom_oauth_flow_deadline(1): + pass + remaining, _ = signal.getitimer(signal.ITIMER_REAL) + assert remaining == pytest.approx(10, abs=0.1) + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + class TestCustomClientToken: @pytest.fixture(autouse=True) diff --git a/tests/test_state.py b/tests/test_state.py index 33dd6aba..1a6c60cd 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -307,7 +307,7 @@ def test_custom_oauth_applies_to_claude_and_codex(self): assert "--client-id custom-client" in result["claude"]["auth_command"] assert result["codex"]["auth"]["args"][-1] == "offline_access,model-serving" - assert result["codex"]["auth"]["timeout_ms"] == 180_000 + assert result["codex"]["auth"]["timeout_ms"] == 370_000 # --------------------------------------------------------------------------- From 2ed79c422eebe851edf47c86b7fd0744ee4a8b2e Mon Sep 17 00:00:00 2001 From: Lilly Luo Date: Wed, 16 Sep 2026 21:12:28 +0000 Subject: [PATCH 6/6] Simplify custom OAuth lock lease --- scripts/repro_custom_oauth_lock_hang.py | 253 +++++------------------- src/ucode/custom_oauth.py | 49 +---- tests/test_agent_codex.py | 2 +- tests/test_custom_oauth.py | 35 ---- tests/test_state.py | 2 +- 5 files changed, 59 insertions(+), 282 deletions(-) diff --git a/scripts/repro_custom_oauth_lock_hang.py b/scripts/repro_custom_oauth_lock_hang.py index 6a10c071..4c26159a 100644 --- a/scripts/repro_custom_oauth_lock_hang.py +++ b/scripts/repro_custom_oauth_lock_hang.py @@ -1,242 +1,91 @@ #!/usr/bin/env python3 -"""Reproduce a hung UG custom-OAuth helper blocking another helper. - -This is entirely offline: it takes the same file lock introduced by PR #620, -but does not open a browser, read credentials, or contact a workspace. -""" +"""Reproduce the old OAuth lock hang or verify the owner-lease fix, offline.""" import argparse import fcntl import multiprocessing -import os -import signal import time from pathlib import Path -from queue import Empty - -DEFAULT_LOCK = Path.home() / ".config/databricks-sdk-py/oauth/ug-oauth-8020.lock" +from ucode.custom_oauth import CustomOAuthFlowTimeout, _custom_oauth_lock -def acquire_and_wait(lock_path, acquired, release): - lock_path.parent.mkdir(parents=True, exist_ok=True) - with lock_path.open("a+b") as lock_file: - fcntl.flock(lock_file, fcntl.LOCK_EX) - lock_file.seek(0) - lock_file.truncate() - lock_file.write(f"{os.getpid()}\n".encode()) - lock_file.flush() - acquired.set() - release.wait() - fcntl.flock(lock_file, fcntl.LOCK_UN) +DEFAULT_LOCK = Path("/tmp/ug-oauth-lock-repro/ug-oauth-8020.lock") -def acquire_and_report(lock_path, acquired): +def _raw_owner(lock_path, ready, seconds): + lock_path.parent.mkdir(parents=True, exist_ok=True) with lock_path.open("a+b") as lock_file: fcntl.flock(lock_file, fcntl.LOCK_EX) - acquired.set() - fcntl.flock(lock_file, fcntl.LOCK_UN) - + ready.set() + time.sleep(seconds + 60) -def hold_with_ug_lease(lock_path, lease_seconds, acquired, result): - from ucode.custom_oauth import ( - CustomOAuthFlowTimeout, - _custom_oauth_lock, - ) - port = int(lock_path.stem.removeprefix("ug-oauth-")) +def _leased_owner(lock_path, ready, seconds): try: with _custom_oauth_lock( lock_path.parent, - f"http://localhost:{port}/callback", - timeout_seconds=1, - lease_seconds=lease_seconds, + "http://localhost:8020/callback", + lease_seconds=seconds, ): - acquired.set() - time.sleep(lease_seconds + 60) - except CustomOAuthFlowTimeout as exc: - result.put(("expired", str(exc))) + ready.set() + time.sleep(seconds + 60) + except CustomOAuthFlowTimeout: + pass -def acquire_after_owner(lock_path, wait_seconds, result): - from ucode.custom_oauth import _custom_oauth_lock - - prefix = "ug-oauth-" - if not lock_path.stem.startswith(prefix): - result.put(("error", f"lock filename must look like {prefix}.lock")) - return - port = int(lock_path.stem.removeprefix(prefix)) +def _waiter(lock_path, acquired): with _custom_oauth_lock( lock_path.parent, - f"http://localhost:{port}/callback", - timeout_seconds=wait_seconds, - lease_seconds=wait_seconds, + "http://localhost:8020/callback", + lease_seconds=5, ): - result.put(("acquired", "waiter acquired the lock after the owner's lease expired")) - + acquired.set() -def ensure_lock_is_free(lock_path): - lock_path.parent.mkdir(parents=True, exist_ok=True) - with lock_path.open("a+b") as lock_file: - try: - fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError: - raise SystemExit( - f"The lock is already held: {lock_path}\n" - f"Find its owner with: fuser -v {lock_path}" - ) from None - fcntl.flock(lock_file, fcntl.LOCK_UN) - - -def automatic_repro(lock_path, blocked_seconds): - ensure_lock_is_free(lock_path) - context = multiprocessing.get_context("spawn") - holder_acquired = context.Event() - release_holder = context.Event() - waiter_acquired = context.Event() - - holder = context.Process( - target=acquire_and_wait, - args=(lock_path, holder_acquired, release_holder), - name="hung-auth-token", - ) - waiter = context.Process( - target=acquire_and_report, - args=(lock_path, waiter_acquired), - name="second-auth-token", - ) - try: - holder.start() - if not holder_acquired.wait(5): - raise SystemExit("The simulated hung helper could not acquire the lock") - print(f"Hung auth helper PID {holder.pid} holds {lock_path}") - - waiter.start() - if waiter_acquired.wait(blocked_seconds): - raise SystemExit("Reproduction failed: the second helper unexpectedly acquired the lock") - - print( - f"REPRODUCED: second auth helper PID {waiter.pid} remained blocked for " - f"{blocked_seconds:g}s" - ) - print(f"While blocked, the owner is visible with: fuser -v {lock_path}") - - release_holder.set() - holder.join(5) - waiter.join(5) - if holder.is_alive() or waiter.is_alive() or not waiter_acquired.is_set(): - raise SystemExit("Cleanup failed: a child process did not exit normally") - print("Released the holder; the second helper acquired the lock and exited.") - finally: - release_holder.set() - for process in (holder, waiter): - if process.pid is not None: - process.join(1) - if process.is_alive(): - process.terminate() - process.join(5) +def _stop(processes): + for process in processes: + if process.is_alive(): + process.terminate() + process.join(5) -def verify_fix(lock_path, lease_seconds): - ensure_lock_is_free(lock_path) +def run(mode, seconds): + lock_path = DEFAULT_LOCK context = multiprocessing.get_context("spawn") - holder_acquired = context.Event() - holder_result = context.Queue() - waiter_result = context.Queue() - holder = context.Process( - target=hold_with_ug_lease, - args=(lock_path, lease_seconds, holder_acquired, holder_result), - name="hung-auth-token", - ) - waiter = context.Process( - target=acquire_after_owner, - args=(lock_path, lease_seconds + 5, waiter_result), - name="bounded-auth-token", - ) + ready = context.Event() + acquired = context.Event() + owner_target = _raw_owner if mode == "broken" else _leased_owner + owner = context.Process(target=owner_target, args=(lock_path, ready, seconds)) + waiter = context.Process(target=_waiter, args=(lock_path, acquired)) + processes = (owner, waiter) + try: - holder.start() - if not holder_acquired.wait(5): - raise SystemExit("The simulated hung helper could not acquire the lock") + owner.start() + if not ready.wait(5): + raise SystemExit("Owner failed to acquire the lock") waiter.start() - holder.join(lease_seconds + 5) - waiter.join(lease_seconds + 10) - if holder.is_alive() or waiter.is_alive(): - raise SystemExit("FIX FAILED: a helper remained blocked beyond the owner's lease") - try: - holder_outcome, holder_detail = holder_result.get(timeout=1) - waiter_outcome, waiter_detail = waiter_result.get(timeout=1) - except Empty: - raise SystemExit("FIX FAILED: a helper exited without reporting an outcome") from None - if holder_outcome != "expired" or waiter_outcome != "acquired": - raise SystemExit(f"FIX FAILED: {holder_detail}; {waiter_detail}") - print(f"OWNER EVICTED: {holder_detail}") - print(f"FIX VERIFIED: {waiter_detail}.") - print("Only the lock owner can enter OAuth at any time, so browser flows remain serialized.") + waiter.join(seconds + 2) + + if mode == "broken": + if acquired.is_set(): + raise SystemExit("Unexpectedly acquired the lock") + print("BROKEN REPRODUCED: the waiter is still blocked behind the hung owner.") + else: + if waiter.is_alive() or not acquired.is_set(): + raise SystemExit("FIX FAILED: the waiter did not acquire the released lock") + print("FIX VERIFIED: the owner lease expired and the waiter acquired the lock.") finally: - for process in (holder, waiter): - if process.pid is not None: - process.join(1) - if process.is_alive(): - process.terminate() - process.join(5) - for result in (holder_result, waiter_result): - result.close() - result.join_thread() - - -def manual_repro(lock_path): - ensure_lock_is_free(lock_path) - stopping = multiprocessing.Event() - - def stop(_signum, _frame): - stopping.set() - - signal.signal(signal.SIGINT, stop) - signal.signal(signal.SIGTERM, stop) - with lock_path.open("a+b") as lock_file: - fcntl.flock(lock_file, fcntl.LOCK_EX) - print(f"PID {os.getpid()} holds {lock_path}", flush=True) - print("Start Claude Code or Codex through UG now; press Ctrl-C here to release.", flush=True) - while not stopping.wait(0.2): - pass - fcntl.flock(lock_file, fcntl.LOCK_UN) - print("Lock released.") + _stop(processes) def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--lock-path", type=Path, default=DEFAULT_LOCK) - parser.add_argument( - "--blocked-seconds", - type=float, - default=2, - help="how long the automatic repro must observe the second helper blocked", - ) - parser.add_argument( - "--manual", - action="store_true", - help="hold the lock until Ctrl-C so a real UG launch can be tested", - ) - parser.add_argument( - "--verify-fix", - metavar="SECONDS", - type=float, - help="use UG's real lock helper and verify an owner is evicted after SECONDS", - ) + parser.add_argument("mode", choices=("broken", "fixed")) + parser.add_argument("--seconds", type=float, required=True) args = parser.parse_args() - if args.blocked_seconds <= 0: - parser.error("--blocked-seconds must be positive") - lock_path = args.lock_path.expanduser().resolve() - if args.verify_fix is not None and args.verify_fix <= 0: - parser.error("--verify-fix must be positive") - if args.manual and args.verify_fix is not None: - parser.error("--manual and --verify-fix cannot be used together") - if args.manual: - manual_repro(lock_path) - elif args.verify_fix is not None: - verify_fix(lock_path, args.verify_fix) - else: - automatic_repro(lock_path, args.blocked_seconds) + if args.seconds <= 0: + parser.error("--seconds must be positive") + run(args.mode, args.seconds) if __name__ == "__main__": diff --git a/src/ucode/custom_oauth.py b/src/ucode/custom_oauth.py index 4683e2f4..39eb219a 100644 --- a/src/ucode/custom_oauth.py +++ b/src/ucode/custom_oauth.py @@ -3,11 +3,9 @@ from __future__ import annotations import platform -import random import shlex import signal import subprocess -import time from collections.abc import Iterator, Sequence from contextlib import contextmanager from os import getpid @@ -22,16 +20,10 @@ from ucode.ui import err_console, normalize_workspace_url, print_warning_err DEFAULT_REDIRECT_URL = f"http://{LOCALHOST}:8020" -# Custom OAuth may need a human to finish browser consent. An owner gets three minutes; a waiter gets -# a little longer so it can inherit the lock after that lease expires. Codex's outer process timeout -# covers both a full wait and a fresh owner's full browser flow. +# Custom OAuth may need a human to finish browser consent. Codex's outer process timeout covers one +# full wait followed by a fresh owner's full browser flow. CUSTOM_OAUTH_FLOW_TIMEOUT_SECONDS = 180.0 -CUSTOM_OAUTH_LOCK_TIMEOUT_SECONDS = 185.0 -CUSTOM_OAUTH_TIMEOUT_MS = 370_000 - - -class CustomOAuthLockTimeout(RuntimeError): - """Another custom-OAuth helper held the shared callback-port lock for too long.""" +CUSTOM_OAUTH_TIMEOUT_MS = 365_000 class CustomOAuthFlowTimeout(RuntimeError): @@ -114,7 +106,6 @@ def _custom_oauth_lock( cache_dir: Path, redirect_url: str, *, - timeout_seconds: float, lease_seconds: float, ) -> Iterator[None]: """Serialize helpers sharing a callback port with a POSIX file lock. @@ -128,25 +119,7 @@ def _custom_oauth_lock( port = urlparse(redirect_url).port lock_path = cache_dir / f"ug-oauth-{port}.lock" with lock_path.open("a+b") as lock_file: - deadline = time.monotonic() + timeout_seconds - delay = 0.1 - while True: - try: - fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) - break - except BlockingIOError: - remaining = deadline - time.monotonic() - if remaining <= 0: - lock_file.seek(0) - holder = lock_file.read().decode(errors="replace").strip() - holder_detail = f" PID {holder}" if holder.isdigit() else " an unknown process" - raise CustomOAuthLockTimeout( - f"Timed out after {timeout_seconds:g}s waiting for custom OAuth lock " - f"{lock_path}, held by{holder_detail}. If that process is no longer " - "authenticating, inspect it before terminating it." - ) from None - time.sleep(min(random.uniform(delay / 2, delay), remaining)) - delay = min(delay * 2, 5.0) + fcntl.flock(lock_file, fcntl.LOCK_EX) lock_file.seek(0) lock_file.truncate() lock_file.write(f"{getpid()}\n".encode()) @@ -169,21 +142,12 @@ def expire(_signum: int, _frame: object) -> None: ) previous_handler = signal.signal(signal.SIGALRM, expire) - previous_timer = signal.setitimer(signal.ITIMER_REAL, timeout_seconds) - started = time.monotonic() + signal.setitimer(signal.ITIMER_REAL, timeout_seconds) try: yield finally: signal.setitimer(signal.ITIMER_REAL, 0) signal.signal(signal.SIGALRM, previous_handler) - previous_remaining, previous_interval = previous_timer - if previous_remaining > 0: - elapsed = time.monotonic() - started - signal.setitimer( - signal.ITIMER_REAL, - max(previous_remaining - elapsed, 1e-6), - previous_interval, - ) def get_custom_client_token( @@ -209,7 +173,6 @@ def get_custom_client_token( with _custom_oauth_lock( Path(cache.filename).parent, config["redirect_url"], - timeout_seconds=CUSTOM_OAUTH_LOCK_TIMEOUT_SECONDS, lease_seconds=CUSTOM_OAUTH_FLOW_TIMEOUT_SECONDS, ): # Read only after acquiring the lock: another helper may have just @@ -247,7 +210,7 @@ def get_custom_client_token( raise ValueError("OAuth returned no access token") cache.save(credentials) return token - except (CustomOAuthFlowTimeout, CustomOAuthLockTimeout): + except CustomOAuthFlowTimeout: raise except Exception as exc: raise RuntimeError( diff --git a/tests/test_agent_codex.py b/tests/test_agent_codex.py index 4e960176..21038377 100644 --- a/tests/test_agent_codex.py +++ b/tests/test_agent_codex.py @@ -145,7 +145,7 @@ def test_auth_timeout_allows_custom_browser_login_only(self, render, custom): ) overlay = render(WS, custom_oauth=config) auth = overlay["model_providers"][codex.CODEX_MODEL_PROVIDER_NAME]["auth"] - assert auth["timeout_ms"] == (370_000 if custom else 5000) + assert auth["timeout_ms"] == (365_000 if custom else 5000) def test_provider_adds_routing_header(self): overlay = codex.render_overlay(WS, provider="main.aarushi.aarushi-openai") diff --git a/tests/test_custom_oauth.py b/tests/test_custom_oauth.py index 79820d62..c1d13b65 100644 --- a/tests/test_custom_oauth.py +++ b/tests/test_custom_oauth.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import signal import time from datetime import UTC, datetime, timedelta from pathlib import Path @@ -19,8 +18,6 @@ from ucode.cli import app from ucode.custom_oauth import ( CustomOAuthFlowTimeout, - CustomOAuthLockTimeout, - _custom_oauth_flow_deadline, _custom_oauth_lock, get_custom_client_token, ) @@ -36,63 +33,31 @@ def test_releases_lock_when_login_fails(self, tmp_path): with _custom_oauth_lock( tmp_path, "http://localhost:8020/callback", - timeout_seconds=1, lease_seconds=1, ): raise ValueError("login failed") with _custom_oauth_lock( tmp_path, "http://127.0.0.1:8020/other-callback", - timeout_seconds=1, lease_seconds=1, ): assert len(list(tmp_path.glob("*.lock"))) == 1 - def test_times_out_with_holder_pid_without_entering(self, tmp_path): - entered = False - with _custom_oauth_lock( - tmp_path, - "http://localhost:8020/callback", - timeout_seconds=1, - lease_seconds=1, - ): - with pytest.raises(CustomOAuthLockTimeout, match=r"held by PID \d+"): - with _custom_oauth_lock( - tmp_path, - "http://localhost:8020/callback", - timeout_seconds=0.01, - lease_seconds=1, - ): - entered = True - assert entered is False - def test_owner_lease_interrupts_work_and_releases_lock(self, tmp_path): with pytest.raises(CustomOAuthFlowTimeout, match="its lock was released"): with _custom_oauth_lock( tmp_path, "http://localhost:8020/callback", - timeout_seconds=1, lease_seconds=0.01, ): time.sleep(1) with _custom_oauth_lock( tmp_path, "http://localhost:8020/callback", - timeout_seconds=1, lease_seconds=1, ): pass - def test_flow_deadline_restores_an_existing_timer(self): - signal.setitimer(signal.ITIMER_REAL, 10) - try: - with _custom_oauth_flow_deadline(1): - pass - remaining, _ = signal.getitimer(signal.ITIMER_REAL) - assert remaining == pytest.approx(10, abs=0.1) - finally: - signal.setitimer(signal.ITIMER_REAL, 0) - class TestCustomClientToken: @pytest.fixture(autouse=True) diff --git a/tests/test_state.py b/tests/test_state.py index 1a6c60cd..b02436f6 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -307,7 +307,7 @@ def test_custom_oauth_applies_to_claude_and_codex(self): assert "--client-id custom-client" in result["claude"]["auth_command"] assert result["codex"]["auth"]["args"][-1] == "offline_access,model-serving" - assert result["codex"]["auth"]["timeout_ms"] == 370_000 + assert result["codex"]["auth"]["timeout_ms"] == 365_000 # ---------------------------------------------------------------------------