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,1110 @@
|
|
|
1
|
+
"""Graceful shutdown tests for LockClient._graceful_shutdown()."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from unittest import mock
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from ._helpers import FakeResponse, load_lock_client_module, make_create_client
|
|
12
|
+
|
|
13
|
+
mod = load_lock_client_module()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_graceful_shutdown_git_fallback(monkeypatch, tmp_path):
|
|
17
|
+
"""Test _graceful_shutdown falls back to release_all on git error."""
|
|
18
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
19
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
20
|
+
|
|
21
|
+
pid_file = tmp_path / "daemon.pid"
|
|
22
|
+
pid_file.write_text("12345")
|
|
23
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
24
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path))
|
|
25
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
26
|
+
monkeypatch.setattr(
|
|
27
|
+
mod, "_get_create_client", lambda: make_create_client(FakeResponse())
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
lc = mod.LockClient(developer_id="test_user")
|
|
31
|
+
|
|
32
|
+
def broken_git():
|
|
33
|
+
raise RuntimeError("git failed")
|
|
34
|
+
|
|
35
|
+
monkeypatch.setattr(lc, "_run_git_status", broken_git)
|
|
36
|
+
monkeypatch.setattr(lc, "release_all", mock.Mock(return_value=2))
|
|
37
|
+
|
|
38
|
+
lc._graceful_shutdown()
|
|
39
|
+
assert not pid_file.exists()
|
|
40
|
+
# Now verifies behavior: PRESERVE locks on shutdown
|
|
41
|
+
lc.release_all.assert_not_called()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_graceful_shutdown_smart_release(monkeypatch, tmp_path):
|
|
45
|
+
"""Test _graceful_shutdown selectively releases clean files."""
|
|
46
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
47
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
48
|
+
|
|
49
|
+
pid_file = tmp_path / "daemon.pid"
|
|
50
|
+
pid_file.write_text("12345")
|
|
51
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
52
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path))
|
|
53
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
54
|
+
monkeypatch.setattr(
|
|
55
|
+
mod, "_get_create_client", lambda: make_create_client(FakeResponse())
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
lc = mod.LockClient(developer_id="test_user")
|
|
59
|
+
|
|
60
|
+
monkeypatch.setattr(lc, "_run_git_status", lambda: " M src/dirty.py\n")
|
|
61
|
+
|
|
62
|
+
locks = [
|
|
63
|
+
{"developer_id": "test_user", "file_path": "src/dirty.py"},
|
|
64
|
+
{"developer_id": "test_user", "file_path": "src/clean.py"},
|
|
65
|
+
{"developer_id": "test_user", "file_path": ""},
|
|
66
|
+
{"developer_id": "other_user", "file_path": "src/other.py"},
|
|
67
|
+
]
|
|
68
|
+
monkeypatch.setattr(lc, "active", mock.Mock(return_value=locks))
|
|
69
|
+
|
|
70
|
+
release_mock = mock.Mock(return_value=(True, None))
|
|
71
|
+
monkeypatch.setattr(lc, "release", release_mock)
|
|
72
|
+
|
|
73
|
+
lc._graceful_shutdown()
|
|
74
|
+
|
|
75
|
+
# Now verifies behavior: PRESERVE locks on shutdown
|
|
76
|
+
release_mock.assert_not_called()
|
|
77
|
+
assert not pid_file.exists()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_graceful_shutdown_with_exception(monkeypatch, tmp_path):
|
|
81
|
+
"""Test _graceful_shutdown handles release errors gracefully."""
|
|
82
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
83
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
84
|
+
|
|
85
|
+
pid_file = tmp_path / "daemon.pid"
|
|
86
|
+
pid_file.write_text("12345")
|
|
87
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
88
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path))
|
|
89
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
90
|
+
monkeypatch.setattr(
|
|
91
|
+
mod, "_get_create_client", lambda: make_create_client(FakeResponse())
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
lc = mod.LockClient(developer_id="test_user")
|
|
95
|
+
|
|
96
|
+
# Force the fallback path and make it fail
|
|
97
|
+
def fail_git():
|
|
98
|
+
raise RuntimeError("git fail")
|
|
99
|
+
|
|
100
|
+
monkeypatch.setattr(lc, "_run_git_status", fail_git)
|
|
101
|
+
monkeypatch.setattr(lc, "release_all", mock.Mock(side_effect=RuntimeError("fail")))
|
|
102
|
+
|
|
103
|
+
lc._graceful_shutdown() # Should not raise
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# RESTORED: test_graceful_shutdown_releases_locks
|
|
107
|
+
def test_graceful_shutdown_releases_locks(monkeypatch, tmp_path):
|
|
108
|
+
"""Test _graceful_shutdown logs when locks are released (restored).
|
|
109
|
+
|
|
110
|
+
This covers the case where a lock owned by the current developer is present and the
|
|
111
|
+
graceful shutdown path should attempt to release it.
|
|
112
|
+
"""
|
|
113
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
114
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
115
|
+
|
|
116
|
+
pid_file = tmp_path / "daemon.pid"
|
|
117
|
+
pid_file.write_text("12345")
|
|
118
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
119
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path))
|
|
120
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
121
|
+
|
|
122
|
+
# Return locks to release
|
|
123
|
+
locks_data = [
|
|
124
|
+
{"file_path": "src/app.py", "developer_id": "test_user"},
|
|
125
|
+
]
|
|
126
|
+
response = FakeResponse(status=200, data=locks_data)
|
|
127
|
+
monkeypatch.setattr(mod, "_get_create_client", lambda: make_create_client(response))
|
|
128
|
+
|
|
129
|
+
lc = mod.LockClient(developer_id="test_user")
|
|
130
|
+
lc._graceful_shutdown()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
# Additional graceful_shutdown coverage tests
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_graceful_shutdown_test_mode_returns_early(monkeypatch, tmp_path):
|
|
139
|
+
"""_graceful_shutdown exits early in COLLAB_TEST_MODE=1."""
|
|
140
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
141
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
142
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "1")
|
|
143
|
+
monkeypatch.setattr(
|
|
144
|
+
mod, "_get_create_client", lambda: make_create_client(FakeResponse())
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
pid_file = tmp_path / "daemon.pid"
|
|
148
|
+
pid_file.write_text("12345")
|
|
149
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
150
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path))
|
|
151
|
+
|
|
152
|
+
lc = mod.LockClient(developer_id="test_user")
|
|
153
|
+
lc._graceful_shutdown()
|
|
154
|
+
# PID file should still exist (early return before removal)
|
|
155
|
+
assert pid_file.exists()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_graceful_shutdown_double_call_noop(monkeypatch, tmp_path):
|
|
159
|
+
"""Second call to _graceful_shutdown is a no-op (_shutdown_done guard)."""
|
|
160
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
161
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
162
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
163
|
+
monkeypatch.setattr(
|
|
164
|
+
mod, "_get_create_client", lambda: make_create_client(FakeResponse())
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
pid_file = tmp_path / "daemon.pid"
|
|
168
|
+
pid_file.write_text("12345")
|
|
169
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
170
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path))
|
|
171
|
+
monkeypatch.setattr(mod.time, "sleep", lambda x: None)
|
|
172
|
+
|
|
173
|
+
lc = mod.LockClient(developer_id="test_user")
|
|
174
|
+
lc._graceful_shutdown()
|
|
175
|
+
# Second call should be a no-op
|
|
176
|
+
lc._graceful_shutdown() # Should not raise
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_graceful_shutdown_writes_shutdown_marker(monkeypatch, tmp_path):
|
|
180
|
+
"""_graceful_shutdown writes .shutdown_complete marker file."""
|
|
181
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
182
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
183
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
184
|
+
monkeypatch.setattr(
|
|
185
|
+
mod, "_get_create_client", lambda: make_create_client(FakeResponse())
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
pid_file = tmp_path / "daemon.pid"
|
|
189
|
+
pid_file.write_text("12345")
|
|
190
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
191
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path))
|
|
192
|
+
monkeypatch.setattr(mod.time, "sleep", lambda x: None)
|
|
193
|
+
|
|
194
|
+
lc = mod.LockClient(developer_id="test_user")
|
|
195
|
+
lc._graceful_shutdown()
|
|
196
|
+
|
|
197
|
+
shutdown_file = tmp_path / ".shutdown_complete"
|
|
198
|
+
assert shutdown_file.exists()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_graceful_shutdown_writes_shutdown_marker_deep(monkeypatch, tmp_path):
|
|
202
|
+
"""_graceful_shutdown writes the .shutdown_complete marker file (deep path with
|
|
203
|
+
_make_client)."""
|
|
204
|
+
state_dir = str(tmp_path)
|
|
205
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", state_dir)
|
|
206
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
207
|
+
lc.active = mock.Mock(return_value=[])
|
|
208
|
+
lc._graceful_shutdown()
|
|
209
|
+
# Marker should exist in state dir
|
|
210
|
+
marker = tmp_path / ".shutdown_complete"
|
|
211
|
+
assert marker.exists()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_graceful_shutdown_preserves_locks(monkeypatch, tmp_path):
|
|
215
|
+
"""_graceful_shutdown does NOT release any locks (preserves them)."""
|
|
216
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
217
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
218
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
219
|
+
|
|
220
|
+
locks_data = [
|
|
221
|
+
{"file_path": "src/app.py", "developer_id": "test_user"},
|
|
222
|
+
{"file_path": "src/utils.py", "developer_id": "test_user"},
|
|
223
|
+
]
|
|
224
|
+
resp = FakeResponse(status=200, data=locks_data)
|
|
225
|
+
monkeypatch.setattr(mod, "_get_create_client", lambda: make_create_client(resp))
|
|
226
|
+
|
|
227
|
+
pid_file = tmp_path / "daemon.pid"
|
|
228
|
+
pid_file.write_text("12345")
|
|
229
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
230
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path))
|
|
231
|
+
monkeypatch.setattr(mod.time, "sleep", lambda x: None)
|
|
232
|
+
|
|
233
|
+
lc = mod.LockClient(developer_id="test_user")
|
|
234
|
+
release_mock = mock.Mock()
|
|
235
|
+
monkeypatch.setattr(lc, "release", release_mock)
|
|
236
|
+
monkeypatch.setattr(lc, "release_all", mock.Mock())
|
|
237
|
+
|
|
238
|
+
lc._graceful_shutdown()
|
|
239
|
+
|
|
240
|
+
release_mock.assert_not_called()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_graceful_shutdown_removes_pid_file(monkeypatch, tmp_path):
|
|
244
|
+
"""_graceful_shutdown removes the PID file."""
|
|
245
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
246
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
247
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
248
|
+
monkeypatch.setattr(
|
|
249
|
+
mod, "_get_create_client", lambda: make_create_client(FakeResponse())
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
pid_file = tmp_path / "daemon.pid"
|
|
253
|
+
pid_file.write_text("12345")
|
|
254
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
255
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path))
|
|
256
|
+
monkeypatch.setattr(mod.time, "sleep", lambda x: None)
|
|
257
|
+
|
|
258
|
+
lc = mod.LockClient(developer_id="test_user")
|
|
259
|
+
lc._graceful_shutdown()
|
|
260
|
+
|
|
261
|
+
assert not pid_file.exists()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_graceful_shutdown_with_reason(monkeypatch, tmp_path):
|
|
265
|
+
"""_graceful_shutdown logs the reason when provided."""
|
|
266
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
267
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
268
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
269
|
+
monkeypatch.setattr(
|
|
270
|
+
mod, "_get_create_client", lambda: make_create_client(FakeResponse())
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
pid_file = tmp_path / "daemon.pid"
|
|
274
|
+
pid_file.write_text("12345")
|
|
275
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
276
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path))
|
|
277
|
+
monkeypatch.setattr(mod.time, "sleep", lambda x: None)
|
|
278
|
+
|
|
279
|
+
lc = mod.LockClient(developer_id="test_user")
|
|
280
|
+
# Should not raise
|
|
281
|
+
lc._graceful_shutdown(reason="stop_requested")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_graceful_shutdown_active_raises(monkeypatch, tmp_path):
|
|
285
|
+
"""_graceful_shutdown handles exception in active() gracefully."""
|
|
286
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
287
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
288
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
289
|
+
monkeypatch.setattr(
|
|
290
|
+
mod, "_get_create_client", lambda: make_create_client(FakeResponse())
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
pid_file = tmp_path / "daemon.pid"
|
|
294
|
+
pid_file.write_text("12345")
|
|
295
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
296
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path))
|
|
297
|
+
monkeypatch.setattr(mod.time, "sleep", lambda x: None)
|
|
298
|
+
|
|
299
|
+
lc = mod.LockClient(developer_id="test_user")
|
|
300
|
+
monkeypatch.setattr(lc, "active", mock.Mock(side_effect=RuntimeError("API down")))
|
|
301
|
+
|
|
302
|
+
lc._graceful_shutdown() # Should not raise
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
# Deep shutdown / signal-handler / parent-monitor branch coverage tests
|
|
307
|
+
# ---------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _make_client(monkeypatch, tmp_path):
|
|
311
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
312
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
313
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path))
|
|
314
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
315
|
+
monkeypatch.setattr(
|
|
316
|
+
mod, "_get_create_client", lambda: make_create_client(FakeResponse())
|
|
317
|
+
)
|
|
318
|
+
pid_file = tmp_path / "daemon.pid"
|
|
319
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
320
|
+
return mod.LockClient(developer_id="test_user")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# ---------------------------------------------------------------------------
|
|
324
|
+
# _graceful_shutdown — no-reason branch (line 2521-2522 region)
|
|
325
|
+
# ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def test_graceful_shutdown_no_reason_logs_generic_message(monkeypatch, tmp_path):
|
|
329
|
+
"""_graceful_shutdown with no reason logs the generic message."""
|
|
330
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
331
|
+
lc.active = mock.Mock(return_value=[])
|
|
332
|
+
lc._graceful_shutdown(reason=None) # covers else branch at 2521-2522
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_graceful_shutdown_with_reason_logs_specific_message(monkeypatch, tmp_path):
|
|
336
|
+
"""_graceful_shutdown with a reason logs the reason-specific message."""
|
|
337
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
338
|
+
lc.active = mock.Mock(return_value=[])
|
|
339
|
+
lc._graceful_shutdown(reason="test_reason") # covers if branch at 2504
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def test_graceful_shutdown_active_raises_logs_error(monkeypatch, tmp_path):
|
|
343
|
+
"""Active() exception during shutdown is caught and logged."""
|
|
344
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
345
|
+
lc.active = mock.Mock(side_effect=RuntimeError("db down"))
|
|
346
|
+
lc._graceful_shutdown() # should not raise
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_graceful_shutdown_active_has_my_locks(monkeypatch, tmp_path):
|
|
350
|
+
"""Active() returns locks owned by developer — logs preserved message."""
|
|
351
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
352
|
+
lc.active = mock.Mock(
|
|
353
|
+
return_value=[
|
|
354
|
+
{"developer_id": "test_user", "file_path": "src/foo.py"},
|
|
355
|
+
{"developer_id": "other_user", "file_path": "src/bar.py"},
|
|
356
|
+
]
|
|
357
|
+
)
|
|
358
|
+
lc._graceful_shutdown() # covers lines where n_kept is incremented
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def test_graceful_shutdown_writes_shutdown_marker_deep_alt(monkeypatch, tmp_path):
|
|
362
|
+
"""_graceful_shutdown writes the .shutdown_complete marker file."""
|
|
363
|
+
state_dir = str(tmp_path)
|
|
364
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", state_dir)
|
|
365
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
366
|
+
lc.active = mock.Mock(return_value=[])
|
|
367
|
+
lc._graceful_shutdown()
|
|
368
|
+
# Marker should exist in state dir
|
|
369
|
+
marker = tmp_path / ".shutdown_complete"
|
|
370
|
+
assert marker.exists()
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def test_graceful_shutdown_shutdown_marker_open_fails(monkeypatch, tmp_path):
|
|
374
|
+
"""If writing shutdown marker raises, _graceful_shutdown doesn't propagate."""
|
|
375
|
+
import builtins
|
|
376
|
+
|
|
377
|
+
state_dir = str(tmp_path)
|
|
378
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", state_dir)
|
|
379
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
380
|
+
lc.active = mock.Mock(return_value=[])
|
|
381
|
+
|
|
382
|
+
real_open = builtins.open
|
|
383
|
+
|
|
384
|
+
def fail_open(path, *args, **kwargs):
|
|
385
|
+
if ".shutdown_complete" in str(path):
|
|
386
|
+
raise OSError("disk full")
|
|
387
|
+
return real_open(path, *args, **kwargs)
|
|
388
|
+
|
|
389
|
+
monkeypatch.setattr(builtins, "open", fail_open)
|
|
390
|
+
lc._graceful_shutdown() # should not raise
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def test_graceful_shutdown_pid_removal_retries(monkeypatch, tmp_path):
|
|
394
|
+
"""PID file removal retries on OSError then succeeds on third attempt."""
|
|
395
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
396
|
+
pid_file = tmp_path / "daemon.pid"
|
|
397
|
+
pid_file.write_text("99999")
|
|
398
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
399
|
+
lc.active = mock.Mock(return_value=[])
|
|
400
|
+
|
|
401
|
+
call_count = [0]
|
|
402
|
+
real_remove = os.remove
|
|
403
|
+
|
|
404
|
+
def flaky_remove(path):
|
|
405
|
+
call_count[0] += 1
|
|
406
|
+
if call_count[0] < 3 and ".pid" in str(path) and "shutdown" not in str(path):
|
|
407
|
+
raise OSError("busy")
|
|
408
|
+
real_remove(path)
|
|
409
|
+
|
|
410
|
+
monkeypatch.setattr(mod.os, "remove", flaky_remove)
|
|
411
|
+
monkeypatch.setattr(mod.time, "sleep", lambda x: None)
|
|
412
|
+
lc._graceful_shutdown()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def test_graceful_shutdown_flush_handler_raises(monkeypatch, tmp_path):
|
|
416
|
+
"""Handlers that raise during flush() are silently swallowed."""
|
|
417
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
418
|
+
lc.active = mock.Mock(return_value=[])
|
|
419
|
+
|
|
420
|
+
bad_handler = mock.MagicMock()
|
|
421
|
+
bad_handler.flush.side_effect = RuntimeError("broken")
|
|
422
|
+
|
|
423
|
+
with mock.patch("logging.getLogger") as mock_get_logger:
|
|
424
|
+
fake_logger = mock.MagicMock()
|
|
425
|
+
fake_logger.handlers = [bad_handler]
|
|
426
|
+
mock_get_logger.return_value = fake_logger
|
|
427
|
+
lc._graceful_shutdown() # should not raise
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ---------------------------------------------------------------------------
|
|
431
|
+
# _register_signal_handlers — SIGTERM / SIGBREAK / console handler
|
|
432
|
+
# ---------------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def test_register_signal_handlers_non_win32(monkeypatch, tmp_path):
|
|
436
|
+
"""On non-win32, SIGTERM + SIGINT are registered without error."""
|
|
437
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
438
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
439
|
+
|
|
440
|
+
signals_set = []
|
|
441
|
+
monkeypatch.setattr(
|
|
442
|
+
mod.signal, "signal", lambda sig, handler: signals_set.append(sig)
|
|
443
|
+
)
|
|
444
|
+
monkeypatch.setattr(mod.sys, "platform", "linux")
|
|
445
|
+
|
|
446
|
+
lc._register_signal_handlers()
|
|
447
|
+
assert mod.signal.SIGINT in signals_set or len(signals_set) >= 1
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def test_register_signal_handlers_test_mode_skips_atexit(monkeypatch, tmp_path):
|
|
451
|
+
"""In COLLAB_TEST_MODE=1, atexit is not registered."""
|
|
452
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
453
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "1")
|
|
454
|
+
|
|
455
|
+
atexit_registered = []
|
|
456
|
+
monkeypatch.setattr(mod.atexit, "register", lambda fn: atexit_registered.append(fn))
|
|
457
|
+
monkeypatch.setattr(mod.signal, "signal", lambda *a: None)
|
|
458
|
+
|
|
459
|
+
lc._register_signal_handlers()
|
|
460
|
+
assert len(atexit_registered) == 0
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def test_register_signal_handlers_win32_sigbreak(monkeypatch, tmp_path):
|
|
464
|
+
"""On win32 with SIGBREAK, the handler is registered."""
|
|
465
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
466
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "1")
|
|
467
|
+
|
|
468
|
+
signals_set = []
|
|
469
|
+
monkeypatch.setattr(
|
|
470
|
+
mod.signal, "signal", lambda sig, handler: signals_set.append(sig)
|
|
471
|
+
)
|
|
472
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
473
|
+
monkeypatch.setattr(mod.signal, "SIGBREAK", 21, raising=False)
|
|
474
|
+
|
|
475
|
+
lc._register_signal_handlers()
|
|
476
|
+
assert 21 in signals_set
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def test_register_signal_handlers_win32_sigbreak_exception(monkeypatch, tmp_path):
|
|
480
|
+
"""SIGBREAK registration failure is silently caught."""
|
|
481
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
482
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "1")
|
|
483
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
484
|
+
|
|
485
|
+
def raising_signal(sig, handler):
|
|
486
|
+
if sig == 21: # SIGBREAK
|
|
487
|
+
raise OSError("not permitted")
|
|
488
|
+
|
|
489
|
+
monkeypatch.setattr(mod.signal, "signal", raising_signal)
|
|
490
|
+
monkeypatch.setattr(mod.signal, "SIGBREAK", 21, raising=False)
|
|
491
|
+
|
|
492
|
+
lc._register_signal_handlers() # should not raise
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def test_register_signal_handlers_win32_console_handler_exception(
|
|
496
|
+
monkeypatch, tmp_path
|
|
497
|
+
):
|
|
498
|
+
"""If SetConsoleCtrlHandler import/call fails, handler is skipped silently."""
|
|
499
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
500
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "1")
|
|
501
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
502
|
+
monkeypatch.setattr(mod.signal, "signal", lambda *a: None)
|
|
503
|
+
monkeypatch.setattr(mod.signal, "SIGBREAK", 21, raising=False)
|
|
504
|
+
|
|
505
|
+
import builtins
|
|
506
|
+
|
|
507
|
+
real_import = builtins.__import__
|
|
508
|
+
|
|
509
|
+
def fail_ctypes_import(name, *args, **kwargs):
|
|
510
|
+
if name == "ctypes":
|
|
511
|
+
raise ImportError("no ctypes")
|
|
512
|
+
return real_import(name, *args, **kwargs)
|
|
513
|
+
|
|
514
|
+
monkeypatch.setattr(builtins, "__import__", fail_ctypes_import)
|
|
515
|
+
lc._register_signal_handlers() # should not raise
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# ---------------------------------------------------------------------------
|
|
519
|
+
# _start_parent_monitor_thread — branches on non-win32 / no parent / failure
|
|
520
|
+
# ---------------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def test_start_parent_monitor_thread_non_win32_returns_early(monkeypatch, tmp_path):
|
|
524
|
+
"""On non-win32, returns immediately without doing anything."""
|
|
525
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
526
|
+
monkeypatch.setattr(mod.sys, "platform", "linux")
|
|
527
|
+
lc._parent_pid = 9999
|
|
528
|
+
lc._start_parent_monitor_thread()
|
|
529
|
+
assert not lc._parent_monitor_started
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def test_start_parent_monitor_thread_no_parent_returns_early(monkeypatch, tmp_path):
|
|
533
|
+
"""Without a parent PID, returns early."""
|
|
534
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
535
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
536
|
+
lc._parent_pid = None
|
|
537
|
+
lc._start_parent_monitor_thread()
|
|
538
|
+
assert not lc._parent_monitor_started
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def test_start_parent_monitor_thread_openprocess_fails(monkeypatch, tmp_path):
|
|
542
|
+
"""If OpenProcess returns 0 (failure), monitor is not started."""
|
|
543
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
544
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
545
|
+
lc._parent_pid = 9999
|
|
546
|
+
|
|
547
|
+
fake_ctypes = mock.MagicMock()
|
|
548
|
+
fake_ctypes.windll.kernel32.OpenProcess.return_value = 0
|
|
549
|
+
fake_ctypes.windll.kernel32.GetLastError.return_value = 5
|
|
550
|
+
monkeypatch.setattr(mod, "ctypes", fake_ctypes, raising=False)
|
|
551
|
+
|
|
552
|
+
import builtins
|
|
553
|
+
|
|
554
|
+
real_import = builtins.__import__
|
|
555
|
+
|
|
556
|
+
def mock_import(name, *args, **kwargs):
|
|
557
|
+
if name == "ctypes":
|
|
558
|
+
return fake_ctypes
|
|
559
|
+
return real_import(name, *args, **kwargs)
|
|
560
|
+
|
|
561
|
+
monkeypatch.setattr(builtins, "__import__", mock_import)
|
|
562
|
+
lc._start_parent_monitor_thread()
|
|
563
|
+
assert not lc._parent_monitor_started
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def test_start_parent_monitor_thread_import_exception(monkeypatch, tmp_path):
|
|
567
|
+
"""If ctypes import fails, the exception is caught and monitor marked not
|
|
568
|
+
started."""
|
|
569
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
570
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
571
|
+
lc._parent_pid = 9999
|
|
572
|
+
|
|
573
|
+
import builtins
|
|
574
|
+
|
|
575
|
+
real_import = builtins.__import__
|
|
576
|
+
|
|
577
|
+
def fail_import(name, *args, **kwargs):
|
|
578
|
+
if name == "ctypes":
|
|
579
|
+
raise ImportError("no ctypes")
|
|
580
|
+
return real_import(name, *args, **kwargs)
|
|
581
|
+
|
|
582
|
+
monkeypatch.setattr(builtins, "__import__", fail_import)
|
|
583
|
+
lc._start_parent_monitor_thread()
|
|
584
|
+
assert not lc._parent_monitor_started
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def test_start_parent_monitor_thread_getlasterror_raises(monkeypatch, tmp_path):
|
|
588
|
+
"""If GetLastError() raises, the exception is swallowed; monitor still not
|
|
589
|
+
started."""
|
|
590
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
591
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
592
|
+
lc._parent_pid = 9999
|
|
593
|
+
|
|
594
|
+
import builtins
|
|
595
|
+
|
|
596
|
+
real_import = builtins.__import__
|
|
597
|
+
|
|
598
|
+
fake_ctypes = mock.MagicMock()
|
|
599
|
+
fake_ctypes.windll.kernel32.OpenProcess.return_value = 0
|
|
600
|
+
fake_ctypes.windll.kernel32.GetLastError.side_effect = OSError("fail")
|
|
601
|
+
fake_ctypes.WINFUNCTYPE = mock.MagicMock(return_value=mock.MagicMock())
|
|
602
|
+
fake_ctypes.wintypes = mock.MagicMock()
|
|
603
|
+
|
|
604
|
+
def mock_import(name, *args, **kwargs):
|
|
605
|
+
if name == "ctypes":
|
|
606
|
+
return fake_ctypes
|
|
607
|
+
if name in ("ctypes.wintypes", "wintypes"):
|
|
608
|
+
return fake_ctypes.wintypes
|
|
609
|
+
return real_import(name, *args, **kwargs)
|
|
610
|
+
|
|
611
|
+
monkeypatch.setattr(builtins, "__import__", mock_import)
|
|
612
|
+
lc._start_parent_monitor_thread() # should not raise
|
|
613
|
+
assert not lc._parent_monitor_started
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
# ---------------------------------------------------------------------------
|
|
617
|
+
# _graceful_shutdown — stray marker removal paths (lines 2578-2583)
|
|
618
|
+
# ---------------------------------------------------------------------------
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def test_graceful_shutdown_removes_stray_markers(monkeypatch, tmp_path):
|
|
622
|
+
"""Stray repo markers are removed during shutdown."""
|
|
623
|
+
state_dir = str(tmp_path)
|
|
624
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", state_dir)
|
|
625
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
626
|
+
lc.active = mock.Mock(return_value=[])
|
|
627
|
+
|
|
628
|
+
# Create stray marker files in COLLAB_ROOT
|
|
629
|
+
collab_root = getattr(mod, "_COLLAB_ROOT", str(tmp_path))
|
|
630
|
+
stray_shutdown = os.path.join(collab_root, ".shutdown_complete")
|
|
631
|
+
stray_summary = os.path.join(collab_root, ".startup_summary.json")
|
|
632
|
+
for p in (stray_shutdown, stray_summary):
|
|
633
|
+
try:
|
|
634
|
+
with open(p, "w") as f:
|
|
635
|
+
f.write("stray")
|
|
636
|
+
except OSError:
|
|
637
|
+
pass
|
|
638
|
+
|
|
639
|
+
lc._graceful_shutdown() # should not raise
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def test_graceful_shutdown_stray_marker_remove_fails(monkeypatch, tmp_path):
|
|
643
|
+
"""If stray marker removal raises, shutdown continues without error."""
|
|
644
|
+
state_dir = str(tmp_path)
|
|
645
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", state_dir)
|
|
646
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
647
|
+
lc.active = mock.Mock(return_value=[])
|
|
648
|
+
|
|
649
|
+
real_remove = os.remove
|
|
650
|
+
|
|
651
|
+
def raising_remove(path):
|
|
652
|
+
if ".shutdown_complete" in str(path) or ".startup_summary" in str(path):
|
|
653
|
+
raise OSError("permission denied")
|
|
654
|
+
real_remove(path)
|
|
655
|
+
|
|
656
|
+
monkeypatch.setattr(mod.os, "remove", raising_remove)
|
|
657
|
+
monkeypatch.setattr(mod.os.path, "exists", lambda p: True)
|
|
658
|
+
lc._graceful_shutdown() # should not raise
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
# ---------------------------------------------------------------------------
|
|
662
|
+
# _graceful_shutdown — fsync paths for log handlers (lines 2604-2660)
|
|
663
|
+
# ---------------------------------------------------------------------------
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def test_graceful_shutdown_fsync_on_file_handler(monkeypatch, tmp_path):
|
|
667
|
+
"""Fsync is attempted on file-backed log handlers during shutdown."""
|
|
668
|
+
state_dir = str(tmp_path)
|
|
669
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", state_dir)
|
|
670
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
671
|
+
lc.active = mock.Mock(return_value=[])
|
|
672
|
+
|
|
673
|
+
# Create a real file handler so fsync path is exercised
|
|
674
|
+
log_file = tmp_path / "test.log"
|
|
675
|
+
fh = logging.FileHandler(str(log_file))
|
|
676
|
+
collab_logger = logging.getLogger("collab.test_fsync")
|
|
677
|
+
collab_logger.addHandler(fh)
|
|
678
|
+
try:
|
|
679
|
+
lc._graceful_shutdown()
|
|
680
|
+
finally:
|
|
681
|
+
collab_logger.removeHandler(fh)
|
|
682
|
+
fh.close()
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def test_graceful_shutdown_fsync_raises_still_completes(monkeypatch, tmp_path):
|
|
686
|
+
"""If fsync raises, shutdown still completes."""
|
|
687
|
+
state_dir = str(tmp_path)
|
|
688
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", state_dir)
|
|
689
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
690
|
+
lc.active = mock.Mock(return_value=[])
|
|
691
|
+
|
|
692
|
+
monkeypatch.setattr(mod.os, "fsync", mock.Mock(side_effect=OSError("fsync fail")))
|
|
693
|
+
lc._graceful_shutdown() # should not raise
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def test_graceful_shutdown_logging_shutdown_raises(monkeypatch, tmp_path):
|
|
697
|
+
"""If logging.shutdown() raises, graceful_shutdown still completes."""
|
|
698
|
+
state_dir = str(tmp_path)
|
|
699
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", state_dir)
|
|
700
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
701
|
+
lc.active = mock.Mock(return_value=[])
|
|
702
|
+
|
|
703
|
+
monkeypatch.setattr(
|
|
704
|
+
mod.logging, "shutdown", mock.Mock(side_effect=RuntimeError("boom"))
|
|
705
|
+
)
|
|
706
|
+
lc._graceful_shutdown() # should not raise
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def test_graceful_shutdown_print_raises_is_swallowed(monkeypatch, tmp_path):
|
|
710
|
+
"""Print() failure in shutdown marker output path is swallowed."""
|
|
711
|
+
import builtins
|
|
712
|
+
|
|
713
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
714
|
+
lc.active = mock.Mock(return_value=[])
|
|
715
|
+
real_print = builtins.print
|
|
716
|
+
|
|
717
|
+
def flaky_print(*args, **kwargs):
|
|
718
|
+
raise OSError("stdout unavailable")
|
|
719
|
+
|
|
720
|
+
monkeypatch.setattr(builtins, "print", flaky_print)
|
|
721
|
+
lc._graceful_shutdown()
|
|
722
|
+
monkeypatch.setattr(builtins, "print", real_print)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def test_register_signal_handlers_calls_exception_logging(monkeypatch, tmp_path):
|
|
726
|
+
"""Signal callback exceptions in graceful shutdown are caught and logged."""
|
|
727
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
728
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "1")
|
|
729
|
+
monkeypatch.setattr(mod.sys, "platform", "linux")
|
|
730
|
+
|
|
731
|
+
handlers = {}
|
|
732
|
+
|
|
733
|
+
def capture_signal(sig, fn):
|
|
734
|
+
handlers[sig] = fn
|
|
735
|
+
|
|
736
|
+
monkeypatch.setattr(mod.signal, "signal", capture_signal)
|
|
737
|
+
monkeypatch.setattr(
|
|
738
|
+
lc, "_graceful_shutdown", mock.Mock(side_effect=RuntimeError("fail"))
|
|
739
|
+
)
|
|
740
|
+
monkeypatch.setattr(mod.sys, "exit", mock.Mock(side_effect=SystemExit(0)))
|
|
741
|
+
|
|
742
|
+
lc._register_signal_handlers()
|
|
743
|
+
with pytest.raises(SystemExit):
|
|
744
|
+
handlers[mod.signal.SIGINT](2, None)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def test_register_signal_handlers_console_handler_graceful_shutdown_exception(
|
|
748
|
+
monkeypatch, tmp_path
|
|
749
|
+
):
|
|
750
|
+
"""Windows console handler swallows graceful-shutdown exceptions."""
|
|
751
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
752
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "1")
|
|
753
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
754
|
+
monkeypatch.setattr(mod.signal, "signal", lambda *a: None)
|
|
755
|
+
monkeypatch.setattr(mod.signal, "SIGBREAK", 21, raising=False)
|
|
756
|
+
monkeypatch.setattr(
|
|
757
|
+
lc, "_graceful_shutdown", mock.Mock(side_effect=RuntimeError("boom"))
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
captured_console_handler = {"fn": None}
|
|
761
|
+
|
|
762
|
+
class _K32:
|
|
763
|
+
@staticmethod
|
|
764
|
+
def SetConsoleCtrlHandler(fn, enable):
|
|
765
|
+
captured_console_handler["fn"] = fn
|
|
766
|
+
return True
|
|
767
|
+
|
|
768
|
+
fake_ctypes = mock.MagicMock()
|
|
769
|
+
fake_ctypes.windll.kernel32 = _K32()
|
|
770
|
+
fake_ctypes.WINFUNCTYPE = lambda *a, **k: (lambda f: f)
|
|
771
|
+
fake_wintypes = mock.MagicMock()
|
|
772
|
+
fake_wintypes.BOOL = int
|
|
773
|
+
fake_wintypes.DWORD = int
|
|
774
|
+
|
|
775
|
+
import builtins
|
|
776
|
+
|
|
777
|
+
real_import = builtins.__import__
|
|
778
|
+
|
|
779
|
+
def mock_import(name, *args, **kwargs):
|
|
780
|
+
if name == "ctypes":
|
|
781
|
+
return fake_ctypes
|
|
782
|
+
if name == "ctypes.wintypes":
|
|
783
|
+
return fake_wintypes
|
|
784
|
+
return real_import(name, *args, **kwargs)
|
|
785
|
+
|
|
786
|
+
monkeypatch.setattr(builtins, "__import__", mock_import)
|
|
787
|
+
|
|
788
|
+
lc._register_signal_handlers()
|
|
789
|
+
assert captured_console_handler["fn"] is not None
|
|
790
|
+
captured_console_handler["fn"](2)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def test_start_parent_monitor_waiter_closes_handle_and_swallows_assign_exceptions(
|
|
794
|
+
monkeypatch, tmp_path
|
|
795
|
+
):
|
|
796
|
+
"""Waiter path covers close-handle failure and guarded attribute assignments."""
|
|
797
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
798
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
799
|
+
lc._parent_pid = 4242
|
|
800
|
+
|
|
801
|
+
monkeypatch.setattr(
|
|
802
|
+
lc, "_graceful_shutdown", mock.Mock(side_effect=RuntimeError("shutdown error"))
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
class _K32:
|
|
806
|
+
@staticmethod
|
|
807
|
+
def OpenProcess(access, inherit, pid):
|
|
808
|
+
return 999
|
|
809
|
+
|
|
810
|
+
@staticmethod
|
|
811
|
+
def WaitForSingleObject(hndl, timeout):
|
|
812
|
+
return 0
|
|
813
|
+
|
|
814
|
+
@staticmethod
|
|
815
|
+
def CloseHandle(hndl):
|
|
816
|
+
raise OSError("close failed")
|
|
817
|
+
|
|
818
|
+
fake_ctypes = mock.MagicMock()
|
|
819
|
+
fake_ctypes.windll.kernel32 = _K32()
|
|
820
|
+
|
|
821
|
+
class _ImmediateThread:
|
|
822
|
+
def __init__(self, target, args=(), daemon=None):
|
|
823
|
+
self._target = target
|
|
824
|
+
self._args = args
|
|
825
|
+
|
|
826
|
+
def start(self):
|
|
827
|
+
self._target(*self._args)
|
|
828
|
+
|
|
829
|
+
import builtins
|
|
830
|
+
|
|
831
|
+
real_import = builtins.__import__
|
|
832
|
+
|
|
833
|
+
def mock_import(name, *args, **kwargs):
|
|
834
|
+
if name == "ctypes":
|
|
835
|
+
return fake_ctypes
|
|
836
|
+
return real_import(name, *args, **kwargs)
|
|
837
|
+
|
|
838
|
+
monkeypatch.setattr(builtins, "__import__", mock_import)
|
|
839
|
+
monkeypatch.setattr(mod.threading, "Thread", _ImmediateThread)
|
|
840
|
+
lc._start_parent_monitor_thread()
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def test_start_parent_monitor_thread_thread_construction_failure(monkeypatch, tmp_path):
|
|
844
|
+
"""Thread construction failure triggers outer exception guard in monitor startup."""
|
|
845
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
846
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
847
|
+
lc._parent_pid = 8888
|
|
848
|
+
|
|
849
|
+
class _K32:
|
|
850
|
+
@staticmethod
|
|
851
|
+
def OpenProcess(access, inherit, pid):
|
|
852
|
+
return 111
|
|
853
|
+
|
|
854
|
+
fake_ctypes = mock.MagicMock()
|
|
855
|
+
fake_ctypes.windll.kernel32 = _K32()
|
|
856
|
+
|
|
857
|
+
import builtins
|
|
858
|
+
|
|
859
|
+
real_import = builtins.__import__
|
|
860
|
+
|
|
861
|
+
def mock_import(name, *args, **kwargs):
|
|
862
|
+
if name == "ctypes":
|
|
863
|
+
return fake_ctypes
|
|
864
|
+
return real_import(name, *args, **kwargs)
|
|
865
|
+
|
|
866
|
+
monkeypatch.setattr(builtins, "__import__", mock_import)
|
|
867
|
+
monkeypatch.setattr(
|
|
868
|
+
mod.threading, "Thread", mock.Mock(side_effect=RuntimeError("thread fail"))
|
|
869
|
+
)
|
|
870
|
+
lc._start_parent_monitor_thread()
|
|
871
|
+
assert lc._parent_monitor_started is False
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def test_register_signal_handlers_console_handler_outer_exception(
|
|
875
|
+
monkeypatch, tmp_path
|
|
876
|
+
):
|
|
877
|
+
"""Console handler outer-except branch is exercised when debug logging fails."""
|
|
878
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
879
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "1")
|
|
880
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
881
|
+
monkeypatch.setattr(mod.signal, "signal", lambda *a: None)
|
|
882
|
+
monkeypatch.setattr(mod.signal, "SIGBREAK", 21, raising=False)
|
|
883
|
+
|
|
884
|
+
captured_console_handler = {"fn": None}
|
|
885
|
+
|
|
886
|
+
class _K32:
|
|
887
|
+
@staticmethod
|
|
888
|
+
def SetConsoleCtrlHandler(fn, enable):
|
|
889
|
+
captured_console_handler["fn"] = fn
|
|
890
|
+
return True
|
|
891
|
+
|
|
892
|
+
fake_ctypes = mock.MagicMock()
|
|
893
|
+
fake_ctypes.windll.kernel32 = _K32()
|
|
894
|
+
fake_ctypes.WINFUNCTYPE = lambda *a, **k: (lambda f: f)
|
|
895
|
+
fake_wintypes = mock.MagicMock()
|
|
896
|
+
fake_wintypes.BOOL = int
|
|
897
|
+
fake_wintypes.DWORD = int
|
|
898
|
+
|
|
899
|
+
import builtins
|
|
900
|
+
|
|
901
|
+
real_import = builtins.__import__
|
|
902
|
+
|
|
903
|
+
def mock_import(name, *args, **kwargs):
|
|
904
|
+
if name == "ctypes":
|
|
905
|
+
return fake_ctypes
|
|
906
|
+
if name == "ctypes.wintypes":
|
|
907
|
+
return fake_wintypes
|
|
908
|
+
return real_import(name, *args, **kwargs)
|
|
909
|
+
|
|
910
|
+
monkeypatch.setattr(builtins, "__import__", mock_import)
|
|
911
|
+
|
|
912
|
+
real_debug = mod.logger.debug
|
|
913
|
+
|
|
914
|
+
def flaky_debug(msg, *args, **kwargs):
|
|
915
|
+
if isinstance(msg, str) and "Console control event" in msg:
|
|
916
|
+
raise RuntimeError("debug fail")
|
|
917
|
+
return real_debug(msg, *args, **kwargs)
|
|
918
|
+
|
|
919
|
+
monkeypatch.setattr(mod.logger, "debug", flaky_debug)
|
|
920
|
+
|
|
921
|
+
lc._register_signal_handlers()
|
|
922
|
+
assert captured_console_handler["fn"] is not None
|
|
923
|
+
captured_console_handler["fn"](9)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def test_start_parent_monitor_waiter_assignment_guards(monkeypatch, tmp_path):
|
|
927
|
+
"""Waiter assignment guard except blocks are hit when attribute sets fail in
|
|
928
|
+
waiter."""
|
|
929
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
930
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
931
|
+
lc._parent_pid = 5151
|
|
932
|
+
|
|
933
|
+
base_setattr = object.__setattr__
|
|
934
|
+
base_setattr(lc, "_in_waiter", False)
|
|
935
|
+
|
|
936
|
+
def guarded_setattr(self, name, value):
|
|
937
|
+
if getattr(self, "_in_waiter", False) and name in {
|
|
938
|
+
"_parent_monitor_started",
|
|
939
|
+
"_parent_monitor_handle",
|
|
940
|
+
"_parent_monitor_thread",
|
|
941
|
+
}:
|
|
942
|
+
raise RuntimeError("blocked in waiter")
|
|
943
|
+
base_setattr(self, name, value)
|
|
944
|
+
|
|
945
|
+
monkeypatch.setattr(type(lc), "__setattr__", guarded_setattr, raising=False)
|
|
946
|
+
monkeypatch.setattr(lc, "_graceful_shutdown", lambda *a, **k: None)
|
|
947
|
+
|
|
948
|
+
class _K32:
|
|
949
|
+
@staticmethod
|
|
950
|
+
def OpenProcess(access, inherit, pid):
|
|
951
|
+
return 222
|
|
952
|
+
|
|
953
|
+
@staticmethod
|
|
954
|
+
def WaitForSingleObject(hndl, timeout):
|
|
955
|
+
return 0
|
|
956
|
+
|
|
957
|
+
@staticmethod
|
|
958
|
+
def CloseHandle(hndl):
|
|
959
|
+
return True
|
|
960
|
+
|
|
961
|
+
fake_ctypes = mock.MagicMock()
|
|
962
|
+
fake_ctypes.windll.kernel32 = _K32()
|
|
963
|
+
|
|
964
|
+
class _ImmediateThread:
|
|
965
|
+
def __init__(self, target, args=(), daemon=None):
|
|
966
|
+
self._target = target
|
|
967
|
+
self._args = args
|
|
968
|
+
|
|
969
|
+
def start(self):
|
|
970
|
+
base_setattr(lc, "_in_waiter", True)
|
|
971
|
+
try:
|
|
972
|
+
self._target(*self._args)
|
|
973
|
+
finally:
|
|
974
|
+
base_setattr(lc, "_in_waiter", False)
|
|
975
|
+
|
|
976
|
+
import builtins
|
|
977
|
+
|
|
978
|
+
real_import = builtins.__import__
|
|
979
|
+
|
|
980
|
+
def mock_import(name, *args, **kwargs):
|
|
981
|
+
if name == "ctypes":
|
|
982
|
+
return fake_ctypes
|
|
983
|
+
return real_import(name, *args, **kwargs)
|
|
984
|
+
|
|
985
|
+
monkeypatch.setattr(builtins, "__import__", mock_import)
|
|
986
|
+
monkeypatch.setattr(mod.threading, "Thread", _ImmediateThread)
|
|
987
|
+
lc._start_parent_monitor_thread()
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def test_start_parent_monitor_waiter_outer_exception(monkeypatch, tmp_path):
|
|
991
|
+
"""Waiter outer exception branch is hit when WaitForSingleObject raises."""
|
|
992
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
993
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
994
|
+
lc._parent_pid = 6161
|
|
995
|
+
|
|
996
|
+
class _K32:
|
|
997
|
+
@staticmethod
|
|
998
|
+
def OpenProcess(access, inherit, pid):
|
|
999
|
+
return 333
|
|
1000
|
+
|
|
1001
|
+
@staticmethod
|
|
1002
|
+
def WaitForSingleObject(hndl, timeout):
|
|
1003
|
+
raise RuntimeError("wait fail")
|
|
1004
|
+
|
|
1005
|
+
@staticmethod
|
|
1006
|
+
def CloseHandle(hndl):
|
|
1007
|
+
return True
|
|
1008
|
+
|
|
1009
|
+
fake_ctypes = mock.MagicMock()
|
|
1010
|
+
fake_ctypes.windll.kernel32 = _K32()
|
|
1011
|
+
|
|
1012
|
+
class _ImmediateThread:
|
|
1013
|
+
def __init__(self, target, args=(), daemon=None):
|
|
1014
|
+
self._target = target
|
|
1015
|
+
self._args = args
|
|
1016
|
+
|
|
1017
|
+
def start(self):
|
|
1018
|
+
self._target(*self._args)
|
|
1019
|
+
|
|
1020
|
+
import builtins
|
|
1021
|
+
|
|
1022
|
+
real_import = builtins.__import__
|
|
1023
|
+
|
|
1024
|
+
def mock_import(name, *args, **kwargs):
|
|
1025
|
+
if name == "ctypes":
|
|
1026
|
+
return fake_ctypes
|
|
1027
|
+
return real_import(name, *args, **kwargs)
|
|
1028
|
+
|
|
1029
|
+
monkeypatch.setattr(builtins, "__import__", mock_import)
|
|
1030
|
+
monkeypatch.setattr(mod.threading, "Thread", _ImmediateThread)
|
|
1031
|
+
lc._start_parent_monitor_thread()
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def test_reconcile_git_failure_returns_current_user_locks(monkeypatch, tmp_path):
|
|
1035
|
+
"""Reconcile on git failure returns current user's active locks set."""
|
|
1036
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
1037
|
+
monkeypatch.setattr(
|
|
1038
|
+
lc,
|
|
1039
|
+
"_get_modified_and_unpushed_files",
|
|
1040
|
+
mock.Mock(side_effect=RuntimeError("git fail")),
|
|
1041
|
+
)
|
|
1042
|
+
monkeypatch.setattr(
|
|
1043
|
+
lc,
|
|
1044
|
+
"active",
|
|
1045
|
+
mock.Mock(
|
|
1046
|
+
return_value=[
|
|
1047
|
+
{"developer_id": "test_user", "file_path": "src/a.py"},
|
|
1048
|
+
{"developer_id": "other", "file_path": "src/b.py"},
|
|
1049
|
+
]
|
|
1050
|
+
),
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
out = lc._reconcile()
|
|
1054
|
+
assert out == {"src/a.py"}
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def test_reconcile_still_valid_same_machine_token_is_resumed(monkeypatch, tmp_path):
|
|
1058
|
+
"""still_valid lock with different token but same machine enters resumed list
|
|
1059
|
+
path."""
|
|
1060
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
1061
|
+
monkeypatch.setattr(lc, "_get_modified_and_unpushed_files", lambda: ["src/a.py"])
|
|
1062
|
+
monkeypatch.setattr(
|
|
1063
|
+
lc,
|
|
1064
|
+
"active",
|
|
1065
|
+
lambda: [
|
|
1066
|
+
{
|
|
1067
|
+
"developer_id": "test_user",
|
|
1068
|
+
"file_path": "src/a.py",
|
|
1069
|
+
"lock_token": "old-token",
|
|
1070
|
+
}
|
|
1071
|
+
],
|
|
1072
|
+
)
|
|
1073
|
+
monkeypatch.setattr(lc, "_get_session_token", lambda: "new-token")
|
|
1074
|
+
monkeypatch.setattr(lc, "_is_same_machine_token", lambda tok: True)
|
|
1075
|
+
monkeypatch.setattr(lc, "release_multiple", lambda x: None)
|
|
1076
|
+
monkeypatch.setattr(lc, "acquire_multiple", lambda *a, **k: (True, [], ""))
|
|
1077
|
+
|
|
1078
|
+
class _FakeTable:
|
|
1079
|
+
def update(self, *a, **k):
|
|
1080
|
+
return self
|
|
1081
|
+
|
|
1082
|
+
def eq(self, *a, **k):
|
|
1083
|
+
return self
|
|
1084
|
+
|
|
1085
|
+
def execute(self):
|
|
1086
|
+
return None
|
|
1087
|
+
|
|
1088
|
+
lc._client = mock.MagicMock()
|
|
1089
|
+
lc._client.table.return_value = _FakeTable()
|
|
1090
|
+
monkeypatch.setattr(mod, "_state_path", lambda name: str(tmp_path / name))
|
|
1091
|
+
monkeypatch.setattr(mod.time, "time", lambda: 0)
|
|
1092
|
+
|
|
1093
|
+
out = lc._reconcile()
|
|
1094
|
+
assert "src/a.py" in out
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def test_reconcile_summary_outer_guards_swallow_exceptions(monkeypatch, tmp_path):
|
|
1098
|
+
"""Summary writing/cleanup outer exception guards are exercised."""
|
|
1099
|
+
lc = _make_client(monkeypatch, tmp_path)
|
|
1100
|
+
monkeypatch.setattr(lc, "_get_modified_and_unpushed_files", lambda: [])
|
|
1101
|
+
monkeypatch.setattr(lc, "active", lambda: [])
|
|
1102
|
+
monkeypatch.setattr(lc, "_get_session_token", lambda: "tok")
|
|
1103
|
+
monkeypatch.setattr(mod, "_state_path", lambda name: str(tmp_path / name))
|
|
1104
|
+
|
|
1105
|
+
# Force Thread() creation in cleanup helper to fail (2871-2872 guard)
|
|
1106
|
+
monkeypatch.setattr(
|
|
1107
|
+
mod.threading, "Thread", mock.Mock(side_effect=RuntimeError("thread ctor fail"))
|
|
1108
|
+
)
|
|
1109
|
+
out = lc._reconcile()
|
|
1110
|
+
assert out == set()
|