返回知识中心
指纹

中止原因、信号组合与迟到的监听器

一支探针记录中止真正携带的值:默认原因、timeout 与 any 如何只挑一个、事件只触发一次,以及中止之后才挂的监听器为什么永远不跑。

BotBrowser Team

文档中心

想直接进入 指纹 文档吗?

这篇文章属于知识中心的指南库。若你要步骤化配置、参考说明和持续更新,请直接进入对应 docs 分区。

BotBrowser 支持在已声明 profile 下回放授权的同源夹具,并保存一份有界的记录,说明页面实际走到了哪几条取消路径、每条路径带的是什么原因,但它不能让中止之后才附加的监听器执行,不能把非 Error 的原因改写成 Error,也不能让一个组合信号汇报两个原因。本评测用一支探针跑十二个固定用例,记录 signal.reason 在中止前后的内容、每个内建来源产生哪个 DOMException 名称、AbortSignal.timeout() 与 AbortSignal.any() 如何在多个原因之间做出选择、abort() 被调用两次时事件触发几次、事后附加的监听器会发生什么,以及 throwIfAborted() 背后的同一性规则。

摘要

  • 任何一次中止之前,reason 属性就已经存在,读取它得到 undefined,所以「属性存在」这个判断永远不能证明发生过中止。
  • 不带参数调用 abort() 会存入一个 DOMException,其构造器是 DOMException,name 是 AbortError,消息是 This operation was aborted。
  • 显式原因按同一性保留,取消方传进去的那个对象,就是 catch 块收到的那个对象。
  • 原因不必是 Error:传 42 会让信号保持已中止状态且原因是数字,之后读 reason.message 或 reason.name 什么也拿不到。
  • AbortSignal.timeout(15) 一开始并未中止,定时器到期时报出名字为 TimeoutError 的 DOMException,而不是 AbortError。
  • AbortSignal.any([a, b]) 按原样转发获胜成员的原因,先中止的那个成员获胜,与它在数组里的位置无关。
  • 用一个已经中止的成员构造组合信号,在 any() 调用期间就已经中止,因为成员是同步检查的。
  • 对同一个控制器调用两次 abort() 只产生一个中止事件,并保留第一个原因。
  • 中止之后才附加的监听器永远不会执行,signal.onabort 同样不会,尽管 signal.aborted 已经是 true。
  • 中止事件是普通 Event,没有 reason 字段,处理函数必须读 signal.reason,而不是读自己的参数。
  • throwIfAborted() 按同一性重新抛出已存的原因,取消方自己的错误对象会原样到达调用方的 catch 块。

目录

第一栏显示一个 AbortController,中止前原因为 undefined,中止后变成名字为 AbortError 的 DOMException。第二栏显示按同一性保留的显式对象原因,以及数字原因 42。第三栏显示 AbortSignal.timeout 报出 TimeoutError,AbortSignal.any 转发第一个来源的原因。第四栏显示两次 abort 只产生一个事件、迟到的监听器不执行、throwIfAborted 重新抛出同一个错误对象。

中止前后原因里是什么

Web 平台里的取消是一条携带单个值的信号,而这个值是消费方事后唯一能检查的东西。探针先看一个刚建好的控制器:

node=v26.9.0
initial -> aborted=false hasReason=true
default-reason -> DOMException:AbortError(This operation was aborted) message="This operation was aborted"
explicit-reason -> same=true object:[object Object]
static-abort -> aborted=true string:pre-aborted
timeout-reason -> DOMException:TimeoutError(The operation was aborted due to timeout) message="The operation was aborted due to timeout"
any-reason -> same=true Error:Error(first won)
any-preborted -> aborted=true string:already
fire-once -> events=1 reasonKept=number:1
late-listener-after-task -> not-yet onabort=false aborted=true
throwIfAborted -> same:Error:Error(propagated)
non-error-reason -> aborted=true number:42
event-type -> Event
race -> winner=string:second-source

第一行最容易被读错。一个从未中止过的控制器已经有 reason 属性,探针的 hasReason=true 来自一次普通的 'reason' in signal 判断。这个属性是接口的一部分,不是取消的标记,所以一个靠「有没有这个属性」决定是否走快路径的辅助函数,永远都会走快路径。真正回答问题的两个字段是 signal.aborted,以及关心原因时的 signal.reason !== undefined。

默认原因是一个 DOMException。探针把它的身份两半都打出来了:DOMException:AbortError。这个拆分值得记住,因为按 error.constructor.name 分类的日志辅助函数会把中止和任何其它平台异常归到一组,而按 error.name 分类的辅助函数能把它们分开。两个字符串都没有提到是哪段代码取消的,而 This operation was aborted 是平台自己的措辞,不是对被停下工作的描述。

传一个原因改变的是消费方看到什么,不是存储规则。探针用一个普通对象中止,然后拿存入值和原对象做严格相等比较,结果是 same=true。进入时没有复制、包装、克隆或序列化。这一点有两个后果:其一,消费方可以直接从 signal.reason 读到自己定义的字段,比如请求编号或重试计数;其二,中止之后修改这个对象对所有后续读者可见,因为对象只有一个,而事后想把原因冻结的消费方其实是在改动一个共享值,不是保护副本。

接口也接受不是错误的值。探针用数字 42 中止,信号报告 aborted=true,原因是 number:42。为错误写的取消处理函数这时会去读 reason.name 得到 undefined,而调用 reason.message.length 的处理函数会在自己的错误分支里抛出 TypeError,这是出现第二次故障最糟的位置。防御性的写法是在处理函数开头放一个小归一化器:原因是 Error 就直接用,否则构造一个把该值放进字段的错误。

AbortSignal.abort() 把整个序列短路。探针用字符串 pre-aborted 调用它,得到一个创建时就已经中止的信号,原因就是这个字符串。这个静态构造器是唯一能在没有中间控制器的情况下拿到已中止信号的途径,对一个想返回「已取消,不要再继续」的结果、又不想分配一个以后再也不用的控制器的函数来说,它是自然选择。

中止事件本身什么也不携带。探针先注册监听器、再中止,然后问处理函数拿到的是什么类型的对象,打印出来是 Event。这个对象上没有 reason 字段,所以常见写法 signal.addEventListener('abort', event => log(event.reason)) 会打印 undefined,而原因就在信号上隔一个属性的位置。在中止之后再读 signal.reason 也仍然有效,当中止和处理函数分处不同模块时,这正是要紧的那种情况。原因放在信号上而不是事件上,和被拒绝的 Promise 把错误放在 Promise 上是同一个道理,Promise 组合器的失败形态说明了 all、allSettled 和 any 各自如何汇报是哪个成员失败。

timeout 与 any 如何只挑一个原因

平台附带两个组合来源,各自回答不同的问题。AbortSignal.timeout() 回答「到了一定时间之后」,AbortSignal.any() 回答「这几个里先发生哪一个」。两者产生的信号形状与其它信号一样,且都只挑一个原因,而不是合并。

AbortSignal.timeout(15) 一开始是 aborted=false,探针在等待之前就记下了这一点。定时器到期时,原因是一个名字为 TimeoutError、消息为 The operation was aborted due to timeout 的 DOMException。这个名字与普通的 AbortError 不同,而这个差别是区分「到期」和「有人主动取消」的唯一结构性线索,前提是你没有自己创建那条信号。把 AbortError 当作终结、把 TimeoutError 当作可重试的重试策略完全依赖这次名字判断,所以只跟 AbortError 比较的处理函数,会把一次超时当成永久停止,而且不吭声。

时长也被嵌在消息文本里,想把它解析出来的辅助函数拿到的是平台措辞,不是一个干净的数字。需要知道是哪一段期限到期的页面,得用自己的表按传入的值去索引,因为平台不会把延时时长作为字段返回。

AbortSignal.any() 接收一组信号,返回一个在第一个成员中止时中止的信号。探针创建两个控制器、把它们组合,先用一个带名字的错误中止第一个,再中止第二个,然后拿组合信号的原因和第一个错误做严格相等比较,结果是 same=true。组合按同一性只转发一个原因,并忽略之后的所有成员,所以想知道是哪个来源触发的消费方,必须在传入之前把这条信息挂到原因上,通常是给每个成员的原因套一个带来源标签的对象。组合信号一旦中止,其它成员之后的中止通过它就完全观察不到。

探针还表明组合是即时的,不是惰性的。用一个已经中止的信号和一个仍然活着的信号构造组合,结果在 any() 调用期间就已经中止,原因是取自那个已经死掉的成员的 already。返回的信号没有任何时间是活的。所以,把调用方的信号和一个新期限组合起来的函数必须处理调用方信号早已死掉的情况,也不能假定期限那条分支有朝一日会被走到。

竞态用例把剩下的歧义固定下来。探针先中止第二个控制器、再中止第一个,然后读组合的原因,打印出来的获胜者是第二个来源。决定胜负的是 abort() 调用的顺序,不是数组的顺序,这意味着一个把期限列在前面、把用户取消列在后面的页面,在用户先动手时依然报出用户取消。这是绝大多数调用方想要的行为,值得明确说出来,因为一个遍历数组并偏好靠前成员的实现,在任何单一来源的测试里都会看起来完全一样。

把两个来源合起来用时,名字的价值就体现出来。AbortSignal.any([AbortSignal.timeout(5000), userSignal]) 产生的信号,在期限到时报告 TimeoutError,在调用方取消时报告调用方自己的原因。一个读 reason.name 的 catch 块,就能在不额外维护标志的情况下区分到期和取消;而一个传入带错误码的 Error 的取消,错误码会被保留,因为组合保留的是对象本身,不是拿平台异常去替换。

中止事件触发几次

控制器是一次性器件,探针把这一点对事件和已存原因的含义都钉住了。它对同一个控制器用两个不同的值调用两次 abort(),并统计中止事件的数量,打印出来是 events=1,保留的原因是第一个值。所以 abort() 对事件而言是幂等的,第一个原因即为最终结果。一个既从期限处理函数、又从清理路径调用 abort() 的页面,不需要自己加锁来避免重复投递,不过当第二次调用在走到 abort() 之前还要做实际工作时,加锁仍然值得。

迟到的监听器是整个接口里最锋利的边角。探针先中止,再注册监听器,再设置 signal.onabort,然后等满一个宏任务。打印出来是 late-listener-after-task -> not-yet onabort=false aborted=true。两种写法都没有执行。再等更久也没用,因为事件在更早那次 abort() 调用期间就已经派发完毕,对后来的订阅者没有重播。因此,一个把信号藏在接口后面、在消费它的函数内部才挂监听器的辅助函数有一个静默的漏洞:如果中止发生在调用这个辅助函数之前,辅助函数就会一直等下去。

正确的写法是先查标志。想在任何一条分支上都只执行一次的消费方,先读 signal.aborted,立刻处理已经死掉的情况,然后才为活着的情况附加监听器,并带 { once: true },让处理函数在唯一一次派发之后被移除。探针自己等待期限原因用的正是这个模式,这也是期限用例必须另外留一个定时器存活的原因:中止来自探针无法控制的来源,监听器是唯一能观察到它的方式。

还有第二个更细微的后果。因为在中止之前注册的监听器是在 abort() 调用期间执行的,同一栈上 abort() 之后的代码已经能看到处理函数的效果。这让 abort() 对已有订阅者变成同步广播,对其余所有人则是空操作,这种组合的行为与带缓冲的事件发射器不同。一个先中止、随即断言某个监视函数被调用的测试会看到它,而同样思路写在异步发射器上的测试则必须等待。这种同步投递就是微任务与定时器描述的同一个回合边界:以微任务入队的回调会在下一个任务之前执行,而派发期间被调用的监听器比两者都早。

throwIfAborted 为什么抛出同一个对象

信号提供一个把已中止状态变成抛出值的守卫,探针检查到底抛出了什么。它用一个带名字的错误中止,调用 throwIfAborted(),然后拿捕获到的值和原对象做严格相等比较,打印结果是 same:Error:Error(propagated),说明守卫重新抛出的是已存原因,而不是新的包装。调用方的 catch 块因此收到的是取消方自己的对象,连同挂在它上面的字段,这是把请求编号或原因码跨模块边界传递最省事的方式。

信号活着的时候守卫什么都不做,所以它可以放在每个即将开工的步骤开头。这个位置就是让取消变得及时的原因:一个在每次迭代前调用守卫的循环,会在中止之后跑到的第一次迭代就停下,而不是等到下一个 await 边界。在 async 函数里,同步抛出会自动变成被拒绝的 Promise,这通常正是调用方期待的形态;在普通函数里,它按普通异常传播。

检查不该假定的,是抛出来的值具有错误的形状。探针最后一个用例用数字 42 中止,确认 aborted 为 true、原因是 number。记录 error.name 的 catch 块会记下 undefined,而调用 error.message.trim() 的块会在处理一次取消时自己抛出。能在所有观察到的用例里成立的规则是:查 signal.aborted,把 reason 当作未知值,检查之后才收窄为 Error。这个顺序也让组合信号保持诚实,因为期限原因是 DOMException,而取消原因可以是任何东西。

记录一次中止读数

一条中止读数支撑的是一个窄结论:这个运行时、这个来源、这个原因,产生了这些观察。四个字段让它保持可核对:

  1. 来源类型,因为普通控制器、静态中止、期限和组合,各自按不同规则挑选原因。
  2. 原因的构造器与名字,因为 AbortError 与 TimeoutError 共用构造器,只在名字上不同。
  3. 同一性结果,因为把捕获值拿来和取消方对象比较的消费方,依赖的是转发而不是复制。
  4. 监听器是在中止之前还是之后附加的,因为迟到的监听器不管等多久都会被跳过。

公开来源

实践结论

把原因当作取消唯一携带的值,把信号当作一次性标志:附加监听器之前先读 signal.aborted,一条记录描述取消时写明来源,需要区分到期和手动取消时比较 reason.name 而不是原因的构造器。同一机制在调度侧的表现见调度器优先级与任务后工作,它说明取消令牌如何改变排队工作的退场顺序;在资源侧的表现见端口保护,那里用一条有界的期限信号让本地探针不至于一直挂住。

#AbortSignal#AbortController#取消#DOMException#异步

本文属于指南: 什么是浏览器指纹?原理、风险、信号与隐私边界

让 BotBrowser 从研究走向生产

先用这些指南理解模型,再进入跨平台验证、隔离上下文和面向规模化的浏览器部署。