Node.js 20.3+:用 AbortSignal.any 合并超时与主动取消

83 次浏览3 条回复

一个异步操作往往同时受两类取消条件约束:自身执行超时,以及进程收到的主动停止请求。与其让业务函数分别监听多个信号,可以在调用边界用 AbortSignal.any() 合并,再把单一 signal 向下传递。

环境:Node.js 20.3+;Linux、macOS 或 Windows;仅使用内置模块;项目使用 ESM。

创建 cancel-demo.mjs:

import { setTimeout as sleep } from 'node:timers/promises';

async function runJob({ signal }) {
  for (let step = 1; step <= 10; step += 1) {
    await sleep(100, undefined, { signal });
    console.log(`step ${step}`);
  }
}

const shutdown = new AbortController();
process.once('SIGINT', () => {
  shutdown.abort(new Error('shutdown requested'));
});

const signal = AbortSignal.any([
  shutdown.signal,
  AbortSignal.timeout(250),
]);

try {
  await runJob({ signal });
} catch (error) {
  if (error.name !== 'AbortError') {
    throw error;
  }

  const reason = signal.reason;
  console.error(
    'cancelled:',
    reason instanceof Error ? `${reason.name}: ${reason.message}` : reason,
  );
}

运行:

node cancel-demo.mjs

未进行人工操作时,通常先输出 step 1、step 2,随后输出类似:

cancelled: TimeoutError: The operation was aborted due to timeout

也可以在超时前按 Ctrl+C,此时合并信号保留主动取消的原因,输出 cancelled: Error: shutdown requested。具体完成几个步骤受调度时序影响,不应据此写精确断言。

关键点是让 runJob 只接收外部信号,不在函数内部私自创建计时器;这样调用方可以组合超时、用户取消和服务关闭等策略。node:timers/promises、内置 fetch 等支持 signal 的 API 都可复用同一模式。捕获拒绝时检查的是操作抛出的 AbortError,而区分取消来源时读取合并信号的 reason。

补一个跨 API 复用时的细节:示例对 node:timers/promises 成立,它通常拒绝一个 AbortError,取消来源则在 signal.reason(以及该错误的 cause)里;但 Node.js 内置 fetch 遇到自定义取消原因时,可能直接以 signal.reason 拒绝。因此,不宜把仅检查 error.name === 'AbortError' 抽成所有 API 共用的判断。

环境:Node.js 20.3+。可以在调用边界统一写成:

function isCancellation(error, signal) {
  return signal.aborted && (
    error === signal.reason ||
    error?.cause === signal.reason ||
    error?.name === 'AbortError'
  );
}

try {
  await runJob({ signal });
} catch (error) {
  if (!isCancellation(error, signal)) throw error;
  console.error('cancelled:', signal.reason);
}

这里保留 signal.aborted 作为前置条件,可避免仅凭错误名把普通业务异常误判为取消。若只针对当前的 sleep 示例,原写法已经足够;这个分支主要用于后续把同一组合信号传给 fetch 或混合多个支持取消的 API。

还可以把“第一个取消原因会被保留”写成不依赖真实计时的测试,避免 CI 负载导致毫秒级断言波动。

环境:Node.js 20.3+;仅使用内置模块。创建 cancellation.test.mjs:

import assert from 'node:assert/strict';
import test from 'node:test';
import { setTimeout as sleep } from 'node:timers/promises';

test('AbortSignal.any 保留最先触发的原因', async () => {
  const shutdown = new AbortController();
  const deadline = new AbortController();
  const signal = AbortSignal.any([
    shutdown.signal,
    deadline.signal,
  ]);
  const reason = new Error('shutdown requested');

  shutdown.abort(reason);
  deadline.abort(new DOMException('deadline', 'TimeoutError'));

  await assert.rejects(
    sleep(1_000, undefined, { signal }),
    (error) => {
      assert.equal(signal.reason, reason);
      assert.equal(error.name, 'AbortError');
      assert.equal(error.cause, reason);
      return true;
    },
  );
});

运行 node --test cancellation.test.mjs。这里用两个可手动触发的控制器验证组合语义;生产代码仍可把第二个信号替换为 AbortSignal.timeout(...)。这样测试关注的是取消协议,而不是调度器恰好在哪一毫秒执行回调。

再补一个把示例封装进可复用函数时容易遗漏的生命周期问题:process.once('SIGINT', ...) 只有在信号真正到来后才会自动移除。若任务先正常完成或超时,而调用方随后再次启动任务,旧监听器仍留在 process 上,循环调用还可能触发 MaxListenersExceededWarning。

环境:Node.js 20.3+;仅使用内置模块。可以保存回调引用,并在 finally 中清理:

const shutdown = new AbortController();
const onSigint = () => {
  shutdown.abort(new Error('shutdown requested'));
};
const signal = AbortSignal.any([
  shutdown.signal,
  AbortSignal.timeout(250),
]);

process.once('SIGINT', onSigint);
try {
  await runJob({ signal });
} catch (error) {
  const isCancellation = signal.aborted && (
    error === signal.reason ||
    error?.cause === signal.reason ||
    error?.name === 'AbortError'
  );
  if (!isCancellation) throw error;
  console.error('cancelled:', signal.reason);
} finally {
  process.off('SIGINT', onSigint);
}

对只执行一次、随后退出的顶层脚本,原写法足够;当这段逻辑进入测试、后台任务重启流程或库函数时,显式清理更重要。即使 SIGINT 已触发,off 也只是无操作,因此可以统一放进 finally。