|
4 | 4 | from __future__ import annotations |
5 | 5 |
|
6 | 6 | import configparser |
| 7 | +import contextlib |
7 | 8 | import functools |
| 9 | +import hashlib |
| 10 | +import importlib |
8 | 11 | import json |
9 | 12 | import logging |
10 | 13 | import logging.handlers |
|
15 | 18 | import shutil |
16 | 19 | import subprocess |
17 | 20 | import time |
| 21 | +from collections.abc import Callable |
18 | 22 | from concurrent.futures import ( |
19 | 23 | ThreadPoolExecutor, |
20 | 24 | as_completed, |
|
23 | 27 | TimeoutError as FutureTimeoutError, |
24 | 28 | ) |
25 | 29 | from pathlib import Path |
| 30 | +from types import ModuleType |
26 | 31 | from typing import Literal, cast, overload |
27 | 32 | from urllib import error as urllib_error |
28 | 33 | from urllib import request as urllib_request |
|
42 | 47 | spinner, |
43 | 48 | ) |
44 | 49 |
|
| 50 | +# `fcntl` (POSIX file locking) doesn't exist on Windows. Load it dynamically |
| 51 | +# so we degrade to no cross-process locking there instead of failing to |
| 52 | +# import this module at all; see `_refresh_lock`. |
| 53 | +fcntl: ModuleType | None |
| 54 | +try: |
| 55 | + fcntl = importlib.import_module("fcntl") |
| 56 | +except ImportError: |
| 57 | + fcntl = None |
| 58 | + |
45 | 59 | UNIX_DATABRICKS_INSTALL_URL = ( |
46 | 60 | "https://raw.githubusercontent.com/databricks/setup-cli/main/install.sh" |
47 | 61 | ) |
|
51 | 65 | AI_GATEWAY_V2_DOCS_URL = "https://docs.databricks.com/aws/en/ai-gateway/overview-beta" |
52 | 66 | MIN_DATABRICKS_CLI_VERSION = (0, 298, 0) |
53 | 67 | TOKEN_REFRESH_INTERVAL_SECONDS = 1800 |
| 68 | +# A `--force-refresh` within this window of a peer process's successful |
| 69 | +# force-refresh (same workspace+profile) is considered redundant: the OAuth |
| 70 | +# refresh token is rotating, so redeeming it twice in quick succession from |
| 71 | +# concurrent `ucode opencode` sessions can revoke the token family (#190). |
| 72 | +TOKEN_REFRESH_COALESCE_SECONDS = 60 |
| 73 | +_LOCK_ACQUIRE_TIMEOUT_SECONDS = 15 |
| 74 | +_LOCK_POLL_INTERVAL_SECONDS = 0.2 |
54 | 75 |
|
55 | 76 |
|
56 | 77 | def _debug_enabled() -> bool: |
@@ -776,6 +797,116 @@ def ensure_databricks_auth(workspace: str, profile: str | None = None) -> None: |
776 | 797 | run_databricks_login(workspace, profile) |
777 | 798 |
|
778 | 799 |
|
| 800 | +def _refresh_lock_paths(workspace: str, profile: str | None) -> tuple[Path, Path]: |
| 801 | + """Per-workspace+profile lock file and sentinel path, both under APP_DIR. |
| 802 | +
|
| 803 | + Hashing the workspace+profile pair keeps concurrent refreshes for |
| 804 | + *different* workspaces from serializing against each other, while still |
| 805 | + giving every process refreshing the *same* workspace+profile a shared, |
| 806 | + stable path to flock.""" |
| 807 | + digest = hashlib.sha256(f"{workspace}|{profile or ''}".encode()).hexdigest()[:16] |
| 808 | + lock_path = APP_DIR / f".token-refresh-{digest}.lock" |
| 809 | + sentinel_path = Path(f"{lock_path}.ts") |
| 810 | + return lock_path, sentinel_path |
| 811 | + |
| 812 | + |
| 813 | +def _sentinel_is_fresh(sentinel_path: Path) -> bool: |
| 814 | + """Whether `sentinel_path`'s mtime is within TOKEN_REFRESH_COALESCE_SECONDS.""" |
| 815 | + try: |
| 816 | + mtime = sentinel_path.stat().st_mtime |
| 817 | + except OSError: |
| 818 | + return False |
| 819 | + return (time.time() - mtime) < TOKEN_REFRESH_COALESCE_SECONDS |
| 820 | + |
| 821 | + |
| 822 | +def _touch_sentinel(sentinel_path: Path) -> None: |
| 823 | + try: |
| 824 | + sentinel_path.touch() |
| 825 | + except OSError as exc: |
| 826 | + _debug("refresh_lock", f"failed to touch sentinel {sentinel_path}: {exc}") |
| 827 | + |
| 828 | + |
| 829 | +@contextlib.contextmanager |
| 830 | +def _refresh_lock(lock_path: Path): |
| 831 | + """Best-effort cross-process exclusive lock guarding a force-refresh. |
| 832 | +
|
| 833 | + Degrades to a no-op (yields ``False`` without locking anything) when |
| 834 | + ``fcntl`` is unavailable (Windows) or the lock can't be opened or |
| 835 | + acquired for any ``OSError`` — a missing or stuck lock must never |
| 836 | + prevent a token refresh from proceeding. Acquisition is a bounded, |
| 837 | + non-blocking retry loop (flock has no native timeout) capped at |
| 838 | + ``_LOCK_ACQUIRE_TIMEOUT_SECONDS`` before falling back to proceeding |
| 839 | + without the lock.""" |
| 840 | + if fcntl is None: |
| 841 | + yield False |
| 842 | + return |
| 843 | + |
| 844 | + try: |
| 845 | + APP_DIR.mkdir(parents=True, exist_ok=True) |
| 846 | + handle = lock_path.open("a+") |
| 847 | + except OSError as exc: |
| 848 | + _debug("refresh_lock", f"could not open lock file {lock_path}: {exc}") |
| 849 | + yield False |
| 850 | + return |
| 851 | + |
| 852 | + acquired = False |
| 853 | + try: |
| 854 | + deadline = time.monotonic() + _LOCK_ACQUIRE_TIMEOUT_SECONDS |
| 855 | + while time.monotonic() < deadline: |
| 856 | + try: |
| 857 | + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) |
| 858 | + acquired = True |
| 859 | + break |
| 860 | + except OSError: |
| 861 | + time.sleep(_LOCK_POLL_INTERVAL_SECONDS) |
| 862 | + _debug("refresh_lock", f"acquired={acquired} path={lock_path}") |
| 863 | + yield acquired |
| 864 | + finally: |
| 865 | + if acquired: |
| 866 | + try: |
| 867 | + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) |
| 868 | + except OSError: |
| 869 | + pass |
| 870 | + handle.close() |
| 871 | + |
| 872 | + |
| 873 | +def _perform_force_refresh( |
| 874 | + workspace: str, |
| 875 | + profile: str | None, |
| 876 | + cmd: list[str], |
| 877 | + fetch: Callable[[], str], |
| 878 | +) -> str: |
| 879 | + """Serialize `--force-refresh` across processes and coalesce redundant ones. |
| 880 | +
|
| 881 | + Holds a cross-process flock (see `_refresh_lock`) for the duration of an |
| 882 | + actual force-refresh so two processes can never redeem the rotating |
| 883 | + refresh token concurrently. If a peer already force-refreshed within |
| 884 | + `TOKEN_REFRESH_COALESCE_SECONDS`, drops `--force-refresh` from `cmd` (in |
| 885 | + place, so retries via the same `fetch` closure stay coalesced too) and |
| 886 | + just fetches the now-current cached token instead.""" |
| 887 | + lock_path, sentinel_path = _refresh_lock_paths(workspace, profile) |
| 888 | + with _refresh_lock(lock_path) as locked: |
| 889 | + _debug( |
| 890 | + "get_databricks_token", |
| 891 | + f"force-refresh lock acquired={locked} workspace={workspace} profile={profile or '<none>'}", |
| 892 | + ) |
| 893 | + if _sentinel_is_fresh(sentinel_path): |
| 894 | + _debug( |
| 895 | + "get_databricks_token", |
| 896 | + f"coalescing --force-refresh: peer refreshed within " |
| 897 | + f"{TOKEN_REFRESH_COALESCE_SECONDS}s", |
| 898 | + ) |
| 899 | + if "--force-refresh" in cmd: |
| 900 | + cmd.remove("--force-refresh") |
| 901 | + return fetch() |
| 902 | + |
| 903 | + _debug("get_databricks_token", "performing --force-refresh") |
| 904 | + token = fetch() |
| 905 | + if token: |
| 906 | + _touch_sentinel(sentinel_path) |
| 907 | + return token |
| 908 | + |
| 909 | + |
779 | 910 | def get_databricks_token( |
780 | 911 | workspace: str, |
781 | 912 | profile: str | None = None, |
@@ -835,7 +966,10 @@ def _fetch() -> str: |
835 | 966 | _debug("auth token", f"exception: {type(exc).__name__}: {exc}") |
836 | 967 | return "" |
837 | 968 |
|
838 | | - token = _fetch() |
| 969 | + if force_refresh: |
| 970 | + token = _perform_force_refresh(workspace, profile, cmd, _fetch) |
| 971 | + else: |
| 972 | + token = _fetch() |
839 | 973 | if not token: |
840 | 974 | # Session may have expired — attempt non-interactive re-auth and retry once. |
841 | 975 | _debug("auth token", "empty on first fetch; attempting auth login --no-browser") |
|
0 commit comments