diff --git a/src/AVEVA.Historian.Client/HistorianClientOptions.cs b/src/AVEVA.Historian.Client/HistorianClientOptions.cs
index 29908b4..c197027 100644
--- a/src/AVEVA.Historian.Client/HistorianClientOptions.cs
+++ b/src/AVEVA.Historian.Client/HistorianClientOptions.cs
@@ -64,6 +64,14 @@ public sealed class HistorianClientOptions
///
public string? ConnectViaAddress { get; init; }
+ ///
+ /// Diagnostic override for the native OpenConnection mode the WCF event-read chain uses (default
+ /// 0x402, read-only process). Set to e.g. 0x501 (event) or 0x401 (write-enabled)
+ /// to probe whether CM_EVENT registration / event-row retrieval needs a different connection type on a
+ /// 2023 R2 server. Null = the default read-only process mode. Intended for protocol investigation.
+ ///
+ public uint? EventReadConnectionModeOverride { get; init; }
+
///
/// For : when true the channel uses TLS
/// (https://); when false it uses plaintext (http://). Matches the stock
diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs
index d1aaca5..7ff341e 100644
--- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs
+++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs
@@ -147,7 +147,7 @@ internal sealed class HistorianWcfEventOrchestrator
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
uint clientHandle = HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
_options, histBinding, histEndpoint, contextKey, cancellationToken,
- connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode,
+ connectionMode: _options.EventReadConnectionModeOverride ?? HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode,
additionalSetup: (historyChannel, context) =>
AddCmEventTagViaAddT(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrBinding, retrEndpoint));
return RunEventQuery(retrBinding, retrEndpoint, clientHandle, startUtc, endUtc, filter, cancellationToken);
diff --git a/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs b/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs
index 9e22ce8..1b74b48 100644
--- a/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs
+++ b/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs
@@ -36,9 +36,14 @@ public sealed class WcfEventReadSpikeTests
public async Task WcfEventRead_DiagnosticDump_AgainstRemoteHistorian()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_HOST");
- if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows())
+ bool certificate = string.Equals(
+ Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_TRANSPORT"), "certificate", StringComparison.OrdinalIgnoreCase);
+ // The certificate (TLS) transport and NegotiateAuthentication-based app auth are cross-platform;
+ // the integrated (Windows SSPI transport-security) transport is Windows-only. Skip when the gate
+ // is unconfigured, or when an integrated run is requested off Windows.
+ if (string.IsNullOrWhiteSpace(host) || (!certificate && !OperatingSystem.IsWindows()))
{
- return; // inert without the dedicated gate / off Windows
+ return;
}
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_PORT"), out int p)
@@ -50,8 +55,17 @@ public sealed class WcfEventReadSpikeTests
string? spn = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_SPN");
string? dnsId = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_DNSID");
string? via = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_VIA");
- bool certificate = string.Equals(
- Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_TRANSPORT"), "certificate", StringComparison.OrdinalIgnoreCase);
+ bool bypassVersion = string.Equals(
+ Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_NOVERSIONCHECK"), "1", StringComparison.Ordinal);
+ int timeoutSec = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_TIMEOUT_SEC"), out int ts) && ts > 0
+ ? ts : 30;
+ string? connModeRaw = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_CONNMODE");
+ uint? connModeOverride = null;
+ if (!string.IsNullOrWhiteSpace(connModeRaw))
+ {
+ string hex = connModeRaw.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? connModeRaw[2..] : connModeRaw;
+ if (uint.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out uint cm)) { connModeOverride = cm; }
+ }
bool allowUntrusted = string.Equals(
Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_ALLOW_UNTRUSTED"), "1", StringComparison.Ordinal);
// Certificate transport carries no Windows transport credential (cert-validated TLS channel);
@@ -71,22 +85,49 @@ public sealed class WcfEventReadSpikeTests
ServerDnsIdentity = string.IsNullOrWhiteSpace(dnsId) ? null : dnsId,
AllowUntrustedServerCertificate = allowUntrusted,
ConnectViaAddress = string.IsNullOrWhiteSpace(via) ? null : via,
+ VerifyServerInterfaceVersion = !bypassVersion,
+ ConnectTimeout = TimeSpan.FromSeconds(timeoutSec),
+ RequestTimeout = TimeSpan.FromSeconds(timeoutSec),
+ EventReadConnectionModeOverride = connModeOverride,
};
HistorianWcfEventOrchestrator orchestrator = new(options);
DateTime endUtc = DateTime.UtcNow;
DateTime startUtc = endUtc - TimeSpan.FromDays(days);
+ int budgetSec = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_BUDGET_SEC"), out int bgs) && bgs > 0
+ ? bgs : 60;
+
int observed = 0;
bool hasFirstEvent = false;
string outcome = "completed";
try
{
- await foreach (HistorianEvent evt in orchestrator.ReadEventsAsync(startUtc, endUtc, filter: null, CancellationToken.None))
+ // Race the read against an overall budget. The chain is synchronous WCF, so a stuck call
+ // can't be token-cancelled — but the orchestrator's static/instance diagnostics are set as it
+ // progresses, so on a budget timeout we still dump them to see which phase (auth / CM_EVENT
+ // registration / query) is hanging. The abandoned task keeps running in the background harness.
+ using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(budgetSec));
+ Task readTask = Task.Run(async () =>
{
- observed++;
- hasFirstEvent = true;
- _ = evt; // event identity intentionally NOT logged (sanitized)
+ int n = 0;
+ await foreach (HistorianEvent evt in orchestrator.ReadEventsAsync(startUtc, endUtc, filter: null, budget.Token))
+ {
+ n++;
+ _ = evt; // event identity intentionally NOT logged (sanitized)
+ }
+ return n;
+ });
+
+ Task finished = await Task.WhenAny(readTask, Task.Delay(TimeSpan.FromSeconds(budgetSec + 5)));
+ if (finished == readTask)
+ {
+ observed = await readTask;
+ hasFirstEvent = observed > 0;
+ }
+ else
+ {
+ outcome = $"TIMED OUT after {budgetSec}s (chain still running — see return codes for phase reached)";
}
}
catch (Exception ex)
@@ -100,7 +141,7 @@ public sealed class WcfEventReadSpikeTests
}
// Sanitized diagnostic dump — counts, native return codes, buffer lengths, sha256 ONLY.
- _output.WriteLine($"[C2 WCF spike] transport: {options.Transport} (integratedSec={integrated}, allowUntrusted={allowUntrusted})");
+ _output.WriteLine($"[C2 WCF spike] transport: {options.Transport} (integratedSec={integrated}, allowUntrusted={allowUntrusted}, connMode={(connModeOverride.HasValue ? "0x" + connModeOverride.Value.ToString("X") : "default-0x402")})");
_output.WriteLine($"[C2 WCF spike] outcome: {outcome}");
_output.WriteLine($"[C2 WCF spike] events observed: {observed}");
_output.WriteLine($"[C2 WCF spike] hasFirstEvent: {hasFirstEvent}");