diff --git a/flocks/cli/service_manager.py b/flocks/cli/service_manager.py index 46134d52..7aeb2c96 100644 --- a/flocks/cli/service_manager.py +++ b/flocks/cli/service_manager.py @@ -26,7 +26,7 @@ import httpx from flocks.browser.admin import stop_all_daemons as stop_all_browser_daemons -from flocks.cli.service_config import ServiceConfig, loopback_host +from flocks.cli.service_config import ServiceConfig, loopback_host, service_config_payload from flocks.cli.service_control import ( read_logs, read_supervisor_status, @@ -760,6 +760,8 @@ def _resolve_upgrade_runtime(console, *, frontend_port: int, attempt_recover: bo error = result.get("error") if action == "recovered": _console_print(console, "[flocks] 已恢复未完成升级,正式 WebUI 将继续接管端口。") + elif action == "failure_preserved": + _console_print(console, "[flocks] 已清理升级临时页,并保留回滚失败状态。") elif action != "noop": _console_print(console, "[flocks] 已清理升级临时页残留。") @@ -1130,6 +1132,11 @@ def _backend_command_and_env(root: Path, config: ServiceConfig) -> tuple[list[st env = os.environ.copy() env["_FLOCKS_WEBUI_HOST"] = config.frontend_host env["_FLOCKS_WEBUI_PORT"] = str(config.frontend_port) + env["_FLOCKS_SERVICE_CONFIG"] = json.dumps( + service_config_payload(config), + ensure_ascii=True, + sort_keys=True, + ) env["PYTHONUNBUFFERED"] = "1" env.setdefault("FLOCKS_CONSOLE_BASE_URL", DEFAULT_FLOCKS_CONSOLE_BASE_URL) return command, env @@ -1371,6 +1378,8 @@ def _wait_for_supervisor_ready( timeout: float = SUPERVISOR_START_TIMEOUT_SECONDS, ) -> dict[str, Any]: """Wait for the supervisor control API and managed services to become ready.""" + from flocks.cli.service_process import tcp_port_accepts_connections + deadline = time.monotonic() + timeout last_payload: dict[str, Any] | None = None while time.monotonic() < deadline: @@ -1381,7 +1390,12 @@ def _wait_for_supervisor_ready( last_payload = status.raw backend_state = status.backend.state webui_state = status.webui.state - if backend_state == "healthy" and webui_state in {"healthy", "static"}: + if ( + backend_state == "healthy" + and webui_state in {"healthy", "static"} + and status.backend.port is not None + and tcp_port_accepts_connections(status.backend.host, status.backend.port) + ): return status.raw if backend_state == "degraded" or webui_state == "degraded": return status.raw @@ -1389,7 +1403,7 @@ def _wait_for_supervisor_ready( pass time.sleep(0.5) if last_payload is not None: - return last_payload + raise ServiceError("Flocks daemon 启动超时:后端 TCP 端口未就绪,请检查日志。") raise ServiceError("Flocks daemon 启动超时,请检查日志。") @@ -1426,8 +1440,6 @@ def _start_supervisor_process(config: ServiceConfig, paths: RuntimePaths, consol """Spawn the detached service supervisor daemon.""" root = ensure_install_layout() log_path = supervisor_log_path(paths) - if not supervisor_uses_tcp_control(): - supervisor_socket_path(paths).unlink(missing_ok=True) command = resolve_flocks_cli_command(root) + [ "service-daemon", "--server-host", @@ -1449,6 +1461,11 @@ def _start_supervisor_process(config: ServiceConfig, paths: RuntimePaths, consol command.append("--skip-webui-build") env = os.environ.copy() env["PYTHONUNBUFFERED"] = "1" + env["_FLOCKS_SERVICE_CONFIG"] = json.dumps( + service_config_payload(config), + ensure_ascii=True, + sort_keys=True, + ) return _spawn_process(command, cwd=root, log_path=log_path, env=env) @@ -1513,6 +1530,7 @@ def _stop_all_unlocked(console, *, paths: RuntimePaths) -> None: cleanup_config = ServiceConfig() legacy_config = _legacy_runtime_config(paths, cleanup_config) stop_status = None + daemon_pid = None if not supervisor_is_running(paths): console.print("[flocks] Flocks daemon 未运行。") cleanup_legacy_runtime_processes(paths, console) @@ -1521,6 +1539,7 @@ def _stop_all_unlocked(console, *, paths: RuntimePaths) -> None: return try: stop_status = read_supervisor_status(paths=paths, timeout=1.0) + daemon_pid = stop_status.daemon.pid cleanup_config = stop_status.config legacy_config = _legacy_runtime_config(paths, cleanup_config) except Exception: @@ -1532,7 +1551,9 @@ def _stop_all_unlocked(console, *, paths: RuntimePaths) -> None: deadline = time.monotonic() + 20.0 while time.monotonic() < deadline: - if not supervisor_is_running(paths): + control_stopped = not supervisor_is_running(paths) + daemon_stopped = not pid_is_running(daemon_pid) + if control_stopped and daemon_stopped: cleanup_legacy_runtime_processes(paths, console) cleanup_orphan_service_ports(cleanup_config, console, extra_configs=[legacy_config]) stop_all_browser_daemons() @@ -1558,8 +1579,9 @@ def _start_all_without_stop(config: ServiceConfig, console) -> None: cleanup_orphan_service_ports(config, console) _ensure_webui_dist(ensure_install_layout(), config, console) process = _start_supervisor_process(config, paths, console) - console.print("[flocks] Flocks daemon 已启动。") + console.print("[flocks] Flocks daemon 进程已启动,正在等待服务就绪...") payload = _wait_for_supervisor_ready(paths, process=process) + console.print("[flocks] Flocks daemon 已启动。") _print_status_payload(payload, console, include_daemon_step=False) if not _startup_payload_is_ready(payload): raise ServiceError(_startup_failure_message(payload)) diff --git a/flocks/cli/service_supervisor.py b/flocks/cli/service_supervisor.py index e9439730..6e6f8d69 100644 --- a/flocks/cli/service_supervisor.py +++ b/flocks/cli/service_supervisor.py @@ -119,6 +119,7 @@ def __init__( self._shutdown_requested = threading.Event() self._server: ThreadingHTTPServer | None = None self._server_thread: threading.Thread | None = None + self._control_socket_identity: tuple[int, int] | None = None self._backend_paused = False self._webui_paused = False self.backend = ManagedService( @@ -190,6 +191,8 @@ def _start_control_server(self) -> None: socket_path.unlink(missing_ok=True) assert _UnixControlServer is not None server = _UnixControlServer(str(socket_path), handler) + stat_result = socket_path.stat() + self._control_socket_identity = (stat_result.st_dev, stat_result.st_ino) self._server = server self._server_thread = threading.Thread(target=server.serve_forever, name="flocks-supervisor-control", daemon=True) self._server_thread.start() @@ -201,8 +204,17 @@ def _stop_control_server(self) -> None: self._server.server_close() if self._server_thread is not None: self._server_thread.join(timeout=5.0) - if not supervisor_uses_tcp_control(): - supervisor_socket_path(self.paths).unlink(missing_ok=True) + if not supervisor_uses_tcp_control() and self._control_socket_identity is not None: + socket_path = supervisor_socket_path(self.paths) + try: + stat_result = socket_path.stat() + except FileNotFoundError: + pass + else: + current_identity = (stat_result.st_dev, stat_result.st_ino) + if current_identity == self._control_socket_identity: + socket_path.unlink(missing_ok=True) + self._control_socket_identity = None def _handler_class(self): daemon = self diff --git a/flocks/server/app.py b/flocks/server/app.py index 78271d13..97a9c90f 100644 --- a/flocks/server/app.py +++ b/flocks/server/app.py @@ -472,17 +472,20 @@ async def _delayed_trigger_runtime_start() -> None: except Exception as e: log.warning("workflow.trigger_runtime.start_failed", {"error": str(e)}) - try: - from flocks.updater.updater import recover_upgrade_state + if os.getenv("_FLOCKS_SERVICE_CONFIG"): + log.info("updater.recovery.skipped", {"reason": "managed_service"}) + else: + try: + from flocks.updater.updater import recover_upgrade_state - await _run_startup_phase( - log, - "updater.recover_upgrade_state", - lambda: asyncio.to_thread(recover_upgrade_state), - ) - log.info("updater.recovery.checked") - except Exception as e: - log.warning("updater.recovery.failed", {"error": str(e)}) + await _run_startup_phase( + log, + "updater.recover_upgrade_state", + lambda: asyncio.to_thread(recover_upgrade_state), + ) + log.info("updater.recovery.checked") + except Exception as e: + log.warning("updater.recovery.failed", {"error": str(e)}) blocking_startup_ms = int((time.perf_counter() - startup_started_at) * 1000) log.info("server.startup.ready", { diff --git a/flocks/updater/restart_handoff.py b/flocks/updater/restart_handoff.py index 0d429a4d..62eab44c 100644 --- a/flocks/updater/restart_handoff.py +++ b/flocks/updater/restart_handoff.py @@ -11,6 +11,7 @@ import argparse import asyncio +import json import shutil import subprocess import time @@ -83,6 +84,12 @@ def _stop_supervisor_before_restart( if not service_control.supervisor_is_running(paths): return True + daemon_pid = None + try: + daemon_pid = service_control.read_supervisor_status(paths=paths, timeout=1.0).daemon.pid + except Exception: + pass + try: service_control.request_stop(paths=paths, timeout=timeout_seconds) except Exception as exc: @@ -91,10 +98,12 @@ def _stop_supervisor_before_restart( deadline = time.monotonic() + timeout_seconds while time.monotonic() < deadline: - if not service_control.supervisor_is_running(paths): + control_stopped = not service_control.supervisor_is_running(paths) + daemon_stopped = not service_manager.pid_is_running(daemon_pid) + if control_stopped and daemon_stopped: return True time.sleep(poll_interval_seconds) - return not service_control.supervisor_is_running(paths) + return not service_control.supervisor_is_running(paths) and not service_manager.pid_is_running(daemon_pid) def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: @@ -118,6 +127,7 @@ def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: parser.add_argument("--bundle-sha256") parser.add_argument("--cleanup-dir") parser.add_argument("--prepare-handover", action="store_true") + parser.add_argument("--service-config-json") parser.add_argument("restart_argv", nargs=argparse.REMAINDER) args = parser.parse_args(argv) if args.restart_argv and args.restart_argv[0] == "--": @@ -177,10 +187,35 @@ def _rollback_failed_upgrade(args: argparse.Namespace, error: str) -> None: def _prepare_upgrade_handover(args: argparse.Namespace) -> bool: + from flocks.cli.service_config import ServiceConfig, service_config_from_payload from flocks.updater import updater try: - updater._prepare_upgrade_handover(args.version) + config_payload = { + "backend_host": args.backend_host, + "backend_port": args.backend_port, + "frontend_host": args.frontend_host, + "frontend_port": args.frontend_port, + } + if args.service_config_json: + payload = json.loads(args.service_config_json) + if not isinstance(payload, dict): + raise ValueError("service config snapshot must be a JSON object") + config_payload.update(payload) + else: + legacy_host = _argv_option(args.restart_argv, "--server-host") + legacy_port = _argv_option(args.restart_argv, "--server-port") + if legacy_host: + config_payload["legacy_backend_host"] = legacy_host + if legacy_port: + config_payload["legacy_backend_port"] = int(legacy_port) + config = service_config_from_payload( + config_payload, + default=ServiceConfig(), + no_browser=True, + skip_frontend_build=True, + ) + updater._prepare_upgrade_handover(args.version, config=config) except Exception as exc: _record_handoff_log(f"prepare_handover_failed error={exc}") return False @@ -210,6 +245,17 @@ def _cli_subcommand(argv: Sequence[str]) -> str | None: return None +def _argv_option(argv: Sequence[str], option: str) -> str | None: + """Return a CLI option value from either ``--name value`` or ``--name=value``.""" + prefix = f"{option}=" + for index, value in enumerate(argv): + if value.startswith(prefix): + return value[len(prefix) :] + if value == option and index + 1 < len(argv): + return argv[index + 1] + return None + + def _restart_argv_for_current_runtime(args: argparse.Namespace, restart_argv: Sequence[str]) -> list[str]: if _cli_subcommand(restart_argv) != "serve": return list(restart_argv) @@ -237,6 +283,7 @@ def _restart_argv_for_current_runtime(args: argparse.Namespace, restart_argv: Se def run(argv: Sequence[str] | None = None) -> int: args = _parse_args(argv) restart_argv = _restart_argv_for_current_runtime(args, args.restart_argv) + args.restart_argv = restart_argv if not restart_argv: _record_handoff_log("missing_restart_argv") return 2 diff --git a/flocks/updater/updater.py b/flocks/updater/updater.py index 3ebdf779..3a8dd1a2 100644 --- a/flocks/updater/updater.py +++ b/flocks/updater/updater.py @@ -54,7 +54,7 @@ _CURL_USER_AGENT = "curl/8.7.1" _FRONTEND_DEPENDENCY_INSTALL_TIMEOUT_SECONDS = 300 _FRONTEND_BUILD_TIMEOUT_SECONDS = 300 -_DEPENDENCY_SYNC_TIMEOUT_SECONDS = 180 +_DEPENDENCY_SYNC_TIMEOUT_SECONDS = 300 _WINDOWS_DEPENDENCY_SYNC_TIMEOUT_SECONDS = 300 _CANCELLATION_RETRY_DELAY_SECONDS = 0.1 @@ -538,7 +538,7 @@ def _dependency_sync_timeout_seconds() -> int: def _build_dependency_sync_command(uv_path: str, *, uv_default_index: str | None = None) -> list[str]: """Build the ``uv sync`` command used by the self-updater.""" - cmd = [uv_path, "sync", "--frozen", "--no-python-downloads"] + cmd = [uv_path, "sync", "--no-python-downloads"] if uv_default_index: cmd.extend(["--default-index", uv_default_index]) return cmd @@ -569,10 +569,31 @@ async def _run_uv_sync(cmd: list[str]) -> tuple[int, str, str]: def _timeout_message() -> str: return f"Dependency sync timed out after {effective_timeout}s while running uv sync." + def _log_timeout(exc: subprocess.TimeoutExpired, *, fallback_without_default_index: bool) -> None: + log.warning( + "updater.dependencies.sync_timeout", + { + "command": list(exc.cmd) if not isinstance(exc.cmd, str) else exc.cmd, + "timeout": effective_timeout, + "stdout": _clean_process_output(exc.stdout), + "stderr": _clean_process_output(exc.stderr), + "fallback_without_default_index": fallback_without_default_index, + }, + ) + try: code, _, err = await _run_uv_sync(uv_cmd) - except subprocess.TimeoutExpired: - return _timeout_message() + except subprocess.TimeoutExpired as exc: + _log_timeout(exc, fallback_without_default_index=bool(uv_default_index)) + if not uv_default_index: + return _timeout_message() + uv_cmd = _build_dependency_sync_command(uv_path) + uv_default_index = None + try: + code, _, err = await _run_uv_sync(uv_cmd) + except subprocess.TimeoutExpired as fallback_exc: + _log_timeout(fallback_exc, fallback_without_default_index=False) + return _timeout_message() if ( code != 0 @@ -595,7 +616,8 @@ def _timeout_message() -> str: await asyncio.sleep(2) try: code, _, err = await _run_uv_sync(uv_cmd) - except subprocess.TimeoutExpired: + except subprocess.TimeoutExpired as exc: + _log_timeout(exc, fallback_without_default_index=False) return _timeout_message() if code != 0 and uv_default_index: @@ -610,7 +632,8 @@ def _timeout_message() -> str: uv_cmd = _build_dependency_sync_command(uv_path) try: code, _, err = await _run_uv_sync(uv_cmd) - except subprocess.TimeoutExpired: + except subprocess.TimeoutExpired as exc: + _log_timeout(exc, fallback_without_default_index=False) return _timeout_message() if code != 0: @@ -618,7 +641,8 @@ def _timeout_message() -> str: await asyncio.sleep(3) try: code, _, err = await _run_uv_sync(uv_cmd) - except subprocess.TimeoutExpired: + except subprocess.TimeoutExpired as exc: + _log_timeout(exc, fallback_without_default_index=False) return _timeout_message() if code != 0: @@ -1978,9 +2002,23 @@ def print(self, *args, **kwargs) -> None: def _current_service_config(): from flocks.cli import service_manager - from flocks.cli.service_config import service_config_from_status_payload + from flocks.cli.service_config import ServiceConfig, service_config_from_payload, service_config_from_status_payload from flocks.cli.service_control import read_supervisor_status + config_json = os.getenv("_FLOCKS_SERVICE_CONFIG") + if config_json: + try: + payload = json.loads(config_json) + if isinstance(payload, dict): + return service_config_from_payload( + payload, + default=ServiceConfig(), + no_browser=True, + skip_frontend_build=True, + ) + except (TypeError, ValueError): + log.warning("updater.service_config.environment_invalid") + try: status = read_supervisor_status(paths=service_manager.runtime_paths(), timeout=1.0) except Exception as exc: @@ -2047,7 +2085,7 @@ def _upgrade_page_probe_urls(frontend_host: str, frontend_port: int) -> list[str def _wait_for_upgrade_page(config) -> None: page_urls = _upgrade_page_probe_urls(config.frontend_host, config.frontend_port) - with httpx.Client(timeout=1.5) as client: + with httpx.Client(timeout=1.5, trust_env=False) as client: for _ in range(40): for page_url in page_urls: try: @@ -2176,17 +2214,15 @@ def _looks_like_upgrade_page_process(pid: int) -> bool: return "http.server" in command_line and "upgrade-page" in command_line and page_dir in command_line -def _prepare_upgrade_handover(version: str) -> dict[str, Any]: +def _prepare_upgrade_handover(version: str, *, config=None) -> dict[str, Any]: from flocks.cli import service_manager + from flocks.cli.service_config import service_config_payload from flocks.cli.service_control import request_prepare_upgrade - config = _current_service_config() + config = config or _current_service_config() payload: dict[str, Any] = { "version": version, - "backend_host": config.backend_host, - "backend_port": config.backend_port, - "frontend_host": config.frontend_host, - "frontend_port": config.frontend_port, + **service_config_payload(config), "skip_frontend_build": True, "phase": _UPGRADE_PHASE_HANDOVER_PREPARING, } @@ -2347,7 +2383,11 @@ def _start_frontend_with_fallback(config, console, *, allow_build_fallback: bool raise RuntimeError(result.webui.last_error or "WebUI restart did not become healthy") -def cleanup_orphan_upgrade_state(*, frontend_port: int | None = None) -> bool: +def cleanup_orphan_upgrade_state( + *, + frontend_port: int | None = None, + preserve_upgrade_state: bool = False, +) -> bool: state = read_upgrade_runtime_state(frontend_port=frontend_port) if not state["has_artifacts"]: return False @@ -2358,7 +2398,8 @@ def cleanup_orphan_upgrade_state(*, frontend_port: int | None = None) -> bool: else: _stop_upgrade_page_server(frontend_port=resolved_port) - _clear_upgrade_state() + if not preserve_upgrade_state: + _clear_upgrade_state() _upgrade_server_pid_path().unlink(missing_ok=True) shutil.rmtree(_upgrade_page_dir(), ignore_errors=True) return True @@ -2378,6 +2419,8 @@ def resolve_upgrade_runtime_state( } resolved_port = state["frontend_port"] + payload = state.get("payload") + rollback_failed = isinstance(payload, dict) and payload.get("phase") == _UPGRADE_PHASE_ROLLBACK_FAILED if attempt_recover and state["payload_present"]: try: recover_upgrade_state() @@ -2387,14 +2430,26 @@ def resolve_upgrade_runtime_state( "error": None, } except Exception as exc: - cleanup_orphan_upgrade_state(frontend_port=resolved_port) + cleanup_orphan_upgrade_state( + frontend_port=resolved_port, + preserve_upgrade_state=True, + ) return { **state, "action": "cleanup_after_failed_recover", "error": str(exc), } - cleanup_orphan_upgrade_state(frontend_port=resolved_port) + cleanup_orphan_upgrade_state( + frontend_port=resolved_port, + preserve_upgrade_state=rollback_failed, + ) + if rollback_failed: + return { + **state, + "action": "failure_preserved", + "error": str(payload.get("last_error") or "Rollback failed"), + } return { **state, "action": "cleaned", @@ -2459,6 +2514,7 @@ def _rollback_failed_update( console = _NullConsole() config = _service_config_from_payload(payload, skip_frontend_build=True) _stop_upgrade_page_server(frontend_port=config.frontend_port) + rollback_error = restore_error try: _start_frontend_with_fallback( config, @@ -2466,13 +2522,20 @@ def _rollback_failed_update( allow_build_fallback=restored_backup, ) except Exception as exc: + rollback_error = str(exc) log.error( "updater.frontend.rollback_failed", {"error": str(exc), "restored_backup": restored_backup, "restore_error": restore_error}, ) - finally: + if rollback_error is None: _clear_upgrade_state() - shutil.rmtree(_upgrade_page_dir(), ignore_errors=True) + else: + _persist_upgrade_state( + payload, + phase=_UPGRADE_PHASE_ROLLBACK_FAILED, + last_error=rollback_error, + ) + shutil.rmtree(_upgrade_page_dir(), ignore_errors=True) def recover_upgrade_state() -> None: @@ -2488,9 +2551,15 @@ def recover_upgrade_state() -> None: _start_frontend_with_fallback(config, console, allow_build_fallback=True) except Exception as exc: log.error("updater.frontend.resume_failed", {"error": str(exc)}) + _persist_upgrade_state( + payload, + phase=_UPGRADE_PHASE_ROLLBACK_FAILED, + last_error=str(exc), + ) raise - finally: + else: _clear_upgrade_state() + finally: shutil.rmtree(_upgrade_page_dir(), ignore_errors=True) @@ -2507,8 +2576,14 @@ def rollback_upgrade_handover() -> None: _start_frontend_with_fallback(config, console, allow_build_fallback=False) except Exception as exc: log.error("updater.frontend.rollback_failed", {"error": str(exc)}) - finally: + _persist_upgrade_state( + payload, + phase=_UPGRADE_PHASE_ROLLBACK_FAILED, + last_error=str(exc), + ) + else: _clear_upgrade_state() + finally: shutil.rmtree(_upgrade_page_dir(), ignore_errors=True) @@ -2607,6 +2682,8 @@ def _safe_remove(target: Path) -> None: def _replace_install_dir( source_dir: Path, install_root: Path, + *, + _relative_parts: tuple[str, ...] = (), ) -> None: """ Overwrite *install_root* with the contents of *source_dir*, while @@ -2623,7 +2700,11 @@ def _replace_install_dir( if item.is_dir() and not item.is_symlink(): if target.exists() or target.is_symlink(): if target.is_dir() and not target.is_symlink(): - _replace_install_dir(item, target) + _replace_install_dir( + item, + target, + _relative_parts=(*_relative_parts, item.name), + ) continue _safe_remove(target) shutil.copytree(item, target, symlinks=True) @@ -2633,7 +2714,8 @@ def _replace_install_dir( shutil.copy2(item, target) for child in install_root.iterdir(): - if child.name not in source_names and child.name not in _PRESERVE_NAMES: + preserve_webui_dist = _relative_parts == ("webui",) and child.name == "dist" + if child.name not in source_names and child.name not in _PRESERVE_NAMES and not preserve_webui_dist: _safe_remove(child) @@ -3629,6 +3711,9 @@ def _build_restart_handoff_argv( raise ValueError("restart command is empty") config = _handoff_service_config() + from flocks.cli.service_config import service_config_payload + + config_json = json.dumps(service_config_payload(config), ensure_ascii=True, sort_keys=True) managed_restart_argv = [ restart_argv[0], "-m", @@ -3686,6 +3771,7 @@ def _build_restart_handoff_argv( argv.extend(["--cleanup-dir", str(cleanup_dir)]) if prepare_handover: argv.append("--prepare-handover") + argv.extend(["--service-config-json", config_json]) argv.extend(["--", *managed_restart_argv]) return argv diff --git a/tests/cli/test_service_manager.py b/tests/cli/test_service_manager.py index d75c1cac..a99b26f1 100644 --- a/tests/cli/test_service_manager.py +++ b/tests/cli/test_service_manager.py @@ -920,6 +920,40 @@ def test_startup_status_lines_can_skip_daemon_step() -> None: assert lines[:1] == ["[flocks] Flocks service 已启动。"] +def test_wait_for_supervisor_ready_waits_for_backend_tcp_listener(monkeypatch, tmp_path: Path) -> None: + paths = _make_runtime_paths(tmp_path) + status = _supervisor_status() + listener_states = iter([False, True]) + probes: list[tuple[str, int]] = [] + + def tcp_listener_ready(host: str, port: int) -> bool: + probes.append((host, port)) + return next(listener_states) + + monkeypatch.setattr(service_manager, "read_supervisor_status", lambda *_args, **_kwargs: status) + monkeypatch.setattr(service_process, "tcp_port_accepts_connections", tcp_listener_ready) + monkeypatch.setattr(service_manager.time, "sleep", lambda _seconds: None) + + payload = service_manager._wait_for_supervisor_ready(paths, timeout=1.0) + + assert payload == status.raw + assert probes == [("0.0.0.0", 9000), ("0.0.0.0", 9000)] + + +def test_wait_for_supervisor_ready_fails_when_tcp_listener_never_starts(monkeypatch, tmp_path: Path) -> None: + paths = _make_runtime_paths(tmp_path) + status = _supervisor_status() + monotonic_values = iter([0.0, 0.0, 2.0]) + + monkeypatch.setattr(service_manager, "read_supervisor_status", lambda *_args, **_kwargs: status) + monkeypatch.setattr(service_process, "tcp_port_accepts_connections", lambda *_args: False) + monkeypatch.setattr(service_manager.time, "monotonic", lambda: next(monotonic_values)) + monkeypatch.setattr(service_manager.time, "sleep", lambda _seconds: None) + + with pytest.raises(service_manager.ServiceError, match="TCP"): + service_manager._wait_for_supervisor_ready(paths, timeout=1.0) + + def test_build_status_lines_reports_daemon_down_without_port_scans(monkeypatch, tmp_path: Path) -> None: paths = _make_runtime_paths(tmp_path) calls: list[str] = [] @@ -1141,6 +1175,7 @@ def test_start_all_without_stop_starts_supervisor_daemon(monkeypatch, tmp_path: assert calls == ["daemon", "ready", "status"] assert console.messages == [ "[flocks] Flocks daemon 启动中...", + "[flocks] Flocks daemon 进程已启动,正在等待服务就绪...", "[flocks] Flocks daemon 已启动。", ] @@ -1371,6 +1406,17 @@ def test_start_backend_reports_started_after_probe_succeeds(monkeypatch, tmp_pat backend_env = spawn_calls[0]["kwargs"]["env"] assert backend_env["_FLOCKS_WEBUI_HOST"] == "127.0.0.1" assert backend_env["_FLOCKS_WEBUI_PORT"] == "5173" + assert json.loads(backend_env["_FLOCKS_SERVICE_CONFIG"]) == { + "backend_host": "127.0.0.1", + "backend_port": 5173, + "frontend_host": "127.0.0.1", + "frontend_port": 5173, + "legacy_backend_host": "127.0.0.1", + "legacy_backend_port": 8000, + "no_browser": False, + "server_port_migration_hint": False, + "skip_frontend_build": False, + } assert not paths.backend_pid.exists() assert backend_env["FLOCKS_CONSOLE_BASE_URL"] == service_manager.DEFAULT_FLOCKS_CONSOLE_BASE_URL @@ -1613,6 +1659,25 @@ def test_supervisor_rejects_static_webui_stop_control_api(monkeypatch, tmp_path: assert exc_info.value.response.status_code == 409 +@pytest.mark.skipif(sys.platform == "win32", reason="uses Unix socket inode ownership") +def test_supervisor_does_not_unlink_replacement_control_socket(monkeypatch, tmp_path: Path) -> None: + paths = _make_runtime_paths(tmp_path) + paths.run_dir.mkdir(parents=True) + paths.log_dir.mkdir(parents=True) + monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) + daemon = service_supervisor.SupervisorDaemon(service_manager.ServiceConfig()) + socket_path = service_control.supervisor_socket_path(paths) + socket_path.write_text("old", encoding="utf-8") + old_stat = socket_path.stat() + daemon._control_socket_identity = (old_stat.st_dev, old_stat.st_ino) + socket_path.unlink() + socket_path.write_text("replacement", encoding="utf-8") + + daemon._stop_control_server() + + assert socket_path.read_text(encoding="utf-8") == "replacement" + + @pytest.mark.skipif(sys.platform == "win32", reason="uses the Unix domain socket control API") def test_supervisor_upgrade_prepare_control_api_pauses_real_child_restart(monkeypatch, tmp_path: Path) -> None: del tmp_path @@ -2068,6 +2133,7 @@ def print(self, message) -> None: monkeypatch.setattr(service_manager, "supervisor_is_running", lambda _paths: next(states)) monkeypatch.setattr(service_manager, "read_supervisor_status", lambda *_args, **_kwargs: _supervisor_status(payload)) monkeypatch.setattr(service_manager, "request_stop", lambda **_kwargs: calls.append("/stop") or {"status": "stopping"}) + monkeypatch.setattr(service_manager, "pid_is_running", lambda _pid: False) monkeypatch.setattr(service_manager, "cleanup_legacy_runtime_processes", lambda _paths, _console: calls.append("legacy")) monkeypatch.setattr(service_manager, "cleanup_orphan_service_ports", lambda _config, _console, **_kwargs: calls.append("cleanup")) monkeypatch.setattr(service_manager, "stop_all_browser_daemons", lambda: calls.append("browser")) @@ -2082,6 +2148,47 @@ def print(self, message) -> None: ] +def test_stop_all_waits_for_daemon_pid_after_control_api_stops(monkeypatch, tmp_path: Path) -> None: + paths = _make_runtime_paths(tmp_path) + events: list[str] = [] + control_states = iter([True, False, False]) + process_states = iter([True, False]) + + monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) + monkeypatch.setattr(service_manager, "supervisor_is_running", lambda _paths: next(control_states)) + monkeypatch.setattr(service_manager, "read_supervisor_status", lambda *_args, **_kwargs: _supervisor_status()) + monkeypatch.setattr(service_manager, "request_stop", lambda **_kwargs: events.append("stop") or {}) + monkeypatch.setattr( + service_manager, + "pid_is_running", + lambda pid: events.append(f"pid:{pid}") or next(process_states), + ) + monkeypatch.setattr(service_manager.time, "sleep", lambda _seconds: events.append("sleep")) + monkeypatch.setattr( + service_manager, + "cleanup_legacy_runtime_processes", + lambda *_args, **_kwargs: events.append("legacy"), + ) + monkeypatch.setattr( + service_manager, + "cleanup_orphan_service_ports", + lambda *_args, **_kwargs: events.append("cleanup"), + ) + monkeypatch.setattr(service_manager, "stop_all_browser_daemons", lambda: events.append("browser")) + + service_manager.stop_all(DummyConsole()) + + assert events == [ + "stop", + "pid:100", + "sleep", + "pid:100", + "legacy", + "cleanup", + "browser", + ] + + def test_stop_all_reports_when_supervisor_is_down(monkeypatch, tmp_path: Path) -> None: paths = _make_runtime_paths(tmp_path) console = DummyConsole() diff --git a/tests/server/test_lifespan.py b/tests/server/test_lifespan.py index de667769..1328fed2 100644 --- a/tests/server/test_lifespan.py +++ b/tests/server/test_lifespan.py @@ -19,8 +19,17 @@ def warn(self, *_args, **_kwargs) -> None: @pytest.mark.asyncio -async def test_lifespan_cleans_leftovers_before_recovering_upgrade_state( +@pytest.mark.parametrize( + ("managed_service", "expected_events"), + [ + (False, ["cleanup_replaced_files", "recover_upgrade_state"]), + (True, ["cleanup_replaced_files"]), + ], +) +async def test_lifespan_recovers_upgrade_state_only_outside_managed_service( monkeypatch: pytest.MonkeyPatch, + managed_service: bool, + expected_events: list[str], ) -> None: events: list[str] = [] @@ -50,6 +59,10 @@ async def fake_async_noop(*_args, **_kwargs) -> None: monkeypatch.setattr(app_module.Config, "get", fake_config_get) monkeypatch.setattr(app_module.asyncio, "to_thread", fake_to_thread) monkeypatch.setattr(app_module.asyncio, "sleep", fake_async_noop) + if managed_service: + monkeypatch.setenv("_FLOCKS_SERVICE_CONFIG", "{}") + else: + monkeypatch.delenv("_FLOCKS_SERVICE_CONFIG", raising=False) monkeypatch.setitem( sys.modules, @@ -149,4 +162,4 @@ async def fake_async_noop(*_args, **_kwargs) -> None: async with app_module.lifespan(SimpleNamespace()): pass - assert events == ["cleanup_replaced_files", "recover_upgrade_state"] + assert events == expected_events diff --git a/tests/updater/test_restart_handoff.py b/tests/updater/test_restart_handoff.py index e9df3cbb..fc4a1c8f 100644 --- a/tests/updater/test_restart_handoff.py +++ b/tests/updater/test_restart_handoff.py @@ -1,3 +1,4 @@ +import json import shutil import sys from pathlib import Path @@ -383,6 +384,89 @@ def test_run_rolls_back_prepared_handover_when_restart_spawn_fails(monkeypatch, assert "rollback-handover" in events +def test_prepare_upgrade_handover_uses_snapshotted_service_config(monkeypatch, tmp_path: Path) -> None: + from flocks.updater import updater + + config_payload = { + "backend_host": "10.0.0.8", + "backend_port": 5273, + "frontend_host": "10.0.0.8", + "frontend_port": 5273, + "legacy_backend_host": "0.0.0.0", + "legacy_backend_port": 9000, + } + args = restart_handoff._parse_args( + [ + *_handoff_args(tmp_path, ["python", "-m", "flocks.cli.main", "start"], prepare_handover=True)[:-5], + "--service-config-json", + json.dumps(config_payload), + "--", + "python", + "-m", + "flocks.cli.main", + "start", + ] + ) + captured: dict[str, object] = {} + + monkeypatch.setattr( + updater, + "_prepare_upgrade_handover", + lambda version, *, config=None: captured.update(version=version, config=config) or {}, + ) + + assert restart_handoff._prepare_upgrade_handover(args) is True + assert captured["version"] == "2026.4.1" + config = captured["config"] + assert config.backend_host == "10.0.0.8" + assert config.backend_port == 5273 + assert config.legacy_backend_host == "0.0.0.0" + assert config.legacy_backend_port == 9000 + + +def test_prepare_upgrade_handover_builds_config_from_legacy_handoff_args(monkeypatch, tmp_path: Path) -> None: + from flocks.updater import updater + + args = restart_handoff._parse_args( + _handoff_args( + tmp_path, + [ + "python", + "-m", + "flocks.cli.main", + "start", + "--no-browser", + "--skip-webui-build", + "--host", + "127.0.0.1", + "--port", + "8000", + "--server-host", + "0.0.0.0", + "--server-port", + "9000", + ], + prepare_handover=True, + ) + ) + captured: dict[str, object] = {} + + monkeypatch.setattr( + updater, + "_prepare_upgrade_handover", + lambda version, *, config=None: captured.update(version=version, config=config) or {}, + ) + + assert restart_handoff._prepare_upgrade_handover(args) is True + config = captured["config"] + assert config.backend_host == "127.0.0.1" + assert config.backend_port == 8000 + assert config.frontend_host == "127.0.0.1" + assert config.frontend_port == 5173 + assert config.legacy_backend_host == "0.0.0.0" + assert config.legacy_backend_port == 9000 + + @pytest.mark.skipif(sys.platform == "win32", reason="uses the Unix domain socket control API") def test_stop_supervisor_before_restart_waits_until_real_control_api_stops(monkeypatch) -> None: short_root = make_short_runtime_root("flocks-handoff-") @@ -394,6 +478,7 @@ def test_stop_supervisor_before_restart_waits_until_real_control_api_stops(monke try: wait_for_supervisor(paths, running=True) + monkeypatch.setattr(service_manager, "pid_is_running", lambda _pid: False) assert restart_handoff._stop_supervisor_before_restart(timeout_seconds=5.0, poll_interval_seconds=0.05) is True @@ -405,6 +490,35 @@ def test_stop_supervisor_before_restart_waits_until_real_control_api_stops(monke shutil.rmtree(short_root, ignore_errors=True) +def test_stop_supervisor_before_restart_waits_for_daemon_pid_after_control_stops(monkeypatch) -> None: + from flocks.cli import service_control + + events: list[str] = [] + control_states = iter([True, False, False]) + process_states = iter([True, False]) + + monkeypatch.setattr(service_control, "supervisor_is_running", lambda _paths: next(control_states)) + monkeypatch.setattr( + service_control, + "read_supervisor_status", + lambda **_kwargs: SimpleNamespace(daemon=SimpleNamespace(pid=4321)), + ) + monkeypatch.setattr( + service_control, + "request_stop", + lambda **_kwargs: events.append("stop"), + ) + monkeypatch.setattr( + service_manager, + "pid_is_running", + lambda pid: events.append(f"pid:{pid}") or next(process_states), + ) + monkeypatch.setattr(restart_handoff.time, "sleep", lambda _seconds: events.append("sleep")) + + assert restart_handoff._stop_supervisor_before_restart() is True + assert events == ["stop", "pid:4321", "sleep", "pid:4321"] + + def test_ensure_backend_port_free_waits_again_after_timeout(monkeypatch) -> None: events: list[str] = [] wait_results = iter([False, True]) diff --git a/tests/updater/test_updater.py b/tests/updater/test_updater.py index cbeb4b04..f926bdd7 100644 --- a/tests/updater/test_updater.py +++ b/tests/updater/test_updater.py @@ -1,3 +1,4 @@ +import json import os import shutil import subprocess @@ -63,6 +64,7 @@ def _webui_control_status( def test_current_service_config_requires_supervisor_control_api(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("_FLOCKS_SERVICE_CONFIG", raising=False) monkeypatch.setattr( service_control, "read_supervisor_status", @@ -73,6 +75,36 @@ def test_current_service_config_requires_supervisor_control_api(monkeypatch: pyt updater._current_service_config() +def test_current_service_config_uses_environment_snapshot_when_control_api_is_down( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv( + "_FLOCKS_SERVICE_CONFIG", + json.dumps( + { + "backend_host": "10.0.0.8", + "backend_port": 5273, + "frontend_host": "10.0.0.8", + "frontend_port": 5273, + "legacy_backend_host": "0.0.0.0", + "legacy_backend_port": 9000, + } + ), + ) + monkeypatch.setattr( + service_control, + "read_supervisor_status", + lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("control down")), + ) + + config = updater._current_service_config() + + assert config.backend_host == "10.0.0.8" + assert config.backend_port == 5273 + assert config.legacy_backend_host == "0.0.0.0" + assert config.legacy_backend_port == 9000 + + def test_run_handles_none_process_output(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: def fake_run(*args, **kwargs): return subprocess.CompletedProcess(args=args[0], returncode=0, stdout=None, stderr=None) @@ -516,7 +548,6 @@ def test_build_dependency_sync_command_installs_project_on_windows( assert updater._build_dependency_sync_command("uv", uv_default_index="https://mirror.example/simple") == [ "uv", "sync", - "--frozen", "--no-python-downloads", "--default-index", "https://mirror.example/simple", @@ -528,7 +559,68 @@ def test_build_dependency_sync_command_keeps_project_install_on_non_windows( ) -> None: monkeypatch.setattr(updater.sys, "platform", "linux") - assert updater._build_dependency_sync_command("uv") == ["uv", "sync", "--frozen", "--no-python-downloads"] + assert updater._build_dependency_sync_command("uv") == ["uv", "sync", "--no-python-downloads"] + + +def test_dependency_sync_timeout_is_300_seconds_on_all_platforms(monkeypatch: pytest.MonkeyPatch) -> None: + for platform in ("linux", "darwin", "win32"): + monkeypatch.setattr(updater.sys, "platform", platform) + assert updater._dependency_sync_timeout_seconds() == 300 + + +@pytest.mark.asyncio +async def test_dependency_sync_timeout_retries_without_mirror_and_logs_output( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + commands: list[list[str]] = [] + warnings: list[tuple[str, dict[str, object]]] = [] + + async def fake_run_async(cmd, **_kwargs): + commands.append(list(cmd)) + if len(commands) == 1: + raise subprocess.TimeoutExpired( + cmd=cmd, + timeout=300, + output=b"downloaded 42 MB", + stderr=b"mirror stalled", + ) + return 0, "", "" + + monkeypatch.setattr(updater, "_run_async", fake_run_async) + monkeypatch.setattr(updater.log, "warning", lambda event, details: warnings.append((event, details))) + monkeypatch.setattr(updater.asyncio, "sleep", lambda _seconds: None) + + error = await updater._sync_project_dependencies( + uv_path="uv", + install_root=tmp_path, + uv_default_index="https://mirror.example/simple", + sync_timeout=300, + ) + + assert error is None + assert commands == [ + [ + "uv", + "sync", + "--no-python-downloads", + "--default-index", + "https://mirror.example/simple", + ], + ["uv", "sync", "--no-python-downloads"], + ] + assert warnings == [ + ( + "updater.dependencies.sync_timeout", + { + "command": commands[0], + "timeout": 300, + "stdout": "downloaded 42 MB", + "stderr": "mirror stalled", + "fallback_without_default_index": True, + }, + ) + ] def test_wheel_build_config_does_not_force_include_runtime_or_build_outputs() -> None: @@ -827,6 +919,18 @@ def test_build_restart_handoff_argv_rewrites_serve_to_managed_start( ) assert "--prepare-handover" in argv[: argv.index("--")] + config_json = argv[argv.index("--service-config-json") + 1] + assert json.loads(config_json) == { + "backend_host": "10.0.0.8", + "backend_port": 5273, + "frontend_host": "10.0.0.8", + "frontend_port": 5273, + "legacy_backend_host": "0.0.0.0", + "legacy_backend_port": 9000, + "no_browser": False, + "server_port_migration_hint": False, + "skip_frontend_build": False, + } assert argv[argv.index("--") + 1 :] == [ "python", "-m", @@ -1104,6 +1208,36 @@ def test_prepare_upgrade_handover_writes_state_and_stops_frontend_with_real_cont shutil.rmtree(short_root, ignore_errors=True) +def test_prepare_upgrade_handover_persists_complete_service_config( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path / ".flocks")) + config = service_manager.ServiceConfig( + backend_host="10.0.0.8", + backend_port=5273, + frontend_host="10.0.0.8", + frontend_port=5273, + legacy_backend_host="0.0.0.0", + legacy_backend_port=9000, + server_port_migration_hint=True, + no_browser=True, + ) + + monkeypatch.setattr(service_control, "request_prepare_upgrade", lambda **_kwargs: None) + monkeypatch.setattr(updater, "_start_upgrade_page_server", lambda *_args: {}) + + updater._prepare_upgrade_handover("2026.4.1", config=config) + + state = updater._read_upgrade_state() + assert state is not None + assert state["legacy_backend_host"] == "0.0.0.0" + assert state["legacy_backend_port"] == 9000 + assert state["server_port_migration_hint"] is True + assert state["no_browser"] is True + assert state["skip_frontend_build"] is True + + @pytest.mark.skipif(sys.platform == "win32", reason="uses the Unix domain socket control API") def test_prepare_upgrade_handover_restores_frontend_when_upgrade_page_fails_with_real_control_api( monkeypatch: pytest.MonkeyPatch, @@ -1267,7 +1401,7 @@ def fake_restart_webui(config, *, force_frontend_build=False, **_kwargs): assert updater._read_upgrade_state() is None -def test_recover_upgrade_state_restart_failure_clears_state_without_restarting_page( +def test_recover_upgrade_state_restart_failure_preserves_failure_state( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -1301,7 +1435,48 @@ def fake_restart_webui(config, *, force_frontend_build=False, **_kwargs): updater.recover_upgrade_state() assert starts == [("resume", True, None), ("restart_webui", False, True)] - assert updater._read_upgrade_state() is None + state = updater._read_upgrade_state() + assert state is not None + assert state["phase"] == "rollback_failed" + assert state["last_error"] == "still broken" + + +def test_resolve_upgrade_runtime_state_preserves_rollback_failure_during_cleanup( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + flocks_root = tmp_path / ".flocks" + monkeypatch.setenv("FLOCKS_ROOT", str(flocks_root)) + stopped: list[int | None] = [] + updater._write_upgrade_state( + { + "version": "2026.4.1", + "backend_host": "127.0.0.1", + "backend_port": 5173, + "frontend_host": "127.0.0.1", + "frontend_port": 5173, + "phase": "rollback_failed", + "last_error": "still broken", + } + ) + updater._upgrade_server_pid_path().write_text("4321", encoding="utf-8") + updater._upgrade_page_dir().mkdir(parents=True) + monkeypatch.setattr( + updater, + "_stop_upgrade_page_server", + lambda *, frontend_port=None: stopped.append(frontend_port), + ) + + result = updater.resolve_upgrade_runtime_state(attempt_recover=False, frontend_port=5173) + + state = updater._read_upgrade_state() + assert result["action"] == "failure_preserved" + assert result["error"] == "still broken" + assert state is not None + assert state["phase"] == "rollback_failed" + assert stopped == [5173] + assert not updater._upgrade_server_pid_path().exists() + assert not updater._upgrade_page_dir().exists() def test_start_upgrade_page_server_binds_configured_frontend_host( @@ -1399,6 +1574,7 @@ def test_wait_for_upgrade_page_uses_access_host_for_local_probe( monkeypatch: pytest.MonkeyPatch, ) -> None: requested_urls: list[str] = [] + client_options: dict[str, object] = {} class _FakeClient: def __enter__(self): @@ -1411,13 +1587,18 @@ def get(self, url): requested_urls.append(url) return SimpleNamespace(status_code=200) - monkeypatch.setattr(updater.httpx, "Client", lambda timeout: _FakeClient()) + monkeypatch.setattr( + updater.httpx, + "Client", + lambda **kwargs: client_options.update(kwargs) or _FakeClient(), + ) monkeypatch.setattr(service_manager, "access_host", lambda host: "127.0.0.1" if host == "0.0.0.0" else host) monkeypatch.setattr(updater.time, "sleep", lambda _seconds: None) updater._wait_for_upgrade_page(service_manager.ServiceConfig(frontend_host="0.0.0.0", frontend_port=5173)) assert requested_urls == ["http://127.0.0.1:5173"] + assert client_options == {"timeout": 1.5, "trust_env": False} def test_wait_for_upgrade_page_falls_back_from_ipv6_to_ipv4_probe( @@ -1438,7 +1619,7 @@ def get(self, url): raise OSError("ipv6 unavailable") return SimpleNamespace(status_code=200) - monkeypatch.setattr(updater.httpx, "Client", lambda timeout: _FakeClient()) + monkeypatch.setattr(updater.httpx, "Client", lambda **_kwargs: _FakeClient()) monkeypatch.setattr(updater.time, "sleep", lambda _seconds: None) updater._wait_for_upgrade_page(service_manager.ServiceConfig(frontend_host="::", frontend_port=5173)) @@ -1502,7 +1683,7 @@ def fake_restart_webui(config, *, force_frontend_build=False, **_kwargs) -> serv assert updater._read_upgrade_state() is None -def test_rollback_failed_update_clears_state_when_restore_and_frontend_both_fail( +def test_rollback_failed_update_preserves_state_when_restore_and_frontend_both_fail( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -1549,7 +1730,10 @@ def fake_restart_webui(config, **_kwargs) -> service_control.SupervisorStatus: "resume:True", "rmtree:upgrade-page", ] - assert updater._read_upgrade_state() is None + state = updater._read_upgrade_state() + assert state is not None + assert state["phase"] == "rollback_failed" + assert state["last_error"] == "frontend still broken" def test_backup_current_version_excludes_all_dist_directories( @@ -1666,6 +1850,24 @@ def test_replace_install_dir_preserves_webui_node_modules( assert locked_binary.read_text(encoding="utf-8") == "locked" +def test_replace_install_dir_preserves_existing_webui_dist_when_source_has_no_dist( + tmp_path: Path, +) -> None: + source_dir = tmp_path / "source" + install_root = tmp_path / "install" + source_webui = source_dir / "webui" + target_webui = install_root / "webui" + + source_webui.mkdir(parents=True) + (source_webui / "package.json").write_text('{"name":"webui"}', encoding="utf-8") + (target_webui / "dist").mkdir(parents=True) + (target_webui / "dist" / "index.html").write_text("old", encoding="utf-8") + + updater._replace_install_dir(source_dir, install_root) + + assert (target_webui / "dist" / "index.html").read_text(encoding="utf-8") == "old" + + def test_replace_install_dir_copies_dot_flocks_plugins_from_source( tmp_path: Path, ) -> None: @@ -1870,6 +2072,7 @@ async def fake_sleep(_seconds) -> None: monkeypatch.setattr(updater, "_write_version_marker", lambda version: events.append(f"marker:{version}")) monkeypatch.setattr(updater, "_refresh_global_cli_entry", lambda _root: None) monkeypatch.setattr(updater, "_build_restart_argv", lambda install_root=None: ["/usr/bin/python3", "-m", "flocks.cli.main", "start"]) + monkeypatch.setattr(updater, "_handoff_service_config", service_manager.ServiceConfig) monkeypatch.setattr(updater.asyncio, "sleep", fake_sleep) monkeypatch.setattr( updater, @@ -1965,7 +2168,6 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): [ "/usr/bin/uv", "sync", - "--frozen", "--no-python-downloads", "--default-index", "https://mirrors.aliyun.com/pypi/simple", @@ -2005,7 +2207,6 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): if cmd == [ "/usr/bin/uv", "sync", - "--frozen", "--no-python-downloads", "--default-index", "https://mirrors.aliyun.com/pypi/simple", @@ -2039,12 +2240,11 @@ async def fake_sleep(_seconds) -> None: [ "/usr/bin/uv", "sync", - "--frozen", "--no-python-downloads", "--default-index", "https://mirrors.aliyun.com/pypi/simple", ], - ["/usr/bin/uv", "sync", "--frozen", "--no-python-downloads"], + ["/usr/bin/uv", "sync", "--no-python-downloads"], ] @@ -2636,7 +2836,7 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): progresses = [step async for step in updater.perform_update("2026.4.1", restart=False)] assert progresses[-1].stage == "done" - assert sync_calls == [([r"C:\tools\uv.exe", "sync", "--frozen", "--no-python-downloads"], install_root)] + assert sync_calls == [([r"C:\tools\uv.exe", "sync", "--no-python-downloads"], install_root)] assert (install_root / ".venv" / "Scripts" / "python.exe").read_text(encoding="utf-8") == "old" assert not (install_root / ".venv.flocks_backup").exists() @@ -3072,7 +3272,7 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): assert progresses[-1].message == "Frontend dependency install timed out after 300s while running npm ci." assert events == [ "replace", - "/usr/bin/uv sync --frozen --no-python-downloads", + "/usr/bin/uv sync --no-python-downloads", "/usr/bin/npm install", "/usr/bin/npm ci", "restore",