Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessSession.cs
T
Joseph Doherty fca978de07 docs(src): add missing XML docs and strip tracking-ID comments
Sweep of 203 source files resolving CommentChecker findings: add
<summary>/<param>/<returns>/<inheritdoc> where missing, and remove
resolved task/issue tracking markers (Tests-NNN, Worker-NNN, Server-NNN,
Task N) from code comments. Comment/doc-only — no logic changes.
Server+Tests build clean under TreatWarningsAsErrors.
2026-07-07 14:09:49 -04:00

1298 lines
46 KiB
C#

using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
public sealed class MxAccessSession : IDisposable
{
private readonly object mxAccessComObject;
private readonly IMxAccessServer mxAccessServer;
private readonly IMxAccessEventSink eventSink;
private readonly MxAccessHandleRegistry handleRegistry;
private readonly MxAccessValueCache valueCache;
private bool disposed;
private MxAccessSession(
object mxAccessComObject,
IMxAccessServer mxAccessServer,
IMxAccessEventSink eventSink,
MxAccessHandleRegistry handleRegistry,
MxAccessValueCache valueCache,
int creationThreadId)
{
this.mxAccessComObject = mxAccessComObject ?? throw new ArgumentNullException(nameof(mxAccessComObject));
this.mxAccessServer = mxAccessServer ?? throw new ArgumentNullException(nameof(mxAccessServer));
this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink));
this.handleRegistry = handleRegistry ?? throw new ArgumentNullException(nameof(handleRegistry));
this.valueCache = valueCache ?? throw new ArgumentNullException(nameof(valueCache));
CreationThreadId = creationThreadId;
}
/// <summary>The thread ID where this session was created.</summary>
public int CreationThreadId { get; }
/// <summary>The registry for tracking opened handles.</summary>
public MxAccessHandleRegistry HandleRegistry => handleRegistry;
/// <summary>
/// Per-session last-value cache populated by the event sink. ReadBulk
/// consults this cache before falling back to its own snapshot
/// lifecycle so it can serve a "current value" for an already-advised
/// tag without touching the existing subscription.
/// </summary>
public MxAccessValueCache ValueCache => valueCache;
/// <summary>Creates a WorkerReady message with session metadata.</summary>
/// <param name="workerProcessId">Process ID of the worker.</param>
/// <returns>The populated <see cref="WorkerReady"/> message.</returns>
public WorkerReady CreateWorkerReady(int workerProcessId)
{
return new WorkerReady
{
WorkerProcessId = workerProcessId,
MxaccessProgid = MxAccessInteropInfo.ProgId,
MxaccessClsid = MxAccessInteropInfo.Clsid,
ReadyTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
};
}
/// <summary>
/// Test-only seam: constructs a session that bypasses the live COM
/// factory. The caller supplies the <see cref="IMxAccessServer"/> and
/// <see cref="IMxAccessEventSink"/> directly so tests can exercise
/// session methods without touching MXAccess COM. This is exposed via
/// <c>InternalsVisibleTo("ZB.MOM.WW.MxGateway.Worker.Tests")</c>; production code
/// must use the <see cref="Create"/> factory.
///
/// A runtime guard rejects an <see cref="MxAccessBaseEventSink"/> —
/// the production sink wired by <see cref="Create"/> — because the
/// <c>new object()</c> stand-in this factory uses for the COM object
/// would silently bypass <see cref="System.Runtime.InteropServices.Marshal.IsComObject(object)"/>
/// during disposal and mask lifetime regressions.
/// </summary>
/// <param name="mxAccessServer">The server abstraction to drive.</param>
/// <param name="eventSink">The event sink to attach to the session.</param>
/// <param name="handleRegistry">Optional handle registry; a fresh one is created when null.</param>
/// <param name="valueCache">Optional value cache; a fresh one is created when null.</param>
/// <param name="creationThreadId">Optional creation thread id; defaults to the current managed thread id.</param>
/// <returns>A test-only <see cref="MxAccessSession"/> backed by the supplied test doubles.</returns>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="eventSink"/> is the production
/// <see cref="MxAccessBaseEventSink"/>. Tests must pass a test
/// double sink — production code must use <see cref="Create"/>.
/// </exception>
internal static MxAccessSession CreateForTesting(
IMxAccessServer mxAccessServer,
IMxAccessEventSink eventSink,
MxAccessHandleRegistry? handleRegistry = null,
MxAccessValueCache? valueCache = null,
int? creationThreadId = null)
{
if (eventSink is MxAccessBaseEventSink)
{
throw new ArgumentException(
"CreateForTesting must not be used with the production MxAccessBaseEventSink. "
+ "Use MxAccessSession.Create for production code; pass a test-double IMxAccessEventSink here.",
nameof(eventSink));
}
return new MxAccessSession(
new object(),
mxAccessServer,
eventSink,
handleRegistry ?? new MxAccessHandleRegistry(),
valueCache ?? new MxAccessValueCache(),
creationThreadId ?? Environment.CurrentManagedThreadId);
}
/// <summary>Creates and initializes an MXAccess COM session.</summary>
/// <param name="factory">Factory to create the MXAccess COM object.</param>
/// <param name="eventSink">Event sink to attach to the COM object.</param>
/// <param name="sessionId">Identifier of the session.</param>
/// <returns>The initialized <see cref="MxAccessSession"/>.</returns>
public static MxAccessSession Create(
IMxAccessComObjectFactory factory,
IMxAccessEventSink eventSink,
string sessionId)
{
if (factory is null)
{
throw new ArgumentNullException(nameof(factory));
}
if (eventSink is null)
{
throw new ArgumentNullException(nameof(eventSink));
}
object? mxAccessComObject = null;
try
{
mxAccessComObject = factory.Create();
if (mxAccessComObject is null)
{
throw new InvalidOperationException("MXAccess COM factory returned null.");
}
eventSink.Attach(mxAccessComObject, sessionId);
// Share the event sink's value cache when one is wired (the
// production MxAccessBaseEventSink path) so OnDataChange writes and
// ReadBulk reads both see the same instance. Fall back to a fresh
// cache for test fakes that supply their own sink — ReadBulk simply
// never serves cached values in that case.
MxAccessValueCache valueCache = eventSink is MxAccessBaseEventSink baseSink
? baseSink.ValueCache
: new MxAccessValueCache();
return new MxAccessSession(
mxAccessComObject,
new MxAccessComServer(mxAccessComObject),
eventSink,
new MxAccessHandleRegistry(),
valueCache,
Environment.CurrentManagedThreadId);
}
catch (Exception exception)
{
try
{
eventSink.Detach();
}
catch
{
// Preserve the creation failure while still releasing the COM object below.
}
if (mxAccessComObject is not null && Marshal.IsComObject(mxAccessComObject))
{
Marshal.FinalReleaseComObject(mxAccessComObject);
}
throw MxAccessCreationException.From(exception);
}
}
/// <summary>Registers a client with MXAccess and returns the server handle.</summary>
/// <param name="clientName">Name of the client to register.</param>
/// <returns>The server handle assigned to the registered client.</returns>
public int Register(string clientName)
{
ThrowIfDisposed();
int serverHandle = mxAccessServer.Register(clientName);
handleRegistry.RegisterServerHandle(serverHandle, clientName);
return serverHandle;
}
/// <summary>Unregisters a client from MXAccess.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
public void Unregister(int serverHandle)
{
ThrowIfDisposed();
mxAccessServer.Unregister(serverHandle);
handleRegistry.UnregisterServerHandle(serverHandle);
}
/// <summary>Adds an item to an MXAccess server and returns the item handle.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemDefinition">Definition or address of the item to add.</param>
/// <returns>The item handle assigned to the added item.</returns>
public int AddItem(
int serverHandle,
string itemDefinition)
{
ThrowIfDisposed();
int itemHandle = mxAccessServer.AddItem(serverHandle, itemDefinition);
handleRegistry.RegisterItemHandle(
serverHandle,
itemHandle,
itemDefinition,
string.Empty,
hasItemContext: false);
return itemHandle;
}
/// <summary>Adds an item with context to an MXAccess server and returns the item handle.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemDefinition">Definition or address of the item to add.</param>
/// <param name="itemContext">Context string for the item.</param>
/// <returns>The item handle assigned to the added item.</returns>
public int AddItem2(
int serverHandle,
string itemDefinition,
string itemContext)
{
ThrowIfDisposed();
int itemHandle = mxAccessServer.AddItem2(serverHandle, itemDefinition, itemContext);
handleRegistry.RegisterItemHandle(
serverHandle,
itemHandle,
itemDefinition,
itemContext,
hasItemContext: true);
return itemHandle;
}
/// <summary>Removes an item from an MXAccess server.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
public void RemoveItem(
int serverHandle,
int itemHandle)
{
ThrowIfDisposed();
mxAccessServer.RemoveItem(serverHandle, itemHandle);
handleRegistry.RemoveItemHandle(serverHandle, itemHandle);
// Evict the last-value entry so a future AddItem + Advise on the
// same handle id (which MXAccess may reuse) does not serve a stale
// OnDataChange snapshot from the previous lifetime.
valueCache.Remove(serverHandle, itemHandle);
}
/// <summary>Advises on item changes with plain subscription.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
public void Advise(
int serverHandle,
int itemHandle)
{
ThrowIfDisposed();
mxAccessServer.Advise(serverHandle, itemHandle);
handleRegistry.RegisterAdviceHandle(
serverHandle,
itemHandle,
MxAccessAdviceKind.Plain);
}
/// <summary>Removes plain advice subscription from an item.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
public void UnAdvise(
int serverHandle,
int itemHandle)
{
ThrowIfDisposed();
mxAccessServer.UnAdvise(serverHandle, itemHandle);
handleRegistry.RemoveAdviceHandles(serverHandle, itemHandle);
}
/// <summary>Advises on item changes with supervisory subscription.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
public void AdviseSupervisory(
int serverHandle,
int itemHandle)
{
ThrowIfDisposed();
mxAccessServer.AdviseSupervisory(serverHandle, itemHandle);
handleRegistry.RegisterAdviceHandle(
serverHandle,
itemHandle,
MxAccessAdviceKind.Supervisory);
}
/// <summary>Suspends data acquisition for an advised item and returns the native MXAccess status.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <returns>The boxed native MXAccess status produced by the call.</returns>
public object Suspend(
int serverHandle,
int itemHandle)
{
ThrowIfDisposed();
return mxAccessServer.Suspend(serverHandle, itemHandle);
}
/// <summary>Reactivates data acquisition for a suspended item and returns the native MXAccess status.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <returns>The boxed native MXAccess status produced by the call.</returns>
public object Activate(
int serverHandle,
int itemHandle)
{
ThrowIfDisposed();
return mxAccessServer.Activate(serverHandle, itemHandle);
}
/// <summary>Authenticates an MXAccess user and returns its user id.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="verifyUser">MXAccess user name to authenticate.</param>
/// <param name="verifyUserPassword">Raw MXAccess credential; never logged.</param>
/// <returns>The MXAccess user id for the authenticated user.</returns>
public int AuthenticateUser(
int serverHandle,
string verifyUser,
string verifyUserPassword)
{
ThrowIfDisposed();
return mxAccessServer.AuthenticateUser(serverHandle, verifyUser, verifyUserPassword);
}
/// <summary>Resolves an ArchestrA user GUID to an MXAccess user id.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="userIdGuid">ArchestrA user GUID to resolve.</param>
/// <returns>The MXAccess user id for the resolved user.</returns>
public int ArchestrAUserToId(
int serverHandle,
string userIdGuid)
{
ThrowIfDisposed();
return mxAccessServer.ArchestrAUserToId(serverHandle, userIdGuid);
}
/// <summary>Adds a buffered item to an MXAccess server and returns the item handle.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemDefinition">Definition or address of the item to add.</param>
/// <param name="itemContext">Context string for the item.</param>
/// <returns>The item handle assigned to the added item.</returns>
public int AddBufferedItem(
int serverHandle,
string itemDefinition,
string itemContext)
{
ThrowIfDisposed();
int itemHandle = mxAccessServer.AddBufferedItem(serverHandle, itemDefinition, itemContext);
handleRegistry.RegisterItemHandle(
serverHandle,
itemHandle,
itemDefinition,
itemContext,
hasItemContext: true);
return itemHandle;
}
/// <summary>Sets the buffered-update interval for an MXAccess server.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="updateIntervalMilliseconds">Buffered update interval in milliseconds.</param>
public void SetBufferedUpdateInterval(
int serverHandle,
int updateIntervalMilliseconds)
{
ThrowIfDisposed();
mxAccessServer.SetBufferedUpdateInterval(serverHandle, updateIntervalMilliseconds);
}
/// <summary>Writes a value to an item.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="value">COM-marshalable value to write.</param>
/// <param name="userId">MXAccess user id (security classification) for the write.</param>
public void Write(
int serverHandle,
int itemHandle,
object? value,
int userId)
{
ThrowIfDisposed();
mxAccessServer.Write(serverHandle, itemHandle, value, userId);
}
/// <summary>Writes a value with an explicit source timestamp to an item.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="value">COM-marshalable value to write.</param>
/// <param name="timestamp">COM-marshalable source timestamp for the write.</param>
/// <param name="userId">MXAccess user id (security classification) for the write.</param>
public void Write2(
int serverHandle,
int itemHandle,
object? value,
object? timestamp,
int userId)
{
ThrowIfDisposed();
mxAccessServer.Write2(serverHandle, itemHandle, value, timestamp, userId);
}
/// <summary>Performs a secured/verified write to an item.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="currentUserId">MXAccess user id of the operator performing the write.</param>
/// <param name="verifierUserId">MXAccess user id of the verifier authorizing the write.</param>
/// <param name="value">COM-marshalable value to write.</param>
public void WriteSecured(
int serverHandle,
int itemHandle,
int currentUserId,
int verifierUserId,
object? value)
{
ThrowIfDisposed();
mxAccessServer.WriteSecured(serverHandle, itemHandle, currentUserId, verifierUserId, value);
}
/// <summary>Performs a secured/verified write with an explicit source timestamp.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="currentUserId">MXAccess user id of the operator performing the write.</param>
/// <param name="verifierUserId">MXAccess user id of the verifier authorizing the write.</param>
/// <param name="value">COM-marshalable value to write.</param>
/// <param name="timestamp">COM-marshalable source timestamp for the write.</param>
public void WriteSecured2(
int serverHandle,
int itemHandle,
int currentUserId,
int verifierUserId,
object? value,
object? timestamp)
{
ThrowIfDisposed();
mxAccessServer.WriteSecured2(serverHandle, itemHandle, currentUserId, verifierUserId, value, timestamp);
}
/// <summary>Adds multiple items in bulk, returning success/failure results.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="tagAddresses">Enumerable of item definitions to add.</param>
/// <returns>The per-item success/failure results, in the order the tags were provided.</returns>
public IReadOnlyList<SubscribeResult> AddItemBulk(
int serverHandle,
IEnumerable<string> tagAddresses)
{
ThrowIfDisposed();
if (tagAddresses is null)
{
throw new ArgumentNullException(nameof(tagAddresses));
}
List<SubscribeResult> results = new();
foreach (string? tagAddress in tagAddresses)
{
if (string.IsNullOrWhiteSpace(tagAddress))
{
results.Add(Failed(serverHandle, tagAddress ?? string.Empty, itemHandle: 0, "Tag address is required."));
continue;
}
try
{
int itemHandle = AddItem(serverHandle, tagAddress);
results.Add(Succeeded(serverHandle, tagAddress, itemHandle));
}
catch (Exception exception)
{
results.Add(Failed(serverHandle, tagAddress, itemHandle: 0, exception.Message));
}
}
return results;
}
/// <summary>Advises on multiple items in bulk, returning success/failure results.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandles">Enumerable of item handles to advise on.</param>
/// <returns>The per-item success/failure results, in the order the handles were provided.</returns>
public IReadOnlyList<SubscribeResult> AdviseItemBulk(
int serverHandle,
IEnumerable<int> itemHandles)
{
ThrowIfDisposed();
if (itemHandles is null)
{
throw new ArgumentNullException(nameof(itemHandles));
}
List<SubscribeResult> results = new();
foreach (int itemHandle in itemHandles)
{
try
{
Advise(serverHandle, itemHandle);
results.Add(Succeeded(serverHandle, string.Empty, itemHandle));
}
catch (Exception exception)
{
results.Add(Failed(serverHandle, string.Empty, itemHandle, exception.Message));
}
}
return results;
}
/// <summary>Removes multiple items in bulk, returning success/failure results.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandles">Enumerable of item handles to remove.</param>
/// <returns>The per-item success/failure results, in the order the handles were provided.</returns>
public IReadOnlyList<SubscribeResult> RemoveItemBulk(
int serverHandle,
IEnumerable<int> itemHandles)
{
ThrowIfDisposed();
if (itemHandles is null)
{
throw new ArgumentNullException(nameof(itemHandles));
}
List<SubscribeResult> results = new();
foreach (int itemHandle in itemHandles)
{
try
{
RemoveItem(serverHandle, itemHandle);
results.Add(Succeeded(serverHandle, string.Empty, itemHandle));
}
catch (Exception exception)
{
results.Add(Failed(serverHandle, string.Empty, itemHandle, exception.Message));
}
}
return results;
}
/// <summary>Removes advice subscriptions from multiple items in bulk, returning success/failure results.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandles">Enumerable of item handles to unadvise.</param>
/// <returns>The per-item success/failure results, in the order the handles were provided.</returns>
public IReadOnlyList<SubscribeResult> UnAdviseItemBulk(
int serverHandle,
IEnumerable<int> itemHandles)
{
ThrowIfDisposed();
if (itemHandles is null)
{
throw new ArgumentNullException(nameof(itemHandles));
}
List<SubscribeResult> results = new();
foreach (int itemHandle in itemHandles)
{
try
{
UnAdvise(serverHandle, itemHandle);
results.Add(Succeeded(serverHandle, string.Empty, itemHandle));
}
catch (Exception exception)
{
results.Add(Failed(serverHandle, string.Empty, itemHandle, exception.Message));
}
}
return results;
}
/// <summary>Adds multiple items and subscribes to them in bulk, returning success/failure results.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="tagAddresses">Enumerable of item definitions to add and subscribe to.</param>
/// <returns>The per-item success/failure results, in the order the tags were provided.</returns>
public IReadOnlyList<SubscribeResult> SubscribeBulk(
int serverHandle,
IEnumerable<string> tagAddresses)
{
ThrowIfDisposed();
if (tagAddresses is null)
{
throw new ArgumentNullException(nameof(tagAddresses));
}
List<SubscribeResult> results = new();
foreach (string? tagAddress in tagAddresses)
{
if (string.IsNullOrWhiteSpace(tagAddress))
{
results.Add(Failed(serverHandle, tagAddress ?? string.Empty, itemHandle: 0, "Tag address is required."));
continue;
}
int itemHandle = 0;
try
{
itemHandle = AddItem(serverHandle, tagAddress);
Advise(serverHandle, itemHandle);
results.Add(Succeeded(serverHandle, tagAddress, itemHandle));
}
catch (Exception exception)
{
string errorMessage = exception.Message;
if (itemHandle != 0)
{
errorMessage = AppendRemoveItemCleanup(serverHandle, itemHandle, errorMessage);
}
results.Add(Failed(serverHandle, tagAddress, itemHandle, errorMessage));
}
}
return results;
}
/// <summary>Unsubscribes from multiple items in bulk, returning success/failure results.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandles">Enumerable of item handles to unsubscribe from.</param>
/// <returns>The per-item success/failure results, in the order the handles were provided.</returns>
public IReadOnlyList<SubscribeResult> UnsubscribeBulk(
int serverHandle,
IEnumerable<int> itemHandles)
{
ThrowIfDisposed();
if (itemHandles is null)
{
throw new ArgumentNullException(nameof(itemHandles));
}
List<SubscribeResult> results = new();
foreach (int itemHandle in itemHandles)
{
List<string> errors = new();
try
{
UnAdvise(serverHandle, itemHandle);
}
catch (Exception exception)
{
errors.Add($"UnAdvise failed: {exception.Message}");
}
try
{
RemoveItem(serverHandle, itemHandle);
}
catch (Exception exception)
{
errors.Add($"RemoveItem failed: {exception.Message}");
}
results.Add(errors.Count == 0
? Succeeded(serverHandle, string.Empty, itemHandle)
: Failed(serverHandle, string.Empty, itemHandle, string.Join("; ", errors)));
}
return results;
}
/// <summary>
/// Bulk write — runs <see cref="Write"/> sequentially for each entry.
/// Each entry's <paramref name="convertValue"/> turns the protobuf
/// MxValue into a COM-marshalable variant. Per-item failures are
/// captured as <see cref="BulkWriteResult"/> entries with
/// <c>was_successful = false</c>; the loop never throws.
/// </summary>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="entries">The write entries to process.</param>
/// <param name="convertValue">Converts protobuf MxValue to COM-compatible variant.</param>
/// <returns>The per-entry write results, in the order the entries were provided.</returns>
public IReadOnlyList<BulkWriteResult> WriteBulk(
int serverHandle,
IReadOnlyList<WriteBulkEntry> entries,
Func<MxValue, object?> convertValue)
{
ThrowIfDisposed();
if (entries is null)
{
throw new ArgumentNullException(nameof(entries));
}
if (convertValue is null)
{
throw new ArgumentNullException(nameof(convertValue));
}
List<BulkWriteResult> results = new(entries.Count);
foreach (WriteBulkEntry entry in entries)
{
results.Add(ExecuteBulkWriteEntry(
serverHandle,
entry.ItemHandle,
() => Write(serverHandle, entry.ItemHandle, convertValue(entry.Value), entry.UserId)));
}
return results;
}
/// <summary>Bulk Write2 — sequential MXAccess <see cref="Write2"/> per entry.</summary>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="entries">The write2 entries to process.</param>
/// <param name="convertValue">Converts protobuf MxValue to COM-compatible variant.</param>
/// <returns>The per-entry write results, in the order the entries were provided.</returns>
public IReadOnlyList<BulkWriteResult> Write2Bulk(
int serverHandle,
IReadOnlyList<Write2BulkEntry> entries,
Func<MxValue, object?> convertValue)
{
ThrowIfDisposed();
if (entries is null)
{
throw new ArgumentNullException(nameof(entries));
}
if (convertValue is null)
{
throw new ArgumentNullException(nameof(convertValue));
}
List<BulkWriteResult> results = new(entries.Count);
foreach (Write2BulkEntry entry in entries)
{
results.Add(ExecuteBulkWriteEntry(
serverHandle,
entry.ItemHandle,
() => Write2(
serverHandle,
entry.ItemHandle,
convertValue(entry.Value),
convertValue(entry.TimestampValue),
entry.UserId)));
}
return results;
}
/// <summary>Bulk WriteSecured — sequential MXAccess <see cref="WriteSecured"/> per entry.</summary>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="entries">The WriteSecured entries to process.</param>
/// <param name="convertValue">Converts protobuf MxValue to COM-compatible variant.</param>
/// <returns>The per-entry write results, in the order the entries were provided.</returns>
public IReadOnlyList<BulkWriteResult> WriteSecuredBulk(
int serverHandle,
IReadOnlyList<WriteSecuredBulkEntry> entries,
Func<MxValue, object?> convertValue)
{
ThrowIfDisposed();
if (entries is null)
{
throw new ArgumentNullException(nameof(entries));
}
if (convertValue is null)
{
throw new ArgumentNullException(nameof(convertValue));
}
List<BulkWriteResult> results = new(entries.Count);
foreach (WriteSecuredBulkEntry entry in entries)
{
results.Add(ExecuteBulkWriteEntry(
serverHandle,
entry.ItemHandle,
() => WriteSecured(
serverHandle,
entry.ItemHandle,
entry.CurrentUserId,
entry.VerifierUserId,
convertValue(entry.Value))));
}
return results;
}
/// <summary>Bulk WriteSecured2 — sequential MXAccess <see cref="WriteSecured2"/> per entry.</summary>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="entries">The WriteSecured2 entries to process.</param>
/// <param name="convertValue">Converts protobuf MxValue to COM-compatible variant.</param>
/// <returns>The per-entry write results, in the order the entries were provided.</returns>
public IReadOnlyList<BulkWriteResult> WriteSecured2Bulk(
int serverHandle,
IReadOnlyList<WriteSecured2BulkEntry> entries,
Func<MxValue, object?> convertValue)
{
ThrowIfDisposed();
if (entries is null)
{
throw new ArgumentNullException(nameof(entries));
}
if (convertValue is null)
{
throw new ArgumentNullException(nameof(convertValue));
}
List<BulkWriteResult> results = new(entries.Count);
foreach (WriteSecured2BulkEntry entry in entries)
{
results.Add(ExecuteBulkWriteEntry(
serverHandle,
entry.ItemHandle,
() => WriteSecured2(
serverHandle,
entry.ItemHandle,
entry.CurrentUserId,
entry.VerifierUserId,
convertValue(entry.Value),
convertValue(entry.TimestampValue))));
}
return results;
}
/// <summary>
/// Bulk read snapshot. For each requested tag, returns the most recent
/// OnDataChange value if the tag is already advised AND a cached value
/// exists (no subscription side effects); otherwise takes the AddItem
/// + Advise + wait + UnAdvise + RemoveItem snapshot lifecycle itself.
/// <paramref name="timeout"/> bounds the wait per-tag in the snapshot
/// case; <paramref name="pumpStep"/> is invoked on every poll
/// iteration so the worker's STA can dispatch the incoming MXAccess
/// message that carries the value.
/// </summary>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="tagAddresses">The tag addresses to read.</param>
/// <param name="timeout">The timeout per tag.</param>
/// <param name="pumpStep">Action invoked on each poll iteration.</param>
/// <returns>The per-tag read results, in the order the tags were provided.</returns>
public IReadOnlyList<BulkReadResult> ReadBulk(
int serverHandle,
IReadOnlyList<string> tagAddresses,
TimeSpan timeout,
Action pumpStep)
{
ThrowIfDisposed();
if (tagAddresses is null)
{
throw new ArgumentNullException(nameof(tagAddresses));
}
if (pumpStep is null)
{
throw new ArgumentNullException(nameof(pumpStep));
}
List<BulkReadResult> results = new(tagAddresses.Count);
foreach (string? tagAddress in tagAddresses)
{
if (string.IsNullOrWhiteSpace(tagAddress))
{
results.Add(FailedRead(serverHandle, tagAddress ?? string.Empty, itemHandle: 0, wasCached: false, "Tag address is required."));
continue;
}
results.Add(ReadOneTag(serverHandle, tagAddress, timeout, pumpStep));
}
return results;
}
private BulkReadResult ReadOneTag(
int serverHandle,
string tagAddress,
TimeSpan timeout,
Action pumpStep)
{
// 1. Cached-and-advised fast path: scan the registry for a live item
// handle matching this tag and check whether the value cache has a
// payload for it. If so, return the cached value without touching
// the existing subscription — the caller didn't create it, so
// ReadBulk must not tear it down.
if (TryGetCachedReadFor(serverHandle, tagAddress, out int cachedItemHandle, out MxAccessValueCache.CachedValue cachedValue))
{
return SucceededRead(
serverHandle,
tagAddress,
cachedItemHandle,
wasCached: true,
cachedValue);
}
// 2. Snapshot lifecycle. Reserve our own item handle, advise, pump
// until we see a fresh OnDataChange (or the deadline elapses),
// then tear it down.
int itemHandle = 0;
bool advised = false;
try
{
itemHandle = AddItem(serverHandle, tagAddress);
ulong baseline = valueCache.CurrentVersion(serverHandle, itemHandle);
Advise(serverHandle, itemHandle);
advised = true;
DateTime deadline = DateTime.UtcNow + timeout;
bool gotValue = valueCache.TryWaitForUpdate(
serverHandle,
itemHandle,
baseline,
deadline,
pumpStep,
out MxAccessValueCache.CachedValue snapshot);
return gotValue
? SucceededRead(serverHandle, tagAddress, itemHandle, wasCached: false, snapshot)
: FailedRead(
serverHandle,
tagAddress,
itemHandle,
wasCached: false,
$"ReadBulk timed out after {timeout.TotalMilliseconds:F0} ms waiting for first OnDataChange.");
}
catch (Exception exception)
{
return FailedRead(serverHandle, tagAddress, itemHandle, wasCached: false, exception.Message);
}
finally
{
// Snapshot teardown — best-effort. Errors here are noted on the
// diagnostic message of the original result (above) by appending
// a cleanup suffix; we never re-throw from finally.
if (advised)
{
try { UnAdvise(serverHandle, itemHandle); } catch { /* swallow — best effort */ }
}
if (itemHandle != 0)
{
try { RemoveItem(serverHandle, itemHandle); } catch { /* swallow — best effort */ }
}
}
}
private bool TryGetCachedReadFor(
int serverHandle,
string tagAddress,
out int itemHandle,
out MxAccessValueCache.CachedValue cachedValue)
{
// Linear scan — bulk-read sizes are small in practice and the registry
// is keyed by handle, not by tag. If profiling ever shows this hot, a
// reverse tag→handle map can be added on the registry side.
foreach (RegisteredItemHandle registered in handleRegistry.ItemHandles)
{
if (registered.ServerHandle != serverHandle)
{
continue;
}
if (!string.Equals(registered.ItemDefinition, tagAddress, StringComparison.Ordinal))
{
continue;
}
if (!handleRegistry.ContainsAdviceHandle(serverHandle, registered.ItemHandle, MxAccessAdviceKind.Plain)
&& !handleRegistry.ContainsAdviceHandle(serverHandle, registered.ItemHandle, MxAccessAdviceKind.Supervisory))
{
// Tag is added but not advised — no fresh OnDataChange will
// arrive without us advising. Fall through to the snapshot
// path which advises explicitly.
continue;
}
if (valueCache.TryGet(serverHandle, registered.ItemHandle, out cachedValue))
{
itemHandle = registered.ItemHandle;
return true;
}
}
itemHandle = 0;
cachedValue = default;
return false;
}
private BulkWriteResult ExecuteBulkWriteEntry(
int serverHandle,
int itemHandle,
Action invokeWrite)
{
try
{
invokeWrite();
return new BulkWriteResult
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
WasSuccessful = true,
ErrorMessage = string.Empty,
};
}
catch (System.Runtime.InteropServices.COMException comException)
{
BulkWriteResult result = new()
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
WasSuccessful = false,
ErrorMessage = comException.Message,
};
result.Hresult = comException.HResult;
return result;
}
catch (Exception exception)
{
return new BulkWriteResult
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
WasSuccessful = false,
ErrorMessage = exception.Message,
};
}
}
private static BulkReadResult SucceededRead(
int serverHandle,
string tagAddress,
int itemHandle,
bool wasCached,
MxAccessValueCache.CachedValue snapshot)
{
BulkReadResult result = new()
{
ServerHandle = serverHandle,
TagAddress = tagAddress,
ItemHandle = itemHandle,
WasSuccessful = true,
WasCached = wasCached,
Quality = snapshot.Quality,
ErrorMessage = string.Empty,
};
if (snapshot.Value is not null)
{
result.Value = snapshot.Value;
}
if (snapshot.SourceTimestamp is not null)
{
result.SourceTimestamp = snapshot.SourceTimestamp;
}
if (snapshot.Statuses is not null)
{
result.Statuses.Add(snapshot.Statuses);
}
return result;
}
private static BulkReadResult FailedRead(
int serverHandle,
string tagAddress,
int itemHandle,
bool wasCached,
string errorMessage)
{
return new BulkReadResult
{
ServerHandle = serverHandle,
TagAddress = tagAddress,
ItemHandle = itemHandle,
WasSuccessful = false,
WasCached = wasCached,
ErrorMessage = errorMessage,
};
}
/// <summary>Gracefully shuts down the session, cleaning up all handles.</summary>
/// <returns>The shutdown result, including any per-handle cleanup failures.</returns>
public MxAccessShutdownResult ShutdownGracefully()
{
if (disposed)
{
return new MxAccessShutdownResult(Array.Empty<MxAccessShutdownFailure>());
}
List<MxAccessShutdownFailure> failures = new();
CleanupAdviceHandles(failures);
CleanupItemHandles(failures);
CleanupServerHandles(failures);
DisposeCore(failures);
return new MxAccessShutdownResult(failures);
}
/// <inheritdoc />
public void Dispose()
{
if (disposed)
{
return;
}
DisposeCore(failures: null);
}
private void CleanupAdviceHandles(ICollection<MxAccessShutdownFailure> failures)
{
HashSet<long> cleanedPairs = new();
foreach (RegisteredAdviceHandle adviceHandle in handleRegistry.AdviceHandles)
{
long key = CreateItemKey(adviceHandle.ServerHandle, adviceHandle.ItemHandle);
if (!cleanedPairs.Add(key))
{
continue;
}
try
{
mxAccessServer.UnAdvise(adviceHandle.ServerHandle, adviceHandle.ItemHandle);
handleRegistry.RemoveAdviceHandles(adviceHandle.ServerHandle, adviceHandle.ItemHandle);
}
catch (Exception exception)
{
failures.Add(new MxAccessShutdownFailure(
nameof(UnAdvise),
adviceHandle.ServerHandle,
adviceHandle.ItemHandle,
exception));
}
}
}
private void CleanupItemHandles(ICollection<MxAccessShutdownFailure> failures)
{
foreach (RegisteredItemHandle itemHandle in handleRegistry.ItemHandles)
{
try
{
mxAccessServer.RemoveItem(itemHandle.ServerHandle, itemHandle.ItemHandle);
handleRegistry.RemoveItemHandle(itemHandle.ServerHandle, itemHandle.ItemHandle);
}
catch (Exception exception)
{
failures.Add(new MxAccessShutdownFailure(
nameof(RemoveItem),
itemHandle.ServerHandle,
itemHandle.ItemHandle,
exception));
}
}
}
private void CleanupServerHandles(ICollection<MxAccessShutdownFailure> failures)
{
foreach (RegisteredServerHandle serverHandle in handleRegistry.ServerHandles)
{
try
{
mxAccessServer.Unregister(serverHandle.ServerHandle);
handleRegistry.UnregisterServerHandle(serverHandle.ServerHandle);
}
catch (Exception exception)
{
failures.Add(new MxAccessShutdownFailure(
nameof(Unregister),
serverHandle.ServerHandle,
itemHandle: null,
exception));
}
}
}
private static long CreateItemKey(
int serverHandle,
int itemHandle)
{
return ((long)serverHandle << 32) | (uint)itemHandle;
}
private string AppendRemoveItemCleanup(
int serverHandle,
int itemHandle,
string errorMessage)
{
try
{
RemoveItem(serverHandle, itemHandle);
return $"{errorMessage}; cleanup RemoveItem succeeded.";
}
catch (Exception cleanupException)
{
return $"{errorMessage}; cleanup RemoveItem failed: {cleanupException.Message}";
}
}
private static SubscribeResult Succeeded(
int serverHandle,
string tagAddress,
int itemHandle)
{
return new SubscribeResult
{
ServerHandle = serverHandle,
TagAddress = tagAddress,
ItemHandle = itemHandle,
WasSuccessful = true,
ErrorMessage = string.Empty,
};
}
private static SubscribeResult Failed(
int serverHandle,
string tagAddress,
int itemHandle,
string errorMessage)
{
return new SubscribeResult
{
ServerHandle = serverHandle,
TagAddress = tagAddress,
ItemHandle = itemHandle,
WasSuccessful = false,
ErrorMessage = errorMessage,
};
}
private void DisposeCore(ICollection<MxAccessShutdownFailure>? failures)
{
Exception? detachException = null;
try
{
eventSink.Detach();
}
catch (Exception exception)
{
detachException = exception;
failures?.Add(new MxAccessShutdownFailure(
"DetachEvents",
serverHandle: null,
itemHandle: null,
exception));
}
try
{
if (Marshal.IsComObject(mxAccessComObject))
{
Marshal.FinalReleaseComObject(mxAccessComObject);
}
}
catch (Exception exception) when (failures is not null)
{
failures.Add(new MxAccessShutdownFailure(
"ReleaseComObject",
serverHandle: null,
itemHandle: null,
exception));
}
disposed = true;
if (detachException is not null && failures is null)
{
throw detachException;
}
}
private void ThrowIfDisposed()
{
if (disposed)
{
throw new ObjectDisposedException(nameof(MxAccessSession));
}
}
}