Enlightenment E16 是一款诞生于 1997 年的经典窗口管理器,由 Carsten Haitzler 开发。尽管大多数用户早已转向 E17 及更高版本,但仍有少数资深爱好者坚守在 E16 生态中。我便是其中之一 —— 它主题丰富、可高度定制、内存占用极低(峰值仅 24MB),对键盘爱好者极为友好。然而,正是这样一款「老古董」,在其二十余年的代码积累中,隐藏着一些鲜为人知的严重缺陷。最近,我亲自修复了一个可复现的致命 bug,其根因竟然出在 Newton 算法的错误实现上。
Bug 症状与初步定位
一切始于一个普通的深夜。我正在为课程幻灯片做最后冲刺,同时在后台编译一份 PDF 讲义。当我使用 Atril 文档查看器打开该 PDF 时,整个桌面瞬间冻结。鼠标无响应,键盘输入无效,唯有从 TTY 强制终止 X11 会话才能恢复。
重启后,我尝试多次打开该 PDF,发现冻结是确定性的 —— 每次都能复现。这给了我调试的信心。挂载 gdb 到运行中的 E16 进程,堆栈跟踪显示所有采样点都停在 imlib2 的字体缓存相关函数中:
#0 __strcmp_evex ()
#1 __imlib_hash_find (hash=0x55bc9c111420, key="\001\001\001\001\001") object.c:172
#2 __imlib_font_cache_glyph_get (fn=..., index=0) font_draw.c:30
#3 __imlib_font_get_next_glyph (... utf8="Kickoff.pdf — Introduction...") font_main.c:218
...
#8 TextstateTextFitMB (ts=..., textwidth_limit=291) text.c:350
#9 TextstateTextFit (...) text.c:559
#10 TextstateTextDraw (... text="Kickoff.pdf — Introduction...") text.c:638
反复挂载调试器后,我发现程序并未死锁。__imlib_font_cache_glyph_get 确实在不同字形索引(0、20、73、81、82、87、88……)之间切换,说明内层的字体测量在缓慢推进。真正的无限循环发生在外层 ——Frame 8 的 TextstateTextFitMB 函数在 text.c:350 处反复调用文本尺寸测量,每次都使用相同的窗口标题文本和相同的宽度限制(291 像素)。
这是一个文本截断功能的中间省略号(middle-ellipsis)算法。当窗口标题过长、无法完整显示在装饰边框中时,E16 会尝试从字符串中间删除若干字符,用省略号替代。我开始怀疑问题出在这个截断逻辑的迭代算法上。
根因分析:Newton 迭代的收敛失效
为了定位问题,我需要深入理解 TextstateTextFitMB 函数的实现逻辑。核心代码是一个 Newton 风格的搜索算法:根据当前宽度与目标宽度的偏差,估算需要删除(或添加)多少个字符。算法使用 cw = width / len_n 作为导数(每个字符的平均像素宽度),然后计算修正量。
这个思路本身很聪明 ——Newton 迭代法在函数可微、初始猜测足够好的情况下,确实能快速收敛。然而,代码中缺少一个关键的保护措施:迭代次数上限。
Newton 方法有两个致命缺陷:它可能不收敛,也可能 overshoot(越过最优解导致发散)。具体行为取决于初始猜测点、目标函数的形态,以及导数估计的质量。在这个场景中,两个条件叠加导致了算法在两个状态之间永远振荡:
nuke_count = 8 nc2 = 36 wc_len = 81 len_n = 76
nuke_count = 11 nc2 = 35 wc_len = 81 len_n = 73
nuke_count = 8 nc2 = 36 wc_len = 81 len_n = 76
...
可以看到,nuke_count 在 8 和 11 之间来回跳动,nc2(剩余字符数)在 35 和 36 之间切换,整个系统陷入了无限循环。每次迭代都尝试删除不同数量的字符,但无论删除多少,下一轮的结果都回到原点。
为什么普通窗口标题从未触发这个 bug?原因在于退出容差(ε)的设置:只有当 nc2 落在 [0, 3*cw) 范围内时才接受结果。在较短的字符串或较大的平均字符宽度 cw 条件下,算法会进入 <= 2*cw 分支,修正步长变为 1,能够正常收敛。只有当字符串足够长、字符足够窄(在本例中,81 个宽字符,平均宽度约 3 像素,标题栏空间仅 291 像素)时,算法才会跌入振荡陷阱。
修复策略:三重防御机制
针对这个 bug,我实施了三个对称的防御性修改,同时应用于多字节和 ASCII 两种字符处理路径。
第一道防线是迭代次数上限。将最大迭代次数限制为 32 次。当超过此上限时,如果当前试验的 nc2 >= 0(即没有截得太短),则直接接受该结果;否则将 nuke_count 加 1 后重试。这确保了算法在有限时间内终止,同时一旦 Newton 步振荡,能够快速选择第一个可行的候选解。
第二道防线是下界保护。在循环内部将 nuke_count 最低值设为 1,防止负修正量产生「尾巴超过头部」的退化字符串(即删除的字符数反而导致字符串变长)。
第三道防线是除零保护。将 cw 最低值设为 1,确保在极端情况下(测量到零宽度字符)不会触发除零错误。
这套修复方案的核心理念是:在承认算法可能失效的前提下,通过硬性约束保证程序能够「优雅地失败」,而不是陷入不可恢复的状态。
老旧代码库的调试方法论反思
这个案例揭示了老旧代码库调试的一些独特挑战与机遇。与现代软件相比,E16 这类遗留系统缺乏完善的测试覆盖,边界条件往往在真实使用场景中才暴露出来。调试此类问题需要几个关键能力:理解底层算法(Newton 迭代的收敛条件)、熟练使用 gdb 进行动态分析(堆栈跟踪、局部变量 Dump)、以及对系统行为的敏感度(区分死锁与无限循环)。
我使用的调试技术组合值得记录:确定性复现(每次打开特定 PDF 都触发冻结)大大缩小了搜索空间;gdb 的采样分析定位了热点函数;Dump 帧局部变量揭示了振荡模式。这些都不是高深技术,但需要耐心和系统性思维。
在现代软件开发中,我们经常被鼓励使用最新框架和工具。但这个案例表明,真正有价值的可能是那些经过时间检验的「老」技术 —— 以及理解它们为什么会失效的能力。
资料来源:本文技术细节来自 Kamila Szewczyk 在 iczelia.net 发表的文章《Fixing a 20-year-old bug in Enlightenment E16》。