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
11 changes: 1 addition & 10 deletions src/Netclaw.Actors.Tests/Sessions/SessionLogActorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,19 +110,10 @@ await AwaitAssertAsync(async () =>
await ExpectTerminatedAsync(dispatcher1, cancellationToken: TestContext.Current.CancellationToken);

var dispatcher2 = SpawnDispatcher(Sys, basePath, timeProvider);
dispatcher2.Tell(new TextOutput { SessionId = sessionId, Text = "second" }, ActorRefs.NoSender);

// Spin the send inside AwaitAssertAsync so each polling iteration
// re-Tells "second". On Windows the SessionLogActor's AppendLine
// can throw IOException (SHARING_VIOLATION) after the previous
// dispatcher's writer closed but before AV / kernel handle
// cleanup completes — the actor's existing catch-and-Debug-log
// contract drops the message in that case. Re-sending each
// iteration keeps trying until a write lands. Multiple landed
// writes are fine; Assert.Contains is duplicate-tolerant.
await AwaitAssertAsync(async () =>
{
dispatcher2.Tell(new TextOutput { SessionId = sessionId, Text = "second" }, ActorRefs.NoSender);

var logFile = SessionLogFile.GetLogPath(sessionId, basePath);
Assert.True(File.Exists(logFile));
Assert.Single(Directory.GetFiles(Path.GetDirectoryName(logFile)!, "*.log", SearchOption.TopDirectoryOnly));
Expand Down
36 changes: 33 additions & 3 deletions src/Netclaw.Actors/Protocol/SessionLogFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ public static class SessionLogFile
{
public const string FileName = "session.log";

// Bounded retry budget for transient Windows file-sharing conflicts. On NTFS
// a concurrent reader holding the file with FileShare.Read (e.g. File.ReadAllText*,
// tail-f tools, Search Indexer, AV scan-on-close) blocks any FileAccess.Write
// open regardless of the writer's own share mask — share-mode intersection is
// bidirectional and the reader's mask must permit Write. The kernel/AV hand-off
// window is typically sub-10ms; 10/20/40/80ms backoff covers the long tail
// without exceeding an actor's per-message processing budget.
private const int MaxAttempts = 4;
private static readonly int[] BackoffMs = [10, 20, 40, 80];

public static string GetLogsDirectory(SessionId sessionId, string sessionLogsBasePath)
{
var sanitized = SessionDirectoryHelper.SanitizeSessionId(sessionId);
Expand All @@ -33,8 +43,28 @@ public static void AppendLine(SessionId sessionId, string sessionLogsBasePath, s
var logPath = GetLogPath(sessionId, sessionLogsBasePath);
Directory.CreateDirectory(Path.GetDirectoryName(logPath)!);

using var stream = new FileStream(logPath, FileMode.Append, FileAccess.Write, FileShare.Read);
using var writer = new StreamWriter(stream) { AutoFlush = true };
writer.WriteLine(line);
for (var attempt = 0; ; attempt++)
{
try
{
// FileShare.ReadWrite | FileShare.Delete is the canonical log-file mask:
// it lets concurrent readers (tail, audit consumers, tests) coexist and
// lets log rotation / Directory.Delete proceed on Windows. The
// single-writer invariant is enforced by SessionLogActor's mailbox,
// not by this share mask.
using var stream = new FileStream(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM - comment above explains

logPath, FileMode.Append, FileAccess.Write,
FileShare.ReadWrite | FileShare.Delete);
using var writer = new StreamWriter(stream) { AutoFlush = true };
writer.WriteLine(line);
return;
}
catch (Exception ex) when (
(ex is IOException || ex is UnauthorizedAccessException)
&& attempt < MaxAttempts - 1)
{
Thread.Sleep(BackoffMs[attempt]);
}
}
}
}
9 changes: 6 additions & 3 deletions src/Netclaw.Actors/Sessions/SessionLogActor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ private void OnUserMessage(SendUserMessage msg)
}
catch (Exception ex)
{
_log.Debug(ex, "Failed to write user message log entry for {SessionId}", _sessionId.Value);
// AppendLine retries transient IO failures internally; reaching this
// catch means the audit line was lost. Audit-trail loss is a real
// production fault — log loudly, not at Debug.
_log.Warning(ex, "Dropped user message audit line for {SessionId}", _sessionId.Value);
}
}

Expand Down Expand Up @@ -102,7 +105,7 @@ private void OnOutput(SessionOutput output)
}
catch (Exception ex)
{
_log.Debug(ex, "Failed to write session log entry for {SessionId}", _sessionId.Value);
_log.Warning(ex, "Dropped session log audit line for {SessionId}", _sessionId.Value);
}
}

Expand All @@ -114,7 +117,7 @@ private void OnDiagnostic(SessionLogDiagnostic diagnostic)
}
catch (Exception ex)
{
_log.Debug(ex, "Failed to write diagnostic log entry for {SessionId}", _sessionId.Value);
_log.Warning(ex, "Dropped diagnostic audit line for {SessionId}", _sessionId.Value);
}
}

Expand Down
Loading