diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml new file mode 100644 index 000000000..3e614acde --- /dev/null +++ b/.github/workflows/typecheck.yml @@ -0,0 +1,43 @@ +name: Type check + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + mypy: + # `--platform` is what selects the branches mypy resolves, so all three run + # on Linux. Checking out on three runner OSes would cost three times as much + # and still check whatever platform each runner happened to be. + strategy: + fail-fast: false + matrix: + platform: + - linux + - darwin + - win32 + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.12' + + - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 + + - name: Install dependencies + run: uv sync --frozen + + - name: mypy --platform ${{ matrix.platform }} + run: uv run mypy --platform ${{ matrix.platform }} strix/ diff --git a/Makefile b/Makefile index 05038f799..2d6c306bc 100644 --- a/Makefile +++ b/Makefile @@ -46,8 +46,11 @@ lint: @echo "✅ Linting complete!" type-check: - @echo "🔍 Type checking with mypy..." - uv run mypy strix/ + @echo "🔍 Type checking with mypy (linux, darwin, win32)..." + @for platform in linux darwin win32; do \ + echo " → $$platform"; \ + uv run mypy --platform $$platform strix/ || exit 1; \ + done @echo "🔍 Type checking with pyright..." uv run pyright strix/ @echo "✅ Type checking complete!" diff --git a/strix/config/codex.py b/strix/config/codex.py index 9f81ff6db..b171f48ee 100644 --- a/strix/config/codex.py +++ b/strix/config/codex.py @@ -16,6 +16,7 @@ import hashlib import json import logging import secrets +import sys import threading import time import urllib.parse @@ -103,27 +104,52 @@ def logout() -> None: AUTH_PATH.unlink() +# `fcntl` exists only on POSIX. Resolve it once behind a platform guard, which +# mypy narrows on every `--platform` it is asked to check, rather than at the +# call sites where it would need a per-platform ignore. +if sys.platform != "win32": + import fcntl + + _HAS_FILE_LOCKING = True + + def _lock_exclusive(descriptor: int) -> None: + fcntl.flock(descriptor, fcntl.LOCK_EX) + + def _unlock(descriptor: int) -> None: + fcntl.flock(descriptor, fcntl.LOCK_UN) + +else: + _HAS_FILE_LOCKING = False + + def _lock_exclusive(descriptor: int) -> None: + """Windows has no advisory file lock; the in-process lock still holds.""" + + def _unlock(descriptor: int) -> None: + """Windows has no advisory file lock; the in-process lock still holds.""" + + @contextlib.contextmanager def _refresh_guard() -> Iterator[None]: """Serialize token refresh within (lock) and across (flock) Strix processes, so concurrent runs can't both spend the single-use refresh token.""" with _refresh_lock: + if not _HAS_FILE_LOCKING: + yield + return try: - import fcntl - lock_path = AUTH_PATH.with_suffix(".lock") lock_path.parent.mkdir(parents=True, exist_ok=True) handle = lock_path.open("w") - except (ImportError, OSError): + except OSError: yield return try: with contextlib.suppress(OSError): - fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + _lock_exclusive(handle.fileno()) yield finally: with contextlib.suppress(OSError): - fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + _unlock(handle.fileno()) handle.close() diff --git a/strix/runtime/session_manager.py b/strix/runtime/session_manager.py index 3c95d31ae..379231acd 100644 --- a/strix/runtime/session_manager.py +++ b/strix/runtime/session_manager.py @@ -40,15 +40,19 @@ _PROTECTED_METADATA_NAMES = (".git", ".agents", ".codex") def _host_identity_env() -> dict[str, str]: - # Read the platform through a local so it is not narrowed to whichever OS is - # type-checking: comparing sys.platform directly makes one of these branches - # statically dead, and which one flips between Linux and macOS. - platform_name: str = sys.platform - if platform_name != "linux": - return {} - # Bind-mount ownership only needs mapping on Linux, where the container uid - # must match the host's. - return {"STRIX_HOST_UID": str(os.getuid()), "STRIX_HOST_GID": str(os.getgid())} + # The outer guard is a real platform check, so mypy narrows it under every + # --platform it is asked about and `os.getuid` resolves where it exists. + if sys.platform != "win32": + # Inside it, read the platform through a local so it is not narrowed to + # whichever OS is type-checking: comparing sys.platform directly makes + # one of these branches statically dead, and which one flips between + # Linux and macOS. + platform_name: str = sys.platform + if platform_name == "linux": + # Bind-mount ownership only needs mapping on Linux, where the + # container uid must match the host's. + return {"STRIX_HOST_UID": str(os.getuid()), "STRIX_HOST_GID": str(os.getgid())} + return {} def build_bind_mounts(local_sources: list[dict[str, Any]]) -> list[dict[str, Any]]: