From b61d8e5dfbb074f9b045983b6532faabf4b7e103 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:21:50 +0000 Subject: [PATCH 1/3] windows: heap-allocate bunx fast-path environment block The .bunx fast path on Windows wrote the process environment into a fixed [u16; 32767] buffer. That limit belongs to CreateProcessA (ANSI); CreateProcessW with CREATE_UNICODE_ENVIRONMENT has no documented block size limit, and CI environments routinely exceed 32 KB of env data. The panic reported in BUN-3MAQ (index out of bounds writing past the buffer) was already prevented on main by the length pre-checks added in #30722, which made the fast path bail and fall through to the libuv slow path instead of crashing. This change removes the cap entirely: write_windows_env_block now sizes a Vec to the actual contents so the fast path works regardless of environment size, and the now-unused 64 KB static scratch buffer and error branch in the caller are removed. --- src/dotenv/env_loader.rs | 69 ++++++++++++++++---------------- src/runtime/cli/run_command.rs | 19 +-------- test/cli/install/bun-run.test.ts | 60 +++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 51 deletions(-) diff --git a/src/dotenv/env_loader.rs b/src/dotenv/env_loader.rs index 22ec9a62c562..5a662ec13ce1 100644 --- a/src/dotenv/env_loader.rs +++ b/src/dotenv/env_loader.rs @@ -1395,44 +1395,45 @@ impl Map { }) } - /// Write the Windows environment block into a buffer - /// This can be passed to CreateProcessW's lpEnvironment parameter - pub fn write_windows_env_block( - &mut self, - result: &mut [u16; 32767], - ) -> Result<*const u16, bun_core::Error> { - let mut i: usize = 0; - let mut it = self.map.iterator(); - while let Some(pair) = it.next() { - if i + pair.key_ptr.len() + 7 >= result.len() { - return Err(bun_core::Error::from_name("TooManyEnvironmentVariables")); - } - i += strings::convert_utf8_to_utf16_in_buffer(&mut result[i..], pair.key_ptr).len(); - if i + 7 >= result.len() { - return Err(bun_core::Error::from_name("TooManyEnvironmentVariables")); - } - result[i] = b'=' as u16; - i += 1; - if i + pair.value_ptr.value.len() + 5 >= result.len() { - return Err(bun_core::Error::from_name("TooManyEnvironmentVariables")); + /// Build a heap-allocated Windows environment block suitable for + /// `CreateProcessW`'s `lpEnvironment` with `CREATE_UNICODE_ENVIRONMENT`. + /// + /// The 32,767-character limit applies to `CreateProcessA` (ANSI) only; the + /// Unicode block has no documented size limit, so this sizes the buffer to + /// the actual contents instead of failing when the environment is large. + pub fn write_windows_env_block(&mut self) -> Vec { + // UTF-16 output is at most one code unit per UTF-8 input byte (ASCII + // is 1:1; multi-byte sequences shrink; surrogate pairs are 2 units + // from 4 bytes), so the UTF-8 byte length is a safe upper bound. + let mut capacity: usize = 4; + { + let mut it = self.map.iterator(); + while let Some(pair) = it.next() { + capacity += pair.key_ptr.len() + 1 + pair.value_ptr.value.len() + 1; } - i += strings::convert_utf8_to_utf16_in_buffer(&mut result[i..], &pair.value_ptr.value) + } + + let mut result = vec![0u16; capacity]; + let mut i: usize = 0; + { + let mut it = self.map.iterator(); + while let Some(pair) = it.next() { + i += strings::convert_utf8_to_utf16_in_buffer(&mut result[i..], pair.key_ptr) + .len(); + result[i] = b'=' as u16; + i += 1; + i += strings::convert_utf8_to_utf16_in_buffer( + &mut result[i..], + &pair.value_ptr.value, + ) .len(); - if i + 5 >= result.len() { - return Err(bun_core::Error::from_name("TooManyEnvironmentVariables")); + result[i] = 0; + i += 1; } - result[i] = 0; - i += 1; } - result[i] = 0; - i += 1; - result[i] = 0; - i += 1; - result[i] = 0; - i += 1; - result[i] = 0; - - Ok(result.as_ptr()) + // Terminator: four trailing NUL u16s (already zero-initialized above). + result.truncate(i + 4); + result } pub fn iterator(&mut self) -> ::Iterator<'_> { diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index a98d7bce7f27..49b21f9613dc 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -3930,9 +3930,6 @@ mod bunx_fast_path_buffers { // buffers (bunx fast-path runs once on the main thread) → RacyCell. pub(super) static DIRECT_LAUNCH_BUFFER: bun_core::RacyCell = bun_core::RacyCell::new(WPathBuffer::ZEROED); - // Same `[PATH_MAX_WIDE]u16` shape as the launch buffer. - pub(super) static ENVIRONMENT_BUFFER: bun_core::RacyCell = - bun_core::RacyCell::new(WPathBuffer::ZEROED); } impl BunXFastPath { @@ -4069,19 +4066,7 @@ impl BunXFastPath { // contract is that *this* `passthrough` wins. ctx.passthrough = passthrough.to_vec(); - // SAFETY: process-lifetime static, single-threaded CLI dispatch. - let env_buf = unsafe { &mut *bunx_fast_path_buffers::ENVIRONMENT_BUFFER.get() }; - let environment = match env.map.write_windows_env_block(&mut env_buf.0) { - Ok(env) => Some(env), - Err(_) => { - // The shim's `NtClose(metadata_handle)` only - // runs if `try_startup_from_bun_js` is reached. Close it - // explicitly so the slow-path fallback doesn't inherit a - // dangling open HANDLE for the process lifetime. - Fd::from_native(handle as u64).close(); - return; - } - }; + let env_block = env.map.write_windows_env_block(); let run_ctx = bun_install::windows_shim::bun_shim_impl::FromBunRunContext { handle, @@ -4092,7 +4077,7 @@ impl BunXFastPath { force_use_bun: ctx.debug.run_in_bun, direct_launch_with_bun_js: Self::direct_launch_callback, cli_context: ::core::ptr::from_mut(ctx), - environment, + environment: Some(env_block.as_ptr()), }; bun_core::scoped_log!( diff --git a/test/cli/install/bun-run.test.ts b/test/cli/install/bun-run.test.ts index 497e0ea8e921..e72c6775ec7a 100644 --- a/test/cli/install/bun-run.test.ts +++ b/test/cli/install/bun-run.test.ts @@ -1006,4 +1006,64 @@ describe.concurrent("bun run", () => { expect(exitCode).toBe(1); }); }); + + // BUN-3MAQ: the Windows .bunx fast path serialized the environment into a + // fixed 32,767-u16 buffer. CreateProcessW with CREATE_UNICODE_ENVIRONMENT has + // no such limit on the block, so a large environment must still reach the + // child. POSIX would hit E2BIG long before this, so the test is Windows-only. + it.if(isWindows)( + "runs a node_modules/.bin entry with an environment block larger than 32,767 wide chars", + async () => { + using dir = tempDir("bun-run-large-env", { + "package.json": JSON.stringify({ + name: "consumer", + version: "0.0.0", + dependencies: { "print-env-len": "file:./print-env-len" }, + }), + "print-env-len": { + "package.json": JSON.stringify({ + name: "print-env-len", + version: "0.0.0", + bin: { "print-env-len": "./bin.js" }, + }), + "bin.js": `#!/usr/bin/env node\nprocess.stdout.write(String((process.env.HUGE_A || "").length + (process.env.HUGE_B || "").length));\n`, + }, + }); + + { + await using install = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + install.stdout.text(), + install.stderr.text(), + install.exited, + ]); + expect({ stdout, stderr, exitCode }).toMatchObject({ exitCode: 0 }); + } + + expect(await Bun.file(join(String(dir), "node_modules", ".bin", "print-env-len.bunx")).exists()).toBe(true); + + // Two 25,000-char values plus the rest of bunEnv push the serialized + // block past 32,767 u16s without approaching any per-variable or + // per-block ceiling Windows actually enforces. + const chunk = Buffer.alloc(25_000, "a").toString(); + await using proc = Bun.spawn({ + cmd: [bunExe(), "run", "print-env-len"], + cwd: String(dir), + env: { ...bunEnv, HUGE_A: chunk, HUGE_B: chunk }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("error"); + expect(stdout).toBe(String(chunk.length * 2)); + expect(exitCode).toBe(0); + }, + ); }); From 010361c4a5de27887bb262bdcc3729754664faf8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:24:31 +0000 Subject: [PATCH 2/3] [autofix.ci] apply automated fixes --- src/dotenv/env_loader.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/dotenv/env_loader.rs b/src/dotenv/env_loader.rs index 5a662ec13ce1..3e66d613e933 100644 --- a/src/dotenv/env_loader.rs +++ b/src/dotenv/env_loader.rs @@ -1418,8 +1418,7 @@ impl Map { { let mut it = self.map.iterator(); while let Some(pair) = it.next() { - i += strings::convert_utf8_to_utf16_in_buffer(&mut result[i..], pair.key_ptr) - .len(); + i += strings::convert_utf8_to_utf16_in_buffer(&mut result[i..], pair.key_ptr).len(); result[i] = b'=' as u16; i += 1; i += strings::convert_utf8_to_utf16_in_buffer( From ada400abcf12a4e4427b739719fa05023d781020 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:45:01 +0000 Subject: [PATCH 3/3] test: clarify large-env test guards the contract, not the spawn path --- test/cli/install/bun-run.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/cli/install/bun-run.test.ts b/test/cli/install/bun-run.test.ts index e72c6775ec7a..08532c2e7ab1 100644 --- a/test/cli/install/bun-run.test.ts +++ b/test/cli/install/bun-run.test.ts @@ -1007,10 +1007,12 @@ describe.concurrent("bun run", () => { }); }); - // BUN-3MAQ: the Windows .bunx fast path serialized the environment into a - // fixed 32,767-u16 buffer. CreateProcessW with CREATE_UNICODE_ENVIRONMENT has - // no such limit on the block, so a large environment must still reach the - // child. POSIX would hit E2BIG long before this, so the test is Windows-only. + // BUN-3MAQ: guards the user-visible contract that a >32,767-u16 environment + // block reaches a .bunx child intact. CreateProcessW with + // CREATE_UNICODE_ENVIRONMENT has no documented block-size limit. This does + // not assert which spawn path (fast .bunx shim vs. libuv fallback) was + // taken, since both are correct; it fails only if the child loses env data + // or the process crashes. POSIX hits E2BIG first, so Windows-only. it.if(isWindows)( "runs a node_modules/.bin entry with an environment block larger than 32,767 wide chars", async () => {