Compare commits

..

1 Commits

Author SHA1 Message Date
Claude Bot
307f8b3a40 fix(install): use normalized url_hash for cache folder naming
The cache folder name for npm packages with custom registries was using
`String.Builder.stringHash(scope.url.href)` to generate the hash suffix,
while `scope.url_hash` (used for manifest cache keys) normalizes trailing
slashes via `withoutTrailingSlash()`. This inconsistency meant that the
same registry URL with/without a trailing slash could produce different
cache folder names, causing unnecessary re-downloads when both a global
~/.npmrc and a project-local .npmrc were present (since the two-pass
loading could result in slightly different URL normalization).

Fix: use `scope.url_hash` directly, which already normalizes trailing
slashes, ensuring cache folder names are stable and consistent with
manifest cache keys.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-04 02:23:33 +00:00
6 changed files with 80 additions and 87 deletions

View File

@@ -186,9 +186,15 @@ pub const PathWatcher = struct {
.manager = manager,
});
// uv_fs_event_init on Windows unconditionally returns 0 (vendor/libuv/src/win/fs-event.c).
// bun.assert evaluates its argument before the inline early-return, so this runs in release too.
bun.assert(uv.uv_fs_event_init(manager.vm.uvLoop(), &this.handle) == .zero);
errdefer {
_ = manager.watchers.swapRemove(event_path);
this.manager = null;
this.deinit();
}
if (uv.uv_fs_event_init(manager.vm.uvLoop(), &this.handle).toError(.watch)) |err| {
return .{ .err = err };
}
this.handle.data = this;
// UV_FS_EVENT_RECURSIVE only works for Windows and OSX
@@ -198,12 +204,6 @@ pub const PathWatcher = struct {
event_path.ptr,
if (recursive) uv.UV_FS_EVENT_RECURSIVE else 0,
).toError(.watch)) |err| {
// `errdefer` doesn't fire on `return .{ .err = ... }` (that's a successful return of a
// Maybe(T), not an error-union return). Clean up the map entry and the half-initialized
// watcher inline. See #26254.
_ = manager.watchers.swapRemove(event_path);
this.manager = null; // prevent deinit() from re-entering unregisterWatcher
this.deinit();
return .{ .err = err };
}
// we handle this in node_fs_watcher

View File

@@ -70,6 +70,8 @@ pub const DeclarationBlock = struct {
.declarations = &declarations,
.options = options,
};
errdefer decl_parser.deinit();
var parser = css.RuleBodyParser(PropertyDeclarationParser).new(input, &decl_parser);
while (parser.next()) |res| {
@@ -78,10 +80,6 @@ pub const DeclarationBlock = struct {
options.warn(e);
continue;
}
// errdefer doesn't fire on `return .{ .err = ... }` — Result(T) is a tagged
// union, not an error union. Free any declarations accumulated so far.
css.deepDeinit(css.Property, input.allocator(), &declarations);
css.deepDeinit(css.Property, input.allocator(), &important_declarations);
return .{ .err = e };
}
}

View File

@@ -951,23 +951,19 @@ pub const UnresolvedColor = union(enum) {
options: *const css.ParserOptions,
parser: *ComponentParser,
pub fn parsefn(this: *@This(), input2: *css.Parser) Result(UnresolvedColor) {
// errdefer doesn't fire on `return .{ .err = ... }` — Result(T) is a
// tagged union, not an error union. Clean up `light` inline.
var light = switch (input2.parseUntilBefore(css.Delimiters{ .comma = true }, TokenList, this, @This().parsefn2)) {
const light = switch (input2.parseUntilBefore(css.Delimiters{ .comma = true }, TokenList, this, @This().parsefn2)) {
.result => |vv| vv,
.err => |e| return .{ .err = e },
};
if (input2.expectComma().asErr()) |e| {
light.deinit(input2.allocator());
return .{ .err = e };
}
// TODO: fix this
errdefer light.deinit();
if (input2.expectComma().asErr()) |e| return .{ .err = e };
const dark = switch (TokenListFns.parse(input2, this.options, 0)) {
.result => |vv| vv,
.err => |e| {
light.deinit(input2.allocator());
return .{ .err = e };
},
.err => |e| return .{ .err = e },
};
// TODO: fix this
errdefer dark.deinit();
return .{ .result = UnresolvedColor{
.light_dark = .{
.light = light,

View File

@@ -251,7 +251,7 @@ pub fn cachedNPMPackageFolderNamePrint(this: *const PackageManager, buf: []u8, n
const visible_hostname = scope.url.hostname[0..@min(scope.url.hostname.len, 12)];
end = std.fmt.bufPrint(available, "@@{s}__{f}{f}{f}", .{
visible_hostname,
bun.fmt.hexIntLower(String.Builder.stringHash(scope.url.href)),
bun.fmt.hexIntLower(scope.url_hash),
CacheVersion.Formatter{ .version_number = CacheVersion.current },
PatchHashFmt{ .hash = patch_hash },
}) catch unreachable;

View File

@@ -493,4 +493,64 @@ registry=https://somehost.com/org1/npm/registry/
// Should be empty since there's no matching token for /org1/npm/registry/
expect(result.default_registry_token).toBe("");
});
it("second install with both global and local npmrc does not re-fetch packages", async () => {
// Regression test: when both global ~/.npmrc sets registry and a local .npmrc
// exists, a second `bun install` should not make unnecessary network requests
// for packages already installed in node_modules with a valid lockfile.
const { packageDir, packageJson } = await registry.createTestDir();
await Bun.$`rm -rf ${packageDir}/bunfig.toml`;
const homeDir = `${packageDir}/home_dir`;
await Bun.$`mkdir -p ${homeDir}`;
// Global .npmrc: sets default registry
const homeIni = /* ini */ `
registry=http://localhost:${registry.port}/
`;
await Bun.$`echo ${homeIni} > ${homeDir}/.npmrc`;
// Local .npmrc: sets scoped registry on the same host
const localIni = /* ini */ `
@types:registry=http://localhost:${registry.port}/
`;
await Bun.$`echo ${localIni} > ${packageDir}/.npmrc`;
await Bun.$`echo ${JSON.stringify({
name: "foo",
dependencies: {
"no-deps": "1.0.0",
"@types/no-deps": "1.0.0",
},
})} > package.json`.cwd(packageDir);
const installEnv = {
...env,
XDG_CONFIG_HOME: `${homeDir}`,
};
// First install - should download packages
await Bun.$`${bunExe()} install`.env(installEnv).cwd(packageDir).throws(true);
// Verify node_modules and lockfile exist
expect(await Bun.file(join(packageDir, "node_modules", "no-deps", "package.json")).exists()).toBeTrue();
expect(await Bun.file(join(packageDir, "bun.lock")).exists()).toBeTrue();
// Second install - should not re-download anything
await using result2 = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: packageDir,
env: installEnv,
stdout: "pipe",
stderr: "pipe",
});
const [out2, err2, exitCode2] = await Promise.all([result2.stdout.text(), result2.stderr.text(), result2.exited]);
expect(stderrForInstall(err2)).not.toContain("error:");
// Second install should show "no changes" - not re-install packages
expect(out2).not.toContain("packages installed");
expect(exitCode2).toBe(0);
});
});

View File

@@ -1,5 +1,5 @@
import { pathToFileURL } from "bun";
import { bunEnv, bunExe, bunRun, bunRunAsScript, isWindows, tempDir, tempDirWithFiles } from "harness";
import { bunRun, bunRunAsScript, isWindows, tempDirWithFiles } from "harness";
import fs, { FSWatcher } from "node:fs";
import path from "path";
@@ -725,64 +725,3 @@ describe("immediately closing", () => {
for (let i = 0; i < 100; i++) fs.watch(testDir, { persistent: false, recursive: false }).close();
});
});
// On Windows, if fs.watch() fails after getOrPut() inserts into the internal path->watcher
// map (e.g. uv_fs_event_start fails on a dangling junction, an ACL-protected dir, or a
// directory deleted mid-watch), an errdefer that was silently broken by a !*T -> Maybe(*T)
// refactor left the entry in place with a dangling key and an uninitialized value. The next
// fs.watch() on the same path collided with the poisoned entry, returned the garbage value
// as a *PathWatcher, and segfaulted at 0xFFFFFFFFFFFFFFFF calling .handlers.put() on it.
//
// https://github.com/oven-sh/bun/issues/26254
// https://github.com/oven-sh/bun/issues/20203
// https://github.com/oven-sh/bun/issues/19635
//
// Must run in a subprocess: on an unpatched build this segfaults the whole runtime.
test.skipIf(!isWindows)("retrying a failed fs.watch does not crash (windows)", async () => {
using dir = tempDir("fswatch-retry-failed", { "index.js": "" });
const base = String(dir);
const fixture = /* js */ `
const { mkdirSync, rmdirSync, symlinkSync, watch } = require("node:fs");
const { join } = require("node:path");
const base = ${JSON.stringify(base)};
const target = join(base, "target");
const link = join(base, "link");
mkdirSync(target);
symlinkSync(target, link, "junction"); // junctions need no admin rights on Windows
rmdirSync(target); // junction now dangles
// Call 1: readlink(link) SUCCEEDS (returns the vanished target path into
// a stack-local buffer), then uv_fs_event_start(target) fails ENOENT.
// On unpatched builds: map entry left with dangling key + uninit value.
try { watch(link); throw new Error("expected first watch to fail"); }
catch (e) { if (e.code !== "ENOENT") throw e; }
// Call 2: identical stack frame layout -> identical outbuf address ->
// identical key slice -> getOrPut returns found_existing=true ->
// returns uninitialized value as a *PathWatcher -> segfault on unpatched builds.
// Correct behaviour: throw ENOENT again.
try { watch(link); throw new Error("expected second watch to fail"); }
catch (e) { if (e.code !== "ENOENT") throw e; }
// Call 3: a valid watch must still work (map must not be corrupted).
watch(base).close();
console.log("OK");
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", fixture],
cwd: base,
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.trim()).toBe("OK");
expect(exitCode).toBe(0); // unpatched: exitCode is 3 (Windows segfault)
});