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` (952) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (962) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (259: 45 daemon_client + 19 conn-manager + 22 app-commands + 130 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
102 changes: 88 additions & 14 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -921,6 +921,11 @@ def _check_locked_files(root: str, try_open=None) -> list[str]:
_lock_probe_error: str | None = None


def _install_root() -> str:
"""Windows install dir (``~/.emrg/install``) — single source of truth."""
return os.path.join(os.path.expanduser("~"), ".emrg", "install")


def check_install_writable() -> list[str]:
"""Windows: probe ``install\\`` for files locked against overwrite.

Expand All@@ -937,7 +942,7 @@ def check_install_writable() -> list[str]:
_lock_probe_error = None
if not is_win():
return []
root = os.path.join(os.path.expanduser("~"), ".emrg", "install")
root = _install_root()
if not os.path.isdir(root):
return []
files = _iter_install_files(root)
Expand DownExpand Up@@ -1027,6 +1032,53 @@ def stop_lock_owners() -> None:
_windows_cats_cache: list[tuple[str, list[str]]] | None = None


def _classify_locked_files(
locked: list[str],
mh_holders: list[tuple[int, str, str, int, list[str], str]],
root: str,
) -> tuple[list[str], list[str]]:
"""Split createfile-probe locked files into self-held vs residual.

Rant 2026-08-18T18:57:09: when stop_all itself runs from
install\\python-dist\\python.exe, the probe reports the interpreter's own
DLLs (python313.dll, select.pyd, ...) as locked — but those locks belong
to the stop_all process (module-holder tag ``excluded``) and are released
the moment stop_all exits, BEFORE the installer overwrites (installer runs
stop_all synchronously via ewWaitUntilTerminated). Counting them as
residuals aborts a perfectly fine install.

Returns ``(self_held, residual)`` install-relative paths:
- self_held: locked file attributed ONLY to excluded (self/ancestor) holders
- residual: locked file with an external (``target``) holder, or one that
cannot be attributed to any known holder (conservative — could be a
plain non-DLL lock held by an external process that loaded no module).
"""
if not locked or not root:
return [], list(locked)
# Separator-agnostic: module-holder files arrive with backslashes (PS
# Substring), locked paths are native. Normalize both to forward slashes
# so the attribution works identically on Windows and in POSIX unit tests.
def _norm(p: str) -> str:
return p.replace("\\", "/")

root_n = _norm(root)
tag_by_rel: dict[str, set[str]] = {}
for _pid, _name, _exe, _parent, files, tag in mh_holders:
for f in files:
tag_by_rel.setdefault(_norm(f), set()).add(tag)
self_held: list[str] = []
residual: list[str] = []
for p in locked:
rel = _norm(os.path.relpath(_norm(p), root_n))
tags = tag_by_rel.get(rel, set())
if tags == {"excluded"}:
self_held.append(rel)
else:
# No tags (unattributable) OR has an external target holder.
residual.append(rel)
return self_held, residual


def _verify_windows_categories() -> list[tuple[str, list[str]]]:
"""Windows residual scan, one ``(category, residual_strings)`` entry per
check — so the operator can see each check's result instead of guessing
Expand DownExpand Up@@ -1066,9 +1118,10 @@ def _verify_windows_categories() -> list[tuple[str, list[str]]]:
# section locks; RM missed the browser-harness child, CreateFileW probes
# cannot see module locks at all). Any external holder = residual.
mh_out = _module_holder_ps()
mh_holders = _parse_module_holders(mh_out)
mh = [
f"install-module holder (pid {pid}, {name or 'unknown'}, loads {', '.join(files[:3])})"
for pid, name, _exe, _parent, files, tag in _parse_module_holders(mh_out)
for pid, name, _exe, _parent, files, tag in mh_holders
if tag == "target"
]
cats.append(("module-holder", mh))
Expand All@@ -1095,9 +1148,29 @@ def _verify_windows_categories() -> list[tuple[str, list[str]]]:
global _lock_probe_error
_lock_probe_error = None
locked = check_install_writable()
probe_items = [f"locked file (createfile-probe): {p}" for p in locked]
probe_items = []
if _lock_probe_error:
probe_items.append(f"lock-probe failed (error: {_lock_probe_error})")
# Self-held attribution (rant 2026-08-18T18:57:09): when stop_all runs
# from install\python-dist\python.exe, that interpreter MUST load its own
# python-dist DLLs (python313.dll, select.pyd, ...) — those image-section
# locks are held by the stop_all process itself (module-holder tag
# ``excluded`` = self + ancestor chain) and are RELEASED when stop_all
# exits, before the installer starts overwriting (make-installer.sh uses
# ewWaitUntilTerminated). They are NOT residuals — counting them aborts
# the install while nothing is actually wrong. A locked file is residual
# only when an EXTERNAL holder (module-holder ``target`` / RM owner)
# exists or the lock cannot be attributed to any known holder (conservative).
self_held, residual_locked = _classify_locked_files(
locked, mh_holders, _install_root() if is_win() else ""
)
probe_items.extend(f"locked file (createfile-probe): {p}" for p in residual_locked)
if self_held:
print(
f"emrg stop: WARNING {len(self_held)} file(s) locked by stop_all "
f"runtime itself (python-dist DLL) — self-held, released when "
f"stop_all exits; installer continues"
)
cats.append(("createfile-probe", probe_items))

# bundled-git residual
Expand DownExpand Up@@ -1347,19 +1420,20 @@ def stop_all() -> int:
print(f"emrg stop: ERROR retry {attempt}/2: {e} ({type(e).__name__})")
time.sleep(0.3)
print(f"emrg stop: retry {attempt}/2 done ({time.monotonic() - s:.1f}s)")
# Self-lock final guard (rant 2026-08-18T16:09:45 + 16:24:01): after
# both kill retries the probe still reports locked files but neither
# the module-holder enumeration nor RM found an EXTERNAL owner — the
# lock holder is stop_all's own runtime (python-dist loaded
# install\lib modules) or an undetectable handle. The installer
# cannot win; a freshly launched installer process holds no locks.
# Exit 1 so the install aborts with this explanation instead of a
# code-5 dialog.
# Self-lock final guard (rant 2026-08-18T16:09:45 + 16:24:01, refined
# 18:57:09): after both kill retries the probe still reports locked
# files but neither the module-holder enumeration nor RM found an
# EXTERNAL owner — the lock holder is stop_all's own runtime
# (python-dist loaded install\lib modules). The installer runs stop_all
# synchronously (ewWaitUntilTerminated), so these locks are released
# when stop_all exits and the overwrite proceeds — advisory only, NOT
# a hard abort (the pre-18:57:09 guard wrongly blocked installs whose
# only locks were python-dist DLLs held by stop_all itself).
if locked and not _module_holder_external_found and _rm_no_external_owner:
print(
"emrg stop: WARNING lock holder is the stop_all runtime itself "
"(no external module-holder / RM owner) - installer will fail; "
"re-run installer (fresh process won't hold the lock)"
f"emrg stop: WARNING {len(locked)} file(s) locked by the "
f"stop_all runtime itself (python-dist DLL) — released when "
f"stop_all exits; installer continues"
)
residuals = verify()
if residuals:
Expand Down
37 changes: 37 additions & 0 deletions emrg/client/python_tui/widgets/markdown.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,43 @@ def render(self, ctx: RenderContext) -> list[Line]:
return lines


class UserMarkdown(Markdown):
"""User message rendered as markdown with the role prefix preserved.

Plan B (rant 2026-08-18T18:52:45, superseding 18:50:14): user messages
go through the same Rich markdown pipeline as assistant messages — free
width-based wrapping, CJK wide-char handling — while keeping the
``> `` prefix + bold cyan role visual. The markdown is rendered at
``ctx.width - len(prefix)`` so the prefix on the first line never
overflows the buffer width (continuation lines get a same-width indent).
"""

_ROLE_PREFIX = "> "
_ROLE_STYLE = "bold cyan"

def render(self, ctx: RenderContext) -> list[Line]:
from rich.style import Style

from emrg.client.python_tui.rich_bridge import rich_renderable_to_lines
from emrg.client.python_tui.widgets.base import Span

prefix = self._ROLE_PREFIX
indent = " " * len(prefix)
role_style = Style.parse(self._ROLE_STYLE)
avail = max(1, ctx.width - len(prefix))

md = RichMarkdown(self.text, code_theme="monokai")
md_lines = rich_renderable_to_lines(md, avail)
lines: list[Line] = []
for i, line in enumerate(md_lines):
lead = prefix if i == 0 else indent
line.spans.insert(0, Span(text=lead, style=role_style))
line.style = ctx.style
lines.append(line)
self._dirty = False
return lines


@dataclass
class StreamingMarkdown(Widget):
"""Incremental markdown renderer for token-by-token streaming.
Expand Down
12 changes: 11 additions & 1 deletion emrg/client/widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@
from rich.style import Style
from emrg.client.python_tui import ChatRow, ToolCard
from emrg.client.python_tui.widgets.base import Line, Span, Widget
from emrg.client.python_tui.widgets.markdown import StreamingMarkdown
from emrg.client.python_tui.widgets.markdown import StreamingMarkdown, UserMarkdown


class InputWidget(Widget):
Expand DownExpand Up@@ -670,6 +670,11 @@ def dirty(self, v): self._dirty = v
def add(self, role_or_widget, content=None):
if isinstance(role_or_widget, Widget):
self.rows.append(role_or_widget)
elif role_or_widget == "user":
# Plan B (rant 2026-08-18T18:52:45, superseding 18:50:14): user
# messages render as markdown (free width wrap, CJK handling)
# while keeping the "> " prefix + cyan role visual.
self.rows.append(UserMarkdown(content or ""))
else:
self.rows.append(ChatRow(role=role_or_widget, content=content or ""))
self._line_cache.append(None) # 新 row 无缓存
Expand All@@ -693,6 +698,11 @@ def update_last(self, content):
row.dirty = True
self._dirty = True
return
if isinstance(row, UserMarkdown):
row.text = content
row.dirty = True
self._dirty = True
return

def last_tool_card(self):
for row in reversed(self.rows):
Expand Down
88 changes: 80 additions & 8 deletions tests/test_installer_stop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,23 +158,24 @@ def test_stop_all_py_deletefile_semantic_lock_probe():
assert "GENERIC_READ = 0x80000000" not in content # 旧常量赋值已移除
assert "SetFileInformationByHandle" in content
assert "FILE_DISPOSITION_INFO = 2" in content
# 自锁防护(rant 2026-08-18T16:09:45):开头打印 python-dist 运行时 + 兜底
# WARNING(lock holder 是 stop_all 自身 → 提示重跑安装器
# 自锁防护(rant 2026-08-18T16:09:45,18:57:09 改为提示性):开头打印
# python-dist 运行时 + verify 对 self-held 锁不中止安装(stop_all 退出即释放
assert "python-dist runtime:" in content
assert "lock holder is the stop_all runtime itself" in content
assert "re-run installer (fresh process won't hold the lock)" in content
assert "self-held" in content
assert "installer continues" in content
assert "re-run installer (fresh process won't hold the lock)" not in content # 旧文案已移除


def test_stop_all_py_rm_no_external_owner_flag():
"""rant 2026-08-18T16:09:45 — _print_rm_diag 记录"无外部 owner"证据。

RM owners==0 或全部 owner 被祖先链排除 → _rm_no_external_owner=True,
stop_all 重试循环后据此输出自锁 WARNING 并 exit 1。
stop_all 重试循环后据此输出自锁提示(18:57:09 改为 advisory,不再 exit 1
"""
content = _read("emrg/_stop_all.py")
assert "_rm_no_external_owner" in content
assert 'd["owners"] == 0 or "owner(s) excluded" in stdout' in content
assert "re-run installer (fresh process won't hold the lock)" in content
assert "installer continues" in content


def test_stop_all_py_module_holder_enumeration():
Expand DownExpand Up@@ -212,9 +213,10 @@ def test_stop_all_py_module_holder_enumeration():
assert '"RM re-scan", rm' in verify_src
assert verify_src.index('"module-holder"') < verify_src.index('"RM re-scan"')
assert "install-module holder" in verify_src
# 自锁兜底:外部 module-holder 与 RM owner 都无 → WARNING
# 自锁兜底:外部 module-holder 与 RM owner 都无 → 提示性 WARNING(18:57:09
# 改为 advisory —— stop_all 退出即释放,安装继续)
assert "_module_holder_external_found" in content
assert "no external module-holder / RM owner" in content
assert "installer continues" in content
# createfile-probe 降级为补充(CreateFileW 探测对 DLL 模块锁假阴性)
assert "createfile-probe" in content
assert "module locks need the module-holder scan" in content
Expand DownExpand Up@@ -302,3 +304,73 @@ def test_agent_md_no_stop_emrg_cmd_refs():
"""No stale stop-emrg.cmd references in docs."""
for rel in ("README.md", "README.cn.md", "Agent.md"):
assert "stop-emrg.cmd" not in _read(rel), rel


def test_classify_locked_files_self_held_only():
"""rant 2026-08-18T18:57:09 — python-dist DLLs locked by stop_all's own
runtime (module-holder tag=excluded) are self-held → NOT residuals."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [
root + "\\bin\\python-dist\\python313.dll",
root + "\\bin\\python-dist\\select.pyd",
]
holders = [
(11572, "python.exe", "python-dist", 1,
["bin/python-dist/python313.dll", "bin/python-dist/select.pyd"], "excluded"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert sorted(self_held) == ["bin/python-dist/python313.dll", "bin/python-dist/select.pyd"]
assert residual == []


def test_classify_locked_files_external_holder_residual():
"""A locked file held by an EXTERNAL (target) module-holder stays residual."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [root + "\\lib\\websockets\\speedups.cp313-win_amd64.pyd"]
holders = [
(9280, "python.exe", "browser_harness", 9556,
["lib/websockets/speedups.cp313-win_amd64.pyd"], "target"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert self_held == []
assert residual == ["lib/websockets/speedups.cp313-win_amd64.pyd"]


def test_classify_locked_files_unattributable_residual_conservative():
"""A locked file with NO known module-holder stays residual (conservative —
could be a plain non-DLL lock held by an external process)."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [root + "\\bin\\some-data-file.dat"]
self_held, residual = _classify_locked_files(locked, [], root)
assert self_held == []
assert residual == ["bin/some-data-file.dat"]


def test_classify_locked_files_mixed():
"""Mixed: self-held python-dist + external pyd + unattributable data."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [
root + "\\bin\\python-dist\\python313.dll",
root + "\\lib\\websockets\\speedups.cp313-win_amd64.pyd",
root + "\\bin\\data.dat",
]
holders = [
(11572, "python.exe", "python-dist", 1,
["bin/python-dist/python313.dll"], "excluded"),
(9280, "python.exe", "browser_harness", 9556,
["lib/websockets/speedups.cp313-win_amd64.pyd"], "target"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert self_held == ["bin/python-dist/python313.dll"]
assert sorted(residual) == [
"bin/data.dat",
"lib/websockets/speedups.cp313-win_amd64.pyd",
]
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
emrg: TUI user messages render as markdown + stop_all self-held lock attribution by argszero · Pull Request #847 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (952) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (962) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (259: 45 daemon_client + 19 conn-manager + 22 app-commands + 130 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
102 changes: 88 additions & 14 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -921,6 +921,11 @@ def _check_locked_files(root: str, try_open=None) -> list[str]:
_lock_probe_error: str | None = None


def _install_root() -> str:
"""Windows install dir (``~/.emrg/install``) — single source of truth."""
return os.path.join(os.path.expanduser("~"), ".emrg", "install")


def check_install_writable() -> list[str]:
"""Windows: probe ``install\\`` for files locked against overwrite.

Expand All@@ -937,7 +942,7 @@ def check_install_writable() -> list[str]:
_lock_probe_error = None
if not is_win():
return []
root = os.path.join(os.path.expanduser("~"), ".emrg", "install")
root = _install_root()
if not os.path.isdir(root):
return []
files = _iter_install_files(root)
Expand DownExpand Up@@ -1027,6 +1032,53 @@ def stop_lock_owners() -> None:
_windows_cats_cache: list[tuple[str, list[str]]] | None = None


def _classify_locked_files(
locked: list[str],
mh_holders: list[tuple[int, str, str, int, list[str], str]],
root: str,
) -> tuple[list[str], list[str]]:
"""Split createfile-probe locked files into self-held vs residual.

Rant 2026-08-18T18:57:09: when stop_all itself runs from
install\\python-dist\\python.exe, the probe reports the interpreter's own
DLLs (python313.dll, select.pyd, ...) as locked — but those locks belong
to the stop_all process (module-holder tag ``excluded``) and are released
the moment stop_all exits, BEFORE the installer overwrites (installer runs
stop_all synchronously via ewWaitUntilTerminated). Counting them as
residuals aborts a perfectly fine install.

Returns ``(self_held, residual)`` install-relative paths:
- self_held: locked file attributed ONLY to excluded (self/ancestor) holders
- residual: locked file with an external (``target``) holder, or one that
cannot be attributed to any known holder (conservative — could be a
plain non-DLL lock held by an external process that loaded no module).
"""
if not locked or not root:
return [], list(locked)
# Separator-agnostic: module-holder files arrive with backslashes (PS
# Substring), locked paths are native. Normalize both to forward slashes
# so the attribution works identically on Windows and in POSIX unit tests.
def _norm(p: str) -> str:
return p.replace("\\", "/")

root_n = _norm(root)
tag_by_rel: dict[str, set[str]] = {}
for _pid, _name, _exe, _parent, files, tag in mh_holders:
for f in files:
tag_by_rel.setdefault(_norm(f), set()).add(tag)
self_held: list[str] = []
residual: list[str] = []
for p in locked:
rel = _norm(os.path.relpath(_norm(p), root_n))
tags = tag_by_rel.get(rel, set())
if tags == {"excluded"}:
self_held.append(rel)
else:
# No tags (unattributable) OR has an external target holder.
residual.append(rel)
return self_held, residual


def _verify_windows_categories() -> list[tuple[str, list[str]]]:
"""Windows residual scan, one ``(category, residual_strings)`` entry per
check — so the operator can see each check's result instead of guessing
Expand DownExpand Up@@ -1066,9 +1118,10 @@ def _verify_windows_categories() -> list[tuple[str, list[str]]]:
# section locks; RM missed the browser-harness child, CreateFileW probes
# cannot see module locks at all). Any external holder = residual.
mh_out = _module_holder_ps()
mh_holders = _parse_module_holders(mh_out)
mh = [
f"install-module holder (pid {pid}, {name or 'unknown'}, loads {', '.join(files[:3])})"
for pid, name, _exe, _parent, files, tag in _parse_module_holders(mh_out)
for pid, name, _exe, _parent, files, tag in mh_holders
if tag == "target"
]
cats.append(("module-holder", mh))
Expand All@@ -1095,9 +1148,29 @@ def _verify_windows_categories() -> list[tuple[str, list[str]]]:
global _lock_probe_error
_lock_probe_error = None
locked = check_install_writable()
probe_items = [f"locked file (createfile-probe): {p}" for p in locked]
probe_items = []
if _lock_probe_error:
probe_items.append(f"lock-probe failed (error: {_lock_probe_error})")
# Self-held attribution (rant 2026-08-18T18:57:09): when stop_all runs
# from install\python-dist\python.exe, that interpreter MUST load its own
# python-dist DLLs (python313.dll, select.pyd, ...) — those image-section
# locks are held by the stop_all process itself (module-holder tag
# ``excluded`` = self + ancestor chain) and are RELEASED when stop_all
# exits, before the installer starts overwriting (make-installer.sh uses
# ewWaitUntilTerminated). They are NOT residuals — counting them aborts
# the install while nothing is actually wrong. A locked file is residual
# only when an EXTERNAL holder (module-holder ``target`` / RM owner)
# exists or the lock cannot be attributed to any known holder (conservative).
self_held, residual_locked = _classify_locked_files(
locked, mh_holders, _install_root() if is_win() else ""
)
probe_items.extend(f"locked file (createfile-probe): {p}" for p in residual_locked)
if self_held:
print(
f"emrg stop: WARNING {len(self_held)} file(s) locked by stop_all "
f"runtime itself (python-dist DLL) — self-held, released when "
f"stop_all exits; installer continues"
)
cats.append(("createfile-probe", probe_items))

# bundled-git residual
Expand DownExpand Up@@ -1347,19 +1420,20 @@ def stop_all() -> int:
print(f"emrg stop: ERROR retry {attempt}/2: {e} ({type(e).__name__})")
time.sleep(0.3)
print(f"emrg stop: retry {attempt}/2 done ({time.monotonic() - s:.1f}s)")
# Self-lock final guard (rant 2026-08-18T16:09:45 + 16:24:01): after
# both kill retries the probe still reports locked files but neither
# the module-holder enumeration nor RM found an EXTERNAL owner — the
# lock holder is stop_all's own runtime (python-dist loaded
# install\lib modules) or an undetectable handle. The installer
# cannot win; a freshly launched installer process holds no locks.
# Exit 1 so the install aborts with this explanation instead of a
# code-5 dialog.
# Self-lock final guard (rant 2026-08-18T16:09:45 + 16:24:01, refined
# 18:57:09): after both kill retries the probe still reports locked
# files but neither the module-holder enumeration nor RM found an
# EXTERNAL owner — the lock holder is stop_all's own runtime
# (python-dist loaded install\lib modules). The installer runs stop_all
# synchronously (ewWaitUntilTerminated), so these locks are released
# when stop_all exits and the overwrite proceeds — advisory only, NOT
# a hard abort (the pre-18:57:09 guard wrongly blocked installs whose
# only locks were python-dist DLLs held by stop_all itself).
if locked and not _module_holder_external_found and _rm_no_external_owner:
print(
"emrg stop: WARNING lock holder is the stop_all runtime itself "
"(no external module-holder / RM owner) - installer will fail; "
"re-run installer (fresh process won't hold the lock)"
f"emrg stop: WARNING {len(locked)} file(s) locked by the "
f"stop_all runtime itself (python-dist DLL) — released when "
f"stop_all exits; installer continues"
)
residuals = verify()
if residuals:
Expand Down
37 changes: 37 additions & 0 deletions emrg/client/python_tui/widgets/markdown.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,43 @@ def render(self, ctx: RenderContext) -> list[Line]:
return lines


class UserMarkdown(Markdown):
"""User message rendered as markdown with the role prefix preserved.

Plan B (rant 2026-08-18T18:52:45, superseding 18:50:14): user messages
go through the same Rich markdown pipeline as assistant messages — free
width-based wrapping, CJK wide-char handling — while keeping the
``> `` prefix + bold cyan role visual. The markdown is rendered at
``ctx.width - len(prefix)`` so the prefix on the first line never
overflows the buffer width (continuation lines get a same-width indent).
"""

_ROLE_PREFIX = "> "
_ROLE_STYLE = "bold cyan"

def render(self, ctx: RenderContext) -> list[Line]:
from rich.style import Style

from emrg.client.python_tui.rich_bridge import rich_renderable_to_lines
from emrg.client.python_tui.widgets.base import Span

prefix = self._ROLE_PREFIX
indent = " " * len(prefix)
role_style = Style.parse(self._ROLE_STYLE)
avail = max(1, ctx.width - len(prefix))

md = RichMarkdown(self.text, code_theme="monokai")
md_lines = rich_renderable_to_lines(md, avail)
lines: list[Line] = []
for i, line in enumerate(md_lines):
lead = prefix if i == 0 else indent
line.spans.insert(0, Span(text=lead, style=role_style))
line.style = ctx.style
lines.append(line)
self._dirty = False
return lines


@dataclass
class StreamingMarkdown(Widget):
"""Incremental markdown renderer for token-by-token streaming.
Expand Down
12 changes: 11 additions & 1 deletion emrg/client/widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@
from rich.style import Style
from emrg.client.python_tui import ChatRow, ToolCard
from emrg.client.python_tui.widgets.base import Line, Span, Widget
from emrg.client.python_tui.widgets.markdown import StreamingMarkdown
from emrg.client.python_tui.widgets.markdown import StreamingMarkdown, UserMarkdown


class InputWidget(Widget):
Expand DownExpand Up@@ -670,6 +670,11 @@ def dirty(self, v): self._dirty = v
def add(self, role_or_widget, content=None):
if isinstance(role_or_widget, Widget):
self.rows.append(role_or_widget)
elif role_or_widget == "user":
# Plan B (rant 2026-08-18T18:52:45, superseding 18:50:14): user
# messages render as markdown (free width wrap, CJK handling)
# while keeping the "> " prefix + cyan role visual.
self.rows.append(UserMarkdown(content or ""))
else:
self.rows.append(ChatRow(role=role_or_widget, content=content or ""))
self._line_cache.append(None) # 新 row 无缓存
Expand All@@ -693,6 +698,11 @@ def update_last(self, content):
row.dirty = True
self._dirty = True
return
if isinstance(row, UserMarkdown):
row.text = content
row.dirty = True
self._dirty = True
return

def last_tool_card(self):
for row in reversed(self.rows):
Expand Down
88 changes: 80 additions & 8 deletions tests/test_installer_stop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,23 +158,24 @@ def test_stop_all_py_deletefile_semantic_lock_probe():
assert "GENERIC_READ = 0x80000000" not in content # 旧常量赋值已移除
assert "SetFileInformationByHandle" in content
assert "FILE_DISPOSITION_INFO = 2" in content
# 自锁防护(rant 2026-08-18T16:09:45):开头打印 python-dist 运行时 + 兜底
# WARNING(lock holder 是 stop_all 自身 → 提示重跑安装器
# 自锁防护(rant 2026-08-18T16:09:45,18:57:09 改为提示性):开头打印
# python-dist 运行时 + verify 对 self-held 锁不中止安装(stop_all 退出即释放
assert "python-dist runtime:" in content
assert "lock holder is the stop_all runtime itself" in content
assert "re-run installer (fresh process won't hold the lock)" in content
assert "self-held" in content
assert "installer continues" in content
assert "re-run installer (fresh process won't hold the lock)" not in content # 旧文案已移除


def test_stop_all_py_rm_no_external_owner_flag():
"""rant 2026-08-18T16:09:45 — _print_rm_diag 记录"无外部 owner"证据。

RM owners==0 或全部 owner 被祖先链排除 → _rm_no_external_owner=True,
stop_all 重试循环后据此输出自锁 WARNING 并 exit 1。
stop_all 重试循环后据此输出自锁提示(18:57:09 改为 advisory,不再 exit 1
"""
content = _read("emrg/_stop_all.py")
assert "_rm_no_external_owner" in content
assert 'd["owners"] == 0 or "owner(s) excluded" in stdout' in content
assert "re-run installer (fresh process won't hold the lock)" in content
assert "installer continues" in content


def test_stop_all_py_module_holder_enumeration():
Expand DownExpand Up@@ -212,9 +213,10 @@ def test_stop_all_py_module_holder_enumeration():
assert '"RM re-scan", rm' in verify_src
assert verify_src.index('"module-holder"') < verify_src.index('"RM re-scan"')
assert "install-module holder" in verify_src
# 自锁兜底:外部 module-holder 与 RM owner 都无 → WARNING
# 自锁兜底:外部 module-holder 与 RM owner 都无 → 提示性 WARNING(18:57:09
# 改为 advisory —— stop_all 退出即释放,安装继续)
assert "_module_holder_external_found" in content
assert "no external module-holder / RM owner" in content
assert "installer continues" in content
# createfile-probe 降级为补充(CreateFileW 探测对 DLL 模块锁假阴性)
assert "createfile-probe" in content
assert "module locks need the module-holder scan" in content
Expand DownExpand Up@@ -302,3 +304,73 @@ def test_agent_md_no_stop_emrg_cmd_refs():
"""No stale stop-emrg.cmd references in docs."""
for rel in ("README.md", "README.cn.md", "Agent.md"):
assert "stop-emrg.cmd" not in _read(rel), rel


def test_classify_locked_files_self_held_only():
"""rant 2026-08-18T18:57:09 — python-dist DLLs locked by stop_all's own
runtime (module-holder tag=excluded) are self-held → NOT residuals."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [
root + "\\bin\\python-dist\\python313.dll",
root + "\\bin\\python-dist\\select.pyd",
]
holders = [
(11572, "python.exe", "python-dist", 1,
["bin/python-dist/python313.dll", "bin/python-dist/select.pyd"], "excluded"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert sorted(self_held) == ["bin/python-dist/python313.dll", "bin/python-dist/select.pyd"]
assert residual == []


def test_classify_locked_files_external_holder_residual():
"""A locked file held by an EXTERNAL (target) module-holder stays residual."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [root + "\\lib\\websockets\\speedups.cp313-win_amd64.pyd"]
holders = [
(9280, "python.exe", "browser_harness", 9556,
["lib/websockets/speedups.cp313-win_amd64.pyd"], "target"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert self_held == []
assert residual == ["lib/websockets/speedups.cp313-win_amd64.pyd"]


def test_classify_locked_files_unattributable_residual_conservative():
"""A locked file with NO known module-holder stays residual (conservative —
could be a plain non-DLL lock held by an external process)."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [root + "\\bin\\some-data-file.dat"]
self_held, residual = _classify_locked_files(locked, [], root)
assert self_held == []
assert residual == ["bin/some-data-file.dat"]


def test_classify_locked_files_mixed():
"""Mixed: self-held python-dist + external pyd + unattributable data."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [
root + "\\bin\\python-dist\\python313.dll",
root + "\\lib\\websockets\\speedups.cp313-win_amd64.pyd",
root + "\\bin\\data.dat",
]
holders = [
(11572, "python.exe", "python-dist", 1,
["bin/python-dist/python313.dll"], "excluded"),
(9280, "python.exe", "browser_harness", 9556,
["lib/websockets/speedups.cp313-win_amd64.pyd"], "target"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert self_held == ["bin/python-dist/python313.dll"]
assert sorted(residual) == [
"bin/data.dat",
"lib/websockets/speedups.cp313-win_amd64.pyd",
]
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' emrg: TUI user messages render as markdown + stop_all self-held lock attribution by argszero · Pull Request #847 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (952) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (962) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (259: 45 daemon_client + 19 conn-manager + 22 app-commands + 130 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
102 changes: 88 additions & 14 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -921,6 +921,11 @@ def _check_locked_files(root: str, try_open=None) -> list[str]:
_lock_probe_error: str | None = None


def _install_root() -> str:
"""Windows install dir (``~/.emrg/install``) — single source of truth."""
return os.path.join(os.path.expanduser("~"), ".emrg", "install")


def check_install_writable() -> list[str]:
"""Windows: probe ``install\\`` for files locked against overwrite.

Expand All@@ -937,7 +942,7 @@ def check_install_writable() -> list[str]:
_lock_probe_error = None
if not is_win():
return []
root = os.path.join(os.path.expanduser("~"), ".emrg", "install")
root = _install_root()
if not os.path.isdir(root):
return []
files = _iter_install_files(root)
Expand DownExpand Up@@ -1027,6 +1032,53 @@ def stop_lock_owners() -> None:
_windows_cats_cache: list[tuple[str, list[str]]] | None = None


def _classify_locked_files(
locked: list[str],
mh_holders: list[tuple[int, str, str, int, list[str], str]],
root: str,
) -> tuple[list[str], list[str]]:
"""Split createfile-probe locked files into self-held vs residual.

Rant 2026-08-18T18:57:09: when stop_all itself runs from
install\\python-dist\\python.exe, the probe reports the interpreter's own
DLLs (python313.dll, select.pyd, ...) as locked — but those locks belong
to the stop_all process (module-holder tag ``excluded``) and are released
the moment stop_all exits, BEFORE the installer overwrites (installer runs
stop_all synchronously via ewWaitUntilTerminated). Counting them as
residuals aborts a perfectly fine install.

Returns ``(self_held, residual)`` install-relative paths:
- self_held: locked file attributed ONLY to excluded (self/ancestor) holders
- residual: locked file with an external (``target``) holder, or one that
cannot be attributed to any known holder (conservative — could be a
plain non-DLL lock held by an external process that loaded no module).
"""
if not locked or not root:
return [], list(locked)
# Separator-agnostic: module-holder files arrive with backslashes (PS
# Substring), locked paths are native. Normalize both to forward slashes
# so the attribution works identically on Windows and in POSIX unit tests.
def _norm(p: str) -> str:
return p.replace("\\", "/")

root_n = _norm(root)
tag_by_rel: dict[str, set[str]] = {}
for _pid, _name, _exe, _parent, files, tag in mh_holders:
for f in files:
tag_by_rel.setdefault(_norm(f), set()).add(tag)
self_held: list[str] = []
residual: list[str] = []
for p in locked:
rel = _norm(os.path.relpath(_norm(p), root_n))
tags = tag_by_rel.get(rel, set())
if tags == {"excluded"}:
self_held.append(rel)
else:
# No tags (unattributable) OR has an external target holder.
residual.append(rel)
return self_held, residual


def _verify_windows_categories() -> list[tuple[str, list[str]]]:
"""Windows residual scan, one ``(category, residual_strings)`` entry per
check — so the operator can see each check's result instead of guessing
Expand DownExpand Up@@ -1066,9 +1118,10 @@ def _verify_windows_categories() -> list[tuple[str, list[str]]]:
# section locks; RM missed the browser-harness child, CreateFileW probes
# cannot see module locks at all). Any external holder = residual.
mh_out = _module_holder_ps()
mh_holders = _parse_module_holders(mh_out)
mh = [
f"install-module holder (pid {pid}, {name or 'unknown'}, loads {', '.join(files[:3])})"
for pid, name, _exe, _parent, files, tag in _parse_module_holders(mh_out)
for pid, name, _exe, _parent, files, tag in mh_holders
if tag == "target"
]
cats.append(("module-holder", mh))
Expand All@@ -1095,9 +1148,29 @@ def _verify_windows_categories() -> list[tuple[str, list[str]]]:
global _lock_probe_error
_lock_probe_error = None
locked = check_install_writable()
probe_items = [f"locked file (createfile-probe): {p}" for p in locked]
probe_items = []
if _lock_probe_error:
probe_items.append(f"lock-probe failed (error: {_lock_probe_error})")
# Self-held attribution (rant 2026-08-18T18:57:09): when stop_all runs
# from install\python-dist\python.exe, that interpreter MUST load its own
# python-dist DLLs (python313.dll, select.pyd, ...) — those image-section
# locks are held by the stop_all process itself (module-holder tag
# ``excluded`` = self + ancestor chain) and are RELEASED when stop_all
# exits, before the installer starts overwriting (make-installer.sh uses
# ewWaitUntilTerminated). They are NOT residuals — counting them aborts
# the install while nothing is actually wrong. A locked file is residual
# only when an EXTERNAL holder (module-holder ``target`` / RM owner)
# exists or the lock cannot be attributed to any known holder (conservative).
self_held, residual_locked = _classify_locked_files(
locked, mh_holders, _install_root() if is_win() else ""
)
probe_items.extend(f"locked file (createfile-probe): {p}" for p in residual_locked)
if self_held:
print(
f"emrg stop: WARNING {len(self_held)} file(s) locked by stop_all "
f"runtime itself (python-dist DLL) — self-held, released when "
f"stop_all exits; installer continues"
)
cats.append(("createfile-probe", probe_items))

# bundled-git residual
Expand DownExpand Up@@ -1347,19 +1420,20 @@ def stop_all() -> int:
print(f"emrg stop: ERROR retry {attempt}/2: {e} ({type(e).__name__})")
time.sleep(0.3)
print(f"emrg stop: retry {attempt}/2 done ({time.monotonic() - s:.1f}s)")
# Self-lock final guard (rant 2026-08-18T16:09:45 + 16:24:01): after
# both kill retries the probe still reports locked files but neither
# the module-holder enumeration nor RM found an EXTERNAL owner — the
# lock holder is stop_all's own runtime (python-dist loaded
# install\lib modules) or an undetectable handle. The installer
# cannot win; a freshly launched installer process holds no locks.
# Exit 1 so the install aborts with this explanation instead of a
# code-5 dialog.
# Self-lock final guard (rant 2026-08-18T16:09:45 + 16:24:01, refined
# 18:57:09): after both kill retries the probe still reports locked
# files but neither the module-holder enumeration nor RM found an
# EXTERNAL owner — the lock holder is stop_all's own runtime
# (python-dist loaded install\lib modules). The installer runs stop_all
# synchronously (ewWaitUntilTerminated), so these locks are released
# when stop_all exits and the overwrite proceeds — advisory only, NOT
# a hard abort (the pre-18:57:09 guard wrongly blocked installs whose
# only locks were python-dist DLLs held by stop_all itself).
if locked and not _module_holder_external_found and _rm_no_external_owner:
print(
"emrg stop: WARNING lock holder is the stop_all runtime itself "
"(no external module-holder / RM owner) - installer will fail; "
"re-run installer (fresh process won't hold the lock)"
f"emrg stop: WARNING {len(locked)} file(s) locked by the "
f"stop_all runtime itself (python-dist DLL) — released when "
f"stop_all exits; installer continues"
)
residuals = verify()
if residuals:
Expand Down
37 changes: 37 additions & 0 deletions emrg/client/python_tui/widgets/markdown.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,43 @@ def render(self, ctx: RenderContext) -> list[Line]:
return lines


class UserMarkdown(Markdown):
"""User message rendered as markdown with the role prefix preserved.

Plan B (rant 2026-08-18T18:52:45, superseding 18:50:14): user messages
go through the same Rich markdown pipeline as assistant messages — free
width-based wrapping, CJK wide-char handling — while keeping the
``> `` prefix + bold cyan role visual. The markdown is rendered at
``ctx.width - len(prefix)`` so the prefix on the first line never
overflows the buffer width (continuation lines get a same-width indent).
"""

_ROLE_PREFIX = "> "
_ROLE_STYLE = "bold cyan"

def render(self, ctx: RenderContext) -> list[Line]:
from rich.style import Style

from emrg.client.python_tui.rich_bridge import rich_renderable_to_lines
from emrg.client.python_tui.widgets.base import Span

prefix = self._ROLE_PREFIX
indent = " " * len(prefix)
role_style = Style.parse(self._ROLE_STYLE)
avail = max(1, ctx.width - len(prefix))

md = RichMarkdown(self.text, code_theme="monokai")
md_lines = rich_renderable_to_lines(md, avail)
lines: list[Line] = []
for i, line in enumerate(md_lines):
lead = prefix if i == 0 else indent
line.spans.insert(0, Span(text=lead, style=role_style))
line.style = ctx.style
lines.append(line)
self._dirty = False
return lines


@dataclass
class StreamingMarkdown(Widget):
"""Incremental markdown renderer for token-by-token streaming.
Expand Down
12 changes: 11 additions & 1 deletion emrg/client/widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@
from rich.style import Style
from emrg.client.python_tui import ChatRow, ToolCard
from emrg.client.python_tui.widgets.base import Line, Span, Widget
from emrg.client.python_tui.widgets.markdown import StreamingMarkdown
from emrg.client.python_tui.widgets.markdown import StreamingMarkdown, UserMarkdown


class InputWidget(Widget):
Expand DownExpand Up@@ -670,6 +670,11 @@ def dirty(self, v): self._dirty = v
def add(self, role_or_widget, content=None):
if isinstance(role_or_widget, Widget):
self.rows.append(role_or_widget)
elif role_or_widget == "user":
# Plan B (rant 2026-08-18T18:52:45, superseding 18:50:14): user
# messages render as markdown (free width wrap, CJK handling)
# while keeping the "> " prefix + cyan role visual.
self.rows.append(UserMarkdown(content or ""))
else:
self.rows.append(ChatRow(role=role_or_widget, content=content or ""))
self._line_cache.append(None) # 新 row 无缓存
Expand All@@ -693,6 +698,11 @@ def update_last(self, content):
row.dirty = True
self._dirty = True
return
if isinstance(row, UserMarkdown):
row.text = content
row.dirty = True
self._dirty = True
return

def last_tool_card(self):
for row in reversed(self.rows):
Expand Down
88 changes: 80 additions & 8 deletions tests/test_installer_stop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,23 +158,24 @@ def test_stop_all_py_deletefile_semantic_lock_probe():
assert "GENERIC_READ = 0x80000000" not in content # 旧常量赋值已移除
assert "SetFileInformationByHandle" in content
assert "FILE_DISPOSITION_INFO = 2" in content
# 自锁防护(rant 2026-08-18T16:09:45):开头打印 python-dist 运行时 + 兜底
# WARNING(lock holder 是 stop_all 自身 → 提示重跑安装器
# 自锁防护(rant 2026-08-18T16:09:45,18:57:09 改为提示性):开头打印
# python-dist 运行时 + verify 对 self-held 锁不中止安装(stop_all 退出即释放
assert "python-dist runtime:" in content
assert "lock holder is the stop_all runtime itself" in content
assert "re-run installer (fresh process won't hold the lock)" in content
assert "self-held" in content
assert "installer continues" in content
assert "re-run installer (fresh process won't hold the lock)" not in content # 旧文案已移除


def test_stop_all_py_rm_no_external_owner_flag():
"""rant 2026-08-18T16:09:45 — _print_rm_diag 记录"无外部 owner"证据。

RM owners==0 或全部 owner 被祖先链排除 → _rm_no_external_owner=True,
stop_all 重试循环后据此输出自锁 WARNING 并 exit 1。
stop_all 重试循环后据此输出自锁提示(18:57:09 改为 advisory,不再 exit 1
"""
content = _read("emrg/_stop_all.py")
assert "_rm_no_external_owner" in content
assert 'd["owners"] == 0 or "owner(s) excluded" in stdout' in content
assert "re-run installer (fresh process won't hold the lock)" in content
assert "installer continues" in content


def test_stop_all_py_module_holder_enumeration():
Expand DownExpand Up@@ -212,9 +213,10 @@ def test_stop_all_py_module_holder_enumeration():
assert '"RM re-scan", rm' in verify_src
assert verify_src.index('"module-holder"') < verify_src.index('"RM re-scan"')
assert "install-module holder" in verify_src
# 自锁兜底:外部 module-holder 与 RM owner 都无 → WARNING
# 自锁兜底:外部 module-holder 与 RM owner 都无 → 提示性 WARNING(18:57:09
# 改为 advisory —— stop_all 退出即释放,安装继续)
assert "_module_holder_external_found" in content
assert "no external module-holder / RM owner" in content
assert "installer continues" in content
# createfile-probe 降级为补充(CreateFileW 探测对 DLL 模块锁假阴性)
assert "createfile-probe" in content
assert "module locks need the module-holder scan" in content
Expand DownExpand Up@@ -302,3 +304,73 @@ def test_agent_md_no_stop_emrg_cmd_refs():
"""No stale stop-emrg.cmd references in docs."""
for rel in ("README.md", "README.cn.md", "Agent.md"):
assert "stop-emrg.cmd" not in _read(rel), rel


def test_classify_locked_files_self_held_only():
"""rant 2026-08-18T18:57:09 — python-dist DLLs locked by stop_all's own
runtime (module-holder tag=excluded) are self-held → NOT residuals."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [
root + "\\bin\\python-dist\\python313.dll",
root + "\\bin\\python-dist\\select.pyd",
]
holders = [
(11572, "python.exe", "python-dist", 1,
["bin/python-dist/python313.dll", "bin/python-dist/select.pyd"], "excluded"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert sorted(self_held) == ["bin/python-dist/python313.dll", "bin/python-dist/select.pyd"]
assert residual == []


def test_classify_locked_files_external_holder_residual():
"""A locked file held by an EXTERNAL (target) module-holder stays residual."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [root + "\\lib\\websockets\\speedups.cp313-win_amd64.pyd"]
holders = [
(9280, "python.exe", "browser_harness", 9556,
["lib/websockets/speedups.cp313-win_amd64.pyd"], "target"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert self_held == []
assert residual == ["lib/websockets/speedups.cp313-win_amd64.pyd"]


def test_classify_locked_files_unattributable_residual_conservative():
"""A locked file with NO known module-holder stays residual (conservative —
could be a plain non-DLL lock held by an external process)."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [root + "\\bin\\some-data-file.dat"]
self_held, residual = _classify_locked_files(locked, [], root)
assert self_held == []
assert residual == ["bin/some-data-file.dat"]


def test_classify_locked_files_mixed():
"""Mixed: self-held python-dist + external pyd + unattributable data."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [
root + "\\bin\\python-dist\\python313.dll",
root + "\\lib\\websockets\\speedups.cp313-win_amd64.pyd",
root + "\\bin\\data.dat",
]
holders = [
(11572, "python.exe", "python-dist", 1,
["bin/python-dist/python313.dll"], "excluded"),
(9280, "python.exe", "browser_harness", 9556,
["lib/websockets/speedups.cp313-win_amd64.pyd"], "target"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert self_held == ["bin/python-dist/python313.dll"]
assert sorted(residual) == [
"bin/data.dat",
"lib/websockets/speedups.cp313-win_amd64.pyd",
]
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' emrg: TUI user messages render as markdown + stop_all self-held lock attribution by argszero · Pull Request #847 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (952) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (962) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (259: 45 daemon_client + 19 conn-manager + 22 app-commands + 130 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
102 changes: 88 additions & 14 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -921,6 +921,11 @@ def _check_locked_files(root: str, try_open=None) -> list[str]:
_lock_probe_error: str | None = None


def _install_root() -> str:
"""Windows install dir (``~/.emrg/install``) — single source of truth."""
return os.path.join(os.path.expanduser("~"), ".emrg", "install")


def check_install_writable() -> list[str]:
"""Windows: probe ``install\\`` for files locked against overwrite.

Expand All@@ -937,7 +942,7 @@ def check_install_writable() -> list[str]:
_lock_probe_error = None
if not is_win():
return []
root = os.path.join(os.path.expanduser("~"), ".emrg", "install")
root = _install_root()
if not os.path.isdir(root):
return []
files = _iter_install_files(root)
Expand DownExpand Up@@ -1027,6 +1032,53 @@ def stop_lock_owners() -> None:
_windows_cats_cache: list[tuple[str, list[str]]] | None = None


def _classify_locked_files(
locked: list[str],
mh_holders: list[tuple[int, str, str, int, list[str], str]],
root: str,
) -> tuple[list[str], list[str]]:
"""Split createfile-probe locked files into self-held vs residual.

Rant 2026-08-18T18:57:09: when stop_all itself runs from
install\\python-dist\\python.exe, the probe reports the interpreter's own
DLLs (python313.dll, select.pyd, ...) as locked — but those locks belong
to the stop_all process (module-holder tag ``excluded``) and are released
the moment stop_all exits, BEFORE the installer overwrites (installer runs
stop_all synchronously via ewWaitUntilTerminated). Counting them as
residuals aborts a perfectly fine install.

Returns ``(self_held, residual)`` install-relative paths:
- self_held: locked file attributed ONLY to excluded (self/ancestor) holders
- residual: locked file with an external (``target``) holder, or one that
cannot be attributed to any known holder (conservative — could be a
plain non-DLL lock held by an external process that loaded no module).
"""
if not locked or not root:
return [], list(locked)
# Separator-agnostic: module-holder files arrive with backslashes (PS
# Substring), locked paths are native. Normalize both to forward slashes
# so the attribution works identically on Windows and in POSIX unit tests.
def _norm(p: str) -> str:
return p.replace("\\", "/")

root_n = _norm(root)
tag_by_rel: dict[str, set[str]] = {}
for _pid, _name, _exe, _parent, files, tag in mh_holders:
for f in files:
tag_by_rel.setdefault(_norm(f), set()).add(tag)
self_held: list[str] = []
residual: list[str] = []
for p in locked:
rel = _norm(os.path.relpath(_norm(p), root_n))
tags = tag_by_rel.get(rel, set())
if tags == {"excluded"}:
self_held.append(rel)
else:
# No tags (unattributable) OR has an external target holder.
residual.append(rel)
return self_held, residual


def _verify_windows_categories() -> list[tuple[str, list[str]]]:
"""Windows residual scan, one ``(category, residual_strings)`` entry per
check — so the operator can see each check's result instead of guessing
Expand DownExpand Up@@ -1066,9 +1118,10 @@ def _verify_windows_categories() -> list[tuple[str, list[str]]]:
# section locks; RM missed the browser-harness child, CreateFileW probes
# cannot see module locks at all). Any external holder = residual.
mh_out = _module_holder_ps()
mh_holders = _parse_module_holders(mh_out)
mh = [
f"install-module holder (pid {pid}, {name or 'unknown'}, loads {', '.join(files[:3])})"
for pid, name, _exe, _parent, files, tag in _parse_module_holders(mh_out)
for pid, name, _exe, _parent, files, tag in mh_holders
if tag == "target"
]
cats.append(("module-holder", mh))
Expand All@@ -1095,9 +1148,29 @@ def _verify_windows_categories() -> list[tuple[str, list[str]]]:
global _lock_probe_error
_lock_probe_error = None
locked = check_install_writable()
probe_items = [f"locked file (createfile-probe): {p}" for p in locked]
probe_items = []
if _lock_probe_error:
probe_items.append(f"lock-probe failed (error: {_lock_probe_error})")
# Self-held attribution (rant 2026-08-18T18:57:09): when stop_all runs
# from install\python-dist\python.exe, that interpreter MUST load its own
# python-dist DLLs (python313.dll, select.pyd, ...) — those image-section
# locks are held by the stop_all process itself (module-holder tag
# ``excluded`` = self + ancestor chain) and are RELEASED when stop_all
# exits, before the installer starts overwriting (make-installer.sh uses
# ewWaitUntilTerminated). They are NOT residuals — counting them aborts
# the install while nothing is actually wrong. A locked file is residual
# only when an EXTERNAL holder (module-holder ``target`` / RM owner)
# exists or the lock cannot be attributed to any known holder (conservative).
self_held, residual_locked = _classify_locked_files(
locked, mh_holders, _install_root() if is_win() else ""
)
probe_items.extend(f"locked file (createfile-probe): {p}" for p in residual_locked)
if self_held:
print(
f"emrg stop: WARNING {len(self_held)} file(s) locked by stop_all "
f"runtime itself (python-dist DLL) — self-held, released when "
f"stop_all exits; installer continues"
)
cats.append(("createfile-probe", probe_items))

# bundled-git residual
Expand DownExpand Up@@ -1347,19 +1420,20 @@ def stop_all() -> int:
print(f"emrg stop: ERROR retry {attempt}/2: {e} ({type(e).__name__})")
time.sleep(0.3)
print(f"emrg stop: retry {attempt}/2 done ({time.monotonic() - s:.1f}s)")
# Self-lock final guard (rant 2026-08-18T16:09:45 + 16:24:01): after
# both kill retries the probe still reports locked files but neither
# the module-holder enumeration nor RM found an EXTERNAL owner — the
# lock holder is stop_all's own runtime (python-dist loaded
# install\lib modules) or an undetectable handle. The installer
# cannot win; a freshly launched installer process holds no locks.
# Exit 1 so the install aborts with this explanation instead of a
# code-5 dialog.
# Self-lock final guard (rant 2026-08-18T16:09:45 + 16:24:01, refined
# 18:57:09): after both kill retries the probe still reports locked
# files but neither the module-holder enumeration nor RM found an
# EXTERNAL owner — the lock holder is stop_all's own runtime
# (python-dist loaded install\lib modules). The installer runs stop_all
# synchronously (ewWaitUntilTerminated), so these locks are released
# when stop_all exits and the overwrite proceeds — advisory only, NOT
# a hard abort (the pre-18:57:09 guard wrongly blocked installs whose
# only locks were python-dist DLLs held by stop_all itself).
if locked and not _module_holder_external_found and _rm_no_external_owner:
print(
"emrg stop: WARNING lock holder is the stop_all runtime itself "
"(no external module-holder / RM owner) - installer will fail; "
"re-run installer (fresh process won't hold the lock)"
f"emrg stop: WARNING {len(locked)} file(s) locked by the "
f"stop_all runtime itself (python-dist DLL) — released when "
f"stop_all exits; installer continues"
)
residuals = verify()
if residuals:
Expand Down
37 changes: 37 additions & 0 deletions emrg/client/python_tui/widgets/markdown.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,43 @@ def render(self, ctx: RenderContext) -> list[Line]:
return lines


class UserMarkdown(Markdown):
"""User message rendered as markdown with the role prefix preserved.

Plan B (rant 2026-08-18T18:52:45, superseding 18:50:14): user messages
go through the same Rich markdown pipeline as assistant messages — free
width-based wrapping, CJK wide-char handling — while keeping the
``> `` prefix + bold cyan role visual. The markdown is rendered at
``ctx.width - len(prefix)`` so the prefix on the first line never
overflows the buffer width (continuation lines get a same-width indent).
"""

_ROLE_PREFIX = "> "
_ROLE_STYLE = "bold cyan"

def render(self, ctx: RenderContext) -> list[Line]:
from rich.style import Style

from emrg.client.python_tui.rich_bridge import rich_renderable_to_lines
from emrg.client.python_tui.widgets.base import Span

prefix = self._ROLE_PREFIX
indent = " " * len(prefix)
role_style = Style.parse(self._ROLE_STYLE)
avail = max(1, ctx.width - len(prefix))

md = RichMarkdown(self.text, code_theme="monokai")
md_lines = rich_renderable_to_lines(md, avail)
lines: list[Line] = []
for i, line in enumerate(md_lines):
lead = prefix if i == 0 else indent
line.spans.insert(0, Span(text=lead, style=role_style))
line.style = ctx.style
lines.append(line)
self._dirty = False
return lines


@dataclass
class StreamingMarkdown(Widget):
"""Incremental markdown renderer for token-by-token streaming.
Expand Down
12 changes: 11 additions & 1 deletion emrg/client/widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@
from rich.style import Style
from emrg.client.python_tui import ChatRow, ToolCard
from emrg.client.python_tui.widgets.base import Line, Span, Widget
from emrg.client.python_tui.widgets.markdown import StreamingMarkdown
from emrg.client.python_tui.widgets.markdown import StreamingMarkdown, UserMarkdown


class InputWidget(Widget):
Expand DownExpand Up@@ -670,6 +670,11 @@ def dirty(self, v): self._dirty = v
def add(self, role_or_widget, content=None):
if isinstance(role_or_widget, Widget):
self.rows.append(role_or_widget)
elif role_or_widget == "user":
# Plan B (rant 2026-08-18T18:52:45, superseding 18:50:14): user
# messages render as markdown (free width wrap, CJK handling)
# while keeping the "> " prefix + cyan role visual.
self.rows.append(UserMarkdown(content or ""))
else:
self.rows.append(ChatRow(role=role_or_widget, content=content or ""))
self._line_cache.append(None) # 新 row 无缓存
Expand All@@ -693,6 +698,11 @@ def update_last(self, content):
row.dirty = True
self._dirty = True
return
if isinstance(row, UserMarkdown):
row.text = content
row.dirty = True
self._dirty = True
return

def last_tool_card(self):
for row in reversed(self.rows):
Expand Down
88 changes: 80 additions & 8 deletions tests/test_installer_stop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,23 +158,24 @@ def test_stop_all_py_deletefile_semantic_lock_probe():
assert "GENERIC_READ = 0x80000000" not in content # 旧常量赋值已移除
assert "SetFileInformationByHandle" in content
assert "FILE_DISPOSITION_INFO = 2" in content
# 自锁防护(rant 2026-08-18T16:09:45):开头打印 python-dist 运行时 + 兜底
# WARNING(lock holder 是 stop_all 自身 → 提示重跑安装器
# 自锁防护(rant 2026-08-18T16:09:45,18:57:09 改为提示性):开头打印
# python-dist 运行时 + verify 对 self-held 锁不中止安装(stop_all 退出即释放
assert "python-dist runtime:" in content
assert "lock holder is the stop_all runtime itself" in content
assert "re-run installer (fresh process won't hold the lock)" in content
assert "self-held" in content
assert "installer continues" in content
assert "re-run installer (fresh process won't hold the lock)" not in content # 旧文案已移除


def test_stop_all_py_rm_no_external_owner_flag():
"""rant 2026-08-18T16:09:45 — _print_rm_diag 记录"无外部 owner"证据。

RM owners==0 或全部 owner 被祖先链排除 → _rm_no_external_owner=True,
stop_all 重试循环后据此输出自锁 WARNING 并 exit 1。
stop_all 重试循环后据此输出自锁提示(18:57:09 改为 advisory,不再 exit 1
"""
content = _read("emrg/_stop_all.py")
assert "_rm_no_external_owner" in content
assert 'd["owners"] == 0 or "owner(s) excluded" in stdout' in content
assert "re-run installer (fresh process won't hold the lock)" in content
assert "installer continues" in content


def test_stop_all_py_module_holder_enumeration():
Expand DownExpand Up@@ -212,9 +213,10 @@ def test_stop_all_py_module_holder_enumeration():
assert '"RM re-scan", rm' in verify_src
assert verify_src.index('"module-holder"') < verify_src.index('"RM re-scan"')
assert "install-module holder" in verify_src
# 自锁兜底:外部 module-holder 与 RM owner 都无 → WARNING
# 自锁兜底:外部 module-holder 与 RM owner 都无 → 提示性 WARNING(18:57:09
# 改为 advisory —— stop_all 退出即释放,安装继续)
assert "_module_holder_external_found" in content
assert "no external module-holder / RM owner" in content
assert "installer continues" in content
# createfile-probe 降级为补充(CreateFileW 探测对 DLL 模块锁假阴性)
assert "createfile-probe" in content
assert "module locks need the module-holder scan" in content
Expand DownExpand Up@@ -302,3 +304,73 @@ def test_agent_md_no_stop_emrg_cmd_refs():
"""No stale stop-emrg.cmd references in docs."""
for rel in ("README.md", "README.cn.md", "Agent.md"):
assert "stop-emrg.cmd" not in _read(rel), rel


def test_classify_locked_files_self_held_only():
"""rant 2026-08-18T18:57:09 — python-dist DLLs locked by stop_all's own
runtime (module-holder tag=excluded) are self-held → NOT residuals."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [
root + "\\bin\\python-dist\\python313.dll",
root + "\\bin\\python-dist\\select.pyd",
]
holders = [
(11572, "python.exe", "python-dist", 1,
["bin/python-dist/python313.dll", "bin/python-dist/select.pyd"], "excluded"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert sorted(self_held) == ["bin/python-dist/python313.dll", "bin/python-dist/select.pyd"]
assert residual == []


def test_classify_locked_files_external_holder_residual():
"""A locked file held by an EXTERNAL (target) module-holder stays residual."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [root + "\\lib\\websockets\\speedups.cp313-win_amd64.pyd"]
holders = [
(9280, "python.exe", "browser_harness", 9556,
["lib/websockets/speedups.cp313-win_amd64.pyd"], "target"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert self_held == []
assert residual == ["lib/websockets/speedups.cp313-win_amd64.pyd"]


def test_classify_locked_files_unattributable_residual_conservative():
"""A locked file with NO known module-holder stays residual (conservative —
could be a plain non-DLL lock held by an external process)."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [root + "\\bin\\some-data-file.dat"]
self_held, residual = _classify_locked_files(locked, [], root)
assert self_held == []
assert residual == ["bin/some-data-file.dat"]


def test_classify_locked_files_mixed():
"""Mixed: self-held python-dist + external pyd + unattributable data."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [
root + "\\bin\\python-dist\\python313.dll",
root + "\\lib\\websockets\\speedups.cp313-win_amd64.pyd",
root + "\\bin\\data.dat",
]
holders = [
(11572, "python.exe", "python-dist", 1,
["bin/python-dist/python313.dll"], "excluded"),
(9280, "python.exe", "browser_harness", 9556,
["lib/websockets/speedups.cp313-win_amd64.pyd"], "target"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert self_held == ["bin/python-dist/python313.dll"]
assert sorted(residual) == [
"bin/data.dat",
"lib/websockets/speedups.cp313-win_amd64.pyd",
]
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' emrg: TUI user messages render as markdown + stop_all self-held lock attribution by argszero · Pull Request #847 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (952) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (962) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (259: 45 daemon_client + 19 conn-manager + 22 app-commands + 130 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
102 changes: 88 additions & 14 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -921,6 +921,11 @@ def _check_locked_files(root: str, try_open=None) -> list[str]:
_lock_probe_error: str | None = None


def _install_root() -> str:
"""Windows install dir (``~/.emrg/install``) — single source of truth."""
return os.path.join(os.path.expanduser("~"), ".emrg", "install")


def check_install_writable() -> list[str]:
"""Windows: probe ``install\\`` for files locked against overwrite.

Expand All@@ -937,7 +942,7 @@ def check_install_writable() -> list[str]:
_lock_probe_error = None
if not is_win():
return []
root = os.path.join(os.path.expanduser("~"), ".emrg", "install")
root = _install_root()
if not os.path.isdir(root):
return []
files = _iter_install_files(root)
Expand DownExpand Up@@ -1027,6 +1032,53 @@ def stop_lock_owners() -> None:
_windows_cats_cache: list[tuple[str, list[str]]] | None = None


def _classify_locked_files(
locked: list[str],
mh_holders: list[tuple[int, str, str, int, list[str], str]],
root: str,
) -> tuple[list[str], list[str]]:
"""Split createfile-probe locked files into self-held vs residual.

Rant 2026-08-18T18:57:09: when stop_all itself runs from
install\\python-dist\\python.exe, the probe reports the interpreter's own
DLLs (python313.dll, select.pyd, ...) as locked — but those locks belong
to the stop_all process (module-holder tag ``excluded``) and are released
the moment stop_all exits, BEFORE the installer overwrites (installer runs
stop_all synchronously via ewWaitUntilTerminated). Counting them as
residuals aborts a perfectly fine install.

Returns ``(self_held, residual)`` install-relative paths:
- self_held: locked file attributed ONLY to excluded (self/ancestor) holders
- residual: locked file with an external (``target``) holder, or one that
cannot be attributed to any known holder (conservative — could be a
plain non-DLL lock held by an external process that loaded no module).
"""
if not locked or not root:
return [], list(locked)
# Separator-agnostic: module-holder files arrive with backslashes (PS
# Substring), locked paths are native. Normalize both to forward slashes
# so the attribution works identically on Windows and in POSIX unit tests.
def _norm(p: str) -> str:
return p.replace("\\", "/")

root_n = _norm(root)
tag_by_rel: dict[str, set[str]] = {}
for _pid, _name, _exe, _parent, files, tag in mh_holders:
for f in files:
tag_by_rel.setdefault(_norm(f), set()).add(tag)
self_held: list[str] = []
residual: list[str] = []
for p in locked:
rel = _norm(os.path.relpath(_norm(p), root_n))
tags = tag_by_rel.get(rel, set())
if tags == {"excluded"}:
self_held.append(rel)
else:
# No tags (unattributable) OR has an external target holder.
residual.append(rel)
return self_held, residual


def _verify_windows_categories() -> list[tuple[str, list[str]]]:
"""Windows residual scan, one ``(category, residual_strings)`` entry per
check — so the operator can see each check's result instead of guessing
Expand DownExpand Up@@ -1066,9 +1118,10 @@ def _verify_windows_categories() -> list[tuple[str, list[str]]]:
# section locks; RM missed the browser-harness child, CreateFileW probes
# cannot see module locks at all). Any external holder = residual.
mh_out = _module_holder_ps()
mh_holders = _parse_module_holders(mh_out)
mh = [
f"install-module holder (pid {pid}, {name or 'unknown'}, loads {', '.join(files[:3])})"
for pid, name, _exe, _parent, files, tag in _parse_module_holders(mh_out)
for pid, name, _exe, _parent, files, tag in mh_holders
if tag == "target"
]
cats.append(("module-holder", mh))
Expand All@@ -1095,9 +1148,29 @@ def _verify_windows_categories() -> list[tuple[str, list[str]]]:
global _lock_probe_error
_lock_probe_error = None
locked = check_install_writable()
probe_items = [f"locked file (createfile-probe): {p}" for p in locked]
probe_items = []
if _lock_probe_error:
probe_items.append(f"lock-probe failed (error: {_lock_probe_error})")
# Self-held attribution (rant 2026-08-18T18:57:09): when stop_all runs
# from install\python-dist\python.exe, that interpreter MUST load its own
# python-dist DLLs (python313.dll, select.pyd, ...) — those image-section
# locks are held by the stop_all process itself (module-holder tag
# ``excluded`` = self + ancestor chain) and are RELEASED when stop_all
# exits, before the installer starts overwriting (make-installer.sh uses
# ewWaitUntilTerminated). They are NOT residuals — counting them aborts
# the install while nothing is actually wrong. A locked file is residual
# only when an EXTERNAL holder (module-holder ``target`` / RM owner)
# exists or the lock cannot be attributed to any known holder (conservative).
self_held, residual_locked = _classify_locked_files(
locked, mh_holders, _install_root() if is_win() else ""
)
probe_items.extend(f"locked file (createfile-probe): {p}" for p in residual_locked)
if self_held:
print(
f"emrg stop: WARNING {len(self_held)} file(s) locked by stop_all "
f"runtime itself (python-dist DLL) — self-held, released when "
f"stop_all exits; installer continues"
)
cats.append(("createfile-probe", probe_items))

# bundled-git residual
Expand DownExpand Up@@ -1347,19 +1420,20 @@ def stop_all() -> int:
print(f"emrg stop: ERROR retry {attempt}/2: {e} ({type(e).__name__})")
time.sleep(0.3)
print(f"emrg stop: retry {attempt}/2 done ({time.monotonic() - s:.1f}s)")
# Self-lock final guard (rant 2026-08-18T16:09:45 + 16:24:01): after
# both kill retries the probe still reports locked files but neither
# the module-holder enumeration nor RM found an EXTERNAL owner — the
# lock holder is stop_all's own runtime (python-dist loaded
# install\lib modules) or an undetectable handle. The installer
# cannot win; a freshly launched installer process holds no locks.
# Exit 1 so the install aborts with this explanation instead of a
# code-5 dialog.
# Self-lock final guard (rant 2026-08-18T16:09:45 + 16:24:01, refined
# 18:57:09): after both kill retries the probe still reports locked
# files but neither the module-holder enumeration nor RM found an
# EXTERNAL owner — the lock holder is stop_all's own runtime
# (python-dist loaded install\lib modules). The installer runs stop_all
# synchronously (ewWaitUntilTerminated), so these locks are released
# when stop_all exits and the overwrite proceeds — advisory only, NOT
# a hard abort (the pre-18:57:09 guard wrongly blocked installs whose
# only locks were python-dist DLLs held by stop_all itself).
if locked and not _module_holder_external_found and _rm_no_external_owner:
print(
"emrg stop: WARNING lock holder is the stop_all runtime itself "
"(no external module-holder / RM owner) - installer will fail; "
"re-run installer (fresh process won't hold the lock)"
f"emrg stop: WARNING {len(locked)} file(s) locked by the "
f"stop_all runtime itself (python-dist DLL) — released when "
f"stop_all exits; installer continues"
)
residuals = verify()
if residuals:
Expand Down
37 changes: 37 additions & 0 deletions emrg/client/python_tui/widgets/markdown.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,43 @@ def render(self, ctx: RenderContext) -> list[Line]:
return lines


class UserMarkdown(Markdown):
"""User message rendered as markdown with the role prefix preserved.

Plan B (rant 2026-08-18T18:52:45, superseding 18:50:14): user messages
go through the same Rich markdown pipeline as assistant messages — free
width-based wrapping, CJK wide-char handling — while keeping the
``> `` prefix + bold cyan role visual. The markdown is rendered at
``ctx.width - len(prefix)`` so the prefix on the first line never
overflows the buffer width (continuation lines get a same-width indent).
"""

_ROLE_PREFIX = "> "
_ROLE_STYLE = "bold cyan"

def render(self, ctx: RenderContext) -> list[Line]:
from rich.style import Style

from emrg.client.python_tui.rich_bridge import rich_renderable_to_lines
from emrg.client.python_tui.widgets.base import Span

prefix = self._ROLE_PREFIX
indent = " " * len(prefix)
role_style = Style.parse(self._ROLE_STYLE)
avail = max(1, ctx.width - len(prefix))

md = RichMarkdown(self.text, code_theme="monokai")
md_lines = rich_renderable_to_lines(md, avail)
lines: list[Line] = []
for i, line in enumerate(md_lines):
lead = prefix if i == 0 else indent
line.spans.insert(0, Span(text=lead, style=role_style))
line.style = ctx.style
lines.append(line)
self._dirty = False
return lines


@dataclass
class StreamingMarkdown(Widget):
"""Incremental markdown renderer for token-by-token streaming.
Expand Down
12 changes: 11 additions & 1 deletion emrg/client/widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@
from rich.style import Style
from emrg.client.python_tui import ChatRow, ToolCard
from emrg.client.python_tui.widgets.base import Line, Span, Widget
from emrg.client.python_tui.widgets.markdown import StreamingMarkdown
from emrg.client.python_tui.widgets.markdown import StreamingMarkdown, UserMarkdown


class InputWidget(Widget):
Expand DownExpand Up@@ -670,6 +670,11 @@ def dirty(self, v): self._dirty = v
def add(self, role_or_widget, content=None):
if isinstance(role_or_widget, Widget):
self.rows.append(role_or_widget)
elif role_or_widget == "user":
# Plan B (rant 2026-08-18T18:52:45, superseding 18:50:14): user
# messages render as markdown (free width wrap, CJK handling)
# while keeping the "> " prefix + cyan role visual.
self.rows.append(UserMarkdown(content or ""))
else:
self.rows.append(ChatRow(role=role_or_widget, content=content or ""))
self._line_cache.append(None) # 新 row 无缓存
Expand All@@ -693,6 +698,11 @@ def update_last(self, content):
row.dirty = True
self._dirty = True
return
if isinstance(row, UserMarkdown):
row.text = content
row.dirty = True
self._dirty = True
return

def last_tool_card(self):
for row in reversed(self.rows):
Expand Down
88 changes: 80 additions & 8 deletions tests/test_installer_stop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,23 +158,24 @@ def test_stop_all_py_deletefile_semantic_lock_probe():
assert "GENERIC_READ = 0x80000000" not in content # 旧常量赋值已移除
assert "SetFileInformationByHandle" in content
assert "FILE_DISPOSITION_INFO = 2" in content
# 自锁防护(rant 2026-08-18T16:09:45):开头打印 python-dist 运行时 + 兜底
# WARNING(lock holder 是 stop_all 自身 → 提示重跑安装器
# 自锁防护(rant 2026-08-18T16:09:45,18:57:09 改为提示性):开头打印
# python-dist 运行时 + verify 对 self-held 锁不中止安装(stop_all 退出即释放
assert "python-dist runtime:" in content
assert "lock holder is the stop_all runtime itself" in content
assert "re-run installer (fresh process won't hold the lock)" in content
assert "self-held" in content
assert "installer continues" in content
assert "re-run installer (fresh process won't hold the lock)" not in content # 旧文案已移除


def test_stop_all_py_rm_no_external_owner_flag():
"""rant 2026-08-18T16:09:45 — _print_rm_diag 记录"无外部 owner"证据。

RM owners==0 或全部 owner 被祖先链排除 → _rm_no_external_owner=True,
stop_all 重试循环后据此输出自锁 WARNING 并 exit 1。
stop_all 重试循环后据此输出自锁提示(18:57:09 改为 advisory,不再 exit 1
"""
content = _read("emrg/_stop_all.py")
assert "_rm_no_external_owner" in content
assert 'd["owners"] == 0 or "owner(s) excluded" in stdout' in content
assert "re-run installer (fresh process won't hold the lock)" in content
assert "installer continues" in content


def test_stop_all_py_module_holder_enumeration():
Expand DownExpand Up@@ -212,9 +213,10 @@ def test_stop_all_py_module_holder_enumeration():
assert '"RM re-scan", rm' in verify_src
assert verify_src.index('"module-holder"') < verify_src.index('"RM re-scan"')
assert "install-module holder" in verify_src
# 自锁兜底:外部 module-holder 与 RM owner 都无 → WARNING
# 自锁兜底:外部 module-holder 与 RM owner 都无 → 提示性 WARNING(18:57:09
# 改为 advisory —— stop_all 退出即释放,安装继续)
assert "_module_holder_external_found" in content
assert "no external module-holder / RM owner" in content
assert "installer continues" in content
# createfile-probe 降级为补充(CreateFileW 探测对 DLL 模块锁假阴性)
assert "createfile-probe" in content
assert "module locks need the module-holder scan" in content
Expand DownExpand Up@@ -302,3 +304,73 @@ def test_agent_md_no_stop_emrg_cmd_refs():
"""No stale stop-emrg.cmd references in docs."""
for rel in ("README.md", "README.cn.md", "Agent.md"):
assert "stop-emrg.cmd" not in _read(rel), rel


def test_classify_locked_files_self_held_only():
"""rant 2026-08-18T18:57:09 — python-dist DLLs locked by stop_all's own
runtime (module-holder tag=excluded) are self-held → NOT residuals."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [
root + "\\bin\\python-dist\\python313.dll",
root + "\\bin\\python-dist\\select.pyd",
]
holders = [
(11572, "python.exe", "python-dist", 1,
["bin/python-dist/python313.dll", "bin/python-dist/select.pyd"], "excluded"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert sorted(self_held) == ["bin/python-dist/python313.dll", "bin/python-dist/select.pyd"]
assert residual == []


def test_classify_locked_files_external_holder_residual():
"""A locked file held by an EXTERNAL (target) module-holder stays residual."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [root + "\\lib\\websockets\\speedups.cp313-win_amd64.pyd"]
holders = [
(9280, "python.exe", "browser_harness", 9556,
["lib/websockets/speedups.cp313-win_amd64.pyd"], "target"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert self_held == []
assert residual == ["lib/websockets/speedups.cp313-win_amd64.pyd"]


def test_classify_locked_files_unattributable_residual_conservative():
"""A locked file with NO known module-holder stays residual (conservative —
could be a plain non-DLL lock held by an external process)."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [root + "\\bin\\some-data-file.dat"]
self_held, residual = _classify_locked_files(locked, [], root)
assert self_held == []
assert residual == ["bin/some-data-file.dat"]


def test_classify_locked_files_mixed():
"""Mixed: self-held python-dist + external pyd + unattributable data."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [
root + "\\bin\\python-dist\\python313.dll",
root + "\\lib\\websockets\\speedups.cp313-win_amd64.pyd",
root + "\\bin\\data.dat",
]
holders = [
(11572, "python.exe", "python-dist", 1,
["bin/python-dist/python313.dll"], "excluded"),
(9280, "python.exe", "browser_harness", 9556,
["lib/websockets/speedups.cp313-win_amd64.pyd"], "target"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert self_held == ["bin/python-dist/python313.dll"]
assert sorted(residual) == [
"bin/data.dat",
"lib/websockets/speedups.cp313-win_amd64.pyd",
]
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' emrg: TUI user messages render as markdown + stop_all self-held lock attribution by argszero · Pull Request #847 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (952) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (962) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (259: 45 daemon_client + 19 conn-manager + 22 app-commands + 130 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
102 changes: 88 additions & 14 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -921,6 +921,11 @@ def _check_locked_files(root: str, try_open=None) -> list[str]:
_lock_probe_error: str | None = None


def _install_root() -> str:
"""Windows install dir (``~/.emrg/install``) — single source of truth."""
return os.path.join(os.path.expanduser("~"), ".emrg", "install")


def check_install_writable() -> list[str]:
"""Windows: probe ``install\\`` for files locked against overwrite.

Expand All@@ -937,7 +942,7 @@ def check_install_writable() -> list[str]:
_lock_probe_error = None
if not is_win():
return []
root = os.path.join(os.path.expanduser("~"), ".emrg", "install")
root = _install_root()
if not os.path.isdir(root):
return []
files = _iter_install_files(root)
Expand DownExpand Up@@ -1027,6 +1032,53 @@ def stop_lock_owners() -> None:
_windows_cats_cache: list[tuple[str, list[str]]] | None = None


def _classify_locked_files(
locked: list[str],
mh_holders: list[tuple[int, str, str, int, list[str], str]],
root: str,
) -> tuple[list[str], list[str]]:
"""Split createfile-probe locked files into self-held vs residual.

Rant 2026-08-18T18:57:09: when stop_all itself runs from
install\\python-dist\\python.exe, the probe reports the interpreter's own
DLLs (python313.dll, select.pyd, ...) as locked — but those locks belong
to the stop_all process (module-holder tag ``excluded``) and are released
the moment stop_all exits, BEFORE the installer overwrites (installer runs
stop_all synchronously via ewWaitUntilTerminated). Counting them as
residuals aborts a perfectly fine install.

Returns ``(self_held, residual)`` install-relative paths:
- self_held: locked file attributed ONLY to excluded (self/ancestor) holders
- residual: locked file with an external (``target``) holder, or one that
cannot be attributed to any known holder (conservative — could be a
plain non-DLL lock held by an external process that loaded no module).
"""
if not locked or not root:
return [], list(locked)
# Separator-agnostic: module-holder files arrive with backslashes (PS
# Substring), locked paths are native. Normalize both to forward slashes
# so the attribution works identically on Windows and in POSIX unit tests.
def _norm(p: str) -> str:
return p.replace("\\", "/")

root_n = _norm(root)
tag_by_rel: dict[str, set[str]] = {}
for _pid, _name, _exe, _parent, files, tag in mh_holders:
for f in files:
tag_by_rel.setdefault(_norm(f), set()).add(tag)
self_held: list[str] = []
residual: list[str] = []
for p in locked:
rel = _norm(os.path.relpath(_norm(p), root_n))
tags = tag_by_rel.get(rel, set())
if tags == {"excluded"}:
self_held.append(rel)
else:
# No tags (unattributable) OR has an external target holder.
residual.append(rel)
return self_held, residual


def _verify_windows_categories() -> list[tuple[str, list[str]]]:
"""Windows residual scan, one ``(category, residual_strings)`` entry per
check — so the operator can see each check's result instead of guessing
Expand DownExpand Up@@ -1066,9 +1118,10 @@ def _verify_windows_categories() -> list[tuple[str, list[str]]]:
# section locks; RM missed the browser-harness child, CreateFileW probes
# cannot see module locks at all). Any external holder = residual.
mh_out = _module_holder_ps()
mh_holders = _parse_module_holders(mh_out)
mh = [
f"install-module holder (pid {pid}, {name or 'unknown'}, loads {', '.join(files[:3])})"
for pid, name, _exe, _parent, files, tag in _parse_module_holders(mh_out)
for pid, name, _exe, _parent, files, tag in mh_holders
if tag == "target"
]
cats.append(("module-holder", mh))
Expand All@@ -1095,9 +1148,29 @@ def _verify_windows_categories() -> list[tuple[str, list[str]]]:
global _lock_probe_error
_lock_probe_error = None
locked = check_install_writable()
probe_items = [f"locked file (createfile-probe): {p}" for p in locked]
probe_items = []
if _lock_probe_error:
probe_items.append(f"lock-probe failed (error: {_lock_probe_error})")
# Self-held attribution (rant 2026-08-18T18:57:09): when stop_all runs
# from install\python-dist\python.exe, that interpreter MUST load its own
# python-dist DLLs (python313.dll, select.pyd, ...) — those image-section
# locks are held by the stop_all process itself (module-holder tag
# ``excluded`` = self + ancestor chain) and are RELEASED when stop_all
# exits, before the installer starts overwriting (make-installer.sh uses
# ewWaitUntilTerminated). They are NOT residuals — counting them aborts
# the install while nothing is actually wrong. A locked file is residual
# only when an EXTERNAL holder (module-holder ``target`` / RM owner)
# exists or the lock cannot be attributed to any known holder (conservative).
self_held, residual_locked = _classify_locked_files(
locked, mh_holders, _install_root() if is_win() else ""
)
probe_items.extend(f"locked file (createfile-probe): {p}" for p in residual_locked)
if self_held:
print(
f"emrg stop: WARNING {len(self_held)} file(s) locked by stop_all "
f"runtime itself (python-dist DLL) — self-held, released when "
f"stop_all exits; installer continues"
)
cats.append(("createfile-probe", probe_items))

# bundled-git residual
Expand DownExpand Up@@ -1347,19 +1420,20 @@ def stop_all() -> int:
print(f"emrg stop: ERROR retry {attempt}/2: {e} ({type(e).__name__})")
time.sleep(0.3)
print(f"emrg stop: retry {attempt}/2 done ({time.monotonic() - s:.1f}s)")
# Self-lock final guard (rant 2026-08-18T16:09:45 + 16:24:01): after
# both kill retries the probe still reports locked files but neither
# the module-holder enumeration nor RM found an EXTERNAL owner — the
# lock holder is stop_all's own runtime (python-dist loaded
# install\lib modules) or an undetectable handle. The installer
# cannot win; a freshly launched installer process holds no locks.
# Exit 1 so the install aborts with this explanation instead of a
# code-5 dialog.
# Self-lock final guard (rant 2026-08-18T16:09:45 + 16:24:01, refined
# 18:57:09): after both kill retries the probe still reports locked
# files but neither the module-holder enumeration nor RM found an
# EXTERNAL owner — the lock holder is stop_all's own runtime
# (python-dist loaded install\lib modules). The installer runs stop_all
# synchronously (ewWaitUntilTerminated), so these locks are released
# when stop_all exits and the overwrite proceeds — advisory only, NOT
# a hard abort (the pre-18:57:09 guard wrongly blocked installs whose
# only locks were python-dist DLLs held by stop_all itself).
if locked and not _module_holder_external_found and _rm_no_external_owner:
print(
"emrg stop: WARNING lock holder is the stop_all runtime itself "
"(no external module-holder / RM owner) - installer will fail; "
"re-run installer (fresh process won't hold the lock)"
f"emrg stop: WARNING {len(locked)} file(s) locked by the "
f"stop_all runtime itself (python-dist DLL) — released when "
f"stop_all exits; installer continues"
)
residuals = verify()
if residuals:
Expand Down
37 changes: 37 additions & 0 deletions emrg/client/python_tui/widgets/markdown.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,43 @@ def render(self, ctx: RenderContext) -> list[Line]:
return lines


class UserMarkdown(Markdown):
"""User message rendered as markdown with the role prefix preserved.

Plan B (rant 2026-08-18T18:52:45, superseding 18:50:14): user messages
go through the same Rich markdown pipeline as assistant messages — free
width-based wrapping, CJK wide-char handling — while keeping the
``> `` prefix + bold cyan role visual. The markdown is rendered at
``ctx.width - len(prefix)`` so the prefix on the first line never
overflows the buffer width (continuation lines get a same-width indent).
"""

_ROLE_PREFIX = "> "
_ROLE_STYLE = "bold cyan"

def render(self, ctx: RenderContext) -> list[Line]:
from rich.style import Style

from emrg.client.python_tui.rich_bridge import rich_renderable_to_lines
from emrg.client.python_tui.widgets.base import Span

prefix = self._ROLE_PREFIX
indent = " " * len(prefix)
role_style = Style.parse(self._ROLE_STYLE)
avail = max(1, ctx.width - len(prefix))

md = RichMarkdown(self.text, code_theme="monokai")
md_lines = rich_renderable_to_lines(md, avail)
lines: list[Line] = []
for i, line in enumerate(md_lines):
lead = prefix if i == 0 else indent
line.spans.insert(0, Span(text=lead, style=role_style))
line.style = ctx.style
lines.append(line)
self._dirty = False
return lines


@dataclass
class StreamingMarkdown(Widget):
"""Incremental markdown renderer for token-by-token streaming.
Expand Down
12 changes: 11 additions & 1 deletion emrg/client/widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@
from rich.style import Style
from emrg.client.python_tui import ChatRow, ToolCard
from emrg.client.python_tui.widgets.base import Line, Span, Widget
from emrg.client.python_tui.widgets.markdown import StreamingMarkdown
from emrg.client.python_tui.widgets.markdown import StreamingMarkdown, UserMarkdown


class InputWidget(Widget):
Expand DownExpand Up@@ -670,6 +670,11 @@ def dirty(self, v): self._dirty = v
def add(self, role_or_widget, content=None):
if isinstance(role_or_widget, Widget):
self.rows.append(role_or_widget)
elif role_or_widget == "user":
# Plan B (rant 2026-08-18T18:52:45, superseding 18:50:14): user
# messages render as markdown (free width wrap, CJK handling)
# while keeping the "> " prefix + cyan role visual.
self.rows.append(UserMarkdown(content or ""))
else:
self.rows.append(ChatRow(role=role_or_widget, content=content or ""))
self._line_cache.append(None) # 新 row 无缓存
Expand All@@ -693,6 +698,11 @@ def update_last(self, content):
row.dirty = True
self._dirty = True
return
if isinstance(row, UserMarkdown):
row.text = content
row.dirty = True
self._dirty = True
return

def last_tool_card(self):
for row in reversed(self.rows):
Expand Down
88 changes: 80 additions & 8 deletions tests/test_installer_stop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,23 +158,24 @@ def test_stop_all_py_deletefile_semantic_lock_probe():
assert "GENERIC_READ = 0x80000000" not in content # 旧常量赋值已移除
assert "SetFileInformationByHandle" in content
assert "FILE_DISPOSITION_INFO = 2" in content
# 自锁防护(rant 2026-08-18T16:09:45):开头打印 python-dist 运行时 + 兜底
# WARNING(lock holder 是 stop_all 自身 → 提示重跑安装器
# 自锁防护(rant 2026-08-18T16:09:45,18:57:09 改为提示性):开头打印
# python-dist 运行时 + verify 对 self-held 锁不中止安装(stop_all 退出即释放
assert "python-dist runtime:" in content
assert "lock holder is the stop_all runtime itself" in content
assert "re-run installer (fresh process won't hold the lock)" in content
assert "self-held" in content
assert "installer continues" in content
assert "re-run installer (fresh process won't hold the lock)" not in content # 旧文案已移除


def test_stop_all_py_rm_no_external_owner_flag():
"""rant 2026-08-18T16:09:45 — _print_rm_diag 记录"无外部 owner"证据。

RM owners==0 或全部 owner 被祖先链排除 → _rm_no_external_owner=True,
stop_all 重试循环后据此输出自锁 WARNING 并 exit 1。
stop_all 重试循环后据此输出自锁提示(18:57:09 改为 advisory,不再 exit 1
"""
content = _read("emrg/_stop_all.py")
assert "_rm_no_external_owner" in content
assert 'd["owners"] == 0 or "owner(s) excluded" in stdout' in content
assert "re-run installer (fresh process won't hold the lock)" in content
assert "installer continues" in content


def test_stop_all_py_module_holder_enumeration():
Expand DownExpand Up@@ -212,9 +213,10 @@ def test_stop_all_py_module_holder_enumeration():
assert '"RM re-scan", rm' in verify_src
assert verify_src.index('"module-holder"') < verify_src.index('"RM re-scan"')
assert "install-module holder" in verify_src
# 自锁兜底:外部 module-holder 与 RM owner 都无 → WARNING
# 自锁兜底:外部 module-holder 与 RM owner 都无 → 提示性 WARNING(18:57:09
# 改为 advisory —— stop_all 退出即释放,安装继续)
assert "_module_holder_external_found" in content
assert "no external module-holder / RM owner" in content
assert "installer continues" in content
# createfile-probe 降级为补充(CreateFileW 探测对 DLL 模块锁假阴性)
assert "createfile-probe" in content
assert "module locks need the module-holder scan" in content
Expand DownExpand Up@@ -302,3 +304,73 @@ def test_agent_md_no_stop_emrg_cmd_refs():
"""No stale stop-emrg.cmd references in docs."""
for rel in ("README.md", "README.cn.md", "Agent.md"):
assert "stop-emrg.cmd" not in _read(rel), rel


def test_classify_locked_files_self_held_only():
"""rant 2026-08-18T18:57:09 — python-dist DLLs locked by stop_all's own
runtime (module-holder tag=excluded) are self-held → NOT residuals."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [
root + "\\bin\\python-dist\\python313.dll",
root + "\\bin\\python-dist\\select.pyd",
]
holders = [
(11572, "python.exe", "python-dist", 1,
["bin/python-dist/python313.dll", "bin/python-dist/select.pyd"], "excluded"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert sorted(self_held) == ["bin/python-dist/python313.dll", "bin/python-dist/select.pyd"]
assert residual == []


def test_classify_locked_files_external_holder_residual():
"""A locked file held by an EXTERNAL (target) module-holder stays residual."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [root + "\\lib\\websockets\\speedups.cp313-win_amd64.pyd"]
holders = [
(9280, "python.exe", "browser_harness", 9556,
["lib/websockets/speedups.cp313-win_amd64.pyd"], "target"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert self_held == []
assert residual == ["lib/websockets/speedups.cp313-win_amd64.pyd"]


def test_classify_locked_files_unattributable_residual_conservative():
"""A locked file with NO known module-holder stays residual (conservative —
could be a plain non-DLL lock held by an external process)."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [root + "\\bin\\some-data-file.dat"]
self_held, residual = _classify_locked_files(locked, [], root)
assert self_held == []
assert residual == ["bin/some-data-file.dat"]


def test_classify_locked_files_mixed():
"""Mixed: self-held python-dist + external pyd + unattributable data."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [
root + "\\bin\\python-dist\\python313.dll",
root + "\\lib\\websockets\\speedups.cp313-win_amd64.pyd",
root + "\\bin\\data.dat",
]
holders = [
(11572, "python.exe", "python-dist", 1,
["bin/python-dist/python313.dll"], "excluded"),
(9280, "python.exe", "browser_harness", 9556,
["lib/websockets/speedups.cp313-win_amd64.pyd"], "target"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert self_held == ["bin/python-dist/python313.dll"]
assert sorted(residual) == [
"bin/data.dat",
"lib/websockets/speedups.cp313-win_amd64.pyd",
]
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' emrg: TUI user messages render as markdown + stop_all self-held lock attribution by argszero · Pull Request #847 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (952) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (962) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (259: 45 daemon_client + 19 conn-manager + 22 app-commands + 130 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
102 changes: 88 additions & 14 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -921,6 +921,11 @@ def _check_locked_files(root: str, try_open=None) -> list[str]:
_lock_probe_error: str | None = None


def _install_root() -> str:
"""Windows install dir (``~/.emrg/install``) — single source of truth."""
return os.path.join(os.path.expanduser("~"), ".emrg", "install")


def check_install_writable() -> list[str]:
"""Windows: probe ``install\\`` for files locked against overwrite.

Expand All@@ -937,7 +942,7 @@ def check_install_writable() -> list[str]:
_lock_probe_error = None
if not is_win():
return []
root = os.path.join(os.path.expanduser("~"), ".emrg", "install")
root = _install_root()
if not os.path.isdir(root):
return []
files = _iter_install_files(root)
Expand DownExpand Up@@ -1027,6 +1032,53 @@ def stop_lock_owners() -> None:
_windows_cats_cache: list[tuple[str, list[str]]] | None = None


def _classify_locked_files(
locked: list[str],
mh_holders: list[tuple[int, str, str, int, list[str], str]],
root: str,
) -> tuple[list[str], list[str]]:
"""Split createfile-probe locked files into self-held vs residual.

Rant 2026-08-18T18:57:09: when stop_all itself runs from
install\\python-dist\\python.exe, the probe reports the interpreter's own
DLLs (python313.dll, select.pyd, ...) as locked — but those locks belong
to the stop_all process (module-holder tag ``excluded``) and are released
the moment stop_all exits, BEFORE the installer overwrites (installer runs
stop_all synchronously via ewWaitUntilTerminated). Counting them as
residuals aborts a perfectly fine install.

Returns ``(self_held, residual)`` install-relative paths:
- self_held: locked file attributed ONLY to excluded (self/ancestor) holders
- residual: locked file with an external (``target``) holder, or one that
cannot be attributed to any known holder (conservative — could be a
plain non-DLL lock held by an external process that loaded no module).
"""
if not locked or not root:
return [], list(locked)
# Separator-agnostic: module-holder files arrive with backslashes (PS
# Substring), locked paths are native. Normalize both to forward slashes
# so the attribution works identically on Windows and in POSIX unit tests.
def _norm(p: str) -> str:
return p.replace("\\", "/")

root_n = _norm(root)
tag_by_rel: dict[str, set[str]] = {}
for _pid, _name, _exe, _parent, files, tag in mh_holders:
for f in files:
tag_by_rel.setdefault(_norm(f), set()).add(tag)
self_held: list[str] = []
residual: list[str] = []
for p in locked:
rel = _norm(os.path.relpath(_norm(p), root_n))
tags = tag_by_rel.get(rel, set())
if tags == {"excluded"}:
self_held.append(rel)
else:
# No tags (unattributable) OR has an external target holder.
residual.append(rel)
return self_held, residual


def _verify_windows_categories() -> list[tuple[str, list[str]]]:
"""Windows residual scan, one ``(category, residual_strings)`` entry per
check — so the operator can see each check's result instead of guessing
Expand DownExpand Up@@ -1066,9 +1118,10 @@ def _verify_windows_categories() -> list[tuple[str, list[str]]]:
# section locks; RM missed the browser-harness child, CreateFileW probes
# cannot see module locks at all). Any external holder = residual.
mh_out = _module_holder_ps()
mh_holders = _parse_module_holders(mh_out)
mh = [
f"install-module holder (pid {pid}, {name or 'unknown'}, loads {', '.join(files[:3])})"
for pid, name, _exe, _parent, files, tag in _parse_module_holders(mh_out)
for pid, name, _exe, _parent, files, tag in mh_holders
if tag == "target"
]
cats.append(("module-holder", mh))
Expand All@@ -1095,9 +1148,29 @@ def _verify_windows_categories() -> list[tuple[str, list[str]]]:
global _lock_probe_error
_lock_probe_error = None
locked = check_install_writable()
probe_items = [f"locked file (createfile-probe): {p}" for p in locked]
probe_items = []
if _lock_probe_error:
probe_items.append(f"lock-probe failed (error: {_lock_probe_error})")
# Self-held attribution (rant 2026-08-18T18:57:09): when stop_all runs
# from install\python-dist\python.exe, that interpreter MUST load its own
# python-dist DLLs (python313.dll, select.pyd, ...) — those image-section
# locks are held by the stop_all process itself (module-holder tag
# ``excluded`` = self + ancestor chain) and are RELEASED when stop_all
# exits, before the installer starts overwriting (make-installer.sh uses
# ewWaitUntilTerminated). They are NOT residuals — counting them aborts
# the install while nothing is actually wrong. A locked file is residual
# only when an EXTERNAL holder (module-holder ``target`` / RM owner)
# exists or the lock cannot be attributed to any known holder (conservative).
self_held, residual_locked = _classify_locked_files(
locked, mh_holders, _install_root() if is_win() else ""
)
probe_items.extend(f"locked file (createfile-probe): {p}" for p in residual_locked)
if self_held:
print(
f"emrg stop: WARNING {len(self_held)} file(s) locked by stop_all "
f"runtime itself (python-dist DLL) — self-held, released when "
f"stop_all exits; installer continues"
)
cats.append(("createfile-probe", probe_items))

# bundled-git residual
Expand DownExpand Up@@ -1347,19 +1420,20 @@ def stop_all() -> int:
print(f"emrg stop: ERROR retry {attempt}/2: {e} ({type(e).__name__})")
time.sleep(0.3)
print(f"emrg stop: retry {attempt}/2 done ({time.monotonic() - s:.1f}s)")
# Self-lock final guard (rant 2026-08-18T16:09:45 + 16:24:01): after
# both kill retries the probe still reports locked files but neither
# the module-holder enumeration nor RM found an EXTERNAL owner — the
# lock holder is stop_all's own runtime (python-dist loaded
# install\lib modules) or an undetectable handle. The installer
# cannot win; a freshly launched installer process holds no locks.
# Exit 1 so the install aborts with this explanation instead of a
# code-5 dialog.
# Self-lock final guard (rant 2026-08-18T16:09:45 + 16:24:01, refined
# 18:57:09): after both kill retries the probe still reports locked
# files but neither the module-holder enumeration nor RM found an
# EXTERNAL owner — the lock holder is stop_all's own runtime
# (python-dist loaded install\lib modules). The installer runs stop_all
# synchronously (ewWaitUntilTerminated), so these locks are released
# when stop_all exits and the overwrite proceeds — advisory only, NOT
# a hard abort (the pre-18:57:09 guard wrongly blocked installs whose
# only locks were python-dist DLLs held by stop_all itself).
if locked and not _module_holder_external_found and _rm_no_external_owner:
print(
"emrg stop: WARNING lock holder is the stop_all runtime itself "
"(no external module-holder / RM owner) - installer will fail; "
"re-run installer (fresh process won't hold the lock)"
f"emrg stop: WARNING {len(locked)} file(s) locked by the "
f"stop_all runtime itself (python-dist DLL) — released when "
f"stop_all exits; installer continues"
)
residuals = verify()
if residuals:
Expand Down
37 changes: 37 additions & 0 deletions emrg/client/python_tui/widgets/markdown.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,43 @@ def render(self, ctx: RenderContext) -> list[Line]:
return lines


class UserMarkdown(Markdown):
"""User message rendered as markdown with the role prefix preserved.

Plan B (rant 2026-08-18T18:52:45, superseding 18:50:14): user messages
go through the same Rich markdown pipeline as assistant messages — free
width-based wrapping, CJK wide-char handling — while keeping the
``> `` prefix + bold cyan role visual. The markdown is rendered at
``ctx.width - len(prefix)`` so the prefix on the first line never
overflows the buffer width (continuation lines get a same-width indent).
"""

_ROLE_PREFIX = "> "
_ROLE_STYLE = "bold cyan"

def render(self, ctx: RenderContext) -> list[Line]:
from rich.style import Style

from emrg.client.python_tui.rich_bridge import rich_renderable_to_lines
from emrg.client.python_tui.widgets.base import Span

prefix = self._ROLE_PREFIX
indent = " " * len(prefix)
role_style = Style.parse(self._ROLE_STYLE)
avail = max(1, ctx.width - len(prefix))

md = RichMarkdown(self.text, code_theme="monokai")
md_lines = rich_renderable_to_lines(md, avail)
lines: list[Line] = []
for i, line in enumerate(md_lines):
lead = prefix if i == 0 else indent
line.spans.insert(0, Span(text=lead, style=role_style))
line.style = ctx.style
lines.append(line)
self._dirty = False
return lines


@dataclass
class StreamingMarkdown(Widget):
"""Incremental markdown renderer for token-by-token streaming.
Expand Down
12 changes: 11 additions & 1 deletion emrg/client/widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@
from rich.style import Style
from emrg.client.python_tui import ChatRow, ToolCard
from emrg.client.python_tui.widgets.base import Line, Span, Widget
from emrg.client.python_tui.widgets.markdown import StreamingMarkdown
from emrg.client.python_tui.widgets.markdown import StreamingMarkdown, UserMarkdown


class InputWidget(Widget):
Expand DownExpand Up@@ -670,6 +670,11 @@ def dirty(self, v): self._dirty = v
def add(self, role_or_widget, content=None):
if isinstance(role_or_widget, Widget):
self.rows.append(role_or_widget)
elif role_or_widget == "user":
# Plan B (rant 2026-08-18T18:52:45, superseding 18:50:14): user
# messages render as markdown (free width wrap, CJK handling)
# while keeping the "> " prefix + cyan role visual.
self.rows.append(UserMarkdown(content or ""))
else:
self.rows.append(ChatRow(role=role_or_widget, content=content or ""))
self._line_cache.append(None) # 新 row 无缓存
Expand All@@ -693,6 +698,11 @@ def update_last(self, content):
row.dirty = True
self._dirty = True
return
if isinstance(row, UserMarkdown):
row.text = content
row.dirty = True
self._dirty = True
return

def last_tool_card(self):
for row in reversed(self.rows):
Expand Down
88 changes: 80 additions & 8 deletions tests/test_installer_stop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,23 +158,24 @@ def test_stop_all_py_deletefile_semantic_lock_probe():
assert "GENERIC_READ = 0x80000000" not in content # 旧常量赋值已移除
assert "SetFileInformationByHandle" in content
assert "FILE_DISPOSITION_INFO = 2" in content
# 自锁防护(rant 2026-08-18T16:09:45):开头打印 python-dist 运行时 + 兜底
# WARNING(lock holder 是 stop_all 自身 → 提示重跑安装器
# 自锁防护(rant 2026-08-18T16:09:45,18:57:09 改为提示性):开头打印
# python-dist 运行时 + verify 对 self-held 锁不中止安装(stop_all 退出即释放
assert "python-dist runtime:" in content
assert "lock holder is the stop_all runtime itself" in content
assert "re-run installer (fresh process won't hold the lock)" in content
assert "self-held" in content
assert "installer continues" in content
assert "re-run installer (fresh process won't hold the lock)" not in content # 旧文案已移除


def test_stop_all_py_rm_no_external_owner_flag():
"""rant 2026-08-18T16:09:45 — _print_rm_diag 记录"无外部 owner"证据。

RM owners==0 或全部 owner 被祖先链排除 → _rm_no_external_owner=True,
stop_all 重试循环后据此输出自锁 WARNING 并 exit 1。
stop_all 重试循环后据此输出自锁提示(18:57:09 改为 advisory,不再 exit 1
"""
content = _read("emrg/_stop_all.py")
assert "_rm_no_external_owner" in content
assert 'd["owners"] == 0 or "owner(s) excluded" in stdout' in content
assert "re-run installer (fresh process won't hold the lock)" in content
assert "installer continues" in content


def test_stop_all_py_module_holder_enumeration():
Expand DownExpand Up@@ -212,9 +213,10 @@ def test_stop_all_py_module_holder_enumeration():
assert '"RM re-scan", rm' in verify_src
assert verify_src.index('"module-holder"') < verify_src.index('"RM re-scan"')
assert "install-module holder" in verify_src
# 自锁兜底:外部 module-holder 与 RM owner 都无 → WARNING
# 自锁兜底:外部 module-holder 与 RM owner 都无 → 提示性 WARNING(18:57:09
# 改为 advisory —— stop_all 退出即释放,安装继续)
assert "_module_holder_external_found" in content
assert "no external module-holder / RM owner" in content
assert "installer continues" in content
# createfile-probe 降级为补充(CreateFileW 探测对 DLL 模块锁假阴性)
assert "createfile-probe" in content
assert "module locks need the module-holder scan" in content
Expand DownExpand Up@@ -302,3 +304,73 @@ def test_agent_md_no_stop_emrg_cmd_refs():
"""No stale stop-emrg.cmd references in docs."""
for rel in ("README.md", "README.cn.md", "Agent.md"):
assert "stop-emrg.cmd" not in _read(rel), rel


def test_classify_locked_files_self_held_only():
"""rant 2026-08-18T18:57:09 — python-dist DLLs locked by stop_all's own
runtime (module-holder tag=excluded) are self-held → NOT residuals."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [
root + "\\bin\\python-dist\\python313.dll",
root + "\\bin\\python-dist\\select.pyd",
]
holders = [
(11572, "python.exe", "python-dist", 1,
["bin/python-dist/python313.dll", "bin/python-dist/select.pyd"], "excluded"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert sorted(self_held) == ["bin/python-dist/python313.dll", "bin/python-dist/select.pyd"]
assert residual == []


def test_classify_locked_files_external_holder_residual():
"""A locked file held by an EXTERNAL (target) module-holder stays residual."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [root + "\\lib\\websockets\\speedups.cp313-win_amd64.pyd"]
holders = [
(9280, "python.exe", "browser_harness", 9556,
["lib/websockets/speedups.cp313-win_amd64.pyd"], "target"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert self_held == []
assert residual == ["lib/websockets/speedups.cp313-win_amd64.pyd"]


def test_classify_locked_files_unattributable_residual_conservative():
"""A locked file with NO known module-holder stays residual (conservative —
could be a plain non-DLL lock held by an external process)."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [root + "\\bin\\some-data-file.dat"]
self_held, residual = _classify_locked_files(locked, [], root)
assert self_held == []
assert residual == ["bin/some-data-file.dat"]


def test_classify_locked_files_mixed():
"""Mixed: self-held python-dist + external pyd + unattributable data."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [
root + "\\bin\\python-dist\\python313.dll",
root + "\\lib\\websockets\\speedups.cp313-win_amd64.pyd",
root + "\\bin\\data.dat",
]
holders = [
(11572, "python.exe", "python-dist", 1,
["bin/python-dist/python313.dll"], "excluded"),
(9280, "python.exe", "browser_harness", 9556,
["lib/websockets/speedups.cp313-win_amd64.pyd"], "target"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert self_held == ["bin/python-dist/python313.dll"]
assert sorted(residual) == [
"bin/data.dat",
"lib/websockets/speedups.cp313-win_amd64.pyd",
]
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); emrg: TUI user messages render as markdown + stop_all self-held lock attribution by argszero · Pull Request #847 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (952) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (962) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (259: 45 daemon_client + 19 conn-manager + 22 app-commands + 130 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
102 changes: 88 additions & 14 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -921,6 +921,11 @@ def _check_locked_files(root: str, try_open=None) -> list[str]:
_lock_probe_error: str | None = None


def _install_root() -> str:
"""Windows install dir (``~/.emrg/install``) — single source of truth."""
return os.path.join(os.path.expanduser("~"), ".emrg", "install")


def check_install_writable() -> list[str]:
"""Windows: probe ``install\\`` for files locked against overwrite.

Expand All@@ -937,7 +942,7 @@ def check_install_writable() -> list[str]:
_lock_probe_error = None
if not is_win():
return []
root = os.path.join(os.path.expanduser("~"), ".emrg", "install")
root = _install_root()
if not os.path.isdir(root):
return []
files = _iter_install_files(root)
Expand DownExpand Up@@ -1027,6 +1032,53 @@ def stop_lock_owners() -> None:
_windows_cats_cache: list[tuple[str, list[str]]] | None = None


def _classify_locked_files(
locked: list[str],
mh_holders: list[tuple[int, str, str, int, list[str], str]],
root: str,
) -> tuple[list[str], list[str]]:
"""Split createfile-probe locked files into self-held vs residual.

Rant 2026-08-18T18:57:09: when stop_all itself runs from
install\\python-dist\\python.exe, the probe reports the interpreter's own
DLLs (python313.dll, select.pyd, ...) as locked — but those locks belong
to the stop_all process (module-holder tag ``excluded``) and are released
the moment stop_all exits, BEFORE the installer overwrites (installer runs
stop_all synchronously via ewWaitUntilTerminated). Counting them as
residuals aborts a perfectly fine install.

Returns ``(self_held, residual)`` install-relative paths:
- self_held: locked file attributed ONLY to excluded (self/ancestor) holders
- residual: locked file with an external (``target``) holder, or one that
cannot be attributed to any known holder (conservative — could be a
plain non-DLL lock held by an external process that loaded no module).
"""
if not locked or not root:
return [], list(locked)
# Separator-agnostic: module-holder files arrive with backslashes (PS
# Substring), locked paths are native. Normalize both to forward slashes
# so the attribution works identically on Windows and in POSIX unit tests.
def _norm(p: str) -> str:
return p.replace("\\", "/")

root_n = _norm(root)
tag_by_rel: dict[str, set[str]] = {}
for _pid, _name, _exe, _parent, files, tag in mh_holders:
for f in files:
tag_by_rel.setdefault(_norm(f), set()).add(tag)
self_held: list[str] = []
residual: list[str] = []
for p in locked:
rel = _norm(os.path.relpath(_norm(p), root_n))
tags = tag_by_rel.get(rel, set())
if tags == {"excluded"}:
self_held.append(rel)
else:
# No tags (unattributable) OR has an external target holder.
residual.append(rel)
return self_held, residual


def _verify_windows_categories() -> list[tuple[str, list[str]]]:
"""Windows residual scan, one ``(category, residual_strings)`` entry per
check — so the operator can see each check's result instead of guessing
Expand DownExpand Up@@ -1066,9 +1118,10 @@ def _verify_windows_categories() -> list[tuple[str, list[str]]]:
# section locks; RM missed the browser-harness child, CreateFileW probes
# cannot see module locks at all). Any external holder = residual.
mh_out = _module_holder_ps()
mh_holders = _parse_module_holders(mh_out)
mh = [
f"install-module holder (pid {pid}, {name or 'unknown'}, loads {', '.join(files[:3])})"
for pid, name, _exe, _parent, files, tag in _parse_module_holders(mh_out)
for pid, name, _exe, _parent, files, tag in mh_holders
if tag == "target"
]
cats.append(("module-holder", mh))
Expand All@@ -1095,9 +1148,29 @@ def _verify_windows_categories() -> list[tuple[str, list[str]]]:
global _lock_probe_error
_lock_probe_error = None
locked = check_install_writable()
probe_items = [f"locked file (createfile-probe): {p}" for p in locked]
probe_items = []
if _lock_probe_error:
probe_items.append(f"lock-probe failed (error: {_lock_probe_error})")
# Self-held attribution (rant 2026-08-18T18:57:09): when stop_all runs
# from install\python-dist\python.exe, that interpreter MUST load its own
# python-dist DLLs (python313.dll, select.pyd, ...) — those image-section
# locks are held by the stop_all process itself (module-holder tag
# ``excluded`` = self + ancestor chain) and are RELEASED when stop_all
# exits, before the installer starts overwriting (make-installer.sh uses
# ewWaitUntilTerminated). They are NOT residuals — counting them aborts
# the install while nothing is actually wrong. A locked file is residual
# only when an EXTERNAL holder (module-holder ``target`` / RM owner)
# exists or the lock cannot be attributed to any known holder (conservative).
self_held, residual_locked = _classify_locked_files(
locked, mh_holders, _install_root() if is_win() else ""
)
probe_items.extend(f"locked file (createfile-probe): {p}" for p in residual_locked)
if self_held:
print(
f"emrg stop: WARNING {len(self_held)} file(s) locked by stop_all "
f"runtime itself (python-dist DLL) — self-held, released when "
f"stop_all exits; installer continues"
)
cats.append(("createfile-probe", probe_items))

# bundled-git residual
Expand DownExpand Up@@ -1347,19 +1420,20 @@ def stop_all() -> int:
print(f"emrg stop: ERROR retry {attempt}/2: {e} ({type(e).__name__})")
time.sleep(0.3)
print(f"emrg stop: retry {attempt}/2 done ({time.monotonic() - s:.1f}s)")
# Self-lock final guard (rant 2026-08-18T16:09:45 + 16:24:01): after
# both kill retries the probe still reports locked files but neither
# the module-holder enumeration nor RM found an EXTERNAL owner — the
# lock holder is stop_all's own runtime (python-dist loaded
# install\lib modules) or an undetectable handle. The installer
# cannot win; a freshly launched installer process holds no locks.
# Exit 1 so the install aborts with this explanation instead of a
# code-5 dialog.
# Self-lock final guard (rant 2026-08-18T16:09:45 + 16:24:01, refined
# 18:57:09): after both kill retries the probe still reports locked
# files but neither the module-holder enumeration nor RM found an
# EXTERNAL owner — the lock holder is stop_all's own runtime
# (python-dist loaded install\lib modules). The installer runs stop_all
# synchronously (ewWaitUntilTerminated), so these locks are released
# when stop_all exits and the overwrite proceeds — advisory only, NOT
# a hard abort (the pre-18:57:09 guard wrongly blocked installs whose
# only locks were python-dist DLLs held by stop_all itself).
if locked and not _module_holder_external_found and _rm_no_external_owner:
print(
"emrg stop: WARNING lock holder is the stop_all runtime itself "
"(no external module-holder / RM owner) - installer will fail; "
"re-run installer (fresh process won't hold the lock)"
f"emrg stop: WARNING {len(locked)} file(s) locked by the "
f"stop_all runtime itself (python-dist DLL) — released when "
f"stop_all exits; installer continues"
)
residuals = verify()
if residuals:
Expand Down
37 changes: 37 additions & 0 deletions emrg/client/python_tui/widgets/markdown.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,43 @@ def render(self, ctx: RenderContext) -> list[Line]:
return lines


class UserMarkdown(Markdown):
"""User message rendered as markdown with the role prefix preserved.

Plan B (rant 2026-08-18T18:52:45, superseding 18:50:14): user messages
go through the same Rich markdown pipeline as assistant messages — free
width-based wrapping, CJK wide-char handling — while keeping the
``> `` prefix + bold cyan role visual. The markdown is rendered at
``ctx.width - len(prefix)`` so the prefix on the first line never
overflows the buffer width (continuation lines get a same-width indent).
"""

_ROLE_PREFIX = "> "
_ROLE_STYLE = "bold cyan"

def render(self, ctx: RenderContext) -> list[Line]:
from rich.style import Style

from emrg.client.python_tui.rich_bridge import rich_renderable_to_lines
from emrg.client.python_tui.widgets.base import Span

prefix = self._ROLE_PREFIX
indent = " " * len(prefix)
role_style = Style.parse(self._ROLE_STYLE)
avail = max(1, ctx.width - len(prefix))

md = RichMarkdown(self.text, code_theme="monokai")
md_lines = rich_renderable_to_lines(md, avail)
lines: list[Line] = []
for i, line in enumerate(md_lines):
lead = prefix if i == 0 else indent
line.spans.insert(0, Span(text=lead, style=role_style))
line.style = ctx.style
lines.append(line)
self._dirty = False
return lines


@dataclass
class StreamingMarkdown(Widget):
"""Incremental markdown renderer for token-by-token streaming.
Expand Down
12 changes: 11 additions & 1 deletion emrg/client/widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@
from rich.style import Style
from emrg.client.python_tui import ChatRow, ToolCard
from emrg.client.python_tui.widgets.base import Line, Span, Widget
from emrg.client.python_tui.widgets.markdown import StreamingMarkdown
from emrg.client.python_tui.widgets.markdown import StreamingMarkdown, UserMarkdown


class InputWidget(Widget):
Expand DownExpand Up@@ -670,6 +670,11 @@ def dirty(self, v): self._dirty = v
def add(self, role_or_widget, content=None):
if isinstance(role_or_widget, Widget):
self.rows.append(role_or_widget)
elif role_or_widget == "user":
# Plan B (rant 2026-08-18T18:52:45, superseding 18:50:14): user
# messages render as markdown (free width wrap, CJK handling)
# while keeping the "> " prefix + cyan role visual.
self.rows.append(UserMarkdown(content or ""))
else:
self.rows.append(ChatRow(role=role_or_widget, content=content or ""))
self._line_cache.append(None) # 新 row 无缓存
Expand All@@ -693,6 +698,11 @@ def update_last(self, content):
row.dirty = True
self._dirty = True
return
if isinstance(row, UserMarkdown):
row.text = content
row.dirty = True
self._dirty = True
return

def last_tool_card(self):
for row in reversed(self.rows):
Expand Down
88 changes: 80 additions & 8 deletions tests/test_installer_stop.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,23 +158,24 @@ def test_stop_all_py_deletefile_semantic_lock_probe():
assert "GENERIC_READ = 0x80000000" not in content # 旧常量赋值已移除
assert "SetFileInformationByHandle" in content
assert "FILE_DISPOSITION_INFO = 2" in content
# 自锁防护(rant 2026-08-18T16:09:45):开头打印 python-dist 运行时 + 兜底
# WARNING(lock holder 是 stop_all 自身 → 提示重跑安装器
# 自锁防护(rant 2026-08-18T16:09:45,18:57:09 改为提示性):开头打印
# python-dist 运行时 + verify 对 self-held 锁不中止安装(stop_all 退出即释放
assert "python-dist runtime:" in content
assert "lock holder is the stop_all runtime itself" in content
assert "re-run installer (fresh process won't hold the lock)" in content
assert "self-held" in content
assert "installer continues" in content
assert "re-run installer (fresh process won't hold the lock)" not in content # 旧文案已移除


def test_stop_all_py_rm_no_external_owner_flag():
"""rant 2026-08-18T16:09:45 — _print_rm_diag 记录"无外部 owner"证据。

RM owners==0 或全部 owner 被祖先链排除 → _rm_no_external_owner=True,
stop_all 重试循环后据此输出自锁 WARNING 并 exit 1。
stop_all 重试循环后据此输出自锁提示(18:57:09 改为 advisory,不再 exit 1
"""
content = _read("emrg/_stop_all.py")
assert "_rm_no_external_owner" in content
assert 'd["owners"] == 0 or "owner(s) excluded" in stdout' in content
assert "re-run installer (fresh process won't hold the lock)" in content
assert "installer continues" in content


def test_stop_all_py_module_holder_enumeration():
Expand DownExpand Up@@ -212,9 +213,10 @@ def test_stop_all_py_module_holder_enumeration():
assert '"RM re-scan", rm' in verify_src
assert verify_src.index('"module-holder"') < verify_src.index('"RM re-scan"')
assert "install-module holder" in verify_src
# 自锁兜底:外部 module-holder 与 RM owner 都无 → WARNING
# 自锁兜底:外部 module-holder 与 RM owner 都无 → 提示性 WARNING(18:57:09
# 改为 advisory —— stop_all 退出即释放,安装继续)
assert "_module_holder_external_found" in content
assert "no external module-holder / RM owner" in content
assert "installer continues" in content
# createfile-probe 降级为补充(CreateFileW 探测对 DLL 模块锁假阴性)
assert "createfile-probe" in content
assert "module locks need the module-holder scan" in content
Expand DownExpand Up@@ -302,3 +304,73 @@ def test_agent_md_no_stop_emrg_cmd_refs():
"""No stale stop-emrg.cmd references in docs."""
for rel in ("README.md", "README.cn.md", "Agent.md"):
assert "stop-emrg.cmd" not in _read(rel), rel


def test_classify_locked_files_self_held_only():
"""rant 2026-08-18T18:57:09 — python-dist DLLs locked by stop_all's own
runtime (module-holder tag=excluded) are self-held → NOT residuals."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [
root + "\\bin\\python-dist\\python313.dll",
root + "\\bin\\python-dist\\select.pyd",
]
holders = [
(11572, "python.exe", "python-dist", 1,
["bin/python-dist/python313.dll", "bin/python-dist/select.pyd"], "excluded"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert sorted(self_held) == ["bin/python-dist/python313.dll", "bin/python-dist/select.pyd"]
assert residual == []


def test_classify_locked_files_external_holder_residual():
"""A locked file held by an EXTERNAL (target) module-holder stays residual."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [root + "\\lib\\websockets\\speedups.cp313-win_amd64.pyd"]
holders = [
(9280, "python.exe", "browser_harness", 9556,
["lib/websockets/speedups.cp313-win_amd64.pyd"], "target"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert self_held == []
assert residual == ["lib/websockets/speedups.cp313-win_amd64.pyd"]


def test_classify_locked_files_unattributable_residual_conservative():
"""A locked file with NO known module-holder stays residual (conservative —
could be a plain non-DLL lock held by an external process)."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [root + "\\bin\\some-data-file.dat"]
self_held, residual = _classify_locked_files(locked, [], root)
assert self_held == []
assert residual == ["bin/some-data-file.dat"]


def test_classify_locked_files_mixed():
"""Mixed: self-held python-dist + external pyd + unattributable data."""
from emrg._stop_all import _classify_locked_files

root = "C:\\Users\\x\\.emrg\\install"
locked = [
root + "\\bin\\python-dist\\python313.dll",
root + "\\lib\\websockets\\speedups.cp313-win_amd64.pyd",
root + "\\bin\\data.dat",
]
holders = [
(11572, "python.exe", "python-dist", 1,
["bin/python-dist/python313.dll"], "excluded"),
(9280, "python.exe", "browser_harness", 9556,
["lib/websockets/speedups.cp313-win_amd64.pyd"], "target"),
]
self_held, residual = _classify_locked_files(locked, holders, root)
assert self_held == ["bin/python-dist/python313.dll"]
assert sorted(residual) == [
"bin/data.dat",
"lib/websockets/speedups.cp313-win_amd64.pyd",
]
Loading
Loading