Files
90CNServer/docs/启动器窗口风暴排查.md
T
hanfuit adcc5b0f3a docs:新增踩坑记录,13 条坑的成因复盘与判断规则
和「排查记录」不同,这份文档不写怎么修,只回答「为什么会踩、为什么没早发现」,
给的是可迁移的判断,而不是单次操作步骤。

- P-001 启动器套娃开窗:三层成因(打包改变 sys.executable 语义 / Popen 只保证
  进程创建不保证脚本执行 / 端口顺延把必然失败伪装成正常启动)、四个掩盖因素、
  四个不成立的隐含假设,以及「拉起子进程要校验副作用而非返回值」等五条教训
- P-002~P-013:DLL 指纹绑定、CRC32 种子、CRC 槽位扫错节、CSS 特异性、IE11、
  旧进程占端口、WebView2 缓存、console=False、图片占位时序、仓库与部署副本、
  replace 静默失败、补丁包缺文件
- 结尾提炼八条通用规则与发版检查清单
- 文档索引:根 README、docs/README 各补一行;排查文档顶部加成因复盘的交叉链接
2026-11-05 17:27:47 +08:00

129 lines
7.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 启动器「窗口越开越多、里面一片空白」排查记录
> 版本:v2026.11.05 之后修复(启动器 15,945,162 字节)
> 现象只在部分电脑出现,开发机复现不了;本文记录根因、复现手法、修复点与现场取证方法。
>
> 想看「为什么会写出这种代码、为什么长期没被发现」的成因复盘,见 [踩坑记录.md](踩坑记录.md) 的 **P-001**。
## 一、现象
- 双击启动器后,屏幕上每隔一两秒冒出一个新的启动器窗口,都是黑底、没有界面内容
- 内存、CPU 一路涨,任务管理器里 `DNF90启动器.exe` 一片
- 游戏内嵌的 GM 页(`127.0.0.1:18112`)打不开 / 空白
- 开机第一次启动更容易出现;已经有一种「别的东西占着 18112」的电脑反而看不到
## 二、根因:启动器把自己当成了 python.exe
`launcher/server.py` 的 `start_gmweb()` 要拉起 GM 页服务,写法是:
```python
subprocess.Popen([sys.executable, script, "--port", str(GM_WEB_PORT)])
# ^^^^^^^^^^^^^^ 源码运行时是 python.exe,打包后是启动器自己
```
源码运行时 `sys.executable` 是 `python.exe`,`python.exe serve.py --port 18112` 正常。
用 PyInstaller 打成 **单文件** 后,`sys.executable` 变成 `DNF90启动器.exe`,于是实际执行的是:
```
DNF90启动器.exe Z:\90server\gmweb\serve.py --port 18112
```
单文件 bootloader 不认这种「拿参数当脚本」的用法,它只会**再跑一遍内嵌的 server.py**,而 server.py 又没有 `sys.argv` 分支处理,于是:
1. 新进程重新解包 16MB 的单文件包 → 绑定下一个空闲端口(19528)
2. 它也会调用 `start_gmweb()`,发现 18112 还是空的(因为 serve.py 从来没被真正执行过)
3. 于是**又拉起一个自己**
4. 每一代在拉完下一代的瞬间创建自己的窗口
链条是这样的(每代约 1.5 秒,端口从 19527 顺延到 19546):
```
exe(19527) ──spawn──> exe(19528) ──spawn──> exe(19529) ──> … 直到端口用尽
│ │ │
开窗口 开窗口 开窗口 ← 全是没有内容的新窗口
```
端口到 19546 用完后 `ThreadingHTTPServer` 绑定失败,`console=False` + 窗口程序会弹一个 traceback 对话框,所以有些机器上还会看到报错弹窗。
## 三、为什么开发机复现不了
`start_gmweb()` 的第一句是:
```python
if port_open(GM_WEB_PORT):
print("GM 页服务已在运行")
return
```
开发机上很早就有一个 `pythonw.exe serve.py`(或 `gmweb\start_gmweb.bat` 起的)占着 18112,所以这段直接 return,永远走不到那句错误的 spawn。实测把那个进程结束后,**6 秒内就堆出 3 个实例、端口涨到 19531,而 18112 始终没有监听**。
结论:只要一台机器满足「`gmweb\serve.py` 存在」+「18112 空闲」,就一定会套娃;反之会被掩盖。这就是「个别电脑」的来源。
## 四、复现与验证手法
```bash
# 1. 先确认 18112 是空的(否则会被 return 掩盖)
netstat -ano | findstr 127.0.0.1:18112
# 2. 直接跑打包后的 exe,观察端口是否一路顺延
DNF90启动器.exe &
sleep 6; netstat -ano | findstr 127.0.0.1:195
# 3. 反向验证「exe 会不会执行传进去的 py」
printf 'open(r"C:\\temp\\hit.txt","w").write("ok")' > probe.py
DNF90启动器.exe probe.py # 旧版:hit.txt 不生成,却开出一个窗口
```
修复后第 3 步会真的执行 `probe.py`(生成 `hit.txt`)并且**不开窗口**。
## 五、修复内容
| 改动 | 说明 |
|------|------|
| **多调用模式** `run_script_instead()` | `exe <xxx.py> [args]` 时用 `runpy.run_path` 直接执行该脚本,不开窗口、不提权、不占互斥量。打包后 `sys.executable` 拉起 `serve.py` 从此是合法的 |
| **单实例互斥** `acquire_single_instance()` | 命名互斥量 `Local\DNF90Launcher-5E9C1B7A`;重复启动时把已有窗口 `ShowWindow(SW_RESTORE)` + `SetForegroundWindow` 后退出,不再开第二个窗口 |
| **就绪校验 + 进程内兜底** | spawn 后最多等 6 秒确认 18112 真的在监听;没监听就改用 `importlib` 在本进程线程里跑 `serve.py` 的 `main()`,**任何情况下都不会再拉起一个新窗口** |
| **提权带参** `self_elevate()` | 原来 `ShellExecuteW(..., None, ...)` 提权时把参数丢了,重启出来的进程是「干干净净」的启动器;现在用 `list2cmdline(sys.argv[1:])` 原样带上,并且 gmweb 子进程(`DNF90_GMWEB_CHILD=1`)不再提权 |
| **WebView2 数据目录** | 显式指定 `%LOCALAPPDATA%\DNF90Launcher\WebView2` 并 `private_mode=False`。默认位置放在只读目录 / 网络盘 / 映射盘时建不起来,窗口能出但一片空白,且不报错 |
| **运行日志** `启动器日志.txt` | exe 同目录,超过 512KB 自动转 `.old.txt`;`console=False` 下 print 和未捕获异常全部落盘,现场才有据可查 |
### 关于「没内容」的两层含义
1. 套娃出来的窗口是**每代新进程**,它们连的是各自顺延的端口,页面还在加载就被下一代挤爆,观感就是黑框;
2. 真正属于「内容出不来」的是 WebView2 数据目录的问题(只读目录/网络盘),已按上表固定到 `LOCALAPPDATA`。
## 六、现场取证
让用户把 **exe 同目录的 `启动器日志.txt`** 发回来即可,正常启动长这样:
```
[2026-11-05 17:20:38] ===== 启动 pid=24412
[2026-11-05 17:20:38] exe=Z:\90server\DNF90启动器.exe
[2026-11-05 17:20:38] argv=['Z:\\90server\\DNF90启动器.exe']
[2026-11-05 17:20:38] SERVER_ROOT=Z:\90server
[2026-11-05 17:20:38] 启动器端口:19527
[2026-11-05 17:20:38] gmweb:已拉起子进程 pid=20260
[2026-11-05 17:20:39] ===== 启动 pid=20260
[2026-11-05 17:20:39] argv=['Z:\\90server\\DNF90启动器.exe', 'Z:\\90server\\gmweb\\serve.py', '--port', '18112']
[2026-11-05 17:20:39] 多调用模式:执行 Z:\90server\gmweb\serve.py ['--port', '18112']
[2026-11-05 17:20:39] gmweb:http://127.0.0.1:18112/ 就绪
[2026-11-05 17:20:41] WebView2 数据目录:C:\Users\...\AppData\Local\DNF90Launcher\WebView2
[2026-11-05 17:20:41] WebView2 运行时:135.0.3179.73
```
判读要点:
| 日志特征 | 结论 |
|----------|------|
| 出现多条 `===== 启动` 且 pid 各不相同,但**没有**「多调用模式」 | 又在套娃(旧版 exe,或 `serve.py` 参数被吞) |
| `已有启动器在运行,唤起原窗口后退出` | 单实例生效,属正常 |
| `gmweb:子进程未监听,改为进程内启动` | 子进程起不来,已自动兜底;看上一行 `子进程启动失败` 的原因 |
| `警告:注册表里没查到 WebView2 运行时` | 机器缺 Edge WebView2 Runtime,装一下即可(窗口会是空白) |
| 完全没有日志文件 | exe 所在目录不可写(如 Program Files),换目录或给写权限 |
## 七、相关坑(顺带记录)
- **exe 名字带中文**没问题,日志和 WebView2 目录都能正常处理;但 exe 放在**只读目录**会让日志与 WebView2 同时失效,表现就是「能开窗口但空白、也没有日志」。
- `_kill_webview2_children()` 用的是 `wmic`,Windows 11 24H2 起 `wmic` 默认已被移除、部分安全策略也会拦它;这段现在是 `try/except` 静默失败,不影响主流程,但别再依赖它清理残留进程。
- 启动器单文件每次启动都要解包约 16MB;因此「重复启动」的代价不只是多一个窗口,还会多解包一次。单实例互斥顺带把这件事省了。