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,3730 @@
1
+ """PID and Daemon-related tests for LockClient.
2
+
3
+ These tests were moved out of the canonical `test_lock_client.py` to keep concerns
4
+ separated and make the canonical file a small shim.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import builtins
10
+ import json
11
+ import os
12
+ import signal
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ import types
17
+ from pathlib import Path
18
+ from unittest import mock
19
+
20
+ import pytest
21
+
22
+ from ._helpers import (
23
+ FakeClient,
24
+ FakeResponse,
25
+ load_lock_client_module,
26
+ make_create_client,
27
+ )
28
+
29
+ mod = load_lock_client_module()
30
+
31
+
32
+ def test_pid_file_helpers(tmp_path, monkeypatch):
33
+ pid_file = tmp_path / "daemon.pid"
34
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
35
+ monkeypatch.setenv("COLLAB_TEST_MODE", "0")
36
+ if pid_file.exists():
37
+ pid_file.unlink()
38
+
39
+ mod.LockClient._write_pid(424242)
40
+ assert pid_file.exists()
41
+ assert mod.LockClient._read_pid() == 424242
42
+
43
+ mod.LockClient._remove_pid()
44
+ assert not pid_file.exists()
45
+
46
+
47
+ def test_read_pid_missing_file(tmp_path, monkeypatch):
48
+ """Test reading PID when file doesn't exist."""
49
+ pid_file = tmp_path / "missing.pid"
50
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
51
+
52
+ pid = mod.LockClient._read_pid()
53
+ assert pid is None
54
+
55
+
56
+ def test_write_pid_oserror(tmp_path, monkeypatch):
57
+ """Test _write_pid handles OSError gracefully."""
58
+ monkeypatch.setattr(mod, "PID_FILE", str(tmp_path / "nonexistent" / "dir" / "pid"))
59
+
60
+ # Should not raise
61
+ mod.LockClient._write_pid(12345)
62
+
63
+
64
+ def test_remove_pid_no_file(tmp_path, monkeypatch):
65
+ """Test _remove_pid is safe when file doesn't exist."""
66
+ monkeypatch.setattr(mod, "PID_FILE", str(tmp_path / "nonexistent.pid"))
67
+ mod.LockClient._remove_pid() # Should not raise
68
+
69
+
70
+ def test_is_process_alive_current_process():
71
+ """Test _is_process_alive returns True for current process."""
72
+ result = mod.LockClient._is_process_alive(os.getpid())
73
+ assert result is True
74
+
75
+
76
+ def test_is_process_alive_nonexistent_pid_lock_client():
77
+ """Test _is_process_alive returns False for a very high PID."""
78
+ result = mod.LockClient._is_process_alive(99999999)
79
+ assert result is False
80
+
81
+
82
+ def test_daemon_status_not_running(tmp_path, monkeypatch):
83
+ """Test daemon status when not running."""
84
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
85
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
86
+
87
+ pid_file = tmp_path / "daemon.pid"
88
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
89
+ fake_create = make_create_client(FakeResponse())
90
+ monkeypatch.setattr(mod, "_get_create_client", lambda: fake_create)
91
+
92
+ # Ensure cmdline verification will match the watcher for the current PID
93
+ def _fake_cmdline(p):
94
+ return f"{sys.executable} lock_client.py watch"
95
+
96
+ monkeypatch.setattr(
97
+ mod.LockClient, "_get_cmdline_for_pid", staticmethod(_fake_cmdline)
98
+ )
99
+
100
+ lc = mod.LockClient(developer_id="test_user")
101
+ is_running = lc.daemon_status()
102
+ assert is_running is False
103
+
104
+
105
+ def test_daemon_status_running(tmp_path, monkeypatch):
106
+ """Test daemon status when daemon is running."""
107
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
108
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
109
+
110
+ pid_file = tmp_path / "daemon.pid"
111
+ pid_file.write_text(str(os.getpid()))
112
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
113
+ fake_create = make_create_client(FakeResponse())
114
+ monkeypatch.setattr(mod, "_get_create_client", lambda: fake_create)
115
+
116
+ lc = mod.LockClient(developer_id="test_user")
117
+ is_running = lc.daemon_status()
118
+ assert is_running is True
119
+
120
+
121
+ def test_daemon_start_already_running(tmp_path, monkeypatch, capsys):
122
+ """Test daemon_start when watcher is already running."""
123
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
124
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
125
+
126
+ pid_file = tmp_path / "daemon.pid"
127
+ pid_file.write_text(str(os.getpid()))
128
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
129
+ fake_create = make_create_client(FakeResponse())
130
+ monkeypatch.setattr(mod, "_get_create_client", lambda: fake_create)
131
+ # Ensure daemon_start sees a valid watcher cmdline and exits early.
132
+ monkeypatch.setattr(
133
+ mod.LockClient,
134
+ "_get_cmdline_for_pid",
135
+ staticmethod(
136
+ lambda _p: (f"python lock_client.py watch --daemon --pid-file {pid_file}")
137
+ ),
138
+ )
139
+
140
+ lc = mod.LockClient(developer_id="test_user")
141
+ lc.daemon_start()
142
+ captured = capsys.readouterr()
143
+ out = captured.out.lower()
144
+ assert ("already running" in out) or ("started" in out)
145
+
146
+
147
+ def test_daemon_start_launches_process(tmp_path, monkeypatch, capsys):
148
+ """Test daemon_start launches a background process."""
149
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
150
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
151
+
152
+ pid_file = tmp_path / "daemon.pid"
153
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
154
+ fake_create = make_create_client(FakeResponse())
155
+ monkeypatch.setattr(mod, "_get_create_client", lambda: fake_create)
156
+
157
+ class FakeProc:
158
+ pid = 99999999
159
+
160
+ def mock_popen(*args, **kwargs):
161
+ return FakeProc()
162
+
163
+ monkeypatch.setattr(subprocess, "Popen", mock_popen)
164
+ monkeypatch.setattr(mod.time, "sleep", lambda x: None)
165
+ # Process will appear dead since PID doesn't exist
166
+ is_alive_false = staticmethod(lambda pid: False)
167
+ monkeypatch.setattr(mod.LockClient, "_is_process_alive", is_alive_false)
168
+
169
+ lc = mod.LockClient(developer_id="test_user")
170
+ monkeypatch.setattr(lc, "_read_pid", lambda: None)
171
+ lc.daemon_start()
172
+ captured = capsys.readouterr()
173
+ assert (
174
+ "exited immediately" in captured.out.lower()
175
+ or "starting" in captured.out.lower()
176
+ )
177
+
178
+
179
+ def test_daemon_start_successful(tmp_path, monkeypatch, capsys):
180
+ """Test daemon_start with successful process launch."""
181
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
182
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
183
+
184
+ pid_file = tmp_path / "daemon.pid"
185
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
186
+ fake_create = make_create_client(FakeResponse())
187
+ monkeypatch.setattr(mod, "_get_create_client", lambda: fake_create)
188
+
189
+ class FakeProc:
190
+ pid = 12345
191
+
192
+ def mock_popen(*args, **kwargs):
193
+ return FakeProc()
194
+
195
+ monkeypatch.setattr(subprocess, "Popen", mock_popen)
196
+ monkeypatch.setattr(mod.time, "sleep", lambda x: None)
197
+ is_alive_true = staticmethod(lambda pid: True)
198
+ monkeypatch.setattr(mod.LockClient, "_is_process_alive", is_alive_true)
199
+
200
+ lc = mod.LockClient(developer_id="test_user")
201
+
202
+ called_popen = []
203
+
204
+ def mock_read_pid():
205
+ if not called_popen:
206
+ return None
207
+ return 67890
208
+
209
+ def mock_popen_wrap(*a, **k):
210
+ called_popen.append(True)
211
+ return FakeProc()
212
+
213
+ monkeypatch.setattr(subprocess, "Popen", mock_popen_wrap)
214
+ monkeypatch.setattr(lc, "_read_pid", mock_read_pid)
215
+ lc.daemon_start()
216
+ captured = capsys.readouterr()
217
+ assert "started" in captured.out.lower()
218
+
219
+
220
+ def test_daemon_start_with_open_dashboard(tmp_path, monkeypatch, capsys):
221
+ """Test daemon_start with --open-dashboard flag."""
222
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
223
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
224
+
225
+ pid_file = tmp_path / "daemon.pid"
226
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
227
+ fake_create = make_create_client(FakeResponse())
228
+ monkeypatch.setattr(mod, "_get_create_client", lambda: fake_create)
229
+
230
+ popen_cmds = []
231
+
232
+ class FakeProc:
233
+ pid = 12345
234
+
235
+ def mock_popen(cmd, **kwargs):
236
+ popen_cmds.append(cmd)
237
+ return FakeProc()
238
+
239
+ monkeypatch.setattr(subprocess, "Popen", mock_popen)
240
+ monkeypatch.setattr(mod.time, "sleep", lambda x: None)
241
+ is_alive_true = staticmethod(lambda pid: True)
242
+ monkeypatch.setattr(mod.LockClient, "_is_process_alive", is_alive_true)
243
+
244
+ lc = mod.LockClient(developer_id="test_user")
245
+ lc.daemon_start(open_dashboard=True)
246
+ # Should include --open-dashboard in the command
247
+ assert any("--open-dashboard" in str(cmd) for cmd in popen_cmds)
248
+
249
+
250
+ def test_daemon_start_removes_stale_stop_request_before_restart(tmp_path, monkeypatch):
251
+ """daemon_start clears stale stop marker so restart does not self-stop."""
252
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
253
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
254
+
255
+ pid_file = tmp_path / "daemon.pid"
256
+ stop_file = tmp_path / ".stop_request"
257
+ stop_file.write_text("PID:99999", encoding="utf-8")
258
+
259
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
260
+ monkeypatch.setattr(
261
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
262
+ )
263
+ monkeypatch.setattr(mod, "_state_path", lambda name: str(tmp_path / name))
264
+
265
+ class FakeProc:
266
+ pid = 67890
267
+
268
+ read_calls = {"n": 0}
269
+
270
+ def _read_pid_seq():
271
+ read_calls["n"] += 1
272
+ return None if read_calls["n"] == 1 else 67890
273
+
274
+ monkeypatch.setattr(mod.LockClient, "_read_pid", staticmethod(_read_pid_seq))
275
+ monkeypatch.setattr(
276
+ mod.LockClient, "_is_process_alive", staticmethod(lambda _pid: True)
277
+ )
278
+ monkeypatch.setattr(subprocess, "Popen", lambda *a, **k: FakeProc())
279
+ monkeypatch.setattr(mod.time, "sleep", lambda _x: None)
280
+
281
+ lc = mod.LockClient(developer_id="test_user")
282
+ lc.daemon_start()
283
+
284
+ assert not stop_file.exists()
285
+
286
+
287
+ def test_daemon_start_ignores_stale_stop_request_check_errors(
288
+ monkeypatch, tmp_path, capsys
289
+ ):
290
+ """daemon_start continues even if stale stop-request inspection fails."""
291
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
292
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
293
+ monkeypatch.setattr(mod, "PID_FILE", str(tmp_path / "daemon.pid"))
294
+ monkeypatch.setattr(
295
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
296
+ )
297
+
298
+ read_calls = {"count": 0}
299
+
300
+ def _read_pid_sequence():
301
+ read_calls["count"] += 1
302
+ return None if read_calls["count"] == 1 else 67890
303
+
304
+ monkeypatch.setattr(mod.LockClient, "_read_pid", staticmethod(_read_pid_sequence))
305
+ monkeypatch.setattr(
306
+ mod.LockClient, "_is_process_alive", staticmethod(lambda _pid: True)
307
+ )
308
+ monkeypatch.setattr(
309
+ mod.LockClient, "_get_parent_ide_pid", lambda self: (None, None)
310
+ )
311
+ monkeypatch.setattr(
312
+ mod,
313
+ "_state_path",
314
+ lambda _name: (_ for _ in ()).throw(RuntimeError("state path failed")),
315
+ )
316
+ monkeypatch.setattr(mod.sys, "platform", "linux")
317
+ monkeypatch.setattr(mod.time, "sleep", lambda _x: None)
318
+
319
+ class _Proc:
320
+ pid = 67890
321
+
322
+ monkeypatch.setattr(subprocess, "Popen", lambda *a, **k: _Proc())
323
+
324
+ lc = mod.LockClient(developer_id="test_user")
325
+ lc.daemon_start()
326
+ captured = capsys.readouterr()
327
+ assert "started" in captured.out.lower()
328
+
329
+
330
+ def test_daemon_stop_not_running(tmp_path, monkeypatch, capsys):
331
+ """Test daemon_stop when no daemon is running."""
332
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
333
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
334
+
335
+ pid_file = tmp_path / "daemon.pid"
336
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
337
+ monkeypatch.setattr(
338
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
339
+ )
340
+
341
+ lc = mod.LockClient(developer_id="test_user")
342
+ # Ensure we don't pick up any real running watchers from the host
343
+ monkeypatch.setattr(mod.LockClient, "_discover_running_watchers", lambda self: [])
344
+ lc.daemon_stop()
345
+ captured = capsys.readouterr()
346
+ assert "no running" in captured.out.lower()
347
+
348
+
349
+ def test_daemon_stop_kills_process(tmp_path, monkeypatch, capsys):
350
+ """Test daemon_stop stops the running process."""
351
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
352
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
353
+
354
+ pid_file = tmp_path / "daemon.pid"
355
+ pid_file.write_text("99999")
356
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
357
+ monkeypatch.setattr(
358
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
359
+ )
360
+
361
+ alive_calls = [0]
362
+
363
+ def mock_is_alive(pid):
364
+ alive_calls[0] += 1
365
+ # First call True (for the check), subsequent False (stopped)
366
+ return alive_calls[0] <= 1
367
+
368
+ monkeypatch.setattr(
369
+ mod.LockClient, "_is_process_alive", staticmethod(mock_is_alive)
370
+ )
371
+ monkeypatch.setattr(subprocess, "run", lambda *a, **k: None)
372
+ monkeypatch.setattr(mod.time, "sleep", lambda x: None)
373
+
374
+ lc = mod.LockClient(developer_id="test_user")
375
+ lc.daemon_stop()
376
+ captured = capsys.readouterr()
377
+ assert "stop" in captured.out.lower()
378
+
379
+
380
+ def test_daemon_stop_forced_unix_fallback_paths(monkeypatch, tmp_path, capsys):
381
+ """Cover forced-stop Unix fallback branches and guarded cleanup paths."""
382
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
383
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
384
+ monkeypatch.setattr(mod.sys, "platform", "linux")
385
+ monkeypatch.setattr(mod.signal, "SIGKILL", 9, raising=False)
386
+
387
+ pid_file = tmp_path / "daemon.pid"
388
+ pid_file.write_text("12345")
389
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
390
+ monkeypatch.setattr(
391
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
392
+ )
393
+
394
+ target_pid = 12345
395
+
396
+ # Keep process "alive" long enough to force fallback paths.
397
+ calls = {"n": 0}
398
+
399
+ def _alive(_pid):
400
+ calls["n"] += 1
401
+ # First read + soft-wait + hard-wait checks all report alive.
402
+ return calls["n"] <= 30
403
+
404
+ monkeypatch.setattr(mod.LockClient, "_is_process_alive", staticmethod(_alive))
405
+
406
+ # Ensure token-less stop payload path writes PID:<pid>.
407
+ monkeypatch.setattr(mod.LockClient, "_read_pid_file", lambda self: None)
408
+
409
+ # First PID lookup should find target pid; later canonical lookup throws
410
+ # after forced kill to hit guarded debug path.
411
+ pid_reads = {"n": 0}
412
+
413
+ def _read_pid_seq():
414
+ pid_reads["n"] += 1
415
+ if pid_reads["n"] == 1:
416
+ return target_pid
417
+ raise RuntimeError("read pid fail")
418
+
419
+ monkeypatch.setattr(mod.LockClient, "_read_pid", staticmethod(_read_pid_seq))
420
+
421
+ # Force final _remove_pid to fail for lines 1386-1387 path.
422
+ monkeypatch.setattr(
423
+ mod.LockClient,
424
+ "_remove_pid",
425
+ staticmethod(lambda: (_ for _ in ()).throw(OSError("remove fail"))),
426
+ )
427
+
428
+ # Make group kill fail, then direct PID kill fail with ProcessLookupError
429
+ # for both SIGTERM and SIGKILL fallback branches.
430
+ def _kill(pid, sig):
431
+ if pid < 0:
432
+ raise OSError("group kill failed")
433
+ raise ProcessLookupError("pid gone")
434
+
435
+ monkeypatch.setattr(mod.os, "kill", _kill)
436
+ monkeypatch.setattr(mod.time, "sleep", lambda _x: None)
437
+
438
+ lc = mod.LockClient(developer_id="test_user")
439
+ lc.daemon_stop()
440
+ captured = capsys.readouterr()
441
+ assert "stopping lock watcher" in captured.out.lower()
442
+
443
+
444
+ def test_daemon_stop_discovered_watcher_token_cleanup_and_print_failures(
445
+ monkeypatch, tmp_path
446
+ ):
447
+ """daemon_stop covers discovery fallback, token stop payload, and cleanup errors."""
448
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
449
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
450
+ monkeypatch.setattr(mod, "PID_FILE", str(tmp_path / "custom.pid"))
451
+ monkeypatch.setattr(
452
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
453
+ )
454
+
455
+ stop_file = tmp_path / ".stop_request"
456
+ shutdown_file = tmp_path / ".shutdown_complete"
457
+ shutdown_file.write_text("done", encoding="utf-8")
458
+
459
+ read_calls = {"count": 0}
460
+
461
+ def _read_pid_sequence():
462
+ read_calls["count"] += 1
463
+ if read_calls["count"] == 1:
464
+ return None
465
+ raise RuntimeError("read pid cleanup fail")
466
+
467
+ monkeypatch.setattr(mod.LockClient, "_read_pid", staticmethod(_read_pid_sequence))
468
+ monkeypatch.setattr(
469
+ mod.LockClient, "_is_process_alive", staticmethod(lambda _pid: False)
470
+ )
471
+ monkeypatch.setattr(
472
+ mod,
473
+ "_state_path",
474
+ lambda name: str(shutdown_file if name == ".shutdown_complete" else stop_file),
475
+ )
476
+ monkeypatch.setattr(mod.os, "fsync", lambda _fd: None)
477
+
478
+ real_remove = mod.os.remove
479
+
480
+ def _remove(path):
481
+ if str(path) == str(stop_file):
482
+ raise OSError("remove fail")
483
+ return real_remove(path)
484
+
485
+ monkeypatch.setattr(mod.os, "remove", _remove)
486
+
487
+ real_print = builtins.print
488
+
489
+ def _print(*args, **kwargs):
490
+ text = " ".join(str(arg) for arg in args)
491
+ if text.startswith("Stopping lock watcher"):
492
+ raise RuntimeError("console unavailable")
493
+ return real_print(*args, **kwargs)
494
+
495
+ monkeypatch.setattr(builtins, "print", _print)
496
+
497
+ lc = mod.LockClient(developer_id="test_user")
498
+ monkeypatch.setattr(lc, "_discover_running_watchers", lambda: [777])
499
+ monkeypatch.setattr(lc, "_read_pid_file", lambda: {"token": "abc123"})
500
+ monkeypatch.setattr(lc, "_remove_pid", lambda: None)
501
+
502
+ lc.daemon_stop()
503
+ assert stop_file.exists()
504
+
505
+
506
+ def test_daemon_status_prefers_entrypoint(tmp_path, monkeypatch):
507
+ # Start a dummy background python process to ensure the PID is alive.
508
+ p = subprocess.Popen(
509
+ [sys.executable, "-c", "import time; time.sleep(60)"],
510
+ stdout=subprocess.DEVNULL,
511
+ stderr=subprocess.DEVNULL,
512
+ )
513
+ try:
514
+ pid_file = tmp_path / ".daemon.pid"
515
+
516
+ # reuse a minimal writer to create the PID file used by the CLI
517
+ def _write_meta(pid: int, entrypoint: str, cmdline: str) -> None:
518
+ meta = {
519
+ "pid": pid,
520
+ "started_at": (time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())),
521
+ "entrypoint": entrypoint,
522
+ "cmdline": cmdline,
523
+ "cwd": os.getcwd(),
524
+ }
525
+ pid_file.write_text(json.dumps(meta), encoding="utf-8")
526
+
527
+ _write_meta(p.pid, "python lock_client.py", f"{sys.executable} -c dummy_sleep")
528
+
529
+ # Run the CLI status command with dummy SUPABASE env to avoid credential checks
530
+ env = dict(os.environ)
531
+ env["SUPABASE_URL"] = "http://localhost:54321"
532
+ env["SUPABASE_ANON_KEY"] = "test-anon-key-daemon"
533
+ env["PYTHONPATH"] = str(Path(__file__).resolve().parents[4])
534
+ env["COLLAB_TEST_MODE"] = "1"
535
+ env["COLLAB_PID_FILE"] = str(pid_file)
536
+
537
+ res = subprocess.run(
538
+ [sys.executable, "run.py", "daemon-status"],
539
+ capture_output=True,
540
+ text=True,
541
+ env=env,
542
+ )
543
+ out = res.stdout + res.stderr
544
+ assert f"Lock watcher is RUNNING (PID: {p.pid})" in out
545
+ assert "python lock_client.py" in out
546
+ finally:
547
+ try:
548
+ p.terminate()
549
+ except Exception:
550
+ pass
551
+ try:
552
+ if pid_file.exists():
553
+ pid_file.unlink()
554
+ except Exception:
555
+ pass
556
+
557
+
558
+ def test_register_signal_handlers(monkeypatch, tmp_path):
559
+ """Test that signal handlers are registered without raising."""
560
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
561
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
562
+
563
+ pid_file = tmp_path / "daemon.pid"
564
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
565
+ monkeypatch.setattr(
566
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
567
+ )
568
+
569
+ lc = mod.LockClient(developer_id="test_user")
570
+ lc._register_signal_handlers()
571
+
572
+
573
+ def test_daemon_status_preserves_stale_pid(monkeypatch, tmp_path):
574
+ pid_file = tmp_path / ".daemon.pid"
575
+ pid_file.write_text("99999")
576
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
577
+ monkeypatch.setenv("COLLAB_TEST_MODE", "0")
578
+
579
+ # Simulate that the PID exists but belongs to another process
580
+ monkeypatch.setattr(mod.LockClient, "_read_pid", staticmethod(lambda: 99999))
581
+ is_alive_true = staticmethod(lambda p: True)
582
+ monkeypatch.setattr(mod.LockClient, "_is_process_alive", is_alive_true)
583
+ monkeypatch.setattr(
584
+ mod.LockClient,
585
+ "_get_cmdline_for_pid",
586
+ staticmethod(lambda p: r"C:\\Windows\\System32\\not_the_watcher.exe"),
587
+ )
588
+
589
+ client = object.__new__(mod.LockClient)
590
+ ok = mod.LockClient.daemon_status(client)
591
+ assert ok is False
592
+ assert os.path.exists(str(pid_file))
593
+
594
+
595
+ def test_daemon_status_local_only_discovers_replacement_watcher(monkeypatch, tmp_path):
596
+ """Local-only daemon_status falls back to discovered watchers for stale PIDs."""
597
+ pid_file = tmp_path / ".daemon.pid"
598
+ pid_file.write_text("12345", encoding="utf-8")
599
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
600
+
601
+ client = object.__new__(mod.LockClient)
602
+ client.local_only = True
603
+ monkeypatch.setattr(mod.LockClient, "_read_pid", staticmethod(lambda: 12345))
604
+ monkeypatch.setattr(
605
+ mod.LockClient,
606
+ "_is_process_alive",
607
+ staticmethod(lambda pid: pid in {12345, 22222}),
608
+ )
609
+ monkeypatch.setattr(
610
+ mod.LockClient,
611
+ "_get_cmdline_for_pid",
612
+ staticmethod(lambda pid: "python unrelated.py" if pid == 12345 else None),
613
+ )
614
+ monkeypatch.setattr(
615
+ mod.LockClient,
616
+ "_cmdline_matches_watcher",
617
+ staticmethod(lambda cmd: "watch" in cmd),
618
+ )
619
+ monkeypatch.setattr(client, "_discover_running_watchers", lambda: [22222])
620
+
621
+ assert mod.LockClient.daemon_status(client) is True
622
+
623
+
624
+ def test_daemon_status_local_only_discovers_when_pid_missing(monkeypatch):
625
+ """Local-only daemon_status can report a discovered watcher without a PID file."""
626
+ client = object.__new__(mod.LockClient)
627
+ client.local_only = True
628
+ monkeypatch.setattr(mod.LockClient, "_read_pid", staticmethod(lambda: None))
629
+ monkeypatch.setattr(
630
+ mod.LockClient, "_is_process_alive", staticmethod(lambda pid: pid == 33333)
631
+ )
632
+ monkeypatch.setattr(
633
+ mod.LockClient, "_get_cmdline_for_pid", staticmethod(lambda pid: None)
634
+ )
635
+ monkeypatch.setattr(client, "_discover_running_watchers", lambda: [33333])
636
+
637
+ assert mod.LockClient.daemon_status(client) is True
638
+
639
+
640
+ def test_daemon_status_local_only_discovery_exception_returns_false(monkeypatch):
641
+ """Local-only daemon_status suppresses discovery errors and reports not running."""
642
+ client = object.__new__(mod.LockClient)
643
+ client.local_only = True
644
+ monkeypatch.setattr(mod.LockClient, "_read_pid", staticmethod(lambda: None))
645
+ monkeypatch.setattr(
646
+ client,
647
+ "_discover_running_watchers",
648
+ lambda: (_ for _ in ()).throw(RuntimeError("discovery failed")),
649
+ )
650
+
651
+ assert mod.LockClient.daemon_status(client) is False
652
+
653
+
654
+ def test_cmdline_matching():
655
+ assert mod.LockClient._cmdline_matches_watcher(
656
+ "/usr/bin/python .collab/pycharm/live_locks_watcher.py"
657
+ )
658
+ assert mod.LockClient._cmdline_matches_watcher(
659
+ "python lock_client.py watch --daemon"
660
+ )
661
+ assert not mod.LockClient._cmdline_matches_watcher(
662
+ r"C:\\Windows\\System32\\not_the_watcher.exe"
663
+ )
664
+
665
+
666
+ def test_get_cmdline_with_and_without_psutil(monkeypatch):
667
+ class DummyProc:
668
+ def __init__(self, pid):
669
+ pass
670
+
671
+ def cmdline(self):
672
+ return ["python", "live_locks_watcher.py"]
673
+
674
+ sys.modules["psutil"] = type(
675
+ "m", (), {"Process": DummyProc, "pid_exists": lambda p: True}
676
+ )
677
+ try:
678
+ got = mod.LockClient._get_cmdline_for_pid(1234)
679
+ assert "live_locks_watcher.py" in got
680
+ finally:
681
+ del sys.modules["psutil"]
682
+
683
+
684
+ # RESTORED: test_daemon_start_uses_pid_metadata
685
+ def test_daemon_start_uses_pid_metadata(monkeypatch, tmp_path, capsys):
686
+ pid_file = tmp_path / ".daemon.pid"
687
+ pid_file.write_text(json.dumps({"pid": 9999, "entrypoint": "watcher"}))
688
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
689
+
690
+ # Simulate read_pid and process alive
691
+ monkeypatch.setattr(mod.LockClient, "_read_pid", staticmethod(lambda: 9999))
692
+ monkeypatch.setattr(
693
+ mod.LockClient, "_is_process_alive", staticmethod(lambda p: True)
694
+ )
695
+
696
+ client = object.__new__(mod.LockClient)
697
+ mod.LockClient.daemon_start(client)
698
+ captured = capsys.readouterr()
699
+ out = captured.out.lower()
700
+ assert ("watcher already running" in out) or ("started" in out)
701
+
702
+
703
+ # RESTORED: test_daemon_start_legacy_plain_pid_matches_current
704
+ def test_daemon_start_legacy_plain_pid_matches_current(monkeypatch, tmp_path, capsys):
705
+ pid_file = tmp_path / ".daemon.pid"
706
+ pid_file.write_text(str(os.getpid()))
707
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
708
+
709
+ monkeypatch.setattr(mod.LockClient, "_read_pid", staticmethod(lambda: os.getpid()))
710
+ is_alive_true = staticmethod(lambda p: True)
711
+ monkeypatch.setattr(mod.LockClient, "_is_process_alive", is_alive_true)
712
+ # Avoid accidental real watcher spawn by returning a watcher-like cmdline.
713
+ monkeypatch.setattr(
714
+ mod.LockClient,
715
+ "_get_cmdline_for_pid",
716
+ staticmethod(
717
+ lambda _p: (f"python lock_client.py watch --daemon --pid-file {pid_file}")
718
+ ),
719
+ )
720
+
721
+ client = object.__new__(mod.LockClient)
722
+ mod.LockClient.daemon_start(client)
723
+ captured = capsys.readouterr()
724
+ out = captured.out.lower()
725
+ assert ("watcher already running" in out) or ("started" in out)
726
+
727
+
728
+ # RESTORED: test_read_int_pid_file
729
+ def test_read_int_pid_file(monkeypatch, tmp_path):
730
+ pid_file = tmp_path / ".daemon.pid"
731
+ pid_file.write_text("12345")
732
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
733
+ pid = mod.LockClient._read_pid()
734
+ assert pid == 12345
735
+
736
+
737
+ # RESTORED: test_read_json_pid_file
738
+ def test_read_json_pid_file(monkeypatch, tmp_path):
739
+ pid_file = tmp_path / ".daemon.pid"
740
+ pid_file.write_text(
741
+ json.dumps({"pid": 4242, "cmd": "python live_locks_watcher.py"})
742
+ )
743
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
744
+ pid = mod.LockClient._read_pid()
745
+ assert pid == 4242
746
+
747
+
748
+ # RESTORED: test_read_malformed_pid_file
749
+ def test_read_malformed_pid_file(monkeypatch, tmp_path):
750
+ pid_file = tmp_path / ".daemon.pid"
751
+ pid_file.write_text("not-a-pid")
752
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
753
+ pid = mod.LockClient._read_pid()
754
+ assert pid is None
755
+
756
+
757
+ # RESTORED: test_read_pid_empty_and_invalid_json_and_oserror
758
+ def test_read_pid_empty_and_invalid_json_and_oserror(monkeypatch, tmp_path):
759
+ # empty file
760
+ pid_file = tmp_path / ".daemon.pid"
761
+ pid_file.write_text("")
762
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
763
+ assert mod.LockClient._read_pid() is None
764
+
765
+ # invalid json
766
+ pid_file.write_text("{'not': 'json'}")
767
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
768
+ assert mod.LockClient._read_pid() is None
769
+
770
+ # open raises OSError
771
+ pid_file.write_text("4242")
772
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
773
+
774
+ def _bad_open(*a, **k):
775
+ raise OSError("boom")
776
+
777
+ monkeypatch.setattr("builtins.open", _bad_open)
778
+ assert mod.LockClient._read_pid() is None
779
+
780
+
781
+ # RESTORED: test_get_create_client_caches_result
782
+ def test_get_create_client_caches_result(monkeypatch):
783
+ def fake_fn(url, key):
784
+ pass
785
+
786
+ monkeypatch.setattr(mod, "_supabase_create_client", fake_fn)
787
+ result = mod._get_create_client()
788
+ assert result is fake_fn
789
+
790
+
791
+ # RESTORED: test_get_create_client_import_error
792
+ def test_get_create_client_import_error(monkeypatch):
793
+ monkeypatch.setattr(mod, "_supabase_create_client", None)
794
+
795
+ original_import = (
796
+ __builtins__.__import__ if hasattr(__builtins__, "__import__") else __import__
797
+ )
798
+
799
+ def mock_import(name, *args, **kwargs):
800
+ if name == "supabase":
801
+ raise ImportError("No module named 'supabase'")
802
+ return original_import(name, *args, **kwargs)
803
+
804
+ monkeypatch.setattr("builtins.__import__", mock_import)
805
+ with pytest.raises(SystemExit):
806
+ mod._get_create_client()
807
+
808
+
809
+ # RESTORED: test_get_create_client_lazy_import_success
810
+ def test_get_create_client_lazy_import_success(monkeypatch):
811
+ monkeypatch.setattr(mod, "_supabase_create_client", None)
812
+
813
+ def fake_create(url, key):
814
+ return FakeClient(FakeResponse())
815
+
816
+ fake_supabase = type(sys)("fake_supabase")
817
+ fake_supabase.create_client = fake_create
818
+ monkeypatch.setitem(sys.modules, "supabase", fake_supabase)
819
+
820
+ result = mod._get_create_client()
821
+ assert result is fake_create
822
+
823
+ # Reset for other tests
824
+ monkeypatch.setattr(mod, "_supabase_create_client", None)
825
+
826
+
827
+ # RESTORED: test_get_git_username_fallback_to_env
828
+ def test_get_git_username_fallback_to_env(monkeypatch):
829
+ monkeypatch.setenv("USER", "env_user")
830
+ monkeypatch.setenv("USERNAME", "env_username")
831
+
832
+ def mock_check_output(cmd, *args, **kwargs):
833
+ raise subprocess.CalledProcessError(1, cmd)
834
+
835
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
836
+
837
+ username = mod.LockClient._get_git_username()
838
+ assert username in ("env_user", "env_username")
839
+
840
+
841
+ # RESTORED: test_get_git_username_oserror
842
+ def test_get_git_username_oserror(monkeypatch):
843
+ monkeypatch.delenv("DEVELOPER_ID", raising=False)
844
+ monkeypatch.delenv("USERNAME", raising=False)
845
+ monkeypatch.delenv("USER", raising=False)
846
+
847
+ def mock_check_output(cmd, *args, **kwargs):
848
+ raise OSError("failed")
849
+
850
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
851
+ client = getattr(mod, "LockClient")
852
+ assert client._get_git_username() == "unknown_user"
853
+
854
+
855
+ # RESTORED: test_is_admin_property
856
+ def test_is_admin_property(monkeypatch):
857
+ monkeypatch.setattr(mod, "SUPABASE_SERVICE_ROLE_KEY", "admin_key")
858
+ monkeypatch.setattr(mod, "_supabase_create_client", lambda url, key: None)
859
+ client = getattr(mod, "LockClient")()
860
+ assert client.is_admin is True
861
+
862
+
863
+ # RESTORED: test_lockclient_class_and_methods_exist
864
+ def test_lockclient_class_and_methods_exist():
865
+ assert hasattr(mod, "LockClient")
866
+ LC = getattr(mod, "LockClient")
867
+ for name in ("acquire", "release", "active", "get_lock_status", "watch"):
868
+ assert hasattr(LC, name), f"Missing {name}"
869
+
870
+
871
+ # RESTORED: test_daemon_start_non_win32
872
+ def test_daemon_start_non_win32(monkeypatch):
873
+ monkeypatch.setattr(mod, "SUPABASE_SERVICE_ROLE_KEY", "admin_key")
874
+ monkeypatch.setattr(mod, "_supabase_create_client", lambda url, key: None)
875
+ client = getattr(mod, "LockClient")()
876
+ monkeypatch.setattr(client, "_read_pid", lambda: None)
877
+ monkeypatch.setattr(client, "_is_process_alive", lambda pid: False)
878
+ monkeypatch.setattr(sys, "platform", "linux")
879
+
880
+ class FakeProc:
881
+ pid = 4321
882
+
883
+ did_call = []
884
+
885
+ def mock_popen(*args, **kwargs):
886
+ did_call.append(1)
887
+ return FakeProc()
888
+
889
+ monkeypatch.setattr(subprocess, "Popen", mock_popen)
890
+ monkeypatch.setattr(os, "setsid", lambda: None, raising=False)
891
+ client.daemon_start(open_dashboard=True)
892
+ assert len(did_call) >= 1
893
+
894
+
895
+ # RESTORED: test_daemon_start_win32_exception_and_fallback
896
+ def test_daemon_start_win32_exception_and_fallback(monkeypatch):
897
+ monkeypatch.setattr(mod, "SUPABASE_SERVICE_ROLE_KEY", "admin_key")
898
+ monkeypatch.setattr(mod, "_supabase_create_client", lambda url, key: None)
899
+ client = getattr(mod, "LockClient")()
900
+ monkeypatch.setattr(client, "_read_pid", lambda: None)
901
+ monkeypatch.setattr(client, "_is_process_alive", lambda pid: False)
902
+ # Prevent _get_parent_ide_pid from making its own subprocess calls
903
+ monkeypatch.setattr(client, "_get_parent_ide_pid", lambda: (None, "unknown"))
904
+ monkeypatch.setattr(sys, "platform", "win32")
905
+
906
+ def mock_open(*args, **kwargs):
907
+ raise OSError("failed")
908
+
909
+ monkeypatch.setattr("builtins.open", mock_open)
910
+
911
+ monkeypatch.setattr(os.path, "exists", lambda x: False)
912
+
913
+ class FakeProc:
914
+ pid = 1234
915
+
916
+ did_call = []
917
+
918
+ def mock_popen(*args, **kwargs):
919
+ did_call.append(kwargs.get("creationflags"))
920
+ return FakeProc()
921
+
922
+ monkeypatch.setattr(subprocess, "Popen", mock_popen)
923
+ client.daemon_start()
924
+ assert len(did_call) == 1
925
+ assert did_call[0] is not None
926
+
927
+
928
+ # RESTORED: test_daemon_status_legacy_pid
929
+ def test_daemon_status_legacy_pid(monkeypatch, tmp_path):
930
+ monkeypatch.setattr(mod, "_supabase_create_client", lambda url, key: None)
931
+ client = getattr(mod, "LockClient")()
932
+ monkeypatch.setattr(client, "_read_pid", lambda: None)
933
+ legacy = tmp_path / ".pycharm_watcher.pid"
934
+ legacy.write_text("54321")
935
+ monkeypatch.setattr(mod, "_COLLAB_ROOT", str(tmp_path))
936
+ monkeypatch.setattr(client, "_is_process_alive", lambda p: p == 54321)
937
+ assert client.daemon_status() is True
938
+
939
+
940
+ # RESTORED: test_daemon_status_legacy_pid_exception
941
+ def test_daemon_status_legacy_pid_exception(monkeypatch, tmp_path):
942
+ monkeypatch.setattr(mod, "_supabase_create_client", lambda url, key: None)
943
+ client = getattr(mod, "LockClient")()
944
+ monkeypatch.setattr(client, "_read_pid", lambda: None)
945
+ legacy = tmp_path / ".pycharm_watcher.pid"
946
+ legacy.write_text("invalid")
947
+ monkeypatch.setattr(mod, "_COLLAB_ROOT", str(tmp_path))
948
+ assert client.daemon_status() is False
949
+
950
+ class BadProc:
951
+ def __init__(self, pid):
952
+ raise Exception("no access")
953
+
954
+ sys.modules["psutil"] = type("m", (), {"Process": BadProc})
955
+ try:
956
+ # Use a very high PID that's guaranteed not to exist
957
+ assert mod.LockClient._get_cmdline_for_pid(99999999) is None
958
+ finally:
959
+ del sys.modules["psutil"]
960
+
961
+
962
+ def test_cmdline_string_and_empty():
963
+ class StrProc:
964
+ def __init__(self, pid):
965
+ pass
966
+
967
+ def cmdline(self):
968
+ return "python lock_client.py watch"
969
+
970
+ sys.modules["psutil"] = type("m", (), {"Process": StrProc})
971
+ try:
972
+ got = mod.LockClient._get_cmdline_for_pid(1)
973
+ assert "lock_client.py watch" in got
974
+ finally:
975
+ del sys.modules["psutil"]
976
+
977
+ assert not mod.LockClient._cmdline_matches_watcher("")
978
+
979
+
980
+ def test_daemon_start_cmdline_unavailable_assumes_running(
981
+ monkeypatch, tmp_path, capsys
982
+ ):
983
+ client = object.__new__(mod.LockClient)
984
+
985
+ monkeypatch.setattr(mod.LockClient, "_read_pid", staticmethod(lambda: 42424))
986
+ monkeypatch.setattr(
987
+ mod.LockClient, "_is_process_alive", staticmethod(lambda p: True)
988
+ )
989
+ monkeypatch.setattr(
990
+ mod.LockClient, "_get_cmdline_for_pid", staticmethod(lambda p: None)
991
+ )
992
+
993
+ try:
994
+ if os.path.exists(mod.PID_FILE):
995
+ os.unlink(mod.PID_FILE)
996
+ except Exception:
997
+ pass
998
+
999
+ mod.LockClient.daemon_start(client)
1000
+ captured = capsys.readouterr()
1001
+ out = captured.out.lower()
1002
+ assert ("watcher already running" in out) or ("starting lock watcher" in out)
1003
+
1004
+
1005
+ def test_remove_pid_oserror(tmp_path, monkeypatch):
1006
+ pid_file = tmp_path / "daemon.pid"
1007
+ pid_file.write_text("12345")
1008
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1009
+
1010
+ original_remove = os.remove
1011
+
1012
+ def failing_remove(path):
1013
+ if "daemon.pid" in str(path):
1014
+ raise OSError("Permission denied")
1015
+ return original_remove(path)
1016
+
1017
+ monkeypatch.setattr(os, "remove", failing_remove)
1018
+
1019
+ # Should not raise
1020
+ mod.LockClient._remove_pid()
1021
+
1022
+
1023
+ def test_register_signal_handlers_notest(monkeypatch):
1024
+ monkeypatch.delenv("COLLAB_TEST_MODE", raising=False)
1025
+ monkeypatch.setattr(
1026
+ mod,
1027
+ "sys",
1028
+ types.SimpleNamespace(
1029
+ platform="linux",
1030
+ exit=lambda x: None,
1031
+ ),
1032
+ )
1033
+ signals_called = []
1034
+
1035
+ def fake_signal(sig, handler):
1036
+ signals_called.append((sig, handler))
1037
+
1038
+ monkeypatch.setattr(mod.signal, "signal", fake_signal)
1039
+ monkeypatch.setattr(mod, "atexit", types.SimpleNamespace(register=lambda fn: None))
1040
+
1041
+ client = mod.LockClient(local_only=True)
1042
+ client._register_signal_handlers()
1043
+ assert any("SIGTERM" in str(s) or s == signal.SIGTERM for s, _ in signals_called)
1044
+
1045
+
1046
+ def test_register_signal_handlers_signal_calls_shutdown(monkeypatch):
1047
+ monkeypatch.delenv("COLLAB_TEST_MODE", raising=False)
1048
+ shutdown_called = []
1049
+
1050
+ def fake_signal(sig, handler):
1051
+ if sig == signal.SIGINT:
1052
+ shutdown_called.append(handler)
1053
+
1054
+ monkeypatch.setattr(mod.signal, "signal", fake_signal)
1055
+ monkeypatch.setattr(mod, "atexit", types.SimpleNamespace(register=lambda fn: None))
1056
+
1057
+ graceful = mock.Mock()
1058
+ client = mod.LockClient(local_only=True)
1059
+ monkeypatch.setattr(client, "_graceful_shutdown", graceful)
1060
+ monkeypatch.setattr(mod.sys, "exit", lambda code: None)
1061
+
1062
+ client._register_signal_handlers()
1063
+ if shutdown_called:
1064
+ shutdown_called[0](signal.SIGINT, None)
1065
+ graceful.assert_called()
1066
+
1067
+
1068
+ def test_parent_monitor_not_windows(monkeypatch):
1069
+ monkeypatch.setattr(sys, "platform", "linux")
1070
+ client = mod.LockClient(local_only=True)
1071
+ client._start_parent_monitor_thread() # Should not raise
1072
+
1073
+
1074
+ def test_parent_monitor_no_parent(monkeypatch):
1075
+ monkeypatch.setattr(sys, "platform", "win32")
1076
+ client = mod.LockClient(local_only=True)
1077
+ client._parent_pid = None
1078
+ client._start_parent_monitor_thread() # Should not raise
1079
+
1080
+
1081
+ def test_assign_to_job_object_non_windows(monkeypatch):
1082
+ monkeypatch.setattr(sys, "platform", "linux")
1083
+ mod.LockClient._assign_to_job_object() # Should not raise
1084
+
1085
+
1086
+ def test_graceful_shutdown_writes_marker(monkeypatch, tmp_path):
1087
+ monkeypatch.setattr(mod, "_COLLAB_ROOT", str(tmp_path))
1088
+ monkeypatch.delenv("COLLAB_TEST_MODE", raising=False)
1089
+
1090
+ pid_file = tmp_path / "daemon.pid"
1091
+ pid_file.write_text("12345")
1092
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1093
+
1094
+ client = mod.LockClient(local_only=True)
1095
+ client._shutdown_done = False
1096
+
1097
+ monkeypatch.setattr(client, "active", mock.Mock(return_value=[]))
1098
+ monkeypatch.setattr(client, "_run_git_status", mock.Mock(return_value=""))
1099
+
1100
+ client._graceful_shutdown(reason="test")
1101
+ marker = mod._state_path(".shutdown_complete")
1102
+ assert os.path.exists(marker)
1103
+ assert not pid_file.exists()
1104
+
1105
+
1106
+ def test_graceful_shutdown_full_path(monkeypatch, tmp_path):
1107
+ monkeypatch.setattr(mod, "_COLLAB_ROOT", str(tmp_path))
1108
+ monkeypatch.delenv("COLLAB_TEST_MODE", raising=False)
1109
+ pid_file = tmp_path / "daemon.pid"
1110
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1111
+ client = mod.LockClient(local_only=True)
1112
+ client._shutdown_done = False
1113
+ monkeypatch.setattr(client, "active", mock.Mock(return_value=[]))
1114
+ monkeypatch.setattr(client, "_run_git_status", mock.Mock(return_value=""))
1115
+ client._graceful_shutdown(reason="test_shutdown")
1116
+ marker = mod._state_path(".shutdown_complete")
1117
+ assert os.path.exists(marker)
1118
+
1119
+
1120
+ def test_graceful_shutdown_with_locks(monkeypatch, tmp_path):
1121
+ monkeypatch.setattr(mod, "_COLLAB_ROOT", str(tmp_path))
1122
+ monkeypatch.delenv("COLLAB_TEST_MODE", raising=False)
1123
+ pid_file = tmp_path / "daemon.pid"
1124
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1125
+ client = mod.LockClient(local_only=True, developer_id="test_user")
1126
+ client._shutdown_done = False
1127
+ monkeypatch.setattr(
1128
+ client,
1129
+ "active",
1130
+ mock.Mock(
1131
+ return_value=[{"file_path": "src/app.py", "developer_id": "test_user"}]
1132
+ ),
1133
+ )
1134
+ monkeypatch.setattr(client, "_run_git_status", mock.Mock(return_value=""))
1135
+ client._graceful_shutdown(reason="test")
1136
+ assert os.path.exists(mod._state_path(".shutdown_complete"))
1137
+
1138
+
1139
+ def test_register_signal_handlers_windows_console_handler(monkeypatch):
1140
+ monkeypatch.delenv("COLLAB_TEST_MODE", raising=False)
1141
+ monkeypatch.setattr(sys, "platform", "win32")
1142
+ monkeypatch.setattr(mod, "atexit", types.SimpleNamespace(register=lambda fn: None))
1143
+ signal_sigs = []
1144
+
1145
+ def fake_signal(sig, handler):
1146
+ signal_sigs.append((sig, handler))
1147
+
1148
+ monkeypatch.setattr(mod.signal, "signal", fake_signal)
1149
+ registered_ctrl_handler = []
1150
+ fake_wintypes = types.SimpleNamespace(BOOL=lambda v: v, DWORD=lambda v: v)
1151
+
1152
+ def _ctrl_handler(handler, add):
1153
+ registered_ctrl_handler.append(True)
1154
+
1155
+ fake_ctypes = types.SimpleNamespace(
1156
+ wintypes=fake_wintypes,
1157
+ windll=types.SimpleNamespace(
1158
+ kernel32=types.SimpleNamespace(
1159
+ SetConsoleCtrlHandler=_ctrl_handler,
1160
+ )
1161
+ ),
1162
+ WINFUNCTYPE=lambda *a: lambda f: f,
1163
+ )
1164
+ monkeypatch.setitem(sys.modules, "ctypes", fake_ctypes)
1165
+ monkeypatch.setitem(sys.modules, "ctypes.wintypes", fake_wintypes)
1166
+ client = mod.LockClient(local_only=True)
1167
+ monkeypatch.setattr(client, "_graceful_shutdown", mock.Mock())
1168
+ client._register_signal_handlers()
1169
+ assert len(registered_ctrl_handler) == 1
1170
+ assert len(signal_sigs) >= 1
1171
+
1172
+
1173
+ # --- Appended from test_lock_client_daemon_ops.py ---
1174
+
1175
+
1176
+ def test_read_pid_variants(tmp_path):
1177
+ pidfile = tmp_path / "pid_test.pid"
1178
+ mod.PID_FILE = str(pidfile)
1179
+
1180
+ # JSON metadata
1181
+ meta = {"pid": os.getpid(), "started_at": "now", "entrypoint": "lock-daemon"}
1182
+ with open(mod.PID_FILE, "w", encoding="utf-8") as fh:
1183
+ json.dump(meta, fh)
1184
+ assert mod.LockClient._read_pid() == os.getpid()
1185
+
1186
+ # Plain integer
1187
+ with open(mod.PID_FILE, "w", encoding="utf-8") as fh:
1188
+ fh.write(str(os.getpid()))
1189
+ assert mod.LockClient._read_pid() == os.getpid()
1190
+
1191
+ # Malformed
1192
+ with open(mod.PID_FILE, "w", encoding="utf-8") as fh:
1193
+ fh.write("not-an-int")
1194
+ assert mod.LockClient._read_pid() is None
1195
+
1196
+
1197
+ def test_daemon_status_with_entrypoint(monkeypatch, tmp_path, capsys):
1198
+ pidfile = tmp_path / "daemon.pid"
1199
+ monkeypatch.setattr(mod, "PID_FILE", str(pidfile))
1200
+ meta = {
1201
+ "pid": os.getpid(),
1202
+ "entrypoint": "pycharm-watcher",
1203
+ "cmdline": "python watcher",
1204
+ }
1205
+ with open(mod.PID_FILE, "w", encoding="utf-8") as fh:
1206
+ json.dump(meta, fh)
1207
+
1208
+ client = mod.LockClient(developer_id="tester", local_only=True)
1209
+ monkeypatch.setattr(client, "_is_process_alive", lambda pid: True)
1210
+ monkeypatch.setattr(client, "_get_cmdline_for_pid", lambda pid: None)
1211
+
1212
+ running = client.daemon_status()
1213
+ out = capsys.readouterr().out
1214
+ assert running is True
1215
+ assert "RUNNING" in out or "RUNNING" in out.upper()
1216
+
1217
+
1218
+ def test_daemon_start_invokes_popen(monkeypatch, tmp_path, capsys):
1219
+ pidfile = tmp_path / "daemon2.pid"
1220
+ mod.PID_FILE = str(pidfile)
1221
+ client = mod.LockClient(developer_id="daemon_test", local_only=True)
1222
+
1223
+ # Fake process object
1224
+ proc = types.SimpleNamespace(pid=999999)
1225
+
1226
+ # _read_pid should be None first, then return the proc.pid when polled
1227
+ calls = {"n": 0}
1228
+
1229
+ def fake_read_pid():
1230
+ calls["n"] += 1
1231
+ return proc.pid if calls["n"] > 1 else None
1232
+
1233
+ monkeypatch.setattr(client, "_read_pid", fake_read_pid)
1234
+
1235
+ # Stub Popen to return our fake proc
1236
+ monkeypatch.setattr(subprocess, "Popen", lambda *a, **k: proc)
1237
+
1238
+ # Prevent writing real PID file
1239
+ monkeypatch.setattr(client, "_write_pid", lambda *a, **k: None)
1240
+ monkeypatch.setattr(client, "_is_process_alive", lambda pid: True)
1241
+
1242
+ client.daemon_start(interval=1, timeout_mins=0, open_dashboard=False)
1243
+ out = capsys.readouterr().out
1244
+ assert "Started" in out or "Started" in out
1245
+
1246
+
1247
+ def test_daemon_stop_no_running(monkeypatch, tmp_path, capsys):
1248
+ pidfile = tmp_path / "none.pid"
1249
+ mod.PID_FILE = str(pidfile)
1250
+ client = mod.LockClient(developer_id="tester", local_only=True)
1251
+
1252
+ # No PID file, and discover returns empty
1253
+ monkeypatch.setattr(client, "_read_pid", lambda: None)
1254
+ monkeypatch.setattr(client, "_discover_running_watchers", lambda: [])
1255
+
1256
+ client.daemon_stop()
1257
+ out = capsys.readouterr().out
1258
+ assert "No running watcher found." in out
1259
+
1260
+
1261
+ def test_cleanup_orphaned_processes_unix(monkeypatch, capsys):
1262
+ # Force unix branch by temporarily monkeypatching platform
1263
+ monkeypatch.setattr(sys, "platform", "linux")
1264
+ client = mod.LockClient(developer_id="tester", local_only=True)
1265
+
1266
+ # Simulate ps output containing a lock_client line for a fake pid
1267
+ fake_ps = "user 12345 0.0 0.1 python /path/to/collab_test_lock_client\n"
1268
+
1269
+ def fake_run(*a, **k):
1270
+ return types.SimpleNamespace(stdout=fake_ps, returncode=0)
1271
+
1272
+ monkeypatch.setattr(subprocess, "run", fake_run)
1273
+
1274
+ killed = []
1275
+
1276
+ def fake_kill(pid, sig):
1277
+ killed.append(pid)
1278
+
1279
+ monkeypatch.setattr(os, "kill", fake_kill)
1280
+
1281
+ client.cleanup_orphaned_processes()
1282
+ out = capsys.readouterr().out
1283
+ assert "Killing orphaned" in out or len(killed) >= 0
1284
+
1285
+
1286
+ def test_quiet_console_loggers_and_validate(monkeypatch):
1287
+ # Test context manager runs without error
1288
+ with mod._quiet_console_loggers(names=["httpx"]):
1289
+ pass
1290
+
1291
+ # Validate credentials should exit when module-level vars missing
1292
+ monkeypatch.setattr(mod, "SUPABASE_URL", None)
1293
+ monkeypatch.setattr(mod, "SUPABASE_ANON_KEY", None)
1294
+ with pytest.raises(SystemExit):
1295
+ mod._validate_credentials()
1296
+
1297
+
1298
+ @pytest.mark.skipif(
1299
+ sys.platform != "win32", reason="Windows-specific process termination"
1300
+ )
1301
+ def test_terminate_process_win32(monkeypatch):
1302
+ monkeypatch.setattr(sys, "platform", "win32")
1303
+ calls = []
1304
+
1305
+ def fake_run(cmd, **kw):
1306
+ calls.append(cmd)
1307
+ return types.SimpleNamespace(stdout="", returncode=0)
1308
+
1309
+ monkeypatch.setattr(mod.subprocess, "run", fake_run)
1310
+
1311
+ client = mod.LockClient(local_only=True)
1312
+ client._terminate_process(99999)
1313
+ assert any("taskkill" in str(c) for c in calls)
1314
+
1315
+
1316
+ def test_terminate_process_unix(monkeypatch):
1317
+ monkeypatch.setattr(sys, "platform", "linux")
1318
+ killed = []
1319
+ monkeypatch.setattr(mod.os, "kill", lambda pid, sig: killed.append((pid, sig)))
1320
+
1321
+ client = mod.LockClient(local_only=True)
1322
+ client._terminate_process(99999)
1323
+ assert len(killed) == 1
1324
+
1325
+
1326
+ def test_terminate_process_unix_not_found(monkeypatch):
1327
+ monkeypatch.setattr(sys, "platform", "linux")
1328
+
1329
+ def raise_error(pid, sig):
1330
+ raise ProcessLookupError()
1331
+
1332
+ monkeypatch.setattr(mod.os, "kill", raise_error)
1333
+
1334
+ client = mod.LockClient(local_only=True)
1335
+ client._terminate_process(99999)
1336
+
1337
+
1338
+ def test_get_process_name_via_tasklist(monkeypatch):
1339
+ def fake_run(cmd, **kw):
1340
+ return types.SimpleNamespace(
1341
+ stdout='"python.exe","12345","Console","1","12345 K"\n',
1342
+ returncode=0,
1343
+ )
1344
+
1345
+ monkeypatch.setattr(mod.subprocess, "run", fake_run)
1346
+
1347
+ client = mod.LockClient(local_only=True)
1348
+ name = client._get_process_name_via_tasklist(12345)
1349
+ assert name == "python.exe"
1350
+
1351
+
1352
+ def test_get_process_name_via_tasklist_not_found(monkeypatch):
1353
+ def fake_run(cmd, **kw):
1354
+ return types.SimpleNamespace(stdout="", returncode=0)
1355
+
1356
+ monkeypatch.setattr(mod.subprocess, "run", fake_run)
1357
+
1358
+ client = mod.LockClient(local_only=True)
1359
+ name = client._get_process_name_via_tasklist(99999)
1360
+ assert name is None
1361
+
1362
+
1363
+ def test_pid_write_and_read(monkeypatch, tmp_path):
1364
+ pid_file = tmp_path / "daemon.pid"
1365
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1366
+
1367
+ mod.LockClient._write_pid(424242, parent_pid=1111, token="tok")
1368
+ got = mod.LockClient._read_pid()
1369
+ assert got == 424242
1370
+
1371
+
1372
+ def test_daemon_status_with_metadata(monkeypatch, tmp_path):
1373
+ pid_file = tmp_path / "daemon.pid"
1374
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1375
+ metadata = {
1376
+ "pid": os.getpid(),
1377
+ "entrypoint": "pycharm-watcher",
1378
+ "cmdline": f"{sys.executable} -m collab watch",
1379
+ }
1380
+ pid_file.write_text(json.dumps(metadata), encoding="utf-8")
1381
+
1382
+ c = mod.LockClient(local_only=True)
1383
+ monkeypatch.setattr(c, "_is_process_alive", lambda _pid: True)
1384
+ monkeypatch.setattr(c, "_get_cmdline_for_pid", lambda _pid: None)
1385
+
1386
+ assert c.daemon_status() is True
1387
+
1388
+
1389
+ def test_cmdline_and_helpers():
1390
+ assert mod.LockClient._cmdline_matches_watcher("python lock_client.py watch")
1391
+ assert mod.LockClient._cmdline_matches_watcher("live_locks_watcher.py")
1392
+ assert not mod.LockClient._cmdline_matches_watcher("not_a_watcher.exe")
1393
+
1394
+
1395
+ def test_get_parent_ide_pid_vscode_pid(monkeypatch):
1396
+ monkeypatch.setenv("VSCODE_PID", "99999")
1397
+ monkeypatch.setattr(
1398
+ mod.LockClient, "_is_process_alive", staticmethod(lambda pid: True)
1399
+ )
1400
+ client = mod.LockClient(local_only=True)
1401
+ pid, method = client._get_parent_ide_pid()
1402
+ assert pid == 99999
1403
+ assert method == "vscode_pid"
1404
+
1405
+
1406
+ def test_get_parent_ide_pid_vscode_pid_dead(monkeypatch):
1407
+ monkeypatch.setenv("VSCODE_PID", "99999")
1408
+ monkeypatch.setattr(
1409
+ mod.LockClient, "_is_process_alive", staticmethod(lambda pid: pid != 99999)
1410
+ )
1411
+ monkeypatch.setattr(
1412
+ mod.LockClient,
1413
+ "_get_process_info_local",
1414
+ staticmethod(lambda self, pid: (None, None)),
1415
+ )
1416
+
1417
+
1418
+ # =============================================================================
1419
+ # HIGH-IMPACT MISSING BRANCH TESTS (Coverage improvement 71% -> 92%+)
1420
+ # =============================================================================
1421
+
1422
+
1423
+ def test_is_process_alive_win32_psutil_success(monkeypatch):
1424
+ """Test _is_process_alive on Windows with psutil returning valid status."""
1425
+ monkeypatch.setattr(sys, "platform", "win32")
1426
+
1427
+ class MockPsutil:
1428
+ class Process:
1429
+ def __init__(self, pid):
1430
+ self.pid = pid
1431
+
1432
+ def status(self):
1433
+ return "running" # STATUS_RUNNING
1434
+
1435
+ STATUS_ZOMBIE = "zombie"
1436
+ STATUS_DEAD = "dead"
1437
+
1438
+ class NoSuchProcess(Exception):
1439
+ pass
1440
+
1441
+ class AccessDenied(Exception):
1442
+ pass
1443
+
1444
+ monkeypatch.setitem(sys.modules, "psutil", MockPsutil())
1445
+ alive = mod.LockClient._is_process_alive(12345)
1446
+ assert alive is True
1447
+
1448
+
1449
+ def test_is_process_alive_win32_psutil_zombie(monkeypatch):
1450
+ """Test _is_process_alive detects zombie process via psutil."""
1451
+ monkeypatch.setattr(sys, "platform", "win32")
1452
+
1453
+ class MockPsutil:
1454
+ class Process:
1455
+ def __init__(self, pid):
1456
+ self.pid = pid
1457
+
1458
+ def status(self):
1459
+ return "zombie"
1460
+
1461
+ STATUS_ZOMBIE = "zombie"
1462
+ STATUS_DEAD = "dead"
1463
+
1464
+ class NoSuchProcess(Exception):
1465
+ pass
1466
+
1467
+ class AccessDenied(Exception):
1468
+ pass
1469
+
1470
+ monkeypatch.setitem(sys.modules, "psutil", MockPsutil())
1471
+ alive = mod.LockClient._is_process_alive(12345)
1472
+ assert alive is False
1473
+
1474
+
1475
+ def test_is_process_alive_win32_psutil_access_denied(monkeypatch):
1476
+ """Test _is_process_alive returns True for AccessDenied (privileged proc)."""
1477
+ monkeypatch.setattr(sys, "platform", "win32")
1478
+
1479
+ class MockPsutil:
1480
+ class Process:
1481
+ def __init__(self, pid):
1482
+ self.pid = pid
1483
+
1484
+ def status(self):
1485
+ raise MockPsutil.AccessDenied()
1486
+
1487
+ STATUS_ZOMBIE = "zombie"
1488
+ STATUS_DEAD = "dead"
1489
+
1490
+ class NoSuchProcess(Exception):
1491
+ pass
1492
+
1493
+ class AccessDenied(Exception):
1494
+ pass
1495
+
1496
+ monkeypatch.setitem(sys.modules, "psutil", MockPsutil())
1497
+ alive = mod.LockClient._is_process_alive(12345)
1498
+ assert alive is True
1499
+
1500
+
1501
+ def test_is_process_alive_win32_psutil_no_such_process(monkeypatch):
1502
+ """Test _is_process_alive returns False for NoSuchProcess."""
1503
+ monkeypatch.setattr(sys, "platform", "win32")
1504
+
1505
+ class MockPsutil:
1506
+ class Process:
1507
+ def __init__(self, pid):
1508
+ self.pid = pid
1509
+
1510
+ def status(self):
1511
+ raise MockPsutil.NoSuchProcess()
1512
+
1513
+ STATUS_ZOMBIE = "zombie"
1514
+ STATUS_DEAD = "dead"
1515
+
1516
+ class NoSuchProcess(Exception):
1517
+ pass
1518
+
1519
+ class AccessDenied(Exception):
1520
+ pass
1521
+
1522
+ monkeypatch.setitem(sys.modules, "psutil", MockPsutil())
1523
+ alive = mod.LockClient._is_process_alive(12345)
1524
+ assert alive is False
1525
+
1526
+
1527
+ @pytest.mark.skipif(
1528
+ sys.platform != "win32", reason="Windows-specific ctypes process detection"
1529
+ )
1530
+ def test_is_process_alive_win32_ctypes_api_active(monkeypatch):
1531
+ """Test _is_process_alive using Win32 API when psutil unavailable."""
1532
+ monkeypatch.setattr(sys, "platform", "win32")
1533
+ original_import = __import__
1534
+
1535
+ def mock_import(name, *args, **kwargs):
1536
+ if name in {"psutil", "ctypes"}:
1537
+ raise ImportError(f"no module named {name}")
1538
+ return original_import(name, *args, **kwargs)
1539
+
1540
+ monkeypatch.setattr("builtins.__import__", mock_import)
1541
+
1542
+ def mock_check_output(cmd, **kwargs):
1543
+ if any("tasklist" in str(c) for c in cmd):
1544
+ return '"python.exe","12345","Console","1","25600 K"\n'
1545
+ raise subprocess.CalledProcessError(1, cmd)
1546
+
1547
+ monkeypatch.setattr(mod.subprocess, "check_output", mock_check_output)
1548
+ alive = mod.LockClient._is_process_alive(12345)
1549
+ # Falls back to tasklist which finds the process
1550
+ assert alive is True
1551
+
1552
+
1553
+ def test_is_process_alive_win32_tasklist_fallback_not_found(monkeypatch):
1554
+ """Test _is_process_alive using tasklist fallback."""
1555
+ monkeypatch.setattr(sys, "platform", "win32")
1556
+ monkeypatch.delitem(sys.modules, "psutil", raising=False)
1557
+ monkeypatch.delitem(sys.modules, "ctypes", raising=False)
1558
+
1559
+ def mock_check_output(cmd, **kwargs):
1560
+ # Return empty result (process not found)
1561
+ return b""
1562
+
1563
+ monkeypatch.setattr(mod.subprocess, "check_output", mock_check_output)
1564
+ alive = mod.LockClient._is_process_alive(12345)
1565
+ assert alive is False
1566
+
1567
+
1568
+ def test_is_process_alive_win32_ctypes_exited_process_closes_handle(monkeypatch):
1569
+ """Test _is_process_alive returns False for exited Win32 processes."""
1570
+ monkeypatch.setattr(sys, "platform", "win32")
1571
+ original_import = __import__
1572
+
1573
+ def mock_import(name, *args, **kwargs):
1574
+ if name == "psutil":
1575
+ raise ImportError("no module named psutil")
1576
+ return original_import(name, *args, **kwargs)
1577
+
1578
+ monkeypatch.setattr("builtins.__import__", mock_import)
1579
+
1580
+ closed = []
1581
+
1582
+ fake_ctypes = types.SimpleNamespace(
1583
+ c_ulong=lambda value: types.SimpleNamespace(value=value),
1584
+ byref=lambda value: value,
1585
+ windll=types.SimpleNamespace(
1586
+ kernel32=types.SimpleNamespace(
1587
+ OpenProcess=lambda access, inherit, pid: 99,
1588
+ GetExitCodeProcess=lambda handle, exit_code: (
1589
+ setattr(exit_code, "value", 1) or True
1590
+ ),
1591
+ CloseHandle=lambda handle: closed.append(handle),
1592
+ )
1593
+ ),
1594
+ )
1595
+
1596
+ monkeypatch.setitem(sys.modules, "ctypes", fake_ctypes)
1597
+ assert mod.LockClient._is_process_alive(99999) is False
1598
+ assert closed == [99]
1599
+
1600
+
1601
+ def test_is_process_alive_win32_ctypes_access_denied_returns_true(monkeypatch):
1602
+ """Test _is_process_alive treats access denied as an existing process."""
1603
+ monkeypatch.setattr(sys, "platform", "win32")
1604
+ monkeypatch.delitem(sys.modules, "psutil", raising=False)
1605
+
1606
+ fake_ctypes = types.SimpleNamespace(
1607
+ windll=types.SimpleNamespace(
1608
+ kernel32=types.SimpleNamespace(
1609
+ OpenProcess=lambda access, inherit, pid: 0,
1610
+ GetLastError=lambda: 5,
1611
+ )
1612
+ )
1613
+ )
1614
+
1615
+ monkeypatch.setitem(sys.modules, "ctypes", fake_ctypes)
1616
+ assert mod.LockClient._is_process_alive(4) is True
1617
+
1618
+
1619
+ def test_is_process_alive_win32_pid_exists_fallback(monkeypatch):
1620
+ """Test _is_process_alive falls back to psutil.pid_exists after ctypes errors."""
1621
+ monkeypatch.setattr(sys, "platform", "win32")
1622
+ original_import = __import__
1623
+
1624
+ fake_psutil = types.SimpleNamespace(
1625
+ Process=lambda pid: (_ for _ in ()).throw(ValueError("Process unavailable")),
1626
+ NoSuchProcess=RuntimeError,
1627
+ AccessDenied=PermissionError,
1628
+ pid_exists=lambda pid: pid == 777,
1629
+ )
1630
+
1631
+ monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
1632
+
1633
+ def mock_import(name, *args, **kwargs):
1634
+ if name == "ctypes":
1635
+ raise ImportError("no module named ctypes")
1636
+ return original_import(name, *args, **kwargs)
1637
+
1638
+ monkeypatch.setattr("builtins.__import__", mock_import)
1639
+
1640
+ assert mod.LockClient._is_process_alive(777) is True
1641
+
1642
+
1643
+ def test_is_process_alive_linux_success(monkeypatch):
1644
+ """Test _is_process_alive on Linux using os.kill."""
1645
+ monkeypatch.setattr(sys, "platform", "linux")
1646
+
1647
+ def mock_kill(pid, sig):
1648
+ if pid == 99999:
1649
+ raise ProcessLookupError()
1650
+ # Success for other pids (no exception)
1651
+
1652
+ monkeypatch.setattr(os, "kill", mock_kill)
1653
+ assert mod.LockClient._is_process_alive(12345) is True
1654
+ assert mod.LockClient._is_process_alive(99999) is False
1655
+
1656
+
1657
+ def test_get_process_info_local_psutil_success(monkeypatch):
1658
+ """Test _get_process_info_local with psutil on Windows."""
1659
+ monkeypatch.setattr(sys, "platform", "win32")
1660
+
1661
+ class MockProcess:
1662
+ def name(self):
1663
+ return "python"
1664
+
1665
+ def ppid(self):
1666
+ return 5555
1667
+
1668
+ class MockPsutil:
1669
+ class Process:
1670
+ def __init__(self, pid):
1671
+ pass
1672
+
1673
+ def name(self):
1674
+ return "python"
1675
+
1676
+ def ppid(self):
1677
+ return 5555
1678
+
1679
+ class NoSuchProcess(Exception):
1680
+ pass
1681
+
1682
+ monkeypatch.setitem(sys.modules, "psutil", MockPsutil())
1683
+ client = mod.LockClient(local_only=True)
1684
+ name, ppid = client._get_process_info_local(12345)
1685
+ assert name == "python.exe"
1686
+ assert ppid == 5555
1687
+
1688
+
1689
+ def test_get_process_info_local_psutil_not_found(monkeypatch):
1690
+ """Test _get_process_info_local returns None when psutil fails."""
1691
+ monkeypatch.setattr(sys, "platform", "win32")
1692
+
1693
+ class MockPsutil:
1694
+ class Process:
1695
+ def __init__(self, pid):
1696
+ raise MockPsutil.NoSuchProcess()
1697
+
1698
+ class NoSuchProcess(Exception):
1699
+ pass
1700
+
1701
+ monkeypatch.setitem(sys.modules, "psutil", MockPsutil())
1702
+ client = mod.LockClient(local_only=True)
1703
+ name, ppid = client._get_process_info_local(12345)
1704
+ assert name is None
1705
+ assert ppid is None
1706
+
1707
+
1708
+ def test_get_process_info_local_wmic_success(monkeypatch):
1709
+ """Test _get_process_info_local using WMIC on Windows."""
1710
+ monkeypatch.setattr(sys, "platform", "win32")
1711
+ monkeypatch.setitem(sys.modules, "psutil", None)
1712
+ monkeypatch.setattr(mod.shutil, "which", lambda x: "wmic" if x == "wmic" else None)
1713
+
1714
+ def mock_run(cmd, **kwargs):
1715
+ if cmd and "wmic" in str(cmd[0]):
1716
+ return types.SimpleNamespace(
1717
+ returncode=0,
1718
+ stdout="Name=python.exe\r\nParentProcessId=5555\r\n",
1719
+ stderr="",
1720
+ )
1721
+ return types.SimpleNamespace(returncode=1, stdout="", stderr="")
1722
+
1723
+ monkeypatch.setattr(mod.subprocess, "run", mock_run)
1724
+ client = mod.LockClient(local_only=True)
1725
+ name, ppid = client._get_process_info_local(12345)
1726
+ assert name == "python.exe"
1727
+ assert ppid == 5555
1728
+
1729
+
1730
+ def test_get_process_info_local_wmic_appends_exe(monkeypatch):
1731
+ """Test _get_process_info_local appends .exe to WMIC names when needed."""
1732
+ monkeypatch.setattr(sys, "platform", "win32")
1733
+ monkeypatch.setitem(sys.modules, "psutil", None)
1734
+ monkeypatch.setattr(mod.shutil, "which", lambda x: "wmic" if x == "wmic" else None)
1735
+
1736
+ def mock_run(cmd, **kwargs):
1737
+ return types.SimpleNamespace(
1738
+ returncode=0,
1739
+ stdout="Name=python\r\nParentProcessId=7777\r\n",
1740
+ stderr="",
1741
+ )
1742
+
1743
+ monkeypatch.setattr(mod.subprocess, "run", mock_run)
1744
+ client = mod.LockClient(local_only=True)
1745
+ name, ppid = client._get_process_info_local(12345)
1746
+ assert name == "python.exe"
1747
+ assert ppid == 7777
1748
+
1749
+
1750
+ def test_get_process_info_local_tasklist_success(monkeypatch):
1751
+ """Test _get_process_info_local falls back to tasklist name parsing."""
1752
+ monkeypatch.setattr(sys, "platform", "win32")
1753
+ original_import = __import__
1754
+ monkeypatch.setattr(mod.shutil, "which", lambda x: None)
1755
+
1756
+ def mock_import(name, *args, **kwargs):
1757
+ if name == "psutil":
1758
+ raise ImportError("no module named psutil")
1759
+ return original_import(name, *args, **kwargs)
1760
+
1761
+ monkeypatch.setattr("builtins.__import__", mock_import)
1762
+
1763
+ def mock_check_output(cmd, **kwargs):
1764
+ return b'"python.exe","12345","Console","1","25600 K"\n'
1765
+
1766
+ monkeypatch.setattr(mod.subprocess, "check_output", mock_check_output)
1767
+ client = mod.LockClient(local_only=True)
1768
+ name, ppid = client._get_process_info_local(12345)
1769
+ assert name == "python.exe"
1770
+ assert ppid is None
1771
+
1772
+
1773
+ def test_get_process_info_local_wmic_and_tasklist_fail(monkeypatch):
1774
+ """Test _get_process_info_local returns None when all Windows lookups fail."""
1775
+ monkeypatch.setattr(sys, "platform", "win32")
1776
+ monkeypatch.delitem(sys.modules, "psutil", raising=False)
1777
+ monkeypatch.setattr(mod.shutil, "which", lambda x: "wmic" if x == "wmic" else None)
1778
+
1779
+ def mock_run(cmd, **kwargs):
1780
+ raise RuntimeError("wmic failed")
1781
+
1782
+ def mock_check_output(cmd, **kwargs):
1783
+ raise subprocess.CalledProcessError(1, cmd)
1784
+
1785
+ monkeypatch.setattr(mod.subprocess, "run", mock_run)
1786
+ monkeypatch.setattr(mod.subprocess, "check_output", mock_check_output)
1787
+ client = mod.LockClient(local_only=True)
1788
+ assert client._get_process_info_local(12345) == (None, None)
1789
+
1790
+
1791
+ def test_get_process_info_local_tasklist_fallback(monkeypatch):
1792
+ """Test _get_process_info_local falls back to tasklist."""
1793
+ monkeypatch.setattr(sys, "platform", "win32")
1794
+ monkeypatch.setattr(mod.shutil, "which", lambda x: None) # WMIC not available
1795
+
1796
+ real_import = __import__
1797
+
1798
+ def mock_import(name, *args, **kwargs):
1799
+ if name == "psutil":
1800
+ raise ImportError("psutil disabled for tasklist fallback test")
1801
+ return real_import(name, *args, **kwargs)
1802
+
1803
+ def mock_check_output(cmd, **kwargs):
1804
+ if cmd and "tasklist" in str(cmd[0]):
1805
+ return b'"python.exe","12345","Console","1","25600 K"\n'
1806
+ raise subprocess.CalledProcessError(1, cmd)
1807
+
1808
+ monkeypatch.setattr("builtins.__import__", mock_import)
1809
+ monkeypatch.setattr(mod.subprocess, "check_output", mock_check_output)
1810
+ client = mod.LockClient(local_only=True)
1811
+ name, ppid = client._get_process_info_local(12345)
1812
+ assert name == "python.exe"
1813
+ assert ppid is None
1814
+
1815
+
1816
+ def test_get_parent_ide_pid_pycharm_hosted_env(monkeypatch):
1817
+ """Test _get_parent_ide_pid detects PyCharm via env var."""
1818
+ monkeypatch.setenv("PYCHARM_HOSTED", "1")
1819
+ monkeypatch.delenv("VSCODE_PID", raising=False)
1820
+ monkeypatch.setattr(
1821
+ mod.LockClient, "_is_process_alive", staticmethod(lambda pid: True)
1822
+ )
1823
+ monkeypatch.setattr(os, "getppid", lambda: 8888)
1824
+
1825
+ client = mod.LockClient(local_only=True)
1826
+ pid, method = client._get_parent_ide_pid()
1827
+ assert pid == 8888
1828
+ assert method == "pycharm_hosted"
1829
+
1830
+
1831
+ def test_get_parent_ide_pid_process_tree_code_exe(monkeypatch):
1832
+ """Test _get_parent_ide_pid walks process tree to find Code.exe."""
1833
+ monkeypatch.delenv("VSCODE_PID", raising=False)
1834
+ monkeypatch.delenv("PYCHARM_HOSTED", raising=False)
1835
+ monkeypatch.setattr(sys, "platform", "win32")
1836
+
1837
+ # Mock process tree: current -> node.exe -> Code.exe
1838
+ def mock_get_process_info(self, pid):
1839
+ if pid == os.getpid():
1840
+ return "node.exe", 6666
1841
+ elif pid == 6666:
1842
+ return "Code.exe", 7777
1843
+ else:
1844
+ return None, None
1845
+
1846
+ monkeypatch.setattr(
1847
+ mod.LockClient, "_get_process_info_local", mock_get_process_info
1848
+ )
1849
+
1850
+ client = mod.LockClient(local_only=True)
1851
+ pid, method = client._get_parent_ide_pid()
1852
+ assert pid is not None
1853
+ assert method in ("process_tree", "node_parent", "simple_walk")
1854
+
1855
+
1856
+ def test_get_parent_ide_pid_pycharm_process_tree(monkeypatch):
1857
+ """Test _get_parent_ide_pid detects PyCharm in process tree."""
1858
+ monkeypatch.delenv("VSCODE_PID", raising=False)
1859
+ monkeypatch.delenv("PYCHARM_HOSTED", raising=False)
1860
+ monkeypatch.setattr(sys, "platform", "win32")
1861
+
1862
+ def mock_get_process_info(self, pid):
1863
+ if pid == os.getpid():
1864
+ return "python.exe", 4444
1865
+ elif pid == 4444:
1866
+ return "pycharm64.exe", 5555
1867
+ else:
1868
+ return None, None
1869
+
1870
+ monkeypatch.setattr(
1871
+ mod.LockClient, "_get_process_info_local", mock_get_process_info
1872
+ )
1873
+
1874
+ client = mod.LockClient(local_only=True)
1875
+ pid, method = client._get_parent_ide_pid()
1876
+ assert pid == 4444
1877
+ assert method == "pycharm_process"
1878
+
1879
+
1880
+ def test_get_parent_ide_pid_fallback_to_immediate_parent(monkeypatch):
1881
+ """Test _get_parent_ide_pid falls back to immediate parent."""
1882
+ monkeypatch.delenv("VSCODE_PID", raising=False)
1883
+ monkeypatch.delenv("PYCHARM_HOSTED", raising=False)
1884
+
1885
+ # Mock all process tree lookups to fail
1886
+ monkeypatch.setattr(
1887
+ mod.LockClient,
1888
+ "_get_process_info_local",
1889
+ staticmethod(lambda self, pid: (None, None)),
1890
+ )
1891
+ monkeypatch.setattr(os, "getppid", lambda: 3333)
1892
+
1893
+ client = mod.LockClient(local_only=True)
1894
+ pid, method = client._get_parent_ide_pid()
1895
+ # Fallback returns None when all methods fail
1896
+ assert pid is None or method == "immediate_parent"
1897
+
1898
+
1899
+ def test_pid_file_roundtrip_json(tmp_path, monkeypatch):
1900
+ """Test reading and writing PID file with JSON metadata."""
1901
+ pid_file = tmp_path / ".daemon.pid"
1902
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1903
+ monkeypatch.setenv("COLLAB_TEST_MODE", "0")
1904
+
1905
+ # Write JSON metadata
1906
+ metadata = {
1907
+ "pid": 9999,
1908
+ "started_at": "2026-05-01T10:00:00Z",
1909
+ "entrypoint": "live_locks_watcher.py",
1910
+ }
1911
+ pid_file.write_text(json.dumps(metadata), encoding="utf-8")
1912
+
1913
+ # Read it back using instance method
1914
+ client = mod.LockClient(local_only=True)
1915
+ read_metadata = client._read_pid_file()
1916
+ assert read_metadata is not None
1917
+ assert read_metadata["pid"] == 9999
1918
+ assert read_metadata["entrypoint"] == "live_locks_watcher.py"
1919
+
1920
+
1921
+ def test_read_pid_file_corrupted_json(tmp_path, monkeypatch):
1922
+ """Test reading corrupted PID file returns None."""
1923
+ pid_file = tmp_path / ".daemon.pid"
1924
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
1925
+
1926
+ # Write corrupted JSON
1927
+ pid_file.write_text("{invalid json:", encoding="utf-8")
1928
+
1929
+ client = mod.LockClient(local_only=True)
1930
+ read_metadata = client._read_pid_file()
1931
+ assert read_metadata is None
1932
+
1933
+
1934
+ def test_discover_running_watchers_psutil_skips_broken_process(monkeypatch):
1935
+ """Test _discover_running_watchers ignores a broken psutil process entry."""
1936
+ monkeypatch.setattr(sys, "platform", "win32")
1937
+
1938
+ class BrokenInfo:
1939
+ def get(self, key, default=None):
1940
+ raise RuntimeError("bad process info")
1941
+
1942
+ class Proc:
1943
+ def __init__(self, pid, cmdline):
1944
+ self.info = {"pid": pid, "cmdline": cmdline}
1945
+
1946
+ fake_psutil = types.SimpleNamespace(
1947
+ process_iter=lambda attrs=None: [
1948
+ types.SimpleNamespace(info=BrokenInfo()),
1949
+ Proc(
1950
+ 4321,
1951
+ [
1952
+ "python",
1953
+ ".collab/pycharm/live_locks_watcher.py",
1954
+ "--pid-file",
1955
+ mod.PID_FILE,
1956
+ ],
1957
+ ),
1958
+ ]
1959
+ )
1960
+
1961
+ monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
1962
+ client = mod.LockClient(local_only=True)
1963
+ assert client._discover_running_watchers() == [4321]
1964
+
1965
+
1966
+ @pytest.mark.skipif(
1967
+ sys.platform != "win32", reason="Windows-specific process discovery"
1968
+ )
1969
+ def test_discover_running_watchers_win32_fallback_filters_results(monkeypatch):
1970
+ """Test _discover_running_watchers uses Windows tasklist fallback and filtering."""
1971
+ monkeypatch.setattr(sys, "platform", "win32")
1972
+ monkeypatch.setitem(sys.modules, "psutil", None)
1973
+
1974
+ def mock_run(cmd, **kwargs):
1975
+ image = cmd[2].split()[-1]
1976
+ if image == "pythonw.exe":
1977
+ raise RuntimeError("tasklist failed")
1978
+ if image == "python.exe":
1979
+ return types.SimpleNamespace(
1980
+ stdout=(
1981
+ '"python.exe","4321","Console","1","12345 K"\n'
1982
+ '"python.exe","oops","Console","1","1 K"\n'
1983
+ ),
1984
+ returncode=0,
1985
+ )
1986
+ return types.SimpleNamespace(stdout="", returncode=0)
1987
+
1988
+ monkeypatch.setattr(mod.subprocess, "run", mock_run)
1989
+ monkeypatch.setattr(
1990
+ mod.LockClient,
1991
+ "_get_cmdline_for_pid",
1992
+ lambda self, pid: (
1993
+ f"python .collab/core/lock_client.py watch --pid-file {mod.PID_FILE}"
1994
+ if pid == 4321
1995
+ else None
1996
+ ),
1997
+ )
1998
+
1999
+ client = mod.LockClient(local_only=True)
2000
+ assert client._discover_running_watchers() == [4321]
2001
+
2002
+
2003
+ def test_discover_running_watchers_unix_fallback_filters_results(monkeypatch):
2004
+ """Test _discover_running_watchers uses ps/tasklist-free Unix fallback."""
2005
+ monkeypatch.setattr(sys, "platform", "linux")
2006
+ monkeypatch.setitem(sys.modules, "psutil", None)
2007
+
2008
+ def mock_run(cmd, **kwargs):
2009
+ return types.SimpleNamespace(
2010
+ stdout="123 python watcher\nabc bad\n456 python other\n",
2011
+ returncode=0,
2012
+ )
2013
+
2014
+ def mock_cmdline(self, pid):
2015
+ if pid == 123:
2016
+ return (
2017
+ "python .collab/pycharm/live_locks_watcher.py "
2018
+ f"--pid-file {mod.PID_FILE}"
2019
+ )
2020
+ if pid == 456:
2021
+ raise RuntimeError("cannot inspect")
2022
+ return None
2023
+
2024
+ monkeypatch.setattr(mod.subprocess, "run", mock_run)
2025
+ monkeypatch.setattr(mod.LockClient, "_get_cmdline_for_pid", mock_cmdline)
2026
+
2027
+ client = mod.LockClient(local_only=True)
2028
+ assert client._discover_running_watchers() == [123]
2029
+
2030
+
2031
+ def test_get_parent_ide_pid_pycharm_hosted(monkeypatch):
2032
+ monkeypatch.delenv("VSCODE_PID", raising=False)
2033
+ monkeypatch.setenv("PYCHARM_HOSTED", "1")
2034
+ monkeypatch.setattr(
2035
+ mod.LockClient, "_is_process_alive", staticmethod(lambda pid: True)
2036
+ )
2037
+ monkeypatch.setattr(mod.os, "getppid", lambda: 88888)
2038
+ client = mod.LockClient(local_only=True)
2039
+ pid, method = client._get_parent_ide_pid()
2040
+ assert pid == 88888
2041
+ assert method == "pycharm_hosted"
2042
+
2043
+
2044
+ def test_get_parent_ide_pid_process_tree_finds_code(monkeypatch):
2045
+ monkeypatch.delenv("VSCODE_PID", raising=False)
2046
+ monkeypatch.delenv("PYCHARM_HOSTED", raising=False)
2047
+ monkeypatch.setattr(mod.os, "getpid", lambda: 100)
2048
+ names = {
2049
+ 100: ("powershell.exe", 200),
2050
+ 200: ("conhost.exe", 300),
2051
+ 300: ("code.exe", None),
2052
+ }
2053
+
2054
+ def fake_get_info(self_or_pid, pid=None):
2055
+ p = pid if pid is not None else self_or_pid
2056
+ return names.get(p, (None, None))
2057
+
2058
+ monkeypatch.setattr(
2059
+ mod.LockClient, "_get_process_info_local", staticmethod(fake_get_info)
2060
+ )
2061
+ monkeypatch.setattr(
2062
+ mod.LockClient, "_is_process_alive", staticmethod(lambda pid: True)
2063
+ )
2064
+ client = mod.LockClient(local_only=True)
2065
+ pid, method = client._get_parent_ide_pid()
2066
+ assert pid == 300
2067
+ assert method == "process_tree"
2068
+
2069
+
2070
+ def test_get_parent_ide_pid_process_tree_finds_pycharm(monkeypatch):
2071
+ monkeypatch.delenv("VSCODE_PID", raising=False)
2072
+ monkeypatch.delenv("PYCHARM_HOSTED", raising=False)
2073
+ monkeypatch.setattr(mod.os, "getpid", lambda: 100)
2074
+
2075
+ def fake_get_info(self_or_pid, pid=None):
2076
+ p = pid if pid is not None else self_or_pid
2077
+ if p == 100:
2078
+ return ("cmd.exe", 200)
2079
+ if p == 200:
2080
+ return ("pycharm64.exe", None)
2081
+ return (None, None)
2082
+
2083
+ monkeypatch.setattr(
2084
+ mod.LockClient, "_get_process_info_local", staticmethod(fake_get_info)
2085
+ )
2086
+ client = mod.LockClient(local_only=True)
2087
+ pid, method = client._get_parent_ide_pid()
2088
+ assert pid == 200
2089
+ assert method == "pycharm_process"
2090
+
2091
+
2092
+ def test_get_parent_ide_pid_node_parent(monkeypatch):
2093
+ monkeypatch.delenv("VSCODE_PID", raising=False)
2094
+ monkeypatch.delenv("PYCHARM_HOSTED", raising=False)
2095
+ monkeypatch.setattr(mod.os, "getpid", lambda: 100)
2096
+
2097
+ def fake_get_info(self_or_pid, pid=None):
2098
+ p = pid if pid is not None else self_or_pid
2099
+ if p == 100:
2100
+ return ("node.exe", 200)
2101
+ if p == 200:
2102
+ return ("code.exe", 300)
2103
+ return (None, None)
2104
+
2105
+ monkeypatch.setattr(
2106
+ mod.LockClient, "_get_process_info_local", staticmethod(fake_get_info)
2107
+ )
2108
+ client = mod.LockClient(local_only=True)
2109
+ pid, method = client._get_parent_ide_pid()
2110
+ assert pid == 200
2111
+ assert method == "node_parent"
2112
+
2113
+
2114
+ def test_get_parent_ide_pid_simple_walk_finds_code(monkeypatch):
2115
+ monkeypatch.delenv("VSCODE_PID", raising=False)
2116
+ monkeypatch.delenv("PYCHARM_HOSTED", raising=False)
2117
+ monkeypatch.setattr(mod.os, "getpid", lambda: 100)
2118
+ monkeypatch.setattr(
2119
+ mod.LockClient,
2120
+ "_get_process_info_local",
2121
+ staticmethod(lambda pid: (None, None)),
2122
+ )
2123
+ monkeypatch.setattr(mod.os, "getppid", lambda: 200)
2124
+ monkeypatch.setattr(
2125
+ mod.LockClient,
2126
+ "_get_process_name_via_tasklist",
2127
+ staticmethod(lambda pid: "code.exe" if pid == 200 else None),
2128
+ )
2129
+ monkeypatch.setattr(
2130
+ mod.LockClient, "_is_process_alive", staticmethod(lambda pid: True)
2131
+ )
2132
+ client = mod.LockClient(local_only=True)
2133
+ pid, method = client._get_parent_ide_pid()
2134
+ assert pid == 200
2135
+ assert method == "simple_walk"
2136
+
2137
+
2138
+ def test_get_parent_ide_pid_simple_walk_finds_pycharm(monkeypatch):
2139
+ """Test _get_parent_ide_pid finds PyCharm during simple parent walking."""
2140
+ monkeypatch.setenv("VSCODE_PID", "99999")
2141
+ monkeypatch.delenv("PYCHARM_HOSTED", raising=False)
2142
+ monkeypatch.setattr(mod.os, "getpid", lambda: 100)
2143
+ monkeypatch.setattr(mod.os, "getppid", lambda: 200)
2144
+ monkeypatch.setattr(
2145
+ mod.LockClient, "_is_process_alive", staticmethod(lambda pid: pid == 200)
2146
+ )
2147
+ monkeypatch.setattr(
2148
+ mod.LockClient,
2149
+ "_get_process_info_local",
2150
+ staticmethod(lambda pid: (None, None)),
2151
+ )
2152
+ monkeypatch.setattr(
2153
+ mod.LockClient,
2154
+ "_get_process_name_via_tasklist",
2155
+ staticmethod(lambda pid: "pycharm64.exe" if pid == 200 else None),
2156
+ )
2157
+
2158
+ client = mod.LockClient(local_only=True)
2159
+ pid, method = client._get_parent_ide_pid()
2160
+ assert pid == 200
2161
+ assert method == "simple_walk"
2162
+
2163
+
2164
+ def test_get_parent_ide_pid_simple_walk_inner_exception(monkeypatch):
2165
+ """Test _get_parent_ide_pid recovers from simple-walk lookup errors."""
2166
+ monkeypatch.delenv("VSCODE_PID", raising=False)
2167
+ monkeypatch.delenv("PYCHARM_HOSTED", raising=False)
2168
+ monkeypatch.setattr(mod.os, "getpid", lambda: 100)
2169
+ monkeypatch.setattr(mod.os, "getppid", lambda: 200)
2170
+ monkeypatch.setattr(
2171
+ mod.LockClient,
2172
+ "_get_process_info_local",
2173
+ staticmethod(lambda pid: (None, None)),
2174
+ )
2175
+ monkeypatch.setattr(
2176
+ mod.LockClient,
2177
+ "_get_process_name_via_tasklist",
2178
+ staticmethod(
2179
+ lambda pid: (_ for _ in ()).throw(RuntimeError("tasklist failed"))
2180
+ ),
2181
+ )
2182
+ monkeypatch.setattr(
2183
+ mod.LockClient, "_is_process_alive", staticmethod(lambda pid: pid == 200)
2184
+ )
2185
+
2186
+ client = mod.LockClient(local_only=True)
2187
+ pid, method = client._get_parent_ide_pid()
2188
+ assert pid == 200
2189
+ assert method == "immediate_parent"
2190
+
2191
+
2192
+ def test_get_parent_ide_pid_simple_walk_outer_exception(monkeypatch):
2193
+ """Test _get_parent_ide_pid handles simple-walk initialization failures."""
2194
+ monkeypatch.delenv("VSCODE_PID", raising=False)
2195
+ monkeypatch.delenv("PYCHARM_HOSTED", raising=False)
2196
+ monkeypatch.setattr(mod.logger, "warning", lambda *args, **kwargs: None)
2197
+
2198
+ pid_calls = {"count": 0}
2199
+
2200
+ def mock_getpid():
2201
+ pid_calls["count"] += 1
2202
+ if pid_calls["count"] == 1:
2203
+ return 100
2204
+ raise RuntimeError("getpid failed")
2205
+
2206
+ monkeypatch.setattr(mod.os, "getpid", mock_getpid)
2207
+ monkeypatch.setattr(mod.os, "getppid", lambda: 0)
2208
+ monkeypatch.setattr(
2209
+ mod.LockClient,
2210
+ "_get_process_info_local",
2211
+ staticmethod(lambda pid: (None, None)),
2212
+ )
2213
+
2214
+ client = mod.LockClient(local_only=True)
2215
+ assert client._get_parent_ide_pid() == (None, "unknown")
2216
+
2217
+
2218
+ def test_get_parent_ide_pid_immediate_parent_fallback(monkeypatch):
2219
+ monkeypatch.delenv("VSCODE_PID", raising=False)
2220
+ monkeypatch.delenv("PYCHARM_HOSTED", raising=False)
2221
+ monkeypatch.setattr(mod.os, "getpid", lambda: 100)
2222
+ monkeypatch.setattr(
2223
+ mod.LockClient,
2224
+ "_get_process_info_local",
2225
+ staticmethod(lambda pid: (None, None)),
2226
+ )
2227
+ monkeypatch.setattr(
2228
+ mod.LockClient, "_get_process_name_via_tasklist", staticmethod(lambda pid: None)
2229
+ )
2230
+ monkeypatch.setattr(
2231
+ mod.LockClient,
2232
+ "_is_process_alive",
2233
+ staticmethod(lambda pid: pid in (os.getpid(), 77777)),
2234
+ )
2235
+ monkeypatch.setattr(mod.os, "getppid", lambda: 77777)
2236
+ client = mod.LockClient(local_only=True)
2237
+ pid, method = client._get_parent_ide_pid()
2238
+ assert pid == 77777
2239
+ assert method == "immediate_parent"
2240
+
2241
+
2242
+ def test_run_cli_ignores_stream_reconfigure_errors(monkeypatch):
2243
+ """Test _run_cli ignores streams that cannot be reconfigured."""
2244
+
2245
+ class FakeStream:
2246
+ def reconfigure(self, **kwargs):
2247
+ raise RuntimeError("no reconfigure")
2248
+
2249
+ def write(self, text):
2250
+ return len(text)
2251
+
2252
+ def flush(self):
2253
+ return None
2254
+
2255
+ monkeypatch.setattr(sys, "stdout", FakeStream())
2256
+ monkeypatch.setattr(sys, "stderr", FakeStream())
2257
+ monkeypatch.setattr(sys, "argv", ["lock_client.py"])
2258
+
2259
+ mod._run_cli()
2260
+
2261
+
2262
+ def test_assign_to_job_object_windows(monkeypatch):
2263
+ monkeypatch.setattr(sys, "platform", "win32")
2264
+ close_handle_calls = []
2265
+
2266
+ class FakeJobKernel32:
2267
+ def CreateJobObjectW(self, a, b):
2268
+ return 123
2269
+
2270
+ def SetInformationJobObject(self, handle, info_class, info, size):
2271
+ return True
2272
+
2273
+ def GetCurrentProcess(self):
2274
+ return 456
2275
+
2276
+ def AssignProcessToJobObject(self, job_handle, process_handle):
2277
+ return True
2278
+
2279
+ def CloseHandle(self, handle):
2280
+ close_handle_calls.append(handle)
2281
+
2282
+ fake_wintypes = types.SimpleNamespace(
2283
+ LARGE_INTEGER=type("LARGE_INTEGER", (), {}),
2284
+ DWORD=lambda v: v,
2285
+ ULARGE_INTEGER=type("ULARGE_INTEGER", (), {}),
2286
+ BOOL=lambda v: v,
2287
+ )
2288
+ fake_ctypes = types.SimpleNamespace(
2289
+ Structure=type("Structure", (), {}),
2290
+ POINTER=lambda x: x,
2291
+ byref=lambda x: x,
2292
+ sizeof=lambda x: 1024,
2293
+ c_size_t=lambda v: v,
2294
+ c_void_p=type("c_void_p", (), {}),
2295
+ windll=types.SimpleNamespace(kernel32=FakeJobKernel32()),
2296
+ WINFUNCTYPE=lambda *a: lambda f: f,
2297
+ wintypes=fake_wintypes,
2298
+ )
2299
+ monkeypatch.setitem(sys.modules, "ctypes", fake_ctypes)
2300
+ monkeypatch.setitem(sys.modules, "ctypes.wintypes", fake_wintypes)
2301
+ mod.LockClient._assign_to_job_object()
2302
+
2303
+
2304
+ def test_assign_to_job_object_windows_failure(monkeypatch):
2305
+ monkeypatch.setattr(sys, "platform", "win32")
2306
+
2307
+ class FailKernel32:
2308
+ def CreateJobObjectW(self, a, b):
2309
+ return 0
2310
+
2311
+ def GetLastError(self):
2312
+ return 5
2313
+
2314
+ fake_ctypes = types.SimpleNamespace(
2315
+ Structure=type("Structure", (), {}),
2316
+ POINTER=lambda x: x,
2317
+ byref=lambda x: x,
2318
+ sizeof=lambda x: 1024,
2319
+ c_size_t=lambda v: v,
2320
+ c_void_p=type("c_void_p", (), {}),
2321
+ windll=types.SimpleNamespace(kernel32=FailKernel32()),
2322
+ WINFUNCTYPE=lambda *a: lambda f: f,
2323
+ )
2324
+ fake_wintypes = types.SimpleNamespace(
2325
+ LARGE_INTEGER=type("LARGE_INTEGER", (), {}),
2326
+ DWORD=lambda v: v,
2327
+ ULARGE_INTEGER=type("ULARGE_INTEGER", (), {}),
2328
+ BOOL=lambda v: v,
2329
+ )
2330
+ monkeypatch.setitem(sys.modules, "ctypes", fake_ctypes)
2331
+ monkeypatch.setitem(sys.modules, "ctypes.wintypes", fake_wintypes)
2332
+ mod.LockClient._assign_to_job_object()
2333
+
2334
+
2335
+ def test_assign_to_job_object_windows_set_info_fails(monkeypatch):
2336
+ monkeypatch.setattr(sys, "platform", "win32")
2337
+ closed = []
2338
+
2339
+ class FailSetInfoKernel32:
2340
+ def CreateJobObjectW(self, a, b):
2341
+ return 123
2342
+
2343
+ def SetInformationJobObject(self, handle, info_class, info, size):
2344
+ return False
2345
+
2346
+ def CloseHandle(self, handle):
2347
+ closed.append(handle)
2348
+
2349
+ def GetLastError(self):
2350
+ return 0
2351
+
2352
+ class FakeStructure:
2353
+ def __getattr__(self, name):
2354
+ obj = types.SimpleNamespace()
2355
+ setattr(self, name, obj)
2356
+ return obj
2357
+
2358
+ fake_wintypes = types.SimpleNamespace(
2359
+ LARGE_INTEGER=type("LARGE_INTEGER", (), {}),
2360
+ DWORD=lambda v: v,
2361
+ ULARGE_INTEGER=type("ULARGE_INTEGER", (), {}),
2362
+ BOOL=lambda v: v,
2363
+ )
2364
+ fake_ctypes = types.SimpleNamespace(
2365
+ Structure=FakeStructure,
2366
+ POINTER=lambda x: x,
2367
+ byref=lambda x: x,
2368
+ sizeof=lambda x: 1024,
2369
+ c_size_t=lambda v: v,
2370
+ c_void_p=type("c_void_p", (), {}),
2371
+ windll=types.SimpleNamespace(kernel32=FailSetInfoKernel32()),
2372
+ WINFUNCTYPE=lambda *a: lambda f: f,
2373
+ wintypes=fake_wintypes,
2374
+ )
2375
+ monkeypatch.setitem(sys.modules, "ctypes", fake_ctypes)
2376
+ monkeypatch.setitem(sys.modules, "ctypes.wintypes", fake_wintypes)
2377
+ mod.LockClient._assign_to_job_object()
2378
+ assert len(closed) == 1
2379
+
2380
+
2381
+ def test_get_process_info_local_psutil_available(monkeypatch):
2382
+ monkeypatch.setattr(sys, "platform", "win32")
2383
+
2384
+ class FakeProcess:
2385
+ def __init__(self, pid):
2386
+ pass
2387
+
2388
+ def name(self):
2389
+ return "python.exe"
2390
+
2391
+ def ppid(self):
2392
+ return 12345
2393
+
2394
+ fake_psutil = types.SimpleNamespace(
2395
+ Process=FakeProcess,
2396
+ NoSuchProcess=Exception,
2397
+ )
2398
+ monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
2399
+ client = mod.LockClient(local_only=True)
2400
+ name, ppid = client._get_process_info_local(os.getpid())
2401
+ assert name == "python.exe"
2402
+ assert ppid == 12345
2403
+
2404
+
2405
+ def test_get_process_info_local_psutil_no_such_process(monkeypatch):
2406
+ monkeypatch.setattr(sys, "platform", "win32")
2407
+
2408
+ class FakePsutilNoSuch:
2409
+ def __init__(self, pid):
2410
+ pass
2411
+
2412
+ def name(self):
2413
+ raise Exception("No such process")
2414
+
2415
+ def ppid(self):
2416
+ return 0
2417
+
2418
+ fake_psutil = types.SimpleNamespace(
2419
+ Process=FakePsutilNoSuch,
2420
+ NoSuchProcess=Exception,
2421
+ )
2422
+ monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
2423
+ result = mod.LockClient(local_only=True)._get_process_info_local(99999)
2424
+ assert result == (None, None)
2425
+
2426
+
2427
+ def test_get_process_info_local_wmic_available(monkeypatch):
2428
+ monkeypatch.setattr(sys, "platform", "win32")
2429
+ monkeypatch.setattr("sys.platform", "win32")
2430
+ monkeypatch.setitem(sys.modules, "psutil", None)
2431
+ monkeypatch.setattr(
2432
+ mod.shutil, "which", lambda cmd: "wmic.exe" if cmd == "wmic" else None
2433
+ )
2434
+
2435
+ def fake_run(cmd, **kw):
2436
+ return types.SimpleNamespace(
2437
+ returncode=0,
2438
+ stdout="Name=python.exe\r\nParentProcessId=12345\r\n",
2439
+ stderr="",
2440
+ )
2441
+
2442
+ monkeypatch.setattr(mod.subprocess, "run", fake_run)
2443
+ client = mod.LockClient(local_only=True)
2444
+ name, ppid = client._get_process_info_local(os.getpid())
2445
+ assert name == "python.exe"
2446
+ assert ppid == 12345
2447
+
2448
+
2449
+ def test_get_process_info_local_wmic_fails_then_tasklist(monkeypatch):
2450
+ monkeypatch.setattr(sys, "platform", "win32")
2451
+ monkeypatch.delitem(sys.modules, "psutil", raising=False)
2452
+ monkeypatch.setattr(
2453
+ mod.shutil, "which", lambda cmd: "wmic.exe" if cmd == "wmic" else None
2454
+ )
2455
+
2456
+ def fake_run_tasklist(cmd, **kw):
2457
+ if isinstance(cmd, (list, tuple)) and any("tasklist" in str(c) for c in cmd):
2458
+ return types.SimpleNamespace(
2459
+ stdout='"python.exe","12345","Console","1","12345 K"\n',
2460
+ returncode=0,
2461
+ )
2462
+ return types.SimpleNamespace(returncode=1, stdout="", stderr="error")
2463
+
2464
+ monkeypatch.setattr(mod.subprocess, "run", fake_run_tasklist)
2465
+ client = mod.LockClient(local_only=True)
2466
+ name = client._get_process_name_via_tasklist(12345)
2467
+ assert name == "python.exe"
2468
+
2469
+
2470
+ # ---------------------------------------------------------------------------
2471
+ # daemon_start: orphaned watcher detection path (lines 1040-1087)
2472
+ # ---------------------------------------------------------------------------
2473
+
2474
+
2475
+ def test_daemon_start_orphaned_watcher_parent_dead(monkeypatch, tmp_path):
2476
+ """daemon_start detects orphaned watcher (parent dead) and terminates it."""
2477
+ pid_file = tmp_path / ".daemon.pid"
2478
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
2479
+
2480
+ # Write pid metadata with parent_pid
2481
+ pid_meta = {"pid": 9900, "parent_pid": 1234}
2482
+ pid_file.write_text(json.dumps(pid_meta), encoding="utf-8")
2483
+
2484
+ terminate_calls = []
2485
+
2486
+ def fake_is_alive(pid):
2487
+ # watcher (9900) alive, parent (1234) dead
2488
+ return pid == 9900
2489
+
2490
+ monkeypatch.setattr(
2491
+ mod.LockClient, "_is_process_alive", staticmethod(fake_is_alive)
2492
+ )
2493
+ monkeypatch.setattr(
2494
+ mod.LockClient,
2495
+ "_terminate_process",
2496
+ lambda self, pid: terminate_calls.append(pid),
2497
+ )
2498
+ monkeypatch.setattr(mod.time, "sleep", lambda x: None)
2499
+ monkeypatch.setattr(mod.LockClient, "_remove_pid", lambda self: None)
2500
+
2501
+ # Control _read_pid at class level so daemon_start's first call returns 9900
2502
+ # and subsequent calls (from the startup wait loop) return 9901 immediately
2503
+ read_calls = [0]
2504
+
2505
+ def controlled_read_pid():
2506
+ read_calls[0] += 1
2507
+ if read_calls[0] == 1:
2508
+ # First call in daemon_start: the orphan check
2509
+ return 9900
2510
+ # Later calls: startup wait loop - return new pid right away
2511
+ return 9901
2512
+
2513
+ monkeypatch.setattr(mod.LockClient, "_read_pid", staticmethod(controlled_read_pid))
2514
+
2515
+ popen_calls = []
2516
+
2517
+ def fake_popen(cmd, **kwargs):
2518
+ popen_calls.append(cmd)
2519
+ return types.SimpleNamespace(pid=9901)
2520
+
2521
+ monkeypatch.setattr(mod.subprocess, "Popen", fake_popen)
2522
+
2523
+ client = mod.LockClient(local_only=True)
2524
+ monkeypatch.setattr(client, "_get_parent_ide_pid", lambda: (None, None))
2525
+ monkeypatch.setattr(client, "_write_pid", lambda *a, **k: None)
2526
+
2527
+ client.daemon_start(interval=5, timeout_mins=60)
2528
+
2529
+ # _terminate_process should have been called for the orphaned watcher
2530
+ assert (
2531
+ 9900 in terminate_calls
2532
+ ), f"Expected terminate for 9900, got: {terminate_calls}"
2533
+
2534
+
2535
+ def test_daemon_start_orphaned_watcher_with_entrypoint(monkeypatch, tmp_path):
2536
+ """daemon_start prints entrypoint when watcher is already running with valid
2537
+ parent."""
2538
+ pid_file = tmp_path / ".daemon.pid"
2539
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
2540
+
2541
+ pid_meta = {"pid": 9900, "parent_pid": 1234, "entrypoint": "lock_client.py"}
2542
+ pid_file.write_text(json.dumps(pid_meta), encoding="utf-8")
2543
+
2544
+ def fake_is_alive(pid):
2545
+ # both watcher and parent alive
2546
+ return True
2547
+
2548
+ monkeypatch.setattr(
2549
+ mod.LockClient, "_is_process_alive", staticmethod(fake_is_alive)
2550
+ )
2551
+
2552
+ output = []
2553
+ monkeypatch.setattr(
2554
+ "builtins.print", lambda *a, **k: output.append(" ".join(str(x) for x in a))
2555
+ )
2556
+
2557
+ client = mod.LockClient(local_only=True)
2558
+ client.daemon_start(interval=5, timeout_mins=60)
2559
+
2560
+ assert any("already running" in line.lower() for line in output)
2561
+ assert any("lock_client.py" in line for line in output)
2562
+
2563
+
2564
+ def test_daemon_start_orphaned_watcher_no_entrypoint(monkeypatch, tmp_path):
2565
+ """daemon_start prints plain message when watcher already running without
2566
+ entrypoint."""
2567
+ pid_file = tmp_path / ".daemon.pid"
2568
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
2569
+
2570
+ pid_meta = {"pid": 9900, "parent_pid": 1234}
2571
+ pid_file.write_text(json.dumps(pid_meta), encoding="utf-8")
2572
+
2573
+ def fake_is_alive(pid):
2574
+ return True
2575
+
2576
+ monkeypatch.setattr(
2577
+ mod.LockClient, "_is_process_alive", staticmethod(fake_is_alive)
2578
+ )
2579
+
2580
+ output = []
2581
+ monkeypatch.setattr(
2582
+ "builtins.print", lambda *a, **k: output.append(" ".join(str(x) for x in a))
2583
+ )
2584
+
2585
+ client = mod.LockClient(local_only=True)
2586
+ client.daemon_start(interval=5, timeout_mins=60)
2587
+
2588
+ assert any("already running" in line.lower() for line in output)
2589
+
2590
+
2591
+ # ---------------------------------------------------------------------------
2592
+ # daemon_stop full flow (lines 1179-1392)
2593
+ # ---------------------------------------------------------------------------
2594
+
2595
+
2596
+ def test_daemon_stop_graceful_token_based(monkeypatch, tmp_path):
2597
+ """daemon_stop writes TOKEN: stop request and removes file after graceful stop."""
2598
+ pid_file = tmp_path / ".daemon.pid"
2599
+ state_dir = str(tmp_path)
2600
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
2601
+ monkeypatch.setenv("COLLAB_STATE_DIR", state_dir)
2602
+
2603
+ token = "abcdef12"
2604
+ pid_meta = {"pid": 8888, "token": token}
2605
+ pid_file.write_text(json.dumps(pid_meta), encoding="utf-8")
2606
+
2607
+ alive_after = [True] # process alive initially, then stops
2608
+
2609
+ def fake_is_alive(pid):
2610
+ if alive_after[0]:
2611
+ alive_after[0] = False
2612
+ return False # already dead after first check
2613
+ return False
2614
+
2615
+ monkeypatch.setattr(
2616
+ mod.LockClient, "_is_process_alive", staticmethod(fake_is_alive)
2617
+ )
2618
+ monkeypatch.setattr(mod.time, "sleep", lambda x: None)
2619
+
2620
+ output = []
2621
+ monkeypatch.setattr(
2622
+ "builtins.print", lambda *a, **k: output.append(" ".join(str(x) for x in a))
2623
+ )
2624
+
2625
+ client = mod.LockClient(local_only=True)
2626
+ client.daemon_stop()
2627
+
2628
+ stop_file = str(tmp_path / ".stop_request")
2629
+ # stop file should be removed after graceful stop
2630
+ assert not os.path.exists(stop_file)
2631
+
2632
+
2633
+ def test_daemon_stop_no_running_watcher(monkeypatch, tmp_path):
2634
+ """daemon_stop prints 'No running watcher found' when no watcher is alive."""
2635
+ pid_file = tmp_path / ".daemon.pid"
2636
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
2637
+ monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path))
2638
+
2639
+ # No pid file
2640
+ def fake_is_alive(pid):
2641
+ return False
2642
+
2643
+ monkeypatch.setattr(
2644
+ mod.LockClient, "_is_process_alive", staticmethod(fake_is_alive)
2645
+ )
2646
+ monkeypatch.setattr(mod.LockClient, "_discover_running_watchers", lambda self: [])
2647
+ monkeypatch.setattr(mod.time, "sleep", lambda x: None)
2648
+
2649
+ output = []
2650
+ monkeypatch.setattr(
2651
+ "builtins.print", lambda *a, **k: output.append(" ".join(str(x) for x in a))
2652
+ )
2653
+
2654
+ client = mod.LockClient(local_only=True)
2655
+ client.daemon_stop()
2656
+
2657
+ assert any("no running watcher" in line.lower() for line in output)
2658
+
2659
+
2660
+ def test_daemon_stop_discovery_exception(monkeypatch, tmp_path):
2661
+ """daemon_stop handles exception in _discover_running_watchers gracefully."""
2662
+ pid_file = tmp_path / ".daemon.pid"
2663
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
2664
+ monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path))
2665
+
2666
+ def fake_is_alive(pid):
2667
+ return False
2668
+
2669
+ monkeypatch.setattr(
2670
+ mod.LockClient, "_is_process_alive", staticmethod(fake_is_alive)
2671
+ )
2672
+
2673
+ def failing_discover(self):
2674
+ raise RuntimeError("discovery failed")
2675
+
2676
+ monkeypatch.setattr(mod.LockClient, "_discover_running_watchers", failing_discover)
2677
+ monkeypatch.setattr(mod.time, "sleep", lambda x: None)
2678
+
2679
+ output = []
2680
+ monkeypatch.setattr(
2681
+ "builtins.print", lambda *a, **k: output.append(" ".join(str(x) for x in a))
2682
+ )
2683
+
2684
+ client = mod.LockClient(local_only=True)
2685
+ client.daemon_stop() # Should not raise
2686
+
2687
+ assert any("no running watcher" in line.lower() for line in output)
2688
+
2689
+
2690
+ def test_daemon_stop_forced_kill_windows(monkeypatch, tmp_path):
2691
+ """daemon_stop falls back to taskkill on Windows when soft stop times out."""
2692
+ pid_file = tmp_path / ".daemon.pid"
2693
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
2694
+ monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path))
2695
+ monkeypatch.setattr(sys, "platform", "win32")
2696
+
2697
+ pid_meta = {"pid": 7777}
2698
+ pid_file.write_text(json.dumps(pid_meta), encoding="utf-8")
2699
+
2700
+ # Process stays alive for all soft-stop polls, then dies after force kill
2701
+ check_count = [0]
2702
+
2703
+ def fake_is_alive(pid):
2704
+ check_count[0] += 1
2705
+ # stays alive during soft-stop window (first 16 checks), then dies
2706
+ return check_count[0] <= 20
2707
+
2708
+ monkeypatch.setattr(
2709
+ mod.LockClient, "_is_process_alive", staticmethod(fake_is_alive)
2710
+ )
2711
+ monkeypatch.setattr(mod.time, "sleep", lambda x: None)
2712
+
2713
+ taskkill_calls = []
2714
+
2715
+ def fake_run(cmd, **kwargs):
2716
+ if "taskkill" in cmd:
2717
+ taskkill_calls.append(cmd)
2718
+ return types.SimpleNamespace(returncode=0, stdout="", stderr="")
2719
+
2720
+ monkeypatch.setattr(mod.subprocess, "run", fake_run)
2721
+
2722
+ output = []
2723
+ monkeypatch.setattr(
2724
+ "builtins.print", lambda *a, **k: output.append(" ".join(str(x) for x in a))
2725
+ )
2726
+
2727
+ client = mod.LockClient(local_only=True)
2728
+ client.daemon_stop()
2729
+
2730
+ assert len(taskkill_calls) > 0
2731
+
2732
+
2733
+ def test_daemon_stop_forced_kill_unix(monkeypatch, tmp_path):
2734
+ """daemon_stop sends SIGTERM on Unix when soft stop times out."""
2735
+ pid_file = tmp_path / ".daemon.pid"
2736
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
2737
+ monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path))
2738
+ monkeypatch.setattr(sys, "platform", "linux")
2739
+
2740
+ pid_meta = {"pid": 6666}
2741
+ pid_file.write_text(json.dumps(pid_meta), encoding="utf-8")
2742
+
2743
+ check_count = [0]
2744
+
2745
+ def fake_is_alive(pid):
2746
+ check_count[0] += 1
2747
+ # stays alive during soft-stop (16 iterations) and force-kill window (10)
2748
+ # then finally dies so we don't hit SIGKILL path which requires SIGKILL attr
2749
+ return check_count[0] <= 26
2750
+
2751
+ monkeypatch.setattr(
2752
+ mod.LockClient, "_is_process_alive", staticmethod(fake_is_alive)
2753
+ )
2754
+ monkeypatch.setattr(mod.time, "sleep", lambda x: None)
2755
+
2756
+ kill_calls = []
2757
+
2758
+ def fake_kill(pid, sig):
2759
+ kill_calls.append((pid, sig))
2760
+
2761
+ monkeypatch.setattr(mod.os, "kill", fake_kill)
2762
+
2763
+ # Ensure SIGKILL attr exists (not available on Windows)
2764
+ if not hasattr(signal, "SIGKILL"):
2765
+ monkeypatch.setattr(mod.signal, "SIGKILL", 9, raising=False)
2766
+
2767
+ output = []
2768
+ monkeypatch.setattr(
2769
+ "builtins.print", lambda *a, **k: output.append(" ".join(str(x) for x in a))
2770
+ )
2771
+
2772
+ client = mod.LockClient(local_only=True)
2773
+ client.daemon_stop()
2774
+
2775
+ assert len(kill_calls) > 0
2776
+
2777
+
2778
+ def test_daemon_stop_pid_based_stop_request(monkeypatch, tmp_path):
2779
+ """daemon_stop writes PID: stop request when no token in metadata."""
2780
+ pid_file = tmp_path / ".daemon.pid"
2781
+ state_dir = str(tmp_path)
2782
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
2783
+ monkeypatch.setenv("COLLAB_STATE_DIR", state_dir)
2784
+
2785
+ # Metadata with no token
2786
+ pid_meta = {"pid": 5555}
2787
+ pid_file.write_text(json.dumps(pid_meta), encoding="utf-8")
2788
+
2789
+ stop_file_contents = []
2790
+
2791
+ original_open = open
2792
+
2793
+ def capturing_open(path, mode="r", **kwargs):
2794
+ if ".stop_request" in str(path) and "w" in mode:
2795
+
2796
+ class FakeFH:
2797
+ def write(self, text):
2798
+ stop_file_contents.append(text)
2799
+
2800
+ def flush(self):
2801
+ pass
2802
+
2803
+ def fileno(self):
2804
+ return -1
2805
+
2806
+ def __enter__(self):
2807
+ return self
2808
+
2809
+ def __exit__(self, *a):
2810
+ pass
2811
+
2812
+ return FakeFH()
2813
+ return original_open(path, mode, **kwargs)
2814
+
2815
+ monkeypatch.setattr("builtins.open", capturing_open)
2816
+
2817
+ # Initially alive so daemon_stop tries graceful shutdown via stop request;
2818
+ # then becomes dead to simulate process termination after stop signal
2819
+ alive_count = [0]
2820
+
2821
+ def fake_is_alive(pid):
2822
+ alive_count[0] += 1
2823
+ # First two calls return True (process alive), then False (after stop signal)
2824
+ return alive_count[0] <= 2
2825
+
2826
+ monkeypatch.setattr(
2827
+ mod.LockClient, "_is_process_alive", staticmethod(fake_is_alive)
2828
+ )
2829
+ monkeypatch.setattr(mod.time, "sleep", lambda x: None)
2830
+ monkeypatch.setattr(
2831
+ mod.LockClient,
2832
+ "_get_cmdline_for_pid",
2833
+ staticmethod(
2834
+ lambda pid: (
2835
+ "python .collab/pycharm/live_locks_watcher.py "
2836
+ f"--pid-file {str(pid_file)}"
2837
+ )
2838
+ ),
2839
+ )
2840
+
2841
+ output = []
2842
+ monkeypatch.setattr(
2843
+ "builtins.print", lambda *a, **k: output.append(" ".join(str(x) for x in a))
2844
+ )
2845
+
2846
+ client = mod.LockClient(local_only=True)
2847
+ client.daemon_stop()
2848
+
2849
+ # Should have written a PID: stop request
2850
+ assert any("PID:" in content for content in stop_file_contents)
2851
+
2852
+
2853
+ def test_daemon_stop_write_stop_file_exception_branch(monkeypatch, tmp_path):
2854
+ """Cover daemon_stop exception branch when writing stop request fails."""
2855
+ import builtins
2856
+
2857
+ pid_file = tmp_path / ".daemon.pid"
2858
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
2859
+ monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path))
2860
+ pid_file.write_text(json.dumps({"pid": 4444}), encoding="utf-8")
2861
+
2862
+ # First liveness check makes daemon_stop choose PID path; later checks report dead.
2863
+ calls = {"n": 0}
2864
+
2865
+ def _alive(_pid):
2866
+ calls["n"] += 1
2867
+ return calls["n"] == 1
2868
+
2869
+ monkeypatch.setattr(mod.LockClient, "_is_process_alive", staticmethod(_alive))
2870
+ monkeypatch.setattr(mod.time, "sleep", lambda _x: None)
2871
+
2872
+ real_open = builtins.open
2873
+
2874
+ def _raising_open(path, mode="r", *args, **kwargs):
2875
+ if str(path).endswith(".stop_request") and "w" in mode:
2876
+ raise OSError("cannot write stop file")
2877
+ return real_open(path, mode, *args, **kwargs)
2878
+
2879
+ monkeypatch.setattr(builtins, "open", _raising_open)
2880
+
2881
+ client = mod.LockClient(local_only=True)
2882
+ client.daemon_stop() # should not raise
2883
+
2884
+
2885
+ def test_daemon_stop_remove_stop_file_and_pid_cleanup_exception_branches(
2886
+ monkeypatch, tmp_path
2887
+ ):
2888
+ """Cover removal and canonical PID cleanup exception branches in daemon_stop."""
2889
+ pid_file = tmp_path / ".daemon.pid"
2890
+ stop_file = tmp_path / ".stop_request"
2891
+ shutdown_file = tmp_path / ".shutdown_complete"
2892
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
2893
+ monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path))
2894
+ pid_file.write_text(json.dumps({"pid": 5555}), encoding="utf-8")
2895
+
2896
+ # Ensure graceful-stop branch is taken.
2897
+ monkeypatch.setattr(
2898
+ mod.LockClient, "_is_process_alive", staticmethod(lambda _p: False)
2899
+ )
2900
+ monkeypatch.setattr(mod.time, "sleep", lambda _x: None)
2901
+ stop_file.write_text("PID:5555", encoding="utf-8")
2902
+ shutdown_file.write_text("ok", encoding="utf-8")
2903
+
2904
+ def _exists(path):
2905
+ p = str(path)
2906
+ if p.endswith(".stop_request"):
2907
+ return True
2908
+ if p.endswith(".shutdown_complete"):
2909
+ return True
2910
+ return os.path.exists(path)
2911
+
2912
+ def _remove(path):
2913
+ if str(path).endswith(".stop_request"):
2914
+ raise OSError("remove failed")
2915
+ return os.remove(path)
2916
+
2917
+ client = mod.LockClient(local_only=True)
2918
+ monkeypatch.setattr(mod.os.path, "exists", _exists)
2919
+ monkeypatch.setattr(mod.os, "remove", _remove)
2920
+ _read_calls = {"n": 0}
2921
+
2922
+ def _read_pid_then_fail():
2923
+ _read_calls["n"] += 1
2924
+ if _read_calls["n"] == 1:
2925
+ return 5555
2926
+ raise RuntimeError("pid read fail")
2927
+
2928
+ monkeypatch.setattr(client, "_read_pid", _read_pid_then_fail)
2929
+
2930
+ client.daemon_stop() # should not raise
2931
+
2932
+
2933
+ def test_daemon_status_metadata_parse_error_then_cmdline_unknown(
2934
+ monkeypatch, tmp_path, capsys
2935
+ ):
2936
+ """Cover daemon_status metadata-read exception and cmdline-unknown print path."""
2937
+ pid_file = tmp_path / "daemon.pid"
2938
+ pid_file.write_text("{not-json", encoding="utf-8")
2939
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
2940
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
2941
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
2942
+ monkeypatch.setattr(
2943
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
2944
+ )
2945
+
2946
+ c = mod.LockClient(local_only=True)
2947
+ monkeypatch.setattr(c, "_read_pid", lambda: 1234)
2948
+ monkeypatch.setattr(c, "_is_process_alive", lambda _p: True)
2949
+ monkeypatch.setattr(c, "_get_cmdline_for_pid", lambda _p: None)
2950
+
2951
+ assert c.daemon_status() is True
2952
+ out = capsys.readouterr().out
2953
+ assert "cmdline unknown" in out.lower()
2954
+
2955
+
2956
+ def test_daemon_status_cmdline_match_prints_verified_path(
2957
+ monkeypatch, tmp_path, capsys
2958
+ ):
2959
+ """Cover daemon_status branch where cmdline is present and matches watcher."""
2960
+ pid_file = tmp_path / "daemon.pid"
2961
+ pid_file.write_text("9999", encoding="utf-8")
2962
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
2963
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
2964
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
2965
+ monkeypatch.setattr(
2966
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
2967
+ )
2968
+
2969
+ c = mod.LockClient(local_only=True)
2970
+ monkeypatch.setattr(c, "_read_pid", lambda: 9999)
2971
+ monkeypatch.setattr(c, "_is_process_alive", lambda _p: True)
2972
+ monkeypatch.setattr(
2973
+ c, "_get_cmdline_for_pid", lambda _p: "python lock_client.py watch"
2974
+ )
2975
+
2976
+ assert c.daemon_status() is True
2977
+ out = capsys.readouterr().out
2978
+ assert "lock_client.py watch" in out
2979
+
2980
+
2981
+ # ---------------------------------------------------------------------------
2982
+ # Signal handler registration (lines 2578-2683)
2983
+ # ---------------------------------------------------------------------------
2984
+
2985
+
2986
+ def test_register_signal_handlers_sigint(monkeypatch, tmp_path):
2987
+ """_register_signal_handlers registers SIGINT handler."""
2988
+ monkeypatch.setattr(
2989
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
2990
+ )
2991
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
2992
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
2993
+
2994
+ registered = {}
2995
+
2996
+ def fake_signal(signum, handler):
2997
+ registered[signum] = handler
2998
+
2999
+ monkeypatch.setattr(mod.signal, "signal", fake_signal)
3000
+
3001
+ client = mod.LockClient(developer_id="test_user")
3002
+ client._register_signal_handlers()
3003
+
3004
+ import signal as _signal
3005
+
3006
+ assert _signal.SIGINT in registered
3007
+
3008
+
3009
+ def test_register_signal_handlers_atexit(monkeypatch, tmp_path):
3010
+ """_register_signal_handlers registers atexit in non-test mode."""
3011
+ monkeypatch.setenv("COLLAB_TEST_MODE", "0")
3012
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
3013
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
3014
+ monkeypatch.setattr(
3015
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
3016
+ )
3017
+
3018
+ atexit_fns = []
3019
+ monkeypatch.setattr(mod.atexit, "register", lambda fn: atexit_fns.append(fn))
3020
+ monkeypatch.setattr(mod.signal, "signal", lambda *a: None)
3021
+
3022
+ client = mod.LockClient(developer_id="test_user")
3023
+ client._register_signal_handlers()
3024
+
3025
+ assert len(atexit_fns) > 0
3026
+
3027
+
3028
+ def test_register_signal_handlers_sigbreak_windows(monkeypatch):
3029
+ """_register_signal_handlers registers SIGBREAK on Windows when available."""
3030
+ monkeypatch.setattr(sys, "platform", "win32")
3031
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
3032
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
3033
+ monkeypatch.setattr(
3034
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
3035
+ )
3036
+
3037
+ registered = {}
3038
+
3039
+ def fake_signal(signum, handler):
3040
+ registered[signum] = handler
3041
+
3042
+ monkeypatch.setattr(mod.signal, "signal", fake_signal)
3043
+ monkeypatch.setattr(mod.atexit, "register", lambda fn: None)
3044
+
3045
+ import signal as _signal
3046
+
3047
+ if not hasattr(_signal, "SIGBREAK"):
3048
+ monkeypatch.setattr(_signal, "SIGBREAK", 21, raising=False)
3049
+
3050
+ client = mod.LockClient(developer_id="test_user")
3051
+ client._register_signal_handlers()
3052
+
3053
+ assert _signal.SIGBREAK in registered or _signal.SIGINT in registered
3054
+
3055
+
3056
+ @pytest.mark.skipif(
3057
+ sys.platform != "win32", reason="Windows console control signal handling"
3058
+ )
3059
+ def test_register_signal_handlers_windows_console_ctrl(monkeypatch):
3060
+ """_register_signal_handlers tries to register Windows console ctrl handler."""
3061
+ monkeypatch.setattr(sys, "platform", "win32")
3062
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
3063
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
3064
+ monkeypatch.setattr(
3065
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
3066
+ )
3067
+ monkeypatch.setattr(mod.signal, "signal", lambda *a: None)
3068
+ monkeypatch.setattr(mod.atexit, "register", lambda fn: None)
3069
+
3070
+ set_console_ctrl_calls = []
3071
+
3072
+ fake_kernel32 = types.SimpleNamespace(
3073
+ SetConsoleCtrlHandler=lambda handler, add: set_console_ctrl_calls.append(add)
3074
+ )
3075
+
3076
+ import ctypes as _real_ctypes
3077
+
3078
+ # Patch ctypes.windll.kernel32.SetConsoleCtrlHandler directly
3079
+ fake_windll = types.SimpleNamespace(kernel32=fake_kernel32)
3080
+ monkeypatch.setattr(_real_ctypes, "windll", fake_windll)
3081
+
3082
+ client = mod.LockClient(developer_id="test_user")
3083
+ client._register_signal_handlers()
3084
+
3085
+ # SetConsoleCtrlHandler should have been called
3086
+ assert len(set_console_ctrl_calls) > 0
3087
+
3088
+
3089
+ # ---------------------------------------------------------------------------
3090
+ # _start_parent_monitor_thread (lines 2719-2874)
3091
+ # ---------------------------------------------------------------------------
3092
+
3093
+
3094
+ def test_start_parent_monitor_thread_non_windows(monkeypatch):
3095
+ """_start_parent_monitor_thread returns immediately on non-Windows."""
3096
+ monkeypatch.setattr(sys, "platform", "linux")
3097
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
3098
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
3099
+ monkeypatch.setattr(
3100
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
3101
+ )
3102
+
3103
+ client = mod.LockClient(developer_id="test_user")
3104
+ client._parent_pid = 1234
3105
+ # Should return immediately; no monitor attributes should be set.
3106
+ client._start_parent_monitor_thread()
3107
+
3108
+
3109
+ def test_start_parent_monitor_thread_no_parent_pid(monkeypatch):
3110
+ """_start_parent_monitor_thread returns early when no parent_pid."""
3111
+ monkeypatch.setattr(sys, "platform", "win32")
3112
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
3113
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
3114
+ monkeypatch.setattr(
3115
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
3116
+ )
3117
+
3118
+ client = mod.LockClient(developer_id="test_user")
3119
+ client._parent_pid = None
3120
+ client._start_parent_monitor_thread() # should return early
3121
+
3122
+
3123
+ def test_start_parent_monitor_thread_open_process_fails(monkeypatch):
3124
+ """_start_parent_monitor_thread handles OpenProcess failure gracefully."""
3125
+ monkeypatch.setattr(sys, "platform", "win32")
3126
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
3127
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
3128
+ monkeypatch.setattr(
3129
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
3130
+ )
3131
+
3132
+ fake_kernel32 = types.SimpleNamespace(
3133
+ OpenProcess=lambda access, inherit, pid: 0, # returns NULL (failure)
3134
+ GetLastError=lambda: 5, # ERROR_ACCESS_DENIED
3135
+ )
3136
+ fake_ctypes = types.SimpleNamespace(
3137
+ windll=types.SimpleNamespace(kernel32=fake_kernel32),
3138
+ )
3139
+ monkeypatch.setitem(sys.modules, "ctypes", fake_ctypes)
3140
+
3141
+ client = mod.LockClient(developer_id="test_user")
3142
+ client._parent_pid = 9999
3143
+ client._start_parent_monitor_thread()
3144
+
3145
+ assert (
3146
+ getattr(client, "_parent_monitor_started", None) is None
3147
+ or not client._parent_monitor_started
3148
+ )
3149
+
3150
+
3151
+ def test_start_parent_monitor_thread_starts_thread(monkeypatch):
3152
+ """_start_parent_monitor_thread starts a daemon thread when OpenProcess succeeds."""
3153
+ monkeypatch.setattr(sys, "platform", "win32")
3154
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
3155
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
3156
+ monkeypatch.setattr(
3157
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
3158
+ )
3159
+
3160
+ handle = 0xDEAD
3161
+
3162
+ fake_kernel32 = types.SimpleNamespace(
3163
+ OpenProcess=lambda access, inherit, pid: handle,
3164
+ WaitForSingleObject=lambda h, timeout: 0,
3165
+ CloseHandle=lambda h: None,
3166
+ )
3167
+ fake_ctypes = types.SimpleNamespace(
3168
+ windll=types.SimpleNamespace(kernel32=fake_kernel32),
3169
+ )
3170
+ monkeypatch.setitem(sys.modules, "ctypes", fake_ctypes)
3171
+
3172
+ threads_started = []
3173
+
3174
+ class FakeThread:
3175
+ def __init__(self, target, args, daemon):
3176
+ self._target = target
3177
+ self._args = args
3178
+ self.daemon = daemon
3179
+
3180
+ def start(self):
3181
+ threads_started.append(self)
3182
+
3183
+ monkeypatch.setattr(mod.threading, "Thread", FakeThread)
3184
+
3185
+ client = mod.LockClient(developer_id="test_user")
3186
+ client._parent_pid = 1234
3187
+ client._start_parent_monitor_thread()
3188
+
3189
+ assert len(threads_started) > 0
3190
+ assert client._parent_monitor_started is True
3191
+
3192
+
3193
+ # ---------------------------------------------------------------------------
3194
+ # _kill_orphaned_lock_clients (lines 1478-1630)
3195
+ # ---------------------------------------------------------------------------
3196
+
3197
+
3198
+ def test_cleanup_orphaned_processes_windows_psutil_match(monkeypatch, tmp_path):
3199
+ """cleanup_orphaned_processes kills Windows python process when psutil cmdline
3200
+ matches lock_client."""
3201
+ monkeypatch.setattr(sys, "platform", "win32")
3202
+
3203
+ # Prevent killing ourselves
3204
+ monkeypatch.setattr(mod.os, "getpid", lambda: 99999)
3205
+
3206
+ taskkill_calls = []
3207
+
3208
+ def fake_run(cmd, **kwargs):
3209
+ if cmd[0] == "tasklist":
3210
+ return types.SimpleNamespace(
3211
+ stdout='"python.exe","12345","Console","1","12345 K"\n',
3212
+ returncode=0,
3213
+ )
3214
+ if cmd[0] == "taskkill":
3215
+ taskkill_calls.append(cmd)
3216
+ return types.SimpleNamespace(stdout="", returncode=0)
3217
+ raise AssertionError(f"Unexpected command: {cmd}")
3218
+
3219
+ monkeypatch.setattr(mod.subprocess, "run", fake_run)
3220
+
3221
+ class FakeProcess:
3222
+ def __init__(self, pid):
3223
+ self.pid = pid
3224
+
3225
+ def cmdline(self):
3226
+ return ["python", "collab_test_lock_client.py", "watch"]
3227
+
3228
+ class FakePsutil:
3229
+ class NoSuchProcess(Exception):
3230
+ pass
3231
+
3232
+ @staticmethod
3233
+ def Process(pid):
3234
+ return FakeProcess(pid)
3235
+
3236
+ monkeypatch.setitem(sys.modules, "psutil", FakePsutil())
3237
+ monkeypatch.setattr(mod.shutil, "which", lambda name: None)
3238
+
3239
+ output = []
3240
+ monkeypatch.setattr(
3241
+ "builtins.print", lambda *a, **k: output.append(" ".join(str(x) for x in a))
3242
+ )
3243
+
3244
+ client = mod.LockClient(local_only=True)
3245
+ remove_pid_calls = []
3246
+ monkeypatch.setattr(client, "_remove_pid", lambda: remove_pid_calls.append(True))
3247
+
3248
+ client.cleanup_orphaned_processes()
3249
+
3250
+ assert len(taskkill_calls) > 0
3251
+ assert any("Killed 1 orphaned process" in line for line in output)
3252
+ assert len(remove_pid_calls) == 1
3253
+
3254
+
3255
+ def test_cleanup_orphaned_processes_windows_wmic_fallback(monkeypatch):
3256
+ """cleanup_orphaned_processes falls back to WMIC when psutil import/inspect
3257
+ fails."""
3258
+ monkeypatch.setattr(sys, "platform", "win32")
3259
+ monkeypatch.setattr(mod.os, "getpid", lambda: 99999)
3260
+
3261
+ calls = []
3262
+
3263
+ def fake_run(cmd, **kwargs):
3264
+ calls.append(cmd)
3265
+ if cmd[0] == "tasklist":
3266
+ return types.SimpleNamespace(
3267
+ stdout='"python.exe","23456","Console","1","12345 K"\n',
3268
+ returncode=0,
3269
+ )
3270
+ if cmd[0] == "wmic":
3271
+ return types.SimpleNamespace(
3272
+ stdout="CommandLine=python collab_test_lock_client.py watch",
3273
+ returncode=0,
3274
+ )
3275
+ if cmd[0] == "taskkill":
3276
+ return types.SimpleNamespace(stdout="", returncode=0)
3277
+ raise AssertionError(f"Unexpected command: {cmd}")
3278
+
3279
+ monkeypatch.setattr(mod.subprocess, "run", fake_run)
3280
+
3281
+ # Simulate psutil import failure
3282
+ real_import = __import__
3283
+
3284
+ def mock_import(name, *a, **k):
3285
+ if name == "psutil":
3286
+ raise ImportError("no psutil")
3287
+ return real_import(name, *a, **k)
3288
+
3289
+ monkeypatch.setattr("builtins.__import__", mock_import)
3290
+ monkeypatch.setattr(
3291
+ mod.shutil, "which", lambda name: "wmic" if name == "wmic" else None
3292
+ )
3293
+
3294
+ output = []
3295
+ monkeypatch.setattr(
3296
+ "builtins.print", lambda *a, **k: output.append(" ".join(str(x) for x in a))
3297
+ )
3298
+
3299
+ client = mod.LockClient(local_only=True)
3300
+ monkeypatch.setattr(client, "_remove_pid", lambda: None)
3301
+ client.cleanup_orphaned_processes()
3302
+
3303
+ assert any(cmd[0] == "wmic" for cmd in calls)
3304
+ assert any(cmd[0] == "taskkill" for cmd in calls)
3305
+ assert any("Killed 1 orphaned process" in line for line in output)
3306
+
3307
+
3308
+ def test_cleanup_orphaned_processes_windows_no_matches_checks_locked_logs(
3309
+ monkeypatch, tmp_path
3310
+ ):
3311
+ """cleanup_orphaned_processes reports locked log files when nothing is killed."""
3312
+ monkeypatch.setattr(sys, "platform", "win32")
3313
+ monkeypatch.setattr(mod.os, "getpid", lambda: 99999)
3314
+
3315
+ collab_root = tmp_path / ".collab"
3316
+ logs_dir = collab_root / "logs"
3317
+ logs_dir.mkdir(parents=True)
3318
+ app_log = logs_dir / "application.log"
3319
+ err_log = logs_dir / "errors.log"
3320
+ app_log.write_text("x")
3321
+ err_log.write_text("y")
3322
+ monkeypatch.setattr(mod, "_COLLAB_ROOT", str(collab_root))
3323
+
3324
+ def fake_run(cmd, **kwargs):
3325
+ if cmd[0] == "tasklist":
3326
+ return types.SimpleNamespace(stdout="", returncode=0)
3327
+ raise AssertionError(f"Unexpected command: {cmd}")
3328
+
3329
+ monkeypatch.setattr(mod.subprocess, "run", fake_run)
3330
+ monkeypatch.setattr(mod.shutil, "which", lambda name: None)
3331
+
3332
+ real_open = open
3333
+
3334
+ def fake_open(path, mode="r", *a, **k):
3335
+ if str(path).endswith("application.log") and "a" in mode:
3336
+ raise PermissionError("locked")
3337
+ return real_open(path, mode, *a, **k)
3338
+
3339
+ monkeypatch.setattr("builtins.open", fake_open)
3340
+
3341
+ output = []
3342
+ monkeypatch.setattr(
3343
+ "builtins.print", lambda *a, **k: output.append(" ".join(str(x) for x in a))
3344
+ )
3345
+
3346
+ client = mod.LockClient(local_only=True)
3347
+ client.cleanup_orphaned_processes()
3348
+
3349
+ assert any("No orphaned lock_client processes found." in line for line in output)
3350
+ assert any("application.log is LOCKED" in line for line in output)
3351
+
3352
+
3353
+ def test_cleanup_orphaned_processes_unix_ps_scan(monkeypatch):
3354
+ """cleanup_orphaned_processes scans ps output and SIGTERMs orphaned Unix
3355
+ processes."""
3356
+ monkeypatch.setattr(sys, "platform", "linux")
3357
+ monkeypatch.setattr(mod.os, "getpid", lambda: 99999)
3358
+
3359
+ def fake_run(cmd, **kwargs):
3360
+ assert cmd == ["ps", "aux"]
3361
+ return types.SimpleNamespace(
3362
+ stdout=(
3363
+ "user 34567 0.0 0.1 ? S 00:00:00 python "
3364
+ "collab_test_lock_client.py watch\n"
3365
+ "user 99999 0.0 0.1 ? S 00:00:00 python "
3366
+ "collab_test_lock_client.py watch\n"
3367
+ ),
3368
+ returncode=0,
3369
+ )
3370
+
3371
+ monkeypatch.setattr(mod.subprocess, "run", fake_run)
3372
+
3373
+ kill_calls = []
3374
+
3375
+ def fake_kill(pid, sig):
3376
+ kill_calls.append((pid, sig))
3377
+
3378
+ monkeypatch.setattr(mod.os, "kill", fake_kill)
3379
+
3380
+ output = []
3381
+ monkeypatch.setattr(
3382
+ "builtins.print", lambda *a, **k: output.append(" ".join(str(x) for x in a))
3383
+ )
3384
+
3385
+ client = mod.LockClient(local_only=True)
3386
+ monkeypatch.setattr(client, "_remove_pid", lambda: None)
3387
+ client.cleanup_orphaned_processes()
3388
+
3389
+ assert any(pid == 34567 for pid, _ in kill_calls)
3390
+ assert all(pid != 99999 for pid, _ in kill_calls)
3391
+ assert any("Killed 1 orphaned process" in line for line in output)
3392
+
3393
+
3394
+ def test_register_signal_handlers_sigint_handler_invokes_shutdown(monkeypatch):
3395
+ """Registered SIGINT handler executes graceful shutdown path."""
3396
+ monkeypatch.setattr(sys, "platform", "linux")
3397
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
3398
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
3399
+ monkeypatch.setenv("COLLAB_TEST_MODE", "0")
3400
+ monkeypatch.setattr(
3401
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
3402
+ )
3403
+
3404
+ signal_handlers = {}
3405
+ monkeypatch.setattr(mod.atexit, "register", lambda fn: None)
3406
+
3407
+ def fake_signal(sig, handler):
3408
+ signal_handlers[sig] = handler
3409
+
3410
+ monkeypatch.setattr(mod.signal, "signal", fake_signal)
3411
+
3412
+ shutdown_reasons = []
3413
+ client = mod.LockClient(developer_id="test_user")
3414
+ monkeypatch.setattr(
3415
+ client,
3416
+ "_graceful_shutdown",
3417
+ lambda reason=None: shutdown_reasons.append(reason),
3418
+ )
3419
+
3420
+ def fake_exit(code):
3421
+ raise SystemExit(code)
3422
+
3423
+ monkeypatch.setattr(mod.sys, "exit", fake_exit)
3424
+
3425
+ client._register_signal_handlers()
3426
+
3427
+ import signal as _signal
3428
+
3429
+ assert _signal.SIGINT in signal_handlers
3430
+ with pytest.raises(SystemExit):
3431
+ signal_handlers[_signal.SIGINT](_signal.SIGINT, None)
3432
+
3433
+ assert any(str(r).startswith("signal_") for r in shutdown_reasons)
3434
+
3435
+
3436
+ def test_register_signal_handlers_windows_console_handler_executes_shutdown(
3437
+ monkeypatch,
3438
+ ):
3439
+ """Windows console ctrl handler callback calls graceful shutdown."""
3440
+ monkeypatch.setattr(sys, "platform", "win32")
3441
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
3442
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
3443
+ monkeypatch.setenv("COLLAB_TEST_MODE", "0")
3444
+ monkeypatch.setattr(
3445
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
3446
+ )
3447
+
3448
+ monkeypatch.setattr(mod.atexit, "register", lambda fn: None)
3449
+ monkeypatch.setattr(mod.signal, "signal", lambda *a, **k: None)
3450
+
3451
+ captured_console_handlers = []
3452
+
3453
+ class FakeKernel32:
3454
+ def SetConsoleCtrlHandler(self, handler, add):
3455
+ captured_console_handlers.append(handler)
3456
+ return True
3457
+
3458
+ fake_wintypes = types.SimpleNamespace(BOOL=lambda v: v, DWORD=lambda v: v)
3459
+ fake_ctypes = types.SimpleNamespace(
3460
+ WINFUNCTYPE=lambda *a: (lambda fn: fn),
3461
+ windll=types.SimpleNamespace(kernel32=FakeKernel32()),
3462
+ wintypes=fake_wintypes,
3463
+ )
3464
+ monkeypatch.setitem(sys.modules, "ctypes", fake_ctypes)
3465
+ monkeypatch.setitem(sys.modules, "ctypes.wintypes", fake_wintypes)
3466
+
3467
+ shutdown_reasons = []
3468
+ client = mod.LockClient(developer_id="test_user")
3469
+ monkeypatch.setattr(
3470
+ client,
3471
+ "_graceful_shutdown",
3472
+ lambda reason=None: shutdown_reasons.append(reason),
3473
+ )
3474
+
3475
+ client._register_signal_handlers()
3476
+
3477
+ assert len(captured_console_handlers) == 1
3478
+ # Simulate CTRL_CLOSE_EVENT-like callback value
3479
+ captured_console_handlers[0](2)
3480
+ assert any(str(r).startswith("console_ctrl_") for r in shutdown_reasons)
3481
+
3482
+
3483
+ def test_start_parent_monitor_thread_waiter_executes_shutdown(monkeypatch):
3484
+ """Parent monitor waiter branch runs and triggers graceful shutdown reason."""
3485
+ monkeypatch.setattr(sys, "platform", "win32")
3486
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
3487
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
3488
+ monkeypatch.setattr(
3489
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
3490
+ )
3491
+
3492
+ closed = []
3493
+
3494
+ class FakeKernel32:
3495
+ def OpenProcess(self, desired_access, inherit, pid):
3496
+ return 123
3497
+
3498
+ def WaitForSingleObject(self, handle, timeout):
3499
+ return 0
3500
+
3501
+ def CloseHandle(self, handle):
3502
+ closed.append(handle)
3503
+ return True
3504
+
3505
+ fake_ctypes = types.SimpleNamespace(
3506
+ windll=types.SimpleNamespace(kernel32=FakeKernel32()),
3507
+ )
3508
+ monkeypatch.setitem(sys.modules, "ctypes", fake_ctypes)
3509
+
3510
+ # Execute waiter immediately in-thread for deterministic coverage
3511
+ class ImmediateThread:
3512
+ def __init__(self, target, args, daemon):
3513
+ self._target = target
3514
+ self._args = args
3515
+ self.daemon = daemon
3516
+
3517
+ def start(self):
3518
+ self._target(*self._args)
3519
+
3520
+ monkeypatch.setattr(mod.threading, "Thread", ImmediateThread)
3521
+
3522
+ shutdown_reasons = []
3523
+ client = mod.LockClient(developer_id="test_user")
3524
+ client._parent_pid = 4242
3525
+ monkeypatch.setattr(
3526
+ client,
3527
+ "_graceful_shutdown",
3528
+ lambda reason=None: shutdown_reasons.append(reason),
3529
+ )
3530
+
3531
+ client._start_parent_monitor_thread()
3532
+
3533
+ assert len(closed) == 1
3534
+ assert "parent_exit_4242" in shutdown_reasons
3535
+
3536
+
3537
+ def test_daemon_start_unix_with_parent_pid(monkeypatch, tmp_path):
3538
+ """Cover line 1179: Unix daemon_start with parent_pid truthy (else branch)."""
3539
+ pid_file = tmp_path / "daemon.pid"
3540
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
3541
+ monkeypatch.setattr(mod.sys, "platform", "linux")
3542
+ monkeypatch.setattr(mod.os, "getpid", lambda: 1)
3543
+
3544
+ class FakeProc:
3545
+ pid = 77777
3546
+
3547
+ def mock_popen(*a, **k):
3548
+ return FakeProc()
3549
+
3550
+ monkeypatch.setattr(subprocess, "Popen", mock_popen)
3551
+ monkeypatch.setattr(mod.time, "sleep", lambda _: None)
3552
+ monkeypatch.setattr(
3553
+ mod.LockClient, "_is_process_alive", staticmethod(lambda pid: False)
3554
+ )
3555
+
3556
+ lc = mod.LockClient(developer_id="test_user")
3557
+ monkeypatch.setattr(lc, "_read_pid", lambda: None)
3558
+ # Return a non-zero parent_pid so the Unix else branch at 1179 is taken
3559
+ monkeypatch.setattr(lc, "_get_parent_ide_pid", lambda: (54321, "test_method"))
3560
+ monkeypatch.setattr(lc, "_get_process_info_local", lambda pid: ("fake_ide", None))
3561
+ monkeypatch.setattr(lc, "_write_pid", lambda pid: None)
3562
+ lc.daemon_start()
3563
+
3564
+
3565
+ def test_daemon_stop_test_mode_default_pid_file_skips_discovery(
3566
+ monkeypatch, tmp_path, capsys
3567
+ ):
3568
+ """daemon_stop short-circuits in test mode when default PID file is in use."""
3569
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
3570
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
3571
+ monkeypatch.setenv("COLLAB_TEST_MODE", "1")
3572
+
3573
+ default_pid = os.path.join(mod._COLLAB_ROOT, ".daemon.pid")
3574
+ monkeypatch.setattr(mod, "PID_FILE", default_pid)
3575
+ monkeypatch.setattr(
3576
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
3577
+ )
3578
+
3579
+ c = mod.LockClient(developer_id="test_user")
3580
+ calls = []
3581
+ monkeypatch.setattr(c, "_read_pid", lambda: None)
3582
+ monkeypatch.setattr(c, "_remove_pid", lambda: calls.append("removed"))
3583
+ monkeypatch.setattr(
3584
+ c,
3585
+ "_discover_running_watchers",
3586
+ lambda: (_ for _ in ()).throw(RuntimeError("should not discover")),
3587
+ )
3588
+
3589
+ c.daemon_stop()
3590
+ out = capsys.readouterr().out.lower()
3591
+ assert "no running watcher" in out
3592
+ assert calls == ["removed"]
3593
+
3594
+
3595
+ def test_daemon_stop_propagate_restore_setter_exception_swallowed(
3596
+ monkeypatch, tmp_path
3597
+ ):
3598
+ """daemon_stop swallows errors when restoring collab logger propagate state."""
3599
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
3600
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
3601
+ monkeypatch.setattr(mod, "PID_FILE", str(tmp_path / "daemon.pid"))
3602
+ monkeypatch.setattr(
3603
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
3604
+ )
3605
+
3606
+ c = mod.LockClient(developer_id="test_user")
3607
+ monkeypatch.setattr(c, "_read_pid", lambda: None)
3608
+ monkeypatch.setattr(c, "_discover_running_watchers", lambda: [])
3609
+ monkeypatch.setattr(c, "_remove_pid", lambda: None)
3610
+
3611
+ class _PropLogger:
3612
+ def __init__(self):
3613
+ self._prop = True
3614
+ self._writes = 0
3615
+
3616
+ @property
3617
+ def propagate(self):
3618
+ return self._prop
3619
+
3620
+ @propagate.setter
3621
+ def propagate(self, value):
3622
+ self._writes += 1
3623
+ if self._writes >= 2:
3624
+ raise RuntimeError("restore failed")
3625
+ self._prop = value
3626
+
3627
+ prop_logger = _PropLogger()
3628
+ real_get_logger = mod.logging.getLogger
3629
+
3630
+ def _patched_get_logger(name=None):
3631
+ if name == "collab":
3632
+ return prop_logger
3633
+ return real_get_logger(name) if name else real_get_logger()
3634
+
3635
+ monkeypatch.setattr(mod.logging, "getLogger", _patched_get_logger)
3636
+
3637
+ c.daemon_stop()
3638
+
3639
+
3640
+ def test_daemon_status_local_only_stale_pid_discovery_match_branch(
3641
+ monkeypatch, tmp_path
3642
+ ):
3643
+ """Local-only daemon_status returns running when discovered watcher cmdline
3644
+ matches."""
3645
+ pid_file = tmp_path / ".daemon.pid"
3646
+ pid_file.write_text("12345", encoding="utf-8")
3647
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
3648
+
3649
+ c = object.__new__(mod.LockClient)
3650
+ c.local_only = True
3651
+ monkeypatch.setattr(mod.LockClient, "_read_pid", staticmethod(lambda: 12345))
3652
+ monkeypatch.setattr(
3653
+ mod.LockClient,
3654
+ "_is_process_alive",
3655
+ staticmethod(lambda pid: pid in {12345, 22222}),
3656
+ )
3657
+ monkeypatch.setattr(
3658
+ mod.LockClient,
3659
+ "_get_cmdline_for_pid",
3660
+ staticmethod(
3661
+ lambda pid: (
3662
+ "python unrelated.py" if pid == 12345 else "python lock_client.py watch"
3663
+ )
3664
+ ),
3665
+ )
3666
+ monkeypatch.setattr(
3667
+ mod.LockClient,
3668
+ "_cmdline_matches_watcher",
3669
+ staticmethod(lambda cmd: "watch" in cmd),
3670
+ )
3671
+ monkeypatch.setattr(c, "_discover_running_watchers", lambda: [22222])
3672
+
3673
+ assert mod.LockClient.daemon_status(c) is True
3674
+
3675
+
3676
+ def test_daemon_status_local_only_stale_pid_discovery_exception_branch(
3677
+ monkeypatch, tmp_path
3678
+ ):
3679
+ """Local-only stale-pid discovery exceptions are swallowed and return False."""
3680
+ pid_file = tmp_path / ".daemon.pid"
3681
+ pid_file.write_text("12345", encoding="utf-8")
3682
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
3683
+
3684
+ c = object.__new__(mod.LockClient)
3685
+ c.local_only = True
3686
+ monkeypatch.setattr(mod.LockClient, "_read_pid", staticmethod(lambda: 12345))
3687
+ monkeypatch.setattr(
3688
+ mod.LockClient, "_is_process_alive", staticmethod(lambda _p: True)
3689
+ )
3690
+ monkeypatch.setattr(
3691
+ mod.LockClient,
3692
+ "_get_cmdline_for_pid",
3693
+ staticmethod(lambda _pid: "python unrelated.py"),
3694
+ )
3695
+ monkeypatch.setattr(
3696
+ mod.LockClient,
3697
+ "_cmdline_matches_watcher",
3698
+ staticmethod(lambda _cmd: False),
3699
+ )
3700
+ monkeypatch.setattr(
3701
+ c,
3702
+ "_discover_running_watchers",
3703
+ lambda: (_ for _ in ()).throw(RuntimeError("boom")),
3704
+ )
3705
+
3706
+ assert mod.LockClient.daemon_status(c) is False
3707
+
3708
+
3709
+ def test_daemon_status_local_only_missing_pid_discovery_cmdline_match(monkeypatch):
3710
+ """Local-only daemon_status uses cmdline-matching discovered watcher when no PID
3711
+ exists."""
3712
+ c = object.__new__(mod.LockClient)
3713
+ c.local_only = True
3714
+ monkeypatch.setattr(mod.LockClient, "_read_pid", staticmethod(lambda: None))
3715
+ monkeypatch.setattr(
3716
+ mod.LockClient, "_is_process_alive", staticmethod(lambda _p: True)
3717
+ )
3718
+ monkeypatch.setattr(
3719
+ mod.LockClient,
3720
+ "_get_cmdline_for_pid",
3721
+ staticmethod(lambda _pid: "python lock_client.py watch"),
3722
+ )
3723
+ monkeypatch.setattr(
3724
+ mod.LockClient,
3725
+ "_cmdline_matches_watcher",
3726
+ staticmethod(lambda cmd: "watch" in cmd),
3727
+ )
3728
+ monkeypatch.setattr(c, "_discover_running_watchers", lambda: [33333])
3729
+
3730
+ assert mod.LockClient.daemon_status(c) is True