Coverage for little_loops / fsm / concurrency.py: 27%

133 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-08 15:34 -0500

1"""Scope-based concurrency control for FSM loops. 

2 

3Prevents concurrent loops from conflicting when operating on 

4the same files or directories through file-based locking. 

5 

6Public exports: 

7 ScopeLock: Dataclass representing a scope lock 

8 LockManager: Manager for acquiring/releasing scope locks 

9 resolve_scope: Resolve ${context.<var>} templates in scope paths 

10""" 

11 

12from __future__ import annotations 

13 

14import errno 

15import fcntl 

16import json 

17import os 

18import re 

19import time 

20from dataclasses import dataclass 

21from datetime import UTC, datetime 

22from pathlib import Path 

23from typing import Any 

24 

25RUNNING_DIR = ".running" 

26 

27# Match ${context.<var>} templates in scope paths 

28_CONTEXT_VAR_RE = re.compile(r"\$\{context\.([^}]+)\}") 

29 

30 

31def resolve_scope(scope: list[str], context: dict[str, Any]) -> list[str]: 

32 """Resolve ${context.<var>} templates in scope paths. 

33 

34 Each template expression referencing a context variable is replaced 

35 with the variable's value. Templates referencing unknown variables 

36 are left as-is (the literal string becomes the path, which 

37 Path.resolve() will turn into an absolute path). 

38 

39 Static paths (no templates) pass through unchanged. 

40 """ 

41 resolved: list[str] = [] 

42 for path in scope: 

43 

44 def _replace(m: re.Match[str]) -> str: 

45 var = m.group(1) 

46 return str(context[var]) if var in context else m.group(0) 

47 

48 resolved.append(_CONTEXT_VAR_RE.sub(_replace, path)) 

49 return resolved 

50 

51 

52def _process_alive(pid: int) -> bool: 

53 """Check if a process is still running. 

54 

55 Returns True if alive (or alive but unreadable due to EPERM), 

56 False only if process does not exist (ESRCH). 

57 """ 

58 try: 

59 os.kill(pid, 0) 

60 return True 

61 except OSError as e: 

62 if e.errno == errno.ESRCH: 

63 return False # No such process 

64 return True # EPERM or other: process exists, no permission 

65 

66 

67def _iso_now() -> str: 

68 """Return current time as ISO8601 string.""" 

69 return datetime.now(UTC).isoformat() 

70 

71 

72@dataclass 

73class ScopeLock: 

74 """Represents a lock on a set of paths for a running loop. 

75 

76 Attributes: 

77 loop_name: Name of the loop holding the lock 

78 scope: List of paths this loop operates on 

79 pid: Process ID of the lock holder 

80 started_at: ISO timestamp when lock was acquired 

81 """ 

82 

83 loop_name: str 

84 scope: list[str] 

85 pid: int 

86 started_at: str 

87 

88 def to_dict(self) -> dict[str, Any]: 

89 """Convert to dictionary for JSON serialization.""" 

90 return { 

91 "loop_name": self.loop_name, 

92 "scope": self.scope, 

93 "pid": self.pid, 

94 "started_at": self.started_at, 

95 } 

96 

97 @classmethod 

98 def from_dict(cls, data: dict[str, Any]) -> ScopeLock: 

99 """Create from dictionary (JSON deserialization).""" 

100 return cls( 

101 loop_name=str(data["loop_name"]), 

102 scope=list(data["scope"]) if isinstance(data["scope"], list) else [str(data["scope"])], 

103 pid=int(data["pid"]), 

104 started_at=str(data["started_at"]), 

105 ) 

106 

107 

108class LockManager: 

109 """Manage scope-based locks for concurrent loop execution. 

110 

111 Lock files are stored in .loops/.running/<instance_id>.lock 

112 and contain JSON with ScopeLock data. 

113 """ 

114 

115 def __init__(self, loops_dir: Path | None = None) -> None: 

116 """Initialize the lock manager. 

117 

118 Args: 

119 loops_dir: Base directory for loops (default: .loops) 

120 """ 

121 self.loops_dir = loops_dir or Path(".loops") 

122 self.running_dir = self.loops_dir / RUNNING_DIR 

123 

124 def acquire(self, loop_name: str, scope: list[str], instance_id: str | None = None) -> bool: 

125 """Attempt to acquire lock for the given scope. 

126 

127 Args: 

128 loop_name: Name of the loop to acquire lock for 

129 scope: List of paths the loop operates on 

130 instance_id: Optional unique instance identifier; falls back to loop_name when None 

131 

132 Returns: 

133 True if lock acquired, False if conflict exists 

134 """ 

135 # Normalize scope - empty means whole project 

136 if not scope: 

137 scope = ["."] 

138 scope = [self._normalize_path(p) for p in scope] 

139 

140 # Ensure running directory exists before opening sentinel lock 

141 self.running_dir.mkdir(parents=True, exist_ok=True) 

142 

143 # Serialize the check-and-create sequence across processes using a 

144 # sentinel file. This eliminates the TOCTOU window between 

145 # find_conflict() (read) and lock-file creation (write). 

146 # .acquire.lock is a dotfile so Path.glob("*.lock") will not match it 

147 # and stale-lock cleanup in find_conflict/list_locks ignores it. 

148 dir_lock_path = self.running_dir / ".acquire.lock" 

149 with open(dir_lock_path, "w") as dir_lock: 

150 fcntl.flock(dir_lock, fcntl.LOCK_EX) 

151 

152 # Check for conflicts (now atomic with write below) 

153 conflict = self.find_conflict(scope) 

154 if conflict: 

155 return False 

156 

157 # Create lock file 

158 lock_file = self.running_dir / f"{instance_id or loop_name}.lock" 

159 lock_file.parent.mkdir(parents=True, exist_ok=True) 

160 lock = ScopeLock( 

161 loop_name=loop_name, 

162 scope=scope, 

163 pid=os.getpid(), 

164 started_at=_iso_now(), 

165 ) 

166 with open(lock_file, "w") as f: 

167 json.dump(lock.to_dict(), f) 

168 

169 return True 

170 

171 def release(self, loop_name: str, instance_id: str | None = None) -> None: 

172 """Release lock for a loop. 

173 

174 Args: 

175 loop_name: Name of the loop to release lock for 

176 instance_id: Optional unique instance identifier; falls back to loop_name when None 

177 """ 

178 lock_file = self.running_dir / f"{instance_id or loop_name}.lock" 

179 lock_file.unlink(missing_ok=True) 

180 

181 def find_conflict(self, scope: list[str]) -> ScopeLock | None: 

182 """Find any running loop with overlapping scope. 

183 

184 Also cleans up stale locks from dead processes. 

185 

186 Args: 

187 scope: Scope to check for conflicts 

188 

189 Returns: 

190 ScopeLock of conflicting loop, or None if no conflict 

191 """ 

192 if not self.running_dir.exists(): 

193 return None 

194 

195 # Normalize once before the comparison loop to avoid O(n*m) stat calls 

196 normalized_scope = [self._normalize_path(p) for p in scope] 

197 

198 for lock_file in self.running_dir.glob("*.lock"): 

199 try: 

200 with open(lock_file) as f: 

201 data = json.load(f) 

202 lock = ScopeLock.from_dict(data) 

203 

204 # Check if process is still alive 

205 if not self._process_alive(lock.pid): 

206 # Stale lock, remove it 

207 lock_file.unlink(missing_ok=True) 

208 continue 

209 

210 # Normalize lock scope (lock files from acquire() are already 

211 # absolute, but normalize defensively in case of legacy files) 

212 lock_scope = [self._normalize_path(p) for p in lock.scope] 

213 if self._scopes_overlap(normalized_scope, lock_scope): 

214 return lock 

215 

216 except (json.JSONDecodeError, KeyError, FileNotFoundError): 

217 # Malformed or deleted lock file, skip 

218 continue 

219 

220 return None 

221 

222 def list_locks(self) -> list[ScopeLock]: 

223 """List all active locks. 

224 

225 Cleans up stale locks as a side effect. 

226 

227 Returns: 

228 List of active ScopeLock objects 

229 """ 

230 locks: list[ScopeLock] = [] 

231 if not self.running_dir.exists(): 

232 return locks 

233 

234 for lock_file in self.running_dir.glob("*.lock"): 

235 try: 

236 with open(lock_file) as f: 

237 data = json.load(f) 

238 lock = ScopeLock.from_dict(data) 

239 

240 if self._process_alive(lock.pid): 

241 locks.append(lock) 

242 else: 

243 # Stale lock, remove it 

244 lock_file.unlink(missing_ok=True) 

245 except (json.JSONDecodeError, KeyError, FileNotFoundError): 

246 continue 

247 

248 return locks 

249 

250 def wait_for_scope(self, scope: list[str], timeout: int = 300) -> bool: 

251 """Wait until scope is available. 

252 

253 Args: 

254 scope: Scope to wait for 

255 timeout: Maximum time to wait in seconds 

256 

257 Returns: 

258 True if scope became available, False if timeout 

259 """ 

260 start = time.time() 

261 while time.time() - start < timeout: 

262 conflict = self.find_conflict(scope) 

263 if conflict is None: 

264 return True 

265 time.sleep(1) 

266 

267 return False 

268 

269 def _scopes_overlap(self, scope1: list[str], scope2: list[str]) -> bool: 

270 """Check if two scopes have any overlapping paths.""" 

271 for p1 in scope1: 

272 for p2 in scope2: 

273 if self._paths_overlap(p1, p2): 

274 return True 

275 return False 

276 

277 def _paths_overlap(self, path1: str, path2: str) -> bool: 

278 """Check if two paths overlap (same, or one contains the other). 

279 

280 Assumes paths are already normalized (pre-resolved absolute strings). 

281 """ 

282 p1 = Path(path1) 

283 p2 = Path(path2) 

284 

285 # Same path 

286 if p1 == p2: 

287 return True 

288 

289 # One is parent of other 

290 try: 

291 p1.relative_to(p2) 

292 return True 

293 except ValueError: 

294 pass 

295 

296 try: 

297 p2.relative_to(p1) 

298 return True 

299 except ValueError: 

300 pass 

301 

302 return False 

303 

304 def _normalize_path(self, path: str) -> str: 

305 """Normalize path for consistent comparison.""" 

306 return str(Path(path).resolve()) 

307 

308 def _process_alive(self, pid: int) -> bool: 

309 """Check if process is still running.""" 

310 return _process_alive(pid)