Compare commits
5 Commits
41891ddad4
..
v1.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f535fb77f | |||
| 8cf19d43c4 | |||
| 0ef4586698 | |||
| ed7c40cac9 | |||
| 1384ed9703 |
@@ -78,8 +78,8 @@ if sys.platform == "darwin":
|
|||||||
info_plist={
|
info_plist={
|
||||||
"CFBundleName": "Better Claude Config",
|
"CFBundleName": "Better Claude Config",
|
||||||
"CFBundleDisplayName": "Better Claude Config",
|
"CFBundleDisplayName": "Better Claude Config",
|
||||||
"CFBundleShortVersionString": "1.0.0",
|
"CFBundleShortVersionString": "1.2.1",
|
||||||
"CFBundleVersion": "1.0.0",
|
"CFBundleVersion": "1.2.1",
|
||||||
"NSHighResolutionCapable": True,
|
"NSHighResolutionCapable": True,
|
||||||
"NSRequiresAquaSystemAppearance": False, # supports dark mode
|
"NSRequiresAquaSystemAppearance": False, # supports dark mode
|
||||||
"LSMinimumSystemVersion": "11.0",
|
"LSMinimumSystemVersion": "11.0",
|
||||||
|
|||||||
+23
-2
@@ -59,7 +59,7 @@ KNOWN_FIELDS = {"command", "args", "env", "url", "type", "headers"}
|
|||||||
# binary. All network I/O here is fail-quiet (returns None on any problem)
|
# binary. All network I/O here is fail-quiet (returns None on any problem)
|
||||||
# so it's safe to run unattended, off the UI thread, at startup.
|
# so it's safe to run unattended, off the UI thread, at startup.
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
__version__ = "1.2.0"
|
__version__ = "1.2.1"
|
||||||
|
|
||||||
REPO_URL = "https://git.avezzano.io/the_og/better-claude-config"
|
REPO_URL = "https://git.avezzano.io/the_og/better-claude-config"
|
||||||
ISSUES_URL = f"{REPO_URL}/issues"
|
ISSUES_URL = f"{REPO_URL}/issues"
|
||||||
@@ -1581,6 +1581,23 @@ def server_log_path(name: str) -> Path | None:
|
|||||||
_STDERR_CAP = 4096 # bytes
|
_STDERR_CAP = 4096 # bytes
|
||||||
|
|
||||||
|
|
||||||
|
def _kill_process_tree_windows(pid: int) -> None:
|
||||||
|
"""
|
||||||
|
Kill `pid` and its whole descendant tree via `taskkill /T /F` (issue #13).
|
||||||
|
|
||||||
|
Popen.kill() only terminates the direct child; runner-style commands
|
||||||
|
(npx → node → server, cmd → real process) leave the actual server alive,
|
||||||
|
leaking a process on every Windows spawn test. taskkill walks the tree.
|
||||||
|
"""
|
||||||
|
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) # no console flash from the GUI exe
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
subprocess.run(
|
||||||
|
["taskkill", "/PID", str(pid), "/T", "/F"],
|
||||||
|
capture_output=True,
|
||||||
|
creationflags=flags,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
||||||
"""
|
"""
|
||||||
Attempt to start a stdio server and observe it for `timeout` seconds.
|
Attempt to start a stdio server and observe it for `timeout` seconds.
|
||||||
@@ -1675,6 +1692,10 @@ def _spawn_test_impl(data: dict, timeout: float) -> dict:
|
|||||||
)
|
)
|
||||||
if os.name != "nt":
|
if os.name != "nt":
|
||||||
popen_kwargs["start_new_session"] = True # own process group → clean kill
|
popen_kwargs["start_new_session"] = True # own process group → clean kill
|
||||||
|
else:
|
||||||
|
# The packaged app is windowed (console=False); without this every
|
||||||
|
# spawn test of a console server flashes a console window.
|
||||||
|
popen_kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proc = subprocess.Popen(args_list, **popen_kwargs)
|
proc = subprocess.Popen(args_list, **popen_kwargs)
|
||||||
@@ -1696,7 +1717,7 @@ def _spawn_test_impl(data: dict, timeout: float) -> dict:
|
|||||||
if os.name != "nt":
|
if os.name != "nt":
|
||||||
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL)
|
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL)
|
||||||
else:
|
else:
|
||||||
proc.kill() # best-effort on Windows
|
_kill_process_tree_windows(proc.pid)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
with contextlib.suppress(subprocess.TimeoutExpired):
|
with contextlib.suppress(subprocess.TimeoutExpired):
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "better-claude-config"
|
name = "better-claude-config"
|
||||||
version = "1.2.0"
|
version = "1.2.1"
|
||||||
description = "Cross-platform GUI for editing the mcpServers block of Claude Desktop and Claude Code configs"
|
description = "Cross-platform GUI for editing the mcpServers block of Claude Desktop and Claude Code configs"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
|
|||||||
+57
-2
@@ -3,9 +3,11 @@ proper test functions with tmp_path/monkeypatch fixtures)."""
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -329,6 +331,55 @@ def test_spawn_test_large_stderr_does_not_deadlock():
|
|||||||
assert len(r["stderr"]) <= c._STDERR_CAP
|
assert len(r["stderr"]) <= c._STDERR_CAP
|
||||||
|
|
||||||
|
|
||||||
|
def test_windows_tree_kill_uses_taskkill(monkeypatch):
|
||||||
|
"""The Windows kill path must walk the process tree (taskkill /T /F);
|
||||||
|
Popen.kill() alone orphans grandchildren (issue #13)."""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
calls.append(cmd)
|
||||||
|
return _FakeRC()
|
||||||
|
|
||||||
|
class _FakeRC:
|
||||||
|
returncode = 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(c.subprocess, "run", fake_run)
|
||||||
|
c._kill_process_tree_windows(1234)
|
||||||
|
assert calls == [["taskkill", "/PID", "1234", "/T", "/F"]]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name != "nt", reason="Windows-only: real process-tree kill")
|
||||||
|
def test_spawn_test_windows_kills_child_process_tree(tmp_path):
|
||||||
|
"""A spawn-tested parent that has its own child (npx -> node style) must
|
||||||
|
not leave the child running after the timeout kill (issue #13)."""
|
||||||
|
import subprocess as sp
|
||||||
|
import time as _t
|
||||||
|
|
||||||
|
pid_file = tmp_path / "child.pid"
|
||||||
|
parent_script = (
|
||||||
|
"import subprocess, sys, time\n"
|
||||||
|
"p = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])\n"
|
||||||
|
f"open({str(pid_file)!r}, 'w').write(str(p.pid))\n"
|
||||||
|
"time.sleep(60)\n"
|
||||||
|
)
|
||||||
|
r = c.spawn_test({"command": sys.executable, "args": ["-c", parent_script]}, timeout=2.0)
|
||||||
|
assert r["outcome"] == "ok" # parent was still running at timeout, then tree-killed
|
||||||
|
assert pid_file.is_file(), "parent never spawned its child"
|
||||||
|
child_pid = int(pid_file.read_text())
|
||||||
|
|
||||||
|
deadline = _t.time() + 5.0
|
||||||
|
alive = True
|
||||||
|
while _t.time() < deadline:
|
||||||
|
out = sp.run(
|
||||||
|
["tasklist", "/FI", f"PID eq {child_pid}"], capture_output=True, text=True
|
||||||
|
).stdout
|
||||||
|
alive = str(child_pid) in out
|
||||||
|
if not alive:
|
||||||
|
break
|
||||||
|
_t.sleep(0.25)
|
||||||
|
assert not alive, f"child pid {child_pid} survived the spawn-test kill"
|
||||||
|
|
||||||
|
|
||||||
def test_spawn_test_non_string_env_value():
|
def test_spawn_test_non_string_env_value():
|
||||||
# Pasted JSON can carry numeric env values ("env": {"PORT": 8080}) that never
|
# Pasted JSON can carry numeric env values ("env": {"PORT": 8080}) that never
|
||||||
# round-trip through the editor. Popen rejects non-str env; spawn_test must
|
# round-trip through the editor. Popen rejects non-str env; spawn_test must
|
||||||
@@ -1138,8 +1189,12 @@ def test_restart_claude_desktop_linux_refuses_without_touching_processes(monkeyp
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def test_dunder_version_matches_pyproject():
|
def test_dunder_version_matches_pyproject():
|
||||||
# Guards against the version drifting out of sync between the two places
|
# Guards against the version drifting out of sync between the two places
|
||||||
# a human might bump it.
|
# a human might bump it. Reads pyproject.toml rather than hard-coding a
|
||||||
assert c.__version__ == "1.2.0"
|
# version here (which would make this a third place to bump).
|
||||||
|
text = (Path(__file__).parent.parent / "pyproject.toml").read_text(encoding="utf-8")
|
||||||
|
m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
|
||||||
|
assert m, "no [project] version found in pyproject.toml"
|
||||||
|
assert c.__version__ == m.group(1)
|
||||||
|
|
||||||
|
|
||||||
def test_parse_version_basic():
|
def test_parse_version_basic():
|
||||||
|
|||||||
Reference in New Issue
Block a user