Stufe 4c (#764): Kommentare in C#, WebUI-Skripten, CSS und HTML auf Englisch
Rund 2.600 Kommentarblöcke in 310 C#-Dateien übersetzt, Begründungen vollständig erhalten: blockweise gleiche Anzahl wie vorher, kein Block unter 60 % seiner Länge, der Code außerhalb der Kommentare per Tokenizer byte-identisch geprüft. Zitate deutscher Oberflächentexte und Testdaten bleiben deutsch. WebUI: Kommentare in JS/CSS/HTML übersetzt, JSDoc-@param an die in 4b umbenannten Parameter angeglichen. Dabei behoben: - 4b hatte "tag" (HTML-Tag) als deutsches "Tag" gelesen und zu "dayValue" gemacht (makeElement, svg, Tagesübersicht) - zurück auf tag/day. - Aufgaben "InProgress" bekamen die Plakettenart "laufend", für die es nie eine CSS-Klasse gab - jetzt "success" wie laufende Instanzen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
da4ceac7d5
commit
837a634a22
@@ -7,16 +7,15 @@ using Shouldly;
|
||||
namespace ClawdDotNet.Manager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// „Autostart durch Manager" — der Schalter, der eine Instanz am Laufen hält (#528).
|
||||
/// "Autostart by manager" — the switch that keeps an instance running (#528).
|
||||
///
|
||||
/// <para><b>Zwei Dinge in einem Schalter, weil es dieselbe Zusage ist:</b> Der Manager
|
||||
/// startet die Instanz beim Hochkommen, und er startet sie nach einem Absturz wieder. Ohne
|
||||
/// den Schalter rührt er nichts an — ein Manager, der nach einem Neustart der Maschine
|
||||
/// alles hochfährt, was er findet, startet auch das, was jemand mit Absicht angehalten
|
||||
/// hat.</para>
|
||||
/// <para><b>Two things in one switch, because it is the same promise:</b> the manager
|
||||
/// starts the instance when coming up, and it starts it again after a crash. Without
|
||||
/// the switch it touches nothing — a manager that boots everything it finds after a restart of the machine
|
||||
/// also starts what someone stopped on purpose.</para>
|
||||
///
|
||||
/// <para><b>Und der Sicherungsautomat ist der eigentliche Inhalt.</b> Wieder hochzufahren
|
||||
/// ist die einfache Hälfte; die schwierige ist zu erkennen, wann man es lassen muss.</para>
|
||||
/// <para><b>And the circuit breaker is the actual content.</b> Booting again
|
||||
/// is the easy half; the hard one is recognizing when to stop.</para>
|
||||
/// </summary>
|
||||
public sealed class AutostartTests : IAsyncDisposable
|
||||
{
|
||||
@@ -41,9 +40,9 @@ public sealed class AutostartTests : IAsyncDisposable
|
||||
Directory.CreateDirectory(_root);
|
||||
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
|
||||
|
||||
// Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - sonst hiesse eine
|
||||
// vorab hinterlegte Antwort "laeuft schon", und der Manager wuerde uebernehmen
|
||||
// statt zu starten.
|
||||
// The socket only answers if a process is really running - otherwise an
|
||||
// answer stored in advance would mean "already running", and the manager would take over
|
||||
// instead of starting.
|
||||
_kanal.Launcher = _launcher;
|
||||
}
|
||||
|
||||
@@ -51,7 +50,7 @@ public sealed class AutostartTests : IAsyncDisposable
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
try { Directory.Delete(_root, recursive: true); }
|
||||
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
||||
catch { /* A leftover test folder does not turn a run red. */ }
|
||||
}
|
||||
|
||||
private ManagerCore Manager(RestartPolicy? policy = null)
|
||||
@@ -64,7 +63,7 @@ public sealed class AutostartTests : IAsyncDisposable
|
||||
restartPolicy: policy);
|
||||
}
|
||||
|
||||
/// <summary>Legt eine Instanz an und setzt den Schalter in ihrer Instanzdatei.</summary>
|
||||
/// <summary>Creates an instance and sets the switch in its instance file.</summary>
|
||||
private string Instanz(string name, bool autostart)
|
||||
{
|
||||
var pfad = _verzeichnisse.CreateInstance(name);
|
||||
@@ -83,7 +82,7 @@ public sealed class AutostartTests : IAsyncDisposable
|
||||
InstanceEventKind.Crashed, Path.GetFullPath(pfad), name, 4711, 137,
|
||||
"unerwartet beendet", DateTimeOffset.UtcNow);
|
||||
|
||||
// ─── Beim Hochkommen ───
|
||||
// ─── When coming up ───
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_Instanz_mit_Autostart_wird_beim_Hochfahren_gestartet()
|
||||
@@ -110,8 +109,8 @@ public sealed class AutostartTests : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Was schon läuft, wird nicht noch einmal gestartet: Ein zweiter Prozess auf derselben
|
||||
/// <c>state.db</c> wäre das Gegenteil von hilfreich.
|
||||
/// What is already running is not started again: a second process on the same
|
||||
/// <c>state.db</c> would be the opposite of helpful.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Was_schon_laeuft_wird_nicht_noch_einmal_gestartet()
|
||||
@@ -143,7 +142,7 @@ public sealed class AutostartTests : IAsyncDisposable
|
||||
manager.Instances.Instances.Single(i => i.Name == "Ohne").AutostartByManager.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ─── Nach einem Absturz ───
|
||||
// ─── After a crash ───
|
||||
|
||||
[Fact]
|
||||
public async Task Nach_einem_Absturz_wird_wieder_gestartet()
|
||||
@@ -155,7 +154,7 @@ public sealed class AutostartTests : IAsyncDisposable
|
||||
|
||||
var vorher = _launcher.Starts;
|
||||
|
||||
// Der Prozess ist weg; die Aufsicht hat es gemeldet.
|
||||
// The process is gone; supervision has reported it.
|
||||
_launcher.Last!.Exit(137);
|
||||
await manager.RestartAfterCrashAsync(Absturz(pfad, "Alpha"));
|
||||
|
||||
@@ -178,14 +177,14 @@ public sealed class AutostartTests : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine verstummte Instanz wird beendet und neu gestartet — aber erst, nachdem der
|
||||
/// alte Prozess nachweislich weg ist.
|
||||
/// A silent instance is ended and restarted — but only after the
|
||||
/// old process is provably gone.
|
||||
///
|
||||
/// <para><b>Der Fall wird hier wirklich hergestellt und nicht behauptet.</b> Der
|
||||
/// Prozess lebt weiter, nur der Socket schweigt — genau die Lage aus #529. Ein Test,
|
||||
/// der stattdessen das Ereignis von Hand baut und die Neustartmethode aufruft, prüft
|
||||
/// die eine Hälfte, die ohnehin funktioniert, und lässt die gefährliche aus: dass vor
|
||||
/// dem Start niemand mehr auf der Datenbank sitzt.</para>
|
||||
/// <para><b>The case is really produced here and not claimed.</b> The
|
||||
/// process lives on, only the socket is silent — exactly the situation from #529. A test
|
||||
/// that instead builds the event by hand and calls the restart method checks
|
||||
/// the half that works anyway, and leaves out the dangerous one: that before
|
||||
/// the start nobody sits on the database any more.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Eine_verstummte_Instanz_wird_erst_beendet_und_dann_neu_gestartet()
|
||||
@@ -198,24 +197,24 @@ public sealed class AutostartTests : IAsyncDisposable
|
||||
var alt = _launcher.Last.ShouldNotBeNull();
|
||||
_launcher.Starts.ShouldBe(1);
|
||||
|
||||
// Ab jetzt haengt sie: Der Prozess lebt, der Socket antwortet nicht mehr.
|
||||
// From now on it hangs: the process lives, the socket no longer answers.
|
||||
_kanal.Verstummen(pfad);
|
||||
|
||||
var aufsicht = manager.Instances.SupervisorFor(pfad).ShouldNotBeNull();
|
||||
|
||||
// Drei Durchgaenge bis zum Alarm - vorher wird nichts unternommen.
|
||||
// Three passes until the alarm - nothing is done before that.
|
||||
for (var i = 0; i < 3; i++) await aufsicht.CheckAsync();
|
||||
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Silent);
|
||||
alt.WasKilled.ShouldBeFalse("Vor Ablauf der Frist wird nicht abgeschossen.");
|
||||
|
||||
// Die Frist laeuft ab (SilentKillGrace: 300ms).
|
||||
// The deadline expires (SilentKillGrace: 300ms).
|
||||
await Task.Delay(400);
|
||||
await aufsicht.CheckAsync();
|
||||
|
||||
alt.WasKilled.ShouldBeTrue("Nach der Frist wird der haengende Prozess beendet.");
|
||||
|
||||
// Der Absturzweg startet neu - und der neue Prozess ist ein anderer.
|
||||
// The crash path restarts - and the new process is a different one.
|
||||
await Warte(() => _launcher.Starts >= 2);
|
||||
|
||||
_launcher.Last.ShouldNotBeNull().Id.ShouldNotBe(alt.Id);
|
||||
@@ -223,11 +222,11 @@ public sealed class AutostartTests : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine Instanz, die aufräumt, wird nicht abgeschossen — auch wenn es dauert.
|
||||
/// An instance that is cleaning up is not killed — even if it takes a while.
|
||||
///
|
||||
/// <para>Sie antwortet ja; sie tut genau das, worum gebeten wurde. Der Unterschied
|
||||
/// zwischen „hängt" und „räumt auf" ist der ganze Grund, warum der Puls mehr sagt als
|
||||
/// „jemand hat geantwortet".</para>
|
||||
/// <para>It does answer; it is doing exactly what it was asked to do. The difference
|
||||
/// between "hangs" and "cleans up" is the whole reason why the pulse says more than
|
||||
/// "someone answered".</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Wer_aufraeumt_gilt_nicht_als_verstummt()
|
||||
@@ -259,11 +258,11 @@ public sealed class AutostartTests : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nach der Grenze setzt der Manager aus.
|
||||
/// After the limit the manager pauses.
|
||||
///
|
||||
/// <para>Was an einer kaputten Konfiguration scheitert, scheitert beim zehnten Versuch
|
||||
/// genauso — dann hat man statt einer toten Instanz eine tote Instanz, ein volles
|
||||
/// Protokoll und einen Bugtracker voller Wiederholungen.</para>
|
||||
/// <para>What fails because of a broken configuration fails the same way on the tenth attempt
|
||||
/// — then instead of a dead instance you have a dead instance, a full
|
||||
/// log and a bug tracker full of repetitions.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Nach_drei_Neustarts_im_Zeitfenster_setzt_der_Manager_aus()
|
||||
@@ -285,8 +284,8 @@ public sealed class AutostartTests : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wer von Hand startet, hat entweder etwas geändert oder weiß, was er tut. Ihm die
|
||||
/// Versuche von vorhin anzurechnen hieße, ihn für den Absturz zu bestrafen.
|
||||
/// Whoever starts by hand has either changed something or knows what they are doing. Charging them with
|
||||
/// the earlier attempts would mean punishing them for the crash.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Ein_Start_von_Hand_raeumt_den_Zaehler_ab()
|
||||
@@ -304,7 +303,7 @@ public sealed class AutostartTests : IAsyncDisposable
|
||||
|
||||
var erschoepft = _launcher.Starts;
|
||||
|
||||
// Ein neuer Manager faengt bei null an - genau wie ein Start von Hand.
|
||||
// A new manager starts at zero - just like a start by hand.
|
||||
await using var frisch = Manager(new RestartPolicy(3, TimeSpan.FromMinutes(10)));
|
||||
await frisch.StartAsync();
|
||||
|
||||
@@ -315,7 +314,7 @@ public sealed class AutostartTests : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Der Sicherungsautomat für sich genommen.</summary>
|
||||
/// <summary>The circuit breaker on its own.</summary>
|
||||
public sealed class RestartLedgerTests
|
||||
{
|
||||
private sealed class Uhr(DateTimeOffset jetzt) : TimeProvider
|
||||
@@ -349,8 +348,8 @@ public sealed class RestartLedgerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Das Zeitfenster wandert: Eine Instanz, die einmal im Monat abstürzt und wieder
|
||||
/// hochkommt, läuft nie in die Grenze — eine, die im Minutentakt fällt, sofort.
|
||||
/// The time window moves: an instance that crashes once a month and comes back
|
||||
/// up never runs into the limit — one that falls every minute does immediately.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Alte_Versuche_fallen_aus_dem_Fenster()
|
||||
@@ -383,12 +382,12 @@ public sealed class RestartLedgerTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Start, der auf einen veralteten Merkzettel trifft.
|
||||
/// A start that meets an outdated note.
|
||||
///
|
||||
/// <para><b>Der Defekt, den erst die Autostart-Tests gezeigt haben.</b> Zwischen zwei
|
||||
/// Aufsichtsdurchgängen liegen Sekunden, und in denen kann eine Instanz wegbrechen. Wer in
|
||||
/// dieser Lücke „starten" verlangte, bekam „läuft schon" — einen Erfolg, hinter dem nichts
|
||||
/// stand. Der nächste Durchgang meldete dann einen Absturz, den niemand mehr erwartete.</para>
|
||||
/// <para><b>The defect that only the autostart tests revealed.</b> Between two
|
||||
/// supervision passes there are seconds, and in them an instance can break away. Whoever
|
||||
/// asked to "start" in this gap got "already running" — a success with nothing
|
||||
/// behind it. The next pass then reported a crash nobody expected any more.</para>
|
||||
/// </summary>
|
||||
public sealed class StaleStateTests : IAsyncDisposable
|
||||
{
|
||||
@@ -409,7 +408,7 @@ public sealed class StaleStateTests : IAsyncDisposable
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
try { Directory.Delete(_root, recursive: true); }
|
||||
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
||||
catch { /* A leftover test folder does not turn a run red. */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -421,7 +420,7 @@ public sealed class StaleStateTests : IAsyncDisposable
|
||||
await using var aufsicht = new InstanceSupervisor(
|
||||
dir, _launcher, _kanal, NullLogger.Instance,
|
||||
new SupervisionOptions(
|
||||
TimeSpan.FromSeconds(30), // absichtlich traege: kein Durchgang dazwischen
|
||||
TimeSpan.FromSeconds(30), // deliberately sluggish: no pass in between
|
||||
TimeSpan.FromMilliseconds(600),
|
||||
3,
|
||||
TimeSpan.FromMilliseconds(300),
|
||||
@@ -431,8 +430,8 @@ public sealed class StaleStateTests : IAsyncDisposable
|
||||
(await aufsicht.StartAsync()).ShouldBeTrue();
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Running);
|
||||
|
||||
// Die Instanz bricht weg. Die Aufsicht hat es noch nicht bemerkt - der naechste
|
||||
// Durchgang kaeme erst in dreissig Sekunden.
|
||||
// The instance breaks away. Supervision has not noticed yet - the next
|
||||
// pass would only come in thirty seconds.
|
||||
_launcher.Last!.Exit(137);
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Running, "Noch nichts bemerkt.");
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ using Shouldly;
|
||||
namespace ClawdDotNet.Manager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests für Sicherung und Wiederherstellung im Manager (#530, Stufe 6).
|
||||
/// Tests for backup and restore in the manager (#530, stage 6).
|
||||
/// </summary>
|
||||
public sealed class BackupManagementTests : IDisposable
|
||||
{
|
||||
@@ -71,13 +71,13 @@ public sealed class BackupManagementTests : IDisposable
|
||||
File.Exists(zipPath).ShouldBeTrue();
|
||||
backupResult.SizeBytes.ShouldBeGreaterThan(0);
|
||||
|
||||
// Prüfen
|
||||
// Check
|
||||
var manifest = await service.InspectAsync(zipPath);
|
||||
manifest.InstanceId.ShouldBe("alpha-123");
|
||||
manifest.InstanceName.ShouldBe("Alpha");
|
||||
manifest.Files.Count.ShouldBeGreaterThan(0);
|
||||
|
||||
// Datei im Ziel modifizieren und Restore prüfen
|
||||
// Modify a file in the target and check the restore
|
||||
await File.WriteAllTextAsync(testDataPath, "Überschriebene Testdaten");
|
||||
|
||||
var restoreResult = await service.RestoreAsync(
|
||||
@@ -89,7 +89,7 @@ public sealed class BackupManagementTests : IDisposable
|
||||
var restoredContent = await File.ReadAllTextAsync(testDataPath);
|
||||
restoredContent.ShouldBe("Wichtige Testdaten");
|
||||
|
||||
// Löschen
|
||||
// Delete
|
||||
File.Delete(zipPath);
|
||||
File.Exists(zipPath).ShouldBeFalse();
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
namespace ClawdDotNet.Manager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ein Instanzprozess, den der Test steuert.
|
||||
/// An instance process the test controls.
|
||||
///
|
||||
/// <para>Ein echter Prozess ließe die Aufsicht nur oberflächlich prüfen: Ein Absturz zum
|
||||
/// gewünschten Zeitpunkt, ein Prozess, der lebt und schweigt, eine wiederverwendete
|
||||
/// Prozesskennung — das alles ist mit einem laufenden Programm entweder nicht oder nur
|
||||
/// mit Wartezeiten herzustellen, und Tests mit Wartezeiten sind Tests, die man
|
||||
/// irgendwann abschaltet.</para>
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
internal sealed class FakeProcess(int id, DateTimeOffset startedAt) : IInstanceProcess
|
||||
{
|
||||
@@ -23,7 +23,7 @@ internal sealed class FakeProcess(int id, DateTimeOffset startedAt) : IInstanceP
|
||||
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
/// <summary>Der Prozess endet — mit oder ohne Zutun.</summary>
|
||||
/// <summary>The process ends — with or without intervention.</summary>
|
||||
public void Exit(int code)
|
||||
{
|
||||
HasExited = true;
|
||||
@@ -47,19 +47,19 @@ internal sealed class FakeLauncher : IInstanceLauncher
|
||||
|
||||
public int Starts { get; private set; }
|
||||
|
||||
/// <summary>Welcher Prozess zu welchem Instanzverzeichnis gehört.</summary>
|
||||
/// <summary>Which process belongs to which instance directory.</summary>
|
||||
public Dictionary<string, FakeProcess> ByDirectory { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Ob für dieses Verzeichnis gerade ein Prozess lebt.</summary>
|
||||
/// <summary>Whether a process is currently alive for this directory.</summary>
|
||||
public bool IsRunning(string instanceDirectory)
|
||||
=> ByDirectory.TryGetValue(Path.GetFullPath(instanceDirectory), out var p)
|
||||
&& !p.HasExited;
|
||||
|
||||
/// <summary>Wenn gesetzt, scheitert der Start damit.</summary>
|
||||
/// <summary>If set, the start fails with this.</summary>
|
||||
public Exception? FailStartWith { get; set; }
|
||||
|
||||
/// <summary>Was <see cref="Attach"/> liefern soll — bereits laufende Prozesse.</summary>
|
||||
/// <summary>What <see cref="Attach"/> should deliver — already running processes.</summary>
|
||||
public Dictionary<int, FakeProcess> Running { get; } = [];
|
||||
|
||||
public DateTimeOffset Now { get; set; } = DateTimeOffset.UnixEpoch;
|
||||
@@ -77,10 +77,10 @@ internal sealed class FakeLauncher : IInstanceLauncher
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Prozess, der schon lief, bevor dieser Manager kam (E-M6).
|
||||
/// A process that was already running before this manager came (E-M6).
|
||||
///
|
||||
/// <para>Zählt nicht als Start — genau darin liegt der Unterschied, den mehrere Tests
|
||||
/// prüfen: Was läuft, wird übernommen und nicht ein zweites Mal hochgefahren.</para>
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
public FakeProcess Fremdstart(string instanceDirectory, int processId)
|
||||
{
|
||||
@@ -95,65 +95,65 @@ internal sealed class FakeLauncher : IInstanceLauncher
|
||||
if (!Running.TryGetValue(processId, out var process))
|
||||
return null;
|
||||
|
||||
// Dieselbe Pruefung wie im Echtbetrieb: gleiche Kennung, aber andere Startzeit
|
||||
// heisst anderer Prozess.
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Der Socket, den der Test antworten oder schweigen lässt.</summary>
|
||||
/// <summary>The socket that the test lets answer or stay silent.</summary>
|
||||
internal sealed class FakeChannel : IInstanceChannel
|
||||
{
|
||||
/// <summary>Die Antwort für alle Verzeichnisse, sofern keine eigene hinterlegt ist.</summary>
|
||||
/// <summary>The answer for all directories, unless one of their own is stored.</summary>
|
||||
public InstanceIdentity? Answer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Antworten je Instanzverzeichnis.
|
||||
/// Answers per instance directory.
|
||||
///
|
||||
/// <para>Nötig, sobald mehr als eine Instanz im Spiel ist: Eine Auskunft, die für jedes
|
||||
/// Verzeichnis dieselbe Instanz nennt, lässt Tests grün werden, die im Betrieb zwei
|
||||
/// verschiedene Instanzen verwechseln würden.</para>
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
public Dictionary<string, InstanceIdentity?> AnswerFor { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Wenn gesetzt, antwortet der Socket nur für Instanzen, die dieser Starter
|
||||
/// tatsächlich gestartet hat — so wie im Betrieb.
|
||||
/// If set, the socket only answers for instances that this launcher
|
||||
/// actually started — as in operation.
|
||||
///
|
||||
/// <para><b>Ohne das ist ein Testaufbau leicht falsch:</b> Wer die Antwort hinterlegt,
|
||||
/// bevor der Manager hochfährt, 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. Das ist mir dreimal passiert; mit dieser Kopplung kann es
|
||||
/// nicht mehr passieren.</para>
|
||||
/// <para><b>Without this a test setup is easily wrong:</b> 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.</para>
|
||||
///
|
||||
/// <para>Nicht gesetzt heißt: Die hinterlegte Antwort gilt immer. Das braucht, wer eine
|
||||
/// Instanz beschreiben will, die läuft, ohne dass dieser Manager sie gestartet hat.</para>
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
public FakeLauncher? Launcher { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Verzeichnisse, deren Instanz verstummt ist: Der Prozess lebt, der Socket schweigt.
|
||||
/// Directories whose instance has fallen silent: the process lives, the socket is silent.
|
||||
///
|
||||
/// <para>Der Fall aus #529 — und der einzige, in dem die Aufsicht von sich aus zur
|
||||
/// Gewalt greift. Ohne diesen Schalter lässt er sich nicht nachstellen: Die Kopplung
|
||||
/// an <see cref="Launcher"/> bindet die Antwort sonst genau an das, was hier
|
||||
/// auseinanderfallen soll.</para>
|
||||
/// <para>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 <see cref="Launcher"/> would otherwise bind the answer to exactly what is supposed to
|
||||
/// fall apart here.</para>
|
||||
/// </summary>
|
||||
public HashSet<string> Verstummt { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Die Instanz hört auf zu antworten, ohne dass ihr Prozess endet.</summary>
|
||||
/// <summary>The instance stops answering without its process ending.</summary>
|
||||
public void Verstummen(string instanceDirectory)
|
||||
=> Verstummt.Add(Path.GetFullPath(instanceDirectory));
|
||||
|
||||
public int Calls { get; private set; }
|
||||
|
||||
/// <summary>Wie oft die Instanz gebeten wurde, sich zu beenden.</summary>
|
||||
/// <summary>How often the instance was asked to end itself.</summary>
|
||||
public int StopRequests { get; private set; }
|
||||
|
||||
/// <summary>Ob die Bitte ankommt. Falsch heisst: Die Instanz hoert gar nicht mehr zu.</summary>
|
||||
/// <summary>Whether the request arrives. False means: the instance no longer listens at all.</summary>
|
||||
public bool StopRequestArrives { get; set; } = true;
|
||||
|
||||
public Task<InstanceIdentity?> AskAsync(string instanceDirectory, CancellationToken ct = default)
|
||||
|
||||
@@ -5,12 +5,12 @@ using Shouldly;
|
||||
namespace ClawdDotNet.Manager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Das Register aller Instanzen einer Maschine (#528, Stufe 3).
|
||||
/// The registry of all instances of a machine (#528, stage 3).
|
||||
///
|
||||
/// <para>Zwei Zusagen stehen hier über allem, und beide folgen aus E-M6: <b>Beim Start
|
||||
/// wird übernommen, nicht gestartet</b>, und <b>beim Beenden wird nichts beendet.</b>
|
||||
/// Fällt der Manager aus, laufen die Instanzen weiter — er darf nicht der Einzelpunkt
|
||||
/// sein, gegen den dieses ganze Vorhaben gebaut wird.</para>
|
||||
/// <para>Two promises stand above everything here, and both follow from E-M6: <b>at startup
|
||||
/// things are taken over, not started</b>, and <b>on exit nothing is ended.</b>
|
||||
/// If the manager fails, the instances keep running — it must not be the single point
|
||||
/// this whole project is built against.</para>
|
||||
/// </summary>
|
||||
public sealed class InstanceRegistryTests : IAsyncDisposable
|
||||
{
|
||||
@@ -34,8 +34,8 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
|
||||
public InstanceRegistryTests()
|
||||
{
|
||||
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
|
||||
// Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im
|
||||
// Betrieb (siehe FakeChannel.Launcher).
|
||||
// The socket only answers if a process is really running - exactly as in
|
||||
// operation (see FakeChannel.Launcher).
|
||||
_kanal.Launcher = _launcher;
|
||||
}
|
||||
|
||||
@@ -52,10 +52,10 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
try { Directory.Delete(_root, recursive: true); }
|
||||
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
||||
catch { /* A leftover test folder does not turn a run red. */ }
|
||||
}
|
||||
|
||||
// ─── Was da ist ───
|
||||
// ─── What is there ───
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_leeres_Instanzenverzeichnis_ergibt_ein_leeres_Register()
|
||||
@@ -80,8 +80,8 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Manager, der beim Hochkommen alles startet, was er findet, würde nach einem
|
||||
/// Neustart der Maschine auch das starten, was jemand mit Absicht angehalten hat.
|
||||
/// A manager that starts everything it finds when coming up would, after a
|
||||
/// restart of the machine, also start what someone stopped on purpose.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Beim_Nachsehen_wird_nichts_von_selbst_gestartet()
|
||||
@@ -110,7 +110,7 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
|
||||
register.Instances.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ─── Anlegen, starten, anhalten ───
|
||||
// ─── Create, start, stop ───
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_neu_angelegte_Instanz_ist_sofort_im_Register_und_laeuft_nicht()
|
||||
@@ -138,7 +138,7 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
|
||||
(await register.StartAsync(pfad)).ShouldBeTrue();
|
||||
register.Instances.Single().Condition.ShouldBe(InstanceCondition.Running);
|
||||
|
||||
// Die Instanz endet auf die Bitte hin.
|
||||
// The instance ends upon the request.
|
||||
var prozess = _launcher.Last!;
|
||||
_kanal.OnStopRequest = _ => { _kanal.Answer = null; prozess.Exit(0); };
|
||||
|
||||
@@ -162,15 +162,15 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
|
||||
{
|
||||
string pfad;
|
||||
|
||||
// Erster Manager legt zwei an und startet nur eine.
|
||||
// The first manager creates two and starts only one.
|
||||
await using (var erster = Register())
|
||||
{
|
||||
pfad = erster.Create("Alpha");
|
||||
erster.Create("Beta");
|
||||
|
||||
// Nur Alpha antwortet. Eine Auskunft, die fuer jedes Verzeichnis dieselbe
|
||||
// Instanz nennt, wuerde hier gruen werden und im Betrieb zwei Instanzen
|
||||
// verwechseln.
|
||||
// Only Alpha answers. Information that names the same instance for every
|
||||
// directory would turn green here and in operation mix up two
|
||||
// instances.
|
||||
_kanal.AnswerFor[Path.GetFullPath(pfad)] =
|
||||
new InstanceIdentity("i-1", "Alpha", "0.1.2");
|
||||
|
||||
@@ -179,7 +179,7 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
|
||||
|
||||
_ereignisse.Clear();
|
||||
|
||||
// Zweiter Manager, derselbe Rechner.
|
||||
// Second manager, the same machine.
|
||||
await using var zweiter = Register();
|
||||
await zweiter.RefreshAsync();
|
||||
|
||||
@@ -187,17 +187,17 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
|
||||
alpha.Condition.ShouldBe(InstanceCondition.Running);
|
||||
alpha.ProcessId.ShouldBe(_launcher.Last!.Id);
|
||||
|
||||
// Beta antwortet nicht und bleibt aus - sie wird nicht mit hochgezogen.
|
||||
// Beta does not answer and stays off - it is not pulled up along with it.
|
||||
zweiter.Instances.Single(i => i.Name == "Beta").Condition
|
||||
.ShouldBe(InstanceCondition.Stopped);
|
||||
|
||||
// Uebernehmen ist kein Starten.
|
||||
// Taking over is not starting.
|
||||
_launcher.Starts.ShouldBe(1);
|
||||
_ereignisse.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wer den Manager schließt, will den Manager schließen — nicht seine Instanzen.
|
||||
/// Whoever closes the manager wants to close the manager — not its instances.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Das_Beenden_des_Managers_beendet_keine_Instanz()
|
||||
@@ -214,12 +214,12 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
|
||||
_launcher.Last!.WasKilled.ShouldBeFalse();
|
||||
_launcher.Last.HasExited.ShouldBeFalse();
|
||||
|
||||
// Und die Zustandsdatei bleibt liegen - sie ist der Hinweis fuer den naechsten.
|
||||
// And the state file stays behind - it is the hint for the next one.
|
||||
InstanceStateStore.Read(pfad).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die eine Ausnahme: Ein Lizenzwiderruf ist kein Ausfall, sondern ein Urteil.
|
||||
/// The one exception: a license revocation is not an outage, but a verdict.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Bei_einem_Grund_der_alle_betrifft_werden_alle_angehalten()
|
||||
@@ -239,7 +239,7 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
|
||||
_kanal.StopRequests.ShouldBe(1);
|
||||
}
|
||||
|
||||
// ─── Vorkommnisse ───
|
||||
// ─── Occurrences ───
|
||||
|
||||
[Fact]
|
||||
public async Task Vorkommnisse_einzelner_Instanzen_kommen_beim_Register_an()
|
||||
|
||||
@@ -5,17 +5,17 @@ using Shouldly;
|
||||
namespace ClawdDotNet.Manager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Die Aufsicht über eine Instanz (#528, Stufe 3).
|
||||
/// The supervision of an instance (#528, stage 3).
|
||||
///
|
||||
/// <para><b>Der Anlass.</b> Am 2026-09-08 hat sich eine Instanz aufgehängt und wurde von
|
||||
/// Windows beendet. Zu diesem Vorgang steht keine einzige Zeile in den Instanzprotokollen,
|
||||
/// kein Bugtracker-Eintrag, kein Watchdog-Alarm — weil Meldeweg und Watchdog in genau dem
|
||||
/// Prozess lebten, über den sie hätten berichten sollen (#529). Die Abnahmebedingung für
|
||||
/// diese Stufe lautet deshalb: <b>eine Instanz abschießen, und der Manager merkt es und
|
||||
/// meldet es.</b></para>
|
||||
/// <para><b>The occasion.</b> On 2026-09-08 an instance hung and was ended by
|
||||
/// Windows. Not a single line about this event is in the instance logs,
|
||||
/// no bug tracker entry, no watchdog alarm — because reporting path and watchdog lived in exactly the
|
||||
/// process they should have reported on (#529). The acceptance condition for
|
||||
/// this stage is therefore: <b>kill an instance, and the manager notices it and
|
||||
/// reports it.</b></para>
|
||||
///
|
||||
/// <para>Der schwierigere Fall steht darunter: ein Prozess, der <em>lebt</em> und nicht
|
||||
/// mehr arbeitet. Den sieht keine Prozessliste — nur der Socket.</para>
|
||||
/// <para>The harder case stands below: a process that is <em>alive</em> and no longer
|
||||
/// works. No process list sees it — only the socket.</para>
|
||||
/// </summary>
|
||||
public sealed class InstanceSupervisorTests : IDisposable
|
||||
{
|
||||
@@ -29,11 +29,11 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
private static readonly InstanceIdentity Test = new("i-1", "Test", "0.1.2");
|
||||
|
||||
/// <summary>
|
||||
/// Kurze Fristen: Der Test soll die Aufsicht prüfen, nicht die Geduld.
|
||||
/// Short deadlines: the test is supposed to check supervision, not patience.
|
||||
///
|
||||
/// <para>Knapp über dem Abfragetakt von 250 ms, damit die Warteschleifen je einen
|
||||
/// Durchgang schaffen — es geht um die Entscheidung am Ende der Frist, nicht darum,
|
||||
/// wie oft dazwischen gefragt wurde.</para>
|
||||
/// <para>Just above the probe interval of 250 ms, so the waiting loops manage one
|
||||
/// pass each — this is about the decision at the end of the deadline, not about
|
||||
/// how often there was asking in between.</para>
|
||||
/// </summary>
|
||||
private static readonly SupervisionOptions Options = new(
|
||||
ProbeInterval: TimeSpan.FromMilliseconds(50),
|
||||
@@ -47,16 +47,16 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
{
|
||||
Directory.CreateDirectory(_dir);
|
||||
|
||||
// Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im
|
||||
// Betrieb. Eine vorab hinterlegte Antwort hiesse sonst "laeuft schon", und die
|
||||
// Aufsicht wuerde uebernehmen statt zu starten.
|
||||
// The socket only answers if a process is really running - exactly as in
|
||||
// operation. An answer stored in advance would otherwise mean "already running", and
|
||||
// supervision would take over instead of starting.
|
||||
_kanal.Launcher = _launcher;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); }
|
||||
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
||||
catch { /* A leftover test folder does not turn a run red. */ }
|
||||
}
|
||||
|
||||
private InstanceSupervisor Aufsicht(SupervisionOptions? options = null)
|
||||
@@ -70,7 +70,7 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
|
||||
private IEnumerable<InstanceEventKind> Arten => _ereignisse.Select(e => e.Kind);
|
||||
|
||||
// ─── Starten ───
|
||||
// ─── Starting ───
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Start_gilt_erst_wenn_die_Instanz_antwortet()
|
||||
@@ -91,16 +91,16 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// „Der Prozess läuft" ist nicht dasselbe wie „die Instanz läuft".
|
||||
/// "The process is running" is not the same as "the instance is running".
|
||||
///
|
||||
/// <para>Zwischen beidem liegen Datenbank, Agenten und Kestrel. Wer nur auf
|
||||
/// <c>Process.Start</c> wartet, hält eine Instanz für gestartet, die gleich an einer
|
||||
/// kaputten Konfiguration scheitert.</para>
|
||||
/// <para>Between the two lie database, agents and Kestrel. Whoever only waits for
|
||||
/// <c>Process.Start</c> considers an instance started that is about to fail on a
|
||||
/// broken configuration.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Ein_Prozess_der_sich_nie_meldet_gilt_nicht_als_gestartet()
|
||||
{
|
||||
_kanal.Answer = null; // laeuft, sagt aber nichts
|
||||
_kanal.Answer = null; // runs, but says nothing
|
||||
|
||||
await using var aufsicht = Aufsicht();
|
||||
(await aufsicht.StartAsync()).ShouldBeFalse();
|
||||
@@ -108,8 +108,8 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
|
||||
Arten.ShouldBe([InstanceEventKind.StartFailed]);
|
||||
|
||||
// Nicht stehen lassen: Ein Prozess, der laeuft und schweigt, haelt den Socket
|
||||
// besetzt und blockiert damit den naechsten Versuch.
|
||||
// Do not leave it standing: a process that runs and stays silent keeps the socket
|
||||
// occupied and thus blocks the next attempt.
|
||||
_launcher.Last!.WasKilled.ShouldBeTrue();
|
||||
InstanceStateStore.Read(_dir).ShouldBeNull();
|
||||
}
|
||||
@@ -126,7 +126,7 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
_ereignisse[0].Message.ShouldContain("Programmdatei fehlt");
|
||||
}
|
||||
|
||||
// ─── Der Fall, um den es geht ───
|
||||
// ─── The case this is about ───
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_abgeschossener_Prozess_wird_bemerkt_und_gemeldet()
|
||||
@@ -136,7 +136,7 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
await using var aufsicht = Aufsicht();
|
||||
await aufsicht.StartAsync();
|
||||
|
||||
// Jemand schiesst die Instanz ab. Niemand hat darum gebeten.
|
||||
// Someone kills the instance. Nobody asked for it.
|
||||
_launcher.Last!.Exit(code: 137);
|
||||
_kanal.Answer = null;
|
||||
|
||||
@@ -149,16 +149,16 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
absturz.InstanceName.ShouldBe("Test");
|
||||
absturz.WorthReporting.ShouldBeTrue("Das ist die Meldung, die es 2026-09-08 nicht gab.");
|
||||
|
||||
// Und die Zustandsdatei zeigt nicht mehr auf einen Prozess, den es nicht gibt.
|
||||
// And the state file no longer points to a process that does not exist.
|
||||
InstanceStateStore.Read(_dir).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der schwierigere Fall: Der Prozess lebt und arbeitet nicht mehr.
|
||||
/// The harder case: the process lives and no longer works.
|
||||
///
|
||||
/// <para>Genau das war der Zustand am 2026-09-08 — die Anwendung stand einwandfrei in
|
||||
/// der Prozessliste, bis Windows sie als „reagiert nicht mehr" beendet hat. Eine
|
||||
/// Aufsicht, die nur Prozesse zählt, sieht davon nichts.</para>
|
||||
/// <para>Exactly that was the state on 2026-09-08 — the application stood flawlessly in
|
||||
/// the process list until Windows ended it as "not responding". A
|
||||
/// supervision that only counts processes sees nothing of it.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Ein_lebender_aber_stummer_Prozess_faellt_auf()
|
||||
@@ -168,10 +168,10 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
await using var aufsicht = Aufsicht();
|
||||
await aufsicht.StartAsync();
|
||||
|
||||
_kanal.Answer = null; // haengt - der Prozess lebt weiter
|
||||
_kanal.Answer = null; // hangs - the process lives on
|
||||
|
||||
// Nicht beim ersten Aussetzer: Der waere Alltag, und ein Alarm bei jedem
|
||||
// Aussetzer ist nach einer Woche der Alarm, den niemand mehr liest.
|
||||
// Not at the first hiccup: that would be everyday life, and an alarm at every
|
||||
// hiccup is after a week the alarm nobody reads any more.
|
||||
await aufsicht.CheckAsync();
|
||||
await aufsicht.CheckAsync();
|
||||
Arten.ShouldNotContain(InstanceEventKind.Unresponsive);
|
||||
@@ -220,7 +220,7 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
Arten.ShouldContain(InstanceEventKind.Recovered);
|
||||
}
|
||||
|
||||
// ─── Anhalten ───
|
||||
// ─── Stopping ───
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_erbetenes_Ende_ist_kein_Absturz()
|
||||
@@ -230,7 +230,7 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
await using var aufsicht = Aufsicht();
|
||||
await aufsicht.StartAsync();
|
||||
|
||||
// Die Instanz endet auf die Bitte hin - so wie im Betrieb.
|
||||
// The instance ends upon the request - as in operation.
|
||||
_launcher.Last!.Exit(code: 0);
|
||||
_kanal.Answer = null;
|
||||
|
||||
@@ -253,7 +253,7 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
await using var aufsicht = Aufsicht();
|
||||
await aufsicht.StartAsync();
|
||||
|
||||
// Antwortet weiter und endet nicht - die Bitte verhallt.
|
||||
// Keeps answering and does not end - the request goes unheard.
|
||||
_kanal.StopRequestArrives = false;
|
||||
|
||||
(await aufsicht.StopAsync()).ShouldBeTrue();
|
||||
@@ -264,14 +264,14 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schweigen ist kein Todesnachweis, solange ein Prozesshandle das Gegenteil sagt.
|
||||
/// Silence is no proof of death as long as a process handle says the opposite.
|
||||
///
|
||||
/// <para><b>Hier lag der gefährlichste Fehlschluss.</b> Die Warteschleife brach ab,
|
||||
/// sobald der Socket nicht mehr antwortete, meldete „beendet" und gab <c>true</c>
|
||||
/// zurück — ohne den Prozess anzufassen. Bei einer verstummten Instanz ist der Socket
|
||||
/// aber von vornherein still: Der Aufruf meldete sofort Erfolg, der Prozess lief
|
||||
/// weiter, und der Neustart setzte einen zweiten auf dieselbe <c>state.db</c>. Genau
|
||||
/// der Schaden, gegen den dieses Vorhaben gebaut ist.</para>
|
||||
/// <para><b>Here lay the most dangerous fallacy.</b> The waiting loop broke off
|
||||
/// as soon as the socket no longer answered, reported "ended" and returned <c>true</c>
|
||||
/// — without touching the process. With a silent instance, however, the socket
|
||||
/// is quiet from the start: the call reported success immediately, the process kept
|
||||
/// running, and the restart put a second one on the same <c>state.db</c>. Exactly
|
||||
/// the damage this project is built against.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Ein_stummer_aber_lebender_Prozess_gilt_nicht_als_beendet()
|
||||
@@ -283,7 +283,7 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
|
||||
var prozess = _launcher.Last.ShouldNotBeNull();
|
||||
|
||||
// Sie haengt: Der Socket schweigt, der Prozess lebt.
|
||||
// It hangs: the socket is silent, the process lives.
|
||||
_kanal.Verstummen(_dir);
|
||||
|
||||
(await aufsicht.StopAsync()).ShouldBeTrue();
|
||||
@@ -296,28 +296,28 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wer aufräumt, wird nicht abgeschossen — die Frist wird nachgezogen.
|
||||
/// Whoever is cleaning up is not killed — the deadline is extended.
|
||||
///
|
||||
/// <para>Die Instanz gibt sich selbst eine Frist zum Auslaufen. Wäre die des Managers
|
||||
/// kürzer, schösse er mitten in das Aufräumen hinein, um das er gerade gebeten
|
||||
/// hat.</para>
|
||||
/// <para>The instance gives itself a deadline for running out. If the manager's were
|
||||
/// shorter, it would shoot into the middle of the cleanup it has just asked
|
||||
/// for.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Wer_aufraeumt_bekommt_die_Frist_nachgezogen()
|
||||
{
|
||||
_kanal.Answer = Test;
|
||||
|
||||
// #722: Die Aufsicht fragt alle 250 ms und schiebt die Frist nur bei "raeume auf" weiter.
|
||||
// Mit 300 ms Frist kam unter Parallellast die naechste Frage zu spaet, und der Test sah
|
||||
// einen Abschuss, wo keiner hingehoert. Eine Sekunde Frist laesst dem Takt Luft; die
|
||||
// Instanz raeumt trotzdem deutlich laenger auf, als die Frist allein erlaubt.
|
||||
// #722: Supervision asks every 250 ms and only extends the deadline on "cleaning up".
|
||||
// With a 300 ms deadline the next question came too late under parallel load, and the test saw
|
||||
// a kill where none belongs. A one-second deadline leaves the tick room; the
|
||||
// instance still cleans up considerably longer than the deadline alone allows.
|
||||
await using var aufsicht = Aufsicht(Options with { StopGrace = TimeSpan.FromSeconds(1), MaxDrainWait = TimeSpan.FromSeconds(20) });
|
||||
await aufsicht.StartAsync();
|
||||
|
||||
var prozess = _launcher.Last.ShouldNotBeNull();
|
||||
|
||||
// Sie antwortet und meldet: Ich raeume auf. Laenger als StopGrace (1 s), aber
|
||||
// kuerzer als MaxDrainWait.
|
||||
// It answers and reports: I am cleaning up. Longer than StopGrace (1 s), but
|
||||
// shorter than MaxDrainWait.
|
||||
_kanal.Answer = Test with { Phase = ShutdownPhase.Draining, OpenWork = 1 };
|
||||
|
||||
_kanal.OnStopRequest = _ => Task.Run(async () =>
|
||||
@@ -334,11 +334,11 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine Instanz, die der Manager nicht selbst gestartet hat und die nicht hört, kann
|
||||
/// er auch nicht beenden — und das muss er sagen, statt Erfolg zu melden.
|
||||
/// An instance that the manager did not start itself and that does not listen, it
|
||||
/// cannot end either — and it has to say so instead of reporting success.
|
||||
///
|
||||
/// <para>Der Fall entsteht nach einem Managerneustart, wenn die Prozesskennung nicht
|
||||
/// mehr zu greifen ist (siehe <see cref="Eine_wiederverwendete_Prozesskennung_wird_nicht_fuer_die_Instanz_gehalten"/>).</para>
|
||||
/// <para>The case arises after a manager restart when the process ID can no longer
|
||||
/// be grabbed (see <see cref="Eine_wiederverwendete_Prozesskennung_wird_nicht_fuer_die_Instanz_gehalten"/>).</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Was_nicht_zu_fassen_ist_wird_nicht_als_beendet_gemeldet()
|
||||
@@ -346,12 +346,12 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
_kanal.Answer = Test;
|
||||
_kanal.StopRequestArrives = false;
|
||||
|
||||
// Sie lief schon, bevor dieser Manager kam - und ohne Zustandsdatei ist ihr
|
||||
// Prozess fuer ihn nicht zu greifen.
|
||||
// It was already running before this manager came - and without a state file its
|
||||
// process cannot be grabbed by it.
|
||||
_launcher.Fremdstart(_dir, 4711);
|
||||
|
||||
await using var aufsicht = Aufsicht();
|
||||
await aufsicht.ReattachAsync(); // uebernommen, aber ohne Prozess
|
||||
await aufsicht.ReattachAsync(); // taken over, but without a process
|
||||
aufsicht.ProcessId.ShouldBeNull();
|
||||
|
||||
(await aufsicht.StopAsync()).ShouldBeFalse();
|
||||
@@ -360,20 +360,20 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
Arten.ShouldNotContain(InstanceEventKind.StoppedOrderly);
|
||||
}
|
||||
|
||||
// ─── Wiederfinden (E-M6) ───
|
||||
// ─── Rediscovering (E-M6) ───
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_laufende_Instanz_wird_nach_einem_Managerneustart_uebernommen()
|
||||
{
|
||||
_kanal.Answer = Test;
|
||||
|
||||
// Erster Manager startet sie.
|
||||
// The first manager starts it.
|
||||
await using (var erster = Aufsicht())
|
||||
await erster.StartAsync();
|
||||
|
||||
_ereignisse.Clear();
|
||||
|
||||
// Zweiter Manager, derselbe Rechner, dieselbe laufende Instanz.
|
||||
// Second manager, the same machine, the same running instance.
|
||||
await using var zweiter = Aufsicht();
|
||||
(await zweiter.ReattachAsync()).ShouldBeTrue();
|
||||
|
||||
@@ -381,18 +381,18 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
zweiter.InstanceName.ShouldBe("Test");
|
||||
zweiter.ProcessId.ShouldBe(_launcher.Last!.Id);
|
||||
|
||||
// Uebernehmen ist kein Starten - es gibt nichts zu melden.
|
||||
// Taking over is not starting - there is nothing to report.
|
||||
_ereignisse.ShouldBeEmpty();
|
||||
_launcher.Starts.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine wiederverwendete Prozesskennung darf nicht zur Übernahme eines fremden
|
||||
/// Programms führen.
|
||||
/// A reused process ID must not lead to taking over a foreign
|
||||
/// program.
|
||||
///
|
||||
/// <para>Nach einem Neustart der Maschine gehört 4711 mit einiger Wahrscheinlichkeit
|
||||
/// etwas ganz anderem. Es dann zu beaufsichtigen — und beim Anhalten zu beenden —
|
||||
/// wäre deutlich schlimmer, als die Instanz gar nicht wiederzufinden.</para>
|
||||
/// <para>After a restart of the machine, 4711 belongs with some probability
|
||||
/// to something entirely different. Supervising it then — and ending it when stopping —
|
||||
/// would be considerably worse than not finding the instance at all.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Eine_wiederverwendete_Prozesskennung_wird_nicht_fuer_die_Instanz_gehalten()
|
||||
@@ -404,7 +404,7 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
|
||||
var alte = _launcher.Last!;
|
||||
|
||||
// Dieselbe Kennung, viel spaeter gestartet: ein anderer Prozess.
|
||||
// The same ID, started much later: a different process.
|
||||
_launcher.Running[alte.Id] = new FakeProcess(alte.Id, alte.StartedAt.AddHours(3));
|
||||
|
||||
await using var zweiter = Aufsicht();
|
||||
@@ -424,7 +424,7 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
|
||||
InstanceStateStore.Read(_dir).ShouldNotBeNull();
|
||||
|
||||
// Der Manager war weg, die Instanz ist inzwischen auch weg.
|
||||
// The manager was gone, the instance is gone by now too.
|
||||
_kanal.Answer = null;
|
||||
|
||||
await using var zweiter = Aufsicht();
|
||||
@@ -438,8 +438,8 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
[Fact]
|
||||
public async Task Ohne_Zustandsdatei_wird_eine_antwortende_Instanz_trotzdem_uebernommen()
|
||||
{
|
||||
// Der Fall nach einem Absturz des Managers, bevor er schreiben konnte - oder
|
||||
// nachdem jemand die Datei geloescht hat. Der Socket antwortet, also laeuft sie.
|
||||
// The case after a crash of the manager before it could write - or
|
||||
// after someone deleted the file. The socket answers, so it is running.
|
||||
_kanal.Answer = Test;
|
||||
_launcher.Fremdstart(_dir, 4711);
|
||||
|
||||
@@ -451,12 +451,12 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
InstanceStateStore.Read(_dir).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// ─── Was gemeldet wird und was nicht ───
|
||||
// ─── What is reported and what is not ───
|
||||
|
||||
/// <summary>
|
||||
/// Der Manager meldet, was aus seiner Sicht relevant ist — und nur das (Konzept,
|
||||
/// Abschnitt 4). Was die Instanz betrifft, bleibt in der Instanz und geht mit ihrem
|
||||
/// eigenen Sub-Token raus.
|
||||
/// The manager reports what is relevant from its point of view — and only that (concept,
|
||||
/// section 4). What concerns the instance stays in the instance and goes out with its
|
||||
/// own sub-token.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(InstanceEventKind.Crashed, true)]
|
||||
|
||||
@@ -8,8 +8,8 @@ using Shouldly;
|
||||
namespace ClawdDotNet.Manager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Die Sicherungen im Manager (#530): verschlüsselt mit dem Lizenzschlüssel, einmal am
|
||||
/// Tag, aufgeräumt, auf Wunsch hochgeladen.
|
||||
/// The backups in the manager (#530): encrypted with the license key, once a
|
||||
/// day, cleaned up, uploaded on request.
|
||||
/// </summary>
|
||||
public sealed class ManagerBackupsTests : IAsyncDisposable
|
||||
{
|
||||
@@ -36,7 +36,7 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
|
||||
await Task.CompletedTask;
|
||||
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
|
||||
try { Directory.Delete(_root, recursive: true); }
|
||||
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
||||
catch { /* A leftover test folder does not turn a run red. */ }
|
||||
}
|
||||
|
||||
private string Instanz(string name, bool automatisch = true, string uhrzeit = "03:00", int behalten = 14)
|
||||
@@ -71,11 +71,11 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
|
||||
return Directory.Exists(ordner) ? Directory.GetFiles(ordner) : [];
|
||||
}
|
||||
|
||||
// ─── Namen ───
|
||||
// ─── Names ───
|
||||
|
||||
/// <summary>
|
||||
/// Ein Präfix ist keine Grenze: Die Archive von <c>Test_2</c> gehören nicht zu
|
||||
/// <c>Test</c> — sonst wirft das Aufräumen der einen die Sicherungen der anderen weg.
|
||||
/// A prefix is not a boundary: the archives of <c>Test_2</c> do not belong to
|
||||
/// <c>Test</c> — otherwise the cleanup of one throws away the backups of the other.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Ineinanderliegende_Instanznamen_werden_nicht_verwechselt()
|
||||
@@ -89,8 +89,8 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("backup_Alpha_2026-09-11_0300.zip")] // taegliche Sicherung der Instanz
|
||||
[InlineData("backup_Alpha_20260911_030000.zip")] // erste Verwaltungsansicht
|
||||
[InlineData("backup_Alpha_2026-09-11_0300.zip")] // daily backup of the instance
|
||||
[InlineData("backup_Alpha_20260911_030000.zip")] // first administration view
|
||||
[InlineData("backup_Alpha_2026-09-11_030000.clawdbak")] // Manager
|
||||
public void Aeltere_Schreibweisen_werden_erkannt(string name)
|
||||
{
|
||||
@@ -106,7 +106,7 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
|
||||
BackupNaming.BelongsTo(name, "Alpha").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ─── Sichern ───
|
||||
// ─── Backing up ───
|
||||
|
||||
[Fact]
|
||||
public async Task Ohne_Lizenzschluessel_wird_keine_Sicherung_angelegt()
|
||||
@@ -141,8 +141,8 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein gescheiterter Upload ist ein Befund, aber kein Grund, die Sicherung für
|
||||
/// misslungen zu erklären — sie liegt ja lokal.
|
||||
/// A failed upload is a finding, but no reason to declare the backup
|
||||
/// failed — it lies locally, after all.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Ein_gescheiterter_Upload_laesst_die_Sicherung_stehen()
|
||||
@@ -183,7 +183,7 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
|
||||
namen.ShouldContain(BackupNaming.FileNameFor("Test", new DateTime(2026, 9, 5, 3, 0, 0)));
|
||||
}
|
||||
|
||||
// ─── Der tägliche Takt ───
|
||||
// ─── The daily timer ───
|
||||
|
||||
[Fact]
|
||||
public async Task Vor_der_Uhrzeit_passiert_nichts()
|
||||
@@ -213,8 +213,8 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auch über einen Neustart des Managers hinweg. Nur im Speicher zu zählen hieße: Wer
|
||||
/// nach der Sicherungszeit neu startet, bekommt eine zweite Sicherung.
|
||||
/// Even across a restart of the manager. Counting only in memory would mean: whoever
|
||||
/// restarts after the backup time gets a second backup.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Ein_Neustart_des_Managers_erzeugt_keine_zweite_Sicherung()
|
||||
@@ -245,7 +245,7 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
|
||||
Sicherungen(pfad).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ─── Hilfen ───
|
||||
// ─── Helpers ───
|
||||
|
||||
private async Task<IReadOnlyList<ManagedInstance>> RegisterAsync()
|
||||
{
|
||||
@@ -273,7 +273,7 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
|
||||
public Task<string?> TestAsync(CancellationToken ct) => Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
/// <summary>Eine Uhr in UTC, damit „lokal" im Test überall dasselbe heißt.</summary>
|
||||
/// <summary>A clock in UTC, so "local" means the same everywhere in the test.</summary>
|
||||
private sealed class FesteUhr(DateTimeOffset start) : TimeProvider
|
||||
{
|
||||
public DateTimeOffset Jetzt { get; set; } = start;
|
||||
|
||||
@@ -4,7 +4,7 @@ using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Manager.Tests;
|
||||
|
||||
/// <summary>Zählt mit, wem ein Token besorgt wurde.</summary>
|
||||
/// <summary>Counts for whom a token was obtained.</summary>
|
||||
internal sealed class FakeTokenSource : IInstanceTokenSource
|
||||
{
|
||||
public List<string> Fuer { get; } = [];
|
||||
@@ -19,12 +19,12 @@ internal sealed class FakeTokenSource : IInstanceTokenSource
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der Instanzmanager als Ganzes (#528, Stufe 3).
|
||||
/// The instance manager as a whole (#528, stage 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
|
||||
/// <para><b>The license is checked here without a license server</b>, and that is intentional: the
|
||||
/// promise is not "the gatekeeper works" — that is the SDK's job —,
|
||||
/// but "a verdict leads to stopping all instances, an outage does not". Exactly
|
||||
/// this path is taken here, triggered via
|
||||
/// <see cref="ManagerCore.HandleLicenseRevokedAsync"/>.</para>
|
||||
/// </summary>
|
||||
public sealed class ManagerCoreTests : IAsyncDisposable
|
||||
@@ -49,8 +49,8 @@ public sealed class ManagerCoreTests : IAsyncDisposable
|
||||
{
|
||||
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).
|
||||
// The socket only answers if a process is really running - exactly as in
|
||||
// operation (see FakeChannel.Launcher).
|
||||
_kanal.Launcher = _launcher;
|
||||
}
|
||||
|
||||
@@ -67,18 +67,17 @@ public sealed class ManagerCoreTests : IAsyncDisposable
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
try { Directory.Delete(_root, recursive: true); }
|
||||
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
||||
catch { /* A leftover test folder does not turn a run red. */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lässt die Instanz ab jetzt antworten — und prüft, dass sie danach wirklich
|
||||
/// gestartet und nicht bloß übernommen wurde.
|
||||
/// Lets the instance answer from now on — and checks that it was really
|
||||
/// started afterwards and not merely taken over.
|
||||
///
|
||||
/// <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>
|
||||
/// <para><b>The difference is not a subtlety.</b> 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. Exactly that happened to me here twice.</para>
|
||||
/// </summary>
|
||||
private async Task<StartRefusal> StarteUndPruefe(ManagerCore manager, string pfad, string name)
|
||||
{
|
||||
@@ -92,13 +91,13 @@ public sealed class ManagerCoreTests : IAsyncDisposable
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
// ─── Hochfahren ───
|
||||
// ─── Starting up ───
|
||||
|
||||
[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.
|
||||
// No gatekeeper means no lock - otherwise there would be a chicken-and-egg lock
|
||||
// before anyone can even enter a key.
|
||||
await using var manager = Manager();
|
||||
|
||||
(await manager.StartAsync()).ShouldBeTrue();
|
||||
@@ -122,8 +121,8 @@ public sealed class ManagerCoreTests : IAsyncDisposable
|
||||
[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.
|
||||
// At creation and not at the first start: then the instance file belongs
|
||||
// to nobody without doubt.
|
||||
await using var manager = Manager();
|
||||
|
||||
var pfad = await manager.CreateInstanceAsync("Frisch");
|
||||
@@ -134,8 +133,8 @@ public sealed class ManagerCoreTests : IAsyncDisposable
|
||||
[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.
|
||||
// The case of an instance that existed before the manager - or for which the
|
||||
// Deploymentcenter was not reachable at creation.
|
||||
var pfad = _verzeichnisse.CreateInstance("Alt");
|
||||
|
||||
await using var manager = Manager();
|
||||
@@ -149,8 +148,8 @@ public sealed class ManagerCoreTests : IAsyncDisposable
|
||||
[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.
|
||||
// Monitoring that only runs with perfect permissions is silent exactly
|
||||
// when you need it.
|
||||
_tokens.Erfolg = false;
|
||||
|
||||
var pfad = _verzeichnisse.CreateInstance("Alt");
|
||||
@@ -161,14 +160,14 @@ public sealed class ManagerCoreTests : IAsyncDisposable
|
||||
(await StarteUndPruefe(manager, pfad, "Alt")).ShouldBe(StartRefusal.None);
|
||||
}
|
||||
|
||||
// ─── Das Urteil ───
|
||||
// ─── The verdict ───
|
||||
|
||||
/// <summary>
|
||||
/// Der einzige Fall, in dem der Manager von sich aus Instanzen anhält.
|
||||
/// The only case in which the manager stops instances on its own initiative.
|
||||
///
|
||||
/// <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>
|
||||
/// <para>A server outage explicitly does not belong here — the SDK separates verdict and
|
||||
/// failed attempt, and only the verdict arrives here. Otherwise a
|
||||
/// network problem would take all installations down at the same time.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Ein_Lizenzwiderruf_haelt_alle_laufenden_Instanzen_an()
|
||||
@@ -181,7 +180,7 @@ public sealed class ManagerCoreTests : IAsyncDisposable
|
||||
|
||||
manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Running);
|
||||
|
||||
// Die Instanz endet auf die Bitte hin.
|
||||
// The instance ends upon the request.
|
||||
var prozess = _launcher.Last!;
|
||||
_kanal.OnStopRequest = _ => { _kanal.AnswerFor[Path.GetFullPath(pfad)] = null; prozess.Exit(0); };
|
||||
|
||||
@@ -208,7 +207,7 @@ public sealed class ManagerCoreTests : IAsyncDisposable
|
||||
_tokens.Fuer.ShouldBeEmpty("Ohne Lizenz wird auch kein Token verbraucht.");
|
||||
}
|
||||
|
||||
// ─── Wenn der Start scheitert ───
|
||||
// ─── When the start fails ───
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_Instanz_die_sich_nicht_meldet_gilt_als_nicht_gestartet()
|
||||
@@ -218,7 +217,7 @@ public sealed class ManagerCoreTests : IAsyncDisposable
|
||||
await using var manager = Manager();
|
||||
await manager.StartAsync();
|
||||
|
||||
// Der Prozess laeuft, die Instanz meldet sich nie.
|
||||
// The process runs, the instance never reports in.
|
||||
(await manager.StartInstanceAsync(pfad)).ShouldBe(StartRefusal.DidNotStart);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ using Shouldly;
|
||||
namespace ClawdDotNet.Manager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>manager.json</c> und der Protokollauszug — die beiden Stellen, an denen der Manager
|
||||
/// mit dem Dateisystem spricht (#528, Stufe 3).
|
||||
/// <c>manager.json</c> and the log excerpt — the two places where the manager
|
||||
/// talks to the file system (#528, stage 3).
|
||||
/// </summary>
|
||||
public sealed class ManagerSettingsTests : IDisposable
|
||||
{
|
||||
@@ -17,7 +17,7 @@ public sealed class ManagerSettingsTests : IDisposable
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); }
|
||||
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
||||
catch { /* A leftover test folder does not turn a run red. */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -32,8 +32,8 @@ public sealed class ManagerSettingsTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Master-Token und Lizenzschlüssel liegen in der Datei nicht im Klartext — zur
|
||||
/// Laufzeit aber schon, sonst könnte niemand damit arbeiten.
|
||||
/// Master token and license key are not in plain text in the file — but they are at
|
||||
/// runtime, otherwise nobody could work with them.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Geheimnisse_stehen_nicht_im_Klartext_in_der_Datei()
|
||||
@@ -54,19 +54,19 @@ public sealed class ManagerSettingsTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine von Hand oder von PowerShell geschriebene Datei hat oft ein Byte-Order-Mark,
|
||||
/// und sie darf trotzdem gelten.
|
||||
/// A file written by hand or by PowerShell often has a byte order mark,
|
||||
/// and it must still apply.
|
||||
///
|
||||
/// <para>Beim Abnahmelauf sah es aus, als würde sie ignoriert — der Manager arbeitete
|
||||
/// mit Vorgaben. Dieser Test klärt, ob das an der Datei lag.</para>
|
||||
/// <para>In the acceptance run it looked as if it were ignored — the manager worked
|
||||
/// with defaults. This test clarifies whether that was due to the file.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Eine_Datei_mit_Byte_Order_Mark_gilt_auch()
|
||||
{
|
||||
var pfad = Path.Combine(_dir, "manager.json");
|
||||
|
||||
// Genau so, wie PowerShell es schreibt: mit BOM, mit doppelten Leerzeichen nach
|
||||
// dem Doppelpunkt, und mit verdoppelten Backslashes im Pfad.
|
||||
// Exactly as PowerShell writes it: with BOM, with double spaces after
|
||||
// the colon, and with doubled backslashes in the path.
|
||||
var inhalt = """
|
||||
{
|
||||
"instanceExecutable": "J:\\Programme\\ClawdDotNet.exe",
|
||||
@@ -88,8 +88,8 @@ public sealed class ManagerSettingsTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine unlesbare Datei darf den Manager nicht am Start hindern — dann liefe gar
|
||||
/// keine Instanz mehr, und das ist der schlechtere Zustand.
|
||||
/// An unreadable file must not prevent the manager from starting — then
|
||||
/// no instance would run at all, and that is the worse state.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Eine_kaputte_Datei_verhindert_den_Start_nicht()
|
||||
@@ -100,15 +100,15 @@ public sealed class ManagerSettingsTests : IDisposable
|
||||
|
||||
store.Load().WebUiPort.ShouldBe(7000);
|
||||
|
||||
// Und der Grund steht fest - sonst arbeitet der Manager stillschweigend mit
|
||||
// Vorgaben, und niemand kann sagen, warum sein Instanzverzeichnis falsch ist.
|
||||
// And the reason is recorded - otherwise the manager silently works with
|
||||
// defaults, and nobody can say why its instance directory is wrong.
|
||||
store.LoadProblem.ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unsinnige Zahlen können in einer von Hand bearbeitbaren Datei stehen. Ein
|
||||
/// Abfragetakt von 0 wäre eine Endlosschleife, ein Alarm nach null Fehlversuchen ein
|
||||
/// Alarm bei jedem Aussetzer.
|
||||
/// Nonsensical numbers can be in a hand-editable file. A
|
||||
/// probe interval of 0 would be an endless loop, an alarm after zero failed attempts an
|
||||
/// alarm at every hiccup.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Unsinnige_Aufsichtswerte_werden_eingefangen()
|
||||
@@ -130,7 +130,7 @@ public sealed class ManagerSettingsTests : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Der Protokollauszug, der einer Absturzmeldung erst ihren Wert gibt.</summary>
|
||||
/// <summary>The log excerpt that only gives a crash report its value.</summary>
|
||||
public sealed class InstanceLogExcerptTests : IDisposable
|
||||
{
|
||||
private readonly string _instanz = Path.Combine(
|
||||
@@ -141,7 +141,7 @@ public sealed class InstanceLogExcerptTests : IDisposable
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_instanz, recursive: true); }
|
||||
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
||||
catch { /* A leftover test folder does not turn a run red. */ }
|
||||
}
|
||||
|
||||
private void Schreibe(string tag, string modul, params string[] zeilen)
|
||||
@@ -154,15 +154,15 @@ public sealed class InstanceLogExcerptTests : IDisposable
|
||||
[Fact]
|
||||
public void Ohne_Protokoll_kommt_nichts_zurueck()
|
||||
{
|
||||
// Kein Fehler, sondern ein Befund: Ein Prozess, der erschlagen wurde, bevor sein
|
||||
// Schreibpuffer geleert war, hinterlaesst genau nichts (#529).
|
||||
// Not an error, but a finding: a process that was killed before its
|
||||
// write buffer was flushed leaves exactly nothing (#529).
|
||||
InstanceLogExcerpt.Read(_instanz).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Module beschreiben denselben Zeitraum. Bei einem Absturz will man wissen, was
|
||||
/// gleichzeitig passiert ist — nicht erst alles aus einer Datei, dann alles aus der
|
||||
/// nächsten.
|
||||
/// Modules describe the same period. With a crash you want to know what
|
||||
/// happened at the same time — not first everything from one file, then everything from the
|
||||
/// next.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Die_Module_werden_nach_Zeit_zusammengefuehrt()
|
||||
@@ -181,7 +181,7 @@ public sealed class InstanceLogExcerptTests : IDisposable
|
||||
zeilen[1].ShouldContain("17:26:49");
|
||||
zeilen[2].ShouldContain("17:26:50");
|
||||
|
||||
// Aus welcher Datei eine Zeile stammt, gehoert dazu.
|
||||
// Which file a line comes from is part of it.
|
||||
zeilen[1].ShouldStartWith("Web.log:");
|
||||
}
|
||||
|
||||
@@ -204,8 +204,8 @@ public sealed class InstanceLogExcerptTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die Datei kann in genau diesem Moment noch beschrieben werden — von einer Instanz,
|
||||
/// die gerade endet. Wer sie exklusiv öffnen wollte, bekäme im Zweifel gar nichts.
|
||||
/// The file may still be written to at exactly this moment — by an instance
|
||||
/// that is just ending. Whoever wanted to open it exclusively might get nothing at all.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Eine_offen_beschriebene_Datei_laesst_sich_lesen()
|
||||
|
||||
@@ -6,7 +6,7 @@ using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Manager.Tests;
|
||||
|
||||
/// <summary>Fängt die letzte Anfrage ab und antwortet fest, ohne Netzwerkzugriff.</summary>
|
||||
/// <summary>Captures the last request and answers with a fixed response, without network access.</summary>
|
||||
internal sealed class CapturingHandler(
|
||||
HttpStatusCode status = HttpStatusCode.OK,
|
||||
string responseBody = """{"status":"success","item_id":1,"is_new":true,"occurrence_count":1}""")
|
||||
@@ -32,13 +32,12 @@ internal sealed class CapturingHandler(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die stellvertretende Meldung (#528, Konzept Abschnitt 4).
|
||||
/// The report on the instance's behalf (#528, concept section 4).
|
||||
///
|
||||
/// <para>Am 2026-09-08 hat sich eine Instanz aufgehängt und wurde von Windows beendet:
|
||||
/// keine Protokollzeile, keine Fehlermeldung, kein Bugtracker-Eintrag. Bauartbedingt —
|
||||
/// Meldeweg und Watchdog lebten in dem Prozess, über den sie berichten sollten. Der
|
||||
/// Manager meldet deshalb an ihrer Stelle, und zwar <b>nur</b> das, was aus seiner Sicht
|
||||
/// relevant ist.</para>
|
||||
/// <para>On 2026-09-08 an instance hung and was ended by Windows:
|
||||
/// no log line, no error message, no bug tracker entry. Inherent in the design —
|
||||
/// reporting path and watchdog lived in the process they were supposed to report on. The
|
||||
/// manager therefore reports in their place, and <b>only</b> what is relevant from its point of view.</para>
|
||||
/// </summary>
|
||||
public sealed class SupervisionReporterTests
|
||||
{
|
||||
@@ -70,13 +69,13 @@ public sealed class SupervisionReporterTests
|
||||
rumpf.ShouldContain("4711");
|
||||
rumpf.ShouldContain("137");
|
||||
rumpf.ShouldContain("instanzmanager");
|
||||
rumpf.ShouldContain("clawddotnet"); // Projektbindung
|
||||
rumpf.ShouldContain("clawddotnet"); // project binding
|
||||
rumpf.ShouldContain("0.1.2"); // build_version
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Was die Instanz betrifft, bleibt in der Instanz — sie meldet es selbst, mit ihrem
|
||||
/// eigenen Sub-Token. Der Manager meldet nur, was niemand bestellt hat.
|
||||
/// What concerns the instance stays in the instance — it reports it itself, with its
|
||||
/// own sub-token. The manager only reports what nobody ordered.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(InstanceEventKind.Started)]
|
||||
@@ -89,9 +88,9 @@ public sealed class SupervisionReporterTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stürzt dieselbe Instanz wiederholt ab, soll der Server hochzählen statt den
|
||||
/// Bugtracker mit Zwillingen zu füllen — wiederholte Abstürze sind ein Befund und
|
||||
/// nicht ein Dutzend gleicher.
|
||||
/// If the same instance crashes repeatedly, the server should count up instead of filling the
|
||||
/// bug tracker with twins — repeated crashes are one finding and
|
||||
/// not a dozen identical ones.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Dieselbe_Instanz_und_dieselbe_Art_ergeben_denselben_Schluessel()
|
||||
@@ -104,11 +103,11 @@ public sealed class SupervisionReporterTests
|
||||
await melder.ReportAsync(Ereignis(InstanceEventKind.Crashed));
|
||||
SchluesselAus(_handler.LastBody!).ShouldBe(erster);
|
||||
|
||||
// Eine andere Art ist ein anderer Befund.
|
||||
// A different kind is a different finding.
|
||||
await melder.ReportAsync(Ereignis(InstanceEventKind.Unresponsive));
|
||||
SchluesselAus(_handler.LastBody!).ShouldNotBe(erster);
|
||||
|
||||
// Und eine andere Instanz erst recht.
|
||||
// And a different instance all the more.
|
||||
await melder.ReportAsync(
|
||||
Ereignis(InstanceEventKind.Crashed, dir: @"C:\Instanzen\Instance-Beta"));
|
||||
SchluesselAus(_handler.LastBody!).ShouldNotBe(erster);
|
||||
@@ -124,9 +123,9 @@ public sealed class SupervisionReporterTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein leerer Auszug ist die aussagekräftigste Zeile der ganzen Meldung: Ein Prozess,
|
||||
/// der erschlagen wurde, bevor sein Schreibpuffer geleert war, hinterlässt nichts.
|
||||
/// Genau das war der Zustand aus #529.
|
||||
/// An empty excerpt is the most meaningful line of the whole report: a process
|
||||
/// that was killed before its write buffer was flushed leaves nothing.
|
||||
/// Exactly that was the state from #529.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Ein_leerer_Protokollauszug_wird_ausdruecklich_gesagt()
|
||||
@@ -137,9 +136,9 @@ public sealed class SupervisionReporterTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Meldeweg, der selbst wirft, wäre die schlechteste aller Welten: Der
|
||||
/// ursprüngliche Befund ginge dabei verloren, und die Aufsicht stürbe an ihrem
|
||||
/// eigenen Bericht.
|
||||
/// A reporting path that throws itself would be the worst of all worlds: the
|
||||
/// original finding would get lost, and the supervision would die of its
|
||||
/// own report.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Ein_Serverfehler_reisst_die_Aufsicht_nicht_mit()
|
||||
|
||||
Reference in New Issue
Block a user