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` (971) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (974) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 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
83 changes: 54 additions & 29 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,7 @@

# Build stamp printed at the start of every run so the operator can tell at a
# glance which stop_all.py generation executed (rant 2026-08-17T21:06:31).
_STOP_ALL_STAMP = "built 2026-08-19 (fixed-port daemon shutdown — rant 08:05:21)"
_STOP_ALL_STAMP = "built 2026-08-19 (read-only lock probe + stop-chain caller log — rants 13:08:41 + 13:11:34)"

# Fixed daemon port (host rant 2026-08-19T08:05:21): the daemon binds a fixed
# loopback port as its single-instance admission. This module is pure stdlib
Expand DownExpand Up@@ -952,26 +952,36 @@ def _iter_install_files(root: str) -> list[str]:


def _win_exclusive_open(path: str) -> None:
"""Open an existing file with DELETE access + FILE_FLAG_DELETE_ON_CLOSE —
the exact semantic the Inno installer's DeleteFile needs. Raises OSError
when another process holds the file (DeleteFile code 5 would occur).
"""Open an existing file with DELETE access + FILE_SHARE_NONE — the
exact sharing semantic the Inno installer's DeleteFile needs. Raises
OSError when another process holds the file (DeleteFile code 5 would
occur).

DeleteFile semantics (rant 2026-08-18T16:09:45): a DLL loaded via
LoadLibrary holds the file with FILE_SHARE_READ only — GENERIC_READ +
FILE_SHARE_NONE probing succeeds (read sharing is granted) → false
"0 locked" while the installer's DeleteFile still fails (the image
section handle does not share FILE_SHARE_DELETE). Requesting DELETE
access (+ delete-on-close disposition, the probe = "would DeleteFile
succeed right now?") fails with ERROR_SHARING_VIOLATION on exactly the
files DeleteFile would fail on. FILE_SHARE_NONE is kept as a complement
— either condition failing means locked."""
access fails with ERROR_SHARING_VIOLATION on exactly the files
DeleteFile would fail on. FILE_SHARE_NONE is kept as a complement —
either condition failing means locked.

⚠️ NO delete-on-close disposition (rant 2026-08-19T13:08:41 — data-loss
bug): the v0.2.4x probe opened with the delete-on-close flag and cleared
it afterwards via the file-disposition-info API — but that clear only
works on Windows 10 1903+; on older systems (or any failed/best-effort
clear) the disposition stays set and closing the handle DELETES the
probed file. The disposition flag adds nothing to the access check
(DELETE access + share-none alone reproduces DeleteFile's sharing
semantics), so the probe now opens with plain FILE_ATTRIBUTE_NORMAL and
never sets a delete disposition — it can never delete anything, only
ask "would DeleteFile succeed?"."""
import ctypes

GENERIC_DELETE = 0x00010000
OPEN_EXISTING = 3
FILE_SHARE_NONE = 0
FILE_FLAG_DELETE_ON_CLOSE = 0x04000000
FILE_DISPOSITION_INFO = 2
FILE_ATTRIBUTE_NORMAL = 0x80
kernel32 = ctypes.windll.kernel32
# 64-bit handle truncation fix (rant 2026-08-18T09:40:40): ctypes defaults
# the restype of a foreign function to c_int — a 64-bit HANDLE gets
Expand All@@ -985,32 +995,15 @@ def _win_exclusive_open(path: str) -> None:
]
kernel32.CloseHandle.restype = ctypes.c_int
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
# SetFileInformationByHandle — after a SUCCESSFUL probe the delete-on-close
# mark must be cleared so the probe never actually deletes the file it
# verified as deletable (it only asks "would DeleteFile succeed?").
kernel32.SetFileInformationByHandle.restype = ctypes.c_int
kernel32.SetFileInformationByHandle.argtypes = [
ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32,
]

class _FileDisposition(ctypes.Structure):
_fields_ = [("DeleteFile", ctypes.c_ubyte)] # BOOLEAN

h = kernel32.CreateFileW(path, GENERIC_DELETE, FILE_SHARE_NONE, None,
OPEN_EXISTING, FILE_FLAG_DELETE_ON_CLOSE, None)
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, None)
# With restype=c_void_p a NULL handle arrives as None (not 0) — cover both
# forms; INVALID_HANDLE_VALUE is c_void_p(-1).value (pm25coder review note,
# PR #832). A failed DELETE open = the installer's DeleteFile would fail.
if not h or h == ctypes.c_void_p(-1).value:
raise OSError(f"CreateFileW failed for {path} (file is locked)")
try:
# Undo the delete-on-close disposition (DeleteFile=FALSE). We hold
# DELETE access, so this clear always succeeds; best-effort otherwise.
_fd = _FileDisposition(0)
kernel32.SetFileInformationByHandle(
h, FILE_DISPOSITION_INFO, ctypes.byref(_fd), ctypes.sizeof(_fd))
finally:
kernel32.CloseHandle(h)
kernel32.CloseHandle(h)


def _check_locked_files(root: str, try_open=None) -> list[str]:
Expand DownExpand Up@@ -1494,6 +1487,33 @@ def _open_stop_log() -> object | None:
return None


def _caller_context() -> str:
"""Best-effort "who called emrg stop" line (rant 2026-08-19T13:11:34):
parent pid + parent command line + our argv — so a post-mortem can
answer "谁杀 daemon / 谁删文件" (which process invoked the stop chain).
Pure stdlib; any failure degrades to the pid-only form, never raises."""
ppid = os.getppid()
parent = ""
try:
if is_win():
out = subprocess.run(
["powershell", "-NoProfile", "-Command",
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={ppid}').CommandLine"],
capture_output=True, text=True, timeout=10,
).stdout.strip()
else:
out = subprocess.run(
["ps", "-o", "command=", "-p", str(ppid)],
capture_output=True, text=True, timeout=10,
).stdout.strip()
if out:
parent = out.splitlines()[0][:160]
except Exception:
pass
argv = " ".join(sys.argv) or "(none)"
return f"caller pid {ppid} ({parent or 'unknown parent'}) | argv: {argv}"


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@@ -1551,6 +1571,11 @@ def stop_all() -> int:
f"python {platform.python_version()} {platform.system()}-{platform.machine()} "
f"| pid {os.getpid()}"
)
# Who called + when (rant 2026-08-19T13:11:34): every stop run must be
# attributable — parent pid/parent cmdline/argv + wall-clock start. This
# is the forensics trail for "谁杀 daemon / 谁删文件".
print(f"emrg stop: started {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"emrg stop: {_caller_context()}")
# Self-lock observability (rant 2026-08-18T16:09:45): when the installer
# runs stop_all with install\python-dist\python.exe, that interpreter's
# site config (._pth/.pth) may import install\lib modules → the runtime
Expand Down
10 changes: 9 additions & 1 deletion emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2024,7 +2024,15 @@ async def _process_message(
})

elif msg_type == "shutdown":
logger.info("shutdown requested by client")
# Rant 2026-08-19T13:11:34 — every daemon kill must be attributable:
# log the requesting peer (loopback client) alongside the action so
# "谁杀 daemon" can be traced from emrgd.log alone.
peer = ""
try:
peer = str(ws.remote_address)
except Exception:
peer = "unknown peer"
logger.info("shutdown requested by client (%s)", peer)
await self._send(ws, {"type": "shutdown_ack"})
try:
await ws.close()
Expand Down
24 changes: 17 additions & 7 deletions tests/test_installer_stop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,21 +146,31 @@ def test_stop_all_py_restart_manager_lock_owners():


def test_stop_all_py_deletefile_semantic_lock_probe():
"""rant 2026-08-18T16:09:45 — lock-probe 假阴性根因 + 修复。
"""rant 2026-08-18T16:09:45 — lock-probe 假阴性根因 + 修复;
rant 2026-08-19T13:08:41 — 数据删除 bug 修复(去掉 FILE_FLAG_DELETE_ON_CLOSE)。

v0.2.48 实证:GENERIC_READ + FILE_SHARE_NONE 探测对 DLL 锁永远假阴性——
LoadLibrary 持有句柄允许读共享 → 探测显示 0 locked,而安装器 DeleteFile
需要句柄共享 FILE_SHARE_DELETE → 覆盖时 code 5。修复 = DELETE 访问
(GENERIC_DELETE=0x10000)+ FILE_FLAG_DELETE_ON_CLOSE=0x04000000 +
OPEN_EXISTING,与安装器 DeleteFile 完全同语义;FILE_SHARE_NONE 保留补充;
探测成功后用 SetFileInformationByHandle 清除 delete-on-close(探测不真删)。
(GENERIC_DELETE=0x10000)+ OPEN_EXISTING + FILE_SHARE_NONE,与安装器
DeleteFile 的共享语义一致;探测成功即"DeleteFile 会成功",仅关闭句柄,
永不设置删除 disposition。

⚠️ 数据删除 bug(rant 2026-08-19T13:08:41):旧实现用
FILE_FLAG_DELETE_ON_CLOSE=0x04000000 打开后再用 SetFileInformationByHandle
清除 disposition——但该清除仅 Windows 10 1903+ 支持,旧系统/清除失败时
disposition 残留,CloseHandle 会真删文件。现探测用普通
FILE_ATTRIBUTE_NORMAL 打开,永不设置 disposition → 只问不删。
"""
content = _read("emrg/_stop_all.py")
assert "GENERIC_DELETE = 0x00010000" in content
assert "FILE_FLAG_DELETE_ON_CLOSE = 0x04000000" in content
assert "GENERIC_READ = 0x80000000" not in content # 旧常量赋值已移除
assert "SetFileInformationByHandle" in content
assert "FILE_DISPOSITION_INFO = 2" in content
# 数据删除 bug 修复:不得再出现 delete-on-close / disposition 清除
assert "FILE_FLAG_DELETE_ON_CLOSE" not in content
assert "SetFileInformationByHandle" not in content
assert "FILE_DISPOSITION_INFO" not in content
assert "FILE_ATTRIBUTE_NORMAL = 0x80" in content
assert "never sets a delete disposition" in content or "never set a delete disposition" in content
# 自锁防护(rant 2026-08-18T16:09:45,18:57:09 改为提示性):开头打印
# python-dist 运行时 + verify 对 self-held 锁不中止安装(stop_all 退出即释放)
assert "python-dist runtime:" in content
Expand Down
51 changes: 51 additions & 0 deletions tests/test_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@

from emrg import _stop_all
from emrg._stop_all import (
_caller_context,
_read_pid_file,
_verify_posix,
match_cmdline,
Expand DownExpand Up@@ -108,6 +109,56 @@ def test_read_pid_file(self, tmp_path, monkeypatch):
assert _read_pid_file() is None # non-positive


class TestCallerContext:
"""_caller_context — stop-chain forensics (rant 2026-08-19T13:11:34):
every stop run must record who invoked it (parent pid/cmdline + argv) so
"谁杀 daemon / 谁删文件" is traceable from the stop log alone."""

def test_posix_queries_parent_cmdline(self, monkeypatch):
calls: list = []
monkeypatch.setattr(_stop_all, "is_win", lambda: False)

class CP:
stdout = "python -m emrg stop\n"

monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: calls.append(cmd) or CP(),
)
out = _caller_context()
assert calls and "ps" in calls[0] # POSIX parent probe
assert "caller pid" in out
assert "python -m emrg stop" in out
assert "argv" in out

def test_windows_queries_powershell_cim(self, monkeypatch):
calls: list = []
monkeypatch.setattr(_stop_all, "is_win", lambda: True)

class CP:
stdout = "C:\\Python313\\python.exe -m emrg stop\n"

monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: calls.append(cmd) or CP(),
)
out = _caller_context()
assert calls and "Get-CimInstance" in calls[0][-1] # Windows parent probe
assert "caller pid" in out
assert "-m emrg stop" in out

def test_probe_failure_degrades_gracefully(self, monkeypatch):
monkeypatch.setattr(_stop_all, "is_win", lambda: False)
monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: (_ for _ in ()).throw(RuntimeError("no ps")),
)
out = _caller_context()
assert "caller pid" in out
assert "unknown parent" in out
assert "argv" in out


class TestVerifyPosix:
def test_no_residuals(self, monkeypatch):
monkeypatch.setattr(_stop_all, "_stop_scan_pids", lambda own: [])
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n 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;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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` (971) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (974) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 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
83 changes: 54 additions & 29 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,7 @@

# Build stamp printed at the start of every run so the operator can tell at a
# glance which stop_all.py generation executed (rant 2026-08-17T21:06:31).
_STOP_ALL_STAMP = "built 2026-08-19 (fixed-port daemon shutdown — rant 08:05:21)"
_STOP_ALL_STAMP = "built 2026-08-19 (read-only lock probe + stop-chain caller log — rants 13:08:41 + 13:11:34)"

# Fixed daemon port (host rant 2026-08-19T08:05:21): the daemon binds a fixed
# loopback port as its single-instance admission. This module is pure stdlib
Expand DownExpand Up@@ -952,26 +952,36 @@ def _iter_install_files(root: str) -> list[str]:


def _win_exclusive_open(path: str) -> None:
"""Open an existing file with DELETE access + FILE_FLAG_DELETE_ON_CLOSE —
the exact semantic the Inno installer's DeleteFile needs. Raises OSError
when another process holds the file (DeleteFile code 5 would occur).
"""Open an existing file with DELETE access + FILE_SHARE_NONE — the
exact sharing semantic the Inno installer's DeleteFile needs. Raises
OSError when another process holds the file (DeleteFile code 5 would
occur).

DeleteFile semantics (rant 2026-08-18T16:09:45): a DLL loaded via
LoadLibrary holds the file with FILE_SHARE_READ only — GENERIC_READ +
FILE_SHARE_NONE probing succeeds (read sharing is granted) → false
"0 locked" while the installer's DeleteFile still fails (the image
section handle does not share FILE_SHARE_DELETE). Requesting DELETE
access (+ delete-on-close disposition, the probe = "would DeleteFile
succeed right now?") fails with ERROR_SHARING_VIOLATION on exactly the
files DeleteFile would fail on. FILE_SHARE_NONE is kept as a complement
— either condition failing means locked."""
access fails with ERROR_SHARING_VIOLATION on exactly the files
DeleteFile would fail on. FILE_SHARE_NONE is kept as a complement —
either condition failing means locked.

⚠️ NO delete-on-close disposition (rant 2026-08-19T13:08:41 — data-loss
bug): the v0.2.4x probe opened with the delete-on-close flag and cleared
it afterwards via the file-disposition-info API — but that clear only
works on Windows 10 1903+; on older systems (or any failed/best-effort
clear) the disposition stays set and closing the handle DELETES the
probed file. The disposition flag adds nothing to the access check
(DELETE access + share-none alone reproduces DeleteFile's sharing
semantics), so the probe now opens with plain FILE_ATTRIBUTE_NORMAL and
never sets a delete disposition — it can never delete anything, only
ask "would DeleteFile succeed?"."""
import ctypes

GENERIC_DELETE = 0x00010000
OPEN_EXISTING = 3
FILE_SHARE_NONE = 0
FILE_FLAG_DELETE_ON_CLOSE = 0x04000000
FILE_DISPOSITION_INFO = 2
FILE_ATTRIBUTE_NORMAL = 0x80
kernel32 = ctypes.windll.kernel32
# 64-bit handle truncation fix (rant 2026-08-18T09:40:40): ctypes defaults
# the restype of a foreign function to c_int — a 64-bit HANDLE gets
Expand All@@ -985,32 +995,15 @@ def _win_exclusive_open(path: str) -> None:
]
kernel32.CloseHandle.restype = ctypes.c_int
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
# SetFileInformationByHandle — after a SUCCESSFUL probe the delete-on-close
# mark must be cleared so the probe never actually deletes the file it
# verified as deletable (it only asks "would DeleteFile succeed?").
kernel32.SetFileInformationByHandle.restype = ctypes.c_int
kernel32.SetFileInformationByHandle.argtypes = [
ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32,
]

class _FileDisposition(ctypes.Structure):
_fields_ = [("DeleteFile", ctypes.c_ubyte)] # BOOLEAN

h = kernel32.CreateFileW(path, GENERIC_DELETE, FILE_SHARE_NONE, None,
OPEN_EXISTING, FILE_FLAG_DELETE_ON_CLOSE, None)
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, None)
# With restype=c_void_p a NULL handle arrives as None (not 0) — cover both
# forms; INVALID_HANDLE_VALUE is c_void_p(-1).value (pm25coder review note,
# PR #832). A failed DELETE open = the installer's DeleteFile would fail.
if not h or h == ctypes.c_void_p(-1).value:
raise OSError(f"CreateFileW failed for {path} (file is locked)")
try:
# Undo the delete-on-close disposition (DeleteFile=FALSE). We hold
# DELETE access, so this clear always succeeds; best-effort otherwise.
_fd = _FileDisposition(0)
kernel32.SetFileInformationByHandle(
h, FILE_DISPOSITION_INFO, ctypes.byref(_fd), ctypes.sizeof(_fd))
finally:
kernel32.CloseHandle(h)
kernel32.CloseHandle(h)


def _check_locked_files(root: str, try_open=None) -> list[str]:
Expand DownExpand Up@@ -1494,6 +1487,33 @@ def _open_stop_log() -> object | None:
return None


def _caller_context() -> str:
"""Best-effort "who called emrg stop" line (rant 2026-08-19T13:11:34):
parent pid + parent command line + our argv — so a post-mortem can
answer "谁杀 daemon / 谁删文件" (which process invoked the stop chain).
Pure stdlib; any failure degrades to the pid-only form, never raises."""
ppid = os.getppid()
parent = ""
try:
if is_win():
out = subprocess.run(
["powershell", "-NoProfile", "-Command",
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={ppid}').CommandLine"],
capture_output=True, text=True, timeout=10,
).stdout.strip()
else:
out = subprocess.run(
["ps", "-o", "command=", "-p", str(ppid)],
capture_output=True, text=True, timeout=10,
).stdout.strip()
if out:
parent = out.splitlines()[0][:160]
except Exception:
pass
argv = " ".join(sys.argv) or "(none)"
return f"caller pid {ppid} ({parent or 'unknown parent'}) | argv: {argv}"


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@@ -1551,6 +1571,11 @@ def stop_all() -> int:
f"python {platform.python_version()} {platform.system()}-{platform.machine()} "
f"| pid {os.getpid()}"
)
# Who called + when (rant 2026-08-19T13:11:34): every stop run must be
# attributable — parent pid/parent cmdline/argv + wall-clock start. This
# is the forensics trail for "谁杀 daemon / 谁删文件".
print(f"emrg stop: started {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"emrg stop: {_caller_context()}")
# Self-lock observability (rant 2026-08-18T16:09:45): when the installer
# runs stop_all with install\python-dist\python.exe, that interpreter's
# site config (._pth/.pth) may import install\lib modules → the runtime
Expand Down
10 changes: 9 additions & 1 deletion emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2024,7 +2024,15 @@ async def _process_message(
})

elif msg_type == "shutdown":
logger.info("shutdown requested by client")
# Rant 2026-08-19T13:11:34 — every daemon kill must be attributable:
# log the requesting peer (loopback client) alongside the action so
# "谁杀 daemon" can be traced from emrgd.log alone.
peer = ""
try:
peer = str(ws.remote_address)
except Exception:
peer = "unknown peer"
logger.info("shutdown requested by client (%s)", peer)
await self._send(ws, {"type": "shutdown_ack"})
try:
await ws.close()
Expand Down
24 changes: 17 additions & 7 deletions tests/test_installer_stop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,21 +146,31 @@ def test_stop_all_py_restart_manager_lock_owners():


def test_stop_all_py_deletefile_semantic_lock_probe():
"""rant 2026-08-18T16:09:45 — lock-probe 假阴性根因 + 修复。
"""rant 2026-08-18T16:09:45 — lock-probe 假阴性根因 + 修复;
rant 2026-08-19T13:08:41 — 数据删除 bug 修复(去掉 FILE_FLAG_DELETE_ON_CLOSE)。

v0.2.48 实证:GENERIC_READ + FILE_SHARE_NONE 探测对 DLL 锁永远假阴性——
LoadLibrary 持有句柄允许读共享 → 探测显示 0 locked,而安装器 DeleteFile
需要句柄共享 FILE_SHARE_DELETE → 覆盖时 code 5。修复 = DELETE 访问
(GENERIC_DELETE=0x10000)+ FILE_FLAG_DELETE_ON_CLOSE=0x04000000 +
OPEN_EXISTING,与安装器 DeleteFile 完全同语义;FILE_SHARE_NONE 保留补充;
探测成功后用 SetFileInformationByHandle 清除 delete-on-close(探测不真删)。
(GENERIC_DELETE=0x10000)+ OPEN_EXISTING + FILE_SHARE_NONE,与安装器
DeleteFile 的共享语义一致;探测成功即"DeleteFile 会成功",仅关闭句柄,
永不设置删除 disposition。

⚠️ 数据删除 bug(rant 2026-08-19T13:08:41):旧实现用
FILE_FLAG_DELETE_ON_CLOSE=0x04000000 打开后再用 SetFileInformationByHandle
清除 disposition——但该清除仅 Windows 10 1903+ 支持,旧系统/清除失败时
disposition 残留,CloseHandle 会真删文件。现探测用普通
FILE_ATTRIBUTE_NORMAL 打开,永不设置 disposition → 只问不删。
"""
content = _read("emrg/_stop_all.py")
assert "GENERIC_DELETE = 0x00010000" in content
assert "FILE_FLAG_DELETE_ON_CLOSE = 0x04000000" in content
assert "GENERIC_READ = 0x80000000" not in content # 旧常量赋值已移除
assert "SetFileInformationByHandle" in content
assert "FILE_DISPOSITION_INFO = 2" in content
# 数据删除 bug 修复:不得再出现 delete-on-close / disposition 清除
assert "FILE_FLAG_DELETE_ON_CLOSE" not in content
assert "SetFileInformationByHandle" not in content
assert "FILE_DISPOSITION_INFO" not in content
assert "FILE_ATTRIBUTE_NORMAL = 0x80" in content
assert "never sets a delete disposition" in content or "never set a delete disposition" in content
# 自锁防护(rant 2026-08-18T16:09:45,18:57:09 改为提示性):开头打印
# python-dist 运行时 + verify 对 self-held 锁不中止安装(stop_all 退出即释放)
assert "python-dist runtime:" in content
Expand Down
51 changes: 51 additions & 0 deletions tests/test_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@

from emrg import _stop_all
from emrg._stop_all import (
_caller_context,
_read_pid_file,
_verify_posix,
match_cmdline,
Expand DownExpand Up@@ -108,6 +109,56 @@ def test_read_pid_file(self, tmp_path, monkeypatch):
assert _read_pid_file() is None # non-positive


class TestCallerContext:
"""_caller_context — stop-chain forensics (rant 2026-08-19T13:11:34):
every stop run must record who invoked it (parent pid/cmdline + argv) so
"谁杀 daemon / 谁删文件" is traceable from the stop log alone."""

def test_posix_queries_parent_cmdline(self, monkeypatch):
calls: list = []
monkeypatch.setattr(_stop_all, "is_win", lambda: False)

class CP:
stdout = "python -m emrg stop\n"

monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: calls.append(cmd) or CP(),
)
out = _caller_context()
assert calls and "ps" in calls[0] # POSIX parent probe
assert "caller pid" in out
assert "python -m emrg stop" in out
assert "argv" in out

def test_windows_queries_powershell_cim(self, monkeypatch):
calls: list = []
monkeypatch.setattr(_stop_all, "is_win", lambda: True)

class CP:
stdout = "C:\\Python313\\python.exe -m emrg stop\n"

monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: calls.append(cmd) or CP(),
)
out = _caller_context()
assert calls and "Get-CimInstance" in calls[0][-1] # Windows parent probe
assert "caller pid" in out
assert "-m emrg stop" in out

def test_probe_failure_degrades_gracefully(self, monkeypatch):
monkeypatch.setattr(_stop_all, "is_win", lambda: False)
monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: (_ for _ in ()).throw(RuntimeError("no ps")),
)
out = _caller_context()
assert "caller pid" in out
assert "unknown parent" in out
assert "argv" in out


class TestVerifyPosix:
def test_no_residuals(self, monkeypatch):
monkeypatch.setattr(_stop_all, "_stop_scan_pids", lambda own: [])
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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` (971) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (974) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 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
83 changes: 54 additions & 29 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,7 @@

# Build stamp printed at the start of every run so the operator can tell at a
# glance which stop_all.py generation executed (rant 2026-08-17T21:06:31).
_STOP_ALL_STAMP = "built 2026-08-19 (fixed-port daemon shutdown — rant 08:05:21)"
_STOP_ALL_STAMP = "built 2026-08-19 (read-only lock probe + stop-chain caller log — rants 13:08:41 + 13:11:34)"

# Fixed daemon port (host rant 2026-08-19T08:05:21): the daemon binds a fixed
# loopback port as its single-instance admission. This module is pure stdlib
Expand DownExpand Up@@ -952,26 +952,36 @@ def _iter_install_files(root: str) -> list[str]:


def _win_exclusive_open(path: str) -> None:
"""Open an existing file with DELETE access + FILE_FLAG_DELETE_ON_CLOSE —
the exact semantic the Inno installer's DeleteFile needs. Raises OSError
when another process holds the file (DeleteFile code 5 would occur).
"""Open an existing file with DELETE access + FILE_SHARE_NONE — the
exact sharing semantic the Inno installer's DeleteFile needs. Raises
OSError when another process holds the file (DeleteFile code 5 would
occur).

DeleteFile semantics (rant 2026-08-18T16:09:45): a DLL loaded via
LoadLibrary holds the file with FILE_SHARE_READ only — GENERIC_READ +
FILE_SHARE_NONE probing succeeds (read sharing is granted) → false
"0 locked" while the installer's DeleteFile still fails (the image
section handle does not share FILE_SHARE_DELETE). Requesting DELETE
access (+ delete-on-close disposition, the probe = "would DeleteFile
succeed right now?") fails with ERROR_SHARING_VIOLATION on exactly the
files DeleteFile would fail on. FILE_SHARE_NONE is kept as a complement
— either condition failing means locked."""
access fails with ERROR_SHARING_VIOLATION on exactly the files
DeleteFile would fail on. FILE_SHARE_NONE is kept as a complement —
either condition failing means locked.

⚠️ NO delete-on-close disposition (rant 2026-08-19T13:08:41 — data-loss
bug): the v0.2.4x probe opened with the delete-on-close flag and cleared
it afterwards via the file-disposition-info API — but that clear only
works on Windows 10 1903+; on older systems (or any failed/best-effort
clear) the disposition stays set and closing the handle DELETES the
probed file. The disposition flag adds nothing to the access check
(DELETE access + share-none alone reproduces DeleteFile's sharing
semantics), so the probe now opens with plain FILE_ATTRIBUTE_NORMAL and
never sets a delete disposition — it can never delete anything, only
ask "would DeleteFile succeed?"."""
import ctypes

GENERIC_DELETE = 0x00010000
OPEN_EXISTING = 3
FILE_SHARE_NONE = 0
FILE_FLAG_DELETE_ON_CLOSE = 0x04000000
FILE_DISPOSITION_INFO = 2
FILE_ATTRIBUTE_NORMAL = 0x80
kernel32 = ctypes.windll.kernel32
# 64-bit handle truncation fix (rant 2026-08-18T09:40:40): ctypes defaults
# the restype of a foreign function to c_int — a 64-bit HANDLE gets
Expand All@@ -985,32 +995,15 @@ def _win_exclusive_open(path: str) -> None:
]
kernel32.CloseHandle.restype = ctypes.c_int
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
# SetFileInformationByHandle — after a SUCCESSFUL probe the delete-on-close
# mark must be cleared so the probe never actually deletes the file it
# verified as deletable (it only asks "would DeleteFile succeed?").
kernel32.SetFileInformationByHandle.restype = ctypes.c_int
kernel32.SetFileInformationByHandle.argtypes = [
ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32,
]

class _FileDisposition(ctypes.Structure):
_fields_ = [("DeleteFile", ctypes.c_ubyte)] # BOOLEAN

h = kernel32.CreateFileW(path, GENERIC_DELETE, FILE_SHARE_NONE, None,
OPEN_EXISTING, FILE_FLAG_DELETE_ON_CLOSE, None)
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, None)
# With restype=c_void_p a NULL handle arrives as None (not 0) — cover both
# forms; INVALID_HANDLE_VALUE is c_void_p(-1).value (pm25coder review note,
# PR #832). A failed DELETE open = the installer's DeleteFile would fail.
if not h or h == ctypes.c_void_p(-1).value:
raise OSError(f"CreateFileW failed for {path} (file is locked)")
try:
# Undo the delete-on-close disposition (DeleteFile=FALSE). We hold
# DELETE access, so this clear always succeeds; best-effort otherwise.
_fd = _FileDisposition(0)
kernel32.SetFileInformationByHandle(
h, FILE_DISPOSITION_INFO, ctypes.byref(_fd), ctypes.sizeof(_fd))
finally:
kernel32.CloseHandle(h)
kernel32.CloseHandle(h)


def _check_locked_files(root: str, try_open=None) -> list[str]:
Expand DownExpand Up@@ -1494,6 +1487,33 @@ def _open_stop_log() -> object | None:
return None


def _caller_context() -> str:
"""Best-effort "who called emrg stop" line (rant 2026-08-19T13:11:34):
parent pid + parent command line + our argv — so a post-mortem can
answer "谁杀 daemon / 谁删文件" (which process invoked the stop chain).
Pure stdlib; any failure degrades to the pid-only form, never raises."""
ppid = os.getppid()
parent = ""
try:
if is_win():
out = subprocess.run(
["powershell", "-NoProfile", "-Command",
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={ppid}').CommandLine"],
capture_output=True, text=True, timeout=10,
).stdout.strip()
else:
out = subprocess.run(
["ps", "-o", "command=", "-p", str(ppid)],
capture_output=True, text=True, timeout=10,
).stdout.strip()
if out:
parent = out.splitlines()[0][:160]
except Exception:
pass
argv = " ".join(sys.argv) or "(none)"
return f"caller pid {ppid} ({parent or 'unknown parent'}) | argv: {argv}"


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@@ -1551,6 +1571,11 @@ def stop_all() -> int:
f"python {platform.python_version()} {platform.system()}-{platform.machine()} "
f"| pid {os.getpid()}"
)
# Who called + when (rant 2026-08-19T13:11:34): every stop run must be
# attributable — parent pid/parent cmdline/argv + wall-clock start. This
# is the forensics trail for "谁杀 daemon / 谁删文件".
print(f"emrg stop: started {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"emrg stop: {_caller_context()}")
# Self-lock observability (rant 2026-08-18T16:09:45): when the installer
# runs stop_all with install\python-dist\python.exe, that interpreter's
# site config (._pth/.pth) may import install\lib modules → the runtime
Expand Down
10 changes: 9 additions & 1 deletion emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2024,7 +2024,15 @@ async def _process_message(
})

elif msg_type == "shutdown":
logger.info("shutdown requested by client")
# Rant 2026-08-19T13:11:34 — every daemon kill must be attributable:
# log the requesting peer (loopback client) alongside the action so
# "谁杀 daemon" can be traced from emrgd.log alone.
peer = ""
try:
peer = str(ws.remote_address)
except Exception:
peer = "unknown peer"
logger.info("shutdown requested by client (%s)", peer)
await self._send(ws, {"type": "shutdown_ack"})
try:
await ws.close()
Expand Down
24 changes: 17 additions & 7 deletions tests/test_installer_stop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,21 +146,31 @@ def test_stop_all_py_restart_manager_lock_owners():


def test_stop_all_py_deletefile_semantic_lock_probe():
"""rant 2026-08-18T16:09:45 — lock-probe 假阴性根因 + 修复。
"""rant 2026-08-18T16:09:45 — lock-probe 假阴性根因 + 修复;
rant 2026-08-19T13:08:41 — 数据删除 bug 修复(去掉 FILE_FLAG_DELETE_ON_CLOSE)。

v0.2.48 实证:GENERIC_READ + FILE_SHARE_NONE 探测对 DLL 锁永远假阴性——
LoadLibrary 持有句柄允许读共享 → 探测显示 0 locked,而安装器 DeleteFile
需要句柄共享 FILE_SHARE_DELETE → 覆盖时 code 5。修复 = DELETE 访问
(GENERIC_DELETE=0x10000)+ FILE_FLAG_DELETE_ON_CLOSE=0x04000000 +
OPEN_EXISTING,与安装器 DeleteFile 完全同语义;FILE_SHARE_NONE 保留补充;
探测成功后用 SetFileInformationByHandle 清除 delete-on-close(探测不真删)。
(GENERIC_DELETE=0x10000)+ OPEN_EXISTING + FILE_SHARE_NONE,与安装器
DeleteFile 的共享语义一致;探测成功即"DeleteFile 会成功",仅关闭句柄,
永不设置删除 disposition。

⚠️ 数据删除 bug(rant 2026-08-19T13:08:41):旧实现用
FILE_FLAG_DELETE_ON_CLOSE=0x04000000 打开后再用 SetFileInformationByHandle
清除 disposition——但该清除仅 Windows 10 1903+ 支持,旧系统/清除失败时
disposition 残留,CloseHandle 会真删文件。现探测用普通
FILE_ATTRIBUTE_NORMAL 打开,永不设置 disposition → 只问不删。
"""
content = _read("emrg/_stop_all.py")
assert "GENERIC_DELETE = 0x00010000" in content
assert "FILE_FLAG_DELETE_ON_CLOSE = 0x04000000" in content
assert "GENERIC_READ = 0x80000000" not in content # 旧常量赋值已移除
assert "SetFileInformationByHandle" in content
assert "FILE_DISPOSITION_INFO = 2" in content
# 数据删除 bug 修复:不得再出现 delete-on-close / disposition 清除
assert "FILE_FLAG_DELETE_ON_CLOSE" not in content
assert "SetFileInformationByHandle" not in content
assert "FILE_DISPOSITION_INFO" not in content
assert "FILE_ATTRIBUTE_NORMAL = 0x80" in content
assert "never sets a delete disposition" in content or "never set a delete disposition" in content
# 自锁防护(rant 2026-08-18T16:09:45,18:57:09 改为提示性):开头打印
# python-dist 运行时 + verify 对 self-held 锁不中止安装(stop_all 退出即释放)
assert "python-dist runtime:" in content
Expand Down
51 changes: 51 additions & 0 deletions tests/test_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@

from emrg import _stop_all
from emrg._stop_all import (
_caller_context,
_read_pid_file,
_verify_posix,
match_cmdline,
Expand DownExpand Up@@ -108,6 +109,56 @@ def test_read_pid_file(self, tmp_path, monkeypatch):
assert _read_pid_file() is None # non-positive


class TestCallerContext:
"""_caller_context — stop-chain forensics (rant 2026-08-19T13:11:34):
every stop run must record who invoked it (parent pid/cmdline + argv) so
"谁杀 daemon / 谁删文件" is traceable from the stop log alone."""

def test_posix_queries_parent_cmdline(self, monkeypatch):
calls: list = []
monkeypatch.setattr(_stop_all, "is_win", lambda: False)

class CP:
stdout = "python -m emrg stop\n"

monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: calls.append(cmd) or CP(),
)
out = _caller_context()
assert calls and "ps" in calls[0] # POSIX parent probe
assert "caller pid" in out
assert "python -m emrg stop" in out
assert "argv" in out

def test_windows_queries_powershell_cim(self, monkeypatch):
calls: list = []
monkeypatch.setattr(_stop_all, "is_win", lambda: True)

class CP:
stdout = "C:\\Python313\\python.exe -m emrg stop\n"

monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: calls.append(cmd) or CP(),
)
out = _caller_context()
assert calls and "Get-CimInstance" in calls[0][-1] # Windows parent probe
assert "caller pid" in out
assert "-m emrg stop" in out

def test_probe_failure_degrades_gracefully(self, monkeypatch):
monkeypatch.setattr(_stop_all, "is_win", lambda: False)
monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: (_ for _ in ()).throw(RuntimeError("no ps")),
)
out = _caller_context()
assert "caller pid" in out
assert "unknown parent" in out
assert "argv" in out


class TestVerifyPosix:
def test_no_residuals(self, monkeypatch):
monkeypatch.setattr(_stop_all, "_stop_scan_pids", lambda own: [])
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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` (971) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (974) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 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
83 changes: 54 additions & 29 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,7 @@

# Build stamp printed at the start of every run so the operator can tell at a
# glance which stop_all.py generation executed (rant 2026-08-17T21:06:31).
_STOP_ALL_STAMP = "built 2026-08-19 (fixed-port daemon shutdown — rant 08:05:21)"
_STOP_ALL_STAMP = "built 2026-08-19 (read-only lock probe + stop-chain caller log — rants 13:08:41 + 13:11:34)"

# Fixed daemon port (host rant 2026-08-19T08:05:21): the daemon binds a fixed
# loopback port as its single-instance admission. This module is pure stdlib
Expand DownExpand Up@@ -952,26 +952,36 @@ def _iter_install_files(root: str) -> list[str]:


def _win_exclusive_open(path: str) -> None:
"""Open an existing file with DELETE access + FILE_FLAG_DELETE_ON_CLOSE —
the exact semantic the Inno installer's DeleteFile needs. Raises OSError
when another process holds the file (DeleteFile code 5 would occur).
"""Open an existing file with DELETE access + FILE_SHARE_NONE — the
exact sharing semantic the Inno installer's DeleteFile needs. Raises
OSError when another process holds the file (DeleteFile code 5 would
occur).

DeleteFile semantics (rant 2026-08-18T16:09:45): a DLL loaded via
LoadLibrary holds the file with FILE_SHARE_READ only — GENERIC_READ +
FILE_SHARE_NONE probing succeeds (read sharing is granted) → false
"0 locked" while the installer's DeleteFile still fails (the image
section handle does not share FILE_SHARE_DELETE). Requesting DELETE
access (+ delete-on-close disposition, the probe = "would DeleteFile
succeed right now?") fails with ERROR_SHARING_VIOLATION on exactly the
files DeleteFile would fail on. FILE_SHARE_NONE is kept as a complement
— either condition failing means locked."""
access fails with ERROR_SHARING_VIOLATION on exactly the files
DeleteFile would fail on. FILE_SHARE_NONE is kept as a complement —
either condition failing means locked.

⚠️ NO delete-on-close disposition (rant 2026-08-19T13:08:41 — data-loss
bug): the v0.2.4x probe opened with the delete-on-close flag and cleared
it afterwards via the file-disposition-info API — but that clear only
works on Windows 10 1903+; on older systems (or any failed/best-effort
clear) the disposition stays set and closing the handle DELETES the
probed file. The disposition flag adds nothing to the access check
(DELETE access + share-none alone reproduces DeleteFile's sharing
semantics), so the probe now opens with plain FILE_ATTRIBUTE_NORMAL and
never sets a delete disposition — it can never delete anything, only
ask "would DeleteFile succeed?"."""
import ctypes

GENERIC_DELETE = 0x00010000
OPEN_EXISTING = 3
FILE_SHARE_NONE = 0
FILE_FLAG_DELETE_ON_CLOSE = 0x04000000
FILE_DISPOSITION_INFO = 2
FILE_ATTRIBUTE_NORMAL = 0x80
kernel32 = ctypes.windll.kernel32
# 64-bit handle truncation fix (rant 2026-08-18T09:40:40): ctypes defaults
# the restype of a foreign function to c_int — a 64-bit HANDLE gets
Expand All@@ -985,32 +995,15 @@ def _win_exclusive_open(path: str) -> None:
]
kernel32.CloseHandle.restype = ctypes.c_int
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
# SetFileInformationByHandle — after a SUCCESSFUL probe the delete-on-close
# mark must be cleared so the probe never actually deletes the file it
# verified as deletable (it only asks "would DeleteFile succeed?").
kernel32.SetFileInformationByHandle.restype = ctypes.c_int
kernel32.SetFileInformationByHandle.argtypes = [
ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32,
]

class _FileDisposition(ctypes.Structure):
_fields_ = [("DeleteFile", ctypes.c_ubyte)] # BOOLEAN

h = kernel32.CreateFileW(path, GENERIC_DELETE, FILE_SHARE_NONE, None,
OPEN_EXISTING, FILE_FLAG_DELETE_ON_CLOSE, None)
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, None)
# With restype=c_void_p a NULL handle arrives as None (not 0) — cover both
# forms; INVALID_HANDLE_VALUE is c_void_p(-1).value (pm25coder review note,
# PR #832). A failed DELETE open = the installer's DeleteFile would fail.
if not h or h == ctypes.c_void_p(-1).value:
raise OSError(f"CreateFileW failed for {path} (file is locked)")
try:
# Undo the delete-on-close disposition (DeleteFile=FALSE). We hold
# DELETE access, so this clear always succeeds; best-effort otherwise.
_fd = _FileDisposition(0)
kernel32.SetFileInformationByHandle(
h, FILE_DISPOSITION_INFO, ctypes.byref(_fd), ctypes.sizeof(_fd))
finally:
kernel32.CloseHandle(h)
kernel32.CloseHandle(h)


def _check_locked_files(root: str, try_open=None) -> list[str]:
Expand DownExpand Up@@ -1494,6 +1487,33 @@ def _open_stop_log() -> object | None:
return None


def _caller_context() -> str:
"""Best-effort "who called emrg stop" line (rant 2026-08-19T13:11:34):
parent pid + parent command line + our argv — so a post-mortem can
answer "谁杀 daemon / 谁删文件" (which process invoked the stop chain).
Pure stdlib; any failure degrades to the pid-only form, never raises."""
ppid = os.getppid()
parent = ""
try:
if is_win():
out = subprocess.run(
["powershell", "-NoProfile", "-Command",
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={ppid}').CommandLine"],
capture_output=True, text=True, timeout=10,
).stdout.strip()
else:
out = subprocess.run(
["ps", "-o", "command=", "-p", str(ppid)],
capture_output=True, text=True, timeout=10,
).stdout.strip()
if out:
parent = out.splitlines()[0][:160]
except Exception:
pass
argv = " ".join(sys.argv) or "(none)"
return f"caller pid {ppid} ({parent or 'unknown parent'}) | argv: {argv}"


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@@ -1551,6 +1571,11 @@ def stop_all() -> int:
f"python {platform.python_version()} {platform.system()}-{platform.machine()} "
f"| pid {os.getpid()}"
)
# Who called + when (rant 2026-08-19T13:11:34): every stop run must be
# attributable — parent pid/parent cmdline/argv + wall-clock start. This
# is the forensics trail for "谁杀 daemon / 谁删文件".
print(f"emrg stop: started {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"emrg stop: {_caller_context()}")
# Self-lock observability (rant 2026-08-18T16:09:45): when the installer
# runs stop_all with install\python-dist\python.exe, that interpreter's
# site config (._pth/.pth) may import install\lib modules → the runtime
Expand Down
10 changes: 9 additions & 1 deletion emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2024,7 +2024,15 @@ async def _process_message(
})

elif msg_type == "shutdown":
logger.info("shutdown requested by client")
# Rant 2026-08-19T13:11:34 — every daemon kill must be attributable:
# log the requesting peer (loopback client) alongside the action so
# "谁杀 daemon" can be traced from emrgd.log alone.
peer = ""
try:
peer = str(ws.remote_address)
except Exception:
peer = "unknown peer"
logger.info("shutdown requested by client (%s)", peer)
await self._send(ws, {"type": "shutdown_ack"})
try:
await ws.close()
Expand Down
24 changes: 17 additions & 7 deletions tests/test_installer_stop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,21 +146,31 @@ def test_stop_all_py_restart_manager_lock_owners():


def test_stop_all_py_deletefile_semantic_lock_probe():
"""rant 2026-08-18T16:09:45 — lock-probe 假阴性根因 + 修复。
"""rant 2026-08-18T16:09:45 — lock-probe 假阴性根因 + 修复;
rant 2026-08-19T13:08:41 — 数据删除 bug 修复(去掉 FILE_FLAG_DELETE_ON_CLOSE)。

v0.2.48 实证:GENERIC_READ + FILE_SHARE_NONE 探测对 DLL 锁永远假阴性——
LoadLibrary 持有句柄允许读共享 → 探测显示 0 locked,而安装器 DeleteFile
需要句柄共享 FILE_SHARE_DELETE → 覆盖时 code 5。修复 = DELETE 访问
(GENERIC_DELETE=0x10000)+ FILE_FLAG_DELETE_ON_CLOSE=0x04000000 +
OPEN_EXISTING,与安装器 DeleteFile 完全同语义;FILE_SHARE_NONE 保留补充;
探测成功后用 SetFileInformationByHandle 清除 delete-on-close(探测不真删)。
(GENERIC_DELETE=0x10000)+ OPEN_EXISTING + FILE_SHARE_NONE,与安装器
DeleteFile 的共享语义一致;探测成功即"DeleteFile 会成功",仅关闭句柄,
永不设置删除 disposition。

⚠️ 数据删除 bug(rant 2026-08-19T13:08:41):旧实现用
FILE_FLAG_DELETE_ON_CLOSE=0x04000000 打开后再用 SetFileInformationByHandle
清除 disposition——但该清除仅 Windows 10 1903+ 支持,旧系统/清除失败时
disposition 残留,CloseHandle 会真删文件。现探测用普通
FILE_ATTRIBUTE_NORMAL 打开,永不设置 disposition → 只问不删。
"""
content = _read("emrg/_stop_all.py")
assert "GENERIC_DELETE = 0x00010000" in content
assert "FILE_FLAG_DELETE_ON_CLOSE = 0x04000000" in content
assert "GENERIC_READ = 0x80000000" not in content # 旧常量赋值已移除
assert "SetFileInformationByHandle" in content
assert "FILE_DISPOSITION_INFO = 2" in content
# 数据删除 bug 修复:不得再出现 delete-on-close / disposition 清除
assert "FILE_FLAG_DELETE_ON_CLOSE" not in content
assert "SetFileInformationByHandle" not in content
assert "FILE_DISPOSITION_INFO" not in content
assert "FILE_ATTRIBUTE_NORMAL = 0x80" in content
assert "never sets a delete disposition" in content or "never set a delete disposition" in content
# 自锁防护(rant 2026-08-18T16:09:45,18:57:09 改为提示性):开头打印
# python-dist 运行时 + verify 对 self-held 锁不中止安装(stop_all 退出即释放)
assert "python-dist runtime:" in content
Expand Down
51 changes: 51 additions & 0 deletions tests/test_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@

from emrg import _stop_all
from emrg._stop_all import (
_caller_context,
_read_pid_file,
_verify_posix,
match_cmdline,
Expand DownExpand Up@@ -108,6 +109,56 @@ def test_read_pid_file(self, tmp_path, monkeypatch):
assert _read_pid_file() is None # non-positive


class TestCallerContext:
"""_caller_context — stop-chain forensics (rant 2026-08-19T13:11:34):
every stop run must record who invoked it (parent pid/cmdline + argv) so
"谁杀 daemon / 谁删文件" is traceable from the stop log alone."""

def test_posix_queries_parent_cmdline(self, monkeypatch):
calls: list = []
monkeypatch.setattr(_stop_all, "is_win", lambda: False)

class CP:
stdout = "python -m emrg stop\n"

monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: calls.append(cmd) or CP(),
)
out = _caller_context()
assert calls and "ps" in calls[0] # POSIX parent probe
assert "caller pid" in out
assert "python -m emrg stop" in out
assert "argv" in out

def test_windows_queries_powershell_cim(self, monkeypatch):
calls: list = []
monkeypatch.setattr(_stop_all, "is_win", lambda: True)

class CP:
stdout = "C:\\Python313\\python.exe -m emrg stop\n"

monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: calls.append(cmd) or CP(),
)
out = _caller_context()
assert calls and "Get-CimInstance" in calls[0][-1] # Windows parent probe
assert "caller pid" in out
assert "-m emrg stop" in out

def test_probe_failure_degrades_gracefully(self, monkeypatch):
monkeypatch.setattr(_stop_all, "is_win", lambda: False)
monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: (_ for _ in ()).throw(RuntimeError("no ps")),
)
out = _caller_context()
assert "caller pid" in out
assert "unknown parent" in out
assert "argv" in out


class TestVerifyPosix:
def test_no_residuals(self, monkeypatch):
monkeypatch.setattr(_stop_all, "_stop_scan_pids", lambda own: [])
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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` (971) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (974) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 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
83 changes: 54 additions & 29 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,7 @@

# Build stamp printed at the start of every run so the operator can tell at a
# glance which stop_all.py generation executed (rant 2026-08-17T21:06:31).
_STOP_ALL_STAMP = "built 2026-08-19 (fixed-port daemon shutdown — rant 08:05:21)"
_STOP_ALL_STAMP = "built 2026-08-19 (read-only lock probe + stop-chain caller log — rants 13:08:41 + 13:11:34)"

# Fixed daemon port (host rant 2026-08-19T08:05:21): the daemon binds a fixed
# loopback port as its single-instance admission. This module is pure stdlib
Expand DownExpand Up@@ -952,26 +952,36 @@ def _iter_install_files(root: str) -> list[str]:


def _win_exclusive_open(path: str) -> None:
"""Open an existing file with DELETE access + FILE_FLAG_DELETE_ON_CLOSE —
the exact semantic the Inno installer's DeleteFile needs. Raises OSError
when another process holds the file (DeleteFile code 5 would occur).
"""Open an existing file with DELETE access + FILE_SHARE_NONE — the
exact sharing semantic the Inno installer's DeleteFile needs. Raises
OSError when another process holds the file (DeleteFile code 5 would
occur).

DeleteFile semantics (rant 2026-08-18T16:09:45): a DLL loaded via
LoadLibrary holds the file with FILE_SHARE_READ only — GENERIC_READ +
FILE_SHARE_NONE probing succeeds (read sharing is granted) → false
"0 locked" while the installer's DeleteFile still fails (the image
section handle does not share FILE_SHARE_DELETE). Requesting DELETE
access (+ delete-on-close disposition, the probe = "would DeleteFile
succeed right now?") fails with ERROR_SHARING_VIOLATION on exactly the
files DeleteFile would fail on. FILE_SHARE_NONE is kept as a complement
— either condition failing means locked."""
access fails with ERROR_SHARING_VIOLATION on exactly the files
DeleteFile would fail on. FILE_SHARE_NONE is kept as a complement —
either condition failing means locked.

⚠️ NO delete-on-close disposition (rant 2026-08-19T13:08:41 — data-loss
bug): the v0.2.4x probe opened with the delete-on-close flag and cleared
it afterwards via the file-disposition-info API — but that clear only
works on Windows 10 1903+; on older systems (or any failed/best-effort
clear) the disposition stays set and closing the handle DELETES the
probed file. The disposition flag adds nothing to the access check
(DELETE access + share-none alone reproduces DeleteFile's sharing
semantics), so the probe now opens with plain FILE_ATTRIBUTE_NORMAL and
never sets a delete disposition — it can never delete anything, only
ask "would DeleteFile succeed?"."""
import ctypes

GENERIC_DELETE = 0x00010000
OPEN_EXISTING = 3
FILE_SHARE_NONE = 0
FILE_FLAG_DELETE_ON_CLOSE = 0x04000000
FILE_DISPOSITION_INFO = 2
FILE_ATTRIBUTE_NORMAL = 0x80
kernel32 = ctypes.windll.kernel32
# 64-bit handle truncation fix (rant 2026-08-18T09:40:40): ctypes defaults
# the restype of a foreign function to c_int — a 64-bit HANDLE gets
Expand All@@ -985,32 +995,15 @@ def _win_exclusive_open(path: str) -> None:
]
kernel32.CloseHandle.restype = ctypes.c_int
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
# SetFileInformationByHandle — after a SUCCESSFUL probe the delete-on-close
# mark must be cleared so the probe never actually deletes the file it
# verified as deletable (it only asks "would DeleteFile succeed?").
kernel32.SetFileInformationByHandle.restype = ctypes.c_int
kernel32.SetFileInformationByHandle.argtypes = [
ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32,
]

class _FileDisposition(ctypes.Structure):
_fields_ = [("DeleteFile", ctypes.c_ubyte)] # BOOLEAN

h = kernel32.CreateFileW(path, GENERIC_DELETE, FILE_SHARE_NONE, None,
OPEN_EXISTING, FILE_FLAG_DELETE_ON_CLOSE, None)
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, None)
# With restype=c_void_p a NULL handle arrives as None (not 0) — cover both
# forms; INVALID_HANDLE_VALUE is c_void_p(-1).value (pm25coder review note,
# PR #832). A failed DELETE open = the installer's DeleteFile would fail.
if not h or h == ctypes.c_void_p(-1).value:
raise OSError(f"CreateFileW failed for {path} (file is locked)")
try:
# Undo the delete-on-close disposition (DeleteFile=FALSE). We hold
# DELETE access, so this clear always succeeds; best-effort otherwise.
_fd = _FileDisposition(0)
kernel32.SetFileInformationByHandle(
h, FILE_DISPOSITION_INFO, ctypes.byref(_fd), ctypes.sizeof(_fd))
finally:
kernel32.CloseHandle(h)
kernel32.CloseHandle(h)


def _check_locked_files(root: str, try_open=None) -> list[str]:
Expand DownExpand Up@@ -1494,6 +1487,33 @@ def _open_stop_log() -> object | None:
return None


def _caller_context() -> str:
"""Best-effort "who called emrg stop" line (rant 2026-08-19T13:11:34):
parent pid + parent command line + our argv — so a post-mortem can
answer "谁杀 daemon / 谁删文件" (which process invoked the stop chain).
Pure stdlib; any failure degrades to the pid-only form, never raises."""
ppid = os.getppid()
parent = ""
try:
if is_win():
out = subprocess.run(
["powershell", "-NoProfile", "-Command",
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={ppid}').CommandLine"],
capture_output=True, text=True, timeout=10,
).stdout.strip()
else:
out = subprocess.run(
["ps", "-o", "command=", "-p", str(ppid)],
capture_output=True, text=True, timeout=10,
).stdout.strip()
if out:
parent = out.splitlines()[0][:160]
except Exception:
pass
argv = " ".join(sys.argv) or "(none)"
return f"caller pid {ppid} ({parent or 'unknown parent'}) | argv: {argv}"


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@@ -1551,6 +1571,11 @@ def stop_all() -> int:
f"python {platform.python_version()} {platform.system()}-{platform.machine()} "
f"| pid {os.getpid()}"
)
# Who called + when (rant 2026-08-19T13:11:34): every stop run must be
# attributable — parent pid/parent cmdline/argv + wall-clock start. This
# is the forensics trail for "谁杀 daemon / 谁删文件".
print(f"emrg stop: started {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"emrg stop: {_caller_context()}")
# Self-lock observability (rant 2026-08-18T16:09:45): when the installer
# runs stop_all with install\python-dist\python.exe, that interpreter's
# site config (._pth/.pth) may import install\lib modules → the runtime
Expand Down
10 changes: 9 additions & 1 deletion emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2024,7 +2024,15 @@ async def _process_message(
})

elif msg_type == "shutdown":
logger.info("shutdown requested by client")
# Rant 2026-08-19T13:11:34 — every daemon kill must be attributable:
# log the requesting peer (loopback client) alongside the action so
# "谁杀 daemon" can be traced from emrgd.log alone.
peer = ""
try:
peer = str(ws.remote_address)
except Exception:
peer = "unknown peer"
logger.info("shutdown requested by client (%s)", peer)
await self._send(ws, {"type": "shutdown_ack"})
try:
await ws.close()
Expand Down
24 changes: 17 additions & 7 deletions tests/test_installer_stop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,21 +146,31 @@ def test_stop_all_py_restart_manager_lock_owners():


def test_stop_all_py_deletefile_semantic_lock_probe():
"""rant 2026-08-18T16:09:45 — lock-probe 假阴性根因 + 修复。
"""rant 2026-08-18T16:09:45 — lock-probe 假阴性根因 + 修复;
rant 2026-08-19T13:08:41 — 数据删除 bug 修复(去掉 FILE_FLAG_DELETE_ON_CLOSE)。

v0.2.48 实证:GENERIC_READ + FILE_SHARE_NONE 探测对 DLL 锁永远假阴性——
LoadLibrary 持有句柄允许读共享 → 探测显示 0 locked,而安装器 DeleteFile
需要句柄共享 FILE_SHARE_DELETE → 覆盖时 code 5。修复 = DELETE 访问
(GENERIC_DELETE=0x10000)+ FILE_FLAG_DELETE_ON_CLOSE=0x04000000 +
OPEN_EXISTING,与安装器 DeleteFile 完全同语义;FILE_SHARE_NONE 保留补充;
探测成功后用 SetFileInformationByHandle 清除 delete-on-close(探测不真删)。
(GENERIC_DELETE=0x10000)+ OPEN_EXISTING + FILE_SHARE_NONE,与安装器
DeleteFile 的共享语义一致;探测成功即"DeleteFile 会成功",仅关闭句柄,
永不设置删除 disposition。

⚠️ 数据删除 bug(rant 2026-08-19T13:08:41):旧实现用
FILE_FLAG_DELETE_ON_CLOSE=0x04000000 打开后再用 SetFileInformationByHandle
清除 disposition——但该清除仅 Windows 10 1903+ 支持,旧系统/清除失败时
disposition 残留,CloseHandle 会真删文件。现探测用普通
FILE_ATTRIBUTE_NORMAL 打开,永不设置 disposition → 只问不删。
"""
content = _read("emrg/_stop_all.py")
assert "GENERIC_DELETE = 0x00010000" in content
assert "FILE_FLAG_DELETE_ON_CLOSE = 0x04000000" in content
assert "GENERIC_READ = 0x80000000" not in content # 旧常量赋值已移除
assert "SetFileInformationByHandle" in content
assert "FILE_DISPOSITION_INFO = 2" in content
# 数据删除 bug 修复:不得再出现 delete-on-close / disposition 清除
assert "FILE_FLAG_DELETE_ON_CLOSE" not in content
assert "SetFileInformationByHandle" not in content
assert "FILE_DISPOSITION_INFO" not in content
assert "FILE_ATTRIBUTE_NORMAL = 0x80" in content
assert "never sets a delete disposition" in content or "never set a delete disposition" in content
# 自锁防护(rant 2026-08-18T16:09:45,18:57:09 改为提示性):开头打印
# python-dist 运行时 + verify 对 self-held 锁不中止安装(stop_all 退出即释放)
assert "python-dist runtime:" in content
Expand Down
51 changes: 51 additions & 0 deletions tests/test_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@

from emrg import _stop_all
from emrg._stop_all import (
_caller_context,
_read_pid_file,
_verify_posix,
match_cmdline,
Expand DownExpand Up@@ -108,6 +109,56 @@ def test_read_pid_file(self, tmp_path, monkeypatch):
assert _read_pid_file() is None # non-positive


class TestCallerContext:
"""_caller_context — stop-chain forensics (rant 2026-08-19T13:11:34):
every stop run must record who invoked it (parent pid/cmdline + argv) so
"谁杀 daemon / 谁删文件" is traceable from the stop log alone."""

def test_posix_queries_parent_cmdline(self, monkeypatch):
calls: list = []
monkeypatch.setattr(_stop_all, "is_win", lambda: False)

class CP:
stdout = "python -m emrg stop\n"

monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: calls.append(cmd) or CP(),
)
out = _caller_context()
assert calls and "ps" in calls[0] # POSIX parent probe
assert "caller pid" in out
assert "python -m emrg stop" in out
assert "argv" in out

def test_windows_queries_powershell_cim(self, monkeypatch):
calls: list = []
monkeypatch.setattr(_stop_all, "is_win", lambda: True)

class CP:
stdout = "C:\\Python313\\python.exe -m emrg stop\n"

monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: calls.append(cmd) or CP(),
)
out = _caller_context()
assert calls and "Get-CimInstance" in calls[0][-1] # Windows parent probe
assert "caller pid" in out
assert "-m emrg stop" in out

def test_probe_failure_degrades_gracefully(self, monkeypatch):
monkeypatch.setattr(_stop_all, "is_win", lambda: False)
monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: (_ for _ in ()).throw(RuntimeError("no ps")),
)
out = _caller_context()
assert "caller pid" in out
assert "unknown parent" in out
assert "argv" in out


class TestVerifyPosix:
def test_no_residuals(self, monkeypatch):
monkeypatch.setattr(_stop_all, "_stop_scan_pids", lambda own: [])
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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` (971) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (974) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 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
83 changes: 54 additions & 29 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,7 @@

# Build stamp printed at the start of every run so the operator can tell at a
# glance which stop_all.py generation executed (rant 2026-08-17T21:06:31).
_STOP_ALL_STAMP = "built 2026-08-19 (fixed-port daemon shutdown — rant 08:05:21)"
_STOP_ALL_STAMP = "built 2026-08-19 (read-only lock probe + stop-chain caller log — rants 13:08:41 + 13:11:34)"

# Fixed daemon port (host rant 2026-08-19T08:05:21): the daemon binds a fixed
# loopback port as its single-instance admission. This module is pure stdlib
Expand DownExpand Up@@ -952,26 +952,36 @@ def _iter_install_files(root: str) -> list[str]:


def _win_exclusive_open(path: str) -> None:
"""Open an existing file with DELETE access + FILE_FLAG_DELETE_ON_CLOSE —
the exact semantic the Inno installer's DeleteFile needs. Raises OSError
when another process holds the file (DeleteFile code 5 would occur).
"""Open an existing file with DELETE access + FILE_SHARE_NONE — the
exact sharing semantic the Inno installer's DeleteFile needs. Raises
OSError when another process holds the file (DeleteFile code 5 would
occur).

DeleteFile semantics (rant 2026-08-18T16:09:45): a DLL loaded via
LoadLibrary holds the file with FILE_SHARE_READ only — GENERIC_READ +
FILE_SHARE_NONE probing succeeds (read sharing is granted) → false
"0 locked" while the installer's DeleteFile still fails (the image
section handle does not share FILE_SHARE_DELETE). Requesting DELETE
access (+ delete-on-close disposition, the probe = "would DeleteFile
succeed right now?") fails with ERROR_SHARING_VIOLATION on exactly the
files DeleteFile would fail on. FILE_SHARE_NONE is kept as a complement
— either condition failing means locked."""
access fails with ERROR_SHARING_VIOLATION on exactly the files
DeleteFile would fail on. FILE_SHARE_NONE is kept as a complement —
either condition failing means locked.

⚠️ NO delete-on-close disposition (rant 2026-08-19T13:08:41 — data-loss
bug): the v0.2.4x probe opened with the delete-on-close flag and cleared
it afterwards via the file-disposition-info API — but that clear only
works on Windows 10 1903+; on older systems (or any failed/best-effort
clear) the disposition stays set and closing the handle DELETES the
probed file. The disposition flag adds nothing to the access check
(DELETE access + share-none alone reproduces DeleteFile's sharing
semantics), so the probe now opens with plain FILE_ATTRIBUTE_NORMAL and
never sets a delete disposition — it can never delete anything, only
ask "would DeleteFile succeed?"."""
import ctypes

GENERIC_DELETE = 0x00010000
OPEN_EXISTING = 3
FILE_SHARE_NONE = 0
FILE_FLAG_DELETE_ON_CLOSE = 0x04000000
FILE_DISPOSITION_INFO = 2
FILE_ATTRIBUTE_NORMAL = 0x80
kernel32 = ctypes.windll.kernel32
# 64-bit handle truncation fix (rant 2026-08-18T09:40:40): ctypes defaults
# the restype of a foreign function to c_int — a 64-bit HANDLE gets
Expand All@@ -985,32 +995,15 @@ def _win_exclusive_open(path: str) -> None:
]
kernel32.CloseHandle.restype = ctypes.c_int
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
# SetFileInformationByHandle — after a SUCCESSFUL probe the delete-on-close
# mark must be cleared so the probe never actually deletes the file it
# verified as deletable (it only asks "would DeleteFile succeed?").
kernel32.SetFileInformationByHandle.restype = ctypes.c_int
kernel32.SetFileInformationByHandle.argtypes = [
ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32,
]

class _FileDisposition(ctypes.Structure):
_fields_ = [("DeleteFile", ctypes.c_ubyte)] # BOOLEAN

h = kernel32.CreateFileW(path, GENERIC_DELETE, FILE_SHARE_NONE, None,
OPEN_EXISTING, FILE_FLAG_DELETE_ON_CLOSE, None)
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, None)
# With restype=c_void_p a NULL handle arrives as None (not 0) — cover both
# forms; INVALID_HANDLE_VALUE is c_void_p(-1).value (pm25coder review note,
# PR #832). A failed DELETE open = the installer's DeleteFile would fail.
if not h or h == ctypes.c_void_p(-1).value:
raise OSError(f"CreateFileW failed for {path} (file is locked)")
try:
# Undo the delete-on-close disposition (DeleteFile=FALSE). We hold
# DELETE access, so this clear always succeeds; best-effort otherwise.
_fd = _FileDisposition(0)
kernel32.SetFileInformationByHandle(
h, FILE_DISPOSITION_INFO, ctypes.byref(_fd), ctypes.sizeof(_fd))
finally:
kernel32.CloseHandle(h)
kernel32.CloseHandle(h)


def _check_locked_files(root: str, try_open=None) -> list[str]:
Expand DownExpand Up@@ -1494,6 +1487,33 @@ def _open_stop_log() -> object | None:
return None


def _caller_context() -> str:
"""Best-effort "who called emrg stop" line (rant 2026-08-19T13:11:34):
parent pid + parent command line + our argv — so a post-mortem can
answer "谁杀 daemon / 谁删文件" (which process invoked the stop chain).
Pure stdlib; any failure degrades to the pid-only form, never raises."""
ppid = os.getppid()
parent = ""
try:
if is_win():
out = subprocess.run(
["powershell", "-NoProfile", "-Command",
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={ppid}').CommandLine"],
capture_output=True, text=True, timeout=10,
).stdout.strip()
else:
out = subprocess.run(
["ps", "-o", "command=", "-p", str(ppid)],
capture_output=True, text=True, timeout=10,
).stdout.strip()
if out:
parent = out.splitlines()[0][:160]
except Exception:
pass
argv = " ".join(sys.argv) or "(none)"
return f"caller pid {ppid} ({parent or 'unknown parent'}) | argv: {argv}"


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@@ -1551,6 +1571,11 @@ def stop_all() -> int:
f"python {platform.python_version()} {platform.system()}-{platform.machine()} "
f"| pid {os.getpid()}"
)
# Who called + when (rant 2026-08-19T13:11:34): every stop run must be
# attributable — parent pid/parent cmdline/argv + wall-clock start. This
# is the forensics trail for "谁杀 daemon / 谁删文件".
print(f"emrg stop: started {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"emrg stop: {_caller_context()}")
# Self-lock observability (rant 2026-08-18T16:09:45): when the installer
# runs stop_all with install\python-dist\python.exe, that interpreter's
# site config (._pth/.pth) may import install\lib modules → the runtime
Expand Down
10 changes: 9 additions & 1 deletion emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2024,7 +2024,15 @@ async def _process_message(
})

elif msg_type == "shutdown":
logger.info("shutdown requested by client")
# Rant 2026-08-19T13:11:34 — every daemon kill must be attributable:
# log the requesting peer (loopback client) alongside the action so
# "谁杀 daemon" can be traced from emrgd.log alone.
peer = ""
try:
peer = str(ws.remote_address)
except Exception:
peer = "unknown peer"
logger.info("shutdown requested by client (%s)", peer)
await self._send(ws, {"type": "shutdown_ack"})
try:
await ws.close()
Expand Down
24 changes: 17 additions & 7 deletions tests/test_installer_stop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,21 +146,31 @@ def test_stop_all_py_restart_manager_lock_owners():


def test_stop_all_py_deletefile_semantic_lock_probe():
"""rant 2026-08-18T16:09:45 — lock-probe 假阴性根因 + 修复。
"""rant 2026-08-18T16:09:45 — lock-probe 假阴性根因 + 修复;
rant 2026-08-19T13:08:41 — 数据删除 bug 修复(去掉 FILE_FLAG_DELETE_ON_CLOSE)。

v0.2.48 实证:GENERIC_READ + FILE_SHARE_NONE 探测对 DLL 锁永远假阴性——
LoadLibrary 持有句柄允许读共享 → 探测显示 0 locked,而安装器 DeleteFile
需要句柄共享 FILE_SHARE_DELETE → 覆盖时 code 5。修复 = DELETE 访问
(GENERIC_DELETE=0x10000)+ FILE_FLAG_DELETE_ON_CLOSE=0x04000000 +
OPEN_EXISTING,与安装器 DeleteFile 完全同语义;FILE_SHARE_NONE 保留补充;
探测成功后用 SetFileInformationByHandle 清除 delete-on-close(探测不真删)。
(GENERIC_DELETE=0x10000)+ OPEN_EXISTING + FILE_SHARE_NONE,与安装器
DeleteFile 的共享语义一致;探测成功即"DeleteFile 会成功",仅关闭句柄,
永不设置删除 disposition。

⚠️ 数据删除 bug(rant 2026-08-19T13:08:41):旧实现用
FILE_FLAG_DELETE_ON_CLOSE=0x04000000 打开后再用 SetFileInformationByHandle
清除 disposition——但该清除仅 Windows 10 1903+ 支持,旧系统/清除失败时
disposition 残留,CloseHandle 会真删文件。现探测用普通
FILE_ATTRIBUTE_NORMAL 打开,永不设置 disposition → 只问不删。
"""
content = _read("emrg/_stop_all.py")
assert "GENERIC_DELETE = 0x00010000" in content
assert "FILE_FLAG_DELETE_ON_CLOSE = 0x04000000" in content
assert "GENERIC_READ = 0x80000000" not in content # 旧常量赋值已移除
assert "SetFileInformationByHandle" in content
assert "FILE_DISPOSITION_INFO = 2" in content
# 数据删除 bug 修复:不得再出现 delete-on-close / disposition 清除
assert "FILE_FLAG_DELETE_ON_CLOSE" not in content
assert "SetFileInformationByHandle" not in content
assert "FILE_DISPOSITION_INFO" not in content
assert "FILE_ATTRIBUTE_NORMAL = 0x80" in content
assert "never sets a delete disposition" in content or "never set a delete disposition" in content
# 自锁防护(rant 2026-08-18T16:09:45,18:57:09 改为提示性):开头打印
# python-dist 运行时 + verify 对 self-held 锁不中止安装(stop_all 退出即释放)
assert "python-dist runtime:" in content
Expand Down
51 changes: 51 additions & 0 deletions tests/test_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@

from emrg import _stop_all
from emrg._stop_all import (
_caller_context,
_read_pid_file,
_verify_posix,
match_cmdline,
Expand DownExpand Up@@ -108,6 +109,56 @@ def test_read_pid_file(self, tmp_path, monkeypatch):
assert _read_pid_file() is None # non-positive


class TestCallerContext:
"""_caller_context — stop-chain forensics (rant 2026-08-19T13:11:34):
every stop run must record who invoked it (parent pid/cmdline + argv) so
"谁杀 daemon / 谁删文件" is traceable from the stop log alone."""

def test_posix_queries_parent_cmdline(self, monkeypatch):
calls: list = []
monkeypatch.setattr(_stop_all, "is_win", lambda: False)

class CP:
stdout = "python -m emrg stop\n"

monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: calls.append(cmd) or CP(),
)
out = _caller_context()
assert calls and "ps" in calls[0] # POSIX parent probe
assert "caller pid" in out
assert "python -m emrg stop" in out
assert "argv" in out

def test_windows_queries_powershell_cim(self, monkeypatch):
calls: list = []
monkeypatch.setattr(_stop_all, "is_win", lambda: True)

class CP:
stdout = "C:\\Python313\\python.exe -m emrg stop\n"

monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: calls.append(cmd) or CP(),
)
out = _caller_context()
assert calls and "Get-CimInstance" in calls[0][-1] # Windows parent probe
assert "caller pid" in out
assert "-m emrg stop" in out

def test_probe_failure_degrades_gracefully(self, monkeypatch):
monkeypatch.setattr(_stop_all, "is_win", lambda: False)
monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: (_ for _ in ()).throw(RuntimeError("no ps")),
)
out = _caller_context()
assert "caller pid" in out
assert "unknown parent" in out
assert "argv" in out


class TestVerifyPosix:
def test_no_residuals(self, monkeypatch):
monkeypatch.setattr(_stop_all, "_stop_scan_pids", lambda own: [])
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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` (971) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (974) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 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
83 changes: 54 additions & 29 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,7 @@

# Build stamp printed at the start of every run so the operator can tell at a
# glance which stop_all.py generation executed (rant 2026-08-17T21:06:31).
_STOP_ALL_STAMP = "built 2026-08-19 (fixed-port daemon shutdown — rant 08:05:21)"
_STOP_ALL_STAMP = "built 2026-08-19 (read-only lock probe + stop-chain caller log — rants 13:08:41 + 13:11:34)"

# Fixed daemon port (host rant 2026-08-19T08:05:21): the daemon binds a fixed
# loopback port as its single-instance admission. This module is pure stdlib
Expand DownExpand Up@@ -952,26 +952,36 @@ def _iter_install_files(root: str) -> list[str]:


def _win_exclusive_open(path: str) -> None:
"""Open an existing file with DELETE access + FILE_FLAG_DELETE_ON_CLOSE —
the exact semantic the Inno installer's DeleteFile needs. Raises OSError
when another process holds the file (DeleteFile code 5 would occur).
"""Open an existing file with DELETE access + FILE_SHARE_NONE — the
exact sharing semantic the Inno installer's DeleteFile needs. Raises
OSError when another process holds the file (DeleteFile code 5 would
occur).

DeleteFile semantics (rant 2026-08-18T16:09:45): a DLL loaded via
LoadLibrary holds the file with FILE_SHARE_READ only — GENERIC_READ +
FILE_SHARE_NONE probing succeeds (read sharing is granted) → false
"0 locked" while the installer's DeleteFile still fails (the image
section handle does not share FILE_SHARE_DELETE). Requesting DELETE
access (+ delete-on-close disposition, the probe = "would DeleteFile
succeed right now?") fails with ERROR_SHARING_VIOLATION on exactly the
files DeleteFile would fail on. FILE_SHARE_NONE is kept as a complement
— either condition failing means locked."""
access fails with ERROR_SHARING_VIOLATION on exactly the files
DeleteFile would fail on. FILE_SHARE_NONE is kept as a complement —
either condition failing means locked.

⚠️ NO delete-on-close disposition (rant 2026-08-19T13:08:41 — data-loss
bug): the v0.2.4x probe opened with the delete-on-close flag and cleared
it afterwards via the file-disposition-info API — but that clear only
works on Windows 10 1903+; on older systems (or any failed/best-effort
clear) the disposition stays set and closing the handle DELETES the
probed file. The disposition flag adds nothing to the access check
(DELETE access + share-none alone reproduces DeleteFile's sharing
semantics), so the probe now opens with plain FILE_ATTRIBUTE_NORMAL and
never sets a delete disposition — it can never delete anything, only
ask "would DeleteFile succeed?"."""
import ctypes

GENERIC_DELETE = 0x00010000
OPEN_EXISTING = 3
FILE_SHARE_NONE = 0
FILE_FLAG_DELETE_ON_CLOSE = 0x04000000
FILE_DISPOSITION_INFO = 2
FILE_ATTRIBUTE_NORMAL = 0x80
kernel32 = ctypes.windll.kernel32
# 64-bit handle truncation fix (rant 2026-08-18T09:40:40): ctypes defaults
# the restype of a foreign function to c_int — a 64-bit HANDLE gets
Expand All@@ -985,32 +995,15 @@ def _win_exclusive_open(path: str) -> None:
]
kernel32.CloseHandle.restype = ctypes.c_int
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
# SetFileInformationByHandle — after a SUCCESSFUL probe the delete-on-close
# mark must be cleared so the probe never actually deletes the file it
# verified as deletable (it only asks "would DeleteFile succeed?").
kernel32.SetFileInformationByHandle.restype = ctypes.c_int
kernel32.SetFileInformationByHandle.argtypes = [
ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32,
]

class _FileDisposition(ctypes.Structure):
_fields_ = [("DeleteFile", ctypes.c_ubyte)] # BOOLEAN

h = kernel32.CreateFileW(path, GENERIC_DELETE, FILE_SHARE_NONE, None,
OPEN_EXISTING, FILE_FLAG_DELETE_ON_CLOSE, None)
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, None)
# With restype=c_void_p a NULL handle arrives as None (not 0) — cover both
# forms; INVALID_HANDLE_VALUE is c_void_p(-1).value (pm25coder review note,
# PR #832). A failed DELETE open = the installer's DeleteFile would fail.
if not h or h == ctypes.c_void_p(-1).value:
raise OSError(f"CreateFileW failed for {path} (file is locked)")
try:
# Undo the delete-on-close disposition (DeleteFile=FALSE). We hold
# DELETE access, so this clear always succeeds; best-effort otherwise.
_fd = _FileDisposition(0)
kernel32.SetFileInformationByHandle(
h, FILE_DISPOSITION_INFO, ctypes.byref(_fd), ctypes.sizeof(_fd))
finally:
kernel32.CloseHandle(h)
kernel32.CloseHandle(h)


def _check_locked_files(root: str, try_open=None) -> list[str]:
Expand DownExpand Up@@ -1494,6 +1487,33 @@ def _open_stop_log() -> object | None:
return None


def _caller_context() -> str:
"""Best-effort "who called emrg stop" line (rant 2026-08-19T13:11:34):
parent pid + parent command line + our argv — so a post-mortem can
answer "谁杀 daemon / 谁删文件" (which process invoked the stop chain).
Pure stdlib; any failure degrades to the pid-only form, never raises."""
ppid = os.getppid()
parent = ""
try:
if is_win():
out = subprocess.run(
["powershell", "-NoProfile", "-Command",
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={ppid}').CommandLine"],
capture_output=True, text=True, timeout=10,
).stdout.strip()
else:
out = subprocess.run(
["ps", "-o", "command=", "-p", str(ppid)],
capture_output=True, text=True, timeout=10,
).stdout.strip()
if out:
parent = out.splitlines()[0][:160]
except Exception:
pass
argv = " ".join(sys.argv) or "(none)"
return f"caller pid {ppid} ({parent or 'unknown parent'}) | argv: {argv}"


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@@ -1551,6 +1571,11 @@ def stop_all() -> int:
f"python {platform.python_version()} {platform.system()}-{platform.machine()} "
f"| pid {os.getpid()}"
)
# Who called + when (rant 2026-08-19T13:11:34): every stop run must be
# attributable — parent pid/parent cmdline/argv + wall-clock start. This
# is the forensics trail for "谁杀 daemon / 谁删文件".
print(f"emrg stop: started {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"emrg stop: {_caller_context()}")
# Self-lock observability (rant 2026-08-18T16:09:45): when the installer
# runs stop_all with install\python-dist\python.exe, that interpreter's
# site config (._pth/.pth) may import install\lib modules → the runtime
Expand Down
10 changes: 9 additions & 1 deletion emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2024,7 +2024,15 @@ async def _process_message(
})

elif msg_type == "shutdown":
logger.info("shutdown requested by client")
# Rant 2026-08-19T13:11:34 — every daemon kill must be attributable:
# log the requesting peer (loopback client) alongside the action so
# "谁杀 daemon" can be traced from emrgd.log alone.
peer = ""
try:
peer = str(ws.remote_address)
except Exception:
peer = "unknown peer"
logger.info("shutdown requested by client (%s)", peer)
await self._send(ws, {"type": "shutdown_ack"})
try:
await ws.close()
Expand Down
24 changes: 17 additions & 7 deletions tests/test_installer_stop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,21 +146,31 @@ def test_stop_all_py_restart_manager_lock_owners():


def test_stop_all_py_deletefile_semantic_lock_probe():
"""rant 2026-08-18T16:09:45 — lock-probe 假阴性根因 + 修复。
"""rant 2026-08-18T16:09:45 — lock-probe 假阴性根因 + 修复;
rant 2026-08-19T13:08:41 — 数据删除 bug 修复(去掉 FILE_FLAG_DELETE_ON_CLOSE)。

v0.2.48 实证:GENERIC_READ + FILE_SHARE_NONE 探测对 DLL 锁永远假阴性——
LoadLibrary 持有句柄允许读共享 → 探测显示 0 locked,而安装器 DeleteFile
需要句柄共享 FILE_SHARE_DELETE → 覆盖时 code 5。修复 = DELETE 访问
(GENERIC_DELETE=0x10000)+ FILE_FLAG_DELETE_ON_CLOSE=0x04000000 +
OPEN_EXISTING,与安装器 DeleteFile 完全同语义;FILE_SHARE_NONE 保留补充;
探测成功后用 SetFileInformationByHandle 清除 delete-on-close(探测不真删)。
(GENERIC_DELETE=0x10000)+ OPEN_EXISTING + FILE_SHARE_NONE,与安装器
DeleteFile 的共享语义一致;探测成功即"DeleteFile 会成功",仅关闭句柄,
永不设置删除 disposition。

⚠️ 数据删除 bug(rant 2026-08-19T13:08:41):旧实现用
FILE_FLAG_DELETE_ON_CLOSE=0x04000000 打开后再用 SetFileInformationByHandle
清除 disposition——但该清除仅 Windows 10 1903+ 支持,旧系统/清除失败时
disposition 残留,CloseHandle 会真删文件。现探测用普通
FILE_ATTRIBUTE_NORMAL 打开,永不设置 disposition → 只问不删。
"""
content = _read("emrg/_stop_all.py")
assert "GENERIC_DELETE = 0x00010000" in content
assert "FILE_FLAG_DELETE_ON_CLOSE = 0x04000000" in content
assert "GENERIC_READ = 0x80000000" not in content # 旧常量赋值已移除
assert "SetFileInformationByHandle" in content
assert "FILE_DISPOSITION_INFO = 2" in content
# 数据删除 bug 修复:不得再出现 delete-on-close / disposition 清除
assert "FILE_FLAG_DELETE_ON_CLOSE" not in content
assert "SetFileInformationByHandle" not in content
assert "FILE_DISPOSITION_INFO" not in content
assert "FILE_ATTRIBUTE_NORMAL = 0x80" in content
assert "never sets a delete disposition" in content or "never set a delete disposition" in content
# 自锁防护(rant 2026-08-18T16:09:45,18:57:09 改为提示性):开头打印
# python-dist 运行时 + verify 对 self-held 锁不中止安装(stop_all 退出即释放)
assert "python-dist runtime:" in content
Expand Down
51 changes: 51 additions & 0 deletions tests/test_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@

from emrg import _stop_all
from emrg._stop_all import (
_caller_context,
_read_pid_file,
_verify_posix,
match_cmdline,
Expand DownExpand Up@@ -108,6 +109,56 @@ def test_read_pid_file(self, tmp_path, monkeypatch):
assert _read_pid_file() is None # non-positive


class TestCallerContext:
"""_caller_context — stop-chain forensics (rant 2026-08-19T13:11:34):
every stop run must record who invoked it (parent pid/cmdline + argv) so
"谁杀 daemon / 谁删文件" is traceable from the stop log alone."""

def test_posix_queries_parent_cmdline(self, monkeypatch):
calls: list = []
monkeypatch.setattr(_stop_all, "is_win", lambda: False)

class CP:
stdout = "python -m emrg stop\n"

monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: calls.append(cmd) or CP(),
)
out = _caller_context()
assert calls and "ps" in calls[0] # POSIX parent probe
assert "caller pid" in out
assert "python -m emrg stop" in out
assert "argv" in out

def test_windows_queries_powershell_cim(self, monkeypatch):
calls: list = []
monkeypatch.setattr(_stop_all, "is_win", lambda: True)

class CP:
stdout = "C:\\Python313\\python.exe -m emrg stop\n"

monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: calls.append(cmd) or CP(),
)
out = _caller_context()
assert calls and "Get-CimInstance" in calls[0][-1] # Windows parent probe
assert "caller pid" in out
assert "-m emrg stop" in out

def test_probe_failure_degrades_gracefully(self, monkeypatch):
monkeypatch.setattr(_stop_all, "is_win", lambda: False)
monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: (_ for _ in ()).throw(RuntimeError("no ps")),
)
out = _caller_context()
assert "caller pid" in out
assert "unknown parent" in out
assert "argv" in out


class TestVerifyPosix:
def test_no_residuals(self, monkeypatch):
monkeypatch.setattr(_stop_all, "_stop_scan_pids", lambda own: [])
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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` (971) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (974) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 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
83 changes: 54 additions & 29 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,7 @@

# Build stamp printed at the start of every run so the operator can tell at a
# glance which stop_all.py generation executed (rant 2026-08-17T21:06:31).
_STOP_ALL_STAMP = "built 2026-08-19 (fixed-port daemon shutdown — rant 08:05:21)"
_STOP_ALL_STAMP = "built 2026-08-19 (read-only lock probe + stop-chain caller log — rants 13:08:41 + 13:11:34)"

# Fixed daemon port (host rant 2026-08-19T08:05:21): the daemon binds a fixed
# loopback port as its single-instance admission. This module is pure stdlib
Expand DownExpand Up@@ -952,26 +952,36 @@ def _iter_install_files(root: str) -> list[str]:


def _win_exclusive_open(path: str) -> None:
"""Open an existing file with DELETE access + FILE_FLAG_DELETE_ON_CLOSE —
the exact semantic the Inno installer's DeleteFile needs. Raises OSError
when another process holds the file (DeleteFile code 5 would occur).
"""Open an existing file with DELETE access + FILE_SHARE_NONE — the
exact sharing semantic the Inno installer's DeleteFile needs. Raises
OSError when another process holds the file (DeleteFile code 5 would
occur).

DeleteFile semantics (rant 2026-08-18T16:09:45): a DLL loaded via
LoadLibrary holds the file with FILE_SHARE_READ only — GENERIC_READ +
FILE_SHARE_NONE probing succeeds (read sharing is granted) → false
"0 locked" while the installer's DeleteFile still fails (the image
section handle does not share FILE_SHARE_DELETE). Requesting DELETE
access (+ delete-on-close disposition, the probe = "would DeleteFile
succeed right now?") fails with ERROR_SHARING_VIOLATION on exactly the
files DeleteFile would fail on. FILE_SHARE_NONE is kept as a complement
— either condition failing means locked."""
access fails with ERROR_SHARING_VIOLATION on exactly the files
DeleteFile would fail on. FILE_SHARE_NONE is kept as a complement —
either condition failing means locked.

⚠️ NO delete-on-close disposition (rant 2026-08-19T13:08:41 — data-loss
bug): the v0.2.4x probe opened with the delete-on-close flag and cleared
it afterwards via the file-disposition-info API — but that clear only
works on Windows 10 1903+; on older systems (or any failed/best-effort
clear) the disposition stays set and closing the handle DELETES the
probed file. The disposition flag adds nothing to the access check
(DELETE access + share-none alone reproduces DeleteFile's sharing
semantics), so the probe now opens with plain FILE_ATTRIBUTE_NORMAL and
never sets a delete disposition — it can never delete anything, only
ask "would DeleteFile succeed?"."""
import ctypes

GENERIC_DELETE = 0x00010000
OPEN_EXISTING = 3
FILE_SHARE_NONE = 0
FILE_FLAG_DELETE_ON_CLOSE = 0x04000000
FILE_DISPOSITION_INFO = 2
FILE_ATTRIBUTE_NORMAL = 0x80
kernel32 = ctypes.windll.kernel32
# 64-bit handle truncation fix (rant 2026-08-18T09:40:40): ctypes defaults
# the restype of a foreign function to c_int — a 64-bit HANDLE gets
Expand All@@ -985,32 +995,15 @@ def _win_exclusive_open(path: str) -> None:
]
kernel32.CloseHandle.restype = ctypes.c_int
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
# SetFileInformationByHandle — after a SUCCESSFUL probe the delete-on-close
# mark must be cleared so the probe never actually deletes the file it
# verified as deletable (it only asks "would DeleteFile succeed?").
kernel32.SetFileInformationByHandle.restype = ctypes.c_int
kernel32.SetFileInformationByHandle.argtypes = [
ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32,
]

class _FileDisposition(ctypes.Structure):
_fields_ = [("DeleteFile", ctypes.c_ubyte)] # BOOLEAN

h = kernel32.CreateFileW(path, GENERIC_DELETE, FILE_SHARE_NONE, None,
OPEN_EXISTING, FILE_FLAG_DELETE_ON_CLOSE, None)
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, None)
# With restype=c_void_p a NULL handle arrives as None (not 0) — cover both
# forms; INVALID_HANDLE_VALUE is c_void_p(-1).value (pm25coder review note,
# PR #832). A failed DELETE open = the installer's DeleteFile would fail.
if not h or h == ctypes.c_void_p(-1).value:
raise OSError(f"CreateFileW failed for {path} (file is locked)")
try:
# Undo the delete-on-close disposition (DeleteFile=FALSE). We hold
# DELETE access, so this clear always succeeds; best-effort otherwise.
_fd = _FileDisposition(0)
kernel32.SetFileInformationByHandle(
h, FILE_DISPOSITION_INFO, ctypes.byref(_fd), ctypes.sizeof(_fd))
finally:
kernel32.CloseHandle(h)
kernel32.CloseHandle(h)


def _check_locked_files(root: str, try_open=None) -> list[str]:
Expand DownExpand Up@@ -1494,6 +1487,33 @@ def _open_stop_log() -> object | None:
return None


def _caller_context() -> str:
"""Best-effort "who called emrg stop" line (rant 2026-08-19T13:11:34):
parent pid + parent command line + our argv — so a post-mortem can
answer "谁杀 daemon / 谁删文件" (which process invoked the stop chain).
Pure stdlib; any failure degrades to the pid-only form, never raises."""
ppid = os.getppid()
parent = ""
try:
if is_win():
out = subprocess.run(
["powershell", "-NoProfile", "-Command",
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={ppid}').CommandLine"],
capture_output=True, text=True, timeout=10,
).stdout.strip()
else:
out = subprocess.run(
["ps", "-o", "command=", "-p", str(ppid)],
capture_output=True, text=True, timeout=10,
).stdout.strip()
if out:
parent = out.splitlines()[0][:160]
except Exception:
pass
argv = " ".join(sys.argv) or "(none)"
return f"caller pid {ppid} ({parent or 'unknown parent'}) | argv: {argv}"


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@@ -1551,6 +1571,11 @@ def stop_all() -> int:
f"python {platform.python_version()} {platform.system()}-{platform.machine()} "
f"| pid {os.getpid()}"
)
# Who called + when (rant 2026-08-19T13:11:34): every stop run must be
# attributable — parent pid/parent cmdline/argv + wall-clock start. This
# is the forensics trail for "谁杀 daemon / 谁删文件".
print(f"emrg stop: started {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"emrg stop: {_caller_context()}")
# Self-lock observability (rant 2026-08-18T16:09:45): when the installer
# runs stop_all with install\python-dist\python.exe, that interpreter's
# site config (._pth/.pth) may import install\lib modules → the runtime
Expand Down
10 changes: 9 additions & 1 deletion emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2024,7 +2024,15 @@ async def _process_message(
})

elif msg_type == "shutdown":
logger.info("shutdown requested by client")
# Rant 2026-08-19T13:11:34 — every daemon kill must be attributable:
# log the requesting peer (loopback client) alongside the action so
# "谁杀 daemon" can be traced from emrgd.log alone.
peer = ""
try:
peer = str(ws.remote_address)
except Exception:
peer = "unknown peer"
logger.info("shutdown requested by client (%s)", peer)
await self._send(ws, {"type": "shutdown_ack"})
try:
await ws.close()
Expand Down
24 changes: 17 additions & 7 deletions tests/test_installer_stop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,21 +146,31 @@ def test_stop_all_py_restart_manager_lock_owners():


def test_stop_all_py_deletefile_semantic_lock_probe():
"""rant 2026-08-18T16:09:45 — lock-probe 假阴性根因 + 修复。
"""rant 2026-08-18T16:09:45 — lock-probe 假阴性根因 + 修复;
rant 2026-08-19T13:08:41 — 数据删除 bug 修复(去掉 FILE_FLAG_DELETE_ON_CLOSE)。

v0.2.48 实证:GENERIC_READ + FILE_SHARE_NONE 探测对 DLL 锁永远假阴性——
LoadLibrary 持有句柄允许读共享 → 探测显示 0 locked,而安装器 DeleteFile
需要句柄共享 FILE_SHARE_DELETE → 覆盖时 code 5。修复 = DELETE 访问
(GENERIC_DELETE=0x10000)+ FILE_FLAG_DELETE_ON_CLOSE=0x04000000 +
OPEN_EXISTING,与安装器 DeleteFile 完全同语义;FILE_SHARE_NONE 保留补充;
探测成功后用 SetFileInformationByHandle 清除 delete-on-close(探测不真删)。
(GENERIC_DELETE=0x10000)+ OPEN_EXISTING + FILE_SHARE_NONE,与安装器
DeleteFile 的共享语义一致;探测成功即"DeleteFile 会成功",仅关闭句柄,
永不设置删除 disposition。

⚠️ 数据删除 bug(rant 2026-08-19T13:08:41):旧实现用
FILE_FLAG_DELETE_ON_CLOSE=0x04000000 打开后再用 SetFileInformationByHandle
清除 disposition——但该清除仅 Windows 10 1903+ 支持,旧系统/清除失败时
disposition 残留,CloseHandle 会真删文件。现探测用普通
FILE_ATTRIBUTE_NORMAL 打开,永不设置 disposition → 只问不删。
"""
content = _read("emrg/_stop_all.py")
assert "GENERIC_DELETE = 0x00010000" in content
assert "FILE_FLAG_DELETE_ON_CLOSE = 0x04000000" in content
assert "GENERIC_READ = 0x80000000" not in content # 旧常量赋值已移除
assert "SetFileInformationByHandle" in content
assert "FILE_DISPOSITION_INFO = 2" in content
# 数据删除 bug 修复:不得再出现 delete-on-close / disposition 清除
assert "FILE_FLAG_DELETE_ON_CLOSE" not in content
assert "SetFileInformationByHandle" not in content
assert "FILE_DISPOSITION_INFO" not in content
assert "FILE_ATTRIBUTE_NORMAL = 0x80" in content
assert "never sets a delete disposition" in content or "never set a delete disposition" in content
# 自锁防护(rant 2026-08-18T16:09:45,18:57:09 改为提示性):开头打印
# python-dist 运行时 + verify 对 self-held 锁不中止安装(stop_all 退出即释放)
assert "python-dist runtime:" in content
Expand Down
51 changes: 51 additions & 0 deletions tests/test_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@

from emrg import _stop_all
from emrg._stop_all import (
_caller_context,
_read_pid_file,
_verify_posix,
match_cmdline,
Expand DownExpand Up@@ -108,6 +109,56 @@ def test_read_pid_file(self, tmp_path, monkeypatch):
assert _read_pid_file() is None # non-positive


class TestCallerContext:
"""_caller_context — stop-chain forensics (rant 2026-08-19T13:11:34):
every stop run must record who invoked it (parent pid/cmdline + argv) so
"谁杀 daemon / 谁删文件" is traceable from the stop log alone."""

def test_posix_queries_parent_cmdline(self, monkeypatch):
calls: list = []
monkeypatch.setattr(_stop_all, "is_win", lambda: False)

class CP:
stdout = "python -m emrg stop\n"

monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: calls.append(cmd) or CP(),
)
out = _caller_context()
assert calls and "ps" in calls[0] # POSIX parent probe
assert "caller pid" in out
assert "python -m emrg stop" in out
assert "argv" in out

def test_windows_queries_powershell_cim(self, monkeypatch):
calls: list = []
monkeypatch.setattr(_stop_all, "is_win", lambda: True)

class CP:
stdout = "C:\\Python313\\python.exe -m emrg stop\n"

monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: calls.append(cmd) or CP(),
)
out = _caller_context()
assert calls and "Get-CimInstance" in calls[0][-1] # Windows parent probe
assert "caller pid" in out
assert "-m emrg stop" in out

def test_probe_failure_degrades_gracefully(self, monkeypatch):
monkeypatch.setattr(_stop_all, "is_win", lambda: False)
monkeypatch.setattr(
_stop_all.subprocess, "run",
lambda cmd, **kw: (_ for _ in ()).throw(RuntimeError("no ps")),
)
out = _caller_context()
assert "caller pid" in out
assert "unknown parent" in out
assert "argv" in out


class TestVerifyPosix:
def test_no_residuals(self, monkeypatch):
monkeypatch.setattr(_stop_all, "_stop_scan_pids", lambda own: [])
Expand Down
Loading