From 95e2d026dfd158ac9886a8b461947dfb95ffc338 Mon Sep 17 00:00:00 2001 From: Thanh Tu Do Date: Thu, 13 Aug 2026 13:12:00 +0700 Subject: [PATCH 1/2] feat: wrap an existing cOAPI without owning its lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1. EtabExtension.CLI must own the raw CSI lifecycle and the exact OS process identity itself — cHelper.CreateObject(path), a checked cOAPI.ApplicationStart(), a process census that proves which ETABS it owns, then a checked cSapModel.InitializeNewModel() — and only afterwards wants EtabSharp's model abstractions over that exact instance. No public path allowed that: the ETABSApplication constructor is internal, and Connect/ConnectToProcess/CreateNew each create the handle themselves. WrapExisting is pure wrapping. It validates its inputs, reads SapModel, and constructs the wrapper around the caller's exact object. It never creates, starts, attaches, enumerates processes, touches the ROT, hides, or exits. The constructor stays internal, and unlike the other factories this one throws rather than returning null, so the reason a wrap failed is not discarded. Also corrects the stale Dispose() documentation: the implementation releases COM references and has never called ApplicationExit. The comment claiming otherwise is what led a downstream design to ban Dispose outright, when the real rule is that Dispose is never a substitute for — nor to be called before — an authoritative ApplicationExit(false), and is the correct cleanup after it. Tests use a DispatchProxy recorder for cOAPI/cSapModel, so "touched nothing but SapModel" is proven rather than asserted by inspection. They are offline and start no ETABS process. The test project's ETABSv1 detection now mirrors the library's (lib\ first, then newest install) — it previously resolved only from an ETABS 22 install, so no test naming an ETABSv1 type could compile on a machine with ETABS 23 or 24. Co-Authored-By: Claude Opus 5 --- src/EtabSharp/Core/ETABSApplication.cs | 15 +- src/EtabSharp/Core/ETABSWrapper.cs | 86 ++++++++- test/EtabSharp.Test/EtabSharp.Test.csproj | 15 +- test/EtabSharp.Test/wrap_existing_test.cs | 205 ++++++++++++++++++++++ 4 files changed, 313 insertions(+), 8 deletions(-) create mode 100644 test/EtabSharp.Test/wrap_existing_test.cs diff --git a/src/EtabSharp/Core/ETABSApplication.cs b/src/EtabSharp/Core/ETABSApplication.cs index d3242ce..551de99 100644 --- a/src/EtabSharp/Core/ETABSApplication.cs +++ b/src/EtabSharp/Core/ETABSApplication.cs @@ -176,9 +176,18 @@ public void Close(bool savePrompt = false) } /// - /// Disposes this wrapper. Calls ApplicationExit(false) — does NOT save. - /// For Mode A (attach) flows, do NOT dispose — release COM manually via ComCleanup. - /// For Mode B (hidden) flows, disposing is correct and will exit the hidden instance. + /// Disposes this wrapper by releasing its COM references. It does not call + /// ApplicationExit and does not shut ETABS down — the instance keeps running. + /// + /// Shutting ETABS down is always an explicit caller action: + /// app.Application.ApplicationExit(false). Dispose is never a substitute for + /// that call and must not be used before it; once the exit has been requested and the + /// process exit confirmed, disposing is the correct way to release the COM references + /// (CSI documents that the cSapModel reference should be dropped after + /// ApplicationExit). + /// + /// Safe in every mode — attach, hidden-instance, and + /// — because it only releases references this wrapper holds. /// public void Dispose() { diff --git a/src/EtabSharp/Core/ETABSWrapper.cs b/src/EtabSharp/Core/ETABSWrapper.cs index ed0999f..10138d8 100644 --- a/src/EtabSharp/Core/ETABSWrapper.cs +++ b/src/EtabSharp/Core/ETABSWrapper.cs @@ -9,9 +9,10 @@ namespace EtabSharp.Core; /// Factory for creating and connecting to ETABS v22+ instances. /// Returns ETABSApplication — the single entry point for all ETABS interaction. /// -/// Two usage patterns: -/// ETABSWrapper.Connect() — Mode A: attach to user's running ETABS -/// ETABSWrapper.CreateNew() — Mode B: start a new hidden instance +/// Three usage patterns: +/// ETABSWrapper.Connect() — Mode A: attach to the user's running ETABS +/// ETABSWrapper.CreateNew() — Mode B: start a new hidden instance +/// ETABSWrapper.WrapExisting() — Mode C: wrap a cOAPI the caller created and started itself /// public static class ETABSWrapper { @@ -21,6 +22,85 @@ public static class ETABSWrapper #region Public Factory Methods + /// + /// Mode C: wraps a the caller has already created and + /// already started, without performing any lifecycle action of its own. + /// + /// This is deliberately low level. It exists for callers that must own the raw + /// CSI lifecycle and the exact OS process identity themselves — creating the object + /// with cHelper.CreateObject(path), checking cOAPI.ApplicationStart(), + /// proving which process they own, and calling cSapModel.InitializeNewModel() — + /// and only then want EtabSharp's model and domain abstractions over that exact + /// instance. Callers that do not need that control should use + /// or instead. + /// + /// This method performs no lifecycle or discovery work: it does not create + /// an object, does not call ApplicationStart, does not attach through + /// GetObject/GetObjectProcess or the ROT, does not enumerate or select + /// ETABS processes, and never calls Hide, Unhide or ApplicationExit. + /// The only member it touches on is SapModel. + /// + /// Ownership: the caller owns the application lifecycle. Wrapping does not + /// transfer it. Disposing the returned wrapper releases COM references only — it does + /// not exit ETABS — so the caller must perform its own authoritative + /// ApplicationExit(false) and process-exit confirmation, and dispose only + /// afterwards. + /// + /// + /// An existing, already-started API object. The exact instance is preserved; nothing + /// is created or re-attached on the caller's behalf. + /// + /// ETABS major version of the running instance (22 or newer). + /// OAPI version reported by that instance; 0 if the caller could not read it. + /// Full ETABS version string of the running instance, e.g. "23.3.0". + /// Optional logger for diagnostics. + /// is null. + /// is null or blank. + /// + /// is below the minimum supported version, or + /// is negative. + /// + /// + /// exposes no SapModel. An object that was never started + /// is not usable, and this method will not start it. + /// + public static ETABSApplication WrapExisting( + ETABSv1.cOAPI api, + int majorVersion, + double apiVersion, + string fullVersion, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(api); + + if (string.IsNullOrWhiteSpace(fullVersion)) + { + throw new ArgumentException( + "Full version must be supplied by the caller; wrapping does not discover it.", + nameof(fullVersion)); + } + + if (majorVersion < MINIMUM_SUPPORTED_VERSION) + { + throw new ArgumentOutOfRangeException( + nameof(majorVersion), + majorVersion, + $"ETABS v{MINIMUM_SUPPORTED_VERSION} is the minimum supported version."); + } + + if (apiVersion < 0) + { + throw new ArgumentOutOfRangeException( + nameof(apiVersion), + apiVersion, + "OAPI version cannot be negative."); + } + + // Unlike Connect/CreateNew this returns non-null or throws: the caller already holds + // a handle it proved, so a silent null would discard the reason wrapping failed. + return new ETABSApplication(api, majorVersion, apiVersion, fullVersion, logger); + } + /// /// Mode A: Connects to the currently running ETABS instance (v22+). /// Does NOT call ApplicationStart or Hide — attaches to whatever ETABS the user has open. diff --git a/test/EtabSharp.Test/EtabSharp.Test.csproj b/test/EtabSharp.Test/EtabSharp.Test.csproj index 55fa735..1ba283c 100644 --- a/test/EtabSharp.Test/EtabSharp.Test.csproj +++ b/test/EtabSharp.Test/EtabSharp.Test.csproj @@ -5,7 +5,7 @@ enable Exe EtabSharp.Test - net10.0 + net8.0;net10.0 false true @@ -40,8 +40,19 @@ + - C:\Program Files\Computers and Structures\ETABS 22\ETABSv1.dll + $(MSBuildThisFileDirectory)..\..\lib\ETABSv1.dll + C:\Program Files\Computers and Structures\ETABS 24\ETABSv1.dll + C:\Program Files (x86)\Computers and Structures\ETABS 24\ETABSv1.dll + C:\Program Files\Computers and Structures\ETABS 23\ETABSv1.dll + C:\Program Files (x86)\Computers and Structures\ETABS 23\ETABSv1.dll + C:\Program Files\Computers and Structures\ETABS 22\ETABSv1.dll C:\Program Files (x86)\Computers and Structures\ETABS 22\ETABSv1.dll diff --git a/test/EtabSharp.Test/wrap_existing_test.cs b/test/EtabSharp.Test/wrap_existing_test.cs new file mode 100644 index 0000000..89f5697 --- /dev/null +++ b/test/EtabSharp.Test/wrap_existing_test.cs @@ -0,0 +1,205 @@ +using EtabSharp.Core; +using ETABSv1; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Reflection; +using Xunit; + +namespace EtabSharp.Test; + +// ───────────────────────────────────────────────────────────── +// ETABSWrapper.WrapExisting — Mode C (pure wrapping) +// +// These tests are OFFLINE. They never start, attach to, or exit ETABS: the +// cOAPI passed in is a recording proxy, so every member the factory touches is +// observable. That is the point of the suite — the contract is as much about +// what WrapExisting must NOT do as what it returns. +// ───────────────────────────────────────────────────────────── + +[Trait("Category", "Offline")] +public class WrapExistingTests +{ + private const int MajorVersion = 23; + private const double ApiVersion = 1.0; + private const string FullVersion = "23.3.0"; + + private static readonly string[] LifecycleMembers = + [ + "ApplicationStart", + "ApplicationExit", + "Hide", + "Unhide", + "Visible", + "SetAsActiveObject", + "UnsetAsActiveObject", + "InternalExec", + "GetOAPIVersionNumber" + ]; + + [Fact] + public void WrapExisting_PreservesTheExactObjectsSupplied() + { + var (api, recorder, sapModel) = CreateRecordingApi(); + + var app = ETABSWrapper.WrapExisting(api, MajorVersion, ApiVersion, FullVersion); + + // Same cSapModel instance — not a re-read from some other application object. + Assert.Same(sapModel, app.SapModel); + Assert.Equal(MajorVersion, app.MajorVersion); + Assert.Equal(ApiVersion, app.ApiVersion); + Assert.Equal(FullVersion, app.FullVersion); + Assert.Single(recorder.Calls, call => call == "get_SapModel"); + } + + [Fact] + public void WrapExisting_TouchesNoLifecycleMemberAndStartsNoProcess() + { + var (api, recorder, _) = CreateRecordingApi(); + var etabsBefore = Process.GetProcessesByName("ETABS").Length; + + _ = ETABSWrapper.WrapExisting(api, MajorVersion, ApiVersion, FullVersion); + + // The only member the factory may touch is SapModel. + Assert.Equal(["get_SapModel"], recorder.Calls); + foreach (var member in LifecycleMembers) + { + Assert.DoesNotContain(recorder.Calls, call => call.Contains(member, StringComparison.Ordinal)); + } + + // No creation, no attach, no ROT, no process selection. + Assert.Equal(etabsBefore, Process.GetProcessesByName("ETABS").Length); + } + + [Fact] + public void WrapExisting_DisposeReleasesReferencesWithoutExitingEtabs() + { + var (api, recorder, _) = CreateRecordingApi(); + var app = ETABSWrapper.WrapExisting(api, MajorVersion, ApiVersion, FullVersion); + + app.Dispose(); + + // Dispose is COM cleanup only. It is never a substitute for an authoritative + // ApplicationExit(false), and must not issue one behind the caller's back. + Assert.DoesNotContain("ApplicationExit", recorder.Calls); + Assert.Equal(["get_SapModel"], recorder.Calls); + } + + [Fact] + public void WrapExisting_IsIdempotentAcrossCallsAndNeverSubstitutesAnotherInstance() + { + var (firstApi, _, firstSapModel) = CreateRecordingApi(); + var (secondApi, _, secondSapModel) = CreateRecordingApi(); + + var first = ETABSWrapper.WrapExisting(firstApi, MajorVersion, ApiVersion, FullVersion); + var second = ETABSWrapper.WrapExisting(secondApi, MajorVersion, ApiVersion, FullVersion); + + Assert.Same(firstSapModel, first.SapModel); + Assert.Same(secondSapModel, second.SapModel); + Assert.NotSame(first.SapModel, second.SapModel); + } + + [Fact] + public void WrapExisting_RejectsANullApi() + { + var error = Assert.Throws( + () => ETABSWrapper.WrapExisting(null!, MajorVersion, ApiVersion, FullVersion)); + + Assert.Equal("api", error.ParamName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void WrapExisting_RejectsMissingFullVersion(string? fullVersion) + { + var (api, recorder, _) = CreateRecordingApi(); + + var error = Assert.Throws( + () => ETABSWrapper.WrapExisting(api, MajorVersion, ApiVersion, fullVersion!)); + + Assert.Equal("fullVersion", error.ParamName); + // Rejected before anything was read from the caller's object. + Assert.Empty(recorder.Calls); + } + + [Theory] + [InlineData(0)] + [InlineData(21)] + public void WrapExisting_RejectsUnsupportedMajorVersion(int majorVersion) + { + var (api, recorder, _) = CreateRecordingApi(); + + var error = Assert.Throws( + () => ETABSWrapper.WrapExisting(api, majorVersion, ApiVersion, FullVersion)); + + Assert.Equal("majorVersion", error.ParamName); + Assert.Empty(recorder.Calls); + } + + [Fact] + public void WrapExisting_RejectsNegativeApiVersion() + { + var (api, recorder, _) = CreateRecordingApi(); + + var error = Assert.Throws( + () => ETABSWrapper.WrapExisting(api, MajorVersion, -1.0, FullVersion)); + + Assert.Equal("apiVersion", error.ParamName); + Assert.Empty(recorder.Calls); + } + + [Fact] + public void WrapExisting_RejectsAnApiWithNoSapModel() + { + var (api, recorder, _) = CreateRecordingApi(withSapModel: false); + + // An object that was never started has no usable model, and WrapExisting will not + // start it — that stays the caller's job. + Assert.Throws( + () => ETABSWrapper.WrapExisting(api, MajorVersion, ApiVersion, FullVersion)); + Assert.Equal(["get_SapModel"], recorder.Calls); + } + + private static (cOAPI Api, CallRecorder Recorder, cSapModel? SapModel) CreateRecordingApi( + bool withSapModel = true) + { + var sapModel = withSapModel ? CreateProxy() : null; + var api = CreateProxy(); + var recorder = ((RecordingProxy)(object)api).Recorder; + recorder.Returns["get_SapModel"] = sapModel; + return (api, recorder, sapModel); + } + + private static T CreateProxy() where T : class => + DispatchProxy.Create(); + + /// Records every member invocation so "did not touch" is provable, not assumed. + public sealed class CallRecorder + { + public List Calls { get; } = []; + public Dictionary Returns { get; } = []; + } + + public class RecordingProxy : DispatchProxy + { + public CallRecorder Recorder { get; } = new(); + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + var name = targetMethod?.Name ?? ""; + Recorder.Calls.Add(name); + + if (Recorder.Returns.TryGetValue(name, out var configured)) + { + return configured; + } + + var returnType = targetMethod?.ReturnType; + return returnType is null || returnType == typeof(void) || !returnType.IsValueType + ? null + : Activator.CreateInstance(returnType); + } + } +} From 2fdc5d70d37cddc1905f1825327719482568efcd Mon Sep 17 00:00:00 2001 From: Thanh Tu Do Date: Thu, 13 Aug 2026 13:29:47 +0700 Subject: [PATCH 2/2] fix: release only the real COM references, independently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dispose released _model.Value and _application.Value through Marshal.ReleaseComObject, but those are ordinary managed EtabSharp wrappers — ETABSModel holds the cSapModel, ETABSApplicationManager holds the cOAPI. That API rejects a non-COM object, and because every release shared one try block, the first failure skipped the two releases that actually matter: _sapModel and _api. Only those two are released now, each in isolation so a failure on one cannot prevent the other. Dispose stays completely non-exiting. The XML wording is corrected too. Saying Dispose "must not be used before" ApplicationExit contradicted the library's own Mode A contract, where an attached session disposes without an exit and the user's ETABS is meant to stay running. The rule is per-mode: attached sessions dispose on their own; a caller-owned session being shut down resolves the authoritative ApplicationExit(false) and process exit first, then disposes. The offline test now materializes both lazy wrappers before disposing and asserts two independent release attempts, no lifecycle call, and a safe double dispose. Restoring the old single-try body fails that test, so it detects the defect rather than merely passing over it. Co-Authored-By: Claude Opus 5 --- src/EtabSharp/Core/ETABSApplication.cs | 68 +++++++++++++---------- src/EtabSharp/Core/ETABSWrapper.cs | 8 +-- test/EtabSharp.Test/wrap_existing_test.cs | 68 +++++++++++++++++++++++ 3 files changed, 110 insertions(+), 34 deletions(-) diff --git a/src/EtabSharp/Core/ETABSApplication.cs b/src/EtabSharp/Core/ETABSApplication.cs index 551de99..73883e2 100644 --- a/src/EtabSharp/Core/ETABSApplication.cs +++ b/src/EtabSharp/Core/ETABSApplication.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using System.Runtime.InteropServices; +using System.Runtime.Versioning; namespace EtabSharp.Core; @@ -176,18 +177,23 @@ public void Close(bool savePrompt = false) } /// - /// Disposes this wrapper by releasing its COM references. It does not call - /// ApplicationExit and does not shut ETABS down — the instance keeps running. + /// Disposes this wrapper by releasing the COM references it holds. It does not + /// call ApplicationExit and never shuts ETABS down. /// - /// Shutting ETABS down is always an explicit caller action: - /// app.Application.ApplicationExit(false). Dispose is never a substitute for - /// that call and must not be used before it; once the exit has been requested and the - /// process exit confirmed, disposing is the correct way to release the COM references - /// (CSI documents that the cSapModel reference should be dropped after - /// ApplicationExit). + /// Attached / external session (, + /// ): disposing on its own is the correct + /// and complete cleanup. The user's ETABS stays running, which is the point — the + /// session was never ours to end. /// - /// Safe in every mode — attach, hidden-instance, and - /// — because it only releases references this wrapper holds. + /// Caller-owned session being shut down (a hidden instance from + /// , or a handle wrapped with + /// ): request the authoritative + /// app.Application.ApplicationExit(false) and resolve the process exit first, + /// then dispose. Dispose is never a substitute for that exit — it releases references, + /// not the application — and CSI documents that the cSapModel reference should + /// be dropped after ApplicationExit. + /// + /// Safe to call more than once. /// public void Dispose() { @@ -196,27 +202,15 @@ public void Dispose() // Only attempt COM cleanup on Windows platforms where Marshal.ReleaseComObject is supported. // This prevents CA1416 diagnostics and avoids calling Windows-only runtime APIs on other platforms. - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + if (OperatingSystem.IsWindows()) { - try - { - // Release child managers first (reverse init order) - if (_model.IsValueCreated) - Marshal.ReleaseComObject(_model.Value); - - if (_application.IsValueCreated) - Marshal.ReleaseComObject(_application.Value); - - // Release raw COM refs last (parents after children) - Marshal.ReleaseComObject(_sapModel); - Marshal.ReleaseComObject(_api); - - _logger.LogInformation("COM references released"); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error releasing COM objects: {Message}", ex.Message); - } + // Only _sapModel and _api are real COM references. _model and _application are + // ordinary managed wrappers around them, and Marshal.ReleaseComObject rejects a + // non-COM object — releasing them here used to throw and, because every release + // shared one try block, could skip the two releases that actually matter. + // Each real reference is now released independently. + ReleaseComReference(_sapModel, nameof(SapModel)); + ReleaseComReference(_api, "cOAPI"); } else { @@ -226,6 +220,20 @@ public void Dispose() GC.SuppressFinalize(this); } + [SupportedOSPlatform("windows")] + private void ReleaseComReference(object comReference, string name) + { + try + { + Marshal.ReleaseComObject(comReference); + _logger.LogInformation("COM reference released: {Reference}", name); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error releasing COM reference {Reference}: {Message}", name, ex.Message); + } + } + #region Advanced / Raw Access /// diff --git a/src/EtabSharp/Core/ETABSWrapper.cs b/src/EtabSharp/Core/ETABSWrapper.cs index 10138d8..ee6fa91 100644 --- a/src/EtabSharp/Core/ETABSWrapper.cs +++ b/src/EtabSharp/Core/ETABSWrapper.cs @@ -41,10 +41,10 @@ public static class ETABSWrapper /// The only member it touches on is SapModel. /// /// Ownership: the caller owns the application lifecycle. Wrapping does not - /// transfer it. Disposing the returned wrapper releases COM references only — it does - /// not exit ETABS — so the caller must perform its own authoritative - /// ApplicationExit(false) and process-exit confirmation, and dispose only - /// afterwards. + /// transfer it, and disposing the returned wrapper releases COM references only — it + /// never exits ETABS. When the caller is shutting that session down, it must request + /// the authoritative ApplicationExit(false) and resolve the process exit itself, + /// and dispose afterwards. /// /// /// An existing, already-started API object. The exact instance is preserved; nothing diff --git a/test/EtabSharp.Test/wrap_existing_test.cs b/test/EtabSharp.Test/wrap_existing_test.cs index 89f5697..c80c091 100644 --- a/test/EtabSharp.Test/wrap_existing_test.cs +++ b/test/EtabSharp.Test/wrap_existing_test.cs @@ -1,5 +1,6 @@ using EtabSharp.Core; using ETABSv1; +using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Diagnostics; @@ -85,6 +86,55 @@ public void WrapExisting_DisposeReleasesReferencesWithoutExitingEtabs() Assert.Equal(["get_SapModel"], recorder.Calls); } + // The managed wrappers are not COM RCWs. Releasing them used to throw inside the one + // shared try block and could skip the two references that actually are COM. + [Fact] + public void WrapExisting_DisposeWithMaterializedWrappersStillAttemptsBothComReleases() + { + var (api, recorder, _) = CreateRecordingApi(); + var log = new RecordingLogger(); + var app = ETABSWrapper.WrapExisting(api, MajorVersion, ApiVersion, FullVersion, log); + + // Materialize both lazy managed wrappers before disposing. + Assert.NotNull(app.Model); + Assert.NotNull(app.Application); + Assert.Equal(["get_SapModel"], recorder.Calls); + + app.Dispose(); + + // Each real COM reference is attempted independently: with a non-COM test double + // both releases fail, and both failures must be recorded — one skipping the other + // is exactly the defect being fixed. + var releaseEntries = log.Entries.FindAll(entry => entry.Contains("COM reference", StringComparison.Ordinal)); + Assert.Equal(2, releaseEntries.Count); + Assert.Contains(releaseEntries, entry => entry.Contains("SapModel", StringComparison.Ordinal)); + Assert.Contains(releaseEntries, entry => entry.Contains("cOAPI", StringComparison.Ordinal)); + + // Still no lifecycle call, even with both wrappers materialized. + Assert.Equal(["get_SapModel"], recorder.Calls); + foreach (var member in LifecycleMembers) + { + Assert.DoesNotContain(recorder.Calls, call => call.Contains(member, StringComparison.Ordinal)); + } + } + + [Fact] + public void WrapExisting_DoubleDisposeIsSafeAndDoesNothingTheSecondTime() + { + var (api, recorder, _) = CreateRecordingApi(); + var log = new RecordingLogger(); + var app = ETABSWrapper.WrapExisting(api, MajorVersion, ApiVersion, FullVersion, log); + _ = app.Model; + _ = app.Application; + + app.Dispose(); + var afterFirst = log.Entries.Count; + app.Dispose(); + + Assert.Equal(afterFirst, log.Entries.Count); + Assert.Equal(["get_SapModel"], recorder.Calls); + } + [Fact] public void WrapExisting_IsIdempotentAcrossCallsAndNeverSubstitutesAnotherInstance() { @@ -175,6 +225,24 @@ private static (cOAPI Api, CallRecorder Recorder, cSapModel? SapModel) CreateRec private static T CreateProxy() where T : class => DispatchProxy.Create(); + /// Captures log entries so per-reference release attempts are observable. + private sealed class RecordingLogger : ILogger + { + public List Entries { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) => + Entries.Add(formatter(state, exception)); + } + /// Records every member invocation so "did not touch" is provable, not assumed. public sealed class CallRecorder {