Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/code-review/02-joblogger-thread-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Priority:** 🔴 Critical
**File:** `src/ArmRipper.Core/Infrastructure/JobLogger.cs`
**Status:** ⬜ Todo
**Status:** ✅ Done

---

Expand Down
6 changes: 3 additions & 3 deletions docs/code-review/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ proposed fix.
| # | Task | Priority | Status | Assignee |
|---|------|----------|--------|----------|
| 1 | [Fix sync-over-async in `StartImportJob`](01-fix-sync-over-async.md) | 🔴 Critical | ✅ Done | — |
| 2 | [Fix `JobLogger` thread-safety](02-joblogger-thread-safety.md) | 🔴 Critical | ⬜ Todo | — |
| 2 | [Fix `JobLogger` thread-safety](02-joblogger-thread-safety.md) | 🔴 Critical | ✅ Done | — |
| 3 | [Fix `CheckMediaPresent` async gap & missing failure state](03-checkmedia-async.md) | 🔴 Critical | ⬜ Todo | — |

### 🟡 Medium (correctness / maintainability)
Expand Down Expand Up @@ -84,9 +84,9 @@ proposed fix.

| Status | Count |
|--------|-------|
| ⬜ Todo | 26 |
| ⬜ Todo | 25 |
| 🔄 In Progress | 0 |
| ✅ Done | 10 |
| ✅ Done | 11 |

---

Expand Down
23 changes: 17 additions & 6 deletions src/ArmRipper.Core/Infrastructure/JobLogger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ public sealed class JobLogger : ILogger
private readonly string _logPath;
private readonly StreamWriter _fileWriter;
private readonly ILogger _inner;
private readonly object _writeLock = new();
private bool _disposed;

public JobLogger(string jobId, string logDirectory, ILogger inner)
{
Expand All @@ -21,7 +23,11 @@ public JobLogger(string jobId, string logDirectory, ILogger inner)
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
var line = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [{logLevel}] {formatter(state, exception)}";
_fileWriter.WriteLine(line);
lock (_writeLock)
{
if (_disposed) return;
_fileWriter.WriteLine(line);
}
_inner.Log(logLevel, eventId, state, exception, formatter);
}

Expand All @@ -31,13 +37,18 @@ public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Except

public void Dispose()
{
_fileWriter.Flush();
_fileWriter.Dispose();
lock (_writeLock)
{
if (_disposed) return;
_disposed = true;
_fileWriter.Flush();
_fileWriter.Dispose();
}
}

public async ValueTask DisposeAsync()
public ValueTask DisposeAsync()
{
await _fileWriter.FlushAsync();
await _fileWriter.DisposeAsync();
Dispose();
return ValueTask.CompletedTask;
}
}
50 changes: 50 additions & 0 deletions tests/ArmRipper.Core.Tests/JobLoggerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using ArmRipper.Core.Infrastructure;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;

namespace ArmRipper.Core.Tests;

public sealed class JobLoggerTests
{
private static JobLogger CreateLogger(out string logPath)
{
var dir = Path.Combine(Path.GetTempPath(), $"joblogger-{Guid.NewGuid():N}");
var jobId = Guid.NewGuid().ToString("N");
logPath = Path.Combine(dir, $"arm_job_{jobId}.log");
return new JobLogger(jobId, dir, NullLogger.Instance);
}

[Fact]
public async Task ConcurrentLogs_AllLinesPreserved()
{
var logger = CreateLogger(out var logPath);

const int messageCount = 200;
var messages = Enumerable.Range(0, messageCount)
.Select(i => $"message-{i}-{new string('x', 100)}")
.ToArray();

await Task.WhenAll(messages.Select(m =>
Task.Run(() => logger.Log(LogLevel.Information, 0, m, null, (s, _) => s!))));

logger.Dispose();

var lines = await File.ReadAllLinesAsync(logPath);
var recovered = lines.Select(l => l.Split("] ").Last()).ToArray();

Assert.Equal(messageCount, lines.Length);
Assert.Equal(messages.OrderBy(m => m), recovered.OrderBy(m => m));
}

[Fact]
public void Log_AfterDispose_DoesNotThrow()
{
var logger = CreateLogger(out var logPath);
logger.Dispose();

var ex = Record.Exception(() =>
logger.Log(LogLevel.Information, 0, "late message", null, (s, _) => s!));

Assert.Null(ex);
}
}