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.token; python -m emrg
```

Python: `uv run pytest tests/ -v` (995) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (1001) — 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 + 128 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 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
141 changes: 88 additions & 53 deletions emrg/_stop_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@
``/F``; POSIX ps-scan (``EMRG.app`` / ``EMRG-*.AppImage``)
- TUI: Windows CIM filter ``python.exe|pythonw.exe -m emrg`` (not
``emrg.server``); POSIX ps-scan
- daemon: ws protocol ``shutdown`` → ``~/.emrg/emrgd.pid`` → SIGTERM /
``taskkill /F /PID`` → 3s poll → cmdline-scan fallback
(missing/stale pid file → kill any ``python*.exe -m emrg(.server)``,
rant 2026-08-17T17:03:38); port file removed once dead
- daemon: ws protocol ``shutdown`` → fixed-port TCP probe wait →
SIGTERM / ``taskkill /F /PID`` → cmdline-scan fallback
(``-m emrg.server``, rant 2026-08-17T17:03:38); port file
removed once dead
- bundled git: Windows ``install\\git\\`` prefix kill (git/ssh/plink/bash
+ fallback prefix full-kill — port of stop-emrg.cmd step 4)
- verify: residual scan; any survivor → ``exit 1`` with a named list
Expand Down Expand Up @@ -76,6 +76,10 @@
_EMRGD_PORT = 56031

_EMRG_CLIENT_RE = re.compile(r"-m\s+emrg(\.server)?(\s|$)")
# daemon-only cmdline identity (``-m emrg.server`` — rant 2026-08-21T16:45:06:
# the fixed port is the daemon ground truth, cmdline scan is the kill fallback;
# TUI clients use ``-m emrg`` and are stopped earlier in the chain).
_EMRG_SERVER_RE = re.compile(r"-m\s+emrg\.server(\s|$)")
_APPIMAGE_RE = re.compile(r"EMRG-[\w.\-]*AppImage(\s|$)")


Expand Down Expand Up @@ -183,16 +187,6 @@ def _pid_alive(pid: int) -> bool:
return False


def _read_pid_file() -> int | None:
"""Read ~/.emrg/emrgd.pid → int pid, or None if missing/invalid."""
try:
raw = (config_dir() / "emrgd.pid").read_text(encoding="utf-8").strip()
pid = int(raw)
return pid if pid > 0 else None
except (OSError, ValueError):
return None


def _kill_pid_windows(pid: int) -> None:
"""Force-kill a pid on Windows (taskkill /F — TerminateProcess)."""
subprocess.run(
Expand All @@ -219,28 +213,32 @@ def _kill_pid_posix(pid: int, grace: float = 3.0) -> None:
pass


def _scan_windows_python_emrg(own_pid: int) -> list[int]:
def _scan_windows_python_emrg(own_pid: int, server_only: bool = False) -> list[int]:
"""Scan python.exe/pythonw.exe whose command line matches ``-m emrg`` /
``-m emrg.server`` (TUI + daemon), excluding ``own_pid``.

``server_only=True`` restricts the match to ``-m emrg.server`` (the daemon
only — rant 2026-08-21T16:45:06: stop_daemon's kill fallback targets the
daemon, TUI clients are stopped earlier in the chain).

Command line is the only reliable identity on Windows (rant
2026-08-17T17:03:38): the daemon's ``emrgd.pid`` can be missing/stale/
mismatched (GUI spawn, crash restart, external unlink — #593 family), so a
live daemon would otherwise survive the pid-file path and keep locking the
``websockets`` C extensions under ``install\\`` — the installer then fails
with ``DeleteFile failed; code 5`` while verify() reports clean.
2026-08-17T17:03:38): a live daemon would otherwise survive the pid-file
path and keep locking the ``websockets`` C extensions under ``install\\`` —
the installer then fails with ``DeleteFile failed; code 5`` while verify()
reports clean.
"""
if not is_win():
return []
cmd_re = r"-m emrg\.server" if server_only else r"-m emrg"
# Literal PowerShell script-block braces must be escaped as {{ }} — same
# contract as stop_tui() (str.format() would raise on unescaped braces).
ps_cmd = (
"Get-CimInstance Win32_Process | "
"Where-Object {{ $_.ProcessId -ne {own} -and "
"$_.Name -match '{name_re}' -and "
"$_.CommandLine -match '-m emrg' }} | "
"$_.CommandLine -match '{cmd_re}' }} | "
"ForEach-Object {{ Write-Output $_.ProcessId }}"
).format(own=own_pid, name_re=_WIN_PY_NAME_RE)
).format(own=own_pid, name_re=_WIN_PY_NAME_RE, cmd_re=cmd_re)
try:
out = subprocess.run(
["powershell", "-NoProfile", "-Command", ps_cmd],
Expand All @@ -251,6 +249,46 @@ def _scan_windows_python_emrg(own_pid: int) -> list[int]:
return [int(p) for p in out.split() if p.strip().isdigit()]


def _daemon_scan_pids(own_pid: int) -> list[int]:
"""Cmdline-scan pids of the daemon only (``-m emrg.server``) — the kill
fallback for stop_daemon (rant 2026-08-21T16:45:06). POSIX ps-scan filtered
to the daemon regex; Windows reuses the CIM scan with server_only."""
if is_win():
return _scan_windows_python_emrg(own_pid, server_only=True)
out = _ps_output()
if out is None:
return []
pids: list[int] = []
for line in out.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 _EMRG_SERVER_RE.search(parts[1]):
pids.append(pid)
return pids


def _port_is_open(port: int, timeout: float = 0.3) -> bool:
"""Fixed-port TCP probe — port open = a live daemon owns it (ground truth,
rant 2026-08-19T08:05:21). Mirrors emrg.connect.is_server_running_sync
semantics without importing the emrg package (standalone in installer)."""
try:
sock = socket.create_connection(("127.0.0.1", port), timeout=timeout)
sock.close()
return True
except OSError:
return False


# ── Minimal WebSocket client (RFC 6455, stdlib only) ────────────

def _ws_recv_exact(sock: socket.socket, n: int) -> bytes:
Expand Down Expand Up @@ -362,55 +400,52 @@ def ws_graceful_shutdown(port: int, token: str, timeout: float = 3.0) -> bool:
# ── Individual stop steps ───────────────────────────────────────

def stop_daemon() -> None:
"""Stop the daemon: ws shutdown → pid file → SIGTERM/taskkill /F → poll.

Also removes ``~/.emrg/emrgd.token`` once the daemon pid is confirmed dead
(the daemon itself removes it on graceful shutdown; a force-killed daemon
cannot, so we clean it up — the next daemon start re-asserts both files).
"""Stop the daemon: ws shutdown → fixed-port wait → cmdline-scan kill
fallback (``-m emrg.server``) → token cleanup (rant 2026-08-21T16:45:06:
the fixed port is the ground truth; the emrgd.pid file is gone, so liveness
is judged by the port and the kill target is found by command line).

Also removes ``~/.emrg/emrgd.token`` once the daemon port is confirmed
closed (the daemon itself removes it on graceful shutdown; a force-killed
daemon cannot, so we clean it up — the next daemon start re-asserts it).
"""
token_path = config_dir() / "emrgd.token"
# Fixed-port shutdown (rant 2026-08-19T08:05:21): the daemon always
# listens on _EMRGD_PORT; the token file only supplies the auth token
# (single line, rant 2026-08-20T14:32:52). If the file is missing/stale,
# fall through to the pid + cmdline paths.
# fall through to the cmdline-scan path.
try:
token = token_path.read_text(encoding="utf-8").strip()
except (OSError, ValueError):
token = ""
if token and ws_graceful_shutdown(_EMRGD_PORT, token):
# wait for the daemon to exit + remove its pid file
# (~10s grace: old stop-emrg.cmd v2 polled emrgd.pid up to
# 10s; a busy daemon mid-tool-loop needs the full window)
# wait for the daemon to exit — the port closing is the ground truth
# (~10s grace: old stop-emrg.cmd v2 polled up to 10s; a busy daemon
# mid-tool-loop needs the full window)
for _ in range(60):
pid = _read_pid_file()
if pid is None or not _pid_alive(pid):
if not _port_is_open(_EMRGD_PORT):
break
time.sleep(0.15)

pid = _read_pid_file()
if pid is not None and _pid_alive(pid):
# Fallback: the daemon survived the ws path (or no ws token) → kill by
# command-line identity (``-m emrg.server``), the only reliable marker on
# Windows (rant 2026-08-17T17:03:38). TUI clients were already stopped
# earlier in the chain, so the daemon-only scan is safe.
for pid in _daemon_scan_pids(os.getpid()):
if is_win():
_kill_pid_windows(pid)
else:
_kill_pid_posix(pid)
# poll up to 10s for it to disappear (matches old v2 grace window)
for _ in range(60):
if not _pid_alive(pid):
if not _port_is_open(_EMRGD_PORT):
break
time.sleep(0.15)

# Fallback: pid file missing/stale/mismatched → the live daemon (or any
# TUI client stop_tui could not reach) would otherwise survive and keep
# locking files under install\. Scan the command line — the only reliable
# identity on Windows (rant 2026-08-17T17:03:38; returns [] on POSIX).
for pid in _scan_windows_python_emrg(os.getpid()):
_kill_pid_windows(pid)

# Token file cleanup: the daemon removes it on graceful shutdown; a
# force-killed daemon cannot, so remove it once the pid is confirmed gone
# (the next daemon start re-asserts both files).
daemon_gone = pid is None or not _pid_alive(pid)
if daemon_gone:
# force-killed daemon cannot, so remove it once the port is confirmed
# closed (the next daemon start re-asserts it).
if not _port_is_open(_EMRGD_PORT):
try:
token_path.unlink()
except OSError:
Expand Down Expand Up @@ -1283,16 +1318,16 @@ def _verify_windows_categories(skip_gui: bool = False) -> list[tuple[str, list[s
pass
cats.append(("GUI", gui))

# daemon residual (emrgd.pid still alive)
# daemon residual (fixed-port TCP probe — rant 2026-08-21T16:45:06: the
# port is the daemon ground truth; emrgd.pid is gone)
daemon: list[str] = []
pid = _read_pid_file()
if pid is not None and _pid_alive(pid):
daemon.append(f"daemon (pid {pid})")
if _port_is_open(_EMRGD_PORT):
daemon.append(f"daemon (port {_EMRGD_PORT} open)")
cats.append(("daemon", daemon))

# python emrg process residual (TUI/daemon by command line — covers the
# pid-file blind spot: a live daemon with a missing/stale pid file would
# otherwise pass verify and the installer would overwrite locked files)
# port-probe blind spot: a live daemon on a different port / a TUI client
# the installer would otherwise miss)
py = [f"python emrg process (pid {p})" for p in _scan_windows_python_emrg(os.getpid())]
cats.append(("cmdline-scan", py))

Expand Down
18 changes: 9 additions & 9 deletions emrg/gui/daemon_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,12 +220,11 @@ class DaemonClient {
throw new Error(`emrgd failed to start within timeout${this._readLogTail()}`);
}

// Rant 2026-08-21T15:26:42:daemon 存活判断改用固定端口 TCP 探测——
// emrgd.pid 不再作为存活依据(可缺失/stale:stop_all 清理、崩溃、外部删除),
// Rant 2026-08-21T15:26:42:daemon 存活判断用固定端口 TCP 探测——
// 固定端口才是 ground truth(rant 2026-08-19T08:05:21,connect.py
// is_server_running_sync 同语义)。端口通 = daemon 活着 = 绝不删 token;
// 端口不通才允许 stale-token 删除+重拉路径。pid 文件降级为纯诊断
// (daemon 自己写/删,其他代码不再读它判断存活)
// is_server_running_sync 同语义;rant 2026-08-21T16:45:06 后 emrgd.pid 已彻底
// 移除)。端口通 = daemon 活着 = 绝不删 token;端口不通才允许 stale-token
// 删除+重拉路径
_daemonProcessAlive(timeoutMs = 1000) {
return new Promise((resolve) => {
const sock = net.connect({ host: "127.0.0.1", port: EMRGD_PORT, timeout: timeoutMs });
Expand Down Expand Up @@ -370,10 +369,11 @@ class DaemonClient {
await this._awaitOpen();
} catch (e) {
// G43 加固(rant 2026-08-09T13:16:36 根因):token 文件存在但连不上时,
// 先探测固定端口(rant 2026-08-21T15:26:42:TCP 探活,不再读 emrgd.pid)——
// daemon 还活着就【绝不删 token 文件】。旧 G43 直接 unlink 会把健康
// daemon 的 token 文件删掉 → 僵尸态(daemon 活着、scheduler 永远
// cannot connect、PID 锁挡住新 spawn)。只有 daemon 真死了才删+重拉。
// 先探测固定端口(rant 2026-08-21T15:26:42:TCP 探活;rant 16:45:06 后
// emrgd.pid 已彻底移除)——daemon 还活着就【绝不删 token 文件】。旧 G43
// 直接 unlink 会把健康 daemon 的 token 文件删掉 → 僵尸态(daemon 活着、
// scheduler 永远 cannot connect、PID 锁挡住新 spawn)。只有 daemon 真死
// 了才删+重拉。
if (await this._daemonProcessAlive()) {
this.logger.warn(
`[gui] ws connect failed: ${e.message} — daemon port alive, keeping token file (transient)`
Expand Down
26 changes: 2 additions & 24 deletions emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,18 +333,6 @@ async def serve(self) -> None:
self._stop_reason = "bind_exit"
return

# ── PID file: diagnostics only (rant 08-05:21 — no longer an
# admission gate). Written AFTER the fixed-port bind succeeded, so only
# the process that actually owns the port writes it; stop_all and
# diagnostics may still read it.
runtime_dir = config_dir()
pid_file = runtime_dir / "emrgd.pid"
try:
pid_file.write_text(str(os.getpid()), encoding="utf-8")
logger.debug("pid file written (diagnostic only): %s (pid=%d)", pid_file, os.getpid())
except OSError:
logger.warning("could not write diagnostic pid file %s", pid_file, exc_info=True)

self._server = await serve(
self._handle_client,
sock=sock,
Expand Down Expand Up @@ -409,9 +397,9 @@ async def serve(self) -> None:
self._stop_reason = "crash"
logger.error("daemon serve crashed — cleanup started", exc_info=True)
finally:
await self._shutdown_all(pid_file)
await self._shutdown_all()

async def _shutdown_all(self, pid_file: Path) -> None:
async def _shutdown_all(self) -> None:
"""Best-effort teardown with per-step logging (rant 2026-08-19T14:02:37).

Every daemon stop path funnels through here: shutdown message,
Expand Down Expand Up @@ -474,16 +462,6 @@ async def _shutdown_all(self, pid_file: Path) -> None:
steps.append(("removed port file", True))
except Exception:
steps.append(("removed port file", False))
# 5. PID file (diagnostic)
try:
if pid_file.exists() and pid_file.read_text(encoding="utf-8").strip() == str(os.getpid()):
pid_file.unlink()
logger.debug("pid file removed: %s", pid_file)
steps.append(("removed pid file", True))
else:
steps.append(("removed pid file (absent)", True))
except OSError:
steps.append(("removed pid file", False))

uptime = max(0, int((datetime.now() - self.start_time).total_seconds()))
all_ok = all(ok for _, ok in steps)
Expand Down
Loading
Loading