collab-runtime 0.2.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. collab/__init__.py +77 -0
  2. collab/__main__.py +11 -0
  3. collab_runtime-0.2.9.dist-info/METADATA +218 -0
  4. collab_runtime-0.2.9.dist-info/RECORD +82 -0
  5. collab_runtime-0.2.9.dist-info/WHEEL +5 -0
  6. collab_runtime-0.2.9.dist-info/entry_points.txt +3 -0
  7. collab_runtime-0.2.9.dist-info/licenses/LICENSE +21 -0
  8. collab_runtime-0.2.9.dist-info/top_level.txt +10 -0
  9. scripts/cleanup.py +395 -0
  10. scripts/collab_git_hook.py +190 -0
  11. scripts/format_code.py +594 -0
  12. scripts/generate_tests.py +560 -0
  13. scripts/validate_code.py +1397 -0
  14. src/__init__.py +4 -0
  15. src/dashboard/index.html +1131 -0
  16. src/live_locks_watcher.py +1982 -0
  17. src/lock_client.py +4268 -0
  18. src/logging_config.py +259 -0
  19. src/main.py +436 -0
  20. tests/backend/__init__.py +0 -0
  21. tests/backend/functional/__init__.py +0 -0
  22. tests/backend/functional/test_package_imports.py +43 -0
  23. tests/backend/integration/__init__.py +0 -0
  24. tests/backend/integration/test_cli_contract_parity.py +220 -0
  25. tests/backend/performance/__init__.py +0 -0
  26. tests/backend/reliability/__init__.py +0 -0
  27. tests/backend/security/__init__.py +0 -0
  28. tests/backend/unit/live_locks_watcher/__init__.py +5 -0
  29. tests/backend/unit/live_locks_watcher/_helpers.py +123 -0
  30. tests/backend/unit/live_locks_watcher/conftest.py +18 -0
  31. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_dashboard.py +188 -0
  32. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_developer.py +56 -0
  33. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_graceful_shutdown.py +459 -0
  34. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_main.py +1925 -0
  35. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_module.py +187 -0
  36. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_multi_session.py +320 -0
  37. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_notify.py +67 -0
  38. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_parsing.py +155 -0
  39. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_process_helpers.py +684 -0
  40. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_processing.py +173 -0
  41. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_prompt_abort.py +71 -0
  42. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_reconcile.py +516 -0
  43. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_scan.py +296 -0
  44. tests/backend/unit/lock_client/__init__.py +1 -0
  45. tests/backend/unit/lock_client/_helpers.py +132 -0
  46. tests/backend/unit/lock_client/test_lock_client_acquire.py +214 -0
  47. tests/backend/unit/lock_client/test_lock_client_active.py +104 -0
  48. tests/backend/unit/lock_client/test_lock_client_api.py +63 -0
  49. tests/backend/unit/lock_client/test_lock_client_cli.py +682 -0
  50. tests/backend/unit/lock_client/test_lock_client_daemon.py +3730 -0
  51. tests/backend/unit/lock_client/test_lock_client_dashboard.py +438 -0
  52. tests/backend/unit/lock_client/test_lock_client_discover.py +241 -0
  53. tests/backend/unit/lock_client/test_lock_client_force_release.py +354 -0
  54. tests/backend/unit/lock_client/test_lock_client_helper_branches.py +1890 -0
  55. tests/backend/unit/lock_client/test_lock_client_history.py +301 -0
  56. tests/backend/unit/lock_client/test_lock_client_isolation.py +316 -0
  57. tests/backend/unit/lock_client/test_lock_client_pid.py +75 -0
  58. tests/backend/unit/lock_client/test_lock_client_reconcile.py +464 -0
  59. tests/backend/unit/lock_client/test_lock_client_release.py +77 -0
  60. tests/backend/unit/lock_client/test_lock_client_shutdown.py +1110 -0
  61. tests/backend/unit/lock_client/test_lock_client_utils.py +474 -0
  62. tests/backend/unit/lock_client/test_lock_client_watch.py +866 -0
  63. tests/backend/unit/scripts/__init__.py +1 -0
  64. tests/backend/unit/scripts/_helpers.py +42 -0
  65. tests/backend/unit/scripts/test_cleanup.py +285 -0
  66. tests/backend/unit/scripts/test_collab_git_hook.py +280 -0
  67. tests/backend/unit/scripts/test_collab_git_hook_ported.py +50 -0
  68. tests/backend/unit/scripts/test_format_code.py +368 -0
  69. tests/backend/unit/scripts/test_format_code_ported.py +177 -0
  70. tests/backend/unit/scripts/test_generate_tests.py +305 -0
  71. tests/backend/unit/scripts/test_hook_templates.py +357 -0
  72. tests/backend/unit/scripts/test_setup_hook_overlay.py +95 -0
  73. tests/backend/unit/scripts/test_validate_code.py +867 -0
  74. tests/backend/unit/scripts/test_validate_code_ported.py +237 -0
  75. tests/backend/unit/test_entrypoints_main_run.py +83 -0
  76. tests/backend/unit/test_logging_config.py +529 -0
  77. tests/backend/unit/test_main_watch_pid_file.py +278 -0
  78. tests/conftest.py +167 -0
  79. tests/frontend/__init__.py +0 -0
  80. tests/frontend/jest/__init__.py +0 -0
  81. tests/frontend/playwright/__init__.py +0 -0
  82. tests/packaging/test_smoke_install.py +76 -0
@@ -0,0 +1,459 @@
1
+ """Graceful shutdown tests for live_locks_watcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._helpers import load_watcher_module
6
+
7
+
8
+ def test_graceful_shutdown_functionality(monkeypatch):
9
+ mod = load_watcher_module()
10
+ """Test graceful shutdown cleans up resources."""
11
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
12
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
13
+ monkeypatch.setenv("DEVELOPER_ID", "test_dev")
14
+
15
+ if hasattr(mod, "_graceful_shutdown"):
16
+ try:
17
+ mod._graceful_shutdown()
18
+ except Exception:
19
+ pass
20
+
21
+
22
+ def test_graceful_shutdown_with_valid_dev_id(monkeypatch, tmp_path):
23
+ mod = load_watcher_module()
24
+ """Test _graceful_shutdown releases locks for clean files and removes PID file."""
25
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "test_dev")
26
+ monkeypatch.setattr(mod, "SUPABASE_URL", "https://test.supabase.co")
27
+ monkeypatch.setattr(mod, "SUPABASE_ANON_KEY", "test_key")
28
+ monkeypatch.setenv("COLLAB_TEST_MODE", "0")
29
+
30
+ pid_file = tmp_path / "watcher.pid"
31
+ pid_file.write_text("12345")
32
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
33
+
34
+ # Mock git status returning empty (all files clean  all locks released)
35
+ monkeypatch.setattr(mod, "_run_git_status_porcelain", lambda: set())
36
+
37
+ class FakeTable:
38
+ def delete(self):
39
+ return self
40
+
41
+ def select(self, *args):
42
+ return self
43
+
44
+ def eq(self, *args):
45
+ return self
46
+
47
+ def execute(self):
48
+ return type("R", (), {"data": []})()
49
+
50
+ class FakeSupaClient:
51
+ def table(self, name):
52
+ return FakeTable()
53
+
54
+ monkeypatch.setattr(mod, "create_client", lambda url, key: FakeSupaClient())
55
+
56
+ mod._graceful_shutdown()
57
+ assert not pid_file.exists()
58
+
59
+
60
+ def test_graceful_shutdown_with_error(monkeypatch, tmp_path):
61
+ mod = load_watcher_module()
62
+ """Test _graceful_shutdown handles errors during lock release."""
63
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "test_dev")
64
+ monkeypatch.setattr(mod, "SUPABASE_URL", "https://test.supabase.co")
65
+ monkeypatch.setattr(mod, "SUPABASE_ANON_KEY", "test_key")
66
+ monkeypatch.setenv("COLLAB_TEST_MODE", "0")
67
+
68
+ pid_file = tmp_path / "watcher.pid"
69
+ pid_file.write_text("12345")
70
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
71
+
72
+ def exploding_client(url, key):
73
+ raise RuntimeError("Connection failed")
74
+
75
+ monkeypatch.setattr(mod, "create_client", exploding_client)
76
+
77
+ mod._graceful_shutdown()
78
+ assert not pid_file.exists()
79
+
80
+
81
+ def test_graceful_shutdown_no_dev_id(monkeypatch, tmp_path):
82
+ mod = load_watcher_module()
83
+ """Test _graceful_shutdown when DEVELOPER_ID is None."""
84
+ monkeypatch.setattr(mod, "DEVELOPER_ID", None)
85
+
86
+ pid_file = tmp_path / "watcher.pid"
87
+ pid_file.write_text("12345")
88
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
89
+ monkeypatch.setenv("COLLAB_TEST_MODE", "0")
90
+
91
+ mod._graceful_shutdown()
92
+ assert not pid_file.exists()
93
+
94
+
95
+ def test_graceful_shutdown_pid_file_missing(monkeypatch, tmp_path):
96
+ mod = load_watcher_module()
97
+ """Test _graceful_shutdown when PID file doesn't exist."""
98
+ monkeypatch.setattr(mod, "DEVELOPER_ID", None)
99
+ monkeypatch.setattr(mod, "PID_FILE", str(tmp_path / "missing.pid"))
100
+
101
+ mod._graceful_shutdown() # Should not raise
102
+
103
+
104
+ def test_graceful_shutdown_pid_oserror(monkeypatch, tmp_path):
105
+ mod = load_watcher_module()
106
+ """Test _graceful_shutdown handles OSError when removing PID file."""
107
+ import os
108
+
109
+ monkeypatch.setattr(mod, "DEVELOPER_ID", None)
110
+
111
+ # Create a PID file path that will fail on os.remove
112
+ pid_file = tmp_path / "locked.pid"
113
+ pid_file.write_text("12345")
114
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
115
+
116
+ original_remove = os.remove
117
+
118
+ def failing_remove(path):
119
+ if "locked.pid" in str(path):
120
+ raise OSError("Permission denied")
121
+ return original_remove(path)
122
+
123
+ monkeypatch.setattr(os, "remove", failing_remove)
124
+
125
+ mod._graceful_shutdown() # Should not raise
126
+
127
+
128
+ def test_graceful_shutdown_guard_prevents_double_run(monkeypatch, tmp_path):
129
+ mod = load_watcher_module()
130
+ """_graceful_shutdown runs only once; second call is a no-op."""
131
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "test_dev")
132
+ monkeypatch.setattr(mod, "SUPABASE_URL", "https://test.supabase.co")
133
+ monkeypatch.setattr(mod, "SUPABASE_ANON_KEY", "test_tag")
134
+ monkeypatch.setattr(mod, "PID_FILE", str(tmp_path / "pid"))
135
+ monkeypatch.setenv("COLLAB_TEST_MODE", "0")
136
+
137
+ # Mock git status  all clean
138
+ monkeypatch.setattr(mod, "_run_git_status_porcelain", lambda: set())
139
+
140
+ call_count = [0]
141
+
142
+ class FakeTable:
143
+ def delete(self):
144
+ return self
145
+
146
+ def select(self, *args):
147
+ return self
148
+
149
+ def eq(self, *args):
150
+ return self
151
+
152
+ def execute(self):
153
+ call_count[0] += 1
154
+ return type("R", (), {"data": []})()
155
+
156
+ class FakeClient:
157
+ def table(self, name):
158
+ return FakeTable()
159
+
160
+ monkeypatch.setattr(mod, "create_client", lambda url, key: FakeClient())
161
+
162
+ mod._graceful_shutdown() # first call  runs
163
+ first_count = call_count[0]
164
+ mod._graceful_shutdown() # second call  guard returns immediately
165
+
166
+ assert call_count[0] == first_count # no additional calls after guard
167
+
168
+
169
+ def test_graceful_shutdown_dev_id_without_credentials(monkeypatch, tmp_path):
170
+ mod = load_watcher_module()
171
+ """_graceful_shutdown skips lock release when credentials are missing."""
172
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "test_dev")
173
+ monkeypatch.setattr(mod, "SUPABASE_URL", None) # missing
174
+ monkeypatch.setattr(mod, "SUPABASE_ANON_KEY", "test_key")
175
+ pid_file = tmp_path / "watcher.pid"
176
+ pid_file.write_text("12345")
177
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
178
+ monkeypatch.setenv("COLLAB_TEST_MODE", "0")
179
+
180
+ mod._graceful_shutdown() # should not attempt API call
181
+ assert not pid_file.exists()
182
+
183
+
184
+ def test_graceful_shutdown_queries_supabase_when_local_empty(monkeypatch, tmp_path):
185
+ mod = load_watcher_module()
186
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "test_dev")
187
+ monkeypatch.setattr(mod, "SUPABASE_URL", "http://test")
188
+ monkeypatch.setattr(mod, "SUPABASE_ANON_KEY", "key")
189
+ monkeypatch.setattr(mod, "_shutdown_done", False)
190
+ monkeypatch.setenv("COLLAB_TEST_MODE", "0")
191
+
192
+ monkeypatch.setattr(mod, "PID_FILE", str(tmp_path / "pid.txt"))
193
+ monkeypatch.setattr(mod, "_run_git_status_porcelain", lambda: {"src/dirty.py"})
194
+
195
+ mod._local_owned_locks.clear()
196
+
197
+ deleted_paths = []
198
+
199
+ class FakeSelectResp:
200
+ data = [{"file_path": "src/clean.py"}, {"file_path": "src/dirty.py"}]
201
+
202
+ class FakeTable:
203
+ def select(self, *args):
204
+ return self
205
+
206
+ def eq(self, *args):
207
+ return self
208
+
209
+ def execute(self):
210
+ return FakeSelectResp()
211
+
212
+ def delete(self):
213
+ return self
214
+
215
+ # For delete eq chaining
216
+ class DeleteFakeTable:
217
+ def __init__(self):
218
+ self.p = None
219
+
220
+ def delete(self):
221
+ return self
222
+
223
+ def eq(self, field, value):
224
+ if field == "file_path":
225
+ self.p = value
226
+ return self
227
+
228
+ def execute(self):
229
+ if self.p:
230
+ deleted_paths.append(self.p)
231
+
232
+ class FakeClient:
233
+ def table(self, name):
234
+ if not getattr(self, "selected", False):
235
+ self.selected = True
236
+ return FakeTable()
237
+ return DeleteFakeTable()
238
+
239
+ monkeypatch.setattr(mod, "create_client", lambda url, key: FakeClient())
240
+
241
+ mod._graceful_shutdown()
242
+
243
+ assert "src/clean.py" in deleted_paths
244
+ assert "src/dirty.py" not in deleted_paths
245
+
246
+
247
+ def test_graceful_shutdown_keeps_dirty_locks(monkeypatch, tmp_path):
248
+ mod = load_watcher_module()
249
+ """§8a: Dirty files are NOT released during shutdown.
250
+
251
+ When _graceful_shutdown runs and git status shows files still dirty, those files'
252
+ locks must be preserved in Supabase (not deleted).
253
+ """
254
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "test_dev")
255
+ monkeypatch.setattr(mod, "SUPABASE_URL", "https://test.supabase.co")
256
+ monkeypatch.setattr(mod, "SUPABASE_ANON_KEY", "test_key")
257
+ monkeypatch.setenv("COLLAB_TEST_MODE", "0")
258
+
259
+ pid_file = tmp_path / "watcher.pid"
260
+ pid_file.write_text("12345")
261
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
262
+
263
+ # Simulate: src/dirty.py is still dirty, src/clean.py is clean
264
+ monkeypatch.setattr(mod, "_run_git_status_porcelain", lambda: {"src/dirty.py"})
265
+ # Pre-populate _local_owned_locks with both files
266
+ mod._local_owned_locks.clear()
267
+ mod._local_owned_locks.update({"src/dirty.py", "src/clean.py"})
268
+
269
+ deleted_files = []
270
+
271
+ class FakeTable:
272
+ def __init__(self):
273
+ self._file_path = None
274
+ self._is_delete = False
275
+
276
+ def delete(self):
277
+ self._is_delete = True
278
+ return self
279
+
280
+ def select(self, *args):
281
+ return self
282
+
283
+ def eq(self, field, value):
284
+ if field == "file_path" and self._is_delete:
285
+ self._file_path = value
286
+ return self
287
+
288
+ def execute(self):
289
+ if self._file_path and self._is_delete:
290
+ deleted_files.append(self._file_path)
291
+ return type("R", (), {"data": []})()
292
+
293
+ class FakeSupaClient:
294
+ def table(self, name):
295
+ return FakeTable()
296
+
297
+ monkeypatch.setattr(mod, "create_client", lambda url, key: FakeSupaClient())
298
+
299
+ mod._graceful_shutdown()
300
+
301
+ # src/clean.py should have been released; src/dirty.py should NOT
302
+ assert "src/clean.py" in deleted_files
303
+ assert "src/dirty.py" not in deleted_files
304
+ assert not pid_file.exists()
305
+
306
+ # Clean up
307
+ mod._local_owned_locks.clear()
308
+
309
+
310
+ def test_graceful_shutdown_local_empty_query_exception(monkeypatch, tmp_path):
311
+ """When local lock set is empty and query fails, shutdown continues safely."""
312
+ mod = load_watcher_module()
313
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "test_dev")
314
+ monkeypatch.setattr(mod, "SUPABASE_URL", "https://test.supabase.co")
315
+ monkeypatch.setattr(mod, "SUPABASE_ANON_KEY", "test_key")
316
+ monkeypatch.setattr(mod, "_shutdown_done", False)
317
+ monkeypatch.setenv("COLLAB_TEST_MODE", "0")
318
+ monkeypatch.setattr(mod, "PID_FILE", str(tmp_path / "watcher.pid"))
319
+ monkeypatch.setattr(mod, "_run_git_status_porcelain", lambda: set())
320
+
321
+ mod._local_owned_locks.clear()
322
+
323
+ class _BrokenTable:
324
+ def select(self, *args):
325
+ return self
326
+
327
+ def eq(self, *args):
328
+ return self
329
+
330
+ def execute(self):
331
+ raise RuntimeError("query fail")
332
+
333
+ class _Client:
334
+ def table(self, _name):
335
+ return _BrokenTable()
336
+
337
+ monkeypatch.setattr(mod, "create_client", lambda url, key: _Client())
338
+ mod._graceful_shutdown() # should not raise
339
+
340
+
341
+ def test_graceful_shutdown_git_failure_releases_all(monkeypatch, tmp_path):
342
+ mod = load_watcher_module()
343
+ """§8b: Git failure during shutdown falls back to blanket release-all.
344
+
345
+ When _run_git_status_porcelain raises an exception, _graceful_shutdown should fall
346
+ back to the legacy blanket delete behavior.
347
+ """
348
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "test_dev")
349
+ monkeypatch.setattr(mod, "SUPABASE_URL", "https://test.supabase.co")
350
+ monkeypatch.setattr(mod, "SUPABASE_ANON_KEY", "test_key")
351
+ monkeypatch.setenv("COLLAB_TEST_MODE", "0")
352
+
353
+ pid_file = tmp_path / "watcher.pid"
354
+ pid_file.write_text("12345")
355
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
356
+
357
+ # Make git status fail
358
+ def failing_git_status():
359
+ raise RuntimeError("git not available")
360
+
361
+ monkeypatch.setattr(mod, "_run_git_status_porcelain", failing_git_status)
362
+
363
+ blanket_deleted = []
364
+
365
+ class FakeTable:
366
+ def __init__(self):
367
+ self._eq_args = []
368
+
369
+ def delete(self):
370
+ return self
371
+
372
+ def eq(self, field, value):
373
+ self._eq_args.append((field, value))
374
+ return self
375
+
376
+ def execute(self):
377
+ # Track that blanket delete was called (developer_id only)
378
+ dev_eq = [a for a in self._eq_args if a[0] == "developer_id"]
379
+ if dev_eq:
380
+ blanket_deleted.append(dev_eq[0][1])
381
+ return None
382
+
383
+ class FakeSupaClient:
384
+ def table(self, name):
385
+ return FakeTable()
386
+
387
+ monkeypatch.setattr(mod, "create_client", lambda url, key: FakeSupaClient())
388
+
389
+ mod._graceful_shutdown()
390
+
391
+ # Blanket release should have been called for test_dev
392
+ assert "test_dev" in blanket_deleted
393
+ assert not pid_file.exists()
394
+
395
+
396
+ def test_graceful_shutdown_release_exception_and_db_query_exception(
397
+ monkeypatch, tmp_path
398
+ ):
399
+ """Cover per-file release exception and fallback DB-query exception branches."""
400
+ mod = load_watcher_module()
401
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "test_dev")
402
+ monkeypatch.setattr(mod, "SUPABASE_URL", "https://test.supabase.co")
403
+ monkeypatch.setattr(mod, "SUPABASE_ANON_KEY", "test_key")
404
+ monkeypatch.setattr(mod, "_shutdown_done", False)
405
+ monkeypatch.setenv("COLLAB_TEST_MODE", "0")
406
+
407
+ pid_file = tmp_path / "watcher.pid"
408
+ pid_file.write_text("12345")
409
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
410
+
411
+ monkeypatch.setattr(mod, "_run_git_status_porcelain", lambda: set())
412
+ mod._local_owned_locks.clear()
413
+ mod._local_owned_locks.add("src/will_fail.py")
414
+
415
+ class FakeClient:
416
+ def __init__(self):
417
+ self._mode = None
418
+
419
+ def table(self, name):
420
+ return self
421
+
422
+ def delete(self):
423
+ self._mode = "delete"
424
+ return self
425
+
426
+ def select(self, *args):
427
+ self._mode = "select"
428
+ return self
429
+
430
+ def eq(self, *args):
431
+ return self
432
+
433
+ def execute(self):
434
+ raise RuntimeError("backend down")
435
+
436
+ monkeypatch.setattr(mod, "create_client", lambda url, key: FakeClient())
437
+ mod._graceful_shutdown()
438
+ assert not pid_file.exists()
439
+
440
+
441
+ def test_graceful_shutdown_pid_remove_retries_then_warns(monkeypatch, tmp_path):
442
+ """PID removal should retry and warn after three OSError attempts."""
443
+ mod = load_watcher_module()
444
+ monkeypatch.setattr(mod, "_shutdown_done", False)
445
+ monkeypatch.setenv("COLLAB_TEST_MODE", "0")
446
+ monkeypatch.setattr(mod, "DEVELOPER_ID", None)
447
+
448
+ pid_file = tmp_path / "blocked.pid"
449
+ pid_file.write_text("12345")
450
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
451
+
452
+ monkeypatch.setattr(mod.os.path, "exists", lambda p: True)
453
+ monkeypatch.setattr(mod.time, "sleep", lambda s: None)
454
+ monkeypatch.setattr(
455
+ mod.os, "remove", lambda p: (_ for _ in ()).throw(OSError("locked"))
456
+ )
457
+
458
+ # no raise expected after retry loop
459
+ mod._graceful_shutdown()