Repository navigation
fix(desktop): 无控制台后的反馈缺口(6 任务 + 门禁缺口修复) - #131
Merged
Merged
Conversation
/dfs-team 三席审计(2026-10-14,靶子 0f7b80f..b05a367)+ 怀疑者复核后的 7 条发现。 怀疑者推翻两条,直接影响修法: ① CPython 的 print() 对 sys.stdout is None 是**静默 no-op 不会崩** ⇒ verify_windows_portable.py :38/:586 两处"裸 print 即崩"是错误前提;真会崩的是 None.write(argparse 路径) ⇒ no-console 闸扫 traceback 抓不到那类回归不是"不严",是判据瞄错对象 ⇒ 改 AST 结构守卫 ② 桌面路径的 lan-address.txt 安全(_check_port_or_fail 在 write_lan_address 之前); 只有 --server-only 路径(:999 写 → start.py:136 才查端口)真会留错地址 P1 ×3:入口先收口后解析(唯一零线索崩溃路径)/ 失败出口统一(_after_start 静默 vs 外层原样 弹栈)/ splash 覆盖冷启动前段 4~6.5s(重阶段搬进 _after_start,先写死锁回归) P2 ×3:AST 守卫 + 修错误注释 / 地址写入时机 + 闸校验内容 / 测试卫生(真实目录污染 + 恒绿) 约束:零新依赖;启动顺序改动先写死锁回归(P0 先例);失败必须可见;门禁只扩不减; 本计划与执行同分支 fix/no-console-feedback-gaps,最后一个 PR(基于 #130 的 docs 分支, 避免 work.log 撞车)。Fog of War 3 条 / Out of Scope 4 条 / 验收 5 项均已显式登记。
--windowed 产物里 sys.stdout/stderr 为 None,而 argparse 的 --help/错参输出走 file.write ⇒ 解析早于收口时,用户得到的是"零线索":退出码非零、无窗口、无弹窗、 launch.log 零行。本机 Python 3.11.8 实测机制与预期不同(argparse._print_message 自带 try/except (AttributeError, OSError): pass)⇒ 不是崩溃而是文本被静默吞掉, 用户可见缺陷一致(零线索),测试契约对两种 CPython 行为都成立。 - desktop.py __main__:ensure_streams(resolve_log_path(os.environ), "--debug-console" in sys.argv[1:]) 提到 _parse_args 之前 - start.py __main__:_ensure_entry_streams(...) 提到 _parse_args 之前 (复用既有函数,整段 try/except ⇒ Android APK 无 desktop.py 不阻断) - tests/test_desktop_shell.py:+4 条 None 流注入回归(desktop/start 各 --help 与 --bogus),用 runpy.run_module(run_name="__main__") 执行**真入口**(修复点在 __main__ 序列,直接调 _parse_args 会绕过它、修复后也永远红);断言不抛、退出码 保持 argparse 语义(help=0/错参=2)、文本落 launch.log;日志隔离到 tmp_path 门禁:test_desktop_shell 78 passed + 2 skipped(RED 4 failed:文本蒸发、日志零行); start_lifecycle + writer_mobile + ci_hardening 82 passed;ruff 0;mypy win32 strict 4 files 零错误。真机冒烟:有终端时 --help exit 0 / --bogus exit 2,ensure_streams 不接管。 独立验收(CRV):PASS;1 黄牌(__main__ 先开句柄后轮转 ⇒ Windows 上 5MB 轮转会 PermissionError 被静默跳过)已折入 Task 3 的启动序列整批修复。
问题(两条不对称的失败出口,都在无控制台后直接变成"用户零线索"或"用户看栈"):
- `_after_start` 异常分支只 `_append_log(exc!r)` 后销毁窗口 ⇒ 无弹窗、无堆栈;
而同函数的就绪超时分支反而有弹窗。
- 外层 except 把 `traceback.format_exc()` 原样塞进弹窗 ⇒ 用户面对一屏 Python 栈。
修法:
- `_after_start` 异常分支改走 `write_crash("DeLector 启动失败", traceback.format_exc(),
log_path)`(全栈只落盘 + 弹窗),保留 result[0]=1 与 _abort_windows 收尾;
- 外层改为先 `_append_log(traceback)`(全栈落盘一次)再 `write_crash(..., f"启动失败,
详情见 {log_path}", ...)`(弹窗中文一句话 + 全路径);
- 不新增第三种弹窗封装(全仓仍只有 _message_box 与 write_crash 两个入口)。
测试:+2 条(切 UI 异常必须弹窗且日志含 Traceback;外层弹窗不得含 Traceback 但日志含、
且给出日志路径),RED 原文分别为"切 UI 阶段异常必须弹窗 assert []"与"弹窗文案不得含
traceback:'Traceback (most recent call last)...RuntimeError: boom-outer'"。
门禁:test_desktop_shell 80 passed + 2 skipped(78+2 → +2);test_server + writer_mobile
+ ci_hardening 369 passed;ruff 0;mypy win32 strict 4 files 零错误。
独立验收(CRV):PASS;两条红旗均已排除(_message_box 失败不会二抛;traceback 恰好落盘
一次无重复)。2 条非阻塞黄牌(result[0]=1 应在 write_crash 之前;日志文案重叠)折入
Task 3 的启动序列整批修复。
问题:首次双击后有约 4s(源码)/6.5s(产物冷启)零可见反馈——端口校验、WebView2 检测、数据落点、`import delector.server`、build_server、serve_in_thread 全在 `_show_window`(splash 现形)之前跑完,用户以为没点中会重复启动。 - 把 `from delector.server import app` / `build_server` / `serve_in_thread` 与阶段 标记 ④⑤⑥ 搬进 `_after_start`(webview.start() 调度的事件循环内、splash 已可见 之后);端口校验 / WebView2 检测 / bootstrap / write_lan_address 留循环外(快且 有弹窗) - 新增 `server_handoff` 交接容器:_run_desktop_inner 建 dict 经 _show_window 透传, _after_start 在 await_ready 之前写入 server/thread,finally 读出并 coordinated_shutdown (带 None 守卫:建服前失败时不会把 None 冲进 shutdown) - P0 死锁约束保持:搬进去的零窗口 API;webview.start() 之前仍无任何窗口操作 (结构 + 运行时双重回归锁覆盖"建服在循环内"这一新形状) - 黄牌 A:__main__ 里 rotate_log_if_needed 提到 ensure_streams 之前(Task 1 让 __main__ 先开 launch.log 句柄,Windows 上 5MB 轮转的 os.replace 会 PermissionError 被静默跳过) - 黄牌 B:_after_start 异常分支 result[0]=1 提到 write_crash 之前(避免 write_crash 意外抛异常时跳过退出码与 _abort_windows 导致 splash 永久挂起) - 黄牌 Y1/Y2/Y5:删 _show_window 死参 thread;order 观测者补上(建服/起服下标必须 晚于 webview.start());__main__ 合并重复的 resolve_log_path 调用 回炉(CRV 首轮 REVISE,全是"漏跑全量"作废的契约): - tests/test_desktop_port_guard.py:三条重写——经假 webview 驱动 _show_window→_after_start 使 build_server 陷阱可达;端口校验改跨函数顺序断言;**恢复"删校验则红"的变异红线** (此前 test_run_desktop_inner_does_not_build_server_when_port_is_taken 是 vacuous 通过) - tests/test_first_screen_from_log.py:阶段标记改为 3 枚取 _run_desktop_inner 顶层 + 3 枚取 _after_start(结构切口:首个 _set_splash_stage 之前),同源不手抄 - 新增 3 条锁:server_handoff 收尸 / __main__ 轮转先于收口 / result[0] 先于 write_crash 门禁(本机实测):**全量 1824 passed + 2 skipped**(修前 4 failed / 1817 passed / 2 skipped; 1823 collected → 1826,净 +3 新增 0 删除);ruff 0;mypy --platform win32 --strict 4 files 零错误。变异自证 8 条(M1 建服搬回 start 前 / M2、M8 交接写入与收尸 / M3、M4 两张黄牌回退 / M5 拆回两次解析 / M6 删端口校验——占用分支 4 条全红且空闲分支仍绿 / M7 删循环内标记)全部 "红→复原绿"。 独立验收(CRV):首轮 REVISE → 回炉 → 复核 PASS。
问题(/dfs-team 审计 + 怀疑者复核后的事实修正):上批的动机叙事之一是"`--windowed`
下裸 `print()` 会崩",**这个前提是错的** —— CPython 的 `print()` 遇到 `sys.stdout is
None` 是**静默 no-op**(builtin print 内部 `if file is None: return`),不抛异常、不产
traceback。真会崩的是 `None.write`(即 argparse 的 `file.write` 路径,Task 1 已修)。
⇒ 真启闸的 no-console 模式扫 traceback **抓不到**这类回归,不是"不够严",是**判据从
一开始就瞄错对象**(且 CI 里子进程 stdout 被重定向进 server.log,标准流并非 None)。
- tests/test_desktop_shell.py:新增 AST 结构守卫(2 条参数化,覆盖 desktop.py/start.py)
· 判据:每个扫描单位(模块顶层 / 各函数体 / `__main__` 块)内,**首个收口调用**
之前不得出现无 `file=` 的 `print(...)`,也不得出现 `sys.stdout.write` /
`sys.stderr.write`
· 收口点集合 = {ensure_streams, _ensure_entry_streams, _redirect_std_streams}
—— 后两者分别是 start.py 入口包装与 run_desktop 转调;不认它们则 start.py 的
main()/__main__ 完全不受管辖(与"守卫覆盖两个入口"的规格目标矛盾)
· 嵌套 def/class/lambda 不深入(由各自单位管辖,防误报)
- tools/verify_windows_portable.py:38 与 :586 两处注释改写为事实:Py3 print 对 None
stdout 是静默 no-op;真会崩的是 None.write;本闸扫不到该类回归的原因(CI 语境标准流
非 None);顺序契约改由上述 AST 守卫钉死。**不改**闸的能力描述为"没用"
变异自证(先证"没守卫时抓不到",再证"守卫能红"):
① 无守卫时在两入口收口前插裸 print ⇒ 既有 124 条全绿(证明此前零覆盖)
② 守卫落地后插 3 处 ⇒ 2 条参数化转红,原文如
「desktop.py:1056(<__main__>)无 file= 关键字参数的 print(...)(收口前写到 None
流 = 静默蒸发)」
③ 编排者独立补做单点:在 start.py 的 `main()` 收口前(:133)插裸 print ⇒ 转红
「start.py:133(main)无 file= 关键字参数的 print(...)」;仓库外副本还原 +
md5 逐字节复核(0e3ad84b…)⇒ 转绿
门禁(本机实测):test_ci_hardening + test_desktop_shell 126 passed + 2 skipped;
test_verify_windows_portable 43 passed;ruff 0;mypy --strict tools 24 files 与
--platform win32 --strict 4 files 零错误;CRLF 纯(lone_LF=0)。
独立验收(CRV):PASS;2 张黄牌——① 已由编排者补做单点变异核销(守卫确能抓住
start.py main() 收口前的裸 print);② 已知逃逸边界(带 file= 的 print、getattr 混淆
形态、无收口调用的模块级输出)属计划字面判据之外且当前零实例,留档不返工。
问题(两条,都属"用户拿到错信息"):
① `dispatch` 的 server-only 分支在委托 `start.main()` **之前**就写了 `lan-address.txt`
(原 :999),而端口占用检查在 start.main 内部(start.py:136-140)⇒ 端口被占时地址
文件已落盘且无回滚,用户拿到"指向别的服务"的地址。(桌面路径不受影响:
`_check_port_or_fail` 在 write 之前,怀疑者已证。)
② 真启闸的 `lan_address_state` 只判 `is_file()` ⇒ 空文件 / 垃圾内容一律判 present。
- desktop.py:`run_server_only` 改为持 `_on_serving` 回调(建服成功后写地址、端口号取
start.main 的真相源)+ `finally` 里"本轮真写过才删";新增 `_remove_lan_address_file`;
`dispatch` 删除提前的 `write_lan_address(...)`。
⇒ 不变量:**文件存在 ⟺ 本进程正持有该端口**。桌面路径(:938)一字未动。
- start.py:`main(..., on_serving: Optional[Callable[[int], None]] = None)` —— 新增**可选**
参数,`build_server` 成功后回调;既有调用点(test_start_lifecycle 的 `start.main()`、
Android APK 的 `getModule("start").callAttr("main")` 无参调用)语义不变。
- tools/verify_windows_portable.py:`lan_address_state` 的 present 判据升级为内容匹配
`^http://<IPv4>:<port>$`(空/垃圾/非 IPv4 主机名 ⇒ 落 absent 分支),文案与 docstring 同步。
TDD:RED-A(端口被占仍留文件 → assert not True where True=exists();捕获 stdout
『[错误] 端口 8000 已被其他程序占用…』)+ RED-B(空文件/垃圾/非 IPv4 均被判 present →
assert 'present' == 'absent-silent')→ GREEN。
**测试净增 6 条,同时删除 1 条旧测试**(如实记账):删除的
`test_dispatch_server_only_writes_lan_address_file` 把 `start.main` 桩成 no-op,钉的正是
本次修掉的旧行为(写地址早于建服),其前提已不成立;替代它的 3 条
(after_serving / removes_on_exit / leaves_no_when_port_busy)覆盖更严。
⇒ test_desktop_shell 87 → 89 collected(净 +2);test_verify_windows_portable 43 → 46(+3)。
变异自证:把闸判据临时退回 `is_file()` ⇒ 3 failed / 43 passed;复原 ⇒ 46 passed。
门禁(本机实测):全量 **1831 passed + 2 skipped**;test_desktop_shell 87 passed + 2 skipped;
test_verify_windows_portable 46 passed;test_server + test_start_lifecycle + test_writer_mobile
339 passed;ruff 0;mypy win32 strict 4 files / --strict tools 24 files 零错误;五文件 CRLF 纯。
独立验收(CRV):PASS;5 张非阻塞黄牌(外部占用的陈旧文件无提示 / TOCTOU 窗口 / 正则对
单段数字略宽松 / 证据计数出入已由编排者核销 / 真机 server-only 退出即删文件属规格要求)。
缺陷 1(计划说 3 条、核实为 **4 条**):4 个 dispatch 用例既无 tmp_path 也无可 DELECTOR_DATA_DIR ⇒ 会把 `lan-address.txt` 写进开发者**真实数据目录** (%LOCALAPPDATA%\DeLector / ~/.local/share/DeLector),并可能滚动真 launch.log; Task 5 之前还可能**删掉**真 lan-address.txt。修法:加**文件级 autouse fixture** (`_isolate_data_dir`)全局钉 `DELECTOR_DATA_DIR=tmp_path`,从根上防后续新增用例再漏 (本文件无用例依赖"未设置"语义,88 passed 不变 ⇒ 无需逐条退化)。 另加**元测试** `test_all_dispatch_tests_isolate_the_data_dir`(ast 扫描所有含 `dispatch(` 的 test 函数,断言隔离可见)——RED 实测精确列出那 4 条漏网用例名。 缺陷 2:`test_ms_since_process_create_returns_none_off_windows` **恒绿**——docstring 声称"非 Windows 不许碰 ctypes.windll",但删掉平台守卫后 `ctypes.windll` 在 Linux 抛的 异常被 `except Exception` 吞掉仍返回 None ⇒ 照绿。修法:改用本文件现成的 `_RecordingCtypes`(monkeypatch sys.modules["ctypes"] + sys.platform="linux"),断言 `touched == []`。 变异自证:临时删 `desktop.py` 的平台守卫 ⇒ 红,原文 「非 Windows 不得触碰 Win32 API(ctypes.windll 链),实际访问:['__path__','wintypes', 'windll','kernel32','HANDLE','POINTER','FILETIME',…]」;仓库外副本复原 + md5 复核 (1b6dfed4cfc37d47979c033370767554 前后一致)⇒ 回绿。 门禁:test_desktop_shell 88 passed + 2 skipped(87+2 → +1 元测试); test_verify_windows_portable + test_server 344 passed;ruff 0;mypy win32 strict 4 files 零错误。⚠️ 本次顺带发现一处**既往门禁缺口**(非本任务引入):`mypy --follow-imports=skip tests` 报 2 处错误,位于 `tests/test_desktop_port_guard.py`(d1656bd / Task 3 回炉那批重写的 文件)—— 当时验收只跑了 win32 4-entry 档、漏跑 tests 档 ⇒ 潜伏至今。已单独派工修复。
背景:Task 3 回炉(d1656bd)重写了 test_desktop_port_guard.py,但那次验收只跑了 `mypy --platform win32 --strict`(4 入口文件档),**漏跑 `mypy --follow-imports=skip tests`** ⇒ 两处 attr-defined 潜伏至今,直到 Task 6 的验收命令把它暴露出来。教训与仓内既有纪律 同族:**改了哪个文件,就要把它涉及的每一档门禁都跑全**(mypy 四档参数各异)。 - :89 `fake_server.app = object()` → `setattr(fake_server, "app", object())` (运行时等价,对 ModuleType 合法;mypy 认 setattr 而不认动态属性赋值) - :273 `return [call.func.id for call in calls]` → 显式循环 + `assert isinstance(call.func, ast.Name)` 后取 `.id`(让收窄对 mypy 可见;过滤条件/排序键/返回内容完全一致) - 未用 `# type: ignore`(遵守禁新增 ignore 的仓规) 门禁:`mypy --follow-imports=skip tests` **121 files 零错误**(修前 2 errors / exit 1); test_desktop_port_guard 6 passed(用例数与断言未变:修前后 `def test_` 均为 6); test_desktop_shell 88 passed + 2 skipped;ruff 0;CRLF 保持(工作区 crlf)。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
承接 10-13 那批「去控制台 + 桌面化观感」的
/dfs-team三席审计(怀疑者复核后 7 条发现),/dfs-exec三挡执行完毕。交付(8 提交)
2eae2ba--windowed下 argparse 的--help/错参输出走file.write,解析早于收口时用户零线索53221f2_after_start异常原本只写repr不弹窗,外层却原样弹完整 tracebackd1656bdwebview.start()事件循环 ⇒ splash 覆盖冷启动前段(消灭 4~6.5s 零反馈)+ 两张黄牌回炉e94568343061ea--server-only地址文件改「持有端口后写、退出即删」+ 闸判据升级为内容匹配6c533452e9f262d1656bd引入的 tests 档 mypy 两处错误(当时验收漏跑该档)920942eWORK_END(另含
fbeca12:把「实现任务 MUST 走分支 + PR」写进 ops.md/PROTOCOL.md,原 PR #130 的内容 —— 本 PR 合并后 #130 可关闭。)本批最有价值的发现:怀疑者推翻了我们自己的前提
上批的动机叙事之一是「
--windowed下裸print()会崩」——这个前提是错的:CPython 的print()对sys.stdout is None是静默 no-op;真会崩的是None.write(argparse 路径,即 P1①)。⇒ 真启闸的 no-console 模式扫 traceback 抓不到那类回归,不是「不够严」,而是判据瞄错对象(且 CI 里子进程 stdout 被重定向,标准流并非 None)。修法不是加严闸,而是加 AST 守卫 + 修正注释。两处「门禁跑不全」造成的潜伏缺陷
mypy --platform win32 --strict、漏跑 tests 档 ⇒ 两处错误潜伏到 Task 6 才暴露(已修)。test_run_desktop_inner_does_not_build_server_when_port_is_taken变成 vacuous 通过(删掉端口校验也不红 ⇒ P0-1 端口闸的行为红线被静默架空)。CRV 首轮 REVISE 抓出 → 回炉恢复判别力 → 复核 PASS。教训:搬家类改动必须跑全量;改了测试文件必须把 mypy 四档都跑全。
门禁(本机实测,批次终值)
pytest1832 passed + 2 skipped;ruff0;mypy 三档 87 / 121 / 4 files 零错误;7 个触碰文件行尾纯 CRLF。CRV 七轮:Task 3 首轮 REVISE → 回炉 → PASS;其余全 PASS。合并后仍需人工
真机双击验收(无黑框 / 品牌图标 / splash 前段有反馈 / 失败有人话弹窗)——只能人工做。