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.
- collab/__init__.py +77 -0
- collab/__main__.py +11 -0
- collab_runtime-0.2.9.dist-info/METADATA +218 -0
- collab_runtime-0.2.9.dist-info/RECORD +82 -0
- collab_runtime-0.2.9.dist-info/WHEEL +5 -0
- collab_runtime-0.2.9.dist-info/entry_points.txt +3 -0
- collab_runtime-0.2.9.dist-info/licenses/LICENSE +21 -0
- collab_runtime-0.2.9.dist-info/top_level.txt +10 -0
- scripts/cleanup.py +395 -0
- scripts/collab_git_hook.py +190 -0
- scripts/format_code.py +594 -0
- scripts/generate_tests.py +560 -0
- scripts/validate_code.py +1397 -0
- src/__init__.py +4 -0
- src/dashboard/index.html +1131 -0
- src/live_locks_watcher.py +1982 -0
- src/lock_client.py +4268 -0
- src/logging_config.py +259 -0
- src/main.py +436 -0
- tests/backend/__init__.py +0 -0
- tests/backend/functional/__init__.py +0 -0
- tests/backend/functional/test_package_imports.py +43 -0
- tests/backend/integration/__init__.py +0 -0
- tests/backend/integration/test_cli_contract_parity.py +220 -0
- tests/backend/performance/__init__.py +0 -0
- tests/backend/reliability/__init__.py +0 -0
- tests/backend/security/__init__.py +0 -0
- tests/backend/unit/live_locks_watcher/__init__.py +5 -0
- tests/backend/unit/live_locks_watcher/_helpers.py +123 -0
- tests/backend/unit/live_locks_watcher/conftest.py +18 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_dashboard.py +188 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_developer.py +56 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_graceful_shutdown.py +459 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_main.py +1925 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_module.py +187 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_multi_session.py +320 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_notify.py +67 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_parsing.py +155 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_process_helpers.py +684 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_processing.py +173 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_prompt_abort.py +71 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_reconcile.py +516 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_scan.py +296 -0
- tests/backend/unit/lock_client/__init__.py +1 -0
- tests/backend/unit/lock_client/_helpers.py +132 -0
- tests/backend/unit/lock_client/test_lock_client_acquire.py +214 -0
- tests/backend/unit/lock_client/test_lock_client_active.py +104 -0
- tests/backend/unit/lock_client/test_lock_client_api.py +63 -0
- tests/backend/unit/lock_client/test_lock_client_cli.py +682 -0
- tests/backend/unit/lock_client/test_lock_client_daemon.py +3730 -0
- tests/backend/unit/lock_client/test_lock_client_dashboard.py +438 -0
- tests/backend/unit/lock_client/test_lock_client_discover.py +241 -0
- tests/backend/unit/lock_client/test_lock_client_force_release.py +354 -0
- tests/backend/unit/lock_client/test_lock_client_helper_branches.py +1890 -0
- tests/backend/unit/lock_client/test_lock_client_history.py +301 -0
- tests/backend/unit/lock_client/test_lock_client_isolation.py +316 -0
- tests/backend/unit/lock_client/test_lock_client_pid.py +75 -0
- tests/backend/unit/lock_client/test_lock_client_reconcile.py +464 -0
- tests/backend/unit/lock_client/test_lock_client_release.py +77 -0
- tests/backend/unit/lock_client/test_lock_client_shutdown.py +1110 -0
- tests/backend/unit/lock_client/test_lock_client_utils.py +474 -0
- tests/backend/unit/lock_client/test_lock_client_watch.py +866 -0
- tests/backend/unit/scripts/__init__.py +1 -0
- tests/backend/unit/scripts/_helpers.py +42 -0
- tests/backend/unit/scripts/test_cleanup.py +285 -0
- tests/backend/unit/scripts/test_collab_git_hook.py +280 -0
- tests/backend/unit/scripts/test_collab_git_hook_ported.py +50 -0
- tests/backend/unit/scripts/test_format_code.py +368 -0
- tests/backend/unit/scripts/test_format_code_ported.py +177 -0
- tests/backend/unit/scripts/test_generate_tests.py +305 -0
- tests/backend/unit/scripts/test_hook_templates.py +357 -0
- tests/backend/unit/scripts/test_setup_hook_overlay.py +95 -0
- tests/backend/unit/scripts/test_validate_code.py +867 -0
- tests/backend/unit/scripts/test_validate_code_ported.py +237 -0
- tests/backend/unit/test_entrypoints_main_run.py +83 -0
- tests/backend/unit/test_logging_config.py +529 -0
- tests/backend/unit/test_main_watch_pid_file.py +278 -0
- tests/conftest.py +167 -0
- tests/frontend/__init__.py +0 -0
- tests/frontend/jest/__init__.py +0 -0
- tests/frontend/playwright/__init__.py +0 -0
- 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
|