diff --git a/WORKMEMORY/work.log b/WORKMEMORY/work.log index 77c0e1a2..810598bd 100644 --- a/WORKMEMORY/work.log +++ b/WORKMEMORY/work.log @@ -654,3 +654,14 @@ Caps: filesystem-read, filesystem-write, shell-exec - **修复(本次提交)**:`docs/agents/ops.md`「Agent 工作惯例」**第 12 条** + `WORKMEMORY/PROTOCOL.md` 铁律**第 8 条**写明——**实现 / 多步任务 MUST 走独立分支(或 worktree)+ PR 合入 master;仅发版类操作(bump / tag / Release 文案 / 发版文档)允许直推 master 且须用户批准**。**本次补规则的改动本身走分支 `docs/branch-discipline-rule` + PR**(否则"改规矩不守规矩",规则当场失效)。 - **暂缓**:给 `dfs-plan` / `dfs-exec` 补「分支隔离」步骤(全局 skill + 发布仓两侧同步)——用户指示 skill 先不动。 - **未做**:不回滚那 9 个提交(CI 全绿、历史已推送,重写收益低且风险高)。 + +### 2026-10-14 | codebuddy | WORK_END(no-console-feedback-gaps) +Caps: filesystem-read, filesystem-write, shell-exec +事件:`/dfs-team` 三席审计(靶子 = 10-13 那批 `0f7b80f..b05a367`)+ 怀疑者复核后开出的 7 条发现,用 `/dfs-exec` 三挡执行 6 个 Tier 2 任务 + 1 处门禁缺口修复,全部交付(8 提交,分支 `fix/no-console-feedback-gaps`,待 PR 合入)。 +- **P1 ×3**:① `2eae2ba` 两个入口改「先收口后解析」——`--windowed` 下 argparse 的 `--help`/错参输出走 `file.write`,解析早于收口时用户零线索(本机 Py3.11 实测:`_print_message` 自带 `except (AttributeError, OSError): pass` ⇒ 文本被**静默吞掉**而非崩溃,缺陷形态一致);② `53221f2` 失败出口统一——`_after_start` 异常分支原本只写 `repr` 不弹窗,外层却把完整 traceback 原样弹给用户 ⇒ 统一为"日志全栈 + 弹窗一句话(含日志路径)";③ `d1656bd` 建服/起服搬进 `webview.start()` 事件循环(`_after_start`),splash 覆盖冷启动前段(消灭 4~6.5s 零反馈),新增 `server_handoff` 交接容器 + finally 收尸。 +- **P2 ×3**:④ `e945683` AST 结构守卫钉"收口前不得写标准流" + 修正 `verify_windows_portable.py:38/:586` 两处**错误注释**;⑤ `43061ea` `--server-only` 地址文件改"持有端口后写、退出即删"(不变量:文件存在 ⟺ 本进程持有该端口)+ 闸判据从 `is_file()` 升级为内容匹配 `^http://:$`;⑥ `6c53345` 测试卫生——4 条 dispatch 用例曾把 `lan-address.txt` 写进**开发者真实数据目录**(新增 autouse fixture + 元测试兜底)+ 一条恒绿用例改记录型假 ctypes。 +- **⚠️ 怀疑者推翻了一条我们自己的前提(本批最值钱的发现)**:上批的动机叙事"`--windowed` 下裸 `print()` 会崩"**是错的** —— CPython 的 `print()` 对 `sys.stdout is None` 是**静默 no-op**;真会崩的是 `None.write`(argparse 路径,即 P1①)。⇒ 真启闸的 no-console 模式扫 traceback **抓不到**那类回归,不是"不够严"而是**判据瞄错对象**(且 CI 里子进程 stdout 被重定向,标准流并非 None)。修法不是加严闸,而是加 AST 守卫 + 修正错误注释。 +- **两处"门禁跑不全"造成的潜伏缺陷(都靠后续任务的验收命令才浮出)**:① Task 3 回炉改写了 `test_desktop_port_guard.py`,但验收只跑 `mypy --platform win32 --strict`(4 入口档)、**漏跑 tests 档** ⇒ 两处 attr-defined 潜伏至 Task 6 才暴露(已修 `2e9f262`);② Task 3 的搬家**作废了另外两个测试套件的契约**(port_guard 2 条 + first_screen 2 条),CRV 首轮 REVISE 抓出,其中 `test_run_desktop_inner_does_not_build_server_when_port_is_taken` 更是**vacuous 通过**(删掉端口校验也不红 ⇒ P0-1 端口闸的行为红线被静默架空)。**教训:搬家类改动必须跑全量;改了测试文件必须把 mypy 四档都跑全。** +- **CRV 七轮**:Task 3 首轮 **REVISE**(2 套件作废 + vacuous 红线)→ 回炉 → 复核 PASS;Task 1/2/4/5/6 与 mypy 修复均 PASS(累计黄牌 12 张,非阻塞,其中最实的一张"`__main__` 先开句柄后轮转 ⇒ Windows 5MB 轮转被静默跳过"已折入 Task 3 修掉)。 +- **门禁(本机实测,批次终值)**:pytest **1832 passed + 2 skipped**;ruff 0;mypy 三档 **87 / 121 / 4** files 零错误;7 个触碰文件行尾纯 CRLF(CRCRLF=0)。 +- **仍待办**:① 本分支 PR 合入(合并 MUST 等 CI 绿);② **真机双击验收**(无黑框 / 品牌图标 / splash 前段有反馈 / 失败有人话弹窗)——只能人工做,代码层已尽力;③ Task 5 遗留边界:端口被**外部程序**占用时上一轮的陈旧 `lan-address.txt` 不复写也不删除(有弹窗+日志,非静默),要清理需 `start.main` 回传 abort 原因,未立项。 diff --git a/desktop.py b/desktop.py index b4f23578..9f5608d5 100644 --- a/desktop.py +++ b/desktop.py @@ -202,6 +202,22 @@ def _remove_stale_lan_address(env: Mapping[str, str], log_path: str) -> None: _append_log(log_path, f"删除残留 {LAN_ADDRESS_FILE_NAME} 失败(不阻断启动):{exc!r}") +def _remove_lan_address_file(env: Mapping[str, str], log_path: str) -> None: + """删除数据目录的 `lan-address.txt`(服务退出后调用)。**绝不抛**。 + + 对外可见的"地址"类文件 MUST 只反映"本进程此刻是否在服务该地址":服务一停(或从未真正 + 持有端口),留着它就是让用户(及托盘)读到一个**已不再服务 / 错误**的目标。删除失败 + (被杀软锁等)不阻断退出,仅留痕。 + """ + path = resolve_lan_address_path(env) + try: + if os.path.isfile(path): + os.remove(path) + _append_log(log_path, f"已删除 {LAN_ADDRESS_FILE_NAME}(服务已退出,不再持有该端口)。") + except OSError as exc: + _append_log(log_path, f"删除 {LAN_ADDRESS_FILE_NAME} 失败(不阻断退出):{exc!r}") + + def write_lan_address(port: int, env: Mapping[str, str], log_path: str) -> Optional[str]: """把局域网地址写进数据目录的 `lan-address.txt`(每次启动覆盖)。返回写入的地址。 @@ -720,26 +736,40 @@ def _after_start( port: int, log_path: str, result: List[int], + server_handoff: Optional[Dict[str, Any]] = None, ) -> None: - """GUI 事件循环**启动后**(由 `webview.start(func, args)` 在独立线程调度)要做的后台工作。 - - 为什么这段必须搬到这里(P0 死锁根因):pywebview 会把 `evaluate_js` / `load_url` marshal 到 - GUI 线程执行,而 GUI 事件循环由 `webview.start()` 才启动 —— 在 start **之前**调它们会永久等待 - (`scratch/repro_splash_deadlock.py` 三模式实测),窗口永不出现、进程永不退出。放进 `func` 后 - 循环已在跑,evaluate_js / load_url 都安全。 - - 就绪后**切到主窗口**(方案 A 的双窗口):先 `main.show()` 亮出普通可缩放窗口,再 - `main.load_url(...)` 加载真实 UI,最后 `splash.destroy()` 收掉启动反馈。顺序不可颠倒: - 销毁 splash 前主窗口必须已在(否则瞬间零窗口 ⇒ GUI 循环提前退出)。 - - **首屏真口径**:`load_url` 之前注册 pywebview 的 `loaded` 事件(页面渲染完成),命中时记 - 「真实 UI 已加载(首屏可用)」。注册放在这里(循环已起)既满足 P0(循环启动前碰窗口/事件会 - 永久阻塞),又因 `load_url` 会 `events.loaded.clear()` 而必须早于它注册。 - - `result` 是回传给主线程 `_show_window` 的退出码容器:失败时置 1 并**销毁两个窗口**,让 - `webview.start()` 得以返回(否则进程会挂着一个空 splash 永不退出)。 - """ + # 本函数是 `webview.start(func, args)` 在独立线程调度的后台工作;**刻意只有单个顶层 try** + # (无 docstring、无其它顶层语句)——后台线程里未接住的异常会让线程静默死亡(窗口不出、零线索), + # 故用单个 try 包住全部,失败统一走 except 留痕 + 收尾。 + # + # 为什么搬到这里(P0 死锁根因):pywebview 把 evaluate_js / load_url marshal 到 GUI 线程,而 + # GUI 事件循环由 webview.start() 才启动 —— start 之前调它们会永久等待(repro 三模式实测)。放进 + # func 后循环已在跑,evaluate_js / load_url 都安全。 + # + # Task 3:建服/起服也搬进这里(from delector.server import app / build_server / + # serve_in_thread,随 ④~⑥ 标记紧贴各自语句)——重活推迟到 splash 可见之后,消灭 4~6.5s 零反馈。 + # server_handoff:回传 server/thread 给主线程的交接容器(_run_desktop_inner 创建、_show_window + # 透传);本函数跑在后台线程无法直接 return,故写进该 dict 供 _run_desktop_inner 收尸。缺省 None + # (单测直调时可省)⇒ 跳过交接,不影响窗口/就绪逻辑。 + # + # 就绪后切到主窗口(方案 A 双窗口):先 main.show() 亮主窗口,再 main.load_url 载真实 UI,最后 + # splash.destroy() 收掉启动反馈;顺序不可颠倒(先亮主窗再毁 splash,否则瞬间零窗口 ⇒ GUI 循环退出)。 + # 首屏真口径:load_url 前注册 loaded 事件(页面渲染完成)记「真实 UI 已加载(首屏可用)」——注册须 + # 早于 load_url(它会 events.loaded.clear())。result:回传主线程的退出码容器,失败置 1 并销毁两窗。 try: + # Task 3:建服/起服搬进事件循环(原在 `_run_desktop_inner` 循环外、建窗之前)。④~⑥ 三枚 + # 阶段标记随语句一并搬入,各自紧贴它要标记的那一步(launch.log 归因不张冠李戴)。 + from delector.server import app + _append_log(log_path, "服务端模块已导入(delector.server)。") + server = build_server(BIND_HOST, port, app) + _append_log(log_path, "HTTP 服务已装配(build_server 完成)。") + thread = serve_in_thread(server) + _append_log(log_path, "本地服务线程已启动(等在就绪窗口内命中)。") + # 把 server/thread 写进交接容器:供主线程 `_run_desktop_inner` 在 webview.start 返回后收尸 + # (本函数跑在后台线程,无法直接 return)。缺省 None / 测试替身非 dict 时跳过交接。 + if isinstance(server_handoff, dict): + server_handoff["server"] = server + server_handoff["thread"] = thread _set_splash_stage(splash, SPLASH_STARTING_SERVER, log_path) _append_log(log_path, "等待服务就绪…") if not await_ready(port, deadline_s=READY_DEADLINE_S): @@ -763,19 +793,28 @@ def _after_start( main.load_url(f"http://127.0.0.1:{port}") # 再把真实 UI 载入主窗口(非 splash) _destroy_window(splash, log_path) # 最后收掉 splash(实例非空 ⇒ 不触发 Application.Exit) _append_log(log_path, "已切换到主窗口,splash 已关闭。") - except Exception as exc: + except Exception: # 后台线程里的异常若不接住,线程会**静默死亡**(又回到"窗口不出、零线索");必须留痕并收尾。 - _append_log(log_path, f"就绪/切 UI 阶段异常:{exc!r}") + # 与就绪超时分支对齐(两条失败出口行为一致):日志 + 弹窗 + 置失败码 + 销毁两窗。 + # 复用 write_crash(全栈落 launch.log + 原生弹窗),不另造第三种失败出口。 + # 先置失败退出码、再 write_crash:write_crash 要落盘 + 弹窗,若它二次异常(本不应,防御性) + # 也不能让退出码停留在"成功"——先钉死 1,再走可能失败的留痕路径。 result[0] = 1 + write_crash("DeLector 启动失败", traceback.format_exc(), log_path) _abort_windows(splash, main, coordinator, log_path) -def _show_window(server: Any, thread: threading.Thread, port: int, log_path: str) -> int: +def _show_window(server_handoff: Any, port: int, log_path: str) -> int: """建 splash + 主窗口 → 起托盘 → 进主线程事件循环;就绪等待与切 UI 交给 `_after_start`。 两个窗口(方案 A):splash 无边框置顶小窗(启动反馈);主窗口是**普通可缩放窗口**(有标题栏、 可最大化/还原/拖边、不置顶、初始隐藏)。就绪后由 `_after_start` 亮主窗口并销毁 splash。 + `server_handoff` 是 Task 3 的**服务交接容器**(`_run_desktop_inner` 创建的空 dict):建服/起服 + 已搬进 `_after_start`,server/thread 由它写进该容器,`_run_desktop_inner` 在 `webview.start()` + 返回后据此收尸。本函数只**透传**。原 `thread` 形参随服务搬走后已无任何读取点(Y1 黄牌: + 删死参而非留一个"为兼容而兼容"的形参——死参会让人误以为这里还管线程生命周期)。 + 时序是本次 P0 死锁的核心:先 `create_window`,再 **`webview.start(_after_start, ...)` 把事件循环 跑起来**,最后让 `_after_start` 在循环内做 `_set_splash_stage` / `await_ready` / `show` / `load_url`。 """ @@ -825,7 +864,8 @@ def _show_window(server: Any, thread: threading.Thread, port: int, log_path: str result: List[int] = [0] # 由 _after_start(后台线程)写回:失败=1,成功保持 0 _append_log(log_path, "进入 GUI 事件循环(webview.start)。") - webview.start(_after_start, (splash, main, coordinator, port, log_path, result)) # 阻塞直到所有窗口关闭 + # 阻塞直到所有窗口关闭;第 7 实参 server_handoff 透传给 _after_start(Task 3 建服/起服在其中)。 + webview.start(_after_start, (splash, main, coordinator, port, log_path, result, server_handoff)) _append_log(log_path, "GUI 事件循环已退出(窗口已关闭)。") return result[0] @@ -868,7 +908,13 @@ def _check_port_or_fail(port: int, log_path: str) -> bool: def _run_desktop_inner(port: int, log_path: str) -> int: - """桌面壳主体:端口/身份校验 → 检测运行时 → 定数据落点 → 起服务 → 起窗口;**任何**路径都先收尸。""" + """桌面壳主体:端口/身份校验 → 检测运行时 → 定数据落点 → 起窗口;**任何**路径都先收尸。 + + Task 3:建服/起服(`from delector.server import app` / `build_server` / `serve_in_thread`) + 已搬进 `_after_start`(`webview.start()` 调度的事件循环内)——重活推迟到 splash 可见之后, + 消灭 4~6.5s 零反馈。此处只留循环外的快速校验与落点;服务由 `_after_start` 落进 + `server_handoff`,在本函数 `finally` 里读出并 `coordinated_shutdown` 收尸。 + """ # P0-1:建服**最早处**先校验端口/身份(要早、要响、要简单)。 # 校验失败 ⇒ 已弹窗 + 已落日志 ⇒ 退出码 3(2 已被"缺 WebView2"占用)。 if not _check_port_or_fail(port, log_path): @@ -883,24 +929,30 @@ def _run_desktop_inner(port: int, log_path: str) -> int: # ⚠️ 必须在 `from delector.server import app` **之前**:那一行一跑,`database` 已按仓库根把 # DATA_DIR 落定,之后再设 DELECTOR_DATA_DIR 就晚了(与 start.py 同款接线)。 + # (Task 3:那行 import 连同 build_server / serve_in_thread 已搬进 `_after_start`——重活推迟 + # 到 splash 可见之后;但顺序约束不变:此处先落定 DATA_DIR,`_after_start` 才 import server。) bootstrap_data_dir(os.environ) _append_log(log_path, "数据目录落点已定(bootstrap_data_dir 已返回,不代表迁移已落稳)。") # 桌面态 LAN 可用时同样落一份地址(托盘「显示局域网地址」读它;--windowed 无控制台)。 # 放在 bootstrap 之后:env 里的数据目录落点已定,文件与库、launch.log 同处一地。 write_lan_address(port, os.environ, log_path) - from delector.server import app - _append_log(log_path, "服务端模块已导入(delector.server)。") - - server = build_server(BIND_HOST, port, app) - _append_log(log_path, "HTTP 服务已装配(build_server 完成)。") - thread = serve_in_thread(server) - _append_log(log_path, "本地服务线程已启动(等在就绪窗口内命中)。") + # 服务交接容器:`_after_start`(webview.start 调度的事件循环内)建好服务后把 server/thread + # 写进来;`webview.start()` 返回(窗口全关)后由此处读出并收尸。空 dict 由本函数创建、经 + # `_show_window` 透传——`_after_start` 跑在后台线程,无法直接 return 给主线程。 + server_handoff: Dict[str, Any] = {} try: - return _show_window(server, thread, port, log_path) + return _show_window(server_handoff, port, log_path) finally: # 关窗 / 异常 / Ctrl+C 全部经此收尸(与托盘「退出」同一路径),绝不留残留服务。 - coordinated_shutdown(server, thread, log_path) + # server/thread 由 `_after_start` 落进 handoff;建服前即失败/退出的极端边界下可能缺失, + # 绝不让 None 冲进 coordinated_shutdown(那会在 shutdown 里 AttributeError)。 + server = server_handoff.get("server") + thread = server_handoff.get("thread") + if server is None or thread is None: + _append_log(log_path, "无已启动服务可收尸(建服阶段之前即失败或退出)。") + else: + coordinated_shutdown(server, thread, log_path) def run_desktop(port: int = DEFAULT_PORT, debug_console: bool = False) -> int: @@ -931,7 +983,9 @@ def run_desktop(port: int = DEFAULT_PORT, debug_console: bool = False) -> int: _append_log(log_path, "收到 Ctrl+C:退出(服务已收尸)。") return 0 except Exception: - write_crash("DeLector 启动失败", traceback.format_exc(), log_path) + # 弹窗只给人话一句(traceback 原样只落盘):面向用户的文案禁止含堆栈,但必须给出日志路径。 + _append_log(log_path, traceback.format_exc()) + write_crash("DeLector 启动失败", f"启动失败,详情见 {log_path}", log_path) return 1 @@ -960,13 +1014,35 @@ def run_server_only(debug_console: bool = False) -> int: 为什么复用而不是另写一套:这条路径要精确保留便携版原来的启动语义(含端口占用时的 身份校验、LAN 访问、`bootstrap_data_dir` 顺序),复用是唯一能保证"不漂移"的做法。 + + 地址出口(Task 5):`--windowed` 下无控制台,`lan-address.txt` 是用户唯一能看到 + `http://:` 的地方。该文件**只在服务真正持有端口之后**才写 —— 由 + `start.main()` 在 `build_server` 成功后回调 `_on_serving`(端口号取 start.main 的 + 真相源,不在调用侧臆造);端口被占 / 启动失败时根本走不到那里(`start.main` 先 + `is_port_in_use` → `sys.exit`),故绝不留下**指向别的服务**的地址文件。服务退出 + (正常或异常)后本进程不再持有该端口 ⇒ 删掉本轮写下的文件("若已写则删除"), + 不留指向已停服务的目标。 """ import start - if debug_console: - start.main(debug_console=True) - else: - start.main() + log_path = resolve_log_path(os.environ) + written: List[str] = [] + + def _on_serving(port: int) -> None: + address = write_lan_address(port, os.environ, log_path) + if address is not None: + written.append(address) + + try: + if debug_console: + start.main(debug_console=True, on_serving=_on_serving) + else: + start.main(on_serving=_on_serving) + finally: + # 退出后不再持有端口:删掉本轮写的地址文件(若写成功)。端口被占 / 建服前失败时 + # `written` 为空 ⇒ 不误删(也就不会有指向错误目标的文件)。 + if written: + _remove_lan_address_file(os.environ, log_path) return 0 @@ -975,8 +1051,8 @@ def dispatch(server_only: bool, port: int, debug_console: bool = False) -> int: ⚠️ `--server-only` **复用** `start.main()`(固定端口,见 `run_server_only` 的理由):该路径 不接受 `--port`。用户若在此组合下显式给了非默认端口,必须**显式告知**它不生效 —— 静默丢弃会被 - 误读成"端口没换成功",正是本任务要消除的一类静默失败(不动 `start.main()` 签名是为了不引入 - port-identity / LAN 面回归)。 + 误读成"端口没换成功",正是本任务要消除的一类静默失败(只给 `start.main()` 加一个**可选回调** + 参数、不加 `port` 参数,故不引入 port-identity / LAN 面回归)。 """ if not server_only: if debug_console: @@ -994,14 +1070,28 @@ def dispatch(server_only: bool, port: int, debug_console: bool = False) -> int: f"你传入的 --port {port} 不会生效。", file=sys.stderr, ) - # 地址出口:无控制台(--windowed)时用户唯一能拿到 `http://:` 的地方。 - # 端口用 DEFAULT_PORT —— 该路径固定 8000,`--port` 不生效(见上方警告)。 - write_lan_address(DEFAULT_PORT, os.environ, log_path) + # 地址出口(Task 5):**不再**在此处提前写 `lan-address.txt`。旧实现在这里先写、`start.main` + # 里才 `is_port_in_use` 判端口 ⇒ 端口被别的程序占用时,用户拿到一份**指向别的服务**的地址。 + # 写入已挪到服务真正持有端口之后(见 `run_server_only`:由 `start.main` 在 `build_server` + # 成功后回调写)。端口被占时下面会早退(`start.main` → `sys.exit`),绝不留下错误地址文件。 if debug_console: return run_server_only(debug_console=True) return run_server_only() if __name__ == "__main__": + # 先收口、后解析(Task 1:唯一零线索路径):`--windowed` 产物里 sys.stdout/stderr 是 + # None,而 argparse 的 `--help`/错参输出走 `file.write` —— 解析早于收口时,用户得到的 + # 是"零线索"(文本当场蒸发或 AttributeError,launch.log 零行、无窗口、无弹窗)。 + # debug 开关从原始 argv 探:此刻还没解析完,拿不到 `_args`。 + # 黄牌 A:日志轮转必须**早于**收口 —— `ensure_streams` 会打开 launch.log 追加句柄,Task 1 把 + # 收口提到最前后,若在开句柄**之后**才轮转,Windows 上对已打开文件 `os.replace` 会 + # PermissionError 被静默跳过 ⇒ launch.log 无界增长。故先轮转(句柄未开、可安全 replace)、 + # 再收口。`run_desktop` 内的既有轮转调用保持不变(窗口路径自身的第一道轮转)。 + # Y5:`resolve_log_path(os.environ)` 只解析**一次**(旧代码调了两次:轮转一次、收口一次—— + # 同一 env 解析两遍既慢又可能因中途改 env 而两处落点不一致)。 + log_path = resolve_log_path(os.environ) + rotate_log_if_needed(log_path) + ensure_streams(log_path, "--debug-console" in sys.argv[1:]) _args = _parse_args(sys.argv[1:]) raise SystemExit(dispatch(_args.server_only, _args.port, _args.debug_console)) diff --git a/docs/plans/2026-10-14-no-console-feedback-gaps.md b/docs/plans/2026-10-14-no-console-feedback-gaps.md new file mode 100644 index 00000000..848a8727 --- /dev/null +++ b/docs/plans/2026-10-14-no-console-feedback-gaps.md @@ -0,0 +1,188 @@ +# Windows 无控制台后的反馈缺口 Implementation Plan + +> **Goal**: 修掉 `/dfs-team` 三席审计(2026-10-14,怀疑者复核后)开出的 7 条发现——核心是"**无控制台之后,用户在失败与等待时还能不能得到人话反馈**":一条零线索的静默崩溃路径、两处不对称的失败出口、一段没有任何可见反馈的冷启动,以及一道判据瞄错了对象的闸。 +> **Tech Stack**: Python 3.11 / pywebview(EdgeChromium) / pystray / PyInstaller(`--windowed`) / pytest / ctypes(Win32)。 +> **Spec Reference**: `/dfs-team` 评估报告(2026-10-14,靶子 = 提交 `0f7b80f..b05a367`「Windows 去控制台 + 桌面化观感」);上一轮计划 `docs/plans/2026-10-13-windows-no-console-desktop-polish.md`。 +> **Global Constraints**: +> - **零新依赖**:只用既有设施(`_message_box` / `write_crash` / `_append_log` / `ensure_streams`)。 +> - **启动顺序改动 MUST 先写死锁回归再动码**:`desktop.py` 有 P0 先例——`evaluate_js`/`load_url` 在 `webview.start()` 之前调用 ⇒ 窗口永不出现(永久等待,不报错)。任何把工作搬进/搬出事件循环的改动,先有一条测试钉住"循环启动前不碰窗口"。 +> - **失败必须可见(本批存在的理由)**:任何失败分支 MUST 落到 `launch.log` + 原生弹窗 + 托盘三处中的至少两处;禁止"静默退出、零字节线索"。 +> - **门禁不削弱**:`ruff check .` / mypy 四档 / `pytest` 全绿;既有断言只扩不减。 +> - **分支纪律(2026-10-14 新增的规矩)**:本计划与其执行同走一个分支 `fix/no-console-feedback-gaps`,最后**一个 PR** 合入 master(实现任务不直推 master)。 +> - **每任务物理证据**(原始命令 + 退出码 + 计数 + diff stat);Tier 1 也不例外。 + +--- + +## 🏛️ Decisions So Far + +- **怀疑者推翻的两条(直接影响修法,先记住)**: + 1. **CPython 的 `print()` 在 `sys.stdout is None` 时是静默 no-op,不会崩**(builtin print 显式 `if file is None: return`)。⇒ 上一轮写在 `tools/verify_windows_portable.py:38` 与 `:586` 的"裸 print 即崩"是**错误前提**;真会崩的是 `None.write`(即本次 P1-1 的 argparse 路径)。所以 no-console 闸扫 traceback 抓不到那类回归不是"不够严",是**判据从一开始就瞄错对象**。 + 2. **桌面路径的 `lan-address.txt` 是安全的**:`_check_port_or_fail`(调用点 `desktop.py:874`,函数体 :833-867)在 `write_lan_address`(`:890`)**之前** ⇒ 端口被占时直接 `return 3`,走不到写地址。只有 `--server-only` 路径(`:999` 写 → `start.py:136` 才查端口)真会留下错误地址。 +- **其余三条 P1 经怀疑者 CONFIRMED**(参数解析早于收口 / 失败出口不统一 / splash 未覆盖冷启动前段 4~6.5s),最小修法与怀疑者给的一致。 +- **`--server-only` 地址弹窗(产品席建议"成功时弹一次可复制地址")暂不做**:属新增行为而非修缺陷,留 Fog of War 待你拍板。 + +--- + +## 🎯 Active Frontier (Unblocked Tasks) + +### Task 1: 入口序列改成「先收口后解析」(P1:唯一零线索崩溃路径)[Tier: 2-Standard] [Mode: AFK] [Role: TDD Builder] + +**Files:** +- Modify: `desktop.py:1005-1007`(`__main__`)、`start.py:219-222`(`__main__`) +- Test: `tests/test_desktop_shell.py`(入口序列的 None 注入回归,风格对齐该文件既有 `_RecordingCtypes` / `_run_with_missing_streams` 设施) + +**Interfaces:** +- Consumes: `ensure_streams(log_path: str, debug_console: bool = False)`(既有,`:365-368` 只补 `None` 流、有终端不接管)、`resolve_log_path(env)`(既有) +- Produces: 两个入口都变成"**先 `ensure_streams`,后 `_parse_args`**" ⇒ `sys.stdout/stderr is None` 时 argparse 的 `file.write` 不再 `AttributeError`;`--help` 与错参文本落 `launch.log`,退出码保持 argparse 既有语义(错参仍非零) + +**Injected Instincts:** +- [ ] `[Instinct: 先 RED 后 GREEN]`:先用 `sys.stdout=sys.stderr=None` + `_parse_args(["--help"])` / `["--bogus"]` 造 RED(当前必炸),再改入口顺序。 +- [ ] `[Instinct: 只补 None]`:`ensure_streams` 不得接管 pytest 捕获流/真实终端(既有语义,不许改)。 + +**Subagent Prompt Scaffold (for /dfs-exec):** +> "Implement Task 1: 入口先收口后解析。Mode: AFK | Role: TDD Builder +> Steps: ① 在 `tests/test_desktop_shell.py` 写失败测试:monkeypatch `sys.stdout/sys.stderr=None`,直接调两个模块的 `_parse_args(["--help"])` 与 `_parse_args(["--bogus"])`,断言**不抛**且 help/错误文本进了临时 `launch.log`(当前会 `AttributeError` ⇒ RED)。② 改 `desktop.py` / `start.py` 的 `__main__`:把 `ensure_streams(resolve_log_path(os.environ), "--debug-console" in sys.argv[1:])` 提到 `_parse_args` 之前。③ 确认 GREEN。④ 回报物理收据(含 RED 原文)。" + +**Step Breakdown:**(RED → 期望报错 → GREEN → 门禁 → Reviewer → 证据 → 提交) + +--- + +### Task 2: 失败出口统一(P1:切主窗异常静默 vs 外层原样弹 traceback)[Tier: 2-Standard] [Mode: AFK] [Role: TDD Builder] + +**Files:** +- Modify: `desktop.py:766-770`(`_after_start` 异常分支)、`desktop.py:933-935`(`write_crash` 调用点的 detail 文案) +- Test: `tests/test_desktop_shell.py`(两条失败出口各一条:弹窗必须出现、日志必须含堆栈) + +**Interfaces:** +- Consumes: `write_crash(title, detail, log_path)`(既有,`:406` = `_append_log` + `_message_box`) +- Produces: + - `_after_start` 异常分支改为 `write_crash("DeLector 启动失败", traceback.format_exc(), log_path)`(日志全栈 + 弹窗),再走既有 `_abort_windows`; + - 外层 `:933-935` 的弹窗 detail 从"完整 traceback"改为"**启动失败,详情见 `launch.log`(路径一并给出)**",traceback 只落盘; + - 两条出口最终行为一致:**日志全栈 + 弹窗一句话** + +**Injected Instincts:** +- [ ] `[Instinct: 不造第三种出口]`:统一复用 `write_crash`,不新增弹窗封装。 +- [ ] `[Instinct: 弹窗不许吐栈]`:面向用户的文案禁止包含 traceback(排障信息只在 `launch.log`)。 + +**Subagent Prompt Scaffold (for /dfs-exec):** +> "Implement Task 2: 统一失败出口。Mode: AFK | Role: TDD Builder +> Steps: ① 写失败测试(monkeypatch 让 `_after_start` 内的窗口调用抛异常):断言 `_message_box` 被调用且 `launch.log` 含 `Traceback`;再写一条断言外层失败的弹窗文案**不含** `Traceback` 但日志含。② 改 `desktop.py:766-770` 走 `write_crash`、`:933-935` 改文案。③ 确认 GREEN。④ 物理收据。" + +**Step Breakdown:**(同上结构) + +--- + +### Task 3: splash 覆盖冷启动前段(P1:首次双击 4~6.5s 零反馈)[Tier: 2-Standard] [Mode: AFK] [Role: TDD Builder] + +**Files:** +- Modify: `desktop.py`(`_run_desktop_inner` 约 `:874-900` 的阶段顺序与 `_after_start`) +- Test: `tests/test_desktop_shell.py`(**先写死锁回归** + 阶段顺序断言) + +**Interfaces:** +- Consumes: `_show_window` / `_after_start` / `webview.start()` / `_check_port_or_fail` / `write_lan_address` / `build_server` / `serve_in_thread` +- Produces: 把 `:892-897`(`from delector.server import app` / `build_server` / `serve_in_thread`)**搬进 `_after_start`**(即 `webview.start()` 调度的事件循环之内、splash 已可见之后)⇒ 用户可见反馈的起点从"重活全干完"提前到"splash 出现";端口校验(`:874`)与 WebView2 检测(`:878`)留在循环外(它们快且有弹窗) +- **硬约束**:`webview.start()` 之前**不得**调用任何窗口 API(既有 P0 死锁,`desktop.py:727` 一带的注释是判据);搬进去的代码不得在循环内抛未接异常 + +**Injected Instincts:** +- [ ] `[Instinct: P0-死锁回归先行]`:先加一条"循环启动前不碰窗口/不调 `evaluate_js`"的结构或行为断言(RED→GREEN),再动顺序;若既有测试已覆盖则点名复用它,不许"我觉得不会死锁"。 +- [ ] `[Instinct: 提前 create_window 无效]`:窗口由 `webview.start()` 才建 ⇒ 不许用"把 `create_window` 提前"充数。 + +**Subagent Prompt Scaffold (for /dfs-exec):** +> "Implement Task 3: splash 提前覆盖冷启动前段。Mode: AFK | Role: TDD Builder +> Steps: ① 先写/点名死锁回归(循环启动前碰窗口 ⇒ 红)。② 写阶段顺序断言(`build_server` 的调用点在 `_after_start` 内 / 或等价可测性质)。③ 把 `:892-897` 搬进 `_after_start`。④ 跑 `tests/test_desktop_shell.py` + 全量。⑤ 物理收据(含 RED 原文)。" + +**Step Breakdown:**(同上结构) + +--- + +### Task 4: no-console 闸判据改 AST 结构守卫 + 修正两处错误注释(P2)[Tier: 2-Standard] [Mode: AFK] [Role: TDD Builder] + +**Files:** +- Modify: `tests/test_ci_hardening.py` 或 `tests/test_desktop_shell.py`(新增 AST 守卫)、`tools/verify_windows_portable.py:38` 与 `:586`(注释) +- Test: 同文件(变异自证:在 `ensure_streams` 之前插一个无 `file=` 的 `print` ⇒ 守卫必须红) + +**Interfaces:** +- Consumes: `desktop.py` / `start.py` 的 AST(既有 AST 守卫先例:`tests/test_ci_hardening.py` 的 `ast` 用法、`test_desktop_shell.py` 的 `_parse_desktop`) +- Produces: + - 守卫:`desktop.py` 与 `start.py` 中,位于 `ensure_streams` 调用点**之前**的模块级/函数级代码里,不得出现无 `file=` 关键字参数的 `print(...)` 或 `sys.stdout.write(...)`; + - `tools/verify_windows_portable.py:38`、`:586` 的注释改写为事实:**Py3 的 `print()` 对 `None` stdout 是静默 no-op;真会崩的是 `None.write`(argparse 的 `file.write` 路径)**,并说明这道闸为何扫不到那类回归(子进程 stdout 被重定向 ⇒ 标准流并非 `None`) + +**Injected Instincts:** +- [ ] `[Instinct: 注释即契约]`:错误注释比没有注释更糟(它会成为后人的"依据");改写必须给出事实与出处(CPython builtin print 行为)。 +- [ ] `[Instinct: 变异自证必做]`:临时在被保护位置插一个裸 `print` ⇒ 守卫必须转红,复原后转绿(仓库外备份 + md5 复核)。 + +**Subagent Prompt Scaffold (for /dfs-exec):** +> "Implement Task 4: AST 结构守卫 + 修错误注释。Mode: AFK | Role: TDD Builder +> Steps: ① 写 AST 守卫(先 RED:当前没有守卫 ⇒ 用'临时插入裸 print'的方式证明它该红)。② 改 `verify_windows_portable.py:38`、`:586` 两处注释为事实陈述。③ 变异自证(插入 ⇒ 红;复原 ⇒ 绿)。④ 门禁 + 物理收据。" + +**Step Breakdown:**(同上结构) + +--- + +### Task 5: `--server-only` 的 `lan-address.txt` 写入时机 + 闸校验内容(P2)[Tier: 2-Standard] [Mode: AFK] [Role: TDD Builder] + +**Files:** +- Modify: `desktop.py:997-1002`(`dispatch` 的 server-only 分支)、`tools/verify_windows_portable.py`(`lan_address_state` 约 `:601` 的判据) +- Test: `tests/test_desktop_shell.py`(端口被占 ⇒ 不留地址文件)、`tests/test_verify_windows_portable.py`(判据升级) + +**Interfaces:** +- Consumes: `run_server_only` / `start.main` / `is_port_in_use`、`lan_address_state(data_dir, log_text)` +- Produces: + - `--server-only` 路径的地址写入**挪到服务真正持有端口之后**(由 `start.main()` 在 `build_server` 成功后回调写,或等价机制);端口被占 / 退出时**删除**已写文件; + - 桌面路径(`:890`)不变(已被证明安全:`:874` 端口校验在前); + - 闸判据从"文件存在"升级为"内容匹配 `^http://:$`"(空文件/垃圾内容 ⇒ 红) + +**Injected Instincts:** +- [ ] `[Instinct: 不许把写入提前到所有权之前]`:对外可见的"地址/端口"类文件,MUST 在本进程确认持有该端口后写。 +- [ ] `[Instinct: 闸要校验内容不只是存在]`:`is_file()` 类判据对"空文件/错内容"零判别力。 + +**Subagent Prompt Scaffold (for /dfs-exec):** +> "Implement Task 5: 地址写入时机 + 闸判据。Mode: AFK | Role: TDD Builder +> Steps: ① RED:端口被占时 `--server-only` 仍写地址文件(现在就会);闸对空文件判 PASS。② 实现:写入移到端口持有之后 + 失败删除;闸判据改内容匹配。③ GREEN + 变异自证(把文件清空 ⇒ 闸必须红)。④ 物理收据。" + +**Step Breakdown:**(同上结构) + +--- + +### Task 6: 测试卫生(P2:三条污染真实数据目录 + 一条恒绿)[Tier: 2-Standard] [Mode: AFK] [Role: TDD Builder] + +**Files:** +- Modify: `tests/test_desktop_shell.py`(约 `:1222` / `:1241` / `:1262` 三条 dispatch 用例;`:1700-1704` 恒绿用例) + +**Interfaces:** +- Consumes: 既有 `tmp_path` / `monkeypatch.setenv("DELECTOR_DATA_DIR", ...)` 惯例(同文件 :1289/:1335/:1437 已在用)、`_RecordingCtypes`(同文件 :1730 一带) +- Produces: + - 三条 dispatch 用例全部隔离到 `tmp_path`(或加 autouse fixture 全局钉住,防后续新增再漏); + - `test_ms_since_process_create_returns_none_off_windows` 改用记录型假 `ctypes`:`sys.platform="linux"` 下断言 `touched == []`(删掉 `desktop.py` 的平台守卫必须转红) + +**Injected Instincts:** +- [ ] `[Instinct: 测试不许碰真实落点]`:凡会写数据目录的路径,MUST 用 `DELECTOR_DATA_DIR` 指向 `tmp_path`。 +- [ ] `[Instinct: 自证过承诺的用例=恒绿]`:声称"不许碰 X"就必须能在"碰了 X"时转红。 + +**Subagent Prompt Scaffold (for /dfs-exec):** +> "Implement Task 6: 测试卫生。Mode: AFK | Role: TDD Builder +> Steps: ① RED:恒绿用例在'删掉平台守卫'的临时变异下必须转红(先证明它现在不红)。② 修三条隔离 + 改恒绿用例。③ GREEN + 变异自证(守卫删掉 ⇒ 红;复原 ⇒ 绿)。④ 物理收据。" + +**Step Breakdown:**(同上结构) + +--- + +## 🌫️ Fog of War (Not Yet Specified) + +- **[Unknown 1]**:Task 3 的 splash 方案**真实收益需真机验证**(HITL:双击后多久出现 splash);PyInstaller 原生 `--splash` 资源是另一条路(可覆盖解释器启动段),未评估、未立项。 +- **[Unknown 2]**:no-console 闸在 CI 上能否**真的**造出 `sys.stdout is None`(`Popen(stdout=None)` 时子进程继承父进程句柄 ⇒ 未必为 None,怀疑者标 UNVERIFIED)⇒ 需实测后才能决定"是否还要改闸的重定向方式";在此之前只做 Task 4 的 AST 守卫(不依赖该机制)。 +- **[Unknown 3]**:`--server-only` 成功时"弹一次可复制地址"的信息窗(产品席建议)——属新增行为,待你拍板;本计划只修"错文件 + 闸判据"。 + +## 🚫 Out of Scope + +- **Android 真机覆盖安装点检**、**发版(v5.18.0)**、PyInstaller `--splash` 资源。 +- `lan-address.txt` 落点从"数据目录"改"程序目录"(便携态)——需你拍板,当前维持数据目录。 +- 上一批 10-13 的 9 个提交本身**不回滚**(CI 全绿、已推送;重写收益低风险高)。 + +## ✅ 验收(本批完成的定义) + +1. `pytest` 全绿(当前基线 **1814 passed + 2 skipped**,新增用例逐条计数对得上)+ `ruff check .` + mypy 四档; +2. 每条 P1 都有"用户不再看到静默失败"的可验证判据(弹窗断言 / 顺序断言 / 阶段断言); +3. Task 4 / 5 / 6 各带**变异自证**(临时破坏 ⇒ 红;复原 ⇒ 绿,仓库外备份 + md5); +4. **一个 PR**(`fix/no-console-feedback-gaps` → `master`)合入,CI 全绿后合并(合并 MUST 等 CI 绿); +5. no-console 步在真实 Windows 产物上的首次执行结果(run `38433233079`)已核对,并作为 Task 4/5 的输入。 diff --git a/start.py b/start.py index 883fcfd0..8d80daec 100644 --- a/start.py +++ b/start.py @@ -12,6 +12,7 @@ import time import webbrowser from contextlib import AbstractContextManager, nullcontext +from typing import Callable, Optional # 为什么必须在最顶部就把 stdout/stderr 重配成 utf-8(本仓既有写法,见 package_windows.py 顶部): # 下面那条启动横幅用 print() 打中文,而**被重定向到文件的 stdout 在 Windows 上默认按 locale/ANSI @@ -129,7 +130,7 @@ def print_banner(port: int, android: bool) -> None: print(" 按 Ctrl+C 停止服务\n") -def main(debug_console: bool = False) -> None: +def main(debug_console: bool = False, on_serving: Optional[Callable[[int], None]] = None) -> None: _ensure_entry_streams(debug_console) port = 8000 android = is_android() @@ -154,6 +155,11 @@ def main(debug_console: bool = False) -> None: from delector.server import app server = build_server(host, port, app) + # Task 5:端口校验已过(is_port_in_use 早退在更上方)、`uvicorn.Server` 已组装 ⇒ 调用方 + # (desktop `--server-only`)据此写对外可见的地址文件。写入 MUST 晚于"确认本进程将持有 + # 该端口":端口被占 / 启动失败时根本到不了这里,故绝不留下指向错误目标的地址。 + if on_serving is not None: + on_serving(port) try: # 禁用信号处理:Android Chaquopy 在子线程里跑 server,而 signal.signal 只允许 # 在 Python 主线程调用。注意 uvicorn 的 API 随版本迁移(0.52 起只有 @@ -217,5 +223,10 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: if __name__ == "__main__": + # 先收口、后解析(Task 1,理由同 desktop.py 的 __main__):None 流下 argparse 的 + # `--help`/错参输出无处可写 ⇒ 用户零线索。复用 main() 同款 `_ensure_entry_streams` + # (整段包 try/except:Android APK 的 Python 源集没有 desktop.py,而 APK 上 sys.stdout + # 本就不是 None,收口失败也不得阻断启动)。debug 开关从原始 argv 探。 + _ensure_entry_streams("--debug-console" in sys.argv[1:]) _args = _parse_args(sys.argv[1:]) main(debug_console=_args.debug_console) diff --git a/tests/test_desktop_port_guard.py b/tests/test_desktop_port_guard.py index 93f20142..0dad8965 100644 --- a/tests/test_desktop_port_guard.py +++ b/tests/test_desktop_port_guard.py @@ -15,14 +15,25 @@ (不做"连过去"的窗口复用——拒绝启动是诚实且最简单的处理); - 被占但不是 DeLector ⇒ 弹窗 + 日志 + 退出码 3。 +Task 3 适配(本文件为何大改) +---------------------------- +Task 3 把 `from delector.server import app` / `build_server` / `serve_in_thread` 从 +`_run_desktop_inner` 搬进了 `_after_start`(`webview.start()` 调度的事件循环内)。原 +`_stub_server_chain` 把 `_show_window` 整个桩掉 ⇒ **build_server 陷阱永不可达**:端口空闲 +时它不红(该调没调到也看不见),端口被占时 `not in events` 恒真(vacuous,删不删校验都绿)。 +修法:流程级用例改经**假 webview** 驱动 `_show_window` → `_after_start`(范式同 +`tests/test_desktop_shell.py` 的录制替身),让陷阱重新可达、重新具备判别力。 + **变异自证**:把 `_run_desktop_inner` 里的校验调用删掉 ⇒ `test_run_desktop_inner_does_not_build_server_when_port_is_taken` 立即转红 -(build_server 陷阱会被调到 / 退出码不再是 3);AST 接线用例同时转红。 +(流程直达 build_server 陷阱 / 退出码不再是 3);AST 接线用例同时转红。 """ import ast +import sys +import types from pathlib import Path -from typing import Any, List +from typing import Any, List, Tuple import pytest @@ -48,22 +59,45 @@ def _stub_environment( `is_port_in_use` / `probe_identity` 是被测函数**调用点内**惰性 import 的源模块 属性,故直接 monkeypatch 源模块(`start` / `delector.core.server_lifecycle`)。 + + `DELECTOR_DATA_DIR` 钉到 tmp_path、`get_local_ip` 钉成回环:`_run_desktop_inner` 会调 + `write_lan_address`,不钉就会写进**真实**数据目录、还真去探一次局域网 IP(Task 3 后流程 + 会一路走到 `_after_start`,这两颗钉子从此是必需项而非可选项)。 """ monkeypatch.setattr(mod, "ensure_webview2_runtime", lambda: True) monkeypatch.setattr(mod, "bootstrap_data_dir", lambda env: str(tmp_path)) monkeypatch.setattr( mod, "_message_box", lambda title, detail: events.append(("box", title, detail)) ) + monkeypatch.setenv("DELECTOR_DATA_DIR", str(tmp_path)) import start from delector.core import server_lifecycle monkeypatch.setattr(start, "is_port_in_use", lambda port: occupied) + monkeypatch.setattr(start, "get_local_ip", lambda: "127.0.0.1") monkeypatch.setattr(server_lifecycle, "probe_identity", lambda port, **_k: identity) +def _stub_fake_server_module(monkeypatch: pytest.MonkeyPatch) -> None: + """把 `delector.server` 换成假模块:`from delector.server import app` 只取 `app` 属性。 + + 不换就会 import 真 server:那一跑把 DATA_DIR 按仓库根落定(desktop.py 的 ⚠️ 注释即为此), + 直接污染测试隔离 —— 正是测试卫生要防的雷。 + """ + fake_server = types.ModuleType("delector.server") + setattr(fake_server, "app", object()) # `from delector.server import app` 只取这个属性 + monkeypatch.setitem(sys.modules, "delector.server", fake_server) + + def _stub_server_chain(monkeypatch: pytest.MonkeyPatch, mod: Any, events: List[Any]) -> None: - """替身服务链:build_server 陷阱记录调用(变异自证的哨兵),其余 noop。""" + """替身服务链:build_server 陷阱记录调用(变异自证的哨兵),其余 noop。 + + Task 3 后建服/起服在 `_after_start` 里,故必须配 `_stub_fake_server_module`(否则真 import + server)。**不再**桩 `_show_window`:端口用例要经它真正走到 `_after_start`,陷阱才可达 + (旧实现桩掉它 ⇒ 端口空闲时建服哨兵永不响、端口被占时 `not in events` 恒真)。 + """ + _stub_fake_server_module(monkeypatch) def trap_build_server(host: str, port: int, app: Any) -> Any: events.append(("build_server", port)) @@ -71,16 +105,69 @@ def trap_build_server(host: str, port: int, app: Any) -> Any: monkeypatch.setattr(mod, "build_server", trap_build_server) monkeypatch.setattr(mod, "serve_in_thread", lambda server: object()) - monkeypatch.setattr(mod, "_show_window", lambda *args: 0) monkeypatch.setattr(mod, "coordinated_shutdown", lambda *args: None) +def _install_fake_webview(monkeypatch: pytest.MonkeyPatch, mod: Any) -> None: + """装一个**假 webview**:`start` 同步调度首参函数(`_after_start`),让建服陷阱可达。 + + Task 3 后 `build_server` / `serve_in_thread` 只在 `_after_start` 体内,而 `_after_start` + 只有经 `webview.start(func, args)` 才被调到 —— 把 `_show_window` 整个桩掉等于把陷阱所在的 + 那一层拆了,端口空闲也永远走不到它。同步调度(不真起 GUI 循环)还让 `server_handoff` 在 + `start` 返回前就装好 server/thread,`_run_desktop_inner` 的 finally 才收得到尸。 + """ + + class _Signal: + def __iadd__(self, _other: Any) -> "_Signal": + return self + + class _Events: + def __init__(self) -> None: + self.closed = _Signal() + self.loaded = _Signal() + + class _FakeWindow: + def __init__(self) -> None: + self.events = _Events() + + def evaluate_js(self, _script: str) -> None: + return None + + def load_url(self, _url: str) -> None: + return None + + def show(self) -> None: + return None + + def destroy(self) -> None: + return None + + def _create_window(*_a: Any, **_k: Any) -> _FakeWindow: + return _FakeWindow() + + def _start(func: Any = None, args: Any = None, **_k: Any) -> None: + if func is not None: + func(*(args or ())) + + fake_webview: Any = types.ModuleType("webview") + fake_webview.create_window = _create_window + fake_webview.start = _start + + monkeypatch.setitem(sys.modules, "webview", fake_webview) + # 就绪判定与托盘都不能真跑:一个会去打真 HTTP(60s 宽限期),一个会拉真 pystray。 + monkeypatch.setattr(mod, "await_ready", lambda *_a, **_k: True) + monkeypatch.setattr(mod, "_start_tray", lambda *_a, **_k: None) + + # ── 分支①:端口空闲 ⇒ 正常建服 ─────────────────────────────────────────────── def test_port_free_proceeds_to_build_server(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): mod = _mod() events: List[Any] = [] _stub_environment(monkeypatch, mod, tmp_path, events, occupied=False, identity=False) _stub_server_chain(monkeypatch, mod, events) + # 经假 webview 驱动 _show_window → _after_start:Task 3 后 build_server 只在后者体内, + # 桩掉 _show_window 会让本陷阱永不可达(旧实现的假绿形态:该调没调到也全绿)。 + _install_fake_webview(monkeypatch, mod) code = mod._run_desktop_inner(8000, str(tmp_path / "launch.log")) @@ -94,11 +181,17 @@ def test_run_desktop_inner_does_not_build_server_when_port_is_taken( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): """被占(任一身份)⇒ 拒绝启动:build_server 陷阱**不得**被调到 —— 这条就是 - "删掉校验调用"变异的红线:校验一删,流程落到 build_server 陷阱,本用例转红。""" + "删掉校验调用"变异的红线:校验一删,流程直达 build_server 陷阱,本用例转红。 + + Task 3 适配:旧版把 `_show_window` 整个桩掉 ⇒ 陷阱不可达 ⇒ `not in events` 恒真 + (vacuous)。现同分支① 经假 webview 驱动到 `_after_start`,陷阱可达 ⇒ 判别力恢复: + 删掉校验 ⇒ 退出码变 0 且陷阱被调到,两条断言各自都能转红。 + """ mod = _mod() events: List[Any] = [] _stub_environment(monkeypatch, mod, tmp_path, events, occupied=True, identity=True) _stub_server_chain(monkeypatch, mod, events) + _install_fake_webview(monkeypatch, mod) log_path = tmp_path / "launch.log" code = mod._run_desktop_inner(8000, str(log_path)) @@ -115,6 +208,7 @@ def test_port_taken_by_delector_pops_dialog_and_logs( events: List[Any] = [] _stub_environment(monkeypatch, mod, tmp_path, events, occupied=True, identity=True) _stub_server_chain(monkeypatch, mod, events) + _install_fake_webview(monkeypatch, mod) log_path = tmp_path / "launch.log" mod._run_desktop_inner(8000, str(log_path)) @@ -137,6 +231,7 @@ def test_port_taken_by_other_program_pops_dialog_and_logs( events: List[Any] = [] _stub_environment(monkeypatch, mod, tmp_path, events, occupied=True, identity=False) _stub_server_chain(monkeypatch, mod, events) + _install_fake_webview(monkeypatch, mod) log_path = tmp_path / "launch.log" code = mod._run_desktop_inner(8000, str(log_path)) @@ -152,27 +247,115 @@ def test_port_taken_by_other_program_pops_dialog_and_logs( assert "其他程序" in content, "拒绝原因必须落 launch.log" -# ── AST 接线钉:校验调用必须在 build_server 之前(结构层冗余保险) ───────────── -def test_port_check_is_wired_before_build_server_in_run_desktop_inner(): - tree = ast.parse((REPO_ROOT / "desktop.py").read_text(encoding="utf-8")) - inner = next( - (n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == "_run_desktop_inner"), - None, +# ── AST 接线钉:校验调用必须在 build_server 之前(Task 3 后两者已不在同一函数)── +def _is_webview_start_call(call: ast.Call) -> bool: + """`webview.start(...)`:属性链锚定 `webview` 对象,防同名方法误判。""" + func = call.func + return ( + isinstance(func, ast.Attribute) + and func.attr == "start" + and isinstance(func.value, ast.Name) + and func.value.id == "webview" ) - assert inner is not None, "desktop.py 里找不到 _run_desktop_inner(前提变了,需重写用例)" + +def _name_calls_in_source_order(node: ast.AST) -> List[str]: + """节点内全部 `f(...)`(Name 型 func)调用名,按 (行号, 列号) 排序后返回。 + + `ast.walk` 是 BFS 不是源码顺序,比先后必须显式排序后再取下标。 + """ calls = [ call - for call in ast.walk(inner) + for call in ast.walk(node) if isinstance(call, ast.Call) and isinstance(call.func, ast.Name) ] - # ast.walk 是 BFS 不是源码顺序,必须按 (lineno, col_offset) 排序后再比先后。 - calls.sort(key=lambda c: (c.lineno, c.col_offset)) + calls.sort(key=lambda call: (call.lineno, call.col_offset)) names: List[str] = [] for call in calls: - assert isinstance(call.func, ast.Name), "前提:func 已在上方过滤为 Name" - names.append(call.func.id) - assert "_check_port_or_fail" in names, "建服前必须调用端口/身份校验(删掉 = 变异自证红线)" - assert names.index("_check_port_or_fail") < names.index("build_server"), ( - "端口/身份校验必须排在 build_server 之前(晚一步就失去意义)" + func = call.func + assert isinstance(func, ast.Name) # 推导式前置过滤对 mypy 不收窄,显式断言让 .id 可见 + names.append(func.id) + return names + + +def test_port_check_is_wired_before_build_server_in_run_desktop_inner(): + """跨函数顺序钉(Task 3 适配):端口校验在 `_run_desktop_inner` 内、且早于进入 GUI 循环的 + 调用;`build_server` 在 `webview.start()` 调度函数(`_after_start`)体内。 + + 旧断言在**同一函数**里找 `_check_port_or_fail` 与 `build_server` 两个名字 —— Task 3 把建服 + 搬进 `_after_start` 后 `names.index("build_server")` 抛 ValueError(本用例当初就这么红的)。 + 顺序断言随之改成跨函数: + - `_check_port_or_fail` 必须在 `_run_desktop_inner` 内、且早于 `_show_window`(后者经 + `webview.start` 调度 `_after_start` 建服)——删掉校验调用 ⇒ 本用例立即转红(变异自证红线); + - `build_server` 必须在 `webview.start()` 首参函数体内 —— 搬回循环外 ⇒ 同样转红。 + """ + tree = ast.parse((REPO_ROOT / "desktop.py").read_text(encoding="utf-8")) + funcs = {node.name: node for node in tree.body if isinstance(node, ast.FunctionDef)} + inner = funcs.get("_run_desktop_inner") + assert inner is not None, "desktop.py 里找不到 _run_desktop_inner(前提变了,需重写用例)" + + inner_names = _name_calls_in_source_order(inner) + assert "_check_port_or_fail" in inner_names, "建服前必须调用端口/身份校验(删掉 = 变异自证红线)" + assert "_show_window" in inner_names, "_run_desktop_inner 必须经 _show_window 进 GUI 循环" + assert inner_names.index("_check_port_or_fail") < inner_names.index("_show_window"), ( + "端口/身份校验必须早于 _show_window(后者经 webview.start 调度 _after_start 建服;" + "晚一步就失去意义)" + ) + + start_calls = [ + call + for call in ast.walk(tree) + if isinstance(call, ast.Call) and _is_webview_start_call(call) + ] + assert len(start_calls) == 1, f"应恰有一处 webview.start(...) 调用,实际 {len(start_calls)}" + first_arg = start_calls[0].args[0] + assert isinstance(first_arg, ast.Name), "webview.start 的首参必须是函数名(而非内联 lambda)" + delegated = funcs.get(first_arg.id) + assert delegated is not None, ( + f"webview.start 首参 {first_arg.id!r} 不是顶层函数(前提变了,需重写用例)" + ) + assert "build_server" in _name_calls_in_source_order(delegated), ( + "build_server 必须在 webview.start() 调度函数(_after_start)体内:Task 3 把重活搬进" + "事件循环,端口空闲时它仍在校验之后才执行" + ) + + +# ── Task 3:server_handoff 交接机制(webview.start 返回后必须收尸)────────────── +def test_server_handoff_reaps_the_fake_server_after_start_returns( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """Y3:交接容器的**直接**锁(此前该机制零直接测试)。 + + Task 3 把建服/起服搬进 `_after_start`(`webview.start()` 调度的后台线程)后,server/thread + 只能经 `server_handoff` 回传主线程收尸。本用例用**真 dict** + 桩 `coordinated_shutdown`, + 断言 `webview.start()` 返回后假 server/thread 真的被收尸 —— 锁住"不留残留服务"。 + 删掉 `_after_start` 里的交接写入、或删掉 `_run_desktop_inner` finally 里的收尸 ⇒ 本用例转红。 + """ + mod = _mod() + events: List[Any] = [] + reaped: List[Tuple[Any, Any]] = [] + _stub_environment(monkeypatch, mod, tmp_path, events, occupied=False, identity=False) + _stub_server_chain(monkeypatch, mod, events) + _install_fake_webview(monkeypatch, mod) + fake_server, fake_thread = object(), object() + # 覆写建服/起服替身:返回**可辨识的哨兵**(才能断言收尸作用于本次起的 server/thread), + # 同时保留 build_server 陷阱记录(哨兵与陷阱不是二选一)。 + def _sentinel_build_server(host: str, port: int, app: Any) -> Any: + events.append(("build_server", port)) + return fake_server + + monkeypatch.setattr(mod, "build_server", _sentinel_build_server) + monkeypatch.setattr(mod, "serve_in_thread", lambda *_a: fake_thread) + + def _reap(server: Any, thread: Any, log_path: str) -> None: + reaped.append((server, thread)) + + monkeypatch.setattr(mod, "coordinated_shutdown", _reap) + + code = mod._run_desktop_inner(8000, str(tmp_path / "launch.log")) + + assert code == 0 + assert ("build_server", 8000) in events, "前提:建服陷阱确被调到(交接容器里才有东西可收)" + assert reaped == [(fake_server, fake_thread)], ( + "webview.start() 返回后必须用交接容器里的 server/thread 收尸一次(绝不留在残留服务)" ) diff --git a/tests/test_desktop_shell.py b/tests/test_desktop_shell.py index 331998dc..54690687 100644 --- a/tests/test_desktop_shell.py +++ b/tests/test_desktop_shell.py @@ -42,6 +42,21 @@ def _mod() -> Any: return desktop +# ── Task 6 卫生:全局把数据目录钉到 tmp_path(autouse 兜底,防漏)──────────────────── +@pytest.fixture(autouse=True) +def _isolate_data_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """任何用例默认把 `DELECTOR_DATA_DIR` 钉到 `tmp_path`:**绝不**落到开发者真实数据目录。 + + 为什么 autouse:`dispatch()` 的 `--server-only` 路径会读 `os.environ` 解析日志/地址落点 + (`resolve_log_path` → `ensure_streams` → `rotate_log_if_needed`)—— 个别用例漏写 `tmp_path` + 就会滚动真 `launch.log`、写出真 `lan-address.txt`(Task 5 前还会删真 `lan-address.txt`),落进 + `%LOCALAPPDATA%\\DeLector` / `~/.local/share/DeLector`。逐条加易漏,故用**全局兜底**:新增用例 + 不写都安全。`resolve_log_path` 的既有用例都显式传 dict(不走 `os.environ`),不受影响;需要别的 + 落点的用例自行 `monkeypatch.setenv` 覆盖即可(后设生效)。 + """ + monkeypatch.setenv("DELECTOR_DATA_DIR", str(tmp_path)) + + # ── ① WebView2 运行时检测:注册表三处 + pv 值 ──────────────────────────────── def test_webview2_registry_targets_the_edgeupdate_client_guid(): """检测锚点必须对准 Edge WebView2 的 EdgeUpdate 客户端项(漏/错 ⇒ 已装机器被误报缺失)。 @@ -211,6 +226,42 @@ def test_run_desktop_rotates_before_redirecting_streams(): ) +def test_main_guard_rotates_before_ensuring_streams() -> None: + """接线钉(**结构层**):`__main__` 里日志轮转必须早于 `ensure_streams`(黄牌 A)。 + + 比照 `test_run_desktop_rotates_before_redirecting_streams`:那条钉 `run_desktop` 内的同一约束, + 这条钉**入口**(`if __name__ == "__main__":`)。Task 1 把收口提到最前后,入口自己也会 + "先开句柄、后轮转";顺序一反,Windows 上对已打开文件 `os.replace` 失败、轮转被静默跳过 + ⇒ launch.log 无界增长。真跑 `__main__` 会进 GUI 循环,故只做结构断言(不跑真入口)。 + + 同时钉 Y5:`resolve_log_path(os.environ)` 在 `__main__` 只应解析**一次**(旧代码轮转、收口 + 各解析一遍)。合并后两处共用同一落点,不会因中途改 env 而两处不一致。 + """ + tree = _parse_desktop() + guard = next( + (node for node in tree.body if isinstance(node, ast.If) and _is_main_guard(node)), + None, + ) + assert guard is not None, "desktop.py 里找不到 `if __name__ == '__main__':` 守卫(前提变了,需重写用例)" + + def _lines(label: str) -> List[int]: + return [ + call.lineno for stmt in guard.body for call in _calls(stmt) if _call_label(call) == label + ] + + rotate_lines = _lines("rotate_log_if_needed") + ensure_lines = _lines("ensure_streams") + resolve_lines = _lines("resolve_log_path") + assert rotate_lines, "__main__ 必须先轮转(否则超大 launch.log 无界增长)" + assert ensure_lines, "__main__ 必须收口标准流(Task 1:--windowed 下 None 流的零线索路径)" + assert min(rotate_lines) < min(ensure_lines), ( + "轮转必须在收口(打开 launch.log 追加句柄)之前(Windows 上对已打开文件 os.replace 会失败)" + ) + assert len(resolve_lines) == 1, ( + f"resolve_log_path 在 __main__ 只应解析一次(轮转/收口共用同一落点),实际 {len(resolve_lines)} 次" + ) + + # ── ③ 退出编排:所有退出路径先 shutdown,超时硬退出 ────────────────────────── def test_coordinated_shutdown_reaps_server_then_reports_success(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """干净退出:先 `shutdown(server, thread)`,返回 True,且**不**触发硬退出。""" @@ -431,6 +482,172 @@ def test_write_crash_still_pops_when_log_write_fails(tmp_path: Path, monkeypatch assert popped == ["标题"], "即便落盘失败,弹窗也必须发生" +# ── Task 3 起共用的建服段替身(_after_start 里的 import/build/serve 已搬进事件循环)────── +def _stub_server_build( + monkeypatch: pytest.MonkeyPatch, mod: Any, order: Optional[List[str]] = None +) -> None: + """把 `_after_start` 的建服段(Task 3 搬入)换成替身:不起真服务、不 import 真 + `delector.server`。 + + 两个理由,缺一不可: + 1. **不起真服务**:`build_server` / `serve_in_thread` 会真绑端口起 uvicorn —— 测试纪律 + (不起真实服务/建窗)禁止; + 2. **不 import 真 `delector.server`**:那一跑会把 DATA_DIR 按仓库根落定(desktop.py 的 + ⚠️ 注释即为此),直接污染测试隔离(正是测试卫生要防的雷)。 + + `order` 非空时把两次调用记进去(供阶段顺序断言观测"建服晚于 webview.start()")。 + """ + fake_server: Any = types.ModuleType("delector.server") + fake_server.app = object() # `from delector.server import app` 只取这个属性 + monkeypatch.setitem(sys.modules, "delector.server", fake_server) + + def _fake_build_server(*_a: Any, **_k: Any) -> str: + if order is not None: + order.append("build_server") + return "FAKE_SERVER" + + def _fake_serve_in_thread(*_a: Any, **_k: Any) -> str: + if order is not None: + order.append("serve_in_thread") + return "FAKE_THREAD" + + monkeypatch.setattr(mod, "build_server", _fake_build_server) + monkeypatch.setattr(mod, "serve_in_thread", _fake_serve_in_thread) + + +# ── Task 2(失败出口统一):切 UI 异常必须弹窗 + 全栈落盘;外层弹窗不许吐栈 ──── +def test_after_start_exception_pops_dialog_and_logs_full_traceback( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """`_after_start` 内窗口调用抛异常 ⇒ 必须弹窗,且 `launch.log` 含全栈 traceback。 + + 缺陷现场:该异常分支此前只写一行 `exc!r` 就 `_abort_windows` 收尸 —— `--windowed` 打包后 + 无控制台,用户看到的是"窗口一闪就没、零线索";而同函数的就绪超时分支反而有弹窗,两条 + 失败出口不一致。无头环境把 MessageBoxW 换替身(不真弹),断言弹窗发生且全栈落盘。 + """ + mod = _mod() + log_path = tmp_path / "launch.log" + popped: List[Tuple[str, str]] = [] + monkeypatch.setattr(mod, "_message_box", lambda title, detail: popped.append((title, detail))) + monkeypatch.setattr(mod, "await_ready", lambda *_a, **_k: True) + _stub_server_build(monkeypatch, mod) # Task 3 后建服段在 _after_start 里:不起真服务 + + class _Signal: + def __iadd__(self, _other: Any) -> "_Signal": + return self + + class _Events: + def __init__(self) -> None: + self.closed = _Signal() + self.loaded = _Signal() + + class _Splash: + def __init__(self) -> None: + self.events = _Events() + + def evaluate_js(self, _script: str) -> None: + return None + + def destroy(self) -> None: + return None + + class _BoomMain: + def __init__(self) -> None: + self.events = _Events() + + def show(self) -> None: + raise RuntimeError("boom-main-show") + + def load_url(self, _url: str) -> None: + return None + + def destroy(self) -> None: + return None + + result: List[int] = [0] + + mod._after_start(_Splash(), _BoomMain(), mod.QuitCoordinator(), 8000, str(log_path), result) + + assert result[0] == 1, "异常路径必须置失败退出码(否则 start() 返回后进程装成功)" + assert popped, "切 UI 阶段异常必须弹窗(无控制台时这是用户唯一可见的失败反馈)" + content = log_path.read_text(encoding="utf-8") + assert "Traceback" in content, "launch.log 必须含全栈 traceback(只有 repr 一行不足以定位)" + assert "boom-main-show" in content, "异常信息必须落盘" + + +def _assigns_result_failure(stmt: ast.stmt) -> bool: + """`result[0] = 1`:把回传主线程的退出码容器置为失败。""" + if not isinstance(stmt, ast.Assign) or len(stmt.targets) != 1: + return False + target = stmt.targets[0] + return ( + isinstance(target, ast.Subscript) + and isinstance(target.value, ast.Name) + and target.value.id == "result" + and isinstance(target.slice, ast.Constant) + and target.slice.value == 0 + and isinstance(stmt.value, ast.Constant) + and stmt.value.value == 1 + ) + + +def test_after_start_pins_failure_code_before_write_crash() -> None: + """结构钉(黄牌 B):`_after_start` 异常出口必须**先** `result[0] = 1`、**再** `write_crash`。 + + `write_crash` 要落盘 + 弹窗,若它二次异常(本不应,防御性)也不能让退出码停留在"成功"—— + 先钉死 1,再走可能失败的留痕路径。两条语句一互换就静默违背该不变量,故用 AST 行号钉住次序。 + 行为侧由 `test_after_start_exception_pops_dialog_and_logs_full_traceback` 守(异常 ⇒ 码 1 + + 弹窗 + 全栈落盘);本条守的是行为测试**看不见**的次序不变量。 + """ + tree = _parse_desktop() + after = _funcs_by_name(tree)["_after_start"] + handlers = [ + handler + for node in ast.walk(after) + if isinstance(node, ast.Try) + for handler in node.handlers + ] + assert handlers, "_after_start 必须接住异常(后台线程里未接住的异常会静默杀死线程)" + for handler in handlers: + assigns = [stmt for stmt in handler.body if _assigns_result_failure(stmt)] + crashes = [call for call in _calls(handler) if _call_label(call) == "write_crash"] + assert assigns, "异常出口必须把 result[0] 置 1(否则 start() 返回后进程装成功)" + assert crashes, "异常出口必须 write_crash(全栈落盘 + 弹窗)" + assert min(stmt.lineno for stmt in assigns) < min(call.lineno for call in crashes), ( + "必须先钉死失败退出码(result[0] = 1)再 write_crash:留痕路径二次异常时退出码不得停留在成功" + ) + + +def test_run_desktop_outer_failure_pops_one_liner_without_traceback( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """外层失败路径:弹窗只给人话一句(给出日志路径),**不得**原样吐 traceback。 + + 缺陷现场:`run_desktop` 的 `except Exception` 此前把 `traceback.format_exc()` 原样传给 + `write_crash` ⇒ 完整堆栈进弹窗(无控制台用户被一大段英文栈吓住,也读不出所以然)。 + 修法:traceback 只落盘,弹窗一句话"启动失败,详情见 "(路径一并给出)。 + """ + mod = _mod() + monkeypatch.setenv("DELECTOR_DATA_DIR", str(tmp_path)) + log_path = tmp_path / "launch.log" + popped: List[Tuple[str, str]] = [] + monkeypatch.setattr(mod, "_message_box", lambda title, detail: popped.append((title, detail))) + + def _boom(*_a: Any, **_k: Any) -> int: + raise RuntimeError("boom-outer") + + monkeypatch.setattr(mod, "_run_desktop_inner", _boom) + + code = mod.run_desktop(port=8000) + + assert code == 1, "外层异常必须以非零码退出" + assert popped, "外层失败必须弹窗" + _title, detail = popped[0] + assert "Traceback" not in detail, f"弹窗文案不得含 traceback(排障信息只许落盘):{detail!r}" + assert "Traceback" in log_path.read_text(encoding="utf-8"), "全栈 traceback 必须落盘" + assert str(log_path) in detail, "弹窗必须给出日志路径,用户/支持者才能定位" + + # ── import 安全性:`import desktop` 不得起服务 / 弹窗 / 拉 GUI ──────────────── def _parse_desktop() -> ast.Module: return ast.parse((REPO_ROOT / "desktop.py").read_text(encoding="utf-8")) @@ -487,12 +704,143 @@ def test_desktop_launch_is_guarded_by_main(): assert guarded, "入口必须在 `if __name__ == '__main__':` 守卫内调用 dispatch" +# ── Task 4:标准流收口前的裸输出守卫(AST 结构级,no-console 顺序契约)────────── +# 判据出处:docs/plans/2026-10-14-no-console-feedback-gaps.md Task 4(怀疑者复核后的事实): +# CPython 的 print() 在 sys.stdout is None 时是**静默 no-op**(builtin print 内部 +# `if file is None: return`),不崩;真会崩的是 `None.write`(argparse 的 file.write +# 路径,Task 1 已修)。故本守卫防的不是"解释器崩",是**零线索**:--windowed 产物里, +# 收口点之前直接写标准流的代码,要么被静默蒸发(print 到 None),要么当场 +# AttributeError(None.write)⇒ 用户面对一扇不出现的窗口、launch.log 零行。 +# +# 为什么是 AST 守卫而不是"真启一次":CI 的子进程 stdout 被重定向进日志 ⇒ 标准流并非 +# None,那类回归在 verify_windows_portable.py 的 (c) 步语境下**不会被触发**,扫 +# traceback 也瞄不到它(判据从一开始就瞄错了对象)。None 语境只有本文件的 +# `sys.stdout=None` 注入单测复现得了;顺序契约则由这里静态钉死。 +# +# 收口点(标准流被保证可写的时刻)= 调用 ensure_streams 一族函数的语句: +# - `ensure_streams`:桌面/跨平台共用的收口实现(desktop.py); +# - `_ensure_entry_streams`:start.py 入口对上述收口的复用包装(内部就是 +# `from desktop import ensure_streams`;其 docstring 明言"复用桌面壳的 launch.log 收口"); +# - `_redirect_std_streams`:run_desktop 内的兼容接线(内部转调 ensure_streams)。 +_STREAM_COVERAGE_CALLS: FrozenSet[str] = frozenset( + {"ensure_streams", "_ensure_entry_streams", "_redirect_std_streams"} +) +# 受守护的入口模块(相对仓库根):桌面入口与跨平台入口(Task 1 把两者 __main__ 都改成先收口后解析)。 +_STREAM_GUARD_FILES: Tuple[str, ...] = ("desktop.py", "start.py") + + +def _walk_flat(node: ast.AST) -> Iterator[ast.AST]: + """walk 但不深入嵌套函数/类/lambda:那些是独立作用域,由各自的语句序列管辖。""" + yield node + for child in ast.iter_child_nodes(node): + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)): + continue + yield from _walk_flat(child) + + +def _stream_write_violation(call: ast.Call) -> Optional[str]: + """裸输出调用 ⇒ 人类可读标签;合法调用(带 file= / 非标准流 write)⇒ None。""" + func = call.func + if isinstance(func, ast.Name) and func.id == "print": + if any(kw.arg == "file" for kw in call.keywords): + return None + return "无 file= 关键字参数的 print(...)(收口前写到 None 流 = 静默蒸发)" + if isinstance(func, ast.Attribute) and func.attr == "write": + target = func.value + if ( + isinstance(target, ast.Attribute) + and target.attr in {"stdout", "stderr"} + and isinstance(target.value, ast.Name) + and target.value.id == "sys" + ): + return f"sys.{target.attr}.write(...)(收口前对 None 流调用 = AttributeError 零线索崩溃)" + return None + + +def _direct_coverage_lineno(stmts: List[ast.stmt]) -> Optional[int]: + """模块级:只认**直接**是收口调用的表达式语句(If/Try 块由各自单位管辖,不进子树)。""" + for stmt in stmts: + if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call): + func = stmt.value.func + if isinstance(func, ast.Name) and func.id in _STREAM_COVERAGE_CALLS: + return stmt.value.lineno + return None + + +def _scoped_coverage_lineno(stmts: List[ast.stmt]) -> Optional[int]: + """函数/入口块:控制流块(try/if/for…)内的收口调用也算界,但不深入嵌套 def/class。""" + best: Optional[int] = None + for stmt in stmts: + if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + continue + for node in _walk_flat(stmt): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id in _STREAM_COVERAGE_CALLS + ): + if best is None or node.lineno < best: + best = node.lineno + return best + + +def _statement_scopes(tree: ast.Module) -> Iterator[Tuple[str, List[ast.stmt], Optional[int]]]: + """产出所有线性执行序列 `(作用域名, 语句表, 收口行号)`;无收口行号 ⇒ 该序列不受静态管辖。""" + yield ("", tree.body, _direct_coverage_lineno(tree.body)) + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + yield (node.name, node.body, _scoped_coverage_lineno(node.body)) + elif isinstance(node, ast.If) and _is_main_guard(node): + yield ("<__main__>", node.body, _scoped_coverage_lineno(node.body)) + + +def _stream_guard_violations(source: str, filename: str) -> List[str]: + violations: List[str] = [] + tree = ast.parse(source, filename=filename) + for scope, stmts, boundary in _statement_scopes(tree): + if boundary is None: + continue # 该序列没有收口点 ⇒ 裸输出的执行时机由调用方决定,静态不可判 + for stmt in stmts: + if stmt.lineno > boundary: + break # 已过收口点:标准流此时已保证可写,允许裸 print / write + for node in _walk_flat(stmt): + if isinstance(node, ast.Call) and node.lineno < boundary: + label = _stream_write_violation(node) + if label is not None: + violations.append(f"{filename}:{node.lineno}({scope}){label}") + return violations + + +@pytest.mark.parametrize("relpath", _STREAM_GUARD_FILES) +def test_no_bare_stream_writes_before_stream_coverage(relpath: str) -> None: + """收口点之前不得出现裸 print / sys.stdout.write / sys.stderr.write(Task 4 结构守卫)。 + + 先证过"无守卫时抓不到":临时在两个入口的收口调用前各插一个裸 print,既有 124 条 + 用例全绿。本用例落地后再做同样的插入 ⇒ 转红(RED 原文见提交信息);删除复原 ⇒ 转绿 + (仓库外副本备份 + md5 复核,未用 git checkout/restore)。 + """ + source = (REPO_ROOT / relpath).read_text(encoding="utf-8") + violations = _stream_guard_violations(source, relpath) + + assert not violations, ( + f"{relpath} 在标准流收口(ensure_streams 一族)之前出现裸输出:\n" + + "\n".join(f" - {v}" for v in violations) + + "\n收口前写标准流 = 无控制台(--windowed)语境下零线索:print 静默蒸发、" + "sys.stdout/stderr.write 直接 AttributeError。请把输出移到收口调用之后,或显式传 file=。" + ) + + # ── P0 死锁回归锁:窗口操作必须在 webview.start() 之后(GUI 事件循环内)执行 ────── # 真根因(scratch/repro_splash_deadlock.py 三模式实测):pywebview 把 evaluate_js / load_url # marshal 到 GUI 线程执行,而 GUI 事件循环由 webview.start() 才启动;在 start **之前**调它们会 # 永久等待 ⇒ 窗口永不出现、进程永不退出。下列断言把"何时才允许碰窗口"钉死,且不只测"函数存在"。 _WINDOW_WRITE_ATTRS = {"evaluate_js", "load_url"} _STAGE_HELPER = "_set_splash_stage" # 该助手内部调 evaluate_js,只能在循环起来后跑 +# P0 硬约束点名的**全部**窗口 API(evaluate_js / load_url / show / destroy,"等"以外就这些)。 +# 行为锁 `test_window_ops_run_only_after_webview_start_at_runtime` 按这个全集断言,而不是只查 +# `_WINDOW_WRITE_ATTRS`:只查 evaluate_js/load_url 会被"把 `main.show()` 挪到 `webview.start()` +# 之前"的变异骗过 —— show 同样是窗口 API,提前调用同样被 marshal 到未启动的 GUI 线程 ⇒ 永久阻塞。 +_WINDOW_OPS = {"evaluate_js", "load_url", "show", "destroy"} def _funcs_by_name(tree: ast.Module) -> Dict[str, ast.FunctionDef]: @@ -713,10 +1061,12 @@ def test_run_desktop_logs_process_level_timing_start() -> None: # 缺陷背景:`run_desktop` 写下「开始启动(进程级计时起点)」之后、到 `_show_window` 的第一条日志 # 之前,`_run_desktop_inner` 这一整段(端口/身份校验 → WebView2 检测 → 定数据落点 → 导入 server → # 建服 → 起服务线程)**一条日志都没有** ⇒ launch.log 上约 3s 全是黑盒,卡死时无法归因到哪一步。 -# 下列锁钉住 6 枚新标记:① 全部存在;② 源码顺序恒为 1→6;③ 每枚都**夹在**它要标记的那条语句 -# 与下一枚锚语句之间(下界「晚于本锚」证伪「标记写在语句之前」;上界「早于下一锚」证伪「整体 -# 下移但保持顺序」——只有下界时后者会假绿);另钉一条:新标记绝不把首屏工具的锚串当子串(否则 -# `first_screen_from_log` 的 `_last_index` / `_first_index_after` 会抢错行)。 +# Task 3 后 6 枚标记的落点随语句拆成两半:①~③ 留循环外(`_run_desktop_inner`),④~⑥ 随建服/ +# 起服搬进循环内(`_after_start` 的 try 体)。下列锁分组钉住:① 全部存在;② 组内源码顺序 +# (1→3 / 4→6);③ 每枚都**夹在**它要标记的那条语句与下一枚锚语句之间(下界「晚于本锚」证伪 +# 「标记写在语句之前」;上界「早于下一锚」证伪「整体下移但保持顺序」——只有下界时后者会假绿); +# 另钉一条:新标记绝不把首屏工具的锚串当子串(否则 `first_screen_from_log` 的 `_last_index` / +# `_first_index_after` 会抢错行)。 _STARTUP_STAGE_MARKERS: List[str] = [ "端口与身份校验通过。", # ← `_check_port_or_fail` 通过之后 "WebView2 运行时检测通过。", # ← `ensure_webview2_runtime` 通过之后 @@ -759,37 +1109,69 @@ def _stmt_has_call(stmt: ast.stmt, label: str) -> bool: return any(_call_label(call) == label for call in _calls(stmt)) -def test_run_desktop_inner_logs_six_stage_markers_in_order_after_their_anchor_stmts() -> None: - """`_run_desktop_inner` 必落 6 枚启动阶段标记,顺序为 1→6,且各自**夹在**它要标记的语句与 - 下一条锚语句之间(下界:晚于本锚语句;上界:早于下一枚锚语句的起始行)。 +_PRE_LOOP_MARKERS: List[str] = _STARTUP_STAGE_MARKERS[:3] # ①~③:循环外(_run_desktop_inner) +_IN_LOOP_MARKERS: List[str] = _STARTUP_STAGE_MARKERS[3:] # ④~⑥:循环内(_after_start,Task 3 搬入) + + +def _after_start_try_body() -> List[ast.stmt]: + """`_after_start` 的 try 体语句(Task 3:建服段搬进循环内,且必须在 try 里)。 + + 为什么必须在 try 里:`_after_start` 跑在 `webview.start()` 调度的**后台线程**,未接住的 + 异常会让线程静默死亡(又回到"窗口不出、零线索")。故实现应是"单个顶层 try 包住全部", + 这里把该形状钉死;④~⑥ 的锚点要从 try 体里取(而不是函数顶层)。 + """ + fn = _funcs_by_name(_parse_desktop())["_after_start"] + assert len(fn.body) == 1 and isinstance(fn.body[0], ast.Try), ( + "_after_start 应只有一个顶层 try(后台线程里的异常必须被接住并留痕)" + ) + return list(fn.body[0].body) + + +def _stmt_span_in(stmts: List[ast.stmt], predicate: Any) -> Tuple[int, int]: + """`stmts` 里首个满足 `predicate` 的语句的(起始行, 结束行)。找不到即报错。""" + for stmt in stmts: + if predicate(stmt): + return stmt.lineno, stmt.end_lineno or stmt.lineno + raise AssertionError(f"语句列表里找不到满足断言的语句(谓词={predicate!r})") + + +def test_stage_markers_stay_with_their_anchor_statements_across_the_move() -> None: + """6 枚启动阶段标记必须**贴着**它们要标记的语句:①~③ 留循环外(`_run_desktop_inner`), + ④~⑥ 随搬移进循环内(`_after_start` 的 try 体)。每组各钉"存在 + 顺序 + 夹逼"。 为什么钉「存在 + 顺序 + 位置」三者:只钉「字符串在源码里出现过」会被「标记写在语句之前 / 顺序颠倒」骗过;而 `first_screen_from_log` 的切片完全依赖**先后次序**,位置错了归因就错。 - 为什么位置要**夹逼**而不只有下界:只有下界时,把 6 枚标记**整体下移**(例如全塞到 - `serve_in_thread` 之后、仍保持 1→6 顺序)仍会全绿 —— `ordered` 与 6 条下界断言都骗得过去, - 但归因已经张冠李戴(每枚标记都不再紧贴它要标记的那一步)。上界(早于下一枚锚)把这种 - 「整体漂移」挡在门外:末枚标记的上界锚是紧随其后的 `try:` 顶层语句。 - """ - entries = _stage_log_entries("_run_desktop_inner") - messages = [message for _lineno, message in entries] + 为什么位置要**夹逼**而不只有下界:只有下界时,把一组标记**整体下移**(仍保持组内顺序) + 仍会全绿,但归因已经张冠李戴(每枚标记都不再紧贴它要标记的那一步)。上界(早于下一枚锚) + 把这种「整体漂移」挡在门外。 - for marker in _STARTUP_STAGE_MARKERS: - assert marker in messages, ( - f"_run_desktop_inner 缺少启动阶段标记 {marker!r};现有 _append_log 字面量:{messages}" + Task 3 的背景:建服/起服搬进 `webview.start()` 调度的事件循环后,④~⑥ 若**留在** + `_run_desktop_inner`(循环外、语句之前),launch.log 的时间线就会说谎(标记写在重活之前) + —— 正是本断言要防的"归因张冠李戴"。故 ④~⑥ 必须随语句搬进 `_after_start` 的 try 体。 + """ + run_entries = _stage_log_entries("_run_desktop_inner") + run_messages = [message for _lineno, message in run_entries] + run_line_of = {message: lineno for lineno, message in run_entries} + for marker in _PRE_LOOP_MARKERS: + assert marker in run_messages, ( + f"循环外标记 {marker!r} 必须仍在 _run_desktop_inner;现有 _append_log 字面量:{run_messages}" ) + ordered_pre = [m for m in run_messages if m in set(_PRE_LOOP_MARKERS)] + assert ordered_pre == _PRE_LOOP_MARKERS, f"循环外三枚标记的源码顺序必须为 1→3,实际:{ordered_pre}" + + loop_entries = _stage_log_entries("_after_start") + loop_messages = [message for _lineno, message in loop_entries] + loop_line_of = {message: lineno for lineno, message in loop_entries} + for marker in _IN_LOOP_MARKERS: + assert marker in loop_messages, ( + f"循环内标记 {marker!r} 必须随语句搬进 _after_start;现有 _append_log 字面量:{loop_messages}" + ) + ordered_loop = [m for m in loop_messages if m in set(_IN_LOOP_MARKERS)] + assert ordered_loop == _IN_LOOP_MARKERS, f"循环内三枚标记的源码顺序必须为 4→6,实际:{ordered_loop}" - ordered = [message for _lineno, message in entries if message in set(_STARTUP_STAGE_MARKERS)] - assert ordered == _STARTUP_STAGE_MARKERS, ( - f"6 枚启动阶段标记的源码顺序必须为 1→6,实际:{ordered}" - ) - - line_of = {message: lineno for lineno, message in entries} - # 7 条锚语句 span:前 6 条是 6 枚标记各自的锚,第 7 条(`try:`)是末枚标记的**上界锚**。 - # 逐枚用 anchor_spans[i] 与 anchor_spans[i+1] 夹逼 —— 只钉"晚于本锚"的下界会被"6 枚标记 - # 整体下移到后面、但仍保持 1→6 顺序"骗过(ordered 与下界断言全绿);加上"早于下一条锚的 - # 起始行"的上界才真正钉住"就落在本条语句之下、下一条锚之上"这个位置区间。 - anchor_spans = [ + # 循环外锚:前 3 条是 ①~③ 各自的锚,第 4 条(含 `_show_window` 的顶层语句)是 ③ 的上界锚。 + run_anchors = [ _stage_stmt_span( "_run_desktop_inner", lambda s: isinstance(s, ast.If) and _stmt_has_call(s, "_check_port_or_fail"), @@ -799,40 +1181,98 @@ def test_run_desktop_inner_logs_six_stage_markers_in_order_after_their_anchor_st lambda s: isinstance(s, ast.If) and _stmt_has_call(s, "ensure_webview2_runtime"), ), _stage_stmt_span("_run_desktop_inner", lambda s: _stmt_has_call(s, "bootstrap_data_dir")), - _stage_stmt_span( - "_run_desktop_inner", + # 锚"含 `_show_window` 调用的顶层语句"(`_run_desktop_inner` 里由 `try:` 体内的 `return` + # 调起)——语义即"③ 必须在调用 `_show_window` 之前"。为何不锚"首个顶层 `try`":将来加一个 + # 与 `_show_window` 无关的顶层 `try` 会让锚前移 ⇒ 假红。 + _stage_stmt_span("_run_desktop_inner", lambda s: _stmt_has_call(s, "_show_window")), + ] + for index, marker in enumerate(_PRE_LOOP_MARKERS): + lower_start, lower_end = run_anchors[index] + upper_start, _upper_end = run_anchors[index + 1] + assert run_line_of[marker] > lower_end, ( + f"标记 {marker!r} 必须写在它要标记的语句(第 {lower_start}-{lower_end} 行)**之后**," + f"实际在第 {run_line_of[marker]} 行 —— 写在语句之前会让归因张冠李戴" + ) + assert run_line_of[marker] < upper_start, ( + f"标记 {marker!r} 必须写在下一枚锚语句(第 {upper_start} 行)**之前**," + f"实际在第 {run_line_of[marker]} 行 —— 整体下移到下一枚锚之后会让归因张冠李戴" + ) + + # 循环内锚(`_after_start` 的 try 体):import app → assign server → assign thread → + # `_set_splash_stage`(④~⑥ 各自夹在前后两个锚之间)。 + loop_stmts = _after_start_try_body() + loop_anchors = [ + _stmt_span_in( + loop_stmts, lambda s: isinstance(s, ast.ImportFrom) and s.module == "delector.server", ), - _stage_stmt_span( - "_run_desktop_inner", + _stmt_span_in( + loop_stmts, lambda s: isinstance(s, ast.Assign) and any(isinstance(t, ast.Name) and t.id == "server" for t in s.targets), ), - _stage_stmt_span( - "_run_desktop_inner", + _stmt_span_in( + loop_stmts, lambda s: isinstance(s, ast.Assign) and any(isinstance(t, ast.Name) and t.id == "thread" for t in s.targets), ), - # 第 7 条锚:**调用 `_show_window` 的那条顶层语句**(`_run_desktop_inner` 里 `_show_window` - # 由 `try:` 体内的 `return` 调起 ⇒ 该 `try:` 就是这条锚)——语义即"第 6 枚标记必须在调用 - # `_show_window` 之前",把"6 枚整体下移到 `_show_window` 之后"的假绿挡在门外。 - # 为何不锚"首个顶层 `try`"(`isinstance(s, ast.Try)`):将来在该函数更早处加一个**与 - # `_show_window` 无关**的顶层 `try` 会让锚**前移** ⇒ 假红。改锚"含 `_show_window` 调用的 - # 顶层语句"即不受影响(`_stmt_has_call` 会 walk 进 `try` 体命中该调用)。 - _stage_stmt_span("_run_desktop_inner", lambda s: _stmt_has_call(s, "_show_window")), + _stmt_span_in(loop_stmts, lambda s: _stmt_has_call(s, "_set_splash_stage")), ] - - for index, marker in enumerate(_STARTUP_STAGE_MARKERS): - lower_start, lower_end = anchor_spans[index] - upper_start, _upper_end = anchor_spans[index + 1] - assert line_of[marker] > lower_end, ( + for index, marker in enumerate(_IN_LOOP_MARKERS): + lower_start, lower_end = loop_anchors[index] + upper_start, _upper_end = loop_anchors[index + 1] + assert loop_line_of[marker] > lower_end, ( f"标记 {marker!r} 必须写在它要标记的语句(第 {lower_start}-{lower_end} 行)**之后**," - f"实际在第 {line_of[marker]} 行 —— 写在语句之前会让归因张冠李戴" + f"实际在第 {loop_line_of[marker]} 行 —— 写在语句之前会让归因张冠李戴" ) - assert line_of[marker] < upper_start, ( + assert loop_line_of[marker] < upper_start, ( f"标记 {marker!r} 必须写在下一枚锚语句(第 {upper_start} 行)**之前**," - f"实际在第 {line_of[marker]} 行 —— 整体下移到下一枚锚之后会让归因张冠李戴" + f"实际在第 {loop_line_of[marker]} 行 —— 整体下移到下一枚锚之后会让归因张冠李戴" + ) + + +def test_server_build_phase_lives_inside_the_start_delegated_function() -> None: + """Task 3 阶段顺序(结构锁):建服/起服必须在 `webview.start()` 调度的函数体内。 + + 把 `from delector.server import app` / `build_server` / `serve_in_thread` 留在 + `_run_desktop_inner`(循环外)⇒ 本断言转红 —— 这正是当前实现的形状(RED)。搬进 + `_after_start` 后,重活发生在 splash 已可见之后,首次双击的反馈起点从"重活全干完" + 提前到"splash 出现"(4~6.5s 零反馈的修法)。 + + 同时钉计划规格的另一半:端口校验与 WebView2 检测**留在循环外**(它们快且有弹窗)。 + """ + tree = _parse_desktop() + funcs = _funcs_by_name(tree) + name, delegated = _delegated_function(tree) + inner_labels = _reachable_labels(delegated, funcs) + + for label in ("build_server", "serve_in_thread"): + assert label in inner_labels, ( + f"{label} 必须搬进 webview.start() 调度的函数({name})体内(splash 可见之后才建服)" + ) + in_loop_imports = [ + node + for node in ast.walk(delegated) + if isinstance(node, ast.ImportFrom) and node.module == "delector.server" + ] + assert in_loop_imports, "`from delector.server import app` 必须在 start 调度的函数体内" + + run_inner = funcs["_run_desktop_inner"] + run_inner_labels = {_call_label(call) for call in _calls(run_inner)} + for label in ("build_server", "serve_in_thread"): + assert label not in run_inner_labels, ( + f"{label} 不得留在 _run_desktop_inner:循环外的重活正是 4~6.5s 零反馈的成因" ) + run_inner_imports = [ + node + for node in ast.walk(run_inner) + if isinstance(node, ast.ImportFrom) and node.module == "delector.server" + ] + assert not run_inner_imports, "`from delector.server import app` 不得留在 _run_desktop_inner" + + # 计划规格:端口校验与 WebView2 检测留在循环外(快且有弹窗,不必等 splash 可见)。 + assert "_check_port_or_fail" in run_inner_labels, "端口/身份校验必须留在循环外(_run_desktop_inner)" + assert "ensure_webview2_runtime" in run_inner_labels, "WebView2 检测必须留在循环外(_run_desktop_inner)" def test_stage_markers_do_not_contain_first_screen_anchor_substrings() -> None: @@ -840,7 +1280,7 @@ def test_stage_markers_do_not_contain_first_screen_anchor_substrings() -> None: 三个锚串互不为子串。 本用例**只比对本文件清单常量、不读 `desktop.py`**。清单与实现的**同步**由 - `test_run_desktop_inner_logs_six_stage_markers_in_order_after_their_anchor_stmts`(读 AST 的 + `test_stage_markers_stay_with_their_anchor_statements_across_the_move`(读 AST 的 存在/顺序/夹逼)保证;解析器侧的**同源**守卫在 `tests/test_first_screen_from_log.py::test_new_stage_markers_do_not_contain_first_screen_anchor_substrings` (它从 `desktop.py` 同源取标记)。 @@ -887,6 +1327,8 @@ def test_loaded_event_records_first_screen_marker(tmp_path: Path, monkeypatch: p 里出现首屏标记。删掉绑定 / 不再记日志 ⇒ 本用例转红(不是只测"字符串在源码里")。 """ mod = _mod() + # Task 3 后建服/起服在 _after_start 里:不起真服务、不 import 真 delector.server。 + _stub_server_build(monkeypatch, mod) log_path = tmp_path / "launch.log" class _Signal: @@ -938,7 +1380,7 @@ def _start(func: Any = None, args: Any = None, **_k: Any) -> None: monkeypatch.setattr(mod, "_start_tray", lambda *_a, **_k: None) monkeypatch.setattr(mod, "_message_box", lambda *_a, **_k: None) - code = mod._show_window(object(), object(), 8000, str(log_path)) + code = mod._show_window(object(), 8000, str(log_path)) assert code == 0 content = log_path.read_text(encoding="utf-8") @@ -952,9 +1394,16 @@ def test_window_ops_run_only_after_webview_start_at_runtime( 替身无法复现"marshal 到未启动 GUI 线程 = 永久阻塞",但能钉住**调用顺序**:`start` 必须先于 任何 load_url / evaluate_js。把 load_url 挪回 start 之前 ⇒ 本断言转红(即真实死锁的静态形状)。 + + Task 3 追加(Y2 黄牌修法):同一份 `order` 也喂给 `_stub_server_build`,于是"建服/起服晚于 + `webview.start()`"这条 docstring 承诺**第一次有了观测者** —— 此前 6 个调用点无一传 `order`, + 承诺无人验证。把 build_server/serve_in_thread 挪回 `_show_window`(start 之前)⇒ 本断言转红。 """ mod = _mod() + # Task 3 后建服/起服在 _after_start 里:不起真服务、不 import 真 delector.server。 + # order 必须在此先建后传入:替身要把 build_server/serve_in_thread 也记进同一份顺序表。 order: List[str] = [] + _stub_server_build(monkeypatch, mod, order) class _Signal: def __iadd__(self, _other: Any) -> "_Signal": @@ -1002,15 +1451,24 @@ def _start(func: Any = None, args: Any = None, **_k: Any) -> None: monkeypatch.setattr(mod, "_start_tray", lambda *_a, **_k: order.append("tray")) monkeypatch.setattr(mod, "_message_box", lambda *_a, **_k: order.append("message_box")) - code = mod._show_window(object(), object(), 8000, str(tmp_path / "launch.log")) + code = mod._show_window(object(), 8000, str(tmp_path / "launch.log")) assert "start" in order, "必须调用 webview.start" start_index = order.index("start") - writes = [index for index, op in enumerate(order) if op in _WINDOW_WRITE_ATTRS] - assert writes, "成功路径必然有窗口写入(evaluate_js / load_url)" - assert all(index > start_index for index in writes), ( - f"窗口写入早于 webview.start()(P0 死锁形状):order={order}" + # 全集而不仅是 evaluate_js/load_url:show / destroy 也是窗口 API,提前调用同样被 marshal 到 + # 未启动的 GUI 线程 ⇒ 永久阻塞(P0 硬约束原文:evaluate_js / load_url / show / destroy 等)。 + window_ops = [index for index, op in enumerate(order) if op in _WINDOW_OPS] + assert window_ops, "成功路径必然有窗口操作(evaluate_js / load_url / show / destroy)" + assert all(index > start_index for index in window_ops), ( + f"窗口操作早于 webview.start()(P0 死锁形状):order={order}" ) + # Task 3 / Y2:建服与起服同样必须晚于 start(它们已搬进 _after_start,即 start 调度函数体内)。 + # 少了这条,"把 build_server 挪回 _show_window(start 之前)"会让 4~6.5s 零反馈复活而全绿。 + for server_op in ("build_server", "serve_in_thread"): + assert server_op in order, f"{server_op} 必须被调到(order={order})" + assert order.index(server_op) > start_index, ( + f"{server_op} 早于 webview.start()(Task 3 回退形状:splash 前的重活零反馈):order={order}" + ) assert code == 0 @@ -1103,11 +1561,13 @@ def test_main_window_is_created_as_a_normal_resizable_window(tmp_path: Path, mon 这条锁直接检查交给 `webview.create_window` 的 kwargs,杜绝"函数存在但参数回退"的假绿。 """ mod = _mod() + # Task 3 后建服/起服在 _after_start 里:不起真服务、不 import 真 delector.server。 + _stub_server_build(monkeypatch, mod) order: List[str] = [] record: Dict[str, Any] = {} _install_recording_webview(monkeypatch, mod, order, record) - code = mod._show_window(object(), object(), 8000, str(tmp_path / "launch.log")) + code = mod._show_window(object(), 8000, str(tmp_path / "launch.log")) assert code == 0 created: List[Dict[str, Any]] = record["created"] @@ -1132,11 +1592,13 @@ def test_final_ui_is_the_main_window_shown_after_ready(tmp_path: Path, monkeypat 同时钉住顺序:先亮主窗口再销毁 splash —— 否则销毁瞬间零窗口会让 pywebview 的 GUI 循环提前退出。 """ mod = _mod() + # Task 3 后建服/起服在 _after_start 里:不起真服务、不 import 真 delector.server。 + _stub_server_build(monkeypatch, mod) order: List[str] = [] record: Dict[str, Any] = {} _install_recording_webview(monkeypatch, mod, order, record) - mod._show_window(object(), object(), 8000, str(tmp_path / "launch.log")) + mod._show_window(object(), 8000, str(tmp_path / "launch.log")) assert "main.show" in order, "必须显式 show 主窗口,否则用户看到的仍是 splash" assert "splash.destroy" in order, "就绪后必须销毁 splash(否则它永不消失)" @@ -1158,11 +1620,13 @@ def test_main_window_close_requests_quit_and_reaps_splash(tmp_path: Path, monkey `webview.start()` 阻塞到**所有**窗口关闭;主窗口关而 splash 尚存会使循环永不返回 ⇒ 进程挂起。 """ mod = _mod() + # Task 3 后建服/起服在 _after_start 里:不起真服务、不 import 真 delector.server。 + _stub_server_build(monkeypatch, mod) order: List[str] = [] record: Dict[str, Any] = {} _install_recording_webview(monkeypatch, mod, order, record) - mod._show_window(object(), object(), 8000, str(tmp_path / "launch.log")) + mod._show_window(object(), 8000, str(tmp_path / "launch.log")) main = record["windows"]["main"] coordinator = record["coordinator"] @@ -1192,6 +1656,73 @@ def destroy(self) -> None: assert destroyed == [1], "主窗口关闭时若 splash 还在,必须一并销毁" +# ── 元测试:本文件每条 `dispatch(...)` 用例都必须隔离数据目录(Task 6 测试卫生)───────── +def _calls_dispatch(fn: ast.FunctionDef) -> bool: + """函数体(含嵌套)里是否有 `xxx.dispatch(...)` 调用。""" + return any( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "dispatch" + for node in ast.walk(fn) + ) + + +def _references_data_dir_env(fn: ast.FunctionDef) -> bool: + """函数体里是否显式引用了 `DELECTOR_DATA_DIR`(字符串常量,用于 monkeypatch.setenv)。""" + return any( + isinstance(node, ast.Constant) and node.value == "DELECTOR_DATA_DIR" for node in ast.walk(fn) + ) + + +def _module_has_autouse_data_dir_fixture(tree: ast.Module) -> bool: + """本文件是否有 autouse fixture 把 `DELECTOR_DATA_DIR` 全局钉住(`tmp_path` 兜底)。""" + for node in tree.body: + if not isinstance(node, ast.FunctionDef): + continue + autouse = any( + isinstance(dec, ast.Call) + and isinstance(dec.func, ast.Attribute) + and dec.func.attr == "fixture" + and any( + kw.arg == "autouse" and isinstance(kw.value, ast.Constant) and kw.value.value is True + for kw in dec.keywords + ) + for dec in node.decorator_list + ) + if autouse and _references_data_dir_env(node): + return True + return False + + +def test_all_dispatch_tests_isolate_the_data_dir() -> None: + """元测试:凡调 `dispatch(...)` 的用例都必须隔离数据目录 —— 防污染**真实**落点。 + + 为什么必须是**元测试**而非逐条断言:隔离是**跨用例**的卫生约束,单条断言只能顾到当下这几条, + 新增一条 `dispatch` 用例忘了 `tmp_path` 时它不会红。`dispatch()` 的 `--server-only` 路径会 + `resolve_log_path(os.environ)` + `ensure_streams` + `rotate_log_if_needed` —— 没隔离就滚动真 + `launch.log`、写出真 `lan-address.txt`(Task 5 前还会删掉真 `lan-address.txt`),落进开发者真实 + 数据目录(Windows `%LOCALAPPDATA%\\DeLector` / Linux `~/.local/share/DeLector`)。 + + 两种隔离方式任一即算通过:用例体里显式 `DELECTOR_DATA_DIR`,或本文件有 autouse fixture 全局兜底。 + """ + tree = ast.parse(Path(__file__).read_text(encoding="utf-8")) + module_guarded = _module_has_autouse_data_dir_fixture(tree) + offenders = [ + node.name + for node in tree.body + if isinstance(node, ast.FunctionDef) + and node.name.startswith("test_") + and _calls_dispatch(node) + and not module_guarded + and not _references_data_dir_env(node) + ] + assert not offenders, ( + "这些 dispatch 用例未隔离数据目录(会滚动真 launch.log、写出真 lan-address.txt 到 " + f"%LOCALAPPDATA%\\DeLector / ~/.local/share/DeLector):{offenders};" + "请各自加 tmp_path + monkeypatch.setenv('DELECTOR_DATA_DIR', ...),或补一条 autouse fixture 全局兜底" + ) + + # ── 入口分派:默认=桌面窗口;`--server-only`=既有服务行为(Task 5 行为变化)──────── def test_parser_accepts_server_only_flag(): """`--server-only` 开关存在且默认关闭;`--port` 仍可用(默认 8000)。""" @@ -1229,7 +1760,8 @@ def test_dispatch_server_only_delegates_to_start_main(monkeypatch: pytest.Monkey mod = _mod() calls: List[str] = [] - monkeypatch.setattr(start, "main", lambda: calls.append("main")) + # Task 5:dispatch 现在会多传一个**可选** on_serving 回调(地址写入时机),故替身须收下它。 + monkeypatch.setattr(start, "main", lambda **kwargs: calls.append("main")) monkeypatch.setattr(mod, "run_desktop", lambda port: pytest.fail("--server-only 不得起桌面窗口")) code = mod.dispatch(server_only=True, port=8123) @@ -1249,7 +1781,8 @@ def test_dispatch_server_only_warns_that_port_is_ignored( import start mod = _mod() - monkeypatch.setattr(start, "main", lambda: None) + # Task 5:替身收下 dispatch 新增的可选 on_serving 回调(地址写入时机)。 + monkeypatch.setattr(start, "main", lambda **kwargs: None) monkeypatch.setattr(mod, "run_desktop", lambda port: pytest.fail("--server-only 不得起桌面窗口")) code = mod.dispatch(server_only=True, port=9000) @@ -1266,7 +1799,8 @@ def test_dispatch_server_only_does_not_warn_for_default_port( import start mod = _mod() - monkeypatch.setattr(start, "main", lambda: None) + # Task 5:替身收下 dispatch 新增的可选 on_serving 回调(地址写入时机)。 + monkeypatch.setattr(start, "main", lambda **kwargs: None) mod.dispatch(server_only=True, port=mod.DEFAULT_PORT) @@ -1274,30 +1808,94 @@ def test_dispatch_server_only_does_not_warn_for_default_port( # ── `--server-only` 的地址出口:数据目录落 lan-address.txt(无控制台时唯一可见)── -def test_dispatch_server_only_writes_lan_address_file( +def test_dispatch_server_only_writes_lan_address_after_serving( tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - """`--server-only` 必须在数据目录写 `lan-address.txt`,内容含 `http://`。 +) -> None: + """`--server-only` 在**服务真正持有端口之后**把地址写进数据目录 `lan-address.txt`(Task 5)。 `--windowed` 打包后 stdout 不可见,start.py 的横幅(唯一带 LAN 地址的输出)没了 ⇒ - 这份文件是用户拿到 `http://:` 的第一出口(双击可看、可复制、随时再看)。 - 端口必须用 DEFAULT_PORT:该路径固定 8000,`--port` 不生效(见上方警告)。 + 这份文件是用户拿到 `http://:` 的第一出口。写入时机是 Task 5 的要害: + 必须晚于端口校验(由 start.main 在 build_server 成功后回调 on_serving),端口被占 / + 启动失败时不得写(见 `test_dispatch_server_only_leaves_no_address_file_when_port_busy`)。 + + 这里用替身 start.main **在回调触发的那一刻** 读盘:只要回调一被调用,数据目录就必须 + 已有含正确端口的地址文件(端口号取自 start.main 的真相源)。 """ import start mod = _mod() monkeypatch.setenv("DELECTOR_DATA_DIR", str(tmp_path)) - monkeypatch.setattr(start, "main", lambda: None) monkeypatch.setattr(start, "get_local_ip", lambda: "192.168.1.50") + captured: Dict[str, str] = {} + + def _fake_main(debug_console: bool = False, on_serving: Any = None) -> None: + assert on_serving is not None, "dispatch 必须把地址写入回调交给 start.main(端口持有后才写)" + on_serving(mod.DEFAULT_PORT) # 模拟 build_server 成功后触发写入 + captured["content"] = (tmp_path / "lan-address.txt").read_text(encoding="utf-8") + + monkeypatch.setattr(start, "main", _fake_main) code = mod.dispatch(server_only=True, port=mod.DEFAULT_PORT) assert code == 0 - lan_file = tmp_path / "lan-address.txt" - assert lan_file.exists(), "--server-only 必须写 lan-address.txt(无控制台时唯一可见的地址出口)" - content = lan_file.read_text(encoding="utf-8") - assert "http://" in content, "文件内容必须是 http:// 开头的地址" - assert f"http://192.168.1.50:{mod.DEFAULT_PORT}" in content, "地址必须用真实端口(--server-only 固定 8000)" + assert "http://" in captured["content"], "文件内容必须是 http:// 开头的地址" + assert f"http://192.168.1.50:{mod.DEFAULT_PORT}" in captured["content"], ( + "持有端口后必须把 http://:<端口> 写进 lan-address.txt" + ) + + +def test_dispatch_server_only_removes_address_file_on_exit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """服务退出后不得留下地址文件(Task 5):它指向已停服务的目标 = 错误目标。 + + 与上条互补:上条钉"持有时写",本条钉"退出后删"(`run_server_only` 的 finally 里 + "若已写则删除")。两态都指向同一不变量:**文件存在 ⟺ 本进程正持有该端口**。 + """ + import start + + mod = _mod() + monkeypatch.setenv("DELECTOR_DATA_DIR", str(tmp_path)) + monkeypatch.setattr(start, "get_local_ip", lambda: "192.168.1.50") + + def _fake_main(debug_console: bool = False, on_serving: Any = None) -> None: + assert on_serving is not None + on_serving(mod.DEFAULT_PORT) # 持有端口 ⇒ 写 + assert (tmp_path / "lan-address.txt").exists(), "写入必须发生在回调触发的那一刻" + + monkeypatch.setattr(start, "main", _fake_main) + + mod.dispatch(server_only=True, port=mod.DEFAULT_PORT) + + assert not (tmp_path / "lan-address.txt").exists(), "退出后必须删除已写地址文件(不留错误目标)" + + +def test_dispatch_server_only_leaves_no_address_file_when_port_busy( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """端口被占 ⇒ `--server-only` 不得留下 `lan-address.txt`(Task 5,RED-A)。 + + 旧实现先写地址、后查端口(写地址在 `dispatch`,`start.main` 里才 `is_port_in_use` → + `sys.exit(1)`)⇒ 端口被别的程序占用时,用户拿到一份**指向别的服务**的地址文件。 + 修法:写入挪到服务真正持有端口之后;端口被占即早退,根本没机会写。这里用**真实** + `start.main`(端口 / 身份探针打桩,不起真服务)复现该路径。 + """ + import start + from delector.core import server_lifecycle + + mod = _mod() + monkeypatch.setenv("DELECTOR_DATA_DIR", str(tmp_path)) + monkeypatch.setattr(start, "get_local_ip", lambda: "192.168.1.50") # 有 LAN ⇒ 旧路径会写 + monkeypatch.setattr(start, "is_port_in_use", lambda _port: True) # 端口被占 + monkeypatch.setattr(server_lifecycle, "probe_identity", lambda *_a, **_k: False) # 非 DeLector + monkeypatch.setattr(start, "open_browser", lambda _port: pytest.fail("不得为无关程序打开页面")) + + with pytest.raises(SystemExit): + mod.dispatch(server_only=True, port=mod.DEFAULT_PORT) + + assert not (tmp_path / "lan-address.txt").exists(), ( + "端口被占时必须早退,绝不得留下指向错误目标的 lan-address.txt" + ) def test_write_lan_address_skips_when_no_lan_ip( @@ -1333,11 +1931,17 @@ def test_write_lan_address_failure_does_not_block_startup( mod = _mod() monkeypatch.setenv("DELECTOR_DATA_DIR", str(tmp_path)) - monkeypatch.setattr(start, "main", lambda: None) monkeypatch.setattr(start, "get_local_ip", lambda: "192.168.1.50") # 让落点变成一个**目录**:open(..., "w") 必抛 IsADirectoryError(OSError 子类)。 monkeypatch.setattr(mod, "resolve_lan_address_path", lambda _env: str(tmp_path)) + # Task 5:地址写入现在由 start.main 在持有端口后回调触发 —— 替身须触发它,写失败才会发生。 + def _fake_main(debug_console: bool = False, on_serving: Any = None) -> None: + if on_serving is not None: + on_serving(mod.DEFAULT_PORT) + + monkeypatch.setattr(start, "main", _fake_main) + code = mod.dispatch(server_only=True, port=mod.DEFAULT_PORT) assert code == 0, "地址文件写失败不得阻断 --server-only 启动" @@ -1435,7 +2039,8 @@ def test_desktop_server_only_entry_captures_warning_before_delegation( mod = _mod() monkeypatch.setenv("DELECTOR_DATA_DIR", str(tmp_path)) - monkeypatch.setattr(start, "main", lambda: None) + # Task 5:替身收下 dispatch 新增的可选 on_serving 回调(地址写入时机)。 + monkeypatch.setattr(start, "main", lambda **kwargs: None) def _exercise() -> None: real_ensure_streams = mod.ensure_streams @@ -1698,10 +2303,25 @@ def _boom() -> float: def test_ms_since_process_create_returns_none_off_windows(monkeypatch: pytest.MonkeyPatch) -> None: - """非 Windows 直接 None(CI 是 ubuntu,`ctypes.windll` 在那儿根本不存在 ⇒ 不许碰它)。""" + """非 Windows 直接 None,且**绝不触碰** `ctypes.windll` 链(CI 是 ubuntu,那儿根本不存在)。 + + 记录型假 ctypes(同 `_RecordingCtypes`)记账每一次属性访问且**不抛**:`sys.platform="linux"` 下 + 平台守卫还在 ⇒ `_ms_since_process_create` 在 `import ctypes` 之前就早退 ⇒ `touched == []`。 + + **变异自证**(为什么必须断言 `touched == []` 而非只断言 `is None`):删掉 `desktop.py` 里 + `_ms_since_process_create` 的平台守卫后,`_win_ms_since_create` 会走 `ctypes.windll.kernel32 …` + —— 假 ctypes 记账(`touched` 非空)、`GetProcessTimes(...)` 返回 0 ⇒ 底层抛 `OSError` 被 + `except Exception` 吞掉**仍返回 None**。旧版只断言 `is None` 时这会**照样绿**(恒绿:静默吞异常 + 骗过了"不许碰 Win32"的承诺);本版由 `touched == []` 钉死,删守卫即转红。 + """ mod = _mod() + touched: List[str] = [] + calls: List[Any] = [] monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setitem(sys.modules, "ctypes", _RecordingCtypes("ctypes", touched, calls)) + assert mod._ms_since_process_create() is None + assert touched == [], f"非 Windows 不得触碰 Win32 API(ctypes.windll 链),实际访问:{touched}" @pytest.mark.skipif( @@ -1932,3 +2552,113 @@ def test_tray_show_lan_address_fails_readably_on_non_utf8_file( assert popped, "坏文件必须给出可见的失败提示,不许让异常逃逸进托盘回调" log_text = (tmp_path / "launch.log").read_text(encoding="utf-8") assert "显示局域网地址" in log_text, "失败也要落 launch.log,不能只有弹窗" + + +# ── Task 1(本轮):入口「先收口后解析」—— --windowed 下 None 流的零反馈回归 ────── +# 背景:`--windowed` 产物(package_windows.py:320)里 `sys.stdout`/`sys.stderr` 是 None, +# 而两个 `__main__` 都是「先 `_parse_args`、后收口」⇒ argparse 的 `--help`/错参输出 +# 无处可写:本机 CPython 3.11 的 `_print_message` 把 `None.write` 的 AttributeError 吞掉 +# (不炸,但文本当场蒸发);会外抛的版本则是 AttributeError + launch.log 零行。两种表现 +# 同一个后果:**用户零线索**(退出码非零、无窗口、无弹窗、日志零行)。修法:把收口 +# (`ensure_streams` / `_ensure_entry_streams`)提到 `_parse_args` 之前,debug 开关从 +# 原始 argv 探(此刻还没解析完,拿不到 `_args`)。 +def _run_entry_module(module_name: str, argv: List[str], monkeypatch: pytest.MonkeyPatch) -> int: + """以 `__main__` 身份执行入口模块(不真起进程),返回其退出码。 + + 为什么是 runpy 而不是「直接调 `_parse_args`」:本回归的 RED/GREEN 分界在 `__main__` + **序列**里(收口与解析的先后)——直接调 `_parse_args` 绕开了那个序列,修复后它照样 + 拿不到流,断言永远红、根本绿不了。runpy 以 `__name__="__main__"` exec 真入口; + `--help`/错参都在 argparse 退出前止住,`dispatch`/`main` 不会被触达(不起服务、不建窗)。 + + 为什么是 runpy 而不是 subprocess:要复现的 `sys.stdout/stderr is None` 只在 + `--windowed` 打包形态出现,子进程的 stdio 永远是管道,造不出这个状态。 + + 「不抛」的保证是结构性的:除 argparse 自己的 SystemExit 外,任何异常(如 + `None.write` 的 AttributeError)都会直接穿出本函数把用例转红,无需额外捕获。 + """ + import runpy + + monkeypatch.setattr(sys, "argv", [f"{module_name}.py", *argv]) + try: + runpy.run_module(module_name, run_name="__main__") + except SystemExit as exc: + if exc.code is None: + return 0 # parser.exit() 的默认语义(--help 走这条) + if isinstance(exc.code, int): + return exc.code + raise + return 0 + + +def test_desktop_entry_help_lands_in_launch_log_when_streams_are_none( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """`--windowed`(stdout/stderr=None)下 `desktop.py --help`:不抛、退出码 0、文本落 launch.log。""" + monkeypatch.setenv("DELECTOR_DATA_DIR", str(tmp_path)) + log_path = tmp_path / "launch.log" + + def _exercise() -> None: + code = _run_entry_module("desktop", ["--help"], monkeypatch) + assert code == 0, "argparse --help 的既有语义是退出码 0(收口不得改变它)" + + _run_with_missing_streams(monkeypatch, _exercise) + + content = log_path.read_text(encoding="utf-8") if log_path.exists() else "" + assert "usage: desktop.py" in content, f"--help 文本必须落进 launch.log,实际:{content!r}" + assert "--server-only" in content and "--debug-console" in content, ( + f"无控制台时 help 是用户唯一的使用说明,必须含全部开关,实际:{content!r}" + ) + + +def test_desktop_entry_bad_arg_lands_in_launch_log_when_streams_are_none( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """错参(--bogus)在 None 流下也必须给出用法+错误,退出码保持 argparse 既有语义(2,非零)。""" + monkeypatch.setenv("DELECTOR_DATA_DIR", str(tmp_path)) + log_path = tmp_path / "launch.log" + + def _exercise() -> None: + code = _run_entry_module("desktop", ["--bogus"], monkeypatch) + assert code == 2, "argparse 错参的既有语义是退出码 2(非零;收口不得吞掉它)" + + _run_with_missing_streams(monkeypatch, _exercise) + + content = log_path.read_text(encoding="utf-8") if log_path.exists() else "" + assert "usage: desktop.py" in content, f"错参也必须给出用法,实际:{content!r}" + assert "unrecognized arguments: --bogus" in content, f"错误必须点名坏参数,实际:{content!r}" + + +def test_start_entry_help_lands_in_launch_log_when_streams_are_none( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """`start.py --help` 同款契约(跨平台入口复用 `_ensure_entry_streams`;APK 无 desktop.py)。""" + monkeypatch.setenv("DELECTOR_DATA_DIR", str(tmp_path)) + log_path = tmp_path / "launch.log" + + def _exercise() -> None: + code = _run_entry_module("start", ["--help"], monkeypatch) + assert code == 0, "argparse --help 的既有语义是退出码 0(收口不得改变它)" + + _run_with_missing_streams(monkeypatch, _exercise) + + content = log_path.read_text(encoding="utf-8") if log_path.exists() else "" + assert "usage: start.py" in content, f"--help 文本必须落进 launch.log,实际:{content!r}" + assert "--debug-console" in content, f"help 必须含调试开关,实际:{content!r}" + + +def test_start_entry_bad_arg_lands_in_launch_log_when_streams_are_none( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """`start.py` 错参(--bogus):不抛、退出码 2、用法+错误落 launch.log(契约同 desktop 入口)。""" + monkeypatch.setenv("DELECTOR_DATA_DIR", str(tmp_path)) + log_path = tmp_path / "launch.log" + + def _exercise() -> None: + code = _run_entry_module("start", ["--bogus"], monkeypatch) + assert code == 2, "argparse 错参的既有语义是退出码 2(非零;收口不得吞掉它)" + + _run_with_missing_streams(monkeypatch, _exercise) + + content = log_path.read_text(encoding="utf-8") if log_path.exists() else "" + assert "usage: start.py" in content, f"错参也必须给出用法,实际:{content!r}" + assert "unrecognized arguments: --bogus" in content, f"错误必须点名坏参数,实际:{content!r}" diff --git a/tests/test_first_screen_from_log.py b/tests/test_first_screen_from_log.py index 78afc3d8..b3d5415f 100644 --- a/tests/test_first_screen_from_log.py +++ b/tests/test_first_screen_from_log.py @@ -12,7 +12,7 @@ import sys import tempfile from pathlib import Path -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Tuple import pytest @@ -123,35 +123,76 @@ def test_missing_file_exits_nonzero_with_reason(tmp_path: Path) -> None: # ── Task 2:6 枚新启动标记不得让解析器抢错锚行(行为级 + 结构化守卫)────────────── +# Task 3 后 6 枚标记的落点拆在两个函数:①~③ 留循环外(`_run_desktop_inner` 顶层),④~⑥ 随 +# 建服/起服搬进循环内(`_after_start`,即 `webview.start()` 调度函数的 try 体)。只扫一个函数 +# 会漏掉半组——本回归锁当初正是这么红的(len(markers)==6 拿到 3)。 +_STAGE_MARKER_FUNCS: Tuple[str, ...] = ("_run_desktop_inner", "_after_start") + + +def _stage_marker_statements(func: ast.FunctionDef) -> List[ast.stmt]: + """取 `func` 里承载"启动阶段标记"的顶层语句。 + + `_run_desktop_inner`:整个函数体顶层即①~③(失败分支的日志嵌在 `if` 内,天然被排除)。 + + `_after_start`:它刻意只有单个顶层 try(后台线程必须接住异常),故取 **try 体**;但 try 体里 + 除④~⑥ 外还有就绪/切窗阶段的日志,其中「服务已就绪。」**本身就是首屏工具的锚行** —— 把它算进 + "新标记"清单会自我矛盾(清单里出现锚行)。故用结构切口:只取到**第一个 `_set_splash_stage` + 之前**的语句,即"建服/起服段"(`from delector.server import app` → `build_server` → + `serve_in_thread`)——④~⑥ 正是紧贴这三条语句的归因日志。 + """ + if func.name == "_after_start": + assert len(func.body) == 1 and isinstance(func.body[0], ast.Try), ( + "_after_start 应只有一个顶层 try(后台线程里未接住的异常会静默杀死线程)" + ) + stmts = list(func.body[0].body) + cut = next( + (index for index, stmt in enumerate(stmts) if _stmt_has_call(stmt, "_set_splash_stage")), + len(stmts), + ) + return stmts[:cut] + return list(func.body) + + +def _stmt_has_call(stmt: ast.stmt, label: str) -> bool: + """语句(含嵌套)里是否出现对 `label(...)` 的调用。""" + return any( + isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == label + for node in ast.walk(stmt) + ) + + def _new_stage_markers_from_desktop() -> List[str]: - """从 desktop.py 的 `_run_desktop_inner` **同源取** 6 枚启动阶段标记(AST 取 `_append_log` 的 - 第二个字符串实参,按源码出现顺序)。 + """从 desktop.py **同源取** 6 枚启动阶段标记(AST 取 `_append_log` 的第二个字符串实参, + 按源码出现顺序)。 为何同源取而不在本文件复制字面量:复制即**第二份真相源** —— desktop.py 一改标记,副本会 **静默漂移**,这枚回归锁转而去守护一份过期字面量(正是本项目反复栽过的坑)。 - 为何只取函数体**顶层**的 `_append_log`:成功路径的 6 枚标记都是顶层语句;失败分支里的日志 - (如「未检测到 WebView2 运行时…」)嵌在 `if` 内、不属于这 6 枚 —— 取顶层即可精确命中。 + + Task 3 起分两处取(`_STAGE_MARKER_FUNCS`,顺序即写入顺序):①~③ 取 `_run_desktop_inner` + 顶层,④~⑥ 取 `_after_start` 的 try 体。只取各函数体的**顶层**:失败分支里的日志(如 + 「未检测到 WebView2 运行时…」)嵌在 `if` 内、不属于这 6 枚 —— 取顶层即可精确命中。 """ tree = ast.parse(DESKTOP.read_text(encoding="utf-8")) - # 显式遍历 + 断言(而非 `next(...)`):`next` 找不到时抛 `StopIteration`,消息难读;这里给 + # 显式建表 + 断言(而非 `next(...)`):`next` 找不到时抛 `StopIteration`,消息难读;这里给 # 人话原因,函数被改名/搬走时一眼可见。 - func: Optional[ast.FunctionDef] = None + funcs: Dict[str, ast.FunctionDef] = {} for node in tree.body: - if isinstance(node, ast.FunctionDef) and node.name == "_run_desktop_inner": - func = node - break - assert func is not None, "desktop.py 里找不到 `_run_desktop_inner`(被改名了?)" + if isinstance(node, ast.FunctionDef): + funcs[node.name] = node markers: List[str] = [] - for stmt in func.body: - if not isinstance(stmt, ast.Expr) or not isinstance(stmt.value, ast.Call): - continue - call = stmt.value - if not (isinstance(call.func, ast.Name) and call.func.id == "_append_log"): - continue - if len(call.args) >= 2: - arg = call.args[1] - if isinstance(arg, ast.Constant) and isinstance(arg.value, str): - markers.append(arg.value) + for name in _STAGE_MARKER_FUNCS: + func = funcs.get(name) + assert func is not None, f"desktop.py 里找不到 `{name}`(被改名了?)" + for stmt in _stage_marker_statements(func): + if not isinstance(stmt, ast.Expr) or not isinstance(stmt.value, ast.Call): + continue + call = stmt.value + if not (isinstance(call.func, ast.Name) and call.func.id == "_append_log"): + continue + if len(call.args) >= 2: + arg = call.args[1] + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + markers.append(arg.value) return markers @@ -164,16 +205,20 @@ def test_new_stage_markers_do_not_hijack_first_screen_parser(tmp_path: Path) -> """ markers = _new_stage_markers_from_desktop() assert len(markers) == 6, ( - f"_run_desktop_inner 顶层应有 6 枚启动标记,实际 {len(markers)} 枚:{markers}" + f"启动阶段标记应共 6 枚(_run_desktop_inner 顶层 3 枚 + _after_start 的 try 体 3 枚)," + f"实际 {len(markers)} 枚:{markers}" "(标记增删请同步本回归锁,勿让它静默守护过期清单)" ) - # 时间戳(秒级)与顺序严格照 desktop.py 的写入次序:**真起点=07**、6 枚标记=08→11、splash=11、 - # 服务就绪=13、首屏=14 ⇒ 期望三段为 6s / 1s / 7s。 + # 时间戳(秒级)与顺序严格照 desktop.py 的写入次序:**真起点=07**、循环外 3 枚=08→09、 + # splash=10(`_show_window` 内、webview.start 之前)、循环内 3 枚=10→11、服务就绪=13、 + # 首屏=14 ⇒ 期望三段为 6s / 1s / 7s。 # ⚠️ 真起点(07)**刻意早于** markers[0](08):这样一旦 markers[0] 变成含「开始启动(进程级 # 计时起点)」的形态,解析器的 `_last_index`(取**最后一次**出现)会命中 markers[0] 而非真起点 # ⇒ 三段数字变味、本用例转红。(旧版把真起点与 markers[0] 都写成 08 ⇒ 起点被抢后三段仍不变, # 用例恒真 —— 见 Fix-3 变异自证。) + # 「已创建 splash 窗口。」是**填充行**(非 6 枚标记之一、也不含任何锚串):它证明锚行之间的 + # 无关行不会干扰切片;它写在 `_show_window` 里,故时刻必须夹在循环外 3 枚与循环内 3 枚之间。 log = _write_log( tmp_path / "launch.log", [ @@ -181,10 +226,10 @@ def test_new_stage_markers_do_not_hijack_first_screen_parser(tmp_path: Path) -> ("2026-10-10 12:00:08", markers[0]), ("2026-10-10 12:00:09", markers[1]), ("2026-10-10 12:00:09", markers[2]), + ("2026-10-10 12:00:10", "已创建 splash 窗口。"), ("2026-10-10 12:00:10", markers[3]), ("2026-10-10 12:00:10", markers[4]), ("2026-10-10 12:00:11", markers[5]), - ("2026-10-10 12:00:11", "已创建 splash 窗口。"), ("2026-10-10 12:00:13", READY + "。"), ("2026-10-10 12:00:14", SCREEN + "。"), ], @@ -208,9 +253,14 @@ def test_new_stage_markers_do_not_contain_first_screen_anchor_substrings() -> No 用 `marker in line.message` 做子串匹配 ⇒ 某枚新标记一旦含「开始启动(进程级计时起点)/ 服务已就绪 / 真实 UI 已加载(首屏可用)」,锚点会**先命中这枚新标记**、计时切片**抢错行**, 端到端首屏数字**静默变味**(不报错,只是算成别的意思)。 + + 6 枚的落点(Task 3):`_run_desktop_inner` 顶层 3 枚 + `_after_start` 的 try 体 3 枚。 """ markers = _new_stage_markers_from_desktop() - assert len(markers) == 6, f"_run_desktop_inner 顶层应有 6 枚启动标记,实际 {len(markers)} 枚:{markers}" + assert len(markers) == 6, ( + f"启动阶段标记应共 6 枚(_run_desktop_inner 顶层 3 枚 + _after_start 的 try 体 3 枚)," + f"实际 {len(markers)} 枚:{markers}" + ) for marker in markers: for anchor in (START, READY, SCREEN): assert anchor not in marker, ( diff --git a/tests/test_verify_windows_portable.py b/tests/test_verify_windows_portable.py index 38142444..33afc490 100644 --- a/tests/test_verify_windows_portable.py +++ b/tests/test_verify_windows_portable.py @@ -312,6 +312,32 @@ def test_lan_address_state_absent_silent(tmp_path: Path) -> None: assert vp.lan_address_state(tmp_path, "INFO: 服务正常\n") == vp._LAN_ABSENT_SILENT +def test_lan_address_state_empty_file_is_not_present(tmp_path: Path) -> None: + """**空文件不算 present**(Task 5 判据升级):`is_file()` 对空文件零判别力。 + + 旧判据只看"文件在" ⇒ 一个 0 字节的 `lan-address.txt` 也能让闸记 PASS,而用户双击看到的 + 是空内容(等于根本没有地址出口)。判据改为**内容匹配 `^http://:$`**: + 空内容不匹配 ⇒ 落到 absent 分支(此处无留痕 ⇒ absent-silent,编排层判红)。 + """ + (tmp_path / vp.LAN_ADDRESS_FILENAME).write_text("", encoding="utf-8") + assert vp.lan_address_state(tmp_path, "") == vp._LAN_ABSENT_SILENT + + +def test_lan_address_state_garbage_content_is_not_present(tmp_path: Path) -> None: + """乱内容(非 `http://:`)同样不算 present ⇒ 落 absent 分支(Task 5)。 + + 旧 `is_file()` 判据对"文件在、但内容不是地址"同样零判别力 —— 判据必须校验内容。 + """ + (tmp_path / vp.LAN_ADDRESS_FILENAME).write_text("这不是地址\n", encoding="utf-8") + assert vp.lan_address_state(tmp_path, "") == vp._LAN_ABSENT_SILENT + + +def test_lan_address_state_non_ipv4_host_is_not_present(tmp_path: Path) -> None: + """`http://<非 IPv4>:`(如主机名 / 回环拼错)不算 present:判据锚定 IPv4 形态。""" + (tmp_path / vp.LAN_ADDRESS_FILENAME).write_text("http://example.com:8000\n", encoding="utf-8") + assert vp.lan_address_state(tmp_path, "") == vp._LAN_ABSENT_SILENT + + def test_no_console_check_skips_off_windows( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: @@ -390,5 +416,8 @@ def test_main_appends_no_console_step_after_standard_launch( # | log_has_traceback: 恒真 | test_log_has_traceback_clean_when_absent | # | log_has_traceback: 判据放宽成 "Error" 子串(含大小写混合) | test_log_has_traceback_ignores_bare_error_words| # | lan_address_state: 恒 present(不读留痕) | test_lan_address_state_absent_but_traced/silent| +# | lan_address_state: present 判据退回 is_file()(不校验内容) | test_lan_address_state_empty_file_is_not_present| +# | | test_lan_address_state_garbage_content_is_not...| +# | | test_lan_address_state_non_ipv4_host_is_not... | # | _check_no_console_launch: 去掉 os.name 守卫(非 Windows 也真启)| test_no_console_check_skips_off_windows | # | main: 不接 no-console 步 / 接到普通真启之前 | test_main_appends_no_console_step_after... | diff --git a/tools/verify_windows_portable.py b/tools/verify_windows_portable.py index 110903e3..63b8df38 100644 --- a/tools/verify_windows_portable.py +++ b/tools/verify_windows_portable.py @@ -23,7 +23,8 @@ (**身份校验**,不是"端口能连上");再终止它,断言**进程消失 + 端口释放 + 无孤儿**。 (c) **无控制台语境再真启一次**(Task 5 新增,**默认开**):以 ``CREATE_NO_WINDOW`` 起 ``--server-only``(不为子进程分配控制台窗口),断言 ① health 就绪 ② 进程可干净终止 - ③ 数据目录生成 ``lan-address.txt``(探不到 LAN 允许不存在,但 launch.log 必须留痕) + ③ 数据目录生成 ``lan-address.txt``(**内容须匹配** ``^http://:$``;探不到 LAN + 允许不存在,但 launch.log 必须留痕) ④ **整段日志无 traceback**。边界说明见 ``_check_no_console_launch`` 与下方覆盖边界。 ⚠️ 覆盖边界(**必读:别误以为它覆盖了窗口形态**) @@ -33,12 +34,18 @@ **窗口形态(双击出不出窗口 / 进程退不退)不在本闸覆盖范围内** —— 那由本机人工双击 + ``scratch/`` 复现脚本覆盖。把这条写在这里,是为了不让后人误以为"CI 绿了 ⇒ 双击没问题"。 -(c) 步也有边界(**别以为它复现了 ``sys.stdout is None``**):CI 里子进程的 stdout/stderr -被**重定向进日志文件** ⇒ 子进程的 ``sys.stdout`` 并非 ``None``,"双击 ``--windowed`` 产物时 -``sys.stdout is None`` 导致裸 ``print`` 即崩"那一类,由 ``tests/test_desktop_shell.py`` 的 -``sys.stdout=None`` 注入单测钉死(那里才复现得了 None 语境)。(c) 步在 CI 钉的是:**无控制台 -窗口语境下起服 + 整段日志无 traceback**(含健康就绪**之后**才发生的崩溃 —— (b) 步只看就绪, -那类它抓不到)+ ``lan-address.txt`` 地址出口。两者互补,都不可省。 +(c) 步也有边界(**别以为它复现了 ``sys.stdout is None``,更别以为它瞄的是那类回归**): +CI 里子进程的 stdout/stderr 被**重定向进日志文件** ⇒ 子进程的 ``sys.stdout`` 并非 +``None``。事实澄清(2026-10-14 怀疑者复核):Py3 的 ``print()`` 在 ``sys.stdout is None`` +时是**静默 no-op**(builtin print 内部 ``if file is None: return``),不会崩;真会崩的是 +``None.write``(argparse 的 ``file.write`` 路径,Task 1 已修)。这道闸扫 traceback 抓不到 +那类回归,不是"不够严",是**判据从一开始就瞄错对象**:本步的子进程标准流被重定向(并非 +``None``)⇒ ``None.write`` 路径在 CI 语境下根本不会被触发,日志里自然没有它的 traceback。 +收口前的裸输出顺序契约,由 ``tests/test_desktop_shell.py`` 的 AST 结构守卫静态钉死 +(``ensure_streams`` 调用点之前不得出现无 ``file=`` 的 ``print`` / ``sys.stdout.write`` / +``sys.stderr.write``)。(c) 步在 CI 钉的是:**无控制台窗口语境下起服 + 整段日志无 +traceback**(含健康就绪**之后**才发生的崩溃 —— (b) 步只看就绪,那类它抓不到)+ +``lan-address.txt`` 地址出口。两者互补,都不可省。 为什么独立实现、不 import 打包脚本 ================================== @@ -90,6 +97,7 @@ import argparse import json import os +import re import shutil import signal import socket @@ -175,13 +183,18 @@ # 据此把「有据可查的不存在」(离线机器)与「静默缺失」区分开,后者才判红。 _LAN_ABSENT_LOG_MARKER = "未探测到局域网 IP" # lan-address.txt 三态(lan_address_state 返回): -# present 文件在 ⇒ 地址出口已生成 -# absent-traced 文件不在但 launch.log 有留痕 ⇒ 允许(离线 / 仅回环),不算缺失 -# absent-silent 文件不在且无留痕 ⇒ **静默缺失**,编排层据此判红 +# present 文件在**且内容匹配** `^http://:$` ⇒ 地址出口已生成 +# absent-traced 文件不在(或内容不匹配)但 launch.log 有留痕 ⇒ 允许(离线 / 仅回环),不算缺失 +# absent-silent 文件不在(或内容不匹配)且无留痕 ⇒ **静默缺失 / 坏内容**,编排层据此判红 _LAN_PRESENT = "present" _LAN_ABSENT_TRACED = "absent-traced" _LAN_ABSENT_SILENT = "absent-silent" +# lan-address.txt 的**内容**判据(Task 5 升级):旧的 `is_file()` 对"空文件 / 乱内容"零判别力 —— +# 一个 0 字节或内容损坏的文件照样能记 PASS,而用户双击看到的是空内容(等于没有地址出口)。 +# 合法内容形如 `http://:`(`desktop.write_lan_address` 每次写 `address + "\n"`)。 +_LAN_ADDRESS_PATTERN = re.compile(r"^http://(?:\d{1,3}\.){3}\d{1,3}:\d{1,5}$") + _REPO_ROOT = Path(__file__).resolve().parents[1] # Windows 专有符号:Linux 上也要能 import(单测 / mypy 在 ubuntu 跑),故 getattr 兜底。 @@ -582,23 +595,49 @@ def log_has_traceback(log_text: str) -> bool: 锚定 ``Traceback (most recent call last)`` 这一**解释器崩溃签名**,而非裸 ``Error`` / ``失败`` 字样:业务日志里的 error / 失败(如「连接重置」「写库超时」)是正常运维噪声, - 宽判据会把这道闸刷成恒红、迟早被绕开。只有解释器真的打印了 traceback 才算「崩过」 - (裸 ``print`` 到 ``sys.stdout is None`` 的那一族崩溃正落在这里)。 + 宽判据会把这道闸刷成恒红、迟早被绕开。只有解释器真的打印了 traceback 才算「崩过」。 + + 覆盖边界(2026-10-14 怀疑者复核后澄清,**别再把判据瞄错对象**):Py3 的 ``print()`` 在 + ``sys.stdout is None`` 时是**静默 no-op**(builtin print 内部 ``if file is None: + return``),不会崩;真会崩的是 ``None.write``(argparse 的 ``file.write`` 路径, + Task 1 已修)。而本闸的子进程 stdout/stderr 被**重定向进日志** ⇒ 标准流并非 ``None``, + ``None.write`` 路径在 CI 语境下根本不会被触发 ⇒ 那类回归的 traceback 不会出现在日志里, + 本判据**瞄不到它**(不是"不够严",是判据从一开始就瞄错了对象)。收口前的裸输出顺序契约 + 由 ``tests/test_desktop_shell.py`` 的 AST 结构守卫静态钉死:``ensure_streams`` 调用点 + 之前不得出现无 ``file=`` 的 ``print`` / ``sys.stdout.write`` / ``sys.stderr.write``。 """ return "Traceback (most recent call last)" in log_text +def _lan_address_content_ok(path: Path) -> bool: + """文件内容是否为合法 `http://:`(读失败 / 编码错一律当**_不_**合法)。 + + 判据锚定**内容**而非"文件存在":空文件 / 垃圾内容对旧 `is_file()` 判据零判别力。 + `desktop.write_lan_address` 写的是 `address + "\\n"` ⇒ 比对前先 `strip()`。 + """ + try: + text = path.read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + return False + return _LAN_ADDRESS_PATTERN.match(text) is not None + + def lan_address_state(data_dir: Path, log_text: str) -> str: """`lan-address.txt` 三态判定(Task 5 断言③判据),返回三态常量之一。 - - 文件在 ⇒ ``_LAN_PRESENT``:地址出口已生成。 - - 文件不在但 launch.log 有「未探测到局域网 IP」留痕 ⇒ ``_LAN_ABSENT_TRACED``: - 离线 / 仅回环机器允许没有地址文件,但**必须留痕**(「有据可查的不存在」)。 - - 文件不在且无留痕 ⇒ ``_LAN_ABSENT_SILENT``:**静默缺失**,编排层据此判红。 + - 文件在**且内容匹配** `^http://:$` ⇒ ``_LAN_PRESENT``:地址出口已生成。 + - 文件不在(或内容**不匹配**)但 launch.log 有「未探测到局域网 IP」留痕 ⇒ + ``_LAN_ABSENT_TRACED``:离线 / 仅回环机器允许没有地址文件,但**必须留痕** + (「有据可查的不存在」)。 + - 文件不在(或内容不匹配)且无留痕 ⇒ ``_LAN_ABSENT_SILENT``:**静默缺失 / 坏内容**, + 编排层据此判红。 - 三态必须可区分:把「静默缺失」和「有据可查的不存在」混为一谈,就等于放过真正的缺失。 + 判据是**内容**而非"文件存在"(Task 5 升级):空文件 / 乱内容对旧的 `is_file()` 零判别力, + 会把"看着有、其实是空的地址出口"记成 PASS。三态必须可区分:把「静默缺失」和 + 「有据可查的不存在」混为一谈,就等于放过真正的缺失。 """ - if (data_dir / LAN_ADDRESS_FILENAME).is_file(): + path = data_dir / LAN_ADDRESS_FILENAME + if path.is_file() and _lan_address_content_ok(path): return _LAN_PRESENT if _LAN_ABSENT_LOG_MARKER in log_text: return _LAN_ABSENT_TRACED @@ -755,10 +794,13 @@ def _check_lan_address(report: Report, data_dir: Path) -> None: 留痕读产物自写的 `launch.log`(`_append_log` 只落该文件,不进子进程 stdout 重定向的 server.log),故此处读 `data_dir / LAUNCH_LOG_FILENAME`。 + + present 的判据是**内容匹配** `^http://:$`(Task 5 升级),不再只看"文件存在": + 空文件 / 乱内容 ⇒ 落 absent 分支(无留痕即判红)。 """ state = lan_address_state(data_dir, _read_log(data_dir / LAUNCH_LOG_FILENAME)) if state == _LAN_PRESENT: - report.ok("无控制台:数据目录生成 lan-address.txt", LAN_ADDRESS_FILENAME) + report.ok("无控制台:数据目录生成 lan-address.txt", f"{LAN_ADDRESS_FILENAME} 内容匹配 http://:") elif state == _LAN_ABSENT_TRACED: report.ok( "无控制台:局域网地址出口有据可查", @@ -767,7 +809,8 @@ def _check_lan_address(report: Report, data_dir: Path) -> None: else: report.fail( "无控制台:局域网地址出口", - f"{LAN_ADDRESS_FILENAME} 既不存在、launch.log 也无「未探测到局域网 IP」留痕 —— 静默缺失", + f"{LAN_ADDRESS_FILENAME} 缺失或内容非 http://:," + "且 launch.log 无「未探测到局域网 IP」留痕 —— 静默缺失 / 坏内容", )