diff --git a/AGENTS.md b/AGENTS.md
index 23cc6d8..4b0d412 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,8 +6,9 @@ It adds only the Revit execution model on top of TUnit; assertions, attributes,
## Non-negotiables
-* One thread owns the Revit API. Every API call runs on the thread that initialized Revit; the executor marshals test bodies and hooks onto it and caps Revit tests to one at a time. Never touch a Revit type off that thread, and never start a second thread or `Task.Run` for Revit work.
-* Inject and eject in matched pairs. The application connects once per test session and releases on the matching session-teardown hook.
+* One thread owns the Revit API. Every API call runs on the thread that initialized Revit; the executor marshals test bodies and hooks onto it and declares a cap of one Revit test at a time. Never touch a Revit type off that thread, and never start a second thread or `Task.Run` for Revit work.
+* Inject and eject in matched pairs, once per test host process. Revit activates once per process, and an IDE that keeps a test host alive starts a test session per run in that process. The first session that executes a test connects, and `RevitConnectionLifetime` releases when the test application finishes. A process that keeps Revit connected never terminates.
+* Revit starts on the first test a session executes, never on discovery. Nothing that runs for a discovery request opens the connection. An IDE that lists the tests of an assembly does not start Revit.
* The package adds the Revit execution model only. It exposes the base classes, the executor, and the injection lifecycle; assertions, attributes, and discovery come from TUnit. Never reimplement what TUnit provides.
* Never break the public surface. Deprecate a renamed member with `[Obsolete]`, name the replacement, and keep the member functional.
* Mark a member the test platform invokes but consumers must not call `[EditorBrowsable(EditorBrowsableState.Never)]`.
@@ -20,8 +21,9 @@ It adds only the Revit execution model on top of TUnit; assertions, attributes,
* A process-wide singleton starts one background STA thread and runs a WPF `Dispatcher` on it. The dispatcher pumps the Win32 messages COM marshaling needs and routes `await` continuations through `DispatcherSynchronizationContext`.
* `RevitThreadExecutor` is the public entry point. It queues the action onto the thread host and returns a task that completes once the body and its continuations finish. Unwrap the dispatcher operation (`operation.Task.Unwrap()`) to await the continuations.
-* A `IParallelLimit` returning `1` holds the Revit thread exclusive; two tests cannot share it.
-* `RevitApplicationTest` holds the static `Application`. `RevitApiTest` opens the connection before the session and closes it after, both on the Revit thread.
+* `RevitThreadExecutor` declares an `IParallelLimit` of `1` through `ITestRegisteredEventReceiver`. TUnit applies it from the version that forwards the event to an installed executor. A TUnit without that forwarding schedules every Revit test at once, and the bodies interleave at each `await` on the shared thread. The limit belongs to TUnit; never reintroduce it here as an attribute on the base class.
+* `RevitApplicationTest` holds the static `Application`. `RevitApiTest` opens the connection before the session, and `RevitConnectionLifetime` closes it after the test application, both on the Revit thread.
+* `RevitConnectionLifetime` is an `ITestHostApplicationLifetime`. The package registers it through the `TestingPlatformBuilderHook` item of `build/Nice3point.TUnit.Revit.props`, packed into `build` and `buildTransitive`; a project that writes its own entry point calls `AddRevit` instead. The test project imports the same props, which a project reference does not deliver.
## Repository map
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 533c413..f3918cb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,15 @@
+# 2027.0.2
+
+- One Revit connection now serves the whole test host process.
+ An IDE that keeps a test host alive starts a test session per run in that process, and every run after the first failed with `BeforeTestSession hook failed: Attempted to write protected memory.`
+ The new `RevitConnectionLifetime` releases the connection when the test application finishes.
+ The package registers it through its build props; a consuming project needs no change, and a project with its own entry point calls `AddRevit`.
+- Removed the `RevitApiTest.RevitSessionCleanup` hook, which released the connection after every session.
+- Revit tests still run without a parallel limit and interleave at every `await` on the shared thread.
+ The limit `RevitThreadExecutor` declares does not reach the scheduler: TUnit installs an executor without forwarding `ITestRegisteredEventReceiver` to it.
+ The fix belongs to TUnit and arrives with the version that carries it.
+ Until then, run the suite with `--maximum-parallel-tests 1`.
+
# 2027.0.1
- Updated TUnit to 1.44
@@ -30,7 +42,7 @@ TUnit initializes Revit with the `English - United States` language. To override
```csharp
using Nice3point.Revit.Injector.Attributes;
-
+
[assembly: RevitLanguage("ENU")]
```
@@ -56,7 +68,7 @@ TUnit initializes Revit from `C:\Program Files\Autodesk\Revit {version}` install
```csharp
using Nice3point.Revit.Injector.Attributes;
-
+
[assembly: RevitInstallationPath("D:\Autodesk\Revit Preview")]
```
@@ -96,4 +108,4 @@ Enable private Nuget source for testing
# 2026.0.0
-Initial release. Enjoy!
\ No newline at end of file
+Initial release. Enjoy!
diff --git a/source/Nice3point.TUnit.Revit/Executors/RevitThreadExecutor.cs b/source/Nice3point.TUnit.Revit/Executors/RevitThreadExecutor.cs
index 2448b58..10235c5 100644
--- a/source/Nice3point.TUnit.Revit/Executors/RevitThreadExecutor.cs
+++ b/source/Nice3point.TUnit.Revit/Executors/RevitThreadExecutor.cs
@@ -8,17 +8,17 @@ namespace Nice3point.TUnit.Revit.Executors;
///
///
/// Revit requires every API call to occur on the same thread that initialised it.
-/// All actions are queued to a process-wide STA thread driven by a WPF
-/// : it pumps Win32 messages for COM marshaling and routes
-/// await continuations back to the same thread through
-/// . Concurrent execution is capped
-/// at one test at a time to keep exclusive access to the Revit thread.
+/// All actions are queued to a process-wide STA thread driven by a WPF :
+/// it pumps Win32 messages for COM marshaling and routes await continuations back to the same thread through .
+/// Concurrent execution is capped at one test at a time to keep exclusive access to the Revit thread.
///
public sealed class RevitThreadExecutor : GenericAbstractExecutor, ITestRegisteredEventReceiver
{
///
/// Applies the Revit parallel limiter so registered tests never run concurrently.
///
+ /// The registration context of the test the executor runs.
+ /// A completed task.
public ValueTask OnTestRegistered(TestRegisteredContext context)
{
context.SetParallelLimiter(RevitCountParallelLimit.Default);
@@ -33,11 +33,24 @@ protected override ValueTask ExecuteAsync(Func action)
ArgumentNullException.ThrowIfNull(action);
return RevitDispatcherThread.Instance.InvokeAsync(action);
}
+
+ ///
+ /// Runs on the Revit thread outside of a test and a hook.
+ ///
+ /// The action to run on the Revit thread.
+ /// A task that completes once the action and its continuations finish.
+ internal static ValueTask InvokeAsync(Func action)
+ {
+ return RevitDispatcherThread.Instance.InvokeAsync(action);
+ }
}
///
/// Restricts Revit API tests to a single concurrent execution.
///
+///
+/// TUnit keys a limit by its type. Every Revit test of a run shares this one.
+///
file sealed class RevitCountParallelLimit : IParallelLimit
{
///
@@ -52,8 +65,7 @@ protected override ValueTask ExecuteAsync(Func action)
}
///
-/// Hosts the process-wide STA thread used for every Revit API call and dispatches
-/// asynchronous actions onto its WPF .
+/// Hosts the process-wide STA thread used for every Revit API call and dispatches asynchronous actions onto its WPF .
///
file sealed class RevitDispatcherThread
{
@@ -90,8 +102,7 @@ private RevitDispatcherThread()
public static RevitDispatcherThread Instance { get; } = new();
///
- /// Queues on the Revit thread and returns a task that
- /// completes once the action and all of its await continuations finish.
+ /// Queues on the Revit thread and returns a task that completes once the action and all of its await continuations finish.
///
public ValueTask InvokeAsync(Func action)
{
diff --git a/source/Nice3point.TUnit.Revit/Nice3point.TUnit.Revit.csproj b/source/Nice3point.TUnit.Revit/Nice3point.TUnit.Revit.csproj
index 97d1560..d773230 100644
--- a/source/Nice3point.TUnit.Revit/Nice3point.TUnit.Revit.csproj
+++ b/source/Nice3point.TUnit.Revit/Nice3point.TUnit.Revit.csproj
@@ -35,6 +35,10 @@
+
+
+
+
diff --git a/source/Nice3point.TUnit.Revit/RevitApiTest.cs b/source/Nice3point.TUnit.Revit/RevitApiTest.cs
index bbd761a..6b0ddde 100644
--- a/source/Nice3point.TUnit.Revit/RevitApiTest.cs
+++ b/source/Nice3point.TUnit.Revit/RevitApiTest.cs
@@ -5,32 +5,25 @@ namespace Nice3point.TUnit.Revit;
///
/// Represents a test class for executing tests within the Revit environment.
-/// This class provides dependency resolution, setup and cleanup methods for initializing and terminating
-/// the connection to the Revit API before and after the test session.
+/// This class provides dependency resolution and the setup that establishes the connection to the Revit API before the test session.
///
+///
+/// closes the connection when the test application finishes.
+///
public abstract class RevitApiTest : RevitApplicationTest
{
///
/// Sets up the Revit session by initializing the connection to the Revit API.
- /// This method is executed before the test session begins, ensuring that the
- /// necessary prerequisites for the tests interacting with the Revit environment are satisfied.
+ /// This method is executed before the test session begins, ensuring that the necessary prerequisites for the tests interacting with the Revit environment are satisfied.
///
+ ///
+ /// The first test a session executes triggers the hook.
+ /// Test discovery executes no test and does not start Revit.
+ ///
[Before(TestSession)]
[HookExecutor]
public static void RevitSessionSetup()
{
InitializeRevitConnection();
}
-
- ///
- /// Cleans up the Revit session by terminating the connection to the Revit API.
- /// This method is executed after the test session concludes, ensuring that
- /// resources and connections related to the Revit environment are properly released.
- ///
- [After(TestSession)]
- [HookExecutor]
- public static void RevitSessionCleanup()
- {
- TerminateRevitConnection();
- }
}
diff --git a/source/Nice3point.TUnit.Revit/RevitApplicationTest.cs b/source/Nice3point.TUnit.Revit/RevitApplicationTest.cs
index 71f0407..3390def 100644
--- a/source/Nice3point.TUnit.Revit/RevitApplicationTest.cs
+++ b/source/Nice3point.TUnit.Revit/RevitApplicationTest.cs
@@ -7,8 +7,13 @@ namespace Nice3point.TUnit.Revit;
/// Represents an abstract base class for tests that require interaction with the Revit application environment.
/// Provides methods to initialize and terminate the connection to the Revit application.
///
+///
+/// One connection serves the whole test host process.
+/// Revit activates once per process, and an IDE that keeps a test host alive starts a test session per run in that process.
+///
public abstract class RevitApplicationTest
{
+ private static readonly Lock ConnectionLock = new();
private static Injector? _injector;
///
@@ -16,21 +21,62 @@ public abstract class RevitApplicationTest
///
protected static Application Application { get; private set; } = null!;
+ ///
+ /// Gets a value indicating whether the process holds an open connection to the Revit application.
+ ///
+ internal static bool IsConnected
+ {
+ get
+ {
+ lock (ConnectionLock)
+ {
+ return _injector is not null;
+ }
+ }
+ }
+
///
/// Initializes the connection to the Revit application.
///
+ /// The process terminated its connection earlier.
+ ///
+ /// The first call of the process opens the connection, and every later call keeps the open one.
+ ///
protected static void InitializeRevitConnection()
{
- _injector = new Injector();
- Application = _injector.InjectApplication();
+ lock (ConnectionLock)
+ {
+ if (_injector is not null)
+ {
+ return;
+ }
+
+ var injector = new Injector();
+ Application = injector.InjectApplication();
+ _injector = injector;
+ }
}
///
/// Terminates the connection to the Revit application.
/// Frees associated resources and properly closes the interaction with the Revit environment.
///
- protected static void TerminateRevitConnection()
+ ///
+ /// A call without an open connection has no effect.
+ /// The connection cannot be reopened in the same process.
+ ///
+ protected internal static void TerminateRevitConnection()
{
- _injector?.EjectApplication();
+ lock (ConnectionLock)
+ {
+ if (_injector is null)
+ {
+ return;
+ }
+
+ _injector.EjectApplication();
+ _injector = null;
+ Application = null!;
+ }
}
}
diff --git a/source/Nice3point.TUnit.Revit/RevitConnectionLifetime.cs b/source/Nice3point.TUnit.Revit/RevitConnectionLifetime.cs
new file mode 100644
index 0000000..72b8d12
--- /dev/null
+++ b/source/Nice3point.TUnit.Revit/RevitConnectionLifetime.cs
@@ -0,0 +1,59 @@
+using System.ComponentModel;
+using Microsoft.Testing.Platform.Extensions.TestHost;
+using Nice3point.TUnit.Revit.Executors;
+
+namespace Nice3point.TUnit.Revit;
+
+///
+/// Releases the Revit connection when the test application finishes.
+///
+///
+/// The test platform runs this after the last test session of the process and before the runtime begins shutting down, in the console host of dotnet run and dotnet test as well as in the server host an IDE keeps alive between runs.
+/// It is the last point at which the Revit thread still accepts work.
+/// A process that keeps Revit connected never terminates.
+///
+[EditorBrowsable(EditorBrowsableState.Never)]
+public sealed class RevitConnectionLifetime : ITestHostApplicationLifetime
+{
+ ///
+ public string Uid => nameof(RevitConnectionLifetime);
+
+ ///
+ public string Version => "1.0.0";
+
+ ///
+ public string DisplayName => "Revit connection lifetime";
+
+ ///
+ public string Description => "Releases the Revit connection on the Revit thread when the test application finishes.";
+
+ ///
+ public Task IsEnabledAsync()
+ {
+ return Task.FromResult(true);
+ }
+
+ ///
+ public Task BeforeRunAsync(CancellationToken cancellationToken)
+ {
+ return Task.CompletedTask;
+ }
+
+ ///
+ ///
+ /// When the process holds no connection, as after a discovery request, this method returns without dispatching to the Revit thread.
+ ///
+ public async Task AfterRunAsync(int exitCode, CancellationToken cancellationToken)
+ {
+ if (!RevitApplicationTest.IsConnected)
+ {
+ return;
+ }
+
+ await RevitThreadExecutor.InvokeAsync(static () =>
+ {
+ RevitApplicationTest.TerminateRevitConnection();
+ return default;
+ }).ConfigureAwait(false);
+ }
+}
diff --git a/source/Nice3point.TUnit.Revit/RevitRegistration.cs b/source/Nice3point.TUnit.Revit/RevitRegistration.cs
new file mode 100644
index 0000000..8dce2a5
--- /dev/null
+++ b/source/Nice3point.TUnit.Revit/RevitRegistration.cs
@@ -0,0 +1,46 @@
+using System.ComponentModel;
+using Microsoft.Testing.Platform.Builder;
+
+namespace Nice3point.TUnit.Revit;
+
+///
+/// Provides extension methods for to add the Revit execution model.
+///
+public static class RevitRegistration
+{
+ /// The to add the extensions to.
+ extension(ITestApplicationBuilder builder)
+ {
+ ///
+ /// Adds the Revit connection lifetime to the specified .
+ ///
+ ///
+ /// The build props of the package add this extension to a test project.
+ /// A project that defines its own entry point calls this method from that entry point.
+ ///
+ public void AddRevit()
+ {
+ builder.TestHost.AddTestHostApplicationLifetime(static _ => new RevitConnectionLifetime());
+ }
+ }
+}
+
+///
+/// Provides the hook Microsoft.Testing.Platform calls while it builds the test application.
+///
+///
+/// The TestingPlatformBuilderHook item of the package's build props names this type, and the build step of the test platform compiles a call to into the generated entry point.
+///
+[EditorBrowsable(EditorBrowsableState.Never)]
+public static class TestingPlatformBuilderHook
+{
+ ///
+ /// Adds the Revit extensions to the specified .
+ ///
+ /// The to add the extensions to.
+ /// The command line arguments the test application was started with.
+ public static void AddExtensions(ITestApplicationBuilder builder, string[] arguments)
+ {
+ builder.AddRevit();
+ }
+}
diff --git a/source/Nice3point.TUnit.Revit/build/Nice3point.TUnit.Revit.props b/source/Nice3point.TUnit.Revit/build/Nice3point.TUnit.Revit.props
new file mode 100644
index 0000000..0214a1f
--- /dev/null
+++ b/source/Nice3point.TUnit.Revit/build/Nice3point.TUnit.Revit.props
@@ -0,0 +1,11 @@
+
+
+
+
+
+ Nice3point.TUnit.Revit
+ Nice3point.TUnit.Revit.TestingPlatformBuilderHook
+
+
+
+
diff --git a/tests/Nice3point.TUnit.Revit.Tests/ConnectionLifetimeTests.cs b/tests/Nice3point.TUnit.Revit.Tests/ConnectionLifetimeTests.cs
new file mode 100644
index 0000000..525df7f
--- /dev/null
+++ b/tests/Nice3point.TUnit.Revit.Tests/ConnectionLifetimeTests.cs
@@ -0,0 +1,17 @@
+namespace Nice3point.TUnit.Revit.Tests;
+
+public sealed class ConnectionLifetimeTests : RevitApiTest
+{
+ [Test]
+ public async Task RevitSessionSetup_SecondCallInProcess_KeepsTheOpenConnection()
+ {
+ // Arrange
+ var application = Application;
+
+ // Act
+ RevitSessionSetup();
+
+ // Assert
+ await Assert.That(Application).IsSameReferenceAs(application);
+ }
+}
diff --git a/tests/Nice3point.TUnit.Revit.Tests/Nice3point.TUnit.Revit.Tests.csproj b/tests/Nice3point.TUnit.Revit.Tests/Nice3point.TUnit.Revit.Tests.csproj
index 89da84b..d4fa3f1 100644
--- a/tests/Nice3point.TUnit.Revit.Tests/Nice3point.TUnit.Revit.Tests.csproj
+++ b/tests/Nice3point.TUnit.Revit.Tests/Nice3point.TUnit.Revit.Tests.csproj
@@ -27,4 +27,7 @@
+
+
+
\ No newline at end of file