Skip to content

Commit 75a39b9

Browse files
committed
Handle exceptions while writing a movie file, and use a temporary file until writing has completed. Addresses #4258 and prevents loss of data in the event of an exception mid-write.
1 parent 35ce3f5 commit 75a39b9

20 files changed

+510
-118
lines changed

src/BizHawk.Client.Common/Api/Classes/MovieApi.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ public string GetInputAsMnemonic(int frame)
5353
return Bk2LogEntryGenerator.GenerateLogEntry(_movieSession.Movie.GetInputState(frame));
5454
}
5555

56+
// TODO: Change return type to indicate success/failure? This method is part of BizHawk's API for external tools/Lua.
5657
public void Save(string filename)
5758
{
5859
if (_movieSession.Movie.NotActive())
@@ -70,7 +71,9 @@ public void Save(string filename)
7071
}
7172
_movieSession.Movie.Filename = filename;
7273
}
73-
_movieSession.Movie.Save();
74+
FileWriteResult result = _movieSession.Movie.Save();
75+
// unitl API is decided
76+
if (result.Exception != null) throw result.Exception;
7477
}
7578

7679
public IReadOnlyDictionary<string, string> GetHeader()
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
using System.Diagnostics;
2+
using System.IO;
3+
4+
using BizHawk.Common.StringExtensions;
5+
6+
namespace BizHawk.Client.Common
7+
{
8+
internal class FileStreamWithTemp : IDisposable
9+
{
10+
public readonly FileStream Stream;
11+
public string FinalPath;
12+
public string TempPath;
13+
14+
public bool UsingTempFile => TempPath != FinalPath;
15+
16+
private bool _finished = false;
17+
18+
private FileStreamWithTemp(string final, string temp)
19+
{
20+
FinalPath = final;
21+
TempPath = temp;
22+
Stream = new(temp, FileMode.Create, FileAccess.Write);
23+
}
24+
25+
// There is no public constructor. This is the only way to create an instance.
26+
public static FileWriteResult<FileStreamWithTemp> Create(string path)
27+
{
28+
string writePath = path;
29+
// If the file already exists, we will write to a temporary location first and preserve the old one until we're done.
30+
if (File.Exists(path))
31+
{
32+
writePath = path.InsertBeforeLast('.', ".saving", out bool inserted);
33+
if (!inserted) writePath = $"{path}.saving";
34+
35+
if (File.Exists(writePath))
36+
{
37+
// The user should probably have dealt with this on the previously failed save.
38+
// But maybe we should support plain old "try again", so let's delete it.
39+
try
40+
{
41+
File.Delete(writePath);
42+
}
43+
catch (IOException ex)
44+
{
45+
return new(FileWriteEnum.FailedToOpen, writePath, ex);
46+
}
47+
}
48+
}
49+
try
50+
{
51+
return new(new FileStreamWithTemp(path, writePath), writePath);
52+
}
53+
catch (IOException ex)
54+
{
55+
return new(FileWriteEnum.FailedToOpen, writePath, ex);
56+
}
57+
}
58+
59+
/// <summary>
60+
/// This method must be called after writing has finished. An exception will be thrown if it isn't.
61+
/// </summary>
62+
public FileWriteResult EndWrite()
63+
{
64+
if (_finished) throw new InvalidOperationException("Cannot finalize twice.");
65+
66+
_finished = true;
67+
Dispose();
68+
69+
if (!UsingTempFile) return new(FileWriteEnum.Success, FinalPath, null);
70+
71+
_finished = false; // In case we fail, allow the caller to try again.
72+
if (File.Exists(FinalPath))
73+
{
74+
try
75+
{
76+
File.Delete(FinalPath);
77+
}
78+
catch (IOException ex)
79+
{
80+
return new(FileWriteEnum.FailedToDeleteOldFile, TempPath, ex);
81+
}
82+
}
83+
try
84+
{
85+
File.Move(TempPath, FinalPath);
86+
}
87+
catch (IOException ex)
88+
{
89+
return new(FileWriteEnum.FailedToRename, TempPath, ex);
90+
}
91+
92+
_finished = true;
93+
return new(FileWriteEnum.Success, FinalPath, null);
94+
}
95+
96+
public void Dispose()
97+
{
98+
Stream.Dispose();
99+
100+
// The caller should call EndWrite and handle potential failure.
101+
Debug.Assert(!_finished, $"{nameof(FileWriteResult)} should not be disposed before calling {nameof(EndWrite)}");
102+
}
103+
}
104+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
#nullable enable
2+
3+
using System.Diagnostics;
4+
5+
namespace BizHawk.Client.Common
6+
{
7+
public enum FileWriteEnum
8+
{
9+
Success,
10+
FailedToOpen,
11+
FailedDuringWrite,
12+
FailedToDeleteOldFile,
13+
FailedToRename,
14+
}
15+
16+
/// <summary>
17+
/// Provides information about the success or failure of an attempt to write to a file.
18+
/// </summary>
19+
public class FileWriteResult
20+
{
21+
public readonly FileWriteEnum Error = FileWriteEnum.Success;
22+
public readonly Exception? Exception;
23+
public readonly string WritePath;
24+
25+
public bool IsError => Error != FileWriteEnum.Success;
26+
27+
public FileWriteResult(FileWriteEnum error, string path, Exception? exception)
28+
{
29+
Error = error;
30+
Exception = exception;
31+
WritePath = path;
32+
}
33+
34+
/// <summary>
35+
/// Converts this instance to a different generic type.
36+
/// The new instance will take the value given only if this instance has no error.
37+
/// </summary>
38+
/// <param name="value">The value of the new instance. Ignored if this instance has an error.</param>
39+
public FileWriteResult<T> Convert<T>(T value)
40+
{
41+
if (Error == FileWriteEnum.Success) return new(value, WritePath);
42+
else return new(this);
43+
}
44+
45+
public FileWriteResult(FileWriteResult other) : this(other.Error, other.WritePath, other.Exception) { }
46+
47+
public string UserFriendlyErrorMessage()
48+
{
49+
switch (Error)
50+
{
51+
case FileWriteEnum.Success:
52+
return $"The file {WritePath} was written successfully.";
53+
case FileWriteEnum.FailedToOpen:
54+
return $"The file {WritePath} could not be created.";
55+
case FileWriteEnum.FailedDuringWrite:
56+
return $"The file {WritePath} was created, but an error occurred while writing to it.";
57+
case FileWriteEnum.FailedToDeleteOldFile:
58+
return $"The file {WritePath} was created successfully, but the old file could not be deleted.";
59+
case FileWriteEnum.FailedToRename:
60+
return $"The file {WritePath} was created successfully, but could not be renamed.";
61+
default:
62+
return "unreachable";
63+
}
64+
}
65+
}
66+
67+
/// <summary>
68+
/// Provides information about the success or failure of an attempt to write to a file.
69+
/// If successful, also provides a related object instance.
70+
/// </summary>
71+
public class FileWriteResult<T> : FileWriteResult
72+
{
73+
/// <summary>
74+
/// Value will not be null if <see cref="FileWriteResult.Error"/> is <see cref="FileWriteEnum.Success"/>.
75+
/// Otherwise, Value will be null.
76+
/// </summary>
77+
public readonly T? Value = default;
78+
79+
public FileWriteResult(FileWriteEnum error, string path, Exception? exception) : base(error, path, exception) { }
80+
81+
public FileWriteResult(T value, string path) : base(FileWriteEnum.Success, path, null)
82+
{
83+
Debug.Assert(value != null, "Should not give a null value on success. Use the non-generic type if there is no value.");
84+
Value = value;
85+
}
86+
87+
public FileWriteResult(FileWriteResult other) : base(other.Error, other.WritePath, other.Exception) { }
88+
}
89+
}
Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
#nullable enable
2+
13
using System.IO;
24
using System.IO.Compression;
35

@@ -7,18 +9,18 @@ namespace BizHawk.Client.Common
79
{
810
public class FrameworkZipWriter : IZipWriter
911
{
10-
private ZipArchive _archive;
12+
private ZipArchive? _archive;
1113

12-
private FileStream _fs;
14+
private FileStreamWithTemp? _fs;
1315

14-
private Zstd _zstd;
16+
private Zstd? _zstd;
1517
private readonly CompressionLevel _level;
1618
private readonly int _zstdCompressionLevel;
1719

18-
public FrameworkZipWriter(string path, int compressionLevel)
20+
private bool _disposed;
21+
22+
private FrameworkZipWriter(int compressionLevel)
1923
{
20-
_fs = new(path, FileMode.Create, FileAccess.Write);
21-
_archive = new(_fs, ZipArchiveMode.Create, leaveOpen: true);
2224
if (compressionLevel == 0)
2325
_level = CompressionLevel.NoCompression;
2426
else if (compressionLevel < 5)
@@ -32,8 +34,28 @@ public FrameworkZipWriter(string path, int compressionLevel)
3234
_zstdCompressionLevel = compressionLevel * 2 + 1;
3335
}
3436

37+
public static FileWriteResult<FrameworkZipWriter> Create(string path, int compressionLevel)
38+
{
39+
FileWriteResult<FileStreamWithTemp> fs = FileStreamWithTemp.Create(path);
40+
if (fs.IsError) return new(fs);
41+
42+
FrameworkZipWriter ret = new(compressionLevel);
43+
ret._fs = fs.Value!;
44+
ret._archive = new(ret._fs.Stream, ZipArchiveMode.Create, leaveOpen: true);
45+
46+
return fs.Convert(ret);
47+
}
48+
49+
public FileWriteResult EndWrite()
50+
{
51+
if (_fs == null) throw new ObjectDisposedException("Cannot use disposed ZipWriter.");
52+
return _fs.EndWrite();
53+
}
54+
3555
public void WriteItem(string name, Action<Stream> callback, bool zstdCompress)
3656
{
57+
if (_archive == null || _zstd == null) throw new ObjectDisposedException("Cannot use disposed ZipWriter.");
58+
3759
// don't compress with deflate if we're already compressing with zstd
3860
// this won't produce meaningful compression, and would just be a timesink
3961
using var stream = _archive.CreateEntry(name, zstdCompress ? CompressionLevel.NoCompression : _level).Open();
@@ -51,19 +73,17 @@ public void WriteItem(string name, Action<Stream> callback, bool zstdCompress)
5173

5274
public void Dispose()
5375
{
54-
if (_archive != null)
55-
{
56-
_archive.Dispose();
57-
_archive = null;
58-
}
59-
_fs?.Flush(flushToDisk: true);
60-
_fs?.Dispose();
76+
if (_disposed) return;
77+
_disposed = true;
78+
79+
_archive!.Dispose();
80+
_archive = null;
81+
_zstd!.Dispose();
82+
_zstd = null;
83+
84+
_fs!.Stream.Flush(flushToDisk: true);
85+
_fs.Dispose();
6186
_fs = null;
62-
if (_zstd != null)
63-
{
64-
_zstd.Dispose();
65-
_zstd = null;
66-
}
6787
}
6888
}
6989
}

src/BizHawk.Client.Common/IZipWriter.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,10 @@ namespace BizHawk.Client.Common
55
public interface IZipWriter : IDisposable
66
{
77
void WriteItem(string name, Action<Stream> callback, bool zstdCompress);
8+
9+
/// <summary>
10+
/// This method must be called after writing has finished.
11+
/// </summary>
12+
FileWriteResult EndWrite();
813
}
914
}

src/BizHawk.Client.Common/movie/MovieConversionExtensions.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ public static IMovie ToBk2(this IMovie old)
7171
return bk2;
7272
}
7373

74-
public static ITasMovie ConvertToSavestateAnchoredMovie(this ITasMovie old, int frame, byte[] savestate)
74+
public static FileWriteResult<ITasMovie> ConvertToSavestateAnchoredMovie(this ITasMovie old, int frame, byte[] savestate)
7575
{
7676
string newFilename = ConvertFileNameToTasMovie(old.Filename);
7777

@@ -115,11 +115,11 @@ public static ITasMovie ConvertToSavestateAnchoredMovie(this ITasMovie old, int
115115
}
116116
}
117117

118-
tas.Save();
119-
return tas;
118+
FileWriteResult saveResult = tas.Save();
119+
return saveResult.Convert(tas);
120120
}
121121

122-
public static ITasMovie ConvertToSaveRamAnchoredMovie(this ITasMovie old, byte[] saveRam)
122+
public static FileWriteResult<ITasMovie> ConvertToSaveRamAnchoredMovie(this ITasMovie old, byte[] saveRam)
123123
{
124124
string newFilename = ConvertFileNameToTasMovie(old.Filename);
125125

@@ -146,8 +146,8 @@ public static ITasMovie ConvertToSaveRamAnchoredMovie(this ITasMovie old, byte[]
146146
tas.Subtitles.Add(sub);
147147
}
148148

149-
tas.Save();
150-
return tas;
149+
FileWriteResult saveResult = tas.Save();
150+
return saveResult.Convert(tas);
151151
}
152152

153153
#pragma warning disable RCS1224 // private but for unit test

src/BizHawk.Client.Common/movie/MovieSession.cs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ public void RunQueuedMovie(bool recordMode, IEmulator emulator)
244244
public void AbortQueuedMovie()
245245
=> _queuedMovie = null;
246246

247-
public void StopMovie(bool saveChanges = true)
247+
public FileWriteResult StopMovie(bool saveChanges = true)
248248
{
249249
if (Movie.IsActive())
250250
{
@@ -262,8 +262,17 @@ public void StopMovie(bool saveChanges = true)
262262

263263
if (saveChanges && Movie.Changes)
264264
{
265-
Movie.Save();
266-
Output($"{Path.GetFileName(Movie.Filename)} written to disk.");
265+
FileWriteResult result = Movie.Save();
266+
if (result.IsError)
267+
{
268+
Output($"Failed to write {Path.GetFileName(Movie.Filename)} to disk.");
269+
Output(result.UserFriendlyErrorMessage());
270+
return result;
271+
}
272+
else
273+
{
274+
Output($"{Path.GetFileName(Movie.Filename)} written to disk.");
275+
}
267276
}
268277
Movie.Stop();
269278

@@ -279,6 +288,8 @@ public void StopMovie(bool saveChanges = true)
279288
}
280289

281290
Movie = null;
291+
292+
return new(FileWriteEnum.Success, "", null);
282293
}
283294

284295
public IMovie Get(string path, bool loadMovie)

0 commit comments

Comments
 (0)