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
927 lines
30 KiB
Python
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"]
|