或者:SpiderMonkey 调试器的实现(及其清理)
在过去两年中,我们对 Firefox DevTools 中的 JavaScript 调试进行了重大改进。开发者反馈为我们的工作提供了信息并验证了我们的工作,这些工作包括性能、源映射、单步执行可靠性、漂亮打印以及更多类型的断点。感谢您。如果您一段时间没有尝试使用 Firefox 来调试现代 JavaScript,现在是时候了。
上面提到的许多工作都集中在调试器前端(使用 React 和 Redux 编写)。我们能够取得稳定进展。与 SpiderMonkey(Firefox 的 JavaScript 引擎)的集成是工作进展较慢的地方。为了解决诸如异步调用堆栈(现在可以在 DevEdition 中使用)等更大的功能,我们需要进行一次重大清理。以下是我们如何做到的。
背景:JS 调试器的简要历史
Firefox 中的 JavaScript 调试器 基于 SpiderMonkey 引擎的 Debugger
API。该 API 是在 2011 年添加的。从那时起,它经历了四个 JIT 编译器的添加、其中两个的退休以及 WebAssembly 编译器的添加。所有这些都在无需对 API 的用户进行重大更改的情况下完成。Debugger
仅在开发者密切观察被调试程序的执行时才会造成性能损失。一旦开发者移开视线,程序就可以恢复到其优化路径。
一些关键决策(有些是我们做出的,有些是情况所迫)影响了 Debugger
的实现
- 无论好坏,Firefox 架构的核心原则都是不同特权级别的 JavaScript 代码可以共享一个堆。对象边缘和函数调用根据需要跨越特权边界。SpiderMonkey 的隔间确保在这一自由的环境中执行必要的安全检查。该 API 必须在隔间边界之间无缝工作。
Debugger
是一个线程内调试 API:被调试程序中的事件在触发它们的同一线程上进行处理。这使实现摆脱了线程问题,但也引入了其他类型的复杂性。Debugger
必须与垃圾回收自然地交互。如果一个对象不会被遗漏,那么垃圾收集器就应该能够回收它,无论它是Debugger
、被调试程序还是其他。Debugger
应该只观察在给定一组 JavaScript 全局对象(例如窗口或沙箱)范围内发生的活动。它不应该影响浏览器中其他地方的活动。但它也应该能够让多个Debugger
观察同一个全局对象,而不会产生太多干扰。
垃圾回收
人们通常通过说垃圾收集器回收“不可达”的对象来解释它们,但这并不完全正确。例如,假设我们编写以下代码
fetch("https://www.example.com/")
.then(res => {
res.body.getReader().closed.then(() => console.log("stream closed!"))
});
一旦我们完成执行此语句,它构造的任何对象都无法被程序的其余部分访问。尽管如此,WHATWG 规范禁止浏览器回收所有内容并终止 fetch
。如果这样做,该消息将不会被记录到控制台中,用户将知道垃圾回收已发生。
垃圾收集器遵循一个有趣的原则:只有在永远不会被遗漏的情况下才能回收对象。也就是说,只有在这样做不会对程序的未来执行产生可观察的影响的情况下才能回收对象的内存——当然,除了为进一步使用提供更多内存之外。
原则的应用
考虑以下代码
// Create a new JavaScript global object, in its own compartment.
var global = newGlobal({ newCompartment: true });
// Create a new Debugger, and use its `onEnterFrame` hook to report function
// calls in `global`.
new Debugger(global).onEnterFrame = (frame) => {
if (frame.callee) {
console.log(`called function ${frame.callee.name}`);
}
};
global.eval(`
function f() { }
function g() { f(); }
g();
`);
在 SpiderMonkey 的 JavaScript shell 中运行时(其中 Debugger
构造函数和 newGlobal
函数可以直接使用),这将打印以下内容
called function g
called function f
就像在 fetch
示例中一样,新 Debugger
在我们完成设置其 onEnterFrame
钩子后就变得无法被程序访问。但是,由于 global
范围内的所有未来函数调用都将产生控制台输出,因此垃圾收集器删除 Debugger
就不正确了。它的缺失将在 global
进行函数调用时变得可观察。
类似的推理适用于许多其他 Debugger
功能。onNewScript
钩子报告将新代码引入被调试程序全局范围内的操作,无论是通过调用 eval
、加载 <script>
元素、设置 onclick
处理程序还是类似操作。或者,设置断点会安排在代码中每次控制到达指定位置时调用其处理程序函数。在所有这些情况下,被调试程序活动都会调用注册到 Debugger
的函数,这些函数可以执行开发者喜欢的任何操作,从而产生可观察的影响。
但是,这种情况有所不同
var global = newGlobal({ newCompartment: true });
new Debugger(global);
global.eval(`
function f() { }
function g() { f(); }
g();
`);
在这里,创建了新的 Debugger
,但在没有设置任何钩子的情况下被丢弃。如果此 Debugger
被处置,没有人会注意到。它应该有资格被垃圾收集器回收。更进一步说,在上面的 onEnterFrame
示例中,如果 global
变得不再必要,没有计时器、事件处理程序或待处理的 fetch 在其中运行代码,那么 global
、它的 Debugger
及其处理程序函数都必须有资格被回收。
原则是 Debugger
对象对 GC 来说没有什么特殊之处。它们只是让我们观察 JavaScript 程序执行的对象,并且遵循与其他对象相同的规则。JavaScript 开发者很乐意知道,如果他们只是避免不必要的纠缠,系统将在安全的情况下立即处理好为他们清理内存。这种便利性也扩展到使用 Debugger
API 的代码。
实现
从上面的描述中可以清楚地看出,当 Debugger
具有 onEnterFrame
钩子、onNewScript
钩子或类似的东西时,它的被调试程序全局对象会保存对它的拥有引用。只要这些全局对象还活着,Debugger
也必须保留。清除所有这些钩子应该会删除该拥有引用。因此,全局对象的存活不再保证 Debugger
会存活。(系统中其他地方的引用当然可能仍然存在。)
这正是它实现的方式。在 C++ 层面上,每个 JavaScript 全局对象都关联着一个 JS::Realm
对象,它拥有一张 DebuggerLink
对象表,每个被调试程序都有一个 DebuggerLink
对象。每个 DebuggerLink
对象保存对它的 Debugger
的一个可选强引用。这在 Debugger
具有有趣的钩子时设置,在其他情况下则清除。因此,只要 Debugger
具有设置的钩子,就有一个强路径,通过 DebuggerLink
中介,从它的被调试程序全局对象到 Debugger
。相反,当钩子被清除时,就不会有这样的路径。
在脚本中设置的断点行为类似。它就像从该脚本到断点处理程序函数及其所属 Debugger
的一个拥有引用。只要脚本还活着,处理程序和 Debugger
也必须还活着。或者,如果脚本被回收,那么这个断点肯定永远不会再被命中,所以处理程序也可以被回收。如果所有 Debugger
的断点的脚本都被回收,那么脚本就不再保护 Debugger
免于被回收了。
但是,事情并不总是那么简单。
发生了哪些变化
最初,Debugger
对象有一个 enabled
标志,当将其设置为 false
时,会立即禁用所有 Debugger
的钩子和断点。目的是提供一个控制点。这样,Firefox Developer Tools 服务器就可以使 Debugger
失效(例如,当工具箱关闭时),确保它不会对系统产生进一步的影响。当然,简单地清除 Debugger
的被调试程序全局对象集(无论如何我们都需要这种功能)几乎具有完全相同的效果。因此,这意味着 enabled
标志是多余的。但是,我们认为,一个简单的布尔标志能造成什么麻烦呢?
我们没有预料到的是,enabled
标志的存在使上面描述的简单实现似乎不切实际。将 enabled
设置为 false
是否真的应该清除被调试程序脚本中的所有断点?将它重新设置为 true
是否应该将它们全部放回?这似乎很荒谬。
因此,我们没有将全局对象和脚本视为它们拥有对其感兴趣的 Debugger
的引用,而是在垃圾回收过程中添加了一个新阶段。一旦收集器找到了尽可能多的要保留的对象,我们就会循环遍历系统中的所有 Debugger
。我们会询问每个 Debugger
:你的任何被调试程序是否肯定会保留?你是否设置了任何钩子或断点?你是否已启用?如果是这样,我们就将 Debugger
本身标记为要保留。
当然,一旦我们决定保留 Debugger
,我们还必须保留它或其处理程序函数可能使用的任何对象。因此,我们会重新启动垃圾回收过程,让它第二次运行到耗尽,并重复扫描所有 Debugger
。
清理垃圾回收
在 2019 年秋季,Logan Smyth、Jason Laster 和我进行了一系列调试器清理工作。 这段名为 Debugger::markIteratively
的代码是我们目标之一。 我们删除了 enabled
标志,引入了上面描述的拥有边(以及其他边),并将 Debugger::markIteratively
缩减到可以安全移除的程度。 这项工作被提交为 错误 1592158:“删除 Debugger::hasAnyLiveFrames
及其可恶的帮凶”。(实际上,在一次偷袭中,Logan 在修补一个阻断程序的补丁时将其删除了,错误 1592116。)
负责垃圾收集的 SpiderMonkey 团队成员也对我们的清理表示感谢。 它从垃圾收集器中删除了一个棘手的特殊情况。 替换的代码在外观和行为上更像是 SpiderMonkey 中的其他所有代码。 “这指向那;因此,如果我们保留这,我们最好也保留那” 这一理念是垃圾收集器的标准路径。 因此,这项工作将 Debugger
从一个令人头疼的问题变成了(几乎)另一种对象类型。
隔间
Debugger
API 也给垃圾收集器维护人员带来了其他难题,因为它与 SpiderMonkey 隔间 和区域进行交互。
在 Firefox 中,JavaScript 堆通常包含来自不同特权级别和来源的对象。 Chrome 对象可以引用内容对象,反之亦然。 自然,Firefox 必须对这些对象如何交互实施某些规则。 例如,内容代码可能只被允许在 Chrome 对象上调用某些方法。 或者,Chrome 代码可能只想看到对象的原始 Web 标准指定方法,而不管内容如何玩弄其原型或重新配置其属性。
(请注意,Firefox 正在进行的 ‘Fission’ 项目将把来自不同来源的 Web 内容隔离到不同的进程中,因此跨来源边将变得不那么常见。 但即使在 Fission 之后,Chrome 和内容 JavaScript 代码之间仍然会有交互。)
运行时、区域和领域
为了实现这些检查,支持垃圾收集,并支持规范的 Web,Firefox 将 JavaScript 世界划分为以下部分:
- 一个可能相互交互的完整 JavaScript 对象世界称为运行时。
- 运行时对象被划分为区域,它们是垃圾收集的单位。 每次垃圾收集处理一组特定的区域。 通常每个浏览器选项卡有一个区域。
- 每个区域被划分为隔间,它们是来源或特权的单位。 给定隔间中的所有对象具有相同的来源和特权级别。
- 一个隔间被划分为领域,对应于 JavaScript 窗口对象,或其他类型的全局对象,例如沙箱或 JSM。
每个脚本都被分配到一个特定的领域,具体取决于它是如何加载的。 每个对象都被分配到一个领域,具体取决于创建它的脚本。
脚本和对象只能直接引用其自身隔间中的对象。 对于跨隔间引用,每个隔间都会保留一组专门的代理,称为跨隔间包装器。 每个包装器都代表另一个隔间中的一个特定对象。 这些包装器会拦截所有属性访问和函数调用,并应用安全检查。 这样做是为了根据包装器隔间及其引用隔间的相对特权级别和来源来决定它们是否应该继续。 SpiderMonkey 不会将对象从一个隔间传递或返回到另一个隔间,而是会在目标隔间中查找该对象的包装器(如果不存在则创建)。 然后,它会传递包装器而不是对象。
包装隔间
一个广泛的断言系统(在垃圾收集器中,以及在 SpiderMonkey 的其他部分)验证从不创建直接的跨隔间边。 此外,脚本只能直接触及自身隔间中的对象。
但是,由于每个跨隔间引用都必须由一个包装器拦截,因此隔间的包装器表也形成了所有跨区域引用的方便注册表。 这正是垃圾收集器需要分别收集一组区域的信息。 如果一个对象在其自身区域之外的隔间中没有代表它的包装器,那么收集器就会知道。 无需检查整个运行时。 如果该对象被回收,其他区域也不会错过它。
跨隔间调试
Debugger
API 的 Debugger.Object
对象给这个整齐的机制带来了一些麻烦。 由于调试器服务器是特权 Chrome 代码,而被调试者通常是内容代码,因此它们属于不同的隔间。 这意味着 Debugger.Object
指向其引用的指针是一个跨隔间引用。
但是 Debugger.Objects
不能是跨隔间包装器。 一个隔间可能有多个 Debugger
对象,每个对象都有自己的一组 Debugger.Objects
,因此在一个隔间中可能有多个 Debugger.Objects
引用同一个被调试者对象。(Debugger.Script
和其他 API 对象也是如此。 为了简单起见,我们这里将重点放在 Debugger.Object
上。)
以前,SpiderMonkey 通过要求每个 Debugger.Object
与隔间包装器表中的一个特殊条目配对来应对这种情况。 表的查找键不仅仅是一个外部对象,而是一个 (Debugger
,外部对象) 对。 这保持了隔间的包装器表记录所有跨隔间引用的不变性。
不幸的是,这些条目需要特殊处理。 如果其隔间中的对象不再指向一个普通的跨隔间包装器,则可以将其删除,因为可以按需构建一个等效的包装器。 但是,只要 Debugger
和被引用者还活着,Debugger.Object
就必须保留。 用户可能会在 Debugger.Object
上放置一个自定义属性,或者将它用作弱映射中的键。 该用户可能希望在再次遇到相应的被调试者对象时找到该属性或弱映射条目。 此外,还需要特别注意确保包装器表条目与 Debugger.Object
的创建同步可靠地创建和删除,即使出现内存不足错误或其他中断也是如此。
清理隔间
作为我们 2019 年秋季代码清理的一部分,我们删除了特殊的包装器表条目。 通过简单地咨询 Debugger
API 自身的 Debugger.Objects
表,我们改变了垃圾收集器查找跨隔间引用的方式。 这是 Debugger
特定的代码,我们当然希望避免这种情况,但之前的安排也是 Debugger
特定的。 目前的做法更加直接。 它看起来更像是普通的垃圾收集器跟踪代码。 这消除了对两个表之间仔细同步的需要。
强制返回和异常
当 SpiderMonkey 调用 Debugger
API 挂钩来报告被调试者中的某种活动时,大多数挂钩可以返回一个恢复值来表明被调试者应该如何继续执行
undefined
表示被调试者应该正常进行,就好像什么都没有发生一样。- 返回一个形如
{ throw: EXN }
的对象意味着被调试者应该继续执行,就好像值EXN
被作为异常抛出一样。 - 返回一个形如
{ return: RETVAL }
的对象意味着被调试者应该立即从当前正在运行的任何函数中返回,RETVAL
作为返回值。 null
表示被调试者应该终止,就好像被慢速脚本对话框终止一样。
在 SpiderMonkey 的 C++ 代码中,有一个名为 ResumeMode
的枚举类型,它具有 Continue
、Throw
、Return
和 Terminate
的值,分别代表这些可能性。 SpiderMonkey 中每个需要向 Debugger
报告事件并尊重恢复值的站点都需要有一个针对这些情况的 switch
语句。 例如,字节码解释器中用于进入函数调用的代码如下所示
switch (DebugAPI::onEnterFrame(cx, activation.entryFrame())) {
case ResumeMode::Continue:
break;
case ResumeMode::Return:
if (!ForcedReturn(cx, REGS)) {
goto error;
}
goto successful_return_continuation;
case ResumeMode::Throw:
case ResumeMode::Terminate:
goto error;
default:
MOZ_CRASH("bad DebugAPI::onEnterFrame resume mode");
}
发现相关的 SpiderMonkey 约定
但是,Logan Smyth 注意到,除了 ResumeMode::Return
之外,所有这些情况都已经由 SpiderMonkey 的“易错操作”约定涵盖。 根据此约定,可能失败的 C++ 函数应接受一个 JSContext*
参数,并返回一个 bool
值。 如果操作成功,它应该返回 true
;否则,它应该返回 false
并设置给定 JSContext
的状态,以指示抛出的异常或终止。
例如,鉴于 JavaScript 对象可以是代理或具有 getter 属性,因此从对象中获取属性是一种易错操作。 因此,SpiderMonkey 的 js::GetProperty
函数具有以下签名
bool js::GetProperty(JSContext* cx,
HandleValue v, HandlePropertyName name,
MutableHandleValue vp);
值 v
是对象,name
是我们希望从中获取的属性的名称。 如果成功,GetProperty
会将值存储在 vp
中并返回 true
。 如果失败,它会告诉 cx
出了什么问题,并返回 false
。 调用此函数的代码可能如下所示
if (!GetProperty(cx, obj, id, &value)) {
return false; // propagate failure to our caller
}
SpiderMonkey 中各种各样的函数都遵循此约定。 它们可以像评估脚本一样复杂,也可以像分配对象一样简单。(有些函数返回 nullptr
而不是 bool
,但原理相同。)
此约定概括了四种 ResumeMode
值中的三种
ResumeMode::Continue
等效于返回true
。ResumeMode::Throw
等效于返回false
并设置JSContext
上的异常。ResumeMode::Terminate
等效于返回false
但不设置JSContext
上的异常。
此约定不支持的唯一情况是 ResumeMode::Return
。
基于 SpiderMonkey 约定构建
接下来,Logan 观察到 SpiderMonkey 已经负责将所有堆栈帧弹出报告给 DebugAPI::onLeaveFrame
函数,以便 Debugger
可以调用帧 onPop
处理程序并执行其他簿记工作。 因此,原则上,要强制立即返回,我们可以
- 将所需的返回值存储在某个地方;
- 返回
false
而不设置异常以强制终止; - 等待终止传播到当前函数调用,此时 SpiderMonkey 将调用
DebugAPI::onLeaveFrame
; - 恢复我们存储的返回值,并将其存储在堆栈帧中的正确位置; 最后
- 返回
true
,就好像什么都没发生一样,模拟普通返回。
使用这种方法,将不需要 ResumeMode
枚举或 DebugAPI
调用站点上的特殊处理。 SpiderMonkey 针对引发和传播异常的普通规则已经非常熟悉任何 SpiderMonkey 开发人员。 这些规则为我们完成了所有工作。
事实证明,用于存储返回值和识别 DebugAPI::onLeaveFrame
中需要干预的机制已经存在于 SpiderMonkey 中。 郭叔玉几年前就实现了它,以处理涉及慢速脚本超时和单步执行的罕见情况。
通过这些见解,Logan 能够将 SpiderMonkey 报告活动给 Debugger
的调用站点转换为类似于其他任何易错函数的调用站点。 上面显示的 DebugAPI::onEnterFrame
调用现在简单地读取为
if (!DebugAPI::onEnterFrame(cx, activation.entryFrame())) {
goto error;
}
其他清理
作为我们 2019 年秋季工作的一部分,我们进行了一些其他小的清理工作。
- 我们将文件 `js/src/vm/Debugger.cpp`(最初有 14k 行,包含完整的 `Debugger` 实现)拆分为八个单独的源文件,并将它们移至目录 `js/src/debugger`。 Phabricator 不会再因为文件长度而拒绝着色。
- 每个 `Debugger` API 对象类型,`Debugger.Object`、`Debugger.Frame`、`Debugger.Environment`、`Debugger.Script` 和 `Debugger.Source`,现在都由其自己的 `js::NativeObject` 的 C++ 子类表示。这使我们能够使用 C++ 提供的组织工具来构建和范围化它们的实现代码。我们还可以用类型替换 C++ 代码中的动态类型检查。编译器可以在编译时检查这些类型。
- 允许 `Debugger.Script` 和 `Debugger.Source` 引用 JavaScript 和 WebAssembly 代码的代码已简化,因此 `Debugger::wrapVariantReferent` 不再需要五个模板参数,而只需要一个,并且可以由 C++ 编译器推断出来。
我相信这项工作已经极大地改善了必须处理 `Debugger` 实现的工程师的生活质量。我希望它能够在未来几年内继续有效地为 Firefox 服务。
关于吉姆·布兰迪
关于 哈拉尔德·基什纳 (digitarald)
哈拉尔德 "digitarald" 基什纳是 Firefox 开发者体验和工具的产品经理,致力于赋能创造者编码、设计和维护一个对所有人开放且可访问的网络。在他在 Mozilla 的 8 年时间里,他在性能、Web API、移动设备、可安装的 Web 应用程序、数据可视化和开发者外联项目中不断提升自己的技能。