diff --git a/crates/socket-patch-core/src/formats/bun/mod.rs b/crates/socket-patch-core/src/formats/bun/mod.rs index 540207601..b67278751 100644 --- a/crates/socket-patch-core/src/formats/bun/mod.rs +++ b/crates/socket-patch-core/src/formats/bun/mod.rs @@ -8,6 +8,8 @@ //! the splice's line coordinates, parse the `packages` section — is //! [`BunTextLock::parse`]; each caller keeps its own refusal wording. +use std::sync::{Arc, Mutex, PoisonError}; + use crate::vendor::bun_lock_text::{check_lock_version, parse_packages_section, BunEntry}; /// Why [`BunTextLock::parse`] refused a lock. @@ -46,4 +48,128 @@ impl BunTextLock { let entries = parse_packages_section(&lines).map_err(BunTextError::Packages)?; Ok(BunTextLock { lines, entries }) } + + /// [`Self::parse`], shared: a scan reads one `bun.lock` in the lock + /// inventory, in VEX discovery and in the hosted redirect (and the + /// vendored preflight once per package), and parsing a large lock is + /// most of a Bun rescan's CPU (#1606). The last lock + /// parsed is kept, and a text whose lines are exactly its + /// [`Self::lines`] gets it back; any other text (a changed lock) is + /// parsed again. A refused lock is not kept. Readers only; a splice + /// edits a copy of [`Self::lines`]. + pub(crate) fn parse_shared(text: &str) -> Result, BunTextError> { + static LAST: ParseMemo = ParseMemo::new(); + LAST.parse(text) + } + + /// Whether `text` is the text this lock was parsed from. + fn is_parse_of(&self, text: &str) -> bool { + let mut lines = self.lines.iter(); + text.split('\n') + .all(|line| lines.next().is_some_and(|parsed| parsed == line)) + && lines.next().is_none() + } +} + +/// The last lock [`BunTextLock::parse_shared`] parsed. +struct ParseMemo(Mutex>>); + +impl ParseMemo { + const fn new() -> Self { + ParseMemo(Mutex::new(None)) + } + + fn parse(&self, text: &str) -> Result, BunTextError> { + let last = || self.0.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(lock) = last().as_ref().filter(|lock| lock.is_parse_of(text)) { + return Ok(Arc::clone(lock)); + } + let lock = Arc::new(BunTextLock::parse(text)?); + *last() = Some(Arc::clone(&lock)); + Ok(lock) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const LOCK: &str = r#"{ + "lockfileVersion": 1, + "workspaces": { + "": { "name": "fixture", "dependencies": { "left-pad": "1.3.0" } }, + }, + "packages": { + "left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPz=="], + } +} +"#; + + #[test] + fn same_text_is_parsed_once() { + let memo = ParseMemo::new(); + let first = memo.parse(LOCK).unwrap(); + // An equal text read again (a fresh String) gets the same parse. + let again = memo.parse(&LOCK.to_string()).unwrap(); + assert!(Arc::ptr_eq(&first, &again)); + assert_eq!(first.entries.len(), 1); + assert_eq!(first.entries[0].key, "left-pad"); + } + + #[test] + fn changed_text_is_parsed_again() { + let memo = ParseMemo::new(); + let first = memo.parse(LOCK).unwrap(); + let bumped = LOCK.replace("left-pad@1.3.0", "left-pad@1.3.1"); + let second = memo.parse(&bumped).unwrap(); + assert!(!Arc::ptr_eq(&first, &second)); + assert!(second.entries[0].elems[0].contains("1.3.1")); + // Back to the first text: parsed again, never a stale result. + let third = memo.parse(LOCK).unwrap(); + assert!(third.entries[0].elems[0].contains("1.3.0")); + assert_eq!( + third.lines, + LOCK.split('\n').map(str::to_string).collect::>() + ); + } + + #[test] + fn refusals_are_not_kept() { + let memo = ParseMemo::new(); + let first = memo.parse(LOCK).unwrap(); + let future = LOCK.replace("\"lockfileVersion\": 1,", "\"lockfileVersion\": 99,"); + let refused = memo.parse(&future).err().unwrap(); + assert!(matches!(refused, BunTextError::Version(_))); + assert_eq!(memo.parse(&future).err(), Some(refused)); + // The refusal left the last good parse in place. + assert!(Arc::ptr_eq(&first, &memo.parse(LOCK).unwrap())); + } + + #[test] + fn a_prefix_or_extension_is_another_text() { + let memo = ParseMemo::new(); + let first = memo.parse(LOCK).unwrap(); + for other in [ + format!("{LOCK}\n"), + LOCK.trim_end().to_string(), + LOCK.replace('\n', "\r\n"), + ] { + let parsed = memo.parse(&other).unwrap(); + assert!(!Arc::ptr_eq(&first, &parsed), "{other:?}"); + assert_eq!( + parsed.lines, + other.split('\n').map(str::to_string).collect::>() + ); + memo.parse(LOCK).unwrap(); + } + } + + #[test] + fn shared_parse_reads_like_the_parse() { + let shared = BunTextLock::parse_shared(LOCK).unwrap(); + let parsed = BunTextLock::parse(LOCK).unwrap(); + assert_eq!(shared.lines, parsed.lines); + assert_eq!(shared.entries.len(), parsed.entries.len()); + assert_eq!(shared.entries[0].elems, parsed.entries[0].elems); + } } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 231394835..c505e94a5 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -5501,15 +5501,15 @@ fn berry_catalog_selectors(yarnrc: Option<&str>, name: &str, ranges: &[&str]) -> // deviations). Byte-for-byte twin of the TS `rewriteBun`. fn parse_bun_hosted_lock( content: &str, -) -> Result<(Vec, Vec), RewriteWarning> { +) -> Result, RewriteWarning> { use crate::vendor::bun_lock_text::{has_workspace_packages, lock_version}; // The shared gate's `Err` text IS the detail: hosted and vendored refuse // an unsupported head with one message (and one remedy per arm — a // future version means "update socket-patch", a missing integer means // "re-lock"), so the two modes cannot drift apart. - let (lines, entries) = match crate::formats::bun::BunTextLock::parse(content) { - Ok(lock) => (lock.lines, lock.entries), + let lock = match crate::formats::bun::BunTextLock::parse_shared(content) { + Ok(lock) => lock, Err(crate::formats::bun::BunTextError::Version(detail)) => { return Err(RewriteWarning { code: "redirect_bun_lock_unsupported".into(), @@ -5543,7 +5543,7 @@ fn parse_bun_hosted_lock( // workspaces is kept at 0 by an in-place install on 1.2.0, 1.2.23 and // 1.3.0 and bumped to 1 by 1.3.9 and every later release; that case is // accepted here either way.) - if lock_version(content) == Some(0) && has_workspace_packages(&entries) { + if lock_version(content) == Some(0) && has_workspace_packages(&lock.entries) { return Err(RewriteWarning { code: "redirect_bun_workspace_unsupported".into(), detail: "Bun version-0 workspace locks cannot preserve hosted tarballs on frozen \ @@ -5556,7 +5556,7 @@ fn parse_bun_hosted_lock( }); } - Ok((lines, entries)) + Ok(lock) } /// The warning for a package the bun rewriters pinned to its hosted URL that @@ -5627,13 +5627,16 @@ fn rewrite_bun_lock( let Some(content) = files.get("bun.lock") else { return; }; - let (mut lines, entries) = match parse_bun_hosted_lock(content) { - Ok(parsed) => parsed, + let lock = match parse_bun_hosted_lock(content) { + Ok(lock) => lock, Err(warning) => { result.warnings.push(warning); return; } }; + let entries = &lock.entries; + // The shared parse is never edited: the first splice copies its lines. + let mut lines = std::borrow::Cow::Borrowed(lock.lines.as_slice()); let user_patched = crate::vendor::bun_lock_text::patched_dependency_keys( files.get("package.json").map(String::as_str), @@ -5788,7 +5791,7 @@ fn rewrite_bun_lock( if rebuilt == original { continue; } - lines[entry.line_idx] = rebuilt.clone(); + lines.to_mut()[entry.line_idx] = rebuilt.clone(); result.edits.push(FileEdit { path: "bun.lock".into(), kind: "redirect_bun_lock_package".into(), @@ -5830,7 +5833,7 @@ fn rewrite_bun_lock( // holds a pin is touched, and the manifests are the engine's advisory // `/package.json` reads. if pinned_any { - let heals = crate::vendor::bun_lock_text::heal_workspace_literals(&mut lines, |dir| { + let heals = crate::vendor::bun_lock_text::heal_workspace_literals(lines.to_mut(), |dir| { let rel = if dir.is_empty() { "package.json".to_string() } else { diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index c3e8cf31c..7d8b0abfe 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -2607,16 +2607,18 @@ pub(crate) async fn restore_bun_locks( let Some(text) = read_or_refuse(view, rel, &pins, &mut result).await else { continue; }; - let (mut lines, entries) = match super::super::parse_bun_hosted_lock(&text) { - Ok(parsed) => parsed, + let lock = match super::super::parse_bun_hosted_lock(&text) { + Ok(lock) => lock, Err(w) => { refuse_all_in(&pins, rel, &mut result, w.detail); continue; } }; + let entries = &lock.entries; + let mut lines = lock.lines.clone(); // (line, uuid, name, version, deps) let mut hits: Vec<(usize, String, String, String, String)> = Vec::new(); - for entry in &entries { + for entry in entries { if !matches!(entry.elems.len(), 2 | 3) || !entry.elems[1].starts_with('{') { continue; } diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index f864c1e5f..9fc219707 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -206,10 +206,9 @@ pub async fn preflight_vendor(project_root: &Path) -> Result<(), (&'static str, } Err(error) => return Err(("vendor_lockfile_missing", error.to_string())), }; - let entries = BunTextLock::parse(&text) - .map_err(|e| ("vendor_lockfile_version_unsupported", e.detail()))? - .entries; - check_workspace_compatibility(&text, &entries) + let lock = BunTextLock::parse_shared(&text) + .map_err(|e| ("vendor_lockfile_version_unsupported", e.detail()))?; + check_workspace_compatibility(&text, &lock.entries) } /// Whether `bun.lock` already wires EVERY packages entry resolving the npm @@ -281,13 +280,12 @@ pub async fn wired_instances_all_ours( } Err(error) => return Err(("vendor_lockfile_missing", error.to_string())), }; - let entries = BunTextLock::parse(&text) - .map_err(|e| ("vendor_lockfile_version_unsupported", e.detail()))? - .entries; + let lock = BunTextLock::parse_shared(&text) + .map_err(|e| ("vendor_lockfile_version_unsupported", e.detail()))?; let target_spec = format!("{name}@{version}"); let target_leaf = tgz_rel_leaf(&name, &version); let mut matched = 0usize; - for entry in &entries { + for entry in &lock.entries { match classify_rewritable(entry, &target_spec, &name, &target_leaf) { Some(TupleShape::Ours { .. }) => matched += 1, Some(TupleShape::Registry) => return Ok(false), diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs b/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs index fa8647d92..9aa634919 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs @@ -1,6 +1,7 @@ //! `bun.lock` / `bun.lockb`: the registry views. use std::path::Path; +use std::sync::Arc; use crate::constants::npm_family::{BUN_LOCK, BUN_LOCKB}; use crate::formats::bun::{BunTextError, BunTextLock}; @@ -19,9 +20,9 @@ use super::{http_url, LockIntegrity, LockfileEntry, UnsupportedNpmLayout}; /// elements. `Err` is the user-facing refusal detail (it names the file): a /// lock the backends refuse — a hand re-indented one included — is one /// neither the inventory nor lockfile discovery reads. -pub(crate) fn bun_text_entries(text: &str) -> Result, String> { - match BunTextLock::parse(text) { - Ok(lock) => Ok(lock.entries), +pub(crate) fn bun_text_entries(text: &str) -> Result, String> { + match BunTextLock::parse_shared(text) { + Ok(lock) => Ok(lock), Err(BunTextError::Version(detail)) => Err(detail), Err(BunTextError::Packages(e)) => Err(format!("{BUN_LOCK}: {e}")), } @@ -115,11 +116,11 @@ pub(super) async fn inventory_bun(root: &Path) -> Option> { pub(super) async fn inventory_bun_in(view: &ProjectView<'_>) -> Option> { let text = view.read_text(BUN_LOCK).await.ok()?; - let entries = bun_text_entries(&text).ok()?; + let lock = bun_text_entries(&text).ok()?; let mut out = Vec::new(); - for entry in entries { - if let Some(hosted) = hosted_pin_entry(&entry) { + for entry in &lock.entries { + if let Some(hosted) = hosted_pin_entry(entry) { out.push(hosted); continue; } diff --git a/crates/socket-patch-core/src/vex/discover/bun.rs b/crates/socket-patch-core/src/vex/discover/bun.rs index 42615ef1d..85b24e95b 100644 --- a/crates/socket-patch-core/src/vex/discover/bun.rs +++ b/crates/socket-patch-core/src/vex/discover/bun.rs @@ -124,8 +124,8 @@ async fn extract_text(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { let Some(text) = ctx.read_text(BUN_LOCK, out).await else { return; }; - let entries = match bun_text_entries(&text) { - Ok(entries) => entries, + let lock = match bun_text_entries(&text) { + Ok(lock) => lock, Err(detail) => { out.diag(DIAG_LOCKFILE_UNPARSEABLE, BUN_LOCK, detail); return; @@ -133,7 +133,7 @@ async fn extract_text(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { }; let mut bundled = Bundled::default(); let mut unwired = Unwired::default(); - for entry in &entries { + for entry in &lock.entries { let elems = &entry.elems; let Some(spec) = elems.first().and_then(|e| decode_json_string(e)) else { continue;