authorgravatar for mlugg@mlugg.co.ukMatthew Lugg <mlugg@mlugg.co.uk> 2025-04-24 01:17:32+01:00
committergravatar for alex@alexrp.comAlex Rønne Petersen <alex@alexrp.com> 2025-04-27 14:08:21+02:00
log160f2dabed9a465075ddf9c46afadca96e415468
tree02ef3e3a693aa0a927f3459ef3277f9dcd1cf287
parent53f298cffa5bbec4c4faa854afb56a5f2e23de48
signaturebadge-check Signed by SSH key SHA256:7B/LJ7bpR1eX8aCXSr4mtd5M45VMPKcx9zY8e95b5QM

std.Build.Cache: fix several bugs

Aside from adding comments to document the logic in `Cache.Manifest.hit` better, this commit fixes two serious bugs. The first, spotted by Andrew, is that when upgrading from a shared to an exclusive lock on the manifest file, we do not seek it back to the start. This is a simple fix. The second is more subtle, and has to do with the computation of file digests. Broadly speaking, the goal of the main loop in `hit` is to iterate the files listed in the manifest file, and check if they've changed, based on stat and a file hash. While doing this, the `bin_digest` field of `std.Build.Cache.File`, which is initially `undefined`, is populated for all files, either straight from the manifest (if the stat matches) or recomputed from the file on-disk. This file digest is then used to update `man.hash.hasher`, which is building the final hash used as, for instance, the output directory name when the compiler emits into the cache directory. When `hit` returns a cache miss, it is expected that `man.hash.hasher` includes the digests of all "initial files"; that is, those which have been already added with e.g. `addFilePath`, but not those which will later be added with `addFilePost` (even though the manifest file has told us about some such files). Previously, `hit` was using the `unhit` function to do this in a few cases. However, this is incorrect, because `hit` assumes that all files already have their `bin_digest` field populated; this function is only valid to call *after* `hit` returns. Instead, we need to actually compute the hashes which haven't yet been populated. Even if this logic has been working, there was still a bug here, because we called `unhit` when upgrading from a shared to an exclusive lock, writing the (potentially `undefined`) file digests, but the loop itself writes the file digests *again*! All in all, the hashing logic here was actually incredibly broken. I've taken the opportunity to restructure this section of the code into what I think is a more readable format. A new function, `hitWithCurrentLock`, uses the open manifest file to try and find a cache hit. It returns a tagged union which, in the miss case, tells the caller (`hit`) how many files already have their hash populated. This avoids redundant work recomputing the same hash multiple times in situations where the lock needs upgrading. This also eliminates the outer loop from `hit`, which was a little confusing because it iterated no more than twice! The bugs fixed here could manifest in several different ways depending on how contended file locks were satisfied. Most notably, on a cache miss, the Zig compiler might have written the compilation output to the incorrect directory (because it incorrectly constructed a hash using `undefined` or repeated file digests), resulting in all future hits on this manifest causing `error.FileNotFound`. This is #23110. I have been able to reproduce #23110 on `master`, and have not been able to after this commit, so I am relatively sure this commit resolves that issue. Resolves: #23110

3 files changed, 233 insertions(+), 165 deletions(-)

lib/std/Build/Cache.zig+231-163
......@@ -337,6 +337,7 @@ pub const Manifest = struct {
337337 manifest_create: fs.File.OpenError,
338338 manifest_read: fs.File.ReadError,
339339 manifest_lock: fs.File.LockError,
340 manifest_seek: fs.File.SeekError,
340341 file_open: FileOp,
341342 file_stat: FileOp,
342343 file_read: FileOp,
......@@ -488,7 +489,6 @@ pub const Manifest = struct {
488489 /// option, one may call `toOwnedLock` to obtain a smaller object which can represent
489490 /// the lock. `deinit` is safe to call whether or not `toOwnedLock` has been called.
490491 pub fn hit(self: *Manifest) HitError!bool {
491 const gpa = self.cache.gpa;
492492 assert(self.manifest_file == null);
493493
494494 self.diagnostic = .none;
......@@ -501,12 +501,12 @@ pub const Manifest = struct {
501501
502502 self.hex_digest = binToHex(bin_digest);
503503
504 self.hash.hasher = hasher_init;
505 self.hash.hasher.update(&bin_digest);
506
507504 @memcpy(manifest_file_path[0..self.hex_digest.len], &self.hex_digest);
508505 manifest_file_path[hex_digest_len..][0..ext.len].* = ext.*;
509506
507 // We'll try to open the cache with an exclusive lock, but if that would block
508 // and `want_shared_lock` is set, a shared lock might be sufficient, so we'll
509 // open with a shared lock instead.
510510 while (true) {
511511 if (self.cache.manifest_dir.createFile(&manifest_file_path, .{
512512 .read = true,
......@@ -575,26 +575,71 @@ pub const Manifest = struct {
575575 self.want_refresh_timestamp = true;
576576
577577 const input_file_count = self.files.entries.len;
578 while (true) : (self.unhit(bin_digest, input_file_count)) {
579 const file_contents = self.manifest_file.?.reader().readAllAlloc(gpa, manifest_file_size_max) catch |err| switch (err) {
580 error.OutOfMemory => return error.OutOfMemory,
581 error.StreamTooLong => return error.OutOfMemory,
582 else => |e| {
583 self.diagnostic = .{ .manifest_read = e };
578
579 // We're going to construct a second hash. Its input will begin with the digest we've
580 // already computed (`bin_digest`), and then it'll have the digests of each input file,
581 // including "post" files (see `addFilePost`). If this is a hit, we learn the set of "post"
582 // files from the manifest on disk. If this is a miss, we'll learn those from future calls
583 // to `addFilePost` etc. As such, the state of `self.hash.hasher` after this function
584 // depends on whether this is a hit or a miss.
585 //
586 // If we return `true` indicating a cache hit, then `self.hash.hasher` must already include
587 // the digests of the "post" files, so the caller can call `final`. Otherwise, on a cache
588 // miss, `self.hash.hasher` will include the digests of all non-"post" files -- that is,
589 // the ones we've already been told about. The rest will be discovered through calls to
590 // `addFilePost` etc, which will update the hasher. After all files are added, the user can
591 // use `final`, and will at some point `writeManifest` the file list to disk.
592
593 self.hash.hasher = hasher_init;
594 self.hash.hasher.update(&bin_digest);
595
596 hit: {
597 const file_digests_populated: usize = digests: {
598 switch (try self.hitWithCurrentLock()) {
599 .hit => break :hit,
600 .miss => |m| if (!try self.upgradeToExclusiveLock()) {
601 break :digests m.file_digests_populated;
602 },
603 }
604 // We've just had a miss with the shared lock, and upgraded to an exclusive lock. Someone
605 // else might have modified the digest, so we need to check again before deciding to miss.
606 // Before trying again, we must reset `self.hash.hasher` and `self.files`.
607 // This is basically just the first half of `unhit`.
608 self.hash.hasher = hasher_init;
609 self.hash.hasher.update(&bin_digest);
610 while (self.files.count() != input_file_count) {
611 var file = self.files.pop().?;
612 file.key.deinit(self.cache.gpa);
613 }
614 // Also, seek the file back to the start.
615 self.manifest_file.?.seekTo(0) catch |err| {
616 self.diagnostic = .{ .manifest_seek = err };
584617 return error.CacheCheckFailed;
585 },
618 };
619
620 switch (try self.hitWithCurrentLock()) {
621 .hit => break :hit,
622 .miss => |m| break :digests m.file_digests_populated,
623 }
586624 };
587 defer gpa.free(file_contents);
588
589 var any_file_changed = false;
590 var line_iter = mem.tokenizeScalar(u8, file_contents, '\n');
591 var idx: usize = 0;
592 if (if (line_iter.next()) |line| !std.mem.eql(u8, line, manifest_header) else true) {
593 if (try self.upgradeToExclusiveLock()) continue;
594 self.manifest_dirty = true;
595 while (idx < input_file_count) : (idx += 1) {
596 const ch_file = &self.files.keys()[idx];
597 self.populateFileHash(ch_file) catch |err| {
625
626 // This is a guaranteed cache miss. We're almost ready to return `false`, but there's a
627 // little bookkeeping to do first. The first `file_digests_populated` entries in `files`
628 // have their `bin_digest` populated; there may be some left in `input_file_count` which
629 // we'll need to populate ourselves. Other than that, this is basically `unhit`.
630 self.manifest_dirty = true;
631 self.hash.hasher = hasher_init;
632 self.hash.hasher.update(&bin_digest);
633 while (self.files.count() != input_file_count) {
634 var file = self.files.pop().?;
635 file.key.deinit(self.cache.gpa);
636 }
637 for (self.files.keys(), 0..) |*file, idx| {
638 if (idx < file_digests_populated) {
639 // `bin_digest` is already populated by `hitWithCurrentLock`, so we can use it directly.
640 self.hash.hasher.update(&file.bin_digest);
641 } else {
642 self.populateFileHash(file) catch |err| {
598643 self.diagnostic = .{ .file_hash = .{
599644 .file_index = idx,
600645 .err = err,
......@@ -602,172 +647,195 @@ pub const Manifest = struct {
602647 return error.CacheCheckFailed;
603648 };
604649 }
605 return false;
606650 }
607 while (line_iter.next()) |line| {
608 defer idx += 1;
609
610 var iter = mem.tokenizeScalar(u8, line, ' ');
611 const size = iter.next() orelse return error.InvalidFormat;
612 const inode = iter.next() orelse return error.InvalidFormat;
613 const mtime_nsec_str = iter.next() orelse return error.InvalidFormat;
614 const digest_str = iter.next() orelse return error.InvalidFormat;
615 const prefix_str = iter.next() orelse return error.InvalidFormat;
616 const file_path = iter.rest();
617
618 const stat_size = fmt.parseInt(u64, size, 10) catch return error.InvalidFormat;
619 const stat_inode = fmt.parseInt(fs.File.INode, inode, 10) catch return error.InvalidFormat;
620 const stat_mtime = fmt.parseInt(i64, mtime_nsec_str, 10) catch return error.InvalidFormat;
621 const file_bin_digest = b: {
622 if (digest_str.len != hex_digest_len) return error.InvalidFormat;
623 var bd: BinDigest = undefined;
624 _ = fmt.hexToBytes(&bd, digest_str) catch return error.InvalidFormat;
625 break :b bd;
626 };
651 return false;
652 }
653
654 if (self.want_shared_lock) {
655 self.downgradeToSharedLock() catch |err| {
656 self.diagnostic = .{ .manifest_lock = err };
657 return error.CacheCheckFailed;
658 };
659 }
627660
628 const prefix = fmt.parseInt(u8, prefix_str, 10) catch return error.InvalidFormat;
629 if (prefix >= self.cache.prefixes_len) return error.InvalidFormat;
661 return true;
662 }
630663
631 if (file_path.len == 0) return error.InvalidFormat;
664 /// Assumes that `self.hash.hasher` has been updated only with the original digest, that
665 /// `self.files` contains only the original input files, and that `self.manifest_file.?` is
666 /// seeked to the start of the file.
667 fn hitWithCurrentLock(self: *Manifest) HitError!union(enum) {
668 hit,
669 miss: struct {
670 file_digests_populated: usize,
671 },
672 } {
673 const gpa = self.cache.gpa;
674 const input_file_count = self.files.entries.len;
632675
633 const cache_hash_file = f: {
634 const prefixed_path: PrefixedPath = .{
635 .prefix = prefix,
636 .sub_path = file_path, // expires with file_contents
637 };
638 if (idx < input_file_count) {
639 const file = &self.files.keys()[idx];
640 if (!file.prefixed_path.eql(prefixed_path))
641 return error.InvalidFormat;
676 const file_contents = self.manifest_file.?.reader().readAllAlloc(gpa, manifest_file_size_max) catch |err| switch (err) {
677 error.OutOfMemory => return error.OutOfMemory,
678 error.StreamTooLong => return error.OutOfMemory,
679 else => |e| {
680 self.diagnostic = .{ .manifest_read = e };
681 return error.CacheCheckFailed;
682 },
683 };
684 defer gpa.free(file_contents);
685
686 var any_file_changed = false;
687 var line_iter = mem.tokenizeScalar(u8, file_contents, '\n');
688 var idx: usize = 0;
689 const header_valid = valid: {
690 const line = line_iter.next() orelse break :valid false;
691 break :valid std.mem.eql(u8, line, manifest_header);
692 };
693 if (!header_valid) {
694 return .{ .miss = .{ .file_digests_populated = 0 } };
695 }
696 while (line_iter.next()) |line| {
697 defer idx += 1;
698
699 var iter = mem.tokenizeScalar(u8, line, ' ');
700 const size = iter.next() orelse return error.InvalidFormat;
701 const inode = iter.next() orelse return error.InvalidFormat;
702 const mtime_nsec_str = iter.next() orelse return error.InvalidFormat;
703 const digest_str = iter.next() orelse return error.InvalidFormat;
704 const prefix_str = iter.next() orelse return error.InvalidFormat;
705 const file_path = iter.rest();
706
707 const stat_size = fmt.parseInt(u64, size, 10) catch return error.InvalidFormat;
708 const stat_inode = fmt.parseInt(fs.File.INode, inode, 10) catch return error.InvalidFormat;
709 const stat_mtime = fmt.parseInt(i64, mtime_nsec_str, 10) catch return error.InvalidFormat;
710 const file_bin_digest = b: {
711 if (digest_str.len != hex_digest_len) return error.InvalidFormat;
712 var bd: BinDigest = undefined;
713 _ = fmt.hexToBytes(&bd, digest_str) catch return error.InvalidFormat;
714 break :b bd;
715 };
716
717 const prefix = fmt.parseInt(u8, prefix_str, 10) catch return error.InvalidFormat;
718 if (prefix >= self.cache.prefixes_len) return error.InvalidFormat;
719
720 if (file_path.len == 0) return error.InvalidFormat;
642721
643 file.stat = .{
722 const cache_hash_file = f: {
723 const prefixed_path: PrefixedPath = .{
724 .prefix = prefix,
725 .sub_path = file_path, // expires with file_contents
726 };
727 if (idx < input_file_count) {
728 const file = &self.files.keys()[idx];
729 if (!file.prefixed_path.eql(prefixed_path))
730 return error.InvalidFormat;
731
732 file.stat = .{
733 .size = stat_size,
734 .inode = stat_inode,
735 .mtime = stat_mtime,
736 };
737 file.bin_digest = file_bin_digest;
738 break :f file;
739 }
740 const gop = try self.files.getOrPutAdapted(gpa, prefixed_path, FilesAdapter{});
741 errdefer _ = self.files.pop();
742 if (!gop.found_existing) {
743 gop.key_ptr.* = .{
744 .prefixed_path = .{
745 .prefix = prefix,
746 .sub_path = try gpa.dupe(u8, file_path),
747 },
748 .contents = null,
749 .max_file_size = null,
750 .handle = null,
751 .stat = .{
644752 .size = stat_size,
645753 .inode = stat_inode,
646754 .mtime = stat_mtime,
647 };
648 file.bin_digest = file_bin_digest;
649 break :f file;
650 }
651 const gop = try self.files.getOrPutAdapted(gpa, prefixed_path, FilesAdapter{});
652 errdefer _ = self.files.pop();
653 if (!gop.found_existing) {
654 gop.key_ptr.* = .{
655 .prefixed_path = .{
656 .prefix = prefix,
657 .sub_path = try gpa.dupe(u8, file_path),
658 },
659 .contents = null,
660 .max_file_size = null,
661 .handle = null,
662 .stat = .{
663 .size = stat_size,
664 .inode = stat_inode,
665 .mtime = stat_mtime,
666 },
667 .bin_digest = file_bin_digest,
668 };
669 }
670 break :f gop.key_ptr;
671 };
672
673 const pp = cache_hash_file.prefixed_path;
674 const dir = self.cache.prefixes()[pp.prefix].handle;
675 const this_file = dir.openFile(pp.sub_path, .{ .mode = .read_only }) catch |err| switch (err) {
676 error.FileNotFound => {
677 if (try self.upgradeToExclusiveLock()) continue;
678 return false;
679 },
680 else => |e| {
681 self.diagnostic = .{ .file_open = .{
682 .file_index = idx,
683 .err = e,
684 } };
685 return error.CacheCheckFailed;
686 },
687 };
688 defer this_file.close();
755 },
756 .bin_digest = file_bin_digest,
757 };
758 }
759 break :f gop.key_ptr;
760 };
689761
690 const actual_stat = this_file.stat() catch |err| {
691 self.diagnostic = .{ .file_stat = .{
762 const pp = cache_hash_file.prefixed_path;
763 const dir = self.cache.prefixes()[pp.prefix].handle;
764 const this_file = dir.openFile(pp.sub_path, .{ .mode = .read_only }) catch |err| switch (err) {
765 error.FileNotFound => {
766 // Every digest before this one has been populated successfully.
767 return .{ .miss = .{ .file_digests_populated = idx } };
768 },
769 else => |e| {
770 self.diagnostic = .{ .file_open = .{
692771 .file_index = idx,
693 .err = err,
772 .err = e,
694773 } };
695774 return error.CacheCheckFailed;
696 };
697 const size_match = actual_stat.size == cache_hash_file.stat.size;
698 const mtime_match = actual_stat.mtime == cache_hash_file.stat.mtime;
699 const inode_match = actual_stat.inode == cache_hash_file.stat.inode;
700
701 if (!size_match or !mtime_match or !inode_match) {
702 self.manifest_dirty = true;
703
704 cache_hash_file.stat = .{
705 .size = actual_stat.size,
706 .mtime = actual_stat.mtime,
707 .inode = actual_stat.inode,
708 };
709
710 if (self.isProblematicTimestamp(cache_hash_file.stat.mtime)) {
711 // The actual file has an unreliable timestamp, force it to be hashed
712 cache_hash_file.stat.mtime = 0;
713 cache_hash_file.stat.inode = 0;
714 }
715
716 var actual_digest: BinDigest = undefined;
717 hashFile(this_file, &actual_digest) catch |err| {
718 self.diagnostic = .{ .file_read = .{
719 .file_index = idx,
720 .err = err,
721 } };
722 return error.CacheCheckFailed;
723 };
775 },
776 };
777 defer this_file.close();
724778
725 if (!mem.eql(u8, &cache_hash_file.bin_digest, &actual_digest)) {
726 cache_hash_file.bin_digest = actual_digest;
727 // keep going until we have the input file digests
728 any_file_changed = true;
729 }
730 }
779 const actual_stat = this_file.stat() catch |err| {
780 self.diagnostic = .{ .file_stat = .{
781 .file_index = idx,
782 .err = err,
783 } };
784 return error.CacheCheckFailed;
785 };
786 const size_match = actual_stat.size == cache_hash_file.stat.size;
787 const mtime_match = actual_stat.mtime == cache_hash_file.stat.mtime;
788 const inode_match = actual_stat.inode == cache_hash_file.stat.inode;
789
790 if (!size_match or !mtime_match or !inode_match) {
791 cache_hash_file.stat = .{
792 .size = actual_stat.size,
793 .mtime = actual_stat.mtime,
794 .inode = actual_stat.inode,
795 };
731796
732 if (!any_file_changed) {
733 self.hash.hasher.update(&cache_hash_file.bin_digest);
797 if (self.isProblematicTimestamp(cache_hash_file.stat.mtime)) {
798 // The actual file has an unreliable timestamp, force it to be hashed
799 cache_hash_file.stat.mtime = 0;
800 cache_hash_file.stat.inode = 0;
734801 }
735 }
736802
737 if (any_file_changed) {
738 if (try self.upgradeToExclusiveLock()) continue;
739 // cache miss
740 // keep the manifest file open
741 self.unhit(bin_digest, input_file_count);
742 return false;
743 }
803 var actual_digest: BinDigest = undefined;
804 hashFile(this_file, &actual_digest) catch |err| {
805 self.diagnostic = .{ .file_read = .{
806 .file_index = idx,
807 .err = err,
808 } };
809 return error.CacheCheckFailed;
810 };
744811
745 if (idx < input_file_count) {
746 if (try self.upgradeToExclusiveLock()) continue;
747 self.manifest_dirty = true;
748 while (idx < input_file_count) : (idx += 1) {
749 self.populateFileHash(&self.files.keys()[idx]) catch |err| {
750 self.diagnostic = .{ .file_hash = .{
751 .file_index = idx,
752 .err = err,
753 } };
754 return error.CacheCheckFailed;
755 };
812 if (!mem.eql(u8, &cache_hash_file.bin_digest, &actual_digest)) {
813 cache_hash_file.bin_digest = actual_digest;
814 // keep going until we have the input file digests
815 any_file_changed = true;
756816 }
757 return false;
758817 }
759818
760 if (self.want_shared_lock) {
761 self.downgradeToSharedLock() catch |err| {
762 self.diagnostic = .{ .manifest_lock = err };
763 return error.CacheCheckFailed;
764 };
819 if (!any_file_changed) {
820 self.hash.hasher.update(&cache_hash_file.bin_digest);
765821 }
822 }
766823
767 return true;
824 // If the manifest was somehow missing one of our input files, or if any file hash has changed,
825 // then this is a cache miss. However, we have successfully populated some or all of the file
826 // digests.
827 if (any_file_changed or idx < input_file_count) {
828 return .{ .miss = .{ .file_digests_populated = idx } };
768829 }
830
831 return .hit;
769832 }
770833
834 /// Reset `self.hash.hasher` to the state it should be in after `hit` returns `false`.
835 /// The hasher contains the original input digest, and all original input file digests (i.e.
836 /// not including post files).
837 /// Assumes that `bin_digest` is populated for all files up to `input_file_count`. As such,
838 /// this is not necessarily safe to call within `hit`.
771839 pub fn unhit(self: *Manifest, bin_digest: BinDigest, input_file_count: usize) void {
772840 // Reset the hash.
773841 self.hash.hasher = hasher_init;
lib/std/Build/Step.zig+1-1
......@@ -759,7 +759,7 @@ fn failWithCacheError(s: *Step, man: *const Build.Cache.Manifest, err: Build.Cac
759759 switch (err) {
760760 error.CacheCheckFailed => switch (man.diagnostic) {
761761 .none => unreachable,
762 .manifest_create, .manifest_read, .manifest_lock => |e| return s.fail("failed to check cache: {s} {s}", .{
762 .manifest_create, .manifest_read, .manifest_lock, .manifest_seek => |e| return s.fail("failed to check cache: {s} {s}", .{
763763 @tagName(man.diagnostic), @errorName(e),
764764 }),
765765 .file_open, .file_stat, .file_read, .file_hash => |op| {
src/Compilation.zig+1-1
......@@ -2129,7 +2129,7 @@ pub fn update(comp: *Compilation, main_progress_node: std.Progress.Node) !void {
21292129 const is_hit = man.hit() catch |err| switch (err) {
21302130 error.CacheCheckFailed => switch (man.diagnostic) {
21312131 .none => unreachable,
2132 .manifest_create, .manifest_read, .manifest_lock => |e| return comp.setMiscFailure(
2132 .manifest_create, .manifest_read, .manifest_lock, .manifest_seek => |e| return comp.setMiscFailure(
21332133 .check_whole_cache,
21342134 "failed to check cache: {s} {s}",
21352135 .{ @tagName(man.diagnostic), @errorName(e) },