From 1268dba44cc64909011cbfc01f2c0bb58ad453d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 21:34:24 +0000 Subject: [PATCH 1/4] Start fix for #1607 Assisted-by: Claude Code:claude-opus-5-5 From 4fed6c33249c587ff6c70370eb0c749e895410fb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 21:48:50 +0000 Subject: [PATCH 2/4] Speed up the project-patch check on big locks Every scan checks the project for its own patches (Yarn patch: locators, patch-package scripts) before touching a package, and hosted scans check again under the write lock. On a large Yarn Berry lock that check split every line of yarn.lock to find the few resolution lines that carry a patch: locator, and it recompiled the two patch-package regexes each time. Now it jumps straight to the "@patch:" occurrences and compiles the regexes once, only for a project whose scripts run patch-package. What it finds is unchanged. On the yarn-berry/rescan bench fixture this cuts about 20M instructions (~2%) from each scan. Refs #1607 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/user_patches.rs | 78 +++++++++++++++++-- 1 file changed, 70 insertions(+), 8 deletions(-) diff --git a/crates/socket-patch-core/src/patch/user_patches.rs b/crates/socket-patch-core/src/patch/user_patches.rs index fc0ac8c18..01d6ba319 100644 --- a/crates/socket-patch-core/src/patch/user_patches.rs +++ b/crates/socket-patch-core/src/patch/user_patches.rs @@ -6,7 +6,9 @@ use std::collections::{BTreeSet, VecDeque}; use std::path::Path; +use std::sync::LazyLock; +use regex::Regex; use serde_json::Value; use crate::formats::text::strip_bom; @@ -107,8 +109,9 @@ impl UserPatchIndex { if let Some(text) = index.read(&yarn_lock, "npm").await { // Only Berry has patch: locators. Every Berry lock carries // builtin compat patches, so scan the resolution lines rather - // than parse what can be a very large lockfile. - for line in text.lines() { + // than parse what can be a very large lockfile, and visit + // only the few lines that can hold one. + for line in lines_containing(&text, "@patch:") { let Some(value) = line.trim_start().strip_prefix("resolution:") else { continue; }; @@ -514,10 +517,12 @@ impl UserPatchIndex { async fn patch_package(&mut self, dir: &Path, value: &Value) { self.patch_directory(&dir.join("patches"), false).await; - let flag = regex::Regex::new(r#"--patch-dir(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))"#) - .unwrap(); + // Compiled once, and only for a project whose scripts run patch-package. + static FLAG: LazyLock = LazyLock::new(|| { + Regex::new(r#"--patch-dir(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))"#).unwrap() + }); // `cd` as a command, not the tail of a word like `abcd`. - let cd = regex::Regex::new(r"(?:^|[\s;&|(])cd\s").unwrap(); + static CD: LazyLock = LazyLock::new(|| Regex::new(r"(?:^|[\s;&|(])cd\s").unwrap()); if let Some(scripts) = value["scripts"].as_object() { for script in scripts .values() @@ -526,17 +531,17 @@ impl UserPatchIndex { { // A `cd` after patch-package (`patch-package && cd ios && pod // install`) can't move the directory it already read. - let moved = cd + let moved = CD .find(script) .is_some_and(|cd| cd.start() < script.rfind("patch-package").unwrap_or(0)); - if moved || (script.contains("--patch-dir") && !flag.is_match(script)) { + if moved || (script.contains("--patch-dir") && !FLAG.is_match(script)) { self.unknown( "npm", &dir.join("package.json"), "cannot resolve patch-package directory without executing the script", ); } - for captures in flag.captures_iter(script) { + for captures in FLAG.captures_iter(script) { let path = captures.iter().skip(1).flatten().next().unwrap().as_str(); if path.contains(['$', '`']) || moved { self.unknown( @@ -724,6 +729,24 @@ fn locator_target(locator: &str) -> (&str, Option<&str>) { } } +/// The lines of `text` that contain `needle`, each once, as `str::lines` +/// spells them (a `\r` before a `\n` is dropped, a final bare one kept). Searching for the +/// needle skips the rest of the text instead of splitting every line. +fn lines_containing<'a>(text: &'a str, needle: &'a str) -> impl Iterator + 'a { + let mut from = 0; + std::iter::from_fn(move || { + let at = from + text.get(from..)?.find(needle)?; + let start = text[..at].rfind('\n').map_or(0, |i| i + 1); + let Some(end) = text[at..].find('\n').map(|i| at + i) else { + from = text.len(); + return Some(&text[start..]); + }; + from = end; + let line = &text[start..end]; + Some(line.strip_suffix('\r').unwrap_or(line)) + }) +} + fn lock_options() -> serde_saphyr::Options { // Lockfiles of large monorepos exceed the default 250k-node budget, and a // parse failure would protect every npm package. @@ -836,6 +859,45 @@ mod tests { assert!(index.conflicts("pkg:npm/@s/pkg@1.3.0").is_empty()); } + #[test] + fn lines_containing_matches_a_filtered_lines_walk() { + for text in [ + "", + "@patch:", + "a\nb@patch:c\n", + "x@patch:y@patch:z\r\nno\r\n q@patch:\r\n", + "\n\n@patch:\n\n", + "a@patch:\nb@patch:", + "@patch:@patch:\r", + "lone\rcarriage@patch:\r\rx\n", + "\u{e9}@patch:\u{e9}\n\u{e9}", + ] { + let slow: Vec<&str> = text.lines().filter(|l| l.contains("@patch:")).collect(); + let fast: Vec<&str> = lines_containing(text, "@patch:").collect(); + assert_eq!(fast, slow, "{text:?}"); + } + } + + #[tokio::test] + async fn yarn_lock_patches_are_found_among_many_unpatched_entries() { + let tmp = tempfile::tempdir().unwrap(); + let mut lock = String::from("__metadata:\r\n version: 8\r\n"); + for i in 0..200 { + lock.push_str(&format!( + "\"dep-{i}@npm:1.0.0\":\r\n version: 1.0.0\r\n resolution: \"dep-{i}@npm:1.0.0\"\r\n" + )); + } + lock.push_str("\"first@patch:first@npm%3A1.0.0#./a.patch\":\r\n resolution: \"first@patch:first@npm%3A1.0.0#./a.patch::version=1.0.0&hash=a\"\r\n"); + lock.push_str("\"ts@patch:ts@npm%3A5.0.0#optional!builtin\":\r\n resolution: \"ts@patch:ts@npm%3A5.0.0#optional!builtin::version=5.0.0&hash=b\"\r\n"); + lock.push_str("\"last@patch:last@npm%3A2.0.0#./b.patch\":\n resolution: \"last@patch:last@npm%3A2.0.0#./b.patch::version=2.0.0&hash=c\""); + write(tmp.path(), "yarn.lock", &lock); + let index = UserPatchIndex::discover(tmp.path()).await; + assert!(!index.conflicts("pkg:npm/first@1.0.0").is_empty()); + assert!(!index.conflicts("pkg:npm/last@2.0.0").is_empty()); + assert!(index.conflicts("pkg:npm/ts@5.0.0").is_empty()); + assert!(index.conflicts("pkg:npm/dep-7@1.0.0").is_empty()); + } + #[tokio::test] async fn patch_package_supports_scoped_nested_sequenced_dev_and_custom_directories() { let tmp = tempfile::tempdir().unwrap(); From f04cb201a1483ef7fdc5eda4ac7cbf16ca8000be Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 21:55:19 +0000 Subject: [PATCH 3/4] Check canonical purls a byte at a time Scans key every crawled package's purl several times, and each key first checks whether the purl is already canonical. That check looked for '?', '#' and '%' by decoding every character. Scanning bytes gives the same answer (all three are ASCII, and no multi-byte UTF-8 character contains an ASCII byte) for a fraction of the cost: about 10M fewer instructions per yarn-berry/rescan bench run. Refs #1607 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/utils/purl_key.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/utils/purl_key.rs b/crates/socket-patch-core/src/utils/purl_key.rs index 11c50b38f..379f42089 100644 --- a/crates/socket-patch-core/src/utils/purl_key.rs +++ b/crates/socket-patch-core/src/utils/purl_key.rs @@ -61,7 +61,9 @@ pub fn canonical_base_purl(purl: &str) -> String { /// no qualifiers, subpath or percent escapes, and a lowercase type whose /// names and versions keep their spelling. fn is_canonical_base(purl: &str) -> bool { - if purl.trim().len() != purl.len() || purl.contains(['?', '#', '%']) { + // A byte scan: these are ASCII, and no multi-byte UTF-8 sequence + // contains an ASCII byte. Matching chars costs a decode per char. + if purl.trim().len() != purl.len() || purl.bytes().any(|b| matches!(b, b'?' | b'#' | b'%')) { return false; } let Some((ty, _)) = purl @@ -272,6 +274,9 @@ mod tests { "pkg:maven/org.Example/Lib@1.0", "pkg:gem/nokogiri@1.16.5", "pkg:cargo/serde@1.0.0", + "pkg:npm/caf\u{e9}@1.0.0", + "pkg:npm/caf\u{e9}%41@1.0.0", + "pkg:npm/\u{2603}@1.0.0?x=\u{e9}", "pkg:npm/no-version", "pkg:npm/@scope", "pkg:npm", From d9b8faadf622894de5603ea99a32e62aebfcf4ba Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 22:50:57 +0000 Subject: [PATCH 4/4] Guard the patch-line search against stalls The helper that finds yarn.lock lines holding a patch: locator now moves past each line it returns, so a needle that is empty or holds a newline can't keep matching the same spot. A test pins the documented line-ending rules instead of only comparing against str::lines. Refs #1607 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/patch/user_patches.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-core/src/patch/user_patches.rs b/crates/socket-patch-core/src/patch/user_patches.rs index 01d6ba319..4d0924060 100644 --- a/crates/socket-patch-core/src/patch/user_patches.rs +++ b/crates/socket-patch-core/src/patch/user_patches.rs @@ -732,16 +732,19 @@ fn locator_target(locator: &str) -> (&str, Option<&str>) { /// The lines of `text` that contain `needle`, each once, as `str::lines` /// spells them (a `\r` before a `\n` is dropped, a final bare one kept). Searching for the /// needle skips the rest of the text instead of splitting every line. +/// `needle` must be non-empty and on one line, so each match moves on. fn lines_containing<'a>(text: &'a str, needle: &'a str) -> impl Iterator + 'a { + debug_assert!(!needle.is_empty() && !needle.contains('\n')); let mut from = 0; std::iter::from_fn(move || { let at = from + text.get(from..)?.find(needle)?; let start = text[..at].rfind('\n').map_or(0, |i| i + 1); let Some(end) = text[at..].find('\n').map(|i| at + i) else { - from = text.len(); + // Past the end: the next call finds nothing. + from = text.len() + 1; return Some(&text[start..]); }; - from = end; + from = end + 1; let line = &text[start..end]; Some(line.strip_suffix('\r').unwrap_or(line)) }) @@ -876,6 +879,9 @@ mod tests { let fast: Vec<&str> = lines_containing(text, "@patch:").collect(); assert_eq!(fast, slow, "{text:?}"); } + // The documented contract, independent of the toolchain's `lines`. + let got: Vec<&str> = lines_containing("a@patch:\r\nb@patch:\r", "@patch:").collect(); + assert_eq!(got, ["a@patch:", "b@patch:\r"]); } #[tokio::test]