workerd 中 util.inspect 对 Streams 的定制化输出:C++ 与 TypeScript 实现的双轨测试规范 📅 发布时间:2026/9/16 18:24:32 👁 浏览次数: workerd 中 util.inspect 对 Streams 的定制化输出C 与 TypeScript 实现的双轨测试规范【免费下载链接】workerdThe JavaScript / Wasm runtime that powers Cloudflare Workers项目地址: https://gitcode.com/GitHub_Trending/wo/workerd导读本篇文章围绕 workerdCloudflare Workers 的 JavaScript/Wasm 运行时中node:util的inspect对各类 Streams 对象的输出格式展开梳理 src/tests/streams/inspect/AGENTS.md 这份非正式规范的完整内容C 实现如何通过自定义 inspect 暴露流对象的锁状态与内部状态、TypeScript 实现为何只能输出裸的ClassName {}、每类流的精确输出形状以及背后支撑这些断言的三组兼容性旗标。读完本文你将能准确理解 workerd 中流对象在调试输出层面的双实现差异并能定位到对应的测试用例与源码实现在实际开发中利用util.inspect快速判断流的锁定、错误与读取进度状态。一、这份文档的定位以测试为规范util.inspect是 Node.js 生态中最重要的调试工具之一它决定了console.log打印一个对象时能看到什么。workerd 对标准 Web Streams 体系ReadableStream、WritableStream、TransformStream及FixedLengthStream、IdentityTransformStream等扩展做了两套实现遗留的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 }inspectValueReadableerrored ReadableStream[state]: erroredinspectErroredReadablebyte ReadableStream[supportsBYOB]: trueinspectByteReadableWritableStreamWritableStream { locked, [state]: writable→closed, [expectsBytes]: false }inspectWritableerroring WritableStream[state]: erroring→errored过渡状态可观察inspectErroringWritableFixedLengthStream复合 readablewritable[length]以 bigint 形式递减5n→2n→0n随读取耗尽inspectFixedLengthStreamIdentityTransformStream复合结构abort 驱动 writable 进入errored首次失败的 read 驱动 readable 进入erroredinspectErroredIdentityStream三、源码级验证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.hjsg::JsString inspectState(jsg::Lock js); bool inspectSupportsBYOB(); jsg::Optionaluint64_t inspectLength();JSG_INSPECT_PROPERTY是 workerd 的 JSGJavaScript glue绑定宏它把 C 侧的方法注册为util.inspect输出中的自定义属性。从结构看inspectState返回流的内部状态字符串readable/closed/errored等inspectSupportsBYOB返回布尔值inspectLength返回可选的uint64_t正是FixedLengthStream中显示为 bigint 的剩余字节数。Writable 侧同样如此在 src/workerd/api/streams/writable.hJSG_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-testcompatibilityFlags [ 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-testcompatibilityFlags [ 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 侧的开关旗标experimental与autogates则服务于 per-isolate JavaScript 引导机制。4.3 运行时如何区分实现which-impl.js测试代码在断言时会用同一个预期逻辑适配两侧输出区分依据在 which-impl.jsexport 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 模块导入并安装整套ReadableStream、WritableStream、TransformStream、IdentityTransformStream、FixedLengthStream等构造器。4.4 断言的分流机制checker 函数inspect.js 用一个工厂函数统一两侧预期function checker(opts) { return (value, cppExpected, bare) strictEqual(util.inspect(value, opts), usingTsImpl ? bare : cppExpected); }每次调用传入三个参数待检查对象、C 侧的精确输出字符串、TS 侧的裸输出。测试里显式传breakLength: Infinity或breakLength: 100来控制util.inspect的换行行为保证多行复合对象的输出格式稳定可比较。五、逐流面解析每个断言的精确输出下面逐项展开原文档表格对应的测试函数给出 inspect.js 中逐字钉死的输出字符串并解释每个字段的含义。5.1 值 ReadableStreaminspectValueReadable构造一个手动 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]: closedlocked: true这条链路展示了locked是否被 reader/writer 独占与[state]readable→closed两个维度的独立变化加锁不改变状态读尽才改变状态。5.2 errored ReadableStreaminspectErroredReadable在start阶段就调用controller.error(new Error(Oops!))inspect.js输出为ReadableStream { locked: false, [state]: errored, [supportsBYOB]: false, [length]: undefined }说明[state]存在errored取值且错误发生后流的其余内部字段仍会正常列出。5.3 byte ReadableStreaminspectByteReadable以type: bytes构造底层源inspect.js输出中关键差异是ReadableStream { locked: false, [state]: readable, [supportsBYOB]: true, [length]: undefined }[supportsBYOB]: true表示该流支持 BYOBBring Your Own Buffer读取模式即可以通过getReader({ mode: byob })传入预先分配的Uint8Array直接填充。这正是字节流与普通值流在 inspect 输出上的区分点。5.4 WritableStreaminspectWritableWritable 侧没有[supportsBYOB]取而代之的是[expectsBytes]inspect.js创建后WritableStream { locked: false, [state]: writable, [expectsBytes]: false }getWriter()后locked: truewriter.write(chunk)后locked: true, [state]: writablewriter.close()后[state]: closed5.5 erroring WritableStreaminspectErroringWritable过渡状态可观察这是最能体现逐字钉死每个生命周期转换点价值的用例inspect.js在write回调中调用controller.error(...)然后分三次断言创建后[state]: writable发起writer.write(chunk)的瞬间[state]: erroring—— 注意这里出现了一个中间过渡状态此时错误尚未完全传播await promise之后[state]: errorederroring是 WritableStream 规范的中间态写入请求已进入错误处理流程、但错误承诺尚未结算。测试特意把这个肉眼可见的过渡状态也钉死说明规范对状态机时序的严格程度。5.6 FixedLengthStreaminspectFixedLengthStreambigint 递减计数器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]: truewritable 侧[expectsBytes]: true——这是一个面向字节的复合流readable 的[length]以bigint 字面量形式显示5n并随读取递减写入[1,2,3]和[4,5]并 close 后[length]仍为5nwritable 侧变为[state]: closed第一次reader.read()取走 3 字节后[length]: 2nreadable 被锁定locked: true第二次reader.read()取走剩余 2 字节后[length]: 0n第三次reader.read()耗尽后readable 进入[state]: closed。[length]递减的方向与读取进度一致这正是该扩展类型作为固定长度语义在调试输出上的直接体现。注意[length]只在FixedLengthStream场景下有意义普通值流与 byte 流中均为undefined它对应 C 侧inspectLength()返回的jsg::Optionaluint64_t。5.7 IdentityTransformStreaminspectErroredIdentityStream两条错误传播路径IdentityTransformStream是 workerd 提供的恒等变换流同样以复合结构输出inspect.jsIdentityTransformStream { readable: ReadableStream { locked: false, [state]: readable, [supportsBYOB]: true, [length]: undefined }, writable: WritableStream { locked: false, [state]: writable, [expectsBytes]: true } }该用例专门验证错误传播的两条路径abort 驱动 writable 出错调用writer.abort(new Error(Oops!))后立即断言writable 侧已进入[state]: errored在现代 abort 语义下立即到达见下一节的旗标说明而 readable 侧此时仍是readable首次失败读取驱动 readable 出错随后getReader()并await reader.read().catch(() {})此时 readable 侧也进入[state]: errored。最终状态是双侧同时errored且 readable 与 writable 均被锁定。这个用例把上游 abort与下游读取失败两条错误传播路径分别钉死验证了复合变换流中错误传播的方向性与时序。六、兼容性旗标输出形状背后的三个开关AGENTS.md 明确指出 C 侧的输出形状超出构造器配对之外还依赖三组兼容性旗标它们的语义如下6.1internal_writable_stream_abort_clears_queuewritable_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_compat、typescript_implemented_streams、experimental没有上面三面流语义旗标。原因正如 AGENTS.md 所说TS 侧的输出与状态无关永远是裸ClassName {}因此不存在需要旗标对齐的状态依赖行为。反过来讲TS 侧断言的存在价值恰恰是让两侧有差异这件事永远可见——如果哪天 TS 实现补上了自定义 inspectinspect-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:util与node:assert模块在普通 Worker 运行时中若未开启nodejs_compatnode:util并不可用。此外src/tests/streams/AGENTS.md 还提到该套件遵循不设 compatibilityDate、只钉旗标的约定wd_test的日期变体机制会分别在2000-01-01与all-compat-flags2999-12-31两个极端日期下运行保证被旗标保护的旧行为与默认行为在两端都通过。九、总结这份规范告诉了我们什么从这份面向util.inspect输出的小规范中可以提炼出 workerd 在流实现演进上的几个关键设计事实双实现并存且有刻意可见的差异C 实现通过JSG_INSPECT_PROPERTY暴露[state]、[supportsBYOB]、[length]、[expectsBytes]等内部字段TypeScript 实现暂未实现自定义 inspect二者差异被测试逐字钉死并持续监控测试是规范的本体所有输出字符串都以逐字断言的形式固化在 inspect.js 中文档只是其描述状态机细节被完整覆盖包括erroring这类过渡状态、FixedLengthStream的[length]递减、复合流的双错误传播路径兼容性旗标精确控制行为三面旗标分别管辖 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),仅供参考