diff --git a/src/bunfig/bunfig.rs b/src/bunfig/bunfig.rs index 34044429cc60..f48d39545038 100644 --- a/src/bunfig/bunfig.rs +++ b/src/bunfig/bunfig.rs @@ -1544,9 +1544,7 @@ impl<'a> Parser<'a> { let remap = |e: FromExprError| -> crate::Error { match e { FromExprError::OutOfMemory => crate::Error::Alloc(bun_alloc::AllocError), - FromExprError::UnexpectedExpr | FromExprError::InvalidRegExp => { - crate::Error::InvalidBunfig - } + FromExprError::UnexpectedExpr => crate::Error::InvalidBunfig, } }; if let Some(public_hoist_pattern_expr) = install_obj.get(b"publicHoistPattern") { diff --git a/src/ini/lib.rs b/src/ini/lib.rs index 7d311cfa4f33..b3202cdc22cd 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -1500,8 +1500,7 @@ mod draft { match pnpm_matcher_from_expr(&public_hoist_pattern_expr, log, source, bump) { Ok(v) => Some(v), Err(FromExprError::OutOfMemory) => return Err(AllocError), - Err(_) => { - // error.InvalidRegExp, error.UnexpectedExpr + Err(FromExprError::UnexpectedExpr) => { log.reset(); None } @@ -1513,8 +1512,7 @@ mod draft { match pnpm_matcher_from_expr(&hoist_pattern_expr, log, source, bump) { Ok(v) => Some(v), Err(FromExprError::OutOfMemory) => return Err(AllocError), - Err(_) => { - // error.InvalidRegExp, error.UnexpectedExpr + Err(FromExprError::UnexpectedExpr) => { log.reset(); None } @@ -1633,8 +1631,8 @@ mod draft { } use bun_install_types::NodeLinker::{ - Behavior as PnpmBehavior, CreateMatcherError, FromExprError, Matcher as PnpmMatcherEntry, - PnpmMatcher, create_matcher, + Behavior as PnpmBehavior, FromExprError, Matcher as PnpmMatcherEntry, PnpmMatcher, + create_matcher, }; /// `PnpmMatcher.fromExpr` operating on @@ -1651,8 +1649,6 @@ mod draft { source: &Source, bump: &Arena, ) -> Result { - let mut buf: Vec = Vec::new(); - let mut matchers: Vec = Vec::new(); let mut has_include = false; let mut has_exclude = false; @@ -1660,23 +1656,7 @@ mod draft { match &expr.data { ExprData::EString(s) => { let mut s = *s; - let pattern = s.slice(bump); - let matcher = match create_matcher(pattern, &mut buf) { - Ok(m) => m, - Err(CreateMatcherError::OutOfMemory) => return Err(FromExprError::OutOfMemory), - Err(CreateMatcherError::InvalidRegExp) => { - log.add_error_fmt_opts( - format_args!("Invalid regex: {}", bstr::BStr::new(pattern)), - bun_ast::AddErrorOptions { - loc: expr.loc, - redact_sensitive_information: true, - source: Some(source), - ..Default::default() - }, - ); - return Err(FromExprError::InvalidRegExp); - } - }; + let matcher = create_matcher(s.slice(bump)); has_include = has_include || !matcher.is_exclude; has_exclude = has_exclude || matcher.is_exclude; matchers.push(matcher); @@ -1684,24 +1664,7 @@ mod draft { ExprData::EArray(patterns) => { for pattern_expr in patterns.items.slice() { if let Some(pattern) = pattern_expr.as_string_cloned(bump)? { - let matcher = match create_matcher(pattern, &mut buf) { - Ok(m) => m, - Err(CreateMatcherError::OutOfMemory) => { - return Err(FromExprError::OutOfMemory); - } - Err(CreateMatcherError::InvalidRegExp) => { - log.add_error_fmt_opts( - format_args!("Invalid regex: {}", bstr::BStr::new(pattern)), - bun_ast::AddErrorOptions { - loc: pattern_expr.loc, - redact_sensitive_information: true, - source: Some(source), - ..Default::default() - }, - ); - return Err(FromExprError::InvalidRegExp); - } - }; + let matcher = create_matcher(pattern); has_include = has_include || !matcher.is_exclude; has_exclude = has_exclude || matcher.is_exclude; matchers.push(matcher); diff --git a/src/install_types/NodeLinker.rs b/src/install_types/NodeLinker.rs index 44964d19f519..9cf3debe3463 100644 --- a/src/install_types/NodeLinker.rs +++ b/src/install_types/NodeLinker.rs @@ -73,6 +73,7 @@ pub mod npm { // (`__bun_regex_*`) defined `#[no_mangle]` in `bun_jsc::regular_expression`. // ══════════════════════════════════════════════════════════════════════════ +use core::cell::OnceCell; use core::ptr::NonNull; use bun_alloc::Arena; @@ -85,42 +86,59 @@ use bun_core::{String as BunString, strings}; // `bun_jsc::regular_expression`; declared here as `extern "Rust"` and // resolved at link time. unsafe extern "Rust" { - /// Compile `pattern` with no flags. `None` ⇔ `error.InvalidRegExp`. - /// Performs `jsc::initialize` lazily on first call. + /// Compile `pattern` with no flags. `None` ⇔ the pattern does not compile. + /// Initializes JSC first (idempotent), so it must only run from + /// [`RegularExpression::matches`]; see the type's docs. fn __bun_regex_compile(pattern: BunString) -> Option>; fn __bun_regex_matches(regex: NonNull<()>, input: &BunString) -> bool; fn __bun_regex_drop(regex: NonNull<()>); } -/// Owned, type-erased JSC regex; drops through the vtable. +/// Owned, type-erased compiled JSC regex; drops through the vtable. // FORWARD_DECL(b0): bun_jsc::RegularExpression — stored as raw NonNull<()> // (NOT Box: a zero-sized opaque Box is a dangling sentinel that would // leak the real JSC allocation and skip its destructor). -pub struct RegularExpression(NonNull<()>); +struct CompiledRegularExpression(NonNull<()>); -impl RegularExpression { - #[inline] - fn matches(&self, input: &BunString) -> bool { - // SAFETY: self.0 was produced by `__bun_regex_compile`. - unsafe { __bun_regex_matches(self.0, input) } - } -} - -impl Drop for RegularExpression { +impl Drop for CompiledRegularExpression { fn drop(&mut self) { // SAFETY: self.0 was produced by `__bun_regex_compile`; runs JSC destructor + free. unsafe { __bun_regex_drop(self.0) } } } -/// Compile `pattern` into a Yarr regex via the link-time extern. `pub` so -/// higher-tier callers re-use this single declaration site instead of -/// duplicating the `__bun_regex_*` extern block (one declarer per upward call, -/// per PORTING.md §extern-Rust-ban). -#[inline] -fn compile_regex(pattern: BunString) -> Option { - // SAFETY: link-time extern; pattern ownership transfers. - unsafe { __bun_regex_compile(pattern) }.map(RegularExpression) +/// A package-name pattern, compiled on first use. +/// +/// Matchers are built while bunfig.toml / .npmrc load, before the command +/// initializes JSC. `__bun_regex_compile` initializes JSC, and the first +/// `jsc::initialize` of the process fixes JSC's options, so compiling here +/// would drop the options the command passes later (`bun -p` needs eval mode +/// to keep the script's completion value, `bun test --isolate` its JIT +/// thresholds). Only the install linker matches, so compiling there leaves +/// the first call to the command. +pub struct RegularExpression { + /// The anchored pattern built by [`create_matcher`]. + source: Box<[u8]>, + /// `None` if the pattern failed to compile; it then never matches. The + /// escaping in [`create_matcher`] leaves Yarr's pattern size limit as the + /// only way to fail. + compiled: OnceCell>, +} + +impl RegularExpression { + fn matches(&self, input: &BunString) -> bool { + let compiled = self.compiled.get_or_init(|| { + // SAFETY: link-time extern; pattern ownership transfers. + unsafe { __bun_regex_compile(BunString::clone_utf8(&self.source)) } + .map(CompiledRegularExpression) + }); + match compiled { + // SAFETY: `regex.0` was produced by `__bun_regex_compile` and stays + // live until `regex` drops. + Some(regex) => unsafe { __bun_regex_matches(regex.0, input) }, + None => false, + } + } } pub struct PnpmMatcher { @@ -148,7 +166,6 @@ pub enum Behavior { #[derive(Debug, strum::IntoStaticStr)] pub enum FromExprError { OutOfMemory, - InvalidRegExp, UnexpectedExpr, } bun_core::impl_tag_error!(FromExprError); @@ -166,10 +183,8 @@ impl PnpmMatcher { log: &mut bun_ast::Log, source: &bun_ast::Source, ) -> Result { - let mut buf: Vec = Vec::new(); // Scratch arena for `E::String::slice` / `as_string_cloned`. - // Freed on return; the patterns are consumed by - // `create_matcher` before then. + // Freed on return; `create_matcher` copies the patterns before then. let arena = Arena::new(); let mut matchers: Vec = Vec::new(); @@ -178,23 +193,7 @@ impl PnpmMatcher { match expr.data { ast::ExprData::EString(mut s) => { - let pattern = s.slice(&arena); - let matcher = match create_matcher(pattern, &mut buf) { - Ok(m) => m, - Err(CreateMatcherError::OutOfMemory) => return Err(FromExprError::OutOfMemory), - Err(CreateMatcherError::InvalidRegExp) => { - log.add_error_fmt_opts( - format_args!("Invalid regex: {}", bstr::BStr::new(pattern)), - bun_ast::AddErrorOptions { - loc: expr.loc, - redact_sensitive_information: true, - source: Some(source), - ..Default::default() - }, - ); - return Err(FromExprError::InvalidRegExp); - } - }; + let matcher = create_matcher(s.slice(&arena)); has_include = has_include || !matcher.is_exclude; has_exclude = has_exclude || matcher.is_exclude; matchers.push(matcher); @@ -202,24 +201,7 @@ impl PnpmMatcher { ast::ExprData::EArray(patterns) => { for pattern_expr in patterns.slice() { if let Some(pattern) = pattern_expr.as_string_cloned(&arena)? { - let matcher = match create_matcher(pattern, &mut buf) { - Ok(m) => m, - Err(CreateMatcherError::OutOfMemory) => { - return Err(FromExprError::OutOfMemory); - } - Err(CreateMatcherError::InvalidRegExp) => { - log.add_error_fmt_opts( - format_args!("Invalid regex: {}", bstr::BStr::new(pattern)), - bun_ast::AddErrorOptions { - loc: pattern_expr.loc, - redact_sensitive_information: true, - source: Some(source), - ..Default::default() - }, - ); - return Err(FromExprError::InvalidRegExp); - } - }; + let matcher = create_matcher(pattern); has_include = has_include || !matcher.is_exclude; has_exclude = has_exclude || matcher.is_exclude; matchers.push(matcher); @@ -321,18 +303,7 @@ impl PnpmMatcher { } } -#[derive(Debug, strum::IntoStaticStr)] -pub enum CreateMatcherError { - OutOfMemory, - InvalidRegExp, -} -bun_core::impl_tag_error!(CreateMatcherError); - -bun_core::oom_from_alloc!(CreateMatcherError); - -pub fn create_matcher(raw: &[u8], buf: &mut Vec) -> Result { - buf.clear(); - +pub fn create_matcher(raw: &[u8]) -> Matcher { let mut trimmed = strings::trim(raw, &strings::WHITESPACE_CHARS); let mut is_exclude = false; @@ -342,27 +313,24 @@ pub fn create_matcher(raw: &[u8], buf: &mut Vec) -> Result`, which is infallible. - buf.push(b'^'); - let _ = escape_reg_exp_for_package_name_matching(trimmed, buf); - buf.push(b'$'); - - // `__bun_regex_compile` is a link-time extern (cold path) and performs - // `jsc::initialize` before compiling. - let regex = compile_regex(BunString::clone_utf8(buf.as_slice())) - .ok_or(CreateMatcherError::InvalidRegExp)?; - - Ok(Matcher { - pattern: Pattern::Regex(regex), + // `escape_reg_exp_*` writes through `io::Write` for `Vec`, which is + // infallible. + let mut source: Vec = Vec::with_capacity(trimmed.len() + 2); + source.push(b'^'); + let _ = escape_reg_exp_for_package_name_matching(trimmed, &mut source); + source.push(b'$'); + + Matcher { + pattern: Pattern::Regex(RegularExpression { + source: source.into_boxed_slice(), + compiled: OnceCell::new(), + }), is_exclude, - }) + } } diff --git a/src/jsc/RegularExpression.rs b/src/jsc/RegularExpression.rs index 829cfa446945..8d0b27c0f311 100644 --- a/src/jsc/RegularExpression.rs +++ b/src/jsc/RegularExpression.rs @@ -91,11 +91,11 @@ impl RegularExpression { #[unsafe(no_mangle)] fn __bun_regex_compile(pattern: BunString) -> Option> { - // bunfig's hoistPattern is compiled during config load, before the CLI initializes JSC itself. - crate::initialize(crate::InitializeOptions { - one_shot: crate::is_one_shot_eval_invocation(), - ..Default::default() - }); + // Idempotent. Only the install commands reach this before initializing + // JSC themselves, and the defaults are right for them; every other command + // has already initialized JSC with its own options by the time a matcher + // first runs (see `bun_install_types::NodeLinker::RegularExpression`). + crate::initialize(crate::InitializeOptions::default()); match RegularExpression::init(pattern, Flags::None) { Ok(r) => core::ptr::NonNull::new(r.cast()), Err(_) => None, diff --git a/test/cli/run/run-eval.test.ts b/test/cli/run/run-eval.test.ts index 0f928a7ce6a7..0c7462e3b5ec 100644 --- a/test/cli/run/run-eval.test.ts +++ b/test/cli/run/run-eval.test.ts @@ -1,7 +1,7 @@ import { SyncSubprocess } from "bun"; import { describe, expect, test } from "bun:test"; import { rmSync, writeFileSync } from "fs"; -import { bunEnv, bunExe, isWindows, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir, tmpdirSync } from "harness"; import { tmpdir } from "os"; import { join, sep } from "path"; @@ -173,6 +173,34 @@ describe("--print for cjs/esm", () => { expect(stdout.toString()).toBe("true\n"); expect(exitCode).toBe(0); }); + + // The hoist patterns in [install] are regexes. Compiling them while + // bunfig.toml loaded initialized JSC before `bun -p` could turn on eval + // mode, so the script's completion value was dropped and every --print + // printed undefined. + describe.each(["hoistPattern", "publicHoistPattern"])("with install.%s in bunfig.toml", key => { + test.concurrent.each([ + { expr: "Math.max(1, 9)", expected: "9" }, + { expr: "[1, 2].map(x => x * 2)", expected: "[ 2, 4 ]" }, + { expr: "1 + 1", expected: "2" }, + { expr: "(await 1) + 1", expected: "2" }, + ])("bun -p $expr", async ({ expr, expected }) => { + using dir = tempDir("eval-install-pattern", { + "bunfig.toml": `[install]\n${key} = ["*eslint*", "!eslint-plugin-*"]\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "-p", expr], + cwd: String(dir), + 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(`${expected}\n`); + expect(exitCode).toBe(0); + }); + }); }); function group(run: (code: string) => SyncSubprocess<"pipe", "inherit">) { diff --git a/test/cli/test/isolation.test.ts b/test/cli/test/isolation.test.ts index e207e40a4764..cf514ec3281b 100644 --- a/test/cli/test/isolation.test.ts +++ b/test/cli/test/isolation.test.ts @@ -475,6 +475,34 @@ describe.concurrent("bun test --isolate", () => { }); }); +// --isolate raises JSC's FTL warm-up threshold. JSC options are set once per +// process, by whoever initializes JSC first. The [install] hoist patterns are +// regexes, and compiling them while bunfig.toml loaded used to initialize JSC +// with the default options before `bun test` could pass its own. +test.concurrent("--isolate: JSC options survive a bunfig.toml with an install hoist pattern", async () => { + using dir = tempDir("isolate-bunfig-hoist-pattern", { + "bunfig.toml": `[install]\nhoistPattern = ["*eslint*"]\n`, + "a.test.ts": ` + import { test, expect } from "bun:test"; + test("passes", () => { + expect(1).toBe(1); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--isolate", "./a.test.ts"], + env: { ...bunEnv, BUN_JSC_dumpOptions: "2" }, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const threshold = stderr.split("\n").find(line => line.includes("thresholdForFTLOptimizeAfterWarmUp=")); + expect(threshold?.trim()).toBe("thresholdForFTLOptimizeAfterWarmUp=1000000"); + expect(normalizeBunSnapshot(stderr, dir)).toContain("1 pass"); + expect(exitCode).toBe(0); +}); + // The eviction test below proves the SourceProvider cache is active (control: // b sees stale v1 → cache hit) and that delete require.cache evicts it // (treatment: b sees fresh v2). A/B timing was removed as flaky; this is the