Skip to content
Closed
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
21 changes: 20 additions & 1 deletion emrg/client/daemon_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,24 @@ def is_running() -> bool:
return is_server_running_sync()


def _tail_daemon_log(log_path: Path | None = None, max_lines: int = 20) -> str:
"""Return the tail of ~/.emrg/emrgd.log (best-effort).

Used when the daemon fails to start within the timeout: stderr is
DEVNULL, so the RotatingFileHandler log (~/.emrg/emrgd.log) is the only
place the real failure reason survives (rant 2026-08-05T15:54:28 关联项 —
config.toml 解析错误等曾只显示 "failed to start within timeout").
"""
try:
log_file = log_path or (Path.home() / ".emrg" / "emrgd.log")
if not log_file.exists():
return "no emrgd.log"
lines = log_file.read_text(encoding="utf-8", errors="replace").splitlines()
return " | ".join(lines[-max_lines:]) or "empty emrgd.log"
except OSError as e:
return f"cannot read emrgd.log: {e}"


async def start_daemon() -> subprocess.Popen:
"""Start emrgd in the background and wait until it accepts connections."""
logger.info("starting emrgd daemon...")
Expand All @@ -80,7 +98,8 @@ async def start_daemon() -> subprocess.Popen:
if is_running():
logger.info("emrgd started (pid=%d)", proc.pid)
return proc
raise RuntimeError("emrgd failed to start within timeout")
# R124: 附加 daemon 日志尾部真实原因(stderr 被 DEVNULL 丢弃,日志是唯一线索)
raise RuntimeError(f"emrgd failed to start within timeout: {_tail_daemon_log()}")


async def check_and_restart_if_stale() -> None:
Expand Down
40 changes: 40 additions & 0 deletions tests/test_daemon_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,46 @@ async def _run():

asyncio.run(_run())

@patch("emrg.client.daemon_manager.is_running", return_value=False)
@patch("emrg.client.daemon_manager.cleanup_server")
@patch("emrg.client.daemon_manager.asyncio.create_subprocess_exec",
new_callable=AsyncMock)
def test_timeout_appends_log_tail(self, mock_spawn, mock_cleanup, mock_is_running):
"""R124: 超时错误附带 emrgd.log 尾部真实原因(stderr 被 DEVNULL 丢弃)。"""
tmp = Path(tempfile.mkdtemp())
emrg_dir = tmp / ".emrg"
emrg_dir.mkdir()
(emrg_dir / "emrgd.log").write_text(
"12:00:01 [ERROR] emrg.config: vision = trues 解析失败\n"
"12:00:02 [ERROR] emrg.server: config load failed\n",
encoding="utf-8",
)
mock_spawn.return_value = MagicMock(pid=1234)

async def _run():
with pytest.raises(RuntimeError) as exc_info:
await daemon_manager.start_daemon()
assert "failed to start" in str(exc_info.value)
assert "vision = trues" in str(exc_info.value)
assert "config load failed" in str(exc_info.value)

with patch("emrg.client.daemon_manager.Path.home",
return_value=tmp):
asyncio.run(_run())

def test_tail_daemon_log_no_file(self):
tmp = Path(tempfile.mkdtemp())
result = daemon_manager._tail_daemon_log(tmp / "missing.log")
assert result == "no emrgd.log"

def test_tail_daemon_log_reads_tail(self):
tmp = Path(tempfile.mkdtemp())
log = tmp / "emrgd.log"
log.write_text("\n".join(f"line {i}" for i in range(30)), encoding="utf-8")
result = daemon_manager._tail_daemon_log(log, max_lines=5)
assert "line 29" in result
assert "line 0" not in result # 只读尾部


# ── check_and_restart_if_stale ───────────────────────────────

Expand Down
Loading