Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs
T
Joseph Doherty 898c7365c4 test(sql): close the blackhole gate's leaked-pause race + bound the shell command
Review follow-up on the blackhole gate. Two robustness gaps, both bounded to the
disposable dedicated container (never shared infra), fixed:

- If the outer token fired while 'docker pause' was in flight, 'paused' was set
  only AFTER the await, so a cancellation there left paused=false while the
  container could still complete the pause — the finally then skipped unpause and
  leaked a frozen container. Mark paused BEFORE the await, and make the finally's
  unpause best-effort (unpausing a never-actually-paused container errors
  harmlessly, and a cleanup failure must never mask the real test outcome).
- The pause/unpause shell commands had no wall-clock bound of their own — only the
  post-pause read was capped — so a hung SSH handshake would hang CI. Give
  RunShellCommandAsync its own 30s hard cap that kills the process and throws
  TimeoutException, distinct from an operator's own cancellation.

Offline skip still clean (1 skipped, 9ms, no socket/docker).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:29:21 -04:00

441 lines
22 KiB
C#

using System.Diagnostics;
using System.Globalization;
using System.Text.Json.Nodes;
using Microsoft.Data.SqlClient;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests;
/// <summary>
/// The single highest-value integration test for the <c>Sql</c> driver: the <b>frozen-peer /
/// blackhole gate</b>. When the database stops answering mid-poll, a reused pooled connection hangs
/// inside <c>ExecuteReaderAsync</c>; the driver must surface <see cref="SqlStatusCodes.BadTimeout"/>
/// within its client-side <c>operationTimeout</c> (a real linked-CTS cancellation) and NOT wedge the
/// poll loop. This is the exact bug class the repo shipped and fixed once in its S7 driver (async
/// reads ignored the socket read timeout → a frozen peer wedged the poll loop; see
/// <c>S7_1500ConnectTimeoutOutageTests</c>, which this mirrors).
/// <para><b>The wall-clock bound is the whole point.</b> <c>operationTimeout</c> is set well BELOW
/// <c>commandTimeout</c> here, deliberately inverted from the production rule, so the two backstops
/// are distinguishable: a working client-side cancellation returns at ≈ <c>operationTimeout</c>; if it
/// were broken and only the ADO.NET <c>CommandTimeout</c> server-side backstop fired, the read would
/// return at ≈ <c>commandTimeout</c> instead — which the upper-bound assertion fails on. A test that
/// merely asserted "eventually BadTimeout" would pass even with the client-side cancellation broken.</para>
/// <para><b>SAFETY — this gate `docker pause`s a DEDICATED container it owns, never shared infra.</b>
/// The pause target is the compile-time constant <see cref="ContainerName"/>
/// (<c>otopcua-sql-blackhole</c>), never anything derived from an env var, so a mis-set endpoint can
/// never aim a pause at the rig's shared always-on SQL Server (<c>10.100.0.35,14330</c>, which hosts
/// ConfigDb for the whole rig). As a second guardrail the gate <b>skips loudly</b> if its endpoint
/// resolves to the shared server's port <see cref="SharedCentralPort"/>. Bring the dedicated stack up
/// with <c>Docker/docker-compose.yml</c> (container <c>otopcua-sql-blackhole</c>, host port 14333).</para>
/// <para><b>Env-gated; offline skip is the normal outcome.</b> With <see cref="EndpointEnvVar"/> unset
/// nothing here opens a socket, shells out to docker, or waits — the suite is instant and green on the
/// macOS dev box, exactly like every other <c>*.IntegrationTests</c> live gate in this tree.</para>
/// <para>
/// <b>Operator run recipe</b> (docker runs on the shared Linux host; drive it from this VM):
/// <code>
/// # 1. Deploy + start the DEDICATED disposable mssql (NOT the shared 14330 server):
/// lmxopcua-fix sync sql-blackhole
/// lmxopcua-fix up sql-blackhole # container otopcua-sql-blackhole on :14333, self-seeds
///
/// # 2. Point the gate at the dedicated container + tell it how to reach docker:
/// export SQL_BLACKHOLE_ENDPOINT=10.100.0.35,14333 # the dedicated container; MUST NOT be ,14330
/// export SQL_BLACKHOLE_PASSWORD=Blackhole_dev_pw1 # matches the compose SA password
/// export SQL_BLACKHOLE_DOCKER_SSH=dohertj2@10.100.0.35 # empty ⇒ run docker locally
///
/// # 3. Run just this gate:
/// dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests \
/// --filter "FullyQualifiedName~SqlBlackholeTimeoutTests"
///
/// # 4. Tear the disposable stack down:
/// lmxopcua-fix down sql-blackhole
/// </code>
/// </para>
/// </summary>
[Trait("Category", "Integration")]
[Trait("Category", "Blackhole")]
public sealed class SqlBlackholeTimeoutTests
{
/// <summary>Endpoint (<c>host,port</c>) of the DEDICATED disposable container. Absent ⇒ offline skip.</summary>
private const string EndpointEnvVar = "SQL_BLACKHOLE_ENDPOINT";
/// <summary>SA password for the dedicated container. Required by the run.</summary>
private const string PasswordEnvVar = "SQL_BLACKHOLE_PASSWORD";
/// <summary>SQL login. Defaults to <c>sa</c>.</summary>
private const string UserEnvVar = "SQL_BLACKHOLE_USER";
/// <summary>
/// Optional SSH target the <c>docker pause</c>/<c>unpause</c> runs through (e.g.
/// <c>dohertj2@10.100.0.35</c>), since <c>docker -H ssh://</c> does not work from this VM. Empty ⇒
/// run docker locally.
/// </summary>
private const string DockerSshEnvVar = "SQL_BLACKHOLE_DOCKER_SSH";
/// <summary>
/// The ONLY container this gate ever pauses — a compile-time constant, never derived from the
/// environment. The shared central SQL Server is not named this, so no env misconfiguration can
/// cause this gate to freeze shared infra. It matches <c>Docker/docker-compose.yml</c>'s
/// <c>container_name</c>.
/// </summary>
private const string ContainerName = "otopcua-sql-blackhole";
/// <summary>
/// The rig's shared always-on SQL Server port (hosts ConfigDb for the whole rig). An endpoint on
/// this port is refused with a loud skip — the dedicated container must be on a different port.
/// </summary>
private const string SharedCentralPort = "14330";
/// <summary>The database the seed lives in; created if missing (never dropped).</summary>
private const string Database = "SqlBlackholeFixture";
/// <summary>The seeded key-value table + its columns and the one baseline key.</summary>
private const string Schema = "sqlpoll";
private const string KeyValueTable = "TagValues";
private const string KeyColumn = "tag_name";
private const string ValueColumn = "num_value";
private const string TimestampColumn = "sample_ts";
private const string BaselineKey = "Line1.Speed";
private const double BaselineValue = 42.0;
/// <summary>The RawPath the driver serves the baseline key under.</summary>
private const string RawPath = "Sql/Line1/Speed";
// ── deadlines chosen so client-side and server-side backstops are DISTINGUISHABLE ──
/// <summary>Client-side wall-clock ceiling on one group. A frozen peer must surface BadTimeout at ≈ this.</summary>
private static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(3);
/// <summary>
/// ADO.NET server-side backstop — set an order of magnitude ABOVE <see cref="OperationTimeout"/> so
/// that if the client-side cancellation were broken and only this fired, the read would return at
/// ≈ 30 s and the upper-bound assertion would catch it.
/// </summary>
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(30);
/// <summary>
/// Upper bound on the post-pause read. Comfortably above <see cref="OperationTimeout"/> (docker
/// pause propagation + pooled-connection validation + thread-pool scheduling) yet comfortably below
/// <see cref="CommandTimeout"/> — so a return here proves the CLIENT-side bound fired, not the
/// server-side backstop.
/// </summary>
private static readonly TimeSpan UpperBound = TimeSpan.FromSeconds(15);
/// <summary>Hard cap on the test's own wait, so a genuinely wedged implementation FAILS rather than hangs CI.</summary>
private static readonly TimeSpan HardCap = TimeSpan.FromSeconds(60);
/// <summary>Hard cap on each docker pause/unpause shell command, so a hung SSH handshake can't hang CI.</summary>
private static readonly TimeSpan ShellCommandHardCap = TimeSpan.FromSeconds(30);
/// <summary>
/// Start reading against the dedicated mssql, <c>docker pause</c> it mid-poll, and assert the next
/// read surfaces <see cref="SqlStatusCodes.BadTimeout"/> within <see cref="OperationTimeout"/> (a
/// client-side linked-CTS cancellation, not the poll thread wedging and not the server-side
/// backstop), that the driver degrades (health <see cref="DriverState.Degraded"/> + host
/// <see cref="HostState.Stopped"/>), and that after <c>docker unpause</c> a subsequent poll recovers
/// to <see cref="DriverState.Healthy"/> / <see cref="HostState.Running"/>.
/// </summary>
[Fact]
public async Task PausedServer_surfacesBadTimeout_withinDeadline_notWedged()
{
var ep = Environment.GetEnvironmentVariable(EndpointEnvVar);
Assert.SkipWhen(
string.IsNullOrWhiteSpace(ep),
$"{EndpointEnvVar} not set — offline skip. Bring up Docker/docker-compose.yml (dedicated " +
$"container {ContainerName} on :14333) and export {EndpointEnvVar}/{PasswordEnvVar} to run this gate.");
var password = Environment.GetEnvironmentVariable(PasswordEnvVar);
Assert.SkipWhen(
string.IsNullOrWhiteSpace(password),
$"{PasswordEnvVar} not set — the blackhole gate needs the dedicated container's SA password.");
var (host, port) = ParseEndpoint(ep!);
// ── SAFETY GUARD: refuse to run against the rig's shared central SQL Server. ──
// The pause target (ContainerName) is a constant and could never be the shared container regardless,
// but refusing the shared PORT stops us even connecting-and-polling against shared infra.
Assert.SkipWhen(
string.Equals(port, SharedCentralPort, StringComparison.Ordinal),
$"{EndpointEnvVar} resolves to port {SharedCentralPort} — that is the rig's SHARED always-on SQL " +
"Server (ConfigDb for the whole rig), which this destructive gate must NEVER pause. Point it at " +
$"the dedicated {ContainerName} container (host port 14333), not the shared server.");
var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named ? named : "sa";
var ssh = Environment.GetEnvironmentVariable(DockerSshEnvVar);
var ct = TestContext.Current.CancellationToken;
// Seed defensively (create-if-missing) so the baseline poll is Good even if compose never ran seed.sql.
await SeedAsync(host, port, user, password!, ct);
await using var driver = await StartDriverAsync(host, port, user, password!, ct);
// Good baseline — prove the endpoint answers before we freeze it.
var baseline = (await ReadOnceAsync(driver, ct)).ShouldHaveSingleItem();
baseline.StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(baseline.Value).ShouldBe(BaselineValue);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
var paused = false;
try
{
// Freeze the container mid-poll: its SQL process is SIGSTOPped, so the pooled connection's next
// ExecuteReaderAsync hangs against a live-but-silent socket — the frozen-peer wedge exactly.
// Mark paused BEFORE the await: if `ct` fires mid-command the process may still complete the pause,
// so the finally must attempt an unpause regardless — never leave the container frozen.
paused = true;
await RunShellCommandAsync(DockerCommand("pause", ssh), ct);
var clock = Stopwatch.StartNew();
// Bounded by HardCap so a wedged implementation FAILS here instead of hanging CI.
var snapshots = await ReadOnceAsync(driver, ct).WaitAsync(HardCap, ct);
clock.Stop();
var frozen = snapshots.ShouldHaveSingleItem();
frozen.StatusCode.ShouldBe(
SqlStatusCodes.BadTimeout,
"a frozen database must surface BadTimeout, not a stale Good or another Bad class");
// The wall-clock contract: returned at ≈ operationTimeout (client-side cancellation), NOT at
// commandTimeout (server-side backstop) and NOT never. UpperBound < CommandTimeout is what
// distinguishes a working client-side abort from a broken one that only the backstop rescued.
clock.Elapsed.ShouldBeLessThan(
UpperBound,
$"BadTimeout must arrive on the client-side {OperationTimeout.TotalSeconds:0}s bound, not the " +
$"server-side {CommandTimeout.TotalSeconds:0}s CommandTimeout backstop");
clock.Elapsed.ShouldBeLessThan(CommandTimeout, "the server-side backstop must not be what fired");
clock.Elapsed.ShouldBeGreaterThanOrEqualTo(
OperationTimeout - TimeSpan.FromSeconds(2),
"returning far faster than operationTimeout would mean a fast-fail misclassified as BadTimeout");
// Driver-level classification, not just the reader: an all-BadTimeout poll degrades health and
// stops the host (SqlDriver.ObservePollOutcome), even though the reader returned successfully.
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Stopped);
}
finally
{
if (paused)
{
// ALWAYS restore the container once a pause was attempted — never leave a frozen container
// behind. Best-effort: unpausing a container that never actually paused (pause command failed)
// errors harmlessly, and a cleanup failure must never mask the real test outcome.
try
{
await RunShellCommandAsync(DockerCommand("unpause", ssh), CancellationToken.None);
}
catch
{
// swallow — cleanup is best-effort; the container is disposable and torn down by the runbook.
}
}
}
// Recovery: after unpause a subsequent poll must return Good and the whole stack must recover.
await PollUntilGoodAsync(driver, ct);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running);
}
// ── helpers ──
/// <summary>Runs one poll of the single baseline reference.</summary>
private static Task<IReadOnlyList<DataValueSnapshot>> ReadOnceAsync(SqlDriver driver, CancellationToken ct)
=> driver.ReadAsync([RawPath], ct);
/// <summary>Polls until a Good snapshot returns, or the recovery deadline elapses.</summary>
private static async Task PollUntilGoodAsync(SqlDriver driver, CancellationToken ct)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(45);
while (DateTime.UtcNow < deadline)
{
var snapshot = (await ReadOnceAsync(driver, ct)).ShouldHaveSingleItem();
if (snapshot.StatusCode == SqlStatusCodes.Good) return;
await Task.Delay(TimeSpan.FromSeconds(1), ct);
}
throw new Xunit.Sdk.XunitException(
"the driver never recovered to a Good poll within 45 s of docker unpause — the poll loop did not survive the outage");
}
/// <summary>Builds a driver over the dedicated container, warmed to hold a pooled connection.</summary>
private static async Task<SqlDriver> StartDriverAsync(
string host, string port, string user, string password, CancellationToken ct)
{
var options = new SqlDriverOptions
{
RawTags = [KeyValueTag()],
OperationTimeout = OperationTimeout,
CommandTimeout = CommandTimeout,
};
var driver = new SqlDriver(
options,
driverInstanceId: "sql-blackhole",
dialect: new SqlServerDialect(),
connectionString: DriverConnectionString(host, port, user, password));
try
{
await driver.InitializeAsync("{}", ct);
}
catch
{
await driver.DisposeAsync();
throw;
}
return driver;
}
/// <summary>The KeyValue tag over the seeded <c>sqlpoll.TagValues</c> baseline row.</summary>
private static RawTagEntry KeyValueTag()
{
var config = new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = $"{Schema}.{KeyValueTable}",
["keyColumn"] = KeyColumn,
["keyValue"] = BaselineKey,
["valueColumn"] = ValueColumn,
["timestampColumn"] = TimestampColumn,
};
return new RawTagEntry(RawPath, config.ToJsonString(), WriteIdempotent: false);
}
/// <summary>
/// The driver's connection string. <c>Min Pool Size=1</c> keeps a warm pooled connection so the
/// post-pause read reuses it and hangs inside <c>ExecuteReaderAsync</c> — exercising the
/// query-execution wedge (the S7 bug class) rather than a fresh connect.
/// </summary>
private static string DriverConnectionString(string host, string port, string user, string password)
=> new SqlConnectionStringBuilder
{
DataSource = $"{host},{port}",
InitialCatalog = Database,
UserID = user,
Password = password,
TrustServerCertificate = true,
Encrypt = false,
ConnectTimeout = 5,
MinPoolSize = 1,
}.ConnectionString;
/// <summary>Creates the database (if missing) and (re)seeds the baseline schema + row.</summary>
private static async Task SeedAsync(
string host, string port, string user, string password, CancellationToken ct)
{
var master = new SqlConnectionStringBuilder
{
DataSource = $"{host},{port}",
InitialCatalog = "master",
UserID = user,
Password = password,
TrustServerCertificate = true,
Encrypt = false,
ConnectTimeout = 10,
}.ConnectionString;
await using (var connection = new SqlConnection(master))
{
await connection.OpenAsync(ct);
await ExecuteAsync(connection, $"IF DB_ID(N'{Database}') IS NULL CREATE DATABASE [{Database}];", ct);
}
var db = new SqlConnectionStringBuilder(master) { InitialCatalog = Database }.ConnectionString;
await using (var connection = new SqlConnection(db))
{
await connection.OpenAsync(ct);
foreach (var statement in SeedStatements())
await ExecuteAsync(connection, statement, ct);
}
}
/// <summary>The seed, one batch per statement (T-SQL requires CREATE SCHEMA to be first in its batch).</summary>
private static IEnumerable<string> SeedStatements()
{
yield return $"IF SCHEMA_ID(N'{Schema}') IS NULL EXEC(N'CREATE SCHEMA [{Schema}]');";
yield return $"DROP TABLE IF EXISTS [{Schema}].[{KeyValueTable}];";
yield return $"""
CREATE TABLE [{Schema}].[{KeyValueTable}] (
[{KeyColumn}] nvarchar(128) NOT NULL,
[{ValueColumn}] float NULL,
[{TimestampColumn}] datetime2(3) NOT NULL
);
""";
yield return string.Create(CultureInfo.InvariantCulture, $"""
INSERT INTO [{Schema}].[{KeyValueTable}] ([{KeyColumn}], [{ValueColumn}], [{TimestampColumn}])
VALUES (N'{BaselineKey}', {BaselineValue}, '2026-07-24T10:00:00');
""");
}
/// <summary>Runs one non-query batch.</summary>
private static async Task ExecuteAsync(SqlConnection connection, string sql, CancellationToken ct)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
await command.ExecuteNonQueryAsync(ct);
}
/// <summary>Splits a <c>host,port</c> endpoint. A missing port is rejected — the guard needs the port.</summary>
private static (string Host, string Port) ParseEndpoint(string endpoint)
{
var parts = endpoint.Split(',', 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2 || parts[1].Length == 0)
{
throw new Xunit.Sdk.XunitException(
$"{EndpointEnvVar} must be 'host,port' (e.g. 10.100.0.35,14333) so the shared-server port " +
$"guard can run — got '{endpoint}'.");
}
return (parts[0], parts[1]);
}
/// <summary>
/// Builds the docker command for the OWNED container only. The verb and the constant container name
/// are the sole inputs — the target is never taken from the environment.
/// </summary>
private static string DockerCommand(string verb, string? ssh)
{
var docker = $"docker {verb} {ContainerName}";
return string.IsNullOrWhiteSpace(ssh) ? docker : $"ssh {ssh} \"{docker}\"";
}
/// <summary>
/// Runs a shell one-liner through the login shell, exactly as the S7 blackhole gate does — but bounded
/// by its own <see cref="ShellCommandHardCap"/> so a hung SSH handshake (unreachable docker host, an auth
/// prompt) fails the test rather than hanging CI indefinitely. The process is killed on timeout.
/// </summary>
private static async Task RunShellCommandAsync(string command, CancellationToken ct)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(ShellCommandHardCap);
using var proc = new Process
{
StartInfo = new ProcessStartInfo("/bin/sh", $"-c \"{command.Replace("\"", "\\\"")}\"")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
},
};
proc.Start();
try
{
await proc.WaitForExitAsync(timeout.Token);
}
catch (OperationCanceledException) when (timeout.IsCancellationRequested && !ct.IsCancellationRequested)
{
try { proc.Kill(entireProcessTree: true); } catch { /* already gone */ }
throw new TimeoutException(
$"shell command did not complete within {ShellCommandHardCap.TotalSeconds:0}s: {command}");
}
proc.ExitCode.ShouldBe(0, $"docker command failed: {await proc.StandardError.ReadToEndAsync(ct)}");
// Give the pause/unpause a moment to take effect before the next poll tick.
await Task.Delay(TimeSpan.FromSeconds(1), ct);
}
}