fix(auth): serialise the token refresh across processes

The login cache is shared by every mdrs process, but the refresh was
guarded by a lock that only reaches inside one. Concurrent runs each
sent the same refresh token, and a provider that rotates them accepts
the first and refuses the rest.

- hold a lock that spans processes across the whole read-refresh-
  write, checking cheaply first so ordinary requests never take it
- write the cache through a temporary file: opening it for writing
  truncates it, and a reader landing in that window found it empty
  and threw the session away
- take the lock for every write, not just the refresh, so a login
  running beside one cannot be silently reverted
- wait for a busy lock on Windows rather than giving up after the ten
  attempts msvcrt allows, and retry the rename it refuses while a
  reader still holds the file open
- bound the refresh request on its own, so a provider that goes quiet
  cannot hold the lock indefinitely
- accept bare hostnames such as localhost, store URLs without the
  trailing slash, and join download paths through one helper
This commit is contained in:
2026-08-14 16:36:50 +09:00
parent 1a4023ba47
commit 0cac30ccf8
9 changed files with 332 additions and 41 deletions
+27 -4
View File
@@ -10,6 +10,31 @@ from mdrsclient.settings import CONFIG_DIRNAME
from mdrsclient.utils import FileLock
def normalize_url(url: str) -> str:
"""
Check a remote URL and put it in the one form every client agrees on.
`simple_host` is what lets a bare hostname through, so a development server on
`localhost` is as acceptable as a deployment behind a domain name. The trailing
slash goes because the URL is joined with a path that brings its own.
"""
if not validators.url(url, simple_host=True, validate_scheme=lambda scheme: scheme in ("http", "https")):
raise IllegalArgumentException("malformed URI sequence")
return url.rstrip("/")
def build_download_url(base_url: str | None, path: str) -> str:
"""
Join a remote base URL with a path the API returned.
The API answers with a relative path and no leading separator, and a configuration
written before the URL was normalised may still carry a trailing one.
"""
if path.startswith(("http://", "https://")):
return path
return f"{(base_url or '').rstrip('/')}/{path.lstrip('/')}"
@runtime_checkable
class ConfigInterface(Protocol):
remote: str
@@ -42,8 +67,7 @@ class InMemoryConfig(ConfigInterface):
@url.setter
def url(self, url: str) -> None:
if not validators.url(url):
raise IllegalArgumentException("malformed URI sequence")
url = normalize_url(url)
with self.__lock:
self.__configs[self.remote] = url
@@ -91,8 +115,7 @@ class ConfigFile(ConfigInterface):
@url.setter
def url(self, url: str) -> None:
if not validators.url(url):
raise IllegalArgumentException("malformed URI sequence")
url = normalize_url(url)
self.__load()
if self.__config.has_section(self.remote):
self.__config.remove_section(self.remote)