From 81da404c5db0221ade92af8fa730f19dc01aca95 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 25 Jun 2026 11:32:09 -0400 Subject: [PATCH] test(spike): bound event read-after-send so the spike can't hang on C2 long-poll The Q3 read-after-send probe (ReusedEventSession_ServesReadAfterSend_BestEffort) long-polled GetNext to the no-data terminal with no tight bound and ran past the 5-min suite timeout on the live run. Bound it two ways: a read-only options copy with a 5s RequestTimeout (so each GetNext RPC deadlines fast) and an 8s CancellationToken passed as ct. Either fuse returns the method in ~10s; the timeout/cancellation is logged as the expected C2-gated outcome (still no assert). Other three spike methods unchanged. Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- .../EventSessionReuseSpikeTests.cs | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/tests/AVEVA.Historian.Client.Tests/EventSessionReuseSpikeTests.cs b/tests/AVEVA.Historian.Client.Tests/EventSessionReuseSpikeTests.cs index 7dcb58c..66c71eb 100644 --- a/tests/AVEVA.Historian.Client.Tests/EventSessionReuseSpikeTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/EventSessionReuseSpikeTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using Grpc.Core; using AVEVA.Historian.Client.Grpc; using AVEVA.Historian.Client.Models; using Xunit; @@ -104,7 +105,6 @@ public sealed class EventSessionReuseSpikeTests if (!TryGetEnv(out string host, out string sandboxTag)) return; HistorianClientOptions options = BuildOptions(host); var writeOrch = new HistorianGrpcEventWriteOrchestrator(options); - var readOrch = new HistorianGrpcEventOrchestrator(options); using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options); HistorianGrpcHandshake.Session session = writeOrch.OpenAndRegisterEventSession(connection, CancellationToken.None); @@ -112,16 +112,34 @@ public sealed class EventSessionReuseSpikeTests bool sent = writeOrch.SendEventOnSession(connection, session, BuildSandboxEvent(sandboxTag, attempt: 0), CancellationToken.None); _output.WriteLine($"seed-send before read-probe ok={sent}"); + // HARD bound so this probe CANNOT hang on the known C2 event-read long-poll (GetNext blocks to the + // no-data terminal on an idle box). Two independent fuses: (1) a read-only options copy with a 5s + // RequestTimeout so each underlying GetNext RPC deadlines quickly (the read orchestrator caps its + // poll deadline at min(10s, RequestTimeout)); (2) an 8s CancellationToken passed as ct so the chain + // is cancelled even if a per-RPC deadline is not honored over a tunnel. Whichever fires first, the + // method returns in ~10s max even when the read never returns data. + HistorianClientOptions readOptions = BuildOptions(host, requestTimeoutOverride: TimeSpan.FromSeconds(5)); + var readOrch = new HistorianGrpcEventOrchestrator(readOptions); + using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(8)); + (DateTime startUtc, DateTime endUtc) = LastSevenDays(); try { List rows = readOrch.RunEventQueryOnSession( - connection, session, startUtc, endUtc, filter: null, CancellationToken.None); + connection, session, startUtc, endUtc, filter: null, readCts.Token); _output.WriteLine($"read-after-send -> OK (rows={rows.Count}) => ONE-KIND (an Event session serves send AND read)"); } + catch (Exception ex) when (ex is OperationCanceledException + || (ex is RpcException rpc && rpc.StatusCode == StatusCode.DeadlineExceeded)) + { + // EXPECTED outcome: the read hit its bound (CTS timeout or per-RPC deadline) without returning — + // consistent with the C2 event-read gating (GetNext long-polls to the no-data terminal). This is + // the recorded one-kind finding, NOT a failure. + _output.WriteLine($"read-after-send did not return within the bound (consistent with C2 event-read gating): {ex.GetType().Name}"); + } catch (Exception ex) { - // Reads are gated C2 — a rejection / long-poll terminal is itself the finding, NOT a failure. + // Any other rejection on the reused session is also the finding, not a failure. _output.WriteLine($"read-after-send -> swallowed ({ex.GetType().Name}: {ex.Message}) => read gated/unverified over gRPC (expected)"); } // No assertion: this method's job is to RECORD the one-kind outcome for B0c, not gate on it. @@ -203,7 +221,9 @@ public sealed class EventSessionReuseSpikeTests return (end - TimeSpan.FromDays(7), end); } - private static HistorianClientOptions BuildOptions(string host) + // requestTimeoutOverride: when set, forces RequestTimeout (used by the read-after-send probe to give + // each GetNext RPC a short deadline). null preserves the env-driven default for the send/idle methods. + private static HistorianClientOptions BuildOptions(string host, TimeSpan? requestTimeoutOverride = null) { string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER"); string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD"); @@ -212,9 +232,10 @@ public sealed class EventSessionReuseSpikeTests ? parsed : HistorianClientOptions.DefaultGrpcPort; bool tls = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TLS"), "true", StringComparison.OrdinalIgnoreCase); - TimeSpan timeout = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TIMEOUT"), out int secs) && secs > 0 - ? TimeSpan.FromSeconds(secs) - : new HistorianClientOptions { Host = host }.RequestTimeout; + TimeSpan timeout = requestTimeoutOverride + ?? (int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TIMEOUT"), out int secs) && secs > 0 + ? TimeSpan.FromSeconds(secs) + : new HistorianClientOptions { Host = host }.RequestTimeout); return new HistorianClientOptions {