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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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