From b5557f608a794ef4969f81f4c470531b24e60244 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:10:28 +0000 Subject: [PATCH 1/5] ini: look a key up once per line, through a hash table in a large object `Parser::parse` scanned the object for every line: two scans for `key = value`, four for `key[] = value`, and one for each segment of a `[section]` header. A file of N distinct keys took N^2 key compares. `parse` now looks the key up once per line. `KeyIndex` scans an object of up to 32 properties, as before. For a larger object it keeps a hash table of the positions of the keys, with the hash of each key. It reads the table from the object's own property list, so it also has the keys of a JSON value that a later `[section]` adds to. A debug build checks the table against a scan for an object of up to 512 properties. `open_section` walks the segments of a header with its lookups through `KeyIndex`. It applies the rules of `E::Object::get_or_put_object`, which the parser does not call any more. --- src/ini/lib.rs | 206 +++++++++++++++++++++++------ test/js/bun/ini/ini.test.ts | 250 +++++++++++++++++++++++++++++++++++- 2 files changed, 416 insertions(+), 40 deletions(-) diff --git a/src/ini/lib.rs b/src/ini/lib.rs index 74b64b7bec4f..646754128673 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -120,9 +120,11 @@ mod draft { use bun_alloc::{AllocError, Arena, ArenaVec, ArenaVecExt as _}; use bun_ast::E::Rope; - use bun_ast::{E, Expr, ExprData, StoreRef}; + use bun_ast::{E, Expr, ExprData, G, StoreRef}; use bun_ast::{Loc, Log, Source}; - use bun_collections::VecExt; + use bun_collections::array_hash_map::hash_string; + use bun_collections::hashbrown::HashTable; + use bun_collections::{HashMap, VecExt}; use bun_core::{Output, ZStr}; use bun_dotenv::Loader as DotEnvLoader; use bun_options_types::schema::api::{BunInstall, Ca, NpmRegistry, NpmUrlAuth}; @@ -135,13 +137,108 @@ mod draft { type OOM = Result; - /// Hard cap on dot-separated segments in a section-header rope. The rope is - /// consumed by `E::Object::get_or_put_object`, which recurses once per - /// `rope.next` link, so an unbounded header overflows the stack. Past the - /// cap the remainder of the header (dots included) becomes the final - /// segment. + /// Bounds the nesting that one section header makes, and the tail walk of `Rope::append`. The rest of a longer header is its last segment. const MAX_SECTION_ROPE_SEGMENTS: usize = 512; + /// A lookup scans up to this many keys. More keys than that get a hash table. + const SCAN_MAX: usize = 32; + + /// A debug build checks the table of an object of up to this many properties against a scan. + const CHECKED_MAX: usize = 512; + + /// Finds a property by key in an object that `Parser::parse` fills, through a hash table if the object is large. + #[derive(Default)] + struct KeyIndex { + /// By object address. + objects: HashMap, + } + + #[derive(Default)] + struct ObjectKeys { + /// The first property of each key. + slots: HashTable, + /// `properties[..indexed]` are in `slots`. + indexed: usize, + } + + struct KeySlot { + /// `hash_string` of the key. A table that grows reads it and not the key. + hash: u32, + /// In `properties`. + position: u32, + } + + impl KeyIndex { + /// The position that `E::Object::as_property` returns. + #[inline] + fn find(&mut self, object: StoreRef, key: &[u8]) -> Option { + if object.properties.len() <= SCAN_MAX { + return object.as_property(key).map(|query| query.i as usize); + } + self.find_indexed(object, key) + } + + #[cold] + #[inline(never)] + fn find_indexed(&mut self, object: StoreRef, key: &[u8]) -> Option { + let props = object.properties.slice(); + let key_at = |slot: &KeySlot| property_key(&props[slot.position as usize]); + let rehash = |slot: &KeySlot| table_hash(slot.hash); + let keys = self.objects.entry(object.as_ptr() as usize).or_default(); + // The table is read from the property list, so it has the keys of a JSON value that a `[section]` reopens. + keys.slots + .reserve(props.len().saturating_sub(keys.indexed), rehash); + for (position, prop) in props.iter().enumerate().skip(keys.indexed) { + let Some(name) = property_key(prop) else { + continue; + }; + let hash = hash_string(name); + keys.slots + .entry( + table_hash(hash), + |slot| slot.hash == hash && key_at(slot) == Some(name), + rehash, + ) + .or_insert(KeySlot { + hash, + position: position as u32, + }); + } + keys.indexed = props.len(); + let hash = hash_string(key); + let found = keys + .slots + .find(table_hash(hash), |slot| { + slot.hash == hash && key_at(slot) == Some(key) + }) + .map(|slot| slot.position as usize); + // `parse` only appends properties and replaces their values, so the table matches the list. + debug_assert!( + props.len() > CHECKED_MAX + || found == object.as_property(key).map(|query| query.i as usize) + ); + found + } + } + + /// The key that `E::Object::as_property` compares for this property, or `None` for a property that it skips. + fn property_key(prop: &G::Property) -> Option<&[u8]> { + prop.value?; + match &prop.key.as_ref()?.data { + ExprData::EString(key) => { + // `as_property` transcodes a UTF-16 key. `parse` and the JSON parser that it calls only make UTF-8 ones. + debug_assert!(key.is_utf8()); + Some(key.data.slice()) + } + _ => None, + } + } + + /// `hashbrown` takes its tag from the top bits. + fn table_hash(hash: u32) -> u64 { + (u64::from(hash) << 32) | u64::from(hash) + } + // ────────────────────────────────────────────────────────────────────────── // Parser // ────────────────────────────────────────────────────────────────────────── @@ -203,6 +300,7 @@ mod draft { let root: StoreRef = self.out.data.e_object().expect("Parser.out is E.Object"); let mut head: StoreRef = root; + let mut keys = KeyIndex::default(); let ropealloc = bump; @@ -249,11 +347,9 @@ mod draft { offset, )? .into_section(); - let mut r = root; - let parent_object = match r.get_or_put_object(section, bump) { - Ok(v) => v, - Err(E::SetError::OutOfMemory) => return Err(AllocError), - Err(E::SetError::Clobber) => { + head = match Self::open_section(&mut keys, root, section) { + Some(object) => object, + None => { // We're in here if key exists but it is not an object // // This is possible if someone did: @@ -289,10 +385,6 @@ mod draft { break 'treat_as_key; } }; - head = parent_object - .data - .e_object() - .expect("get_or_put_object returns E.Object"); break 'treat_as_key; } if !treat_as_key { @@ -367,38 +459,74 @@ mod draft { _ => value_raw, }; - if is_array { - if let Some(val) = E::Object::get(&head, key) { - if !matches!(val.data, ExprData::EArray(_)) { - let mut arr = E::Array::default(); - arr.push(bump, val)?; - head.put(bump, key, Expr::init(arr, Loc::EMPTY))?; - } - } else { - head.put(bump, key, Expr::init(E::Array::default(), Loc::EMPTY))?; - } - } + let at = keys.find(head, key); + let existing = at.and_then(|i| head.properties.slice()[i].value); // safeguard against resetting a previously defined // array by accidentally forgetting the brackets - let mut was_already_array = false; - if let Some(mut val) = E::Object::get(&head, key) { - if matches!(val.data, ExprData::EArray(_)) { - was_already_array = true; - val.data - .e_array_mut() - .expect("infallible: variant checked") - .push(bump, value)?; - head.put(bump, key, val)?; - } + if let Some(ExprData::EArray(mut array)) = existing.map(|val| val.data) { + array.push(bump, value)?; + continue; } - if !was_already_array { - head.put(bump, key, value)?; + + let value = if is_array { + let mut array = E::Array::default(); + if let Some(val) = existing { + array.push(bump, val)?; + } + array.push(bump, value)?; + Expr::init(array, Loc::EMPTY) + } else { + value + }; + match at { + Some(i) => head.properties.slice_mut()[i].value = Some(value), + None => { + let key = Expr::allocate(bump, E::EString::init(key), value.loc); + head.append_property(key, value); + } } } Ok(()) } + /// The object that a section header names, with the objects on the way made if they are missing. `None` if a segment names another kind of value. + fn open_section( + keys: &mut KeyIndex, + root: StoreRef, + section: &mut Rope, + ) -> Option> { + let mut object = root; + let mut segment = StoreRef::from_bump(section); + loop { + let next = ptr::NonNull::new(segment.next).map(StoreRef::from_non_null); + let name = segment.head.data.e_string()?; + let child = match keys.find(object, &name.data) { + Some(i) => match object.properties.slice()[i].value?.data { + ExprData::EObject(existing) => existing, + // Under an array, the rest of the header is in its last element. + ExprData::EArray(array) if next.is_some() => { + match array.items.last()?.data { + ExprData::EObject(last) => last, + _ => return None, + } + } + _ => return None, + }, + None => { + let created = Expr::init(E::Object::default(), segment.head.loc); + object.append_property(segment.head, created); + created.data.e_object()? + } + }; + let Some(next) = next else { + return Some(child); + }; + object = child; + segment = next; + } + } + fn prepare_str( env: &DotEnvLoader, source_path: &[u8], diff --git a/test/js/bun/ini/ini.test.ts b/test/js/bun/ini/ini.test.ts index d38b7747e30e..78cab8c4b327 100644 --- a/test/js/bun/ini/ini.test.ts +++ b/test/js/bun/ini/ini.test.ts @@ -1,7 +1,14 @@ const { iniInternals } = require("bun:internal-for-testing"); const { parse } = iniInternals; import { describe, expect, it, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, tempDir } from "harness"; +import { join } from "node:path"; + +function numbered(count: number, text: (i: number) => string) { + let all = ""; + for (let i = 0; i < count; i++) all += text(i); + return all; +} describe("parse ini", () => { test("weird section", () => { @@ -535,6 +542,194 @@ brr = 3 }); }); + // The parser scans an object of up to 32 keys for a key, and uses a hash table for a larger object. + // Each case runs with the object under test on both sides of that limit. + describe.each([0, 31, 32, 33, 300])("an object with %d other keys", count => { + const otherKeys = Array.from({ length: count }, (_, i) => `other${i}`); + const otherLines = otherKeys.map(key => `${key} = x`); + const others = Object.fromEntries(otherKeys.map(key => [key, "x"])); + + function expectParsed(lines: string[], expected: object) { + const parsed = parse(lines.join("\n")); + expect(parsed).toEqual(expected); + // `toEqual` accepts the keys in any order. + expect(JSON.stringify(parsed)).toBe(JSON.stringify(expected)); + } + + test("a repeated key replaces the value and keeps its place", () => { + expectParsed(["a = 1", ...otherLines, "b = 2", "a = 3"], { a: "3", ...others, b: "2" }); + }); + + test("key[] after a plain key makes an array of both values", () => { + expectParsed(["a = 1", ...otherLines, "b = 2", "a[] = 3"], { a: ["1", "3"], ...others, b: "2" }); + }); + + test("a plain key after key[] adds to the array", () => { + expectParsed(["a[] = 1", ...otherLines, "b = 2", "a = 3", "a[] = 4"], { + a: ["1", "3", "4"], + ...others, + b: "2", + }); + }); + + test("a section that is opened again keeps its keys", () => { + expectParsed(["[s]", "x = 1", ...otherLines, "[t]", "y = 2", "[s]", "z = 3", "x = 9"], { + s: { x: "9", ...others, z: "3" }, + t: { y: "2" }, + }); + }); + + test("a nested section is found again in its parent", () => { + expectParsed(["[a]", ...otherLines, "[a.b]", "x = 1", "[a]", "y = 2", "[a.b]", "z = 3"], { + a: { ...others, b: { x: "1", z: "3" }, y: "2" }, + }); + }); + + test("a key replaces a nested section, and the header of that section is then skipped", () => { + expectParsed(["[a]", ...otherLines, "[a.b]", "x = 1", "[a]", "b = 5", "[a.b]", "y = 2", "[c]", "z = 3"], { + a: { ...others, b: "5" }, + c: { z: "3" }, + }); + }); + + test("the header of a key with a string value is skipped", () => { + expectParsed(["foo = bar", ...otherLines, "[foo]", "x = 1", "[ok]", "y = 2"], { + foo: "bar", + ...others, + ok: { y: "2" }, + }); + }); + + test("a section adds to the keys of a JSON value", () => { + const json = JSON.stringify({ a: 1, b: [2], ...others }); + expectParsed([`foo = '${json}'`, "[foo]", "c = 3", "a = 7", "b = 8"], { + foo: { a: "7", b: [2, "8"], ...others, c: "3" }, + }); + }); + + test("a section replaces the first of two equal keys of a JSON value", () => { + const json = `{"a":1,${otherKeys.map(key => `"${key}":"x",`).join("")}"a":2}`; + expectParsed([`foo = '${json}'`, "[foo]", "a = 3"], { foo: { a: 2, ...others } }); + }); + + test("a header continues in the last object of an array", () => { + const json = JSON.stringify({ x: 1, ...others }); + expectParsed([...otherLines, `a[] = '${json}'`, "[a.b]", "k = v", "[a.c]", "[a.b]", "l = w", "[a]", "m = n"], { + ...others, + a: [{ x: 1, ...others, b: { k: "v", l: "w" }, c: {} }], + }); + }); + + test.each([ + ["whose last element is not an object", "a[] = 1", ["1"]], + ["that is empty", "a = '[]'", []], + ])("a header under an array %s is skipped", (_, line, a) => { + expectParsed([...otherLines, line, "[a.b]", "k = v", "[ok]", "y = 2"], { ...others, a, ok: { y: "2" } }); + }); + + test("a header that is skipped leaves the section that was open", () => { + expectParsed(["[s]", ...otherLines, "a[] = 1", "[s.a.b]", "k = v", "[", "m = n"], { + s: { ...others, a: ["1"], m: "n" }, + }); + }); + + test("a __proto__ key is skipped, and a [__proto__] section is an own property", () => { + expectParsed([...otherLines, "__proto__ = 1", "ok = 1", "[__proto__]", "x = 1", "[s]", "[__proto__]", "y = 2"], { + ...others, + ok: "1", + ["__proto__"]: { x: "1", y: "2" }, + s: {}, + }); + }); + + test("two sections that take turns, with their keys in opposite orders", () => { + const keys = ["first", ...otherKeys]; + const reversed = keys.toReversed(); + expectParsed( + [ + ...keys.flatMap((key, i) => ["[a]", `${key} = a`, "[b]", `${reversed[i]} = b`]), + ...keys.flatMap((key, i) => ["[a]", `${key} = A`, "[b]", `${reversed[i]}[] = B`]), + ], + { + a: Object.fromEntries(keys.map(key => [key, "A"])), + b: Object.fromEntries(reversed.map(key => [key, ["b", "B"]])), + }, + ); + }); + }); + + // The same lines with and without 33 more keys in each object: the first is read through the hash table, the second + // with a scan. + test("an object of many keys is read like a small one in 24 random files", () => { + const filler = numbered(33, i => `filler${i} = x\n`); + const withoutFiller = (value: unknown): unknown => + Array.isArray(value) + ? value.map(withoutFiller) + : value && typeof value === "object" + ? Object.fromEntries( + Object.entries(value) + .filter(([key]) => !key.startsWith("filler")) + .map(([key, inner]) => [key, withoutFiller(inner)]), + ) + : value; + let seed = 1; + const random = (below: number) => (seed = (seed * 48271) % 0x7fffffff) % below; + for (let file = 0; file < 24; file++) { + let small = ""; + let large = filler; + for (let line = 0; line < 30; line++) { + const key = `k${random(5)}`; + const text = [ + `${key} = ${line}\n`, + `${key}[] = ${line}\n`, + `${key} = '{"k${random(5)}":${line},"a":[1,{"b":2}]}'\n`, + `[${key}]\n`, + `[${key}.k${random(5)}]\n`, + `[${key}.a.c]\n`, + ][random(6)]; + small += text; + large += text.startsWith("[") ? text + filler : text; + } + expect(JSON.stringify(withoutFiller(parse(large)))).toBe(JSON.stringify(parse(small))); + } + }); + + test("two keys with the same hash stay apart in an object of many keys", () => { + // The hash table of an object uses the low 32 bits of wyhash. + const [one, two] = ["key85982", "key146395"]; + expect(Bun.hash.wyhash(one) & 0xffff_ffffn).toBe(Bun.hash.wyhash(two) & 0xffff_ffffn); + const parsed = parse(numbered(40, i => `other${i} = x\n`) + `${one} = 1\n${two} = 2\n${one} = 3\n${two}[] = 4\n`); + expect(Object.keys(parsed)).toHaveLength(42); + expect([parsed[one], parsed[two]]).toEqual(["3", ["2", "4"]]); + }); + + test("a key is found among 4,000 keys", () => { + const parsed = parse( + numbered(4000, i => `key${i} = ${i}\n`) + "key0 = first\nkey2000[] = middle\nkey3999 = last\n[key1]\nx = 1\n", + ); + expect(Object.keys(parsed)).toHaveLength(4000); + expect([parsed.key0, parsed.key1, parsed.key2000, parsed.key3999]).toEqual([ + "first", + "1", + ["2000", "middle"], + "last", + ]); + }); + + test("a section is found among 4,000 sections", () => { + const parsed = parse( + numbered(4000, i => `[section${i}]\nkey = ${i}\n`) + + "[section0]\nkey = first\n[section2000]\nkey[] = middle\n[section3999.nested]\nkey = last\n", + ); + expect(Object.keys(parsed)).toHaveLength(4000); + expect([parsed.section0, parsed.section1, parsed.section2000, parsed.section3999]).toEqual([ + { key: "first" }, + { key: "1" }, + { key: ["2000", "middle"] }, + { key: "3999", nested: { key: "last" } }, + ]); + }); + test("bigboi", async () => { const foo = await Bun.$`cat ${__dirname}/foo.ini`.text(); const result = parse(foo); @@ -641,6 +836,59 @@ brr = 3 }); }); +// A read that scans the lines before each line takes 13 to 50 seconds of CPU for these files in a debug build, +// and 5 to 17 seconds in a release build. +describe.concurrent("a large .npmrc is read in time in proportion to its lines", () => { + const lines = isDebug ? 16_000 : 64_000; + + test.each<[string, () => string]>([ + ["keys", () => numbered(lines, i => `key${i} = value\n`)], + ["key[] lines", () => numbered(lines, i => `key${i}[] = value\n`)], + ["sections", () => numbered(lines, i => `[section${i}]\n`)], + ["nested sections", () => numbered(lines, i => `[a.b${i}]\n`)], + ["the keys of one section", () => "[section]\n" + numbered(lines, i => `key${i} = value\n`)], + [ + "the keys that a section adds to a JSON value", + () => + `foo = '{${numbered(lines / 2, i => `"json${i}":${i},`)}"last":0}'\n[foo]\n` + + numbered(lines / 2, i => `key${i} = value\n`), + ], + ["two sections that take turns", () => numbered(lines / 2, i => `[a]\nkey${i} = value\n[b]\nkey${i} = value\n`)], + ])( + "%s", + async (_, npmrc) => { + using dir = tempDir("ini-large-npmrc", { + ".npmrc": npmrc(), + "package.json": JSON.stringify({ name: "x", version: "1.0.0" }), + "home/.gitkeep": "", + }); + const home = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "bin"], + cwd: String(dir), + // `bun pm bin` reads the `.npmrc` of the project, and no other: `home` has none. + env: { + ...bunEnv, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: home, + NPM_CONFIG_USERCONFIG: undefined, + npm_config_userconfig: undefined, + }, + stdout: "pipe", + stderr: "pipe", + timeout: 30_000, + killSignal: "SIGKILL", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]); + // CPU time, in seconds. Wall-clock time means little on a busy machine. + expect(Number(proc.resourceUsage()!.cpuTime.total) / 1e6).toBeLessThan(isDebug || isASAN ? 4 : 1); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + }, + 60_000, + ); +}); + const wtf = { "o": "p", "a with spaces": "b c", From 81e727c93358279b027a7f588e2a19dc5d4344e3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:10:29 +0000 Subject: [PATCH 2/5] ini: collapse the credential lines of a large .npmrc with hash tables `resolve_url_auth` scanned every credential line for each line: up to four `rfind` passes to collapse a key, one pass over `install.url_auth` to skip a key that has its entry, and one pass over the registries for each empty `_auth` line. `Collapsed` keeps the last line of each `(key, option)` in a hash table when there are more than 32 credential lines, with a set of the keys of the lines so far. More than 32 registries get a set of their keys. Up to 32, each is scanned as before. --- src/ini/lib.rs | 77 ++++++++--- test/cli/install/npmrc.test.ts | 232 ++++++++++++++++++++++++++++++++- 2 files changed, 292 insertions(+), 17 deletions(-) diff --git a/src/ini/lib.rs b/src/ini/lib.rs index 646754128673..90c0a912649a 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -46,7 +46,7 @@ pub(crate) fn inline_comment_start(val: &[u8]) -> Option { // ConfigOpt // ────────────────────────────────────────────────────────────────────────── -#[derive(Clone, Copy, PartialEq, Eq, strum::IntoStaticStr, strum::EnumString)] +#[derive(Clone, Copy, PartialEq, Eq, Hash, strum::IntoStaticStr, strum::EnumString)] pub enum ConfigOpt { /// usually `${username}:${password}` encoded in base64, but sent verbatim #[strum(serialize = "_auth")] @@ -1372,10 +1372,33 @@ mod draft { /// npm's config is a flat map, so a key that's repeated across `.npmrc` files collapses to the /// last one read. - fn lookup<'a>(configs: &'a [ConfigItem], key: &[u8], opt: ConfigOpt) -> Option<&'a ConfigItem> { - configs - .iter() - .rfind(|conf_item| conf_item.optname == opt && *conf_item.registry_url == *key) + struct Collapsed<'a> { + configs: &'a [ConfigItem], + /// The last line of each `(key, option)`, if there are more than `SCAN_MAX` lines. + last: Option>, + } + + impl<'a> Collapsed<'a> { + fn new(configs: &'a [ConfigItem]) -> Self { + let last = (configs.len() > SCAN_MAX).then(|| { + let mut last = HashMap::with_capacity(configs.len()); + for item in configs { + last.insert((&*item.registry_url, item.optname), item); + } + last + }); + Collapsed { configs, last } + } + + fn lookup(&self, key: &[u8], opt: ConfigOpt) -> Option<&'a ConfigItem> { + match &self.last { + Some(last) => last.get(&(key, opt)).copied(), + None => self + .configs + .iter() + .rfind(|item| item.optname == opt && *item.registry_url == *key), + } + } } /// npm's `Buffer.from(value, "base64")`, which skips invalid bytes and never fails. @@ -1393,11 +1416,11 @@ mod draft { /// Follows the order of npm's `hasAuth`, without `certfile` and `keyfile` (no mTLS), which /// would stop the walk. - fn credentials_of_key(configs: &[ConfigItem], key: &[u8]) -> Option { + fn credentials_of_key(collapsed: &Collapsed<'_>, key: &[u8]) -> Option { // npm's `opts[k]` truthiness test, applied after the collapse, so a later `username=` // clears an earlier one. let truthy = - |opt| Some(&lookup(configs, key, opt)?.value).filter(|value| !value.is_empty()); + |opt| Some(&collapsed.lookup(key, opt)?.value).filter(|value| !value.is_empty()); let mut credentials = NpmRegistry::default(); if let Some(token) = truthy(ConfigOpt::_AuthToken) { credentials.token.clone_from(token); @@ -1422,14 +1445,21 @@ mod draft { log: &mut Log, ) { install.url_auth.clear(); + let collapsed = Collapsed::new(configs); + // The keys of the lines so far. Only filled if `collapsed` has its table. + let mut seen: HashMap<&[u8], ()> = HashMap::new(); for ConfigItem { registry_url: key, .. } in configs { - if install.url_auth.iter().any(|entry| entry.key == *key) { + let handled = match collapsed.last { + Some(_) => seen.insert(key, ()).is_some(), + None => install.url_auth.iter().any(|entry| entry.key == *key), + }; + if handled { continue; } - if let Some(credentials) = credentials_of_key(configs, key) { + if let Some(credentials) = credentials_of_key(&collapsed, key) { install.url_auth.push(NpmUrlAuth { key: key.clone(), credentials, @@ -1442,8 +1472,9 @@ mod draft { .filter(|item| { item.optname == ConfigOpt::_Auth && item.value.is_empty() - && lookup(configs, &item.registry_url, ConfigOpt::_Auth) - .is_some_and(|collapsed| core::ptr::eq(collapsed, *item)) + && collapsed + .lookup(&item.registry_url, ConfigOpt::_Auth) + .is_some_and(|last| core::ptr::eq(last, *item)) }) .peekable(); if empty.peek().is_none() { @@ -1466,12 +1497,26 @@ mod draft { .chain(from_bunfig) .map(RegistryKey::from_url) .collect(); + // Without its trailing `/` the key is a distinct one, which the walk visits right after the slashed one. + fn spellings(registry: &RegistryKey) -> [&[u8]; 2] { + [®istry.key, ®istry.key[..registry.key.len() - 1]] + } + let registry_keys = (registries.len() > SCAN_MAX).then(|| { + let mut keys: HashMap<&[u8], ()> = HashMap::with_capacity(registries.len() * 2); + for key in registries.iter().flat_map(spellings) { + keys.insert(key, ()); + } + keys + }); for item in empty { - // Without its trailing `/` the key is a distinct one, which the walk visits right after the slashed one. - if registries.iter().any(|registry| { - *item.registry_url == *registry.key - || *item.registry_url == registry.key[..registry.key.len() - 1] - }) { + let names_registry = match ®istry_keys { + Some(keys) => keys.contains_key(&*item.registry_url), + None => registries + .iter() + .flat_map(spellings) + .any(|key| *key == *item.registry_url), + }; + if names_registry { log.add_error_opts( b"ignoring empty _auth value", bun_ast::AddErrorOptions { diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 9ec486cdaaa5..0f8178bc5ae2 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -2,7 +2,18 @@ import { write } from "bun"; import { afterAll, beforeAll, describe, expect, it, test } from "bun:test"; import { chmodSync, mkdirSync, readdirSync, writeFileSync } from "fs"; import { rm } from "fs/promises"; -import { bunEnv, bunExe, isIPv6, isLinux, isWindows, tempDir, tls, unprivilegedSpawnOptions } from "harness"; +import { + bunEnv, + bunExe, + isASAN, + isDebug, + isIPv6, + isLinux, + isWindows, + tempDir, + tls, + unprivilegedSpawnOptions, +} from "harness"; import { createServer as createTlsServer } from "node:tls"; import { dirname, join } from "path"; import { TestRegistry } from "registry"; @@ -742,6 +753,50 @@ ${Object.keys(opts) await diagnosed(`//somehost.com/:_auth=${blob}\n`, `registry=http://somehost.com/\n//somehost.com/:_auth=\n`), ).toBe(true); }); + + // More than 32 credential lines are collapsed with a hash table. + const others = Array.from({ length: 40 }, (_, i) => `//other${i}.example.com/:_auth=${blob}\n`).join(""); + + test("among many credential lines, a home line the project overrides with a value is not diagnosed", async () => { + expect( + await diagnosed( + `//somehost.com/:_auth=\n${others}`, + `registry=http://somehost.com/\n${others}//somehost.com/:_auth=${blob}\n`, + ), + ).toBe(false); + }); + + test("among many credential lines, a project line that clears the home file's value is diagnosed", async () => { + expect( + await diagnosed( + `${others}//somehost.com/:_auth=${blob}\n`, + `registry=http://somehost.com/\n//somehost.com/:_auth=\n${others}`, + ), + ).toBe(true); + }); + + // Without its trailing `/`, the key of a registry is another key that npm reads for it. + test.each([ + ["the host of the registry", `registry=http://somehost.com/\n`, `//somehost.com:_auth=\n`, true], + ["the path of the registry", `@myorg:registry=https://somehost.com/api/\n`, `//somehost.com/api:_auth=\n`, true], + ["a part of that path", `@myorg:registry=https://somehost.com/api/\n`, `//somehost.com/ap:_auth=\n`, false], + ])("a line for %s without a trailing slash", async (_, registry, line, expected) => { + expect(await diagnosed(line, registry)).toBe(expected); + }); + + // More than 32 registries are found through a hash table. + const registries = Array.from( + { length: 40 }, + (_, i) => `@scope${i}:registry=https://registry${i}.example.com/npm/\n`, + ).join(""); + + test.each([ + ["its key", `//registry7.example.com/npm/:_auth=\n`, true], + ["its key without the trailing slash", `//registry7.example.com/npm:_auth=\n`, true], + ["a key above it", `//registry7.example.com/:_auth=\n`, false], + ])("among many registries, a line that names one by %s", async (_, line, expected) => { + expect(await diagnosed(line, registries)).toBe(expected); + }); }); await makeTest( @@ -1997,6 +2052,81 @@ describe.concurrent(".npmrc auth resolves by path-segment ancestor", () => { const auth = await probeAuthorization(host => `//${host}${path}:username=bob`, { path, homeNpmrc: homeBasic }); expect(auth).toBe(`Basic ${Buffer.from("bob:s3cret").toString("base64")}`); }); + + // Up to 32 credential lines are scanned for each key. More than that are collapsed with a hash table. + describe("among many other credential lines", () => { + const others = Array.from({ length: 40 }, (_, i) => `//other${i}.example.com/:_authToken=OTHER${i}`).join("\n"); + + it("a project token overrides the home file's token at the same key", async () => { + const auth = await probeAuthorization(host => `${others}\n//${host}${path}:_authToken=PROJECT`, { + path, + homeNpmrc: host => `//${host}${path}:_authToken=HOMETOKEN\n${others}`, + }); + expect(auth).toBe("Bearer PROJECT"); + }); + + it("a later line overrides an earlier one whose key is spelled another way", async () => { + const auth = await probeAuthorization( + host => `//${host}${path}:_authToken=FIRST\n${others}\n//http://${host}${path}:_authToken=LAST`, + { path }, + ); + expect(auth).toBe("Bearer LAST"); + }); + + it("the project's empty _authToken falsifies the home file's token, so _auth wins", async () => { + const auth = await probeAuthorization( + host => `//${host}${path}:_authToken=\n${others}\n//${host}${path}:_auth=${basic}`, + { path, homeNpmrc: host => `${others}\n//${host}${path}:_authToken=HOMETOKEN` }, + ); + expect(auth).toBe(`Basic ${basic}`); + }); + + it("the project's empty username clears the home file's username and password pair", async () => { + const auth = await probeAuthorization(host => `${others}\n//${host}${path}:username=`, { + path, + homeNpmrc: host => `${homeBasic(host)}\n${others}`, + }); + expect(auth).toBe(null); + }); + + it("a project username overrides the home file's at the same key", async () => { + const auth = await probeAuthorization(host => `${others}\n//${host}${path}:username=bob`, { + path, + homeNpmrc: host => `${others}\n${homeBasic(host)}`, + }); + expect(auth).toBe(`Basic ${Buffer.from("bob:s3cret").toString("base64")}`); + }); + + const thousands = Array.from({ length: 4000 }, (_, i) => `//host${i}.example.com/:_authToken=TOKEN${i}\n`).join( + "", + ); + + it("the token of a registry is found among 4,000 credential lines", () => { + const token = (registry: string, more = "") => + loadNpmrc(`registry=${registry}\n${thousands}${more}`).default_registry_token; + expect([0, 2000, 3999, 4000].map(i => token(`https://host${i}.example.com/`))).toEqual([ + "TOKEN0", + "TOKEN2000", + "TOKEN3999", + "", + ]); + expect(token("https://host7.example.com/", "//HOST7.example.com/:_authToken=LAST\n")).toBe("LAST"); + expect( + loadNpmrc(`registry=https://first.example.com/\n${thousands}registry=https://host9.example.com/\n`), + ).toMatchObject({ + default_registry_url: "https://host9.example.com/", + default_registry_token: "TOKEN9", + }); + }); + + it("the token of a registry is sent among 4,000 credential lines", async () => { + const auth = await probeAuthorization(host => `${thousands}//${host}${path}:_authToken=PROJECT`, { + path, + homeNpmrc: host => `//${host}${path}:_authToken=HOMETOKEN\n${thousands}`, + }); + expect(auth).toBe("Bearer PROJECT"); + }); + }); }); // npm never decodes `_auth`; it forwards the value as `Basic `. An opaque @@ -2602,6 +2732,31 @@ describe(".npmrc diagnostics", () => { expect(stderr).not.toContain("SECRETTOKEN"); }); + // A file of more than 32 keys is read through a hash table of its keys, and so are more than 32 credential lines. + it("warns once, at the first line, about an unknown option that a large file sets twice", async () => { + const others = Array.from({ length: 40 }, (_, i) => `other${i}=x`).join("\n"); + const stderr = await stderrOf( + `registry=https://example.com/\n//example.com/:_authtoken=FIRSTSECRET\n${others}\n//example.com/:_authtoken=LASTSECRET\n`, + ); + expect(stderr.match(/ignoring unknown .npmrc option _authtoken[^\n]*\n\s+at \S+[\\/]\.npmrc:\d+:/g)).toEqual([ + expect.stringMatching(/\.npmrc:2:$/), + ]); + expect(stderr).not.toContain("SECRET"); + }); + + it("reports an empty _auth once among many credential lines, for the registry it names", async () => { + const others = Array.from({ length: 40 }, (_, i) => `//other${i}.example.com/:_auth=`).join("\n"); + const stderr = await stderrOf( + `registry=https://example.com/\n//example.com/:_auth=\n${others}\n//Example.COM/:_auth=\n`, + ); + expect(stderr.match(/ignoring empty _auth value/g)).toEqual(["ignoring empty _auth value"]); + }); + + it("reports an empty _auth for each spelling of the key of a registry", async () => { + const stderr = await stderrOf(`registry=http://somehost.com/\n//somehost.com/:_auth=\n//somehost.com:_auth=\n`); + expect(stderr.match(/ignoring empty _auth value/g)).toHaveLength(2); + }); + it("prints nothing about an unknown option with --silent", async () => { const stderr = await stderrOf(`registry=https://example.com/\n//example.com/:_authtoken=SECRETTOKEN\n`, undefined, [ "--silent", @@ -2689,6 +2844,64 @@ describe(".npmrc diagnostics", () => { } }); +// A read that scans the credential lines for each line, or the credentials for each registry, takes 8 to 13 seconds +// of CPU for these files in a debug build, and 12 to 30 seconds in a release build. +describe.concurrent("a .npmrc of many credential lines is read in time in proportion to them", () => { + const lines = isDebug ? 8_000 : 64_000; + const numbered = (count: number, text: (i: number) => string) => { + let all = ""; + for (let i = 0; i < count; i++) all += text(i); + return all; + }; + + test.each<[string, () => string]>([ + ["_authToken lines", () => numbered(lines, i => `//host${i}.example.com/:_authToken=token${i}\n`)], + ["username lines", () => numbered(lines, i => `//host${i}.example.com/:username=user${i}\n`)], + [ + "username and _password lines", + () => + numbered( + lines / 2, + i => `//host${i}.example.com/:username=user${i}\n//host${i}.example.com/:_password=cGFzcw==\n`, + ), + ], + ["empty _auth lines", () => numbered(lines, i => `//host${i}.example.com/:_auth=\n`)], + [ + "registries, and an empty _auth line for another host each", + () => + numbered( + lines / 2, + i => `@scope${i}:registry=https://host${i}.example.com/\n//other${i}.example.com/:_auth=\n`, + ), + ], + ])( + "%s", + async (_, npmrc) => { + using dir = tempDir("npmrc-many-credentials", { + ".npmrc": npmrc(), + "package.json": JSON.stringify({ name: "x", version: "1.0.0" }), + "home/.gitkeep": "", + }); + const home = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "bin"], + cwd: String(dir), + env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + timeout: 30_000, + killSignal: "SIGKILL", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]); + // CPU time, in seconds. Wall-clock time means little on a busy machine. + expect(Number(proc.resourceUsage()!.cpuTime.total) / 1e6).toBeLessThan(isDebug || isASAN ? 4 : 1); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + }, + 60_000, + ); +}); + describe("--registry override", () => { test("does not send the token configured for the previous registry host to the --registry host", async () => { const tgz = join(import.meta.dir, "registry", "packages", "no-deps", "no-deps-1.0.0.tgz"); @@ -5020,6 +5233,23 @@ describe("trims Unicode whitespace like npm", () => { }); describe("inline comments and boolean options", () => { + // The root object is scanned for a key up to 32 keys, and read through a hash table past that. + test.each([0, 31, 32, 33])("a [section] header after %d other keys", count => { + const others = Array.from({ length: count }, (_, i) => `other${i}=x\n`).join(""); + // A key under a header is not an option. + expect(loadNpmrc(`${others}[section]\nsave-exact=true\n`).save_exact).toBeUndefined(); + // A header makes an object of its name, which is a true option. + expect(loadNpmrc(`${others}[hoist]\n`).hoist).toBe(true); + // The header of a key that has a value is skipped, with the keys under it. + const skipped = loadNpmrc(`${others}hoist=false\n[hoist]\nsave-exact=true\n`); + expect([skipped.hoist, skipped.save_exact]).toEqual([false, undefined]); + // A later `registry=` replaces the first. + expect( + loadNpmrc(`registry=https://first.example.com/\n${others}registry=https://last.example.com/\n`) + .default_registry_url, + ).toBe("https://last.example.com/"); + }); + test("an inline comment is not part of the registry url or the auth token", () => { // ini cuts an unquoted value at the first unescaped `;` or `#`. With the // comment left in, the registry URL no longer matched its `//host/` credential. From 3535545cdaf81a6414eab5d87b5782a2fb1f0e57 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:10:29 +0000 Subject: [PATCH 3/5] install: find a .npmrc credential key through a hash table past 32 keys `UrlAuth::find_entry` scanned every credential key for each key of the walk of a URL. `Options::load` calls it for each registry, so many registries with many credential keys took their product in compares. `UrlAuthList` holds the keys. With more than 32 of them, it finds a key through a hash table of the first entry of each key. Up to 32, it scans them as before. --- .../PackageManager/PackageManagerOptions.rs | 29 ++++++++----- src/install/npm.rs | 41 +++++++++++++++++-- test/cli/install/npmrc.test.ts | 10 +++++ 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index d407a60187a8..1005a625fa13 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -44,7 +44,7 @@ pub struct Options { pub(crate) registries: Npm::registry::Map, /// `.npmrc` `//host/path/` credential lines, resolved by request URL. - pub(crate) url_auth: Vec, + pub(crate) url_auth: Npm::registry::UrlAuthList, pub(crate) cache_directory: &'static [u8], pub enable: Enable, pub do_: Do, @@ -138,7 +138,7 @@ impl Default for Options { // Always assigned in `load()` before read. scope: Npm::registry::Scope::default(), registries: Npm::registry::Map::default(), - url_auth: Vec::new(), + url_auth: Npm::registry::UrlAuthList::default(), cache_directory: b"", enable: Enable::default(), do_: Do::default(), @@ -312,7 +312,7 @@ impl Options { // This URL comes from package data, and an `.npmrc` key has no scheme, so credentials are // never sent over plain http. let own = (normalized && tarball.is_https()) - .then(|| Npm::registry::UrlAuth::find_entry(&self.url_auth, tarball)) + .then(|| self.url_auth.find_entry(tarball)) .flatten(); if scope.has_credentials() && same_origin(tarball, &scope.url.url()) { // The registry's own key is in `scope` already, behind bunfig, env and CLI. @@ -328,8 +328,11 @@ impl Options { } /// `.npmrc`'s credentials for the URL of `scope` yield to bunfig.toml, the environment and flags. -fn fill_scope_from_url_auth(url_auth: &[Npm::registry::UrlAuth], scope: &mut Npm::registry::Scope) { - let own = Npm::registry::UrlAuth::find_entry(url_auth, &scope.url.url()); +fn fill_scope_from_url_auth( + url_auth: &Npm::registry::UrlAuthList, + scope: &mut Npm::registry::Scope, +) { + let own = url_auth.find_entry(&scope.url.url()); scope.url_auth_key = own.map(|entry| entry.key.clone()); if let Some(entry) = own.filter(|_| !scope.has_credentials() || scope.declared_in_npmrc) { scope.copy_credentials_from(&entry.credentials); @@ -384,7 +387,7 @@ impl Options { /// A registry at or under `old` keeps its credentials unless `.npmrc` has a deeper line for it. fn inherits_credentials(&self, old: &Npm::registry::Scope, new_url: &bun_url::URL) -> bool { - use Npm::registry::{UrlAuth, query_free_path}; + use Npm::registry::query_free_path; use bun_core::strings::tokenize; let old_url = old.url.url(); let mut segments = tokenize(query_free_path(new_url), b"/"); @@ -392,8 +395,10 @@ impl Options { == bun_core::without_trailing_slash(old_url.host) && (new_url.is_https() || !old_url.is_https()) && tokenize(query_free_path(&old_url), b"/").all(|base| segments.next() == Some(base)) - && match UrlAuth::find_entry(&self.url_auth, new_url) { - Some(own) => UrlAuth::find_entry(&self.url_auth, &old_url) + && match self.url_auth.find_entry(new_url) { + Some(own) => self + .url_auth + .find_entry(&old_url) .is_some_and(|old| old.key == own.key), None => true, } @@ -737,6 +742,7 @@ impl Options { // Scopes that have credentials but no URL use the default registry. They are registered // further down, once the default registry's URL is final. let mut at_default_registry: Vec<(&[u8], &Api::NpmRegistry)> = Vec::new(); + let mut url_auth = Vec::new(); // npmrc < bunfig for config in [runtime_npmrc].into_iter().chain(bunfigs).flatten() { @@ -760,12 +766,13 @@ impl Options { } } - for url_auth in &config.url_auth { - if let Some(url_auth) = Npm::registry::UrlAuth::from_api(url_auth, env)? { - self.url_auth.push(url_auth); + for entry in &config.url_auth { + if let Some(entry) = Npm::registry::UrlAuth::from_api(entry, env)? { + url_auth.push(entry); } } } + self.url_auth = Npm::registry::UrlAuthList::new(url_auth); for config in bunfigs.into_iter().flatten() { if let Some(cache_directory) = config.cache_directory.as_deref() { diff --git a/src/install/npm.rs b/src/install/npm.rs index 42cc8be8811c..3b6188f42d1d 100644 --- a/src/install/npm.rs +++ b/src/install/npm.rs @@ -615,16 +615,51 @@ pub mod registry { credentials, })) } + } + + /// Every credential key of `.npmrc`, in the order it was read. + #[derive(Default)] + pub(crate) struct UrlAuthList { + entries: Vec, + /// The position of the first entry of each key, if there are more than `SCAN_MAX` entries. + by_key: bun_collections::StringHashMap, + } + + impl UrlAuthList { + /// A lookup scans up to this many entries. More entries than that get a hash table. + const SCAN_MAX: usize = 32; + + pub(crate) fn new(entries: Vec) -> UrlAuthList { + let mut by_key = bun_collections::StringHashMap::new(); + if entries.len() > Self::SCAN_MAX { + for (position, entry) in entries.iter().enumerate() { + // A scan finds the first entry of a key. + bun_core::handle_oom(by_key.get_or_put_value(&entry.key, position as u32)); + } + } + UrlAuthList { entries, by_key } + } + + pub(crate) fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + fn get(&self, key: &[u8]) -> Option<&UrlAuth> { + if self.entries.len() > Self::SCAN_MAX { + return Some(&self.entries[*self.by_key.get(key)? as usize]); + } + self.entries.iter().find(|entry| *entry.key == *key) + } /// Walks the path like npm does. `url` must be a WHATWG serialization, from `Scope::url` or /// `normalize_tarball_url`. - pub(crate) fn find_entry<'a>(list: &'a [UrlAuth], url: &URL) -> Option<&'a UrlAuth> { - if list.is_empty() || !url.has_http_like_protocol() { + pub(crate) fn find_entry(&self, url: &URL) -> Option<&UrlAuth> { + if self.is_empty() || !url.has_http_like_protocol() { return None; } bun_ini::RegistryKey::from_parsed(url) .walk() - .find_map(|key| list.iter().find(|entry| *entry.key == *key)) + .find_map(|key| self.get(key)) } } diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 0f8178bc5ae2..3914464444df 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -2874,6 +2874,16 @@ describe.concurrent("a .npmrc of many credential lines is read in time in propor i => `@scope${i}:registry=https://host${i}.example.com/\n//other${i}.example.com/:_auth=\n`, ), ], + [ + "registries under a path, and a token for another host each", + () => + numbered( + lines / 2, + i => + `@scope${i}:registry=https://host${i}.example.com/org/project/_packaging/feed/npm/registry/\n` + + `//other${i}.example.com/:_authToken=token${i}\n`, + ), + ], ])( "%s", async (_, npmrc) => { From aaf0431c3e42b21cce3a8fef68f7caab53dcf9e8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:10:29 +0000 Subject: [PATCH 4/5] ast: delete E::Object::get_or_put_object and its unreachable caller The INI parser was the last caller of `E::Object::get_or_put_object` that can run. The other caller, `Template::write_to_package_json` in `bun init`, cannot run: `exec` returns for every React template before it reaches that call. Delete the walker with `E::SetError` and `Rope::next_ref`. Delete `write_to_package_json` with `Template::is_react` and `Template::scripts`, which only it used. --- src/ast/e.rs | 85 +-------------------------------- src/runtime/cli/init_command.rs | 63 ------------------------ 2 files changed, 1 insertion(+), 147 deletions(-) diff --git a/src/ast/e.rs b/src/ast/e.rs index 039522c286b9..0bcd16b00791 100644 --- a/src/ast/e.rs +++ b/src/ast/e.rs @@ -1295,7 +1295,7 @@ impl Default for Object { } } -/// Dotted-key path used by the INI parser (`get_or_put_object`). +/// The dot-separated segments of a section header of the INI parser. /// /// Node types are lifetime-free, so `next` is a raw `*mut Rope` /// into the bump arena. Segments are bulk-freed at arena reset. @@ -1323,28 +1323,7 @@ impl Rope { tail.next = rope; Ok(rope) } - - /// Re-borrow `next` as `Option<&Rope>`. Same `StoreRef` arena contract: - /// the pointee is a bump allocation valid until arena reset. Centralises - /// the one `unsafe` so the `get_or_put_object`/`get_rope` walkers - /// don't repeat `if !next.is_null() { unsafe { &*next } }` at every hop. - #[inline] - pub(crate) fn next_ref<'a>(&self) -> Option<&'a Rope> { - // SAFETY: `next` is either null or a bump-arena allocation valid until - // arena reset. Read-only borrow; no `&mut` alias is - // outstanding at any caller (the chain is fully built before walking). - unsafe { self.next.cast_const().as_ref() } - } -} - -// thiserror is not a dep of this crate; hand-roll Error+Display. -#[derive(Debug, strum::IntoStaticStr)] -pub enum SetError { - OutOfMemory, - Clobber, } -bun_core::impl_tag_error!(SetError); -bun_core::oom_from_alloc!(SetError); // ── live Object accessor surface ─────────────────────────────────────────── // Adapted to the current `Vec` API (`append(v)`, `slice()`, `slice_mut()`). @@ -1419,68 +1398,6 @@ impl Object { Expr::allocate(bump, EString::init(value), crate::Loc::EMPTY), ) } - - /// Walks `rope` segments, creating nested objects as needed, and returns - /// the leaf `E.Object` expression. - pub fn get_or_put_object(&mut self, rope: &Rope, _bump: &Bump) -> Result { - let head_key = match rope.head.data.e_string() { - Some(s) => s.data, - None => return Err(SetError::Clobber), - }; - if let Some(existing) = self.get(&head_key) { - match existing.data { - crate::expr::Data::EArray(array) => { - let Some(next) = rope.next_ref() else { - return Err(SetError::Clobber); - }; - if let Some(last) = array.items.last() { - if let crate::expr::Data::EObject(mut obj) = last.data { - return obj.get_or_put_object(next, _bump); - } - return Err(SetError::Clobber); - } - return Err(SetError::Clobber); - } - crate::expr::Data::EObject(mut object) => { - if let Some(next) = rope.next_ref() { - return object.get_or_put_object(next, _bump); - } - return Ok(existing); - } - _ => return Err(SetError::Clobber), - } - } - - if let Some(next) = rope.next_ref() { - let obj = Expr::init(Object::default(), rope.head.loc); - let out = match obj.data { - crate::expr::Data::EObject(mut o) => o.get_or_put_object(next, _bump)?, - _ => unreachable!(), - }; - VecExt::append( - &mut self.properties, - G::Property { - key: Some(rope.head), - value: Some(obj), - flags: own_key_property_flags(&rope.head), - ..G::Property::default() - }, - ); - return Ok(out); - } - - let out = Expr::init(Object::default(), rope.head.loc); - VecExt::append( - &mut self.properties, - G::Property { - key: Some(rope.head), - value: Some(out), - flags: own_key_property_flags(&rope.head), - ..G::Property::default() - }, - ); - Ok(out) - } } /// Data-file parsers (JSON/JSON5/TOML/YAML) define every key as an own diff --git a/src/runtime/cli/init_command.rs b/src/runtime/cli/init_command.rs index bab1fc4b9e6b..79c357daa993 100644 --- a/src/runtime/cli/init_command.rs +++ b/src/runtime/cli/init_command.rs @@ -800,10 +800,6 @@ impl InitCommand { } } - if template.is_react() { - template.write_to_package_json(&mut fields, &bump)?; - } - 'write_package_json: { let (fd, created_close): (Fd, Option) = match package_json_file .as_ref() @@ -1380,47 +1376,6 @@ impl TemplateFile { } impl Template { - fn is_react(self) -> bool { - matches!( - self, - Template::ReactBlank | Template::ReactTailwind | Template::ReactTailwindShadcn - ) - } - - fn write_to_package_json( - self, - fields: &mut PackageJSONFields, - bump: &bun_alloc::Arena, - ) -> Result<(), Error> { - type Rope = bun_ast::E::Rope; - fields.name = self.name().to_vec(); - // Allocate in the process-lifetime CLI arena. - let key: &mut Rope = crate::cli::cli_arena().alloc(Rope { - head: bun_ast::Expr::init(bun_ast::E::String::init(b"scripts"), bun_ast::Loc::EMPTY), - next: core::ptr::null_mut(), - }); - // SAFETY: object is arena-allocated and live for the command duration. - let object = unsafe { &mut *fields.object.unwrap().as_ptr() }; - let mut scripts_json = object.get_or_put_object(key, bump).map_err(|e| match e { - bun_ast::E::SetError::OutOfMemory => Error::Alloc(bun_alloc::AllocError), - bun_ast::E::SetError::Clobber => Error::Unexpected, - })?; - let the_scripts = self.scripts(); - let mut i: usize = 0; - while i < the_scripts.len() { - let script_name = the_scripts[i]; - let script_command = the_scripts[i + 1]; - - scripts_json.data.e_object_mut().unwrap().put_string( - bump, - script_name, - script_command, - )?; - i += 2; - } - Ok(()) - } - fn dependencies(self) -> &'static DependencyGroup { match self { Template::Blank => &DependencyGroup::BLANK, @@ -1441,24 +1396,6 @@ impl Template { } } - fn scripts(self) -> &'static [&'static [u8]] { - match self { - Template::Blank | Template::TypescriptLibrary => &[], - Template::ReactTailwind | Template::ReactTailwindShadcn => &[ - b"dev", b"bun './**/*.html'", - b"build", b"bun 'REPLACE_ME_WITH_YOUR_APP_FILE_NAME.build.ts'", - ], - Template::ReactBlank => &[ - b"dev", - b"bun --hot .", - b"static", - b"bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'", - b"build", - b"NODE_ENV=production bun .", - ], - } - } - const AGENT_RULE: &'static [u8] = include_bytes!("./init/rule.md"); const CURSOR_RULE: TemplateFile = TemplateFile::new( b".cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc", From e1b16db906e475026f5bb9bbf1bfd1e8c0120bd7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:53:03 +0000 Subject: [PATCH 5/5] test: drop the per-test timeout from the .npmrc CPU rows The rows pass under the default timeout. A row takes at most 3.3 s in a debug build with ASAN, and about 0.1 s in a release build. --- test/cli/install/npmrc.test.ts | 48 ++++++++++++-------------- test/js/bun/ini/ini.test.ts | 62 ++++++++++++++++------------------ 2 files changed, 51 insertions(+), 59 deletions(-) diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 3914464444df..d395fa53584e 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -2884,32 +2884,28 @@ describe.concurrent("a .npmrc of many credential lines is read in time in propor `//other${i}.example.com/:_authToken=token${i}\n`, ), ], - ])( - "%s", - async (_, npmrc) => { - using dir = tempDir("npmrc-many-credentials", { - ".npmrc": npmrc(), - "package.json": JSON.stringify({ name: "x", version: "1.0.0" }), - "home/.gitkeep": "", - }); - const home = join(String(dir), "home"); - await using proc = Bun.spawn({ - cmd: [bunExe(), "pm", "bin"], - cwd: String(dir), - env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, - stdout: "pipe", - stderr: "pipe", - stdin: "ignore", - timeout: 30_000, - killSignal: "SIGKILL", - }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]); - // CPU time, in seconds. Wall-clock time means little on a busy machine. - expect(Number(proc.resourceUsage()!.cpuTime.total) / 1e6).toBeLessThan(isDebug || isASAN ? 4 : 1); - expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); - }, - 60_000, - ); + ])("%s", async (_, npmrc) => { + using dir = tempDir("npmrc-many-credentials", { + ".npmrc": npmrc(), + "package.json": JSON.stringify({ name: "x", version: "1.0.0" }), + "home/.gitkeep": "", + }); + const home = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "bin"], + cwd: String(dir), + env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + timeout: 30_000, + killSignal: "SIGKILL", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]); + // CPU time, in seconds. Wall-clock time means little on a busy machine. + expect(Number(proc.resourceUsage()!.cpuTime.total) / 1e6).toBeLessThan(isDebug || isASAN ? 4 : 1); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + }); }); describe("--registry override", () => { diff --git a/test/js/bun/ini/ini.test.ts b/test/js/bun/ini/ini.test.ts index 78cab8c4b327..f38c44382693 100644 --- a/test/js/bun/ini/ini.test.ts +++ b/test/js/bun/ini/ini.test.ts @@ -854,39 +854,35 @@ describe.concurrent("a large .npmrc is read in time in proportion to its lines", numbered(lines / 2, i => `key${i} = value\n`), ], ["two sections that take turns", () => numbered(lines / 2, i => `[a]\nkey${i} = value\n[b]\nkey${i} = value\n`)], - ])( - "%s", - async (_, npmrc) => { - using dir = tempDir("ini-large-npmrc", { - ".npmrc": npmrc(), - "package.json": JSON.stringify({ name: "x", version: "1.0.0" }), - "home/.gitkeep": "", - }); - const home = join(String(dir), "home"); - await using proc = Bun.spawn({ - cmd: [bunExe(), "pm", "bin"], - cwd: String(dir), - // `bun pm bin` reads the `.npmrc` of the project, and no other: `home` has none. - env: { - ...bunEnv, - HOME: home, - USERPROFILE: home, - XDG_CONFIG_HOME: home, - NPM_CONFIG_USERCONFIG: undefined, - npm_config_userconfig: undefined, - }, - stdout: "pipe", - stderr: "pipe", - timeout: 30_000, - killSignal: "SIGKILL", - }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]); - // CPU time, in seconds. Wall-clock time means little on a busy machine. - expect(Number(proc.resourceUsage()!.cpuTime.total) / 1e6).toBeLessThan(isDebug || isASAN ? 4 : 1); - expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); - }, - 60_000, - ); + ])("%s", async (_, npmrc) => { + using dir = tempDir("ini-large-npmrc", { + ".npmrc": npmrc(), + "package.json": JSON.stringify({ name: "x", version: "1.0.0" }), + "home/.gitkeep": "", + }); + const home = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "bin"], + cwd: String(dir), + // `bun pm bin` reads the `.npmrc` of the project, and no other: `home` has none. + env: { + ...bunEnv, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: home, + NPM_CONFIG_USERCONFIG: undefined, + npm_config_userconfig: undefined, + }, + stdout: "pipe", + stderr: "pipe", + timeout: 30_000, + killSignal: "SIGKILL", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]); + // CPU time, in seconds. Wall-clock time means little on a busy machine. + expect(Number(proc.resourceUsage()!.cpuTime.total) / 1e6).toBeLessThan(isDebug || isASAN ? 4 : 1); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + }); }); const wtf = {