diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs index 3d9baaa4..9cc26756 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs @@ -125,6 +125,9 @@ public sealed class SqlBlackholeTimeoutTests /// Hard cap on the test's own wait, so a genuinely wedged implementation FAILS rather than hangs CI. private static readonly TimeSpan HardCap = TimeSpan.FromSeconds(60); + /// Hard cap on each docker pause/unpause shell command, so a hung SSH handshake can't hang CI. + private static readonly TimeSpan ShellCommandHardCap = TimeSpan.FromSeconds(30); + /// /// Start reading against the dedicated mssql, docker pause it mid-poll, and assert the next /// read surfaces within (a @@ -178,8 +181,10 @@ public sealed class SqlBlackholeTimeoutTests { // 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. - await RunShellCommandAsync(DockerCommand("pause", ssh), ct); + // 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. @@ -212,8 +217,17 @@ public sealed class SqlBlackholeTimeoutTests { if (paused) { - // ALWAYS restore the container once paused — never leave a frozen container behind. - await RunShellCommandAsync(DockerCommand("unpause", ssh), CancellationToken.None); + // 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. + } } } @@ -388,9 +402,16 @@ public sealed class SqlBlackholeTimeoutTests return string.IsNullOrWhiteSpace(ssh) ? docker : $"ssh {ssh} \"{docker}\""; } - /// Runs a shell one-liner through the login shell, exactly as the S7 blackhole gate does. + /// + /// Runs a shell one-liner through the login shell, exactly as the S7 blackhole gate does — but bounded + /// by its own 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. + /// 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("\"", "\\\"")}\"") @@ -401,7 +422,17 @@ public sealed class SqlBlackholeTimeoutTests }, }; proc.Start(); - await proc.WaitForExitAsync(ct); + 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);