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);
}
}