workerd ReadableStream 测试套件深度解析:双实现对齐、18 条行为分歧台账与 WPT 补全策略
2026/9/16 11:18:01 网站建设 项目流程

workerd ReadableStream 测试套件深度解析:双实现对齐、18 条行为分歧台账与 WPT 补全策略

【免费下载链接】workerdThe JavaScript / Wasm runtime that powers Cloudflare Workers项目地址: https://gitcode.com/GitHub_Trending/wo/workerd

workerd 内置了两套 Web Streams 实现(C++ 传统实现与 TypeScript 新实现),src/tests/streams/readable/是专门针对"默认值流"ReadableStream的 WPT 风格测试套件。本篇基于该目录的规范文档 readable/AGENTS.md 展开,讲清这套"测试即规范"(the tests are the normative artifact)的测试是如何在 C++ 与 TypeScript 两套实现之间钉住每一条行为差异(Divergence Ledger)、如何配合兼容标志(compatibility flags)划分测试单元(cell),以及每个测试模块的覆盖边界,帮助阅读 workerd 源码或维护 Web Streams 兼容性测试的工程师快速建立全景认知。

一、套件定位:用"分歧台账"规范 ReadableStream 的双实现行为

readable/AGENTS.md 开篇即声明了本套件的方法论:测试本身就是规范性制品。该套件是 workerd 中值型 ReadableStream(ReadableStream默认形态,含ReadableStreamDefaultReader/ReadableStreamDefaultController)的非正式规范,并且与测试代码保持"锁步"(lockstep)演进。文档同时划定了边界:

  • 字节流(type: 'bytes'、BYOB 场景)归属 readable-byte/ 套件;
  • pipeTo/pipeThrough的测试矩阵归属 piping/ 套件;
  • 本套件补全(COMPLEMENTS)WPT 测试(//src/wpt:streams,C++ 侧已知偏差以 expectedFailures 形式记录在 src/wpt/streams-test.ts)。

文档还揭示了一个重要的根因结论:探测 C++ 侧 expectedFailures 后发现,大量 WPT 失败并不源于行为上的能力缺口,而是收敛到少数几个构造期的分歧——最典型的是 C++ 构造函数拒绝highWaterMark: Infinity(整数转换失败)以及参数校验顺序不同。而"reentrancy 一族"测试在有限 hwm 下基本是双端对等的(parity)。这正是台账(Divergence Ledger)存在的原因:不是"让两边都变成谁都能过的模糊断言",而是分叉断言、逐条钉死

二、18 条分歧台账(C++ vs TypeScript)

台账是整个文档的核心资产。以下 18 条全部继承自 readable/AGENTS.md,"Pinned in" 列指明每条分歧被哪个测试钉住:

#领域C++TypeScript钉住位置
1垃圾 underlyingSource接受 null,拒绝数字拒绝 null(符合 spec),接受数字garbageSourceValidation
2构造参数转换顺序source.type → hwm → sizestrategy size → hwm → 然后 source.type(spec)argumentConversionOrder
3非法 hwm(-1/NaN)与 InfinityTypeError;Infinity 被拒绝(整数转换)——是大量 WPT reentrant/errors 失败的根因RangeError;Infinity 被接受(spec)highWaterMarkValidated
4pull 计数(hwm 1、enqueue-in-pull)1,1,3 —— 首次 read 由队列直接供给、不触发 pull,延迟的 pull 批量补发1,2,3(spec:start 后及每次 read 都 pull)pullCountShape
5读掉队列中最后一个 chunk 时的 pull不 pull会 pull(spec)pullOnLastChunkRead
6start() 同步抛错被捕获,流进入 errored从构造函数逃逸(spec)syncStartThrow
7releaseLock 之后的 reader.closed同一个Promise 对象、保持已定状态替换为 TypeError 'This reader has been released'(spec)closedReplacedOnReleaseAfterClose/...Error
8size() 先让流报错再抛错/返回 Infinityenqueue 吞掉错误enqueue 重新抛出 / RangeError(spec)sizeErrorsStreamThenThrows/...ReturnsInfinity
9size() 返回非法值(NaN/-1)enqueue 正常返回但流已 errored(ds 为 null);缺陷:后续 read() 会让 isolate 同步自旋(无法钉住)enqueue 抛 RangeError 'Invalid chunk size',read 以同错误拒绝(spec)invalidSizeReturnValue
10队列总大小算术整数化:饱和(maxSafeInt 形态 -2,-1,1,0);小数 size 截断为 0;缺陷:读一个小数 size 的 chunk 会让 isolate 自旋全程双精度浮点(spec,覆盖全部四种 WPT 形态)queue-math.js(3 个测试)
11tee 的 cancel 组合source.cancel 只拿到"成对完成分支"的 reason;第一分支的 cancel promise 立即定值AggregateError[r1,r2](按分支顺序、保身份;有意偏离 spec 的 array);单独分支的 cancel promise 会挂起直到另一分支被 cancel(spec)——切勿 await 单独分支的 cancelteeCancelReasonCompositeteeCancelReverseOrder
12from(string)按码元迭代得 ['h','i']单 chunk ['hi'](spec:应抛错——两者都偏离 spec)fromString
13async-iterator 原型暴露 constructor + next/return仅 next/returniteratorPrototypeShape
14size() 内部 read()正在途的 chunk直接喂给重入 readchunk 进队列;下一次 enqueue 绕过队列直接喂给重入 read(spec)——两次投递交换readInsideSize
15被采纳(adopted)的 body 流在消费后锁被释放保持锁定bodyIdentityAndLockCoupling
16then-getter 在一个 read 周期内的触发次数1(harness 上下文)2thenGetterFireCountOnRead
17close 两次 / close 后 enqueue / size 非函数 / from-return 的校验消息各自的文案各自的文案closeTerminalitysizeMustBeFunctionfromReturnValidationMessages
18在 close() 仍挂起时(队列有 chunk)调用 error()被忽略:请求的 close 是终局,desiredSize 已为 0,chunk 排空后干净 closedesiredSize 为 hwm 减已入队 chunk 数(-1)直到出错;随后流 errored(spec:close() 只是"请求"了 close,状态仍是 "readable");chunk 丢弃、read/closed 拒绝、desiredSize 为 nullerrorAfterCloseWithQueuedChunk

台账同时记录了若干已探测并钉住的相等性(parity):pull 串行化(绝不被重入)、pull/async-start 拒绝错误对象身份保持、error-undefined 经 closed 透传、error() 两次或 close 完成后是 no-op(close 仍挂起的情况见 #18)、desiredSize 生命周期(1 → 0 close、null error、0 cancel)以及"有挂起 read 时 enqueue 跳过队列"、cancel-with-pending-pull、locked-stream cancel 拒绝但不运行 hook、tee 的错误传播身份 / pull-per-read 形态 / 部分读取后 tee、tee 重入崩溃回归、from() 经 return() 的 cancel 管道身份、async-iterator 协议交错(return/next 无 await)、chunk 以引用持有(变异可见、身份保持)+ 队列中 detach 可观察、以及除 #15 之外的整个集成面(body chunk 归一化,含消息 'This ReadableStream did not return bytes.';disturbed 流构造 Response 抛 TypeError;locked 流构造 Response 被接受;clone 后双读;cancel 后再消费得 'Body has already been used';readAll 的小/大/失败路径;以及 SELF fetch 往返——fetch body 与 Request body,即 workerd#5113 的形态)。

2.1 台账 #1~#3:构造期分歧的源码实证

这三条分歧集中在 construction.js,其头注释明确写道:本文件补全 WPTreadable-streams/general.anyconstructor.any,而 C++ 侧 expectedFailures 在此收窄为"参数转换顺序"与"校验类型"两类。

#1 镜像对称的垃圾源校验(construction.js):new ReadableStream(null)new ReadableStream(42)恰好形成镜像——

if (usingTsImpl) { throws(() => new ReadableStream(null), { name: 'TypeError', message: 'Cannot convert undefined or null to object', }); new ReadableStream(42); // 原语包装对象可转换 } else { new ReadableStream(null); // C++ 接受 null throws(() => new ReadableStream(42), TypeError); }

#2 参数转换顺序(construction.js):用 getter 探针记录读取次序。spec(TypeScript)先转换 queuingStrategy(size 先于 highWaterMark),再读 source 的 type;C++ 则先读 source.type,且 hwm 先于 size:

strictEqual(order.join(','), usingTsImpl ? 'strategy.size,strategy.hwm,source.type' : 'source.type,strategy.hwm,strategy.size');

#3 hwm 校验——台账中最有"系统性影响"的一条(construction.js):-1/NaN时 TypeScript 抛 RangeError、C++ 抛 TypeError;而 spec 视为合法的Infinity被 C++ 的整数转换拒绝(消息为 'The value cannot be converted because it is not an integer.')。正如台账所述,这一条是 C++ 侧大量 WPT reentrant/errors 用例失败的根因——WPT 原始用例常以Infinity作为默认策略,构造直接在 C++ 侧失败,后续所有断言连锁失守。

2.2 台账 #4/#5/#6:pull 时序与 start 抛错

source-algorithms.js 钉住了底层源钩子(start/pull/cancel)的调用时序。台账 #4 的pullCountShape(source-algorithms.js)在 hwm=1、pull 内 enqueue 的场景下断言 pull 计数形状:两边都先 pull 一次(计数 1),第一次 read 后 TypeScript 为 2(每次 read 后都 pull,spec),C++ 仍为 1(首次 read 由队列直接供给),第二次 read 后两边都为 3(C++ 把延迟的 pull 批量补发)。#5 的pullOnLastChunkRead进一步验证"队列排空后是否 pull":TypeScript 拉、C++ 不拉。

而 #6 的syncStartThrow(source-algorithms.js)展示了最尖锐的分叉之一:

if (usingTsImpl) { // spec:同步抛出的 start() 从构造函数逃逸 strictEqual(caught, err); } else { // C++:构造成功,错误被流吞掉 strictEqual(await rejectionOf(rs.getReader().read()), err); }

作为对照,同文件还钉住了两端的 parity:pull 永不重入(pullSerialized)、pull 拒绝与二次抛错都以同一错误对象身份使流 errored(pullRejectionErrorsStream/pullThrowSecondCall)、异步 start 拒绝、cancel 与挂起 pull 共存时 cancel hook 恰好收到一次 reason(cancelWithPendingPull)。

2.3 台账 #10:队列算术——双精度 vs 整数化

queue-math.js 覆盖 WPT "floating-point-total-queue-size" 用例族。TypeScript 按 spec 全程双精度,能精确复现如0 - 1e-16 - 1 + 1e-16的浮点残差;C++ 队列按"类整数"跟踪:Number.MAX_SAFE_INTEGER附近的总和饱和(-2, -1, 1, 0形态),小数 size 直接截断为 0。更值得注意的是文档标注的已知缺陷:在 C++ 侧读取一个小数 size 的 chunk 会让 isolate 同步自旋(与 bad-strategies.js 中 invalid-size 挂起同族),因此queueMathNearZeroClamped/queueMathNearZeroEndsZero两个测试在 C++ 分支下只断言同步 enqueue 时刻的 desiredSize,绝不尝试 read(见 queue-math.js 中if (!usingTsImpl) return;的守卫)——这是"无法钉住就明确规避并注释原因"的工程化处理。

2.4 台账 #14:size() 内的重入 read

reentrancy.js 覆盖了 WPTreentrant-strategies种子。前三个场景(enqueue/close/cancel in size)双端对等:嵌套 enqueue 先到导致输出顺序反转、close 先于 chunk 可读、cancel hook 带 reason 运行。真正分叉的是 #14readInsideSize(reentrancy.js):重入 read 注册于 spec 的"pending-read 检查"之后,于是 TypeScript 下正在途的 chunk 'a' 仍进队列、重入 read 由下一次绕过队列的 enqueue 'b' 满足(inner='b', next='a');C++ 则把在途 chunk 直接喂给重入 read(inner='a', next='b'),投递交换。测试中的守卫细节值得注意:由于 C++ 会直接满足重入 read,size()里必须用innerRead ??= reader.read()保证只在首次 enqueue 时埋点,否则每个后续 chunk 都会被捕获。

三、模块地图:每个文件钉住什么

readable/AGENTS.md 的 Module map 与本目录实际文件一一对应(模块清单以 readable-modules.capnp 中定义的List(Workerd.Worker.Module)常量为唯一真源):

模块覆盖
api-surface.js全局对象、controller 不可构造、getReader 模式、locked 生命周期
construction.js台账 #1–#3、默认 hwm 为 1
source-algorithms.js台账 #4–#6、pull 串行化、拒绝错误身份、cancel-with-pending-pull
controller.jsdesiredSize 记账/终态、error 幂等、close 终局性(#17)、close 排空队列、挂起 close 期间 error(#18)
reader.jsread 顺序、releaseLock、closed 替换(#7)、undefined error、reader.cancel、reader 换锁
cancel.jsreason 身份、locked-cancel、hook 拒绝身份、队列丢弃
bad-strategies.js台账 #8、#9、size 非函数
queue-math.js台账 #10(WPT 浮点形态;C++ 侧仅钉可观察的有界值)
tee.js迁移的边缘用例 + 错误传播 + cancel 组合(#11)+ pull-per-read
tee-reentrancy.js三个 C++ push-loop 崩溃回归(来自 api/streams/streams-test.js)
from.js11 个迁移用例 + fromString(#12)+ return 校验消息
async-iteration.js7 个迁移用例 + 无 await 交错 + 原型形态(#13)
reentrancy.jsenqueue/close/cancel-in-size(对等)+ read-in-size(#14;size() 内必须加守卫,否则 C++ 会捕获后续所有 chunk)
buffer-lifecycle.jschunk 按引用持有、detach 可观察
integration-body.jsreadAll 族、归一化(含 detached 视图、resizable-extent 钉住)、clone、cancel 后消费、SELF 往返
integration-locked-disturbed.jsdisturbed 拒绝、locked 接受、body 身份 + 锁耦合(#15)
gc.js挂起 read + 异步迭代在 gc() 后仍存活
then-interceptors.js台账 #16
legacy-constructors.js无标志 cell(见标志表)
draining-reader.js仅 TS(C++ cell 断言该全局不存在):队列积压 + close 哨兵在一次批量 read中清出;值 chunk原样透传(对象身份);pull 驱动的每次 read yield 且 EOF 作为独立的空批次;expectedLength 为 undefined;error/cancel 传播;锁排他与释放
data-volumes.js值流容量轴:4096 chunk 计数、1 MiB 单字符串 chunk、8 MiB 聚合(128 × 64 KiB)、经 tee 两分支各 1 MiB——每个 chunk 均带索引编码

main.js 以显式具名 re-export汇总了上述全部测试名(绝不使用export *,重名必须在加载期暴露为 SyntaxError 而非静默丢测试),同时提供默认导出:一个回显请求体的 fetch 处理器,供集成测试通过SELF绑定把 ReadableStream 经 fetch 机制双向往返(对应台账中 "SELF fetch round-trips" 一项):

export default { async fetch(request) { return new Response(request.body, { headers: { 'content-type': 'application/octet-stream' }, }); }, };

四、兼容标志与测试单元划分

4.1 标志台账

readable/AGENTS.md 的兼容性标志表如下("Unflagged side" 一列说明无标志侧由谁钉住):

Flag主 cell 中钉住无标志侧
streams_enable_constructors(2022-11-30)是(本套件的对象)readable-cpp-legacycell:构造器与 .from() 抛出点名该 flag 的普通 Error;不暴露 ReadableStreamDefaultController;原生支持的流(Response body)完全可用(含 tee())
transformstream_enable_standard_constructor是(tee 重入测试经 TransformStream 驱动 tee)transform 套件的 legacy cell
capture_async_api_throwsworkers_api_getters_setters_on_prototypeset_tostring_tagfixup-transform-stream-backpressurewritable_stream_spec_compliant_writer其他套件的 legacy cell
pedantic_wpt(无日期 opt-in)readable-cpp-pedanticcell在本套件探测面上零可观察差异(每个探测点 cpp == pedantic)

4.2 四个 wd_test 单元(cell)

BUILD.bazel 定义了四个wd_test目标,测试源集统一为glob(["*.js"]) + ["readable-modules.capnp"]

  • readable-cpp.wd-test:C++ 实现主 cell,钉住 8 个兼容标志(nodejs_compat、streams_enable_constructors、transformstream_enable_standard_constructor、fixup-transform-stream-backpressure、capture_async_api_throws、workers_api_getters_setters_on_prototype、set_tostring_tag、writable_stream_spec_compliant_writer、enable_weak_ref),配置SELF自绑定,并为 gc.js 开启v8Flags = ["--expose-gc"]
  • readable-ts.wd-test:TypeScript 实现主 cell(bazel 侧额外传--experimental)。有意省略C++ cell 的全部语义标志——因为 TS 实现硬编码现代行为,标志的缺失本身就是"TS 实现不消费这些标志"的断言;它额外钉住typescript_implemented_streams与内部测试用expose_draining_reader,并声明 autogateworkerd-autogate-per-isolate-javascript-bootstrap
  • readable-cpp-pedantic.wd-test 对应的 bazel 目标:主 C++ cell + 无日期 opt-in 标志pedantic_wpt,探测结果为无差异,故共享模块不分叉运行;
  • readable-cpp-legacy.wd-test:无标志的回归守卫,仅 C++ 侧运行,且显式generate_all_compat_flags_variant = False——因为 2999-12-31 的 all-compat-flags 变体本会把被省略的标志重新打开,与 cell 目的相悖。

这与父级文档 src/tests/streams/AGENTS.md 约定的整体机制一致:配置不设置 compatibilityDate,日期轴由 wd_test 变体机制持有(@变体跑在 2000-01-01,@all-compat-flags跑在 2999-12-31),测试必须在两个极端都通过;每个依赖日期门控的行为都以按名钉标志的方式在两个配置中固定。若某测试只在@all-compat-flags下失败,说明是某个日期门控标志改变了行为,应找到它(compatibility-date.capnp)并钉住它,而不是钉一个日期。

4.3 draining-reader:TS 侧的内部测试全局

文档特别指出,ts cell 额外设置内部测试标志expose_draining_reader,安装全局ReadableStreamDrainingReader——C++ 桥接消费 TypeScript 流所驱动的"批量排空管道"(bulk-drain conduit)。C++ 实现下不存在该全局;draining-reader.js 在两个 cell 中同时断言两侧事实(TS 侧验证排队积压 + close 哨兵的一次批量清出、值 chunk 对象身份透传、EOF 空批次、error/cancel 传播、锁排他;C++ 侧断言全局缺失)。

五、分歧断言模式:which-impl 而非放宽断言

父级文档 src/tests/streams/AGENTS.md 规定了套件的分歧处理原则:当实现有意不同时,不要放宽断言到"两边恰好都满足"的模糊条件,而是按实现分叉、分别钉死各自一侧的精确行为。本套件的实现入口是 which-impl.js:

export const usingTsImpl = globalThis.Cloudflare.compatibilityFlags['typescript_implemented_streams']; export const pedanticWpt = globalThis.Cloudflare.compatibilityFlags['pedantic_wpt'];

测试文件用usingTsImpl ? RangeError : TypeError这类三元表达式钉住分叉(如 construction.js),并"钉住每一个已知分歧:任何一侧改动都会让对应 cell 失败",同时要求在断言分歧的文件头注释中记录该分歧。台账中每个测试名(garbageSourceValidationpullCountShape等)在 main.js 的具名导出清单里都能找到对应项,形成"文档台账 → 测试名 → 源码断言"的完整追溯链。

六、运行方式与来源迁移史

运行方式继承自父级约定(见 src/tests/streams/AGENTS.md 的 Running 一节):

bazel test //src/tests/streams/readable/... --nocache_test_results

每个 cell 带@all-compat-flags@all-autogates变体,另有默认关闭的@gc-stress变体(需--test_tag_filters=显式启用)。测试通过workerd testtest()处理器执行,断言来自node:assert(两个主配置的标志清单里都有nodejs_compat)。

文档最后记录了套件的**消费来源(Consumed sources)**迁移史,说明其"从散装测试迁入 WPT 风格套件"的演进:

  • streams-async-iterator-test.js(已删除,整体迁入async-iteration.js);
  • streams-tee-edge-cases-test.js的值流半部分(迁入tee.js);
  • streams-test.js的 from/readAll/reader/cancel 家族(BYOB + writable + TransformStream 测试留在原地);
  • api/streams/streams-test.js 的 tee 重入三件套 + ResponseTextLargeBody(迁入tee-reentrancy.js/integration-body.js);
  • ts-webstreams-test.js的三个对等 body 测试(其 TS 身份断言保留原地);
  • streams-js-test.js刻意不动:其用例交错值流与字节流两节,值流半部分将在 readable-byte 套件消费字节半部分时再迁移。

七、小结

src/tests/streams/readable/展示了一种可复用的兼容性工程范式:以"测试即规范"的立场,用一份编号台账把双实现之间的每一条可观察差异(含已知缺陷与有意偏离 spec 的行为)钉到具体测试上;用.capnp常量保证两个 cell 嵌入完全相同的测试代码;用"按名钉标志而非钉日期"的配置策略让测试在 2000-01-01 与 2999-12-31 两个极端日期下都稳定;再用 legacy cell 为旧 compat date 的存量 worker 保留回归守卫。对于需要维护双实现(或多实现)兼容性的项目,这套"分歧台账 + which-impl 分叉断言 + 单元级标志钉住"的组合是一个值得参照的完整样本。

【免费下载链接】workerdThe JavaScript / Wasm runtime that powers Cloudflare Workers项目地址: https://gitcode.com/GitHub_Trending/wo/workerd

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询