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

7.5 KiB
Raw Blame History

启动器「窗口越开越多、里面一片空白」排查记录

版本:v2026.11.05 之后修复(启动器 15,945,162 字节) 现象只在部分电脑出现,开发机复现不了;本文记录根因、复现手法、修复点与现场取证方法。

想看「为什么会写出这种代码、为什么长期没被发现」的成因复盘,见 踩坑记录.md 的 P-001。

一、现象

  • 双击启动器后,屏幕上每隔一两秒冒出一个新的启动器窗口,都是黑底、没有界面内容
  • 内存、CPU 一路涨,任务管理器里 DNF90启动器.exe 一片
  • 游戏内嵌的 GM 页(127.0.0.1:18112)打不开 / 空白
  • 开机第一次启动更容易出现;已经有一种「别的东西占着 18112」的电脑反而看不到

二、根因:启动器把自己当成了 python.exe

launcher/server.py 的 start_gmweb() 要拉起 GM 页服务,写法是:

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() 的第一句是:

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 空闲」,就一定会套娃;反之会被掩盖。这就是「个别电脑」的来源。

四、复现与验证手法

# 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;因此「重复启动」的代价不只是多一个窗口,还会多解包一次。单实例互斥顺带把这件事省了。