oc_chat
Loading...
Searching...
No Matches
lock.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/regenerate/lock.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/regenerate/test_lock.py"
6"""
7The single-worktree ``regenerate`` file lock ([OP-7], §5.3, Plan 142i R5).
8
9Purpose
10-------
11Serialize concurrent ``regenerate`` runs in one worktree with an atomic
12(``O_CREAT|O_EXCL``) lock file carrying the holder's **PID + process-start-time +
13acquire-timestamp**. A second live holder fails closed with ``REGEN_LOCK_HELD``.
14A lock is reclaimed **only on provable holder death** — a **dead PID** or a
15**process-start-time mismatch** (PID reuse) — never on elapsed time ([OP-7]: a
16renewal-less file lock cannot tell a slow-but-alive holder from a dead one, so a
17staleness auto-reclaim would let a second run steal a live lock and corrupt the
18swap). A **live-or-inconclusive** holder is never auto-reclaimed; operator
19``--force`` is the sole override and every reclamation is surfaced as a typed
20``LockReclaimEvent`` (never an unobserved log line).
21
22Responsibilities
23----------------
24- ``acquire(lock_path, *, force, clock, probe)`` — atomically take the lock or
25 fail closed / reclaim-on-provable-death; return a ``LockHandle``.
26- ``release(handle)`` — drop our own lock.
27- ``LockReclaimEvent`` — the typed, assertable reclamation record.
28
29Testability
30-----------
31``acquire`` takes injectable **clock** + **process-probe** seams so the tests
32cover live-PID / dead-PID / start-time-mismatch / live-but-slow / malformed-lock
33/ ``--force`` paths without real sleeps, real PID reuse, or live-PID races.
34
35Diagnostics
36-----------
37Domain: OC_CHAT.MAIN
38Levels:
39 L2 - acquire/release lifecycle
40 L3 - reclaim classification
41 L4 - liveness-probe details
42
43Contracts
44---------
45- Atomic create — ``O_CREAT|O_EXCL`` so the lock file has no create race.
46- Reclaim ⇔ provable death (dead PID or start-time mismatch) OR ``force``.
47- ``force`` reclaims but NEVER skips validation/canonicalisation/derive-verify
48 (that is the pipeline's job); it only reclaims the lock and logs the event.
49- Every probe FAILURE ⇒ inconclusive ⇒ fail-closed (never mapped to "dead").
50
51Dependencies
52------------
53- oc_diagnostics (structured debug logging via _dbg)
54- oc_chat.registry.regenerate.envelope (REGEN_LOCK_HELD / RegenerateError)
55- oc_chat.registry.clock (RealClock wall time for the default acquire timestamp)
56"""
57from __future__ import annotations
58
59import json
60import os
61import sys
62from dataclasses import dataclass
63from pathlib import Path
64from typing import Callable, Optional
65
66from oc_diagnostics import _dbg
67
68from oc_chat.registry.clock import RealClock
69from oc_chat.registry.regenerate.envelope import REGEN_LOCK_HELD, RegenerateError
70
71_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
72
73# Liveness-probe outcomes (only DEAD is a death proof; ALIVE + start-time
74# mismatch is the other; every failure maps to INCONCLUSIVE, never DEAD — H8).
75PROBE_DEAD = "dead"
76PROBE_ALIVE = "alive"
77PROBE_INCONCLUSIVE = "inconclusive"
78
79# Reclaim reasons (the LockReclaimEvent.reason closed set).
80REASON_DEAD_PID = "dead_pid"
81REASON_START_TIME_MISMATCH = "start_time_mismatch"
82REASON_FORCED = "forced_live_or_inconclusive"
83
84#: A clock seam — returns an ISO-8601 wall-clock string (not hash-contributing).
85Clock = Callable[[], str]
86#: A process-probe seam — ``pid -> (outcome, current_process_start_time|None)``.
87Probe = Callable[[int], "tuple[str, Optional[str]]"]
88
89
90@dataclass(frozen=True)
92 """A typed record of one lock reclamation — the contract surface tests assert
93 (surfaced canonically as ``RegenerateResult.lock_events``)."""
94
95 lock_path: str
96 pid: Optional[int]
97 process_start_time: Optional[str]
98 acquired_at: Optional[str]
99 reason: str
100 force: bool
101 timestamp: str
102
103 def to_dict(self) -> dict[str, object]:
104 return {
105 "lock_path": self.lock_path,
106 "pid": self.pid,
107 "process_start_time": self.process_start_time,
108 "acquired_at": self.acquired_at,
109 "reason": self.reason,
110 "force": self.force,
111 "timestamp": self.timestamp,
112 }
113
114
115@dataclass(frozen=True)
117 """A held lock — pass to ``release``; carries the reclaim event if we stole it."""
118
119 lock_path: Path
120 pid: int
121 reclaim_event: Optional[LockReclaimEvent] = None
122
123
124@dataclass(frozen=True)
126 pid: int
127 process_start_time: str
128 acquired_at: str
129
130
131def _default_clock() -> str:
132 # Wall-clock via the central registry clock (clock-discipline: registry code
133 # must not import time/datetime directly). The timestamp is runtime lock
134 # metadata — never hash-contributing.
135 return RealClock().now_wall()
136
137
138# --- liveness probe (no new dependency: ctypes on Windows, /proc on POSIX) ---
139def _probe_posix(pid: int) -> "tuple[str, Optional[str]]":
140 try:
141 os.kill(pid, 0)
142 except ProcessLookupError:
143 return (PROBE_DEAD, None) # ESRCH — provably no such process
144 except PermissionError:
145 return (PROBE_ALIVE, _proc_start_time_posix(pid)) # EPERM — exists (alive)
146 except OSError:
147 return (PROBE_INCONCLUSIVE, None) # any other failure — never "dead"
148 return (PROBE_ALIVE, _proc_start_time_posix(pid))
149
150
151def _proc_start_time_posix(pid: int) -> Optional[str]:
152 try:
153 stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
154 tail = stat[stat.rfind(")") + 2:].split() # skip pid + (comm)
155 return tail[19] # field 22 (starttime) = tail index 19 (after state@0)
156 except (OSError, ValueError, IndexError):
157 return None
158
159
160def _probe_windows(pid: int) -> "tuple[str, Optional[str]]":
161 import ctypes
162 from ctypes import wintypes
163
164 PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
165 ERROR_INVALID_PARAMETER = 87 # OpenProcess on a nonexistent PID -> dead
166 kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
167 kernel32.OpenProcess.restype = wintypes.HANDLE
168 kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
169 kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
170 kernel32.GetProcessTimes.argtypes = [
171 wintypes.HANDLE,
172 ctypes.POINTER(wintypes.FILETIME),
173 ctypes.POINTER(wintypes.FILETIME),
174 ctypes.POINTER(wintypes.FILETIME),
175 ctypes.POINTER(wintypes.FILETIME),
176 ]
177 handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
178 if not handle:
179 err = ctypes.get_last_error()
180 if err == ERROR_INVALID_PARAMETER:
181 return (PROBE_DEAD, None)
182 return (PROBE_INCONCLUSIVE, None) # ACCESS_DENIED etc. — never "dead"
183 try:
184 creation = wintypes.FILETIME()
185 exit_t = wintypes.FILETIME()
186 kernel_t = wintypes.FILETIME()
187 user_t = wintypes.FILETIME()
188 ok = kernel32.GetProcessTimes(
189 handle, ctypes.byref(creation), ctypes.byref(exit_t),
190 ctypes.byref(kernel_t), ctypes.byref(user_t),
191 )
192 if not ok:
193 return (PROBE_INCONCLUSIVE, None)
194 return (PROBE_ALIVE, f"{creation.dwHighDateTime}:{creation.dwLowDateTime}")
195 finally:
196 kernel32.CloseHandle(handle)
197
198
199def _default_probe(pid: int) -> "tuple[str, Optional[str]]":
200 if sys.platform == "win32":
201 return _probe_windows(pid)
202 return _probe_posix(pid)
203
204
205def _read_record(lock_path: Path) -> Optional[_LockRecord]:
206 """Parse the lock record, or ``None`` if missing/unreadable/malformed (H6)."""
207 try:
208 data = json.loads(lock_path.read_text(encoding="utf-8"))
209 return _LockRecord(
210 pid=int(data["pid"]),
211 process_start_time=str(data["process_start_time"]),
212 acquired_at=str(data["acquired_at"]),
213 )
214 except (OSError, ValueError, KeyError, TypeError):
215 return None
216
217
218def _atomic_create(lock_path: Path, pid: int, start_time: str, acquired_at: str) -> None:
219 """Create the lock file atomically (``O_CREAT|O_EXCL``) — raises
220 ``FileExistsError`` if a lock already exists (the no-race create)."""
221 payload = json.dumps(
222 {"pid": pid, "process_start_time": start_time, "acquired_at": acquired_at},
223 sort_keys=True,
224 )
225 fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
226 try:
227 os.write(fd, payload.encode("utf-8"))
228 finally:
229 os.close(fd)
230
231
233 lock_path: Path, record: Optional[_LockRecord], reason: str, *, force: bool, clock: Clock
234) -> LockReclaimEvent:
235 return LockReclaimEvent(
236 lock_path=str(lock_path),
237 pid=record.pid if record is not None else None,
238 process_start_time=record.process_start_time if record is not None else None,
239 acquired_at=record.acquired_at if record is not None else None,
240 reason=reason,
241 force=force,
242 timestamp=clock(),
243 )
244
245
247 lock_path: Path, record: Optional[_LockRecord], force: bool, probe: Probe, clock: Clock
248) -> LockReclaimEvent:
249 """Return a ``LockReclaimEvent`` if reclaimable; else raise ``REGEN_LOCK_HELD``.
250
251 Reclaimable ⇔ provable holder death (dead PID or start-time mismatch), OR
252 ``force`` on a live/inconclusive holder. A malformed record (``None``) is
253 inconclusive — never auto-reclaimed.
254 """
255 reason: Optional[str] = None
256 if record is not None:
257 outcome, current_start = probe(record.pid)
258 if outcome == PROBE_DEAD:
259 reason = REASON_DEAD_PID
260 elif (
261 outcome == PROBE_ALIVE
262 and current_start
263 and record.process_start_time
264 and current_start != record.process_start_time
265 ):
266 reason = REASON_START_TIME_MISMATCH # PID reuse — the holder is gone
267 if reason is not None:
268 return _make_event(lock_path, record, reason, force=False, clock=clock)
269 if force:
270 return _make_event(lock_path, record, REASON_FORCED, force=True, clock=clock)
271 raise RegenerateError(
272 f"regenerate lock held by a live-or-inconclusive process at {lock_path}",
273 code=REGEN_LOCK_HELD,
274 hint="another regenerate is running; wait for it, or pass --force to reclaim (logged)",
275 )
276
277
279 lock_path: Path,
280 *,
281 force: bool = False,
282 clock: Optional[Clock] = None,
283 probe: Optional[Probe] = None,
284) -> LockHandle:
285 """Acquire the regenerate lock at ``lock_path``.
286
287 Raises ``REGEN_LOCK_HELD`` when the lock is held by a live-or-inconclusive
288 process and ``force`` is false. Creates ``lock_path.parent`` first (H5).
289 """
290 func = "acquire"
291 lock_path = Path(lock_path)
292 clock = clock or _default_clock
293 probe = probe or _default_probe
294 lock_path.parent.mkdir(parents=True, exist_ok=True) # H5
295 self_pid = os.getpid()
296 self_start = probe(self_pid)[1] or ""
297 acquired_at = clock()
298 try:
299 _atomic_create(lock_path, self_pid, self_start, acquired_at)
300 return LockHandle(lock_path, self_pid, None)
301 except FileExistsError:
302 pass
303 # The lock exists — classify the holder and reclaim only if provably dead
304 # (or forced). A raise here (REGEN_LOCK_HELD) propagates unchanged.
305 record = _read_record(lock_path)
306 event = _decide_reclaim(lock_path, record, force, probe, clock)
307 lock_path.unlink(missing_ok=True)
308 try:
309 _atomic_create(lock_path, self_pid, self_start, acquired_at)
310 except FileExistsError as exc:
311 raise RegenerateError(
312 "regenerate lock re-acquired by another process during reclamation",
313 code=REGEN_LOCK_HELD,
314 hint="a concurrent regenerate won the race; retry",
315 ) from exc
316 _dbg("OC_CHAT.MAIN", func, f"reclaimed lock ({event.reason})", 2)
317 return LockHandle(lock_path, self_pid, event)
318
319
320def release(handle: LockHandle) -> None:
321 """Release a held lock (best-effort; only removes our own lock record)."""
322 try:
323 record = _read_record(handle.lock_path)
324 if record is None or record.pid == handle.pid:
325 handle.lock_path.unlink(missing_ok=True)
326 except OSError:
327 pass
328
329
330__all__ = [
331 "PROBE_DEAD",
332 "PROBE_ALIVE",
333 "PROBE_INCONCLUSIVE",
334 "REASON_DEAD_PID",
335 "REASON_START_TIME_MISMATCH",
336 "REASON_FORCED",
337 "LockReclaimEvent",
338 "LockHandle",
339 "acquire",
340 "release",
341]
LockReclaimEvent _make_event(Path lock_path, Optional[_LockRecord] record, str reason, *, bool force, Clock clock)
Definition lock.py:234
"tuple[str, Optional[str]]" _probe_posix(int pid)
Definition lock.py:139
Optional[_LockRecord] _read_record(Path lock_path)
Definition lock.py:205
"tuple[str, Optional[str]]" _probe_windows(int pid)
Definition lock.py:160
None release(LockHandle handle)
Definition lock.py:320
None _atomic_create(Path lock_path, int pid, str start_time, str acquired_at)
Definition lock.py:218
LockHandle acquire(Path lock_path, *, bool force=False, Optional[Clock] clock=None, Optional[Probe] probe=None)
Definition lock.py:284
LockReclaimEvent _decide_reclaim(Path lock_path, Optional[_LockRecord] record, bool force, Probe probe, Clock clock)
Definition lock.py:248
Optional[str] _proc_start_time_posix(int pid)
Definition lock.py:151
"tuple[str, Optional[str]]" _default_probe(int pid)
Definition lock.py:199