diff --git a/src/Netclaw.Actors.Tests/Sessions/SessionLogActorTests.cs b/src/Netclaw.Actors.Tests/Sessions/SessionLogActorTests.cs index b16a84016..829fcdce7 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SessionLogActorTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SessionLogActorTests.cs @@ -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)); diff --git a/src/Netclaw.Actors/Protocol/SessionLogFile.cs b/src/Netclaw.Actors/Protocol/SessionLogFile.cs index 723303a0e..dc2568874 100644 --- a/src/Netclaw.Actors/Protocol/SessionLogFile.cs +++ b/src/Netclaw.Actors/Protocol/SessionLogFile.cs @@ -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); @@ -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( + 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]); + } + } } } diff --git a/src/Netclaw.Actors/Sessions/SessionLogActor.cs b/src/Netclaw.Actors/Sessions/SessionLogActor.cs index d848f8ce6..bae5712f7 100644 --- a/src/Netclaw.Actors/Sessions/SessionLogActor.cs +++ b/src/Netclaw.Actors/Sessions/SessionLogActor.cs @@ -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); } } @@ -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); } } @@ -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); } }