From 5414333cbe8a6fed6f65f300ff892e708f68ed6f Mon Sep 17 00:00:00 2001 From: Shad Storhaug Date: Thu, 16 Jan 2025 02:03:18 +0700 Subject: [PATCH] Lucene.Net.Util: Added DisposableResourceInfo class to track scope, thread name, and caller stack trace so this info can be included in the output if there is an exception during dispose. --- .../Support/Util/DisposableResourceInfo.cs | 41 +++++++ .../Support/Util/RandomizedContext.cs | 114 ++++++++++++++++-- .../Util/LuceneTestCase.cs | 23 +++- .../Support/TestApiConsistency.cs | 4 +- 4 files changed, 164 insertions(+), 18 deletions(-) create mode 100644 src/Lucene.Net.TestFramework/Support/Util/DisposableResourceInfo.cs diff --git a/src/Lucene.Net.TestFramework/Support/Util/DisposableResourceInfo.cs b/src/Lucene.Net.TestFramework/Support/Util/DisposableResourceInfo.cs new file mode 100644 index 0000000000..b3f54c0235 --- /dev/null +++ b/src/Lucene.Net.TestFramework/Support/Util/DisposableResourceInfo.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +#nullable enable + +namespace Lucene.Net.Util +{ + /// + /// Allocation information (Thread, allocation stack) for tracking disposable + /// resources. + /// + internal sealed class DisposableResourceInfo // From randomizedtesing + { + private readonly IDisposable resource; + private readonly LifecycleScope scope; + private readonly StackTrace stackTrace; + private readonly string? threadName; + + public DisposableResourceInfo(IDisposable resource, LifecycleScope scope, string? threadName, StackTrace stackTrace) + { + Debug.Assert(resource != null); + + this.resource = resource!; + this.scope = scope; + this.stackTrace = stackTrace; + this.threadName = threadName; + } + + public IDisposable Resource => resource; + + public StackTrace StackTrace => stackTrace; + + public LifecycleScope Scope => scope; + + public string? ThreadName => threadName; + } +} diff --git a/src/Lucene.Net.TestFramework/Support/Util/RandomizedContext.cs b/src/Lucene.Net.TestFramework/Support/Util/RandomizedContext.cs index 9c5911c681..2a5d108e41 100644 --- a/src/Lucene.Net.TestFramework/Support/Util/RandomizedContext.cs +++ b/src/Lucene.Net.TestFramework/Support/Util/RandomizedContext.cs @@ -2,8 +2,12 @@ using NUnit.Framework.Internal; using System; using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO; using System.Reflection; using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; +using System.Text; using System.Threading; #nullable enable @@ -33,6 +37,9 @@ internal class RandomizedContext { // LUCENENET NOTE: Using an underscore to prefix the name hides it from "traits" in Test Explorer internal const string RandomizedContextPropertyName = "_RandomizedContext"; + internal const string RandomizedContextScopeKeyName = "_RandomizedContext_Scope"; + internal const string RandomizedContextThreadNameKeyName = "_RandomizedContext_ThreadName"; + internal const string RandomizedContextStackTraceKeyName = "_RandomizedContext_StackTrace"; private readonly ThreadLocal randomGenerator; private readonly Test currentTest; @@ -40,7 +47,7 @@ internal class RandomizedContext private readonly long randomSeed; private readonly string randomSeedAsHex; private readonly long testSeed; - private Lazy>? toDisposeAtEnd = null; + private Lazy>? disposableResources = null; /// /// Initializes the randomized context. @@ -110,8 +117,8 @@ public static RandomizedContext? CurrentContext /// /// Gets a lazily-initialized concurrent queue to use for resources that will be disposed at the end of the test or suite. /// - internal ConcurrentQueue ToDisposeAtEnd - => (toDisposeAtEnd ??= new Lazy>(() => new ConcurrentQueue())).Value; + internal ConcurrentQueue DisposableResources + => (disposableResources ??= new Lazy>(() => new ConcurrentQueue())).Value; /// /// Registers the given at the end of a given @@ -133,19 +140,19 @@ public T DisposeAtEnd(T resource, LifecycleScope scope) where T : IDisposable { if (scope == LifecycleScope.TEST) { - AddDisposableAtEnd(resource); + AddDisposableAtEnd(resource, scope); } else // LifecycleScope.SUITE { var context = FindClassLevelTest(currentTest).GetRandomizedContext(); if (context is null) throw new InvalidOperationException($"The provided {LifecycleScope.TEST} has no conceptual {LifecycleScope.SUITE} associated with it."); - context.AddDisposableAtEnd(resource); + context.AddDisposableAtEnd(resource, scope); } } else if (currentTest.IsTestClass()) { - AddDisposableAtEnd(resource); + AddDisposableAtEnd(resource, scope); } else { @@ -156,12 +163,12 @@ public T DisposeAtEnd(T resource, LifecycleScope scope) where T : IDisposable } [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal void AddDisposableAtEnd(IDisposable resource) + internal void AddDisposableAtEnd(IDisposable resource, LifecycleScope scope) { // LUCENENET: ConcurrentQueue handles thread-safety internally, so no explicit locking is needed. // Note that if we port more of randomizedtesting later, we may need to change this to a List and // a lock, but for now it will suit our needs without locking. - ToDisposeAtEnd.Enqueue(resource); + DisposableResources.Enqueue(new DisposableResourceInfo(resource, scope, Thread.CurrentThread.Name, new StackTrace(skipFrames: 3))); } private Test? FindClassLevelTest(Test test) @@ -184,11 +191,98 @@ internal void AddDisposableAtEnd(IDisposable resource) internal void DisposeResources() { - Lazy>? toDispose = Interlocked.Exchange(ref toDisposeAtEnd, null); + Lazy>? toDispose = Interlocked.Exchange(ref disposableResources, null); if (toDispose?.IsValueCreated == true) // Does null check on toDispose { - IOUtils.Dispose(toDispose.Value); + Exception? th = null; + + foreach (DisposableResourceInfo disposable in toDispose.Value) + { + try + { + disposable.Resource.Dispose(); + } + catch (Exception t) when (t.IsThrowable()) + { + // Add details about the source of the exception, so they can be printed out later. + t.Data[RandomizedContextScopeKeyName] = disposable.Scope.ToString(); // string + t.Data[RandomizedContextThreadNameKeyName] = disposable.ThreadName; // string + t.Data[RandomizedContextStackTraceKeyName] = disposable.StackTrace; // System.Diagnostics.StackTrace + + if (th is not null) + { + th.AddSuppressed(t); + } + else + { + th = t; + } + } + } + + if (th is not null) + { + ExceptionDispatchInfo.Capture(th).Throw(); // LUCENENET: Rethrow to preserve stack details from the original throw + } } } // toDispose goes out of scope here - no need to Clear(). + + /// + /// Prints a stack trace of the to the destination . + /// The message will show additional stack details relevant to + /// to identify the calling method, , and name of the calling thread. + /// + /// The exception to print. This may contain additional details in . + /// A to write the output to. + internal static void PrintStackTrace(Exception exception, TextWriter destination) + { + destination.WriteLine(FormatStackTrace(exception)); + } + + private static string FormatStackTrace(Exception exception) + { + StringBuilder sb = new StringBuilder(256); + FormatException(exception, sb); + + foreach (var suppressedException in exception.GetSuppressedAsList()) + { + sb.AppendLine("Suppressed: "); + FormatException(suppressedException, sb); + } + + return sb.ToString(); + } + + private static void FormatException(Exception exception, StringBuilder destination) + { + destination.AppendLine(exception.ToString()); + + string? scope = (string?)exception.Data[RandomizedContextScopeKeyName]; + string? threadName = (string?)exception.Data[RandomizedContextThreadNameKeyName]; + StackTrace? stackTrace = (StackTrace?)exception.Data[RandomizedContextStackTraceKeyName]; + + bool hasData = scope != null || threadName != null || stackTrace != null; + if (!hasData) + { + return; + } + + destination.AppendLine("Caller Details:"); + if (scope != null) + { + destination.Append("Scope: "); + destination.AppendLine(scope); + } + if (threadName != null) + { + destination.Append("Thread Name: "); + destination.AppendLine(threadName); + } + if (stackTrace != null) + { + destination.Append("Stack Trace:"); + destination.AppendLine(stackTrace.ToString()); + } + } } } diff --git a/src/Lucene.Net.TestFramework/Util/LuceneTestCase.cs b/src/Lucene.Net.TestFramework/Util/LuceneTestCase.cs index 5ed3a8f674..447c1267c4 100644 --- a/src/Lucene.Net.TestFramework/Util/LuceneTestCase.cs +++ b/src/Lucene.Net.TestFramework/Util/LuceneTestCase.cs @@ -25,6 +25,7 @@ using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; using System.Text.RegularExpressions; using System.Threading; using static Lucene.Net.Search.FieldCache; @@ -972,7 +973,16 @@ System Properties } // LUCENENET: DisposeAfterTest runs last - RandomizedContext.CurrentContext.DisposeResources(); + try + { + RandomizedContext.CurrentContext.DisposeResources(); + } + catch (Exception ex) when (ex.IsThrowable()) + { + NUnit.Framework.TestContext.Error.WriteLine($"[ERROR] An exception occurred during TearDown:"); + RandomizedContext.PrintStackTrace(ex, NUnit.Framework.TestContext.Error); + ExceptionDispatchInfo.Capture(ex).Throw(); + } } /// @@ -990,7 +1000,7 @@ public virtual void OneTimeSetUp() { ClassEnvRule.Before(); } - catch (Exception ex) + catch (Exception ex) when (ex.IsThrowable()) { // This is a bug in the test framework that should be fixed and/or reported if it occurs. if (FailOnTestFixtureOneTimeSetUpError) @@ -1019,7 +1029,7 @@ public virtual void OneTimeTearDown() { ClassEnvRule.After(); } - catch (Exception ex) + catch (Exception ex) when (ex.IsThrowable()) { // LUCENENET: Patch NUnit so it will report a failure in stderr if there was an exception during teardown. NUnit.Framework.TestContext.Error.WriteLine($"[ERROR] OneTimeTearDown: An exception occurred during ClassEnvRule.After() in {GetType().FullName}:\n{ex}"); @@ -1028,7 +1038,7 @@ public virtual void OneTimeTearDown() { CleanupTemporaryFiles(); } - catch (Exception ex) + catch (Exception ex) when (ex.IsThrowable()) { // LUCENENET: Patch NUnit so it will report a failure in stderr if there was an exception during teardown. NUnit.Framework.TestContext.Error.WriteLine($"[ERROR] OneTimeTearDown: An exception occurred during CleanupTemporaryFiles() in {GetType().FullName}:\n{ex}"); @@ -1039,10 +1049,11 @@ public virtual void OneTimeTearDown() { RandomizedContext.CurrentContext.DisposeResources(); } - catch (Exception ex) + catch (Exception ex) when (ex.IsThrowable()) { // LUCENENET: Patch NUnit so it will report a failure in stderr if there was an exception during teardown. - NUnit.Framework.TestContext.Error.WriteLine($"[ERROR] OneTimeTearDown: An exception occurred during RandomizedContext.DisposeResources() in {GetType().FullName}:\n{ex}"); + NUnit.Framework.TestContext.Error.WriteLine($"[ERROR] OneTimeTearDown: An exception occurred during RandomizedContext.DisposeResources() in {GetType().FullName}:"); + RandomizedContext.PrintStackTrace(ex, NUnit.Framework.TestContext.Error); } } diff --git a/src/Lucene.Net.Tests.TestFramework/Support/TestApiConsistency.cs b/src/Lucene.Net.Tests.TestFramework/Support/TestApiConsistency.cs index 114619084e..cde5c3dbd9 100644 --- a/src/Lucene.Net.Tests.TestFramework/Support/TestApiConsistency.cs +++ b/src/Lucene.Net.Tests.TestFramework/Support/TestApiConsistency.cs @@ -38,7 +38,7 @@ public override void TestProtectedFieldNames(Type typeFromTargetAssembly) [TestCase(typeof(Lucene.Net.RandomExtensions))] public override void TestPrivateFieldNames(Type typeFromTargetAssembly) { - base.TestPrivateFieldNames(typeFromTargetAssembly, @"ApiScanTestBase|TestUtil\.MaxRecursionBound|Assert\.FailureFormat|Lucene\.Net\.Util\.RandomizedContext\.RandomizedContextPropertyName|Lucene\.Net\.Util\.DefaultNamespaceTypeWrapper\.AllMembers|^Lucene\.Net\.Store\.BaseDirectoryWrapper\.(?:True|False)"); + base.TestPrivateFieldNames(typeFromTargetAssembly, @"ApiScanTestBase|TestUtil\.MaxRecursionBound|Assert\.FailureFormat|Lucene\.Net\.Util\.RandomizedContext|Lucene\.Net\.Util\.DefaultNamespaceTypeWrapper\.AllMembers|^Lucene\.Net\.Store\.BaseDirectoryWrapper\.(?:True|False)"); } [Test, LuceneNetSpecific] @@ -141,4 +141,4 @@ public override void TestForMembersAcceptingOrReturningListOrDictionary(Type typ base.TestForMembersAcceptingOrReturningListOrDictionary(typeFromTargetAssembly); } } -} \ No newline at end of file +}