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}");