From 78a30f5ab24e74e975d62fac5ed51b79115c7a05 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:04:52 +0000 Subject: [PATCH] Parse a private copy of a shared or resizable buffer Bun.Transpiler#transformSync, scan and scanImports, Bun.markdown.html, ansi, render and react, Bun.YAML.parse and Bun.TOML.parse read the caller's bytes in place. Each one reads a byte more than once. The TypeScript lexer counts the separators in a numeric literal, allocates len - count, then re-reads the digits. The markdown block parser scans a heading line twice, and the HTML renderer searches for the next byte in "&<>\"" and reads that position again. The YAML scanner asserts the byte it already read. TOML measures the UTF-16 length of a string and converts it in a second pass. A worker that writes the SharedArrayBuffer between the two reads aborts the process, and the TOML path writes past the end of its output. A resizable ArrayBuffer that a markdown callback or a macro shrinks unmaps the pages the call still reads. Add ArrayBuffer::can_change_under_borrow (shared or resizable) and ArrayBuffer::try_copy_bytes to bun_jsc. try_copy_bytes copies through the raw pointer and is never inlined, so no &[u8] covers bytes that another thread writes and no caller is compiled together with the copy. StringOrBuffer::from_js_stable uses them to hand these entry points an owned copy of such a buffer, and leaves a fixed unshared view borrowed. transformSync and scan run macros in the middle of the parse, and a macro can write, shrink or transfer() the input. They copy every buffer, as the async transform() does. A fixed, unshared source over Source::MAX_PARSEABLE_LEN stays borrowed: the parser rejects it by its length before a macro can run. TextDecoder.decode, PinnedArrayBuffer::copy_if_resizable, the async transform() and StringOrBuffer::from_js_to_owned_slice made the same copy by hand. They now call try_copy_bytes, and an allocation failure in the last two throws OutOfMemoryError. This covers these entry points only. The parsers still expect bytes that do not change, and other callers of StringOrBuffer::from_js keep the borrow. --- src/jsc/array_buffer.rs | 28 ++++- src/runtime/api.rs | 2 +- src/runtime/api/JSTranspiler.rs | 30 ++++- src/runtime/api/MarkdownObject.rs | 8 +- src/runtime/node/types.rs | 55 +++++++- src/runtime/webcore/TextDecoder.rs | 6 +- test/js/bun/md/md-render-callback.test.ts | 68 ++++++++++ .../bun/util/parse-shared-buffer-fixture.ts | 119 ++++++++++++++++++ test/js/bun/util/parse-shared-buffer.test.ts | 118 +++++++++++++++++ 9 files changed, 413 insertions(+), 21 deletions(-) create mode 100644 test/js/bun/util/parse-shared-buffer-fixture.ts create mode 100644 test/js/bun/util/parse-shared-buffer.test.ts diff --git a/src/jsc/array_buffer.rs b/src/jsc/array_buffer.rs index 149d4671e90c..ce0715a4ef84 100644 --- a/src/jsc/array_buffer.rs +++ b/src/jsc/array_buffer.rs @@ -159,6 +159,27 @@ impl ArrayBuffer { } } + /// True for a `SharedArrayBuffer`, which another thread writes during the call, and for a resizable buffer, whose `resize()` unmaps pages as soon as the call runs JS. + pub fn can_change_under_borrow(&self) -> bool { + self.shared || self.resizable + } + + /// A private copy of the bytes, made out of line so that no caller is compiled to read a `SharedArrayBuffer` source twice. `None` if it cannot be allocated. + #[inline(never)] + pub fn try_copy_bytes(&self) -> Option> { + let mut copy = Vec::new(); + if self.is_detached() { + return Some(copy); + } + copy.try_reserve_exact(self.byte_len).ok()?; + // SAFETY: `ptr` is non-null and valid for `byte_len` bytes, and `copy` has that capacity. The read stays on the raw pointer: another thread can write a `SharedArrayBuffer` during it, so no `&[u8]` may cover those bytes. + unsafe { + core::ptr::copy_nonoverlapping(self.ptr, copy.as_mut_ptr(), self.byte_len); + copy.set_len(self.byte_len); + } + Some(copy) + } + // require('buffer').kMaxLength. // keep in sync with Bun::Buffer::kMaxLength pub const MAX_SIZE: c_uint = c_uint::MAX; @@ -700,12 +721,9 @@ impl PinnedArrayBuffer { { return true; } - let bytes = self.buffer.byte_slice(); - let mut copy = Vec::new(); - if copy.try_reserve_exact(bytes.len()).is_err() { + let Some(mut copy) = self.buffer.try_copy_bytes() else { return false; - } - copy.extend_from_slice(bytes); + }; global.vm().report_extra_memory(copy.len()); self.buffer.ptr = copy.as_mut_ptr(); self.copy = Some(copy); diff --git a/src/runtime/api.rs b/src/runtime/api.rs index 2bb6ee68caec..040d488c4184 100644 --- a/src/runtime/api.rs +++ b/src/runtime/api.rs @@ -268,7 +268,7 @@ fn with_text_format_source_encoded( let mut encoding = SourceEncoding::Utf8Text; let bytes: &[u8] = 'bytes: { if blob_or_buffer_input == BlobOrBufferInput::Bytes && !input_value.is_string() { - if let Some(v) = BlobOrStringOrBuffer::from_js(global, input_value)? { + if let Some(v) = BlobOrStringOrBuffer::from_js_stable(global, input_value)? { _blob_hold = v; encoding = SourceEncoding::Bytes; break 'bytes _blob_hold.slice(); diff --git a/src/runtime/api/JSTranspiler.rs b/src/runtime/api/JSTranspiler.rs index df5d4bc5f529..af14e95d0e3e 100644 --- a/src/runtime/api/JSTranspiler.rs +++ b/src/runtime/api/JSTranspiler.rs @@ -129,6 +129,26 @@ fn level_from_js(global: &JSGlobalObject, value: JSValue) -> JsResult JsResult>> { + let parsed = StringOrBuffer::from_js(global, value)?; + let Some(StringOrBuffer::Buffer(buffer)) = &parsed else { + return Ok(parsed); + }; + if !buffer.buffer.can_change_under_borrow() + && buffer.buffer.byte_len > bun_ast::Source::MAX_PARSEABLE_LEN + { + return Ok(parsed); + } + match buffer.buffer.try_copy_bytes() { + Some(copy) => Ok(Some(StringOrBuffer::owned(copy))), + None => Err(global.throw_out_of_memory()), + } +} + /// Deep-clone a [`MacroMap`]. The keys are `Box<[u8]>`, so an owned copy /// is needed wherever the map is assigned by value. fn clone_macro_map(src: &MacroMap) -> MacroMap { @@ -1270,7 +1290,7 @@ impl JSTranspiler { return Err(global.throw_invalid_argument_type("scan", "code", "string or Uint8Array")); }; - let Some(code_holder) = StringOrBuffer::from_js(global, code_arg)? else { + let Some(code_holder) = code_from_js(global, code_arg)? else { return Err(global.throw_invalid_argument_type("scan", "code", "string or Uint8Array")); }; let code = code_holder.slice(); @@ -1359,7 +1379,9 @@ impl JSTranspiler { }; let code = if let Some(buffer) = code_arg.as_array_buffer(global) { - let bytes = buffer.byte_slice().to_vec(); + let Some(bytes) = buffer.try_copy_bytes() else { + return Err(global.throw_out_of_memory()); + }; global.vm().report_extra_memory(bytes.len()); StringOrBuffer::owned_isolated(bytes) } else if let Some(code) = StringOrBuffer::from_js_async(global, code_arg)? { @@ -1412,7 +1434,7 @@ impl JSTranspiler { }; let arena = Arena::new(); - let Some(code_holder) = StringOrBuffer::from_js(global, code_arg)? else { + let Some(code_holder) = code_from_js(global, code_arg)? else { return Err(global.throw_invalid_argument_type( "transformSync", "code", @@ -1615,7 +1637,7 @@ impl JSTranspiler { )); }; - let Some(code_holder) = StringOrBuffer::from_js(global, code_arg)? else { + let Some(code_holder) = StringOrBuffer::from_js_stable(global, code_arg)? else { return Err(global.throw_invalid_argument_type( "scanImports", "code", diff --git a/src/runtime/api/MarkdownObject.rs b/src/runtime/api/MarkdownObject.rs index 00c72589170a..4380f18d6071 100644 --- a/src/runtime/api/MarkdownObject.rs +++ b/src/runtime/api/MarkdownObject.rs @@ -123,7 +123,7 @@ pub(crate) fn render_to_ansi( .throw_invalid_arguments(format_args!("Expected a string or buffer to render"))); } - let Some(buffer) = StringOrBuffer::from_js(global_this, input_value)? else { + let Some(buffer) = StringOrBuffer::from_js_stable(global_this, input_value)? else { return Err(global_this .throw_invalid_arguments(format_args!("Expected a string or buffer to render"))); }; @@ -191,7 +191,7 @@ fn render_to_html(global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResu .throw_invalid_arguments(format_args!("Expected a string or buffer to render"))); } - let Some(buffer) = StringOrBuffer::from_js(global_this, input_value)? else { + let Some(buffer) = StringOrBuffer::from_js_stable(global_this, input_value)? else { return Err(global_this .throw_invalid_arguments(format_args!("Expected a string or buffer to render"))); }; @@ -294,7 +294,7 @@ fn render(global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult JsResult> { + let mut parsed = Self::from_js(global, value)?; + if let Some(Self::StringOrBuffer(input)) = &mut parsed + && !input.copy_if_can_change() + { + return Err(global.throw_out_of_memory()); + } + Ok(parsed) + } + /// Like [`from_js_with_encoding_value_allow_request_response`] but takes an /// already-parsed [`Encoding`], so callers that must inspect the encoding /// first (e.g. to validate odd-length hex) don't coerce `encoding_value` @@ -319,6 +333,22 @@ impl<'a> StringOrBuffer<'a> { } impl StringOrBuffer<'_> { + /// Replaces a borrowed buffer that [`can_change_under_borrow`](jsc::ArrayBuffer::can_change_under_borrow) with a private copy of its bytes. `false` if the copy cannot be allocated. + #[must_use] + pub(crate) fn copy_if_can_change(&mut self) -> bool { + let Self::Buffer(buffer) = self else { + return true; + }; + if !buffer.buffer.can_change_under_borrow() { + return true; + } + let Some(copy) = buffer.buffer.try_copy_bytes() else { + return false; + }; + *self = Self::owned(copy); + true + } + pub(crate) fn into_js(self, ctx: &JSGlobalObject) -> JsResult { match self { Self::ThreadIsolatedString(str) | Self::String(str) => str.into_js(ctx), @@ -366,11 +396,11 @@ impl StringOrBuffer<'static> { value: JSValue, ) -> JsResult> { if let Some(array_buffer) = value.as_array_buffer(global_object) { - let bytes = array_buffer.byte_slice(); - global_object - .vm() - .report_extra_memory(array_buffer.len as usize); - return Ok(bytes.to_vec()); + let Some(bytes) = array_buffer.try_copy_bytes() else { + return Err(global_object.throw_out_of_memory()); + }; + global_object.vm().report_extra_memory(bytes.len()); + return Ok(bytes); } let str = bun_core::String::from_js(value, global_object)?; @@ -458,6 +488,21 @@ impl StringOrBuffer<'static> { Self::from_js_maybe_async(global, value, Flavor::Sync, StringObjects::Allow) } + /// [`from_js`](Self::from_js), with a buffer copied if [`copy_if_can_change`](Self::copy_if_can_change) says so. + #[inline] + pub(crate) fn from_js_stable( + global: &JSGlobalObject, + value: JSValue, + ) -> JsResult> { + let mut parsed = Self::from_js(global, value)?; + if let Some(input) = &mut parsed + && !input.copy_if_can_change() + { + return Err(global.throw_out_of_memory()); + } + Ok(parsed) + } + /// [`from_js`](Self::from_js) for a work-pool job that reads the bytes itself: strings thread-isolated, buffers pinned and GC-rooted, a resizable buffer copied ([`PinnedArrayBuffer::copy_if_resizable`]). #[inline] pub(crate) fn from_js_async( diff --git a/src/runtime/webcore/TextDecoder.rs b/src/runtime/webcore/TextDecoder.rs index 0d51a10b1502..df992a7aa911 100644 --- a/src/runtime/webcore/TextDecoder.rs +++ b/src/runtime/webcore/TextDecoder.rs @@ -301,8 +301,10 @@ impl TextDecoder { if let Some(ab) = arguments[0].as_array_buffer(global_this) { array_buffer = ab; - if array_buffer.shared || array_buffer.resizable { - owned_input = Box::<[u8]>::from(array_buffer.slice()); + if array_buffer.can_change_under_borrow() { + owned_input = array_buffer + .try_copy_bytes() + .ok_or_else(|| global_this.throw_out_of_memory())?; break 'input_slice &owned_input; } break 'input_slice array_buffer.slice(); diff --git a/test/js/bun/md/md-render-callback.test.ts b/test/js/bun/md/md-render-callback.test.ts index 7351d508360b..5f33acb40665 100644 --- a/test/js/bun/md/md-render-callback.test.ts +++ b/test/js/bun/md/md-render-callback.test.ts @@ -426,4 +426,72 @@ describe("Bun.markdown buffer input", () => { input.buffer.transfer(); expect((input.buffer as ArrayBuffer).detached).toBe(true); }); + + // A pin stops a detach but not a shrink: `resize()` gives the trimmed pages + // back. Option getters and render callbacks run after the call took its + // input, so a resizable input renders from the bytes that were passed in. + describe("a resizable input that shrinks while the call runs", () => { + const source = "# Hello\n\nworld **bold**\n"; + + function resizableInput() { + const bytes = new TextEncoder().encode(source); + const buffer = new ArrayBuffer(bytes.length, { maxByteLength: bytes.length }); + const view = new Uint8Array(buffer); + view.set(bytes); + return { buffer, view }; + } + + test("html: an option getter shrinks it", () => { + const { buffer, view } = resizableInput(); + const options = { + get autolinks() { + buffer.resize(0); + return true; + }, + }; + expect(Markdown.html(view, options)).toBe(Markdown.html(source, { autolinks: true })); + expect(buffer.byteLength).toBe(0); + }); + + test("ansi: a theme getter shrinks it", () => { + const { buffer, view } = resizableInput(); + const theme = { + get colors() { + buffer.resize(0); + return false; + }, + }; + expect(Markdown.ansi(view, theme)).toBe(Markdown.ansi(source, { colors: false })); + expect(buffer.byteLength).toBe(0); + }); + + test("render: a callback shrinks it", () => { + const { buffer, view } = resizableInput(); + const result = Markdown.render(view, { + heading: (children: string) => { + buffer.resize(0); + return `

${children}

`; + }, + paragraph: (children: string) => `

${children}

`, + strong: (children: string) => `${children}`, + }); + expect(result).toBe("

Hello

world bold

"); + expect(buffer.byteLength).toBe(0); + }); + + test("react: an option getter shrinks it", () => { + const { buffer, view } = resizableInput(); + const options = { + reactVersion: 18, + get autolinks() { + buffer.resize(0); + return true; + }, + }; + expect(Markdown.react(view, undefined, options)).toEqual( + Markdown.react(source, undefined, { reactVersion: 18, autolinks: true }), + ); + expect(buffer.byteLength).toBe(0); + }); + }); }); diff --git a/test/js/bun/util/parse-shared-buffer-fixture.ts b/test/js/bun/util/parse-shared-buffer-fixture.ts new file mode 100644 index 000000000000..e2e2948329a8 --- /dev/null +++ b/test/js/bun/util/parse-shared-buffer-fixture.ts @@ -0,0 +1,119 @@ +// Fixture for parse-shared-buffer.test.ts. +// +// bun parse-shared-buffer-fixture.ts +// +// The main thread parses a SharedArrayBuffer while a worker flips a few of its +// bytes back and forth. Each flipped byte is one the parser reads twice, and +// the second value contradicts what the parser concluded from the first. A +// parser that reads the shared bytes in place aborts the process. One that +// reads a private copy sees one value per byte. Prints "ok" after +// calls that ran while the worker wrote. A call can return or throw. +import { isMainThread, Worker, workerData } from "node:worker_threads"; + +type Case = { + text: string; + /** The value the worker flips the byte at `at` to, or `byte` to leave it. */ + flip: (byte: number, at: number) => number; +}; + +// The block parser sees `#`, starts the heading text after it, then scans for +// the end of the line from the `#` again. A newline there ends the line before +// the text starts. +const heading: Case = { text: "# heading\n\nparagraph\n", flip: (byte, at) => (at === 0 ? 0x0a : byte) }; + +// The lexer counts the `_` separators of a numeric literal, allocates +// `length - count` bytes, then copies every byte that is not a `_`. +const separators: Case = { text: "let a = 1_000_000_000_000_000;\n", flip: byte => (byte === 0x5f ? 0x30 : byte) }; + +const CASES: Record = { + transformSync: separators, + scanImports: separators, + markdown: heading, + ansi: heading, + // The scanner reads a byte of a plain scalar, then asserts that the input + // still holds it when it appends it to the string. + yaml: { text: "key: value value value value\n", flip: byte => (byte === 0x76 ? 0x77 : byte) }, + // A string is measured in UTF-16 units, then converted in a second pass. A + // 3-byte sequence that turns into three ASCII letters is three units, not one. + toml: { text: 'a = "€€€€€€€€€€€€€€€€"\n', flip: byte => (byte > 0x7f ? 0x61 : byte) }, +}; + +const api = process.argv[2]; +const calls = Number(process.argv[3]); +const base = new TextEncoder().encode(CASES[api].text); + +if (isMainThread) { + const bytes = new SharedArrayBuffer(base.length); + // The number of passes the worker has made over the bytes it flips. + const passes = new Int32Array(new SharedArrayBuffer(4)); + const worker = new Worker(new URL(import.meta.url), { + workerData: { bytes, passes }, + argv: [api, String(calls)], + }); + worker.unref(); + + const input = new Uint8Array(bytes); + input.set(base); + + // The worker's events cannot reach this thread while it parses in a loop, so + // a worker that stopped shows only as a pass count that stands still. + const workerStopped = (): never => { + console.error("the worker made no pass over the bytes for 10 s"); + process.exit(1); + }; + + if (Atomics.wait(passes, 0, 0, 10_000) === "timed-out") workerStopped(); + + const transpiler = new Bun.Transpiler({ loader: "ts" }); + const parse: Record unknown> = { + transformSync: () => transpiler.transformSync(input), + scanImports: () => transpiler.scanImports(input), + markdown: () => Bun.markdown.html(input), + ansi: () => Bun.markdown.ansi(input), + yaml: () => Bun.YAML.parse(input), + toml: () => Bun.TOML.parse(input), + }; + + // Count a call only when the worker made a pass while it ran. A debug build + // is slow enough that every call counts. A release build can make a thousand + // calls before the worker's thread gets a core of its own. + let seen = Atomics.load(passes, 0); + let progressAt = performance.now(); + for (let overlapped = 0; overlapped < calls; ) { + try { + parse[api](); + } catch {} + const now = Atomics.load(passes, 0); + if (now !== seen) { + seen = now; + overlapped++; + progressAt = performance.now(); + } else if (performance.now() - progressAt > 10_000) { + workerStopped(); + } + } + + console.log("ok"); + process.exit(0); +} else { + const { bytes, passes } = workerData as { bytes: SharedArrayBuffer; passes: Int32Array }; + const input = new Uint8Array(bytes); + + const at: number[] = []; + const to: number[] = []; + for (let i = 0; i < base.length; i++) { + const other = CASES[api].flip(base[i], i); + if (other !== base[i]) { + at.push(i); + to.push(other); + } + } + + // `Atomics.store` so that the compiler keeps every store and the other + // thread sees each one. + for (;;) { + for (let k = 0; k < at.length; k++) Atomics.store(input, at[k], to[k]); + for (let k = 0; k < at.length; k++) Atomics.store(input, at[k], base[at[k]]); + if (Atomics.add(passes, 0, 1) === 0) Atomics.notify(passes, 0); + } +} diff --git a/test/js/bun/util/parse-shared-buffer.test.ts b/test/js/bun/util/parse-shared-buffer.test.ts new file mode 100644 index 000000000000..eb642195f380 --- /dev/null +++ b/test/js/bun/util/parse-shared-buffer.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import path from "node:path"; + +const fixture = path.join(import.meta.dir, "parse-shared-buffer-fixture.ts"); + +// Each parser reads a byte more than once: the TypeScript lexer re-reads the +// digits of a numeric literal after it counts the separators, the markdown +// block parser scans a heading line twice, YAML asserts the byte its scanner +// read, and TOML measures a string before it converts it. A worker that writes +// the SharedArrayBuffer between the two reads aborted the process. +// +// The call counts: a debug build that parses the shared bytes in place aborts +// within 200 calls, and within 50 of `transformSync`, which costs the most. +// +// One at a time: each fixture keeps two cores busy. +test.each([ + ["transformSync", 300], + ["scanImports", 300], + ["markdown", 1000], + ["ansi", 1000], + ["yaml", 1000], + ["toml", 1000], +])("%s parses a SharedArrayBuffer a worker writes", async (api, calls) => { + await using proc = Bun.spawn({ + cmd: [bunExe(), fixture, api, String(calls)], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout: stdout.trim(), stderr: stderr.trim() }).toEqual({ stdout: "ok", stderr: "" }); + expect(exitCode).toBe(0); +}); + +// A macro runs in the middle of the parse. `transformSync` and `scan` read the +// rest of the source after it returns, and read names and string literals back +// from the source when they print. The macro is user code, so it can take the +// bytes away: `resize()` gives the trimmed pages of a resizable ArrayBuffer +// back, a write changes a fixed-length one in place, and `transfer()` moves its +// bytes to a buffer nothing holds. `Bun.markdown` has the resizable cases next +// to its other buffer input tests, in test/js/bun/md/md-render-callback.test.ts. +test.concurrent.each([ + { + macro: "shrinks the resizable input", + buffer: "new ArrayBuffer(bytes.length, { maxByteLength: bytes.length })", + takeAway: "globalThis.input.resize(0);", + }, + { + macro: "overwrites the input and detaches it", + buffer: "new ArrayBuffer(bytes.length)", + takeAway: "new Uint8Array(globalThis.input).fill(0x20); globalThis.input.transfer(); Bun.gc(true);", + }, +])("transformSync and scan parse the bytes they were given when a macro $macro", async ({ buffer, takeAway }) => { + using dir = tempDir("transpiler-macro-takes-input", { + "macro.ts": ` + export function takeAway() { + ${takeAway} + return "from the macro"; + } + `, + "index.ts": ` + import { join } from "node:path"; + + const macro = join(import.meta.dir, "macro.ts"); + const source = + "import { takeAway } from " + JSON.stringify(macro) + ' with { type: "macro" };\\n' + + "export const fromTheMacro = takeAway();\\n" + + "export const filler = " + JSON.stringify(Buffer.alloc(100_000, "x").toString()) + ";\\n" + + "export const afterTheMacro = 'after the macro';\\n"; + + function input() { + const bytes = new TextEncoder().encode(source); + globalThis.input = ${buffer}; + const view = new Uint8Array(globalThis.input); + view.set(bytes); + return view; + } + + const transpiler = new Bun.Transpiler({ loader: "ts" }); + const code = transpiler.transformSync(input()); + const { exports, imports } = transpiler.scan(input()); + console.log( + JSON.stringify({ + byteLength: globalThis.input.byteLength, + transformSync: [ + code.includes('fromTheMacro = "from the macro"'), + code.includes('afterTheMacro = "after the macro"'), + ], + exports: exports.sort(), + imports: imports.map(({ path }) => path === macro), + }), + ); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // A debug build logs each macro call to stdout first. + expect({ result: stdout.trim().split("\n").at(-1), stderr: stderr.trim() }).toEqual({ + result: JSON.stringify({ + byteLength: 0, + transformSync: [true, true], + exports: ["afterTheMacro", "filler", "fromTheMacro"], + imports: [true], + }), + stderr: "", + }); + expect(exitCode).toBe(0); +});