collab-runtime 0.4.1__tar.gz → 0.4.2__tar.gz
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_runtime-0.4.1/collab_runtime.egg-info → collab_runtime-0.4.2}/PKG-INFO +1 -1
- collab_runtime-0.4.2/collab/dashboard/dashboard-format.js +74 -0
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/dashboard_server.py +158 -1
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/live_locks_watcher.py +22 -1
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/lock_client.py +150 -10
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/platform_probe.py +40 -0
- {collab_runtime-0.4.1 → collab_runtime-0.4.2/collab_runtime.egg-info}/PKG-INFO +1 -1
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab_runtime.egg-info/SOURCES.txt +1 -0
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/pyproject.toml +5 -2
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/LICENSE +0 -0
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/README.md +0 -0
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/__init__.py +0 -0
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/__main__.py +0 -0
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/agent_identity.py +0 -0
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/dashboard/index.html +0 -0
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/errors.py +0 -0
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/logging_config.py +0 -0
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/main.py +0 -0
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/safe_subprocess.py +0 -0
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/subprocess_bridge.py +0 -0
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab_runtime.egg-info/dependency_links.txt +0 -0
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab_runtime.egg-info/entry_points.txt +0 -0
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab_runtime.egg-info/requires.txt +0 -0
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab_runtime.egg-info/top_level.txt +0 -0
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/docs/pypi/README.md +0 -0
- {collab_runtime-0.4.1 → collab_runtime-0.4.2}/setup.cfg +0 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure formatting and routing helpers for the Collaborative Lock Dashboard.
|
|
3
|
+
* Loaded in the browser (global DashboardFormat) and in Jest (module.exports).
|
|
4
|
+
*/
|
|
5
|
+
(function (root, factory) {
|
|
6
|
+
const api = factory();
|
|
7
|
+
if (typeof module === "object" && module.exports) {
|
|
8
|
+
module.exports = api;
|
|
9
|
+
} else {
|
|
10
|
+
root.DashboardFormat = api;
|
|
11
|
+
}
|
|
12
|
+
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
|
|
13
|
+
function formatDateLong(dt) {
|
|
14
|
+
return dt.toLocaleDateString([], {
|
|
15
|
+
year: "numeric",
|
|
16
|
+
month: "long",
|
|
17
|
+
day: "numeric",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function formatTime24(dt) {
|
|
22
|
+
return dt.toLocaleTimeString([], {
|
|
23
|
+
hour: "2-digit",
|
|
24
|
+
minute: "2-digit",
|
|
25
|
+
hour12: false,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatDateTime24(dt) {
|
|
30
|
+
return formatDateLong(dt) + " " + formatTime24(dt);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function formatDurationMinutes(totalMinutes) {
|
|
34
|
+
const rounded = Math.max(0, Math.round(Number(totalMinutes) || 0));
|
|
35
|
+
if (!Number.isFinite(rounded) || rounded <= 0) {
|
|
36
|
+
return "0m";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const units = [
|
|
40
|
+
{ label: "mo", minutes: 30 * 24 * 60 },
|
|
41
|
+
{ label: "d", minutes: 24 * 60 },
|
|
42
|
+
{ label: "h", minutes: 60 },
|
|
43
|
+
{ label: "m", minutes: 1 },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
let remaining = rounded;
|
|
47
|
+
const parts = [];
|
|
48
|
+
|
|
49
|
+
units.forEach((unit) => {
|
|
50
|
+
if (remaining >= unit.minutes) {
|
|
51
|
+
const value = Math.floor(remaining / unit.minutes);
|
|
52
|
+
remaining -= value * unit.minutes;
|
|
53
|
+
parts.push(String(value) + unit.label);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return parts.length ? parts.join(" ") : "0m";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function routeFromHash(hashLike) {
|
|
61
|
+
const h = String(hashLike || "")
|
|
62
|
+
.replace("#", "")
|
|
63
|
+
.toLowerCase();
|
|
64
|
+
return h === "history" ? "history" : "locks";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
formatDateLong,
|
|
69
|
+
formatTime24,
|
|
70
|
+
formatDateTime24,
|
|
71
|
+
formatDurationMinutes,
|
|
72
|
+
routeFromHash,
|
|
73
|
+
};
|
|
74
|
+
});
|
|
@@ -8,16 +8,20 @@ from that directory — not from a lone temp file in ``/tmp``.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import atexit
|
|
11
|
+
import fnmatch
|
|
11
12
|
import http.server
|
|
12
13
|
import json
|
|
13
14
|
import logging
|
|
14
15
|
import os
|
|
16
|
+
import re
|
|
15
17
|
import tempfile
|
|
16
18
|
import threading
|
|
17
19
|
import time
|
|
18
20
|
import tomllib
|
|
19
21
|
import urllib.parse
|
|
20
|
-
|
|
22
|
+
import zipfile
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Callable, Iterable, Optional, Sequence, Tuple
|
|
21
25
|
|
|
22
26
|
logger = logging.getLogger(__name__)
|
|
23
27
|
|
|
@@ -206,8 +210,161 @@ def dashboard_directory(resource_root: str) -> str:
|
|
|
206
210
|
return os.path.join(resource_root, "dashboard")
|
|
207
211
|
|
|
208
212
|
|
|
213
|
+
# --- Dashboard static-asset packaging guards -------------------------------
|
|
214
|
+
#
|
|
215
|
+
# index.html loads sibling assets (e.g. ``dashboard-format.js``). If those files
|
|
216
|
+
# are not shipped in the wheel ``[tool.setuptools.package-data]`` the browser gets
|
|
217
|
+
# a 404 and the dashboard renders blank. The helpers below let the runtime warn on
|
|
218
|
+
# a broken install and let tests prove every referenced/shipped asset is packaged.
|
|
219
|
+
|
|
220
|
+
_PACKAGE_DASHBOARD_PREFIX = "dashboard/"
|
|
221
|
+
_WHEEL_DASHBOARD_PREFIX = "collab/dashboard/"
|
|
222
|
+
|
|
223
|
+
_LOCAL_SCRIPT_SRC = re.compile(
|
|
224
|
+
r'<script[^>]+src=["\'](?!https?://)([^"\']+)["\']',
|
|
225
|
+
re.IGNORECASE,
|
|
226
|
+
)
|
|
227
|
+
_LOCAL_LINK_HREF = re.compile(
|
|
228
|
+
r'<link[^>]+href=["\'](?!https?://)([^"\']+)["\']',
|
|
229
|
+
re.IGNORECASE,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _normalize_static_ref(ref: str) -> Optional[str]:
|
|
234
|
+
"""Strip query/fragment and reject absolute or protocol-relative refs."""
|
|
235
|
+
path = ref.split("?", 1)[0].split("#", 1)[0].strip()
|
|
236
|
+
if not path or path.startswith("/"):
|
|
237
|
+
return None
|
|
238
|
+
return path.replace("\\", "/")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def local_static_refs_from_html(html: str) -> Tuple[str, ...]:
|
|
242
|
+
"""Return sorted relative script/link paths referenced by dashboard HTML.
|
|
243
|
+
|
|
244
|
+
CDN (``https://``) and absolute (``/foo``) references are ignored; only assets
|
|
245
|
+
that must ship inside the package are returned.
|
|
246
|
+
"""
|
|
247
|
+
refs: list[str] = []
|
|
248
|
+
for pattern in (_LOCAL_SCRIPT_SRC, _LOCAL_LINK_HREF):
|
|
249
|
+
for raw in pattern.findall(html):
|
|
250
|
+
normalized = _normalize_static_ref(raw)
|
|
251
|
+
if normalized:
|
|
252
|
+
refs.append(normalized)
|
|
253
|
+
return tuple(sorted(set(refs)))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def shipped_dashboard_relative_paths(resource_root: str) -> Tuple[str, ...]:
|
|
257
|
+
"""Return dashboard-relative paths of files that must ship in the wheel.
|
|
258
|
+
|
|
259
|
+
Hidden files (e.g. injected ``.collab-dashboard-*`` temp HTML) are excluded.
|
|
260
|
+
"""
|
|
261
|
+
dash_dir = Path(dashboard_directory(resource_root))
|
|
262
|
+
if not dash_dir.is_dir():
|
|
263
|
+
return ()
|
|
264
|
+
return tuple(
|
|
265
|
+
path.relative_to(dash_dir).as_posix()
|
|
266
|
+
for path in sorted(dash_dir.rglob("*"))
|
|
267
|
+
if path.is_file() and not path.name.startswith(".")
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def missing_local_static_files(resource_root: str, html: str) -> Tuple[str, ...]:
|
|
272
|
+
"""Return local refs in *html* that are absent on disk under the dashboard dir."""
|
|
273
|
+
dash_dir = Path(dashboard_directory(resource_root))
|
|
274
|
+
return tuple(
|
|
275
|
+
rel
|
|
276
|
+
for rel in local_static_refs_from_html(html)
|
|
277
|
+
if not (dash_dir / rel).is_file()
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def verify_dashboard_static_assets(resource_root: str) -> Tuple[str, ...]:
|
|
282
|
+
"""Log and return any local assets referenced by index.html but missing.
|
|
283
|
+
|
|
284
|
+
Called on each template read so a broken wheel (or partial dev tree) surfaces a
|
|
285
|
+
clear error instead of a silent blank dashboard.
|
|
286
|
+
"""
|
|
287
|
+
dash_dir = Path(dashboard_directory(resource_root))
|
|
288
|
+
index_path = dash_dir / "index.html"
|
|
289
|
+
if not index_path.is_file():
|
|
290
|
+
logger.error("Dashboard template missing at %s", index_path)
|
|
291
|
+
return ("index.html",)
|
|
292
|
+
missing = missing_local_static_files(
|
|
293
|
+
resource_root, index_path.read_text(encoding="utf-8")
|
|
294
|
+
)
|
|
295
|
+
for rel in missing:
|
|
296
|
+
logger.error(
|
|
297
|
+
"Dashboard static asset missing at %s (broken wheel or dev tree)",
|
|
298
|
+
dash_dir / rel,
|
|
299
|
+
)
|
|
300
|
+
return missing
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def read_package_data_patterns(pyproject_path: str) -> Tuple[str, ...]:
|
|
304
|
+
"""Return ``[tool.setuptools.package-data].collab`` glob patterns."""
|
|
305
|
+
with open(pyproject_path, "rb") as fh:
|
|
306
|
+
data = tomllib.load(fh)
|
|
307
|
+
patterns = (
|
|
308
|
+
data.get("tool", {})
|
|
309
|
+
.get("setuptools", {})
|
|
310
|
+
.get("package-data", {})
|
|
311
|
+
.get("collab", [])
|
|
312
|
+
)
|
|
313
|
+
if isinstance(patterns, str):
|
|
314
|
+
return (patterns,)
|
|
315
|
+
if isinstance(patterns, list):
|
|
316
|
+
return tuple(str(p) for p in patterns)
|
|
317
|
+
return ()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def package_data_covers(relative_path: str, patterns: Sequence[str]) -> bool:
|
|
321
|
+
"""Return True when a dashboard-relative path matches a package-data glob."""
|
|
322
|
+
for pattern in patterns:
|
|
323
|
+
if not pattern.startswith(_PACKAGE_DASHBOARD_PREFIX):
|
|
324
|
+
continue
|
|
325
|
+
suffix = pattern[len(_PACKAGE_DASHBOARD_PREFIX) :]
|
|
326
|
+
if suffix == "**":
|
|
327
|
+
return True
|
|
328
|
+
if fnmatch.fnmatch(relative_path, suffix):
|
|
329
|
+
return True
|
|
330
|
+
return False
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def missing_package_data_coverage(
|
|
334
|
+
resource_root: str, patterns: Sequence[str]
|
|
335
|
+
) -> Tuple[str, ...]:
|
|
336
|
+
"""Return shipped dashboard files not matched by any package-data glob."""
|
|
337
|
+
return tuple(
|
|
338
|
+
rel
|
|
339
|
+
for rel in shipped_dashboard_relative_paths(resource_root)
|
|
340
|
+
if not package_data_covers(rel, patterns)
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def wheel_dashboard_member_paths(wheel_path: str) -> Tuple[str, ...]:
|
|
345
|
+
"""Return dashboard-relative paths contained in a built wheel archive."""
|
|
346
|
+
members: set[str] = set()
|
|
347
|
+
with zipfile.ZipFile(wheel_path) as archive:
|
|
348
|
+
for name in archive.namelist():
|
|
349
|
+
normalized = name.replace("\\", "/")
|
|
350
|
+
if normalized.startswith(_WHEEL_DASHBOARD_PREFIX) and not (
|
|
351
|
+
normalized.endswith("/")
|
|
352
|
+
):
|
|
353
|
+
members.add(normalized[len(_WHEEL_DASHBOARD_PREFIX) :])
|
|
354
|
+
return tuple(sorted(members))
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def missing_wheel_dashboard_files(
|
|
358
|
+
wheel_path: str, required: Iterable[str]
|
|
359
|
+
) -> Tuple[str, ...]:
|
|
360
|
+
"""Return required dashboard paths absent from a built wheel."""
|
|
361
|
+
present = set(wheel_dashboard_member_paths(wheel_path))
|
|
362
|
+
return tuple(sorted(set(required) - present))
|
|
363
|
+
|
|
364
|
+
|
|
209
365
|
def read_dashboard_template(resource_root: str) -> Optional[str]:
|
|
210
366
|
"""Read ``index.html`` from the dashboard package directory."""
|
|
367
|
+
verify_dashboard_static_assets(resource_root)
|
|
211
368
|
html_path = os.path.join(dashboard_directory(resource_root), "index.html")
|
|
212
369
|
if not os.path.exists(html_path):
|
|
213
370
|
logger.error("Dashboard file not found at %s", html_path)
|
|
@@ -282,6 +282,27 @@ def _git_capture_text(argv: list[str], *, cwd: str | None = None) -> str:
|
|
|
282
282
|
return ""
|
|
283
283
|
|
|
284
284
|
|
|
285
|
+
def _git_capture_status_porcelain() -> str:
|
|
286
|
+
"""Return ``git status --porcelain`` stdout, preserving leading whitespace.
|
|
287
|
+
|
|
288
|
+
Unlike :func:`_git_capture_text`, this trims only surrounding newlines and never
|
|
289
|
+
performs a full ``.strip()``. Porcelain lines begin with a 2-column status field
|
|
290
|
+
(XY) whose first column is a space for worktree-only changes (e.g. ``" M path"``).
|
|
291
|
+
Stripping the whole blob would remove the leading space of the FIRST line, shifting
|
|
292
|
+
the fixed-width parse in :func:`_parse_git_status_path` and silently dropping the
|
|
293
|
+
first character of that path.
|
|
294
|
+
"""
|
|
295
|
+
try:
|
|
296
|
+
captured = safe_subprocess.capture(
|
|
297
|
+
["git", "status", "--porcelain"], policy="git", cwd=_PROJECT_ROOT
|
|
298
|
+
)
|
|
299
|
+
if captured.ok:
|
|
300
|
+
return safe_subprocess.decode_output(captured.stdout).strip("\r\n")
|
|
301
|
+
except Exception as exc:
|
|
302
|
+
logger.debug("git status --porcelain failed: %s", exc)
|
|
303
|
+
return ""
|
|
304
|
+
|
|
305
|
+
|
|
285
306
|
def _get_developer_id() -> str:
|
|
286
307
|
"""Derive developer identity from git config or environment."""
|
|
287
308
|
try:
|
|
@@ -795,7 +816,7 @@ def _get_modified_and_unpushed_files() -> set[str]:
|
|
|
795
816
|
|
|
796
817
|
# Part 1: dirty/staged files
|
|
797
818
|
try:
|
|
798
|
-
out =
|
|
819
|
+
out = _git_capture_status_porcelain()
|
|
799
820
|
if out:
|
|
800
821
|
for line in out.splitlines():
|
|
801
822
|
if len(line) > 3:
|
|
@@ -1720,9 +1720,11 @@ class LockClient:
|
|
|
1720
1720
|
# processes for this workspace if the PID file is missing or stale.
|
|
1721
1721
|
pid = self._read_pid()
|
|
1722
1722
|
pids_to_stop: List[int] = []
|
|
1723
|
+
watcher_found = False
|
|
1723
1724
|
|
|
1724
1725
|
if pid and self._is_process_alive(pid):
|
|
1725
1726
|
pids_to_stop = [pid]
|
|
1727
|
+
watcher_found = True
|
|
1726
1728
|
else:
|
|
1727
1729
|
# Safety rail: during tests, never discover/stop external watcher
|
|
1728
1730
|
# processes when the module is still using the production PID file.
|
|
@@ -1740,21 +1742,17 @@ class LockClient:
|
|
|
1740
1742
|
self._remove_pid()
|
|
1741
1743
|
return
|
|
1742
1744
|
|
|
1743
|
-
# Attempt to discover live watcher processes related to this repo
|
|
1745
|
+
# Attempt to discover live watcher processes related to this repo.
|
|
1746
|
+
# Note: even when no Python watcher is found we still fall through
|
|
1747
|
+
# to the launcher-reaping step below, because an orphaned
|
|
1748
|
+
# ``collab.exe`` wrapper can outlive the watcher it spawned.
|
|
1744
1749
|
try:
|
|
1745
1750
|
found = self._discover_running_watchers()
|
|
1746
1751
|
if found:
|
|
1747
1752
|
pids_to_stop = found
|
|
1748
|
-
|
|
1749
|
-
print("No running watcher found.")
|
|
1750
|
-
logger.info("No running watcher found for this workspace")
|
|
1751
|
-
self._remove_pid()
|
|
1752
|
-
return
|
|
1753
|
+
watcher_found = True
|
|
1753
1754
|
except Exception as e:
|
|
1754
1755
|
logger.debug("Watcher discovery failed: %s", e)
|
|
1755
|
-
print("No running watcher found.")
|
|
1756
|
-
self._remove_pid()
|
|
1757
|
-
return
|
|
1758
1756
|
|
|
1759
1757
|
# Stop each discovered watcher PID (soft stop first, then force)
|
|
1760
1758
|
for target_pid in pids_to_stop:
|
|
@@ -1864,6 +1862,25 @@ class LockClient:
|
|
|
1864
1862
|
logger.info("Stopped watcher (PID: %d) (forced)", target_pid)
|
|
1865
1863
|
print("✅ Stopped.")
|
|
1866
1864
|
|
|
1865
|
+
# Defense-in-depth (Windows): reap orphaned ``collab.exe`` /
|
|
1866
|
+
# ``collab-watcher.exe`` console-script wrappers in this namespace.
|
|
1867
|
+
# These keep the venv ``.exe`` image locked (EBUSY on delete) and can
|
|
1868
|
+
# outlive the Python watcher when started by an older IDE extension.
|
|
1869
|
+
# Give a well-behaved wrapper a brief moment to exit on its own first.
|
|
1870
|
+
if pids_to_stop:
|
|
1871
|
+
time.sleep(0.5)
|
|
1872
|
+
reaped = self._reap_collab_launchers()
|
|
1873
|
+
if reaped:
|
|
1874
|
+
logger.info("Reaped %d orphaned collab launcher wrapper(s)", reaped)
|
|
1875
|
+
print(
|
|
1876
|
+
f"✅ Cleaned up {reaped} leftover collab launcher "
|
|
1877
|
+
f"process(es) locking the virtualenv."
|
|
1878
|
+
)
|
|
1879
|
+
|
|
1880
|
+
if not watcher_found and not reaped:
|
|
1881
|
+
print("No running watcher found.")
|
|
1882
|
+
logger.info("No running watcher found for this workspace")
|
|
1883
|
+
|
|
1867
1884
|
# Final cleanup: ensure canonical PID file removed
|
|
1868
1885
|
try:
|
|
1869
1886
|
self._remove_pid()
|
|
@@ -3335,7 +3352,13 @@ class LockClient:
|
|
|
3335
3352
|
return ""
|
|
3336
3353
|
if not captured.ok:
|
|
3337
3354
|
return ""
|
|
3338
|
-
|
|
3355
|
+
# NOTE: Only trim surrounding newlines, never a full ``.strip()``.
|
|
3356
|
+
# ``git status --porcelain`` lines begin with a 2-column status field
|
|
3357
|
+
# (XY) whose first column is a space for worktree-only changes (e.g.
|
|
3358
|
+
# " M path"). A full strip would remove the leading space of the FIRST
|
|
3359
|
+
# line, shifting the fixed-width parse in ``_parse_git_status_path`` and
|
|
3360
|
+
# silently dropping the first character of that path.
|
|
3361
|
+
return safe_subprocess.decode_output(captured.stdout).strip("\r\n")
|
|
3339
3362
|
|
|
3340
3363
|
@staticmethod
|
|
3341
3364
|
def _git_ref_exists(ref: str) -> bool:
|
|
@@ -3950,6 +3973,123 @@ class LockClient:
|
|
|
3950
3973
|
continue
|
|
3951
3974
|
return found
|
|
3952
3975
|
|
|
3976
|
+
def _launcher_cmdline_in_namespace(self, cmdline: str) -> bool:
|
|
3977
|
+
"""Return True for a watcher-launcher cmdline in this PID-file namespace.
|
|
3978
|
+
|
|
3979
|
+
Used to identify pip console-script wrappers (``collab.exe`` / ``collab-
|
|
3980
|
+
watcher.exe``) that launched *this* workspace's watcher. Requires a ``watch``
|
|
3981
|
+
invocation and a ``--pid-file`` matching the current namespace so launchers from
|
|
3982
|
+
unrelated workspaces are never targeted.
|
|
3983
|
+
"""
|
|
3984
|
+
if not cmdline:
|
|
3985
|
+
return False
|
|
3986
|
+
if "watch" not in cmdline.lower():
|
|
3987
|
+
return False
|
|
3988
|
+
return self._cmdline_matches_current_pid_namespace(cmdline)
|
|
3989
|
+
|
|
3990
|
+
def _discover_collab_launcher_pids(self) -> List[int]:
|
|
3991
|
+
"""Find running collab console-script launcher wrappers for this namespace.
|
|
3992
|
+
|
|
3993
|
+
Windows-only. The pip-generated ``collab.exe`` / ``collab-watcher.exe`` wrappers
|
|
3994
|
+
keep their own image file open for their entire lifetime, so an orphaned wrapper
|
|
3995
|
+
(e.g. spawned by an older IDE extension) blocks deletion of the virtualenv long
|
|
3996
|
+
after the underlying Python watcher has exited. Returns candidate launcher PIDs
|
|
3997
|
+
to reap (may be empty).
|
|
3998
|
+
"""
|
|
3999
|
+
if sys.platform != "win32":
|
|
4000
|
+
return []
|
|
4001
|
+
|
|
4002
|
+
launcher_names = {"collab.exe", "collab-watcher.exe"}
|
|
4003
|
+
candidates: set[int] = set()
|
|
4004
|
+
self_pid = os.getpid()
|
|
4005
|
+
|
|
4006
|
+
# Fast path: psutil enumeration.
|
|
4007
|
+
try:
|
|
4008
|
+
import psutil
|
|
4009
|
+
|
|
4010
|
+
for p in psutil.process_iter(attrs=("pid", "name", "cmdline")):
|
|
4011
|
+
try:
|
|
4012
|
+
pid = int(p.info.get("pid") or 0)
|
|
4013
|
+
if pid <= 0 or pid == self_pid:
|
|
4014
|
+
continue
|
|
4015
|
+
name = (p.info.get("name") or "").lower()
|
|
4016
|
+
if name not in launcher_names:
|
|
4017
|
+
continue
|
|
4018
|
+
cmdline = p.info.get("cmdline")
|
|
4019
|
+
cmd_str = (
|
|
4020
|
+
" ".join(cmdline)
|
|
4021
|
+
if isinstance(cmdline, (list, tuple))
|
|
4022
|
+
else str(cmdline or "")
|
|
4023
|
+
)
|
|
4024
|
+
if self._launcher_cmdline_in_namespace(cmd_str):
|
|
4025
|
+
candidates.add(pid)
|
|
4026
|
+
except Exception:
|
|
4027
|
+
continue
|
|
4028
|
+
return sorted(candidates)
|
|
4029
|
+
except Exception as exc:
|
|
4030
|
+
logger.debug("psutil launcher discovery unavailable/failed: %s", exc)
|
|
4031
|
+
|
|
4032
|
+
# Fallback: tasklist enumeration + per-PID cmdline lookup.
|
|
4033
|
+
try:
|
|
4034
|
+
for pid in platform_probe.iter_collab_launcher_pids():
|
|
4035
|
+
if pid == self_pid:
|
|
4036
|
+
continue
|
|
4037
|
+
cmd = self._get_cmdline_for_pid(pid)
|
|
4038
|
+
if cmd and self._launcher_cmdline_in_namespace(cmd):
|
|
4039
|
+
candidates.add(pid)
|
|
4040
|
+
except Exception as exc:
|
|
4041
|
+
logger.debug("tasklist launcher discovery failed: %s", exc)
|
|
4042
|
+
return sorted(candidates)
|
|
4043
|
+
|
|
4044
|
+
def _reap_collab_launchers(self) -> int:
|
|
4045
|
+
"""Force-terminate orphaned collab launcher wrappers in this namespace.
|
|
4046
|
+
|
|
4047
|
+
Windows-only defense-in-depth for ``daemon_stop``. The Python watcher is now
|
|
4048
|
+
launched via the interpreter, but older/already-deployed IDE extensions launched
|
|
4049
|
+
it through the ``collab.exe`` console-script wrapper. That wrapper can be left
|
|
4050
|
+
running (holding the venv ``.exe`` locked) even after the watcher PID is
|
|
4051
|
+
stopped. Reaping it makes the virtualenv deletable. Returns the number of
|
|
4052
|
+
wrappers terminated.
|
|
4053
|
+
"""
|
|
4054
|
+
if sys.platform != "win32" or _is_test_mode():
|
|
4055
|
+
return 0
|
|
4056
|
+
|
|
4057
|
+
try:
|
|
4058
|
+
launchers = self._discover_collab_launcher_pids()
|
|
4059
|
+
except Exception as exc:
|
|
4060
|
+
logger.debug("collab launcher discovery failed: %s", exc)
|
|
4061
|
+
return 0
|
|
4062
|
+
|
|
4063
|
+
skip = {os.getpid()}
|
|
4064
|
+
try:
|
|
4065
|
+
skip.add(os.getppid())
|
|
4066
|
+
except Exception:
|
|
4067
|
+
pass
|
|
4068
|
+
|
|
4069
|
+
reaped = 0
|
|
4070
|
+
for lpid in launchers:
|
|
4071
|
+
if lpid in skip:
|
|
4072
|
+
continue
|
|
4073
|
+
if not self._is_process_alive(lpid):
|
|
4074
|
+
continue
|
|
4075
|
+
logger.info(
|
|
4076
|
+
"Reaping orphaned collab launcher wrapper (PID: %d) holding venv .exe",
|
|
4077
|
+
lpid,
|
|
4078
|
+
)
|
|
4079
|
+
platform_probe.taskkill_force(lpid, tree=True)
|
|
4080
|
+
# Confirm termination; log if it stubbornly survives.
|
|
4081
|
+
for _ in range(10):
|
|
4082
|
+
if not self._is_process_alive(lpid):
|
|
4083
|
+
break
|
|
4084
|
+
time.sleep(0.2)
|
|
4085
|
+
if self._is_process_alive(lpid):
|
|
4086
|
+
logger.warning(
|
|
4087
|
+
"Collab launcher wrapper (PID: %d) survived reap attempt", lpid
|
|
4088
|
+
)
|
|
4089
|
+
else:
|
|
4090
|
+
reaped += 1
|
|
4091
|
+
return reaped
|
|
4092
|
+
|
|
3953
4093
|
def _read_pid_file(self) -> Optional[Dict[str, Any]]:
|
|
3954
4094
|
"""Read the PID file and return the metadata dictionary if available."""
|
|
3955
4095
|
if not os.path.exists(PID_FILE):
|
|
@@ -20,6 +20,11 @@ logger = logging.getLogger("collab.platform_probe")
|
|
|
20
20
|
|
|
21
21
|
_WIN_CREATION_FLAGS = 0x08000000
|
|
22
22
|
_PYTHON_IMAGE_NAMES = frozenset({"python.exe", "pythonw.exe", "python3.exe"})
|
|
23
|
+
# pip-generated console-script wrappers for the collab runtime. On Windows these
|
|
24
|
+
# ``.exe`` launchers hold their own image file open for the life of the process,
|
|
25
|
+
# which can block deletion of the virtualenv when a watcher was started through
|
|
26
|
+
# the wrapper (older IDE extensions) and the wrapper is left orphaned.
|
|
27
|
+
_COLLAB_LAUNCHER_IMAGE_NAMES = frozenset({"collab.exe", "collab-watcher.exe"})
|
|
23
28
|
|
|
24
29
|
|
|
25
30
|
def _require_pid(pid: int) -> int:
|
|
@@ -283,6 +288,41 @@ def ps_aux() -> str:
|
|
|
283
288
|
return _run_platform([exe, "aux"], timeout=60.0)
|
|
284
289
|
|
|
285
290
|
|
|
291
|
+
def iter_collab_launcher_pids() -> list[int]:
|
|
292
|
+
"""Collect PIDs for collab console-script launcher images (Windows).
|
|
293
|
+
|
|
294
|
+
Enumerates ``collab.exe`` and ``collab-watcher.exe`` processes via tasklist so
|
|
295
|
+
callers can reap orphaned wrappers that keep the virtualenv ``.exe`` locked. Returns
|
|
296
|
+
an empty list off Windows or when tasklist is unavailable.
|
|
297
|
+
"""
|
|
298
|
+
if sys.platform != "win32":
|
|
299
|
+
return []
|
|
300
|
+
pids: list[int] = []
|
|
301
|
+
seen: set[int] = set()
|
|
302
|
+
exe = _resolve("tasklist")
|
|
303
|
+
if not exe:
|
|
304
|
+
return []
|
|
305
|
+
for image in sorted(_COLLAB_LAUNCHER_IMAGE_NAMES):
|
|
306
|
+
out = _run_platform(
|
|
307
|
+
[exe, "/FI", f"IMAGENAME eq {image}", "/FO", "CSV", "/NH"],
|
|
308
|
+
timeout=30.0,
|
|
309
|
+
)
|
|
310
|
+
for line in out.splitlines():
|
|
311
|
+
line = line.strip()
|
|
312
|
+
if not line:
|
|
313
|
+
continue
|
|
314
|
+
parts = line.strip().strip('"').split('","')
|
|
315
|
+
if len(parts) >= 2:
|
|
316
|
+
try:
|
|
317
|
+
pid = int(parts[1])
|
|
318
|
+
if pid not in seen:
|
|
319
|
+
seen.add(pid)
|
|
320
|
+
pids.append(pid)
|
|
321
|
+
except (ValueError, IndexError):
|
|
322
|
+
continue
|
|
323
|
+
return pids
|
|
324
|
+
|
|
325
|
+
|
|
286
326
|
def iter_tasklist_python_pids() -> list[int]:
|
|
287
327
|
"""Collect PIDs from tasklist for known Python image names."""
|
|
288
328
|
pids: list[int] = []
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "collab-runtime"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.2"
|
|
8
8
|
description = "Collaborative file locking runtime"
|
|
9
9
|
readme = "docs/pypi/README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -40,7 +40,10 @@ where = ["."]
|
|
|
40
40
|
include = ["collab*"]
|
|
41
41
|
|
|
42
42
|
[tool.setuptools.package-data]
|
|
43
|
-
|
|
43
|
+
# Ship every dashboard static asset (HTML/JS/CSS) recursively so a wheel can never
|
|
44
|
+
# be missing a file referenced by index.html. A recursive glob means new assets are
|
|
45
|
+
# packaged automatically without re-listing each filename.
|
|
46
|
+
collab = ["dashboard/**"]
|
|
44
47
|
|
|
45
48
|
[tool.black]
|
|
46
49
|
line-length = 88
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|