Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

927 lines
30 KiB
Python

"""Local Web launcher for the installed DeepTutor app."""
from __future__ import annotations
import atexit
from dataclasses import dataclass
import json
import os
from pathlib import Path
import shutil
import signal
import socket
import subprocess
import sys
import threading
import time
from typing import Callable
from urllib import error as urlerror
from urllib import parse as urlparse
from urllib import request as urlrequest
from deeptutor.runtime.banner import labels_for, print_banner, resolve_language
from deeptutor.runtime.home import DEEPTUTOR_HOME_ENV, PACKAGE_ROOT, get_runtime_home
BACKEND_READY_TIMEOUT = 60
FRONTEND_READY_TIMEOUT = 120
FRONTEND_REUSE_PROBE_TIMEOUT = 2
KILL_SIGNAL = getattr(signal, "SIGKILL", signal.SIGTERM)
WEB_CACHE_DIR = Path("data") / "user" / "runtime" / "web"
# Mutable holder so module-level helpers can format messages in the active
# UI language without threading the labels through every function.
_ACTIVE_LABELS: dict[str, str] = labels_for("en")
def _t(key: str, **kwargs: object) -> str:
template = _ACTIVE_LABELS.get(key) or labels_for("en").get(key, key)
if not kwargs:
return template
try:
return template.format(**kwargs)
except (KeyError, IndexError):
return template
@dataclass(slots=True)
class ManagedProcess:
name: str
process: subprocess.Popen[str]
pgid: int | None
@dataclass(frozen=True, slots=True)
class FrontendRuntime:
kind: str
command: list[str]
cwd: Path
@dataclass(frozen=True, slots=True)
class ExistingFrontendRuntime:
url: str
port: int
pid: int | None
lock_path: Path
def _log(message: str) -> None:
print(message, flush=True)
def _reset_runtime_singletons() -> None:
"""Make a just-selected DEEPTUTOR_HOME visible to path/config singletons."""
try:
from deeptutor.services.path_service import PathService
PathService.reset_instance()
except Exception:
pass
try:
from deeptutor.services.config.runtime_settings import RuntimeSettingsService
RuntimeSettingsService._instances.clear()
except Exception:
pass
try:
from deeptutor.services.config.model_catalog import ModelCatalogService
ModelCatalogService._instances.clear()
except Exception:
pass
def _get_pgid(pid: int | None) -> int | None:
if pid is None or os.name == "nt":
return None
try:
return os.getpgid(pid)
except OSError:
return None
def _is_pid_alive(pid: int | None) -> bool:
if pid is None:
return False
try:
os.kill(pid, 0)
except PermissionError:
return True
except OSError:
return False
return True
def _send_tree_signal(pid: int | None, pgid: int | None, sig: signal.Signals | int) -> None:
if pid is None:
return
if os.name == "nt":
cmd = ["taskkill", "/PID", str(pid), "/T"]
if sig == KILL_SIGNAL:
cmd.append("/F")
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
return
if os.name != "nt" and pgid is not None:
os.killpg(pgid, sig)
else:
os.kill(pid, sig)
def _terminate(proc: ManagedProcess | None) -> None:
if proc is None or proc.process.poll() is not None:
return
_log(_t("start.stopping", name=proc.name, pid=proc.process.pid))
try:
_send_tree_signal(proc.process.pid, proc.pgid, signal.SIGTERM)
except Exception:
pass
try:
proc.process.wait(timeout=8)
except subprocess.TimeoutExpired:
try:
_send_tree_signal(proc.process.pid, proc.pgid, KILL_SIGNAL)
except Exception:
pass
def _stream_output(prefix: str, process: subprocess.Popen[str]) -> None:
assert process.stdout is not None
for line in process.stdout:
print(f" {prefix:<8} {line.rstrip()}", flush=True)
def _spawn(command: list[str], *, cwd: Path, env: dict[str, str], name: str) -> ManagedProcess:
kwargs: dict[str, object] = {
"cwd": str(cwd),
"env": env,
"stdout": subprocess.PIPE,
"stderr": subprocess.STDOUT,
"text": True,
"bufsize": 1,
"encoding": "utf-8",
"errors": "replace",
}
if os.name == "nt":
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
else:
kwargs["start_new_session"] = True
process = subprocess.Popen(command, **kwargs) # type: ignore[arg-type,call-overload]
thread = threading.Thread(target=_stream_output, args=(name, process), daemon=True)
thread.start()
return ManagedProcess(name=name, process=process, pgid=_get_pgid(process.pid))
def _port_accepts_connection(port: int) -> bool:
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.25):
return True
except OSError:
return False
def _port_listeners(port: int) -> list[tuple[int, str]]:
"""Best-effort list of ``(pid, command)`` for processes listening on ``port``."""
if os.name == "nt":
return _port_listeners_windows(port)
lsof = shutil.which("lsof")
if not lsof:
return []
try:
completed = subprocess.run(
[lsof, "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-Fp"],
check=False,
capture_output=True,
text=True,
timeout=3,
)
except Exception:
return []
pids: list[int] = []
for line in completed.stdout.splitlines():
if not line.startswith("p"):
continue
try:
pid = int(line[1:])
except ValueError:
continue
if pid not in pids:
pids.append(pid)
return [(pid, _process_command(pid) or "?") for pid in pids]
def _port_listeners_windows(port: int) -> list[tuple[int, str]]:
netstat = shutil.which("netstat")
if not netstat:
return []
try:
completed = subprocess.run(
[netstat, "-ano", "-p", "tcp"],
check=False,
capture_output=True,
text=True,
timeout=5,
)
except Exception:
return []
pids: list[int] = []
for line in completed.stdout.splitlines():
parts = line.split()
if len(parts) < 5 or parts[0].upper() != "TCP" or parts[3].upper() != "LISTENING":
continue
if not parts[1].endswith(f":{port}"):
continue
try:
pid = int(parts[4])
except ValueError:
continue
if pid not in pids:
pids.append(pid)
tasklist = shutil.which("tasklist")
listeners: list[tuple[int, str]] = []
for pid in pids:
name = ""
if tasklist:
try:
result = subprocess.run(
[tasklist, "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
check=False,
capture_output=True,
text=True,
timeout=3,
)
first = result.stdout.strip().splitlines()[:1]
if first and first[0].startswith('"'):
name = first[0].split('","')[0].strip('"')
except Exception:
name = ""
listeners.append((pid, name or "?"))
return listeners
def _suggest_free_port(preferred: int, taken: set[int]) -> int:
for candidate in range(preferred, min(preferred + 200, 65536)):
if candidate not in taken and not _port_accepts_connection(candidate):
return candidate
return preferred
def _prompt_port(label: str, *, default: int, taken: set[int]) -> int:
while True:
try:
raw = input(f"{label} [{default}]: ").strip()
except (EOFError, KeyboardInterrupt):
print()
raise SystemExit(130) from None
value = raw or str(default)
try:
port = int(value)
except ValueError:
port = -1
if 1 <= port <= 65535 and port not in taken and not _port_accepts_connection(port):
return port
_log(_t("start.port_invalid", value=value))
def _prompt_conflict_choice() -> str:
_log("")
_log(f" [1] {_t('start.port_option_change')}")
_log(f" [2] {_t('start.port_option_kill')}")
while True:
try:
raw = input(f"{_t('init.choice')} [1/2]: ").strip()
except (EOFError, KeyboardInterrupt):
print()
raise SystemExit(130) from None
if raw in {"1", "2"}:
return raw
_log(_t("init.choice_invalid"))
def _persist_ports(settings_dir: Path, backend_port: int, frontend_port: int) -> Path:
from deeptutor.services.config.runtime_settings import RuntimeSettingsService
service = RuntimeSettingsService.get_instance(settings_dir)
system = service.load_system(include_process_overrides=False)
system["backend_port"] = backend_port
system["frontend_port"] = frontend_port
service.save_system(system)
return service.path_for("system")
def _prompt_new_ports(
*,
backend_port: int,
frontend_port: int,
check_frontend: bool,
settings_dir: Path,
) -> tuple[int, int]:
backend_occupied = _port_accepts_connection(backend_port)
new_backend = _prompt_port(
_t("init.backend_port"),
default=_suggest_free_port(backend_port + 1, {frontend_port})
if backend_occupied
else backend_port,
taken={frontend_port} if check_frontend else set(),
)
new_frontend = frontend_port
if check_frontend:
frontend_occupied = _port_accepts_connection(frontend_port)
new_frontend = _prompt_port(
_t("init.frontend_port"),
default=_suggest_free_port(frontend_port + 1, {new_backend})
if frontend_occupied
else frontend_port,
taken={new_backend},
)
path = _persist_ports(settings_dir, new_backend, new_frontend)
_log(_t("start.port_saved", path=path))
return new_backend, new_frontend
def _kill_port_listeners(listeners: dict[int, list[tuple[int, str]]]) -> None:
for port, entries in listeners.items():
for pid, command in entries:
_log(_t("start.port_killing", pid=pid, command=command))
try:
_send_tree_signal(pid, None, signal.SIGTERM)
except Exception:
pass
deadline = time.monotonic() + 5
while time.monotonic() < deadline and _port_accepts_connection(port):
time.sleep(0.2)
if _port_accepts_connection(port):
for pid, _command in entries:
try:
_send_tree_signal(pid, None, KILL_SIGNAL)
except Exception:
pass
deadline = time.monotonic() + 3
while time.monotonic() < deadline and _port_accepts_connection(port):
time.sleep(0.2)
if _port_accepts_connection(port):
pids = ", ".join(str(pid) for pid, _command in entries) or "?"
_log(_t("start.port_kill_failed", port=port, pid=pids))
else:
_log(_t("start.port_freed", port=port))
def _resolve_port_conflicts(
*,
backend_port: int,
frontend_port: int,
check_frontend: bool,
settings_dir: Path,
) -> tuple[int, int]:
"""Return free ``(backend_port, frontend_port)``, resolving conflicts interactively.
When stdin is not a TTY (Docker, CI), falls back to exiting with the
historical ``start.port_in_use`` message.
"""
while True:
roles = [("start.backend", backend_port)]
if check_frontend:
roles.append(("start.frontend", frontend_port))
occupied = [(key, port) for key, port in roles if _port_accepts_connection(port)]
if not occupied:
return backend_port, frontend_port
listeners = {port: _port_listeners(port) for _key, port in occupied}
_log(_t("start.port_conflict_title"))
for key, port in occupied:
_log(_t("start.port_conflict_line", role=_t(key), port=port))
entries = listeners[port]
if not entries:
_log(_t("start.port_conflict_unknown_proc"))
for pid, command in entries:
_log(_t("start.port_conflict_proc", pid=pid, command=command))
if sys.stdin is None or not sys.stdin.isatty():
joined = ", ".join(str(port) for _key, port in occupied)
raise SystemExit(_t("start.port_in_use", ports=joined))
if _prompt_conflict_choice() == "1":
backend_port, frontend_port = _prompt_new_ports(
backend_port=backend_port,
frontend_port=frontend_port,
check_frontend=check_frontend,
settings_dir=settings_dir,
)
else:
_kill_port_listeners(listeners)
def _wait_for_http(
*,
name: str,
url: str,
process: ManagedProcess | None,
timeout: int,
should_stop: Callable[[], bool],
) -> None:
_log(_t("start.waiting_for", name=name, url=url))
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if should_stop():
return
if process is not None and process.process.poll() is not None:
raise RuntimeError(_t("start.exited", name=name, code=process.process.returncode))
try:
with urlrequest.urlopen(url, timeout=1): # noqa: S310 # nosec B310 - http(s) health-check URL constructed by caller
_log(_t("start.ready", name=name))
return
except (urlerror.URLError, TimeoutError, OSError):
time.sleep(0.5)
raise RuntimeError(_t("start.not_ready", name=name, timeout=timeout))
def _http_ready(url: str, *, timeout: float) -> bool:
try:
with urlrequest.urlopen(url, timeout=timeout): # noqa: S310 # nosec B310 - launcher health check
return True
except (urlerror.URLError, TimeoutError, OSError):
return False
def _packaged_web_dir() -> Path | None:
try:
import deeptutor_web
except ImportError:
return None
path = Path(deeptutor_web.__file__).resolve().parent
return path if (path / "server.js").exists() else None
def _copy_packaged_web_if_needed(
packaged: Path,
*,
home: Path,
api_base: str,
auth_enabled: bool,
) -> Path:
"""Copy packaged Next.js standalone files into a writable runtime cache.
Next public variables are inlined at build time, so placeholders must be
replaced before ``server.js`` starts. The installed package may live in a
read-only site-packages directory; the cache keeps mutation local to the
active workspace.
"""
cache = home / WEB_CACHE_DIR
marker = cache / ".deeptutor-web-runtime.json"
source_server = packaged / "server.js"
marker_payload = {
"source": str(packaged),
"source_mtime_ns": source_server.stat().st_mtime_ns,
"api_base": api_base,
"auth_enabled": bool(auth_enabled),
}
if (cache / "server.js").exists():
try:
if json.loads(marker.read_text(encoding="utf-8")) == marker_payload:
return cache
except Exception:
pass
if cache.exists():
shutil.rmtree(cache)
shutil.copytree(packaged, cache)
_patch_packaged_web_placeholders(
cache,
api_base=api_base,
auth_enabled="true" if auth_enabled else "false",
)
marker.write_text(json.dumps(marker_payload, indent=2), encoding="utf-8")
return cache
def _patch_packaged_web_placeholders(
web_dir: Path,
*,
api_base: str,
auth_enabled: str,
) -> None:
replacements = {
"__NEXT_PUBLIC_API_BASE_PLACEHOLDER__": api_base,
"__NEXT_PUBLIC_AUTH_ENABLED_PLACEHOLDER__": auth_enabled,
}
roots = [web_dir / ".next", web_dir / "server.js"]
for root in roots:
paths = [root] if root.is_file() else root.rglob("*") if root.exists() else []
for path in paths:
if not path.is_file() or path.suffix not in {".js", ".json", ".html", ".txt"}:
continue
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
updated = text
for placeholder, value in replacements.items():
updated = updated.replace(placeholder, value)
if updated != text:
path.write_text(updated, encoding="utf-8")
def _source_web_dir(home: Path) -> Path | None:
candidates = [home / "web", PACKAGE_ROOT / "web"]
for path in candidates:
if (path / "package.json").exists():
return path
return None
def _resolve_frontend(
home: Path,
frontend_port: int,
*,
api_base: str,
auth_enabled: bool,
) -> FrontendRuntime:
packaged = _packaged_web_dir()
node = shutil.which("node")
if packaged is not None:
if not node:
raise SystemExit("Node.js 20+ is required to run the packaged DeepTutor Web app.")
runtime_web = _copy_packaged_web_if_needed(
packaged,
home=home,
api_base=api_base,
auth_enabled=auth_enabled,
)
return FrontendRuntime("packaged", [node, str(runtime_web / "server.js")], runtime_web)
source = _source_web_dir(home)
if source is not None:
npm = shutil.which("npm")
if not npm:
raise SystemExit(
"npm not found. Source installs require Node.js/npm and `cd web && npm install`."
)
return FrontendRuntime(
"source", [npm, "run", "dev", "--", "--port", str(frontend_port)], source
)
raise SystemExit(
"DeepTutor Web assets are not installed. Install the full app with `pip install -U deeptutor`, "
"or run from a source checkout that contains `web/`."
)
def _coerce_pid(value: object) -> int | None:
if isinstance(value, bool) or not isinstance(value, int | str):
return None
try:
pid = int(value)
except (TypeError, ValueError):
return None
return pid if pid > 0 else None
def _coerce_port(value: object) -> int | None:
if isinstance(value, bool) or not isinstance(value, int | str):
return None
try:
port = int(value)
except (TypeError, ValueError):
return None
return port if 1 <= port <= 65535 else None
def _local_app_url(value: object, port: int) -> str:
fallback = f"http://localhost:{port}"
if not isinstance(value, str) or not value.strip():
return fallback
raw = value.strip().rstrip("/")
try:
parsed = urlparse.urlparse(raw)
except ValueError:
return fallback
if parsed.scheme not in {"http", "https"}:
return fallback
if parsed.hostname not in {"localhost", "127.0.0.1", "::1"}:
return fallback
if parsed.port != port:
return fallback
return raw
def _detect_existing_source_frontend(frontend: FrontendRuntime) -> ExistingFrontendRuntime | None:
"""Return an already-running Next dev server for this source checkout."""
if frontend.kind != "source":
return None
lock_candidates = [
frontend.cwd / ".next" / "dev" / "lock",
frontend.cwd / ".next" / "lock",
]
for lock_path in lock_candidates:
try:
payload = json.loads(lock_path.read_text(encoding="utf-8"))
except Exception:
continue
if not isinstance(payload, dict):
continue
port = _coerce_port(payload.get("port"))
if port is None:
continue
pid = _coerce_pid(payload.get("pid"))
if not _is_pid_alive(pid) and not _port_accepts_connection(port):
continue
return ExistingFrontendRuntime(
url=_local_app_url(payload.get("appUrl"), port),
port=port,
pid=pid,
lock_path=lock_path,
)
return None
def _process_command(pid: int | None) -> str:
if pid is None or os.name == "nt":
return ""
ps = shutil.which("ps")
if not ps:
return ""
try:
completed = subprocess.run(
[ps, "-p", str(pid), "-o", "command="],
check=False,
capture_output=True,
text=True,
timeout=2,
)
except Exception:
return ""
return completed.stdout.strip()
def _looks_like_next_process(pid: int | None) -> bool:
command = _process_command(pid).lower()
return bool(
command
and ("next-server" in command or "next/dist/bin/next" in command or " next dev" in command)
)
def _stop_unhealthy_source_frontend(frontend: ExistingFrontendRuntime) -> bool:
"""Stop a locked Next dev server only when the lock points at Next itself."""
if frontend.pid is None:
return False
if not _is_pid_alive(frontend.pid):
try:
frontend.lock_path.unlink(missing_ok=True)
except OSError:
pass
return True
if not _looks_like_next_process(frontend.pid):
return False
pgid = _get_pgid(frontend.pid)
try:
_send_tree_signal(frontend.pid, pgid, signal.SIGTERM)
except Exception:
return False
deadline = time.monotonic() + 5
while time.monotonic() < deadline and _is_pid_alive(frontend.pid):
time.sleep(0.2)
if _is_pid_alive(frontend.pid):
try:
_send_tree_signal(frontend.pid, pgid, KILL_SIGNAL)
except Exception:
return False
time.sleep(0.5)
try:
frontend.lock_path.unlink(missing_ok=True)
except OSError:
pass
return not _http_ready(frontend.url, timeout=0.5)
def _install_signal_handlers(request_shutdown: Callable[[str | None], None]) -> None:
def _handler(signum: int, _frame) -> None:
try:
signal_name = signal.Signals(signum).name
except ValueError:
signal_name = str(signum)
request_shutdown(signal_name)
for sig_name in ("SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK"):
sig = getattr(signal, sig_name, None)
if sig is None:
continue
try:
signal.signal(sig, _handler)
except (OSError, ValueError):
continue
def start(home: str | Path | None = None) -> None:
runtime_home = get_runtime_home(home)
runtime_home.mkdir(parents=True, exist_ok=True)
os.environ[DEEPTUTOR_HOME_ENV] = str(runtime_home)
_reset_runtime_singletons()
from deeptutor.services.config import (
ensure_runtime_settings_files,
export_runtime_settings_to_env,
load_auth_settings,
load_launch_settings,
)
from deeptutor.services.setup import init_user_directories
init_user_directories(runtime_home)
ensure_runtime_settings_files()
settings = load_launch_settings(runtime_home)
runtime_env = export_runtime_settings_to_env(overwrite=True)
auth_enabled = bool(load_auth_settings()["enabled"])
global _ACTIVE_LABELS
language = resolve_language()
_ACTIVE_LABELS = labels_for(language)
backend_port = settings.backend_port
frontend_port = settings.frontend_port
backend_url = f"http://localhost:{backend_port}"
api_base = (
runtime_env.get("NEXT_PUBLIC_API_BASE_EXTERNAL")
or runtime_env.get("NEXT_PUBLIC_API_BASE")
or backend_url
)
frontend = _resolve_frontend(
runtime_home,
frontend_port,
api_base=api_base,
auth_enabled=auth_enabled,
)
existing_frontend = _detect_existing_source_frontend(frontend)
if existing_frontend is not None and not _http_ready(
existing_frontend.url, timeout=FRONTEND_REUSE_PROBE_TIMEOUT
):
pid = existing_frontend.pid if existing_frontend.pid is not None else "unknown"
_log(_t("start.restarting_frontend", url=existing_frontend.url, pid=pid))
if not _stop_unhealthy_source_frontend(existing_frontend):
raise SystemExit(
_t("start.frontend_restart_failed", url=existing_frontend.url, pid=pid)
)
existing_frontend = None
if existing_frontend is not None:
frontend_port = existing_frontend.port
resolved_backend, resolved_frontend = _resolve_port_conflicts(
backend_port=backend_port,
frontend_port=frontend_port,
check_frontend=existing_frontend is None,
settings_dir=settings.settings_dir,
)
if (resolved_backend, resolved_frontend) != (backend_port, frontend_port):
backend_port, frontend_port = resolved_backend, resolved_frontend
runtime_env = export_runtime_settings_to_env(overwrite=True)
backend_url = f"http://localhost:{backend_port}"
api_base = (
runtime_env.get("NEXT_PUBLIC_API_BASE_EXTERNAL")
or runtime_env.get("NEXT_PUBLIC_API_BASE")
or backend_url
)
frontend = _resolve_frontend(
runtime_home,
frontend_port,
api_base=api_base,
auth_enabled=auth_enabled,
)
frontend_url = (
existing_frontend.url
if existing_frontend is not None
else f"http://localhost:{frontend_port}"
)
print_banner(language=language, mode_key="start.mode")
_log(f"{_t('start.backend'):<10} {backend_url}")
if api_base != backend_url:
_log(f"{_t('start.browser_api'):<10} {api_base}")
_log(f"{_t('start.frontend'):<10} {frontend_url}")
_log(f"{_t('start.workspace'):<10} {runtime_home}")
_log(f"{_t('start.frontend_runtime')}: {frontend.kind}")
_log(_t("start.press_ctrl_c"))
common_env = os.environ.copy()
common_env.update(runtime_env)
common_env[DEEPTUTOR_HOME_ENV] = str(runtime_home)
common_env["BACKEND_PORT"] = str(backend_port)
common_env["FRONTEND_PORT"] = str(frontend_port)
common_env["PORT"] = str(frontend_port)
common_env["HOSTNAME"] = "0.0.0.0"
common_env["NEXT_PUBLIC_API_BASE"] = api_base
common_env["NEXT_PUBLIC_AUTH_ENABLED"] = "true" if auth_enabled else "false"
# The Next.js middleware (web/proxy.ts) runs in the frontend's Node runtime
# and reads these at request time to forward /api/* and /ws/* to the backend
# and to gate the login redirect. The browser uses relative paths, so the
# frontend server reaches the backend on localhost at the resolved port —
# use backend_url (not api_base, which may be an external browser URL).
common_env["DEEPTUTOR_API_BASE_URL"] = backend_url
common_env["DEEPTUTOR_AUTH_ENABLED"] = "true" if auth_enabled else "false"
common_env["PYTHONUNBUFFERED"] = "1"
common_env["PYTHONIOENCODING"] = "utf-8:replace"
backend_cmd = [
sys.executable,
"-m",
"uvicorn",
"deeptutor.api.main:app",
"--host",
"0.0.0.0",
"--port",
str(backend_port),
"--log-level",
"info",
# Disable uvicorn's per-request access log. The selective_access_log
# middleware (deeptutor/api/main.py) surfaces only non-200s, so routine
# 200 polling (/settings, /tools, /knowledge/list, ...) stays out of the
# logs — matching run_server.py's access_log=False.
"--no-access-log",
]
processes: list[ManagedProcess] = []
backend: ManagedProcess | None = None
web: ManagedProcess | None = None
shutdown_requested = False
cleanup_started = False
exit_code = 0
def request_shutdown(signal_name: str | None = None) -> None:
nonlocal shutdown_requested
if shutdown_requested:
return
shutdown_requested = True
if signal_name:
_log(_t("start.received_signal", signal=signal_name))
def cleanup() -> None:
nonlocal cleanup_started
if cleanup_started:
return
cleanup_started = True
_terminate(web)
_terminate(backend)
_install_signal_handlers(request_shutdown)
atexit.register(cleanup)
try:
_log(_t("start.starting_backend"))
backend = _spawn(backend_cmd, cwd=runtime_home, env=common_env, name="backend")
processes.append(backend)
_wait_for_http(
name=_t("start.backend"),
url=f"http://127.0.0.1:{backend_port}/",
process=backend,
timeout=BACKEND_READY_TIMEOUT,
should_stop=lambda: shutdown_requested,
)
if existing_frontend is not None:
pid = existing_frontend.pid if existing_frontend.pid is not None else "unknown"
_log(_t("start.reusing_frontend", url=frontend_url, pid=pid))
_wait_for_http(
name=_t("start.frontend"),
url=frontend_url,
process=None,
timeout=FRONTEND_READY_TIMEOUT,
should_stop=lambda: shutdown_requested,
)
else:
_log(_t("start.starting_frontend"))
web = _spawn(frontend.command, cwd=frontend.cwd, env=common_env, name="frontend")
processes.append(web)
_wait_for_http(
name=_t("start.frontend"),
url=f"http://127.0.0.1:{frontend_port}/",
process=web,
timeout=FRONTEND_READY_TIMEOUT,
should_stop=lambda: shutdown_requested,
)
_log(_t("start.open_in_browser", url=frontend_url))
while not shutdown_requested:
for proc in processes:
if proc.process.poll() is not None:
_log(_t("start.exited", name=proc.name, code=proc.process.returncode))
exit_code = 1
shutdown_requested = True
break
time.sleep(1)
except KeyboardInterrupt:
request_shutdown("SIGINT")
finally:
cleanup()
if exit_code:
raise SystemExit(exit_code)
__all__ = ["start"]