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.
Files changed (26) hide show
  1. {collab_runtime-0.4.1/collab_runtime.egg-info → collab_runtime-0.4.2}/PKG-INFO +1 -1
  2. collab_runtime-0.4.2/collab/dashboard/dashboard-format.js +74 -0
  3. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/dashboard_server.py +158 -1
  4. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/live_locks_watcher.py +22 -1
  5. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/lock_client.py +150 -10
  6. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/platform_probe.py +40 -0
  7. {collab_runtime-0.4.1 → collab_runtime-0.4.2/collab_runtime.egg-info}/PKG-INFO +1 -1
  8. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab_runtime.egg-info/SOURCES.txt +1 -0
  9. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/pyproject.toml +5 -2
  10. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/LICENSE +0 -0
  11. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/README.md +0 -0
  12. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/__init__.py +0 -0
  13. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/__main__.py +0 -0
  14. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/agent_identity.py +0 -0
  15. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/dashboard/index.html +0 -0
  16. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/errors.py +0 -0
  17. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/logging_config.py +0 -0
  18. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/main.py +0 -0
  19. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/safe_subprocess.py +0 -0
  20. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab/subprocess_bridge.py +0 -0
  21. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab_runtime.egg-info/dependency_links.txt +0 -0
  22. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab_runtime.egg-info/entry_points.txt +0 -0
  23. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab_runtime.egg-info/requires.txt +0 -0
  24. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/collab_runtime.egg-info/top_level.txt +0 -0
  25. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/docs/pypi/README.md +0 -0
  26. {collab_runtime-0.4.1 → collab_runtime-0.4.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: collab-runtime
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Collaborative file locking runtime
5
5
  Author-email: KirilMT <kiril.mt95@gmail.com>
6
6
  License-Expression: MIT
@@ -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
- from typing import Any, Callable, Optional, Tuple
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 = _git_capture_text(["git", "status", "--porcelain"])
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
- else:
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
- return safe_subprocess.decode_output(captured.stdout).strip()
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] = []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: collab-runtime
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Collaborative file locking runtime
5
5
  Author-email: KirilMT <kiril.mt95@gmail.com>
6
6
  License-Expression: MIT
@@ -13,6 +13,7 @@ collab/main.py
13
13
  collab/platform_probe.py
14
14
  collab/safe_subprocess.py
15
15
  collab/subprocess_bridge.py
16
+ collab/dashboard/dashboard-format.js
16
17
  collab/dashboard/index.html
17
18
  collab_runtime.egg-info/PKG-INFO
18
19
  collab_runtime.egg-info/SOURCES.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "collab-runtime"
7
- version = "0.4.1"
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
- collab = ["dashboard/index.html"]
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