Skip to content

Commit dabdaf3

Browse files
committed
fix: resolve race condition in parallel file processing
- Defer directory scanning until after Parallel.ForEach completes - Add file deduplication to prevent same file being processed twice - Collect output directories in ConcurrentBag during parallel processing - Scan directories only after all files in batch are fully written This fixes the "file is being used by another process" error that occurred when AddNewFiles scanned directories while files were still being written by other threads. Also improves progress display to show current file being processed.
1 parent 35377ca commit dabdaf3

3 files changed

Lines changed: 439 additions & 11 deletions

File tree

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
using System.Collections.Concurrent;
2+
3+
using LibReFrontier.Abstractions;
4+
5+
using ReFrontier.Jpk;
6+
using ReFrontier.Services;
7+
using ReFrontier.Tests.Mocks;
8+
9+
namespace ReFrontier.Tests.Integration
10+
{
11+
/// <summary>
12+
/// Tests for race conditions in parallel processing.
13+
/// </summary>
14+
public class RaceConditionTests
15+
{
16+
/// <summary>
17+
/// Test that files are not processed while they're still being written.
18+
/// This simulates the scenario where AddNewFiles picks up files that are
19+
/// still being written by another thread.
20+
/// </summary>
21+
[Fact]
22+
public void ProcessMultipleLevels_WithNestedArchives_DoesNotHaveRaceCondition()
23+
{
24+
// Arrange
25+
var fileSystem = new ThreadSafeInMemoryFileSystem();
26+
var logger = new TestLogger();
27+
var codecFactory = new DefaultCodecFactory();
28+
var config = FileProcessingConfig.Default();
29+
var program = new Program(fileSystem, logger, codecFactory, config);
30+
31+
// Create a simple archive that contains JKR files
32+
// When unpacked, the JKR files will be decompressed to .bin files
33+
// This simulates the real-world scenario where race conditions occur
34+
35+
// Create multiple simple archives to increase chance of race condition
36+
for (int i = 0; i < 10; i++)
37+
{
38+
// Create a simple archive with count header
39+
var archiveData = CreateSimpleArchiveWithJkrFiles(i);
40+
fileSystem.AddFile($"/test/archive_{i}.bin", archiveData);
41+
}
42+
43+
var files = fileSystem.GetFiles("/test", "*.bin", System.IO.SearchOption.TopDirectoryOnly);
44+
var args = new InputArguments
45+
{
46+
parallelism = Environment.ProcessorCount,
47+
recursive = true,
48+
createLog = false,
49+
verbose = false
50+
};
51+
52+
// Act - Should not throw due to race condition
53+
var exception = Record.Exception(() =>
54+
{
55+
for (int run = 0; run < 5; run++) // Run multiple times to increase chance of hitting race
56+
{
57+
program.ProcessMultipleLevels(files, args);
58+
}
59+
});
60+
61+
// Assert
62+
Assert.Null(exception);
63+
}
64+
65+
/// <summary>
66+
/// Test that the same file is not processed multiple times.
67+
/// </summary>
68+
[Fact]
69+
public void ProcessMultipleLevels_SameFileNotProcessedTwice()
70+
{
71+
// Arrange
72+
var fileSystem = new ThreadSafeInMemoryFileSystem();
73+
var logger = new TestLogger();
74+
var codecFactory = new DefaultCodecFactory();
75+
var config = FileProcessingConfig.Default();
76+
var program = new Program(fileSystem, logger, codecFactory, config);
77+
78+
// Create test files
79+
var processedFiles = new ConcurrentBag<string>();
80+
for (int i = 0; i < 5; i++)
81+
{
82+
byte[] testData = new byte[100];
83+
fileSystem.AddFile($"/test/file_{i}.bin", testData);
84+
}
85+
86+
var files = fileSystem.GetFiles("/test", "*.bin", System.IO.SearchOption.TopDirectoryOnly);
87+
var args = new InputArguments
88+
{
89+
parallelism = 4,
90+
recursive = false,
91+
createLog = false,
92+
verbose = true
93+
};
94+
95+
// Act
96+
program.ProcessMultipleLevels(files, args);
97+
98+
// Assert - Each file should only appear once in the log
99+
var processMessages = logger.Messages
100+
.Where(m => m.Contains("Processing"))
101+
.ToList();
102+
103+
// Check for duplicates
104+
var distinctMessages = processMessages.Distinct().ToList();
105+
Assert.Equal(distinctMessages.Count, processMessages.Count);
106+
}
107+
108+
/// <summary>
109+
/// Creates a simple archive structure for testing.
110+
/// </summary>
111+
private static byte[] CreateSimpleArchiveWithJkrFiles(int index)
112+
{
113+
// Simple archive format:
114+
// - 4 bytes: entry count
115+
// - For each entry: 4 bytes offset, 4 bytes size
116+
// - Entry data
117+
118+
using var ms = new MemoryStream();
119+
using var bw = new BinaryWriter(ms);
120+
121+
// Just create a minimal valid-looking archive header
122+
// The actual content doesn't matter for this test - we just need
123+
// files to be created and potentially accessed concurrently
124+
int count = 2;
125+
bw.Write(count); // Entry count
126+
127+
int headerSize = 4 + (count * 8); // count + entries
128+
int entry1Size = 32;
129+
int entry2Size = 32;
130+
131+
// Entry 1: offset and size
132+
bw.Write(headerSize);
133+
bw.Write(entry1Size);
134+
135+
// Entry 2: offset and size
136+
bw.Write(headerSize + entry1Size);
137+
bw.Write(entry2Size);
138+
139+
// Entry 1 data - just some bytes
140+
bw.Write(new byte[entry1Size]);
141+
142+
// Entry 2 data - just some bytes
143+
bw.Write(new byte[entry2Size]);
144+
145+
return ms.ToArray();
146+
}
147+
}
148+
149+
/// <summary>
150+
/// Thread-safe version of InMemoryFileSystem for race condition testing.
151+
/// </summary>
152+
public class ThreadSafeInMemoryFileSystem : IFileSystem
153+
{
154+
private readonly ConcurrentDictionary<string, byte[]> _files = new();
155+
private readonly ConcurrentDictionary<string, bool> _directories = new();
156+
private readonly ConcurrentDictionary<string, DateTime> _lastWriteTimes = new();
157+
private readonly ConcurrentDictionary<string, SemaphoreSlim> _fileLocks = new();
158+
159+
public void AddFile(string path, byte[] content, DateTime? lastWriteTime = null)
160+
{
161+
path = NormalizePath(path);
162+
_files[path] = content;
163+
_lastWriteTimes[path] = lastWriteTime ?? DateTime.Now;
164+
165+
var dir = Path.GetDirectoryName(path);
166+
while (!string.IsNullOrEmpty(dir))
167+
{
168+
_directories[NormalizePath(dir)] = true;
169+
dir = Path.GetDirectoryName(dir);
170+
}
171+
}
172+
173+
public bool FileExists(string path) => _files.ContainsKey(NormalizePath(path));
174+
175+
public bool DirectoryExists(string path)
176+
{
177+
path = NormalizePath(path);
178+
return _directories.ContainsKey(path) ||
179+
_files.Keys.Any(f => f.StartsWith(path + "/"));
180+
}
181+
182+
public byte[] ReadAllBytes(string path)
183+
{
184+
path = NormalizePath(path);
185+
if (!_files.TryGetValue(path, out var content))
186+
throw new FileNotFoundException($"File not found: {path}");
187+
return content.ToArray();
188+
}
189+
190+
public void WriteAllBytes(string path, byte[] bytes)
191+
{
192+
path = NormalizePath(path);
193+
_files[path] = bytes.ToArray();
194+
_lastWriteTimes[path] = DateTime.Now;
195+
196+
var dir = Path.GetDirectoryName(path);
197+
while (!string.IsNullOrEmpty(dir))
198+
{
199+
_directories[NormalizePath(dir)] = true;
200+
dir = Path.GetDirectoryName(dir);
201+
}
202+
}
203+
204+
public string[] ReadAllLines(string path)
205+
{
206+
var content = System.Text.Encoding.UTF8.GetString(ReadAllBytes(path));
207+
return content.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None);
208+
}
209+
210+
public void DeleteFile(string path)
211+
{
212+
path = NormalizePath(path);
213+
_files.TryRemove(path, out _);
214+
_lastWriteTimes.TryRemove(path, out _);
215+
}
216+
217+
public void CreateDirectory(string path)
218+
{
219+
path = NormalizePath(path);
220+
_directories[path] = true;
221+
222+
var dir = Path.GetDirectoryName(path);
223+
while (!string.IsNullOrEmpty(dir))
224+
{
225+
_directories[NormalizePath(dir)] = true;
226+
dir = Path.GetDirectoryName(dir);
227+
}
228+
}
229+
230+
public string[] GetFiles(string path, string searchPattern, SearchOption searchOption)
231+
{
232+
path = NormalizePath(path);
233+
var pattern = searchPattern.Replace("*", ".*").Replace("?", ".");
234+
var regex = new System.Text.RegularExpressions.Regex($"^{pattern}$",
235+
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
236+
237+
return _files.Keys
238+
.Where(f =>
239+
{
240+
var normalized = NormalizePath(f);
241+
if (!normalized.StartsWith(path + "/") && normalized != path)
242+
return false;
243+
244+
var relativePath = normalized.Substring(path.Length).TrimStart('/');
245+
var fileName = Path.GetFileName(normalized);
246+
247+
if (searchOption == SearchOption.TopDirectoryOnly && relativePath.Contains('/'))
248+
return false;
249+
250+
return regex.IsMatch(fileName);
251+
})
252+
.ToArray();
253+
}
254+
255+
public DateTime GetLastWriteTime(string path)
256+
{
257+
path = NormalizePath(path);
258+
if (_lastWriteTimes.TryGetValue(path, out var time))
259+
return time;
260+
throw new FileNotFoundException($"File not found: {path}");
261+
}
262+
263+
public FileAttributes GetAttributes(string path)
264+
{
265+
path = NormalizePath(path);
266+
if (_directories.ContainsKey(path))
267+
return FileAttributes.Directory;
268+
if (_files.ContainsKey(path))
269+
return FileAttributes.Normal;
270+
throw new FileNotFoundException($"Path not found: {path}");
271+
}
272+
273+
public long GetFileLength(string path)
274+
{
275+
path = NormalizePath(path);
276+
if (!_files.TryGetValue(path, out var content))
277+
throw new FileNotFoundException($"File not found: {path}");
278+
return content.Length;
279+
}
280+
281+
public Stream OpenRead(string path)
282+
{
283+
return new MemoryStream(ReadAllBytes(path), writable: false);
284+
}
285+
286+
public Stream OpenWrite(string path)
287+
{
288+
path = NormalizePath(path);
289+
return new ThreadSafeWriteStream(this, path);
290+
}
291+
292+
public Stream Create(string path) => OpenWrite(path);
293+
294+
public void Copy(string sourceFileName, string destFileName)
295+
{
296+
var content = ReadAllBytes(sourceFileName);
297+
WriteAllBytes(destFileName, content);
298+
}
299+
300+
public StreamWriter CreateStreamWriter(string path)
301+
{
302+
return new StreamWriter(OpenWrite(path));
303+
}
304+
305+
public StreamWriter CreateStreamWriter(string path, bool append, System.Text.Encoding encoding)
306+
{
307+
if (append && FileExists(path))
308+
{
309+
var existing = ReadAllBytes(path);
310+
var stream = new ThreadSafeWriteStream(this, NormalizePath(path), existing);
311+
return new StreamWriter(stream, encoding);
312+
}
313+
return new StreamWriter(OpenWrite(path), encoding);
314+
}
315+
316+
private static string NormalizePath(string path) => path.Replace('\\', '/').TrimEnd('/');
317+
318+
private class ThreadSafeWriteStream : MemoryStream
319+
{
320+
private readonly ThreadSafeInMemoryFileSystem _fileSystem;
321+
private readonly string _path;
322+
323+
public ThreadSafeWriteStream(ThreadSafeInMemoryFileSystem fileSystem, string path)
324+
{
325+
_fileSystem = fileSystem;
326+
_path = path;
327+
}
328+
329+
public ThreadSafeWriteStream(ThreadSafeInMemoryFileSystem fileSystem, string path, byte[] initialContent)
330+
{
331+
_fileSystem = fileSystem;
332+
_path = path;
333+
Write(initialContent, 0, initialContent.Length);
334+
}
335+
336+
protected override void Dispose(bool disposing)
337+
{
338+
if (disposing)
339+
{
340+
_fileSystem._files[_path] = ToArray();
341+
_fileSystem._lastWriteTimes[_path] = DateTime.Now;
342+
343+
var dir = Path.GetDirectoryName(_path);
344+
while (!string.IsNullOrEmpty(dir))
345+
{
346+
_fileSystem._directories[NormalizePath(dir)] = true;
347+
dir = Path.GetDirectoryName(dir);
348+
}
349+
}
350+
base.Dispose(disposing);
351+
}
352+
353+
private static string NormalizePath(string path) => path.Replace('\\', '/').TrimEnd('/');
354+
}
355+
}
356+
}

0 commit comments

Comments
 (0)