|
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,83 @@ def features(self) -> Any: |
106 | 125 |
|
107 | 126 | return resolve_features(self.config.get("features", [])) |
108 | 127 |
|
| 128 | + # Seconds to wait for another process's session lock; None means |
| 129 | + # $PYTHON_CALDAV_TEST_SERVER_LOCK_TIMEOUT or SESSION_LOCK_TIMEOUT_SECONDS. |
| 130 | + claim_timeout: float | None = None |
| 131 | + |
| 132 | + @staticmethod |
| 133 | + def claim_server(name: str, timeout: float | None = None) -> bool: |
| 134 | + """Take an exclusive lock on server ``name`` for the rest of this process. |
| 135 | +
|
| 136 | + Test runs sharing a server would clobber each other's calendars (and |
| 137 | + one run's teardown may stop a container another run still uses), so |
| 138 | + a second process waits here until the first one exits. Runs against |
| 139 | + different servers proceed in parallel. The OS drops the lock when |
| 140 | + the process dies, so a crashed run leaves nothing stale behind. |
| 141 | +
|
| 142 | + Waits at most ``timeout`` seconds (default |
| 143 | + ``$PYTHON_CALDAV_TEST_SERVER_LOCK_TIMEOUT``, or 30 minutes) - two runs |
| 144 | + taking servers in different orders could otherwise deadlock. A |
| 145 | + timeout is remembered, so the remaining tests for that server fail |
| 146 | + at once. |
| 147 | +
|
| 148 | + Returns True if the lock was taken just now, False if this process |
| 149 | + already held it. Anything learned about the server before a fresh |
| 150 | + claim may be stale, since its previous user may have stopped it. |
| 151 | + """ |
| 152 | + import fcntl |
| 153 | + import time |
| 154 | + |
| 155 | + with _session_locks_mutex: |
| 156 | + if name in _session_locks: |
| 157 | + return False |
| 158 | + if name in _session_lock_failures: |
| 159 | + raise _session_lock_failures[name] |
| 160 | + if timeout is None: |
| 161 | + timeout = float( |
| 162 | + os.environ.get( |
| 163 | + "PYTHON_CALDAV_TEST_SERVER_LOCK_TIMEOUT", SESSION_LOCK_TIMEOUT_SECONDS |
| 164 | + ) |
| 165 | + ) |
| 166 | + lock_file = open(_lock_path(name, ".session"), "a+") |
| 167 | + deadline = time.monotonic() + timeout |
| 168 | + announced = False |
| 169 | + while True: |
| 170 | + try: |
| 171 | + fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) |
| 172 | + break |
| 173 | + except BlockingIOError: |
| 174 | + lock_file.seek(0) |
| 175 | + holder = lock_file.read().strip() or "unknown" |
| 176 | + if time.monotonic() > deadline: |
| 177 | + lock_file.close() |
| 178 | + error = RuntimeError( |
| 179 | + f"Test server {name} is in use by pid {holder}; " |
| 180 | + f"gave up after {timeout:.0f}s" |
| 181 | + ) |
| 182 | + _session_lock_failures[name] = error |
| 183 | + raise error from None |
| 184 | + if not announced: |
| 185 | + print(f"[WAIT] {name} is in use by pid {holder} - waiting") |
| 186 | + announced = True |
| 187 | + time.sleep(1) |
| 188 | + lock_file.truncate(0) |
| 189 | + lock_file.write(str(os.getpid())) |
| 190 | + lock_file.flush() |
| 191 | + _session_locks[name] = lock_file |
| 192 | + return True |
| 193 | + |
| 194 | + def claim(self) -> bool: |
| 195 | + """``claim_server()`` for this server, honouring ``claim_timeout``.""" |
| 196 | + return self.claim_server(self.name, self.claim_timeout) |
| 197 | + |
109 | 198 | @abstractmethod |
110 | 199 | def start(self) -> None: |
111 | 200 | """ |
112 | 201 | Start the server if not already running. |
113 | 202 |
|
| 203 | + Implementations must call ``self.claim()`` first. |
| 204 | +
|
114 | 205 | This method should be idempotent - calling it multiple times |
115 | 206 | should not cause issues. |
116 | 207 |
|
@@ -206,15 +297,15 @@ def get_server_params(self) -> dict[str, Any]: |
206 | 297 | # Pass through scheduling_users if configured (for TestScheduling generation) |
207 | 298 | if "scheduling_users" in self.config: |
208 | 299 | params["scheduling_users"] = self.config["scheduling_users"] |
| 300 | + # setup is always needed, since start() also takes the session lock |
| 301 | + params["setup"] = lambda _: self.start() |
209 | 302 | # Check if server is already running (either started by us or externally) |
210 | 303 | already_running = self._started or self.is_accessible() |
211 | 304 | if already_running: |
212 | 305 | # Server is already running - mark as started but don't add teardown |
213 | 306 | # to avoid stopping a server that was running before tests started |
214 | 307 | self._started = True |
215 | 308 | else: |
216 | | - # Server needs to be started - add setup/teardown callbacks |
217 | | - params["setup"] = lambda _: self.start() |
218 | 309 | params["teardown"] = lambda _: self.stop() |
219 | 310 | return params |
220 | 311 |
|
@@ -340,7 +431,13 @@ def start(self) -> None: |
340 | 431 | Raises: |
341 | 432 | RuntimeError: If Docker is not available or container fails to start |
342 | 433 | """ |
343 | | - if self._started or self.is_accessible(): |
| 434 | + if self.claim(): |
| 435 | + # Whatever was seen before the lock (e.g. at collection) may be |
| 436 | + # stale: the previous holder may have stopped the container. |
| 437 | + self._started = False |
| 438 | + if self._started: |
| 439 | + return |
| 440 | + if self.is_accessible(): |
344 | 441 | self._started = True # Mark as started even if already running |
345 | 442 | # Don't set _started_by_us - we didn't start it this time |
346 | 443 | print(f"[OK] {self.name} is already running") |
@@ -371,11 +468,8 @@ def _lock(self) -> Iterator[None]: |
371 | 468 | repo share the same containers and ports. |
372 | 469 | """ |
373 | 470 | import fcntl |
374 | | - import tempfile |
375 | | - from pathlib import Path |
376 | 471 |
|
377 | | - lock_path = Path(tempfile.gettempdir()) / f"caldav-test-server-{self.name.lower()}.lock" |
378 | | - with open(lock_path, "w") as lock_file: |
| 472 | + with open(_lock_path(self.name), "w") as lock_file: |
379 | 473 | fcntl.flock(lock_file, fcntl.LOCK_EX) |
380 | 474 | try: |
381 | 475 | yield |
@@ -482,6 +576,7 @@ def start(self) -> None: |
482 | 576 | # No accessibility check here - the caldav library handles URL |
483 | 577 | # normalization, RFC6764 discovery, and will provide proper error |
484 | 578 | # messages if the server is unreachable |
| 579 | + self.claim() |
485 | 580 | self._started = True |
486 | 581 |
|
487 | 582 | def stop(self) -> None: |
|
0 commit comments