Files
90CNServer/docs/启动器窗口风暴排查.md
T
hanfuit 0897761c47 docs:补充 18112 进程判读与 serve.py 需重启的规则
起因:同步 gmweb 时看到 18112 被占,先判断成「启动器在跑」、又改口成「残留 pythonw
serve.py」,两次都错 —— 实际是多调用模式下的 gmweb 子进程,进程名就叫 DNF90启动器.exe。

- 排查文档新增小节「18112 上跑的可能是启动器本身」:给出命令行三态判别表
  (带 serve.py 参数 = gmweb 服务 / 只有 exe 路径且占 19527 = 主进程 /
  父 pid 不存在 = 遗留服务),并附 Get-CimInstance 取证命令
- 明确推论:该进程跑的是启动那一刻的 serve.py;静态文件读盘即生效,
  但路由不会,症状是「页面新版、接口旧版,按钮点了没反应」
- AGENTS.md 8.3 同步补上「改 serve.py 后必须让 18112 进程重启」的部署动作
2026-11-05 19:38:32 +08:00

158 lines
9.0 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),换目录或给写权限 |
### 18112 上跑的可能是「启动器」本身,别被进程名骗了
多调用模式的代价:gmweb 子进程的**进程名就是 `DNF90启动器.exe`**,真实身份只在命令行里。
```
DNF90启动器.exe Z:\90server\gmweb\serve.py --port 18112
```
实际踩过:看到 18112 被占、进程名是启动器,先判断成「启动器在跑」,又找不到窗口可点
(父进程早退了、只剩这个子进程),于是改口成「残留的 `pythonw serve.py`」——两次都错了。
**名字不可靠,看命令行**:
```powershell
Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -eq <pid> } |
Select-Object Name,ProcessId,ParentProcessId,CommandLine | Format-List
```
| 命令行特征 | 身份 |
|---|---|
| 带 `...\gmweb\serve.py --port 18112` | gmweb 服务进程(正常形态,父进程通常就是启动器) |
| 只有 exe 路径,且自己占着 `19527` | 启动器主进程 |
| 上面那条的 `ParentProcessId` 已不存在 | 父启动器退了,留下的是**遗留 gmweb 服务**(能继续用,但跑的是旧代码) |
> **关键推论:它跑的是「启动那一刻」的 `serve.py`。**
> 静态文件(`index.html` / `loadout.js` / `.css`)每次请求都读盘,替换后立即生效;
> 但 `serve.py` 的路由是进程启动时注册的,**替换文件不会生效**。
> 症状是「页面是新版(按钮都在),接口却是旧版(点了没反应 / 404)」。
> 所以同步过 `gmweb/` 之后,必须让占着 18112 的进程重启一次(关启动器 → 确认端口空了 → 重新打开)。
## 七、相关坑(顺带记录)
- **exe 名字带中文**没问题,日志和 WebView2 目录都能正常处理;但 exe 放在**只读目录**会让日志与 WebView2 同时失效,表现就是「能开窗口但空白、也没有日志」。
- `_kill_webview2_children()` 用的是 `wmic`,Windows 11 24H2 起 `wmic` 默认已被移除、部分安全策略也会拦它;这段现在是 `try/except` 静默失败,不影响主流程,但别再依赖它清理残留进程。
- 启动器单文件每次启动都要解包约 16MB;因此「重复启动」的代价不只是多一个窗口,还会多解包一次。单实例互斥顺带把这件事省了。