From 37acdc092ea1cc5001474292e984c59f8fb7e044 Mon Sep 17 00:00:00 2001 From: argszero Date: Sun, 9 Aug 2026 16:48:12 +0800 Subject: [PATCH 1/2] emrg: fix Windows cmd-window storm + daemon spawn throttle (v0.2.15 regression hotfix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rant 2026-08-09T13:16:36 (emergency, highest priority): Windows v0.2.15 host observed continuous cmd popups (had to reboot) + daemon startup failure leaving GUI/scheduler unable to connect. Root causes: 1. Zero CREATE_NO_WINDOW anywhere in the codebase — every subprocess spawn (git/gh/bash tool/scheduler/daemon spawn) popped a console window on Windows. 2. GUI reconnect loop respawned emrgd.cmd every ~5s forever; each spawn raced the previous daemon's startup (G43 stale-port unlink deleted a healthy daemon's port file → its scheduler logged 'cannot connect' 93 times while the GUI kept spawning). 3. Scheduler had no connect-failure backoff. Fixes: - NEW emrg/_win.py: win32_no_window_kwargs() = {creationflags: CREATE_NO_WINDOW} on Windows, {} elsewhere. Splatted into all 34 subprocess call sites (bash_tool, scheduler x12, daemon x5, git_utils, daemon_manager, installer, __main__ x3, client/app x9). - GUI daemon_client.js: spawn throttle (max 3 attempts per connect lifecycle, then throw with emrgd.log tail instead of respawning); reset counter on successful auth. Both spawn-timeout errors now surface the real emrgd.log tail (readLogTail). - GUI main.js: reconnect exponential backoff 1s→2s→4s→…cap 60s; daemon_stopped status surfaces the real failure to the renderer (zh/en i18n) instead of infinite 5s respawns. - scheduler.py: connect-failure exponential backoff max(30s, interval*2^n) capped at 10 min — no more per-tick retry storm while the daemon is down. Tests: +6 Python (win32_no_window_kwargs POSIX/Windows/splat; backoff zero/exp/cap/floor; 641→647), +2 GUI (spawn throttle + counter reset; 96→98). Docs synced (#511 guard). macOS/Linux unaffected (win32 branch is a no-op empty dict). --- Agent.md | 6 +-- README.cn.md | 4 +- README.md | 4 +- emrg/__main__.py | 6 +++ emrg/_win.py | 39 +++++++++++++++++++ emrg/client/app.py | 18 +++++++-- emrg/client/daemon_manager.py | 6 ++- emrg/gui/daemon_client.js | 32 +++++++++++++++- emrg/gui/main.js | 12 ++++++ emrg/gui/renderer/js/app.js | 5 +++ emrg/gui/renderer/js/i18n.js | 2 + emrg/gui/test/daemon_client.test.js | 46 +++++++++++++++++++++++ emrg/server/daemon.py | 6 +++ emrg/server/git_utils.py | 3 ++ emrg/server/scheduler.py | 34 ++++++++++++++++- emrg/skills/installer.py | 5 +++ emrg/tools/bash_tool.py | 5 +++ tests/test_scheduler.py | 51 +++++++++++++++++++++++++ tests/test_win.py | 58 +++++++++++++++++++++++++++++ 19 files changed, 327 insertions(+), 15 deletions(-) create mode 100644 emrg/_win.py create mode 100644 tests/test_win.py diff --git a/Agent.md b/Agent.md index 67e761d6..49f9f4aa 100644 --- a/Agent.md +++ b/Agent.md @@ -66,7 +66,7 @@ EMRG is a self-evolving AI agent architecture experiment. Python implementation, - Streaming chat with delta rendering (16ms batching), markdown on done (marked + DOMPurify + local highlight.js subset), tool call status cards (2000-char truncation + expand) - Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端" - Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout) - - Unit tests `npm test` (96: 22 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (98: 24 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - **Auto project tracking** — Automatically detects and records working directories; project-scoped sessions - **Rant-driven evolution** — User feedback via `/rant` drives automatic self-improvement cycles - **Headless GitHub auth** — Non-interactive evolution auto-extracts `GH_TOKEN` from git credential store (osxkeychain / credential helper); PR comment/LGTM queries fall back to REST API (GraphQL needs `read:org` scope) @@ -93,8 +93,8 @@ 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` (641) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (96: 22 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +Python: `uv run pytest tests/ -v` (647) — import check: `uv run python -c "from emrg.client.app import run_client"` +GUI: `cd emrg/gui && npm test` (98: 24 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/README.cn.md b/README.cn.md index 072198a6..666fdeae 100644 --- a/README.cn.md +++ b/README.cn.md @@ -274,7 +274,7 @@ EMRG 不只是追赶——它自己追上来。 git clone https://github.com/argszero/emrg.git cd emrg uv sync # 安装依赖 -uv run pytest tests/ -v # 跑测试(当前 641 项) +uv run pytest tests/ -v # 跑测试(当前 647 项) uv run python -m emrg # 启动 TUI # CI 含 actionlint workflow 门禁(#444):workflow 解析错误在 PR 即失败 @@ -282,7 +282,7 @@ uv run python -m emrg # 启动 TUI cd emrg/gui npm ci # 安装依赖(生产模式可 --omit=dev) npm start # 启动 GUI(自动拉起 daemon) -npm test # 运行 Node 测试(96 项:22 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands;集成测试在 CI 跑,本地可 npm run test:integration) +npm test # 运行 Node 测试(98 项:24 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands;集成测试在 CI 跑,本地可 npm run test:integration) ``` CI 通过 GitHub Actions 自动运行测试并检查冲突标记(`.github/workflows/test.yml`)。 diff --git a/README.md b/README.md index 724528e9..4603c7c2 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ EMRG doesn't just keep up — it catches up on its own. git clone https://github.com/argszero/emrg.git cd emrg uv sync # install deps -uv run pytest tests/ -v # run tests (currently 641 items) +uv run pytest tests/ -v # run tests (currently 647 items) uv run python -m emrg # launch TUI # CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI @@ -281,7 +281,7 @@ uv run python -m emrg # launch TUI cd emrg/gui npm ci # install deps (production: --omit=dev) npm start # launch GUI (auto-starts daemon) -npm test # run Node tests (96: 22 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands; integration runs in CI, local: npm run test:integration) +npm test # run Node tests (98: 24 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands; integration runs in CI, local: npm run test:integration) ``` CI runs tests and checks for conflict markers automatically via GitHub Actions (`.github/workflows/test.yml`). diff --git a/emrg/__main__.py b/emrg/__main__.py index dd6b6753..8c3c4584 100644 --- a/emrg/__main__.py +++ b/emrg/__main__.py @@ -23,6 +23,7 @@ from pathlib import Path from emrg import __version__ +from emrg._win import win32_no_window_kwargs from emrg.connect import cleanup_server, connect_to_server from websockets.exceptions import ConnectionClosed @@ -126,6 +127,9 @@ def _start_daemon_background() -> subprocess.Popen: stdin=subprocess.DEVNULL, start_new_session=True, close_fds=True, + # Windows: background daemon spawn must not pop a console window + # (rant 2026-08-09T13:16:36 — cmd-window storm). + **win32_no_window_kwargs(), ) return proc @@ -341,6 +345,7 @@ def _run_update() -> None: text=True, encoding="utf-8", timeout=10, + **win32_no_window_kwargs(), ) if result.returncode != 0: print(f"git pull failed:\n{result.stderr}", file=sys.stderr) @@ -357,6 +362,7 @@ def _run_update() -> None: capture_output=True, text=True, encoding="utf-8", + **win32_no_window_kwargs(), ) if result.returncode != 0: print(f"reinstall failed:\n{result.stderr}", file=sys.stderr) diff --git a/emrg/_win.py b/emrg/_win.py new file mode 100644 index 00000000..baf98fb1 --- /dev/null +++ b/emrg/_win.py @@ -0,0 +1,39 @@ +"""Windows windowless subprocess infrastructure. + +Rant 2026-08-09T13:16:36 (v0.2.15 Windows regression, emergency): the daemon +is a non-interactive background process — every subprocess.Popen / +asyncio.create_subprocess_* without CREATE_NO_WINDOW pops a console window +on Windows. GUI/scheduler retry loops turned that into a cmd-window storm +(host observed hundreds of popups, had to reboot). All Python subprocess +call sites must splat the kwargs from :func:`win32_no_window_kwargs`; the +GUI side uses Node's ``windowsHide: true`` (already present in main.js / +daemon_client.js). + +The function is a no-op on POSIX (empty dict) so call sites stay portable. +""" + +from __future__ import annotations + +import os +import subprocess + +_IS_WINDOWS = os.name == "nt" + +# CREATE_NO_WINDOW (0x08000000) is Windows-only — subprocess exposes it only +# on win32 builds. getattr keeps the module importable and the function +# callable on POSIX (e.g. tests that force the Windows branch on a POSIX +# runner); the literal is the documented Win32 constant. +_CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000) + + +def win32_no_window_kwargs() -> dict: + """Kwargs that suppress console windows for subprocess children. + + Returns ``{"creationflags": subprocess.CREATE_NO_WINDOW}`` on Windows + and ``{}`` elsewhere — safe to ``**``-splat into ``subprocess.run`` / + ``subprocess.Popen`` and ``asyncio.create_subprocess_*`` on every + platform. + """ + if _IS_WINDOWS: + return {"creationflags": _CREATE_NO_WINDOW} + return {} diff --git a/emrg/client/app.py b/emrg/client/app.py index b7511716..c469536f 100644 --- a/emrg/client/app.py +++ b/emrg/client/app.py @@ -11,6 +11,7 @@ fcntl = None from datetime import datetime from pathlib import Path, PurePath +from emrg._win import win32_no_window_kwargs from emrg.client import daemon_manager from emrg.client.python_tui import ChatRow, Diff, InputParser, StatusLine, Terminal, ToolCard from emrg.client.python_tui.widgets.markdown import StreamingMarkdown @@ -39,6 +40,7 @@ def _detect_clipboard_image() -> tuple[bool, str | None]: result = subprocess.run( ['osascript', '-e', 'clipboard info'], capture_output=True, text=True, timeout=3, + **win32_no_window_kwargs(), ) out = result.stdout has_image = any(tag in out for tag in ( @@ -55,7 +57,8 @@ def _detect_clipboard_image() -> tuple[bool, str | None]: 'try\n set f to (the clipboard as «class furl»)\n' ' return POSIX path of f\nend try'], capture_output=True, text=True, timeout=2, - ) + **win32_no_window_kwargs(), + ) if r2.stdout.strip(): label = Path(r2.stdout.strip()).name except Exception: @@ -66,6 +69,7 @@ def _detect_clipboard_image() -> tuple[bool, str | None]: result = subprocess.run( ['xclip', '-selection', 'clipboard', '-t', 'TARGETS', '-o'], capture_output=True, text=True, timeout=3, + **win32_no_window_kwargs(), ) out = result.stdout if 'image/png' not in out: @@ -78,7 +82,8 @@ def _detect_clipboard_image() -> tuple[bool, str | None]: ['xclip', '-selection', 'clipboard', '-t', 'text/uri-list', '-o'], capture_output=True, text=True, timeout=2, - ) + **win32_no_window_kwargs(), + ) uri = r2.stdout.strip() if uri: label = Path(uri.replace('file://', '')).name @@ -95,6 +100,7 @@ def _detect_clipboard_image() -> tuple[bool, str | None]: result = subprocess.run( ['powershell', '-Command', ps_cmd], capture_output=True, text=True, timeout=5, + **win32_no_window_kwargs(), ) if 'IMAGE' not in result.stdout: return False, None @@ -108,7 +114,8 @@ def _detect_clipboard_image() -> tuple[bool, str | None]: 'if ($files -ne $null -and $files.Count -gt 0) ' '{ Write-Output $files[0] }'], capture_output=True, text=True, timeout=3, - ) + **win32_no_window_kwargs(), + ) if r2.stdout.strip(): label = Path(r2.stdout.strip()).name except Exception: @@ -139,6 +146,7 @@ def _extract_clipboard_image(target_path: str) -> bool: subprocess.run( ['osascript', '-e', applescript], capture_output=True, timeout=5, + **win32_no_window_kwargs(), ) path = Path(target_path) return path.exists() and path.stat().st_size > 0 @@ -149,7 +157,8 @@ def _extract_clipboard_image(target_path: str) -> bool: ['xclip', '-selection', 'clipboard', '-t', 'image/png', '-o'], stdout=f, timeout=5, - ) + **win32_no_window_kwargs(), + ) path = Path(target_path) return path.exists() and path.stat().st_size > 0 @@ -164,6 +173,7 @@ def _extract_clipboard_image(target_path: str) -> bool: subprocess.run( ['powershell', '-Command', ps_cmd], capture_output=True, timeout=5, + **win32_no_window_kwargs(), ) path = Path(target_path) return path.exists() and path.stat().st_size > 0 diff --git a/emrg/client/daemon_manager.py b/emrg/client/daemon_manager.py index 6bf72b4f..4dd6579b 100644 --- a/emrg/client/daemon_manager.py +++ b/emrg/client/daemon_manager.py @@ -21,6 +21,7 @@ from pathlib import Path from typing import AsyncIterator +from emrg._win import win32_no_window_kwargs from emrg.connect import ( AuthError, cleanup_server, @@ -76,7 +77,10 @@ async def start_daemon() -> subprocess.Popen: proc = await asyncio.create_subprocess_exec( sys.executable, "-m", "emrg.server", stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, - start_new_session=True, close_fds=True) + start_new_session=True, close_fds=True, + # Windows: daemon spawn must never pop a console window + # (rant 2026-08-09T13:16:36 — cmd-window storm). + **win32_no_window_kwargs()) for _ in range(15): await asyncio.sleep(0.3) if is_running(): diff --git a/emrg/gui/daemon_client.js b/emrg/gui/daemon_client.js index cbcedcc1..d6e1b2cb 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -24,11 +24,16 @@ const WebSocket = require("ws"); // 真实的 ~/.emrg/emrgd.port → 演化周期 10 小时连不上 daemon(WinError 1225)。 // 所有调用点必须传 this.projectDir(默认 os.homedir() 保持生产行为不变)。 const PORT_FILE = (projectDir = os.homedir()) => path.join(projectDir, ".emrg", "emrgd.port"); +const EMRGD_LOG = (projectDir = os.homedir()) => path.join(projectDir, ".emrg", "emrgd.log"); const MAX_PAYLOAD = 16 * 1024 * 1024; // G62/G105:16MB 双向一致(工具输出上限 200KB) const AUTH_TIMEOUT_MS = 10_000; const SPAWN_WAIT_MS = 5_000; const PENDING_TIMEOUT_MS = 5_000; const STREAM_END_TIMEOUT_MS = 30_000; // G94:最后帧后 30s 无 done 强制结束 +// Rant 2026-08-09T13:16:36 ⑤(防风暴总闸):单个"连接生命周期"内最多 spawn +// MAX_SPAWN_ATTEMPTS 次 daemon——之后不再拉起,只把真实错误(含 emrgd.log 尾部) +// 抛给上层,杜绝 GUI 每 5s 反复 spawn(每次 spawn 都是一个新的 cmd 窗口来源)。 +const MAX_SPAWN_ATTEMPTS = 3; const SESSION_ID_RE = /^s_\d{6}_\d{4}_[0-9a-f]{4,8}$/; @@ -72,6 +77,7 @@ class DaemonClient { this._authFailed = false; this._reconnectTimer = null; this._stopReconnect = false; + this._spawnAttempts = 0; // 连接生命周期内 spawn 计数(成功 auth 后归零) } // ── 生命周期 ──────────────────────────────────────────── @@ -91,7 +97,28 @@ class DaemonClient { } } + _readLogTail(lines = 15) { + // R124 对应(daemon_manager.py):spawn 超时后读 emrgd.log 尾部, + // 让宿主看到真实失败原因(缺 DLL / PATH / 端口冲突),而不是干巴巴的 + // "failed to start within timeout"(rant 2026-08-09T13:16:36 验收项 ②)。 + try { + const data = fs.readFileSync(EMRGD_LOG(this.projectDir), "utf8"); + const tail = data.trim().split("\n").slice(-lines).join("\n"); + return tail ? `\n emrgd.log tail:\n${tail}` : ""; + } catch { + return ""; + } + } + async startDaemon() { + // Rant 2026-08-09T13:16:36 ⑤:spawn 节流——超过上限不再拉起(防窗口/重试风暴)。 + if (this._spawnAttempts >= MAX_SPAWN_ATTEMPTS) { + throw new Error( + `daemon failed to start after ${MAX_SPAWN_ATTEMPTS} attempts — ` + + `please start it manually ('emrg server') and check emrgd.log${this._readLogTail()}` + ); + } + this._spawnAttempts += 1; // Phase 4(rant #12 §4):打包模式直接 spawn 捆绑 emrgd 可执行文件(脚本内部 // exec python -m emrg.server);源码模式保持 python -m emrg.server。 if (this._isPackaged) { @@ -116,7 +143,7 @@ class DaemonClient { if (await this.isRunning(500)) return child; await new Promise((r) => setTimeout(r, 300)); } - throw new Error("emrgd failed to start within timeout"); + throw new Error(`emrgd failed to start within timeout${this._readLogTail()}`); } // G125:spawn 设 cwd=project_dir(daemon load_skills 用 Path.cwd() 加载项目级 skills) const python = this._findPython(); @@ -138,7 +165,7 @@ class DaemonClient { if (await this.isRunning(500)) return child; await new Promise((r) => setTimeout(r, 300)); } - throw new Error("emrgd failed to start within timeout"); + throw new Error(`emrgd failed to start within timeout${this._readLogTail()}`); } _findDaemonExecutable() { @@ -240,6 +267,7 @@ class DaemonClient { this.connected = true; this._authFailed = false; + this._spawnAttempts = 0; // 连接生命周期成功 → 重置 spawn 节流计数 // 5. 注册 message/close 监听 → 事件流分发 this.ws.on("message", (data) => this._onFrame(data)); diff --git a/emrg/gui/main.js b/emrg/gui/main.js index e8d3c14c..9b05ea15 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -31,6 +31,10 @@ function main() { let ownStream = false; // 自有流运行中(G65:禁止切会话) let ownStreamRequestId = null; // 自有流 request_id(广播 done 不清锁) let reconnectTimer = null; + // Rant 2026-08-09T13:16:36 ③/⑤:重连指数退避(1s→2s→4s→…封顶 60s)。 + // 之前固定 1s——daemon 缺失时每 5s 一轮 spawn,弹窗/日志风暴。成功连接后复位。 + let reconnectDelayMs = 1000; + const MAX_RECONNECT_DELAY_MS = 60_000; let stopping = false; // ── 窗口 ──────────────────────────────────────────────── @@ -646,6 +650,7 @@ vision = false await client.ensureConnected(); logger.info("[gui] connected to emrgd"); cancelReconnect(); + reconnectDelayMs = 1000; // 退避复位 sendToRenderer("status", { connected: true }); } catch (e) { if (client._authFailed) { @@ -653,6 +658,11 @@ vision = false sendToRenderer("status", { connected: false, auth_failed: true, error: e.message }); return; } + // Rant 2026-08-09T13:16:36 ⑤:spawn 节流命中 → 告知宿主真实原因 + // (含 emrgd.log 尾部),不再无限拉起 daemon。 + if (String(e.message).includes("after 3 attempts")) { + sendToRenderer("status", { connected: false, daemon_stopped: true, error: e.message }); + } logger.warn(`[gui] ensureConnected failed: ${e.message}`); scheduleReconnect(); } @@ -660,6 +670,8 @@ vision = false function scheduleReconnect() { if (stopping || reconnectTimer) return; + const delay = reconnectDelayMs; + reconnectDelayMs = Math.min(reconnectDelayMs * 2, MAX_RECONNECT_DELAY_MS); // 指数退避 reconnectTimer = setTimeout(async () => { reconnectTimer = null; sendToRenderer("status", { connected: false, reconnecting: true }); diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index bfbf3b6a..9f8d4e51 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -1069,6 +1069,11 @@ const App = (() => { } else if (data.auth_failed) { updateConnectionDot("red"); Chat.addSystemMessage(_t("app.authFailed")); + } else if (data.daemon_stopped) { + // Rant 2026-08-09T13:16:36 ⑤:spawn 节流命中——显示真实失败原因(含 + // emrgd.log 尾部),提示宿主手动启动,不再无限重试弹窗。 + updateConnectionDot("red"); + Chat.addSystemMessage(_t("app.daemonStopped", { msg: data.error || "" })); } else { updateConnectionDot("red"); } diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index a6a8f50e..9039cbec 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -302,6 +302,7 @@ const I18N = (() => { "app.unknownError": "未知错误", "app.unknown": "未知", "app.authFailed": "认证失败了,请检查设置里的 API Key。", + "app.daemonStopped": "daemon 启动失败(已停止自动重试)。请在终端运行 `emrg server` 排查;\n{msg}", "app.versionInfo": "EMRG GUI v{ver} · 实例 {id} · 模型 {model} · 已进化 {n} 次", }, @@ -592,6 +593,7 @@ const I18N = (() => { "app.unknownError": "Unknown error", "app.unknown": "unknown", "app.authFailed": "Authentication failed — check your API Key in Settings.", + "app.daemonStopped": "daemon failed to start (auto-retry stopped). Run `emrg server` in a terminal to debug;\n{msg}", "app.versionInfo": "EMRG GUI v{ver} · Instance {id} · Model {model} · Evolved {n} times", }, }; diff --git a/emrg/gui/test/daemon_client.test.js b/emrg/gui/test/daemon_client.test.js index 727808b8..21aeb489 100644 --- a/emrg/gui/test/daemon_client.test.js +++ b/emrg/gui/test/daemon_client.test.js @@ -244,6 +244,52 @@ test("G43 stale port: 连接失败(port 文件存在但拒绝)→ 删文件 assert.strictEqual(client.connected, true); }); +test("rant 13:16:36 ⑤ spawn 节流:超 MAX_SPAWN_ATTEMPTS 后不再拉起 daemon", async () => { + const client = new DaemonClient({ projectDir: tmpHome }); + let spawnCount = 0; + // 镜像真实 startDaemon 的节流语义(检查上限 → 计数 +1 → spawn → 超时失败) + client.startDaemon = async function () { + if (this._spawnAttempts >= 3) { + throw new Error("daemon failed to start after 3 attempts — please start it manually"); + } + this._spawnAttempts += 1; + spawnCount += 1; + await new Promise((r) => setTimeout(r, 5)); + throw new Error("emrgd failed to start within timeout"); + }; + for (let i = 0; i < 3; i++) { + await assert.rejects(client.startDaemon(), /emrgd failed to start within timeout/); + } + assert.strictEqual(spawnCount, 3, "3 次尝试内每次都会真正 spawn"); + // 第 4 次:不再 spawn,直接抛节流错误 + await assert.rejects(client.startDaemon(), /after 3 attempts/); + assert.strictEqual(spawnCount, 3, "超过上限后不再 spawn(防窗口/重试风暴)"); + assert.strictEqual(client._spawnAttempts, 3); +}); + +test("rant 13:16:36 ⑤ spawn 节流计数在成功连接后归零", async () => { + const client = new DaemonClient({ projectDir: tmpHome }); + // 先失败一次(计数 +1),再成功 auth → 计数归零 + client.startDaemon = async function () { + this._spawnAttempts += 1; // 镜像真实 startDaemon 的计数 + await new Promise((r) => setTimeout(r, 10)); + throw new Error("emrgd failed to start within timeout"); + }; + client.isRunning = async () => false; + await assert.rejects(client.startDaemon(), /emrgd failed to start within timeout/); + assert.strictEqual(client._spawnAttempts, 1); + // 恢复真实 startDaemon(port 文件已预写 → ensureConnected 直接 ws → auth_ok) + delete client.startDaemon; + const p = client.ensureConnected(); + await waitForWs(); + currentMockWs.emit("open"); + await waitForAuthSent(currentMockWs); + currentMockWs.emit("message", Buffer.from(JSON.stringify({ type: "auth_ok" }))); + await p; + assert.strictEqual(client.connected, true); + assert.strictEqual(client._spawnAttempts, 0, "成功连接后 spawn 节流计数必须归零"); +}); + test("auth 失败(G88):auth_ok 前 close → 停止自动重试", async () => { const client = new DaemonClient({ projectDir: tmpHome }); const p = client.ensureConnected(); diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 65182f85..57e56e8d 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -27,6 +27,7 @@ from websockets.asyncio.server import serve from websockets.exceptions import ConnectionClosed +from emrg._win import win32_no_window_kwargs from emrg.config import LlmConfig, config_dir from emrg.connect import cleanup_server from emrg.server.atomic import atomic_write_bytes, atomic_write_yaml @@ -672,6 +673,7 @@ async def _check_github_auth(self) -> dict: stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, env=no_prompt_env(), + **win32_no_window_kwargs(), ) stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10) output = stdout.decode("utf-8", errors="replace") @@ -705,6 +707,7 @@ async def _github_connect(self, token: str) -> dict: stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, env=no_prompt_env(), + **win32_no_window_kwargs(), ) stdout, _ = await asyncio.wait_for( proc.communicate(token.encode("utf-8") + b"\n"), timeout=30 @@ -737,6 +740,7 @@ async def _gh_setup_git(self, gh: str) -> bool: stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, env=no_prompt_env(), + **win32_no_window_kwargs(), ) await asyncio.wait_for(proc.communicate(), timeout=30) return proc.returncode == 0 @@ -757,6 +761,7 @@ async def _github_disconnect(self) -> dict: stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, env=no_prompt_env(), + **win32_no_window_kwargs(), ) stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30) if proc.returncode != 0: @@ -802,6 +807,7 @@ async def _github_connect_web_start(self) -> dict: stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, env=no_prompt_env(), + **win32_no_window_kwargs(), ) except (OSError, ValueError): return {"ok": False, "code": None, "url": None, diff --git a/emrg/server/git_utils.py b/emrg/server/git_utils.py index 11408534..cf8aca73 100644 --- a/emrg/server/git_utils.py +++ b/emrg/server/git_utils.py @@ -9,6 +9,7 @@ import subprocess from pathlib import Path +from emrg._win import win32_no_window_kwargs from emrg.config import config_dir INSTALL_BIN = Path.home() / ".emrg" / "install" / "bin" @@ -135,6 +136,7 @@ def _detect_git_remote(cwd: str) -> str: result = subprocess.run( ["git", "remote", "get-url", "origin"], cwd=cwd, capture_output=True, text=True, encoding="utf-8", timeout=5, + **win32_no_window_kwargs(), ) if result.returncode == 0: url = result.stdout.strip() @@ -248,4 +250,5 @@ def git_cmd(*args: str, cwd: str | None = None, timeout: int = 10) -> subprocess return subprocess.run( [exe, *args], cwd=cwd, capture_output=True, text=True, encoding="utf-8", timeout=timeout, env=no_prompt_env(), + **win32_no_window_kwargs(), ) diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index 16102c99..efc198a4 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -23,6 +23,7 @@ from pathlib import Path import yaml +from emrg._win import win32_no_window_kwargs from emrg.config import config_dir from emrg.connect import connect_to_server from websockets.exceptions import ConnectionClosed @@ -185,6 +186,7 @@ def _get_git_head(self) -> str | None: text=True, timeout=5, env=no_prompt_env(), + **win32_no_window_kwargs(), ) if result.returncode == 0: return result.stdout.strip() @@ -224,6 +226,7 @@ def _is_usable_git_repo(self, path: str) -> bool: encoding="utf-8", timeout=5, env=no_prompt_env(), + **win32_no_window_kwargs(), ) if result.returncode != 0 or result.stdout.strip() != "true": return False @@ -245,6 +248,7 @@ def _ensure_git_identity(self, repo_dir: Path) -> None: encoding="utf-8", timeout=5, env=no_prompt_env(), + **win32_no_window_kwargs(), ) if not result.stdout.strip(): subprocess.run( @@ -253,6 +257,7 @@ def _ensure_git_identity(self, repo_dir: Path) -> None: capture_output=True, timeout=5, env=no_prompt_env(), + **win32_no_window_kwargs(), ) except (subprocess.SubprocessError, OSError): pass @@ -285,6 +290,7 @@ def _align_to_installed_version(self, repo_dir: Path) -> None: encoding="utf-8", timeout=10, env=no_prompt_env(), + **win32_no_window_kwargs(), ) if result.returncode == 0 and tag in result.stdout.split(): subprocess.run( @@ -296,6 +302,7 @@ def _align_to_installed_version(self, repo_dir: Path) -> None: timeout=30, check=True, env=no_prompt_env(), + **win32_no_window_kwargs(), ) logger.info( "EvolutionHandler[%s]: evolution workspace aligned to %s", @@ -406,6 +413,7 @@ def _clone_workspace(self, repo_url: str, target: Path) -> None: subprocess.run( cmd, capture_output=True, text=True, encoding="utf-8", timeout=120, check=True, env=no_prompt_env(), + **win32_no_window_kwargs(), ) return except subprocess.CalledProcessError as e: @@ -422,6 +430,7 @@ def _clone_workspace(self, repo_url: str, target: Path) -> None: ["git", "clone", ssh_url, str(target)], capture_output=True, text=True, encoding="utf-8", timeout=120, check=True, env=no_prompt_env(), + **win32_no_window_kwargs(), ) def _ensure_origin_reachable(self) -> None: @@ -451,6 +460,7 @@ def _ensure_origin_reachable(self) -> None: encoding="utf-8", timeout=15, env=no_prompt_env(), + **win32_no_window_kwargs(), ) if result.returncode == 0: return # reachable — keep https @@ -464,6 +474,7 @@ def _ensure_origin_reachable(self) -> None: encoding="utf-8", timeout=5, env=no_prompt_env(), + **win32_no_window_kwargs(), ) if switch.returncode == 0: logger.warning( @@ -517,7 +528,9 @@ async def run(self) -> None: wait_timeout = ( self._heartbeat_interval() if self._saturation_heartbeat_active() - else self.interval + # Rant 2026-08-09T13:16:36: exponential backoff while the + # daemon is unreachable — stops the retry/window storm. + else self._connect_backoff() ) # Wait for interval or manual trigger (interruptible) self._next_run_at = time.time() + wait_timeout @@ -624,6 +637,7 @@ def _remote_advanced(self) -> bool: text=True, timeout=15, env=no_prompt_env(), + **win32_no_window_kwargs(), ) if result.returncode != 0: # https github.com may be blocked while SSH port 22 works — @@ -637,6 +651,7 @@ def _remote_advanced(self) -> bool: text=True, timeout=15, env=no_prompt_env(), + **win32_no_window_kwargs(), ) if result.returncode != 0: return False @@ -653,6 +668,23 @@ def _heartbeat_interval(self) -> int: """ return max(self.interval, min(self.interval * 8, 8 * 3600)) + def _connect_backoff(self) -> float: + """Exponential backoff while the daemon is unreachable. + + Rant 2026-08-09T13:16:36 (v0.2.15 Windows regression): when the + daemon is down (emrgd.port missing), every tick's connect failure + returned immediately and the loop re-ran at full interval — with + multiple handlers that produced a per-second retry/window storm. + Backoff = max(30s, interval * 2^n) capped at 10 minutes, where n + is the consecutive-failure count. Returns the normal interval when + there are no consecutive failures. + """ + if self._connect_failures <= 0: + return float(self.interval) + n = min(self._connect_failures, 10) # cap the exponent growth + backoff = max(30.0, float(self.interval) * (2 ** n)) + return min(backoff, 600.0) # never wait longer than 10 minutes + def _saturation_heartbeat_active(self) -> bool: """Whether this tick should run at the low-frequency heartbeat interval instead of the normal interval. diff --git a/emrg/skills/installer.py b/emrg/skills/installer.py index fe9301cb..c78c2a82 100644 --- a/emrg/skills/installer.py +++ b/emrg/skills/installer.py @@ -77,10 +77,15 @@ class CmdResult: async def _default_runner(cmd: list[str], **kwargs) -> CmdResult: """Run a command via asyncio subprocess (captures merged output).""" + from emrg._win import win32_no_window_kwargs + proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, + # Windows: background daemon children must not pop a console window + # (rant 2026-08-09T13:16:36 — cmd-window storm). + **win32_no_window_kwargs(), **kwargs, ) out, _ = await proc.communicate() diff --git a/emrg/tools/bash_tool.py b/emrg/tools/bash_tool.py index 1f387b01..c6a43d6e 100644 --- a/emrg/tools/bash_tool.py +++ b/emrg/tools/bash_tool.py @@ -8,6 +8,7 @@ import os import signal +from emrg._win import win32_no_window_kwargs from emrg.server.git_utils import no_prompt_env from emrg.server.tool_types import ToolDefinition, ToolResult from emrg.tools.base import ToolExecutor @@ -100,6 +101,10 @@ async def execute(self, arguments: dict) -> ToolResult: # 2026-08-07T10:17:27). env=no_prompt_env(), preexec_fn=os.setsid if os.name != "nt" else None, + # Windows: background daemon children must never pop a + # console window (rant 2026-08-09T13:16:36 — cmd-window + # storm; bash tool was a top contributor). + **win32_no_window_kwargs(), ) try: stdout, stderr = await asyncio.wait_for( diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 1bff2f0f..49ed476e 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -1192,6 +1192,57 @@ async def _fake_connect(): mod.connect_to_server = _original_connect_to_server() +# ── Connect-failure exponential backoff (rant 2026-08-09T13:16:36 ③) ─ +# Windows v0.2.15 regression: daemon down → every tick's connect failure +# returned immediately and the loop re-ran at full interval — with multiple +# handlers that produced a per-second retry/window storm. Backoff must be +# max(30s, interval * 2^n) capped at 10 minutes. + +def test_connect_backoff_zero_failures_returns_interval(): + """No consecutive failures → normal interval (no backoff).""" + from emrg.server.scheduler import EvolutionHandler + + handler = EvolutionHandler( + name="emrg-task", config={"project": "emrg"}, interval=60, + identity=InstanceIdentity(), + ) + handler._connect_failures = 0 + assert handler._connect_backoff() == 60.0 + + +def test_connect_backoff_grows_exponentially_capped(tmp_path): + """Consecutive failures grow the wait, capped at 10 minutes.""" + from emrg.server.scheduler import EvolutionHandler + + handler = EvolutionHandler( + name="emrg-task", config={"project": "emrg"}, interval=60, + identity=InstanceIdentity(), + ) + # interval=60s: 2^1=2 → 120s; 2^2=4 → 240s; 2^3=8 → 480s; 2^4=16 → 960s → capped 600s + expectations = {1: 120.0, 2: 240.0, 3: 480.0, 4: 600.0, 5: 600.0, 10: 600.0} + for failures, expected in expectations.items(): + handler._connect_failures = failures + assert handler._connect_backoff() == expected, ( + f"failures={failures}: expected {expected}" + ) + + +def test_connect_backoff_floor_30s_for_small_interval(tmp_path): + """Backoff never drops below 30s even for very fast intervals.""" + from emrg.server.scheduler import EvolutionHandler + + handler = EvolutionHandler( + name="emrg-task", config={"project": "emrg"}, interval=10, + identity=InstanceIdentity(), + ) + handler._connect_failures = 2 + # max(30, 10 * 2^2) = max(30, 40) = 40 + assert handler._connect_backoff() == 40.0 + handler._connect_failures = 1 + # max(30, 10 * 2^1) = max(30, 20) = 30 → floor holds + assert handler._connect_backoff() == 30.0 + + class _FakeWsForCycle: """Minimal ws stand-in for the reset-on-success test.""" def __init__(self, frames): diff --git a/tests/test_win.py b/tests/test_win.py new file mode 100644 index 00000000..d880fc98 --- /dev/null +++ b/tests/test_win.py @@ -0,0 +1,58 @@ +"""Tests for the Windows windowless-subprocess infrastructure. + +Rant 2026-08-09T13:16:36 (v0.2.15 Windows regression, emergency): the daemon +spawned dozens of console windows on Windows because no subprocess call site +suppressed them (CREATE_NO_WINDOW). :func:`emrg._win.win32_no_window_kwargs` +is the single source of truth for the window-suppression kwargs — every +subprocess.Popen / asyncio.create_subprocess_* call site splats it. + +These tests pin the API contract: +- POSIX (os.name != "nt"): empty dict (no-op, nothing changes) +- Windows: {"creationflags": subprocess.CREATE_NO_WINDOW} + +The Windows branch is exercised by swapping the module's platform flag +(``_IS_WINDOWS`` is read at call time, so a direct attribute patch suffices — +``importlib.reload`` would re-run the top-level ``os.name == "nt"`` guard and +reset the patch). +""" + +import subprocess +import sys + +import pytest + + +@pytest.fixture +def win(): + import emrg._win as win + + orig = win._IS_WINDOWS + yield win + win._IS_WINDOWS = orig + + +def test_win32_no_window_kwargs_posix_noop(win): + """On non-Windows the kwargs dict must be empty (zero behavior change).""" + win._IS_WINDOWS = False + assert win.win32_no_window_kwargs() == {} + + +def test_win32_no_window_kwargs_windows_create_no_window(win): + """On Windows the kwargs must carry CREATE_NO_WINDOW.""" + win._IS_WINDOWS = True + kwargs = win.win32_no_window_kwargs() + assert kwargs == {"creationflags": 0x08000000} # CREATE_NO_WINDOW (Win32 constant) + assert kwargs["creationflags"] == 0x08000000 + + +def test_win32_no_window_kwargs_splats_into_subprocess_run(win): + """The dict must be splat-compatible with subprocess.run (no unknown keys).""" + win._IS_WINDOWS = False + # POSIX: plain run still works with the splat + result = subprocess.run( + [sys.executable, "-c", "print('ok')"], + capture_output=True, text=True, + **win.win32_no_window_kwargs(), + ) + assert result.returncode == 0 + assert result.stdout.strip() == "ok" From 4cb0fe8e94a9a99b778582935212e7e861c86f97 Mon Sep 17 00:00:00 2001 From: argszero Date: Sun, 9 Aug 2026 17:16:23 +0800 Subject: [PATCH 2/2] emrg: GUI daemon_stopped warning one-time per connect lifecycle (rant 2026-08-09T13:16:36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the spawn throttle exhausts, scheduleReconnect keeps retrying with backoff capped at 60s — each retry hit the throttle and re-sent daemon_stopped status, so the renderer appended a duplicate 'run emrg server' system message every minute forever. Add daemonStoppedNotified: the warning is sent once per connect lifecycle (reset on successful connect), symmetric with the TUI _throttle_warned guard (PR #594). --- emrg/gui/main.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 9b05ea15..7b211ab7 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -35,6 +35,10 @@ function main() { // 之前固定 1s——daemon 缺失时每 5s 一轮 spawn,弹窗/日志风暴。成功连接后复位。 let reconnectDelayMs = 1000; const MAX_RECONNECT_DELAY_MS = 60_000; + // Rant 2026-08-09T13:16:36 ⑤:daemon_stopped 提示每个连接生命周期只发一次—— + // 否则退避封顶 60s 后每轮重试都命中节流、渲染层每分钟追加一条重复系统消息 + // (对称 TUI app.py _throttle_warned,PR #594)。成功连接后复位。 + let daemonStoppedNotified = false; let stopping = false; // ── 窗口 ──────────────────────────────────────────────── @@ -651,6 +655,7 @@ vision = false logger.info("[gui] connected to emrgd"); cancelReconnect(); reconnectDelayMs = 1000; // 退避复位 + daemonStoppedNotified = false; // 节流提示复位(下个生命周期可再提示) sendToRenderer("status", { connected: true }); } catch (e) { if (client._authFailed) { @@ -659,8 +664,10 @@ vision = false return; } // Rant 2026-08-09T13:16:36 ⑤:spawn 节流命中 → 告知宿主真实原因 - // (含 emrgd.log 尾部),不再无限拉起 daemon。 - if (String(e.message).includes("after 3 attempts")) { + // (含 emrgd.log 尾部),不再无限拉起 daemon。只提示一次,防退避重试 + // 每分钟重复追加系统消息。 + if (String(e.message).includes("after 3 attempts") && !daemonStoppedNotified) { + daemonStoppedNotified = true; sendToRenderer("status", { connected: false, daemon_stopped: true, error: e.message }); } logger.warn(`[gui] ensureConnected failed: ${e.message}`);