From aacfaf599f0151de96a7ca045b4432ca81256ccc Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 6 Feb 2026 18:36:48 -0500 Subject: [PATCH 1/2] LT-22388: Fix NullReferenceException in UnitOfWorkService.SaveOnIdle Add an early IsDisposed check to prevent accessing UI state (LastActivityTime) when the service has been disposed but the timer event still fires. --- src/SIL.LCModel/Infrastructure/Impl/UnitOfWorkService.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/SIL.LCModel/Infrastructure/Impl/UnitOfWorkService.cs b/src/SIL.LCModel/Infrastructure/Impl/UnitOfWorkService.cs index d54ee933..3ef107fa 100644 --- a/src/SIL.LCModel/Infrastructure/Impl/UnitOfWorkService.cs +++ b/src/SIL.LCModel/Infrastructure/Impl/UnitOfWorkService.cs @@ -226,6 +226,9 @@ void SaveOnIdle(object sender, ElapsedEventArgs e) // Check if we are already in SaveInternal. if (m_fInSaveInternal) return; + // Check if we are disposed before accessing m_ui + if (IsDisposed) + return; // Don't save if we're in the middle of something and not in the right state to Save! if (UndoOrRedoInProgress || CurrentProcessingState != BusinessTransactionState.ReadyForBeginTask) return; // don't start another, if for example the conflict dialog is open. From 554188931c6bda0726de9f29f6884e4a57b9fe8b Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 6 Feb 2026 18:36:48 -0500 Subject: [PATCH 2/2] Stabilize UnitOfWorkService autosave tests Cover IsDisposed and null UI paths in SaveOnIdle Filter ICU DLL paths for deterministic CustomIcuFallbackTests Add AGENTS onboarding notes and update workspace settings --- .vscode/settings.json | 5 +- AGENTS.md | 127 ++++++++++++++++++ .../Infrastructure/Impl/UnitOfWorkService.cs | 3 + .../Text/CustomIcuFallbackTests.cs | 28 +++- .../Impl/UnitOfWorkServiceTests.cs | 45 +++++++ 5 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 AGENTS.md create mode 100644 tests/SIL.LCModel.Tests/Infrastructure/Impl/UnitOfWorkServiceTests.cs diff --git a/.vscode/settings.json b/.vscode/settings.json index a4d5219c..0e63d507 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,5 @@ { - "csharp.unitTestDebuggingOptions": { - // It would be preferable to "fix" the test projects to target an executable .net core framework (https://stackoverflow.com/a/48885500/2301416) - "type": "clr" // https://github.com/OmniSharp/omnisharp-vscode/wiki/Desktop-.NET-Framework#settingsjson-example + "dotnet.unitTestDebuggingOptions": { + "type": "clr" } } \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..b32e91b8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,127 @@ +# AGENTS: liblcm (LCM) + +## Summary +liblcm (LCM) is the core FieldWorks Language & Culture Model library for linguistic analyses. It provides the data model, serialization, utilities, and tooling for linguistic, anthropological, and text corpus data. It is a multi-project .NET solution with code generation steps and multi-targeting for legacy .NET Framework and modern .NET. + +## High-level repo facts +- Type: .NET solution (multi-project class libraries + build tasks + tools + tests). +- Languages: C# (.cs), MSBuild (.proj/.csproj/.props/.targets), XML, shell/batch scripts. +- Target frameworks: net462, netstandard2.0, net8.0 (see .csproj files in src/ and tests/). +- Build tools: MSBuild, dotnet SDK, GitVersion.MsBuild, NUnit. +- Output: artifacts/ (NuGet packages and binaries by configuration/TFM). + +## Build and validation (validated commands and observations) + +### What CI runs (GitHub Actions) +CI runs on Windows and Ubuntu. See .github/workflows/ci-cd.yml: +1) Install .NET SDK 8.x. +2) Ubuntu: install mono-devel and icu-fw packages. +3) Windows: remove c:\tools\php\icuuc*.dll; install .NET Framework 4.6.1 targeting pack. +4) Build: dotnet build --configuration Release +5) Test: + - Linux: . environ && dotnet test --no-restore --no-build -p:ParallelizeAssembly=false --configuration Release + - Windows: dotnet test --no-restore --no-build -p:ParallelizeAssembly=false --configuration Release +6) Pack: dotnet pack --include-symbols --no-restore --no-build -p:SymbolPackageFormat=snupkg --configuration Release + +Always mirror this sequence when validating a change locally. + +### Local build scripts (not validated here) +- Windows: build.cmd [Debug|Release] [Target] (uses MSBuild on LCM.sln). +- Linux: build.sh [Debug|Release] [Target] (sources environ, uses msbuild on LCM.sln). +These scripts call build/LCM.proj targets (Build/Test/Pack). If you use them, always run from repo root. + +### Tests per README (not validated here) +- Windows, ReSharper: open LCM.sln and “Run Unit Tests”. +- Windows, no ReSharper: use MSBuild, then run nunit3-console.exe from artifacts/Debug/net462. +- Linux terminal: source environ, then run mono with nunit3-console.exe on *Tests.dll in artifacts/Debug/net462. + +### Commands actually run during onboarding +- dotnet test .\LCM.sln → FAILED +- dotnet build --configuration Release → FAILED +Failure signature (both commands): GitVersion.MsBuild (netcoreapp3.1 gitversion.dll) exited with code 1. This blocks build/test in this environment. CI uses fetch-depth 0, so ensure a full git history is available. If GitVersion still fails, check GitVersion prerequisites and local .NET runtime compatibility. + +No command timeouts were observed. + +### Known prerequisites and gotchas +- GitVersion.MsBuild is used across projects; it requires git metadata. CI checks out with fetch-depth 0. +- net462 builds on Windows require the .NET Framework 4.6.1 targeting pack (CI installs it). +- ICU data generation requires ICU binaries (CI installs icu-fw on Ubuntu). +- Some projects warn on NU1701; treat as warnings unless build breaks. +- The build prohibits references to System.Windows.Forms (CheckWinForms target). + +## Project layout and architecture + +### Key solution and build files +- LCM.sln: solution entry point. +- build.cmd / build.sh: wrapper scripts for MSBuild. +- build/LCM.proj: orchestrated build/test/pack, uses NUnit console on output/ for legacy builds. +- Directory.Build.props / Directory.Build.targets: repo-wide build settings and packaging. +- Directory.Solution.props / Directory.Solution.targets: solution-level defaults. +- GitVersion.yml: GitVersion configuration. +- global.json: SDK roll-forward config. +- .editorconfig: formatting rules. + +### Major source projects (src/) +- src/SIL.LCModel: main LCM library (net462; netstandard2.0). +- src/SIL.LCModel.Core: core utilities and ICU data generation (netstandard2.0; net462; net8.0). +- src/SIL.LCModel.Utils: shared utilities (net462; netstandard2.0). +- src/SIL.LCModel.Build.Tasks: MSBuild tasks used for code generation. +- src/SIL.LCModel.FixData: data-fix utilities. +- src/CSTools: auxiliary tools (pg/lg/Tools). + +Code generation targets to know about: +- SIL.LCModel: GenerateModel (MasterLCModel.xml → Generated*.cs). +- SIL.LCModel.Core: GenerateKernelCs, GenerateIcuData. + +### Tests (tests/) +- SIL.LCModel.Tests +- SIL.LCModel.Core.Tests +- SIL.LCModel.Utils.Tests +- SIL.LCModel.FixData.Tests +- TestHelper (support project) + +### CI/validation checks +- GitHub Actions: .github/workflows/ci-cd.yml (build, test, pack, publish). +- Tests run with dotnet test and ParallelizeAssembly=false. +- Packaging uses dotnet pack with symbol packages. + +### Dependencies not obvious from layout +- ICU data and binaries (icu-fw) for Core ICU generation. +- Mono on Linux for some runtime/test workflows. +- GitVersion.MsBuild for versioning (requires git metadata). + +## Root files list +- .editorconfig +- .gitattributes +- .gitignore +- build.cmd +- build.sh +- CHANGELOG.md +- Directory.Build.props +- Directory.Build.targets +- Directory.Solution.props +- Directory.Solution.targets +- environ +- GitVersion.yml +- global.json +- LCM.sln +- LCM.sln.DotSettings +- LICENSE +- README.md + +## Repo top-level directories +- .github/ (GitHub Actions workflow) +- .vscode/ (VS settings) +- artifacts/ (build outputs) +- build/ (LCM.proj) +- src/ (production code) +- tests/ (unit tests) + +## README highlights (summary) +- Describes liblcm as FieldWorks model library for linguistic analyses. +- Build: use build.cmd (Windows) or build.sh (Linux). Default Debug, optional Release. +- Debugging: use LOCAL_NUGET_REPO to publish local packages; see NuGet local feeds. +- Tests: Windows via ReSharper or NUnit console; Linux via mono + NUnit console (requires environ). + +## Trust these instructions +Follow this file first. Only search the repo if these instructions are incomplete or prove incorrect for your task. diff --git a/src/SIL.LCModel/Infrastructure/Impl/UnitOfWorkService.cs b/src/SIL.LCModel/Infrastructure/Impl/UnitOfWorkService.cs index 3ef107fa..2870253a 100644 --- a/src/SIL.LCModel/Infrastructure/Impl/UnitOfWorkService.cs +++ b/src/SIL.LCModel/Infrastructure/Impl/UnitOfWorkService.cs @@ -229,6 +229,9 @@ void SaveOnIdle(object sender, ElapsedEventArgs e) // Check if we are disposed before accessing m_ui if (IsDisposed) return; + if (m_ui == null) + return; + // Don't save if we're in the middle of something and not in the right state to Save! if (UndoOrRedoInProgress || CurrentProcessingState != BusinessTransactionState.ReadyForBeginTask) return; // don't start another, if for example the conflict dialog is open. diff --git a/tests/SIL.LCModel.Core.Tests/Text/CustomIcuFallbackTests.cs b/tests/SIL.LCModel.Core.Tests/Text/CustomIcuFallbackTests.cs index 7e32b7ca..9a01fa26 100644 --- a/tests/SIL.LCModel.Core.Tests/Text/CustomIcuFallbackTests.cs +++ b/tests/SIL.LCModel.Core.Tests/Text/CustomIcuFallbackTests.cs @@ -164,7 +164,8 @@ public void TearDown() public void FixtureSetUp() { // Undo the PATH that got set by the InitializeIcu attribute - Environment.SetEnvironmentVariable("PATH", InitializeIcuAttribute.PreTestPathEnvironment); + var originalPath = InitializeIcuAttribute.PreTestPathEnvironment; + Environment.SetEnvironmentVariable("PATH", RemoveIcuPaths(originalPath)); _dirsToDelete = new List(); _preTestDataDir = Wrapper.DataDirectory; _preTestDataDirEnv = Environment.GetEnvironmentVariable("ICU_DATA"); @@ -237,6 +238,31 @@ private static void PrintIcuDllsOnPath() } } + private static string RemoveIcuPaths(string path) + { + if (string.IsNullOrEmpty(path)) + return path; + + var filtered = new List(); + foreach (var folder in path.Split(Path.PathSeparator)) + { + if (string.IsNullOrWhiteSpace(folder)) + continue; + try + { + if (Directory.Exists(folder) && Directory.EnumerateFiles(folder, "icuuc*.dll").Any()) + continue; + } + catch + { + // If we can't enumerate the folder, keep it to avoid breaking PATH unexpectedly. + } + filtered.Add(folder); + } + + return string.Join(Path.PathSeparator.ToString(), filtered); + } + [Test] public void InitIcuDataDir_NoIcuLibrary() { diff --git a/tests/SIL.LCModel.Tests/Infrastructure/Impl/UnitOfWorkServiceTests.cs b/tests/SIL.LCModel.Tests/Infrastructure/Impl/UnitOfWorkServiceTests.cs new file mode 100644 index 00000000..0a5e1c67 --- /dev/null +++ b/tests/SIL.LCModel.Tests/Infrastructure/Impl/UnitOfWorkServiceTests.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Reflection; +using NUnit.Framework; +using SIL.LCModel; +using SIL.LCModel.Core.KernelInterfaces; + +namespace SIL.LCModel.Infrastructure.Impl +{ + [TestFixture] + public class UnitOfWorkServiceTests : MemoryOnlyBackendProviderTestBase + { + [Test] + public void SaveOnIdle_UiCleared_DoesNotThrow() + { + var uowService = Cache.ServiceLocator.GetInstance(); + var serviceInstance = (object)uowService; + + InvokeNonPublicVoid(serviceInstance, "StopSaveTimer"); + SetNonPublicField(serviceInstance, "m_ui", null); + + Assert.DoesNotThrow(() => + InvokeNonPublicVoid(serviceInstance, "SaveOnIdle", null, null)); + } + + private static void SetNonPublicField(object instance, string fieldName, object value) + { + var field = instance.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + if (field == null) + Assert.Fail("Field not found: " + fieldName); + field.SetValue(instance, value); + } + + private static void InvokeNonPublicVoid(object instance, string methodName, params object[] args) + { + var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + if (method == null) + Assert.Fail("Method not found: " + methodName); + method.Invoke(instance, args); + } + } +}