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,866 @@
1
+ """Watch-related tests for LockClient.watch().
2
+
3
+ Moved from the main `test_lock_client.py` for clarity.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import sys
10
+ from datetime import datetime, timedelta
11
+ from unittest import mock
12
+
13
+ from ._helpers import FakeResponse, load_lock_client_module, make_create_client
14
+
15
+ mod = load_lock_client_module()
16
+
17
+
18
+ def test_watch_idle_timeout(monkeypatch, tmp_path):
19
+ """Test watch() exits on idle timeout."""
20
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
21
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
22
+
23
+ pid_file = tmp_path / "daemon.pid"
24
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
25
+ monkeypatch.setattr(
26
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
27
+ )
28
+
29
+ # Make _run_git_status return empty (no changes)
30
+ monkeypatch.setattr(mod.LockClient, "_run_git_status", staticmethod(lambda: ""))
31
+
32
+ # Make _reconcile return empty set
33
+ monkeypatch.setattr(mod.LockClient, "_reconcile", lambda self: set())
34
+
35
+ # Advance time to trigger timeout quickly
36
+ time_offset = [0]
37
+ real_now = datetime.now
38
+
39
+ def advancing_now():
40
+ return real_now() + timedelta(minutes=time_offset[0])
41
+
42
+ monkeypatch.setattr(
43
+ mod,
44
+ "datetime",
45
+ type(
46
+ "FakeDT",
47
+ (),
48
+ {
49
+ "now": staticmethod(advancing_now),
50
+ "fromisoformat": datetime.fromisoformat,
51
+ },
52
+ )(),
53
+ )
54
+ monkeypatch.setattr(
55
+ mod.time, "sleep", lambda x: time_offset.__setitem__(0, time_offset[0] + 2)
56
+ )
57
+
58
+ lc = mod.LockClient(developer_id="test_user")
59
+ lc.watch(interval=1, timeout_mins=1) # Should exit due to timeout
60
+
61
+
62
+ def test_watch_with_file_changes(monkeypatch, tmp_path):
63
+ """Test watch() detects file changes and acquires locks."""
64
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
65
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
66
+
67
+ pid_file = tmp_path / "daemon.pid"
68
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
69
+
70
+ response = FakeResponse(status=200, data=[{"status": "ok"}])
71
+ monkeypatch.setattr(mod, "_get_create_client", lambda: make_create_client(response))
72
+
73
+ # First reconcile returns empty, then git status returns changes
74
+ git_call_count = [0]
75
+
76
+ def mock_git_status():
77
+ git_call_count[0] += 1
78
+ if git_call_count[0] <= 1:
79
+ return ""
80
+ if git_call_count[0] == 2:
81
+ return " M src/app.py"
82
+ return ""
83
+
84
+ monkeypatch.setattr(
85
+ mod.LockClient, "_run_git_status", staticmethod(mock_git_status)
86
+ )
87
+ monkeypatch.setattr(mod.LockClient, "_reconcile", lambda self: set())
88
+
89
+ loop_count = [0]
90
+
91
+ def mock_sleep(x):
92
+ loop_count[0] += 1
93
+ if loop_count[0] > 3:
94
+ raise KeyboardInterrupt()
95
+
96
+ monkeypatch.setattr(mod.time, "sleep", mock_sleep)
97
+
98
+ lc = mod.LockClient(developer_id="test_user")
99
+ lc.watch(interval=1, timeout_mins=60) # Will exit via KeyboardInterrupt
100
+
101
+
102
+ def test_watch_keyboard_interrupt(monkeypatch, tmp_path):
103
+ """Test watch() handles KeyboardInterrupt gracefully."""
104
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
105
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
106
+
107
+ pid_file = tmp_path / "daemon.pid"
108
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
109
+ monkeypatch.setattr(
110
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
111
+ )
112
+ monkeypatch.setattr(mod.LockClient, "_run_git_status", staticmethod(lambda: ""))
113
+ monkeypatch.setattr(mod.LockClient, "_reconcile", lambda self: set())
114
+ monkeypatch.setattr(mod.time, "sleep", mock.Mock(side_effect=KeyboardInterrupt))
115
+
116
+ lc = mod.LockClient(developer_id="test_user")
117
+ # Should not raise
118
+ lc.watch(
119
+ interval=1,
120
+ timeout_mins=60,
121
+ daemon_mode=True,
122
+ parent_pid=4242,
123
+ )
124
+
125
+
126
+ def test_watch_error_in_loop(monkeypatch, tmp_path):
127
+ """Test watch() handles errors in main loop gracefully."""
128
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
129
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
130
+
131
+ pid_file = tmp_path / "daemon.pid"
132
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
133
+ monkeypatch.setattr(
134
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
135
+ )
136
+ monkeypatch.setattr(mod.LockClient, "_reconcile", lambda self: set())
137
+
138
+ call_count = [0]
139
+
140
+ def error_git_status():
141
+ call_count[0] += 1
142
+ if call_count[0] <= 2:
143
+ raise RuntimeError("Git broken")
144
+ raise KeyboardInterrupt()
145
+
146
+ monkeypatch.setattr(
147
+ mod.LockClient, "_run_git_status", staticmethod(error_git_status)
148
+ )
149
+ monkeypatch.setattr(mod.time, "sleep", lambda x: None)
150
+
151
+ lc = mod.LockClient(developer_id="test_user")
152
+ lc.watch(interval=1, timeout_mins=60)
153
+
154
+
155
+ def test_watch_only_reconciles_on_startup(monkeypatch, tmp_path):
156
+ """Watch() performs reconciliation once at startup, not periodically."""
157
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
158
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
159
+
160
+ pid_file = tmp_path / "daemon.pid"
161
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
162
+ monkeypatch.setattr(
163
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
164
+ )
165
+
166
+ reconcile_calls = [0]
167
+
168
+ def _reconcile_once(self):
169
+ reconcile_calls[0] += 1
170
+ return set()
171
+
172
+ monkeypatch.setattr(mod.LockClient, "_reconcile", _reconcile_once)
173
+ monkeypatch.setattr(mod.LockClient, "_run_git_status", staticmethod(lambda: ""))
174
+ monkeypatch.setattr(
175
+ mod.LockClient, "_is_process_alive", staticmethod(lambda _p: True)
176
+ )
177
+ monkeypatch.setattr(
178
+ mod.LockClient,
179
+ "_get_process_info_local",
180
+ lambda self, pid: ("Code.exe", None),
181
+ )
182
+
183
+ tick = [0]
184
+ real_now = datetime.now
185
+
186
+ def fast_now():
187
+ tick[0] += 1
188
+ return real_now() + timedelta(minutes=tick[0] * 20)
189
+
190
+ monkeypatch.setattr(
191
+ mod,
192
+ "datetime",
193
+ type(
194
+ "FDT",
195
+ (),
196
+ {
197
+ "now": staticmethod(fast_now),
198
+ "fromisoformat": datetime.fromisoformat,
199
+ },
200
+ )(),
201
+ )
202
+
203
+ loop_ticks = [0]
204
+
205
+ def _sleep(_x):
206
+ loop_ticks[0] += 1
207
+ if loop_ticks[0] > 2:
208
+ raise KeyboardInterrupt()
209
+
210
+ monkeypatch.setattr(mod.time, "sleep", _sleep)
211
+ monkeypatch.setattr(mod.os, "getppid", lambda: 1111)
212
+
213
+ lc = mod.LockClient(developer_id="test_user")
214
+ lc.watch(interval=1, timeout_mins=0, daemon_mode=True, parent_pid=4242)
215
+
216
+ assert reconcile_calls == [1]
217
+
218
+
219
+ def test_watch_parent_process_dead(monkeypatch, tmp_path):
220
+ """Test watch() exits when parent process dies."""
221
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
222
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
223
+
224
+ pid_file = tmp_path / "daemon.pid"
225
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
226
+ monkeypatch.setattr(
227
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
228
+ )
229
+ monkeypatch.setattr(mod.LockClient, "_run_git_status", staticmethod(lambda: ""))
230
+ monkeypatch.setattr(mod.LockClient, "_reconcile", lambda self: set())
231
+
232
+ # Make parent check trigger immediately
233
+ check_count = [0]
234
+ real_now = datetime.now
235
+
236
+ def advancing_now():
237
+ check_count[0] += 1
238
+ return real_now() + timedelta(seconds=check_count[0] * 31)
239
+
240
+ monkeypatch.setattr(
241
+ mod,
242
+ "datetime",
243
+ type(
244
+ "FakeDT",
245
+ (),
246
+ {
247
+ "now": staticmethod(advancing_now),
248
+ "fromisoformat": datetime.fromisoformat,
249
+ },
250
+ )(),
251
+ )
252
+
253
+ # Parent is dead
254
+ monkeypatch.setattr(
255
+ mod.LockClient, "_is_process_alive", staticmethod(lambda pid: False)
256
+ )
257
+ monkeypatch.setattr(mod.time, "sleep", lambda x: None)
258
+
259
+ lc = mod.LockClient(developer_id="test_user")
260
+ lc.watch(interval=1, timeout_mins=60)
261
+
262
+
263
+ def test_watch_open_dashboard(monkeypatch, tmp_path):
264
+ """Test watch() opens dashboard when requested."""
265
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
266
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
267
+
268
+ pid_file = tmp_path / "daemon.pid"
269
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
270
+ monkeypatch.setattr(
271
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
272
+ )
273
+ monkeypatch.setattr(mod.LockClient, "_run_git_status", staticmethod(lambda: ""))
274
+ monkeypatch.setattr(mod.LockClient, "_reconcile", lambda self: set())
275
+ monkeypatch.setattr(mod.time, "sleep", mock.Mock(side_effect=KeyboardInterrupt))
276
+
277
+ dashboard_called = [False]
278
+
279
+ def mock_dashboard(self):
280
+ dashboard_called[0] = True
281
+
282
+ monkeypatch.setattr(mod.LockClient, "dashboard", mock_dashboard)
283
+
284
+ lc = mod.LockClient(developer_id="test_user")
285
+ lc.watch(interval=1, timeout_mins=60, open_dashboard=True)
286
+ assert dashboard_called[0]
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # watch() loop: stop-request file handling
291
+ # ---------------------------------------------------------------------------
292
+
293
+
294
+ def _make_watch_client(monkeypatch, tmp_path):
295
+ """Helper to create a client with minimal mocking for watch() tests."""
296
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
297
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
298
+ pid_file = tmp_path / "daemon.pid"
299
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
300
+ monkeypatch.setattr(
301
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
302
+ )
303
+ monkeypatch.setattr(mod.LockClient, "_reconcile", lambda self: set())
304
+ monkeypatch.setattr(mod.LockClient, "_run_git_status", staticmethod(lambda: ""))
305
+ return mod.LockClient(developer_id="test_user")
306
+
307
+
308
+ def test_watch_stop_request_token_based(monkeypatch, tmp_path):
309
+ """Watch() exits when TOKEN: stop request matches session token."""
310
+ lc = _make_watch_client(monkeypatch, tmp_path)
311
+
312
+ state_dir = str(tmp_path)
313
+ monkeypatch.setenv("COLLAB_STATE_DIR", state_dir)
314
+
315
+ # Make session token predictable
316
+ token = "mytoken123"
317
+ monkeypatch.setattr(lc, "_get_session_token", lambda: token)
318
+ monkeypatch.setattr(lc, "_read_pid", lambda: os.getpid())
319
+ monkeypatch.setattr(lc, "_register_signal_handlers", lambda: None)
320
+ monkeypatch.setattr(lc, "_start_parent_monitor_thread", lambda: None)
321
+ monkeypatch.setattr(lc, "_scan_remote_locks", lambda: None)
322
+ monkeypatch.setattr(lc, "_prepare_dashboard_server", lambda: (None, None))
323
+ monkeypatch.setattr(lc, "_write_pid", lambda *a, **k: None)
324
+
325
+ shutdown_called = [False]
326
+
327
+ def mock_shutdown(*a, **k):
328
+ shutdown_called[0] = True
329
+
330
+ monkeypatch.setattr(lc, "_graceful_shutdown", mock_shutdown)
331
+
332
+ # Write stop request file before watch runs
333
+ stop_file = os.path.join(state_dir, ".stop_request")
334
+ with open(stop_file, "w") as f:
335
+ f.write(f"TOKEN:{token}")
336
+
337
+ # Make time advance so parent checks run (>2s)
338
+ call_count = [0]
339
+ real_now = datetime.now
340
+
341
+ def fast_now():
342
+ call_count[0] += 1
343
+ return real_now() + timedelta(seconds=call_count[0] * 5)
344
+
345
+ monkeypatch.setattr(
346
+ mod,
347
+ "datetime",
348
+ type(
349
+ "FDT",
350
+ (),
351
+ {
352
+ "now": staticmethod(fast_now),
353
+ "fromisoformat": datetime.fromisoformat,
354
+ },
355
+ )(),
356
+ )
357
+ monkeypatch.setattr(mod.time, "sleep", lambda x: None)
358
+ monkeypatch.setattr(lc, "_get_modified_and_unpushed_files", lambda: [])
359
+
360
+ lc._parent_pid = None
361
+ lc._initial_ppid = os.getppid()
362
+ lc.watch(interval=1, timeout_mins=60)
363
+
364
+ assert shutdown_called[0]
365
+
366
+
367
+ def test_watch_stop_request_pid_based(monkeypatch, tmp_path):
368
+ """Watch() exits when PID: stop request matches current PID."""
369
+ lc = _make_watch_client(monkeypatch, tmp_path)
370
+
371
+ state_dir = str(tmp_path)
372
+ monkeypatch.setenv("COLLAB_STATE_DIR", state_dir)
373
+
374
+ monkeypatch.setattr(lc, "_get_session_token", lambda: "some_token")
375
+ monkeypatch.setattr(lc, "_read_pid", lambda: os.getpid())
376
+ monkeypatch.setattr(lc, "_register_signal_handlers", lambda: None)
377
+ monkeypatch.setattr(lc, "_start_parent_monitor_thread", lambda: None)
378
+ monkeypatch.setattr(lc, "_scan_remote_locks", lambda: None)
379
+ monkeypatch.setattr(lc, "_prepare_dashboard_server", lambda: (None, None))
380
+ monkeypatch.setattr(lc, "_write_pid", lambda *a, **k: None)
381
+ monkeypatch.setattr(lc, "_get_modified_and_unpushed_files", lambda: [])
382
+
383
+ shutdown_called = [False]
384
+
385
+ def mock_shutdown(*a, **k):
386
+ shutdown_called[0] = True
387
+
388
+ monkeypatch.setattr(lc, "_graceful_shutdown", mock_shutdown)
389
+
390
+ # Write PID-based stop request
391
+ stop_file = os.path.join(state_dir, ".stop_request")
392
+ with open(stop_file, "w") as f:
393
+ f.write(f"PID:{os.getpid()}")
394
+
395
+ call_count = [0]
396
+ real_now = datetime.now
397
+
398
+ def fast_now():
399
+ call_count[0] += 1
400
+ return real_now() + timedelta(seconds=call_count[0] * 5)
401
+
402
+ monkeypatch.setattr(
403
+ mod,
404
+ "datetime",
405
+ type(
406
+ "FDT",
407
+ (),
408
+ {
409
+ "now": staticmethod(fast_now),
410
+ "fromisoformat": datetime.fromisoformat,
411
+ },
412
+ )(),
413
+ )
414
+ monkeypatch.setattr(mod.time, "sleep", lambda x: None)
415
+
416
+ lc._parent_pid = None
417
+ lc._initial_ppid = os.getppid()
418
+ lc.watch(interval=1, timeout_mins=60)
419
+
420
+ assert shutdown_called[0]
421
+
422
+
423
+ def test_watch_heartbeat_missing_after_grace(monkeypatch, tmp_path):
424
+ """Watch() exits when heartbeat file is missing after startup grace period."""
425
+ lc = _make_watch_client(monkeypatch, tmp_path)
426
+
427
+ state_dir = str(tmp_path)
428
+ monkeypatch.setenv("COLLAB_STATE_DIR", state_dir)
429
+ heartbeat_file = str(tmp_path / ".heartbeat")
430
+ # heartbeat does NOT exist
431
+
432
+ monkeypatch.setattr(lc, "_get_session_token", lambda: "tok")
433
+ monkeypatch.setattr(lc, "_read_pid", lambda: os.getpid())
434
+ monkeypatch.setattr(lc, "_register_signal_handlers", lambda: None)
435
+ monkeypatch.setattr(lc, "_start_parent_monitor_thread", lambda: None)
436
+ monkeypatch.setattr(lc, "_scan_remote_locks", lambda: None)
437
+ monkeypatch.setattr(lc, "_prepare_dashboard_server", lambda: (None, None))
438
+ monkeypatch.setattr(lc, "_write_pid", lambda *a, **k: None)
439
+ monkeypatch.setattr(lc, "_get_modified_and_unpushed_files", lambda: [])
440
+
441
+ shutdown_called = [False]
442
+
443
+ def mock_shutdown(*a, **k):
444
+ shutdown_called[0] = True
445
+
446
+ monkeypatch.setattr(lc, "_graceful_shutdown", mock_shutdown)
447
+
448
+ # Simulate time well past grace window (startup_time >> 3s ago)
449
+ import time as _t
450
+
451
+ start_ts = _t.time() - 10 # 10s in the past
452
+
453
+ call_count = [0]
454
+ real_now = datetime.now
455
+
456
+ def fast_now():
457
+ call_count[0] += 1
458
+ return real_now() + timedelta(seconds=call_count[0] * 5)
459
+
460
+ monkeypatch.setattr(
461
+ mod,
462
+ "datetime",
463
+ type(
464
+ "FDT",
465
+ (),
466
+ {
467
+ "now": staticmethod(fast_now),
468
+ "fromisoformat": datetime.fromisoformat,
469
+ },
470
+ )(),
471
+ )
472
+ monkeypatch.setattr(mod.time, "sleep", lambda x: None)
473
+
474
+ # Override time.time to return a large value (past grace)
475
+ def fake_time():
476
+ return start_ts + call_count[0] * 5
477
+
478
+ monkeypatch.setattr(mod.time, "time", fake_time)
479
+
480
+ monkeypatch.setattr(
481
+ mod.LockClient, "_is_process_alive", staticmethod(lambda pid: True)
482
+ )
483
+ monkeypatch.setattr(lc, "_get_process_info_local", lambda pid: ("Code.exe", None))
484
+ monkeypatch.setattr(mod.os, "getppid", lambda: 999)
485
+
486
+ lc._initial_ppid = 999
487
+ lc.watch(
488
+ interval=1,
489
+ timeout_mins=60,
490
+ daemon_mode=True,
491
+ parent_pid=4242,
492
+ heartbeat_file=heartbeat_file,
493
+ heartbeat_grace_seconds=30,
494
+ )
495
+
496
+ assert shutdown_called[0]
497
+
498
+
499
+ def test_watch_parent_pid_dead_shuts_down(monkeypatch, tmp_path):
500
+ """Watch() calls _graceful_shutdown when parent_pid is set but process is dead."""
501
+ lc = _make_watch_client(monkeypatch, tmp_path)
502
+
503
+ state_dir = str(tmp_path)
504
+ monkeypatch.setenv("COLLAB_STATE_DIR", state_dir)
505
+
506
+ monkeypatch.setattr(lc, "_get_session_token", lambda: "tok")
507
+ monkeypatch.setattr(lc, "_read_pid", lambda: os.getpid())
508
+ monkeypatch.setattr(lc, "_register_signal_handlers", lambda: None)
509
+ monkeypatch.setattr(lc, "_start_parent_monitor_thread", lambda: None)
510
+ monkeypatch.setattr(lc, "_scan_remote_locks", lambda: None)
511
+ monkeypatch.setattr(lc, "_prepare_dashboard_server", lambda: (None, None))
512
+ monkeypatch.setattr(lc, "_write_pid", lambda *a, **k: None)
513
+ monkeypatch.setattr(lc, "_get_modified_and_unpushed_files", lambda: [])
514
+
515
+ shutdown_called = [False]
516
+
517
+ def mock_shutdown(*a, **k):
518
+ shutdown_called[0] = True
519
+
520
+ monkeypatch.setattr(lc, "_graceful_shutdown", mock_shutdown)
521
+ monkeypatch.setattr(lc, "_get_process_info_local", lambda pid: ("testide", None))
522
+
523
+ # Parent is dead
524
+ monkeypatch.setattr(
525
+ mod.LockClient, "_is_process_alive", staticmethod(lambda pid: False)
526
+ )
527
+
528
+ call_count = [0]
529
+ real_now = datetime.now
530
+
531
+ def fast_now():
532
+ call_count[0] += 1
533
+ return real_now() + timedelta(seconds=call_count[0] * 5)
534
+
535
+ monkeypatch.setattr(
536
+ mod,
537
+ "datetime",
538
+ type(
539
+ "FDT",
540
+ (),
541
+ {
542
+ "now": staticmethod(fast_now),
543
+ "fromisoformat": datetime.fromisoformat,
544
+ },
545
+ )(),
546
+ )
547
+ monkeypatch.setattr(mod.time, "sleep", lambda x: None)
548
+
549
+ lc._initial_ppid = os.getppid()
550
+ lc.watch(interval=1, timeout_mins=60, daemon_mode=True, parent_pid=9999)
551
+
552
+ assert shutdown_called[0]
553
+
554
+
555
+ def _configure_watch_loop_common(monkeypatch, lc):
556
+ """Common deterministic watch-loop wiring for deep parent/heartbeat branches."""
557
+ monkeypatch.setattr(lc, "_get_session_token", lambda: "tok")
558
+ monkeypatch.setattr(lc, "_read_pid", lambda: os.getpid())
559
+ monkeypatch.setattr(lc, "_register_signal_handlers", lambda: None)
560
+ monkeypatch.setattr(lc, "_start_parent_monitor_thread", lambda: None)
561
+ monkeypatch.setattr(lc, "_scan_remote_locks", lambda: None)
562
+ monkeypatch.setattr(lc, "_prepare_dashboard_server", lambda: (None, None))
563
+ monkeypatch.setattr(lc, "_write_pid", lambda *a, **k: None)
564
+ monkeypatch.setattr(lc, "_get_modified_and_unpushed_files", lambda: [])
565
+ monkeypatch.setattr(mod.time, "sleep", lambda x: None)
566
+
567
+ tick = [0]
568
+ real_now = datetime.now
569
+
570
+ def fast_now():
571
+ tick[0] += 1
572
+ return real_now() + timedelta(seconds=tick[0] * 5)
573
+
574
+ monkeypatch.setattr(
575
+ mod,
576
+ "datetime",
577
+ type(
578
+ "FDT",
579
+ (),
580
+ {"now": staticmethod(fast_now), "fromisoformat": datetime.fromisoformat},
581
+ )(),
582
+ )
583
+
584
+
585
+ def test_watch_heartbeat_stale_softskip_then_shutdown(monkeypatch, tmp_path):
586
+ """Heartbeat stale path: one soft-skip while parent alive, then shutdown."""
587
+ lc = _make_watch_client(monkeypatch, tmp_path)
588
+ _configure_watch_loop_common(monkeypatch, lc)
589
+
590
+ heartbeat = tmp_path / ".heartbeat"
591
+ heartbeat.write_text("alive")
592
+ monkeypatch.setattr(mod.os.path, "getmtime", lambda p: 0.0)
593
+ # Make now_ts very large so age >> grace + soft_extra
594
+ monkeypatch.setattr(mod.time, "time", lambda: 100.0)
595
+
596
+ # Parent alive enables one-time soft skip first, then second check should shut down.
597
+ monkeypatch.setattr(
598
+ mod.LockClient, "_is_process_alive", staticmethod(lambda pid: True)
599
+ )
600
+ monkeypatch.setattr(lc, "_get_process_info_local", lambda pid: ("parent.exe", None))
601
+ monkeypatch.setattr(mod.os, "getppid", lambda: 12345)
602
+
603
+ shutdown_reasons = []
604
+
605
+ def _shutdown(reason=None):
606
+ shutdown_reasons.append(reason)
607
+
608
+ monkeypatch.setattr(lc, "_graceful_shutdown", _shutdown)
609
+
610
+ lc._parent_pid = 9999
611
+ lc._initial_ppid = 12345
612
+
613
+ lc.watch(
614
+ interval=1,
615
+ timeout_mins=60,
616
+ heartbeat_file=str(heartbeat),
617
+ heartbeat_grace_seconds=1,
618
+ )
619
+
620
+ assert "heartbeat_stale" in shutdown_reasons
621
+
622
+
623
+ def test_watch_parent_zombie_name_unresolvable_shutdown(monkeypatch, tmp_path):
624
+ """Parent alive + unknown name streak>=2 triggers zombie-shutdown branch."""
625
+ lc = _make_watch_client(monkeypatch, tmp_path)
626
+ _configure_watch_loop_common(monkeypatch, lc)
627
+
628
+ monkeypatch.setattr(
629
+ mod.LockClient, "_is_process_alive", staticmethod(lambda pid: True)
630
+ )
631
+ monkeypatch.setattr(lc, "_get_process_info_local", lambda pid: (None, None))
632
+ monkeypatch.setattr(mod.os, "getppid", lambda: 7777)
633
+
634
+ shutdown_called = [False]
635
+ monkeypatch.setattr(
636
+ lc, "_graceful_shutdown", lambda *a, **k: shutdown_called.__setitem__(0, True)
637
+ )
638
+
639
+ lc._initial_ppid = 7777
640
+ lc.watch(interval=1, timeout_mins=60, daemon_mode=True, parent_pid=4242)
641
+
642
+ assert shutdown_called[0]
643
+
644
+
645
+ def test_watch_adoption_detected_shutdown(monkeypatch, tmp_path):
646
+ """If current ppid changes from initial, watch performs graceful shutdown."""
647
+ lc = _make_watch_client(monkeypatch, tmp_path)
648
+ _configure_watch_loop_common(monkeypatch, lc)
649
+
650
+ monkeypatch.setattr(
651
+ mod.LockClient, "_is_process_alive", staticmethod(lambda pid: True)
652
+ )
653
+ monkeypatch.setattr(lc, "_get_process_info_local", lambda pid: ("ide.exe", None))
654
+
655
+ # Simulate adoption by different parent PID
656
+ ppid_values = iter([1000, 2000])
657
+ monkeypatch.setattr(mod.os, "getppid", lambda: next(ppid_values, 2000))
658
+
659
+ shutdown_called = [False]
660
+ monkeypatch.setattr(
661
+ lc, "_graceful_shutdown", lambda *a, **k: shutdown_called.__setitem__(0, True)
662
+ )
663
+
664
+ lc._initial_ppid = 1000
665
+ lc.watch(interval=1, timeout_mins=60, daemon_mode=True, parent_pid=4242)
666
+
667
+ assert shutdown_called[0]
668
+
669
+
670
+ def test_watch_orphaned_windows_low_ppid_shutdown(monkeypatch, tmp_path):
671
+ """No explicit parent on Windows and ppid<=4 is treated as orphaned."""
672
+ lc = _make_watch_client(monkeypatch, tmp_path)
673
+ _configure_watch_loop_common(monkeypatch, lc)
674
+
675
+ monkeypatch.setattr(sys, "platform", "win32")
676
+ monkeypatch.setattr(mod.os, "getppid", lambda: 4)
677
+ monkeypatch.setattr(
678
+ mod.LockClient, "_is_process_alive", staticmethod(lambda pid: False)
679
+ )
680
+
681
+ shutdown_called = [False]
682
+ monkeypatch.setattr(
683
+ lc, "_graceful_shutdown", lambda *a, **k: shutdown_called.__setitem__(0, True)
684
+ )
685
+
686
+ lc._initial_ppid = 4
687
+ lc.watch(interval=1, timeout_mins=60, daemon_mode=True, parent_pid=None)
688
+
689
+ assert shutdown_called[0]
690
+
691
+
692
+ def test_watch_orphaned_unix_init_ppid_shutdown(monkeypatch, tmp_path):
693
+ """No explicit parent on Unix and ppid==1 is treated as orphaned."""
694
+ lc = _make_watch_client(monkeypatch, tmp_path)
695
+ _configure_watch_loop_common(monkeypatch, lc)
696
+
697
+ monkeypatch.setattr(sys, "platform", "linux")
698
+ monkeypatch.setattr(mod.os, "getppid", lambda: 1)
699
+ monkeypatch.setattr(
700
+ mod.LockClient, "_is_process_alive", staticmethod(lambda pid: False)
701
+ )
702
+
703
+ shutdown_called = [False]
704
+ monkeypatch.setattr(
705
+ lc, "_graceful_shutdown", lambda *a, **k: shutdown_called.__setitem__(0, True)
706
+ )
707
+
708
+ lc._initial_ppid = 1
709
+ lc.watch(interval=1, timeout_mins=60, daemon_mode=True, parent_pid=None)
710
+
711
+ assert shutdown_called[0]
712
+
713
+
714
+ def test_watch_stop_request_numeric_payload_and_remove_exception(monkeypatch, tmp_path):
715
+ """Cover numeric-only stop payload parsing and remove-failure branch."""
716
+ lc = _make_watch_client(monkeypatch, tmp_path)
717
+ _configure_watch_loop_common(monkeypatch, lc)
718
+
719
+ state_dir = str(tmp_path)
720
+ monkeypatch.setenv("COLLAB_STATE_DIR", state_dir)
721
+ stop_file = os.path.join(state_dir, ".stop_request")
722
+ with open(stop_file, "w", encoding="utf-8") as f:
723
+ # Numeric-only payload covers backward-compatible parse path.
724
+ f.write(str(os.getpid()))
725
+
726
+ monkeypatch.setattr(lc, "_get_session_token", lambda: "tok")
727
+ monkeypatch.setattr(lc, "_read_pid", lambda: os.getpid())
728
+ monkeypatch.setattr(lc, "_register_signal_handlers", lambda: None)
729
+ monkeypatch.setattr(lc, "_start_parent_monitor_thread", lambda: None)
730
+ monkeypatch.setattr(lc, "_scan_remote_locks", lambda: None)
731
+ monkeypatch.setattr(lc, "_prepare_dashboard_server", lambda: (None, None))
732
+ monkeypatch.setattr(lc, "_write_pid", lambda *a, **k: None)
733
+ monkeypatch.setattr(lc, "_get_modified_and_unpushed_files", lambda: [])
734
+
735
+ # Force os.remove(stop_file) failure to hit guarded branch.
736
+ real_remove = os.remove
737
+
738
+ def _remove(path):
739
+ if str(path).endswith(".stop_request"):
740
+ raise OSError("cannot remove stop file")
741
+ return real_remove(path)
742
+
743
+ monkeypatch.setattr(mod.os, "remove", _remove)
744
+
745
+ reasons = []
746
+ monkeypatch.setattr(
747
+ lc, "_graceful_shutdown", lambda reason=None: reasons.append(reason)
748
+ )
749
+
750
+ lc._parent_pid = None
751
+ lc._initial_ppid = os.getppid()
752
+ lc.watch(interval=1, timeout_mins=60)
753
+ assert "stop_requested" in reasons
754
+
755
+
756
+ def test_watch_stop_request_invalid_payload_and_open_exception(monkeypatch, tmp_path):
757
+ """Cover invalid numeric payload parse and outer stop-file exception guard."""
758
+ lc = _make_watch_client(monkeypatch, tmp_path)
759
+ _configure_watch_loop_common(monkeypatch, lc)
760
+
761
+ state_dir = str(tmp_path)
762
+ monkeypatch.setenv("COLLAB_STATE_DIR", state_dir)
763
+ stop_file = os.path.join(state_dir, ".stop_request")
764
+ with open(stop_file, "w", encoding="utf-8") as f:
765
+ f.write("not-a-number")
766
+
767
+ monkeypatch.setattr(lc, "_get_session_token", lambda: "tok")
768
+ monkeypatch.setattr(lc, "_read_pid", lambda: os.getpid())
769
+ monkeypatch.setattr(lc, "_register_signal_handlers", lambda: None)
770
+ monkeypatch.setattr(lc, "_start_parent_monitor_thread", lambda: None)
771
+ monkeypatch.setattr(lc, "_scan_remote_locks", lambda: None)
772
+ monkeypatch.setattr(lc, "_prepare_dashboard_server", lambda: (None, None))
773
+ monkeypatch.setattr(lc, "_write_pid", lambda *a, **k: None)
774
+ monkeypatch.setattr(lc, "_get_modified_and_unpushed_files", lambda: [])
775
+
776
+ # Raise once when reading stop request to hit outer stop-file exception guard.
777
+ import builtins
778
+
779
+ real_open = builtins.open
780
+ gate = {"n": 0}
781
+
782
+ def _open(path, mode="r", *args, **kwargs):
783
+ if str(path).endswith(".stop_request") and "r" in mode and gate["n"] == 0:
784
+ gate["n"] += 1
785
+ raise OSError("cannot read stop file")
786
+ return real_open(path, mode, *args, **kwargs)
787
+
788
+ monkeypatch.setattr(builtins, "open", _open)
789
+
790
+ # Exit loop deterministically after the guarded exception branch.
791
+ ticks = {"n": 0}
792
+
793
+ def _sleep(_x):
794
+ ticks["n"] += 1
795
+ if ticks["n"] > 2:
796
+ raise KeyboardInterrupt()
797
+
798
+ monkeypatch.setattr(mod.time, "sleep", _sleep)
799
+
800
+ lc._parent_pid = None
801
+ lc._initial_ppid = os.getppid()
802
+ lc.watch(interval=1, timeout_mins=60)
803
+
804
+
805
+ def test_watch_heartbeat_stale_read_exception_and_parent_name_resolution(
806
+ monkeypatch, tmp_path
807
+ ):
808
+ """Cover heartbeat stale read-exception and parent-name transition log branches."""
809
+ lc = _make_watch_client(monkeypatch, tmp_path)
810
+ _configure_watch_loop_common(monkeypatch, lc)
811
+
812
+ heartbeat = tmp_path / ".heartbeat"
813
+ heartbeat.write_text("alive", encoding="utf-8")
814
+
815
+ monkeypatch.setattr(lc, "_get_session_token", lambda: "tok")
816
+ monkeypatch.setattr(lc, "_read_pid", lambda: os.getpid())
817
+ monkeypatch.setattr(lc, "_register_signal_handlers", lambda: None)
818
+ monkeypatch.setattr(lc, "_start_parent_monitor_thread", lambda: None)
819
+ monkeypatch.setattr(lc, "_scan_remote_locks", lambda: None)
820
+ monkeypatch.setattr(lc, "_prepare_dashboard_server", lambda: (None, None))
821
+ monkeypatch.setattr(lc, "_write_pid", lambda *a, **k: None)
822
+ monkeypatch.setattr(lc, "_get_modified_and_unpushed_files", lambda: [])
823
+
824
+ # age > grace + soft_extra to hit stale shutdown branch quickly.
825
+ monkeypatch.setattr(mod.time, "time", lambda: 100.0)
826
+ monkeypatch.setattr(mod.os.path, "getmtime", lambda _p: 0.0)
827
+
828
+ # Parent alive and process-info transitions unknown->unknown->resolved.
829
+ sequence = iter([(None, None), (None, None), ("Code.exe", "wmic")])
830
+
831
+ def _proc_info(_pid):
832
+ return next(sequence, ("Code.exe", "wmic"))
833
+
834
+ monkeypatch.setattr(lc, "_get_process_info_local", _proc_info)
835
+ monkeypatch.setattr(
836
+ mod.LockClient, "_is_process_alive", staticmethod(lambda _p: True)
837
+ )
838
+ monkeypatch.setattr(mod.os, "getppid", lambda: 9001)
839
+
840
+ # Reading heartbeat content raises to cover guarded read-failure branch.
841
+ import builtins
842
+
843
+ real_open = builtins.open
844
+
845
+ def _open(path, mode="r", *args, **kwargs):
846
+ if str(path).endswith(".heartbeat") and "r" in mode:
847
+ raise OSError("heartbeat unreadable")
848
+ return real_open(path, mode, *args, **kwargs)
849
+
850
+ monkeypatch.setattr(builtins, "open", _open)
851
+
852
+ reasons = []
853
+ monkeypatch.setattr(
854
+ lc, "_graceful_shutdown", lambda reason=None: reasons.append(reason)
855
+ )
856
+
857
+ lc._parent_pid = 9999
858
+ lc._initial_ppid = 9001
859
+ lc.watch(
860
+ interval=1,
861
+ timeout_mins=60,
862
+ heartbeat_file=str(heartbeat),
863
+ heartbeat_grace_seconds=1,
864
+ )
865
+
866
+ assert "heartbeat_stale" in reasons