diff options
Diffstat (limited to 'app/backends')
| -rw-r--r-- | app/backends/common.py | 46 | ||||
| -rw-r--r-- | app/backends/probe.py | 11 | ||||
| -rw-r--r-- | app/backends/servers.py | 189 | ||||
| -rw-r--r-- | app/backends/sglomni/catalog.py | 16 |
4 files changed, 177 insertions, 85 deletions
diff --git a/app/backends/common.py b/app/backends/common.py index 7974b58..20de432 100644 --- a/app/backends/common.py +++ b/app/backends/common.py @@ -9,6 +9,7 @@ TUI) so it can be reused without pulling curses into a non-interactive run. """ +import ast import os import re import shutil @@ -306,25 +307,50 @@ def update_config_value(key: str, value, """Set ``KEY`` to VALUE in app/converter/config.py and in memory. Only the value of the named assignment changes: indentation and any - trailing comment are preserved. Strings render double-quoted; other - literals (ints, booleans) render bare. After a successful write (or - when the file already holds VALUE) the new value is mirrored onto the - imported ``converter.config`` module, so a wizard's change takes - effect immediately instead of only after the next process start. - Returns True when the file now holds VALUE, False when it could not - be read or written (or KEY has no line in it). + trailing comment are preserved. Strings render as proper Python + literals via ``repr`` (quoting with bare double quotes would instead + produce invalid syntax — or silently change the value — whenever the + string itself contains a quote or a backslash, corrupting + config.py); other literals (ints, booleans) render bare. After a + successful write (or when the file already holds VALUE) the new + value is mirrored onto the imported ``converter.config`` module, so + a wizard's change takes effect immediately instead of only after + the next process start. Returns True when the file now holds VALUE, + False when it could not be read or written (or KEY has no line in + it, or the edit would not parse). """ path = Path(config_path) if config_path is not None else CONFIG_PATH - rendered = f'"{value}"' if isinstance(value, str) else str(value) + rendered = repr(value) if isinstance(value, str) else str(value) try: text = path.read_text(encoding="utf-8") match = re.search( - rf'(?m)^(\s*{re.escape(key)}\s*=\s*)("[^"]*"|\S+)(\s*(?:#.*)?)$', + rf'(?m)^(\s*{re.escape(key)}\s*=\s*)' + # Any valid Python string literal, single- or double-quoted + # (both spellings occur once repr() has written a value), + # else a bare literal token. + r'("[^"\\]*(?:\\.[^"\\]*)*"' + r"|'[^'\\]*(?:\\.[^'\\]*)*'" + r'|\S+)' + r'(\s*(?:#.*)?)$', text) if match is None: return False - if match.group(2) != rendered: + try: + # Semantic equality first: a file still holding the value in + # the old quoting style must not be rewritten (a no-op save + # stays a no-op), and the matched token may be any literal. + same = (match.group(2) == rendered + or ast.literal_eval(match.group(2)) == value) + except (ValueError, SyntaxError): + same = match.group(2) == rendered + if not same: text = text[:match.start(2)] + rendered + text[match.end(2):] + try: + # Never write a file that fails to import: a broken + # config.py breaks every later process start. + compile(text, str(path), "exec") + except (SyntaxError, ValueError): + return False path.write_text(text, encoding="utf-8") except OSError: return False diff --git a/app/backends/probe.py b/app/backends/probe.py index d54a9f3..c84c88d 100644 --- a/app/backends/probe.py +++ b/app/backends/probe.py @@ -107,8 +107,15 @@ def _identify_health(base: str, timeout: float) -> Optional[str]: def _identify_gradio(base: str, timeout: float) -> Optional[str]: - """Identify a qwen-tts Gradio demo from its ``/info`` named endpoints.""" - payload = _get_json(f"{base}/info", timeout) + """Identify a qwen-tts Gradio demo from its ``/info`` named endpoints. + + Modern Gradio (>= 4.x / 5.x) routes its API under ``/gradio_api`` — + its ``/info`` lives at ``/gradio_api/info`` with the legacy ``/info`` + path gone or deprecated — so both prefixes are probed. + """ + payload = _get_json(f"{base}/gradio_api/info", timeout) + if payload is None: + payload = _get_json(f"{base}/info", timeout) if payload is None: return None endpoints = payload.get("named_endpoints") diff --git a/app/backends/servers.py b/app/backends/servers.py index fd28866..781ecb5 100644 --- a/app/backends/servers.py +++ b/app/backends/servers.py @@ -22,6 +22,7 @@ boot screen, which renders the same events. Pid/log files live under ``app/logs/`` which is already gitignored. """ +import contextlib import os import re import signal @@ -142,6 +143,43 @@ def _pid_path(name: str) -> Path: return LOG_DIR / f"{name}-server.pid" +@contextlib.contextmanager +def _start_lock(name: str): + """Serialize a ``start``'s stale-pid cleanup, spawn and pid + publication for NAME. + + Two starters racing through ``start`` could otherwise unlink each + other's just-created, still-empty pid-file reservation (created + before the spawned pid is written) and both end up spawning a + server. The lock (an advisory fcntl/msvcrt lock, blocking so the + loser waits only as long as the winner's spawn takes) is held from + the liveness check until the pid file carries the spawned pid; the + boot wait happens outside it. The lock dies with its holder, so a + crashed starter never wedges later starts; platforms without file + locking degrade to the old unlocked behavior. + """ + LOG_DIR.mkdir(parents=True, exist_ok=True) + handle = open(LOG_DIR / f"{name}-server.start.lock", "w") + try: + try: + import fcntl + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + except OSError: + pass # degrade rather than fail the start + except ImportError: + try: + import msvcrt + handle.write("0") + handle.flush() + msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1) + except (ImportError, OSError): + pass + yield + finally: + handle.close() # releases the lock + + def _read_log_tail(name: str, lines: int = 20) -> List[str]: """Return the last LINES of the server's log (best-effort).""" try: @@ -338,10 +376,11 @@ def _kill_pid(pid: int) -> bool: except PermissionError: return False for _ in range(int(STOP_GRACE_SECONDS * 10)): - # Reap first so an exited (zombie) child ends the wait immediately - # instead of keeping the killpg(0) probe "alive" until SIGKILL. - if _reap_exited(pid): - return True + # Reap each round so an exited (zombie) child stops keeping the + # killpg(0) probe "alive" until SIGKILL. Reaping the launcher is + # NOT proof the group is gone — workers can outlive it — so the + # group probe below, not the reap, decides when the wait ends. + _reap_exited(pid) try: os.killpg(pgid, 0) except ProcessLookupError: @@ -429,82 +468,86 @@ def start(spec, progress: ProgressCallback = None, # Refuse to double-start: a live pid file means a previous start is # still booting (or its process is wedged). Spawning a second server # on the same port would orphan the first with no pid record left. - if alive(spec.name): - report({"kind": "error", - "message": f"a {spec.name} server (pid " - f"{pid_for(spec.name)}) is already starting or " - "running; stop it first"}) - return False - # Refuse to spawn onto a port a foreign process already holds: TCP-up - # but probe-down means the listener is not a usable instance of this - # server. A fresh spawn would then either die on the bind or (launchers - # that fall back silently, like sglang-omni) move to a random port and - # leave every client polling the taken one — the boot watchdog below - # catches that late, so name the conflict here. - if listening: - report({"kind": "error", "message": _port_taken_message(spec)}) - return False - pid_file = _pid_path(spec.name) - if pid_file.exists(): + # The start lock keeps the liveness check, stale cleanup, spawn and + # pid publication serialized against a concurrent starter (see + # _start_lock); the boot wait below runs outside it. + with _start_lock(spec.name): + if alive(spec.name): + report({"kind": "error", + "message": f"a {spec.name} server (pid " + f"{pid_for(spec.name)}) is already starting or " + "running; stop it first"}) + return False + # Refuse to spawn onto a port a foreign process already holds: TCP-up + # but probe-down means the listener is not a usable instance of this + # server. A fresh spawn would then either die on the bind or (launchers + # that fall back silently, like sglang-omni) move to a random port and + # leave every client polling the taken one — the boot watchdog below + # catches that late, so name the conflict here. + if listening: + report({"kind": "error", "message": _port_taken_message(spec)}) + return False + pid_file = _pid_path(spec.name) + if pid_file.exists(): + try: + 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_file.unlink() + 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: - 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); - # the child inherits the handle and the parent's copy is closed right - # after the spawn, so nothing leaks here. - log_handle = _log_path(spec.name).open("a", encoding="utf-8") - popen_kwargs = {"stdout": log_handle, "stderr": subprocess.STDOUT} - if cwd is not None: - popen_kwargs["cwd"] = str(cwd) - if sys.platform == "win32": - popen_kwargs["creationflags"] = \ - subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] - else: - popen_kwargs["start_new_session"] = True - try: - proc = subprocess.Popen(argv, **popen_kwargs) - except OSError as exc: - report({"kind": "error", - "message": f"could not start server: {exc}"}) + pid_handle = None + + cwd = getattr(spec, "cwd", None) + # Append so an earlier boot's output survives (crash-loop debugging); + # the child inherits the handle and the parent's copy is closed right + # after the spawn, so nothing leaks here. + log_handle = _log_path(spec.name).open("a", encoding="utf-8") + popen_kwargs = {"stdout": log_handle, "stderr": subprocess.STDOUT} + if cwd is not None: + popen_kwargs["cwd"] = str(cwd) + if sys.platform == "win32": + popen_kwargs["creationflags"] = \ + subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] + else: + popen_kwargs["start_new_session"] = True + try: + proc = subprocess.Popen(argv, **popen_kwargs) + except OSError as exc: + 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 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: - pid_handle.close() try: - pid_file.unlink() + pid_handle.write(f"{proc.pid} {token}\n".strip() + "\n") + pid_handle.close() 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 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, diff --git a/app/backends/sglomni/catalog.py b/app/backends/sglomni/catalog.py index 8bfb1fa..2452d82 100644 --- a/app/backends/sglomni/catalog.py +++ b/app/backends/sglomni/catalog.py @@ -52,6 +52,16 @@ class ModelEntry: system_hint: Optional[str] = None # remediation when the binary is absent speakers: Optional[Tuple[str, ...]] = None # preset voices (speaker) supports_seed: bool = False # request-scoped seed accepted (Qwen3-TTS Base) + # Whether the model's serving pipeline consumes a separate + # "instructions" field alongside its normal voice conditioning + # (verified against the installed sglang_omni code, not assumed from + # the HTTP schema): Qwen3-TTS Base (clone + instruction conditioning + # in request_builders.py), Qwen3-TTS CustomVoice and VoiceDesign, and + # the MOSS v1.5 pair (reference + instruction in the user message). + # False would mean an instructions field is silently ignored (Higgs, + # Voxtral, fish, dots, ZONOS2 take none; fish's inline event tags + # belong in the text, not this field). + supports_instructions: bool = False # NOTE(unverified upstream): only the two Base entries are known to # accept a request-scoped seed (Voxtral rejects one outright); qwen's # demo client does send seeds to the CustomVoice/VoiceDesign models, @@ -161,6 +171,7 @@ ENTRIES: Tuple[ModelEntry, ...] = ( requires_reference=False, extras=_QWEN_EXTRAS, system_dep="sox", system_hint=_SOX_HINT, speakers=QWEN_CUSTOMVOICE_SPEAKERS, + supports_instructions=True, notes="built-in speakers, lightest model", ), ModelEntry( @@ -172,6 +183,7 @@ ENTRIES: Tuple[ModelEntry, ...] = ( requires_reference=True, extras=_QWEN_EXTRAS, system_dep="sox", system_hint=_SOX_HINT, supports_seed=True, + supports_instructions=True, notes="voice cloning from a reference clip", ), ModelEntry( @@ -183,6 +195,7 @@ ENTRIES: Tuple[ModelEntry, ...] = ( requires_reference=True, extras=_QWEN_EXTRAS, system_dep="sox", system_hint=_SOX_HINT, supports_seed=True, + supports_instructions=True, notes="voice cloning, higher quality", ), ModelEntry( @@ -193,6 +206,7 @@ ENTRIES: Tuple[ModelEntry, ...] = ( capability=CAPABILITY_DESIGN, requires_reference=False, extras=_QWEN_EXTRAS, system_dep="sox", system_hint=_SOX_HINT, + supports_instructions=True, notes="voice described by instructions", ), ModelEntry( @@ -229,6 +243,7 @@ ENTRIES: Tuple[ModelEntry, ...] = ( config="moss_tts.yaml", capability=CAPABILITY_CLONE, requires_reference=True, + supports_instructions=True, notes="voice cloning from a reference clip", ), ModelEntry( @@ -238,6 +253,7 @@ ENTRIES: Tuple[ModelEntry, ...] = ( config="moss_tts_local.yaml", capability=CAPABILITY_CLONE, requires_reference=False, + supports_instructions=True, notes="48 kHz, narration without a reference or cloning", ), ModelEntry( |
