# 启动器「窗口越开越多、里面一片空白」排查记录 > 版本: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 [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 } | 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;因此「重复启动」的代价不只是多一个窗口,还会多解包一次。单实例互斥顺带把这件事省了。