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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
emrg: stop_all dual-write log to fixed path ~/.emrg/logs (tee) by argszero · Pull Request #835 · argszero/emrg · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' emrg: stop_all dual-write log to fixed path ~/.emrg/logs (tee) by argszero · Pull Request #835 · argszero/emrg · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' emrg: stop_all dual-write log to fixed path ~/.emrg/logs (tee) by argszero · Pull Request #835 · argszero/emrg · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' emrg: stop_all dual-write log to fixed path ~/.emrg/logs (tee) by argszero · Pull Request #835 · argszero/emrg · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' emrg: stop_all dual-write log to fixed path ~/.emrg/logs (tee) by argszero · Pull Request #835 · argszero/emrg · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' emrg: stop_all dual-write log to fixed path ~/.emrg/logs (tee) by argszero · Pull Request #835 · argszero/emrg · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); emrg: stop_all dual-write log to fixed path ~/.emrg/logs (tee) by argszero · Pull Request #835 · argszero/emrg · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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