我最喜欢的Bug:无效的代理对

Hacker News Top 新闻

摘要

一篇博客文章,回顾了一个Bug:在CRDT库中,插入相邻的多字节表情符号导致了一个拼接操作,分割了代理对,并静默地破坏了协作编辑器的同步。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/05/16 15:40

# 我最爱的Bug:无效的代理对 来源:https://george.mand.is/2026/05/my-favorite-bugs-invalid-surrogate-pairs/ 2026年5月14日 · 约2000字 · 8分钟阅读 如果你长期从事构建计算机上运行的东西,我想你最终会收获一个最爱的Bug故事。下面这个是我自己的短篇故事。我还搭建了一个[交互式工具](https://george.mand.is/2026/05/my-favorite-bugs-invalid-surrogate-pairs/invalid-surrogate-pairs/),让你可以探索这个Bug核心背后的概念。 ## Bug:两个emoji进去,一个都没留下 当时我正在和团队一起将遗留编辑器迁移到更具协作性的体验上。上层用[TipTap](https://tiptap.dev/)(它本身是[ProseMirror](https://prosemirror.net/)的封装),底层用[Yjs](https://yjs.dev/)处理实时同步的CRDT魔法。效果不错!大部分情况下是这样。 在Alpha/早期发布阶段,当用户还主要是内部人员或早期测试用户时,编辑器有时会静默地停止保存内容。你继续打字,一切看起来正常,但你的编辑不再同步到Yjs文档。下次打开页面时,从故障点之后的所有内容都消失了。 这极其可怕,非常罕见而且几乎无法诊断,因为我们从未能复现。我们真的试过了!最初我怀疑是WiFi连接不稳定和WebSocket行为异常,但无论怎样限速或开关WiFi,都无法复现问题。回想起来,那些场景下的体验竟然异常顽强。感觉就像随机发生,从不在有人看着的时候出现。控制台没有明显错误,没有堆栈跟踪,没有崩溃。只是……“嘿,我觉得我的修改没保存。” 然后有一天,我们的产品经理破解了它。找到这个问题绝非易事。他遇到的情况比任何人都多(大概是因为他最擅长吃自己的狗粮),并且一直在有条理地缩小范围。 *“我感觉自己快疯了,但我认为问题出在我把特定字符组合在一起后,再回去在它们之间插入一个字符……”* 他每周的项目状态邮件中会用 🟢 和 🔴 来表示总体健康状况:绿色表示正常,红色表示有风险。每周他使用的模板里这两个字符都已经存在,他只需要删除不需要的那个(一般是红色那个,我很高兴地说!)。 有一次,他把绿色圆复制粘贴到红色圆的前面(或者反过来)。这个特定操作——将一个多字节emoji插入到另一个旁边——触发了底层CRDT库的拼接操作,把一个代理对从中间劈开。 我记得当时在通话中,他向我以及我的一位直接下属(这位同事一直在为协作编辑过渡辛勤工作)展示了这个问题。我可能有点过于兴奋了——我就是喜欢稀奇古怪的Bug——“我觉得你被这件事*激发了能量*,”他说。他说得没错。 更有趣的是,并非所有emoji都会触发这个Bug。只有那些在`U+FFFF`以上、需要代理对的emoji才会触发。而且也不是所有编辑都会导致问题——只有那些恰好在不正确的字节偏移量上引起**拼接**的操作才会。在我们搞清楚之前,这真是个疯狂的调试经历。 ## 码元、码点和字形簇 那么到底发生了什么?上面那段话里的“`U+FFFF`以上”是什么意思?什么字节偏移? 要理解这个Bug,我们需要引入三个词汇: `` 码元 → 码点 → 字形簇 `` **码元**是JavaScript内部存储字符串时使用的原始16位值(UTF-16)。这是`.length`计数的东西。这也是`.slice()`和`.charCodeAt()`操作的对象。JavaScript默认在码元级别操作。 **码点**是Unicode实际定义为单个字符的东西。像U+1F920(🤠)这样的码点在Unicode看来是一个字符,但它太大,无法放入一个16位码元。因此UTF-16将其拆分为两个码元,称为**代理对**:一个高代理和一个低代理。简单的ASCII字符和许多常见符号适合一个码元,因此对它们来说这种区别无关紧要。但emoji呢?几乎总是两个。 **字形簇**是人类感知为“一个字符”的东西。女宇航员 👩🚀 看起来像是一个字符,但实际上是由三个码点粘合而成:👩(女人)+ 零宽度连接符 + 🚀(火箭)。五个码元,三个码点,一个字形。看似简单的 👨👨👧👧(家庭:男人、男人、女孩、女孩)emoji竟然有十一个!神秘的 ☃ 只有一个。 以下是这些数字的差异: 码元|码点|字形 ---|---|--- A|1|1|1 🤠|2|1|1 👩🚀|5|3|1 👨👨👧👧|11|7|1 我再次暂停,为我在开头提到的[交互式代理对探索器](https://george.mand.is/invalid-surrogate-pairs/)做个广告。你可以输入任何emoji,亲自查看这个分解过程! 来自我的代理对探索器的截图(https://george.mand.is/invalid-surrogate-pairs) ## `.slice()`如何破坏东西 牛仔 🤠 是一个码点,存储为两个码元(一个代理对)。如果在它们之间切片: `` "🤠".slice(0, 1); // → '\uD83E' (孤立的商代理) "🤠".slice(1, 2); // → '\uDD20' (孤立的低代理) `` 这些片段不是有效字符。它们是半对,没有伙伴。单独渲染时,它们会显示为替换字符(�)或静默地被吞掉。但真正的问题出现在你试图编码其中一个时: `` encodeURIComponent("🤠".slice(0, 1)); // URIError: URI malformed `` 这就是导致我们的工具崩溃的原因。 ## 实际发生了什么 Yjs依赖一个名为lib0的实用库。lib0的`splice`方法内部使用了JavaScript的`.slice()`。当CRDT操作恰好落在一个emoji代理对的两个半部分之间时,lib0会产生一个带有孤立代理项的字符串。该字符串最终会在同步期间传递给`encodeURIComponent`,从而抛出一个未捕获的`URIError`。 这个错误没有被捕获。Yjs或TipTap的错误处理都没有捕获它。所以同步就……停止了。编辑器在本地继续工作,给你一切正常的假象,而你的修改却无声无息地消失了。 它只出现在病态的编辑中:用一个emoji替换另一个,或者在两个emoji之间插入一个字符。 ## 我们发布的临时方案 我们无法修复lib0——尽管我很高兴地报告它[最终被修复了](https://github.com/dmonad/lib0/commit/51ab65b46da8110d85384ccec631647de3248c96)!我们无法修补Yjs。我们需要发布一些东西。 所以我们做了两件事: - 尽管我们最初并不关心产品的[离线支持](https://tiptap.dev/docs/guides/offline-support),但添加它相当简单。我们的想法是,将来如果用户断开连接并继续输入,这可以拯救我们。我们会继续在本地更新CRDT,等他们*下次*回到文档时,他们的更改会与当前状态合并。这是一个对冲策略,利用了CRDT真正擅长和设计的目的。 - 一个令人尴尬的核选项(我的决定,上面布满了我的指纹):我们附加了一个全局的`window.addEventListener("error", ...)`监听器,用正则表达式匹配`URIError: URI malformed`。当捕获到错误时,我们会记录事件用于跟踪,并设置一个编辑器检查的状态。如果看到错误,我们会弹出一个模态框,告诉用户出了问题并要求他们重新加载页面。我像鹰一样盯着这个指标,并欣慰地发现它最终出现的频率非常低。 我们不是唯一遇到这个问题的人。上游的问题单([yjs#303](https://github.com/yjs/yjs/issues/303),[tiptap#3020](https://github.com/ueberdosis/tiptap/issues/3020))上,其他编辑器也报告了同样的问题并采用了类似的变通方案。 ## 真正的修复 最终有两件事真正解决了问题: **lib0被修补了。**上游的修复是检测切片后的字符串的第一个字符是否为高代理且没有匹配的低代理,并将其替换为U+FFFD(Unicode替换字符,�)。这不完美,但阻止了`URIError`的发生并防止同步中断。 **我们将emoji设为原子节点类型。**在ProseMirror(以及扩展的TipTap)中,你可以定义自定义节点类型。我们设置了一个扩展,让emoji成为它们自己的节点,这意味着编辑器将每个emoji视为一个不可分割的单位。光标移动和编辑操作无法将emoji分割成两半。这没有修复lib0的bug,并且带来了一些其他具有挑战性的副作用,但它消除了大多数触发问题的编辑模式。 我很高兴地报告,在这个临时的hack阶段,这个Bug*非常*罕见地出现……但当修补后的lib0版本最终落地时,我非常高兴。 ## 现代的答案 如果你在JavaScript中进行字符串操作并且关心不破坏字符,请使用`Intl.Segmenter`: `` const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" }); const segments = [...seg.segment("👩🚀A👍")].map((s) => s.segment); // → ['👩🚀', 'A', '👍'] `` 这按字形簇而不是码元进行分割。没有孤立的代理,没有分裂的emoji。这本来是`.slice()`一直以来应该做的事情,但当然UTF-16比emoji早了几十年。 ## 臭名远扬 发布修复后,我在内部通讯中写了这件事。 后续更新中的通讯截图,我在其中重新讲述了故事 这个Bug成了个内部笑话。同事们会给我发 🟢🔴——这个让一切崩溃的emoji组合。 几年后,我仍然会收到前同事突然发来的表情包和消息,关于这件事。有些bug是你修复的,有些bug修复的是……你? Slack消息:“我现在看不到单词,只看到字形和等待被分割的无效代理对” ## Unicode问题难以忽视 一旦你知道了它,你会在现实中到处看到它。任何执行`str.slice(0, 1)`或`str[0]`以获取“第一个字符”的代码都有可能出问题。最常见的罪魁祸首:从用户名生成首字母的工具。试着在任何显示头像首字母的应用中,将emoji作为名字或姓氏的第一个字符。大多数应用会做类似`firstName[0] + lastName[0]`的事,结果得到半个代理对。有些会渲染出乱码。有些会崩溃。 每次都是同一类Bug。JavaScript在你想要字符时给了你码元,而且没有人注意到,直到有人输入了[基本多文种平面](https://en.wikipedia.org/wiki/Plane_(Unicode)#Basic_Multilingual_Plane)之外的内容。 我重复我深信不疑的真理:任何东西能正常工作都是了不起的。 ## 文末链接 Monica Dinculescu有一篇[关于emoji底层工作原理的精彩文章](https://meowni.ca/posts/emoji-emoji-emoji/),如果你想深入了解,强烈推荐! 最后,再次为我的[交互式代理对探索器](https://george.mand.is/invalid-surrogate-pairs/)打个广告,你可以输入任何emoji并查看分解过程,以防你错过了上面的链接!我认为这是直观地查看并与这里讨论的概念进行交互的好方法。 来自我的代理对探索器的截图(https://george.mand.is/invalid-surrogate-pairs/) 来自我的代理对探索器的截图(https://george.mand.is/invalid-surrogate-pairs/) 来自我的代理对探索器的截图(https://george.mand.is/invalid-surrogate-pairs/) 来自我的代理对探索器的截图(https://george.mand.is/invalid-surrogate-pairs/) -- *如果你喜欢阅读,可以考虑[赞助我的工作](https://github.com/sponsors/georgemandis),[订阅我的通讯](https://buttondown.com/georgemandis)或在[Hacker News上分享](https://news.ycombinator.com/submitlink?u=https://george.mand.is/2026/05/my-favorite-bugs-invalid-surrogate-pairs/&t=)。* *发布于2026年5月14日星期四。[以纯文本阅读此文章](https://george.mand.is/2026/05/my-favorite-bugs-invalid-surrogate-pairs.txt)。*

相似文章

Unicode 字符串的等价性很奇怪 (2016)

Lobsters Hottest

Unicode 字符串等价性很复杂,尤其是涉及校对规则时,会导致意外的结果,例如删除控制字符和非确定性分组。作者讨论了在数据库系统中正确实现 Unicode 支持所面临的挑战。

当非正式文本导致自然语言推理失效:分词失败、分布偏移及针对性缓解策略

arXiv cs.CL

# 分词失败、分布偏移及针对性缓解策略 来源:[https://arxiv.org/html/2604.16787](https://arxiv.org/html/2604.16787) ## 当非正式文本导致自然语言推理失效:分词失败、分布偏移及针对性缓解策略 ###### 摘要 我们研究了在将四种转换操作应用于 SNLI 和 MultiNLI 时,非正式表层形式如何降低 ELECTRA-small(14M)和 RoBERTa-large(355M)的自然语言推理准确率:俚语替换、表情符号替换、Gen-Z 填充词,以及它们的

事故报告:CVE-2024-YIKES

Hacker News Top

一份讽刺性的事故报告描述了一场灾难性的多阶段供应链攻击,该攻击始于一个被篡改的 JavaScript 依赖项,并在 Rust 和 Python 生态系统中蔓延,最终因一只挖矿蠕虫的“意外介入”而得以解决。