using System.Diagnostics; using System.Globalization; using System.Net; using Azaion.Missions.E2E.Helpers; using Xunit; namespace Azaion.Missions.E2E.Tests.ResourceLimits; /// /// NFT-RES-LIM-04 — cold-start RSS. Driven independently from the /// steady-state window because it requires a fresh container start; lives /// in the MigratorRestart collection so it serialises with the /// other docker-compose-restarting tests rather than racing them. /// /// /// The 30-second wait between health-OK and the measurement is the spec's /// way of letting the JIT and the JWKS prefetch settle without doing any /// real work — measuring at health-OK alone would conflate the genuine cold /// baseline with bootstrap noise. /// [Collection("MigratorRestart")] [Trait("Category", "ResLim")] public sealed class ColdStartRssTests { private static readonly MetricCsvRecorder Csv = new("RESLIM_RESULTS_FILE"); private const long ProvisionalColdRssCapMiB = 200; private const string ComposeFile = "/workspace/docker-compose.test.yml"; [SkippableFact] [Trait("Traces", "H1|H3")] [Trait("max_ms", "120000")] public async Task NFT_RES_LIM_04_cold_start_rss_within_provisional_200_MiB() { Skip.IfNot(Environment.GetEnvironmentVariable("COMPOSE_RESTART_ENABLED") == "1", "COMPOSE_RESTART_ENABLED!=1 — docker compose restart unavailable in this consumer image"); Skip.IfNot(MissionsContainerHelper.Enabled, "MissionsContainerHelper disabled — docker CLI unavailable"); // Arrange — bring missions down hard and start it fresh. The // surrounding "MigratorRestart" collection serialises us against // any other test that touches the SUT. DockerCompose("stop missions"); DockerCompose("rm -f missions"); DockerCompose("up -d missions"); await WaitForHealthOkAsync(TimeSpan.FromSeconds(60)); // Act — wait 30s after health-OK so JIT/JWKS settle, then measure. await Task.Delay(TimeSpan.FromSeconds(30)); var rssBytes = ReadRssBytes("missions-sut"); var rssMiB = rssBytes / (double)(1024 * 1024); var pass = rssMiB <= ProvisionalColdRssCapMiB; Csv.Record( category: "ResLim", scenario: "NFT-RES-LIM-04", result: pass ? "pass" : "fail", traces: $"H1|H3; COLD_RSS_MiB={rssMiB.ToString("F1", CultureInfo.InvariantCulture)}"); // Assert — provisional gate. Assert.True(pass, $"cold-start RSS {rssMiB:F1} MiB exceeds provisional {ProvisionalColdRssCapMiB} MiB gate"); } private static async Task WaitForHealthOkAsync(TimeSpan timeout) { using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) }; var deadline = DateTime.UtcNow + timeout; var healthUrl = new Uri(TestEnvironment.MissionsBaseUrl + "/health"); while (DateTime.UtcNow < deadline) { try { using var resp = await http.GetAsync(healthUrl); if (resp.StatusCode == HttpStatusCode.OK) return; } catch (HttpRequestException) { /* not yet listening */ } catch (TaskCanceledException) { /* slow first response */ } await Task.Delay(500); } throw new TimeoutException( $"missions did not become healthy within {timeout.TotalSeconds:F0}s of cold start"); } private static long ReadRssBytes(string containerName) { var raw = Run("docker", $"stats --no-stream --format '{{{{.MemUsage}}}}' {containerName}"); var lhs = raw.Split('/')[0].Trim().Trim('\''); return ParseHumanBytes(lhs); } private static long ParseHumanBytes(string text) { var unitIx = text.IndexOfAny(new[] { 'K', 'M', 'G', 'T', 'B' }); if (unitIx < 0) return long.Parse(text, CultureInfo.InvariantCulture); var num = double.Parse(text.Substring(0, unitIx), CultureInfo.InvariantCulture); var unit = text.Substring(unitIx); return unit switch { "B" => (long)num, "KiB" or "KB" or "K" => (long)(num * 1024), "MiB" or "MB" or "M" => (long)(num * 1024 * 1024), "GiB" or "GB" or "G" => (long)(num * 1024 * 1024 * 1024), "TiB" or "TB" or "T" => (long)(num * 1024L * 1024 * 1024 * 1024), _ => throw new FormatException($"unknown human-bytes unit in '{text}'") }; } private static void DockerCompose(string subcommand) => Run("docker", $"compose -f {ComposeFile} {subcommand}"); private static string Run(string file, string args) { var psi = new ProcessStartInfo(file, args) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; using var p = Process.Start(psi) ?? throw new InvalidOperationException($"failed to launch `{file} {args}`"); var stdout = p.StandardOutput.ReadToEnd(); var stderr = p.StandardError.ReadToEnd(); p.WaitForExit(); if (p.ExitCode != 0) throw new InvalidOperationException( $"`{file} {args}` exited {p.ExitCode}: {stderr}"); return stdout; } }