|
7 | 7 | - DockerTestServer: For servers that run in Docker containers |
8 | 8 | """ |
9 | 9 |
|
| 10 | +import os |
| 11 | +import threading |
10 | 12 | from abc import ABC, abstractmethod |
11 | 13 | from collections.abc import Iterator |
12 | 14 | from contextlib import contextmanager |
13 | | -from typing import Any |
| 15 | +from pathlib import Path |
| 16 | +from typing import IO, Any |
14 | 17 |
|
15 | 18 | try: |
16 | 19 | import niquests as requests |
|
21 | 24 | DEFAULT_HTTP_TIMEOUT = 5 |
22 | 25 | MAX_STARTUP_WAIT_SECONDS = 60 |
23 | 26 | STARTUP_POLL_INTERVAL = 0.05 |
| 27 | +SESSION_LOCK_TIMEOUT_SECONDS = 1800 |
| 28 | + |
| 29 | +# Per-server session locks held by this process, keyed by server name. |
| 30 | +# flock conflicts between two opens even within one process, so each |
| 31 | +# lock file is opened only once. |
| 32 | +_session_locks: dict[str, IO[str]] = {} |
| 33 | +# Servers whose lock timed out; later claims fail at once instead of |
| 34 | +# waiting again for every remaining test. |
| 35 | +_session_lock_failures: dict[str, RuntimeError] = {} |
| 36 | +_session_locks_mutex = threading.Lock() |
| 37 | + |
| 38 | + |
| 39 | +def _lock_path(name: str, suffix: str = "") -> Path: |
| 40 | + import tempfile |
| 41 | + |
| 42 | + return Path(tempfile.gettempdir()) / f"caldav-test-server-{name.lower()}{suffix}.lock" |
24 | 43 |
|
25 | 44 |
|
26 | 45 | class TestServer(ABC): |
@@ -106,11 +125,97 @@ def features(self) -> Any: |
106 | 125 |
|
107 | 126 | return resolve_features(self.config.get("features", [])) |
108 | 127 |
|
| 128 | + @staticmethod |
| 129 | + def claim_server(name: str, timeout: float | None = None) -> bool: |
| 130 | + """Take an exclusive lock on server ``name`` for the rest of this process. |
| 131 | +
|
| 132 | + Test runs sharing a server would clobber each other's calendars (and |
| 133 | + one run's teardown may stop a container another run still uses), so |
| 134 | + a second process waits here until the first one exits. Runs against |
| 135 | + different servers proceed in parallel. The OS drops the lock when |
| 136 | + the process dies, so a crashed run leaves nothing stale behind. |
| 137 | +
|
| 138 | + Waits at most ``timeout`` seconds (default |
| 139 | + ``$PYTHON_CALDAV_TEST_SERVER_LOCK_TIMEOUT``, or 30 minutes) - two runs |
| 140 | + taking servers in different orders could otherwise deadlock. A |
| 141 | + timeout is remembered, so the remaining tests for that server fail |
| 142 | + at once - except with ``timeout=0``, a mere probe. |
| 143 | +
|
| 144 | + Returns True if the lock was taken just now, False if this process |
| 145 | + already held it. Anything learned about the server before a fresh |
| 146 | + claim may be stale, since its previous user may have stopped it. |
| 147 | +
|
| 148 | + Without ``fcntl`` (Windows) nothing is locked between processes. |
| 149 | + """ |
| 150 | + import time |
| 151 | + |
| 152 | + try: |
| 153 | + import fcntl |
| 154 | + except ImportError: |
| 155 | + fcntl = None # type: ignore[assignment] |
| 156 | + |
| 157 | + with _session_locks_mutex: |
| 158 | + if name in _session_locks: |
| 159 | + return False |
| 160 | + if name in _session_lock_failures: |
| 161 | + raise _session_lock_failures[name] |
| 162 | + if timeout is None: |
| 163 | + timeout = float( |
| 164 | + os.environ.get( |
| 165 | + "PYTHON_CALDAV_TEST_SERVER_LOCK_TIMEOUT", SESSION_LOCK_TIMEOUT_SECONDS |
| 166 | + ) |
| 167 | + ) |
| 168 | + lock_file = open(_lock_path(name, ".session"), "a+") |
| 169 | + try: |
| 170 | + deadline = time.monotonic() + timeout |
| 171 | + announced = False |
| 172 | + while fcntl: |
| 173 | + try: |
| 174 | + fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) |
| 175 | + break |
| 176 | + except BlockingIOError: |
| 177 | + lock_file.seek(0) |
| 178 | + holder = lock_file.read().strip() or "unknown" |
| 179 | + if time.monotonic() > deadline: |
| 180 | + error = RuntimeError( |
| 181 | + f"Test server {name} is in use by pid {holder}; " |
| 182 | + f"gave up after {timeout:.0f}s" |
| 183 | + ) |
| 184 | + if timeout: |
| 185 | + _session_lock_failures[name] = error |
| 186 | + raise error from None |
| 187 | + if not announced: |
| 188 | + print(f"[WAIT] {name} is in use by pid {holder} - waiting") |
| 189 | + announced = True |
| 190 | + time.sleep(1) |
| 191 | + lock_file.truncate(0) |
| 192 | + lock_file.write(str(os.getpid())) |
| 193 | + lock_file.flush() |
| 194 | + except BaseException: |
| 195 | + lock_file.close() |
| 196 | + raise |
| 197 | + _session_locks[name] = lock_file |
| 198 | + return True |
| 199 | + |
| 200 | + @staticmethod |
| 201 | + def release_server(name: str) -> None: |
| 202 | + """Give up a claim taken by ``claim_server()``, letting other runs have the server.""" |
| 203 | + with _session_locks_mutex: |
| 204 | + lock_file = _session_locks.pop(name, None) |
| 205 | + if lock_file: |
| 206 | + lock_file.close() |
| 207 | + |
| 208 | + def claim(self) -> bool: |
| 209 | + """``claim_server()`` for this server.""" |
| 210 | + return self.claim_server(self.name) |
| 211 | + |
109 | 212 | @abstractmethod |
110 | 213 | def start(self) -> None: |
111 | 214 | """ |
112 | 215 | Start the server if not already running. |
113 | 216 |
|
| 217 | + Implementations must call ``self.claim()`` first. |
| 218 | +
|
114 | 219 | This method should be idempotent - calling it multiple times |
115 | 220 | should not cause issues. |
116 | 221 |
|
@@ -206,15 +311,15 @@ def get_server_params(self) -> dict[str, Any]: |
206 | 311 | # Pass through scheduling_users if configured (for TestScheduling generation) |
207 | 312 | if "scheduling_users" in self.config: |
208 | 313 | params["scheduling_users"] = self.config["scheduling_users"] |
| 314 | + # setup is always needed, since start() also takes the session lock |
| 315 | + params["setup"] = lambda _: self.start() |
209 | 316 | # Check if server is already running (either started by us or externally) |
210 | 317 | already_running = self._started or self.is_accessible() |
211 | 318 | if already_running: |
212 | 319 | # Server is already running - mark as started but don't add teardown |
213 | 320 | # to avoid stopping a server that was running before tests started |
214 | 321 | self._started = True |
215 | 322 | else: |
216 | | - # Server needs to be started - add setup/teardown callbacks |
217 | | - params["setup"] = lambda _: self.start() |
218 | 323 | params["teardown"] = lambda _: self.stop() |
219 | 324 | return params |
220 | 325 |
|
@@ -340,7 +445,13 @@ def start(self) -> None: |
340 | 445 | Raises: |
341 | 446 | RuntimeError: If Docker is not available or container fails to start |
342 | 447 | """ |
343 | | - if self._started or self.is_accessible(): |
| 448 | + if self.claim(): |
| 449 | + # Whatever was seen before the lock (e.g. at collection) may be |
| 450 | + # stale: the previous holder may have stopped the container. |
| 451 | + self._started = False |
| 452 | + if self._started: |
| 453 | + return |
| 454 | + if self.is_accessible(): |
344 | 455 | self._started = True # Mark as started even if already running |
345 | 456 | # Don't set _started_by_us - we didn't start it this time |
346 | 457 | print(f"[OK] {self.name} is already running") |
@@ -371,11 +482,8 @@ def _lock(self) -> Iterator[None]: |
371 | 482 | repo share the same containers and ports. |
372 | 483 | """ |
373 | 484 | import fcntl |
374 | | - import tempfile |
375 | | - from pathlib import Path |
376 | 485 |
|
377 | | - lock_path = Path(tempfile.gettempdir()) / f"caldav-test-server-{self.name.lower()}.lock" |
378 | | - with open(lock_path, "w") as lock_file: |
| 486 | + with open(_lock_path(self.name), "w") as lock_file: |
379 | 487 | fcntl.flock(lock_file, fcntl.LOCK_EX) |
380 | 488 | try: |
381 | 489 | yield |
@@ -412,6 +520,13 @@ def _start_container(self) -> None: |
412 | 520 |
|
413 | 521 | raise RuntimeError(f"{self.name} failed to start after {MAX_STARTUP_WAIT_SECONDS}s") |
414 | 522 |
|
| 523 | + def get_server_params(self) -> dict[str, Any]: |
| 524 | + params = super().get_server_params() |
| 525 | + # A run that waited for the lock may have restarted a container that |
| 526 | + # was up at collection; stop() leaves alone one we did not start. |
| 527 | + params["teardown"] = lambda _: self.stop() |
| 528 | + return params |
| 529 | + |
415 | 530 | def stop(self) -> None: |
416 | 531 | """Stop the Docker container and cleanup. |
417 | 532 |
|
@@ -482,6 +597,7 @@ def start(self) -> None: |
482 | 597 | # No accessibility check here - the caldav library handles URL |
483 | 598 | # normalization, RFC6764 discovery, and will provide proper error |
484 | 599 | # messages if the server is unreachable |
| 600 | + self.claim() |
485 | 601 | self._started = True |
486 | 602 |
|
487 | 603 | def stop(self) -> None: |
|
0 commit comments