aboutsummaryrefslogtreecommitdiff
path: root/app/backends
diff options
context:
space:
mode:
Diffstat (limited to 'app/backends')
-rw-r--r--app/backends/audiocpp/catalog.py16
-rw-r--r--app/backends/audiocpp/configsync.py17
-rw-r--r--app/backends/audiocpp/models.py90
-rw-r--r--app/backends/audiocpp/remote.py1
-rw-r--r--app/backends/audiocpp/status.py34
-rw-r--r--app/backends/audiocpp/wizard.py2
-rw-r--r--app/backends/common.py67
-rwxr-xr-xapp/backends/faster.py31
-rw-r--r--app/backends/probe.py21
-rw-r--r--app/backends/qwen.py24
-rw-r--r--app/backends/servers.py133
11 files changed, 266 insertions, 170 deletions
diff --git a/app/backends/audiocpp/catalog.py b/app/backends/audiocpp/catalog.py
index 1048185..048e325 100644
--- a/app/backends/audiocpp/catalog.py
+++ b/app/backends/audiocpp/catalog.py
@@ -44,6 +44,8 @@ def request_options_families(audiocpp_dir: Path) -> Dict[str, dict]:
spec = json.loads(spec_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
+ if not isinstance(spec, dict):
+ continue
options = spec.get("options")
request = options.get("request") if isinstance(options, dict) else None
if not isinstance(request, list) or not request:
@@ -161,12 +163,12 @@ def _default_package(packages: List[dict]) -> Optional[dict]:
if not packages:
return None
for package in packages:
- if package.get("default"):
+ if isinstance(package, dict) and package.get("default"):
return package
for package in packages:
- if package.get("format") == "gguf":
+ if isinstance(package, dict) and package.get("format") == "gguf":
return package
- return packages[0]
+ return packages[0] if isinstance(packages[0], dict) else None
def spec_gguf_rooted(spec: dict) -> bool:
@@ -292,6 +294,11 @@ def load_model_catalog(audiocpp_dir: Path) -> List[dict]:
spec = json.loads(spec_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
+ if not isinstance(spec, dict):
+ # Valid JSON that is not an object (a list, a string, a
+ # number): skip it like an unparsable one instead of crashing
+ # the wizard on a half-written spec file.
+ continue
sanitize_model_spec(spec)
tasks = spec.get("tasks") or []
if tasks:
@@ -305,7 +312,8 @@ def load_model_catalog(audiocpp_dir: Path) -> List[dict]:
# No task list: fall back to the category as before.
continue
family = spec.get("family") or spec_path.stem
- packages = spec.get("packages") or []
+ packages = [package for package in (spec.get("packages") or [])
+ if isinstance(package, dict)]
package = _default_package(packages)
if package is None:
# No installable package: skip (cannot be hosted from a path).
diff --git a/app/backends/audiocpp/configsync.py b/app/backends/audiocpp/configsync.py
index bce8142..0a0e5ad 100644
--- a/app/backends/audiocpp/configsync.py
+++ b/app/backends/audiocpp/configsync.py
@@ -14,10 +14,7 @@ from .constants import FALLBACK_PORT
def config_port() -> int:
"""Return the port of AUDIOCPP_API_URL in app/converter/config.py."""
- try:
- return urllib.parse.urlsplit(config.AUDIOCPP_API_URL).port or FALLBACK_PORT
- except ValueError:
- return FALLBACK_PORT
+ return common.port_of(config.AUDIOCPP_API_URL, FALLBACK_PORT)
def update_config_api_url_port(port: int, config_path: Optional[Path] = None) -> bool:
@@ -75,18 +72,6 @@ def update_server_config_port(port: int) -> bool:
return True
-def _apply_port_sync(port: int, accepted: bool) -> None:
- """Write the port into app/converter/config.py, or report when declined."""
- if accepted:
- if not update_config_api_url_port(port):
- print(f"[WARNING] Could not update {CONFIG_PATH}; edit "
- "AUDIOCPP_API_URL by hand so audiobook.py uses the "
- "new port")
- else:
- print("[WARNING] Left AUDIOCPP_API_URL unchanged; audiobook.py "
- f"will still use port {config_port()}")
-
-
def update_server_backend(backend: str) -> bool:
"""Rewrite the 'backend' in the checkout's server.json, or True when none.
diff --git a/app/backends/audiocpp/models.py b/app/backends/audiocpp/models.py
index 16c61e9..d150f62 100644
--- a/app/backends/audiocpp/models.py
+++ b/app/backends/audiocpp/models.py
@@ -16,10 +16,6 @@ from . import catalog as _catalog
# runner kills it and reports exit 124 (see run_console_subprocess).
DOWNLOAD_STALL_TIMEOUT = 300
-# Spec sanitizing lives with the catalog (the wizard's catalog view applies
-# the same in-memory repair; the download path materializes it through its
-# sanitized specs copy). The alias keeps this module's historical name.
-_sanitize_model_spec = _catalog.sanitize_model_spec
def _installed_display_names(audiocpp_dir: Path,
model_entries: Optional[List[dict]],
@@ -255,52 +251,21 @@ def _manager_supports_progress(manager: Path) -> bool:
and _manager_supports_flag(manager, "--cancel-file"))
-def _sanitize_model_spec(spec: dict) -> bool:
- """Repair dot ``strip_prefix`` packages in SPEC, in place.
-
- A package's ``strip_prefix`` is stripped from the front of every file
- path to get the local layout, so it only works when every file is
- listed under that prefix (``<prefix>/<file>``). A dot prefix ("." or
- "./") is meant for files written ``./<file>``; when the package instead
- lists repo-root files bare (``model.gguf``), the manager rejects the
- whole package ("file path does not start with strip_prefix '.': ...")
- and nothing can be downloaded. Root-level files need no prefix at all
- (upstream specs like minimax_music3.json store ""), so dropping the dot
- prefix is the safe repair. Prefixes naming a real directory are left
- alone — the correct remote paths cannot be guessed. Returns True when
- SPEC changed.
- """
- changed = False
- for package in spec.get("packages") or []:
- if not isinstance(package, dict):
- continue
- prefix = str(package.get("strip_prefix") or "").rstrip("/")
- if prefix not in (".", ".."):
- continue
- files = package.get("files")
- if not isinstance(files, list) or not files:
- continue
- if all(isinstance(item, str) and item.startswith(prefix + "/")
- for item in files):
- continue
- package["strip_prefix"] = ""
- changed = True
- return changed
-
-
def _prepare_specs_dir(audiocpp_dir: Path) -> Optional[Path]:
- """Return a temp specs dir with dot ``strip_prefix`` entries repaired.
+ """Return a temp specs dir with broken ``strip_prefix`` entries repaired.
audio.cpp's model manager accepts ``--specs-dir``, so a checkout whose
specs carry a broken ``strip_prefix`` can be installed from a sanitized
copy without modifying the checkout. Every ``model_specs/*.json`` is
- copied; the ones needing a repair are rewritten via
- ``_sanitize_model_spec`` (specs that fail to parse are copied verbatim
- so the manager reports them exactly as it would upstream). Returns None
- when no spec needed a repair (or the specs directory is missing or
- unreadable) — the caller then runs against the checkout's own specs.
- The caller owns the returned directory and removes it when the installs
- are done.
+ copied; the ones needing a repair are rewritten via the catalog's
+ ``sanitize_model_spec`` (both upstream spec bug classes: dot prefixes
+ and missing prefixes on $gguf-rooted single-GGUF packages — glm_tts/
+ outetts shipped like that). Specs that fail to parse are copied
+ verbatim so the manager reports them exactly as it would upstream.
+ Returns None when no spec needed a repair (or the specs directory is
+ missing or unreadable) — the caller then runs against the checkout's
+ own specs. The caller owns the returned directory and removes it when
+ the installs are done.
"""
specs_dir = audiocpp_dir / "model_specs"
try:
@@ -321,7 +286,7 @@ def _prepare_specs_dir(audiocpp_dir: Path) -> Optional[Path]:
except ValueError:
payloads.append((spec_path.name, text))
continue
- if isinstance(spec, dict) and _sanitize_model_spec(spec):
+ if isinstance(spec, dict) and _catalog.sanitize_model_spec(spec):
sanitized = True
text = json.dumps(spec, indent=2, ensure_ascii=False) + "\n"
payloads.append((spec_path.name, text))
@@ -707,12 +672,15 @@ def delete_model_files(server_json: Path, entries: List[dict]) -> int:
Each entry's ``rel`` is resolved exactly like the server resolves it
(relative against ``server_json``'s directory; absolute paths honored),
- then removed as a directory tree or a single file. Missing entries are
- ignored. Returns the number of paths removed. Used by the wizard's
+ then removed as a directory tree or a single file. Paths that escape
+ the checkout (``..`` segments, or an absolute path outside the
+ checkout's tree) are refused rather than deleted — the value comes
+ from a user-editable server.json. Missing entries are ignored.
+ Returns the number of paths removed. Used by the wizard's
"Delete unused models?" step — the regenerated server.json already only
lists the kept models, so no entry cleanup is needed here.
"""
- base = server_json.parent
+ base = server_json.parent.resolve()
removed = 0
for item in entries:
rel = item.get("rel")
@@ -720,16 +688,28 @@ def delete_model_files(server_json: Path, entries: List[dict]) -> int:
continue
path = Path(rel) if Path(rel).is_absolute() else base / rel
try:
- if not path.exists():
+ resolved = path.resolve()
+ except OSError:
+ continue
+ if base not in resolved.parents and resolved != base:
+ print(f"[WARNING] Refusing to remove {path}: outside the "
+ "audio.cpp checkout")
+ continue
+ if resolved == base:
+ print(f"[WARNING] Refusing to remove {path}: it is the "
+ "checkout directory itself")
+ continue
+ try:
+ if not resolved.exists():
continue
- if path.is_dir():
- shutil.rmtree(path, ignore_errors=True)
+ if resolved.is_dir():
+ shutil.rmtree(resolved, ignore_errors=True)
else:
- path.unlink()
+ resolved.unlink()
except OSError as exc:
- print(f"[WARNING] Could not remove {path}: {exc}")
+ print(f"[WARNING] Could not remove {resolved}: {exc}")
continue
- print(f"[OK] Removed unused model {path}")
+ print(f"[OK] Removed unused model {resolved}")
removed += 1
return removed
diff --git a/app/backends/audiocpp/remote.py b/app/backends/audiocpp/remote.py
index 31eddbf..20d4cef 100644
--- a/app/backends/audiocpp/remote.py
+++ b/app/backends/audiocpp/remote.py
@@ -1,6 +1,7 @@
"""Query a running audiocpp_server for its models and voices."""
import json
+import urllib.parse
import urllib.request
from typing import Dict, List, Optional
diff --git a/app/backends/audiocpp/status.py b/app/backends/audiocpp/status.py
index 9ccf8cc..a687066 100644
--- a/app/backends/audiocpp/status.py
+++ b/app/backends/audiocpp/status.py
@@ -54,16 +54,20 @@ def detect() -> BackendStatus:
launch = format_launch_hint(specs)
managed = servers.manages(specs)
remote_running, remote_urls = _detect_remote(managed)
+ running = managed or remote_running
# A more specific "part-way set up" label than unavailable/installed:
- # cloned but never built, or built but not configured.
+ # cloned but never built, or built but not configured. Only meaningful
+ # while the backend is not usable (a running server makes even a
+ # non-built checkout usable remotely — see the BackendStatus docstring).
partial = ""
- if not built:
- partial = "downloaded (not built)"
- elif not configured:
- partial = "built (not configured)"
+ if not running:
+ if not built:
+ partial = "downloaded (not built)"
+ elif not configured:
+ partial = "built (not configured)"
return BackendStatus("audiocpp", "audio.cpp", installed=built,
configured=configured,
- running=managed or remote_running,
+ running=running,
details=details, launch_hint=launch,
servers=specs, managed=managed,
remote=remote_running, remote_urls=remote_urls,
@@ -73,18 +77,12 @@ def detect() -> BackendStatus:
def _detect_remote(managed: bool = False) -> Tuple[bool, dict]:
"""Detect an externally-run audiocpp_server at the remote URL.
- Returns ``(running, {spec_name: url})``. The remote URL is probed only
- when configured (non-empty); a server answering there is ignored when it
- is this tool's own managed server (remote URL == local URL and our pid is
- still alive) — that instance is already reported as "[local]".
+ Returns ``(running, {spec_name: url})``; see probe.detect_remote_url
+ for the shared semantics.
"""
- url = (config.AUDIOCPP_REMOTE_URL or "").strip()
- if not url:
- return False, {}
- if managed and probe.same_endpoint(url, config.AUDIOCPP_API_URL):
- return False, {}
- if probe.identify_server(url) == probe.IDENTITY_AUDIOCPP:
- return True, {"audiocpp": url}
- return False, {}
+ return probe.detect_remote_url(config.AUDIOCPP_REMOTE_URL,
+ config.AUDIOCPP_API_URL,
+ probe.IDENTITY_AUDIOCPP, "audiocpp",
+ managed)
diff --git a/app/backends/audiocpp/wizard.py b/app/backends/audiocpp/wizard.py
index a1a2a6a..52a4f3f 100644
--- a/app/backends/audiocpp/wizard.py
+++ b/app/backends/audiocpp/wizard.py
@@ -331,7 +331,6 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
"build_mode": s.get("build_mode"),
"prebuilt_forced": False,
"lazy_load": True,
- "sync_port": None,
"wav_dir": s["wav_dir"],
"plan": s["plan"],
"download": s["download"],
@@ -1093,7 +1092,6 @@ def _collect_from_flags(args: argparse.Namespace,
"build_mode": build_mode,
"prebuilt_forced": prebuilt_forced,
"lazy_load": lazy_load,
- "sync_port": None,
"wav_dir": wav_dir,
"plan": plan,
"download": args.download,
diff --git a/app/backends/common.py b/app/backends/common.py
index d25a03e..24746b6 100644
--- a/app/backends/common.py
+++ b/app/backends/common.py
@@ -193,12 +193,32 @@ def wav_dir_preview(directory: Path) -> Tuple[str, str]:
return ("no .wav files", "info")
+def port_of(url: str, fallback: int) -> int:
+ """URL's explicit port, else FALLBACK (invalid URLs fall back too)."""
+ try:
+ return urllib.parse.urlsplit(url).port or fallback
+ except ValueError:
+ return fallback
+
+
def url_with_port(url: str, port: int) -> str:
- """Return URL with its port replaced/inserted as PORT."""
+ """Return URL with its port replaced/inserted as PORT.
+
+ Preserves the userinfo ("user:pass@host") and brackets IPv6 hosts
+ ("[::1]"), which a plain f"{host}:{port}" rebuild would mangle.
+ """
parts = urllib.parse.urlsplit(url)
host = parts.hostname or "127.0.0.1"
+ if ":" in host and not host.startswith("["):
+ host = f"[{host}]"
+ netloc = f"{host}:{port}"
+ if parts.username:
+ cred = parts.username
+ if parts.password:
+ cred = f"{cred}:{parts.password}"
+ netloc = f"{cred}@{netloc}"
return urllib.parse.urlunsplit(
- (parts.scheme or "http", f"{host}:{port}", parts.path, "", ""))
+ (parts.scheme or "http", netloc, parts.path, "", ""))
def normalize_remote_url(value: str) -> str:
@@ -408,14 +428,27 @@ def run_console_subprocess(argv: List[str], cwd: Optional[Path] = None,
def _reader() -> None:
nonlocal last_output
try:
- for raw in iter(proc.stdout.readline, b""):
- if not raw:
+ pending = ""
+ while True:
+ # read1 (not read/readline) returns whatever a single pipe
+ # read yields, without waiting to fill the buffer, and \r
+ # is treated as a line break: carriage-return progress bars
+ # (git clone --progress, tqdm/HuggingFace downloads) then
+ # surface incrementally and keep the stall watchdog fed —
+ # a readline-based reader blocked until the next \n would
+ # let a healthy download starve to the timeout.
+ chunk = proc.stdout.read1(4096)
+ if not chunk:
break
last_output = time.monotonic()
- text = raw.decode("utf-8", errors="replace")
- for line in text.splitlines():
+ pending += chunk.decode("utf-8", errors="replace")
+ parts = re.split(r"[\r\n]+", pending)
+ pending = parts.pop()
+ for line in parts:
if line:
emit(line)
+ if pending.strip():
+ emit(pending)
except (OSError, ValueError):
pass
@@ -448,11 +481,10 @@ def run_console_subprocess(argv: List[str], cwd: Optional[Path] = None,
_terminate_process_group(proc)
break
time.sleep(0.1)
- try:
- reader.join(timeout=5)
- finally:
- if reader.is_alive():
- reader.join(timeout=0)
+ # The reader is a daemon: a join timeout here only means the child
+ # closed its stdout but the thread is still draining — there is
+ # nothing useful left to wait for.
+ reader.join(timeout=5)
if cancelled:
return 130
if stalled:
@@ -476,10 +508,17 @@ def _terminate_process_group(proc) -> None:
"""
import signal
if sys.platform == "win32":
+ # TerminateProcess hits a single pid; children the server spawned
+ # would survive as orphans. taskkill /T walks and kills the whole
+ # process tree, then the poll loop reaps the direct child.
try:
- proc.terminate()
- except OSError:
- pass
+ subprocess.run(["taskkill", "/T", "/F", "/PID", str(proc.pid)],
+ capture_output=True, timeout=10)
+ except (OSError, subprocess.SubprocessError):
+ try:
+ proc.terminate()
+ except OSError:
+ pass
deadline = time.time() + 10
while time.time() < deadline:
if proc.poll() is not None:
diff --git a/app/backends/faster.py b/app/backends/faster.py
index 2d0cad3..34fae45 100755
--- a/app/backends/faster.py
+++ b/app/backends/faster.py
@@ -59,6 +59,8 @@ from ui import taskview, tui
FASTER_DIR_NAME = "faster-qwen3-tts"
FASTER_GIT_URL = "https://github.com/andimarafioti/faster-qwen3-tts"
FASTER_PIP_PKG = "faster-qwen3-tts[demo]"
+# The managed server's port when the configured URL names none.
+DEFAULT_PORT = 8000
# The dedicated venv faster-qwen3-tts is installed into (never the app env
# or the qwen backend's; the wheel pulls its own qwen-tts-hf dependency,
# which ships the same qwen_tts module upstream qwen-tts does).
@@ -78,14 +80,6 @@ def _is_cloned() -> bool:
return (_checkout() / "examples" / "openai_server.py").is_file()
-def _config_port() -> int:
- import urllib.parse
- try:
- return urllib.parse.urlsplit(config.FASTER_API_URL).port or 8000
- except ValueError:
- return 8000
-
-
def build_voices(wav_files: list, language: str, whisper_model: str) -> dict:
"""Transcribe each wav file and build the voices mapping."""
voices = {}
@@ -363,7 +357,7 @@ def _print_launch_hint(voices_path: Path) -> None:
return
print("[INFO] Clone faster-qwen3-tts to get examples/openai_server.py,")
print(f" then run it with --voices {voices_path} "
- f"--port {_config_port()}")
+ f"--port {common.port_of(config.FASTER_API_URL, DEFAULT_PORT)}")
def setup_screen(stdscr) -> int:
@@ -471,7 +465,8 @@ def detect() -> BackendStatus:
if cloned and voices_json.exists():
argv = [str(envs.env_python(FASTER_ENV)),
str(_checkout() / "examples" / "openai_server.py"),
- "--voices", str(voices_json), "--port", str(_config_port())]
+ "--voices", str(voices_json), "--port",
+ str(common.port_of(config.FASTER_API_URL, DEFAULT_PORT))]
# identity: /health must report model_loaded before the server is
# really usable (the model loads after the port opens).
specs = [ServerSpec("faster", config.FASTER_API_URL, argv,
@@ -491,17 +486,13 @@ def detect() -> BackendStatus:
def _detect_remote(managed: bool = False):
"""Detect an externally-run faster server at the remote URL.
- Returns ``(running, {spec_name: url})``; see audiocpp._detect_remote for
- the shared semantics (empty URL disables, own server not counted twice).
+ Returns ``(running, {spec_name: url})``; see probe.detect_remote_url
+ for the shared semantics (empty URL disables, own server not counted
+ twice).
"""
- url = (config.FASTER_REMOTE_URL or "").strip()
- if not url:
- return False, {}
- if managed and probe.same_endpoint(url, config.FASTER_API_URL):
- return False, {}
- if probe.identify_server(url) == probe.IDENTITY_FASTER:
- return True, {"faster": url}
- return False, {}
+ return probe.detect_remote_url(config.FASTER_REMOTE_URL,
+ config.FASTER_API_URL,
+ probe.IDENTITY_FASTER, "faster", managed)
def update(*, emit=None, cancel=None) -> int:
diff --git a/app/backends/probe.py b/app/backends/probe.py
index 64dc7ab..818aadb 100644
--- a/app/backends/probe.py
+++ b/app/backends/probe.py
@@ -142,6 +142,27 @@ def same_endpoint(url_a: str, url_b: str) -> bool:
return host_a == host_b and port_a == port_b
+def detect_remote_url(remote_url: str, local_url: str, identity: str,
+ name: str, managed: bool = False) -> tuple:
+ """The shared backend "is something answering at the remote URL?" check.
+
+ Returns ``(running, {name: url})``. An empty URL disables the check; a
+ remote URL equal to the configured local endpoint is ignored while
+ MANAGED (that server was started by this tool and is already reported
+ as "[local]"); otherwise the URL must answer HTTP as IDENTITY to count.
+ The faster and audio.cpp backends use it verbatim; qwen's is
+ model-aware and keeps its own variant.
+ """
+ url = (remote_url or "").strip()
+ if not url:
+ return False, {}
+ if managed and same_endpoint(url, local_url):
+ return False, {}
+ if identify_server(url) == identity:
+ return True, {name: url}
+ return False, {}
+
+
def health_payload(url: str, timeout: float = DEFAULT_TIMEOUT) -> Optional[dict]:
"""Return the server's ``/health`` JSON document, or None.
diff --git a/app/backends/qwen.py b/app/backends/qwen.py
index a94b3cc..c099c84 100644
--- a/app/backends/qwen.py
+++ b/app/backends/qwen.py
@@ -160,7 +160,10 @@ def delete_model_weights(models: Optional[List[str]] = None) -> int:
if not directory.is_dir():
continue
print(f"[INFO] Removing cached {MODEL_REPOS[name]} weights...")
- shutil.rmtree(directory, ignore_errors=True)
+ shutil.rmtree(directory)
+ if directory.exists():
+ print(f"[WARNING] Could not fully remove {directory}")
+ continue
removed += 1
if removed:
print(f"[OK] Deleted cached weights for {removed} "
@@ -174,19 +177,6 @@ def _is_installed() -> bool:
return envs.module_available("qwen_tts", QWEN_ENV)
-def _config_port(url: str, fallback: int) -> int:
- import urllib.parse
- try:
- return urllib.parse.urlsplit(url).port or fallback
- except ValueError:
- return fallback
-
-
-def current_model() -> str:
- """The model a fresh managed start hosts (DEFAULT_MODEL)."""
- return DEFAULT_MODEL
-
-
def model_for_identity(identity: Optional[str]) -> Optional[str]:
"""The model name a qwen demo answers as (None when not a known identity)."""
return IDENTITY_TO_MODEL.get(identity)
@@ -308,7 +298,7 @@ def build_spec(model: str) -> ServerSpec:
"qwen", url,
[str(envs.env_script("qwen-tts-demo", QWEN_ENV)), MODEL_REPOS[model],
"--ip", "127.0.0.1",
- "--port", str(_config_port(url, DEFAULT_PORT))],
+ "--port", str(common.port_of(url, DEFAULT_PORT))],
identity=desired_identity(model))
@@ -336,12 +326,12 @@ def detect() -> BackendStatus:
differs from the default one.
"""
installed = _is_installed()
- model = current_model()
+ model = DEFAULT_MODEL
url = config.QWEN_API_URL
details: List[str] = []
details.append("pip: installed" if installed else
"not installed — run setup to pip install qwen-tts")
- details.append(f"port: {_config_port(url, DEFAULT_PORT)}")
+ details.append(f"port: {common.port_of(url, DEFAULT_PORT)}")
details.append(f"default model: {model}")
specs = [build_spec(model)]
managed = servers.manages(specs)
diff --git a/app/backends/servers.py b/app/backends/servers.py
index c0f8f4d..25ab472 100644
--- a/app/backends/servers.py
+++ b/app/backends/servers.py
@@ -146,6 +146,43 @@ def _pid_alive(pid: int) -> bool:
return True
+def _process_start_token(pid: int) -> Optional[str]:
+ """A token that changes when the OS recycles PID (best effort).
+
+ On Linux the token is /proc's process start time (field 22): after a
+ crash and pid reuse, a bare signal-0 probe would happily bless the new
+ unrelated process as "our server", so the recorded token no longer
+ matching is what breaks that identification. Platforms without an
+ equivalent (macOS, Windows) return None — bare-pid probing stays, as
+ before.
+ """
+ if not sys.platform.startswith("linux"):
+ return None
+ try:
+ stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
+ # Fields 1/2 ("comm") may contain spaces and parentheses; starttime
+ # is field 22, i.e. the 20th entry after the closing paren.
+ fields = stat.rsplit(")", 1)[1].split()
+ return fields[19]
+ except (OSError, IndexError):
+ return None
+
+
+def _pid_owned(pid: int, token: Optional[str]) -> bool:
+ """True when PID is alive AND still the process the token recorded.
+
+ An empty/missing token (legacy pid files, non-Linux platforms) falls
+ back to the bare liveness probe; a recorded token must match the
+ process's current start time, so a recycled pid is reported dead.
+ """
+ if not _pid_alive(pid):
+ return False
+ if not token:
+ return True
+ current = _process_start_token(pid)
+ return current is None or current == token
+
+
def _reap_exited(pid: int) -> bool:
"""Reap PID when it is our exited child; True when confirmed dead.
@@ -169,20 +206,23 @@ def _reap_exited(pid: int) -> bool:
def _kill_pid(pid: int) -> bool:
- """Terminate PID (and its process group on POSIX). Returns True when dead."""
+ """Terminate PID (and its process tree). Returns True when dead."""
if sys.platform == "win32":
+ # TerminateProcess hits a single pid; the server may have spawned
+ # children that would survive as orphans. taskkill /T kills the
+ # whole tree (the same mechanism run_console_subprocess uses).
try:
- os.kill(pid, signal.SIGTERM)
- except (ProcessLookupError, PermissionError, OSError):
- return not _pid_alive(pid)
+ subprocess.run(["taskkill", "/T", "/F", "/PID", str(pid)],
+ capture_output=True, timeout=STOP_GRACE_SECONDS)
+ except (OSError, subprocess.SubprocessError):
+ try:
+ os.kill(pid, signal.SIGTERM)
+ except (ProcessLookupError, PermissionError, OSError):
+ return not _pid_alive(pid)
for _ in range(int(STOP_GRACE_SECONDS * 10)):
if not _pid_alive(pid):
return True
time.sleep(0.1)
- try:
- os.kill(pid, signal.SIGTERM)
- except OSError:
- pass
return not _pid_alive(pid)
# POSIX: kill the whole process group (started with start_new_session=True).
try:
@@ -279,6 +319,19 @@ def start(spec, progress: ProgressCallback = None,
pid_file.unlink()
except OSError:
pass
+ # Reserve the slot atomically (O_EXCL): two concurrent starters can
+ # both pass the liveness check above, but only one wins the create —
+ # the loser refuses instead of spawning a duplicate server on the port.
+ try:
+ pid_handle = pid_file.open("x")
+ except FileExistsError:
+ report({"kind": "error",
+ "message": f"a {spec.name} server is already starting "
+ "(its pid file appeared while this start was "
+ "running); stop it first"})
+ return False
+ except OSError:
+ pid_handle = None
cwd = getattr(spec, "cwd", None)
# Append so an earlier boot's output survives (crash-loop debugging);
@@ -299,13 +352,28 @@ def start(spec, progress: ProgressCallback = None,
report({"kind": "error",
"message": f"could not start server: {exc}"})
log_handle.close()
+ if pid_handle is not None:
+ pid_handle.close()
+ try:
+ pid_file.unlink()
+ except OSError:
+ pass
return False
log_handle.write(f"\n=== boot {datetime.now():%Y-%m-%d %H:%M:%S} "
f"(pid {proc.pid}) ===\n")
log_handle.flush()
log_handle.close()
- pid_file.write_text(str(proc.pid), encoding="utf-8")
+ # "pid token": the process's start time where the platform provides
+ # one, so a recycled pid is never mistaken for our server (see
+ # _pid_owned). Empty token = bare-pid probing.
+ token = _process_start_token(proc.pid) or ""
+ if pid_handle is not None:
+ try:
+ pid_handle.write(f"{proc.pid} {token}\n".strip() + "\n")
+ pid_handle.close()
+ except OSError:
+ pass
report({"kind": "starting", "name": spec.name,
"argv": " ".join(str(a) for a in argv),
"cwd": str(cwd) if cwd is not None else None,
@@ -354,7 +422,10 @@ def stop(name: str) -> bool:
Returns True when the process was terminated (or already gone). Returns
False when there is no pid file — the server was not started by this tool,
- so the user must stop it manually (e.g. close its terminal).
+ so the user must stop it manually (e.g. close its terminal). The pid
+ file is removed only once the kill succeeded: while the process is
+ still running, the record is what keeps a later ``start`` from spawning
+ a duplicate onto the same port.
"""
pid_file = _pid_path(name)
if not pid_file.exists():
@@ -362,15 +433,15 @@ def stop(name: str) -> bool:
"(not started by this tool — stop it manually)")
return False
try:
- pid = int(pid_file.read_text(encoding="utf-8").strip())
- except (OSError, ValueError):
+ pid = int(pid_file.read_text(encoding="utf-8").split()[0])
+ except (OSError, ValueError, IndexError):
print(f"[WARNING] could not read pid file {pid_file}; removing it")
try:
pid_file.unlink()
except OSError:
pass
return False
- if not _pid_alive(pid):
+ if not _pid_owned_recorded(pid, pid_file):
print(f"[INFO] {name} server (pid {pid}) already stopped")
try:
pid_file.unlink()
@@ -381,13 +452,24 @@ def stop(name: str) -> bool:
killed = _kill_pid(pid)
if killed:
print(f"[OK] {name} server stopped")
+ try:
+ pid_file.unlink()
+ except OSError:
+ pass
else:
- print(f"[WARNING] could not stop pid {pid}; stop it manually")
+ print(f"[WARNING] could not stop pid {pid}; stop it manually — the "
+ "pid file is kept so the server is not started twice")
+ return killed
+
+
+def _pid_owned_recorded(pid: int, pid_file: Path) -> bool:
+ """PID alive and still the process recorded in PID_FILE (token aware)."""
try:
- pid_file.unlink()
+ fields = pid_file.read_text(encoding="utf-8").split()
except OSError:
- pass
- return killed
+ return _pid_alive(pid)
+ token = fields[1] if len(fields) > 1 else None
+ return _pid_owned(pid, token)
def manages(specs) -> bool:
@@ -395,13 +477,15 @@ def manages(specs) -> bool:
A server counts as ours when ``start`` recorded a pid file for it and
that pid is still alive — the same ownership rule ``stop`` applies
- before refusing ("not started by this tool"). Used by the backends'
- ``detect()`` so the hub's status table can tag an up server as
- "[remote]" when it was launched outside this tool.
+ before refusing ("not started by this tool"). Where the platform
+ provides a start-time token, a recycled pid no longer counts as ours.
+ Used by the backends' ``detect()`` so the hub's status table can tag
+ an up server as "[remote]" when it was launched outside this tool.
"""
for spec in specs:
+ pid_file = _pid_path(spec.name)
pid = pid_for(spec.name)
- if pid is not None and _pid_alive(pid):
+ if pid is not None and _pid_owned_recorded(pid, pid_file):
return True
return False
@@ -412,12 +496,13 @@ def pid_for(name: str):
if not pid_file.exists():
return None
try:
- return int(pid_file.read_text(encoding="utf-8").strip())
- except (OSError, ValueError):
+ return int(pid_file.read_text(encoding="utf-8").split()[0])
+ except (OSError, ValueError, IndexError):
return None
def alive(name: str) -> bool:
"""True when the server named NAME was started by us and is still alive."""
+ pid_file = _pid_path(name)
pid = pid_for(name)
- return pid is not None and _pid_alive(pid)
+ return pid is not None and _pid_owned_recorded(pid, pid_file)