1. 从一个“关不掉的窗口”说起
我在维护一个自制轻量窗口管理器时,遇到一个特别反常的 Bug:窗口能正常创建、正常拖动、正常缩放,但只要一点关闭按钮,屏幕左上角就会留下一个无法操作的“幽灵窗口”,后台进程没退出,日志里刷满BadWindow错误。最诡异的是,这个问题不是必现的,只有快速点击关闭按钮,或者在窗口刚开出来时就关闭,才会复现。排查了一个周末,最后定位到根因:我在处理窗口退出时,只销毁了装饰窗口,没有把客户窗口先“放回原位”。
这里就引出了本文的主角:exit_reparent()。这个名字是我在重构时起的,它要做的事情一句话就能说清——把当前处于“被管理、被装饰、被剥夺父级自由”状态的窗口,安全地从整个托管链条中拆下来。拆到什么程度?拆到让这个窗口重新变成一棵普普通通的顶层窗口,或者干净利落地消失。听起来不复杂,但真正把这条路径走通之后,你会发现它牵扯到窗口树结构、事件掩码、坐标换算、重入保护、客户端存活判断这一连串问题。
这篇文章适合正在写窗口管理器、合成器,或者用底层 GUI 库做窗口嵌入的人。即使你现在只做应用层开发,里面关于“父子窗口生命周期”的坑,也大概率在setParent或类似 API 上遇到过。先声明一下:这是一个小项目的经验总结,不是教科书,所有的处理方式都经过实际调试,也踩过不少坑。
1.1 为什么窗口管理器要把窗口“收养”起来
要理解exit_reparent()存在的意义,先得理解 reparent 存在的意义。在 X11 环境下,客户窗口在没有任何窗口管理器介入时,父窗口就是根窗口(Root Window)。如果所有窗口都直接挂在根窗口下,窗口管理器就没法给它们画标题栏、画边框、画阴影,因为窗口之间的层级关系是严格的树结构,你不能在一个“别人的窗口”外面凭空套一层装饰而不改变它的父级。
所以几乎所有主流窗口管理器都会在窗口发出映射请求时做这样一件事:
// 处理 MapRequest 时的关键流程 frame = XCreateSimpleWindow(dpy, root, x, y, w, h, 0, border, bg); XReparentWindow(dpy, client->win, frame, 0, title_height); XMapWindow(dpy, frame); XMapWindow(dpy, client->win);第一步是创建一个frame窗口(也就是装饰窗口),把客户窗口从根窗口下摘出来,挂到frame下面。客户窗口的坐标从此不再参照根窗口,而是相对于frame窗口。这一步就是 reparent。
这个机制本身很优雅,但也埋下了一颗雷:一旦客户窗口被挂到frame下面,窗口管理器就必须对这个frame的整个生命周期负责。客户程序只知道自己有一个顶层窗口,它并不知道自己的窗口已经被“收养”了;当它点击关闭按钮,或者调用销毁窗口的接口时,X 服务器会根据窗口树自动处理:如果父窗口还在,那么客户窗口销毁只是删掉一个叶子节点;如果frame先被销毁,X 服务器会把客户窗口自动 reparent 回根窗口,但位置坐标还是之前相对于frame的坐标。事情就是从这一步开始失控的。
1.2 “脱壳”比“戴壳”难得多
reparent 操作正向执行只需要一行接口,但逆向要做的事情远不止一行。你在映射请求阶段,可以非常粗暴地创建一个 frame、设置好尺寸、把客户窗口塞进去,即使位置算错一点点,用户也能看到窗口并马上调整。但在退出流程里,如果手一抖把客户窗口放在错误坐标,用户看到的是窗口突然跳到屏幕角落;如果你直接销毁 frame 而不是先把客户窗口 reparent 回根窗口,客户程序对窗口状态的感知就会错乱;如果你在客户程序已经销毁自己的窗口之后再调用XReparentWindow,就会触发BadWindow致命错误。
所以我才写了这个专门的函数。它的核心思维不是“销毁窗口”,而是“拆分关系”:客户窗口与 frame 窗口之间的父子关系必须拆干净,窗口管理器内部的客户端记录表里的条目必须拆干净,事件选中的掩码必须拆干净,有些场景下焦点关系也要拆干净。只有这些关系全部理清,窗口退出才不会留下任何尾巴。
2. 设计exit_reparent():一次完整的“拆关系”流程
2.1 先定义清楚两个退出方向
我在写函数之前,先给自己列了两个场景,对应两种不同的退出策略。
第一种,客户程序主动要求关闭自己。窗口管理器通过WM_DELETE_WINDOW协议得知这个意图,此时应该还给客户程序一个体面的退出过程:解除 reparent、让客户程序自己销毁窗口,或者至少把窗口放回根窗口附近,由客户程序自己决定后续动作。
第二种,客户程序已经僵死,或者点击关闭按钮后迟迟不响应WM_DELETE_WINDOW。这时候窗口管理器不能无限等下去,要在合理超时之后强制销毁客户窗口。这种情况下,exit_reparent()需要额外处理“客户窗口可能已经不存在”的情况。
因此函数签名设成一个枚举参数:
void exit_reparent(Client *c, int policy);其中policy取值REPARENT_EXIT_CLEAN或者REPARENT_EXIT_FORCE。前者是正常退出路径,后者是强制退出路径。很多初学写的窗口管理器只有一个销毁窗口的调用解决所有问题,看起来也能跑,但一旦遇到客户程序正在处理数据、正在保存配置,直接销毁客户窗口会导致客户程序崩溃或者丢数据。
2.2 核心流程拆解
不管哪种策略,函数内部都要遵守一个总原则:先拆关系,再动窗口,最后清理状态。我把这个原则展开成下面的步骤:
static void exit_reparent(Client *c, int policy) { // 幂等保护:已经在退出流程中就直接返回 if (c == NULL || c->state == STATE_CLOSING) return; c->state = STATE_CLOSING; // 从 WM 的映射表中移除这个客户端 remove_from_client_list(c); // 恢复客户窗口的 event mask,避免 WM 独占事件 XSelectInput(dpy, c->win, c->saved_event_mask); // 归还窗口树关系:客户窗口挂回根窗口,并恢复 root 坐标 XReparentWindow(dpy, c->win, DefaultRootWindow(dpy), c->saved_x, c->saved_y); XMoveResizeWindow(dpy, c->win, c->saved_x, c->saved_y, c->saved_w, c->saved_h); // 把 frame 放入 zombie 列表,下一轮事件循环再销毁 add_to_zombie_list(c->frame); // 强制退出策略下,标记客户窗口为待销毁 if (policy == REPARENT_EXIT_FORCE) schedule_kill(c->win); }这一步里最关键的是坐标还原。我一开始偷懒,直接用XReparentWindow(dpy, c->win, root, 0, 0),想着反正客户窗口马上就要销毁了,坐标无所谓。结果那些不响应关闭请求的客户程序,在被我强制解除 reparent 之后还在运行,窗口就跑到了根窗口的左上角,而且拖动窗口时的偏移量完全错乱。后来我在Client结构体里长期保存一份“reparent 之前的 root 坐标”,每次处理映射请求时记录,拖动 frame 时同步更新,退出时还原,问题才消失。
2.3 为什么要用 zombie 列表延迟销毁 frame
初次实现时,我在exit_reparent()里直接调用了销毁 frame 窗口的接口。表面上很干净,但在窗口管理器的事件循环里,你往往是在处理某个事件的回调中进入了这个函数。比如用户点击了关闭按钮,你现在正处在某个消息事件的switch case分支里,执行销毁操作时,X 服务器会立刻向所有监听这个窗口的客户端发送销毁通知,并且可能触发其它窗口的重叠计算。
如果销毁操作导致当前正在处理的事件对应的窗口被清理掉,部分底层内部指针会变成悬空,轻则下一轮事件读取时报BadWindow,重则直接段错误。我采用的方案是把所有需要销毁的 frame 窗口丢进一个zombie_list,在事件循环的下一次迭代之前统一销毁。这样既不会因为提前销毁而影响当前事件处理,又能保证 frame 不会长期堆积成内存泄漏。
2.4 幂等保护和误触发的边界情况
另一个容易忽略的问题是幂等性。快速连点关闭按钮会触发 Bug,就是因为exit_reparent()没有幂等保护。当用户连点两次关闭按钮,窗口管理器会收到两次WM_DELETE_WINDOW请求。如果第一次调用已经把客户窗口从 frame 下面摘走并且移除了映射表条目,第二次调用再次执行XReparentWindow就会遇到BadWindow。
所以函数开头必须先检查状态:
if (c->state == STATE_CLOSING) return;状态标记一旦设置,后续所有针对该客户端的退出事件都直接忽略。还有一种边界情况是客户程序自己崩溃退出,它的窗口被 X 服务器销毁,但 WM 内部还保留着这个 Client 记录。此时exit_reparent()的第一个参数指向的Client结构仍然存在,但c->win已经失效,调用XReparentWindow同样会报BadWindow。解决方法是每次进入函数前通过窗口查询接口或者自己维护的存根标记检查窗口是否还活着。如果你的 WM 在销毁通知里已经清过一遍 Client 表,这一步通常不会触发,但用来兜底很有必要。
3. 关闭请求背后的协作机制与超时处理
3.1 先走协议,再走强制
我在最初实现里犯过一个方向性错误:把WM_DELETE_WINDOW当成一个普通的“命令”,收到消息就立刻销毁客户窗口。后来查阅老代码才发现,WM_DELETE_WINDOW只是一份“请柬”。窗口管理器把它以ClientMessage形式发给客户程序,客户程序收到后可以自己决定是否销毁窗口。真正定义了这个协议的文档,其本意是让客户程序在退出前弹窗询问用户是否保存改动,或者优雅地释放资源。
因此exit_reparent()的调用路径通常是这样的:
收到 WM_DELETE_WINDOW -> 发送 WM_DELETE_WINDOW ClientMessage 给客户程序 -> 启动一个 2 到 3 秒的超时计时器 -> 超时前收到客户的 DestroyNotify -> 说明客户窗口已经自己销毁,WM 只需要销毁 frame 并清理 Client 记录 -> 超时未收到 DestroyNotify -> 调用 exit_reparent(..., REPARENT_EXIT_FORCE) -> schedule_kill 真正销毁客户窗口如果你没做过窗口管理器,可能不太理解为什么要这么绕。其实就是一句话:窗口程序的退出是客户端和服务器端协作的过程,不是某一边单方面决定的。WM 可以强制销毁窗口,但客户程序也应该有拒绝被立即销毁的机会。协议的优雅,就体现在这种“尽量商量着来,但有最终手段”的设计里。
3.2 强制销毁时的细节:别用全局接口误伤兄弟窗口
当超时发生,我们需要权衡用哪个接口来销毁。直接的全局销毁接口会销毁这个客户程序的所有资源,注意是“所有”,不只这一个窗口。如果一个客户程序打开了多个顶层窗口,其中一个窗口僵死,你用全局销毁接口会把它的所有窗口一并干掉。这显然不合适。
所以在exit_reparent()里,强制销毁路径我使用的是针对单个窗口的销毁调用,只销毁当前窗口及其子窗口。但这里也有个坑:如果客户程序在销毁窗口前还需要做一些清理,而你在exit_reparent()里立刻发起销毁,相当于把它的脚提前抽掉了。更稳妥的方法是,在强制分支里先把客户窗口 reparent 回根窗口,保持它可见,然后给客户程序一个短促的“临终缓冲”,再安排销毁。实际项目中我通常的做法是,先解除 reparent,然后发起单个窗口销毁,接着立刻把 frame 销毁,最后在下一轮事件循环检查销毁通知是否正确到来。
3.3 事件循环里不能阻塞
还有一个很容易出问题的地方:exit_reparent()这个名字容易让人以为它是一个无所不能的同步函数,可以在里面等待客户程序退出。实际上在事件循环模型里,任何阻塞调用都会让整个 WM 卡死。用户会看到所有窗口都停止响应,点击任务栏也没有反馈。
所以这个函数必须是非阻塞的。超时判断放在事件循环的主循环里,用一个client->kill_timer累积时间,只有时间到了,才真正进入强制销毁分支。如果客户程序在超时前拿到了WM_DELETE_WINDOW并且自己销毁了窗口,销毁通知事件到达时,WM 就应该把定时器取消。我把这个逻辑和维护zombie_list、维护Client状态表放在同一个处理函数里,这样整个退出流程的时序才清晰。
4. 容易出现的问题与排查实战记录
4.1 现象一:关闭窗口后窗口跑到屏幕左上角
这是我最早遇到的故障,也是最具迷惑性的一个。表现是:点击关闭按钮,客户程序没有立刻退出,窗口也没有消失,而是鬼一样地跳到屏幕左上角,并且不再响应任何 WM 的拖拽操作。查看窗口树信息,窗口已经变成了根窗口的直接子窗口,坐标是(0, 0)。
根本原因就是我前面提到的坐标还原问题。在 reparent 前的映射请求阶段,WM 获取客户窗口的请求位置可能是(300, 200),reparent 到 frame 后,客户窗口的相对坐标变成(0, 25),因为 frame 有标题栏。关闭时直接 reparent 到根窗口,没有设置坐标,于是客户窗口就留在了相对坐标(0, 25)——它看起来像是(0, 25)在根窗口上的位置,也就是左上角附近。
修复方法是在Client结构体里始终维护saved_x、saved_y,每次 frame 被移动或缩放时都同步更新。这样退出时可以直接还原成用户最后看到的坐标。
4.2 现象二:快速双击关闭按钮导致致命错误
双击关闭按钮,在用户视角只是一个简单的操作,在 WM 事件循环里却是两三个事件接踵而至。第一次请求进入exit_reparent()时,状态被置为STATE_CLOSING,客户窗口被 reparent 回根窗口。第二次请求再次进入时,如果函数没有开头那个幂等判断,就会拿着已经不在 frame 下的c->win去执行XReparentWindow。此时客户可能正在销毁自己的窗口,窗口 ID 已经失效,X 服务器回一个BadWindow,默认的错误处理器会把整个 WM 进程终止。
我排查这个问题的时候曾经百思不得其解,因为崩溃信息只显示致命错误,没有堆栈。后来我在exit_reparent()入口和出口各加了一条日志,才看到同一窗口的退出流程被连续调用了两次。加一个幂等标志,问题立刻消失。
4.3 现象三:关闭后键盘焦点消失
这是最隐蔽的问题。窗口关闭后,所有窗口都在,也没有崩溃,但键盘输入无效,按什么键都没反应。原因在于 WM 通常会把输入焦点设置到 frame 窗口上,以接收键盘事件。当 frame 被销毁而焦点没有重新分配时,X 服务器会把焦点设置为跟随鼠标指针的模式。如果鼠标刚好停在一个不接受键盘输入的窗口上,键盘就完全失灵。
解决办法是在exit_reparent()的收尾阶段主动把焦点交给附近的一个合适窗口:
focus_another_managed_window(c->win);如果找不到合适的窗口,就把焦点设回根窗口或者设为跟随鼠标的模式。这个细节很容易被遗忘,但它直接影响用户使用体验。
4.4 快速定位问题的小工具组合
排查这一类窗口树问题时,光靠眼睛看日志远远不够。我常用的三个命令行工具是窗口树查看工具、事件监听工具和窗口控制工具。窗口树查看工具可以在每次操作后打印整个窗口树,直接看到客户窗口的父级是谁、坐标是多少;事件监听工具用来监听单个窗口的事件,确认销毁通知是否按预期到达;窗口控制工具用来模拟点击关闭按钮和移动窗口,做自动化回归测试。
我在写exit_reparent()期间,每天都会用这三个工具做一遍“打开窗口、移动窗口、点击关闭、判断窗口状态”的脚本化巡检。你会发现,很多偶发问题通过人工点击根本复现不出来,但自动化脚本跑到第五十次的时候就能稳定触发,定位起来效率高很多。
5. 从 reparent 到组件生命周期:这个函数教会我的事
5.1 Wayland 环境下没有 reparent,但同样需要“拆关系”
如果读者是从 Wayland 合成器开发转过来的,可能会觉得exit_reparent()是个 X11 专属问题。这在表面上是成立的:Wayland 不再有全局限定的窗口树重挂操作,客户端自己创建表面,自己决定是否在子表面上绘制阴影,合成器不会把客户端表面“收养”到另一个窗口下面。但在 Wayland 合成器的代码里,当一个表面的关闭请求被处理时,同样需要安全地删除相关资源、清理监听器、避免在销毁回调中访问已经无效的对象。
这个函数的核心思想完全可以迁移过去:任何父子关系、监听关系、引用关系,在退出时都必须显式地“拆开”,而不是单纯地把父对象销毁了事。我在后续学习 Wayland 合成器时,甚至直接把zombie_list的延迟销毁思路套用到资源对象的销毁流程上,效果一模一样。
5.2 对所有 GUI 框架的普适启示
不仅在窗口系统底层,普通 GUI 框架里也存在类似问题。举个例子,一些框架允许动态改变窗口的父子关系,如果你在某个父控件析构时忘了先把它下面的子控件重新挂到别处,子控件就会随着父控件一起销毁,而程序中可能还存着一个指向它的裸指针。是不是和 X11 里 frame 销毁导致客户窗口被意外 reparent 的行为非常像?
这让我意识到,窗口管理器的exit_reparent()本质上是一个通用的“生命周期收口函数”。它的模式可以抽象成四步:摘除引用、恢复现场、延迟销毁、状态标记。这四步放在任何需要动态调整父子关系的场景里,都适用。如果你以后遇到“窗口关闭后崩溃”“组件销毁后悬垂指针”“对象被提前释放”这类问题,不妨回想一下exit_reparent()的设计逻辑,很可能写一个同样结构的函数就能解决。
5.3 一个实用的检查清单
最后我把排查窗口退出问题的检查项整理成清单,也是我每次改完exit_reparent()都会过一遍的内容:
- 客户窗口是否已经恢复到根窗口坐标,而不是
(0, 0)? - frame 窗口是否已经加入 zombie 列表,而不是在事件回调里直接销毁?
- 客户窗口的事件掩码是否恢复,避免 WM 继续拦截客户事件?
Client结构体是否已经从映射表和焦点候选池中移除?- 是否有幂等标志,防止同一个客户被重复触发退出流程?
- 强制退出时,是否只销毁了目标窗口,而不是误伤同进程的其它窗口?
- 焦点是否已经重新分配给下一个可用窗口?
这个清单看着并不长,但每一条背后都是我踩过的真实问题。窗口管理器的代码很多时候不会像 Web 后端那样有高并发,却比高并发更考验开发者的耐心,因为 Bug 触发条件往往是一连串时序事件的叠加。
我在实际维护这个轻量 WM 的过程中最深的一个体会是:窗口的“创建”和“销毁”从来不是对等的。创建窗口可以随便写,销毁时就必须小心翼翼,因为你不知道客户程序此刻到底在干什么。exit_reparent()这个名字本身也是我给自己的一种提醒:退出的本质,是把窗口重新放回它该在的位置,不管是窗口树里的位置,还是用户心理上的位置。最后再分享一个小技巧:写完这个函数后,建议你在日志里把每次 reparent 前后的窗口树对比打印出来,肉眼可见的差异,比任何调试器都直观。