Skip to content

Commit 0798074

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 0798074

21 files changed

+620
-124
lines changed

.global.editorconfig.ini

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,15 @@ dotnet_diagnostic.CA2229.severity = silent
120120
# Opt in to preview features before using them
121121
dotnet_diagnostic.CA2252.severity = silent # CSharpDetectPreviewFeatureAnalyzer very slow
122122

123+
## Nullable rules; generics are a bit wonky and I have no idea why we're allowed to configure these to be not errors or why they aren't errors by default.
124+
125+
# Nullability of reference types in value doesn't match target type.
126+
dotnet_diagnostic.CS8619.severity = error
127+
# Make Foo<string?> an error for class Foo<T> where T : class. Use `where T : class?` if Foo<string?> should be allowed.
128+
dotnet_diagnostic.CS8634.severity = error
129+
# Nullability of type argument doesn't match 'notnull' constraint.
130+
dotnet_diagnostic.CS8714.severity = error
131+
123132
## .NET DocumentationAnalyzers style rules
124133

125134
# Place text in paragraphs

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: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
#nullable enable
2+
3+
using System.Diagnostics;
4+
using System.IO;
5+
6+
using BizHawk.Common.StringExtensions;
7+
8+
namespace BizHawk.Client.Common
9+
{
10+
internal class FileStreamWithTemp : IDisposable
11+
{
12+
private FileStream? _stream; // is never null until this.Dispose()
13+
public FileStream Stream
14+
{
15+
get => _stream ?? throw new ObjectDisposedException("Cannot access a disposed FileStream.");
16+
}
17+
public string FinalPath;
18+
public string TempPath;
19+
20+
public bool UsingTempFile => TempPath != FinalPath;
21+
22+
private bool _finished = false;
23+
24+
private FileStreamWithTemp(string final, string temp)
25+
{
26+
FinalPath = final;
27+
TempPath = temp;
28+
_stream = new(temp, FileMode.Create, FileAccess.Write);
29+
}
30+
31+
// There is no public constructor. This is the only way to create an instance.
32+
public static FileWriteResult<FileStreamWithTemp> Create(string path)
33+
{
34+
string writePath = path;
35+
try
36+
{
37+
// If the file already exists, we will write to a temporary location first and preserve the old one until we're done.
38+
if (File.Exists(path))
39+
{
40+
writePath = path.InsertBeforeLast('.', ".saving", out bool inserted);
41+
if (!inserted) writePath = $"{path}.saving";
42+
43+
if (File.Exists(writePath))
44+
{
45+
// The user should probably have dealt with this on the previously failed save.
46+
// But maybe we should support plain old "try again", so let's delete it.
47+
File.Delete(writePath);
48+
}
49+
}
50+
return new(new FileStreamWithTemp(path, writePath), writePath);
51+
}
52+
catch (IOException ex)
53+
{
54+
return new(FileWriteEnum.FailedToOpen, writePath, ex);
55+
}
56+
}
57+
58+
/// <summary>
59+
/// This method must be called after writing has finished and must not be called twice.
60+
/// Dispose will be called regardless of the result.
61+
/// </summary>
62+
/// <exception cref="InvalidOperationException">If called twice.</exception>
63+
public FileWriteResult CloseAndDispose()
64+
{
65+
// In theory it might make sense to allow the user to try again if we fail inside this method.
66+
// If we implement that, it is probably best to make a static method that takes a FileWriteResult.
67+
// So even then, this method should not ever be called twice.
68+
if (_finished) throw new InvalidOperationException("Cannot close twice.");
69+
70+
_finished = true;
71+
Dispose();
72+
73+
if (!UsingTempFile) return new(FileWriteEnum.Success, FinalPath, null);
74+
75+
if (File.Exists(FinalPath))
76+
{
77+
try
78+
{
79+
File.Delete(FinalPath);
80+
}
81+
catch (IOException ex)
82+
{
83+
return new(FileWriteEnum.FailedToDeleteOldFile, TempPath, ex);
84+
}
85+
}
86+
try
87+
{
88+
File.Move(TempPath, FinalPath);
89+
}
90+
catch (IOException ex)
91+
{
92+
return new(FileWriteEnum.FailedToRename, TempPath, ex);
93+
}
94+
95+
return new(FileWriteEnum.Success, FinalPath, null);
96+
}
97+
98+
/// <summary>
99+
/// Closes and deletes the file. Use if there was an error while writing.
100+
/// Do not call <see cref="CloseAndDispose"/> after this.
101+
/// </summary>
102+
public void Abort()
103+
{
104+
if (_dispoed) throw new ObjectDisposedException("Cannot use a disposed file stream.");
105+
_finished = true;
106+
Dispose();
107+
108+
try
109+
{
110+
// Delete because the file is almost certainly useless and just clutter.
111+
File.Delete(TempPath);
112+
}
113+
catch { /* eat? this is probably not very important */ }
114+
}
115+
116+
private bool _dispoed;
117+
public void Dispose()
118+
{
119+
if (_dispoed) return;
120+
_dispoed = true;
121+
122+
_stream!.Flush(flushToDisk: true);
123+
_stream.Dispose();
124+
_stream = null;
125+
126+
// The caller should call CloseAndDispose and handle potential failure.
127+
Debug.Assert(_finished, $"{nameof(FileWriteResult)} should not be disposed before calling {nameof(CloseAndDispose)}");
128+
}
129+
}
130+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
#nullable enable
2+
3+
using System.Diagnostics;
4+
using System.IO;
5+
6+
namespace BizHawk.Client.Common
7+
{
8+
public enum FileWriteEnum
9+
{
10+
Success,
11+
FailedToOpen,
12+
FailedDuringWrite,
13+
FailedToDeleteOldFile,
14+
FailedToRename,
15+
}
16+
17+
/// <summary>
18+
/// Provides information about the success or failure of an attempt to write to a file.
19+
/// </summary>
20+
public class FileWriteResult
21+
{
22+
public readonly FileWriteEnum Error = FileWriteEnum.Success;
23+
public readonly Exception? Exception;
24+
public readonly string WritePath;
25+
26+
public bool IsError => Error != FileWriteEnum.Success;
27+
28+
public FileWriteResult(FileWriteEnum error, string path, Exception? exception)
29+
{
30+
Error = error;
31+
Exception = exception;
32+
WritePath = path;
33+
}
34+
35+
/// <summary>
36+
/// Converts this instance to a different generic type.
37+
/// The new instance will take the value given only if this instance has no error.
38+
/// </summary>
39+
/// <param name="value">The value of the new instance. Ignored if this instance has an error.</param>
40+
public FileWriteResult<T> Convert<T>(T value) where T : class
41+
{
42+
if (Error == FileWriteEnum.Success) return new(value, WritePath);
43+
else return new(this);
44+
}
45+
46+
public FileWriteResult(FileWriteResult other) : this(other.Error, other.WritePath, other.Exception) { }
47+
48+
public string UserFriendlyErrorMessage()
49+
{
50+
switch (Error)
51+
{
52+
case FileWriteEnum.Success:
53+
return $"The file {WritePath} was written successfully.";
54+
case FileWriteEnum.FailedToOpen:
55+
if (WritePath.Contains(".saving"))
56+
{
57+
return $"The temporary file {WritePath} already exists and could not be deleted.";
58+
}
59+
return $"The file {WritePath} could not be created.";
60+
case FileWriteEnum.FailedDuringWrite:
61+
return $"An error occurred while writing the file."; // No file name here; it should be deleted.
62+
case FileWriteEnum.FailedToDeleteOldFile:
63+
string fileWithoutPath = Path.GetFileName(WritePath);
64+
return $"The file {WritePath} was created successfully, but the old file could not be deleted. You may manually rename the temporary file {fileWithoutPath}.";
65+
case FileWriteEnum.FailedToRename:
66+
fileWithoutPath = Path.GetFileName(WritePath);
67+
return $"The file {WritePath} was created successfully, but could not be renamed. You may manually rename the temporary file {fileWithoutPath}.";
68+
default:
69+
return "unreachable";
70+
}
71+
}
72+
}
73+
74+
/// <summary>
75+
/// Provides information about the success or failure of an attempt to write to a file.
76+
/// If successful, also provides a related object instance.
77+
/// </summary>
78+
public class FileWriteResult<T> : FileWriteResult where T : class // Note: "class" also means "notnull".
79+
{
80+
/// <summary>
81+
/// Value will be null if <see cref="FileWriteResult.IsError"/> is true.
82+
/// Otherwise, Value will not be null.
83+
/// </summary>
84+
public readonly T? Value = default;
85+
86+
public FileWriteResult(FileWriteEnum error, string path, Exception? exception) : base(error, path, exception) { }
87+
88+
public FileWriteResult(T value, string path) : base(FileWriteEnum.Success, path, null)
89+
{
90+
Debug.Assert(value != null, "Should not give a null value on success. Use the non-generic type if there is no value.");
91+
Value = value;
92+
}
93+
94+
public FileWriteResult(FileWriteResult other) : base(other.Error, other.WritePath, other.Exception) { }
95+
}
96+
}

0 commit comments

Comments
 (0)