From 8c7bb81c64d2f86bec46525c3ce790b941250197 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Wed, 19 Aug 2026 08:22:15 +0800 Subject: [PATCH 1/3] emrg: daemon single-instance via fixed-port bind exclusivity (rant 2026-08-19T08:05:21) --- Agent.md | 2 +- emrg/_stop_all.py | 35 +++++--- emrg/connect.py | 42 ++++++---- emrg/server/daemon.py | 190 +++++++++++++++--------------------------- tests/test_connect.py | 53 ++++++++---- tests/test_daemon.py | 94 ++++++++++++++------- tests/test_ws_e2e.py | 17 ++++ 7 files changed, 238 insertions(+), 195 deletions(-) diff --git a/Agent.md b/Agent.md index 07f86773..a0529bac 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (963) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (965) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/_stop_all.py b/emrg/_stop_all.py index c369bf4b..73b600ba 100644 --- a/emrg/_stop_all.py +++ b/emrg/_stop_all.py @@ -58,7 +58,13 @@ # Build stamp printed at the start of every run so the operator can tell at a # glance which stop_all.py generation executed (rant 2026-08-17T21:06:31). -_STOP_ALL_STAMP = "built 2026-08-18 (module-holder enumeration + createfile-probe + taskkill RM + excluded-chain self-exclusion + self-lock guard)" +_STOP_ALL_STAMP = "built 2026-08-19 (fixed-port daemon shutdown — rant 08:05:21)" + +# Fixed daemon port (host rant 2026-08-19T08:05:21): the daemon binds a fixed +# loopback port as its single-instance admission. This module is pure stdlib +# (runs standalone inside the installer) so it cannot import emrg.connect — +# keep in sync with emrg.connect.EMRGD_PORT. +_EMRGD_PORT = 56031 _EMRG_CLIENT_RE = re.compile(r"-m\s+emrg(\.server)?(\s|$)") _APPIMAGE_RE = re.compile(r"EMRG-[\w.\-]*AppImage(\s|$)") @@ -328,20 +334,23 @@ def stop_daemon() -> None: cannot, so we clean it up — the next daemon start re-asserts both files). """ port_path = config_dir() / "emrgd.port" + # Fixed-port shutdown (rant 2026-08-19T08:05:21): the daemon always + # listens on _EMRGD_PORT; the port file only supplies the auth token. If + # the file is missing/stale, fall through to the pid + cmdline paths. try: - port_tok = port_path.read_text(encoding="utf-8").split() - if len(port_tok) == 2: - if ws_graceful_shutdown(int(port_tok[0]), port_tok[1]): - # wait for the daemon to exit + remove its pid file - # (~10s grace: old stop-emrg.cmd v2 polled emrgd.pid up to - # 10s; a busy daemon mid-tool-loop needs the full window) - for _ in range(60): - pid = _read_pid_file() - if pid is None or not _pid_alive(pid): - break - time.sleep(0.15) + text = port_path.read_text(encoding="utf-8").split() + token = text[1] if len(text) == 2 else "" except (OSError, ValueError): - pass # port file missing/corrupt → fall through to pid path + token = "" + if token and ws_graceful_shutdown(_EMRGD_PORT, token): + # wait for the daemon to exit + remove its pid file + # (~10s grace: old stop-emrg.cmd v2 polled emrgd.pid up to + # 10s; a busy daemon mid-tool-loop needs the full window) + for _ in range(60): + pid = _read_pid_file() + if pid is None or not _pid_alive(pid): + break + time.sleep(0.15) pid = _read_pid_file() if pid is not None and _pid_alive(pid): diff --git a/emrg/connect.py b/emrg/connect.py index 2c14b7b8..4d74e9c8 100644 --- a/emrg/connect.py +++ b/emrg/connect.py @@ -6,8 +6,12 @@ ws://127.0.0.1: (local, all platforms) wss://: (remote, Phase 5 — same protocol + TLS + token) -The daemon writes its dynamic port and auth token to ``~/.emrg/emrgd.port`` -(``port\\n token``, mode 0o600). Clients read that file, connect, and send +The daemon listens on the fixed port ``127.0.0.1:EMRGD_PORT`` (56031, rant +2026-08-19T08:05:21 — fixed-port bind exclusivity is the single-instance +admission) and writes its auth token to ``~/.emrg/emrgd.port`` +(``port\\n token``, mode 0o600). Clients read that file for the token, and +connect to the fixed port. +Clients then send a first-frame auth message; the daemon confirms with ``auth_ok`` before the normal protocol loop. Auth failure raises :class:`AuthError` so callers can distinguish it from a transient disconnect (which should be retried). @@ -32,6 +36,13 @@ # Port/token file lives at ~/.emrg/emrgd.port (port\n token, mode 0o600) CONNECT_ID = "emrgd" +# Fixed daemon port (host rant 2026-08-19T08:05:21): the daemon binds a FIXED +# loopback port so kernel-level bind exclusivity (EADDRINUSE) is the single- +# instance admission — no PID file to forge/delete, no race window. The port +# file still carries the auth token; the port itself is now a constant. +# Keep in sync with emrg._stop_all._EMRGD_PORT (that module is pure stdlib). +EMRGD_PORT = 56031 + class AuthError(Exception): """Raised when the daemon rejects the auth handshake. @@ -49,9 +60,11 @@ def get_server_path() -> str: async def connect_to_server(): """Connect to the emrgd server over WebSocket. - Reads ``~/.emrg/emrgd.port``, connects to ``ws://127.0.0.1:``, - sends the first-frame auth message and waits for the ``auth_ok`` - confirmation. Returns the connected WebSocket object (single ws — no + Reads the auth token from ``~/.emrg/emrgd.port``, connects to the FIXED + daemon port ``ws://127.0.0.1:`` (rant 2026-08-19T08:05:21 — + the port is a constant; the file only carries the token), sends the + first-frame auth message and waits for the ``auth_ok`` confirmation. + Returns the connected WebSocket object (single ws — no ``(reader, writer)`` tuple anymore). Raises: @@ -59,7 +72,7 @@ async def connect_to_server(): ConnectionRefusedError / OSError / FileNotFoundError: daemon not running. """ port_path = Path(get_server_path()) - port, token = port_path.read_text(encoding="utf-8").split() + _, token = port_path.read_text(encoding="utf-8").split() # proxy=None: loopback connections must never go through a system proxy. # websockets 17 defaults proxy=True and reads the OS proxy settings — when a # Windows system proxy is enabled (e.g. 10.10.0.28:6501 for HN/Reddit access), @@ -68,7 +81,7 @@ async def connect_to_server(): # local daemon, while the Node.js GUI is unaffected (2026-08-14 incident; root # cause of continuous emrg-task/emrg-promote-task crashes since 2026-08-13). ws = await connect( - f"ws://127.0.0.1:{port}", + f"ws://127.0.0.1:{EMRGD_PORT}", proxy=None, max_size=16 * 1024 * 1024, ) @@ -97,18 +110,15 @@ def cleanup_server() -> None: def is_server_running_sync(timeout: float = 2.0) -> bool: """Synchronous health-check probe (for client startup). - Uses blocking TCP connect to the port in ``emrgd.port``. Reads only the - first line (port), never the token — this is a low-cost liveness probe; - real auth happens on the first frame of a real connection. + Blocking TCP connect to the FIXED daemon port ``127.0.0.1:EMRGD_PORT`` + (rant 2026-08-19T08:05:21). No port-file read: the fixed port is the + ground truth, so a missing/stale ``emrgd.port`` never makes the probe + report "not running" while a daemon is actually alive (the dual-instance + root cause). Real auth happens on the first frame of a real connection. """ - port_path = Path(get_server_path()) - try: - port = int(port_path.read_text(encoding="utf-8").splitlines()[0]) - except (OSError, ValueError, IndexError): - return False sock = None try: - sock = _socket.create_connection(("127.0.0.1", port), timeout=timeout) + sock = _socket.create_connection(("127.0.0.1", EMRGD_PORT), timeout=timeout) return True except (ConnectionRefusedError, OSError): return False diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 67881bc3..f32aa9ef 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -12,6 +12,7 @@ import asyncio import base64 +import errno import json import logging import os @@ -19,7 +20,9 @@ import re import secrets import signal +import socket as _socket import subprocess +import sys from datetime import datetime from pathlib import Path from typing import Optional @@ -30,7 +33,7 @@ from emrg._win import win32_no_window_kwargs from emrg.config import LlmConfig, config_dir -from emrg.connect import cleanup_server, is_server_running_sync +from emrg.connect import EMRGD_PORT, cleanup_server from emrg.server.atomic import atomic_write_bytes, atomic_write_yaml from emrg.server.llm import LlmClient from emrg.server.git_utils import ( @@ -143,56 +146,43 @@ def _get_jinja_env() -> "jinja2.Environment": EVOLUTION_CWD = Path.home() / ".emrg" / "evolution" -def _find_emrg_server_processes(own_pid: int) -> list[int]: - """Return PIDs of other live ``emrg.server`` daemon processes. - - Host rant 2026-08-18T22:15:04 (process-name detection, host's chosen root - fix for the dual-instance restart storm): pid/port files can be missing, - stale, or mismatched (GUI spawn, crash restart, #593 family), so the - single-instance gate must ask the OS "is an earlier emrg.server already - alive?" and refuse to start if so. The process name is the ground truth — - the port file is not (observed 22:04: pids 23863/23864 coexisting while - the port file pointed at only one). +def _create_fixed_port_socket(port: int) -> _socket.socket: + """Create + bind the daemon's fixed loopback listening socket. + + This is the daemon's ONLY single-instance admission (host rant + 2026-08-19T08:05:21): the kernel refuses a second bind on the same + (addr, port) with EADDRINUSE — pure resource exclusivity with no file to + forge/delete (PID files were the unreliable mechanism), no race window, + and automatic release when the process dies. Raises OSError(EADDRINUSE) + when another daemon already owns the port; the caller treats that as + "emrgd already running" and exits itself. + + Socket options: + - Windows: SO_EXCLUSIVEADDRUSE + SO_REUSEADDR together. SO_REUSEADDR alone + allows any socket to hijack the port; SO_EXCLUSIVEADDRUSE forbids that. + Together they still allow a fast restart over lingering TIME_WAIT sockets + (Windows would otherwise block rebinding for 30-120s after a crash). + - POSIX: SO_REUSEADDR only. It permits rebinding while TIME_WAIT sockets + linger but does NOT allow two listeners on the same addr (that would be + SO_REUSEPORT, which we deliberately never set) — exclusivity is kept. """ - pids: list[int] = [] + sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) try: - if platform.system() == "Windows": - # PowerShell CIM scan (same contract as - # _stop_all._scan_windows_python_emrg — literal braces escaped). - ps_cmd = ( - "Get-CimInstance Win32_Process | " - "Where-Object {{ $_.ProcessId -ne {own} -and " - "$_.Name -match 'python' -and " - "$_.CommandLine -match '-m emrg\\.server' }} | " - "ForEach-Object {{ Write-Output $_.ProcessId }}" - ).format(own=own_pid) - out = subprocess.run( - ["powershell", "-NoProfile", "-Command", ps_cmd], - capture_output=True, text=True, timeout=10, **win32_no_window_kwargs(), - ).stdout + if sys.platform == "win32": + sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_EXCLUSIVEADDRUSE, 1) + sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1) else: - out = subprocess.run( - ["ps", "-axo", "pid=,command="], - capture_output=True, text=True, timeout=10, - ).stdout - for line in out.splitlines(): - parts = line.strip().split(None, 1) - if len(parts) != 2: - continue - try: - pid = int(parts[0]) - except ValueError: - continue - if pid == own_pid: - continue - # daemon cmdline: `python -m emrg.server` (installed python or - # uv run) — match the module path only (TUI is `-m emrg`). - if "-m emrg.server" in parts[1] or "emrg/server" in parts[1]: - pids.append(pid) - return pids - except (OSError, subprocess.SubprocessError, TimeoutError): - logger.debug("process-name scan failed — falling back to port probe", exc_info=True) - return [] + sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1) + sock.bind(("127.0.0.1", port)) + sock.listen(128) + sock.setblocking(False) + return sock + except OSError: + try: + sock.close() + except OSError: + pass + raise class EmrgServer: @@ -258,98 +248,54 @@ def __init__(self, llm_config: LlmConfig) -> None: logger.info("skills loaded: %s", [s.name for s in self.skills]) async def serve(self) -> None: - """Start listening for IPC connections (platform-adaptive).""" + """Start listening for IPC connections (fixed-port, platform-adaptive).""" self._running = True - # ── Single-instance admission: process-name detection (PRIMARY) ─── - # (host rant 2026-08-18T22:15:04 — root fix for the dual-instance - # restart storm) pid/port files are unreliable (missing/stale/ - # mismatched → two emrg.server processes coexisting, observed 22:04 - # pids 23863/23864), so first ask the OS: if an earlier emrg.server - # process is already alive, THIS new instance exits itself — never - # force-kill the old one (that is what caused the restart storm). + # ── Single-instance admission: fixed-port bind exclusivity (ONLY) ─── + # (host rant 2026-08-19T08:05:21) PID files are unreliable (plain + # files — content can be overwritten/deleted, and os.kill(pid,0) + # liveness probes misjudge: observed dual instances PID 3924+2592 on + # 08-19) and random ports (port=0) make port exclusivity useless. The + # fixed-port bind IS the admission: the kernel refuses a second bind + # (EADDRINUSE) — no file to forge, no race window, auto-released on + # crash. No transitional compatibility with old-format daemons + # (rant: "升级后即唯一生效"). try: - existing = _find_emrg_server_processes(os.getpid()) - except Exception: - existing = [] - if existing: - logger.error( - "another emrg.server process(es) already running (pids %s) — " - "new instance exiting itself (process-name admission)", - existing, - ) - self._running = False - return - - # ── PID file: prevent duplicate daemon instances ─── - runtime_dir = config_dir() - pid_file = runtime_dir / "emrgd.pid" - - # ── Single-instance admission (secondary): port-file liveness probe ─── - # (rant 2026-08-18T12:49:09 ③) Multiple resident clients (GUI + TUI, - # possibly different installs) each spawn/restart the daemon on their - # own schedule; stale-restart sequences can leave the pid file missing - # while an old daemon is still alive, so the pid-file check alone lets - # a second instance start (observed: 4 emrg.server processes - # coexisting on different ports). Probe the port file next — if a - # live daemon already answers, do NOT start a duplicate. - try: - if is_server_running_sync(timeout=1.0): + sock = _create_fixed_port_socket(EMRGD_PORT) + except OSError as exc: + if exc.errno == errno.EADDRINUSE: logger.error( - "another emrgd instance is already listening (port file %s) — " - "refusing to start a duplicate (single-instance admission)", - runtime_dir / "emrgd.port", + "emrgd already running on 127.0.0.1:%d (EADDRINUSE, " + "fixed-port admission) — new instance exiting itself. " + "Stop it first (emrg server stop).", + EMRGD_PORT, ) self._running = False return - except Exception: - logger.debug("single-instance port probe failed — continuing startup", exc_info=True) + raise + # ── PID file: diagnostics only (rant 08-05:21 — no longer an + # admission gate). Written AFTER the fixed-port bind succeeded, so only + # the process that actually owns the port writes it; stop_all and + # diagnostics may still read it. + runtime_dir = config_dir() + pid_file = runtime_dir / "emrgd.pid" try: - # Atomic create — fails if file already exists - fd = os.open(pid_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY) - os.write(fd, str(os.getpid()).encode()) - os.close(fd) - logger.debug("pid file written: %s (pid=%d)", pid_file, os.getpid()) - except FileExistsError: - # PID file exists — check if the old process is still alive - try: - old_pid_s = pid_file.read_text(encoding="utf-8").strip() - old_pid = int(old_pid_s) - os.kill(old_pid, 0) - # Old process is alive. Host rant 2026-08-18T22:15:04: a new - # instance NEVER force-kills the old one (that takeover path - # caused the 22:04 dual-instance restart storm) — it exits - # itself. The process-name scan above already refused when an - # emrg.server cmdline was found; this pid-file branch is the - # fallback for a live-but-scan-missed process. - logger.error( - "emrgd already running (pid=%d). " - "Stop it first (emrg server stop) — new instance exiting itself.", - old_pid, - ) - self._running = False - return - except (ValueError, OSError): - # Stale PID file — remove and retry - logger.warning("stale pid file (pid %s gone), removing", old_pid_s) - pid_file.unlink() - fd = os.open(pid_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY) - os.write(fd, str(os.getpid()).encode()) - os.close(fd) - logger.debug("pid file written: %s (pid=%d)", pid_file, os.getpid()) + pid_file.write_text(str(os.getpid()), encoding="utf-8") + logger.debug("pid file written (diagnostic only): %s (pid=%d)", pid_file, os.getpid()) + except OSError: + logger.warning("could not write diagnostic pid file %s", pid_file, exc_info=True) self._server = await serve( self._handle_client, - host="127.0.0.1", - port=0, + sock=sock, max_size=16 * 1024 * 1024, # keepalive 超时放宽:TUI 回答结束时全量渲染可阻塞事件循环数秒, # 默认 ping_timeout=20 会导致服务器 CLOSE 1011 踢连接(rant 14:22:06)。 # 保留 ping_interval=20(liveness 检测),容忍 300s 的 pong 延迟。 ping_timeout=300, ) - port = self._server.sockets[0].getsockname()[1] + port = EMRGD_PORT self._auth_token = secrets.token_urlsafe(32) self._assert_port_file(port) # Rant 2026-08-09T18:47:37 B4:启动完成一行自证——pid/port/port 文件路径/写入成功, diff --git a/tests/test_connect.py b/tests/test_connect.py index c3bce612..26ab5a46 100644 --- a/tests/test_connect.py +++ b/tests/test_connect.py @@ -60,23 +60,46 @@ def test_leaves_other_files(self, monkeypatch, tmp_path): class TestIsServerRunningSync: - def test_false_when_port_file_missing(self, monkeypatch, tmp_path): - """Returns False when the port file doesn't exist (daemon not started).""" - monkeypatch.setattr("emrg.connect.config_dir", lambda: tmp_path) + """Probes the FIXED daemon port (rant 2026-08-19T08:05:21) — no port-file + read, so a missing/stale emrgd.port never hides a live daemon.""" - assert is_server_running_sync() is False + def _free_port(self) -> int: + """Bind a probe socket to port 0 to get a free port (avoids colliding + with a real daemon on the well-known EMRGD_PORT).""" + import socket as _socket - def test_false_when_port_file_corrupt(self, monkeypatch, tmp_path): - """Returns False when the port file is unparseable.""" - monkeypatch.setattr("emrg.connect.config_dir", lambda: tmp_path) - (tmp_path / f"{CONNECT_ID}.port").write_text("garbage", encoding="utf-8") + probe = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) + try: + probe.bind(("127.0.0.1", 0)) + return probe.getsockname()[1] + finally: + probe.close() - assert is_server_running_sync() is False + def test_false_when_fixed_port_closed(self, monkeypatch): + """Returns False when nothing listens on the fixed port (no daemon).""" + monkeypatch.setattr("emrg.connect.EMRGD_PORT", self._free_port()) - def test_false_when_connection_refused(self, monkeypatch, tmp_path): - """Returns False when nothing listens on the port.""" - monkeypatch.setattr("emrg.connect.config_dir", lambda: tmp_path) - (tmp_path / f"{CONNECT_ID}.port").write_text("1\nno-token", encoding="utf-8") + assert is_server_running_sync(timeout=0.1) is False + + def test_true_when_fixed_port_listening(self, monkeypatch): + """Returns True when a daemon owns the fixed port — even with NO port + file present (the dual-instance root cause the probe must catch).""" + import socket as _socket + + port = self._free_port() + monkeypatch.setattr("emrg.connect.EMRGD_PORT", port) + srv = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) + srv.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1) + srv.bind(("127.0.0.1", port)) + srv.listen(1) + try: + assert is_server_running_sync(timeout=1.0) is True + finally: + srv.close() + + def test_false_when_connection_refused(self, monkeypatch): + """Returns False when the fixed port is closed.""" + monkeypatch.setattr("emrg.connect.EMRGD_PORT", self._free_port()) assert is_server_running_sync(timeout=0.1) is False @@ -115,6 +138,8 @@ async def fake_connect(uri, **kwargs): asyncio.run(connect_mod.connect_to_server()) - assert captured["uri"] == "ws://127.0.0.1:49152" + # Fixed daemon port (rant 2026-08-19T08:05:21) — the URI no longer + # depends on the port file's port value, only the token is read from it. + assert captured["uri"] == f"ws://127.0.0.1:{connect_mod.EMRGD_PORT}" assert captured["kwargs"]["proxy"] is None assert captured["kwargs"]["max_size"] == 16 * 1024 * 1024 diff --git a/tests/test_daemon.py b/tests/test_daemon.py index bfc75739..332f0ded 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -9,6 +9,7 @@ import asyncio import json +import os import re import tempfile from datetime import datetime @@ -1494,52 +1495,87 @@ async def fake_run_once(state=None): assert reply["type"] == "update_check" -# ── rant 2026-08-18T12:49:09 ③:单 daemon 准入(进程名 + 端口活性探测)── -def test_serve_refuses_duplicate_when_daemon_alive(tmp_path): - """serve() must refuse to start when another emrgd is already listening. +# ── rant 2026-08-19T08:05:21:固定端口 bind 排斥 = 唯一单 daemon 准入 ── +def test_serve_refuses_duplicate_when_fixed_port_bound(tmp_path): + """serve() must exit when another daemon already owns the fixed port. - Multi-client (GUI + TUI, possibly different installs) stale-restart - sequences can leave the pid file missing while an old daemon is still - alive — the process-name admission (rant 2026-08-18T22:15:04) and the - port-file liveness probe catch this and exit cleanly instead of binding - a second port (observed: 4 emrg.server processes). + The fixed-port bind (EADDRINUSE) is the ONLY single-instance admission + (rant 2026-08-19T08:05:21): kernel-level resource exclusivity — no PID + file to forge/delete, no race window. A refused instance must not claim + the pid file nor reach the websockets bind. """ + import errno as _errno from unittest.mock import AsyncMock, patch server = _make_server() - with patch("emrg.server.daemon.config_dir", return_value=tmp_path), \ - patch("emrg.server.daemon._find_emrg_server_processes", return_value=[999]), \ - patch("emrg.server.daemon.is_server_running_sync", return_value=True), \ + + def _deny(port): + raise OSError(_errno.EADDRINUSE, "Address already in use") + + with patch("emrg.server.daemon._create_fixed_port_socket", side_effect=_deny), \ + patch("emrg.server.daemon.config_dir", return_value=tmp_path), \ patch("emrg.server.daemon.serve", new_callable=AsyncMock) as mock_serve: import asyncio asyncio.run(server.serve()) assert server._running is False, "duplicate daemon must not keep running" - mock_serve.assert_not_awaited(), "must not bind a second socket when one daemon is alive" - # no pid file written by the refused instance + mock_serve.assert_not_awaited(), "must not bind when the fixed port is taken" assert not (tmp_path / "emrgd.pid").exists(), "refused instance must not claim the pid file" -def test_serve_proceeds_when_no_live_daemon(tmp_path): - """Negative path: no live daemon on the port file → the admission probe - must NOT block startup (the flow reaches the pid-file section).""" - from unittest.mock import AsyncMock, patch +def test_serve_proceeds_when_fixed_port_free(tmp_path): + """Negative path: fixed port free → bind passes → pid diagnostic written + and the websockets serve is reached with the pre-bound socket.""" + import asyncio + from unittest.mock import MagicMock, patch server = _make_server() - with patch("emrg.server.daemon.config_dir", return_value=tmp_path), \ - patch("emrg.server.daemon._find_emrg_server_processes", return_value=[]), \ - patch("emrg.server.daemon.is_server_running_sync", return_value=False), \ + fake_sock = MagicMock() + + with patch("emrg.server.daemon._create_fixed_port_socket", return_value=fake_sock), \ + patch("emrg.server.daemon.config_dir", return_value=tmp_path), \ patch("emrg.server.daemon.serve", new_callable=AsyncMock, - side_effect=RuntimeError("abort after probe — not reached in this test")): - import asyncio - # Let the probe run but abort at the websockets bind via a side_effect - # on the module-level serve; the pid file write happens between the - # probe and the bind, proving the probe let us through. + side_effect=RuntimeError("abort after admission — bind already verified")): try: asyncio.run(server.serve()) except RuntimeError as e: - assert "abort after probe" in str(e), f"unexpected abort: {e}" + assert "abort after admission" in str(e), f"unexpected abort: {e}" + else: + raise AssertionError("expected the websockets serve abort (admission passed)") + assert (tmp_path / "emrgd.pid").exists(), "bind success must write the diagnostic pid file" + assert (tmp_path / "emrgd.pid").read_text(encoding="utf-8").strip() == str(os.getpid()) + + +def test_serve_rethrows_non_bind_errors(tmp_path): + """A non-EADDRINUSE socket error must propagate — only 'address in use' + means 'already running'.""" + import asyncio + import errno as _errno + from unittest.mock import patch + + server = _make_server() + + def _boom(port): + raise OSError(_errno.EACCES, "Permission denied") + + with patch("emrg.server.daemon._create_fixed_port_socket", side_effect=_boom), \ + patch("emrg.server.daemon.config_dir", return_value=tmp_path): + try: + asyncio.run(server.serve()) + except OSError as e: + assert e.errno == _errno.EACCES, f"unexpected errno: {e.errno}" else: - raise AssertionError("expected the websockets serve abort (probe passed)") - assert (tmp_path / "emrgd.pid").exists(), ( - "no-live-daemon probe must proceed to pid-file write (admission is liveness-based)") + raise AssertionError("expected OSError(EACCES) to propagate") + + +def test_assert_port_file_writes_fixed_port(tmp_path): + """_assert_port_file persists the FIXED port (56031) + auth token.""" + server = _make_server() + server._auth_token = "tok-123" + with patch("emrg.server.daemon.config_dir", return_value=tmp_path): + server._assert_port_file(56031) + port_file = tmp_path / "emrgd.port" + assert port_file.exists() + lines = port_file.read_text(encoding="utf-8").split() + assert lines[0] == "56031" + assert lines[1] == "tok-123" diff --git a/tests/test_ws_e2e.py b/tests/test_ws_e2e.py index 8561e872..97fac41a 100644 --- a/tests/test_ws_e2e.py +++ b/tests/test_ws_e2e.py @@ -80,6 +80,21 @@ async def _boot_server(tmp: Path): _orig_sched_cfg = sched_mod.config_dir _orig_connect_cfg = connect_mod.config_dir + # Fixed-port admission (rant 2026-08-19T08:05:21): the daemon now binds a + # fixed port, but tests must never fight a real daemon (or each other) on + # the well-known EMRGD_PORT — point BOTH the daemon's serve() and the + # connect layer at a free loopback port for the duration of the test. + import socket as _socket + + _probe = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) + _probe.bind(("127.0.0.1", 0)) + _test_port = _probe.getsockname()[1] + _probe.close() + _orig_daemon_port = daemon_mod.EMRGD_PORT + _orig_connect_port = connect_mod.EMRGD_PORT + daemon_mod.EMRGD_PORT = _test_port + connect_mod.EMRGD_PORT = _test_port + # Isolate config dir to tmp (port file, tasks.yml, projects.yml, etc.) daemon_mod.config_dir = lambda: tmp sched_mod.config_dir = lambda: tmp # scheduler builds its own projects_file (#738) @@ -113,6 +128,8 @@ async def _cleanup(): daemon_mod.config_dir = _orig_daemon_cfg sched_mod.config_dir = _orig_sched_cfg connect_mod.config_dir = _orig_connect_cfg + daemon_mod.EMRGD_PORT = _orig_daemon_port + connect_mod.EMRGD_PORT = _orig_connect_port return server, task, _cleanup From a0f9c9308492c76edcc863c39a2280edfdab4bb1 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Wed, 19 Aug 2026 08:31:57 +0800 Subject: [PATCH 2/3] emrg: fix missing AsyncMock/patch imports in fixed-port admission tests --- tests/test_daemon.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 332f0ded..d5fe34e3 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -1527,7 +1527,7 @@ def test_serve_proceeds_when_fixed_port_free(tmp_path): """Negative path: fixed port free → bind passes → pid diagnostic written and the websockets serve is reached with the pre-bound socket.""" import asyncio - from unittest.mock import MagicMock, patch + from unittest.mock import AsyncMock, MagicMock, patch server = _make_server() fake_sock = MagicMock() @@ -1570,6 +1570,8 @@ def _boom(port): def test_assert_port_file_writes_fixed_port(tmp_path): """_assert_port_file persists the FIXED port (56031) + auth token.""" + from unittest.mock import patch + server = _make_server() server._auth_token = "tok-123" with patch("emrg.server.daemon.config_dir", return_value=tmp_path): From 13d8419b5a52066cac1e18ef24fb1afda3ffa2b3 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Wed, 19 Aug 2026 08:36:57 +0800 Subject: [PATCH 3/3] emrg: Windows SO_EXCLUSIVEADDRUSE alone + TIME_WAIT listener-probe retry (SO_REUSEADDR is mutually exclusive, WSAEINVAL) --- Agent.md | 2 +- emrg/server/daemon.py | 70 ++++++++++++++++++++++++++++++++++++------- tests/test_daemon.py | 62 +++++++++++++++++++++++++++++++++++++- 3 files changed, 122 insertions(+), 12 deletions(-) diff --git a/Agent.md b/Agent.md index a0529bac..4b83e50c 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (965) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (967) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index f32aa9ef..60da7104 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -33,7 +33,7 @@ from emrg._win import win32_no_window_kwargs from emrg.config import LlmConfig, config_dir -from emrg.connect import EMRGD_PORT, cleanup_server +from emrg.connect import EMRGD_PORT, cleanup_server, is_server_running_sync from emrg.server.atomic import atomic_write_bytes, atomic_write_yaml from emrg.server.llm import LlmClient from emrg.server.git_utils import ( @@ -145,6 +145,14 @@ def _get_jinja_env() -> "jinja2.Environment": # ── Module-level constants ── EVOLUTION_CWD = Path.home() / ".emrg" / "evolution" +# Windows TIME_WAIT retry: SO_EXCLUSIVEADDRUSE (the only anti-hijack option on +# Windows) blocks rebinding while accepted connections linger in TIME_WAIT. +# serve() treats EADDRINUSE-with-no-listener as a TIME_WAIT remnant and retries +# the bind for up to this many attempts at this interval (~10s), so a crashed +# daemon restarts without a 30-120s stall. +_TIME_WAIT_RETRIES = 20 +_TIME_WAIT_RETRY_DELAY = 0.5 + def _create_fixed_port_socket(port: int) -> _socket.socket: """Create + bind the daemon's fixed loopback listening socket. @@ -154,14 +162,18 @@ def _create_fixed_port_socket(port: int) -> _socket.socket: (addr, port) with EADDRINUSE — pure resource exclusivity with no file to forge/delete (PID files were the unreliable mechanism), no race window, and automatic release when the process dies. Raises OSError(EADDRINUSE) - when another daemon already owns the port; the caller treats that as - "emrgd already running" and exits itself. + when another socket already owns the port; the caller treats a *live* + listener as "emrgd already running" and exits itself. Socket options: - - Windows: SO_EXCLUSIVEADDRUSE + SO_REUSEADDR together. SO_REUSEADDR alone - allows any socket to hijack the port; SO_EXCLUSIVEADDRUSE forbids that. - Together they still allow a fast restart over lingering TIME_WAIT sockets - (Windows would otherwise block rebinding for 30-120s after a crash). + - Windows: SO_EXCLUSIVEADDRUSE only. It forbids any other socket from + binding the same port (SO_REUSEADDR alone would allow port hijacking). + SO_EXCLUSIVEADDRUSE and SO_REUSEADDR are MUTUALLY EXCLUSIVE on Windows + (the second setsockopt fails with WSAEINVAL 10022 — verified on the + Windows CI matrix). The cost is that a closed listening socket with + accepted connections lingering in TIME_WAIT blocks rebinding; serve() + handles that with a listener-probe + bounded retry so a crashed daemon + still restarts quickly (rant acceptance: "无 TIME_WAIT 卡死"). - POSIX: SO_REUSEADDR only. It permits rebinding while TIME_WAIT sockets linger but does NOT allow two listeners on the same addr (that would be SO_REUSEPORT, which we deliberately never set) — exclusivity is kept. @@ -170,7 +182,6 @@ def _create_fixed_port_socket(port: int) -> _socket.socket: try: if sys.platform == "win32": sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_EXCLUSIVEADDRUSE, 1) - sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1) else: sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1) sock.bind(("127.0.0.1", port)) @@ -263,7 +274,14 @@ async def serve(self) -> None: try: sock = _create_fixed_port_socket(EMRGD_PORT) except OSError as exc: - if exc.errno == errno.EADDRINUSE: + if exc.errno != errno.EADDRINUSE: + raise + # The port is taken. Distinguish a LIVE daemon from a Windows + # TIME_WAIT remnant: only a live listener accepts connections. + # (Windows SO_EXCLUSIVEADDRUSE blocks rebinding while accepted + # connections linger in TIME_WAIT — SO_REUSEADDR cannot be combined + # with it, WSAEINVAL 10022; POSIX SO_REUSEADDR never hits this.) + if is_server_running_sync(timeout=0.5): logger.error( "emrgd already running on 127.0.0.1:%d (EADDRINUSE, " "fixed-port admission) — new instance exiting itself. " @@ -272,7 +290,39 @@ async def serve(self) -> None: ) self._running = False return - raise + # No listener behind the port → TIME_WAIT remnant. Retry the bind + # for a bounded window so a crashed daemon restarts without a + # 30-120s stall (rant acceptance: "无 TIME_WAIT 卡死"). + logger.warning( + "port 127.0.0.1:%d busy but no daemon listening — " + "TIME_WAIT remnant, retrying bind (%d x %.1fs)", + EMRGD_PORT, _TIME_WAIT_RETRIES, _TIME_WAIT_RETRY_DELAY, + ) + for _ in range(_TIME_WAIT_RETRIES): + await asyncio.sleep(_TIME_WAIT_RETRY_DELAY) + try: + sock = _create_fixed_port_socket(EMRGD_PORT) + break + except OSError as retry_exc: + if retry_exc.errno != errno.EADDRINUSE: + raise + if is_server_running_sync(timeout=0.5): + logger.error( + "emrgd already running on 127.0.0.1:%d (became " + "live during TIME_WAIT retry) — new instance " + "exiting itself.", + EMRGD_PORT, + ) + self._running = False + return + else: + logger.error( + "port 127.0.0.1:%d busy (TIME_WAIT) but no daemon " + "listening after %d retries — giving up.", + EMRGD_PORT, _TIME_WAIT_RETRIES, + ) + self._running = False + return # ── PID file: diagnostics only (rant 08-05:21 — no longer an # admission gate). Written AFTER the fixed-port bind succeeded, so only diff --git a/tests/test_daemon.py b/tests/test_daemon.py index d5fe34e3..566e4201 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -1497,7 +1497,7 @@ async def fake_run_once(state=None): # ── rant 2026-08-19T08:05:21:固定端口 bind 排斥 = 唯一单 daemon 准入 ── def test_serve_refuses_duplicate_when_fixed_port_bound(tmp_path): - """serve() must exit when another daemon already owns the fixed port. + """serve() must exit when another LIVE daemon owns the fixed port. The fixed-port bind (EADDRINUSE) is the ONLY single-instance admission (rant 2026-08-19T08:05:21): kernel-level resource exclusivity — no PID @@ -1513,6 +1513,7 @@ def _deny(port): raise OSError(_errno.EADDRINUSE, "Address already in use") with patch("emrg.server.daemon._create_fixed_port_socket", side_effect=_deny), \ + patch("emrg.server.daemon.is_server_running_sync", return_value=True), \ patch("emrg.server.daemon.config_dir", return_value=tmp_path), \ patch("emrg.server.daemon.serve", new_callable=AsyncMock) as mock_serve: import asyncio @@ -1546,6 +1547,65 @@ def test_serve_proceeds_when_fixed_port_free(tmp_path): assert (tmp_path / "emrgd.pid").read_text(encoding="utf-8").strip() == str(os.getpid()) +def test_serve_timewait_retry_recovers_bind(tmp_path): + """EADDRINUSE with NO live listener = TIME_WAIT remnant (Windows + SO_EXCLUSIVEADDRUSE) → serve() retries the bind and recovers.""" + import asyncio + import errno as _errno + from unittest.mock import AsyncMock, MagicMock, patch + + server = _make_server() + fake_sock = MagicMock() + bind_calls = {"n": 0} + + def _flaky(port): + bind_calls["n"] += 1 + if bind_calls["n"] == 1: + raise OSError(_errno.EADDRINUSE, "Address already in use") + return fake_sock + + with patch("emrg.server.daemon._create_fixed_port_socket", side_effect=_flaky), \ + patch("emrg.server.daemon.is_server_running_sync", return_value=False), \ + patch("emrg.server.daemon._TIME_WAIT_RETRIES", 3), \ + patch("emrg.server.daemon._TIME_WAIT_RETRY_DELAY", 0.01), \ + patch("emrg.server.daemon.config_dir", return_value=tmp_path), \ + patch("emrg.server.daemon.serve", new_callable=AsyncMock, + side_effect=RuntimeError("abort after retry recovered the bind")): + try: + asyncio.run(server.serve()) + except RuntimeError as e: + assert "abort after retry" in str(e), f"unexpected abort: {e}" + else: + raise AssertionError("expected the websockets serve abort (retry recovered)") + assert bind_calls["n"] == 2, f"expected 2 bind attempts, got {bind_calls['n']}" + assert (tmp_path / "emrgd.pid").exists(), "recovered bind must write the diagnostic pid file" + + +def test_serve_timewait_retry_exhausted(tmp_path): + """EADDRINUSE with no listener that never clears → serve() gives up + gracefully (no pid claim, no websockets bind).""" + import asyncio + import errno as _errno + from unittest.mock import AsyncMock, patch + + server = _make_server() + + def _always_busy(port): + raise OSError(_errno.EADDRINUSE, "Address already in use") + + with patch("emrg.server.daemon._create_fixed_port_socket", side_effect=_always_busy), \ + patch("emrg.server.daemon.is_server_running_sync", return_value=False), \ + patch("emrg.server.daemon._TIME_WAIT_RETRIES", 2), \ + patch("emrg.server.daemon._TIME_WAIT_RETRY_DELAY", 0.01), \ + patch("emrg.server.daemon.config_dir", return_value=tmp_path), \ + patch("emrg.server.daemon.serve", new_callable=AsyncMock) as mock_serve: + asyncio.run(server.serve()) + + assert server._running is False, "must give up after retries" + mock_serve.assert_not_awaited() + assert not (tmp_path / "emrgd.pid").exists(), "failed instance must not claim the pid file" + + def test_serve_rethrows_non_bind_errors(tmp_path): """A non-EADDRINUSE socket error must propagate — only 'address in use' means 'already running'."""