Drei Manager-Tests liessen den Prozess vor der Stopp-Bitte enden; unter Last sah der Aufsichtstakt das zuerst und meldete Crashed statt Stopped. Die FakeChannel bekommt OnStopRequest, die Tests beenden darin. Der Drain-Test bekommt 20 s statt 2 s Luft. AtomicFile schreibt, bis der Leser 20 Blicke hatte, statt fest 40 Mal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
320 lines
11 KiB
C#
320 lines
11 KiB
C#
using ClawdDotNet.Core.Instances;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Manager.Tests;
|
|
|
|
/// <summary>Zählt mit, wem ein Token besorgt wurde.</summary>
|
|
internal sealed class FakeTokenSource : IInstanceTokenSource
|
|
{
|
|
public List<string> Fuer { get; } = [];
|
|
|
|
public bool Erfolg { get; set; } = true;
|
|
|
|
public Task<bool> EnsureTokenAsync(string instanceDirectory, CancellationToken ct = default)
|
|
{
|
|
Fuer.Add(Path.GetFullPath(instanceDirectory));
|
|
return Task.FromResult(Erfolg);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Der Instanzmanager als Ganzes (#528, Stufe 3).
|
|
///
|
|
/// <para><b>Die Lizenz wird hier ohne Lizenzserver geprüft</b>, und das ist Absicht: Die
|
|
/// Zusage lautet nicht „der Torwächter funktioniert" — das ist die Aufgabe des SDK —,
|
|
/// sondern „ein Urteil führt zum Anhalten aller Instanzen, ein Ausfall nicht". Genau
|
|
/// dieser Weg wird hier gegangen, ausgelöst über
|
|
/// <see cref="ManagerCore.HandleLicenseRevokedAsync"/>.</para>
|
|
/// </summary>
|
|
public sealed class ManagerCoreTests : IAsyncDisposable
|
|
{
|
|
private readonly string _root = Path.Combine(
|
|
Path.GetTempPath(), "clawd-tests", "core-" + Guid.NewGuid().ToString("N")[..8]);
|
|
|
|
private readonly FakeLauncher _launcher = new();
|
|
private readonly FakeChannel _kanal = new();
|
|
private readonly FakeTokenSource _tokens = new();
|
|
private readonly InstanceDirectoryManager _verzeichnisse;
|
|
|
|
private static readonly SupervisionOptions Options = new(
|
|
ProbeInterval: TimeSpan.FromMilliseconds(50),
|
|
StartTimeout: TimeSpan.FromMilliseconds(600),
|
|
SilentChecksBeforeAlarm: 3,
|
|
StopGrace: TimeSpan.FromMilliseconds(300),
|
|
SilentKillGrace: TimeSpan.FromMilliseconds(300),
|
|
MaxDrainWait: TimeSpan.FromSeconds(2));
|
|
|
|
public ManagerCoreTests()
|
|
{
|
|
Directory.CreateDirectory(_root);
|
|
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
|
|
// Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im
|
|
// Betrieb (siehe FakeChannel.Launcher).
|
|
_kanal.Launcher = _launcher;
|
|
}
|
|
|
|
private ManagerCore Manager()
|
|
{
|
|
var register = new InstanceRegistry(
|
|
_verzeichnisse, _launcher, _kanal, NullLoggerFactory.Instance, Options);
|
|
|
|
return new ManagerCore(
|
|
new ManagerSettingsStore(_root), register, _tokens, NullLoggerFactory.Instance);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await Task.CompletedTask;
|
|
try { Directory.Delete(_root, recursive: true); }
|
|
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lässt die Instanz ab jetzt antworten — und prüft, dass sie danach wirklich
|
|
/// gestartet und nicht bloß übernommen wurde.
|
|
///
|
|
/// <para><b>Der Unterschied ist keine Feinheit.</b> Wer die Antwort schon vor dem
|
|
/// Hochfahren des Managers hinterlegt, sagt damit „diese Instanz läuft bereits" — der
|
|
/// Manager übernimmt sie dann (E-M6), der Starter wird nie angefasst, und der Test
|
|
/// prüft etwas anderes, als er behauptet. Genau das ist mir hier zweimal
|
|
/// passiert.</para>
|
|
/// </summary>
|
|
private async Task<StartRefusal> StarteUndPruefe(ManagerCore manager, string pfad, string name)
|
|
{
|
|
var vorher = _launcher.Starts;
|
|
|
|
_kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-1", name, "0.1.2");
|
|
|
|
var ergebnis = await manager.StartInstanceAsync(pfad);
|
|
|
|
_launcher.Starts.ShouldBe(vorher + 1, "Gestartet, nicht uebernommen.");
|
|
return ergebnis;
|
|
}
|
|
|
|
// ─── Hochfahren ───
|
|
|
|
[Fact]
|
|
public async Task Ohne_Lizenzpruefung_arbeitet_der_Manager()
|
|
{
|
|
// Kein Torwaechter heisst keine Sperre - sonst gaebe es eine Henne-Ei-Sperre,
|
|
// bevor ueberhaupt jemand einen Schluessel eintragen kann.
|
|
await using var manager = Manager();
|
|
|
|
(await manager.StartAsync()).ShouldBeTrue();
|
|
manager.LicenseHolds.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Beim_Hochfahren_wird_nichts_gestartet()
|
|
{
|
|
_verzeichnisse.CreateInstance("Alpha");
|
|
|
|
await using var manager = Manager();
|
|
await manager.StartAsync();
|
|
|
|
manager.Instances.Instances.Count.ShouldBe(1);
|
|
_launcher.Starts.ShouldBe(0);
|
|
}
|
|
|
|
// ─── Token ───
|
|
|
|
[Fact]
|
|
public async Task Eine_neue_Instanz_bekommt_gleich_ihr_eigenes_Token()
|
|
{
|
|
// Beim Anlegen und nicht beim ersten Start: Dann gehoert die Instanzdatei
|
|
// zweifelsfrei niemandem.
|
|
await using var manager = Manager();
|
|
|
|
var pfad = await manager.CreateInstanceAsync("Frisch");
|
|
|
|
_tokens.Fuer.ShouldBe([Path.GetFullPath(pfad)]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Vor_dem_Start_wird_das_Token_nachgeholt()
|
|
{
|
|
// Der Fall einer Instanz, die es vor dem Manager schon gab - oder bei der das
|
|
// Deploymentcenter beim Anlegen nicht erreichbar war.
|
|
var pfad = _verzeichnisse.CreateInstance("Alt");
|
|
|
|
await using var manager = Manager();
|
|
await manager.StartAsync();
|
|
|
|
(await StarteUndPruefe(manager, pfad, "Alt")).ShouldBe(StartRefusal.None);
|
|
|
|
_tokens.Fuer.ShouldContain(Path.GetFullPath(pfad));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ein_fehlgeschlagener_Tokenbezug_verhindert_den_Start_nicht()
|
|
{
|
|
// Monitoring, das nur bei perfekter Rechtelage laeuft, ist genau dann still,
|
|
// wenn man es braucht.
|
|
_tokens.Erfolg = false;
|
|
|
|
var pfad = _verzeichnisse.CreateInstance("Alt");
|
|
|
|
await using var manager = Manager();
|
|
await manager.StartAsync();
|
|
|
|
(await StarteUndPruefe(manager, pfad, "Alt")).ShouldBe(StartRefusal.None);
|
|
}
|
|
|
|
// ─── Das Urteil ───
|
|
|
|
/// <summary>
|
|
/// Der einzige Fall, in dem der Manager von sich aus Instanzen anhält.
|
|
///
|
|
/// <para>Ein Serverausfall gehört ausdrücklich nicht dazu — das SDK trennt Urteil und
|
|
/// gescheiterten Versuch, und nur das Urteil kommt hier an. Sonst nähme ein
|
|
/// Netzproblem sämtliche Installationen gleichzeitig mit.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Ein_Lizenzwiderruf_haelt_alle_laufenden_Instanzen_an()
|
|
{
|
|
var pfad = _verzeichnisse.CreateInstance("Alpha");
|
|
|
|
await using var manager = Manager();
|
|
await manager.StartAsync();
|
|
await StarteUndPruefe(manager, pfad, "Alpha");
|
|
|
|
manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Running);
|
|
|
|
// Die Instanz endet auf die Bitte hin.
|
|
var prozess = _launcher.Last!;
|
|
_kanal.OnStopRequest = _ => { _kanal.AnswerFor[Path.GetFullPath(pfad)] = null; prozess.Exit(0); };
|
|
|
|
await manager.HandleLicenseRevokedAsync("Widerrufen durch den Hersteller");
|
|
|
|
manager.LicenseHolds.ShouldBeFalse();
|
|
manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Stopped);
|
|
_kanal.StopRequests.ShouldBe(1, "Erst bitten, nicht schiessen.");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Nach_einem_Widerruf_startet_keine_Instanz_mehr()
|
|
{
|
|
var pfad = _verzeichnisse.CreateInstance("Alpha");
|
|
|
|
await using var manager = Manager();
|
|
await manager.StartAsync();
|
|
|
|
await manager.HandleLicenseRevokedAsync("Widerrufen");
|
|
|
|
(await manager.StartInstanceAsync(pfad)).ShouldBe(StartRefusal.NoLicense);
|
|
|
|
_launcher.Starts.ShouldBe(0);
|
|
_tokens.Fuer.ShouldBeEmpty("Ohne Lizenz wird auch kein Token verbraucht.");
|
|
}
|
|
|
|
// ─── Wenn der Start scheitert ───
|
|
|
|
[Fact]
|
|
public async Task Eine_Instanz_die_sich_nicht_meldet_gilt_als_nicht_gestartet()
|
|
{
|
|
var pfad = _verzeichnisse.CreateInstance("Stumm");
|
|
|
|
await using var manager = Manager();
|
|
await manager.StartAsync();
|
|
|
|
// Der Prozess laeuft, die Instanz meldet sich nie.
|
|
(await manager.StartInstanceAsync(pfad)).ShouldBe(StartRefusal.DidNotStart);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Der Wechsel, wie E-M2 ihn meint (#528, Stufe 5).
|
|
///
|
|
/// <para><b>Vorher hiess „Instanz wechseln":</b> Der laufende Prozess fährt sich herunter
|
|
/// und startet sich mit einer anderen Instanz neu. Wer wechselte, beendete zwangsläufig
|
|
/// die Instanz, aus der heraus er es tat — und genau dabei hat sich am 2026-09-08 die
|
|
/// Anwendung aufgehängt (#529). Jetzt laufen alle, und es kommt nur eine nach vorn.</para>
|
|
/// </summary>
|
|
public sealed class InstanceFocusTests : IAsyncDisposable
|
|
{
|
|
private readonly string _root = Path.Combine(
|
|
Path.GetTempPath(), "clawd-tests", "focus-" + Guid.NewGuid().ToString("N")[..8]);
|
|
|
|
private readonly FakeLauncher _launcher = new();
|
|
private readonly FakeChannel _kanal = new();
|
|
private readonly InstanceDirectoryManager _verzeichnisse;
|
|
|
|
private static readonly SupervisionOptions Options = new(
|
|
ProbeInterval: TimeSpan.FromMilliseconds(50),
|
|
StartTimeout: TimeSpan.FromMilliseconds(600),
|
|
SilentChecksBeforeAlarm: 3,
|
|
StopGrace: TimeSpan.FromMilliseconds(300),
|
|
SilentKillGrace: TimeSpan.FromMilliseconds(300),
|
|
MaxDrainWait: TimeSpan.FromSeconds(2));
|
|
|
|
public InstanceFocusTests()
|
|
{
|
|
Directory.CreateDirectory(_root);
|
|
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
|
|
// Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im
|
|
// Betrieb (siehe FakeChannel.Launcher).
|
|
_kanal.Launcher = _launcher;
|
|
}
|
|
|
|
private InstanceRegistry Register() => new(
|
|
_verzeichnisse, _launcher, _kanal, NullLoggerFactory.Instance, Options);
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await Task.CompletedTask;
|
|
try { Directory.Delete(_root, recursive: true); }
|
|
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Eine_laufende_Instanz_wird_nach_vorn_geholt()
|
|
{
|
|
await using var register = Register();
|
|
var pfad = register.Create("Alpha");
|
|
|
|
_kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-1", "Alpha", "0.1.2");
|
|
await register.StartAsync(pfad);
|
|
|
|
(await register.FocusAsync(pfad)).ShouldBeTrue();
|
|
|
|
_kanal.FocusRequests.ShouldBe([Path.GetFullPath(pfad)]);
|
|
|
|
// Und vor allem: Es wurde nichts beendet und nichts neu gestartet.
|
|
_launcher.Starts.ShouldBe(1);
|
|
_kanal.StopRequests.ShouldBe(0);
|
|
_launcher.Last!.HasExited.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Eine_Instanz_die_nicht_laeuft_hat_kein_Fenster()
|
|
{
|
|
await using var register = Register();
|
|
var pfad = register.Create("Alpha");
|
|
|
|
(await register.FocusAsync(pfad)).ShouldBeFalse();
|
|
|
|
// Nicht einmal gefragt: Wer nicht laeuft, antwortet auch nicht.
|
|
_kanal.FocusRequests.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Eine kopflose Instanz hat nichts nach vorn zu holen — und sagt das, statt Erfolg
|
|
/// zu melden.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Eine_kopflose_Instanz_meldet_dass_sie_kein_Fenster_hat()
|
|
{
|
|
await using var register = Register();
|
|
var pfad = register.Create("Alpha");
|
|
|
|
_kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-1", "Alpha", "0.1.2");
|
|
await register.StartAsync(pfad);
|
|
|
|
_kanal.HasWindow = false;
|
|
|
|
(await register.FocusAsync(pfad)).ShouldBeFalse();
|
|
_kanal.FocusRequests.ShouldHaveSingleItem();
|
|
}
|
|
}
|