diff --git a/windows/db_backend.py b/windows/db_backend.py index 4e095d1a..da45e45a 100644 --- a/windows/db_backend.py +++ b/windows/db_backend.py @@ -26,6 +26,7 @@ import os import subprocess import tempfile +import threading from urllib.parse import urlparse from windows import paths @@ -34,6 +35,10 @@ _USE_PGSERVER = None +_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) +_patch_lock = threading.Lock() +_embedded_lock = threading.Lock() + def _check_pgserver(): """Return True if pgserver can be imported (has a Windows wheel).""" @@ -91,6 +96,7 @@ def _preinit_scram(data_dir, password): "--auth-host=scram-sha-256", "--auth-local=scram-sha-256", "--encoding=utf8", f"--pwfile={pwfile}"], check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + stdin=subprocess.DEVNULL, creationflags=_NO_WINDOW, ) finally: if pwfile and os.path.exists(pwfile): @@ -159,25 +165,69 @@ def _clear_stale_data_dir(data_dir): shutil.rmtree(data_dir, ignore_errors=True) +def _patch_pgserver_pg_ctl(): + """Make pgserver's ``pg_ctl start`` survive a double-click (tray) launch. + + The bundled pgserver starts the cluster with ``pg_ctl -w start`` under a + hardcoded 10s :func:`subprocess.run` timeout and with no Windows creation + flags, so the spawned ``postgres`` inherits the launching console. That is + fine for ``AudioMuse-AI.exe start`` (supervisor on the main thread of a real + console), but not for a double-click, where the supervisor boots from a + daemon thread under a hidden console: the inherited console stalls + ``pg_ctl``'s readiness wait past 10s, the timeout fires, and the half-started + postgres is left orphaned holding the data dir -- bricking every later start. + + Replace the ``pg_ctl`` symbol pgserver calls with a wrapper that runs every + ``start`` detached from any console and with a generous timeout. Idempotent; + a no-op off Windows. + """ + if os.name != "nt": + return + import pgserver.postgres_server as ps + if getattr(ps, "_audiomuse_pg_ctl_patched", False): + return + with _patch_lock: + if getattr(ps, "_audiomuse_pg_ctl_patched", False): + return + original = ps.pg_ctl + timeout = paths.pg_start_timeout() + + def pg_ctl(args, **kwargs): + if args and "start" in args: + if kwargs.get("timeout") is None or kwargs["timeout"] < timeout: + kwargs["timeout"] = timeout + kwargs.setdefault("stdin", subprocess.DEVNULL) + kwargs["creationflags"] = (kwargs.get("creationflags") or 0) | _NO_WINDOW + env = kwargs.get("env") + env = dict(os.environ if env is None else env) + env.setdefault("PGCTLTIMEOUT", str(timeout)) + kwargs["env"] = env + return original(args, **kwargs) + + ps.pg_ctl = pg_ctl + ps._audiomuse_pg_ctl_patched = True + + def start_embedded(data_dir): pw = paths.db_password() if _check_pgserver(): + _patch_pgserver_pg_ctl() import database fresh = not os.path.exists(os.path.join(data_dir, "PG_VERSION")) if fresh: _clear_stale_data_dir(data_dir) _preinit_scram(data_dir, pw) - try: - uri = database.start_embedded(data_dir) - except Exception: - if os.path.isdir(data_dir): - logger.warning("PostgreSQL data dir stale after crash — clearing and retrying") + with _embedded_lock: + try: + uri = database.start_embedded(data_dir) + except Exception: + if not fresh: + raise + logger.warning("Fresh PostgreSQL cluster failed to start — clearing and retrying once") import shutil shutil.rmtree(data_dir, ignore_errors=True) _preinit_scram(data_dir, pw) uri = database.start_embedded(data_dir) - else: - raise if not fresh: _harden_existing(data_dir, pw, uri) return _conn_from_uri(uri, pw) @@ -188,15 +238,18 @@ def start_embedded(data_dir): def ensure_embedded_running(data_dir): pw = paths.db_password() if _check_pgserver(): + _patch_pgserver_pg_ctl() import database - return _conn_from_uri(database.ensure_embedded_running(data_dir), pw) + with _embedded_lock: + return _conn_from_uri(database.ensure_embedded_running(data_dir), pw) from windows import embedded_pg return embedded_pg.ensure_running(data_dir, pw) def stop_embedded(): - if _check_pgserver(): - import database - return database.stop_embedded() - from windows import embedded_pg - return embedded_pg.stop() + with _embedded_lock: + if _check_pgserver(): + import database + return database.stop_embedded() + from windows import embedded_pg + return embedded_pg.stop() diff --git a/windows/paths.py b/windows/paths.py index ccc15bb3..702853f4 100644 --- a/windows/paths.py +++ b/windows/paths.py @@ -136,6 +136,18 @@ def pg_port(): return 5432 +def pg_start_timeout(): + """Seconds to wait for embedded PostgreSQL to report ready before giving up. + + pgserver's built-in 10s is too short when the supervisor boots from the tray + app's daemon thread under a hidden console, and on a cold first start while + Windows Defender scans the freshly extracted binaries. A premature timeout + orphans postgres.exe holding the data dir and bricks the next start, so allow + a generous window. + """ + return 120 + + def redis_url(): """Password-bearing URL for the embedded Redis (loopback TCP, scram-equivalent gate).""" return f"redis://:{quote(redis_password(), safe='')}@127.0.0.1:{redis_port()}/0"