2 files changed
@@ -35,19 +35,33 @@ | |||
| 35 | 35 | ||
| 36 | 36 | ||
| 37 | 37 | @contextmanager | |
| 38 | - def _progress_lock(workspace_root: Path): | ||
| 38 | + def _progress_lock(workspace_root: Path, timeout_sec: float = 30.0): | ||
| 39 | 39 | """Acquire an exclusive file lock for gpu_progress.json operations. | |
| 40 | 40 | ||
| 41 | 41 | Prevents race conditions when multiple agents read-modify-write the same file. | |
| 42 | + Uses LOCK_NB + retry loop with timeout to avoid deadlocks. | ||
| 42 | 43 | """ | |
| 43 | 44 | lock_path = workspace_root / "exp" / ".gpu_progress.lock" | |
| 44 | 45 | lock_path.parent.mkdir(parents=True, exist_ok=True) | |
| 45 | 46 | lock_fd = open(lock_path, "w") | |
| 47 | + deadline = time.monotonic() + timeout_sec | ||
| 48 | + acquired = False | ||
| 46 | 49 | try: | |
| 47 | - fcntl.flock(lock_fd, fcntl.LOCK_EX) | ||
| 50 | + while time.monotonic() < deadline: | ||
| 51 | + try: | ||
| 52 | + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) | ||
| 53 | + acquired = True | ||
| 54 | + break | ||
| 55 | + except BlockingIOError: | ||
| 56 | + time.sleep(0.1) | ||
| 57 | + if not acquired: | ||
| 58 | + # Force-break stale lock after timeout | ||
| 59 | + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) | ||
| 60 | + acquired = True | ||
| 48 | 61 | yield | |
| 49 | 62 | finally: | |
| 50 | - fcntl.flock(lock_fd, fcntl.LOCK_UN) | ||
| 63 | + if acquired: | ||
| 64 | + fcntl.flock(lock_fd, fcntl.LOCK_UN) | ||
| 51 | 65 | lock_fd.close() | |
| 52 | 66 | ||
| 53 | 67 | ||
@@ -59,15 +73,28 @@ def _global_gpu_leases_path() -> Path: | |||
| 59 | 73 | ||
| 60 | 74 | ||
| 61 | 75 | @contextmanager | |
| 62 | - def _global_gpu_leases_lock(): | ||
| 63 | - """Serialize cross-project GPU lease updates.""" | ||
| 76 | + def _global_gpu_leases_lock(timeout_sec: float = 30.0): | ||
| 77 | + """Serialize cross-project GPU lease updates with timeout.""" | ||
| 64 | 78 | lock_path = _global_gpu_leases_path().with_suffix(".lock") | |
| 65 | 79 | lock_fd = open(lock_path, "w", encoding="utf-8") | |
| 80 | + deadline = time.monotonic() + timeout_sec | ||
| 81 | + acquired = False | ||
| 66 | 82 | try: | |
| 67 | - fcntl.flock(lock_fd, fcntl.LOCK_EX) | ||
| 83 | + while time.monotonic() < deadline: | ||
| 84 | + try: | ||
| 85 | + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) | ||
| 86 | + acquired = True | ||
| 87 | + break | ||
| 88 | + except BlockingIOError: | ||
| 89 | + time.sleep(0.1) | ||
| 90 | + if not acquired: | ||
| 91 | + # Force-break stale lock after timeout | ||
| 92 | + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) | ||
| 93 | + acquired = True | ||
| 68 | 94 | yield | |
| 69 | 95 | finally: | |
| 70 | - fcntl.flock(lock_fd, fcntl.LOCK_UN) | ||
| 96 | + if acquired: | ||
| 97 | + fcntl.flock(lock_fd, fcntl.LOCK_UN) | ||
| 71 | 98 | lock_fd.close() | |
| 72 | 99 | ||
| 73 | 100 | ||
@@ -1176,6 +1176,26 @@ def test_script_includes_dispatch_logic(self): | |||
| 1176 | 1176 | assert "DISPATCH" in script | |
| 1177 | 1177 | ||
| 1178 | 1178 | ||
| 1179 | + # ══════════════════════════════════════════════ | ||
| 1180 | + # Lock timeout support | ||
| 1181 | + # ══════════════════════════════════════════════ | ||
| 1182 | + | ||
| 1183 | + def test_global_gpu_leases_lock_has_timeout(tmp_path, monkeypatch): | ||
| 1184 | + """Lock should not block forever — should use LOCK_NB + retry with timeout.""" | ||
| 1185 | + import sibyl.gpu_scheduler as gs | ||
| 1186 | + import inspect | ||
| 1187 | + source = inspect.getsource(gs._global_gpu_leases_lock) | ||
| 1188 | + assert "LOCK_NB" in source or "timeout" in source.lower() | ||
| 1189 | + | ||
| 1190 | + | ||
| 1191 | + def test_progress_lock_has_timeout(tmp_path, monkeypatch): | ||
| 1192 | + """Progress lock should also use LOCK_NB + retry with timeout.""" | ||
| 1193 | + import sibyl.gpu_scheduler as gs | ||
| 1194 | + import inspect | ||
| 1195 | + source = inspect.getsource(gs._progress_lock) | ||
| 1196 | + assert "LOCK_NB" in source or "timeout" in source.lower() | ||
| 1197 | + | ||
| 1198 | + | ||
| 1179 | 1199 | # ══════════════════════════════════════════════ | |
| 1180 | 1200 | # Lock window for register/unregister | |
| 1181 | 1201 | # ══════════════════════════════════════════════ | |
0 commit comments