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` (929) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (933) — 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
58 changes: 58 additions & 0 deletions emrg/_stop_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path

# Build stamp printed at the start of every run so the operator can tell at a
Expand Down Expand Up @@ -999,6 +1000,54 @@ def _pythonpath_install_warning(line: str) -> str | None:
return None


class _Tee:
"""Duplicate write()/flush() to BOTH the original stdout and a log file
(rant 2026-08-18T11:20:54). The Inno installer redirects stop_all stdout
to a random temp dir ({tmp}\\stop_all.log) that is deleted when the
install ends or is cancelled — the tee keeps a persistent fixed-path copy
(~/.emrg/logs/stop_all-<ts>.log) for post-mortem forensics. All existing
print() calls keep working untouched (they write to sys.stdout, which is
replaced with a _Tee during stop_all)."""

def __init__(self, orig, f):
self.orig = orig
self.f = f

def write(self, data):
self.orig.write(data)
self.f.write(data)
# Crash-safe: if the installer force-kills this process mid-write,
# append-mode + per-write flush guarantees the fixed-path copy has
# everything printed so far (rant: 不依赖 finally).
self.f.flush()
return len(data)

def flush(self):
self.orig.flush()
self.f.flush()

def isatty(self):
return self.orig.isatty() if hasattr(self.orig, "isatty") else False

def fileno(self):
return self.orig.fileno()


def _open_stop_log() -> object | None:
"""Open the fixed-path dual-write log (rant 2026-08-18T11:20:54):
``~/.emrg/logs/stop_all-YYYYMMDD-HHMMSS.log`` (local time; the timestamp
name makes concurrent stop_all runs naturally isolated). Returns the file
object or None on any failure — best-effort, never breaks the stop flow.
The handle closes naturally at process exit (no finally dependency)."""
try:
d = os.path.join(os.path.expanduser("~"), ".emrg", "logs")
os.makedirs(d, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
return open(os.path.join(d, f"stop_all-{ts}.log"), "a", encoding="utf-8")
except OSError:
return None


def _step_plan() -> list[tuple[str, object]]:
"""Ordered stop steps. Clients (GUI/TUI) FIRST, daemon LAST (rant
2026-08-17T14:15:33): both clients auto-spawn the daemon when it
Expand Down Expand Up @@ -1030,6 +1079,15 @@ def stop_all() -> int:
``ERROR <step>: <reason>`` and the run continues to the final exit code).
"""
t0 = time.monotonic()
# Dual-write log to a fixed path (rant 2026-08-18T11:20:54): the Inno
# {tmp} redirect vanishes when the install ends/cancels — tee a persistent
# copy to ~/.emrg/logs/stop_all-<ts>.log. Every print below automatically
# lands in both. The line is also printed so the Inno-side log and the
# operator both see the fixed location.
_log_f = _open_stop_log()
if _log_f is not None:
sys.stdout = _Tee(sys.stdout, _log_f)
print(f"emrg stop: log also written to {_log_f.name}")
print(
f"emrg stop: stop_all.py {_STOP_ALL_STAMP} | "
f"python {platform.python_version()} {platform.system()}-{platform.machine()} "
Expand Down
61 changes: 60 additions & 1 deletion tests/test_stop_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def test_no_nonstdlib_imports(self):
allowed = {
"base64", "json", "os", "re", "secrets", "signal", "socket",
"subprocess", "sys", "time", "pathlib", "platform", "ctypes", "ast",
"pytest", "annotations", "__future__", "winreg",
"pytest", "annotations", "__future__", "winreg", "datetime",
}
for node in ast.walk(tree):
if isinstance(node, ast.Import):
Expand Down Expand Up @@ -956,3 +956,62 @@ def test_install_warning_positive_and_negative(self):
# unrelated install\lib path must NOT warn (pm25coder review note, PR #832)
assert _stop_all._pythonpath_install_warning(
r"PYTHONPATH(User)=C:\python\install\lib") is None


# ── Fixed-path dual-write log (rant 2026-08-18T11:20:54) ────────

class TestTeeDualWrite:
def test_tee_writes_to_both(self):
class _FakeFile:
def __init__(self):
self.data = []

def write(self, d):
self.data.append(d)

def flush(self):
pass

orig = _FakeFile()
logf = _FakeFile()
tee = _stop_all._Tee(orig, logf)
tee.write("line1\n")
tee.write("line2\n")
tee.flush()
assert orig.data == ["line1\n", "line2\n"]
assert logf.data == ["line1\n", "line2\n"]

def test_open_stop_log_creates_logs_dir(self, monkeypatch, tmp_path):
"""~/.emrg/logs is created and the file matches the timestamp pattern
(stop_all-YYYYMMDD-HHMMSS.log)."""
monkeypatch.setattr(_stop_all.os.path, "expanduser", lambda _: str(tmp_path))
f = _stop_all._open_stop_log()
assert f is not None
name = f.name
f.close()
assert str(tmp_path / ".emrg" / "logs") in name
import re
assert re.search(r"stop_all-\d{8}-\d{6}\.log$", name), name

def test_open_stop_log_oserror_returns_none(self, monkeypatch, tmp_path):
def boom(*a, **k):
raise OSError("cannot create logs dir")

monkeypatch.setattr(_stop_all.os, "makedirs", boom)
assert _stop_all._open_stop_log() is None

def test_stop_all_prints_tee_path(self, monkeypatch, tmp_path, capsys):
"""stop_all() with a tee open prints the fixed-path line; stdout output
still flows (POSIX regression: emrg stop output unchanged)."""
monkeypatch.setattr(_stop_all, "is_win", lambda: False)
monkeypatch.setattr(_stop_all.os.path, "expanduser", lambda _: str(tmp_path))
monkeypatch.setattr(_stop_all, "stop_gui", lambda: None)
monkeypatch.setattr(_stop_all, "stop_tui", lambda: None)
monkeypatch.setattr(_stop_all, "stop_daemon", lambda: None)
monkeypatch.setattr(_stop_all, "_stop_scan_pids", lambda own: [])
monkeypatch.setattr(_stop_all, "verify", lambda: [])
assert _stop_all.stop_all() == 0
out = capsys.readouterr().out
assert "log also written to" in out
assert str(tmp_path / ".emrg" / "logs") in out
assert "exit code 0 (clean)" in out
Loading