From 004136056fef40e46784d00691123ba13a2eaa33 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Wed, 5 Aug 2026 16:28:00 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20start=5Fdaemon=20=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E9=99=84=E5=8A=A0=20emrgd.log=20=E5=B0=BE=E9=83=A8=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E5=8E=9F=E5=9B=A0=EF=BC=88R124=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- emrg/client/daemon_manager.py | 21 +++++++++++++++++- tests/test_daemon_manager.py | 40 +++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/emrg/client/daemon_manager.py b/emrg/client/daemon_manager.py index 7dcfa642..6d81b575 100644 --- a/emrg/client/daemon_manager.py +++ b/emrg/client/daemon_manager.py @@ -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...") @@ -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: diff --git a/tests/test_daemon_manager.py b/tests/test_daemon_manager.py index cfc625d3..a94cf864 100644 --- a/tests/test_daemon_manager.py +++ b/tests/test_daemon_manager.py @@ -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 ───────────────────────────────