using System.Diagnostics; namespace NeonSprawl.Server.Tests.Game.PositionState; /// /// When not in CI, tries TCP to Postgres; if unreachable, runs docker compose up -d from the repo root. /// Tracks whether compose was started so teardown can run docker compose down. /// /// /// All public entry points avoid GetAwaiter().GetResult() on the xUnit synchronization context; /// defers to the thread pool to prevent Rider/VS deadlocks. /// internal static class PostgresDockerHelper { private static readonly Lock StateLock = new(); private static Task? _ensureTask; private static bool _composeStartedByUs; private static string? _repoRoot; /// Async path for (no deadlock). internal static Task EnsurePostgresOrStartDockerAsync(string? connectionString) { if (string.IsNullOrWhiteSpace(connectionString)) { return Task.CompletedTask; } lock (StateLock) { _ensureTask ??= EnsureCoreAsync(connectionString.Trim()); return _ensureTask; } } /// /// Used from (sync only). Runs work on the thread pool /// so Npgsql / Task continuations do not post back to a blocked xUnit context. /// internal static void EnsurePostgresOrStartDockerBlocking(string? connectionString) { if (string.IsNullOrWhiteSpace(connectionString)) { return; } Task.Run(() => EnsurePostgresOrStartDockerAsync(connectionString).GetAwaiter().GetResult()) .GetAwaiter() .GetResult(); } internal static async Task TearDownIfWeStartedAsync() { string? root; bool weStarted; lock (StateLock) { weStarted = _composeStartedByUs; root = _repoRoot; _composeStartedByUs = false; _repoRoot = null; _ensureTask = null; } if (!weStarted || string.IsNullOrEmpty(root)) { return; } await RunDockerComposeAsync(root, "compose down", CancellationToken.None).ConfigureAwait(false); } private static async Task EnsureCoreAsync(string connectionString) { if (IsCiEnvironment()) { await TryConnectWithTimeoutAsync(connectionString, TimeSpan.FromSeconds(30), CancellationToken.None) .ConfigureAwait(false); return; } if (await TryConnectOnceAsync(connectionString, CancellationToken.None).ConfigureAwait(false)) { return; } var root = FindDockerComposeRoot() ?? throw new InvalidOperationException( "Postgres is not reachable and docker-compose.yml was not found by walking up from the test output directory."); await RunDockerComposeAsync(root, "compose up -d", CancellationToken.None).ConfigureAwait(false); await TryConnectWithTimeoutAsync(connectionString, TimeSpan.FromSeconds(60), CancellationToken.None) .ConfigureAwait(false); lock (StateLock) { _composeStartedByUs = true; _repoRoot = root; } } private static bool IsCiEnvironment() => string.Equals(Environment.GetEnvironmentVariable("CI"), "true", StringComparison.OrdinalIgnoreCase) || string.Equals(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase) || string.Equals(Environment.GetEnvironmentVariable("GITLAB_CI"), "true", StringComparison.OrdinalIgnoreCase) || string.Equals(Environment.GetEnvironmentVariable("TF_BUILD"), "True", StringComparison.OrdinalIgnoreCase); private static string? FindDockerComposeRoot() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null) { var yml = Path.Combine(dir.FullName, "docker-compose.yml"); if (File.Exists(yml)) { try { var text = File.ReadAllText(yml); if (text.Contains("neon-sprawl-postgres", StringComparison.Ordinal) || text.Contains("postgres:", StringComparison.OrdinalIgnoreCase)) { return dir.FullName; } } catch (IOException) { // keep walking } } dir = dir.Parent; } return null; } private static async Task RunDockerComposeAsync(string workingDirectory, string arguments, CancellationToken ct) { var psi = new ProcessStartInfo { FileName = "docker", Arguments = arguments, WorkingDirectory = workingDirectory, UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true, }; using var proc = new Process { StartInfo = psi }; if (!proc.Start()) { throw new InvalidOperationException("Failed to start `docker` — is Docker installed and on PATH?"); } await proc.WaitForExitAsync(ct).ConfigureAwait(false); if (proc.ExitCode != 0) { var err = await proc.StandardError.ReadToEndAsync(ct).ConfigureAwait(false); var @out = await proc.StandardOutput.ReadToEndAsync(ct).ConfigureAwait(false); throw new InvalidOperationException( $"`docker {arguments}` failed (exit {proc.ExitCode}). stderr: {err}. stdout: {@out}"); } } private static async Task TryConnectWithTimeoutAsync( string connectionString, TimeSpan overall, CancellationToken ct) { var deadline = DateTime.UtcNow + overall; while (DateTime.UtcNow < deadline) { if (await TryConnectOnceAsync(connectionString, ct).ConfigureAwait(false)) { return; } await Task.Delay(TimeSpan.FromMilliseconds(500), ct).ConfigureAwait(false); } throw new InvalidOperationException( $"Could not open a Postgres connection within {overall.TotalSeconds:0}s. Check the connection string and that the server is up."); } private static async Task TryConnectOnceAsync(string connectionString, CancellationToken ct) { try { await using var conn = new NpgsqlConnection(connectionString); await conn.OpenAsync(ct).ConfigureAwait(false); return true; } catch (NpgsqlException) { return false; } } }