From 5c64c37f69ee031cdc4d664eaa5ca17e14cbbdb0 Mon Sep 17 00:00:00 2001 From: Rafal Maciag Date: Sun, 18 Oct 2026 16:19:21 +0000 Subject: [PATCH 1/2] =?UTF-8?q?test(testing):=20reproduce=20the=20cross-pr?= =?UTF-8?q?ocess=20store=20race=20=E2=80=94=20two=20StoreHolder=20processe?= =?UTF-8?q?s=20start=20EventStoreServers=20at=20one=20barrier=20instant=20?= =?UTF-8?q?and=20each=20must=20keep=20its=20own=20store=20(container=20by?= =?UTF-8?q?=20id,=20marker=20readable,=20unique=20port)=20for=2020=20s=20(?= =?UTF-8?q?card=20microplumberd-testing-store-port-race)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On master: RED 7/10 — both chose 2701; the loser's create hit the name Conflict (4×), or its catch → Cleanup removed the winner's container BY NAME and both lost (3×, the winner's /health/live refused). StoreHolder is not packable. Co-Authored-By: Claude Opus 5.5 --- .../MicroPlumberd.Testing.StoreHolder.csproj | 14 +++++ .../Program.cs | 55 +++++++++++++++++++ .../Integration/StorePortRaceTests.cs | 54 ++++++++++++++++++ .../MicroPlumberd.Tests.csproj | 5 ++ src/MicroPlumberd.sln | 14 +++++ 5 files changed, 142 insertions(+) create mode 100644 src/MicroPlumberd.Testing.StoreHolder/MicroPlumberd.Testing.StoreHolder.csproj create mode 100644 src/MicroPlumberd.Testing.StoreHolder/Program.cs create mode 100644 src/MicroPlumberd.Tests/Integration/StorePortRaceTests.cs diff --git a/src/MicroPlumberd.Testing.StoreHolder/MicroPlumberd.Testing.StoreHolder.csproj b/src/MicroPlumberd.Testing.StoreHolder/MicroPlumberd.Testing.StoreHolder.csproj new file mode 100644 index 0000000..0a22a60 --- /dev/null +++ b/src/MicroPlumberd.Testing.StoreHolder/MicroPlumberd.Testing.StoreHolder.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + diff --git a/src/MicroPlumberd.Testing.StoreHolder/Program.cs b/src/MicroPlumberd.Testing.StoreHolder/Program.cs new file mode 100644 index 0000000..dbd63b2 --- /dev/null +++ b/src/MicroPlumberd.Testing.StoreHolder/Program.cs @@ -0,0 +1,55 @@ +// Holds one EventStoreServer for seconds, starting it at (a barrier shared with a sibling process), +// and checks every 500 ms that the store is still THIS process's: its own container (by the id seen right after start) is +// running, and its own marker event is still readable (a restart of an in-memory store wipes it). +// Prints one line: "PORT=

CONTAINER= RESULT=SURVIVED" or "... RESULT=LOST:"; exit 0 only when it survived. +using Docker.DotNet; +using Docker.DotNet.Models; +using KurrentDB.Client; +using MicroPlumberd.Testing; + +var startAt = new DateTime(long.Parse(args[0]), DateTimeKind.Utc); +var hold = TimeSpan.FromSeconds(int.Parse(args[1])); +var delay = startAt - DateTime.UtcNow; +if (delay > TimeSpan.Zero) await Task.Delay(delay); + +using var docker = new DockerClientConfiguration().CreateClient(); +string? id = null; +string result; +var server = new EventStoreServer(); +try +{ + await server.StartInDocker(); + id = (await docker.Containers.ListContainersAsync(new ContainersListParameters { All = true })) + .FirstOrDefault(c => c.Names.Contains("/" + server.ContainerName))?.ID; + var client = new KurrentDBClient(server.GetEventStoreSettings()); + var mine = $"holder-{Environment.ProcessId}"; + await client.AppendToStreamAsync(mine, StreamState.Any, + [new EventData(Uuid.NewUuid(), "Marker", "{}"u8.ToArray())]); + + result = "SURVIVED"; + var until = DateTime.UtcNow + hold; + while (DateTime.UtcNow < until) + { + await Task.Delay(500); + var running = id != null && (await docker.Containers.ListContainersAsync(new ContainersListParameters { All = true })) + .Any(c => c.ID == id && c.State == "running"); + if (!running) { result = "LOST:my-container-is-gone-or-stopped"; break; } + try + { + var read = client.ReadStreamAsync(Direction.Forwards, mine, StreamPosition.Start); + if (await read.ReadState == ReadState.StreamNotFound) { result = "LOST:my-marker-is-gone(store-restarted)"; break; } + } + catch (Exception e) { result = $"LOST:store-unreachable({e.GetType().Name})"; break; } + } +} +catch (Exception e) +{ + result = $"LOST:start-failed({e.GetType().Name}: {e.Message.Split('\n')[0]})"; +} +finally +{ + await server.DisposeAsync(); +} + +Console.WriteLine($"PORT={server.HttpPort} CONTAINER={id?[..12] ?? "none"} RESULT={result}"); +return result == "SURVIVED" ? 0 : 1; diff --git a/src/MicroPlumberd.Tests/Integration/StorePortRaceTests.cs b/src/MicroPlumberd.Tests/Integration/StorePortRaceTests.cs new file mode 100644 index 0000000..2041b27 --- /dev/null +++ b/src/MicroPlumberd.Tests/Integration/StorePortRaceTests.cs @@ -0,0 +1,54 @@ +using System.Diagnostics; +using System.Reflection; +using FluentAssertions; +using MicroPlumberd.Tests.Utils; +using Xunit.Abstractions; + +namespace MicroPlumberd.Tests.Integration; + +///

+/// card microplumberd-testing-store-port-race: two test PROCESSES on one host (two engineers' suites) each start +/// EventStoreServers. Every process's PortSearcher began at 2701 and a port only counted as taken once its container +/// STARTED; containers are named eventstore-mem-<port> and found BY NAME — so the second process restarted the first's +/// in-memory store (wiping it) or removed it on cleanup ("connection refused"; Docker 409 "removal already in progress" was +/// observed 2026-10-18 13:52Z when two suites overlapped). Two StoreHolder processes start their stores at ONE barrier +/// instant; each must keep its own store, on its own port, in its own container, for the whole hold. +/// +[TestCategory("Integration")] +public class StorePortRaceTests(ITestOutputHelper output) +{ + private static readonly string Holder = typeof(StorePortRaceTests).Assembly + .GetCustomAttributes().Single(a => a.Key == "StoreHolderDll").Value!; + + [Fact] + public async Task TwoProcessesStartingStoresAtOnce_EachKeepTheirOwnStore() + { + File.Exists(Holder).Should().BeTrue($"the StoreHolder child is built beside the tests ({Holder})"); + var barrier = DateTime.UtcNow.AddSeconds(5).Ticks; + + var children = Enumerable.Range(0, 2).Select(_ => Run(barrier, holdSeconds: 20)).ToArray(); + var results = await Task.WhenAll(children); + foreach (var r in results) output.WriteLine($"exit={r.Exit} {r.Line}"); + + results.Should().OnlyContain(r => r.Exit == 0 && r.Line.EndsWith("RESULT=SURVIVED"), "each process keeps its own store"); + results.Select(r => Field(r.Line, "PORT")).Should().OnlyHaveUniqueItems("two processes never share a port"); + results.Select(r => Field(r.Line, "CONTAINER")).Should().OnlyHaveUniqueItems("nor a container"); + } + + private static string Field(string line, string key) => + line.Split(' ').Single(f => f.StartsWith(key + "=", StringComparison.Ordinal))[(key.Length + 1)..]; + + private static async Task<(int Exit, string Line)> Run(long barrierTicks, int holdSeconds) + { + var psi = new ProcessStartInfo("dotnet", [Holder, barrierTicks.ToString(), holdSeconds.ToString()]) + { RedirectStandardOutput = true, RedirectStandardError = true }; + using var process = Process.Start(psi)!; + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + using var bound = new CancellationTokenSource(TimeSpan.FromMinutes(3)); + try { await process.WaitForExitAsync(bound.Token); } + catch (OperationCanceledException) { process.Kill(entireProcessTree: true); return (-1, $"TIMEOUT stderr={await stderr}"); } + var line = (await stdout).Split('\n', StringSplitOptions.RemoveEmptyEntries).LastOrDefault()?.Trim() ?? $"no output; stderr={await stderr}"; + return (process.ExitCode, line); + } +} diff --git a/src/MicroPlumberd.Tests/MicroPlumberd.Tests.csproj b/src/MicroPlumberd.Tests/MicroPlumberd.Tests.csproj index 9ba282c..2845448 100644 --- a/src/MicroPlumberd.Tests/MicroPlumberd.Tests.csproj +++ b/src/MicroPlumberd.Tests/MicroPlumberd.Tests.csproj @@ -25,6 +25,9 @@ + + + all @@ -55,6 +58,8 @@ + + diff --git a/src/MicroPlumberd.sln b/src/MicroPlumberd.sln index fb184da..c2d893e 100644 --- a/src/MicroPlumberd.sln +++ b/src/MicroPlumberd.sln @@ -85,6 +85,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MicroPlumberd.Rewrite.Tests EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MicroPlumberd.Rewrite", "MicroPlumberd.Rewrite\MicroPlumberd.Rewrite.csproj", "{11B13FE4-AF80-4801-9A4E-58752001A8D2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MicroPlumberd.Testing.StoreHolder", "MicroPlumberd.Testing.StoreHolder\MicroPlumberd.Testing.StoreHolder.csproj", "{B31528B5-CD92-4D98-A2C5-AA8371CEAF45}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -527,6 +529,18 @@ Global {11B13FE4-AF80-4801-9A4E-58752001A8D2}.Release|x64.Build.0 = Release|Any CPU {11B13FE4-AF80-4801-9A4E-58752001A8D2}.Release|x86.ActiveCfg = Release|Any CPU {11B13FE4-AF80-4801-9A4E-58752001A8D2}.Release|x86.Build.0 = Release|Any CPU + {B31528B5-CD92-4D98-A2C5-AA8371CEAF45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B31528B5-CD92-4D98-A2C5-AA8371CEAF45}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B31528B5-CD92-4D98-A2C5-AA8371CEAF45}.Debug|x64.ActiveCfg = Debug|Any CPU + {B31528B5-CD92-4D98-A2C5-AA8371CEAF45}.Debug|x64.Build.0 = Debug|Any CPU + {B31528B5-CD92-4D98-A2C5-AA8371CEAF45}.Debug|x86.ActiveCfg = Debug|Any CPU + {B31528B5-CD92-4D98-A2C5-AA8371CEAF45}.Debug|x86.Build.0 = Debug|Any CPU + {B31528B5-CD92-4D98-A2C5-AA8371CEAF45}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B31528B5-CD92-4D98-A2C5-AA8371CEAF45}.Release|Any CPU.Build.0 = Release|Any CPU + {B31528B5-CD92-4D98-A2C5-AA8371CEAF45}.Release|x64.ActiveCfg = Release|Any CPU + {B31528B5-CD92-4D98-A2C5-AA8371CEAF45}.Release|x64.Build.0 = Release|Any CPU + {B31528B5-CD92-4D98-A2C5-AA8371CEAF45}.Release|x86.ActiveCfg = Release|Any CPU + {B31528B5-CD92-4D98-A2C5-AA8371CEAF45}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From a4f738f5740201b008324e0daa7bbd545228c27e Mon Sep 17 00:00:00 2001 From: Rafal Maciag Date: Sun, 18 Oct 2026 16:19:21 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(testing):=20a=20store's=20port=20is=20h?= =?UTF-8?q?eld=20machine-wide=20for=20its=20lifetime=20(a=20lock=20file=20?= =?UTF-8?q?per=20port,=20FileShare.None=20=E2=86=92=20flock,=20released=20?= =?UTF-8?q?by=20the=20OS=20on=20death);=20its=20container=20is=20removed/s?= =?UTF-8?q?topped/restarted=20by=20the=20id=20it=20CREATED,=20never=20by?= =?UTF-8?q?=20name;=20a=20same-name=20container=20of=20another=20LIVE=20pr?= =?UTF-8?q?ocess=20is=20refused,=20named=20=E2=80=94=20never=20restarted?= =?UTF-8?q?=20(that=20wiped=20its=20in-memory=20store)=20or=20removed;=20a?= =?UTF-8?q?=20failed=20start's=20own=20failure=20is=20thrown,=20a=20cleanu?= =?UTF-8?q?p=20failure=20rides=20along=20in=20Data=20(dev-clean:=20the=204?= =?UTF-8?q?09=20masked=20it)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The race row: GREEN 10/10. Rows: a planted live-foreign-owner container of the same name → start refused, it is left stopped and present; dispose leaves it; a start failure whose cleanup also fails throws the start failure. The static readiness/removal seams are given the server, so a row touches only its own store (the readiness test counted every parallel class's stores before). Mutants: by-name Cleanup → the dispose row red; the reuse branch without the refusal → the start row red; the masking catch → the start-failure row red; the port lock non-exclusive (FileShare.ReadWrite) → the race row RED 4/10 (each a create Conflict; with id-removal and the refusal the loser fails alone, the winner survives). Co-Authored-By: Claude Opus 5.5 --- src/MicroPlumberd.Testing/EventStoreServer.cs | 65 ++++++++++++----- src/MicroPlumberd.Testing/PortLock.cs | 48 +++++++++++++ .../EventStoreServerForeignContainerTests.cs | 71 +++++++++++++++++++ .../EventStoreServerReadinessTests.cs | 11 ++- .../EventStoreServerStartFailureTests.cs | 45 ++++++++++++ 5 files changed, 219 insertions(+), 21 deletions(-) create mode 100644 src/MicroPlumberd.Testing/PortLock.cs create mode 100644 src/MicroPlumberd.Tests/Integration/EventStoreServerForeignContainerTests.cs create mode 100644 src/MicroPlumberd.Tests/Integration/EventStoreServerStartFailureTests.cs diff --git a/src/MicroPlumberd.Testing/EventStoreServer.cs b/src/MicroPlumberd.Testing/EventStoreServer.cs index fa9122d..762dd4d 100644 --- a/src/MicroPlumberd.Testing/EventStoreServer.cs +++ b/src/MicroPlumberd.Testing/EventStoreServer.cs @@ -37,8 +37,15 @@ public class EventStoreServer : IDisposable, IAsyncDisposable public static TimeSpan ReadyBudget(TimeSpan perStore, int startingAtOnce) => perStore * Math.Max(1, startingAtOnce); /// The readiness wait itself — a seam so a test can see the budget each start actually waited with. - internal static Func WaitReady { get; set; } = - (settings, budget) => settings.WaitUntilReady(budget); + /// + /// The removal Cleanup makes — a seam so a test can make it fail (as a concurrent removal's 409 did). It is given the + /// server, so a test touches only its own store while the suite runs classes in parallel. + /// + internal static Func RemoveContainer { get; set; } = + (_, docker, id) => docker.Containers.RemoveContainerAsync(id, new ContainerRemoveParameters { Force = true, RemoveVolumes = true }); + + internal static Func WaitReady { get; set; } = + (_, settings, budget) => settings.WaitUntilReady(budget); private static readonly ConcurrentDictionary Undisposed = new(); private static readonly string CurrentOwner = OwnerOf(Environment.ProcessId, Process.GetCurrentProcess().StartTime); @@ -56,7 +63,7 @@ internal EventStoreServer(string? containerName = null) { if (containerName != null) _containerName = containerName; - httpPort = _searcher.FindNextAvailablePort(); + (httpPort, _portLock) = PortLock.Acquire(_searcher); const string eventStoreHostName = "127.0.0.1"; HttpUrl = new Uri($"esdb://admin:changeit@{eventStoreHostName}:{httpPort}?tls=false&tlsVerifyCert=false"); client = new DockerClientConfiguration() @@ -64,6 +71,12 @@ internal EventStoreServer(string? containerName = null) } private string? _containerName; + + // card microplumberd-testing-store-port-race: this store's port, held machine-wide for its lifetime (see PortLock), and + // the id of the container IT created. Removal and restart go by that id — never by name: the name is derived from the + // port, so another process's store can carry it. + private PortLock? _portLock; + private string? _containerId; public string ContainerName => _containerName ?? $"eventstore-mem-{httpPort}"; public int HttpPort => httpPort; @@ -86,10 +99,10 @@ internal EventStoreServer(string? containerName = null) public async Task Stop() { - var container = await GetEventStoreContainer(); - if (container != null) + // This server's own container only (by id) — a name can be another process's store. + if (_containerId != null) { - var data = await client.Containers.InspectContainerAsync(container.ID); + var data = await client.Containers.InspectContainerAsync(_containerId); if (data.State.Running) { await client.Containers.StopContainerAsync(data.ID, new ContainerStopParameters()); @@ -102,10 +115,9 @@ public async Task Stop() public async Task Restart(TimeSpan delay) { - var container = await GetEventStoreContainer(); - if (container != null) + if (_containerId != null) { - var data = await client.Containers.InspectContainerAsync(container.ID); + var data = await client.Containers.InspectContainerAsync(_containerId); if (data.State.Running) { await client.Containers.StopContainerAsync(data.ID, new ContainerStopParameters()); @@ -139,10 +151,20 @@ private async Task StartAndWait(bool wait, bool inMemory, Time await ReapOrphans(client); var container = await GetEventStoreContainer(); + if (container != null && container.ID != _containerId) + { + // A container with this name that THIS server did not create: another live process's store (refused, named — + // restarting it would wipe it, removing it would pull it from under that process), or a dead owner's (reaped). + if (!container.Labels.TryGetValue(OwnerLabel, out var owner) || !IsDeadOwnerOnThisMachine(owner)) + throw new InvalidOperationException( + $"Container '{ContainerName}' ({container.ID[..12]}) belongs to another process ({owner ?? "no owner label"}); " + + "it was neither restarted nor removed. Two processes chose the same store port."); + await client.Containers.RemoveContainerAsync(container.ID, new ContainerRemoveParameters { Force = true, RemoveVolumes = true }); + container = null; + } if (!inMemory && container != null) { - var data = await client.Containers.InspectContainerAsync(container.ID); - await client.Containers.RemoveContainerAsync(data.ID, new ContainerRemoveParameters() { RemoveVolumes = true}); + await client.Containers.RemoveContainerAsync(container.ID, new ContainerRemoveParameters() { RemoveVolumes = true}); container = null; } @@ -178,6 +200,7 @@ private async Task StartAndWait(bool wait, bool inMemory, Time { "2113", default}, } }); + _containerId = response.ID; await client.Containers.StartContainerAsync(response.ID, new ContainerStartParameters()); } else @@ -191,12 +214,16 @@ private async Task StartAndWait(bool wait, bool inMemory, Time // Health THEN a gRPC round trip (WaitUntilReady), on a budget that grows with the stores starting // alongside this one — read now, when they are all competing for the host. - await WaitReady(this.GetEventStoreSettings(), ReadyBudget(readyTimeout, Volatile.Read(ref _starting))); + await WaitReady(this, this.GetEventStoreSettings(), ReadyBudget(readyTimeout, Volatile.Read(ref _starting))); } - catch + catch (Exception started) { - await Cleanup(); - throw; + // The START failure is the one to see (dev-clean, 2026-10-18): a cleanup that throws too (Docker 409 "removal + // already in progress") replaced it, and the first failure was lost. Cleanup's failure rides along, never instead. + try { await Cleanup(); } + catch (Exception cleanup) { started.Data["MicroPlumberd.Testing.CleanupFailure"] = cleanup.ToString(); } + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(started).Throw(); + throw; // unreachable } return this; @@ -285,19 +312,21 @@ private static void RemoveUndisposed() async ValueTask Cleanup() { - var container = await GetEventStoreContainer(); - if (container != null) + // ONLY the container this server created, by its id: a name lookup here removed another process's live store. + if (_containerId != null) { try { - await client.Containers.RemoveContainerAsync(container.ID, new ContainerRemoveParameters() { Force = true, RemoveVolumes = true }); + await RemoveContainer(this, client, _containerId); } catch (DockerContainerNotFoundException) { } + _containerId = null; } Undisposed.TryRemove(this, out _); + Interlocked.Exchange(ref _portLock, null)?.Dispose(); } public void Dispose() diff --git a/src/MicroPlumberd.Testing/PortLock.cs b/src/MicroPlumberd.Testing/PortLock.cs new file mode 100644 index 0000000..8fa978a --- /dev/null +++ b/src/MicroPlumberd.Testing/PortLock.cs @@ -0,0 +1,48 @@ +using System.Net; +using System.Net.Sockets; + +namespace MicroPlumberd.Testing; + +/// +/// A store port held MACHINE-WIDE for the store's lifetime (card microplumberd-testing-store-port-race). Every process's +/// PortSearcher starts at the same port, and a free-port probe only sees a port as taken once its container has STARTED — +/// seconds later — so two processes chose the same port. A lock file per port, opened exclusively (on Unix .NET takes an +/// advisory flock for FileShare.None), is held until the store is disposed and released by the OS if the process dies. +/// +internal sealed class PortLock : IDisposable +{ + /// One directory for every process on the machine (not the per-user/per-process TMPDIR). + internal static string Directory { get; set; } = OperatingSystem.IsWindows() + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "microplumberd-testing-ports") + : "/tmp/microplumberd-testing-ports"; + + private readonly FileStream _held; + private PortLock(FileStream held) => _held = held; + + /// The next port that no other process holds AND that is free to bind, with its lock held. + internal static (int Port, PortLock Lock) Acquire(PortSearcher searcher) + { + System.IO.Directory.CreateDirectory(Directory); + while (true) + { + var port = searcher.FindNextAvailablePort(); + var held = TryHold(port); + if (held != null) return (port, held); + } + } + + internal static PortLock? TryHold(int port) + { + try + { + var stream = new FileStream(Path.Combine(Directory, $"{port}.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + return new PortLock(stream); + } + catch (IOException) + { + return null; // another process holds this port + } + } + + public void Dispose() => _held.Dispose(); +} diff --git a/src/MicroPlumberd.Tests/Integration/EventStoreServerForeignContainerTests.cs b/src/MicroPlumberd.Tests/Integration/EventStoreServerForeignContainerTests.cs new file mode 100644 index 0000000..023d7fe --- /dev/null +++ b/src/MicroPlumberd.Tests/Integration/EventStoreServerForeignContainerTests.cs @@ -0,0 +1,71 @@ +using System.Diagnostics; +using Docker.DotNet; +using Docker.DotNet.Models; +using FluentAssertions; +using MicroPlumberd.Testing; +using MicroPlumberd.Tests.Utils; + +namespace MicroPlumberd.Tests.Integration; + +/// +/// card microplumberd-testing-store-port-race: a container that carries this server's name but belongs to ANOTHER LIVE +/// process is never restarted (that wiped its in-memory store) and never removed (that pulled it from under that process) — +/// not at start, not at dispose. The port lock makes such a name clash rare; these rows hold the second line of defence +/// without a race. The planted container's owner is PID 1 with its real start time: alive for as long as the machine is. +/// +[TestCategory("Integration")] +public class EventStoreServerForeignContainerTests : IAsyncLifetime +{ + private readonly DockerClient _docker = new DockerClientConfiguration().CreateClient(); + private readonly string _name = $"mp-foreign-{Guid.NewGuid():N}"[..24]; + private string _planted = ""; + + public async Task InitializeAsync() + { + var liveForeignOwner = EventStoreServer.OwnerOf(1, Process.GetProcessById(1).StartTime); + _planted = (await _docker.Containers.CreateContainerAsync(new CreateContainerParameters + { + Image = "docker.kurrent.io/kurrent-latest/kurrentdb:latest", + Name = _name, + Labels = new Dictionary { [EventStoreServer.Label] = "eventstore", [EventStoreServer.OwnerLabel] = liveForeignOwner }, + })).ID; + } + + public async Task DisposeAsync() + { + try { await _docker.Containers.RemoveContainerAsync(_planted, new ContainerRemoveParameters { Force = true }); } + catch (DockerContainerNotFoundException) { } + _docker.Dispose(); + } + + private async Task Planted() + { + try { return await _docker.Containers.InspectContainerAsync(_planted); } + catch (DockerContainerNotFoundException) { return null; } + } + + [Fact] + public async Task AStart_OverAnotherLiveProcesssContainerOfTheSameName_IsRefused_AndLeavesItAlone() + { + await using var server = new EventStoreServer(_name); + + var start = await Record.ExceptionAsync(() => server.StartInDocker()); + + start.Should().BeOfType().Which.Message.Should() + .Contain(_name).And.Contain("another process").And.Contain("neither restarted nor removed"); + var planted = await Planted(); + planted.Should().NotBeNull("it was not removed"); + planted!.State.Running.Should().BeFalse("nor (re)started"); + } + + [Fact] + public async Task ADispose_NeverRemovesAContainerThisServerDidNotCreate() + { + var server = new EventStoreServer(_name); + await Record.ExceptionAsync(() => server.StartInDocker()); // refused: it created nothing + + await server.DisposeAsync(); + + (await Planted()).Should().NotBeNull("dispose removes only the container THIS server created, by its id"); + } +} diff --git a/src/MicroPlumberd.Tests/Integration/EventStoreServerReadinessTests.cs b/src/MicroPlumberd.Tests/Integration/EventStoreServerReadinessTests.cs index cebfeb9..a220508 100644 --- a/src/MicroPlumberd.Tests/Integration/EventStoreServerReadinessTests.cs +++ b/src/MicroPlumberd.Tests/Integration/EventStoreServerReadinessTests.cs @@ -17,11 +17,16 @@ public async Task StoresStartedTogether_WaitWithABudgetThatCountsEachOther() { var budgets = new ConcurrentBag(); var real = EventStoreServer.WaitReady; - EventStoreServer.WaitReady = async (settings, budget) => { budgets.Add(budget); await real(settings, budget); }; + await using var a = EventStoreServer.Create(); + await using var b = EventStoreServer.Create(); + // Only these two stores' budgets: other classes start stores through the same static seam in parallel. + EventStoreServer.WaitReady = async (server, settings, budget) => + { + if (ReferenceEquals(server, a) || ReferenceEquals(server, b)) budgets.Add(budget); + await real(server, settings, budget); + }; try { - await using var a = EventStoreServer.Create(); - await using var b = EventStoreServer.Create(); await Task.WhenAll(a.StartInDocker(), b.StartInDocker()); } finally diff --git a/src/MicroPlumberd.Tests/Integration/EventStoreServerStartFailureTests.cs b/src/MicroPlumberd.Tests/Integration/EventStoreServerStartFailureTests.cs new file mode 100644 index 0000000..3dc7355 --- /dev/null +++ b/src/MicroPlumberd.Tests/Integration/EventStoreServerStartFailureTests.cs @@ -0,0 +1,45 @@ +using FluentAssertions; +using MicroPlumberd.Testing; +using MicroPlumberd.Tests.Utils; + +namespace MicroPlumberd.Tests.Integration; + +/// +/// A failed start removes its container — and if that removal ALSO fails (two processes removing one container gave Docker +/// 409 "removal already in progress", 2026-10-18), the START failure is still the one thrown; the cleanup failure rides +/// along in Data. It used to replace it, and the first failure was lost (dev-clean's observation). +/// +[TestCategory("Integration")] +public class EventStoreServerStartFailureTests +{ + private sealed class NotReady() : Exception("the store did not become ready (planted)"); + + [Fact] + public async Task AStartThatFails_AndWhoseCleanupAlsoFails_ThrowsTheStartFailure() + { + await using var server = EventStoreServer.Create(); + var ready = EventStoreServer.WaitReady; + var remove = EventStoreServer.RemoveContainer; + // Only THIS server's store: the suite runs other classes' stores in parallel through the same static seams. + EventStoreServer.WaitReady = (s, settings, budget) => + ReferenceEquals(s, server) ? Task.FromException(new NotReady()) : ready(s, settings, budget); + EventStoreServer.RemoveContainer = async (s, docker, id) => + { + await remove(s, docker, id); // the container really goes + if (ReferenceEquals(s, server)) throw new InvalidOperationException("removal of container is already in progress (planted)"); + }; + try + { + var thrown = await Record.ExceptionAsync(() => server.StartInDocker()); + + thrown.Should().BeOfType("the start failure is the first failure, and it is the one thrown"); + thrown!.Data["MicroPlumberd.Testing.CleanupFailure"].Should().BeOfType() + .Which.Should().Contain("already in progress", "the cleanup failure rides along, not instead"); + } + finally + { + EventStoreServer.WaitReady = ready; + EventStoreServer.RemoveContainer = remove; + } + } +}