Compare commits

...

3 Commits

Author SHA1 Message Date
Jarred Sumner
0744705ce9 Merge branch 'main' into claude/fix-lockfile-determinism 2026-02-20 23:41:12 -08:00
Jarred Sumner
4575e99e5b Merge branch 'main' into claude/fix-lockfile-determinism 2026-02-20 22:51:16 -08:00
Claude Bot
c7d18ed026 fix: sort maps before lockfile serialization for deterministic output
Sort overrides, patched dependencies, catalogs, workspace versions,
workspace paths, and trusted dependencies before writing to both binary
(bun.lockb) and text (bun.lock) lockfile formats. Previously, these
maps were written in insertion order which depends on network response
timing, causing non-deterministic lockfile output across runs.

Fixes #16527

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-20 05:29:18 +00:00
3 changed files with 186 additions and 6 deletions

View File

@@ -254,14 +254,26 @@ pub const Stringifier = struct {
);
if (found_trusted_dependencies.count() > 0) {
// Collect and sort trusted dependency names for deterministic output
var trusted_dep_names = try std.ArrayListUnmanaged(String).initCapacity(allocator, found_trusted_dependencies.count());
defer trusted_dep_names.deinit(allocator);
var td_values_iter = found_trusted_dependencies.valueIterator();
while (td_values_iter.next()) |dep_name| {
trusted_dep_names.appendAssumeCapacity(dep_name.*);
}
std.sort.pdq(String, trusted_dep_names.items, buf, struct {
pub fn lessThan(string_buf: string, l: String, r: String) bool {
return l.order(&r, string_buf, string_buf) == .lt;
}
}.lessThan);
try writeIndent(writer, indent);
try writer.writeAll(
\\"trustedDependencies": [
\\
);
indent.* += 1;
var values_iter = found_trusted_dependencies.valueIterator();
while (values_iter.next()) |dep_name| {
for (trusted_dep_names.items) |dep_name| {
try writeIndent(writer, indent);
try writer.print(
\\"{s}",
@@ -277,15 +289,28 @@ pub const Stringifier = struct {
}
if (found_patched_dependencies.count() > 0) {
// Collect and sort patched dependencies by name for deterministic output
const PatchEntry = struct { string, String };
var patched_entries = try std.ArrayListUnmanaged(PatchEntry).initCapacity(allocator, found_patched_dependencies.count());
defer patched_entries.deinit(allocator);
var pd_values_iter = found_patched_dependencies.valueIterator();
while (pd_values_iter.next()) |value| {
patched_entries.appendAssumeCapacity(value.*);
}
std.sort.pdq(PatchEntry, patched_entries.items, {}, struct {
pub fn lessThan(_: void, l: PatchEntry, r: PatchEntry) bool {
return strings.order(l[0], r[0]) == .lt;
}
}.lessThan);
try writeIndent(writer, indent);
try writer.writeAll(
\\"patchedDependencies": {
\\
);
indent.* += 1;
var values_iter = found_patched_dependencies.valueIterator();
while (values_iter.next()) |value| {
const name_and_version, const patch_path = value.*;
for (patched_entries.items) |entry| {
const name_and_version, const patch_path = entry;
try writeIndent(writer, indent);
try writer.print(
\\{f}: {f},

View File

@@ -72,6 +72,9 @@ pub fn save(this: *Lockfile, options: *const PackageManager.Options, bytes: *std
// < Bun v1.0.4 stopped right here when reading the lockfile
// So we add an extra 8 byte tag to say "hey, there's more data here"
if (this.workspace_versions.count() > 0) {
this.workspace_versions.sort(HashSortCtx(PackageNameHash, Semver.Version){ .keys = this.workspace_versions.keys() });
this.workspace_paths.sort(HashSortCtx(PackageNameHash, String){ .keys = this.workspace_paths.keys() });
try writer.writeAll(std.mem.asBytes(&has_workspace_package_ids_tag));
// We need to track the "version" field in "package.json" of workspace member packages
@@ -111,8 +114,10 @@ pub fn save(this: *Lockfile, options: *const PackageManager.Options, bytes: *std
);
}
if (this.trusted_dependencies) |trusted_dependencies| {
if (this.trusted_dependencies) |*trusted_dependencies| {
if (trusted_dependencies.count() > 0) {
trusted_dependencies.sort(HashSortCtx(u32, void){ .keys = trusted_dependencies.keys() });
try writer.writeAll(std.mem.asBytes(&has_trusted_dependencies_tag));
try Lockfile.Buffers.writeArray(
@@ -129,6 +134,8 @@ pub fn save(this: *Lockfile, options: *const PackageManager.Options, bytes: *std
}
if (this.overrides.map.count() > 0) {
this.overrides.sort(this);
try writer.writeAll(std.mem.asBytes(&has_overrides_tag));
try Lockfile.Buffers.writeArray(
@@ -159,6 +166,8 @@ pub fn save(this: *Lockfile, options: *const PackageManager.Options, bytes: *std
if (this.patched_dependencies.entries.len > 0) {
for (this.patched_dependencies.values()) |patched_dep| bun.assert(!patched_dep.patchfile_hash_is_null);
this.patched_dependencies.sort(HashSortCtx(PackageNameAndVersionHash, PatchedDep){ .keys = this.patched_dependencies.keys() });
try writer.writeAll(std.mem.asBytes(&has_patched_dependencies_tag));
try Lockfile.Buffers.writeArray(
@@ -181,6 +190,8 @@ pub fn save(this: *Lockfile, options: *const PackageManager.Options, bytes: *std
}
if (this.catalogs.hasAny()) {
this.catalogs.sort(this);
try writer.writeAll(std.mem.asBytes(&has_catalogs_tag));
try Lockfile.Buffers.writeArray(
@@ -609,6 +620,19 @@ pub fn load(
return res;
}
/// Generic sort context for sorting ArrayHashMaps by their key values.
/// Used to ensure deterministic serialization order for maps keyed by hashes.
fn HashSortCtx(comptime K: type, comptime V: type) type {
_ = V;
return struct {
keys: []const K,
pub fn lessThan(ctx: @This(), a_index: usize, b_index: usize) bool {
return std.math.order(ctx.keys[a_index], ctx.keys[b_index]) == .lt;
}
};
}
const string = []const u8;
const std = @import("std");

View File

@@ -0,0 +1,131 @@
import { expect, test } from "bun:test";
import { readFileSync } from "fs";
import { bunEnv, bunExe, tempDir } from "harness";
import { join } from "path";
// Regression test for https://github.com/oven-sh/bun/issues/16527
// Verifies that lockfile output is deterministic across multiple installs.
test("bun install produces deterministic text lockfile across multiple runs", async () => {
const lockfiles: string[] = [];
for (let i = 0; i < 3; i++) {
using dir = tempDir("lockfile-determinism-", {
"package.json": JSON.stringify({
name: "lockfile-determinism-test",
version: "1.0.0",
workspaces: ["packages/*"],
}),
"packages/pkg-a/package.json": JSON.stringify({
name: "pkg-a",
version: "1.0.0",
dependencies: {
"pkg-c": "workspace:*",
},
}),
"packages/pkg-b/package.json": JSON.stringify({
name: "pkg-b",
version: "1.0.0",
dependencies: {
"pkg-c": "workspace:*",
"pkg-a": "workspace:*",
},
}),
"packages/pkg-c/package.json": JSON.stringify({
name: "pkg-c",
version: "1.0.0",
}),
"packages/pkg-d/package.json": JSON.stringify({
name: "pkg-d",
version: "2.0.0",
dependencies: {
"pkg-a": "workspace:*",
"pkg-b": "workspace:*",
"pkg-c": "workspace:*",
},
}),
});
await using proc = Bun.spawn({
cmd: [bunExe(), "install", "--save-text-lockfile"],
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).not.toContain("error:");
expect(exitCode).toBe(0);
const lockfileContent = readFileSync(join(String(dir), "bun.lock"), "utf-8");
lockfiles.push(lockfileContent);
}
// All lockfiles should be identical
expect(lockfiles[0]).toBe(lockfiles[1]);
expect(lockfiles[0]).toBe(lockfiles[2]);
}, 30_000);
test("bun install produces deterministic binary lockfile across multiple runs", async () => {
const lockfiles: Buffer[] = [];
for (let i = 0; i < 3; i++) {
using dir = tempDir("lockfile-determinism-", {
"bunfig.toml": "[install]\nsaveTextLockfile = false\n",
"package.json": JSON.stringify({
name: "lockfile-determinism-test",
version: "1.0.0",
workspaces: ["packages/*"],
}),
"packages/pkg-a/package.json": JSON.stringify({
name: "pkg-a",
version: "1.0.0",
dependencies: {
"pkg-c": "workspace:*",
},
}),
"packages/pkg-b/package.json": JSON.stringify({
name: "pkg-b",
version: "1.0.0",
dependencies: {
"pkg-c": "workspace:*",
"pkg-a": "workspace:*",
},
}),
"packages/pkg-c/package.json": JSON.stringify({
name: "pkg-c",
version: "1.0.0",
}),
"packages/pkg-d/package.json": JSON.stringify({
name: "pkg-d",
version: "2.0.0",
dependencies: {
"pkg-a": "workspace:*",
"pkg-b": "workspace:*",
"pkg-c": "workspace:*",
},
}),
});
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
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).not.toContain("error:");
expect(exitCode).toBe(0);
const lockfileContent = readFileSync(join(String(dir), "bun.lockb"));
lockfiles.push(lockfileContent);
}
// All binary lockfiles should be identical
expect(Buffer.compare(lockfiles[0], lockfiles[1])).toBe(0);
expect(Buffer.compare(lockfiles[0], lockfiles[2])).toBe(0);
}, 30_000);