namespace ClawdDotNet.Manager.Tests; /// /// An instance process the test controls. /// /// A real process would only allow checking supervision superficially: a crash at the /// desired moment, a process that lives and stays silent, a reused /// process ID — all of that can either not be produced with a running program or only /// with waiting times, and tests with waiting times are tests that /// eventually get switched off. /// internal sealed class FakeProcess(int id, DateTimeOffset startedAt) : IInstanceProcess { public int Id { get; } = id; public DateTimeOffset StartedAt { get; } = startedAt; public bool HasExited { get; private set; } public int? ExitCode { get; private set; } public bool WasKilled { get; private set; } public bool Disposed { get; private set; } /// The process ends — with or without intervention. public void Exit(int code) { HasExited = true; ExitCode = code; } public void Kill() { WasKilled = true; if (!HasExited) Exit(-1); } public void Dispose() => Disposed = true; } internal sealed class FakeLauncher : IInstanceLauncher { private int _nextId = 1000; public FakeProcess? Last { get; private set; } public int Starts { get; private set; } /// Which process belongs to which instance directory. public Dictionary ByDirectory { get; } = new(StringComparer.OrdinalIgnoreCase); /// Whether a process is currently alive for this directory. public bool IsRunning(string instanceDirectory) => ByDirectory.TryGetValue(Path.GetFullPath(instanceDirectory), out var p) && !p.HasExited; /// If set, the start fails with this. public Exception? FailStartWith { get; set; } /// What should deliver — already running processes. public Dictionary Running { get; } = []; public DateTimeOffset Now { get; set; } = DateTimeOffset.UnixEpoch; public IInstanceProcess Start(string instanceDirectory) { Starts++; if (FailStartWith is not null) throw FailStartWith; Last = new FakeProcess(_nextId++, Now); Running[Last.Id] = Last; ByDirectory[Path.GetFullPath(instanceDirectory)] = Last; return Last; } /// /// A process that was already running before this manager came (E-M6). /// /// Does not count as a start — exactly therein lies the difference several tests /// check: what runs is taken over and not booted a second time. /// public FakeProcess StartedElsewhere(string instanceDirectory, int processId) { var process = new FakeProcess(processId, Now); Running[processId] = process; ByDirectory[Path.GetFullPath(instanceDirectory)] = process; return process; } public IInstanceProcess? Attach(int processId, DateTimeOffset startedAt) { if (!Running.TryGetValue(processId, out var process)) return null; // The same check as in real operation: same ID, but a different start time // means a different process. return (process.StartedAt - startedAt).Duration() > TimeSpan.FromSeconds(2) ? null : process; } } /// The socket that the test lets answer or stay silent. internal sealed class FakeChannel : IInstanceChannel { /// The answer for all directories, unless one of their own is stored. public InstanceIdentity? Answer { get; set; } /// /// Answers per instance directory. /// /// Needed as soon as more than one instance is involved: information that names the same /// instance for every directory lets tests turn green that in operation would mix up two /// different instances. /// public Dictionary AnswerFor { get; } = new(StringComparer.OrdinalIgnoreCase); /// /// If set, the socket only answers for instances that this launcher /// actually started — as in operation. /// /// Without this a test setup is easily wrong: whoever stores the answer /// before the manager comes up says with it "this instance is already running". The manager /// then takes it over (E-M6), the launcher is never touched — and the test checks something /// other than it claims. That happened to me three times; with this coupling it can /// no longer happen. /// /// Not set means: the stored answer always applies. That is needed by whoever wants to describe an /// instance that runs without this manager having started it. /// public FakeLauncher? Launcher { get; set; } /// /// Directories whose instance has fallen silent: the process lives, the socket is silent. /// /// The case from #529 — and the only one in which supervision resorts to /// force on its own initiative. Without this switch it cannot be reproduced: the coupling /// to would otherwise bind the answer to exactly what is supposed to /// fall apart here. /// public HashSet Verstummt { get; } = new(StringComparer.OrdinalIgnoreCase); /// The instance stops answering without its process ending. public void GoSilent(string instanceDirectory) => Verstummt.Add(Path.GetFullPath(instanceDirectory)); public int Calls { get; private set; } /// How often the instance was asked to end itself. public int StopRequests { get; private set; } /// Whether the request arrives. False means: the instance no longer listens at all. public bool StopRequestArrives { get; set; } = true; public Task AskAsync(string instanceDirectory, CancellationToken ct = default) { Calls++; if (Verstummt.Contains(Path.GetFullPath(instanceDirectory))) return Task.FromResult(null); if (Launcher is not null && !Launcher.IsRunning(instanceDirectory)) return Task.FromResult(null); return Task.FromResult( AnswerFor.TryGetValue(Path.GetFullPath(instanceDirectory), out var ownOnes) ? ownOnes : Answer); } /// /// #722: What the instance does when the request arrives — typically: end. A test that /// ended the process before asking raced the supervisor's own check: under load the check /// saw the process gone first and called it a crash, not a stop. /// public Action? OnStopRequest { get; set; } public Task RequestStopAsync(string instanceDirectory, CancellationToken ct = default) { StopRequests++; if (StopRequestArrives) OnStopRequest?.Invoke(instanceDirectory); return Task.FromResult(StopRequestArrives); } }