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