Skip to content

Commit 608ab05

Browse files
committed
fs.Dir.deleteTree: Add configurable max_retries to avoid infinite loops
Previously, it was possible for deleteTree to get into a state where it would continually retry deleting the same directory over and over infinitely, since it would think all of the children were successfully deleted but when deleting the directory it would hit `error.DirNotEmpty`. Now, there is a (user-configurable) limit to the number of times it will retry deleting directories, and return `error.TooManyRetries` if the limit is exceeded. Closes #15465
1 parent 5b9e528 commit 608ab05

File tree

9 files changed

+98
-29
lines changed

9 files changed

+98
-29
lines changed

doc/docgen.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ pub fn main() !void {
9999
var toc = try genToc(allocator, &tokenizer);
100100

101101
try fs.cwd().makePath(tmp_dir_name);
102-
defer fs.cwd().deleteTree(tmp_dir_name) catch {};
102+
defer fs.cwd().deleteTree(tmp_dir_name, .{}) catch {};
103103

104104
try genHtml(allocator, &tokenizer, &toc, buffered_writer.writer(), zig_exe, opt_zig_lib_dir, do_code_tests);
105105
try buffered_writer.flush();

lib/std/Build.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -776,7 +776,7 @@ fn makeUninstall(uninstall_step: *Step, prog_node: *std.Progress.Node) anyerror!
776776
if (self.verbose) {
777777
log.info("rm {s}", .{full_path});
778778
}
779-
fs.cwd().deleteTree(full_path) catch {};
779+
fs.cwd().deleteTree(full_path, .{}) catch {};
780780
}
781781

782782
// TODO remove empty directories

lib/std/Build/RemoveDirStep.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ fn make(step: *Step, prog_node: *std.Progress.Node) !void {
2828
const b = step.owner;
2929
const self = @fieldParentPtr(RemoveDirStep, "step", step);
3030

31-
b.build_root.handle.deleteTree(self.dir_path) catch |err| {
31+
b.build_root.handle.deleteTree(self.dir_path, .{}) catch |err| {
3232
if (b.build_root.path) |base| {
3333
return step.fail("unable to recursively delete path '{s}/{s}': {s}", .{
3434
base, self.dir_path, @errorName(err),

lib/std/fs.zig

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2089,6 +2089,10 @@ pub const Dir = struct {
20892089
FileBusy,
20902090
DeviceBusy,
20912091

2092+
/// The number of retries to delete thought-to-be-empty directories
2093+
/// exceeded `max_retries`.
2094+
TooManyRetries,
2095+
20922096
/// One of the path components was not a directory.
20932097
/// This error is unreachable if `sub_path` does not contain a path separator.
20942098
NotDir,
@@ -2101,17 +2105,33 @@ pub const Dir = struct {
21012105
BadPathName,
21022106
} || os.UnexpectedError;
21032107

2108+
pub const DeleteTreeOptions = struct {
2109+
/// After successfully deleting all of a directories children, it is possible for
2110+
/// the directory to not actually be empty (either from a new child being added while
2111+
/// iterating or a child being in a `DELETE_PENDING` state on Windows). In this scenario,
2112+
/// the directory will be iterated again and then will be attempted to be deleted again.
2113+
/// This provides a limit to the total number of such retries.
2114+
/// In `deleteTree`, the retry limit is per-directory.
2115+
/// In `deleteTreeMinStackSize`, the retry limit is function-wide.
2116+
max_retries: usize = 4,
2117+
2118+
/// When initially trying to delete `sub_path`, it will first try and delete it as
2119+
/// the provided kind.
2120+
kind_hint: File.Kind = .File,
2121+
};
2122+
21042123
/// Whether `full_path` describes a symlink, file, or directory, this function
21052124
/// removes it. If it cannot be removed because it is a non-empty directory,
21062125
/// this function recursively removes its entries and then tries again.
21072126
/// This operation is not atomic on most file systems.
2108-
pub fn deleteTree(self: Dir, sub_path: []const u8) DeleteTreeError!void {
2109-
var initial_iterable_dir = (try self.deleteTreeOpenInitialSubpath(sub_path, .File)) orelse return;
2127+
pub fn deleteTree(self: Dir, sub_path: []const u8, options: DeleteTreeOptions) DeleteTreeError!void {
2128+
var initial_iterable_dir = (try self.deleteTreeOpenInitialSubpath(sub_path, options.kind_hint)) orelse return;
21102129

21112130
const StackItem = struct {
21122131
name: []const u8,
21132132
parent_dir: Dir,
21142133
iter: IterableDir.Iterator,
2134+
retries_remaining: usize,
21152135
};
21162136

21172137
var stack = std.BoundedArray(StackItem, 16){};
@@ -2125,6 +2145,7 @@ pub const Dir = struct {
21252145
.name = sub_path,
21262146
.parent_dir = self,
21272147
.iter = initial_iterable_dir.iterateAssumeFirstIteration(),
2148+
.retries_remaining = options.max_retries,
21282149
});
21292150

21302151
process_stack: while (stack.len != 0) {
@@ -2162,10 +2183,14 @@ pub const Dir = struct {
21622183
.name = entry.name,
21632184
.parent_dir = top.iter.dir,
21642185
.iter = iterable_dir.iterateAssumeFirstIteration(),
2186+
.retries_remaining = options.max_retries,
21652187
});
21662188
continue :process_stack;
21672189
} else |_| {
2168-
try top.iter.dir.deleteTreeMinStackSizeWithKindHint(entry.name, entry.kind);
2190+
try top.iter.dir.deleteTreeMinStackSize(entry.name, .{
2191+
.max_retries = options.max_retries,
2192+
.kind_hint = entry.kind,
2193+
});
21692194
break :handle_entry;
21702195
}
21712196
} else {
@@ -2207,6 +2232,7 @@ pub const Dir = struct {
22072232
// pop the value from the stack.
22082233
const parent_dir = top.parent_dir;
22092234
const name = top.name;
2235+
const retries_remaining = top.retries_remaining;
22102236
_ = stack.pop();
22112237

22122238
var need_to_retry: bool = false;
@@ -2217,6 +2243,7 @@ pub const Dir = struct {
22172243
};
22182244

22192245
if (need_to_retry) {
2246+
if (retries_remaining == 0) return error.TooManyRetries;
22202247
// Since we closed the handle that the previous iterator used, we
22212248
// need to re-open the dir and re-create the iterator.
22222249
var iterable_dir = iterable_dir: {
@@ -2282,6 +2309,7 @@ pub const Dir = struct {
22822309
.name = name,
22832310
.parent_dir = parent_dir,
22842311
.iter = iterable_dir.iterateAssumeFirstIteration(),
2312+
.retries_remaining = retries_remaining - 1,
22852313
});
22862314
continue :process_stack;
22872315
}
@@ -2290,13 +2318,10 @@ pub const Dir = struct {
22902318

22912319
/// Like `deleteTree`, but only keeps one `Iterator` active at a time to minimize the function's stack size.
22922320
/// This is slower than `deleteTree` but uses less stack space.
2293-
pub fn deleteTreeMinStackSize(self: Dir, sub_path: []const u8) DeleteTreeError!void {
2294-
return self.deleteTreeMinStackSizeWithKindHint(sub_path, .File);
2295-
}
2296-
2297-
fn deleteTreeMinStackSizeWithKindHint(self: Dir, sub_path: []const u8, kind_hint: File.Kind) DeleteTreeError!void {
2321+
pub fn deleteTreeMinStackSize(self: Dir, sub_path: []const u8, options: DeleteTreeOptions) DeleteTreeError!void {
2322+
var retries_remaining = options.max_retries;
22982323
start_over: while (true) {
2299-
var iterable_dir = (try self.deleteTreeOpenInitialSubpath(sub_path, kind_hint)) orelse return;
2324+
var iterable_dir = (try self.deleteTreeOpenInitialSubpath(sub_path, options.kind_hint)) orelse return;
23002325
var cleanup_dir_parent: ?IterableDir = null;
23012326
defer if (cleanup_dir_parent) |*d| d.close();
23022327

@@ -2386,14 +2411,22 @@ pub const Dir = struct {
23862411
if (cleanup_dir_parent) |d| {
23872412
d.dir.deleteDir(dir_name) catch |err| switch (err) {
23882413
// These two things can happen due to file system race conditions.
2389-
error.FileNotFound, error.DirNotEmpty => continue :start_over,
2414+
error.FileNotFound, error.DirNotEmpty => {
2415+
if (retries_remaining == 0) return error.TooManyRetries;
2416+
retries_remaining -= 1;
2417+
continue :start_over;
2418+
},
23902419
else => |e| return e,
23912420
};
23922421
continue :start_over;
23932422
} else {
23942423
self.deleteDir(sub_path) catch |err| switch (err) {
23952424
error.FileNotFound => return,
2396-
error.DirNotEmpty => continue :start_over,
2425+
error.DirNotEmpty => {
2426+
if (retries_remaining == 0) return error.TooManyRetries;
2427+
retries_remaining -= 1;
2428+
continue :start_over;
2429+
},
23972430
else => |e| return e,
23982431
};
23992432
return;
@@ -2834,7 +2867,7 @@ pub fn deleteTreeAbsolute(absolute_path: []const u8) !void {
28342867
var dir = try cwd().openDir(dirname, .{});
28352868
defer dir.close();
28362869

2837-
return dir.deleteTree(path.basename(absolute_path));
2870+
return dir.deleteTree(path.basename(absolute_path), .{});
28382871
}
28392872

28402873
/// Same as `Dir.readLink`, except it asserts the path is absolute.

lib/std/fs/test.zig

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ test "Dir.Iterator but dir is deleted during iteration" {
304304
// This is a contrived reproduction, but this could happen outside of the program, in another thread, etc.
305305
// If we get an error while trying to delete, we can skip this test (this will happen on platforms
306306
// like Windows which will give FileBusy if the directory is currently open for iteration).
307-
tmp.dir.deleteTree("subdir") catch return error.SkipZigTest;
307+
tmp.dir.deleteTree("subdir", .{}) catch return error.SkipZigTest;
308308

309309
// Now, when we try to iterate, the next call should return null immediately.
310310
const entry = try iterator.next();
@@ -696,7 +696,7 @@ test "makePath, put some files in it, deleteTree" {
696696
try tmp.dir.makePath("os_test_tmp" ++ fs.path.sep_str ++ "b" ++ fs.path.sep_str ++ "c");
697697
try tmp.dir.writeFile("os_test_tmp" ++ fs.path.sep_str ++ "b" ++ fs.path.sep_str ++ "c" ++ fs.path.sep_str ++ "file.txt", "nonsense");
698698
try tmp.dir.writeFile("os_test_tmp" ++ fs.path.sep_str ++ "b" ++ fs.path.sep_str ++ "file2.txt", "blah");
699-
try tmp.dir.deleteTree("os_test_tmp");
699+
try tmp.dir.deleteTree("os_test_tmp", .{});
700700
if (tmp.dir.openDir("os_test_tmp", .{})) |dir| {
701701
_ = dir;
702702
@panic("expected error");
@@ -712,7 +712,7 @@ test "makePath, put some files in it, deleteTreeMinStackSize" {
712712
try tmp.dir.makePath("os_test_tmp" ++ fs.path.sep_str ++ "b" ++ fs.path.sep_str ++ "c");
713713
try tmp.dir.writeFile("os_test_tmp" ++ fs.path.sep_str ++ "b" ++ fs.path.sep_str ++ "c" ++ fs.path.sep_str ++ "file.txt", "nonsense");
714714
try tmp.dir.writeFile("os_test_tmp" ++ fs.path.sep_str ++ "b" ++ fs.path.sep_str ++ "file2.txt", "blah");
715-
try tmp.dir.deleteTreeMinStackSize("os_test_tmp");
715+
try tmp.dir.deleteTreeMinStackSize("os_test_tmp", .{});
716716
if (tmp.dir.openDir("os_test_tmp", .{})) |dir| {
717717
_ = dir;
718718
@panic("expected error");
@@ -721,12 +721,48 @@ test "makePath, put some files in it, deleteTreeMinStackSize" {
721721
}
722722
}
723723

724+
test "deleteTree max retries" {
725+
// While TooManyRetries is relevant for non-Windows platforms, it is trickier to
726+
// reproduce the necessary conditions, as getting DirNotEmpty when deleting a directory
727+
// that `deleteTree` thinks is now empty would likely mean something like a file/dir being
728+
// created while the directory is being iterated and its children deleted.
729+
if (builtin.os.tag != .windows) return error.SkipZigTest;
730+
731+
var tmp = std.testing.tmpDir(.{});
732+
defer tmp.cleanup();
733+
734+
// Make a subdirectory with a file in it and keep the file handle open.
735+
var leaked_handle = blk: {
736+
var dir = try tmp.dir.makeOpenPath("dir", .{});
737+
defer dir.close();
738+
739+
var file = try dir.createFile("neverclose", .{});
740+
break :blk file;
741+
};
742+
743+
{
744+
// We always want to close the file handle even on test failure, but we want it to be
745+
// open for these expectError calls.
746+
defer leaked_handle.close();
747+
748+
// Since the file handle was never closed, it will enter into a `DELETE_PENDING`
749+
// state which will cause deleteTree to continually delete the file and directory,
750+
// getting 'success' from the file delete but then DirNotEmpty from the directory
751+
// delete.
752+
try std.testing.expectError(error.TooManyRetries, tmp.dir.deleteTree("dir", .{}));
753+
try std.testing.expectError(error.TooManyRetries, tmp.dir.deleteTreeMinStackSize("dir", .{}));
754+
}
755+
756+
// Now that the leaked handle has been closed, deleteTree should succeed.
757+
try tmp.dir.deleteTree("dir", .{});
758+
}
759+
724760
test "makePath in a directory that no longer exists" {
725761
if (builtin.os.tag == .windows) return error.SkipZigTest; // Windows returns FileBusy if attempting to remove an open dir
726762

727763
var tmp = tmpDir(.{});
728764
defer tmp.cleanup();
729-
try tmp.parent_dir.deleteTree(&tmp.sub_path);
765+
try tmp.parent_dir.deleteTree(&tmp.sub_path, .{});
730766

731767
try testing.expectError(error.FileNotFound, tmp.dir.makePath("sub-path"));
732768
}
@@ -751,7 +787,7 @@ fn testFilenameLimits(iterable_dir: IterableDir, maxed_filename: []const u8) !vo
751787
}
752788

753789
// ensure that we can delete the tree
754-
try iterable_dir.dir.deleteTree(maxed_filename);
790+
try iterable_dir.dir.deleteTree(maxed_filename, .{});
755791
}
756792

757793
test "max file name component lengths" {
@@ -874,15 +910,15 @@ test "access file" {
874910

875911
try tmp.dir.writeFile("os_test_tmp" ++ fs.path.sep_str ++ "file.txt", "");
876912
try tmp.dir.access("os_test_tmp" ++ fs.path.sep_str ++ "file.txt", .{});
877-
try tmp.dir.deleteTree("os_test_tmp");
913+
try tmp.dir.deleteTree("os_test_tmp", .{});
878914
}
879915

880916
test "sendfile" {
881917
var tmp = tmpDir(.{});
882918
defer tmp.cleanup();
883919

884920
try tmp.dir.makePath("os_test_tmp");
885-
defer tmp.dir.deleteTree("os_test_tmp") catch {};
921+
defer tmp.dir.deleteTree("os_test_tmp", .{}) catch {};
886922

887923
var dir = try tmp.dir.openDir("os_test_tmp", .{});
888924
defer dir.close();
@@ -947,7 +983,7 @@ test "copyRangeAll" {
947983
defer tmp.cleanup();
948984

949985
try tmp.dir.makePath("os_test_tmp");
950-
defer tmp.dir.deleteTree("os_test_tmp") catch {};
986+
defer tmp.dir.deleteTree("os_test_tmp", .{}) catch {};
951987

952988
var dir = try tmp.dir.openDir("os_test_tmp", .{});
953989
defer dir.close();

lib/std/fs/watch.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -643,7 +643,7 @@ test "write a file, watch it, write it again, delete it" {
643643
if (builtin.single_threaded) return error.SkipZigTest;
644644

645645
try std.fs.cwd().makePath(test_tmp_dir);
646-
defer std.fs.cwd().deleteTree(test_tmp_dir) catch {};
646+
defer std.fs.cwd().deleteTree(test_tmp_dir, .{}) catch {};
647647

648648
return testWriteWatchWriteDelete(std.testing.allocator);
649649
}

lib/std/testing.zig

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -519,7 +519,7 @@ pub const TmpDir = struct {
519519

520520
pub fn cleanup(self: *TmpDir) void {
521521
self.dir.close();
522-
self.parent_dir.deleteTree(&self.sub_path) catch {};
522+
self.parent_dir.deleteTree(&self.sub_path, .{}) catch {};
523523
self.parent_dir.close();
524524
self.* = undefined;
525525
}
@@ -535,7 +535,7 @@ pub const TmpIterableDir = struct {
535535

536536
pub fn cleanup(self: *TmpIterableDir) void {
537537
self.iterable_dir.close();
538-
self.parent_dir.deleteTree(&self.sub_path) catch {};
538+
self.parent_dir.deleteTree(&self.sub_path, .{}) catch {};
539539
self.parent_dir.close();
540540
self.* = undefined;
541541
}

src/Package.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -719,7 +719,7 @@ fn renameTmpIntoCache(
719719
},
720720
error.PathAlreadyExists, error.AccessDenied => {
721721
// Package has been already downloaded and may already be in use on the system.
722-
cache_dir.deleteTree(tmp_dir_sub_path) catch |del_err| {
722+
cache_dir.deleteTree(tmp_dir_sub_path, .{}) catch |del_err| {
723723
std.log.warn("unable to delete temp directory: {s}", .{@errorName(del_err)});
724724
};
725725
},

src/link.zig

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -937,7 +937,7 @@ pub const File = struct {
937937
// Work around windows `renameW` can't fail with `PathAlreadyExists`
938938
// See https://github.com/ziglang/zig/issues/8362
939939
if (cache_directory.handle.access(o_sub_path, .{})) |_| {
940-
try cache_directory.handle.deleteTree(o_sub_path);
940+
try cache_directory.handle.deleteTree(o_sub_path, .{});
941941
continue;
942942
} else |err| switch (err) {
943943
error.FileNotFound => {},
@@ -961,7 +961,7 @@ pub const File = struct {
961961
o_sub_path,
962962
) catch |err| switch (err) {
963963
error.PathAlreadyExists => {
964-
try cache_directory.handle.deleteTree(o_sub_path);
964+
try cache_directory.handle.deleteTree(o_sub_path, .{});
965965
continue;
966966
},
967967
else => |e| return e,

0 commit comments

Comments
 (0)