diff --git a/patches/zstd/mt-clear-job-ready.patch b/patches/zstd/mt-clear-job-ready.patch new file mode 100644 index 000000000000..b46a4e419376 --- /dev/null +++ b/patches/zstd/mt-clear-job-ready.patch @@ -0,0 +1,35 @@ +Clear jobReady when the job table is erased. + +With nbWorkers >= 1, ZSTDMT_createCompressionJob() sets mtctx->jobReady when +it has prepared a job and no worker is free to take it. +ZSTDMT_releaseAllJobResources() erases every job description, the prepared one +too, and leaves the flag set. ZSTDMT_initCStream_internal() does not clear it +either. + +The next frame on that context then skips the preparation and posts the +erased description. The worker calls ZSTDMT_getCCtx(NULL) and the process gets +SIGSEGV (zstdmt_compress.c:697). + +The caller does not have to reset anything to get there. zstd erases the table +and starts a new session by itself when a job fails, and when +ZSTDMT_compressStream_generic() returns stage_wrong. A frame on the same +context after one of those, while a job waited for a worker, crashes. + +ZSTD_CCtx_reset(cctx, ZSTD_reset_session_only) in an open frame is one more +way in. reset() of a node:zlib zstd stream makes that call, and Node v26.10.0 +exits with SIGSEGV there. + +facebook/zstd has the same code on its dev branch, and no issue there reports +it (checked 2026-09-29). + +--- a/lib/compress/zstdmt_compress.c ++++ b/lib/compress/zstdmt_compress.c +@@ -1023,6 +1023,8 @@ + } + mtctx->inBuff.buffer = g_nullBuffer; + mtctx->inBuff.filled = 0; ++ /* Bun: the loop above erased the job that jobReady refers to. */ ++ mtctx->jobReady = 0; + mtctx->allJobsCompleted = 1; + } + diff --git a/scripts/build/deps/zstd.ts b/scripts/build/deps/zstd.ts index 0e12a93386f2..0596d2987d7a 100644 --- a/scripts/build/deps/zstd.ts +++ b/scripts/build/deps/zstd.ts @@ -41,10 +41,17 @@ export const zstd: Dependency = { commit: ZSTD_COMMIT, }), - // x64 targets nehalem, so zstd picks its BMI2 kernels at run time and - // probes CPUID in every CCtx/DCtx init. CPUID is a VM exit under a - // hypervisor (about 2 us each, two per init). Probe once instead. - patches: ["patches/zstd/bmi2-probe-once.patch"], + patches: [ + // x64 targets nehalem, so zstd picks its BMI2 kernels at run time and + // probes CPUID in every CCtx/DCtx init. CPUID is a VM exit under a + // hypervisor (about 2 us each, two per init). Probe once instead. + "patches/zstd/bmi2-probe-once.patch", + // With nbWorkers >= 1, zstd keeps its jobReady flag when it erases the + // job table, so the next frame on that context posts an erased job to a + // worker (SIGSEGV). zstd gets there by itself after a failed job, and + // node:zlib reset() gets there too. Not reported upstream yet. + "patches/zstd/mt-clear-job-ready.patch", + ], build: cfg => { const sources = [...SOURCES]; diff --git a/src/runtime/node/node_zlib_binding.rs b/src/runtime/node/node_zlib_binding.rs index f425ae08dd54..12dac919356e 100644 --- a/src/runtime/node/node_zlib_binding.rs +++ b/src/runtime/node/node_zlib_binding.rs @@ -731,7 +731,7 @@ impl CompressionStream { global_this: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { - // reset() destroys and re-creates the brotli/zstd encoder state (or + // reset() re-creates the brotli encoder state (or resets the zstd session, or // mutates the z_stream). Doing so while an async write is running on // the threadpool would be a use-after-free / data race, so node throws // a plain Error here rather than touching live state. diff --git a/src/runtime/node/zlib/NativeZstd.rs b/src/runtime/node/zlib/NativeZstd.rs index 8a56248734c2..84069f3f2cd6 100644 --- a/src/runtime/node/zlib/NativeZstd.rs +++ b/src/runtime/node/zlib/NativeZstd.rs @@ -458,11 +458,48 @@ mod _impl { } } + /// Keeps the dictionary and parameters, as node does since v26.10.0 (nodejs/node#65867). pub(crate) fn reset(&mut self) -> Error { - // Matches node's `ZstdContext::ResetStream()`, which calls `Init()` - // with its default (empty) dictionary — a reset drops the dictionary. - // `init` frees the previous context itself. - self.init(self.pledged_src_size, None) + // No `..`: a field added to `Context` must be kept or cleared here to compile. + let Self { + mode, + state, + pledged_src_size, + flush: _, + input: _, + output: _, + remaining: _, + } = *self; + // JS can reach this with no context: init() was never called, or it failed. + let Some(state) = state else { + return Error::OK; + }; + let result = match mode { + NodeMode::ZSTD_COMPRESS => { + // SAFETY: state is a valid CCtx set by init(). + let result = + unsafe { c::ZSTD_CCtx_reset(state.cast(), c::ZSTD_reset_session_only) }; + if c::ZSTD_isError(result) > 0 { + result + } else { + // A session reset clears the pledged size: zstd keeps it for one frame. + // SAFETY: state is a valid CCtx set by init(). + unsafe { + c::ZSTD_CCtx_setPledgedSrcSize(state.cast(), pledged_src_size as _) + } + } + } + // SAFETY: state is a valid DCtx set by init(). + NodeMode::ZSTD_DECOMPRESS => unsafe { + c::ZSTD_DCtx_reset(state.cast(), c::ZSTD_reset_session_only) + }, + _ => unreachable!(), + }; + if c::ZSTD_isError(result) == 0 { + return Error::OK; + } + self.remaining = result as u64; + self.get_error_info() } /// Frees the Zstd encoder/decoder state without changing mode. diff --git a/src/zstd/lib.rs b/src/zstd/lib.rs index 974a545f386d..b2f1933e9df6 100644 --- a/src/zstd/lib.rs +++ b/src/zstd/lib.rs @@ -37,6 +37,8 @@ pub mod c { // ZSTD_cParameter pub const ZSTD_c_compressionLevel: ZSTD_cParameter = 100; + // ZSTD_ResetDirective + pub const ZSTD_reset_session_only: ZSTD_ResetDirective = 1; pub const ZSTD_reset_session_and_parameters: ZSTD_ResetDirective = 3; // ZSTD_ErrorCode (zstd_errors.h) — only the public stable subset. diff --git a/test/js/node/test/parallel/test-zlib-zstd-reset.js b/test/js/node/test/parallel/test-zlib-zstd-reset.js new file mode 100644 index 000000000000..839669bc630a --- /dev/null +++ b/test/js/node/test/parallel/test-zlib-zstd-reset.js @@ -0,0 +1,68 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { finished } = require('stream/promises'); +const test = require('node:test'); +const zlib = require('zlib'); + +const dictionary = Buffer.from( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + + 'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', +); +const input = Buffer.from( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(100), +); + +async function collect(stream, ...data) { + const chunks = []; + stream.on('data', (chunk) => chunks.push(chunk)); + for (let i = 0; i < data.length - 1; i++) { + stream.write(data[i]); + } + stream.end(data[data.length - 1]); + await finished(stream); + return Buffer.concat(chunks); +} + +test('ZstdCompress reset preserves its initial options', async () => { + const options = { + dictionary, + pledgedSrcSize: input.length, + params: { + [zlib.constants.ZSTD_c_compressionLevel]: 19, + [zlib.constants.ZSTD_c_checksumFlag]: 1, + }, + }; + const expected = await collect(zlib.createZstdCompress(options), input); + const reset = zlib.createZstdCompress(options); + reset.reset(); + + assert.deepStrictEqual(await collect(reset, input), expected); +}); + +test('ZstdDecompress reset preserves its dictionary', async () => { + const compressed = zlib.zstdCompressSync(input, { dictionary }); + const decompress = zlib.createZstdDecompress({ dictionary }); + decompress.reset(); + + assert.deepStrictEqual(await collect(decompress, compressed), input); +}); + +test('ZstdDecompress reset preserves its parameters', async () => { + const compressed = await collect(zlib.createZstdCompress({ + params: { + [zlib.constants.ZSTD_c_windowLog]: 11, + }, + }), Buffer.alloc(2048), Buffer.alloc(2048)); + const decompress = zlib.createZstdDecompress({ + params: { + [zlib.constants.ZSTD_d_windowLogMax]: 10, + }, + }); + decompress.reset(); + + await assert.rejects(collect(decompress, compressed), { + code: 'ZSTD_error_frameParameter_windowTooLarge', + }); +}); diff --git a/test/js/node/zlib/zlib-zstd-reset.test.ts b/test/js/node/zlib/zlib-zstd-reset.test.ts new file mode 100644 index 000000000000..a77c6854dec8 --- /dev/null +++ b/test/js/node/zlib/zlib-zstd-reset.test.ts @@ -0,0 +1,358 @@ +/** + * The tests of streams in this file run in both Bun and Node.js: `bun test` + * runs them here, and the last test runs this same file under Node.js, when + * the `node` of the machine is v26.10.0 or later. The tests of handles run in + * Bun only, in a new process. + * + * reset() of a zstd stream starts a new frame and keeps the dictionary and the + * parameters of the stream. Node does this since v26.10.0 + * (https://github.com/nodejs/node/pull/65867). Node's own test, + * test/js/node/test/parallel/test-zlib-zstd-reset.js, resets before the first + * write. This file has the other orders. + */ +import assert from "node:assert"; +import { finished } from "node:stream/promises"; +import { after, before, describe, test } from "node:test"; +import { fileURLToPath } from "node:url"; +import zlib from "node:zlib"; + +const { + ZSTD_c_checksumFlag, + ZSTD_c_compressionLevel, + ZSTD_c_jobSize, + ZSTD_c_nbWorkers, + ZSTD_c_windowLog, + ZSTD_d_windowLogMax, + ZSTD_e_end, +} = zlib.constants; + +const dictionary = Buffer.from( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " + + "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", +); +const input = Buffer.alloc(5700, "Lorem ipsum dolor sit amet, consectetur adipiscing elit. "); +const half = input.subarray(0, input.length / 2); +const rest = input.subarray(input.length / 2); + +const compressOptions = { + dictionary, + pledgedSrcSize: input.length, + params: { [ZSTD_c_compressionLevel]: 19, [ZSTD_c_checksumFlag]: 1 }, +}; + +/** Collects what the stream emits from now on. */ +function collect(stream) { + const chunks = []; + stream.on("data", chunk => chunks.push(chunk)); + return chunks; +} + +function write(stream, chunk) { + return new Promise((resolve, reject) => stream.write(chunk, err => (err ? reject(err) : resolve(undefined)))); +} + +/** + * Writes `first`, ends the stream with `last`, and gives the bytes the stream emits until it ends. + * Two chunks, because zstd replaces the pledged size with the size of the input when the first chunk is also the last. + */ +async function run(stream, first, last) { + const chunks = collect(stream); + stream.write(first); + stream.end(last); + await finished(stream); + return Buffer.concat(chunks); +} + +/** The frame of a compressor that nothing reset. */ +function frame(options = compressOptions) { + return run(zlib.createZstdCompress(options), half, rest); +} + +test("each option changes the frame that the other tests compare with", async () => { + const expected = await frame(); + for (const without of ["dictionary", "pledgedSrcSize", "params"]) { + assert.notDeepStrictEqual(await frame({ ...compressOptions, [without]: undefined }), expected, without); + } +}); + +test("ZstdCompress: _handle.reset() keeps the options", async () => { + const stream = zlib.createZstdCompress(compressOptions); + stream._handle.reset(); + assert.deepStrictEqual(await run(stream, half, rest), await frame()); +}); + +test("ZstdCompress: a second reset() keeps the options", async () => { + const stream = zlib.createZstdCompress(compressOptions); + stream.reset(); + stream.reset(); + assert.deepStrictEqual(await run(stream, half, rest), await frame()); +}); + +test("ZstdCompress: reset() after a write drops the open frame and keeps the options", async () => { + const stream = zlib.createZstdCompress(compressOptions); + const chunks = collect(stream); + await write(stream, Buffer.from("reset() drops this frame")); + stream.reset(); + stream.write(half); + stream.end(rest); + await finished(stream); + assert.deepStrictEqual(Buffer.concat(chunks), await frame()); +}); + +test("ZstdCompress: reset() between two frames keeps the options for the second frame", async () => { + const stream = zlib.createZstdCompress(compressOptions); + const chunks = collect(stream); + stream.write(half); + stream.write(rest); + await new Promise(resolve => stream.flush(ZSTD_e_end, resolve)); + const first = Buffer.concat(chunks.splice(0)); + assert.deepStrictEqual(zlib.zstdDecompressSync(first, { dictionary }), input); + stream.reset(); + stream.write(half); + stream.end(rest); + await finished(stream); + assert.deepStrictEqual(Buffer.concat(chunks), await frame()); +}); + +// A session reset of zstd clears the pledged size. reset() sets it again. +test("ZstdCompress: pledgedSrcSize applies again after reset()", async () => { + const stream = zlib.createZstdCompress({ pledgedSrcSize: input.length }); + stream.reset(); + await assert.rejects(run(stream, half, rest.subarray(1)), { code: "ZSTD_error_srcSize_wrong" }); +}); + +test("ZstdCompress: reset() after a second _handle.init() keeps the options of that init()", async () => { + const other = Buffer.from(dictionary).reverse(); + const stream = zlib.createZstdCompress({ dictionary: other, params: { [ZSTD_c_compressionLevel]: 1 } }); + const params = new Uint32Array(ZSTD_c_checksumFlag + 1).fill(-1); + params[ZSTD_c_compressionLevel] = 19; + params[ZSTD_c_checksumFlag] = 1; + stream._handle.init(params, input.length, stream._writeState, () => {}, dictionary); + stream._handle.reset(); + assert.deepStrictEqual(stream._processChunk(input, ZSTD_e_end), zlib.zstdCompressSync(input, compressOptions)); +}); + +test("ZstdCompress: reset() keeps ZSTD_c_nbWorkers", async () => { + const job = 512 * 1024; + const params = { [ZSTD_c_jobSize]: job, [ZSTD_c_checksumFlag]: 1 }; + const oneWorker = { params: { ...params, [ZSTD_c_nbWorkers]: 1 } }; + // Two jobs. One chunk is one job: a stream with workers ends with no output + // after a chunk of more than one job, in Node too. + const text = Buffer.alloc(2 * job, input); + const first = text.subarray(0, job); + const last = text.subarray(job); + const expected = await run(zlib.createZstdCompress(oneWorker), first, last); + assert.deepStrictEqual(zlib.zstdDecompressSync(expected), text); + assert.notDeepStrictEqual(await run(zlib.createZstdCompress({ params }), first, last), expected); + + const stream = zlib.createZstdCompress(oneWorker); + stream.reset(); + assert.deepStrictEqual(await run(stream, first, last), expected); +}); + +test("ZstdDecompress: reset() in the middle of a frame keeps the dictionary", async () => { + const compressed = zlib.zstdCompressSync(input, { dictionary }); + const stream = zlib.createZstdDecompress({ dictionary }); + const chunks = collect(stream); + await write(stream, compressed.subarray(0, 10)); + stream.reset(); + stream.end(compressed); + await finished(stream); + assert.deepStrictEqual(Buffer.concat(chunks), input); +}); + +test("ZstdDecompress: reset() between two frames keeps the dictionary", async () => { + const stream = zlib.createZstdDecompress({ dictionary }); + const chunks = collect(stream); + await write(stream, zlib.zstdCompressSync(half, { dictionary })); + stream.reset(); + stream.end(zlib.zstdCompressSync(rest, { dictionary })); + await finished(stream); + assert.deepStrictEqual(Buffer.concat(chunks), input); +}); + +test("ZstdDecompress: reset() after a frame keeps ZSTD_d_windowLogMax", async () => { + const large = await run( + zlib.createZstdCompress({ params: { [ZSTD_c_windowLog]: 11 } }), + Buffer.alloc(2048), + Buffer.alloc(2048), + ); + const stream = zlib.createZstdDecompress({ params: { [ZSTD_d_windowLogMax]: 10 } }); + const chunks = collect(stream); + await write(stream, zlib.zstdCompressSync(Buffer.from("a frame with a small window"))); + assert.deepStrictEqual(Buffer.concat(chunks).toString(), "a frame with a small window"); + stream.reset(); + stream.end(large); + await assert.rejects(finished(stream), { code: "ZSTD_error_frameParameter_windowTooLarge" }); +}); + +test("ZstdDecompress: _processChunk() after _handle.reset() keeps the dictionary", () => { + const compressed = zlib.zstdCompressSync(input, { dictionary }); + const stream = zlib.createZstdDecompress({ dictionary }); + const handle = stream._handle; + // _processChunk() closes the handle when it returns. Keep the handle open for the second call. + const close = handle.close; + handle.close = () => {}; + try { + const first = stream._processChunk(compressed, ZSTD_e_end); + stream._handle = handle; + handle.reset(); + const second = stream._processChunk(compressed, ZSTD_e_end); + assert.deepStrictEqual({ first, second }, { first: input, second: input }); + } finally { + close.call(handle); + } +}); + +// What the process of `handleFixture` prints when every case in it is correct. +const handleFixtureOutput = [ + "a compressor with no context, reset() and a write: []", + "a decompressor with no context, reset() and a write: []", + "reset() while a job waits for the worker: same frame true", + "a job fails while another job waits for the worker: ZSTD_error_srcSize_wrong, same frame true", + "reset() while the worker uses the dictionary: same frame true", +]; + +// Cases that drive the native handle. A process that gets one of them wrong +// can crash, and Node v26.10.0 does crash in four of the five. +const handleFixture = /* js */ ` + const zlib = require("node:zlib"); + const { ZSTD_c_checksumFlag, ZSTD_c_jobSize, ZSTD_c_nbWorkers, ZSTD_e_continue, ZSTD_e_end } = zlib.constants; + + // reset() works on the context that init() made. A handle that has none + // gets none from reset(), so the write after it does nothing. + const Handle = zlib.createZstdCompress()._handle.constructor; + for (const [name, mode, chunk] of [ + ["compressor", zlib.constants.ZSTD_COMPRESS, Buffer.from("x")], + ["decompressor", zlib.constants.ZSTD_DECOMPRESS, zlib.zstdCompressSync("x")], + ]) { + const handle = new Handle(mode); + handle.reset(); + const written = Buffer.alloc(64); + handle.writeSync(ZSTD_e_end, chunk, 0, chunk.length, written, 0, written.length); + const bytes = written.toString("hex").replace(/(00)+$/, ""); + console.log("a " + name + " with no context, reset() and a write: [" + bytes + "]"); + } + + // The other cases use one worker thread. zstd prepares a job and keeps it + // (mtctx->jobReady) while the worker is busy. It erased its job table at + // the start of the next frame and still took the job for prepared, so the + // worker ran an erased job: SIGSEGV in ZSTDMT_compressionJob. + // patches/zstd/mt-clear-job-ready.patch clears the flag. + const job = 512 * 1024; + const params = { [ZSTD_c_nbWorkers]: 1, [ZSTD_c_jobSize]: job, [ZSTD_c_checksumFlag]: 1 }; + // Two jobs of hex digits with no repeats in them: the worker needs longer + // for a job than the next call needs to prepare one. + const cipher = require("node:crypto").createCipheriv("aes-128-ctr", Buffer.alloc(16), Buffer.alloc(16)); + const slow = Buffer.from(cipher.update(Buffer.alloc(job)).toString("hex")); + const out = Buffer.alloc(2 * slow.length); + + // A frame of 256 bytes. The first call does not end the frame, so zstd + // does not know that the frame is small, and gives it to the worker. + function smallFrame(stream) { + stream._handle.writeSync(ZSTD_e_continue, slow, 0, 256, out, 0, out.length); + stream._handle.writeSync(ZSTD_e_end, null, 0, 0, out, 0, out.length); + return Buffer.from(out.subarray(0, out.length - stream._writeState[0])); + } + // Compares the next frame of the stream with the frame of a new stream. + const sameFrame = (stream, options) => + "same frame " + smallFrame(stream).equals(smallFrame(zlib.createZstdCompress(options))); + + { + // reset() in that state. The worker takes the first job. The second job + // waits when the worker is still busy, and then the two writes have + // given no output. If the worker was faster, a new stream tries again. + // The last try counts in either state: the scheduler must not fail this. + let stream, produced, attempts = 0; + do { + stream = zlib.createZstdCompress({ params }); + produced = 0; + for (const offset of [0, job]) { + stream._handle.writeSync(ZSTD_e_continue, slow, offset, job, out, 0, out.length); + produced += out.length - stream._writeState[0]; + } + } while (produced !== 0 && ++attempts < 10); + stream._handle.reset(); + console.log("reset() while a job waits for the worker: " + sameFrame(stream, { params })); + } + + { + // No reset(). The first job is longer than the pledged size, so it fails + // while the second job waits, and zstd starts a new session by itself. + // The pledged size is more than 512 KiB, or zstd uses no worker. + const options = { params: { ...params, [ZSTD_c_jobSize]: 2 * job } }; + const stream = zlib.createZstdCompress({ ...options, pledgedSrcSize: job + 1 }); + const errors = []; + // The onerror that zlib.ts installs closes the handle. + stream._handle.onerror = (message, errno, code) => errors.push(code); + for (let i = 0; i < 2; i++) stream._handle.writeSync(ZSTD_e_continue, slow, 0, 2 * job, out, 0, out.length); + stream._handle.writeSync(ZSTD_e_end, null, 0, 0, out, 0, out.length); + console.log("a job fails while another job waits for the worker: " + errors + ", " + sameFrame(stream, options)); + } + + { + // With a dictionary. reset() used to free the context, and zstd frees + // the dictionary before it stops the worker that reads it (#44201). + const options = { params, dictionary: slow.subarray(0, 64 * 1024) }; + const stream = zlib.createZstdCompress(options); + stream._handle.writeSync(ZSTD_e_continue, slow, 0, job, out, 0, out.length); + stream._handle.reset(); + console.log("reset() while the worker uses the dictionary: " + sameFrame(stream, options)); + } +`; + +// Only in Bun: when Node.js runs this file it must not spawn itself again. +if (typeof Bun !== "undefined") { + const { bunEnv, bunExe, nodeExe } = await import("harness"); + // Node.js runs this file only when it keeps the options in reset(). An + // older one fails the tests, and some cannot load a .ts file. + const node = (() => { + const node = nodeExe(); + if (!node) return null; + const { stdout } = Bun.spawnSync({ cmd: [node, "-p", "process.versions.node"], env: bunEnv, stderr: "ignore" }); + const [major, minor] = stdout.toString().split(".").map(Number); + return major > 26 || (major === 26 && minor >= 10) ? node : null; + })(); + + // The process starts before the first test and runs beside the tests above. + let fixture: ReturnType | undefined; + const spawnHandleFixture = () => + Bun.spawn({ + cmd: [bunExe(), "-e", handleFixture], + // The finalizer of every handle runs at exit, as on the ASAN CI lanes. + env: { ...bunEnv, BUN_DESTRUCT_VM_ON_EXIT: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + before(() => { + fixture = spawnHandleFixture(); + }); + after(() => { + fixture?.kill(); + }); + + test("reset() of a zstd handle, in a new process", async () => { + const proc = fixture!; + // stderr is drained and not compared: a debug build writes to it. + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + assert.deepStrictEqual( + { stdout: stdout.trim().split(/\r?\n/), exitCode }, + { stdout: handleFixtureOutput, exitCode: 0 }, + ); + }); + + describe("Node.js compatibility", () => { + (node ? test : test.skip)("all tests pass in Node.js", async () => { + // A direct run, not `node --test`: the runner mode forks a second node + // process per file. node:test still exits non-zero on any failure. + await using proc = Bun.spawn({ + cmd: [node!, fileURLToPath(import.meta.url)], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + assert.deepStrictEqual({ exitCode, output: exitCode === 0 ? "" : stdout + stderr }, { exitCode: 0, output: "" }); + }); + }); +}