From 5ddeba43965aaa5b1f5477130e00f6d7150b232a Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Wed, 19 Aug 2026 13:22:43 +0800 Subject: [PATCH] emrg: read-only lock probe + stop-chain caller logging (rants 13:08:41 + 13:11:34) --- Agent.md | 2 +- emrg/_stop_all.py | 83 +++++++++++++++++++++++------------- emrg/server/daemon.py | 10 ++++- tests/test_installer_stop.py | 24 ++++++++--- tests/test_stop_all.py | 51 ++++++++++++++++++++++ 5 files changed, 132 insertions(+), 38 deletions(-) diff --git a/Agent.md b/Agent.md index 51b95e2b..3fd67ace 100644 --- a/Agent.md +++ b/Agent.md @@ -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 路径不受影响) diff --git a/emrg/_stop_all.py b/emrg/_stop_all.py index 73b600ba..f2e9f06e 100644 --- a/emrg/_stop_all.py +++ b/emrg/_stop_all.py @@ -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 @@ -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 @@ -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]: @@ -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 @@ -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 diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index f423b5e7..bbcd4460 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -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() diff --git a/tests/test_installer_stop.py b/tests/test_installer_stop.py index e5a43553..54f3c589 100644 --- a/tests/test_installer_stop.py +++ b/tests/test_installer_stop.py @@ -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 diff --git a/tests/test_stop_all.py b/tests/test_stop_all.py index a3d75658..b78cab17 100644 --- a/tests/test_stop_all.py +++ b/tests/test_stop_all.py @@ -19,6 +19,7 @@ from emrg import _stop_all from emrg._stop_all import ( + _caller_context, _read_pid_file, _verify_posix, match_cmdline, @@ -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: [])