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 number Diff line number Diff 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` (825) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (834) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (257: 45 daemon_client + 19 conn-manager + 22 app-commands + 129 renderer smoke + 16 i18n + 7 integration + 3 commands + 7 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
15 changes: 15 additions & 0 deletions bin/stop-emrg.cmd
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,21 @@ echo [stop-emrg] ============ begin ============
echo [stop-emrg] EMRG_DIR=%EMRG_DIR%
echo [stop-emrg] INSTALL=%INSTALL%

echo [0] call emrg stop (all processes, best-effort)...
if exist "%INSTALL%\bin\emrg.cmd" goto :stop_all_installed
where emrg >nul 2>&1
if errorlevel 1 (
echo [0] emrg command not found -- continue (first-time install)
goto :step1
)
call emrg stop
echo [0] emrg stop done (exit=%errorlevel%)
goto :step1
:stop_all_installed
call "%INSTALL%\bin\emrg.cmd" stop
echo [0] emrg stop done (exit=%errorlevel%)
:step1

echo [1] check GUI (EMRG.exe)...
taskkill /IM EMRG.exe >nul 2>&1
if not errorlevel 1 (
Expand Down
148 changes: 148 additions & 0 deletions emrg/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
emrg server Run daemon in foreground
emrg server stop Stop the running daemon
emrg server restart Restart the daemon
emrg stop Stop ALL running emrg processes (daemon, TUI, GUI)
emrg update git pull + reinstall from source
"""

Expand All @@ -15,6 +16,7 @@
import json
import logging
import os
import re
import signal
import subprocess
import sys
Expand Down Expand Up @@ -68,6 +70,15 @@ def _build_parser() -> argparse.ArgumentParser:
)
# (no action = foreground run, handled in main())

# emrg stop
sub.add_parser(
"stop",
help="Stop ALL running emrg processes (daemon, TUI, GUI)",
description="Stop every running emrg process: the daemon, TUI clients and "
"the GUI app. Graceful stop first, force-kill stragglers. Used by the "
"Windows installer pre-stop (stop-emrg.cmd step [0]).",
)

# emrg update
sub.add_parser(
"update",
Expand Down Expand Up @@ -109,6 +120,8 @@ def main() -> None:
_run_daemon()
elif parsed.command == "rant":
_send_rant(" ".join(parsed.message), project=parsed.project)
elif parsed.command == "stop":
_stop_all()
elif parsed.command == "update":
_run_update()
else:
Expand Down Expand Up @@ -193,6 +206,141 @@ async def _get_pid():
print("daemon not running.")


# ── Stop everything (`emrg stop`) ──────────────────────────────

_EMRG_CLIENT_RE = re.compile(r"-m\s+emrg(\.server)?(\s|$)")


def _match_emrg_client(cmd: str) -> bool:
"""True if a process command line belongs to an emrg process (TUI/daemon/GUI).

Matches:
- `python -m emrg` (TUI client)
- `python -m emrg.server` (daemon; protocol/pid stop may have missed it)
- `/Applications/EMRG.app/...` (macOS GUI)
Does NOT match lookalikes like `-m emrg.serverless` or `-m emrgx`.
"""
if "EMRG.app" in cmd:
return True
return bool(_EMRG_CLIENT_RE.search(cmd))


def _scan_emrg_client_pids(ps_output: str, own_pid: int) -> list[int]:
"""Parse `ps -axww -o pid=,command=` output → pids of emrg processes.

`own_pid` is excluded so `emrg stop` (itself `python -m emrg stop`) never
kills the CLI that is running it.
"""
pids: list[int] = []
for line in ps_output.splitlines():
line = line.strip()
if not line:
continue
parts = line.split(None, 1)
if len(parts) != 2:
continue
try:
pid = int(parts[0])
except ValueError:
continue
if pid == own_pid:
continue
if _match_emrg_client(parts[1]):
pids.append(pid)
return pids


def _stop_pids(pids: list[int]) -> list[int]:
"""Graceful SIGTERM → short grace → SIGKILL. Returns pids that survived."""
for pid in pids:
try:
os.kill(pid, signal.SIGTERM)
except (ProcessLookupError, PermissionError):
pass
alive: list[int] = []
for _ in range(20): # ~3s grace window
alive = []
for pid in pids:
try:
os.kill(pid, 0)
alive.append(pid)
except (ProcessLookupError, PermissionError):
pass
if not alive:
break
time.sleep(0.15)
for pid in alive:
try:
os.kill(pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError):
pass
return alive


def _stop_posix_clients() -> None:
"""Kill TUI/GUI emrg client processes on POSIX (ps scan + SIGTERM/SIGKILL)."""
try:
out = subprocess.run(
["ps", "-axww", "-o", "pid=,command="],
capture_output=True, text=True, timeout=10,
**win32_no_window_kwargs(),
).stdout
except (OSError, subprocess.SubprocessError, TimeoutError):
print("emrg stop: could not scan processes (ps unavailable).")
return
pids = _scan_emrg_client_pids(out, os.getpid())
if not pids:
print("emrg stop: no other emrg client processes found.")
return
print(f"emrg stop: found {len(pids)} client process(es), stopping ...")
survivors = _stop_pids(pids)
if survivors:
print(f"emrg stop: WARNING {len(survivors)} process(es) survived SIGKILL: {survivors}")
else:
print("emrg stop: client processes stopped.")


def _stop_windows_clients() -> None:
"""Kill GUI (EMRG.exe) + TUI (python -m emrg, excluding daemon) on Windows.

Mirrors bin/stop-emrg.cmd steps [1]/[2]: graceful GUI stop then unconditional
/F fallback (host 2026-08-10T01:27:07Z lesson), TUI via PowerShell command
line filter (wmic-free, Win11 24H2+ safe).
"""
kw = win32_no_window_kwargs()
# GUI: graceful first, then unconditional force (no survivor gate)
subprocess.run(["taskkill", "/IM", "EMRG.exe"], capture_output=True, **kw)
time.sleep(0.5)
subprocess.run(["taskkill", "/F", "/IM", "EMRG.exe"], capture_output=True, **kw)
# TUI: python.exe running `-m emrg` but NOT `emrg.server` (daemon)
ps_cmd = (
"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 }"
)
subprocess.run(
["powershell", "-NoProfile", "-Command", ps_cmd],
capture_output=True, **kw,
)


def _stop_all() -> None:
"""Stop every running emrg process: daemon, TUI, GUI.

Graceful stop first, force-kill stragglers — the CLI counterpart of
bin/stop-emrg.cmd (host request 2026-08-15: `emrg stop` must check all
open emrg TUI/GUI/server processes and stop them all).
"""
print("emrg stop: stopping daemon ...")
_stop_daemon()
if sys.platform == "win32":
_stop_windows_clients()
else:
_stop_posix_clients()
print("emrg stop: done.")


def _restart_daemon() -> None:
"""Stop and restart the daemon."""
print("restarting daemon ...")
Expand Down
23 changes: 23 additions & 0 deletions tests/test_installer_stop.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,29 @@ def test_stop_emrg_cmd_covers_gui_tui_daemon_and_inline_step4():
assert "Get-CimInstance" in verify_block


def test_stop_emrg_cmd_step0_calls_emrg_stop():
"""Host request 2026-08-15 (session s_260815_0844): stop-emrg.cmd must call
`emrg stop` at the very beginning (step [0]) — it stops daemon+TUI+GUI in one
shot. First-time install may not have the `emrg` command on PATH → skip
without error and continue with the rest of the script."""
content = (REPO_ROOT / "bin" / "stop-emrg.cmd").read_text(encoding="utf-8")
# step [0] runs before step [1] (GUI taskkill)
step0_idx = content.index("echo [0] call emrg stop")
gui_idx = content.index("taskkill /IM EMRG.exe")
assert step0_idx < gui_idx
# preferred: installed launcher path (upgrade case — PATH may lack install\bin)
assert 'call "%INSTALL%\\bin\\emrg.cmd" stop' in content
# fallback: `emrg` on PATH; missing → continue without error (first-time install)
assert "where emrg >nul 2>&1" in content
assert "emrg command not found -- continue" in content
# both paths fall through to :step1 (never to :verify — step 4 must still run)
assert content.index("goto :step1") < gui_idx
# no %VAR% inside the new paren block (v1 parse-time expansion lesson)
block = content[step0_idx:gui_idx]
assert "%VAR%" not in block
assert "%errorlevel%" in block # echo-only use, never gates on it


def test_stop_git_merged_single_file():
# rant 2026-08-12T14:00:05 验收:bin/ 下无 stop-git.ps1;grep stop-git 仅历史注释
assert not (REPO_ROOT / "bin" / "stop-git.ps1").exists()
Expand Down
61 changes: 59 additions & 2 deletions tests/test_main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
"""Tests for emrg/__main__.py argument parsing."""
from emrg.__main__ import _build_parser
"""Tests for emrg/__main__.py argument parsing and `emrg stop` process matching."""
from emrg.__main__ import (
_build_parser,
_match_emrg_client,
_scan_emrg_client_pids,
)


class TestBuildParser:
Expand Down Expand Up @@ -57,3 +61,56 @@ def test_rant_with_long_project_flag(self):
assert args.command == "rant"
assert args.project == "emrg"
assert args.message == ["hello"]

def test_stop_command(self):
parser = _build_parser()
args = parser.parse_args(["stop"])
assert args.command == "stop"


class TestMatchEmrgClient:
"""`emrg stop` process matching — positive and negative states."""

def test_matches_tui(self):
assert _match_emrg_client("python -m emrg")
assert _match_emrg_client("python -m emrg --init-auto-evolve")

def test_matches_daemon(self):
assert _match_emrg_client("python -m emrg.server")
assert _match_emrg_client("pythonw -m emrg.server")

def test_matches_macos_gui(self):
assert _match_emrg_client("/Applications/EMRG.app/Contents/MacOS/EMRG")
assert _match_emrg_client("EMRG.app/Contents/MacOS/EMRG --no-sandbox")

def test_does_not_match_lookalikes(self):
# module-like but different module: must not match
assert not _match_emrg_client("python -m emrg.serverless")
assert not _match_emrg_client("python -m emrgx")
# unrelated processes
assert not _match_emrg_client("git fetch origin master")
assert not _match_emrg_client("python -m pytest tests/")
assert not _match_emrg_client("EMRGX.app/Contents/MacOS/EMRGX")
# `-m emrg` as substring of a longer flag (e.g. -X something) must not match
assert not _match_emrg_client("python -X dev main.py -m emrgistry")


class TestScanEmrgClientPids:
def test_parses_and_excludes_own_pid(self):
ps_out = (
" 100 /usr/bin/python -m emrg\n"
" 200 /usr/bin/python -m emrg.server\n"
" 300 /usr/bin/python -m pytest\n"
" 400 /Applications/EMRG.app/Contents/MacOS/EMRG\n"
)
pids = _scan_emrg_client_pids(ps_out, own_pid=200)
# 100 (TUI) + 400 (GUI) matched; 200 excluded as own pid; 300 unrelated
assert pids == [100, 400]

def test_empty_and_malformed_lines(self):
ps_out = " 100 /usr/bin/python -m emrg\n\n \nnot-a-pid /bin/ls\n"
pids = _scan_emrg_client_pids(ps_out, own_pid=9999)
assert pids == [100]

def test_no_matches(self):
assert _scan_emrg_client_pids(" 1 /sbin/launchd\n 2 /usr/libexec/foo\n", own_pid=9999) == []
Loading