和「排查记录」不同,这份文档不写怎么修,只回答「为什么会踩、为什么没早发现」, 给的是可迁移的判断,而不是单次操作步骤。 - P-001 启动器套娃开窗:三层成因(打包改变 sys.executable 语义 / Popen 只保证 进程创建不保证脚本执行 / 端口顺延把必然失败伪装成正常启动)、四个掩盖因素、 四个不成立的隐含假设,以及「拉起子进程要校验副作用而非返回值」等五条教训 - P-002~P-013:DLL 指纹绑定、CRC32 种子、CRC 槽位扫错节、CSS 特异性、IE11、 旧进程占端口、WebView2 缓存、console=False、图片占位时序、仓库与部署副本、 replace 静默失败、补丁包缺文件 - 结尾提炼八条通用规则与发版检查清单 - 文档索引:根 README、docs/README 各补一行;排查文档顶部加成因复盘的交叉链接
18 KiB
踩坑记录
记录本项目里踩过、且容易再踩的坑。每条只回答四个问题:现象是什么、为什么会踩、 为什么没早发现、以后怎么绕开。细节排查看对应文档,这里留的是可复用的判断。
新增一条的格式:
现象 → 一句话结论 → 成因 → 为什么没早发现 → 教训。
| 编号 | 坑 | 一句话教训 |
|---|---|---|
| P-001 | 启动器越开越多窗口,里面一片空白 | 同一行代码在「源码运行」和「单文件打包」下语义不同,sys.executable 是典型 |
| P-002 | 改了 DLL,客户端连不上 | DLL 与服务端 exe 是一对,指纹不配套就是废的 |
| P-003 | 自校验 CRC 算出来永远对不上 | 别手动补算法里的 final XOR,库函数返回值就是可续算值 |
| P-004 | 写 CRC 槽位把 DLL 写坏 | 扫特征值时先按节表限定范围,代码段里的同名立即数是陷阱 |
| P-005 | 分隔竖线怎么都不显示 | CSS 特异性:写在低权重选择器上的声明会被高权重规则清掉 |
| P-006 | 网页好看,游戏里全乱 | 内嵌 IE11,没有 flex、没有现代选择器 |
| P-007 | 改了代码却没生效 | 单文件包 + 固定端口,旧进程不退就是连到旧服务 |
| P-008 | 页面永远是旧的 | WebView2 会缓存,静态响应要 no-store + ?v= |
| P-009 | 用户报错,但什么都看不到 | console=False 等于关掉全部输出,必须自己落盘日志 |
| P-010 | 缺图时占位不生效、只显示破图 | 图片可能在脚本执行前就加载失败了,占位要兜住这个时序 |
| P-011 | 仓库改了,实际跑的还是旧的 | 部署目录只是副本,改完必须回灌仓库并 diff -rq |
| P-012 | 批量改文件「改成功了」其实没改 | 脚本里先 assert 再 replace,否则静默失败 |
| P-013 | 补丁包发出去用不了 | 发版按清单勾,别凭记忆 |
P-001 启动器越开越多窗口,里面一片空白
细节见 启动器窗口风暴排查.md,这里记的是为什么会踩。
现象:双击 DNF90启动器.exe,每隔一两秒冒出一个新窗口,都是黑底、没有界面内容;
任务管理器里一片同名进程;游戏内 GM 页(127.0.0.1:18112)打不开。
只在部分电脑出现,开发机复现不了。
一句话结论:subprocess.Popen([sys.executable, "serve.py", ...]) 在源码运行时是
「用解释器跑脚本」,打包成单文件后变成「启动器把自己再启动一次,把脚本路径当参数传进去」——
而 PyInstaller 的 bootloader 根本不理这些参数。
成因:三层叠加
- 语义变了,代码没变。
sys.executable在源码下是python.exe,--onefile打包后是 启动器自身。同一行代码,两种运行环境,两条完全不同的执行路径。 Popen只保证「进程创建成功」,不保证「脚本被执行」。 它返回的Popen对象只能说明 fork/exec 成功;参数被 bootloader 吞掉这种失败没有任何回传,上一代永远不会知道下一代没干活。- 端口顺延把异常伪装成正常。
while port_open(port): port += 1让每一代都能顺利绑定 19528、19529…… 每一代都「启动成功」了,于是每一代都能继续分出下一代。真正让它停下的是port < DEFAULT_PORT + 20这个本来就有的上限,不是为这种情况设计的保护。
链条:
exe(19527) ──spawn──> exe(19528) ──spawn──> exe(19529) ──> … 到端口用尽
│ │ │
开窗口 开窗口 开窗口 ← 全是空窗口
每一代的执行顺序都是先 spawn 下一代,再创建自己的窗口,所以窗口是「一个接一个」冒出来的, 而不是同时出现——这个细节让现象看起来更像「用户自己在反复双击」。
为什么长期没人发现
四个掩盖因素同时成立,每一个都足以让问题隐形:
| 掩盖因素 | 效果 |
|---|---|
if port_open(18112): return |
开发机常年有 pythonw serve.py 占着 18112,永远走不到出错那句 |
| 开发机常跑着 gmweb 服务 | 于是「18112 被占用」成了常态,反而成了保护伞 |
console=False |
print 和 traceback 无处可去,现象只剩「窗口变多」 |
| 没有单实例锁 | 看起来像用户手抖多点了两下 |
换个角度,是四个假设同时不成立:
| 代码里的隐含假设 | 实际情况 |
|---|---|
| 打包后可执行文件仍是解释器 | 不是,是启动器自己 |
| spawn 成功 == 目的达成 | 只知道进程起来了,不知道它干了什么 |
| 同一时刻只有一个实例 | 想开几个开几个,每个都独立拉一遍 gmweb |
| 用户能描述故障 | 用户只会说「弹了一堆黑窗口」,console=False 下也没东西可给 |
前两个是技术假设,后两个是可观测性缺失——这才是它能活到用户机器上的真正原因。
怎么修的(不只是改那一行)
把四个假设逐个补上,缺一条都会复发:
| 补的假设 | 做法 |
|---|---|
| 打包后也能执行脚本 | 多调用模式 run_script_instead():exe <xxx.py> [args] 用 runpy 直接跑脚本,不开窗、不提权 |
| spawn 成功 ≠ 目的达成 | spawn 后最多等 6 秒确认 18112 真的在监听,没监听就 importlib 在本进程线程里兜底 |
| 只应有一个实例 | 命名互斥量 Local\DNF90Launcher-5E9C1B7A,重复启动唤起已有窗口后退出 |
| 出问题要能取证 | exe 同目录 启动器日志.txt,pid / argv / 端口 / 子进程全记 |
教训(可迁移的部分)
- 凡是往
subprocess里塞sys.executable的地方,打包前都要重新想一遍它到底指向谁。 这不止Popen:multiprocessing、os.execv、某些库的 worker 拉起,全都是同一个坑。 - 拉起子进程后要校验「副作用」,不是校验「返回值」。 返回值只证明进程存在,端口在监听 才证明事情干了。
- 任何自增/顺延逻辑都会把「必然失败」变成「看起来正常的第 N 次尝试」。 端口顺延、重试次数、 文件名加序号——同一个模式。加顺延的时候就要同时想「顺延到上限之后怎么报错」。
- 开发环境里「恰好被占用」的路径,等于没测过。 想让某条分支真的被跑到,得先把常态占用的东西停掉。
console=False的窗口程序必须自带日志,否则所有异常都等于「用户说不行」。
P-002 改了 DLL,客户端连不上
现象:换过 Client\90CN.dll 后,游戏能启动,一登录就被踢回,服务端日志没有明显报错。
一句话结论:DNF90Server.exe 编译期就把 DLL 的 SHA-256(boundDllSHA256)写死了,
DLL 一换指纹就不配套,服务端在登录阶段拒绝这个客户端。
成因:这是有意设计的防篡改绑定,不是 bug。麻烦在于它是编译期常量,不走配置。
为什么没早发现:单独测 DLL 改动时(比如只改 UI)不会重编服务端,本地能连上是因为当时 用的还是配套的那一对;一旦只更新了其中一半,问题才暴露。
教训:90CN.dll 和 DNF90Server.exe 永远同批构建、同批发。改 DLL 的流程固定为
「改代码 → 编译 → 补 CRC → 重编服务端 → 一起替换」。发版前用 patch_dll_crc.py info
和文件大小/哈希核对一遍,别单换一个。
P-003 CRC32 算出另一个变体
现象:自校验永远不通过,客户端弹框退出;脚本自己 verify 也是不符。
一句话结论:给 binascii.crc32(data, 0xFFFFFFFF) 传种子等于把 conditioning 做了两次。
成因:crc32 的第二个参数是已经过 final XOR 的运行中 CRC,不是「初始值」。传
0xFFFFFFFF 相当于手工再补一次算法内部的翻转,算出来是另一个 variant。分段续算的正确写法
是直接用上一段的返回值:crc = zlib.crc32(part1) 然后 zlib.crc32(part2, crc)。
为什么没早发现:单段数据算出来的值看起来「像那么回事」(是合法 CRC32,只是变体不同), 肉眼比对不出来,只有客户端实际拒绝才暴露。
教训:不要「手动补」标准算法里的常量。库函数文档写「可续算」就说明它的返回值已经是可以
直接喂回去的形态。写完先拿一个已知答案的向量自测(比如 CRC32("123456789") = 0xCBF43926)。
P-004 写 CRC 槽位把 DLL 写坏
现象:补完 CRC 后 DLL 一加载就 AV,或者读槽位那行立刻崩。
一句话结论:特征值 0xC0DEC0DE 在 .text 段里也有一份(MSVC 把 magic != 0xC0DEC0DEu
编译成立即数),扫到第一个命中就写,把机器码改坏了。
成因:定位槽位用的是「全文搜 magic」,但 magic 作为比较立即数会出现在代码里。
.text 通常是只读的,写进去要么不生效,要么直接破坏指令。
为什么没早发现:不同编译器/优化级别下 .text 里那份立即数时有时无,本地构建能过,
换个编译参数就崩,看着像随机故障。
教训:按 PE 节表限定扫描范围,只在 .data / .rdata 这类数据节里找;写到 expected
字段时把槽位自身 4 字节排除掉,否则「算出来的值」和「写进去的值」互相影响,永远不自洽。
P-005 竖线怎么都不显示
现象:关于页左右两栏之间的分隔竖线死活不出来,改 .box-r 的 border-left 没有任何反应。
一句话结论:.cols .box { border: 0 } 的权重是 (0,2,0),比 .box-r 的 (0,1,0) 高,把边框清掉了。
成因:为了去掉分组框默认边框,给 .cols 下的 .box 统一清零;单类选择器写了
border-left,权重不够,被覆盖。
为什么没早发现:Inspector 里看到的是「computed border = 0」,很容易以为是没写进去, 反复改同一条规则。
教训:排查「样式没生效」先看哪条规则赢了(DevTools 里划线的那条),而不是反复重写自己的。
修的时候用同权重选择器(.cols .box-r),别靠 !important 硬压。
P-006 样式在游戏里全乱
现象:浏览器里排版正常,内嵌到游戏里就错位、堆叠、居中失效。
一句话结论:游戏内嵌的是 IE11 内核(WebView 老控件),没有 flex、没有 CSS 变量、
没有 transform(只有 -ms-transform)。
成因:GM 页跑在游戏客户端内嵌窗口里,UA 是 IE11。用现代布局等于没写。
为什么没早发现:本地调试常用 Edge/Chrome 打开,看起来一切正常。
教训:gmweb 的布局规则固定为 float + box-sizing:border-box;垂直居中用
position:absolute; top:50%; -ms-transform:translateY(-50%)。改完必须在 800×720 的真实
内嵌尺寸下截图确认,别只看浏览器。同理,JS 也要避开 IE11 不认的语法(箭头函数、
let/const 在部分内核上仍有问题)。
P-007 改了代码却没生效
现象:重新打包、替换了 exe,双击后界面还是老样子、旧版本号。 一句话结论:单文件包固定用 19527 端口,旧进程没退出时新 exe 起不来新服务, 窗口连到的还是旧进程。
成因:--onefile 每次启动解包一份到临时目录,但端口是固定的;旧进程占着端口,
新进程只能顺延或失败,用户看到的窗口可能来自旧实例。
为什么没早发现:现象和「打包没成功」「缓存」完全一样,容易往错的方向查。
教训:改完先 tasklist | findstr DNF90 确认旧进程真的退了再验证;替换文件前先把运行中的
关掉,否则 Copy-Item 可能静默失败(文件被占用)。验证时看日志里的 pid 和「启动器端口」,
别用肉眼判断版本。
P-008 页面永远是旧的
现象:改了 CSS/JS,重启启动器后页面样式没变。 一句话结论:WebView2 会缓存本地页面,静态响应必须显式禁缓存。
成因:内嵌浏览器按 URL 缓存,127.0.0.1 的静态资源同样会被缓存,重启进程不一定清。
为什么没早发现:清一次缓存/换个端口就「好了」,于是误判成偶发。
教训:静态响应统一加 Cache-Control: no-store;引用资源时再叠一层
?v=YYYYMMDDx(改一次资源就换一个字母后缀)。两层都要,一层不够。
P-009 用户报错,但什么都看不到
现象:用户说「打不开」「闪一下就没了」,让他截图,什么都截不到。
一句话结论:console=False 的窗口程序 sys.stdout 是 None,print 和 traceback 全部丢失。
成因:PyInstaller 打成无控制台窗口的程序后,标准输出没有载体;未捕获异常也不会落盘。
为什么没早发现:开发时始终有控制台,报错看得一清二楚。
教训:窗口程序必须自带日志文件(exe 同目录、带时间戳、超限转 .old.txt),并且在最开头
就把 pid、argv、关键路径写进去——这些在事后无法补录。装好 tee 之后注意别再重复写:
自己 print 一遍又直接写文件一遍,日志会变成双份。
P-010 缺图占位不生效
现象:二维码图片缺失时,本该显示「未配置」占位,实际显示的是破图图标。
一句话结论:img 的 onerror 绑定晚于图片加载失败,事件早就过去了。
成因:脚本在 DOMContentLoaded 之后绑定;图片在 HTML 解析阶段就请求并 404 了,
onerror 那时候还不存在。
为什么没早发现:本地图片都在,只有用户机器上缺图才复现。
教训:绑定后立刻补一次兜底判断——img.complete && img.naturalWidth === 0 就当失败处理。
「事件绑定晚于事件发生」这个模式在前端反复出现,凡是依赖 load/error 的都要补这一手。
P-011 仓库和实际跑的不是一份
现象:仓库里明明改了,实际跑的还是旧行为;或者提交时发现少文件。
一句话结论:Z:\90server 只是部署副本,不是工作区;改完必须回灌仓库。
成因:调试时常直接在部署目录改,改完忘了同步;或者只提交了源码,漏了配置文件/静态资源。
为什么没早发现:两边看起来「都能跑」,直到下次从仓库重新构建才发现差异。
教训:改完用 diff -rq <仓库目录> <部署目录> 核对,排除故意的差异(比如 serve.py
只在仓库里);提交前 git status 看有没有该进的 ini / 静态资源。曾漏过一次
90CN_web.ini 和整个 gmweb 目录,教训是「配置文件和代码一样是交付物」。
P-012 批量改文件「改成功了」其实没改
现象:脚本打印「patched」,重新打包后行为没变。
一句话结论:str.replace() 找不到目标时不报错,静默返回原串。
成因:待匹配文本里空格、引号、换行只要差一个字符,替换就整体失效,而脚本照样往下走、 照样打印成功。
为什么没早发现:输出一片「ok」,没人会去 diff 结果。
教训:每处替换前 assert old in content,替换后再 assert new in content;脚本结束打印
实际改动条数(n 为 0 就该警惕)。改完务必看一眼 git diff --stat,行数对不上就是没改成。
P-013 补丁包缺文件
现象:补丁包发出去,用户装上后回退成 1040 宽窗口、GM 页打不开、或者登录被踢。 一句话结论:发版是清单活,不是「把改过的拖进去」。
成因:一次发版踩了三个:DLL 与 exe 指纹不配套、漏了 gmweb 整个目录、漏了
Client\90CN_web.ini(缺它就回退默认宽度和 close_action=0)。
为什么没早发现:本地测试用的是完整目录,只验证「能跑」,没验证「只装补丁包能不能跑」。
教训:发版固定清单——① Client\90CN.dll 与 Server\runtime\bin\DNF90Server.exe 成对且
指纹一致;② gmweb\(含 qr\ 四张图);③ Client\90CN_web.ini;④ 启动器 exe 与
更新日志.txt;⑤ 版本号三处一致(CURRENT_VERSION、客户端显示、更新日志)。装完在
干净目录里实跑一遍,再对外发。
从这些坑里提炼的通用规则
- 打包会改变语义。 任何依赖
sys.executable、__file__、工作目录、相对路径的代码, 打包前后都要重新推一遍。 - 校验副作用,不校验返回值。 进程起来了 ≠ 事情干了;文件写了 ≠ 内容对了。
- 顺延/重试会把确定性失败伪装成正常。 加顺延就同时加「上限之后怎么报错」。
- 开发环境的常态占用会掩护 bug。 想测某条分支,先把它前面的短路条件拆掉。
- 没有输出的程序等于没有可维护性。 窗口程序一律自带日志,开头就写 pid/argv/路径。
- 「改完没生效」先怀疑三类:旧进程没退、缓存没清、文件没同步到实际运行的位置。
- 成对的东西不能分开动:DLL 与 exe、
.url与发布说明、仓库与部署目录。 - 发版是清单活。 清单写下来,别凭记忆。
相关文档
| 文档 | 内容 |
|---|---|
| 启动器窗口风暴排查.md | P-001 的完整排查过程、复现步骤、日志判读表 |
| 客户端补丁与CRC校验.md | P-002 / P-003 / P-004 的机制细节与工具用法 |
| 开发文档.md | 启动器与 GM 工具的开发、打包、部署 |
| 服务端开发文档.md | 服务端 GM 接口与 DLL 指纹绑定 |