From f33bbdb9cef6c511f9a282a475354d311770102b Mon Sep 17 00:00:00 2001 From: Trae User Date: Sun, 20 Sep 2026 22:48:19 +0800 Subject: [PATCH 1/3] fix(dev-launcher): verify daemon identity and proxy auth before reuse - Start the quick Web worker in the foreground and accept an existing runtime only when /api/health echoes the recorded pid, port and token, so a stale run directory can no longer point the UI at another daemon. - Forward the daemon port and token to Vite so /api and /ws reach this worktree's daemon instead of a hard-coded 127.0.0.1:28080. - Add --use-embedded-assets to dev_web.py and drop the implicit --yes from the shell launchers; --yes stays with the embedded workflow. - On Windows replace the target before tightening its ACL, because the protected DACL applied to token.tmp blocked the rename and left the token next to an unusable temp file. --- .../skills/development-environment/SKILL.md | 16 +- scripts/dev_environment.py | 210 +++++++++++++++++- scripts/dev_web.bat | 4 +- scripts/dev_web.py | 29 ++- scripts/dev_web.sh | 2 +- src/utils/atomic_file.hpp | 43 ++-- tests/scripts/dev_environment_test.py | 146 +++++++++++- tests/scripts/dev_web_test.py | 24 ++ web/README.md | 21 +- web/vite.config.js | 13 +- 10 files changed, 448 insertions(+), 60 deletions(-) diff --git a/.agents/skills/development-environment/SKILL.md b/.agents/skills/development-environment/SKILL.md index 5a10e0e0..6565d470 100644 --- a/.agents/skills/development-environment/SKILL.md +++ b/.agents/skills/development-environment/SKILL.md @@ -35,23 +35,23 @@ On macOS or Linux: ./scripts/dev_tui.sh ``` +`dev_web` defaults to rapid frontend development: it starts or reuses a current-worktree daemon, then keeps Vite running in the foreground. Open the Vite URL (normally `http://127.0.0.1:5173`) rather than the daemon URL to see hot reload changes. It does not rebuild an existing native executable or `web/dist`. + +Use `dev_web --embedded` only to rebuild `web/dist`, rebuild the native daemon with embedded assets, and validate the production-like static UI. If quick mode lacks a compatible executable, an interactive terminal asks before compiling; non-interactive callers must explicitly pass `--build-daemon`. + Pass `--build-dir ` only when the user explicitly supplies a candidate build directory. Do not copy `acecode`, `acecode-desktop`, DLLs, or other build artifacts between worktrees. ## Build reuse and rebuild policy 启动器只复用当前工作树内的 CMake 构建,检查源码路径、平台、架构、目标产物及 Desktop 配置。多配置构建会编译并启动同一配置。其他已登记工作树仅可提供经验证的前端产物和编译缓存,不提供本工作树实际运行的程序。 -Every launch incrementally builds the verified target, so source changes are incorporated even when the configured build is reused. Web and Desktop also refresh frontend assets when their inputs are newer than `web/dist`. - -If no compatible configured build exists, the launcher reports the platform CMake preset and asks for confirmation before configuration. Preserve that safety boundary: +Desktop and TUI launches incrementally build their verified targets. Web only refreshes frontend assets and rebuilds its daemon in explicit `--embedded` mode. -- Windows target-specific batch launchers automatically approve this first configuration so they work when double-clicked. -- For the shared Python launcher and POSIX target-specific launchers, state that configuration is needed, name the preset, and ask the user for explicit confirmation before adding `--yes`. -- If the user declines, do not configure, compile, or start a surface. +If a compatible configured build does not exist, Desktop and TUI report the platform CMake preset and ask for confirmation before configuration. Web quick mode follows its `--build-daemon` authorization rule above; embedded mode follows the normal build confirmation flow. If the user declines, do not configure, compile, or start a surface. -The shared launcher calls the existing Python surface launchers: `scripts/dev_web.py` for Web and `scripts/dev_desktop.py` for Desktop. Web uses a worktree-isolated runtime directory and opens its resulting local URL; Desktop opens its application window; TUI opens a new terminal window. +The shared launcher calls `scripts/dev_web.py` only to start the Web daemon; quick Web mode then runs Vite with a worktree-isolated daemon runtime directory. Desktop opens its application window; TUI opens a new terminal window. -Windows 的 MSVC 构建会按 x64 或 ARM64 初始化 VS 环境;有效 MinGW 构建不要求 VS。Web 重建前发现既存 PID 记录时会明确失败并给出检查或停止命令;不要通过删 PID 文件、宽泛终止进程等方式绕过此检查。显式 `--run-dir` 只检查所指定的目录。 +Windows 的 MSVC 构建会按 x64 或 ARM64 初始化 VS 环境;有效 MinGW 构建不要求 VS。Quick Web mode reuses only a healthy daemon in its own runtime directory and never deletes PID files or broadly terminates processes. If port 28080 is unavailable, it reserves an available loopback port, starts the daemon on it, and forwards that port only to the launched Vite process. Explicit `--run-dir` only checks the specified directory. ## Report outcome diff --git a/scripts/dev_environment.py b/scripts/dev_environment.py index 0aafceb5..33563041 100644 --- a/scripts/dev_environment.py +++ b/scripts/dev_environment.py @@ -11,8 +11,12 @@ import re import shlex import shutil +import socket import subprocess import sys +import time +import urllib.error +import urllib.request from dataclasses import dataclass from pathlib import Path from typing import Iterable @@ -211,7 +215,7 @@ def ask_to_build(preset: str, target: str, assume_yes: bool) -> bool: print(f"[INFO] Proposed build: cmake --build {binary_dir} --target {executable_target}") if assume_yes: return True - if not sys.stdin.isatty(): + if sys.stdin is None or not sys.stdin.isatty(): print("[ERROR] Refusing to compile without interactive confirmation. Re-run with --yes to confirm.", file=sys.stderr) return False try: @@ -477,6 +481,37 @@ def selected_web_runtime_dir(root: Path, extra: list[str]) -> Path: return args.run_dir.resolve() if args.run_dir.is_absolute() else (root / args.run_dir).resolve() +def web_launcher_options(extra: list[str]) -> tuple[bool, bool, list[str]]: + """Return embedded mode, explicit native-build approval, and remaining args.""" + embedded = False + build_daemon = False + remaining: list[str] = [] + for argument in extra: + if argument == "--embedded": + embedded = True + elif argument == "--build-daemon": + build_daemon = True + else: + remaining.append(argument) + return embedded, build_daemon, remaining + + +def vite_options(extra: list[str]) -> list[str]: + """Strip daemon-only runtime options before forwarding options to Vite.""" + result: list[str] = [] + skip_next = False + for argument in extra: + if skip_next: + skip_next = False + elif argument == "--run-dir": + skip_next = True + elif argument.startswith("--run-dir="): + continue + else: + result.append(argument) + return result + + def web_runtime_is_available(root: Path, candidate: BuildCandidate | None, extra: list[str]) -> bool: run_dir = selected_web_runtime_dir(root, extra) explicit = any(arg == "--run-dir" or arg.startswith("--run-dir=") for arg in extra) @@ -560,6 +595,124 @@ def tui_command(root: Path, executable: Path, extra: list[str] | None = None) -> return None +def daemon_port(runtime_dir: Path) -> int | None: + try: + port = int((runtime_dir / "daemon.port").read_text(encoding="utf-8").strip()) + return port if 1 <= port <= 65535 else None + except (OSError, ValueError): + return None + + +def daemon_is_healthy(root: Path, candidate: BuildCandidate, run_dir: Path, timeout_seconds: float = 0) -> bool: + del root, candidate + deadline = time.monotonic() + timeout_seconds + while True: + try: + pid = int((run_dir / "daemon.pid").read_text(encoding="utf-8").strip()) + port = daemon_port(run_dir) + token = daemon_token(run_dir) + if pid > 0 and port is not None and token: + request = urllib.request.Request( + f"http://127.0.0.1:{port}/api/health", + headers={"X-ACECode-Token": token}, + ) + with urllib.request.urlopen(request, timeout=1) as response: + payload = json.loads(response.read().decode("utf-8")) + # A stale runtime can point at another daemon already using the + # same port. Match the daemon identity from the health payload, + # not merely TCP reachability. + if int(payload.get("pid", -1)) == pid and int(payload.get("port", -1)) == port: + return True + except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError, + urllib.error.URLError): + pass + if time.monotonic() >= deadline: + return False + time.sleep(0.1) + + +def reserve_loopback_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("127.0.0.1", 0)) + return listener.getsockname()[1] + + +def start_quick_web_daemon(root: Path, candidate: BuildCandidate, run_dir: Path, port: int) -> int | None: + """Start the worker directly; the detached daemon wrapper rejects token.tmp runtimes.""" + command = [ + str(candidate.executable), "daemon", "--foreground", + f"--cwd={root.as_posix()}", f"--run-dir={run_dir.as_posix()}", f"--port={port}", + ] + options = {"cwd": root, "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL} + if os.name == "nt": + options["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + try: + subprocess.Popen(command, **options) + except OSError as error: + print(f"[ERROR] Could not start Web daemon: {error}", file=sys.stderr) + return None + if not daemon_is_healthy(root, candidate, run_dir, timeout_seconds=15): + return None + return daemon_port(run_dir) + + +def daemon_token(runtime_dir: Path) -> str | None: + for name in ("token", "token.tmp"): + try: + token = (runtime_dir / name).read_text(encoding="utf-8").strip() + if token: + return token + except OSError: + pass + return None + + +def launch_vite(root: Path, daemon_port_number: int, runtime_dir: Path, extra: list[str]) -> int: + token = daemon_token(runtime_dir) + if token is None: + print(f"[ERROR] Web daemon did not provide an authentication token: {runtime_dir}", file=sys.stderr) + return 1 + builder = load_web_builder(root) + if builder is None: + print("[ERROR] Cannot load Web development server helper.", file=sys.stderr) + return 1 + try: + _, pnpm = builder.ensure_node_and_pnpm() + except (OSError, subprocess.SubprocessError, SystemExit): + return 1 + web_dir = root / "web" + if not (web_dir / "node_modules").is_dir(): + print("[INFO] Installing Web dependencies...") + if subprocess.run([pnpm, "install"], cwd=web_dir, check=False).returncode != 0: + return 1 + environment = os.environ.copy() + environment["ACECODE_DAEMON_PORT"] = str(daemon_port_number) + environment["ACECODE_DAEMON_TOKEN"] = token + print(f"[INFO] Starting Vite with daemon proxy: http://127.0.0.1:{daemon_port_number}") + return subprocess.run([pnpm, "dev", *vite_options(extra)], cwd=web_dir, env=environment, check=False).returncode + + +def launch_quick_web(root: Path, candidate: BuildCandidate, extra: list[str]) -> int: + run_dir = selected_web_runtime_dir(root, extra) + if daemon_is_healthy(root, candidate, run_dir): + port = daemon_port(run_dir) + print(f"[INFO] Reusing Web daemon: http://127.0.0.1:{port}") + return launch_vite(root, port, run_dir, extra) + if (run_dir / "daemon.pid").exists(): + print(f"[ERROR] Existing Web daemon runtime is unhealthy; inspect it before retrying: {run_dir}", file=sys.stderr) + return 1 + port = start_quick_web_daemon(root, candidate, run_dir, 28080) + if port is None: + fallback_port = reserve_loopback_port() + print(f"[INFO] Standard development port unavailable; retrying on {fallback_port}.") + port = start_quick_web_daemon(root, candidate, run_dir, fallback_port) + if port is None: + print("[ERROR] Web daemon could not be started.", file=sys.stderr) + return 1 + print(f"[INFO] Started Web daemon: http://127.0.0.1:{port}") + return launch_vite(root, port, run_dir, extra) + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Start an ACECode development environment", allow_abbrev=False) parser.add_argument("target", nargs="?", choices=TARGETS, help="development surface to start") @@ -574,7 +727,7 @@ def parse_args() -> argparse.Namespace: def choose_target(target: str | None) -> str | None: if target: return target - if not sys.stdin.isatty(): + if sys.stdin is None or not sys.stdin.isatty(): print("[ERROR] Specify one target: web, desktop, or tui.", file=sys.stderr) return None selected = input("Choose development target [web/desktop/tui]: ").strip().lower() @@ -590,6 +743,45 @@ def main() -> int: if not target: return 2 root = project_root() + embedded, build_daemon, extra = web_launcher_options(args.extra) if target == "web" else (False, False, args.extra) + if target == "web" and not embedded: + if "--yes" in extra: + print("[ERROR] --yes is only available to the embedded validation workflow; use --build-daemon for quick mode.", file=sys.stderr) + return 2 + candidate = find_compatible_build(root, target, args.build_dir) + if args.build_dir and candidate is None: + print(f"[ERROR] --build-dir does not contain a compatible configured {target} build: {(root / args.build_dir).resolve()}", file=sys.stderr) + return 1 + if candidate is None: + preset = default_preset(target) + if preset is None: + print("[ERROR] No supported CMake preset for this platform and architecture.", file=sys.stderr) + return 1 + if not build_daemon and (sys.stdin is None or not sys.stdin.isatty()): + print("[ERROR] No compatible daemon executable was found. Re-run with --build-daemon to authorize its native build.", file=sys.stderr) + return 1 + if not ask_to_build(preset, target, build_daemon): + return 1 + if args.dry_run: + print(f"[INFO] Dry run: cmake --preset {preset}") + return 0 + if not ensure_windows_environment(root, None): + return 1 + sccache = find_sccache() + built = configure_and_build(root, preset, target, sccache) + if not built and sccache: + print("[INFO] sccache configuration failed; retrying normal compilation.") + built = configure_and_build(root, preset, target, None) + if not built: + return 1 + candidate = find_compatible_build(root, target, built) + if candidate is None: + print("[ERROR] Build completed but did not produce a compatible executable.", file=sys.stderr) + return 1 + if args.dry_run: + print(f"[INFO] Dry run: start Vite with {candidate.executable}") + return 0 + return launch_quick_web(root, candidate, extra) if target == "desktop" and "--list" in args.extra: command = [sys.executable, str(root / "scripts/dev_desktop.py"), *args.extra] if args.build_dir: @@ -607,7 +799,7 @@ def main() -> int: if args.build_dir and candidate is None: print(f"[ERROR] --build-dir does not contain a compatible configured {target} build: {(root / args.build_dir).resolve()}", file=sys.stderr) return 1 - if not args.dry_run and target == "web" and not web_runtime_is_available(root, candidate, args.extra): + if not args.dry_run and target == "web" and not web_runtime_is_available(root, candidate, extra): return 1 if not args.dry_run and not ensure_windows_environment(root, candidate): return 1 @@ -620,7 +812,7 @@ def main() -> int: if preset is None: print("[ERROR] No supported CMake preset for this platform and architecture.", file=sys.stderr) return 1 - if not ask_to_build(preset, target, args.yes): + if not ask_to_build(preset, target, args.yes or (target == "web" and embedded and os.name == "nt")): return 1 if args.dry_run: print(f"[INFO] Dry run: cmake --preset {preset}") @@ -649,6 +841,10 @@ def main() -> int: return 1 else: return 1 + if not args.dry_run and target == "web": + if not refresh_web_assets(root): + print("[ERROR] Web asset refresh failed; embedded development environment was not started.", file=sys.stderr) + return 1 if not args.dry_run and not build_target(root, candidate.build_dir, target, sccache, candidate.configuration): if sccache: preset = default_preset(target) @@ -662,12 +858,12 @@ def main() -> int: else: print("[ERROR] Incremental build failed; development environment was not started.", file=sys.stderr) return 1 - if target in {"web", "desktop"} and not args.dry_run: - refreshed = refresh_web_assets(root, force=True) if target == "desktop" and "--rebuild" in args.extra else refresh_web_assets(root) + if target == "desktop" and not args.dry_run: + refreshed = refresh_web_assets(root, force=True) if "--rebuild" in args.extra else refresh_web_assets(root) if not refreshed: print("[ERROR] Web asset refresh failed; development environment was not started.", file=sys.stderr) return 1 - return launch_surface(root, target, candidate, args.dry_run, args.extra) + return launch_surface(root, target, candidate, args.dry_run, extra) if __name__ == "__main__": diff --git a/scripts/dev_web.bat b/scripts/dev_web.bat index 6058b479..98309a98 100644 --- a/scripts/dev_web.bat +++ b/scripts/dev_web.bat @@ -1,6 +1,6 @@ @echo off REM ACECode Web development launcher (Windows) -REM Usage: scripts\dev_web.bat [Web daemon options] +REM Usage: scripts\dev_web.bat [--embedded] [--build-daemon] [Vite options] setlocal set "SCRIPT_DIR=%~dp0" @@ -18,5 +18,5 @@ if not errorlevel 1 ( ) ) -"%PYTHON%" "%SCRIPT_DIR%dev_environment.py" web --yes %* +"%PYTHON%" "%SCRIPT_DIR%dev_environment.py" web %* exit /b %errorlevel% diff --git a/scripts/dev_web.py b/scripts/dev_web.py index 92836d4e..b7ffc827 100644 --- a/scripts/dev_web.py +++ b/scripts/dev_web.py @@ -42,6 +42,11 @@ def main() -> int: default=None, help="Web assets directory; defaults to /web/dist", ) + parser.add_argument( + "--use-embedded-assets", + action="store_true", + help="Serve assets embedded in the executable instead of web/dist", + ) parser.add_argument( "--run-dir", default=None, help="Isolate daemon runtime files to this directory" ) @@ -67,14 +72,16 @@ def main() -> int: print(" Build the acecode target first, or pass --build-dir.", file=sys.stderr) return 1 - static_dir = Path(args.static_dir) if args.static_dir else project_root / "web" / "dist" - if not static_dir.is_absolute(): - static_dir = project_root / static_dir - static_dir = static_dir.resolve() - if not (static_dir / "index.html").is_file(): - print(f"[ERROR] Web UI assets not found: {static_dir / 'index.html'}", file=sys.stderr) - print(" Run `pnpm --dir web build` first, or pass --static-dir.", file=sys.stderr) - return 1 + static_dir = None + if not args.use_embedded_assets: + static_dir = Path(args.static_dir) if args.static_dir else project_root / "web" / "dist" + if not static_dir.is_absolute(): + static_dir = project_root / static_dir + static_dir = static_dir.resolve() + if not (static_dir / "index.html").is_file(): + print(f"[ERROR] Web UI assets not found: {static_dir / 'index.html'}", file=sys.stderr) + print(" Run `pnpm --dir web build` first, pass --static-dir, or use --use-embedded-assets.", file=sys.stderr) + return 1 workspace = Path(args.cwd).resolve() if args.cwd else project_root if not workspace.is_dir(): @@ -89,7 +96,9 @@ def main() -> int: print("[ERROR] --port must be between 1 and 65535", file=sys.stderr) return 1 command.append(f"--port={args.port}") - command.extend((f"--cwd={workspace}", f"--static-dir={static_dir}")) + command.append(f"--cwd={workspace}") + if static_dir is not None: + command.append(f"--static-dir={static_dir}") if args.run_dir: run_dir = Path(args.run_dir) if not run_dir.is_absolute(): @@ -99,7 +108,7 @@ def main() -> int: print(f"[INFO] Starting Web UI daemon: {executable}") print(f"[INFO] Workspace: {workspace}") - print(f"[INFO] Static assets: {static_dir}") + print(f"[INFO] Static assets: {static_dir if static_dir is not None else 'embedded executable assets'}") print("[INFO] Desktop GUI is not started.", flush=True) if args.foreground: diff --git a/scripts/dev_web.sh b/scripts/dev_web.sh index 6a76b9cc..9ee912ce 100755 --- a/scripts/dev_web.sh +++ b/scripts/dev_web.sh @@ -1,6 +1,6 @@ #!/bin/bash # ACECode Web development launcher (macOS / Linux) -# Usage: ./scripts/dev_web.sh [Web daemon options] +# Usage: ./scripts/dev_web.sh [--embedded] [--build-daemon] [Vite options] set -e diff --git a/src/utils/atomic_file.hpp b/src/utils/atomic_file.hpp index ce75b54e..57008a04 100644 --- a/src/utils/atomic_file.hpp +++ b/src/utils/atomic_file.hpp @@ -57,10 +57,28 @@ inline bool atomic_write_file(const std::string& path, fs::remove(tmp, rmec); return false; } +#endif + } + + fs::rename(tmp, target, ec); + if (ec) { +#ifdef _WIN32 + if (!::MoveFileExW(tmp.wstring().c_str(), + target.wstring().c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { + // Rename failed. Keep tmp present so caller can inspect. + return false; + } #else - // Restrict ACL to current user only. Best-effort: failure does not - // abort the write since the file is still on a per-user profile path - // by convention. Future hardening could fail-hard here. + // Rename failed. Keep tmp present so caller can inspect. + return false; +#endif + } + +#ifdef _WIN32 + if (restrict_permissions) { + // Apply the protected ACL after replacing the target. Applying it to + // token.tmp first can prevent the subsequent rename on Windows. HANDLE token = nullptr; if (::OpenProcessToken(::GetCurrentProcess(), TOKEN_QUERY, &token)) { DWORD len = 0; @@ -70,8 +88,6 @@ inline bool atomic_write_file(const std::string& path, if (::GetTokenInformation(token, TokenUser, buf.data(), len, &len)) { PSID user_sid = reinterpret_cast(buf.data())->User.Sid; EXPLICIT_ACCESSW ea{}; - // Rename/replacement needs DELETE on the file when the - // parent does not grant FILE_DELETE_CHILD (e.g. Modify). ea.grfAccessPermissions = GENERIC_READ | GENERIC_WRITE | DELETE; ea.grfAccessMode = SET_ACCESS; ea.grfInheritance = NO_INHERITANCE; @@ -80,9 +96,9 @@ inline bool atomic_write_file(const std::string& path, ea.Trustee.ptstrName = reinterpret_cast(user_sid); PACL acl = nullptr; if (::SetEntriesInAclW(1, &ea, nullptr, &acl) == ERROR_SUCCESS && acl) { - std::wstring tmp_w = tmp.wstring(); + std::wstring target_w = target.wstring(); ::SetNamedSecurityInfoW( - const_cast(tmp_w.c_str()), + const_cast(target_w.c_str()), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, nullptr, nullptr, acl, nullptr); @@ -92,21 +108,8 @@ inline bool atomic_write_file(const std::string& path, } ::CloseHandle(token); } -#endif } - - fs::rename(tmp, target, ec); - if (ec) { -#ifdef _WIN32 - if (::MoveFileExW(tmp.wstring().c_str(), - target.wstring().c_str(), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { - return true; - } #endif - // Rename failed. Keep tmp present so caller can inspect. - return false; - } return true; } diff --git a/tests/scripts/dev_environment_test.py b/tests/scripts/dev_environment_test.py index 636b6463..789c220e 100644 --- a/tests/scripts/dev_environment_test.py +++ b/tests/scripts/dev_environment_test.py @@ -91,7 +91,7 @@ def test_runtime_directory_is_scoped_to_worktree(self): self.assertEqual(runtime, root / ".acecode/dev-run/my-project-abcdef123456") def test_noninteractive_target_selection_fails(self): - with patch.object(dev_environment.sys.stdin, "isatty", return_value=False): + with patch.object(dev_environment.sys, "stdin", None): self.assertIsNone(dev_environment.choose_target(None)) def test_sccache_discovery_prefers_usable_path_and_has_install_hint(self): @@ -211,11 +211,27 @@ def test_sccache_stats_parses_real_nested_shape(self): {"hits": 9, "misses": 3, "errors": 8}, ) - def test_main_refreshes_build_and_web_assets_before_web_start(self): + def test_main_starts_quick_web_without_rebuilding_existing_daemon(self): candidate = dev_environment.BuildCandidate( Path("C:/work/build"), Path("C:/work"), Path("C:/work/build/acecode.exe") ) args = type("Args", (), {"target": "web", "build_dir": None, "yes": False, "dry_run": False, "extra": []})() + with patch.object(dev_environment, "parse_args", return_value=args), \ + patch.object(dev_environment, "project_root", return_value=Path("C:/work")), \ + patch.object(dev_environment, "find_compatible_build", return_value=candidate), \ + patch.object(dev_environment, "launch_quick_web", return_value=0) as launch, \ + patch.object(dev_environment, "build_target") as build, \ + patch.object(dev_environment, "refresh_web_assets") as refresh: + self.assertEqual(dev_environment.main(), 0) + launch.assert_called_once_with(Path("C:/work"), candidate, []) + build.assert_not_called() + refresh.assert_not_called() + + def test_main_embedded_mode_builds_and_refreshes_assets(self): + candidate = dev_environment.BuildCandidate( + Path("C:/work/build"), Path("C:/work"), Path("C:/work/build/acecode.exe") + ) + args = type("Args", (), {"target": "web", "build_dir": None, "yes": False, "dry_run": False, "extra": ["--embedded"]})() with patch.object(dev_environment, "parse_args", return_value=args), \ patch.object(dev_environment, "project_root", return_value=Path("C:/work")), \ patch.object(dev_environment, "find_sccache", return_value=None), \ @@ -229,14 +245,127 @@ def test_main_refreshes_build_and_web_assets_before_web_start(self): refresh.assert_called_once_with(Path("C:/work")) launch.assert_called_once_with(Path("C:/work"), "web", candidate, False, []) + def test_quick_web_reuses_healthy_daemon_and_passes_its_port_to_vite(self): + root = Path("C:/work") + candidate = dev_environment.BuildCandidate(root / "build", root, root / "build/acecode.exe") + run_dir = root / ".acecode/dev-run/test" + with patch.object(dev_environment, "selected_web_runtime_dir", return_value=run_dir), \ + patch.object(dev_environment, "daemon_is_healthy", return_value=True), \ + patch.object(dev_environment, "daemon_port", return_value=38123), \ + patch.object(dev_environment, "launch_vite", return_value=0) as vite, \ + patch.object(dev_environment, "start_quick_web_daemon") as start: + self.assertEqual(dev_environment.launch_quick_web(root, candidate, []), 0) + vite.assert_called_once_with(root, 38123, run_dir, []) + start.assert_not_called() + + def test_quick_web_falls_back_to_system_selected_port(self): + root = Path("C:/work") + candidate = dev_environment.BuildCandidate(root / "build", root, root / "build/acecode.exe") + run_dir = root / ".acecode/dev-run/test" + with patch.object(dev_environment, "selected_web_runtime_dir", return_value=run_dir), \ + patch.object(dev_environment, "daemon_is_healthy", return_value=False), \ + patch.object(Path, "exists", return_value=False), \ + patch.object(dev_environment, "reserve_loopback_port", return_value=38123), \ + patch.object(dev_environment, "start_quick_web_daemon", side_effect=[None, 38123]) as start, \ + patch.object(dev_environment, "launch_vite", return_value=0) as vite: + self.assertEqual(dev_environment.launch_quick_web(root, candidate, []), 0) + self.assertEqual([call.args[3] for call in start.call_args_list], [28080, 38123]) + vite.assert_called_once_with(root, 38123, run_dir, []) + + def test_quick_daemon_start_requires_a_healthy_runtime(self): + root = Path("C:/work") + run_dir = root / ".acecode/dev-run/test" + candidate = dev_environment.BuildCandidate(root / "build", root, root / "build/acecode.exe") + with patch.object(dev_environment.subprocess, "Popen"), \ + patch.object(dev_environment, "daemon_is_healthy", return_value=False), \ + patch.object(dev_environment, "daemon_port", return_value=28080): + self.assertIsNone(dev_environment.start_quick_web_daemon(root, candidate, run_dir, 28080)) + + def test_healthy_daemon_requires_authenticated_identity_match(self): + root = Path("C:/work") + run_dir = root / ".acecode/dev-run/test" + candidate = dev_environment.BuildCandidate(root / "build", root, root / "build/acecode.exe") + with tempfile.TemporaryDirectory() as temp: + runtime = Path(temp) / "runtime" + runtime.mkdir() + (runtime / "daemon.pid").write_text("42", encoding="utf-8") + (runtime / "daemon.port").write_text("38123", encoding="utf-8") + (runtime / "token").write_text("secret", encoding="utf-8") + response = type("Response", (), { + "__enter__": lambda self: self, + "__exit__": lambda self, *args: None, + "read": lambda self: b'{"pid": 41, "port": 38123}', + })() + with patch.object(dev_environment.urllib.request, "urlopen", return_value=response) as urlopen: + self.assertFalse(dev_environment.daemon_is_healthy(root, candidate, runtime)) + urlopen.assert_called_once() + + def test_quick_daemon_starts_foreground_worker_with_isolated_runtime(self): + root = Path("C:/work") + run_dir = root / ".acecode/dev-run/test" + candidate = dev_environment.BuildCandidate(root / "build", root, root / "build/acecode.exe") + with patch.object(dev_environment.subprocess, "Popen") as popen, \ + patch.object(dev_environment, "daemon_is_healthy", return_value=True), \ + patch.object(dev_environment, "daemon_port", return_value=28080): + self.assertEqual(dev_environment.start_quick_web_daemon(root, candidate, run_dir, 28080), 28080) + self.assertEqual(popen.call_args.args[0], [ + str(candidate.executable), "daemon", "--foreground", + "--cwd=C:/work", "--run-dir=C:/work/.acecode/dev-run/test", "--port=28080", + ]) + + def test_launch_vite_passes_daemon_credentials_only_to_child_environment(self): + root = Path("C:/work") + run_dir = root / ".acecode/dev-run/test" + builder = type("Builder", (), {"ensure_node_and_pnpm": staticmethod(lambda: ("node", "pnpm"))}) + with patch.object(dev_environment, "load_web_builder", return_value=builder), \ + patch.object(dev_environment, "daemon_token", return_value="test-token"), \ + patch.object(Path, "is_dir", return_value=True), \ + patch.object(dev_environment.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as run: + self.assertEqual(dev_environment.launch_vite(root, 38123, run_dir, ["--host", "127.0.0.1"]), 0) + command = run.call_args.args[0] + environment = run.call_args.kwargs["env"] + self.assertEqual(command, ["pnpm", "dev", "--host", "127.0.0.1"]) + self.assertEqual(environment["ACECODE_DAEMON_PORT"], "38123") + self.assertEqual(environment["ACECODE_DAEMON_TOKEN"], "test-token") + + def test_vite_options_strip_daemon_runtime_argument(self): + self.assertEqual( + dev_environment.vite_options(["--run-dir", "runtime", "--host", "127.0.0.1"]), + ["--host", "127.0.0.1"], + ) + self.assertEqual( + dev_environment.vite_options(["--run-dir=runtime", "--port", "5174"]), + ["--port", "5174"], + ) + + def test_launch_vite_refuses_missing_daemon_token(self): + root = Path("C:/work") + run_dir = root / ".acecode/dev-run/test" + with patch.object(dev_environment, "daemon_token", return_value=None), \ + patch.object(dev_environment, "load_web_builder") as builder: + self.assertEqual(dev_environment.launch_vite(root, 38123, run_dir, []), 1) + builder.assert_not_called() + + def test_missing_quick_daemon_refuses_noninteractive_build_without_authorization(self): + args = type("Args", (), {"target": "web", "build_dir": None, "yes": False, "dry_run": False, "extra": []})() + with patch.object(dev_environment, "parse_args", return_value=args), \ + patch.object(dev_environment, "project_root", return_value=Path("C:/work")), \ + patch.object(dev_environment, "find_compatible_build", return_value=None), \ + patch.object(dev_environment, "default_preset", return_value="windows-x64-release"), \ + patch.object(dev_environment.sys, "stdin", None), \ + patch.object(dev_environment, "configure_and_build") as build: + self.assertEqual(dev_environment.main(), 1) + build.assert_not_called() + def test_confirmation_eof_refuses_configuration(self): - with patch.object(dev_environment.sys.stdin, "isatty", return_value=True), \ + stdin = type("Stdin", (), {"isatty": staticmethod(lambda: True)})() + with patch.object(dev_environment.sys, "stdin", stdin), \ patch("builtins.input", side_effect=EOFError): self.assertFalse(dev_environment.ask_to_build("windows-x64-release", "web", False)) def test_main_retries_failed_sccache_build_without_cache(self): candidate = dev_environment.BuildCandidate(Path("C:/work/build"), Path("C:/work"), Path("C:/work/build/acecode.exe")) - args = type("Args", (), {"target": "web", "build_dir": None, "yes": False, "dry_run": False, "extra": []})() + args = type("Args", (), {"target": "web", "build_dir": None, "yes": False, "dry_run": False, "extra": ["--embedded"]})() cache = Path("C:/tools/sccache.exe") with patch.object(dev_environment, "parse_args", return_value=args), \ patch.object(dev_environment, "project_root", return_value=Path("C:/work")), \ @@ -261,8 +390,13 @@ def test_web_launch_forwards_the_build_and_isolated_runtime_directory(self): result = dev_environment.launch_surface(Path("C:/work"), "web", candidate, dry_run=True, extra=[]) self.assertEqual(result, 0) - def test_windows_target_launchers_auto_approve_initial_configuration(self): - for target in ("web", "desktop", "tui"): + def test_windows_web_launcher_does_not_auto_approve_native_build(self): + wrapper = (ROOT / "scripts/dev_web.bat").read_text(encoding="utf-8") + self.assertIn('dev_environment.py" web %*', wrapper) + self.assertNotIn('dev_environment.py" web --yes %*', wrapper) + + def test_windows_other_target_launchers_auto_approve_initial_configuration(self): + for target in ("desktop", "tui"): wrapper = (ROOT / "scripts" / f"dev_{target}.bat").read_text(encoding="utf-8") self.assertIn(f'dev_environment.py" {target} --yes %*', wrapper) diff --git a/tests/scripts/dev_web_test.py b/tests/scripts/dev_web_test.py index 377a8ac2..4c7cfaa4 100644 --- a/tests/scripts/dev_web_test.py +++ b/tests/scripts/dev_web_test.py @@ -61,6 +61,30 @@ def test_successful_launch_without_port_fails(self): self.assertEqual(result, 1) self.assertIsNone(opened) + def test_embedded_assets_do_not_require_web_dist(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with patch.object(dev_web, "find_project_root", return_value=root), \ + patch.object(dev_web, "find_executable", return_value=root / "acecode.exe"), \ + patch.object(sys, "argv", ["dev_web.py", "--use-embedded-assets", "--no-browser"]), \ + patch.object(dev_web.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as run, \ + patch.object(dev_web, "_wait_for_port", return_value=12345), \ + patch.object(dev_web, "_open_web_ui"): + self.assertEqual(dev_web.main(), 0) + self.assertFalse(any(argument.startswith("--static-dir=") for argument in run.call_args.args[0])) + + def test_port_zero_is_rejected(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "web/dist").mkdir(parents=True) + (root / "web/dist/index.html").write_text("test", encoding="utf-8") + with patch.object(dev_web, "find_project_root", return_value=root), \ + patch.object(dev_web, "find_executable", return_value=root / "acecode.exe"), \ + patch.object(sys, "argv", ["dev_web.py", "--port=0", "--no-browser"]), \ + patch.object(dev_web.subprocess, "run") as run: + self.assertEqual(dev_web.main(), 1) + run.assert_not_called() + @unittest.skipUnless(os.name == "nt", "Windows batch wrapper") def test_batch_falls_back_to_py_and_preserves_exit_code(self): with tempfile.TemporaryDirectory() as directory: diff --git a/web/README.md b/web/README.md index 118ed699..0006d537 100644 --- a/web/README.md +++ b/web/README.md @@ -6,14 +6,29 @@ ## 开发流程 +快速前端开发使用仓库启动器: + +```bash +# Windows +scripts\dev_web.bat + +# macOS / Linux +./scripts/dev_web.sh +``` + +它会复用当前工作树的开发 daemon,或用已有的当前工作树 `acecode` 可执行文件启动一个 daemon,然后在前台运行 Vite。浏览器打开 Vite 地址(默认 `http://127.0.0.1:5173`)才能看到热更新;`/api` 与 `/ws` 自动代理到该 daemon。现有 native 可执行文件和 `web/dist` 不会因前端修改而重建。 + +首次没有 compatible daemon 可执行文件时,交互式启动器会先显示 native 构建并请求确认;自动化调用必须明确传 `--build-daemon` 才允许构建。 + +直接运行 Vite 仍然可用: + ```bash cd web pnpm install # 一次性安装依赖(也可用 npm / bun) -pnpm dev # 起 Vite dev server,默认 http://localhost:5173 - # /api 与 /ws 自动代理到 127.0.0.1:28080(本机 daemon) +pnpm dev # 默认将 /api 与 /ws 代理到 127.0.0.1:28080 ``` -需要先在另一个终端跑 `acecode daemon --foreground` 让 API 可用。 +这种手动方式需要自行在另一个终端启动 `acecode daemon --foreground --port=28080`。若要验证嵌入 `acecode` 的生产静态资源而非热更新页面,使用 `scripts/dev_web.bat --embedded` 或 `./scripts/dev_web.sh --embedded`;该显式模式会重新构建 `web/dist` 和 native daemon。 ## 全屏热浪快捷键 diff --git a/web/vite.config.js b/web/vite.config.js index 52d76984..0998a2a5 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -4,13 +4,20 @@ import tailwindcss from '@tailwindcss/vite'; import { viteSingleFile } from 'vite-plugin-singlefile'; import localizeStaticCopyBabelPlugin from './scripts/localize-static-copy-babel.mjs'; +const daemonPort = process.env.ACECODE_DAEMON_PORT || '28080'; +const daemonToken = process.env.ACECODE_DAEMON_TOKEN; +const daemonHttpTarget = `http://127.0.0.1:${daemonPort}`; +const daemonWsTarget = `ws://127.0.0.1:${daemonPort}`; +const daemonProxyHeaders = daemonToken ? { 'X-ACECode-Token': daemonToken } : undefined; + // daemon 在 build 时把 web/dist/ 嵌入二进制(见 cmake/acecode_embed_assets.cmake)。 // 用 viteSingleFile 把 JS/CSS 全部 inline 进 index.html — 一来 daemon 只需要 // serve 单个文件,二来绕开 Crow keep-alive 在多资源连接复用时丢 Content-Type // 的已知问题(同一 TCP 连接的第二个请求会回空 body / 空头)。 // 代价:bundle 不能拆 chunk,首屏 ~250KB(gzip 后 ~70KB),对内嵌部署完全 OK。 // -// dev 模式下跑 `pnpm dev` 起 Vite 5173,/api 与 /ws 自动代理到 127.0.0.1:28080。 +// dev 模式下跑 `pnpm dev` 起 Vite 5173;/api 与 /ws 默认代理到 127.0.0.1:28080。 +// scripts/dev_web 会为它启动的 Vite 进程设置 ACECODE_DAEMON_PORT,以代理到该工作树的 daemon。 export default defineConfig({ plugins: [ react({ babel: { plugins: [localizeStaticCopyBabelPlugin] } }), @@ -34,8 +41,8 @@ export default defineConfig({ server: { port: 5173, proxy: { - '/api': { target: 'http://127.0.0.1:28080', changeOrigin: true }, - '/ws': { target: 'ws://127.0.0.1:28080', ws: true, changeOrigin: true }, + '/api': { target: daemonHttpTarget, changeOrigin: true, headers: daemonProxyHeaders }, + '/ws': { target: daemonWsTarget, ws: true, changeOrigin: true, headers: daemonProxyHeaders }, }, }, }); From b7d84ecd8ecdb14b9d93862e9407ab049ca6b95d Mon Sep 17 00:00:00 2001 From: Trae User Date: Mon, 21 Sep 2026 09:07:33 +0800 Subject: [PATCH 2/3] feat(dev-launcher): harden web runtime and stabilise desktop instance identity Wave 1 - Web quick mode (scripts/dev_environment.py): - Capture the daemon worker's stdout/stderr into /daemon-worker.log with a timestamped header per spawn and 1 MiB rotation, so a failed start points at real diagnostics instead of a silent timeout. - Fail fast when the worker exits before the health check elapses, and report both daemon-worker.log and the daemon's own daemon-startup.log. - Self-heal a stale runtime only when the recorded pid is dead: clean it up via `daemon stop --run-dir=` and retry once. A live pid is never terminated and an unreadable pid file is skipped and reported. - Derive the default port from the runtime directory name via zlib.crc32 (28080-28280) with linear wraparound and millisecond bind probes, replacing the fixed 28080 plus 15-second health wait. Add --port for an explicit port that fails immediately when busy, and never use the salted built-in hash(). - Add a `prune` target that reports pruned/skipped/total, always exits 0, and deletes only directories whose pid is provably dead. Wave 2 - Desktop instance identity (src/desktop, scripts/dev_desktop.py): - Add header-only is_valid_instance_id() and parse_allow_multiple_instances(). The latter whitelists 1/true/yes/on so a stray "0" cannot silently enable multi-instance. The former rejects anything outside [A-Za-z0-9_.-] and also rejects all-dot identities, because ".", ".." would otherwise pass the character whitelist and let the instance run directory escape its container. - Read ACECODE_DESKTOP_INSTANCE_ID and ACECODE_DESKTOP_ALLOW_MULTIPLE_INSTANCES as process-level, dev-only overrides. The C++ side validates and never rewrites: an invalid identity falls back to a random uuid, so behaviour is unchanged when the variables are absent. - Inject a stable - identity from the dev launcher so repeated runs reuse one instance directory. Only the child environment is touched; the user's global configuration is never read or written. Also add the Windows SDK winrt/cppwinrt include directories to run_build.py and run_cmake_configure.py: wrl.h and EventToken.h live there, and without them the desktop WebView2 host fails with C1083. Verification: - tests/scripts: 93 unittest cases pass. - acecode_unit_tests: 4762 cases, 4756 pass. The 6 failures also fail on a stashed baseline build and touch none of the changed files. - End-to-end: 16/16 checks for Web quick mode against the real acecode.exe, and 14/14 for the desktop identity against the real acecode-desktop.exe. --- .../skills/development-environment/SKILL.md | 30 +- run_build.py | 5 +- run_cmake_configure.py | 5 +- scripts/dev_desktop.py | 36 +- scripts/dev_environment.py | 341 ++++++++++++++++-- src/desktop/instance_startup.hpp | 41 +++ src/desktop/main.cpp | 26 +- tests/desktop/instance_startup_test.cpp | 48 +++ tests/scripts/dev_desktop_test.py | 110 ++++++ tests/scripts/dev_environment_review_test.py | 2 +- tests/scripts/dev_environment_test.py | 299 ++++++++++++++- 11 files changed, 883 insertions(+), 60 deletions(-) diff --git a/.agents/skills/development-environment/SKILL.md b/.agents/skills/development-environment/SKILL.md index 6565d470..ba5cfdfd 100644 --- a/.agents/skills/development-environment/SKILL.md +++ b/.agents/skills/development-environment/SKILL.md @@ -51,7 +51,35 @@ If a compatible configured build does not exist, Desktop and TUI report the plat The shared launcher calls `scripts/dev_web.py` only to start the Web daemon; quick Web mode then runs Vite with a worktree-isolated daemon runtime directory. Desktop opens its application window; TUI opens a new terminal window. -Windows 的 MSVC 构建会按 x64 或 ARM64 初始化 VS 环境;有效 MinGW 构建不要求 VS。Quick Web mode reuses only a healthy daemon in its own runtime directory and never deletes PID files or broadly terminates processes. If port 28080 is unavailable, it reserves an available loopback port, starts the daemon on it, and forwards that port only to the launched Vite process. Explicit `--run-dir` only checks the specified directory. +Windows 的 MSVC 构建会按 x64 或 ARM64 初始化 VS 环境;有效 MinGW 构建不要求 VS。 + +## Quick Web 运行时行为 + +- **端口**:默认端口由运行目录名(`worktree 名-commit 前 12 位`)经 `zlib.crc32` 派生,落在 28080–28280。同一 worktree 同一 commit 每次拿到同一端口,不同 worktree / commit 天然错开。被占时线性顺延,只用毫秒级 bind 探测 —— 不会再为被占端口白等 15 秒健康检查。 +- 切 commit 会让运行目录与派生端口同时变化;直接访问 daemon 端口时一律以启动输出为准,日常请走 Vite 地址。 +- `--port ` 显式指定 daemon 端口:空闲即用,被占立即报错,绝不顺延。`--run-dir` 只检查指定的目录。 +- **陈旧运行目录自愈**:健康校验不通过且记录的 pid 已死时,自动清理该目录的运行时文件并重试一次;pid 仍存活则保留"请手动停止"提示。任何路径都不会终止存活进程,也不会删除判定不了的目录。 +- **失败诊断**:worker 的 stdout/stderr 落到 `/daemon-worker.log`(超过 1MB 滚动保留 `.log.1`)。启动失败或 worker 提前退出时,错误信息同时指向它与 daemon 自带的 `daemon-startup.log`;worker 秒崩会在秒级暴露,不等满健康检查超时。 + +## 回收陈旧运行目录 + +```powershell +.\scripts\dev_web.bat prune # Windows +python scripts/dev_environment.py prune +``` + +输出"已清理 / 跳过(含原因)/ 总计"三段:只清理 pid 已判死的目录,含已删除 worktree 的遗留和旧版本 `.acecode-dev-run/`;结构不认识的目录只报告不删除。存在跳过项时退出码仍为 0。日常每次启动也会顺带清理本 worktree 前缀下 pid 已死的同级目录,健康复用路径不扫描目录。 + +## Desktop 实例身份与多开 + +`scripts/dev_desktop.py` 启动前会注入两个**进程级**环境变量,它们只作用于本次启动的进程树,不读写、也不依赖用户的全局配置: + +- `ACECODE_DESKTOP_INSTANCE_ID` —— 取 `worktree 名-commit 前 12 位`(经字符净化),与 Web 运行目录用的是同一套身份。同一 worktree 同一 commit 反复启动拿到同一身份,因此 desktop 的附加实例运行目录(`run/desktop-instances/`)可以复用而不是每次新建;切 commit 身份随之变化。 +- `ACECODE_DESKTOP_ALLOW_MULTIPLE_INSTANCES=1` —— 只让本次启动允许附加实例,不改动全局的 `desktop.allow_multiple_instances`。 + +desktop 侧对这两个值的处理是**只校验不净化**:`ACECODE_DESKTOP_INSTANCE_ID` 只接受 `[A-Za-z0-9_.-]` 且长度 1–64,非法(含空、空格、`/`、`\`、`..`、超长)就丢弃并回退到随机 uuid;`ACECODE_DESKTOP_ALLOW_MULTIPLE_INSTANCES` 走白名单(`1`/`true`/`yes`/`on`,大小写不敏感),其余值一律视为关闭。未设置这两个变量时行为与以前完全一致。 + +因此直接运行 `acecode-desktop`(不经开发脚本)仍是随机身份 + 跟随全局配置;需要多开或复用稳定身份时走 `dev_desktop`。 ## Report outcome diff --git a/run_build.py b/run_build.py index a82cdc88..0ff504de 100644 --- a/run_build.py +++ b/run_build.py @@ -40,7 +40,10 @@ def prepend(name, dirs): prepend("PATH", [SDK_BIN, MSVC_BIN, cmake, ninja]) prepend("LIB", [MSVC_LIB, SDK_LIB + r"\ucrt\x64", SDK_LIB + r"\um\x64"]) -prepend("INCLUDE", [MSVC_INC, SDK_INC + r"\ucrt", SDK_INC + r"\um", SDK_INC + r"\shared"]) +# `winrt` and `cppwinrt` are required by the desktop WebView2 host: wrl.h and +# EventToken.h live there, not under um/ or shared/. +prepend("INCLUDE", [MSVC_INC, SDK_INC + r"\ucrt", SDK_INC + r"\um", + SDK_INC + r"\shared", SDK_INC + r"\winrt", SDK_INC + r"\cppwinrt"]) env["VCPKG_ROOT"] = VCPKG_ROOT env["VCPKG_DEFAULT_TRIPLET"] = "x64-windows-static" diff --git a/run_cmake_configure.py b/run_cmake_configure.py index a27c9ea4..93e4a9c3 100644 --- a/run_cmake_configure.py +++ b/run_cmake_configure.py @@ -39,7 +39,10 @@ def prepend(name, dirs): prepend("PATH", [SDK_BIN, MSVC_BIN, cmake, ninja]) prepend("LIB", [MSVC_LIB, SDK_LIB + r"\ucrt\x64", SDK_LIB + r"\um\x64"]) -prepend("INCLUDE", [MSVC_INC, SDK_INC + r"\ucrt", SDK_INC + r"\um", SDK_INC + r"\shared"]) +# `winrt` and `cppwinrt` are required by the desktop WebView2 host: wrl.h and +# EventToken.h live there, not under um/ or shared/. +prepend("INCLUDE", [MSVC_INC, SDK_INC + r"\ucrt", SDK_INC + r"\um", + SDK_INC + r"\shared", SDK_INC + r"\winrt", SDK_INC + r"\cppwinrt"]) # vcpkg config for manifest-mode find_package env["VCPKG_ROOT"] = VCPKG_ROOT diff --git a/scripts/dev_desktop.py b/scripts/dev_desktop.py index 9eb29fcc..24181e93 100644 --- a/scripts/dev_desktop.py +++ b/scripts/dev_desktop.py @@ -230,12 +230,34 @@ def pick_desktop_build(builds: list[Path], preferred: str | None = None) -> Path # ─── 启动 Desktop ─────────────────────────────────────────────────────────── -def launch_desktop(desktop_path: Path, dev_web_dir: Path) -> None: - """启动 desktop app,并设置 ACECODE_DEV_WEB_DIR 环境变量。""" - env = os.environ.copy() - env["ACECODE_DEV_WEB_DIR"] = str(dev_web_dir.resolve()) - +def desktop_instance_identity(project_root: Path) -> str: + """Stable `-` identity, shared with the Web runtime naming.""" + from dev_environment import current_commit, sanitize_identity + + commit = current_commit(project_root) + return sanitize_identity(project_root.name, commit or project_root.name) + + +def desktop_environment(project_root: Path, dev_web_dir: Path) -> tuple[dict, str]: + """Development-only process overrides, never persisted to user config.""" + environment = os.environ.copy() + environment["ACECODE_DEV_WEB_DIR"] = str(dev_web_dir.resolve()) + instance_id = desktop_instance_identity(project_root) + environment["ACECODE_DESKTOP_INSTANCE_ID"] = instance_id + environment["ACECODE_DESKTOP_ALLOW_MULTIPLE_INSTANCES"] = "1" + return environment, instance_id + + +def launch_desktop(desktop_path: Path, dev_web_dir: Path, instance_id: str | None = None, + project_root: Path | None = None, environment: dict | None = None) -> None: + """启动 desktop app,并注入开发期进程级覆盖。""" + if environment is None: + environment, derived = desktop_environment(project_root or find_project_root(), dev_web_dir) + instance_id = instance_id or derived info(f"ACECODE_DEV_WEB_DIR = {dev_web_dir.resolve()}") + info(f"ACECODE_DESKTOP_INSTANCE_ID = {instance_id}") + info("进程级覆盖:本实例允许多开(不读写全局配置)") + env = environment if sys.platform == "darwin": if desktop_path.suffix == ".app": @@ -366,7 +388,9 @@ def main() -> None: ok(f"Desktop 构建: {display_path(desktop_path, project_root)}") # 5. 启动 desktop - launch_desktop(desktop_path, dev_web_dir) + environment, instance_id = desktop_environment(project_root, dev_web_dir) + ok(f"Desktop 实例身份: {instance_id}") + launch_desktop(desktop_path, dev_web_dir, instance_id=instance_id, environment=environment) ok("Desktop 已启动") # 6. 打印使用提示 diff --git a/scripts/dev_environment.py b/scripts/dev_environment.py index 33563041..6beb6b1a 100644 --- a/scripts/dev_environment.py +++ b/scripts/dev_environment.py @@ -17,14 +17,23 @@ import time import urllib.error import urllib.request +import zlib from dataclasses import dataclass from pathlib import Path from typing import Iterable from dev_build_artifacts import find_named_artifacts -TARGETS = ("web", "desktop", "tui") +LAUNCH_TARGETS = ("web", "desktop", "tui") +PRUNE_TARGET = "prune" +TARGETS = (*LAUNCH_TARGETS, PRUNE_TARGET) CACHE_MARKER = ".acecode-sccache.json" +WORKER_LOG_NAME = "daemon-worker.log" +WORKER_LOG_MAX_BYTES = 1024 * 1024 +PORT_RANGE_START = 28080 +PORT_RANGE_COUNT = 201 +PORT_RANGE_END = PORT_RANGE_START + PORT_RANGE_COUNT - 1 +DAEMON_STARTUP_LOG_NAME = "daemon-startup.log" @dataclass(frozen=True) @@ -466,10 +475,26 @@ def refresh_web_assets(root: Path, force: bool = False) -> bool: return True +def sanitize_identity(name: str, identity: str) -> str: + """Build the shared filesystem-safe `-` runtime identity.""" + return re.sub(r"[^A-Za-z0-9_.-]", "-", f"{name}-{identity[:12]}") + + +def safe_identity(root: Path) -> str: + return sanitize_identity(root.name, current_commit(root) or root.name) + + +def runtime_root(root: Path) -> Path: + return root / ".acecode" / "dev-run" + + def worktree_runtime_dir(root: Path) -> Path: - identity = current_commit(root) or root.name - safe = re.sub(r"[^A-Za-z0-9_.-]", "-", f"{root.name}-{identity[:12]}") - return root / ".acecode" / "dev-run" / safe + return runtime_root(root) / safe_identity(root) + + +def worktree_prefix(root: Path) -> str: + """Prefix every runtime directory owned by this worktree shares.""" + return re.sub(r"[^A-Za-z0-9_.-]", "-", root.name) + "-" def selected_web_runtime_dir(root: Path, extra: list[str]) -> Path: @@ -481,6 +506,97 @@ def selected_web_runtime_dir(root: Path, extra: list[str]) -> Path: return args.run_dir.resolve() if args.run_dir.is_absolute() else (root / args.run_dir).resolve() +def selected_web_port(extra: list[str]) -> int | None: + """Return the daemon port explicitly requested on the command line, if any.""" + parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False) + parser.add_argument("--port", type=int) + args, _ = parser.parse_known_args(extra) + return args.port + + +def derived_port(identity: str) -> int: + """Derive a stable port from the runtime identity. + + Uses zlib.crc32 because the built-in hash() is salted per process and would + hand out a different port on every run. + """ + digest = zlib.crc32(identity.encode("utf-8")) + return PORT_RANGE_START + digest % PORT_RANGE_COUNT + + +def port_is_available(port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + try: + probe.bind(("127.0.0.1", port)) + return True + except OSError: + return False + + +def select_runtime_port(run_dir: Path, extra: list[str]) -> int | None: + """Pick the daemon port: explicit takes precedence, otherwise derive and probe. + + The derivation key is the runtime directory name, so the port changes only + when the runtime identity does - the run-dir and its port move together. + """ + explicit = selected_web_port(extra) + if explicit is not None: + if port_is_available(explicit): + return explicit + print(f"[ERROR] Requested Web daemon port is already bound: {explicit}", file=sys.stderr) + print("[INFO] Stop the process using it or start without --port.", file=sys.stderr) + return None + start = derived_port(run_dir.name) + for offset in range(PORT_RANGE_COUNT): + candidate = PORT_RANGE_START + (start - PORT_RANGE_START + offset) % PORT_RANGE_COUNT + if port_is_available(candidate): + return candidate + print(f"[ERROR] No free Web daemon port in {PORT_RANGE_START}-{PORT_RANGE_END}.", file=sys.stderr) + return None + + +def pid_is_alive(pid: int) -> bool: + """Report whether a recorded pid still maps to a live process. + + Never signals the process: POSIX uses signal 0 as a probe and Windows opens + it with query-only access. + """ + if pid <= 0: + return False + if os.name != "nt": + try: + os.kill(pid, 0) + return True + except OSError: + return False + return windows_pid_is_alive(pid) + + +def windows_pid_is_alive(pid: int) -> bool: + import ctypes + + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + STILL_ACTIVE = 259 + kernel32 = ctypes.windll.kernel32 + handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + return False + try: + code = ctypes.c_ulong() + if kernel32.GetExitCodeProcess(handle, ctypes.byref(code)): + return code.value == STILL_ACTIVE + return False + finally: + kernel32.CloseHandle(handle) + + +def runtime_pid(run_dir: Path) -> int | None: + try: + return int((run_dir / "daemon.pid").read_text(encoding="utf-8").strip()) + except (OSError, ValueError): + return None + + def web_launcher_options(extra: list[str]) -> tuple[bool, bool, list[str]]: """Return embedded mode, explicit native-build approval, and remaining args.""" embedded = False @@ -503,12 +619,13 @@ def vite_options(extra: list[str]) -> list[str]: for argument in extra: if skip_next: skip_next = False - elif argument == "--run-dir": + continue + if argument.startswith("--run-dir=") or argument.startswith("--port="): + continue + if argument in ("--run-dir", "--port"): skip_next = True - elif argument.startswith("--run-dir="): continue - else: - result.append(argument) + result.append(argument) return result @@ -519,7 +636,7 @@ def web_runtime_is_available(root: Path, candidate: BuildCandidate | None, extra # A new commit changes the default run-dir name but an older worker # can still hold this build's executable open. Inspect only this # worktree's own launcher directories, without stopping any process. - prefix = re.sub(r"[^A-Za-z0-9_.-]", "-", root.name + "-") + prefix = worktree_prefix(root) for previous in sorted(run_dir.parent.iterdir()): if previous.name.startswith(prefix) and (previous / "daemon.pid").exists(): run_dir = previous @@ -603,10 +720,59 @@ def daemon_port(runtime_dir: Path) -> int | None: return None -def daemon_is_healthy(root: Path, candidate: BuildCandidate, run_dir: Path, timeout_seconds: float = 0) -> bool: +def daemon_worker_log_path(runtime_dir: Path) -> Path: + return runtime_dir / WORKER_LOG_NAME + + +def worker_failure_detail(runtime_dir: Path, reason: str) -> str: + return (f"{reason}; see {daemon_worker_log_path(runtime_dir)} and " + f"{runtime_dir / DAEMON_STARTUP_LOG_NAME}") + + +def open_worker_log(runtime_dir: Path, command: list[str]): + """Open the append-mode worker log, writing a header for this spawn.""" + log_path = daemon_worker_log_path(runtime_dir) + runtime_dir.mkdir(parents=True, exist_ok=True) + previous = log_path.with_name(f"{WORKER_LOG_NAME}.1") + try: + if log_path.is_file() and log_path.stat().st_size > WORKER_LOG_MAX_BYTES: + previous.unlink(missing_ok=True) + log_path.replace(previous) + except OSError: + pass # Rotation is best-effort; never block startup on it. + handle = log_path.open("ab") + stamp = time.strftime("%Y-%m-%d %H:%M:%S") + # The header carries no token; only paths and the requested port. + handle.write(f"\n===== {stamp} spawn: {' '.join(command)} =====\n".encode("utf-8", "replace")) + handle.flush() + return handle + + +def spawn_daemon_worker(root: Path, runtime_dir: Path, command: list[str]): + """Start the daemon worker as the only place this script launches it.""" + log = open_worker_log(runtime_dir, command) + options = {"cwd": root, "stdout": log, "stderr": subprocess.STDOUT} + if os.name == "nt": + options["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + try: + worker = subprocess.Popen(command, **options) + except OSError as error: + log.close() + print(f"[ERROR] Could not start Web daemon: {error}", file=sys.stderr) + return None + log.close() + return worker + + +def daemon_is_healthy(root: Path, candidate: BuildCandidate, run_dir: Path, + timeout_seconds: float = 0, worker=None) -> bool: del root, candidate deadline = time.monotonic() + timeout_seconds while True: + # A worker that already exited will never answer /api/health; failing + # here keeps a crash visible in seconds instead of after the timeout. + if worker is not None and worker.poll() is not None: + return False try: pid = int((run_dir / "daemon.pid").read_text(encoding="utf-8").strip()) port = daemon_port(run_dir) @@ -631,31 +797,57 @@ def daemon_is_healthy(root: Path, candidate: BuildCandidate, run_dir: Path, time time.sleep(0.1) -def reserve_loopback_port() -> int: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: - listener.bind(("127.0.0.1", 0)) - return listener.getsockname()[1] - - def start_quick_web_daemon(root: Path, candidate: BuildCandidate, run_dir: Path, port: int) -> int | None: """Start the worker directly; the detached daemon wrapper rejects token.tmp runtimes.""" command = [ str(candidate.executable), "daemon", "--foreground", f"--cwd={root.as_posix()}", f"--run-dir={run_dir.as_posix()}", f"--port={port}", ] - options = {"cwd": root, "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL} - if os.name == "nt": - options["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP - try: - subprocess.Popen(command, **options) - except OSError as error: - print(f"[ERROR] Could not start Web daemon: {error}", file=sys.stderr) + worker = spawn_daemon_worker(root, run_dir, command) + if worker is None: return None - if not daemon_is_healthy(root, candidate, run_dir, timeout_seconds=15): + if not daemon_is_healthy(root, candidate, run_dir, timeout_seconds=15, worker=worker): + exit_code = worker.poll() + reason = f"worker exited with code {exit_code}" if exit_code is not None else "health check timed out" + print(f"[ERROR] {worker_failure_detail(run_dir, reason)}", file=sys.stderr) return None return daemon_port(run_dir) +def stop_stale_runtime(root: Path, candidate: BuildCandidate | None, run_dir: Path) -> bool: + """Clear runtime files for a pid already confirmed dead. Never signals it.""" + if candidate is None: + print("[ERROR] No verified executable is available to clean that runtime; check it manually.", file=sys.stderr) + return False + command = [str(candidate.executable), "daemon", "stop", f"--run-dir={run_dir.as_posix()}"] + try: + result = subprocess.run(command, cwd=root, text=True, capture_output=True, timeout=15, check=False) + except (OSError, subprocess.TimeoutExpired) as error: + print(f"[ERROR] Could not clean the stale runtime: {error}", file=sys.stderr) + return False + if result.returncode != 0: + detail = result.stdout.strip() or result.stderr.strip() or f"exit code {result.returncode}" + print(f"[ERROR] Stale runtime cleanup failed: {detail}", file=sys.stderr) + return False + return True + + +def reconcile_stale_runtime(root: Path, candidate: BuildCandidate | None, run_dir: Path) -> bool: + """Self-heal a rejected runtime only when its recorded pid is provably dead.""" + if not (run_dir / "daemon.pid").exists(): + return True # Nothing ever claimed this directory; start fresh. + pid = runtime_pid(run_dir) + if pid is None: + print(f"[ERROR] Web daemon runtime has an unreadable pid file; inspect it before retrying: {run_dir}", file=sys.stderr) + return False + if pid_is_alive(pid): + print(f"[ERROR] Existing Web daemon runtime is unhealthy; stop it manually before retrying: {run_dir}", file=sys.stderr) + print(f"[INFO] Its recorded pid {pid} is still alive, so nothing was stopped or removed.", file=sys.stderr) + return False + print(f"[INFO] Recovering stale runtime whose pid {pid} is dead: {run_dir}") + return stop_stale_runtime(root, candidate, run_dir) + + def daemon_token(runtime_dir: Path) -> str | None: for name in ("token", "token.tmp"): try: @@ -692,30 +884,109 @@ def launch_vite(root: Path, daemon_port_number: int, runtime_dir: Path, extra: l return subprocess.run([pnpm, "dev", *vite_options(extra)], cwd=web_dir, env=environment, check=False).returncode +def prune_candidates(root: Path) -> list[Path]: + """Runtime directories worth inspecting: the worktree root plus the legacy tree.""" + candidates: list[Path] = [] + base = runtime_root(root) + if base.is_dir(): + candidates.extend(sorted(path for path in base.iterdir())) + legacy = root / ".acecode-dev-run" + if legacy.is_dir(): + candidates.append(legacy) + return candidates + + +def classify_runtime_directory(directory: Path) -> tuple[bool, str]: + """Return whether the directory can be deleted and the reason why (not).""" + if not directory.is_dir(): + return False, "not a directory" + pid = runtime_pid(directory) + if pid is None: + return False, "no readable daemon.pid" + if pid_is_alive(pid): + return False, f"pid {pid} is alive" + return True, str(pid) + + +def prune_runtime_directories(directories: Iterable[Path]) -> tuple[list[tuple[Path, str]], list[tuple[Path, str]]]: + """Delete only directories whose recorded pid is confirmed dead.""" + pruned: list[tuple[Path, str]] = [] + skipped: list[tuple[Path, str]] = [] + for directory in directories: + deletable, detail = classify_runtime_directory(directory) + if not deletable: + skipped.append((directory, detail)) + continue + try: + shutil.rmtree(directory) + except OSError as error: + skipped.append((directory, f"remove failed: {error}")) + continue + pruned.append((directory, detail)) + return pruned, skipped + + +def prune_runtime_tree(root: Path, dry_run: bool = False) -> int: + candidates = prune_candidates(root) + if dry_run: + verdicts = [classify_runtime_directory(path) for path in candidates] + count = sum(1 for deletable, _ in verdicts if deletable) + print(f"[INFO] Dry run: pruning would remove {count} of {len(verdicts)} inspected path(s).") + return 0 + pruned, skipped = prune_runtime_directories(candidates) + print("[INFO] Pruned:") + for directory, pid in pruned or []: + print(f" {directory} (dead pid={pid})") + if not pruned: + print(" (none)") + print("[INFO] Skipped:") + for directory, reason in skipped or []: + print(f" {directory} ({reason})") + if not skipped: + print(" (none)") + print(f"[INFO] Total: pruned {len(pruned)}, skipped {len(skipped)}") + return 0 + + +def prune_worktree_runtime_dirs(root: Path, current: Path) -> int: + """Drop this worktree's own runtime directories left behind by dead pids.""" + base = runtime_root(root) + if not base.is_dir(): + return 0 + prefix = worktree_prefix(root) + stale = [ + previous for previous in sorted(base.iterdir()) + if previous.is_dir() and previous.name != current.name and previous.name.startswith(prefix) + ] + pruned, _ = prune_runtime_directories(stale) + for directory, pid in pruned: + print(f"[INFO] Removed stale runtime for dead pid {pid}: {directory}") + return len(pruned) + + def launch_quick_web(root: Path, candidate: BuildCandidate, extra: list[str]) -> int: run_dir = selected_web_runtime_dir(root, extra) if daemon_is_healthy(root, candidate, run_dir): port = daemon_port(run_dir) print(f"[INFO] Reusing Web daemon: http://127.0.0.1:{port}") return launch_vite(root, port, run_dir, extra) - if (run_dir / "daemon.pid").exists(): - print(f"[ERROR] Existing Web daemon runtime is unhealthy; inspect it before retrying: {run_dir}", file=sys.stderr) + if not reconcile_stale_runtime(root, candidate, run_dir): return 1 - port = start_quick_web_daemon(root, candidate, run_dir, 28080) - if port is None: - fallback_port = reserve_loopback_port() - print(f"[INFO] Standard development port unavailable; retrying on {fallback_port}.") - port = start_quick_web_daemon(root, candidate, run_dir, fallback_port) + prune_worktree_runtime_dirs(root, run_dir) + port = select_runtime_port(run_dir, extra) if port is None: + return 1 + started = start_quick_web_daemon(root, candidate, run_dir, port) + if started is None: print("[ERROR] Web daemon could not be started.", file=sys.stderr) return 1 - print(f"[INFO] Started Web daemon: http://127.0.0.1:{port}") - return launch_vite(root, port, run_dir, extra) + print(f"[INFO] Started Web daemon: http://127.0.0.1:{started}") + return launch_vite(root, started, run_dir, extra) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Start an ACECode development environment", allow_abbrev=False) - parser.add_argument("target", nargs="?", choices=TARGETS, help="development surface to start") + parser.add_argument("target", nargs="?", choices=TARGETS, help="development surface to start, or prune to reclaim stale runtimes") parser.add_argument("--build-dir", type=Path, help="build directory to validate and use") parser.add_argument("--yes", action="store_true", help="confirm a required CMake build") parser.add_argument("--dry-run", action="store_true", help="print the selected command without starting it") @@ -743,6 +1014,8 @@ def main() -> int: if not target: return 2 root = project_root() + if target == PRUNE_TARGET: + return prune_runtime_tree(root, dry_run=args.dry_run) embedded, build_daemon, extra = web_launcher_options(args.extra) if target == "web" else (False, False, args.extra) if target == "web" and not embedded: if "--yes" in extra: diff --git a/src/desktop/instance_startup.hpp b/src/desktop/instance_startup.hpp index f7a7ee95..a2f8ff59 100644 --- a/src/desktop/instance_startup.hpp +++ b/src/desktop/instance_startup.hpp @@ -4,12 +4,53 @@ namespace acecode::desktop { +// Maximum length accepted from the instance-identity override. Longer values +// are rejected outright rather than truncated, so the accepted value is always +// exactly what the caller supplied. +constexpr std::size_t kMaxInstanceIdLength = 64; + struct InstanceStartupPlan { bool start = false; bool primary = false; std::string run_subdirectory; }; +// Accept only characters that are safe inside every supported filesystem path, +// and reject the all-dots spellings that would resolve to a parent directory. +// The caller supplies this value from outside the process; anything else falls +// back to a generated identifier instead of being rewritten. +inline bool is_valid_instance_id(const std::string& value) { + if (value.empty() || value.size() > kMaxInstanceIdLength) return false; + bool all_dots = true; + for (char character : value) { + const bool lower = character >= 'a' && character <= 'z'; + const bool upper = character >= 'A' && character <= 'Z'; + const bool digit = character >= '0' && character <= '9'; + const bool symbol = character == '_' || character == '.' || character == '-'; + if (!(lower || upper || digit || symbol)) return false; + if (character != '.') all_dots = false; + } + // "." and ".." are valid path syntax but not valid identities: they name the + // current or parent directory, so the instance run directory would escape + // its own container instead of staying inside it. + return !all_dots; +} + +inline char ascii_lower(char character) { + return (character >= 'A' && character <= 'Z') ? static_cast(character - 'A' + 'a') : character; +} + +// Whitelist parsing: only unambiguous affirmative spellings enable the +// override. Anything else - including "0" and empty - keeps it disabled, so a +// stray value can never silently turn the behavior on. +inline bool parse_allow_multiple_instances(const std::string& value) { + if (value.empty()) return false; + std::string normalized; + normalized.reserve(value.size()); + for (char character : value) normalized.push_back(ascii_lower(character)); + return normalized == "1" || normalized == "true" || normalized == "yes" || normalized == "on"; +} + // The primary retains the stable singleton and daemon directory. Additional // developer instances must never attach to, replace or stop its daemon. inline InstanceStartupPlan plan_instance_startup( diff --git a/src/desktop/main.cpp b/src/desktop/main.cpp index 3d7d3caa..cd55c673 100644 --- a/src/desktop/main.cpp +++ b/src/desktop/main.cpp @@ -804,10 +804,30 @@ int main(int argc, char** argv) { // Read the global preference before enforcing the singleton. Still acquire // it when possible so the primary keeps normal focus/handoff behavior. SingleInstance singleton; - const std::string desktop_owner_instance = acecode::generate_uuid(); + // Development-only overrides, read from the process environment so they can + // never persist into or depend on the user's global configuration. Without + // them this behaves exactly as before. + std::string desktop_owner_instance = acecode::generate_uuid(); + std::string injected_instance; + if (acecode::getenv_utf8("ACECODE_DESKTOP_INSTANCE_ID", injected_instance) + && !injected_instance.empty()) { + if (acecode::desktop::is_valid_instance_id(injected_instance)) { + desktop_owner_instance = injected_instance; + LOG_INFO("[desktop] using injected desktop instance id: " + desktop_owner_instance); + } else { + LOG_WARN("[desktop] ignoring invalid ACECODE_DESKTOP_INSTANCE_ID override; " + "falling back to a random instance id"); + } + } + bool allow_multiple_instances = desktop_cfg.desktop.allow_multiple_instances; + std::string allow_override; + if (acecode::getenv_utf8("ACECODE_DESKTOP_ALLOW_MULTIPLE_INSTANCES", allow_override)) { + allow_multiple_instances = acecode::desktop::parse_allow_multiple_instances(allow_override); + LOG_INFO("[desktop] process-level allow_multiple_instances override: " + + std::string(allow_multiple_instances ? "enabled" : "disabled")); + } const auto instance_plan = plan_instance_startup( - desktop_cfg.desktop.allow_multiple_instances, - singleton.try_acquire(), desktop_owner_instance); + allow_multiple_instances, singleton.try_acquire(), desktop_owner_instance); if (!instance_plan.start) { if (startup_open_request.has_value()) { std::string handoff_error; diff --git a/tests/desktop/instance_startup_test.cpp b/tests/desktop/instance_startup_test.cpp index 5ed4d24a..b7717c0d 100644 --- a/tests/desktop/instance_startup_test.cpp +++ b/tests/desktop/instance_startup_test.cpp @@ -2,6 +2,8 @@ #include "desktop/instance_startup.hpp" +using acecode::desktop::is_valid_instance_id; +using acecode::desktop::parse_allow_multiple_instances; using acecode::desktop::plan_instance_startup; TEST(DesktopInstanceStartup, PrimaryRetainsStableRuntimeWithEitherPreference) { @@ -31,3 +33,49 @@ TEST(DesktopInstanceStartup, EnabledPreferenceSeparatesAllAdditionalRuntimes) { EXPECT_NE(first.run_subdirectory, second.run_subdirectory); EXPECT_NE(second.run_subdirectory, "desktop-shared"); } + +TEST(DesktopInstanceStartup, AcceptsIdentitiesSafeForEveryFilesystem) { + EXPECT_TRUE(is_valid_instance_id("acecode-f33bbdb9cef6")); + EXPECT_TRUE(is_valid_instance_id("a")); + EXPECT_TRUE(is_valid_instance_id("My.Project_2")); + EXPECT_TRUE(is_valid_instance_id(std::string(64, 'a'))); +} + +TEST(DesktopInstanceStartup, RejectsAnythingThatCouldEscapeItsDirectory) { + EXPECT_FALSE(is_valid_instance_id("")); + EXPECT_FALSE(is_valid_instance_id("with space")); + EXPECT_FALSE(is_valid_instance_id("path/traversal")); + EXPECT_FALSE(is_valid_instance_id("..")); + EXPECT_FALSE(is_valid_instance_id("../escape")); + EXPECT_FALSE(is_valid_instance_id("back\\slash")); + EXPECT_FALSE(is_valid_instance_id(std::string("null\0inside", 11))); + EXPECT_FALSE(is_valid_instance_id(std::string(65, 'a'))); +} + +TEST(DesktopInstanceStartup, RejectsAllDotIdentitiesThatResolveToADirectory) { + // 字符白名单会放过点号,必须单独挡掉"."与".."这类目录自指 + EXPECT_FALSE(is_valid_instance_id(".")); + EXPECT_FALSE(is_valid_instance_id("..")); + EXPECT_FALSE(is_valid_instance_id("...")); + // 含点号但不是一个纯点号序列的标识仍然有效 + EXPECT_TRUE(is_valid_instance_id("a.b")); + EXPECT_TRUE(is_valid_instance_id("a..b")); + EXPECT_TRUE(is_valid_instance_id("..a")); + EXPECT_TRUE(is_valid_instance_id("-")); +} + +TEST(DesktopInstanceStartup, MultipleInstanceOverrideUsesAnAffirmativeWhitelist) { + for (const std::string value : {"1", "true", "TRUE", "True", "yes", "YES", "on", "ON"}) { + EXPECT_TRUE(parse_allow_multiple_instances(value)) << value; + } + for (const std::string value : {"", "0", "false", "no", "off", "maybe", "2", "-1"}) { + EXPECT_FALSE(parse_allow_multiple_instances(value)) << value; + } +} + +TEST(DesktopInstanceStartup, InjectedIdentityOnlyAppliesToAdditionalInstances) { + const auto primary = plan_instance_startup(false, true, "acecode-f33bbdb9cef6"); + EXPECT_EQ(primary.run_subdirectory, "desktop-shared"); + const auto extra = plan_instance_startup(true, false, "acecode-f33bbdb9cef6"); + EXPECT_EQ(extra.run_subdirectory, "desktop-instances/acecode-f33bbdb9cef6"); +} diff --git a/tests/scripts/dev_desktop_test.py b/tests/scripts/dev_desktop_test.py index ff7663d6..4127698d 100644 --- a/tests/scripts/dev_desktop_test.py +++ b/tests/scripts/dev_desktop_test.py @@ -6,11 +6,13 @@ import ast import importlib.util import os +import re import subprocess import sys import tempfile import unittest from pathlib import Path +from unittest import mock sys.dont_write_bytecode = True @@ -120,6 +122,114 @@ def test_ansi_color_code_has_single_terminator(self) -> None: finally: dev_desktop._COLOR = original + def test_desktop_instance_identity_matches_web_runtime_naming(self) -> None: + with tempfile.TemporaryDirectory() as root_text: + repo = Path(root_text) / "my.worktree" + # 目录名需要真实存在,否则 git 调用无从谈起 + repo.mkdir() + identity = "0123456789abcdef0123456789abcdef01234567" + with mock.patch.object(dev_desktop, "desktop_instance_identity", + wraps=dev_desktop.desktop_instance_identity): + actual = self._identity_with_commit(repo, identity) + self.assertEqual(actual, "my.worktree-0123456789ab") + self.assertEqual(re.sub(r"[^A-Za-z0-9_.-]", "-", actual), actual) + + def test_desktop_instance_identity_sanitizes_unsafe_worktree_names(self) -> None: + with tempfile.TemporaryDirectory() as root_text: + repo = Path(root_text) / "fix dev launcher+identity" + repo.mkdir() + actual = self._identity_with_commit(repo, "a" * 40) + self.assertEqual(actual, "fix-dev-launcher-identity-aaaaaaaaaaaa") + + def _identity_with_commit(self, repo: Path, commit: str) -> str: + """`desktop_instance_identity` 依赖 git,测试里替换掉 commit 查询。""" + import dev_environment + + with mock.patch.object(dev_environment, "current_commit", return_value=commit): + return dev_desktop.desktop_instance_identity(repo) + + def test_falls_back_to_worktree_name_when_commit_is_unknown(self) -> None: + with tempfile.TemporaryDirectory() as root_text: + repo = Path(root_text) / "detached" + repo.mkdir() + self.assertEqual( + self._identity_with_commit(repo, None), + "detached-detached", + ) + + def test_desktop_environment_injects_process_level_overrides(self) -> None: + with tempfile.TemporaryDirectory() as root_text: + repo = Path(root_text) / "acecode" + repo.mkdir() + web = repo / "web" / "dist" + web.mkdir(parents=True) + + with mock.patch.dict(os.environ, {"ACECODE_TEST_SENTINEL": "kept"}, clear=False): + environment, identity = dev_desktop.desktop_environment(repo, web) + + self.assertEqual(identity, "acecode-acecode") + self.assertEqual(environment["ACECODE_DESKTOP_INSTANCE_ID"], identity) + self.assertEqual(environment["ACECODE_DESKTOP_ALLOW_MULTIPLE_INSTANCES"], "1") + self.assertEqual(environment["ACECODE_DEV_WEB_DIR"], str(web.resolve())) + # 继承父进程环境,而不是另起一份干净环境 + self.assertEqual(environment["ACECODE_TEST_SENTINEL"], "kept") + + def test_desktop_environment_is_identical_across_repeated_calls(self) -> None: + with tempfile.TemporaryDirectory() as root_text: + repo = Path(root_text) / "acecode" + repo.mkdir() + web = repo / "web" / "dist" + web.mkdir(parents=True) + + first, first_identity = dev_desktop.desktop_environment(repo, web) + second, second_identity = dev_desktop.desktop_environment(repo, web) + self.assertEqual(first_identity, second_identity) + self.assertEqual(first["ACECODE_DESKTOP_INSTANCE_ID"], + second["ACECODE_DESKTOP_INSTANCE_ID"]) + + def test_launch_desktop_passes_overrides_to_child_process(self) -> None: + with tempfile.TemporaryDirectory() as root_text: + repo = Path(root_text) / "acecode" + repo.mkdir() + web = repo / "web" / "dist" + web.mkdir(parents=True) + binary = repo / "build" / "acecode-desktop.exe" + binary.parent.mkdir() + binary.write_bytes(b"") + + environment, identity = dev_desktop.desktop_environment(repo, web) + with mock.patch.object(dev_desktop.subprocess, "Popen") as popen: + dev_desktop.launch_desktop(binary, web, instance_id=identity, + environment=environment) + + self.assertEqual(popen.call_count, 1) + _, kwargs = popen.call_args + passed = kwargs["env"] + self.assertEqual(passed["ACECODE_DESKTOP_INSTANCE_ID"], identity) + self.assertEqual(passed["ACECODE_DESKTOP_ALLOW_MULTIPLE_INSTANCES"], "1") + self.assertEqual(passed["ACECODE_DEV_WEB_DIR"], str(web.resolve())) + self.assertEqual(popen.call_args.args[0], [str(binary)]) + + def test_launch_desktop_never_writes_user_configuration(self) -> None: + with tempfile.TemporaryDirectory() as root_text: + repo = Path(root_text) / "acecode" + repo.mkdir() + web = repo / "web" / "dist" + web.mkdir(parents=True) + binary = repo / "build" / "acecode-desktop.exe" + binary.parent.mkdir() + binary.write_bytes(b"") + + with mock.patch.object(dev_desktop.subprocess, "Popen"), \ + mock.patch.object(dev_desktop, "desktop_instance_identity", + return_value="acecode-deadbeef1234"): + dev_desktop.launch_desktop(binary, web, project_root=repo) + + # 开发期覆盖只走进程环境,任何配置文件都不应被创建或改写 + leftovers = [path for path in repo.rglob("*") if path.is_file() + and path != binary] + self.assertEqual(leftovers, []) + if __name__ == "__main__": unittest.main() diff --git a/tests/scripts/dev_environment_review_test.py b/tests/scripts/dev_environment_review_test.py index ff751df2..84895b38 100644 --- a/tests/scripts/dev_environment_review_test.py +++ b/tests/scripts/dev_environment_review_test.py @@ -133,7 +133,7 @@ def test_every_default_preset_exists(self): presets = {item["name"] for item in json.loads((ROOT / "CMakePresets.json").read_text(encoding="utf-8"))["configurePresets"]} for system in ("Windows", "Darwin", "Linux"): for machine in ("amd64", "arm64"): - for target in launcher.TARGETS: + for target in launcher.LAUNCH_TARGETS: with self.subTest(system=system, machine=machine, target=target), \ patch.object(launcher.platform, "system", return_value=system), \ patch.object(launcher, "native_machine", return_value=machine): diff --git a/tests/scripts/dev_environment_test.py b/tests/scripts/dev_environment_test.py index 789c220e..f0a42e94 100644 --- a/tests/scripts/dev_environment_test.py +++ b/tests/scripts/dev_environment_test.py @@ -1,11 +1,15 @@ +import contextlib import importlib.util +import io import json import os from pathlib import Path import sys import tempfile +import time import subprocess import unittest +import zlib from unittest.mock import patch @@ -258,27 +262,291 @@ def test_quick_web_reuses_healthy_daemon_and_passes_its_port_to_vite(self): vite.assert_called_once_with(root, 38123, run_dir, []) start.assert_not_called() - def test_quick_web_falls_back_to_system_selected_port(self): + def test_quick_web_starts_on_the_identity_derived_port(self): root = Path("C:/work") candidate = dev_environment.BuildCandidate(root / "build", root, root / "build/acecode.exe") - run_dir = root / ".acecode/dev-run/test" + run_dir = root / ".acecode/dev-run/work-abcdef123456" + expected = dev_environment.derived_port("work-abcdef123456") with patch.object(dev_environment, "selected_web_runtime_dir", return_value=run_dir), \ patch.object(dev_environment, "daemon_is_healthy", return_value=False), \ patch.object(Path, "exists", return_value=False), \ - patch.object(dev_environment, "reserve_loopback_port", return_value=38123), \ - patch.object(dev_environment, "start_quick_web_daemon", side_effect=[None, 38123]) as start, \ + patch.object(dev_environment, "port_is_available", return_value=True), \ + patch.object(dev_environment, "start_quick_web_daemon", return_value=expected) as start, \ patch.object(dev_environment, "launch_vite", return_value=0) as vite: self.assertEqual(dev_environment.launch_quick_web(root, candidate, []), 0) - self.assertEqual([call.args[3] for call in start.call_args_list], [28080, 38123]) - vite.assert_called_once_with(root, 38123, run_dir, []) + self.assertEqual([call.args[3] for call in start.call_args_list], [expected]) + vite.assert_called_once_with(root, expected, run_dir, []) + + def test_derived_port_is_stable_across_processes(self): + """crc32 keeps the port stable; the built-in hash() would not.""" + import zlib + for identity in ("acecode-f33bbdb9cef6", "my-project-abcdef123456", "dev-0123456789ab"): + expected = 28080 + zlib.crc32(identity.encode("utf-8")) % 201 + self.assertEqual(dev_environment.derived_port(identity), expected) + self.assertTrue(28080 <= expected <= 28280) + self.assertNotEqual( + dev_environment.derived_port("acecode-aaaaaaaaaaaa"), + dev_environment.derived_port("acecode-bbbbbbbbbbbb"), + ) + + def _rotation_order(self, identity: str) -> list[int]: + start = dev_environment.derived_port(identity) + return [(start - dev_environment.PORT_RANGE_START + offset) % dev_environment.PORT_RANGE_COUNT + + dev_environment.PORT_RANGE_START + for offset in range(dev_environment.PORT_RANGE_COUNT)] + + def test_derived_port_succeeds_linearly_and_wraps_in_range(self): + run_dir = Path("C:/work/.acecode/dev-run/work-abcdef123456") + order = self._rotation_order(run_dir.name) + busy = set(order) + free_port = order[-1] + busy.discard(free_port) + probed: list[int] = [] + + def available(port): + probed.append(port) + return port not in busy + + with patch.object(dev_environment, "port_is_available", side_effect=available): + self.assertEqual(dev_environment.select_runtime_port(run_dir, []), free_port) + self.assertEqual(probed, order) + + def test_derived_port_probes_in_order_without_waiting(self): + run_dir = Path("C:/work/.acecode/dev-run/work-000000000000") + order = self._rotation_order(run_dir.name) + busy = set(order[:3]) + probed: list[int] = [] + + def available(port): + probed.append(port) + return port not in busy + + with patch.object(dev_environment, "port_is_available", side_effect=available): + self.assertEqual(dev_environment.select_runtime_port(run_dir, []), order[3]) + self.assertEqual(probed, order[:4]) + + def test_exhausted_port_range_reports_failure(self): + root = Path("C:/work") + run_dir = root / ".acecode/dev-run/work-000000000000" + with patch.object(dev_environment, "port_is_available", return_value=False) as probe: + with (contextlib.redirect_stderr(io.StringIO()) as output): + self.assertIsNone(dev_environment.select_runtime_port(run_dir, [])) + self.assertIn("28080-28280", output.getvalue()) + self.assertEqual(probe.call_count, 201) + + def test_explicit_port_is_used_when_free(self): + root = Path("C:/work") + run_dir = root / ".acecode/dev-run/work-000000000000" + with patch.object(dev_environment, "port_is_available", return_value=True) as probe: + self.assertEqual(dev_environment.select_runtime_port(run_dir, ["--port", "39001"]), 39001) + probe.assert_called_once_with(39001) + + def test_explicit_busy_port_fails_without_adjacent_probing(self): + root = Path("C:/work") + run_dir = root / ".acecode/dev-run/work-000000000000" + with patch.object(dev_environment, "port_is_available", return_value=False) as probe: + with contextlib.redirect_stderr(io.StringIO()) as output: + self.assertIsNone(dev_environment.select_runtime_port(run_dir, ["--port", "39001"])) + probe.assert_called_once_with(39001) + self.assertIn("39001", output.getvalue()) + + def test_quick_web_recovers_a_runtime_whose_pid_is_dead(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + candidate = dev_environment.BuildCandidate(root / "build", root, root / "build/acecode.exe") + run_dir = root / ".acecode/dev-run/work-000000000000" + run_dir.mkdir(parents=True) + (run_dir / "daemon.pid").write_text("4242", encoding="utf-8") + with patch.object(dev_environment, "selected_web_runtime_dir", return_value=run_dir), \ + patch.object(dev_environment, "daemon_is_healthy", return_value=False), \ + patch.object(dev_environment, "pid_is_alive", return_value=False) as alive, \ + patch.object(dev_environment.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as stop, \ + patch.object(dev_environment, "port_is_available", return_value=True), \ + patch.object(dev_environment, "start_quick_web_daemon", return_value=28080) as start, \ + patch.object(dev_environment, "launch_vite", return_value=0): + self.assertEqual(dev_environment.launch_quick_web(root, candidate, []), 0) + alive.assert_called_once_with(4242) + self.assertEqual(stop.call_args.args[0][1:4], ["daemon", "stop", f"--run-dir={run_dir.as_posix()}"]) + start.assert_called_once() + + def test_quick_web_never_stops_a_live_pid(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + candidate = dev_environment.BuildCandidate(root / "build", root, root / "build/acecode.exe") + run_dir = root / ".acecode/dev-run/work-000000000000" + run_dir.mkdir(parents=True) + (run_dir / "daemon.pid").write_text("4242", encoding="utf-8") + with patch.object(dev_environment, "selected_web_runtime_dir", return_value=run_dir), \ + patch.object(dev_environment, "daemon_is_healthy", return_value=False), \ + patch.object(dev_environment, "pid_is_alive", return_value=True), \ + patch.object(dev_environment.subprocess, "run") as stop, \ + patch.object(dev_environment, "start_quick_web_daemon") as start: + with contextlib.redirect_stderr(io.StringIO()) as output: + self.assertEqual(dev_environment.launch_quick_web(root, candidate, []), 1) + stop.assert_not_called() + start.assert_not_called() + self.assertIn("stop it manually", output.getvalue()) + self.assertIn("still alive", output.getvalue()) + + def test_unreadable_pid_file_skips_self_healing(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + candidate = dev_environment.BuildCandidate(root / "build", root, root / "build/acecode.exe") + run_dir = root / ".acecode/dev-run/work-000000000000" + run_dir.mkdir(parents=True) + (run_dir / "daemon.pid").write_text("not-a-pid", encoding="utf-8") + with patch.object(dev_environment, "selected_web_runtime_dir", return_value=run_dir), \ + patch.object(dev_environment, "daemon_is_healthy", return_value=False), \ + patch.object(dev_environment, "pid_is_alive") as alive, \ + patch.object(dev_environment.subprocess, "run") as stop, \ + patch.object(dev_environment, "start_quick_web_daemon") as start: + with contextlib.redirect_stderr(io.StringIO()) as output: + self.assertEqual(dev_environment.launch_quick_web(root, candidate, []), 1) + alive.assert_not_called() + stop.assert_not_called() + start.assert_not_called() + self.assertIn("unreadable pid file", output.getvalue()) + + def test_prune_reports_pruned_skipped_and_totals(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + dead = root / ".acecode/dev-run/work-000000000000" + live = root / ".acecode/dev-run/work-111111111111" + unknown = root / ".acecode/dev-run/other-222222222222" + for path, pid in ((dead, "100"), (live, "200"), (unknown, None)): + path.mkdir(parents=True) + if pid: + (path / "daemon.pid").write_text(pid, encoding="utf-8") + legacy = root / ".acecode-dev-run" + legacy.mkdir() + (legacy / "daemon.pid").write_text("300", encoding="utf-8") + + def alive(pid): + return pid == 200 + + with patch.object(dev_environment, "pid_is_alive", side_effect=alive): + with contextlib.redirect_stdout(io.StringIO()) as output: + self.assertEqual(dev_environment.prune_runtime_tree(root), 0) + report = output.getvalue() + self.assertFalse(dead.exists()) + self.assertFalse(legacy.exists()) + self.assertTrue(live.exists()) + self.assertTrue(unknown.exists()) + self.assertIn("dead pid=100", report) + self.assertIn("dead pid=300", report) + self.assertIn("pid 200 is alive", report) + self.assertIn("no readable daemon.pid", report) + self.assertIn("Total: pruned 2, skipped 2", report) + + def test_prune_dry_run_deletes_nothing(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + dead = root / ".acecode/dev-run/work-000000000000" + dead.mkdir(parents=True) + (dead / "daemon.pid").write_text("100", encoding="utf-8") + with patch.object(dev_environment, "pid_is_alive", return_value=False): + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(dev_environment.prune_runtime_tree(root, dry_run=True), 0) + self.assertTrue(dead.exists()) + + def test_startup_prunes_only_dead_sibling_directories(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "work" + current = root / ".acecode/dev-run/work-000000000000" + stale = root / ".acecode/dev-run/work-111111111111" + foreign = root / ".acecode/dev-run/other-222222222222" + for path, pid in ((current, "1"), (stale, "2"), (foreign, "3")): + path.mkdir(parents=True) + (path / "daemon.pid").write_text(pid, encoding="utf-8") + + def alive(pid): + return pid in (1, 3) + + with patch.object(dev_environment, "pid_is_alive", side_effect=alive): + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(dev_environment.prune_worktree_runtime_dirs(root, current), 1) + self.assertTrue(current.exists()) + self.assertFalse(stale.exists()) + self.assertTrue(foreign.exists()) + + def test_worker_log_header_and_rotation(self): + with tempfile.TemporaryDirectory() as directory: + run_dir = Path(directory) + command = ["C:/build/acecode.exe", "daemon", "--foreground"] + dev_environment.open_worker_log(run_dir, command).close() + log = run_dir / "daemon-worker.log" + self.assertIn("spawn: C:/build/acecode.exe daemon --foreground", log.read_text(encoding="utf-8")) + log.write_text("x" * (dev_environment.WORKER_LOG_MAX_BYTES + 1), encoding="utf-8") + dev_environment.open_worker_log(run_dir, command).close() + self.assertLess(log.stat().st_size, dev_environment.WORKER_LOG_MAX_BYTES) + self.assertTrue((run_dir / "daemon-worker.log.1").exists()) + + def test_spawn_redirects_worker_output_into_the_runtime_log(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "work" + run_dir = root / ".acecode/dev-run/test" + command = ["C:/build/acecode.exe", "daemon", "--foreground", "--port=28080"] + worker = type("Worker", (), {"poll": staticmethod(lambda: None)})() + with patch.object(dev_environment.subprocess, "Popen", return_value=worker) as popen: + self.assertIs(dev_environment.spawn_daemon_worker(root, run_dir, command), worker) + options = popen.call_args.kwargs + self.assertEqual(popen.call_args.args[0], command) + self.assertEqual(options["stderr"], subprocess.STDOUT) + self.assertEqual(Path(options["stdout"].name), run_dir / "daemon-worker.log") + self.assertTrue((run_dir / "daemon-worker.log").is_file()) + + def test_worker_exit_fails_before_the_health_timeout(self): + root = Path("C:/work") + run_dir = root / ".acecode/dev-run/test" + candidate = dev_environment.BuildCandidate(root / "build", root, root / "build/acecode.exe") + worker = type("Worker", (), {"poll": staticmethod(lambda: 3)})() + started = time.monotonic() + self.assertFalse(dev_environment.daemon_is_healthy(root, candidate, run_dir, timeout_seconds=30, worker=worker)) + self.assertLess(time.monotonic() - started, 5) + + def test_start_failure_points_at_both_logs(self): + root = Path("C:/work") + run_dir = root / ".acecode/dev-run/test" + candidate = dev_environment.BuildCandidate(root / "build", root, root / "build/acecode.exe") + worker = type("Worker", (), {"poll": staticmethod(lambda: 3)})() + with patch.object(dev_environment, "spawn_daemon_worker", return_value=worker), \ + patch.object(dev_environment, "daemon_port", return_value=28080): + with contextlib.redirect_stderr(io.StringIO()) as output: + self.assertIsNone(dev_environment.start_quick_web_daemon(root, candidate, run_dir, 28080)) + self.assertIn("daemon-worker.log", output.getvalue()) + self.assertIn("daemon-startup.log", output.getvalue()) + + def test_pid_is_alive_reads_the_process_table(self): + self.assertTrue(dev_environment.pid_is_alive(os.getpid())) + self.assertFalse(dev_environment.pid_is_alive(0)) + self.assertFalse(dev_environment.pid_is_alive(999983)) + + def test_runtime_pid_reads_and_tolerates_junk(self): + with tempfile.TemporaryDirectory() as directory: + run_dir = Path(directory) + (run_dir / "daemon.pid").write_text("123", encoding="utf-8") + self.assertEqual(dev_environment.runtime_pid(run_dir), 123) + (run_dir / "daemon.pid").write_text("junk", encoding="utf-8") + self.assertIsNone(dev_environment.runtime_pid(run_dir)) + self.assertIsNone(dev_environment.runtime_pid(run_dir / "missing")) + + def test_prune_target_runs_without_a_build(self): + args = type("Args", (), {"target": "prune", "build_dir": None, "yes": False, "dry_run": False, "extra": []})() + with patch.object(dev_environment, "parse_args", return_value=args), \ + patch.object(dev_environment, "project_root", return_value=Path("C:/work")), \ + patch.object(dev_environment, "prune_runtime_tree", return_value=0) as prune: + self.assertEqual(dev_environment.main(), 0) + prune.assert_called_once_with(Path("C:/work"), dry_run=False) def test_quick_daemon_start_requires_a_healthy_runtime(self): root = Path("C:/work") run_dir = root / ".acecode/dev-run/test" candidate = dev_environment.BuildCandidate(root / "build", root, root / "build/acecode.exe") - with patch.object(dev_environment.subprocess, "Popen"), \ + worker = type("Worker", (), {"poll": staticmethod(lambda: None)})() + with patch.object(dev_environment, "spawn_daemon_worker", return_value=worker), \ patch.object(dev_environment, "daemon_is_healthy", return_value=False), \ - patch.object(dev_environment, "daemon_port", return_value=28080): + patch.object(dev_environment, "daemon_port", return_value=28080), \ + contextlib.redirect_stderr(io.StringIO()): self.assertIsNone(dev_environment.start_quick_web_daemon(root, candidate, run_dir, 28080)) def test_healthy_daemon_requires_authenticated_identity_match(self): @@ -304,14 +572,15 @@ def test_quick_daemon_starts_foreground_worker_with_isolated_runtime(self): root = Path("C:/work") run_dir = root / ".acecode/dev-run/test" candidate = dev_environment.BuildCandidate(root / "build", root, root / "build/acecode.exe") - with patch.object(dev_environment.subprocess, "Popen") as popen, \ + worker = type("Worker", (), {"poll": staticmethod(lambda: None)})() + with patch.object(dev_environment, "spawn_daemon_worker", return_value=worker) as spawn, \ patch.object(dev_environment, "daemon_is_healthy", return_value=True), \ patch.object(dev_environment, "daemon_port", return_value=28080): self.assertEqual(dev_environment.start_quick_web_daemon(root, candidate, run_dir, 28080), 28080) - self.assertEqual(popen.call_args.args[0], [ + self.assertEqual(spawn.call_args.args, (root, run_dir, [ str(candidate.executable), "daemon", "--foreground", "--cwd=C:/work", "--run-dir=C:/work/.acecode/dev-run/test", "--port=28080", - ]) + ])) def test_launch_vite_passes_daemon_credentials_only_to_child_environment(self): root = Path("C:/work") @@ -328,14 +597,18 @@ def test_launch_vite_passes_daemon_credentials_only_to_child_environment(self): self.assertEqual(environment["ACECODE_DAEMON_PORT"], "38123") self.assertEqual(environment["ACECODE_DAEMON_TOKEN"], "test-token") - def test_vite_options_strip_daemon_runtime_argument(self): + def test_vite_options_strip_daemon_runtime_arguments(self): self.assertEqual( dev_environment.vite_options(["--run-dir", "runtime", "--host", "127.0.0.1"]), ["--host", "127.0.0.1"], ) self.assertEqual( dev_environment.vite_options(["--run-dir=runtime", "--port", "5174"]), - ["--port", "5174"], + [], + ) + self.assertEqual( + dev_environment.vite_options(["--port=28080", "--run-dir=runtime", "--open"]), + ["--open"], ) def test_launch_vite_refuses_missing_daemon_token(self): From 5752ab5ed43533a7d20fc9cd0dc0dde0934950df Mon Sep 17 00:00:00 2001 From: Trae User Date: Mon, 21 Sep 2026 13:07:56 +0800 Subject: [PATCH 3/3] docs: add dev launcher multi-instance requirements --- ...multiinstance-optimization-requirements.md | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 docs/specs/2026-09-20-dev-launcher-multiinstance-optimization-requirements.md diff --git a/docs/specs/2026-09-20-dev-launcher-multiinstance-optimization-requirements.md b/docs/specs/2026-09-20-dev-launcher-multiinstance-optimization-requirements.md new file mode 100644 index 00000000..a33eb9df --- /dev/null +++ b/docs/specs/2026-09-20-dev-launcher-multiinstance-optimization-requirements.md @@ -0,0 +1,180 @@ +# 开发环境启动器多实例优化需求规格 + +**日期:** 2026-09-20 +**状态:** 需求与技术方案均已确认(§7 决策已落定);后续按波次分别走 OpenSpec 提议 +**来源:** 多实例特性提交 `42912f75`(`plan_instance_startup` / `desktop-instances` 模型)落地后的开发启动器排查与交接记录(分支 `fix/dev-launcher-runtime-identity` 上的实测复现) + +## 1. 目标 + +多实例(主仓库 + 多个 worktree 并行开发)成为常态后,`dev_environment.py` 系启动脚本的三个旧假设失效:28080 端口基本空闲、run-dir 天然可复用、失败可人工排查。本规格让 web / desktop 两条开发启动链路做到: + +- **快**:端口决策秒级完成,不再为被占的 28080 白等 15 秒。 +- **稳**:陈旧运行目录自愈,重启即可恢复,不需要手动 `daemon stop`。 +- **可诊断**:启动失败时能在 run-dir 内直接读到真实退出原因。 +- **吃到多实例红利**:desktop 开发场景获得稳定实例身份,同一 worktree 同一 commit 重启复用同一 daemon,不再每次产生新的随机实例目录。 + +## 2. 范围 + +### 本轮范围(A + B + C + D + E + G) + +| 项 | 一句话 | 主要落点 | +|---|---|---| +| A 陈旧自愈 | 死 pid 的 run-dir 自动清理并重试启动 | `scripts/dev_environment.py` | +| B 固定端口 | 端口由 worktree 身份确定性派生,被占立即换 | `scripts/dev_environment.py` | +| C 失败可诊断 | daemon worker 输出落盘到 run-dir 内日志 | `scripts/dev_environment.py` | +| D 死目录清理 | 启动自动清 + 手动全量清理命令 | `scripts/dev_environment.py` | +| E 实例身份 | desktop 接受外部指定实例身份,dev 脚本注入稳定身份 | `src/desktop/main.cpp`、`scripts/dev_desktop.py` | +| G 进程级多开 | desktop 支持进程级"允许本实例多开"覆盖,不污染全局配置 | `src/desktop/main.cpp`、`scripts/dev_desktop.py` | + +每项均需同步更新 `.agents/skills/development-environment/SKILL.md` 与对应 Python 单测(`tests/scripts/`,unittest 风格)。 + +### 不在本轮范围 + +- **F** run-dir 统一到 `~/.acecode/run/`(脚本与产品两套运行时目录世界的合并,涉及 `daemon_pool` 复用判定,另立议题)。 +- **H** `token.tmp` 回退读取移除(3 行小改,建议后续与 `atomic_file.hpp` 修复合并处理)。 + +## 3. 需求明细 + +### 3.1 A:陈旧运行目录自愈 + +启动时 run-dir 健康检查未通过(现状:直接硬失败),改为按 pid 死活分支: + +| 检查结果 | 行为 | +|---|---| +| run-dir 健康(pid / port / token 与 `/api/health` 身份匹配) | 现状保持:复用 daemon | +| 不健康,且记录的 pid **已死** | 自动清理该 run-dir 的运行时文件,**重试一次**启动;重试仍失败则报错并指向 C 的日志文件 | +| 不健康,且记录的 pid **活着**(真 daemon,只是身份不匹配) | 不干预、不终止,保留现有"请手动停止"类提示 | + +**安全红线:任何自动清理路径都绝不对存活进程发出终止。** + +### 3.2 B:worktree 派生固定端口 + +- 默认端口不再固定 28080,改由 worktree 身份(目录名 + commit)**确定性派生**,落在 28080–28280 区间内。 +- 同一 worktree 每次启动得到同一端口;不同 worktree 天然错开,互不抢占。 +- 派生端口被占时**立即**换下一个候选,不等待健康检查超时(消灭 15 秒白等)。 +- 日常通过 Vite 代理访问不受影响;直接访问 daemon 端口时以启动输出为准。 +- 若用户显式指定端口,显式值优先于派生值。 + +### 3.3 C:失败可诊断 + +- 启动器拉起的 daemon worker 的输出(stdout/stderr)落盘到 run-dir 内的日志文件(目录已被 `.gitignore` 覆盖,不污染仓库)。 +- 启动失败时,错误输出明确指向该日志文件路径。 +- 目标:定位一次失败不再依赖 heartbeat + netstat + `daemon status` 反推。 + +### 3.4 D:死目录清理 + +- **启动时自动清理**:每次启动顺带清理「目录名匹配当前 worktree 前缀 **且** pid 已死」的 run-dir,日常零堆积。 +- **手动全量清理命令**:一次性清理所有可判定为死(pid 已死)的 run-dir,包括:已删除 worktree 的遗留、现存存量(约 20 个目录 / 19 个死 pid)、以及旧版本遗留的 `.acecode-dev-run/` 目录。 +- 清理动作只作用于「pid 已死」可判定的目录;判定不了的(缺 pid 文件等)跳过并报告,不猜。 + +### 3.5 E:desktop 实例身份稳定化 + +- desktop 新增**外部指定实例身份**的覆盖入口(环境变量或 CLI,形态见开放决策);不指定时行为与现状完全一致(随机 uuid)。 +- dev 启动路径(`dev_desktop.py` / `dev_environment.py desktop`)自动注入身份 = `-`,与 web 启动器现有 run-dir 命名策略对齐。 +- 效果:同一 worktree 同一 commit 重启 desktop 复用同一实例目录与 daemon;切 commit 后新开实例,旧实例目录由 D 回收。 +- 身份字符串的可用字符约束与 web 侧命名规则一致(避免路径非法字符)。 + +### 3.6 G:进程级"允许本实例多开"覆盖 + +- 背景:desktop 的单例锁是机器级命名互斥体、锁名编译期固定,**安装版与开发版、不同 worktree 的 dev desktop 之间竞争同一把锁**。默认 `allow_multiple_instances=false` 时,只要机器上已有任何 desktop 在跑(如安装版常驻 dogfood),dev desktop 会直接不启动并聚焦已有实例——E 的稳定身份走不到生效分支(instance_id 仅在允许多开时使用)。 +- 需求:desktop 支持进程级覆盖"本实例允许多开"(入口形态见开放决策),dev 启动路径默认注入;**不读写全局用户配置**。 +- 效果:安装版 desktop 常驻时可直接并行启动 dev desktop;安装版的日常行为(二次启动聚焦已有窗口)不受影响。 +- 覆盖未使用时,行为与现状完全一致。 + +## 4. 约束 + +- **进程安全**:任何自动清理/自愈不得终止存活进程(A、D 共同红线)。 +- **token 安全**:daemon 认证 token 只经进程环境传递,不落日志、不进命令行参数(维持现状)。 +- **不改 daemon 端口语义**:不引入 `--port=0` 之类新语义,端口选择完全在脚本侧解决(`daemon status --json` 属可选的输出格式增强,见开放决策 3)。 +- **向后兼容**:E/G 的覆盖入口未使用时,desktop 产品行为与全局配置语义完全不变;`--run-dir` 显式指定的用户路径行为不变。 +- **仓库流程**:非平凡行为变更,两波各自先建 OpenSpec change 再实现;实现后同步 `development-environment` SKILL 文档。 +- **测试**:Python 侧用 unittest(本机无 pytest);C++ 侧改动(E/G)须编译 `acecode_unit_tests` 并跑 `ctest`。 +- **输出风格**:脚本控制台输出沿用现有 ASCII 风格提示语,不引入 emoji。 + +## 5. 验收标准 + +1. 28080 被其他实例占用时启动 web:端口探测立即完成(毫秒级 bind 探测),全程无 15 秒健康检查等待。 +2. 同一 worktree 连续两次启动 web 得到同一端口;两个不同 worktree 并行启动互不冲突。 +3. 手动杀掉 daemon 后重跑同一启动命令:自动清理死 run-dir 并成功恢复,全程无需 `daemon stop`。 +4. run-dir 指向一个存活的异名 daemon(pid 活、身份不匹配):启动器不终止它,输出保留"请手动停止"类提示。 +5. 人为制造启动失败:run-dir 内日志文件包含 worker 真实输出,错误信息指明该文件路径。 +6. 同一 worktree 同一 commit 两次启动 desktop:落在同一实例目录、复用同一 daemon;切 commit 后落到新目录。 +7. 不指定身份与多开覆盖直接运行 `acecode-desktop`:行为与现状一致(随机实例目录、遵循全局配置)。 +8. 安装版 desktop 常驻时启动 dev desktop:不改动全局配置即可启动为独立实例;全局配置文件未被写入,安装版二次启动仍表现为聚焦已有窗口。 +9. 手动全量清理命令一次清掉现存死目录与 `.acecode-dev-run/` 遗留;日常启动后死目录不再堆积。 +10. `tests/scripts/` 单测全绿;Wave 2 的 C++ 编译与 `ctest` 通过。 + +## 6. 分波交付 + +| 波次 | 内容 | 交付物 | +|---|---|---| +| Wave 1(纯 Python,先行) | C(先落日志拿到诊断能力)→ A(已验证修复路径)→ B → D | 一个 OpenSpec change + 单测 | +| Wave 2(含 C++) | E + G:desktop 身份与多开两个进程级覆盖 + dev 脚本注入 | 一个 OpenSpec change + 单测 + ctest | + +Wave 2 建议在 `fix/dev-launcher-runtime-identity`(含未验证的 `atomic_file.hpp` 改动)验证合并后再动 C++,避免两笔未验证的 C++ 改动叠加。 + +## 7. 已定技术决策(grilling 两轮落定,2026-09-20/21) + +以下为技术方案阶段全部决策,作为 Wave 1 / Wave 2 OpenSpec 提议的直接输入。 + +### 7.1 端口派生(B) + +- 散列:`zlib.crc32(safe_identity) % 201 + 28080`,`safe_identity` 复用 `worktree_runtime_dir` 的 `-` 字符串——端口与 run-dir 同源同变。禁用 Python 内置 `hash()`(每进程随机加盐)。 +- 被占后线性 +1 顺延(区间内回绕),每候选毫秒级 bind 探测;全区间占满才报错。 +- 已知副作用:identity 含 commit12,切 commit 后端口随 run-dir 一起变;Vite 代理目标不可写死,验收 2 仅在 commit 不变时成立。 + +### 7.2 显式端口(B) + +- 新增 `--port`(与 `--run-dir` 同级,走 extra parser)。 +- 显式端口被占 → 立即报错退出,不顺延(显式 = 外部有确切依赖,静默换端口更难查)。 + +### 7.3 A 自愈载体 + +- 脚本内联 `pid_is_alive(pid)`(Windows ctypes `OpenProcess` / POSIX `os.kill(pid, 0)`);死 → `daemon stop --run-dir=...`(复用其 `cleanup_runtime_files`,已实测验证路径);活 → 维持"请手动停止"类提示。 +- 不用 `daemon status` 退出码判死活(rc=1 含"pid 活但不可复用",误用会踩红线);不给 status 加 `--json`(保持 Wave 1 纯 Python)。 +- **保守假设(Q10)**:pid 号被 OS 回收给无关进程时判"活"、不自愈、走跳过报告;不做进程身份校验,红线优先于自愈覆盖率。 + +### 7.4 C 日志形态 + +- 脚本写 `daemon-worker.log`:Popen stdout/stderr 重定向,append + 每次拉起写分隔头(时间戳 + 参数摘要),>1MB 滚动保留 1 代(`.log.1`)。 +- 与 daemon 自带 `daemon-startup.log`(`startup_diagnostics.cpp`)并存;启动失败时报错同时指向两个路径。 +- 不做多代保留(与 D 的"零堆积"目标一致)。 + +### 7.5 D 命令形态 + +- `prune` 作为与 `web/desktop/tui` 平级的 positional target。 +- 输出三段:已清理(目录 + 死 pid)、跳过(目录 + 原因)、总计;存在跳过项退出码仍为 0。 +- 结构不认识的目录(含 `.acecode-dev-run/` 遗留)一律只报告不删除。 + +### 7.6 D 自动清理时点 + +- 放在"健康检查判不可复用 → 准备新拉起"路径上、端口探测之前;健康复用路径不扫目录(零开销)。 + +### 7.7 E/G 覆盖入口(Wave 2) + +- 只用环境变量,dev 专用定位,C++ 不加 CLI: + - `ACECODE_DESKTOP_INSTANCE_ID=-`;空串视为未设置 → 回退随机 uuid。 + - `ACECODE_DESKTOP_ALLOW_MULTIPLE_INSTANCES`:白名单解析,大小写不敏感 `1`/`true`/`yes`/`on` 为真,其余一切(含 `0`、空串)为假;未设置不进入覆盖分支。 +- 由 `dev_desktop.py` 注入子进程环境;文档写进 SKILL 与 dev 文档,不进对外产品文档。 + +### 7.8 E 身份字符串责任划分 + +- Python 生成时 sanitize(`worktree_runtime_dir` 正则提为共享 helper 供 `dev_desktop.py` 复用)。 +- C++ 只校验不清洗:`^[A-Za-z0-9_.-]{1,64}$`,不合法 → 忽略注入值、回退随机 uuid(行为退化为现状,绝不产生非法路径)。 + +### 7.9 测试切面 + +- Python:三个外部依赖做成模块级小函数供 `patch.object` 打桩——`pid_is_alive()`、`port_is_available(port)`、集中封装的 worker Popen 调用(断言重定向到 `daemon-worker.log`)。另加:crc32 派生端口固定向量测试(防换成不稳定散列)、线性顺延顺序断言(含回绕)。 +- C++(Wave 2):`is_valid_instance_id()` 与 `parse_allow_multiple_instances()` 做成 `instance_startup.hpp` 的 header-only 纯函数;`main.cpp` 只 `getenv` + 透传,env 读取不进被测代码;单测与 `plan_instance_startup` 现有测试同文件扩展。 + +### 7.10 worker 提前退出快速失败(C 的延伸) + +- 健康等待循环中加 `Popen.poll()`:worker 进程已退出 → 立即失败并指向日志文件,不再等满健康检查超时。至此"启动失败秒级可见"覆盖全部场景。 + +## 8. 变更记录 + +| 日期 | 变更 | 原因 | +|---|---|---| +| 2026-09-20 | 初版 | brainstorming 澄清产出:范围定为 A–E(F/G/H 出局),端口策略取 worktree 派生、清理取"自动 + 手动全量"、实例身份取 worktree+commit | +| 2026-09-20 | G 拉回范围并入 Wave 2;新增 3.6 与验收 8 | 用户确认 desktop 会并行跑(安装版 dogfood / 多 worktree);单例锁为机器级固定锁名,E 在默认配置下走不到生效分支,G 与 E 同代码缝成本极低 | +| 2026-09-21 | §7 由"开放决策"改为"已定技术决策"(12 项);状态推进为方案已确认 | grilling 两轮:第一轮 8 项(端口派生、显式端口、自愈载体、日志形态、prune 形态、E/G 入口、sanitize 划分、清理时点)全按推荐;第二轮 4 项(测试切面、pid 复用保守边界、bool env 白名单解析、worker 提前退出快速失败)全按推荐 |