Skip to content

Commit 91ce3ba

Browse files
committed
fix(LT-22388): null-safety in SaveOnIdle and harden FileTransactionLogger
SaveOnIdle crashed with NullReferenceException when m_logger was null (the default state when LCM_TransactionLogPath is unset). The 1-second timer fires, passes all guards, then dereferences m_logger without a null-conditional operator — unlike every other call site in the same file. UnitOfWorkService: - SaveOnIdle L260: m_logger.AddBreadCrumb → m_logger?.AddBreadCrumb FileTransactionLogger (additional hardening): - Guard AddBreadCrumb against null description (prevents NRE on string concat / ToCharArray) - Add disposed-check with double-lock pattern so the timer-thread race against Dispose does not throw ObjectDisposedException - Replace fire-and-forget WriteAsync with synchronous Write+Flush; fix byte-count bug (old code used description.Length+1, truncating on Windows where Environment.NewLine is 2 chars) - Null out m_stream on dispose; make m_lock readonly Tests (4 new, all verified red-then-green): - SaveOnIdle_LoggerNull_DoesNotThrow (regression for LT-22388) - AddBreadCrumb_AfterDispose_DoesNotThrow - AddBreadCrumb_NullDescription_DoesNotThrow - AddBreadCrumb_WritesFullMessageIncludingNewline All 1709 existing tests pass (net462), zero regressions.
1 parent 8ce8338 commit 91ce3ba

4 files changed

Lines changed: 126 additions & 6 deletions

File tree

src/SIL.LCModel/FileTransactionLogger.cs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ namespace SIL.LCModel
77
{
88
internal class FileTransactionLogger : ITransactionLogger, IDisposable
99
{
10-
private object m_lock = new object();
10+
private readonly object m_lock = new object();
1111
private FileStream m_stream;
12+
private bool m_disposed;
1213

1314
internal FileTransactionLogger(string filePath)
1415
{
@@ -22,10 +23,15 @@ internal FileTransactionLogger(string filePath)
2223

2324
public void AddBreadCrumb(string description)
2425
{
26+
if (description == null || m_disposed)
27+
return;
2528
lock (m_lock)
2629
{
27-
m_stream.WriteAsync(Encoding.UTF8.GetBytes((description + Environment.NewLine).ToCharArray()), 0,
28-
description.Length + 1);
30+
if (m_disposed)
31+
return;
32+
var bytes = Encoding.UTF8.GetBytes(description + Environment.NewLine);
33+
m_stream.Write(bytes, 0, bytes.Length);
34+
m_stream.Flush();
2935
}
3036
}
3137

@@ -34,8 +40,13 @@ protected virtual void Dispose(bool disposing)
3440
Debug.WriteLineIf(!disposing, "****** Missing Dispose() call for " + GetType() + ". *******");
3541
lock (m_lock)
3642
{
37-
m_stream?.Flush();
38-
m_stream?.Dispose();
43+
if (!m_disposed)
44+
{
45+
m_stream?.Flush();
46+
m_stream?.Dispose();
47+
m_stream = null;
48+
m_disposed = true;
49+
}
3950
}
4051
}
4152

src/SIL.LCModel/Infrastructure/Impl/UnitOfWorkService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ void SaveOnIdle(object sender, ElapsedEventArgs e)
257257
// If it is less than 2s since the user did something don't save to smooth performance (unless the user has been busy as a beaver for more than 5 minutes)
258258
if (now - m_ui.LastActivityTime < TimeSpan.FromSeconds(2.0) && now < (m_lastSave + TimeSpan.FromMinutes(5)))
259259
return;
260-
m_logger.AddBreadCrumb("Saving from SaveOnIdle");
260+
m_logger?.AddBreadCrumb("Saving from SaveOnIdle");
261261
SaveInternal();
262262
}
263263
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright (c) 2026 SIL International
2+
// This software is licensed under the LGPL, version 2.1 or later
3+
// (http://www.gnu.org/licenses/lgpl-2.1.html)
4+
5+
using System;
6+
using System.IO;
7+
using System.Text;
8+
using NUnit.Framework;
9+
10+
namespace SIL.LCModel
11+
{
12+
[TestFixture]
13+
public class FileTransactionLoggerTests
14+
{
15+
private string m_tempFile;
16+
17+
[SetUp]
18+
public void Setup()
19+
{
20+
m_tempFile = Path.GetTempFileName();
21+
}
22+
23+
[TearDown]
24+
public void Teardown()
25+
{
26+
try
27+
{
28+
if (File.Exists(m_tempFile))
29+
File.Delete(m_tempFile);
30+
}
31+
catch
32+
{
33+
// best-effort cleanup
34+
}
35+
}
36+
37+
/// <summary>
38+
/// After Dispose(), calling AddBreadCrumb must not throw ObjectDisposedException.
39+
/// In production the timer thread can race against Dispose.
40+
/// </summary>
41+
[Test]
42+
public void AddBreadCrumb_AfterDispose_DoesNotThrow()
43+
{
44+
var logger = new FileTransactionLogger(m_tempFile);
45+
logger.Dispose();
46+
47+
Assert.DoesNotThrow(() => logger.AddBreadCrumb("late write"));
48+
}
49+
50+
/// <summary>
51+
/// Passing a null description must not throw NullReferenceException.
52+
/// </summary>
53+
[Test]
54+
public void AddBreadCrumb_NullDescription_DoesNotThrow()
55+
{
56+
using (var logger = new FileTransactionLogger(m_tempFile))
57+
{
58+
Assert.DoesNotThrow(() => logger.AddBreadCrumb(null));
59+
}
60+
}
61+
62+
/// <summary>
63+
/// The full message including newline must be written, not truncated.
64+
/// On Windows Environment.NewLine is "\r\n" (2 chars) but old code used
65+
/// description.Length + 1, losing the final byte.
66+
/// </summary>
67+
[Test]
68+
public void AddBreadCrumb_WritesFullMessageIncludingNewline()
69+
{
70+
const string message = "hello";
71+
using (var logger = new FileTransactionLogger(m_tempFile))
72+
{
73+
logger.AddBreadCrumb(message);
74+
}
75+
76+
// Wait briefly for async write, then read
77+
System.Threading.Thread.Sleep(200);
78+
79+
string content = File.ReadAllText(m_tempFile, Encoding.UTF8);
80+
string expected = message + Environment.NewLine;
81+
Assert.AreEqual(expected, content,
82+
"AddBreadCrumb should write the full message plus Environment.NewLine");
83+
}
84+
}
85+
}

tests/SIL.LCModel.Tests/Infrastructure/Impl/UnitOfWorkServiceTests.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,30 @@ public void SaveOnIdle_UiCleared_DoesNotThrow()
2626
InvokeNonPublicVoid(serviceInstance, "SaveOnIdle", null, null));
2727
}
2828

29+
/// <summary>
30+
/// Regression test: m_logger is null by default (no LCM_TransactionLogPath env var).
31+
/// SaveOnIdle must use null-conditional on m_logger.AddBreadCrumb to avoid NRE.
32+
/// Reproduces the crash reported in LT-22388.
33+
/// </summary>
34+
[Test]
35+
public void SaveOnIdle_LoggerNull_DoesNotThrow()
36+
{
37+
var uowService = Cache.ServiceLocator.GetInstance<IUnitOfWorkService>();
38+
var serviceInstance = (object)uowService;
39+
40+
InvokeNonPublicVoid(serviceInstance, "StopSaveTimer");
41+
42+
// Force m_logger to null (default production state when LCM_TransactionLogPath is unset)
43+
SetNonPublicField(serviceInstance, "m_logger", null);
44+
45+
// Force m_lastSave far enough in the past to pass BOTH the 10-second guard
46+
// AND the 5-minute "busy beaver" clause so we actually reach m_logger.AddBreadCrumb
47+
SetNonPublicField(serviceInstance, "m_lastSave", DateTime.Now.AddMinutes(-10));
48+
49+
Assert.DoesNotThrow(() =>
50+
InvokeNonPublicVoid(serviceInstance, "SaveOnIdle", null, null));
51+
}
52+
2953
private static void SetNonPublicField(object instance, string fieldName, object value)
3054
{
3155
var field = instance.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic);

0 commit comments

Comments
 (0)