diff --git a/src/io/lib.rs b/src/io/lib.rs index 8d4870736197..986cfa119ee3 100644 --- a/src/io/lib.rs +++ b/src/io/lib.rs @@ -504,6 +504,7 @@ bun_dispatch::link_interface! { LifecycleScript, InstallGit, SecurityScan, + CapturedChild, ] { fn has_on_read_chunk() -> bool; fn on_read_chunk(chunk: pipes::Chunk<'_>, has_more: pipes::ReadState) -> bool; diff --git a/src/io/posix_event_loop.rs b/src/io/posix_event_loop.rs index d3c93569769d..a26052400ca8 100644 --- a/src/io/posix_event_loop.rs +++ b/src/io/posix_event_loop.rs @@ -205,6 +205,7 @@ pub enum PollTag { StaticPipeWriter, ShellStaticPipeWriter, SecurityScanStaticPipeWriter, + CapturedChildStaticPipeWriter, BufferedReader, DnsResolver, GetAddrInfoRequest, @@ -227,6 +228,7 @@ pub mod poll_tag { pub const STATIC_PIPE_WRITER: PollTag = PollTag::StaticPipeWriter; pub const SHELL_STATIC_PIPE_WRITER: PollTag = PollTag::ShellStaticPipeWriter; pub const SECURITY_SCAN_STATIC_PIPE_WRITER: PollTag = PollTag::SecurityScanStaticPipeWriter; + pub const CAPTURED_CHILD_STATIC_PIPE_WRITER: PollTag = PollTag::CapturedChildStaticPipeWriter; pub const BUFFERED_READER: PollTag = PollTag::BufferedReader; pub const DNS_RESOLVER: PollTag = PollTag::DnsResolver; pub const GET_ADDR_INFO_REQUEST: PollTag = PollTag::GetAddrInfoRequest; diff --git a/src/runtime/cli/captured_child.rs b/src/runtime/cli/captured_child.rs new file mode 100644 index 000000000000..38142b7d5325 --- /dev/null +++ b/src/runtime/cli/captured_child.rs @@ -0,0 +1,354 @@ +//! Runs a child process to completion on an event loop and captures its output. The input is written to its stdin, and +//! its stdout and stderr are read, as each pipe becomes ready. So neither process waits on the other, and no thread blocks +//! in a write. + +use core::cell::Cell; +use core::ffi::{CStr, c_void}; + +use bun_event_loop::EventLoopHandle; +use bun_event_loop::MiniEventLoop::init_global; +use bun_io::BufferedReader; +#[cfg(unix)] +use bun_io::{FilePollFlag, PosixFlags}; +use bun_jsc::virtual_machine::VirtualMachine; +use bun_ptr::{JsCell, RefPtr, ThisPtr}; +#[cfg(unix)] +use bun_spawn::SpawnResultExt as _; +use bun_spawn::process::sync::Result as Captured; +use bun_spawn::subprocess::{Source, StaticPipeWriter, StaticPipeWriterProcess, StdioKind}; +use bun_spawn::{Process, ProcessHandle, Rusage, SpawnEnv, SpawnOptions, Status, Stdio}; +#[cfg(windows)] +use bun_sys::windows::libuv as uv; +#[cfg(unix)] +use bun_sys::{Fd, FdExt as _}; + +type StdinWriter = StaticPipeWriter; + +const SIGTERM: u8 = bun_sys::SignalCode::of(bun_core::SignalCode::SIGTERM).0; + +// Callbacks reach the child through a `ThisPtr` while `run` is on the stack, so mutable state sits in cells and every +// access is a short shared borrow. +#[derive(bun_ptr::CellRefCounted)] +pub(crate) struct CapturedChild { + ref_count: Cell, + event_loop: EventLoopHandle, + process: JsCell>, + exit_status: JsCell>, + stdin: JsCell>>, + /// Closed here, not by the writer: see `StaticPipeWriter::leave_fd_open`. `bun fmt` works on the thread pool. + #[cfg(unix)] + stdin_fd: Cell>, + stdout: JsCell, + stderr: JsCell, + open_pipes: Cell, + /// The first error from any of the three pipes. + pipe_error: JsCell>, +} + +#[cfg(windows)] +fn new_pipe() -> Stdio { + let pipe = Box::new(bun_core::ffi::zeroed::()); + Stdio::Buffer(bun_core::heap::into_raw(pipe) as bun_spawn::windows::UvPipePtr) +} + +#[cfg(unix)] +fn new_pipe() -> Stdio { + Stdio::Buffer +} + +impl CapturedChild { + /// Runs `argv` in `cwd` with `input` on its stdin, and returns once it has exited and its output is read. `Err` is the + /// name of the error that kept it from running, or its output from being read. + pub(crate) fn run( + argv: &[&CStr], + envp: &[&CStr], + cwd: &[u8], + input: Vec, + ) -> Result { + let Some(vm) = VirtualMachine::get_or_null() else { + let mini = init_global(None, None); + // SAFETY: this thread's loop, which lives as long as the thread. Nothing else borrows it here. + let tick = || unsafe { (*mini).tick_once(core::ptr::null_mut()) }; + let event_loop = EventLoopHandle::init_mini(mini); + return Self::run_on(event_loop, tick, argv, envp, cwd, input); + }; + // A lint thread can have a VM, and then the thread's loop is the VM's: a tick would run its timers and callbacks. + // `Bun.spawnSync` has the same problem, so the VM keeps a separate loop to wait for a child on. + // SAFETY: this thread's VM. The borrows end with the call, as in `spawn_maybe_sync`. + let Some(sync_loop) = (unsafe { (*vm).rare_data().spawn_sync_event_loop(&mut *vm) }) else { + return Err(b"no event loop"); + }; + let sync_loop = sync_loop.prepare(vm.cast()); + let tick = || _ = sync_loop.tick_with_timeout(None); + let event_loop = EventLoopHandle::init(sync_loop.event_loop_ptr()); + Self::run_on(event_loop, tick, argv, envp, cwd, input) + } + + fn run_on( + event_loop: EventLoopHandle, + tick: impl Fn(), + argv: &[&CStr], + envp: &[&CStr], + cwd: &[u8], + input: Vec, + ) -> Result { + let child = RefPtr::new(CapturedChild { + ref_count: Cell::new(1), + event_loop, + process: JsCell::new(None), + exit_status: JsCell::new(None), + stdin: JsCell::new(None), + #[cfg(unix)] + stdin_fd: Cell::new(None), + stdout: JsCell::new(BufferedReader::init::()), + stderr: JsCell::new(BufferedReader::init::()), + open_pipes: Cell::new(0), + pipe_error: JsCell::new(None), + }); + let this = child.this_ptr(); + Self::spawn(this, argv, envp, cwd, input)?; + while this.exit_status.get().is_none() || this.open_pipes.get() != 0 { + tick(); + } + if let Some(err) = this.pipe_error.take() { + return Err(err.name()); + } + let status = this.exit_status.take().expect("the loop waits for it"); + if let Status::Err(err) = &status { + return Err(err.name()); + } + Ok(Captured { + status, + stdout: this.stdout.with_mut(|r| core::mem::take(r.final_buffer())), + stderr: this.stderr.with_mut(|r| core::mem::take(r.final_buffer())), + }) + } + + fn spawn( + this: ThisPtr, + argv: &[&CStr], + envp: &[&CStr], + cwd: &[u8], + input: Vec, + ) -> Result<(), &'static [u8]> { + // Windows: the `uv::Pipe` allocations are ours until the spawn succeeds. + let spawn_options = SpawnOptions { + stdin: match input.is_empty() { + true => Stdio::Ignore, + false => new_pipe(), + }, + stdout: new_pipe(), + stderr: new_pipe(), + cwd: Box::from(cwd), + #[cfg(windows)] + windows: bun_spawn::WindowsOptions { + loop_: this.event_loop, + ..Default::default() + }, + stream: false, + // The kernel ends the child when bun dies, however that happens. Git catches this signal to remove its lock files. + #[cfg(any(target_os = "linux", target_os = "android"))] + linux_pdeathsig: Some(SIGTERM), + ..Default::default() + }; + #[cfg(windows)] + let mut spawn_options = spawn_options; + + let spawned = bun_spawn::spawn_process_cstr(&spawn_options, argv, SpawnEnv::Strings(envp)); + let spawned = match spawned { + Ok(Ok(spawned)) => spawned, + failed => { + #[cfg(windows)] + { + spawn_options.stdin.deinit(); + spawn_options.stdout.deinit(); + spawn_options.stderr.deinit(); + } + return Err(match failed { + Ok(Err(err)) => err.name(), + Err(err) => err.name().as_bytes(), + Ok(Ok(_)) => unreachable!(), + }); + } + }; + #[cfg(windows)] + let mut spawned = spawned; + + // The child runs from here on, so a pipe that fails to start is only noted: `run` still waits for the exit. + let this_ptr: *mut c_void = this.as_ptr().cast(); + #[cfg(unix)] + for (reader, fd, is_memfd) in [ + (&this.stdout, spawned.stdout, spawned.memfds[1]), + (&this.stderr, spawned.stderr, spawned.memfds[2]), + ] { + let Some(fd) = fd else { continue }; + reader.with_mut(|r| r.set_parent(this_ptr)); + if is_memfd { + reader.with_mut(|r| r.start_memfd(fd)); + continue; + } + let _ = bun_sys::set_nonblocking(fd); + this.open_pipes.set(this.open_pipes.get() + 1); + // If this fails, it has reported through `on_reader_error`. + let started = reader.with_mut(|r| { + r.flags.insert(PosixFlags::NONBLOCKING | PosixFlags::SOCKET); + r.start(fd, true) + }); + if started.is_ok() { + reader.with_mut(|r| { + if let Some(poll) = r.handle.get_poll() { + poll.set_flag(FilePollFlag::Socket); + } + }); + } + } + #[cfg(windows)] + for (reader, pipe) in [ + (&this.stdout, spawned.stdout.take()), + (&this.stderr, spawned.stderr.take()), + ] { + // Take sole ownership of each pipe before `spawned` drops. + let bun_spawn::SpawnedStdio::Buffer(pipe) = pipe else { + continue; + }; + reader.with_mut(|r| { + r.set_source(bun_io::Source::Pipe(pipe)); + r.set_parent(this_ptr); + }); + this.open_pipes.set(this.open_pipes.get() + 1); + if let Err(err) = reader.with_mut(|r| r.start_with_current_pipe()) { + Self::on_pipe_closed(this, Some(err)); + } + } + + #[cfg(unix)] + let stdin = spawned.stdin.map(Some); + #[cfg(windows)] + let stdin = match spawned.stdin.take() { + pipe @ bun_spawn::SpawnedStdio::Buffer(_) => Some(pipe), + _ => None, + }; + if let Some(stdin) = stdin { + let source = Source::from_owned_bytes(input.into_boxed_slice()); + let writer = StdinWriter::create(this.event_loop, this.as_ptr(), stdin, source); + this.open_pipes.set(this.open_pipes.get() + 1); + this.stdin.set(Some(writer.clone())); + #[cfg(unix)] + { + // SAFETY: as for `start` below. + unsafe { (*writer.as_ptr()).leave_fd_open() }; + this.stdin_fd.set(spawned.stdin); + } + // SAFETY: `writer` keeps it alive and nothing else borrows it. If this ends in `on_close_io`, that only takes + // the other ref out of `this.stdin`. + if let Err(err) = unsafe { (*writer.as_ptr()).start() } { + // It has closed the descriptor. + #[cfg(unix)] + this.stdin_fd.set(None); + Self::on_stdin_closed(this, Some(err)); + } + } + + let process = spawned.to_process_handle(this.event_loop); + process.set_exit_handler(this); + match process.watch_or_reap() { + Ok(false) => { + this.process.set(Some(process)); + if this.pipe_error.get().is_some() { + Self::terminate(this); + } + } + Ok(true) => {} + Err(err) => { + if !process.has_exited() { + // Nothing will report its exit, and while it runs its pipes stay open. + let _ = process.kill(bun_sys::SignalCode::SIGKILL.0); + process.on_exit(Status::Err(err), &bun_core::ffi::zeroed::()); + } + } + } + Ok(()) + } + + fn on_stdin_closed(this: ThisPtr, err: Option) { + // Empty if the writer has been counted out already. + if this.stdin.take().is_some() { + this.close_stdin_fd(); + Self::on_pipe_closed(this, err); + } + } + + fn close_stdin_fd(&self) { + #[cfg(unix)] + if let Some(fd) = self.stdin_fd.take() { + fd.close(); + } + } + + fn on_pipe_closed(this: ThisPtr, err: Option) { + debug_assert!(this.open_pipes.get() > 0); + this.open_pipes.set(this.open_pipes.get() - 1); + if let Some(err) = err { + this.pipe_error.with_mut(|first| { + first.get_or_insert(err); + }); + Self::terminate(this); + } + } + + /// For when a pipe has failed: a child that waits for that pipe would never exit. + fn terminate(this: ThisPtr) { + if let Some(process) = this.process.get() { + let _ = process.kill(SIGTERM); + } + } + + fn on_process_exit(this: ThisPtr, _: &Process, status: Status, _: &Rusage) { + this.exit_status.set(Some(status)); + // Nobody needs the input that it hasn't read, and a process that it started can hold the other end open. + if let Some(writer) = this.stdin.get().clone() { + // SAFETY: `writer` keeps it alive and nothing else borrows it. This ends in `on_close_io`. + unsafe { (*writer.as_ptr()).close() }; + } + } +} + +impl Drop for CapturedChild { + fn drop(&mut self) { + self.process.set(None); + // Dropping the writer can close it, which calls `on_close_io`. That has to find the slot empty. + drop(self.stdin.take()); + self.close_stdin_fd(); + self.stdout.with_mut(|r| r.deinit()); + self.stderr.with_mut(|r| r.deinit()); + } +} + +impl StaticPipeWriterProcess for CapturedChild { + const POLL_OWNER_TAG: bun_io::PollTag = bun_io::PollTag::CapturedChildStaticPipeWriter; + unsafe fn on_close_io(this: *mut Self, _: StdioKind) { + // SAFETY: the backref given to `StdinWriter::create`. `run` keeps the child alive until every pipe is closed. + let this = unsafe { ThisPtr::new(this) }; + // A child that exits without reading all of its input is not an error: its status says how it went. + Self::on_stdin_closed(this, None); + } +} + +bun_spawn::link_impl_ProcessExit! { + CapturedChild for CapturedChild => |this| { + // SAFETY: `this` is the live child installed via `set_exit_handler`. + on_process_exit(process, status, rusage) => + CapturedChild::on_process_exit(ThisPtr::new(this), &*process, status, rusage), + } +} + +bun_io::impl_buffered_reader_parent! { + CapturedChild for CapturedChild; + has_on_read_chunk = false; + // SAFETY: `this` is the live child registered via `set_parent`. + on_reader_done = |this| CapturedChild::on_pipe_closed(ThisPtr::new(this), None); + // SAFETY: `this` is the live child registered via `set_parent`. + on_reader_error = |this, err| CapturedChild::on_pipe_closed(ThisPtr::new(this), Some(err)); + loop_ = |this| (*this).event_loop.native_loop(); + event_loop = |this| (*this).event_loop.as_event_loop_ctx(); +} diff --git a/src/runtime/cli/lint_command.rs b/src/runtime/cli/lint_command.rs index bb4b680e43d5..492e8ba72181 100644 --- a/src/runtime/cli/lint_command.rs +++ b/src/runtime/cli/lint_command.rs @@ -3,12 +3,15 @@ //! way to run a configuration file that's a program, and (`lint_js.rs`) rules written in JavaScript. use bstr::BStr; +use core::ffi::CStr; +use std::ffi::CString; use bun_core::{Global, Output, ZStr}; use bun_lint_driver::cli::{Options, UsageError}; use bun_lint_driver::{Environment, Outcome, Script, Stream, bunfig, git}; use bun_sys::File; +use super::captured_child::CapturedChild; use super::check_command::working_directory; use super::command::{ContextData, Tag}; use super::script_or_command::{arguments_before, directory_of_cwd, place_of_command}; @@ -198,54 +201,23 @@ fn run_to_end( cwd: &[u8], adapt: &dyn Fn(&mut bun_dotenv::Map) -> bool, input: &[&[u8]], -) -> Result> { - use crate::api::bun::process::sync::{Options as SpawnOptions, SyncStdio, spawn}; +) -> Result> { + let not_started = |why: &[u8]| [b"Could not start a process: ", why].concat(); let mut variables = bun_dotenv::Loader::init(); let is_ready = variables.load_process().is_ok() && adapt(&mut variables.map); let Some(envp) = is_ready .then(|| variables.map.create_null_delimited_env_map().ok()) .flatten() else { - return Err(b"Could not start a process: out of memory".to_vec()); + return Err(not_started(b"out of memory")); }; - let [read_end, write_end] = match bun_sys::pipe() { - Ok(ends) => ends.map(File::from_fd), - Err(err) => return Err([&b"Could not start a process: "[..], err.name()].concat()), + let argv: Result, _> = argv.into_iter().map(CString::new).collect(); + let Ok(argv) = argv else { + return Err(not_started(b"an argument has a NUL character")); }; - // The child must not inherit the write end, or it would wait for itself to close it. - #[cfg(unix)] - for end in [&read_end, &write_end] { - let _ = bun_sys::set_close_on_exec(end.handle()); - } - let options = SpawnOptions { - argv, - stdout: SyncStdio::Buffer, - stderr: SyncStdio::Buffer, - stdin: SyncStdio::Pipe(read_end.handle()), - cwd: Box::<[u8]>::from(cwd), - envp: Some(envp.as_ptr()), - #[cfg(windows)] - windows: crate::api::bun::process::WindowsOptions { - loop_: bun_jsc::EventLoopHandle::init_mini(bun_event_loop::MiniEventLoop::init_global( - None, None, - )), - ..Default::default() - }, - ..Default::default() - }; - // Write the input while reading the program's output, so that neither side blocks the other. - let spawned = std::thread::scope(|scope| { - scope.spawn(move || input.iter().try_for_each(|it| write_end.write_all(it))); - let spawned = spawn(&options); - // Closing the read end makes a writer that's still writing fail instead of blocking. - drop(read_end); - spawned - }); - match spawned { - Ok(Ok(result)) => Ok(result), - Ok(Err(err)) => Err([&b"Could not start a process: "[..], err.name()].concat()), - Err(err) => Err([&b"Could not start a process: "[..], err.name().as_bytes()].concat()), - } + let argv: Vec<&CStr> = argv.iter().map(CString::as_c_str).collect(); + let envp: Vec<&CStr> = envp.iter().collect(); + CapturedChild::run(&argv, &envp, cwd, input.concat()).map_err(not_started) } /// Reports an invalid `bun ` command line. @@ -292,7 +264,7 @@ pub(crate) fn run_and_exit( if let Some(cwd) = cwd { change_directory(cwd); } - // Start one process at a time, because spawning uses state that threads share. + // Start one process at a time, so that lint threads don't all spawn children at once. let spawn_lock = bun_threading::Guarded::new(()); let run_script = |script: &Script| { let _spawn_guard = spawn_lock.lock(); diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index af5c7e3718cf..4b4686485f2e 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -136,6 +136,8 @@ pub(crate) mod upgrade_command; pub(crate) mod add_command; #[path = "audit_command.rs"] pub(crate) mod audit_command; +#[path = "captured_child.rs"] +pub(crate) mod captured_child; #[path = "check_command.rs"] pub(crate) mod check_command; #[path = "dedupe_command.rs"] diff --git a/src/runtime/dispatch.rs b/src/runtime/dispatch.rs index c1ad1c666d0d..3e88b7b3bad5 100644 --- a/src/runtime/dispatch.rs +++ b/src/runtime/dispatch.rs @@ -901,6 +901,9 @@ pub(crate) unsafe fn __bun_run_file_poll(poll: *mut FilePoll, size_or_offset: i6 poll_tag::SECURITY_SCAN_STATIC_PIPE_WRITER => { poll_arm!(StaticPipeWriterPoll>) } + poll_tag::CAPTURED_CHILD_STATIC_PIPE_WRITER => { + poll_arm!(StaticPipeWriterPoll) + } // `bun.shell.Interpreter.IOWriter.Poll` poll_tag::SHELL_BUFFERED_WRITER => poll_arm!(ShellBufferedWriterPoll, |h| { // SAFETY: tag matched, so `owner.ptr` is a live `*mut ShellBufferedWriterPoll` diff --git a/src/spawn/lib.rs b/src/spawn/lib.rs index 70eda17189d9..9bb50c1303db 100644 --- a/src/spawn/lib.rs +++ b/src/spawn/lib.rs @@ -55,6 +55,7 @@ bun_dispatch::link_interface! { LifecycleScript, InstallGit, SecurityScan, + CapturedChild, Shell, FilterRunHandle, MultiRunHandle, diff --git a/src/spawn/process.rs b/src/spawn/process.rs index 8235db3b16e4..719e28480ede 100644 --- a/src/spawn/process.rs +++ b/src/spawn/process.rs @@ -2465,8 +2465,6 @@ mod spawn_process_body { Inherit, Ignore, Buffer, - /// The child gets this descriptor. It stays the caller's to close. - Pipe(Fd), } impl SyncStdio { @@ -2474,7 +2472,6 @@ mod spawn_process_body { match self { SyncStdio::Inherit => SpawnOptionsStdio::inherit(), SyncStdio::Ignore => SpawnOptionsStdio::ignore(), - SyncStdio::Pipe(fd) => SpawnOptionsStdio::Pipe(fd), SyncStdio::Buffer => { #[cfg(windows)] { diff --git a/src/spawn/static_pipe_writer.rs b/src/spawn/static_pipe_writer.rs index 75eb8687cd5b..af36dff01c3b 100644 --- a/src/spawn/static_pipe_writer.rs +++ b/src/spawn/static_pipe_writer.rs @@ -153,6 +153,16 @@ impl StaticPipeWriter

{ unsafe { RefPtr::from_raw(this) } } + /// Leaves the descriptor open when the writer closes, for the owner to close in `on_close_io`. Call it before `start`, + /// which still closes the descriptor if it fails. + /// + /// By default the thread pool closes it, and only then does the reader see the end of the input. An owner that waits + /// for the reader on a thread of that pool can keep every one of its threads busy, and then would wait forever. + #[cfg(unix)] + pub fn leave_fd_open(&mut self) { + self.writer.close_fd = false; + } + pub fn start(&mut self) -> bun_sys::Result<()> { bun_output::scoped_log!( StaticPipeWriter, diff --git a/test/cli/format/format.test.ts b/test/cli/format/format.test.ts index bf602778695e..0dd55023d3f0 100644 --- a/test/cli/format/format.test.ts +++ b/test/cli/format/format.test.ts @@ -7991,6 +7991,19 @@ try { expect(await inHere("../*/x.js")).toEqual(["x.js", "../there/x.js"]); }); + // A child process runs each config while the thread that needs it waits. With every pool thread waiting, nothing the wait + // depends on can be left to the pool. + test("config files that are programs work with the smallest thread pool", async () => { + const files: Record = {}; + for (const name of ["a", "b"]) { + files[`${name}/prettier.config.mjs`] = "export default { semi: false }\n"; + files[`${name}/x.js`] = "a;\n"; + } + const { stdout, exitCode } = await format(files, ["-l", "."], { env: { GOMAXPROCS: "2" } }); + expect(stdout.trim().split("\n").sort()).toEqual(["a/x.js", "b/x.js"]); + expect(exitCode).toBe(1); + }); + // A configuration file can be a program. Prettier 3.9.9 runs every one that a pattern reaches. oxfmt 0.72.0 doesn't // accept `..`, and only searches the working directory for a pattern's matches. describe("a pattern that leaves a working directory without a configuration file", () => { diff --git a/test/cli/lint/js-plugins.test.ts b/test/cli/lint/js-plugins.test.ts index 016e4b1ae856..517cd15d1daf 100644 --- a/test/cli/lint/js-plugins.test.ts +++ b/test/cli/lint/js-plugins.test.ts @@ -62,6 +62,32 @@ const oxlintrc = (config: object) => // Each thread that lints a file starts a VM, which takes seconds in a debug build. const timeout = isDebug || isASAN ? 120_000 : 10_000; +// The thread that lints a.js has a VM, and it's the first to need the Prettier config, which a child process evaluates. +test.concurrent( + "a config file that is a program is read on a thread that has a VM", + async () => { + const files = { + "plugin.mjs": `const create = context => ({ Program: node => context.report({ node, message: "seen" }) }); + export default { meta: { name: "demo" }, rules: { seen: { create } } };`, + ".oxlintrc.json": oxlintrc({ + jsPlugins: ["./plugin.mjs"], + rules: { "bun/format": "error", "demo/seen": "error" }, + }), + "prettier.config.mjs": "export default { semi: false };\n", + "a.js": "a;\n", + }; + const { stdout, exitCode } = await lint(files, ["-f", "unix", "a.js"]); + expect( + stdout + .split("\n") + .filter(line => line.startsWith("a.js")) + .sort(), + ).toEqual(["a.js:1:1: seen [Error/demo(seen)]", "a.js:1:2: Delete `;` [Error/bun(format)]"]); + expect(exitCode).toBe(1); + }, + timeout, +); + // Same as under ESLint 10.12.0. test.concurrent( "a plugin's `process.env` has the inherited variables but not the project's .env",