Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` (934) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (938) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (258: 45 daemon_client + 19 conn-manager + 22 app-commands + 129 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 路径不受影响)
Expand Down
38 changes: 32 additions & 6 deletions emrg/client/daemon_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,25 +168,51 @@ async def check_and_restart_if_stale() -> None:
logger.info(
"%s, restarting (old pid=%d)", restart_reason, server_pid,
)
# Kill old server: SIGTERM first, SIGKILL if still alive
# Kill old server: SIGTERM first, SIGKILL if still alive.
# ⚠️ (rant 2026-08-18T12:49:09 ②) The old daemon must be TRULY
# dead before the port file is removed and a new daemon spawns.
# Previously cleanup_server() deleted the port file BEFORE the
# wait, so is_running() (a port-file probe) returned False
# instantly and a new daemon spawned while the old one was still
# shutting down → multiple emrg.server instances on different
# ports. Wait on the old PID itself (POSIX os.kill(pid,0) probe),
# then remove the port file only after it is gone.
try:
os.kill(server_pid, signal.SIGTERM)
except (ProcessLookupError, OSError):
pass
cleanup_server()
# Wait for old server to die
for _ in range(10):

def _old_pid_alive() -> bool:
if sys.platform == "win32":
# os.kill(pid, 0) would TerminateProcess on Windows —
# never use it as a liveness probe. Windows SIGTERM is
# an immediate hard kill, so the port probe suffices.
return is_running()
try:
os.kill(server_pid, 0)
return True
except ProcessLookupError:
return False
except OSError:
return True # EPERM → process exists

for _ in range(50): # up to 10s for graceful shutdown
await asyncio.sleep(0.2)
if not is_running():
if not _old_pid_alive():
break
else:
# SIGTERM didn't work — force kill
logger.warning("old daemon (pid=%d) didn't die, sending SIGKILL", server_pid)
try:
os.kill(server_pid, signal.SIGKILL)
await asyncio.sleep(0.3)
except (ProcessLookupError, OSError):
pass
for _ in range(10): # up to 2s for SIGKILL to land
await asyncio.sleep(0.2)
if not _old_pid_alive():
break
# Old daemon is gone — now safe to remove its port file
cleanup_server()
except (ConnectionRefusedError, FileNotFoundError, OSError, json.JSONDecodeError,
asyncio.TimeoutError, ConnectionClosed):
# G129 (rant 2026-08-09T08:03:46): only genuinely transient connection
Expand Down
23 changes: 22 additions & 1 deletion emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@

from emrg._win import win32_no_window_kwargs
from emrg.config import LlmConfig, config_dir
from emrg.connect import cleanup_server
from emrg.connect import 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 (
Expand Down Expand Up @@ -213,6 +213,27 @@ async def serve(self) -> None:
# ── PID file: prevent duplicate daemon instances ───
runtime_dir = config_dir()
pid_file = runtime_dir / "emrgd.pid"

# ── Single-instance admission: 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 first — if a
# live daemon already answers, do NOT start a duplicate.
try:
if is_server_running_sync(timeout=1.0):
logger.error(
"another emrgd instance is already listening (port file %s) — "
"refusing to start a duplicate (single-instance admission)",
runtime_dir / "emrgd.port",
)
self._running = False
return
except Exception:
logger.debug("single-instance port probe failed — continuing startup", exc_info=True)

try:
# Atomic create — fails if file already exists
fd = os.open(pid_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
Expand Down
48 changes: 48 additions & 0 deletions tests/test_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -1492,3 +1492,51 @@ async def fake_run_once(state=None):
assert calls == [], "no force → must return cache without a fresh fetch"
reply = json.loads(writer._frames[-1])
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.

Multi-client (GUI + TUI, possibly different installs) stale-restart
sequences can leave the pid file missing while an old daemon is still
alive — the port-file liveness probe catches this and exits cleanly
instead of binding a second port (observed: 4 emrg.server processes).
"""
from unittest.mock import AsyncMock, patch

server = _make_server()
with patch("emrg.server.daemon.config_dir", return_value=tmp_path), \
patch("emrg.server.daemon.is_server_running_sync", return_value=True), \
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
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

server = _make_server()
with patch("emrg.server.daemon.config_dir", return_value=tmp_path), \
patch("emrg.server.daemon.is_server_running_sync", return_value=False), \
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.
try:
asyncio.run(server.serve())
except RuntimeError as e:
assert "abort after probe" in str(e), f"unexpected abort: {e}"
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)")
67 changes: 67 additions & 0 deletions tests/test_daemon_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import asyncio
import json
import signal
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
Expand Down Expand Up @@ -155,12 +156,78 @@ def test_source_newer_triggers_restart(self, mock_connect, mock_kill,
# started_at in the past → source mtime (1e12) > server_start
mock_connect.return_value = FakeWS([_ping_pong_frame()])

def fake_kill(pid, sig):
# liveness probe (sig=0) → old daemon already dead → no wait
if sig == 0:
raise ProcessLookupError(pid)

mock_kill.side_effect = fake_kill

with patch("emrg.client.daemon_manager.get_server_path",
return_value=str(port_file)):
asyncio.run(daemon_manager.check_and_restart_if_stale())
# SIGTERM sent to pid 9999
kill_calls = [c.args for c in mock_kill.call_args_list]
assert any(9999 in call and call[0] == 9999 for call in kill_calls)
# rant 12:49:09 ②:port file cleanup happens only AFTER old pid confirmed dead
assert mock_cleanup.called

@patch("emrg.client.daemon_manager._get_config_mtime", return_value=0.0)
@patch("emrg.client.daemon_manager._get_server_source_mtime", return_value=1e12)
@patch("emrg.client.daemon_manager.is_running", return_value=True)
@patch("emrg.client.daemon_manager.cleanup_server")
@patch("emrg.client.daemon_manager.os.kill")
@patch("emrg.client.daemon_manager.connect_to_server", new_callable=AsyncMock)
def test_restart_waits_until_old_pid_dead_before_cleanup(
self, mock_connect, mock_kill, mock_cleanup, mock_running,
mock_src, mock_cfg, tmp_path):
"""rant 12:49:09 ② — old daemon takes ~0.4s to die: cleanup_server()
must NOT run while the old pid is still alive (multi-instance guard)."""
port_file = tmp_path / "emrgd.port"
port_file.write_text("12345\ntoken\n")
mock_connect.return_value = FakeWS([_ping_pong_frame()])

probe_calls = {"n": 0}

def fake_kill(pid, sig):
if sig == 0:
probe_calls["n"] += 1
if probe_calls["n"] < 3:
return # still alive (no exception = process exists)
raise ProcessLookupError(pid) # dies on 3rd probe

mock_kill.side_effect = fake_kill

with patch("emrg.client.daemon_manager.get_server_path",
return_value=str(port_file)):
asyncio.run(daemon_manager.check_and_restart_if_stale())
# waited ≥2 probe rounds (old pid alive → no cleanup yet), then cleanup after death
assert probe_calls["n"] >= 3, f"should probe liveness ≥3 times, got {probe_calls['n']}"
assert mock_cleanup.called

@patch("emrg.client.daemon_manager._get_config_mtime", return_value=0.0)
@patch("emrg.client.daemon_manager._get_server_source_mtime", return_value=1e12)
@patch("emrg.client.daemon_manager.is_running", return_value=True)
@patch("emrg.client.daemon_manager.cleanup_server")
@patch("emrg.client.daemon_manager.os.kill")
@patch("emrg.client.daemon_manager.connect_to_server", new_callable=AsyncMock)
def test_restart_force_kills_stuck_old_pid(
self, mock_connect, mock_kill, mock_cleanup, mock_running,
mock_src, mock_cfg, tmp_path):
"""rant 12:49:09 ② — old daemon never dies on SIGTERM → SIGKILL fallback,
and cleanup still happens after the kill."""
port_file = tmp_path / "emrgd.port"
port_file.write_text("12345\ntoken\n")
mock_connect.return_value = FakeWS([_ping_pong_frame()])
mock_kill.side_effect = lambda pid, sig: None # pid stays "alive" forever

with patch("emrg.client.daemon_manager.get_server_path",
return_value=str(port_file)):
asyncio.run(daemon_manager.check_and_restart_if_stale())
kill_calls = [c.args for c in mock_kill.call_args_list]
assert any(c[1] == signal.SIGKILL for c in kill_calls), (
"stuck old pid must be SIGKILLed after the SIGTERM grace window")
assert mock_cleanup.called

@patch("emrg.client.daemon_manager._get_config_mtime", return_value=0.0)
@patch("emrg.client.daemon_manager._get_server_source_mtime", return_value=0.0)
Expand Down
Loading