From 1b4650ad436f95df8e9bdac9cec3abab101bc54a Mon Sep 17 00:00:00 2001 From: Dario Kondratiuk Date: Sat, 24 Oct 2026 11:41:30 -0300 Subject: [PATCH] Write the step cache, screenshots, and AI trace through AtomicWrite (upstream writeFileAtomic) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01DH1b4gwq2kmMK94pdZRQZm --- src/E2E/Cache/StepCache.cs | 24 +------------- src/E2E/Expect/ScreenshotAssertion.cs | 4 +-- src/E2E/Internal/AiTrace.cs | 5 +-- src/E2E/Internal/AtomicWrite.cs | 48 +++++++++++++++++++++------ 4 files changed, 40 insertions(+), 41 deletions(-) diff --git a/src/E2E/Cache/StepCache.cs b/src/E2E/Cache/StepCache.cs index 95b6bf11..f11670b5 100644 --- a/src/E2E/Cache/StepCache.cs +++ b/src/E2E/Cache/StepCache.cs @@ -244,29 +244,7 @@ public void Write(string key, CacheEntry entry) ArgumentNullException.ThrowIfNull(entry); Directory.CreateDirectory(_directory); var path = PathFor(key); - var temp = path + "." + Environment.ProcessId + "." + Guid.NewGuid().ToString("N") + ".tmp"; - try - { - File.WriteAllText(temp, JsonSerializer.Serialize(entry, JsonDefaults.Options)); - File.Move(temp, path, overwrite: true); - } - catch - { - try - { - File.Delete(temp); - } - catch (IOException) - { - // The write error is the one to report. - } - catch (UnauthorizedAccessException) - { - // The write error is the one to report. - } - - throw; - } + AtomicWrite.WriteFileAtomic(path, JsonSerializer.Serialize(entry, JsonDefaults.Options)); } public void Delete(string key) diff --git a/src/E2E/Expect/ScreenshotAssertion.cs b/src/E2E/Expect/ScreenshotAssertion.cs index 91353468..a068c9d8 100644 --- a/src/E2E/Expect/ScreenshotAssertion.cs +++ b/src/E2E/Expect/ScreenshotAssertion.cs @@ -253,9 +253,7 @@ private static async Task KeepAsync(Run run, Locator? subject, RgbaImage? expect } Directory.CreateDirectory(Path.GetDirectoryName(run.File)!); - var temporary = run.File + "." + Guid.NewGuid().ToString("N")[..8] + ".tmp"; - await System.IO.File.WriteAllBytesAsync(temporary, ImageOps.EncodePng(settled)).ConfigureAwait(false); - System.IO.File.Move(temporary, run.File, overwrite: true); + await AtomicWrite.WriteFileAtomicAsync(run.File, ImageOps.EncodePng(settled)).ConfigureAwait(false); store.MarkWritten(run.File); if (expected is null && !store.Update) { diff --git a/src/E2E/Internal/AiTrace.cs b/src/E2E/Internal/AiTrace.cs index b7ecfc54..0251845b 100644 --- a/src/E2E/Internal/AiTrace.cs +++ b/src/E2E/Internal/AiTrace.cs @@ -901,16 +901,13 @@ public void Flush(bool force) _pending = null; Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!); - var temp = Path + "." + Guid.NewGuid().ToString("n") + ".tmp"; try { - File.WriteAllText(temp, JsonSerializer.Serialize(Collector.Document(), Options)); - File.Move(temp, Path, overwrite: true); + AtomicWrite.WriteFileAtomic(Path, JsonSerializer.Serialize(Collector.Document(), Options)); _dirty = false; } finally { - File.Delete(temp); _written = Stopwatch.GetTimestamp(); } } diff --git a/src/E2E/Internal/AtomicWrite.cs b/src/E2E/Internal/AtomicWrite.cs index 3d86b1be..c35fee86 100644 --- a/src/E2E/Internal/AtomicWrite.cs +++ b/src/E2E/Internal/AtomicWrite.cs @@ -15,26 +15,52 @@ namespace E2E.Internal; internal static class AtomicWrite { /// Writes to atomically; a failed write leaves no temporary file behind. - public static async Task WriteFileAtomicAsync(string filePath, string content, CancellationToken cancellationToken = default) + public static Task WriteFileAtomicAsync(string filePath, string content, CancellationToken cancellationToken = default) => + WriteFileAtomicAsync(filePath, new UTF8Encoding(false).GetBytes(content), cancellationToken); + + /// The bytes form of (upstream accepts a Uint8Array). + public static async Task WriteFileAtomicAsync(string filePath, byte[] content, CancellationToken cancellationToken = default) { - var temporary = filePath + "." + Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(8)) + ".tmp"; + var temporary = TemporaryPath(filePath); try { - await File.WriteAllTextAsync(temporary, content, new UTF8Encoding(false), cancellationToken).ConfigureAwait(false); + await File.WriteAllBytesAsync(temporary, content, cancellationToken).ConfigureAwait(false); File.Move(temporary, filePath, overwrite: true); } catch { - try - { - File.Delete(temporary); - } - catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException) - { - // The write may have failed before the temporary file existed, and the first failure is the one to report. - } + DeleteQuietly(temporary); + throw; + } + } + /// The synchronous twin (upstream's writeFileAtomicSync), for a store that is written from synchronous code. + public static void WriteFileAtomic(string filePath, string content) + { + var temporary = TemporaryPath(filePath); + try + { + File.WriteAllText(temporary, content, new UTF8Encoding(false)); + File.Move(temporary, filePath, overwrite: true); + } + catch + { + DeleteQuietly(temporary); throw; } } + + private static string TemporaryPath(string filePath) => filePath + "." + Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(8)) + ".tmp"; + + private static void DeleteQuietly(string temporary) + { + try + { + File.Delete(temporary); + } + catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException) + { + // The write may have failed before the temporary file existed, and the first failure is the one to report. + } + } }