From 07a7dd2fb5f733a7a4ece57904b02f255e423a35 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Thu, 15 Oct 2026 13:04:26 +0200 Subject: [PATCH 1/3] test: isolate allocation assertions from background GC accounting --- DECISIONS.md | 7 + IMPLEMENTATION_PLAN.md | 8 + Mammoth.LiteMapper.sln | 15 ++ Mammoth.LiteMapper.slnx | 1 + STATUS.md | 8 + docs/ALLOCATION_TESTS.md | 23 +++ docs/USAGE.md | 2 + ...mmoth.LiteMapper.AllocationTestHost.csproj | 10 ++ .../Program.cs | 141 ++++++++++++++++++ .../AllocationTestProcess.cs | 77 ++++++++++ .../AllocationTestProcessTests.cs | 106 +++++++++++++ .../CompactCycleTrackerTests.cs | 4 + .../LazyCyclePathTests.cs | 8 + .../Mammoth.LiteMapper.Generator.Tests.csproj | 19 +++ 14 files changed, 429 insertions(+) create mode 100644 docs/ALLOCATION_TESTS.md create mode 100644 tests/Mammoth.LiteMapper.AllocationTestHost/Mammoth.LiteMapper.AllocationTestHost.csproj create mode 100644 tests/Mammoth.LiteMapper.AllocationTestHost/Program.cs create mode 100644 tests/Mammoth.LiteMapper.Generator.Tests/AllocationTestProcess.cs create mode 100644 tests/Mammoth.LiteMapper.Generator.Tests/AllocationTestProcessTests.cs diff --git a/DECISIONS.md b/DECISIONS.md index 6f3ba9c..84feac8 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,12 @@ # Mammoth.LiteMapper Decision Log +## Issue #58: allocation-only GC isolation (2026-10-15) + +- No product contract, public API, mapping semantics or performance acceptance policy changes. Test-only reflection dispatch reuses the existing test assembly; emitted mapper code remains static. +- .NET 10 background GC can charge unused allocation-context bytes to GC.GetAllocatedBytesForCurrentThread (runtime issues #134724/#134855). Exact single-batch comparisons can therefore fail without additional mapper allocations. Historical CI lacks per-window GC traces; the runtime cause is strongly supported by isolated controls, not retrospectively proven for every event. +- Disable concurrent GC only in a dedicated allocation child process. Keep normal/default GC in the ordinary correctness host. Preserve exact assertions and observable real-allocation controls; do not add tolerances, change equality direction, discard samples or retry until passing. +- Configuration and child assertion failure must be observable in parent tests. A missing host, invalid child request, nonzero exit or bounded-execution failure cannot silently pass. The mitigation is test infrastructure, not a library performance claim or application configuration recommendation. + ## Generator symbol package layout (2026-10-15) - Contract: specification sections 4.1, 22.19 and 24.6 require analyzer isolation, valid shipping packages and portable reproducible symbols. No public API or mapping semantic change is needed. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 06f0a42..411e267 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,5 +1,13 @@ # Mammoth.LiteMapper Implementation Plan +## Issue #58: isolate allocation-regression execution (2026-10-15) + +- Scope: a separate test-only child host with background GC disabled. Keep default GC for ordinary tests, all existing exact assertions, and product/runtime mapping behavior unchanged. +- Test-first: verify effective child configuration, parent isolation, zero and real allocation controls, and propagation of an intentional exact-allocation assertion failure. Exercise boolean data rows and fail closed for invalid child execution. +- Implementation: route only the allocation-sensitive methods through the child, reuse the same test assembly, bound execution and pipe reads, and copy the host with the test build in both solution formats. No tolerance or retry-until-pass. +- Validation: repeated focused allocation checks, complete correctness tests and Roslyn 4.8/4.14/5.9 hosts, independent review, then draft PR to active develop and exact-head CI. Exclude local timing benchmarks; preserve workflows and gates. Packaging work is separate. +- Boundary: isolated feat/codex-allocation-test-isolation originally branched from develop a0e21684000653ae7445d9352e22df0fbd02f642 after 4.0.1 publication, then rebased onto develop c3ef53ec3762d825c2945cbc6394af310bda1933 after merged symbols PR #59. No merge, tag, publication or machine setting changes. + ## Generator symbol package repair (2026-10-15) - Scope: user-authorized packaging maintenance on an isolated branch from develop after the 4.0.1 release. Prepare corrected 4.0.1 symbols separately, then open a draft PR against develop; the release branch has been removed. This is direct maintenance, not a claimed tracker item. diff --git a/Mammoth.LiteMapper.sln b/Mammoth.LiteMapper.sln index cda0c99..354abce 100644 --- a/Mammoth.LiteMapper.sln +++ b/Mammoth.LiteMapper.sln @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mammoth.LiteMapper.Generato EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mammoth.LiteMapper.AllocationTestHost", "tests\Mammoth.LiteMapper.AllocationTestHost\Mammoth.LiteMapper.AllocationTestHost.csproj", "{0D2AF503-DF26-4ED3-9BCD-87A27875F620}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mammoth.LiteMapper.Generator.Tests", "tests\Mammoth.LiteMapper.Generator.Tests\Mammoth.LiteMapper.Generator.Tests.csproj", "{490B9D73-BD11-409A-AD32-5AC2D4946CF3}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mammoth.LiteMapper.Runtime.Tests", "tests\Mammoth.LiteMapper.Runtime.Tests\Mammoth.LiteMapper.Runtime.Tests.csproj", "{0A429483-44E1-4590-B63A-1A8FBC9CC417}" @@ -85,6 +87,18 @@ Global {674F176D-B422-48A7-8806-342285AE7BB2}.Release|x64.Build.0 = Release|Any CPU {674F176D-B422-48A7-8806-342285AE7BB2}.Release|x86.ActiveCfg = Release|Any CPU {674F176D-B422-48A7-8806-342285AE7BB2}.Release|x86.Build.0 = Release|Any CPU + {0D2AF503-DF26-4ED3-9BCD-87A27875F620}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0D2AF503-DF26-4ED3-9BCD-87A27875F620}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0D2AF503-DF26-4ED3-9BCD-87A27875F620}.Debug|x64.ActiveCfg = Debug|Any CPU + {0D2AF503-DF26-4ED3-9BCD-87A27875F620}.Debug|x64.Build.0 = Debug|Any CPU + {0D2AF503-DF26-4ED3-9BCD-87A27875F620}.Debug|x86.ActiveCfg = Debug|Any CPU + {0D2AF503-DF26-4ED3-9BCD-87A27875F620}.Debug|x86.Build.0 = Debug|Any CPU + {0D2AF503-DF26-4ED3-9BCD-87A27875F620}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0D2AF503-DF26-4ED3-9BCD-87A27875F620}.Release|Any CPU.Build.0 = Release|Any CPU + {0D2AF503-DF26-4ED3-9BCD-87A27875F620}.Release|x64.ActiveCfg = Release|Any CPU + {0D2AF503-DF26-4ED3-9BCD-87A27875F620}.Release|x64.Build.0 = Release|Any CPU + {0D2AF503-DF26-4ED3-9BCD-87A27875F620}.Release|x86.ActiveCfg = Release|Any CPU + {0D2AF503-DF26-4ED3-9BCD-87A27875F620}.Release|x86.Build.0 = Release|Any CPU {490B9D73-BD11-409A-AD32-5AC2D4946CF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {490B9D73-BD11-409A-AD32-5AC2D4946CF3}.Debug|Any CPU.Build.0 = Debug|Any CPU {490B9D73-BD11-409A-AD32-5AC2D4946CF3}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -213,6 +227,7 @@ Global {4B4EC83E-2794-469A-87BB-D152A047BDA9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {723164FA-7035-46C7-B349-B154ABAC9139} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {674F176D-B422-48A7-8806-342285AE7BB2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {0D2AF503-DF26-4ED3-9BCD-87A27875F620} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {490B9D73-BD11-409A-AD32-5AC2D4946CF3} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {0A429483-44E1-4590-B63A-1A8FBC9CC417} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {DF2A75F8-6465-4363-8122-D496115D1993} = {0AB3BF05-4346-4AA6-1389-037BE0695223} diff --git a/Mammoth.LiteMapper.slnx b/Mammoth.LiteMapper.slnx index bdb4250..30693f2 100644 --- a/Mammoth.LiteMapper.slnx +++ b/Mammoth.LiteMapper.slnx @@ -18,6 +18,7 @@ + diff --git a/STATUS.md b/STATUS.md index f28f7dd..83c95c1 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,5 +1,13 @@ # Mammoth.LiteMapper Status +## Issue #58: isolated allocation regressions (2026-10-15) + +- Scope: test-only allocation host disables background GC, validates effective configuration, reuses the selected existing assertion body once and fails the parent on child errors or a bounded timeout. Only three existing methods/four rows dispatch through it. Exact assertions, default-GC functional coverage, product sources, workflows and performance gates are unchanged. +- Test-first evidence: four control cases fail before isolation. The integrated focused set passes 8/8, followed by ten complete repetitions (80/80), zero failures/skips. Exact controls distinguish 0 and 64 bytes; the intentionally incorrect zero-allocation assertion fails in the child and its unique message plus real allocation count reach the parent. A copied-runtimeconfig negative probe fails before invocation when concurrent GC is enabled, then restores the original bytes. +- Correctness: Release solution build and both solution path lists pass. Full generator suites pass 1,091/1,091 each on Roslyn 4.8.0, 4.14.0 and 5.9.0; runtime 5/5 and integration 1/1 pass, all with zero failures/skips. Independent read-only static review is clean after correcting the test-host dependency closure. Two pre-existing transitive System.Security.Cryptography.Pkcs 9.0.6 audit warnings remain (NU1902/NU1903). +- Limits: the full local solution-test attempt was stopped after packaging stalled in its first dotnet pack command; completed correctness TRX results are retained, and packaging is not represented as complete. Its separate repair is out of scope. No local benchmark timing runs. The mitigation does not prove throughput or retrospectively establish every historical CI spike's cause. +- Delivery: isolated feat/codex-allocation-test-isolation originally branched from develop a0e21684000653ae7445d9352e22df0fbd02f642 after 4.0.1 publication, then rebased onto develop c3ef53ec3762d825c2945cbc6394af310bda1933 containing merged symbols PR #59. PR #60 remains open for human review; current exact-head CI evidence is recorded there. No merge, tag, publication or persistent machine-setting changes. + ## Generator symbol packaging checkpoint (2026-10-15) - Scope: direct user-authorized maintenance from develop after 4.0.1 publication. The old release branch is absent. The isolated fix branch targets develop and stops at draft PR/human review. diff --git a/docs/ALLOCATION_TESTS.md b/docs/ALLOCATION_TESTS.md new file mode 100644 index 0000000..cfc8804 --- /dev/null +++ b/docs/ALLOCATION_TESTS.md @@ -0,0 +1,23 @@ +# Allocation regression tests + +The generator test suite includes exact allocation-count regressions for recursive mapping. Run them through MSTest as usual: + +```sh +dotnet restore Mammoth.LiteMapper.sln +dotnet build Mammoth.LiteMapper.sln -c Release --no-restore +dotnet test tests/Mammoth.LiteMapper.Generator.Tests/Mammoth.LiteMapper.Generator.Tests.csproj -c Release --no-build --no-restore --filter "TestCategory=Allocation" +``` + +The allocation-sensitive test methods dispatch to the test-only `Mammoth.LiteMapper.AllocationTestHost` process. Its runtime configuration disables background GC (`System.GC.Concurrent=false`). The ordinary generator test host keeps its default GC configuration, and the other correctness tests continue to run there. This configuration does not change LiteMapper packages or applications. + +The child loads the same test assembly and runs the existing assertions once. Boolean data rows are forwarded explicitly. Assertion failures, missing methods, invalid configuration, nonzero process exits and timeouts fail the parent test; there is no retry, tolerance or selection of the lowest measurement. Standard output and error are returned to MSTest for diagnosis. + +## Why isolation is necessary + +[dotnet/runtime#134724](https://github.com/dotnet/runtime/issues/134724) reports phantom `GC.GetAllocatedBytesForCurrentThread` deltas under background GC, including Windows and Ubuntu x64. The [proposed runtime fix](https://github.com/dotnet/runtime/pull/134855) retires unused allocation-context space when background GC clears an abandoned context. This is CLR accounting behavior; the Roslyn version in the CI matrix selects a compiler, while these tests execute on .NET 10. + +The investigation reproduced the counter anomaly in a package-free .NET 10.0.13 program without LiteMapper or Roslyn. Exact historical CI failures did not capture GC traces, so the matching runtime issue is strong supporting evidence rather than a retrospective proof of each spike. Disabling JIT tiering or PGO did not eliminate the local test failures. Extra warm-up, retries or a wider allocation budget do not address the defective accounting path. + +The child verifies its effective GC configuration before executing tests. Regression controls also verify that ordinary-host GC remains unchanged, zero-allocation work reads zero, a real heap allocation is counted exactly, and an intentional exact-allocation assertion failure reaches the parent as a failed assertion. The existing tracker size, promotion, spill-reuse and traversal assertions remain exact. + +This mitigation does not establish throughput or latency performance and is not a substitute for functional cycle-tracking tests under normal GC. It also does not make arbitrary allocation measurements deterministic: allocations must stay outside measurement windows unless they are the behavior being checked. Reassess the mitigation against a verified runtime fix before removing it; retain the positive controls and exact regression assertions. diff --git a/docs/USAGE.md b/docs/USAGE.md index f0fbf42..7b1a663 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -4,6 +4,8 @@ Use Mammoth.LiteMapper to declare compile-time mappings, configure conversions, This guide describes Mammoth.LiteMapper 4.0.1. The installation examples pin that version, and the [compiling samples](../samples/) demonstrate the supported mappings. See the [4.0.1 changelog](../CHANGELOG.md#401) for the patch changes and the [migration guide](#versions-and-upgrading) for earlier releases. +Repository contributors can follow the [allocation regression validation guide](ALLOCATION_TESTS.md). Its GC isolation applies only to test processes; applications need no GC configuration for LiteMapper. + Runnable examples: - `samples/Mammoth.LiteMapper.Samples.Basic/Program.cs` diff --git a/tests/Mammoth.LiteMapper.AllocationTestHost/Mammoth.LiteMapper.AllocationTestHost.csproj b/tests/Mammoth.LiteMapper.AllocationTestHost/Mammoth.LiteMapper.AllocationTestHost.csproj new file mode 100644 index 0000000..5627b99 --- /dev/null +++ b/tests/Mammoth.LiteMapper.AllocationTestHost/Mammoth.LiteMapper.AllocationTestHost.csproj @@ -0,0 +1,10 @@ + + + Exe + net10.0 + false + false + false + false + + diff --git a/tests/Mammoth.LiteMapper.AllocationTestHost/Program.cs b/tests/Mammoth.LiteMapper.AllocationTestHost/Program.cs new file mode 100644 index 0000000..b98587e --- /dev/null +++ b/tests/Mammoth.LiteMapper.AllocationTestHost/Program.cs @@ -0,0 +1,141 @@ +using System; +using System.IO; +using System.Reflection; +using System.Runtime.Loader; + +namespace Mammoth.LiteMapper.AllocationTestHost +{ + internal static class Program + { + private const int InvalidHostConfiguration = 2; + private const int InvalidArguments = 3; + private const int InvocationFailed = 4; + + private static int Main(string[] args) + { + if (!TryParseArguments(args, out var assemblyPath, out var typeName, out var methodName, out var arguments)) + return InvalidArguments; + + if (!GC.GetConfigurationVariables().TryGetValue("ConcurrentGC", out var configuredValue) || + configuredValue is not bool concurrentGc || concurrentGc) + { + Console.Error.WriteLine("AllocationTestHost: ConcurrentGC must be configured as false."); + return InvalidHostConfiguration; + } + + Console.WriteLine("AllocationTestHost: ConcurrentGC=False"); + + var assemblyDirectory = Path.GetDirectoryName(assemblyPath)!; + AssemblyLoadContext.Default.Resolving += (_, assemblyName) => ResolveDependency(assemblyDirectory, assemblyName); + + try + { + var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath); + var type = assembly.GetType(typeName, throwOnError: true, ignoreCase: false)!; + var parameterTypes = new Type[arguments.Length]; + Array.Fill(parameterTypes, typeof(bool)); + var method = type.GetMethod( + methodName, + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance, + binder: null, + types: parameterTypes, + modifiers: null); + + if (method == null) + throw new MissingMethodException(type.FullName, methodName); + if (method.ReturnType != typeof(void)) + throw new InvalidOperationException("Allocation-test methods must be synchronous and return void."); + + object? instance = null; + if (!method.IsStatic) + instance = Activator.CreateInstance(type, nonPublic: true) ?? + throw new InvalidOperationException("The test type could not be constructed."); + + AppContext.SetData("Mammoth.LiteMapper.AllocationTestHost", true); + method.Invoke(instance, arguments); + return 0; + } + catch (TargetInvocationException exception) when (exception.InnerException != null) + { + Console.Error.WriteLine(exception.InnerException); + return InvocationFailed; + } + catch (Exception exception) + { + Console.Error.WriteLine(exception); + return InvocationFailed; + } + } + + private static bool TryParseArguments( + string[] args, + out string assemblyPath, + out string typeName, + out string methodName, + out object?[] arguments) + { + assemblyPath = string.Empty; + typeName = string.Empty; + methodName = string.Empty; + arguments = Array.Empty(); + + if (args.Length < 3 || string.IsNullOrWhiteSpace(args[1]) || string.IsNullOrWhiteSpace(args[2])) + { + WriteUsage(); + return false; + } + + try + { + assemblyPath = Path.GetFullPath(args[0]); + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException or PathTooLongException) + { + Console.Error.WriteLine("AllocationTestHost: invalid test assembly path: " + exception.Message); + return false; + } + + if (!File.Exists(assemblyPath)) + { + Console.Error.WriteLine("AllocationTestHost: test assembly does not exist: " + assemblyPath); + return false; + } + + typeName = args[1]; + methodName = args[2]; + arguments = new object?[args.Length - 3]; + for (var index = 3; index < args.Length; index++) + { + if (args[index] == "true") + arguments[index - 3] = true; + else if (args[index] == "false") + arguments[index - 3] = false; + else + { + Console.Error.WriteLine("AllocationTestHost: boolean arguments must be lowercase true or false."); + return false; + } + } + + return true; + } + + private static Assembly? ResolveDependency(string assemblyDirectory, AssemblyName assemblyName) + { + if (string.IsNullOrEmpty(assemblyName.Name)) + return null; + + var dependencyPath = Path.Combine(assemblyDirectory, assemblyName.Name + ".dll"); + return File.Exists(dependencyPath) + ? AssemblyLoadContext.Default.LoadFromAssemblyPath(dependencyPath) + : null; + } + + private static void WriteUsage() + { + Console.Error.WriteLine( + "Usage: dotnet Mammoth.LiteMapper.AllocationTestHost.dll " + + " [true|false ...]"); + } + } +} diff --git a/tests/Mammoth.LiteMapper.Generator.Tests/AllocationTestProcess.cs b/tests/Mammoth.LiteMapper.Generator.Tests/AllocationTestProcess.cs new file mode 100644 index 0000000..ae2a36e --- /dev/null +++ b/tests/Mammoth.LiteMapper.Generator.Tests/AllocationTestProcess.cs @@ -0,0 +1,77 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Mammoth.LiteMapper.Generator.Tests +{ + internal static class AllocationTestProcess + { + public static bool IsChild => AppContext.GetData("Mammoth.LiteMapper.AllocationTestHost") is true; + + public static bool Run(Type testClass, string methodName, params bool[] arguments) + { + if (IsChild) + { + Assert.AreEqual(false, GC.GetConfigurationVariables()["ConcurrentGC"], + "Allocation assertions must execute with background GC disabled in the child."); + return false; + } + + var assemblyPath = testClass.Assembly.Location; + var directory = Path.GetDirectoryName(assemblyPath)!; + var hostPath = Path.Combine(directory, "AllocationTestHost", "Mammoth.LiteMapper.AllocationTestHost.dll"); + Assert.IsTrue(File.Exists(hostPath), "Build the allocation-test host before running these tests: " + hostPath); + + using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + using var process = new Process(); + process.StartInfo = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet") + { + WorkingDirectory = directory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + process.StartInfo.ArgumentList.Add(hostPath); + process.StartInfo.ArgumentList.Add(assemblyPath); + process.StartInfo.ArgumentList.Add(testClass.FullName!); + process.StartInfo.ArgumentList.Add(methodName); + foreach (var argument in arguments) + process.StartInfo.ArgumentList.Add(argument ? "true" : "false"); + + process.Start(); + var output = process.StandardOutput.ReadToEndAsync(deadline.Token); + var error = process.StandardError.ReadToEndAsync(deadline.Token); + try + { + Task.WhenAll(output, error, process.WaitForExitAsync(deadline.Token)) + .WaitAsync(deadline.Token).GetAwaiter().GetResult(); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + // The child can exit between the status check and termination. + } + Assert.Fail("Allocation-test child timed out: " + testClass.FullName + "." + methodName); + } + + var standardOutput = output.GetAwaiter().GetResult(); + var standardError = error.GetAwaiter().GetResult(); + Console.Write(standardOutput); + Console.Error.Write(standardError); + Assert.AreEqual(0, process.ExitCode, + "Allocation-test child failed: " + testClass.FullName + "." + methodName + + Environment.NewLine + standardOutput + standardError); + return true; + } + } +} diff --git a/tests/Mammoth.LiteMapper.Generator.Tests/AllocationTestProcessTests.cs b/tests/Mammoth.LiteMapper.Generator.Tests/AllocationTestProcessTests.cs new file mode 100644 index 0000000..69411af --- /dev/null +++ b/tests/Mammoth.LiteMapper.Generator.Tests/AllocationTestProcessTests.cs @@ -0,0 +1,106 @@ +using System; +using System.Runtime.CompilerServices; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Mammoth.LiteMapper.Generator.Tests +{ + [TestClass] + public sealed class AllocationTestProcessTests + { + private const string PositiveControlFailureMessage = "The allocation-process positive control must reject a real allocation."; + private static byte[]? _escapedAllocation; + + [TestMethod] + [TestCategory("Allocation")] + public void ParentConcurrentGcSettingIsUnchangedWhenChildDisablesConcurrentGc() + { + Assert.IsTrue(GetConcurrentGcSetting(), "The parent test process must retain the default concurrent GC setting."); + + Assert.IsTrue(AllocationTestProcess.Run(typeof(AllocationTestProcessTests), nameof(VerifyChildSettings))); + + Assert.IsTrue(GetConcurrentGcSetting(), "Launching an allocation-test child must not change the parent GC setting."); + } + + [TestMethod] + [TestCategory("Allocation")] + [DataRow(false)] + [DataRow(true)] + public void NativeAllocationCounterDistinguishesNoWorkFromAnEscapedArray(bool allocate) + { + Assert.IsTrue(AllocationTestProcess.Run( + typeof(AllocationTestProcessTests), + nameof(VerifyNativeAllocationCounter), + allocate)); + } + + [TestMethod] + [TestCategory("Allocation")] + public void ChildAllocationAssertionFailurePropagatesThroughProcessBoundary() + { + var failure = Assert.ThrowsExactly(() => + { + AllocationTestProcess.Run(typeof(AllocationTestProcessTests), nameof(VerifyPositiveControlFailure)); + }); + + StringAssert.Contains(failure.Message, PositiveControlFailureMessage); + StringAssert.Contains(failure.Message, "64"); + } + + private static void VerifyChildSettings() + { + Assert.IsTrue(AllocationTestProcess.IsChild, "The verification method must run in the isolated child process."); + Assert.IsFalse(GetConcurrentGcSetting(), "Allocation-test children must disable concurrent GC before running the selected method."); + } + + private static void VerifyNativeAllocationCounter(bool allocate) + { + WarmUpKnownAllocation(); + _ = GC.GetAllocatedBytesForCurrentThread(); + + var before = GC.GetAllocatedBytesForCurrentThread(); + if (allocate) + { + CreateEscapedKnownAllocation(); + } + + var actual = GC.GetAllocatedBytesForCurrentThread() - before; + GC.KeepAlive(_escapedAllocation); + + Assert.AreEqual(allocate ? 64L : 0L, actual, "The native thread allocation counter must expose exact, noise-free measurements in the isolated child."); + } + + private static void VerifyPositiveControlFailure() + { + WarmUpKnownAllocation(); + _ = GC.GetAllocatedBytesForCurrentThread(); + + var before = GC.GetAllocatedBytesForCurrentThread(); + CreateEscapedKnownAllocation(); + var actual = GC.GetAllocatedBytesForCurrentThread() - before; + GC.KeepAlive(_escapedAllocation); + + Assert.AreEqual(0L, actual, PositiveControlFailureMessage); + } + + private static bool GetConcurrentGcSetting() + { + return (bool)GC.GetConfigurationVariables()["ConcurrentGC"]; + } + + private static void WarmUpKnownAllocation() + { + for (var index = 0; index < 16; index++) + { + CreateEscapedKnownAllocation(); + } + + _escapedAllocation = null; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void CreateEscapedKnownAllocation() + { + _escapedAllocation = new byte[37]; + } + } +} diff --git a/tests/Mammoth.LiteMapper.Generator.Tests/CompactCycleTrackerTests.cs b/tests/Mammoth.LiteMapper.Generator.Tests/CompactCycleTrackerTests.cs index 644703d..fd8f46c 100644 --- a/tests/Mammoth.LiteMapper.Generator.Tests/CompactCycleTrackerTests.cs +++ b/tests/Mammoth.LiteMapper.Generator.Tests/CompactCycleTrackerTests.cs @@ -325,8 +325,12 @@ private sealed class ThrowingSet : System.Collections.Generic.HashSet } [TestMethod] + [TestCategory("Allocation")] public void CompactLayoutAndGeneratedTraversalAllocationsMatchOriginalOncePromoted() { + if (AllocationTestProcess.Run(typeof(CompactCycleTrackerTests), nameof(CompactLayoutAndGeneratedTraversalAllocationsMatchOriginalOncePromoted))) + return; + var result = Generate(ChainFixture(true) + @" public static class Probe { diff --git a/tests/Mammoth.LiteMapper.Generator.Tests/LazyCyclePathTests.cs b/tests/Mammoth.LiteMapper.Generator.Tests/LazyCyclePathTests.cs index cd6cd4e..d9cea92 100644 --- a/tests/Mammoth.LiteMapper.Generator.Tests/LazyCyclePathTests.cs +++ b/tests/Mammoth.LiteMapper.Generator.Tests/LazyCyclePathTests.cs @@ -14,10 +14,14 @@ namespace Mammoth.LiteMapper.Generator.Tests public sealed class LazyCyclePathTests { [TestMethod] + [TestCategory("Allocation")] [DataRow(false)] [DataRow(true)] public void SuccessfulChainAllocationsScaleLinearlyAtSafeDepths(bool tracking) { + if (AllocationTestProcess.Run(typeof(LazyCyclePathTests), nameof(SuccessfulChainAllocationsScaleLinearlyAtSafeDepths), tracking)) + return; + var result = Generate(ChainFixture(tracking) + @" public static class Probe { @@ -59,8 +63,12 @@ private static void Validate(NodeDto? value, int depth) } [TestMethod] + [TestCategory("Allocation")] public void ShallowAndWideTraversalAllocationControls() { + if (AllocationTestProcess.Run(typeof(LazyCyclePathTests), nameof(ShallowAndWideTraversalAllocationControls))) + return; + var result = Generate(ChainFixture(true) + @" public static class Probe { diff --git a/tests/Mammoth.LiteMapper.Generator.Tests/Mammoth.LiteMapper.Generator.Tests.csproj b/tests/Mammoth.LiteMapper.Generator.Tests/Mammoth.LiteMapper.Generator.Tests.csproj index 0ac6047..1cde43e 100644 --- a/tests/Mammoth.LiteMapper.Generator.Tests/Mammoth.LiteMapper.Generator.Tests.csproj +++ b/tests/Mammoth.LiteMapper.Generator.Tests/Mammoth.LiteMapper.Generator.Tests.csproj @@ -20,6 +20,7 @@ + @@ -27,4 +28,22 @@ <_Parameter2>$(RoslynTestVersion) + + + + + + <_AllocationTestHostDirectory>@(_AllocationTestHostTargetPath->'%(RootDir)%(Directory)') + + + <_AllocationTestHostFiles Include="$(_AllocationTestHostDirectory)*.dll" /> + <_AllocationTestHostFiles Include="@(_AllocationTestHostTargetPath->'%(RootDir)%(Directory)%(Filename).deps.json')" /> + <_AllocationTestHostFiles Include="@(_AllocationTestHostTargetPath->'%(RootDir)%(Directory)%(Filename).runtimeconfig.json')" /> + + + From b9ae8904a946d587f0f0673a7bee6a2935685ef2 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Thu, 15 Oct 2026 15:54:28 +0200 Subject: [PATCH 2/3] test: improve smoke timeout diagnostics and bounded cleanup --- DECISIONS.md | 7 + IMPLEMENTATION_PLAN.md | 7 + STATUS.md | 8 + docs/USAGE.md | 2 +- .../Milestone15SamplesAndBenchmarksTests.cs | 6 +- .../ProcessFixture/Program.cs | 75 +++++++++ .../TestProcess.cs | 97 ++++++++++- .../TestProcessTests.cs | 157 +++++++++++++++++- 8 files changed, 347 insertions(+), 12 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 84feac8..bb37a65 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,12 @@ # Mammoth.LiteMapper Decision Log +## Issue #58 follow-up: benchmark smoke liveness (2026-10-15) + +- The user approved a benchmark-only five-minute process deadline after exact-head Windows CI 38739919081 exceeded the old 60-second limit. This bounds startup, outer build, BenchmarkDotNet generated restore/build, Dry execution, reporting and pipe closure; it is not a throughput gate or a measured runtime percentile. Sample process limits remain 60 seconds. +- Packaging TestProcess keeps complete successful stdout/stderr. Timeout messages retain only the last 4,096 characters of each stream, configured/elapsed time, termination action and cleanup status. Execution and pipe completion share the primary deadline; a separate one-second cleanup wait allows post-kill exit and draining without an unbounded retry. +- A direct process that has already exited cannot provide a reliable portable descendant-termination handle. Bound and cancel local pipe reads, report cleanup incomplete when handles remain open, and do not claim orphan cleanup was confirmed. Fixtures explicitly clean up their own known descendants. +- Preserve the existing assertions, normal-GC coverage, allocation host and symbols/publication checks. The allocation host's separate deadline is unchanged. No product/specification semantics, benchmark algorithms, numeric performance thresholds, workflow gates or system settings change. + ## Issue #58: allocation-only GC isolation (2026-10-15) - No product contract, public API, mapping semantics or performance acceptance policy changes. Test-only reflection dispatch reuses the existing test assembly; emitted mapper code remains static. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 411e267..cba9116 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,5 +1,12 @@ # Mammoth.LiteMapper Implementation Plan +## Issue #58 follow-up: smoke process deadlines (2026-10-15) + +- Authorized scope: benchmark smoke only receives a 300-second completion deadline; sample calls retain 60 seconds. Preserve all success assertions, allocation isolation, symbols validation and workflow gates. No local timing benchmarks. +- Test-first: subprocess fixtures cover retained bounded stdout/stderr tails, successful full-output compatibility, direct-child termination and inherited pipes after direct-child exit. Keep finite fixture watchdogs and clean up only owned fixture processes. +- Implementation: drain streams incrementally while retaining complete successful output, add bounded timeout tails and elapsed/cleanup diagnostics, and allow one second after termination request for exit and pipe closure. If inherited descendants cannot be confirmed terminated after the direct child exits, report incomplete cleanup rather than claim success. +- Validation: focused process regressions against the old helper first, repeat focused checks after implementation, broader correctness/package checks excluding the local benchmark launcher, independent static review, then push the same PR and monitor exact-head CI. No merge, closure, publication or machine setting changes. + ## Issue #58: isolate allocation-regression execution (2026-10-15) - Scope: a separate test-only child host with background GC disabled. Keep default GC for ordinary tests, all existing exact assertions, and product/runtime mapping behavior unchanged. diff --git a/STATUS.md b/STATUS.md index 83c95c1..663eeae 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,5 +1,13 @@ # Mammoth.LiteMapper Status +## Issue #58 follow-up: smoke timeout diagnostics (2026-10-15) + +- User-approved scope: only BenchmarkHarnessRunsDryJobAndRecordsPinnedComparisons receives a 300-second process deadline; sample calls retain 60 seconds. All existing success assertions, allocation isolation, symbols/publication validation, benchmark algorithms and workflow settings remain unchanged. +- Red-first: all three new subprocess cases fail against the old helper while four existing controls pass. Integrated tests retain full successful 1 MiB stderr output, bound timeout tails to 4,096 characters per stream, confirm direct/tree cleanup and distinguish an exited parent with inherited pipe holders. +- Implementation: incremental concurrent capture, one execution deadline for process exit and both pipes, then at most one second for cleanup/draining after a termination request. Timeout diagnostics include configured/elapsed time, termination action and cleanup status. When the direct process already exited, descendant termination remains unconfirmed and incomplete cleanup is reported explicitly. Test fixtures clean up only recorded PID/start-time identities. +- Validation: Release solution build passes with zero errors and two existing transitive dependency audit warnings. Final process regressions pass three complete repetitions (21/21), allocation preservation 8/8, and broader process/release/publication safeguards 248/248, zero failures/skips. Independent read-only static review is clean after correcting Windows fixture handle inheritance and ordering readiness before the new fixture deadlines. +- Limits and delivery: local timing benchmarks and nested real-build/package-launching cases are excluded; final full Windows/Linux, Roslyn, package, trim and required Native AOT evidence belongs to the exact-head PR CI. Five minutes is a bounded smoke liveness budget, not a measured percentile or performance gate. Update existing PR #60 for human review only; no merge, closure, publication or machine/system settings changes. + ## Issue #58: isolated allocation regressions (2026-10-15) - Scope: test-only allocation host disables background GC, validates effective configuration, reuses the selected existing assertion body once and fails the parent on child errors or a bounded timeout. Only three existing methods/four rows dispatch through it. Exact assertions, default-GC functional coverage, product sources, workflows and performance gates are unchanged. diff --git a/docs/USAGE.md b/docs/USAGE.md index 7b1a663..97820f8 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -4,7 +4,7 @@ Use Mammoth.LiteMapper to declare compile-time mappings, configure conversions, This guide describes Mammoth.LiteMapper 4.0.1. The installation examples pin that version, and the [compiling samples](../samples/) demonstrate the supported mappings. See the [4.0.1 changelog](../CHANGELOG.md#401) for the patch changes and the [migration guide](#versions-and-upgrading) for earlier releases. -Repository contributors can follow the [allocation regression validation guide](ALLOCATION_TESTS.md). Its GC isolation applies only to test processes; applications need no GC configuration for LiteMapper. +Repository contributors can follow the [allocation regression validation guide](ALLOCATION_TESTS.md). Its GC isolation applies only to test processes; applications need no GC configuration for LiteMapper. Contributor process validation gives the BenchmarkDotNet smoke test a five-minute completion deadline and sample runs 60 seconds; timeout diagnostics include bounded output tails and cleanup status. These are process liveness checks, not throughput gates. Runnable examples: diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone15SamplesAndBenchmarksTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone15SamplesAndBenchmarksTests.cs index a1bcc93..0bab9c9 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone15SamplesAndBenchmarksTests.cs +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone15SamplesAndBenchmarksTests.cs @@ -17,7 +17,7 @@ public void SamplesCompileAndRun() [TestMethod] public void BenchmarkHarnessRunsDryJobAndRecordsPinnedComparisons() { - var result = RunDotnet("run --project benchmarks\\Mammoth.LiteMapper.Benchmarks\\Mammoth.LiteMapper.Benchmarks.csproj -c Release --no-restore -- --filter *FlatObjectBenchmarks* --job Dry --warmupCount 1 --iterationCount 1", Repository.Root); + var result = RunDotnet("run --project benchmarks\\Mammoth.LiteMapper.Benchmarks\\Mammoth.LiteMapper.Benchmarks.csproj -c Release --no-restore -- --filter *FlatObjectBenchmarks* --job Dry --warmupCount 1 --iterationCount 1", Repository.Root, TimeSpan.FromSeconds(300)); StringAssert.Contains(result.Output, "Manual"); StringAssert.Contains(result.Output, "LiteMapper"); @@ -29,9 +29,9 @@ public void BenchmarkHarnessRunsDryJobAndRecordsPinnedComparisons() Assert.IsFalse(result.Output.Contains("There are not any results runs", StringComparison.OrdinalIgnoreCase), result.Output); } - private static ProcessResult RunDotnet(string arguments, string workingDirectory) + private static ProcessResult RunDotnet(string arguments, string workingDirectory, TimeSpan? timeout = null) { - var result = TestProcess.Run("dotnet", arguments, workingDirectory, TimeSpan.FromSeconds(60)); + var result = TestProcess.Run("dotnet", arguments, workingDirectory, timeout ?? TimeSpan.FromSeconds(60)); Assert.AreEqual(0, result.ExitCode, "dotnet " + arguments + Environment.NewLine + result.Output + Environment.NewLine + result.Error); return result; } diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/ProcessFixture/Program.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/ProcessFixture/Program.cs index 0aa0933..e996579 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/ProcessFixture/Program.cs +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/ProcessFixture/Program.cs @@ -42,8 +42,83 @@ using (var child = Process.Start(start)) { } Console.Out.Write("parent-exited"); break; + case "timeout-output": + WriteIdentity(args[1]); + Console.Out.Write("stdout-head-marker|" + new string('o', 8192) + "|stdout-tail-marker"); + Console.Error.Write("stderr-head-marker|" + new string('e', 8192) + "|stderr-tail-marker"); + Console.Out.Flush(); + Console.Error.Flush(); + Thread.Sleep(8000); + break; + case "tree-timeout": + WriteIdentity(args[1]); + using (var child = StartFixture("tree-child", args[2])) + { + WaitForIdentity(args[2], child); + Console.Out.Write("tree-parent-stdout-ready"); + Console.Error.Write("tree-parent-stderr-ready"); + Console.Out.Flush(); + Console.Error.Flush(); + Thread.Sleep(8000); + } + break; + case "tree-child": + WriteIdentity(args[1]); + Console.Out.Write("tree-child-stdout-ready"); + Console.Error.Write("tree-child-stderr-ready"); + Console.Out.Flush(); + Console.Error.Flush(); + Thread.Sleep(8000); + break; + case "orphan-pipe": + using (var child = StartFixture("orphan-pipe-holder", args[1])) + { + WaitForIdentity(args[1], child); + } + Console.Out.Write("orphan-parent-stdout-exited"); + Console.Error.Write("orphan-parent-stderr-exited"); + break; + case "orphan-pipe-holder": + WriteIdentity(args[1]); + Console.Out.Write("orphan-child-stdout-ready"); + Console.Error.Write("orphan-child-stderr-ready"); + Console.Out.Flush(); + Console.Error.Flush(); + Thread.Sleep(8000); + break; case "environment": Console.Write(Environment.GetEnvironmentVariable("MSBUILDDISABLENODEREUSE") + ":" + Environment.GetEnvironmentVariable("DOTNET_CLI_USE_MSBUILD_SERVER")); break; } return 0; + +static Process StartFixture(string mode, string identityFile) +{ + // Redirecting stdin explicitly passes the inherited stdout/stderr handles on + // Windows even though the fixture runs without a console window. + var start = new ProcessStartInfo(Environment.ProcessPath!) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + }; + start.ArgumentList.Add(Assembly.GetExecutingAssembly().Location); + start.ArgumentList.Add(mode); + start.ArgumentList.Add(identityFile); + return Process.Start(start)!; +} + +static void WriteIdentity(string path) +{ + using var process = Process.GetCurrentProcess(); + File.WriteAllText(path, Environment.ProcessId + "|" + process.StartTime.ToUniversalTime().Ticks); +} + +static void WaitForIdentity(string path, Process child) +{ + var deadline = Stopwatch.StartNew(); + while (!File.Exists(path) && !child.HasExited && deadline.Elapsed < TimeSpan.FromSeconds(3)) + { + Thread.Sleep(10); + } +} diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/TestProcess.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/TestProcess.cs index 86dfee6..aad3937 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/TestProcess.cs +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/TestProcess.cs @@ -1,5 +1,8 @@ using System; +using System.ComponentModel; using System.Diagnostics; +using System.IO; +using System.Text; using System.Threading; using System.Threading.Tasks; @@ -7,13 +10,18 @@ namespace Mammoth.LiteMapper.Packaging.Tests { internal static class TestProcess { + private const int DiagnosticTailLength = 4096; + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(1); + /// /// Drains both pipes concurrently and bounds process exit and pipe closure together, /// including inherited handles that outlive the direct child during build validation. /// public static ProcessResult Run(string fileName, string arguments, string workingDirectory, TimeSpan timeout) { + var elapsed = Stopwatch.StartNew(); using var deadline = new CancellationTokenSource(timeout); + using var stopReading = new CancellationTokenSource(); using var process = new Process(); process.StartInfo = new ProcessStartInfo(fileName, arguments) { @@ -27,31 +35,106 @@ public static ProcessResult Run(string fileName, string arguments, string workin process.StartInfo.Environment["DOTNET_CLI_USE_MSBUILD_SERVER"] = "0"; process.StartInfo.Environment["UseSharedCompilation"] = "false"; process.Start(); - var output = process.StandardOutput.ReadToEndAsync(deadline.Token); - var error = process.StandardError.ReadToEndAsync(deadline.Token); + var output = new CapturedOutput(); + var error = new CapturedOutput(); + var completion = Task.WhenAll( + output.ReadAsync(process.StandardOutput, stopReading.Token), + error.ReadAsync(process.StandardError, stopReading.Token), + process.WaitForExitAsync(stopReading.Token)); try { - Task.WhenAll(output, error, process.WaitForExitAsync(deadline.Token)) - .WaitAsync(deadline.Token).GetAwaiter().GetResult(); + completion.WaitAsync(deadline.Token).GetAwaiter().GetResult(); } catch (OperationCanceledException) when (deadline.IsCancellationRequested) { + string termination; try { if (!process.HasExited) { process.Kill(entireProcessTree: true); + termination = "Process-tree termination requested."; + } + else + { + termination = "Direct process already exited; descendant termination cannot be confirmed."; } } catch (InvalidOperationException) { - // The child may exit between the check and termination. + termination = "Direct process exited before termination could be requested."; + } + catch (Exception exception) when (exception is Win32Exception or AggregateException) + { + termination = "Process-tree termination failed: " + exception.Message; + } + + string cleanup; + try + { + completion.WaitAsync(CleanupTimeout).GetAwaiter().GetResult(); + cleanup = "Cleanup completed: direct process exited and redirected pipes closed."; + } + catch (TimeoutException) + { + cleanup = "Cleanup incomplete after " + CleanupTimeout + ": process exit or redirected pipe closure was not confirmed."; + } + catch (Exception exception) + { + cleanup = "Cleanup incomplete: " + exception.GetType().Name + ": " + exception.Message; + } + finally + { + stopReading.Cancel(); + // Reads canceled after the cleanup bound may finish after Run returns. + _ = completion.ContinueWith(task => { _ = task.Exception; }, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); } - throw new TimeoutException(fileName + " " + arguments + " timed out after " + timeout + "."); + throw new TimeoutException(fileName + " " + arguments + " timed out after " + timeout + + " (elapsed " + elapsed.Elapsed + "). " + termination + Environment.NewLine + cleanup + + Environment.NewLine + "Standard output (last " + DiagnosticTailLength + " characters):" + + Environment.NewLine + output.GetText(tailOnly: true) + + Environment.NewLine + "Standard error (last " + DiagnosticTailLength + " characters):" + + Environment.NewLine + error.GetText(tailOnly: true)); } - return new ProcessResult(process.ExitCode, output.GetAwaiter().GetResult(), error.GetAwaiter().GetResult()); + return new ProcessResult(process.ExitCode, output.GetText(), error.GetText()); + } + + private sealed class CapturedOutput + { + private readonly StringBuilder text = new StringBuilder(); + + public async Task ReadAsync(StreamReader reader, CancellationToken cancellationToken) + { + var buffer = new char[4096]; + try + { + int count; + while ((count = await reader.ReadAsync(buffer.AsMemory(), cancellationToken).ConfigureAwait(false)) != 0) + { + lock (text) + { + text.Append(buffer, 0, count); + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) { } + catch (IOException) when (cancellationToken.IsCancellationRequested) { } + } + + public string GetText(bool tailOnly = false) + { + lock (text) + { + var start = tailOnly ? Math.Max(0, text.Length - DiagnosticTailLength) : 0; + return text.ToString(start, text.Length - start); + } + } } } diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/TestProcessTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/TestProcessTests.cs index 887f001..48395de 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/TestProcessTests.cs +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/TestProcessTests.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics; +using System.Globalization; using System.IO; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -27,6 +28,91 @@ public void DeadlineIncludesProcessExitAndInheritedPipeHandles(string mode) Assert.IsTrue(elapsed.Elapsed < TimeSpan.FromSeconds(3), "The deadline must bound pipe draining as well as waiting for the direct child."); } + [TestMethod] + public void TimeoutReportsBoundedOutputTailsAndTerminatesTheDirectProcess() + { + var identityFile = CreateIdentityFilePath(); + var timeout = TimeSpan.FromSeconds(2); + var elapsed = Stopwatch.StartNew(); + try + { + var exception = Assert.ThrowsExactly(() => RunFixture("timeout-output", timeout, identityFile)); + + StringAssert.Contains(exception.Message, timeout.ToString()); + Assert.IsTrue(exception.Message.Contains("elapsed", StringComparison.OrdinalIgnoreCase)); + StringAssert.Contains(exception.Message, "Cleanup completed"); + StringAssert.Contains(exception.Message, "stdout-tail-marker"); + StringAssert.Contains(exception.Message, "stderr-tail-marker"); + Assert.IsFalse(exception.Message.Contains("stdout-head-marker", StringComparison.Ordinal)); + Assert.IsFalse(exception.Message.Contains("stderr-head-marker", StringComparison.Ordinal)); + Assert.IsTrue(exception.Message.Length <= 9000, "Diagnostic output must retain bounded tails instead of embedding complete redirected streams."); + Assert.IsTrue(File.Exists(identityFile), "Fixture startup must be distinguished from an empty launch failure."); + Assert.IsFalse(IsOwnedFixtureProcessAlive(identityFile), "The timed-out direct process must be gone before Run returns."); + Assert.IsTrue(elapsed.Elapsed < TimeSpan.FromSeconds(5), "Timeout cleanup must remain bounded."); + } + finally + { + CleanupOwnedFixtureProcess(identityFile); + } + } + + [TestMethod] + public void TimeoutTerminatesTheProcessTreeWhileTheDirectProcessIsAlive() + { + var parentIdentityFile = CreateIdentityFilePath(); + var childIdentityFile = CreateIdentityFilePath(); + var timeout = TimeSpan.FromSeconds(4); + var elapsed = Stopwatch.StartNew(); + try + { + var exception = Assert.ThrowsExactly(() => RunFixture("tree-timeout", timeout, parentIdentityFile, childIdentityFile)); + + StringAssert.Contains(exception.Message, "Cleanup completed"); + StringAssert.Contains(exception.Message, "tree-parent-stdout-ready"); + StringAssert.Contains(exception.Message, "tree-parent-stderr-ready"); + StringAssert.Contains(exception.Message, "tree-child-stdout-ready"); + StringAssert.Contains(exception.Message, "tree-child-stderr-ready"); + Assert.IsTrue(File.Exists(parentIdentityFile), "The direct fixture process must report that it started."); + Assert.IsTrue(File.Exists(childIdentityFile), "The descendant fixture process must report that it started."); + Assert.IsFalse(IsOwnedFixtureProcessAlive(parentIdentityFile), "The direct fixture process must be terminated before Run returns."); + Assert.IsFalse(IsOwnedFixtureProcessAlive(childIdentityFile), "The direct process tree must be terminated before Run returns."); + Assert.IsTrue(elapsed.Elapsed < TimeSpan.FromSeconds(6), "Process-tree cleanup must remain bounded."); + } + finally + { + CleanupOwnedFixtureProcess(childIdentityFile); + CleanupOwnedFixtureProcess(parentIdentityFile); + } + } + + [TestMethod] + public void TimeoutAfterDirectChildExitReportsIncompleteCleanupAndReturnsBoundedly() + { + var childIdentityFile = CreateIdentityFilePath(); + var timeout = TimeSpan.FromSeconds(4); + var elapsed = Stopwatch.StartNew(); + try + { + var exception = Assert.ThrowsExactly(() => RunFixture("orphan-pipe", timeout, childIdentityFile)); + + StringAssert.Contains(exception.Message, timeout.ToString()); + Assert.IsTrue(exception.Message.Contains("elapsed", StringComparison.OrdinalIgnoreCase)); + StringAssert.Contains(exception.Message, "Cleanup incomplete"); + StringAssert.Contains(exception.Message, "Direct process already exited"); + StringAssert.Contains(exception.Message, "orphan-parent-stdout-exited"); + StringAssert.Contains(exception.Message, "orphan-parent-stderr-exited"); + StringAssert.Contains(exception.Message, "orphan-child-stdout-ready"); + StringAssert.Contains(exception.Message, "orphan-child-stderr-ready"); + Assert.IsTrue(File.Exists(childIdentityFile), "The pipe-holding descendant must report that it started."); + Assert.IsTrue(IsOwnedFixtureProcessAlive(childIdentityFile), "An exited direct parent leaves no portable process-tree handle for terminating its orphaned descendant."); + Assert.IsTrue(elapsed.Elapsed < TimeSpan.FromSeconds(6), "Orphaned inherited pipes must not make Run wait for the fixture watchdog."); + } + finally + { + CleanupOwnedFixtureProcess(childIdentityFile); + } + } + [TestMethod] public void DotnetChildrenDisableReusableBuildServers() { @@ -36,9 +122,78 @@ public void DotnetChildrenDisableReusableBuildServers() } private static ProcessResult RunFixture(string mode, TimeSpan timeout) + { + return RunFixture(mode, timeout, Array.Empty()); + } + + private static ProcessResult RunFixture(string mode, TimeSpan timeout, params string[] arguments) { var fixture = Path.Combine(AppContext.BaseDirectory, "Mammoth.LiteMapper.ProcessFixture.dll"); - return TestProcess.Run("dotnet", "\"" + fixture + "\" " + mode, AppContext.BaseDirectory, timeout); + var command = "\"" + fixture + "\" " + mode; + foreach (var argument in arguments) + { + command += " \"" + argument.Replace("\"", "\\\"") + "\""; + } + + return TestProcess.Run("dotnet", command, AppContext.BaseDirectory, timeout); + } + + private static string CreateIdentityFilePath() + { + return Path.Combine(Path.GetTempPath(), "litemapper-process-" + Guid.NewGuid().ToString("N") + ".pid"); + } + + private static bool IsOwnedFixtureProcessAlive(string identityFile) + { + using var process = OpenOwnedFixtureProcess(identityFile); + return process != null && !process.HasExited; + } + + private static void CleanupOwnedFixtureProcess(string identityFile) + { + using var process = OpenOwnedFixtureProcess(identityFile); + if (process != null && !process.HasExited) + { + process.Kill(entireProcessTree: true); + Assert.IsTrue(process.WaitForExit(3000), "The owned fixture process did not exit during test cleanup."); + } + + if (File.Exists(identityFile)) + { + File.Delete(identityFile); + } + } + + private static Process? OpenOwnedFixtureProcess(string identityFile) + { + if (!File.Exists(identityFile)) + { + return null; + } + + var identity = File.ReadAllText(identityFile).Split('|'); + if (identity.Length != 2 || + !int.TryParse(identity[0], NumberStyles.None, CultureInfo.InvariantCulture, out var processId) || + !long.TryParse(identity[1], NumberStyles.None, CultureInfo.InvariantCulture, out var startTimeTicks)) + { + throw new AssertFailedException("Fixture process identity was malformed."); + } + + try + { + var process = Process.GetProcessById(processId); + if (process.StartTime.ToUniversalTime().Ticks != startTimeTicks) + { + process.Dispose(); + return null; + } + + return process; + } + catch (ArgumentException) + { + return null; + } } } } From a6faf309edfabd1180cdb28fa8c9aef12e483013 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Thu, 15 Oct 2026 16:13:15 +0200 Subject: [PATCH 3/3] test: use stable Linux fixture process identities --- DECISIONS.md | 1 + IMPLEMENTATION_PLAN.md | 1 + STATUS.md | 1 + .../ProcessFixture/FixtureProcessIdentity.cs | 44 +++++++++++++++++++ .../ProcessFixture/Program.cs | 3 +- .../TestProcessTests.cs | 37 ++++++++++++++-- 6 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 tests/Mammoth.LiteMapper.Packaging.Tests/ProcessFixture/FixtureProcessIdentity.cs diff --git a/DECISIONS.md b/DECISIONS.md index bb37a65..2354dbc 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -2,6 +2,7 @@ ## Issue #58 follow-up: benchmark smoke liveness (2026-10-15) +- Linux fixture ownership uses PID plus the exact kernel start token from procfs stat field 22. Cross-process Process.StartTime comparisons are unsuitable on Linux because .NET caches independently estimated wall-clock boot times (dotnet/runtime#108959). Non-Linux fixtures retain exact UTC start ticks. Missing processes are treated as exited; malformed accessible procfs records fail validation. Keep the orphan-alive assertion and exact identity check without a tolerance. - The user approved a benchmark-only five-minute process deadline after exact-head Windows CI 38739919081 exceeded the old 60-second limit. This bounds startup, outer build, BenchmarkDotNet generated restore/build, Dry execution, reporting and pipe closure; it is not a throughput gate or a measured runtime percentile. Sample process limits remain 60 seconds. - Packaging TestProcess keeps complete successful stdout/stderr. Timeout messages retain only the last 4,096 characters of each stream, configured/elapsed time, termination action and cleanup status. Execution and pipe completion share the primary deadline; a separate one-second cleanup wait allows post-kill exit and draining without an unbounded retry. - A direct process that has already exited cannot provide a reliable portable descendant-termination handle. Bound and cancel local pipe reads, report cleanup incomplete when handles remain open, and do not claim orphan cleanup was confirmed. Fixtures explicitly clean up their own known descendants. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index cba9116..48156aa 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -2,6 +2,7 @@ ## Issue #58 follow-up: smoke process deadlines (2026-10-15) +- CI follow-up: correct Linux fixture ownership with the kernel start token, cover procfs command names and malformed records with red-first parser cases, preserve every existing cleanup/liveness assertion, and rerun exact-head Windows/Linux validation. This is test infrastructure only. - Authorized scope: benchmark smoke only receives a 300-second completion deadline; sample calls retain 60 seconds. Preserve all success assertions, allocation isolation, symbols validation and workflow gates. No local timing benchmarks. - Test-first: subprocess fixtures cover retained bounded stdout/stderr tails, successful full-output compatibility, direct-child termination and inherited pipes after direct-child exit. Keep finite fixture watchdogs and clean up only owned fixture processes. - Implementation: drain streams incrementally while retaining complete successful output, add bounded timeout tails and elapsed/cleanup diagnostics, and allow one second after termination request for exit and pipe closure. If inherited descendants cannot be confirmed terminated after the direct child exits, report incomplete cleanup rather than claim success. diff --git a/STATUS.md b/STATUS.md index 663eeae..ecb8efc 100644 --- a/STATUS.md +++ b/STATUS.md @@ -2,6 +2,7 @@ ## Issue #58 follow-up: smoke timeout diagnostics (2026-10-15) +- CI correction: b9ae890 passed full Windows validation and all six Roslyn jobs; Linux passed 1,392 cases and failed only the new orphan fixture's cross-process StartTime ownership check. Linux .NET estimates boot time separately per process, making exact converted UTC ticks unsuitable. Fixture ownership now compares the exact kernel start token on Linux and retains UTC ticks elsewhere, without weakening PID reuse protection or the orphan-alive assertion. Seven procfs parser cases fail before implementation; the integrated process set passes three complete local runs (42/42), broader safeguards pass 255/255, and the Release packaging-test project builds without warnings/errors. No test failures/skips. Linux execution awaits corrected-head CI. - User-approved scope: only BenchmarkHarnessRunsDryJobAndRecordsPinnedComparisons receives a 300-second process deadline; sample calls retain 60 seconds. All existing success assertions, allocation isolation, symbols/publication validation, benchmark algorithms and workflow settings remain unchanged. - Red-first: all three new subprocess cases fail against the old helper while four existing controls pass. Integrated tests retain full successful 1 MiB stderr output, bound timeout tails to 4,096 characters per stream, confirm direct/tree cleanup and distinguish an exited parent with inherited pipe holders. - Implementation: incremental concurrent capture, one execution deadline for process exit and both pipes, then at most one second for cleanup/draining after a termination request. Timeout diagnostics include configured/elapsed time, termination action and cleanup status. When the direct process already exited, descendant termination remains unconfirmed and incomplete cleanup is reported explicitly. Test fixtures clean up only recorded PID/start-time identities. diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/ProcessFixture/FixtureProcessIdentity.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/ProcessFixture/FixtureProcessIdentity.cs new file mode 100644 index 0000000..ccde38f --- /dev/null +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/ProcessFixture/FixtureProcessIdentity.cs @@ -0,0 +1,44 @@ +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; + +namespace Mammoth.LiteMapper.ProcessFixture +{ + public static class FixtureProcessIdentity + { + public static long GetStartStamp(Process process) + { + // .NET's Linux StartTime uses a wall-clock boot-time estimate cached + // separately in each process. Compare the kernel token directly. + if (OperatingSystem.IsLinux()) + { + var statPath = process.Id == Environment.ProcessId ? "/proc/self/stat" : + "/proc/" + process.Id.ToString(CultureInfo.InvariantCulture) + "/stat"; + return ParseLinuxStartStamp(File.ReadAllText(statPath)); + } + + return process.StartTime.ToUniversalTime().Ticks; + } + + public static long ParseLinuxStartStamp(string stat) + { + // comm (field 2) can contain spaces and ')' characters. Fields after + // its final ')' begin at field 3; starttime is field 22 (index 19). + var commandEnd = stat.LastIndexOf(')'); + if (commandEnd < 0) + { + throw new InvalidDataException("Fixture procfs record has no complete command name."); + } + + var fields = stat.Substring(commandEnd + 1).Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (fields.Length < 20 || + !long.TryParse(fields[19], NumberStyles.None, CultureInfo.InvariantCulture, out var startStamp)) + { + throw new InvalidDataException("Fixture procfs record has no valid kernel start token."); + } + + return startStamp; + } + } +} diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/ProcessFixture/Program.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/ProcessFixture/Program.cs index e996579..6e22e4b 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/ProcessFixture/Program.cs +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/ProcessFixture/Program.cs @@ -5,6 +5,7 @@ using System.Reflection; using System.Threading; using System.Threading.Tasks; +using Mammoth.LiteMapper.ProcessFixture; // Release-script tests launch this native process instead of any real .NET or // consumer operation. Returning a real process exit exercises LASTEXITCODE. @@ -111,7 +112,7 @@ static Process StartFixture(string mode, string identityFile) static void WriteIdentity(string path) { using var process = Process.GetCurrentProcess(); - File.WriteAllText(path, Environment.ProcessId + "|" + process.StartTime.ToUniversalTime().Ticks); + File.WriteAllText(path, Environment.ProcessId + "|" + FixtureProcessIdentity.GetStartStamp(process)); } static void WaitForIdentity(string path, Process child) diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/TestProcessTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/TestProcessTests.cs index 48395de..3f6794b 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/TestProcessTests.cs +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/TestProcessTests.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Globalization; using System.IO; +using Mammoth.LiteMapper.ProcessFixture; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Mammoth.LiteMapper.Packaging.Tests @@ -9,6 +10,26 @@ namespace Mammoth.LiteMapper.Packaging.Tests [TestClass] public sealed class TestProcessTests { + [TestMethod] + [DataRow("fixture")] + [DataRow("fixture with spaces")] + [DataRow("fixture ) with (parentheses)")] + public void LinuxFixtureIdentityUsesKernelStartTicksAfterTheCompleteCommandName(string commandName) + { + var stat = "123 (" + commandName + ") S 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 987654 23"; + Assert.AreEqual(987654L, FixtureProcessIdentity.ParseLinuxStartStamp(stat), "Ownership must use field 22 directly, independent of wall-clock boot-time estimates."); + } + + [TestMethod] + [DataRow("123 missing-command S 4 5")] + [DataRow("123 (fixture) S 4 5")] + [DataRow("123 (fixture) S 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 invalid 23")] + [DataRow("123 (fixture) S 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 -1 23")] + public void MalformedLinuxFixtureIdentityFailsLoudly(string stat) + { + Assert.ThrowsExactly(() => FixtureProcessIdentity.ParseLinuxStartStamp(stat)); + } + [TestMethod] public void ConcurrentDrainingHandlesStderrPressureBeforeStdoutCloses() { @@ -174,15 +195,16 @@ private static void CleanupOwnedFixtureProcess(string identityFile) var identity = File.ReadAllText(identityFile).Split('|'); if (identity.Length != 2 || !int.TryParse(identity[0], NumberStyles.None, CultureInfo.InvariantCulture, out var processId) || - !long.TryParse(identity[1], NumberStyles.None, CultureInfo.InvariantCulture, out var startTimeTicks)) + !long.TryParse(identity[1], NumberStyles.None, CultureInfo.InvariantCulture, out var startStamp)) { throw new AssertFailedException("Fixture process identity was malformed."); } + Process? process = null; try { - var process = Process.GetProcessById(processId); - if (process.StartTime.ToUniversalTime().Ticks != startTimeTicks) + process = Process.GetProcessById(processId); + if (FixtureProcessIdentity.GetStartStamp(process) != startStamp) { process.Dispose(); return null; @@ -190,10 +212,17 @@ private static void CleanupOwnedFixtureProcess(string identityFile) return process; } - catch (ArgumentException) + catch (Exception exception) when (exception is ArgumentException || exception is InvalidOperationException || + exception is FileNotFoundException || exception is DirectoryNotFoundException) { + process?.Dispose(); return null; } + catch + { + process?.Dispose(); + throw; + } } } }