From 91404bdbe8b403afa11c8ccfdde5e49887c65f99 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:37:08 +0000 Subject: [PATCH 1/6] ByteStream: take the buffer action before signal_drained() in on_data on_data called signal_drained() while buffer_action was still in its cell. signal_drained() dispatches to the producer, which can run JS or synchronously deliver more data, re-entering on_data/on_cancel; either consumes the action, so the buffer_action.replace(None).unwrap() that followed hit None and panicked (core::option::unwrap_failed), aborting the process. Seen in the wild when aborting an in-flight fetch whose streaming response body had a parked read. Move the action out of the cell before signal_drained() on the Err path, and re-take it with let-else instead of unwrap() after the non-Err signal_drained(), bailing out when a re-entrant consumer already settled it. This is the same rule the existing R-2 comment states for reject(). Co-authored-by: Jarred Sumner --- src/runtime/webcore/ByteStream.rs | 16 +++- .../fetch/fetch-abort-parked-reads-fixture.ts | 81 +++++++++++++++++++ test/js/web/fetch/fetch.stream.test.ts | 29 ++++++- 3 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 test/js/web/fetch/fetch-abort-parked-reads-fixture.ts diff --git a/src/runtime/webcore/ByteStream.rs b/src/runtime/webcore/ByteStream.rs index a696aace9e38..5fe7b495f2b2 100644 --- a/src/runtime/webcore/ByteStream.rs +++ b/src/runtime/webcore/ByteStream.rs @@ -305,7 +305,6 @@ impl ByteStream { } if self.buffer_action.get().is_some() { - self.signal_drained(); if let streams::Result::Err(err) = &stream { // Explicit post-reject cleanup; runs after `action.reject` // (`?` would skip it). @@ -313,8 +312,10 @@ impl ByteStream { let global = self.parent_const().global_this(); // R-2: move the action out of the cell *before* calling - // `reject` (which resolves a JS promise and may re-enter). + // `signal_drained` or `reject` (both can re-enter `on_data` / + // `on_cancel` and consume the slot). let mut action = self.buffer_action.replace(None).unwrap(); + self.signal_drained(); let res = action.reject(global, err); self.buffer.with_mut(|b| { @@ -330,9 +331,18 @@ impl ByteStream { return res; } + // R-2: `signal_drained` dispatches to the producer, which can run + // JS or synchronously deliver more data, re-entering `on_data` / + // `on_cancel`; either consumes `buffer_action`, so re-take it with + // `let`-`else` below instead of `unwrap`. + self.signal_drained(); + if self.has_received_last_chunk.get() { // `defer { this.buffer_action = null; }` — handled by `replace(None)` below. - let mut action = self.buffer_action.replace(None).unwrap(); + let Some(mut action) = self.buffer_action.replace(None) else { + // Consumed re-entrantly during `signal_drained`. + return Ok(()); + }; if self.buffer.get().capacity() == 0 && matches!(stream, streams::Result::Done) { bun_output::scoped_log!( diff --git a/test/js/web/fetch/fetch-abort-parked-reads-fixture.ts b/test/js/web/fetch/fetch-abort-parked-reads-fixture.ts new file mode 100644 index 000000000000..c55fe158ebc7 --- /dev/null +++ b/test/js/web/fetch/fetch-abort-parked-reads-fixture.ts @@ -0,0 +1,81 @@ +// Regression fixture: aborting an in-flight fetch whose streaming response +// body has a parked consumer (reader.read() or a native body.text() buffering +// action) delivers Err to ByteStream::on_data. on_data used to call +// signal_drained() while buffer_action was still in its cell; signal_drained +// dispatches to the producer, which can re-enter on_data/on_cancel and consume +// the action, so the later buffer_action.replace(None).unwrap() panicked and +// aborted the process. The re-entrant timing is not deterministically +// reachable from JS, so this fixture stress-drives the abort paths and +// asserts every parked consumer settles with the process alive. + +const ITERATIONS = 12; + +const chunk = new TextEncoder().encode("data: " + Buffer.alloc(512, "x").toString() + "\n\n"); + +using server = Bun.serve({ + port: 0, + idleTimeout: 0, + fetch() { + // One event, then hold the connection open silently so client-side + // consumers are still parked when the aborts land. + return new Response( + new ReadableStream({ + start(c) { + c.enqueue(chunk); + }, + }), + { headers: { "Content-Type": "text/event-stream" } }, + ); + }, +}); + +async function setup(i: number): Promise<{ parked: Promise; abort: () => void }> { + const ac = new AbortController(); + const res = await fetch(server.url, { signal: ac.signal }); + + let parked: Promise; + switch (i % 3) { + case 0: { + const reader = res.body!.getReader(); + await reader.read(); + parked = reader.read(); + break; + } + case 1: { + const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader(); + await reader.read(); + parked = reader.read(); + break; + } + default: { + // Native buffering fast path: installs a BufferAction on the ByteStream. + parked = res.body!.text(); + break; + } + } + + if (i % 4 >= 2) { + // Abort from inside another signal's abort listener so the fetch abort + // re-enters from event dispatch. + const outer = new AbortController(); + outer.signal.addEventListener("abort", () => ac.abort()); + return { parked, abort: () => outer.abort() }; + } + return { parked, abort: () => ac.abort() }; +} + +const streams = await Promise.all(Array.from({ length: ITERATIONS }, (_, i) => setup(i))); + +for (const { abort } of streams) abort(); + +// Every parked consumer must settle (reject with the abort reason, or resolve +// if its chunk raced in first); a hang here fails the test by timeout. +const results = await Promise.allSettled(streams.map(s => s.parked)); + +// Let scheduled producer callbacks (receive resumes, trailing socket data) +// land before exiting so a delayed crash still fails the fixture. +await Bun.sleep(0); +await Bun.sleep(0); +Bun.gc(true); + +console.log(`done ${results.length}`); diff --git a/test/js/web/fetch/fetch.stream.test.ts b/test/js/web/fetch/fetch.stream.test.ts index e84b0fa66c5c..a0a05b5291d6 100644 --- a/test/js/web/fetch/fetch.stream.test.ts +++ b/test/js/web/fetch/fetch.stream.test.ts @@ -1,7 +1,7 @@ import { Socket } from "bun"; -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, test } from "bun:test"; import { createReadStream, readFileSync } from "fs"; -import { gcTick, isWindows, tempDirWithFilesAnon } from "harness"; +import { bunEnv, bunExe, gcTick, isWindows, tempDirWithFilesAnon } from "harness"; import http from "http"; import type { AddressInfo } from "net"; import path, { join } from "path"; @@ -1422,3 +1422,28 @@ describe.concurrent("fetch() with streaming", () => { server.kill("SIGTERM"); }); }); + +// ByteStream::on_data used to call signal_drained() before taking the pending +// buffer action out of its cell; the drain signal can re-enter and consume the +// action, so the unwrap() that followed panicked and killed the process +// (seen as a crash when aborting fetches with parked reads on streaming +// bodies). The race is timing-dependent, so this stress fixture exercises the +// abort paths and asserts every parked consumer settles with exit code 0. +test.concurrent( + "aborting streaming fetches with parked body consumers settles them without crashing", + async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dir, "fetch-abort-parked-reads-fixture.ts")], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout).toBe("done 12\n"); + expect(exitCode).toBe(0); + }, + 30_000, +); From 7776f2844eacd51222779da8e7fbca46ee5a3fda Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:41:14 +0000 Subject: [PATCH 2/6] test: drop the per-test timeout, the runner default is enough --- test/js/web/fetch/fetch.stream.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/js/web/fetch/fetch.stream.test.ts b/test/js/web/fetch/fetch.stream.test.ts index a0a05b5291d6..bd08d34e39a5 100644 --- a/test/js/web/fetch/fetch.stream.test.ts +++ b/test/js/web/fetch/fetch.stream.test.ts @@ -1445,5 +1445,4 @@ test.concurrent( expect(stdout).toBe("done 12\n"); expect(exitCode).toBe(0); }, - 30_000, ); From 64dad7f16d1ea4be001a901d45eb512dcfff4abe Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:43:46 +0000 Subject: [PATCH 3/6] [autofix.ci] apply automated fixes --- test/js/web/fetch/fetch.stream.test.ts | 27 ++++++++++++-------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/test/js/web/fetch/fetch.stream.test.ts b/test/js/web/fetch/fetch.stream.test.ts index bd08d34e39a5..2590a11d8f33 100644 --- a/test/js/web/fetch/fetch.stream.test.ts +++ b/test/js/web/fetch/fetch.stream.test.ts @@ -1429,20 +1429,17 @@ describe.concurrent("fetch() with streaming", () => { // (seen as a crash when aborting fetches with parked reads on streaming // bodies). The race is timing-dependent, so this stress fixture exercises the // abort paths and asserts every parked consumer settles with exit code 0. -test.concurrent( - "aborting streaming fetches with parked body consumers settles them without crashing", - async () => { - await using proc = Bun.spawn({ - cmd: [bunExe(), join(import.meta.dir, "fetch-abort-parked-reads-fixture.ts")], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); +test.concurrent("aborting streaming fetches with parked body consumers settles them without crashing", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dir, "fetch-abort-parked-reads-fixture.ts")], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); - expect(stdout).toBe("done 12\n"); - expect(exitCode).toBe(0); - }, -); + expect(stderr).toBe(""); + expect(stdout).toBe("done 12\n"); + expect(exitCode).toBe(0); +}); From 859129f9fa23ff5d269a741e91a3f3189487c5b2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:04:10 +0000 Subject: [PATCH 4/6] tighten the re-entrancy comments --- src/runtime/webcore/ByteStream.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/runtime/webcore/ByteStream.rs b/src/runtime/webcore/ByteStream.rs index 5fe7b495f2b2..9c34f83ca0a4 100644 --- a/src/runtime/webcore/ByteStream.rs +++ b/src/runtime/webcore/ByteStream.rs @@ -311,9 +311,8 @@ impl ByteStream { bun_output::scoped_log!(ByteStream, "ByteStream.onData err action.reject()"); let global = self.parent_const().global_this(); - // R-2: move the action out of the cell *before* calling - // `signal_drained` or `reject` (both can re-enter `on_data` / - // `on_cancel` and consume the slot). + // R-2: move the action out of the cell *before* `signal_drained` + // and `reject`; both can re-enter and consume the slot. let mut action = self.buffer_action.replace(None).unwrap(); self.signal_drained(); let res = action.reject(global, err); @@ -331,10 +330,8 @@ impl ByteStream { return res; } - // R-2: `signal_drained` dispatches to the producer, which can run - // JS or synchronously deliver more data, re-entering `on_data` / - // `on_cancel`; either consumes `buffer_action`, so re-take it with - // `let`-`else` below instead of `unwrap`. + // R-2: the drain signal can re-enter and consume `buffer_action`, + // so the paths below re-take it with `let`-`else`. self.signal_drained(); if self.has_received_last_chunk.get() { From d6dfededfc7e0741a024b809387c6b8b8f333d4e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:34:33 +0000 Subject: [PATCH 5/6] test: drive the on_data re-entrancy deterministically via an internal-for-testing producer No in-tree producer re-enters ByteStream::on_data synchronously from the drain signal (fetch and server bodies schedule socket resumes, the rewriter defers behind its driving flag), so the stress fixture could not turn the panic red. Add SourceHandle::TestingCancelOnDrain, installed through bun:internal-for-testing, whose ready() re-enters on_cancel, and a fixture that parks body.text() on a streaming fetch response and then severs the connection: the tasklet delivers Err straight to on_data with the action still in its cell (unlike abort, which errors the JS stream first and consumes the action via done() -> cancel() before on_data runs). On the old ordering this panics every run (Option::unwrap() on None in ByteStream::on_data, via FetchTasklet::on_body_received); with the fix the text() promise rejects with the network error and the process exits cleanly. --- src/codegen/generate-js2native.ts | 1 + src/js/internal-for-testing.ts | 9 +++++ src/runtime/webcore/ByteStream.rs | 27 ++++++++++++- src/runtime/webcore/streams.rs | 9 ++++- .../bytestream-cancel-on-drain-fixture.ts | 40 +++++++++++++++++++ test/js/web/fetch/fetch.stream.test.ts | 20 ++++++++++ 6 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 test/js/web/fetch/bytestream-cancel-on-drain-fixture.ts diff --git a/src/codegen/generate-js2native.ts b/src/codegen/generate-js2native.ts index 24abca42a866..ccc9ce85d83d 100644 --- a/src/codegen/generate-js2native.ts +++ b/src/codegen/generate-js2native.ts @@ -88,6 +88,7 @@ const rustIdentifierPaths: Record = { "runtime/node/types.rs": "runtime/node/types.rs", "runtime/socket/socket.rs": "runtime/socket/socket.rs", "runtime/timer/Timer.rs": "runtime/timer/Timer.rs", + "runtime/webcore/ByteStream.rs": "runtime/webcore/ByteStream.rs", "runtime/webcore/FileSink.rs": "runtime/webcore/FileSink.rs", "shell.rs": "runtime/shell/shell.rs", "sourcemap/InternalSourceMap.rs": "sourcemap/InternalSourceMap.rs", diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index 329098f96732..9b1fed3e91d9 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -760,3 +760,12 @@ export const fetchH3Internals = { export const fileSinkInternals = { liveCount: $newRustFunction("runtime/webcore/FileSink.rs", "TestingAPIs.fileSinkLiveCount", 0) as () => number, }; + +export const byteStreamInternals = { + // Swap a ByteStream-backed stream's producer for one whose drain signal + // re-enters on_cancel, making consumed-during-signal_drained re-entrancy + // deterministic in tests. + cancelOnDrain: $newRustFunction("runtime/webcore/ByteStream.rs", "TestingAPIs.byteStreamCancelOnDrain", 1) as ( + stream: ReadableStream, + ) => void, +}; diff --git a/src/runtime/webcore/ByteStream.rs b/src/runtime/webcore/ByteStream.rs index 9c34f83ca0a4..f5819771f3a8 100644 --- a/src/runtime/webcore/ByteStream.rs +++ b/src/runtime/webcore/ByteStream.rs @@ -604,7 +604,7 @@ impl ByteStream { streams::Result::Pending(self.pending.as_ptr()) } - fn on_cancel(&self) { + pub(crate) fn on_cancel(&self) { bun_jsc::mark_binding!(); let view = self.value(); if self.buffer.get().capacity() > 0 { @@ -766,3 +766,28 @@ impl ByteStream { Ok(promise) } } + +pub mod testing_apis { + use super::*; + + /// `bun:internal-for-testing`: swap the stream's producer for + /// [`streams::SourceHandle::TestingCancelOnDrain`], whose drain signal + /// re-enters `on_cancel` and consumes the pending buffer action. + pub(crate) fn byte_stream_cancel_on_drain( + global: &JSGlobalObject, + frame: &bun_jsc::CallFrame, + ) -> bun_jsc::JsResult { + let stream = readable_stream::ReadableStream::from_js(frame.argument(0), global)?; + let Some(bytes) = stream.and_then(|s| s.ptr.bytes()) else { + return Err(global.throw(format_args!("expected a ByteStream-backed ReadableStream"))); + }; + bytes + .parent_const() + .producer + .set(streams::SourceHandle::TestingCancelOnDrain(bytes)); + Ok(JSValue::UNDEFINED) + } +} +// `generated_js2native.rs` snake-cases `TestingAPIs` as `testing_ap_is` +// (acronym splitter treats `AP|Is` as two words); alias so both resolve. +pub use testing_apis as testing_ap_is; diff --git a/src/runtime/webcore/streams.rs b/src/runtime/webcore/streams.rs index f62a3363fbad..c19b50005e19 100644 --- a/src/runtime/webcore/streams.rs +++ b/src/runtime/webcore/streams.rs @@ -912,6 +912,10 @@ pub enum SourceHandle { ServerRequestBody(crate::server::AnyRequestContext), S3DownloadBody(BackRef), HTMLRewriter(BackRef), + /// `bun:internal-for-testing` only: `ready()` re-enters the stream's + /// `on_cancel`, making consumed-during-`signal_drained` re-entrancy + /// deterministic for tests. + TestingCancelOnDrain(BackRef), } impl SourceHandle { @@ -954,6 +958,7 @@ impl SourceHandle { SourceHandle::S3DownloadBody(mut p) => unsafe { p.get_mut() }.on_stream_cancelled(), SourceHandle::ServerRequestBody(_) => {} SourceHandle::HTMLRewriter(p) => p.on_close(err), + SourceHandle::TestingCancelOnDrain(_) => {} } } @@ -977,6 +982,7 @@ impl SourceHandle { SourceHandle::FetchResponseBody(p) => p.on_ready(), SourceHandle::ServerRequestBody(any) => any.on_request_body_stream_drained(), SourceHandle::HTMLRewriter(p) => p.on_ready(), + SourceHandle::TestingCancelOnDrain(p) => p.on_cancel(), // Remaining variants leave `on_ready` at the trait default (no-op). SourceHandle::Subprocess(_) | SourceHandle::ShellWritable(_) @@ -996,7 +1002,8 @@ impl SourceHandle { | SourceHandle::Subprocess(_) | SourceHandle::ShellWritable(_) | SourceHandle::S3DownloadBody(_) - | SourceHandle::HTMLRewriter(_) => {} + | SourceHandle::HTMLRewriter(_) + | SourceHandle::TestingCancelOnDrain(_) => {} } } } diff --git a/test/js/web/fetch/bytestream-cancel-on-drain-fixture.ts b/test/js/web/fetch/bytestream-cancel-on-drain-fixture.ts new file mode 100644 index 000000000000..a283e8b0c0a2 --- /dev/null +++ b/test/js/web/fetch/bytestream-cancel-on-drain-fixture.ts @@ -0,0 +1,40 @@ +// Deterministic repro for the ByteStream::on_data re-entrancy panic: park a +// native buffering action (body.text()) on a streaming fetch response, swap +// the stream's producer for the internal-for-testing handle whose drain +// signal re-enters on_cancel, then sever the connection so the fetch tasklet +// delivers Err straight to on_data (unlike abort, this path does not error +// the JS stream first, so the action is still parked). on_data(Err) used to +// run signal_drained() with the action still in its cell; the re-entrant +// on_cancel consumed (rejected) it, and the unwrap() that followed aborted +// the whole process. +import { byteStreamInternals } from "bun:internal-for-testing"; +import net from "node:net"; +import type { AddressInfo } from "node:net"; + +const sockets: net.Socket[] = []; +const server = net.createServer(s => { + sockets.push(s); + // Headers only: no body chunk is ever delivered, so the Err below is the + // first and only on_data call, independent of delivery timing. + s.write("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n"); +}); +const { promise: listening, resolve: onListening } = Promise.withResolvers(); +server.listen(0, "127.0.0.1", onListening); +await listening; +const port = (server.address() as AddressInfo).port; + +const res = await fetch(`http://127.0.0.1:${port}/`); +const body = res.body!; +const text = body.text(); // parks a BufferAction on the ByteStream +byteStreamInternals.cancelOnDrain(body); + +// Sever the connection mid-stream: the tasklet fails the body with an error +// delivered to ByteStream::on_data while the buffer action is still parked. +for (const s of sockets) s.destroy(); + +const outcome = await text.then( + () => "resolved", + e => `rejected:${(e as Error)?.name}`, +); +console.log(outcome); +server.close(); diff --git a/test/js/web/fetch/fetch.stream.test.ts b/test/js/web/fetch/fetch.stream.test.ts index 2590a11d8f33..0f0a436f9f58 100644 --- a/test/js/web/fetch/fetch.stream.test.ts +++ b/test/js/web/fetch/fetch.stream.test.ts @@ -1443,3 +1443,23 @@ test.concurrent("aborting streaming fetches with parked body consumers settles t expect(stdout).toBe("done 12\n"); expect(exitCode).toBe(0); }); + +// Deterministic version of the regression above: the re-entrant consumption is +// not reachable from plain JS (the in-tree producers defer their drain +// signals), so the fixture installs a bun:internal-for-testing producer whose +// drain signal re-enters on_cancel, consuming the parked body.text() buffer +// action from inside on_data(Err) exactly where the wild crash did. +test.concurrent("buffer action consumed re-entrantly during on_data(Err) settles text() instead of crashing", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dir, "bytestream-cancel-on-drain-fixture.ts")], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout).toBe("rejected:TypeError\n"); + expect(exitCode).toBe(0); +}); From 59278bdafa3071a7c1ec77c796d300c37b3eb2a8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:36:49 +0000 Subject: [PATCH 6/6] [autofix.ci] apply automated fixes --- test/js/web/fetch/fetch.stream.test.ts | 27 ++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/test/js/web/fetch/fetch.stream.test.ts b/test/js/web/fetch/fetch.stream.test.ts index 0f0a436f9f58..fda8e9098669 100644 --- a/test/js/web/fetch/fetch.stream.test.ts +++ b/test/js/web/fetch/fetch.stream.test.ts @@ -1449,17 +1449,20 @@ test.concurrent("aborting streaming fetches with parked body consumers settles t // signals), so the fixture installs a bun:internal-for-testing producer whose // drain signal re-enters on_cancel, consuming the parked body.text() buffer // action from inside on_data(Err) exactly where the wild crash did. -test.concurrent("buffer action consumed re-entrantly during on_data(Err) settles text() instead of crashing", async () => { - await using proc = Bun.spawn({ - cmd: [bunExe(), join(import.meta.dir, "bytestream-cancel-on-drain-fixture.ts")], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); +test.concurrent( + "buffer action consumed re-entrantly during on_data(Err) settles text() instead of crashing", + async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dir, "bytestream-cancel-on-drain-fixture.ts")], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); - expect(stdout).toBe("rejected:TypeError\n"); - expect(exitCode).toBe(0); -}); + expect(stderr).toBe(""); + expect(stdout).toBe("rejected:TypeError\n"); + expect(exitCode).toBe(0); + }, +);