本文由glm5.3撰写;一次"钉在副屏的截图会自己跳回主屏"的 bug 修复,最终演变成对 KDE layer-shell 协议、KWin 窗口管理内核和一张 patch 分发管线的三重深潜。这篇文章记录完整的排查链路:从隔离虚拟显示器复现,到读 KWin 源码找到协议级根因,再到修复过程中意外引爆的性能优化与 patch 自噬事故。
起因:一个很小的 bug
我用的是 Spectacle 的一个自维护 patch 分支,额外功能之一是"钉图"(把截图钉在桌面上置顶显示)。某天发现一个诡异现象:在主屏钉的图,被我用快捷键挪到副屏后,只要点一下或者滚轮缩放,它就会瞬移回主屏。
小 bug,但很硌手。
第一步:复现,但不在真实桌面弹窗
调试桌面软件最忌讳在真实会话里弹测试窗口——打断自己的工作流,还可能把窗口管理搞乱。我的验证环境是一套隔离的虚拟 KWin:
两个虚拟输出、模拟混合缩放、配合 KDE 的 fake-input 协议注入真实输入事件,再通过 KWin scripting DBus 接口执行和用户快捷键完全等价的
workspace.sendClientToScreen(pin, target)。探针输出一行行 JSON:每一步之后 KWin 眼中这个窗口在哪个输出、什么坐标。很快拿到了决定性证据:
复现稳定,而且确认:纯点击不会跳回,跳回由缩放引发的某种"重排"造成。
第二步:读 KWin 源码,找到协议级根因
带着"缩放时发生了什么"去读 KWin 的 layer-shell 实现,答案在
LayerShellV1Window:m_desiredOutput在 layer surface 创建时一次性定死;
- 每次重排,窗口都被放回
desiredOutput.topLeft + margins;
- 没有任何协议请求能改变一个已存在 surface 的 output。
而"挪到副屏"这个操作,KWin 是当作普通窗口移动处理的:改内部 frameGeometry,完全不动 desiredOutput、不动 margins。于是缩放触发
setDesiredSize → KWin 重排 → 表面被强行放回创建屏。根因清楚了,推论也很残酷:客户端改 margins 没用。我试过两种 margins 方案,探针都直接证伪——其中一种甚至算出了
x=-1400 这种荒谬坐标(相对坐标口径混乱,margin 相对新屏原点反而把窗口推出桌面外)。唯一协议合法的路径只有一条:销毁旧 surface,在新 output 上重建。
第三步:重建方案,和它的两个坑
方案看起来直白:检测
windowHandle()->screen() 与目标屏不一致 → destroy → 重建 → 恢复全部 layer-shell 配置。但实现后探针显示窗口又跳回来了,而且日志里有一次诡异的"反向迁移"。细看日志时间线才明白:重建后的新 surface 还没收到 wl_surface enter 事件时,Qt 的
windowHandle()->screen() 返回的是主屏(primary)。下一次 movePin 读到这个假值,判定"窗口在主屏",又把它迁移回来——自噬循环。解法是引入一个状态闸门:只有实际观察到 surface 落在目标屏上之后(enter 事件已到),才允许下一次跨屏迁移:
初值还必须设为
false——否则构造函数里 show() 之前的第一次 movePin 读到假主屏值,会把刚钉到副屏的 pin 又拽回主屏。两个方向都堵死后,探针终于全绿:缩放、点击之后,窗口稳稳留在副屏。第四步:修 bug 送的性能问题
回归测试跑起来后,顺手测了整个截图流程,发现新问题:一次矩形截图竟然要 5~20 秒,期间 CPU 多核打满。
采样器(轮询
/proc 记录进程树 CPU/内存轨迹)抓出的现场触目惊心:一次截图,Python OCR 子进程被串行拉起了 10 次,每次全屏检测 1.4 秒左右。深挖下去是三层问题叠加:层 | 问题 | 耗时 |
检测调度 | SelectionEditor patch 断链状态下重触发循环,10 次冗余检测 | ~14s |
进程模型 | 每次检测冷启动 python + ONNX 会话重建 | ~0.5s/次 |
应用生命周期 | 每次 F1 都 systemd 冷启动整个 QML 应用 | ~1s/次 |
逐个击破:
- 正确性——修复 SelectionEditor patch(见下文的自噬事故),冗余检测归零;
- 进程复用——给 OCR 脚本加
--server行协议模式,Python 进程常驻、模型会话只建一次,C++ 侧用单例 QProcess 复用,每次请求只付纯推理的 ~0.4s;
- 应用驻留——加
SPECTACLE_STAY_RESIDENT开关,截图任务结束后不退进程只隐藏窗口,下次快捷键激活直接命中现成进程,省掉整个冷启动。配合一个 systemd 用户单元,开机即驻留。
顺带修掉一个退出卡顿:ESC 时如果 OCR 子进程还在推理,析构 QProcess 会同步等它——改成 kill 优先、限时收尸,退出不再被 Python 拖住。
第五步:patch 分发管线的自噬事故
这个项目的分发方式是 patch 链:上游 tarball + 五个按序应用的 patch,makepkg 构建时逐个打上。我修 SelectionEditor.cpp 时,把分发 patch 里对应段用"工作树 → 上游"的 diff 重新生成——这里埋了雷。
工作树并不等于"前四个 patch 应用后"的状态,它缺了另一个 patch(empty-selection,空选区=鼠标所在屏)对同一文件的改动。重新生成的段落把这处行为吞掉了,结果:bug 修好了,但"回车直接截当前屏幕"这个老功能神秘消失。用户一句"单独截某一个屏幕被你弄没了"把我打回排查——这次学乖了,直接在打完全链 patch 的树上 grep 关键标记(空选区改动的
screenAt 调用、被误删的函数定义),当场定位。这轮学到的教训值得刻碑:
- patch 链项目的唯一内容真相源,是"全部 patch 应用完成后的树",不是工作树;
- 重新生成任何 patch 段后,必须对净源码树跑全链 dry-run + 关键标记断言 + 编译三连;
- 我伪造的
index 00000000..0000000行曾让 patch 把整段误判为文件删除;哈希行干脆不写比写错强。
最终架构
性能对比:一次截图从 5~20s 降到 ~1s,第二次 F1 从冷启动 ~1s 降到零等待。
复盘
- 先建可复现的隔离环境,再动手——虚拟双输出 + fake-input + KWin scripting,让每个假设都能被一行探针输出裁决。
- 别信客户端单侧视角——Qt 报告的屏幕和 KWin 内部状态可以短暂不一致,协议对象的"真相"只在事件往返之后。
- 性能问题的答案在采样数据里,不在猜测里——一次进程树采样直接把"10 次冗余 OCR"钉在墙上。
- patch 链项目里,内容真相源是全链应用后的树;重生成段落必须过"全链 dry-run + 标记断言 + 编译"三关。
