| 237 | |
| 238 | |
| 239 | def lock_status(paths: Paths, stale_after_seconds: int = DEFAULT_LOCK_STALE_SECONDS, name: str = "compile.lock") -> dict[str, object]: |
| 240 | path = compiler_lock_path(paths, name=name) |
| 241 | if not path.exists(): |
| 242 | return {"locked": False, "stale": False, "path": str(path)} |
| 243 | raw = load_json(path) |
| 244 | created_at = datetime.fromisoformat(str(raw["created_at"]).replace("Z", "+00:00")) |
| 245 | age = (utc_now() - created_at).total_seconds() |
| 246 | owner_pid = raw.get("pid") |
| 247 | pid_alive = _pid_alive(owner_pid) |
| 248 | # Three ways to become stale: |
| 249 | # 1. pid is gone (SIGKILL / reboot / crash) — immediate |
| 250 | # 2. age exceeds hard upper bound (process is hung past tolerance) |
| 251 | # 3. negative age (clock skew / NTP rollback) — never trust a lock from the future |
| 252 | stale = (not pid_alive) or age > stale_after_seconds or age < 0 |
| 253 | stale_reason: str | None = None |
| 254 | if not pid_alive: |
| 255 | stale_reason = "pid_not_alive" |
| 256 | elif age < 0: |
| 257 | stale_reason = "negative_age" |
| 258 | elif age > stale_after_seconds: |
| 259 | stale_reason = "exceeded_max_age" |
| 260 | return { |
| 261 | "locked": True, |
| 262 | "stale": stale, |
| 263 | "stale_reason": stale_reason, |
| 264 | "path": str(path), |
| 265 | "age_seconds": age, |
| 266 | "owner_pid": owner_pid, |
| 267 | "pid_alive": pid_alive, |
| 268 | } |
| 269 | |
| 270 | |
| 271 | @contextmanager |