workerd 中 util.inspect 对 Streams 的定制化输出:C++ 与 TypeScript 实现的双轨测试规范
2026/9/16 18:24:26 网站建设 项目流程

workerd 中 util.inspect 对 Streams 的定制化输出:C++ 与 TypeScript 实现的双轨测试规范

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

导读

本篇文章围绕 workerd(Cloudflare Workers 的 JavaScript/Wasm 运行时)中node:utilinspect对各类 Streams 对象的输出格式展开,梳理 src/tests/streams/inspect/AGENTS.md 这份"非正式规范"的完整内容:C++ 实现如何通过自定义 inspect 暴露流对象的锁状态与内部状态、TypeScript 实现为何只能输出裸的ClassName {}、每类流的精确输出形状,以及背后支撑这些断言的三组兼容性旗标。读完本文,你将能准确理解 workerd 中流对象在调试输出层面的双实现差异,并能定位到对应的测试用例与源码实现,在实际开发中利用util.inspect快速判断流的锁定、错误与读取进度状态。

一、这份文档的定位:以测试为规范

util.inspect是 Node.js 生态中最重要的调试工具之一,它决定了console.log打印一个对象时能看到什么。workerd 对标准 Web Streams 体系(ReadableStreamWritableStreamTransformStreamFixedLengthStreamIdentityTransformStream等扩展)做了两套实现:

  • 遗留的C++ 实现,位于 src/workerd/api/streams/;
  • 较新的TypeScript 实现,位于 src/per_isolate/webstreams/。

为了让调试输出在两套实现下都有确定、可预期、可回归的语义,测试套件把node:utilinspect 对**每一个流面(stream surface)**的输出行为固化为规范。正如 AGENTS.md 开头所强调的:

Informal specification ofnode:utilinspect output for every stream surface, derived from — and kept in lockstep with — this suite.The tests are the normative artifact.

即:测试是规范性的最终产物(normative artifact),文档只是对测试行为的描述,二者必须保持同步。这与 src/tests/streams/AGENTS.md 中"行为以测试为准、配置不设 compatibilityDate 而只钉旗标"的套件总体约定一脉相承。

二、整个套件的核心:一处刻意保留的实现背离

这份规范的全部意义都集中在一个问题上:C++ 实现为流对象安装了自定义 inspect,而 TypeScript 实现没有

  • C++ 侧:安装的自定义 inspect 会暴露锁/状态内部信息,即[state][supportsBYOB][length][expectsBytes]这几个方括号命名的内部字段,加上公开的locked布尔值;
  • TypeScript 侧:没有任何自定义 inspect,无论流处于何种状态,util.inspect都只输出裸的ClassName {},因此依赖内省(introspection)的消费者在 TS 实现下会丢失上述全部内部字段。

值得注意的是,这两侧的输出在每个生命周期转换点上都被逐字(verbatim)钉死,也就是说,测试不仅仅断言"初始状态长什么样",还断言状态迁移过程中每一步的输出,从而让这套背离始终可见、可回归。

下表是原文档中完整列出的流面与输出形状对照(已结合测试源码补充具体字段值):

流面C++ 形状(每个状态逐字钉死)钉死位置(测试函数)
值 ReadableStreamReadableStream { locked, [state]: 'readable'→'closed', [supportsBYOB]: false, [length]: undefined }inspectValueReadable
errored ReadableStream[state]: 'errored'inspectErroredReadable
byte ReadableStream[supportsBYOB]: trueinspectByteReadable
WritableStreamWritableStream { locked, [state]: 'writable'→'closed', [expectsBytes]: false }inspectWritable
erroring WritableStream[state]: 'erroring'→'errored'(过渡状态可观察)inspectErroringWritable
FixedLengthStream复合 readable+writable;[length]以 bigint 形式递减5n→2n→0n),随读取耗尽inspectFixedLengthStream
IdentityTransformStream复合结构;abort 驱动 writable 进入'errored',首次失败的 read 驱动 readable 进入'errored'inspectErroredIdentityStream

三、源码级验证:C++ 的自定义 inspect 是如何安装的

AGENTS.md 声称"C++ 实现安装了自定义 inspect",这一点可以直接在源码中印证。

在 src/workerd/api/streams/readable.h 的JSG_RESOURCE_TYPE(ReadableStream, ...)资源描述宏中:

JSG_INSPECT_PROPERTY(state, inspectState); JSG_INSPECT_PROPERTY(supportsBYOB, inspectSupportsBYOB); JSG_INSPECT_PROPERTY(length, inspectLength);

对应的方法声明在同文件 readable.h:

jsg::JsString inspectState(jsg::Lock& js); bool inspectSupportsBYOB(); jsg::Optional<uint64_t> inspectLength();

JSG_INSPECT_PROPERTY是 workerd 的 JSG(JavaScript glue)绑定宏,它把 C++ 侧的方法注册为util.inspect输出中的自定义属性。从结构看,inspectState返回流的内部状态字符串(readable/closed/errored等),inspectSupportsBYOB返回布尔值,inspectLength返回可选的uint64_t(正是FixedLengthStream中显示为 bigint 的剩余字节数)。

Writable 侧同样如此,在 src/workerd/api/streams/writable.h:

JSG_INSPECT_PROPERTY(state, inspectState); JSG_INSPECT_PROPERTY(expectsBytes, inspectExpectsBytes);

这两段代码就是表格中[state][supportsBYOB][length][expectsBytes]四个内部字段的真实来源——它们只存在于 C++ 实现,TypeScript 实现(src/per_isolate/webstreams/)的类定义中没有对应的 inspect 注册逻辑,因此输出永远退化为裸的ClassName {}

四、测试套件结构:同一份模块,跑两套实现

这套测试的核心工程手法是"一套测试模块,两个配置入口",让同一份 JS 测试代码分别针对 C++ 实现与 TypeScript 实现各跑一遍。目录结构如下(对应 src/tests/streams/AGENTS.md 中描述的套件标准布局):

src/tests/streams/inspect/ AGENTS.md # 本文所依据的非正式规范 inspect-modules.capnp # 测试模块清单,唯一定义一次 inspect-cpp.wd-test # 用 C++ 实现跑这套模块 inspect-ts.wd-test # 用 TypeScript 实现跑同一套模块 main.js # 入口:显式具名 re-export 全部测试 which-impl.js # 导出 usingTsImpl,区分当前跑的是哪套实现 inspect.js # 全部 inspect 断言,逐字钉死输出字符串 BUILD.bazel # wd_test() 目标

4.1 模块清单的单一事实来源

inspect-modules.capnp 把三个测试模块定义为List(Workerd.Worker.Module)常量,两个.wd-test配置都引用它:

const modules :List(Workerd.Worker.Module) = [ (name = "main", esModule = embed "main.js"), (name = "which-impl", esModule = embed "which-impl.js"), (name = "inspect", esModule = embed "inspect.js"), ];

两个配置共享同一份模块,意味着"同一段测试代码必须同时在两套实现下通过"——任何行为漂移都会让其中一个配置失败,从机制上杜绝了双实现各自维护一份测试、悄悄分叉的可能。

4.2 两个 wd-test 配置:钉死各自阵营的旗标

C++ 侧配置 inspect-cpp.wd-test:

compatibilityFlags = [ "nodejs_compat", "streams_enable_constructors", "transformstream_enable_standard_constructor", "internal_writable_stream_abort_clears_queue", "writable_stream_spec_compliant_writer", "workers_api_getters_setters_on_prototype", ]

TypeScript 侧配置 inspect-ts.wd-test:

compatibilityFlags = [ "nodejs_compat", "typescript_implemented_streams", "experimental", ] autogates = [ "workerd-autogate-per-isolate-javascript-bootstrap" ]

注意nodejs_compat同时出现在两侧——它正是node:util(以及node:assert)可用性的来源,测试代码中的import util from 'node:util'依赖它。而typescript_implemented_streams是 TS 侧的开关旗标,experimentalautogates则服务于 per-isolate JavaScript 引导机制。

4.3 运行时如何区分实现:which-impl.js

测试代码在断言时会用同一个预期逻辑适配两侧输出,区分依据在 which-impl.js:

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

usingTsImpl为真时按 TypeScript 实现的裸ClassName {}断言,否则按 C++ 实现的完整字符串断言。由此可以反推:typescript_implemented_streams旗标在运行时确实会切换 streams 的实现来源。这在 src/per_isolate/main.ts 中也有印证——per-isolate 引导脚本在if (compatFlags['typescript_implemented_streams'])分支下从 TS 模块导入并安装整套ReadableStreamWritableStreamTransformStreamIdentityTransformStreamFixedLengthStream等构造器。

4.4 断言的分流机制:checker 函数

inspect.js 用一个工厂函数统一两侧预期:

function checker(opts) { return (value, cppExpected, bare) => strictEqual(util.inspect(value, opts), usingTsImpl ? bare : cppExpected); }

每次调用传入三个参数:待检查对象、C++ 侧的精确输出字符串、TS 侧的裸输出。测试里显式传breakLength: InfinitybreakLength: 100来控制util.inspect的换行行为,保证多行复合对象的输出格式稳定可比较。

五、逐流面解析:每个断言的精确输出

下面逐项展开原文档表格对应的测试函数,给出 inspect.js 中逐字钉死的输出字符串,并解释每个字段的含义。

5.1 值 ReadableStream:inspectValueReadable

构造一个手动 pull 的流(第一次 pull 入队'hello',第二次close()),观察从创建到读完的完整生命周期(inspect.js):

  • 创建后:ReadableStream { locked: false, [state]: 'readable', [supportsBYOB]: false, [length]: undefined }
  • getReader()后:locked: true(锁状态翻转,[state]仍为'readable'
  • 第一次read()取出数据后:仍为'readable'(还有可读数据)
  • 第二次read()触发 close 后:[state]: 'closed'locked: true

这条链路展示了locked(是否被 reader/writer 独占)与[state]readableclosed)两个维度的独立变化:加锁不改变状态,读尽才改变状态

5.2 errored ReadableStream:inspectErroredReadable

start阶段就调用controller.error(new Error('Oops!'))(inspect.js),输出为:

ReadableStream { locked: false, [state]: 'errored', [supportsBYOB]: false, [length]: undefined }

说明[state]存在'errored'取值,且错误发生后流的其余内部字段仍会正常列出。

5.3 byte ReadableStream:inspectByteReadable

type: 'bytes'构造底层源(inspect.js),输出中关键差异是:

ReadableStream { locked: false, [state]: 'readable', [supportsBYOB]: true, [length]: undefined }

[supportsBYOB]: true表示该流支持 BYOB(Bring Your Own Buffer)读取模式,即可以通过getReader({ mode: 'byob' })传入预先分配的Uint8Array直接填充。这正是字节流与普通值流在 inspect 输出上的区分点。

5.4 WritableStream:inspectWritable

Writable 侧没有[supportsBYOB],取而代之的是[expectsBytes](inspect.js):

  • 创建后:WritableStream { locked: false, [state]: 'writable', [expectsBytes]: false }
  • getWriter()后:locked: true
  • writer.write('chunk')后:locked: true, [state]: 'writable'
  • writer.close()后:[state]: 'closed'

5.5 erroring WritableStream:inspectErroringWritable(过渡状态可观察)

这是最能体现"逐字钉死每个生命周期转换点"价值的用例(inspect.js):在write回调中调用controller.error(...),然后分三次断言:

  1. 创建后:[state]: 'writable'
  2. 发起writer.write('chunk')瞬间[state]: 'erroring'—— 注意这里出现了一个中间过渡状态,此时错误尚未完全传播;
  3. await promise之后:[state]: 'errored'

'erroring'是 WritableStream 规范的中间态:写入请求已进入错误处理流程、但错误承诺尚未结算。测试特意把这个肉眼可见的过渡状态也钉死,说明规范对状态机时序的严格程度。

5.6 FixedLengthStream:inspectFixedLengthStream(bigint 递减计数器)

FixedLengthStream是 workerd 对 Web Streams 的扩展(固定长度字节流),inspect 输出为复合结构,同时展示 readable 与 writable 两侧(inspect.js)。以new FixedLengthStream(5)为例:

FixedLengthStream { readable: ReadableStream { locked: false, [state]: 'readable', [supportsBYOB]: true, [length]: 5n }, writable: WritableStream { locked: false, [state]: 'writable', [expectsBytes]: true } }

关键观察点:

  • readable 侧[supportsBYOB]: true,writable 侧[expectsBytes]: true——这是一个面向字节的复合流;
  • readable 的[length]bigint 字面量形式显示(5n),并随读取递减
    • 写入[1,2,3][4,5]并 close 后,[length]仍为5n(writable 侧变为[state]: 'closed');
    • 第一次reader.read()取走 3 字节后,[length]: 2n,readable 被锁定(locked: true);
    • 第二次reader.read()取走剩余 2 字节后,[length]: 0n
    • 第三次reader.read()耗尽后,readable 进入[state]: 'closed'

[length]递减的方向与读取进度一致,这正是该扩展类型作为"固定长度"语义在调试输出上的直接体现。注意[length]只在FixedLengthStream场景下有意义(普通值流与 byte 流中均为undefined),它对应 C++ 侧inspectLength()返回的jsg::Optional<uint64_t>

5.7 IdentityTransformStream:inspectErroredIdentityStream(两条错误传播路径)

IdentityTransformStream是 workerd 提供的恒等变换流,同样以复合结构输出(inspect.js):

IdentityTransformStream { readable: ReadableStream { locked: false, [state]: 'readable', [supportsBYOB]: true, [length]: undefined }, writable: WritableStream { locked: false, [state]: 'writable', [expectsBytes]: true } }

该用例专门验证错误传播的两条路径:

  1. abort 驱动 writable 出错:调用writer.abort(new Error('Oops!'))后立即断言,writable 侧已进入[state]: 'errored'(在现代 abort 语义下立即到达,见下一节的旗标说明),而 readable 侧此时仍是'readable'
  2. 首次失败读取驱动 readable 出错:随后getReader()await reader.read().catch(() => {}),此时 readable 侧也进入[state]: 'errored'

最终状态是双侧同时'errored',且 readable 与 writable 均被锁定。这个用例把"上游 abort"与"下游读取失败"两条错误传播路径分别钉死,验证了复合变换流中错误传播的方向性与时序。

六、兼容性旗标:输出形状背后的三个开关

AGENTS.md 明确指出 C++ 侧的输出形状超出构造器配对之外还依赖三组兼容性旗标,它们的语义如下:

6.1internal_writable_stream_abort_clears_queue+writable_stream_spec_compliant_writer

这是成对出现的旗标组合。如 inspect-cpp.wd-test 中的注释所述:IdentityTransformStream 的 abort 只有在现代 abort 语义下才会立即到达'errored'状态。换句话说,5.7 节中断言的"abort 后瞬间就是'errored'",依赖这两面旗标将 writable 的 abort 行为对齐到规范实现(清空队列、符合规范的 writer 语义)。若缺少它们,abort 后的状态迁移可能经过中间态或延迟,导致输出字符串与钉死的断言不符。

6.2workers_api_getters_setters_on_prototype

该旗标决定locked等公开属性是放在原型上的访问器(getter/setter)还是实例自身属性(own property)。如 inspect-cpp.wd-test 的注释所述:复合流(Identity/FixedLength)的 inspect 输出只有在访问器位于原型上时才会按readable在前、writable在后的顺序列出;若改用实例自有属性,顺序会翻转。这一点在 readable.h 中也有对应实现:JSG_READONLY_PROTOTYPE_PROPERTY(locked, isLocked)JSG_READONLY_INSTANCE_PROPERTY(locked, isLocked)getJsgPropertyOnPrototypeTemplate()旗标分支选择。

6.3 TypeScript 侧:不钉任何流语义旗标

inspect-ts.wd-test 只设置了nodejs_compattypescript_implemented_streamsexperimental没有上面三面流语义旗标。原因正如 AGENTS.md 所说:TS 侧的输出与状态无关(永远是裸ClassName {}),因此不存在需要旗标对齐的状态依赖行为。反过来讲,TS 侧断言的存在价值恰恰是让"两侧有差异"这件事永远可见——如果哪天 TS 实现补上了自定义 inspect,inspect-ts.wd-test会立刻失败,提醒维护者更新规范与文档。

七、测试的迁移历史:从 api/streams 到独立套件

AGENTS.md 的结尾记录了这套测试的来龙去脉:它从 src/workerd/api/streams/streams-test.js 迁移而来,而原文件中依赖 KV 绑定(env.KV)的partiallyReadStream用例被留在了原地(streams-test.js)。该用例验证的是:一个已经通过 BYOB reader 读取了一部分、随后releaseLock()的字节流,仍能被序列化写入 KV 而不抛错——它依赖 KV 这个外部资源,与纯 inspect 行为无关,因此不适合进入这套纯模块化的 inspect 套件。

从测试工程的角度看,这次迁移的意义在于:把 inspect 行为从混合了外部资源依赖的大测试文件中拆出,独立成"一套模块 + 两个配置"的专门套件,使双实现的 inspect 差异获得独立的回归防线。

八、如何运行与验证

该套件通过 Bazel 的wd_test宏注册(见 BUILD.bazel),生成两个测试目标,数据依赖由glob(["*.js"]) + inspect-modules.capnp提供:

inspect_suite_srcs = glob(["*.js"]) + ["inspect-modules.capnp"] wd_test(src = "inspect-cpp.wd-test", data = inspect_suite_srcs) wd_test(src = "inspect-ts.wd-test", args = ["--experimental"], data = inspect_suite_srcs)

其中inspect-ts.wd-test额外传入--experimental参数,对应 TS 实现目前处于实验特性阶段的现状。可用的 Bazel 命令形如:

bazel test //src/tests/streams/inspect:all

需要说明的是,inspect套件依赖nodejs_compat旗标提供的node:utilnode:assert模块;在普通 Worker 运行时中,若未开启nodejs_compatnode:util并不可用。此外,src/tests/streams/AGENTS.md 还提到,该套件遵循"不设 compatibilityDate、只钉旗标"的约定,wd_test的日期变体机制会分别在@(2000-01-01)与@all-compat-flags(2999-12-31)两个极端日期下运行,保证被旗标保护的旧行为与默认行为在两端都通过。

九、总结:这份规范告诉了我们什么

从这份面向util.inspect输出的小规范中,可以提炼出 workerd 在流实现演进上的几个关键设计事实:

  1. 双实现并存且有刻意可见的差异:C++ 实现通过JSG_INSPECT_PROPERTY暴露[state][supportsBYOB][length][expectsBytes]等内部字段,TypeScript 实现暂未实现自定义 inspect,二者差异被测试逐字钉死并持续监控;
  2. 测试是规范的本体:所有输出字符串都以逐字断言的形式固化在 inspect.js 中,文档只是其描述;
  3. 状态机细节被完整覆盖:包括'erroring'这类过渡状态、FixedLengthStream[length]递减、复合流的双错误传播路径;
  4. 兼容性旗标精确控制行为:三面旗标分别管辖 abort 语义与属性挂载位置,任何一面缺失都会改变 inspect 输出,这正是"用旗标而非日期"来钉行为的工程实践。

对于使用 workerd 开发或维护流相关代码的开发者,这套规范提供了一个实用参考:开启nodejs_compat后,通过util.inspect查看流对象,即可直观确认流的锁定状态、可读状态、BYOB 能力与剩余字节数——前提是运行在 C++ 实现上;若运行在typescript_implemented_streams旗标下的 TS 实现上,则只能看到裸的类名,这一限制值得在编写依赖内省输出的调试工具时留意。

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

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

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

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

立即咨询