//! Atomic filesystem writes. //! //! `writeFileAtomic` writes to `.tmp`, fsyncs, and renames to //! ``. Crash-safe replacement for `createFile + writeAll + close`: //! if the process dies mid-write, the destination file is left at its //! prior contents (or absent) rather than truncated or half-written. //! //! Used by the snapshot writer so a ctrl-C or kernel panic mid-run //! can't produce a corrupt `history/-portfolio.srf`. const std = @import("std"); /// Suffix appended to the temp file during atomic writes. Exposed so /// callers that want to sweep orphan temp files (e.g. from a previous /// crash) know what to look for. pub const tmp_suffix = ".tmp"; /// Write `bytes` to `path` atomically. /// /// Strategy: /// 1. Write to `.tmp` (truncating any previous tmp file). /// 2. `fsync` the tmp file so the data is durable before we rename. /// 3. Rename tmp -> path (atomic on POSIX when src/dst are on the same /// filesystem, which is guaranteed here because both are the literal /// path plus `.tmp`). /// /// On any error the tmp file is best-effort removed so we don't leave /// clutter behind. The caller's `path` is unchanged unless the final /// rename succeeds. /// /// The allocator is used for a short-lived temp-path buffer /// (`path.len + tmp_suffix.len` bytes) and freed before return. pub fn writeFileAtomic( io: std.Io, allocator: std.mem.Allocator, path: []const u8, bytes: []const u8, ) !void { const tmp_path = try std.fmt.allocPrint(allocator, "{s}{s}", .{ path, tmp_suffix }); defer allocator.free(tmp_path); { var tmp_file = try std.Io.Dir.cwd().createFile(io, tmp_path, .{ .truncate = true, .exclusive = false, }); errdefer { tmp_file.close(io); std.Io.Dir.cwd().deleteFile(io, tmp_path) catch {}; } try tmp_file.writeStreamingAll(io, bytes); // fsync so the kernel flushes data to disk before the rename // appears. Without this, a crash between rename() and the data // hitting disk could leave an empty-but-present file at `path`. try tmp_file.sync(io); tmp_file.close(io); } std.Io.Dir.cwd().rename(tmp_path, std.Io.Dir.cwd(), path, io) catch |err| { std.Io.Dir.cwd().deleteFile(io, tmp_path) catch {}; return err; }; } // ── Tests ──────────────────────────────────────────────────── test "writeFileAtomic creates new file" { const io = std.testing.io; var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp_dir.dir.realPathFile(io, ".", &path_buf); const dir_path = path_buf[0..dir_len]; const file_path = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "atomic_new.txt" }); defer std.testing.allocator.free(file_path); try writeFileAtomic(io, std.testing.allocator, file_path, "hello world\n"); const contents = try std.Io.Dir.cwd().readFileAlloc(io, file_path, std.testing.allocator, .limited(4096)); defer std.testing.allocator.free(contents); try std.testing.expectEqualStrings("hello world\n", contents); // Tmp file should have been consumed by rename. const tmp_path = try std.fmt.allocPrint(std.testing.allocator, "{s}{s}", .{ file_path, tmp_suffix }); defer std.testing.allocator.free(tmp_path); try std.testing.expectError(error.FileNotFound, std.Io.Dir.cwd().access(io, tmp_path, .{})); // Clean up for the next test run. std.Io.Dir.cwd().deleteFile(io, file_path) catch {}; } test "writeFileAtomic overwrites existing file" { const io = std.testing.io; var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp_dir.dir.realPathFile(io, ".", &path_buf); const dir_path = path_buf[0..dir_len]; const file_path = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "atomic_over.txt" }); defer std.testing.allocator.free(file_path); // Seed with old content. { var f = try std.Io.Dir.cwd().createFile(io, file_path, .{}); try f.writeStreamingAll(io, "old contents"); f.close(io); } try writeFileAtomic(io, std.testing.allocator, file_path, "new contents"); const contents = try std.Io.Dir.cwd().readFileAlloc(io, file_path, std.testing.allocator, .limited(4096)); defer std.testing.allocator.free(contents); try std.testing.expectEqualStrings("new contents", contents); std.Io.Dir.cwd().deleteFile(io, file_path) catch {}; } test "writeFileAtomic: missing parent directory surfaces FileNotFound" { // Point at a path whose parent directory doesn't exist. The tmp dir // itself exists (so the filesystem is fine), but the "missing" // subdirectory does not — createFile on the .tmp file must fail // with FileNotFound regardless of platform. const io = std.testing.io; var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp_dir.dir.realPathFile(io, ".", &path_buf); const dir_path = path_buf[0..dir_len]; const bad_path = try std.fs.path.join( std.testing.allocator, &.{ dir_path, "missing", "file.txt" }, ); defer std.testing.allocator.free(bad_path); try std.testing.expectError( error.FileNotFound, writeFileAtomic(io, std.testing.allocator, bad_path, "x"), ); }