From 29b69f6c0f013a91b2f78cf60adb126416b9c3dd Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Mon, 10 Aug 2026 09:10:19 +0800 Subject: [PATCH 1/2] =?UTF-8?q?emrg:=20installer=20pre-stop=20=E2=80=94=20?= =?UTF-8?q?gracefully=20close=20GUI/TUI/daemon=20before=20overwriting=20(r?= =?UTF-8?q?ant=202026-08-10T08:50:44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Agent.md | 2 +- README.cn.md | 2 +- README.md | 2 +- bin/emrgd.cmd | 8 +++++ bin/stop-emrg.cmd | 68 ++++++++++++++++++++++++++++++++++++ packaging/build-runtime.sh | 1 + packaging/make-installer.sh | 30 ++++++++++++++++ tests/test_installer_stop.py | 56 +++++++++++++++++++++++++++++ 8 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 bin/stop-emrg.cmd create mode 100644 tests/test_installer_stop.py diff --git a/Agent.md b/Agent.md index f9439f0..926fefe 100644 --- a/Agent.md +++ b/Agent.md @@ -93,7 +93,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` (670) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (674) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (107: 29 daemon_client + 22 app-commands + 31 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 c2c8ede..eb960fc 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 # 跑测试(当前 670 项) +uv run pytest tests/ -v # 跑测试(当前 674 项) uv run python -m emrg # 启动 TUI # CI 含 actionlint workflow 门禁(#444):workflow 解析错误在 PR 即失败 diff --git a/README.md b/README.md index 7ddfc00..07424ed 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 670 items) +uv run pytest tests/ -v # run tests (currently 674 items) uv run python -m emrg # launch TUI # CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI diff --git a/bin/emrgd.cmd b/bin/emrgd.cmd index 17eb86a..7e58c88 100644 --- a/bin/emrgd.cmd +++ b/bin/emrgd.cmd @@ -15,4 +15,12 @@ REM (RotatingFileHandler),StreamHandler 仅附加,无控制台不影响日 set PYEXE=%DIR%\python-dist\pythonw.exe if not exist "%PYEXE%" set PYEXE=%DIR%\python-dist\python.exe if not exist "%PYEXE%" set PYEXE=%DIR%\python-dist\python3.13.exe +REM R124: `emrgd.cmd stop` — 优雅关闭 daemon(rant 2026-08-10T08:50:44 安装器预停止): +REM 复用 CLI `emrg server stop`(协议 shutdown → ping-pid SIGTERM 兜底,见 +REM emrg/__main__.py _stop_daemon)。pythonw 无控制台,print 丢弃无害,shutdown 无需 stdout。 +REM 用标签而非括号块,保证 %errorlevel% 在 python 退出后才展开。 +if /I not "%~1"=="stop" goto :start +"%PYEXE%" -m emrg server stop +exit /b %errorlevel% +:start "%PYEXE%" -m emrg.server %* diff --git a/bin/stop-emrg.cmd b/bin/stop-emrg.cmd new file mode 100644 index 0000000..03922f3 --- /dev/null +++ b/bin/stop-emrg.cmd @@ -0,0 +1,68 @@ +@echo off +REM stop-emrg.cmd — gracefully stop EMRG GUI/TUI/daemon before the installer +REM overwrites ~\.emrg\install files (rant 2026-08-10T08:50:44: Inno Setup got +REM stuck at "停止已有进程" because the windowless pythonw daemon holds file locks +REM and Inno CloseApplications cannot see it). +REM +REM Order matters (mirrors bin/emrg-uninstall steps 1a/1b): +REM 1. GUI (EMRG.exe): graceful WM_CLOSE first (taskkill without /F), +REM /F fallback after ~5s if still alive +REM 2. TUI (python.exe -m emrg): command-line filter (wmic, PowerShell +REM fallback), excludes the daemon (pythonw.exe -m emrg.server) +REM 3. daemon: `emrg server stop` protocol shutdown via the OLD install's CLI +REM (present since #364 — version-safe), emrgd.pid poll (<=10s), then +REM taskkill /F /PID fallback +REM +REM Returns 0 when nothing EMRG-related survives; 1 if a process could not be +REM stopped (installer aborts with a clear message instead of hanging). +REM Safe on clean install: no old install dir -> everything is skipped -> 0. +setlocal +set "EMRG_DIR=%USERPROFILE%\.emrg" +set "INSTALL=%EMRG_DIR%\install" + +REM --- 1. GUI: graceful WM_CLOSE, then /F fallback --- +taskkill /IM EMRG.exe >nul 2>&1 +if not errorlevel 1 ( + REM give the GUI up to ~5s to exit cleanly (ping = portable sleep) + ping -n 6 127.0.0.1 >nul 2>&1 +) +tasklist /FI "IMAGENAME eq EMRG.exe" 2>nul | findstr /i "EMRG.exe" >nul +if not errorlevel 1 taskkill /F /IM EMRG.exe >nul 2>&1 + +REM --- 2. TUI: python.exe -m emrg (daemon is pythonw.exe -m emrg.server, excluded) --- +where wmic >nul 2>&1 +if not errorlevel 1 ( + REM %% = literal % in batch files (wmic LIKE wildcard) + wmic process where "name='python.exe' and commandline like '%%-m emrg%%' and commandline not like '%%emrg.server%%'" call terminate >nul 2>&1 +) else ( + powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \"Name='python.exe'\" | Where-Object { $_.CommandLine -match '-m emrg' -and $_.CommandLine -notmatch 'emrg\.server' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }" >nul 2>&1 +) + +REM --- 3. daemon: protocol shutdown via old install's CLI, pid poll, /F fallback --- +if exist "%INSTALL%\bin\emrg.cmd" ( + call "%INSTALL%\bin\emrg.cmd" server stop +) +if not exist "%EMRG_DIR%\emrgd.pid" goto :verify +set /a TRIES=0 +:wait_pid +if not exist "%EMRG_DIR%\emrgd.pid" goto :verify +set /a TRIES+=1 +if %TRIES% geq 10 goto :kill_pid +ping -n 2 127.0.0.1 >nul 2>&1 +goto :wait_pid +:kill_pid +set "DPID=" +for /f "usebackq delims=" %%p in ("%EMRG_DIR%\emrgd.pid") do set "DPID=%%p" +if defined DPID taskkill /F /PID %DPID% >nul 2>&1 + +:verify +set "EXIT_CODE=0" +tasklist /FI "IMAGENAME eq EMRG.exe" 2>nul | findstr /i "EMRG.exe" >nul && set "EXIT_CODE=1" +if exist "%EMRG_DIR%\emrgd.pid" ( + set "DPID=" + for /f "usebackq delims=" %%p in ("%EMRG_DIR%\emrgd.pid") do set "DPID=%%p" + if defined DPID ( + tasklist /FI "PID eq %DPID%" 2>nul | findstr /i "%DPID%" >nul && set "EXIT_CODE=1" + ) +) +endlocal & exit /b %EXIT_CODE% diff --git a/packaging/build-runtime.sh b/packaging/build-runtime.sh index e36d44e..b3fe78e 100755 --- a/packaging/build-runtime.sh +++ b/packaging/build-runtime.sh @@ -68,6 +68,7 @@ cp -R "$PY_ROOT/." "$DIST/bin/python-dist/" cp "$ROOT/bin/emrgd" emrgd cp "$ROOT/bin/emrg.cmd" emrg.cmd 2>/dev/null || true cp "$ROOT/bin/emrgd.cmd" emrgd.cmd 2>/dev/null || true + cp "$ROOT/bin/stop-emrg.cmd" stop-emrg.cmd 2>/dev/null || true cp "$ROOT/bin/emrg-uninstall" emrg-uninstall chmod +x emrg emrgd emrg-uninstall ) diff --git a/packaging/make-installer.sh b/packaging/make-installer.sh index c45a831..c91cd10 100755 --- a/packaging/make-installer.sh +++ b/packaging/make-installer.sh @@ -242,6 +242,11 @@ Compression=lzma2 SolidCompression=yes [Files] Source: "$STAGE_WIN/payload\\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion +; R124: dontcopy — 供 [Code] PrepareToInstall 在覆盖文件前 ExtractTemporaryFile 取出并 +; 运行 bin\stop-emrg.cmd(升级安装前优雅关闭 GUI/TUI/daemon,rant 2026-08-10T08:50:44: +; pythonw daemon 锁文件导致卡在"停止已有进程")。正常安装时该文件仍由上方通配符 +; 装入 {app}\bin\stop-emrg.cmd。 +Source: "$STAGE_WIN/payload\\bin\\stop-emrg.cmd"; DestDir: "{tmp}"; Flags: dontcopy [Icons] Name: "{userprograms}\\EMRG"; Filename: "{app}\\emrg-gui\\EMRG\\EMRG.exe"; IconFilename: "{app}\\emrg-gui\\EMRG\\EMRG.exe" [UninstallRun] @@ -357,6 +362,31 @@ begin if CurUninstallStep = usPostUninstall then RemoveBinDirFromPath; end; + +// R124: 升级安装前优雅关闭运行中的 EMRG 进程(rant 2026-08-10T08:50:44)—— +// Inno CloseApplications 看不到无窗口的 pythonw daemon(emrgd.cmd → pythonw.exe +// -m emrg.server 常驻锁文件),覆盖 ~/.emrg\install 时卡在"停止已有进程"。 +// PrepareToInstall 在安装开始前运行 bin\stop-emrg.cmd:taskkill EMRG.exe +// 优雅→/F 兜底、wmic/PowerShell 命令行过滤 TUI、emrg server stop 协议关闭 +// daemon + emrgd.pid 轮询兜底(顺序 GUI→TUI→daemon)。干净安装(无旧 install) +// 脚本自行跳过。返回非空字符串 = 中止安装并显示该消息(宁可中止也不卡死)。 +// {cmd} = cmd.exe(Inno 预定义常量,批处理文件须经 cmd 启动)。 +function PrepareToInstall(var NeedsRestart: Boolean): String; +var + ResultCode: Integer; + StopScript: string; +begin + Result := ''; + ExtractTemporaryFile('stop-emrg.cmd'); + StopScript := ExpandConstant('{tmp}\\stop-emrg.cmd'); + if Exec(ExpandConstant('{cmd}'), '/c "' + StopScript + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + begin + if ResultCode <> 0 then + Result := 'EMRG could not stop all running processes (stop-emrg.cmd exit code ' + IntToStr(ResultCode) + '). Please close EMRG (GUI/TUI) and retry the install.'; + end + else + Result := 'EMRG could not run the process-stop helper (stop-emrg.cmd). Please close EMRG (GUI/TUI) and retry the install.'; +end; EOF # Windows 路径转义(iscc 需要 Windows 路径,但在 bash/msys 下用当前路径) echo " iscc version: $("$ISCC" /? 2>&1 | head -1)" diff --git a/tests/test_installer_stop.py b/tests/test_installer_stop.py new file mode 100644 index 0000000..89dcc65 --- /dev/null +++ b/tests/test_installer_stop.py @@ -0,0 +1,56 @@ +"""Windows 安装器预停止接线回归测试(rant 2026-08-10T08:50:44)。 + +安装器(Inno Setup,make-installer.sh 生成 emrg.iss)覆盖 ~/.emrg/install 前必须 +先优雅关闭 GUI/TUI/daemon,否则无窗口的 pythonw daemon 独占锁文件 → 卡在 +"停止已有进程"(宿主只能重启系统)。本测试纯文本断言(不执行 iscc/cmd —— +macOS/CI 无 Windows),钉死四处接线: + 1. bin/stop-emrg.cmd 存在且覆盖三步:GUI 优雅关闭+/F 兜底、TUI 命令行过滤、 + daemon 协议关闭 + emrgd.pid 轮询兜底(顺序 GUI → daemon) + 2. bin/emrgd.cmd 含 stop 分支(复用 `emrg server stop`) + 3. make-installer.sh 的 .iss 模板含 [Files] dontcopy + [Code] PrepareToInstall + 4. build-runtime.sh 把 stop-emrg.cmd 复制进 runtime bin/ +""" + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def test_stop_emrg_cmd_covers_gui_tui_daemon_in_order(): + content = (REPO_ROOT / "bin" / "stop-emrg.cmd").read_text(encoding="utf-8") + # GUI:无 /F 优雅 WM_CLOSE 优先,/F 兜底 + assert "taskkill /IM EMRG.exe" in content + assert "taskkill /F /IM EMRG.exe" in content + assert content.index("taskkill /IM EMRG.exe") < content.index("taskkill /F /IM EMRG.exe") + # TUI:命令行过滤(wmic LIKE 通配符须 %% 转义) + assert "wmic" in content and "commandline like" in content + assert "powershell" in content # wmic 缺失(Win11 24H2+)时的回退 + # daemon:协议关闭 + pid 轮询兜底(用实际执行行而非注释里的字面量) + assert 'call "%INSTALL%\\bin\\emrg.cmd" server stop' in content + assert "emrgd.pid" in content + # 顺序:GUI 在 daemon 之前(GUI 不能复活 daemon) + daemon_line = content.index('call "%INSTALL%\\bin\\emrg.cmd" server stop') + assert content.index("taskkill /IM EMRG.exe") < daemon_line + # 干净安装安全:无旧 install 目录时跳过 + assert 'set "INSTALL=%EMRG_DIR%\\install"' in content + + +def test_emrgd_cmd_has_stop_branch(): + content = (REPO_ROOT / "bin" / "emrgd.cmd").read_text(encoding="utf-8") + assert 'if /I not "%~1"=="stop" goto :start' in content + assert "-m emrg server stop" in content + assert "exit /b %errorlevel%" in content + + +def test_make_installer_iss_has_prepare_to_install(): + content = (REPO_ROOT / "packaging" / "make-installer.sh").read_text(encoding="utf-8") + assert "dontcopy" in content + assert "stop-emrg.cmd" in content + assert "PrepareToInstall" in content + assert "ExtractTemporaryFile('stop-emrg.cmd')" in content + assert "SW_HIDE" in content # 批处理执行不弹控制台窗口(#592 纪律) + + +def test_build_runtime_copies_stop_emrg_cmd(): + content = (REPO_ROOT / "packaging" / "build-runtime.sh").read_text(encoding="utf-8") + assert 'cp "$ROOT/bin/stop-emrg.cmd" stop-emrg.cmd' in content From 3a98df8a221cfc4f11511ecc7dcaf31defa5cb5a Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Mon, 10 Aug 2026 09:14:51 +0800 Subject: [PATCH 2/2] =?UTF-8?q?emrg:=20stop-emrg.cmd=20delayed-expansion?= =?UTF-8?q?=20fix=20=E2=80=94=20verify-block=20pid=20check=20used=20parse-?= =?UTF-8?q?time=20%DPID%=20(rant=202026-08-10T08:50:44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/stop-emrg.cmd | 6 ++++-- tests/test_installer_stop.py | 9 +++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/bin/stop-emrg.cmd b/bin/stop-emrg.cmd index 03922f3..43bd337 100644 --- a/bin/stop-emrg.cmd +++ b/bin/stop-emrg.cmd @@ -16,7 +16,7 @@ REM REM Returns 0 when nothing EMRG-related survives; 1 if a process could not be REM stopped (installer aborts with a clear message instead of hanging). REM Safe on clean install: no old install dir -> everything is skipped -> 0. -setlocal +setlocal enabledelayedexpansion set "EMRG_DIR=%USERPROFILE%\.emrg" set "INSTALL=%EMRG_DIR%\install" @@ -58,11 +58,13 @@ if defined DPID taskkill /F /PID %DPID% >nul 2>&1 :verify set "EXIT_CODE=0" tasklist /FI "IMAGENAME eq EMRG.exe" 2>nul | findstr /i "EMRG.exe" >nul && set "EXIT_CODE=1" +REM 括号块内 %-变量 在块解析时展开(set "DPID=" 之后取到旧值/空值)→ 必须用 +REM enabledelayedexpansion 的 !DPID!(运行时展开);if defined 本身是运行时判定。 if exist "%EMRG_DIR%\emrgd.pid" ( set "DPID=" for /f "usebackq delims=" %%p in ("%EMRG_DIR%\emrgd.pid") do set "DPID=%%p" if defined DPID ( - tasklist /FI "PID eq %DPID%" 2>nul | findstr /i "%DPID%" >nul && set "EXIT_CODE=1" + tasklist /FI "PID eq !DPID!" 2>nul | findstr /i "!DPID!" >nul && set "EXIT_CODE=1" ) ) endlocal & exit /b %EXIT_CODE% diff --git a/tests/test_installer_stop.py b/tests/test_installer_stop.py index 89dcc65..5a54764 100644 --- a/tests/test_installer_stop.py +++ b/tests/test_installer_stop.py @@ -33,6 +33,15 @@ def test_stop_emrg_cmd_covers_gui_tui_daemon_in_order(): assert content.index("taskkill /IM EMRG.exe") < daemon_line # 干净安装安全:无旧 install 目录时跳过 assert 'set "INSTALL=%EMRG_DIR%\\install"' in content + # 括号块内 pid 判定必须用延迟展开(!DPID!)——%DPID% 在块解析时展开, + # set "DPID=" 后取到旧值/空值 → if defined 恒假 → daemon 存活误报干净 + # (rant 2026-08-10T08:50:44,cmd.exe 经典括号块展开坑) + assert "setlocal enabledelayedexpansion" in content + # 从标签定义处(而非前面 wait 循环的 goto :verify)截取校验块 + verify_block = content[content.index(":verify\nset \"EXIT_CODE=0\""):] + assert "PID eq !DPID!" in verify_block + # 非延迟展开 %DPID% 不得出现在括号块内(块解析时展开=恒旧值) + assert "%DPID%" not in verify_block def test_emrgd_cmd_has_stop_branch():