Vorher hiess "Instanz wechseln": Der laufende Prozess faehrt sich herunter und startet sich mit einer anderen Instanz neu. Wer wechselte, beendete zwangslaeufig die Instanz, aus der heraus er es tat - und genau dabei hat sich die Anwendung am 2026-09-08 aufgehaengt (#529). Jetzt laufen alle, und es kommt eine nach vorn. Neu: POST /api/instanz/fenster, wieder ausschliesslich ueber den Socket. Die Instanz holt ihr Fenster SELBST nach vorn; der Manager kennt kein Fenstersystem und soll keines kennen. Kopflos laeuft die Bitte ins Leere, und das ist die richtige Antwort. Das letzte Wort hat der Fenstermanager - Windows laesst einen Hintergrundprozess den Vordergrund nicht einfach an sich reissen; der kurze Ausflug ueber Topmost half im Versuch, unter Linux entscheidet der Fenstermanager. Mehr als bitten kann eine Anwendung nicht. ShutdownSignal ist zu InstanceControl geworden: beenden und Fenster sind zwei Bitten desselben Absenders ueber denselben Weg. Beenden gilt einmal, das Fenster beliebig oft - jeder Wechsel auf diese Instanz ist einer. --managed schaltet den Wechsel im Instanzfenster ab. Der Manager gibt es jedem Instanzprozess mit; das Fenster graut seinen Instanz-Schalter aus und erklaert auf Nachfrage, wo gewechselt wird. Zwei Schloesser an derselben Tuer: Der Knopf ist aus, und der Weg dahinter prueft es noch einmal - denn hinter ihm wartet ein Selbst-Neustart. instanceWindows in manager.json, vorbelegt auf true: Jede Instanz behaelt ihr Fenster. Auf einer Maschine ohne Bildschirmsitzung gehoert false hin. ABNAHME an echten Fenstern: Instanz ueber den Manager gestartet, PID 18128, Fenstertitel "ClawdDotNet", Befehlszeile mit --managed. Fenster von aussen minimiert (IsIconic True), dann POST /instanzen/Instance-Test/fenster: IsIconic False, im Vordergrund - und PID unveraendert 18128. Nichts neu gestartet. Das ist die Abnahmebedingung dieser Stufe. Build ohne Warnungen, 780 Tests gruen, 8 uebersprungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
298 lines
11 KiB
C#
298 lines
11 KiB
C#
using ClawdDotNet.App;
|
|
using ClawdDotNet.App.Services;
|
|
using ClawdDotNet.App.Settings;
|
|
using ClawdDotNet.Core.Config;
|
|
using ClawdDotNet.Core.Instances;
|
|
using ClawdDotNet.Core.Tools;
|
|
using ClawdDotNet.Web.Endpoints;
|
|
using ClawdDotNet.Web.Tests.Infrastructure;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Web.Tests;
|
|
|
|
/// <summary>
|
|
/// Der Socket im Instanzverzeichnis (#528, Stufe 2 / E-M1).
|
|
///
|
|
/// <para>Über ihn wird der Instanzmanager die Instanz erreichen. Drei Zusagen hält
|
|
/// dieser Test fest: Er ist <b>immer</b> da, auch ohne Weboberfläche. Er braucht
|
|
/// <b>kein Token</b> — seine Grenze steht am Dateisystem. Und der TCP-Port braucht
|
|
/// weiterhin eins, denn dort steht keine.</para>
|
|
///
|
|
/// <para>Der letzte Punkt ist der, an dem eine Verwechslung teuer wäre: Wer die
|
|
/// Unterscheidung aus fehlenden IP-Adressen erraten wollte, ließe im Zweifel eine
|
|
/// TCP-Anfrage ohne Token durch. Sie kommt deshalb aus einer Marke, die der
|
|
/// Socket-Zuhörer selbst setzt.</para>
|
|
/// </summary>
|
|
public sealed class InstanceSocketTests : IDisposable
|
|
{
|
|
private readonly string _root = Path.Combine(
|
|
Path.GetTempPath(), "clawd-tests", "sock-" + Guid.NewGuid().ToString("N")[..8]);
|
|
|
|
private const string Token = "ein-hinreichend-langes-testtoken";
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Directory.Delete(_root, recursive: true); }
|
|
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
|
}
|
|
|
|
private AppHost NeuerHost(bool mitWeboberflaeche, int port = 0)
|
|
{
|
|
var instancesDir = Path.Combine(_root, "Instances");
|
|
var instancePath = Path.Combine(instancesDir, "Test");
|
|
Directory.CreateDirectory(instancePath);
|
|
|
|
return new AppHost
|
|
{
|
|
Settings = new SettingsManager(_root),
|
|
Directories = new InstanceDirectoryManager(instancesDir),
|
|
Instance = new InstanceConfig
|
|
{
|
|
InstanceName = "Test",
|
|
WorkingDirectory = instancePath,
|
|
WebUiEnabled = mitWeboberflaeche,
|
|
WebUiBinding = "loopback",
|
|
WebUiToken = mitWeboberflaeche ? Token : "",
|
|
WebServerPort = port
|
|
},
|
|
InstancePath = instancePath,
|
|
LogDirectory = Path.Combine(instancePath, "Logs"),
|
|
LoggerFactory = NullLoggerFactory.Instance,
|
|
Tools = new ToolRegistry()
|
|
};
|
|
}
|
|
|
|
private static int FreierPort()
|
|
{
|
|
using var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
|
probe.Start();
|
|
var port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port;
|
|
probe.Stop();
|
|
return port;
|
|
}
|
|
|
|
// ─── Der Socket trägt ───
|
|
|
|
[Fact]
|
|
public async Task Der_Socket_antwortet_auch_ohne_Weboberflaeche()
|
|
{
|
|
// Die Vorbelegung: webUiEnabled ist aus. Frueher lief dann gar nichts, und die
|
|
// Instanz war von aussen nicht ansprechbar.
|
|
await using var host = NeuerHost(mitWeboberflaeche: false);
|
|
await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance);
|
|
|
|
web.ShouldNotBeNull();
|
|
web!.Url.ShouldBeNull();
|
|
|
|
using var client = InstanceEndpoint.CreateClient(host.InstancePath);
|
|
var antwort = await client.GetAsync("/api/info");
|
|
|
|
antwort.EnsureSuccessStatusCode();
|
|
(await antwort.Content.ReadAsStringAsync()).ShouldContain("Test");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ueber_den_Socket_braucht_es_kein_Token()
|
|
{
|
|
await using var host = NeuerHost(mitWeboberflaeche: true, port: FreierPort());
|
|
await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance);
|
|
|
|
web.ShouldNotBeNull();
|
|
|
|
// Kein Authorization-Kopf, mit Absicht.
|
|
using var client = InstanceEndpoint.CreateClient(host.InstancePath);
|
|
(await client.GetAsync("/api/info")).EnsureSuccessStatusCode();
|
|
}
|
|
|
|
// ─── Der Port bleibt bewacht ───
|
|
|
|
[Fact]
|
|
public async Task Ueber_TCP_gilt_die_Token_Pflicht_weiter()
|
|
{
|
|
var port = FreierPort();
|
|
|
|
await using var host = NeuerHost(mitWeboberflaeche: true, port: port);
|
|
await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance);
|
|
|
|
web.ShouldNotBeNull();
|
|
web!.Url.ShouldNotBeNull();
|
|
|
|
using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") };
|
|
|
|
(await client.GetAsync("/api/info")).StatusCode
|
|
.ShouldBe(System.Net.HttpStatusCode.Unauthorized);
|
|
|
|
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {Token}");
|
|
(await client.GetAsync("/api/info")).EnsureSuccessStatusCode();
|
|
}
|
|
|
|
// ─── Adressen ───
|
|
|
|
[Fact]
|
|
public void Zwei_Instanzen_bekommen_zwei_Adressen()
|
|
{
|
|
var a = Path.Combine(_root, "Instances", "Eins");
|
|
var b = Path.Combine(_root, "Instances", "Zwei");
|
|
|
|
InstanceEndpoint.Describe(a).ShouldNotBe(InstanceEndpoint.Describe(b));
|
|
InstanceEndpoint.PipeName(a).ShouldNotBe(InstanceEndpoint.PipeName(b));
|
|
}
|
|
|
|
[Fact]
|
|
public void Dieselbe_Instanz_bekommt_dieselbe_Adresse()
|
|
{
|
|
// Der Manager rechnet die Adresse aus dem Pfad aus, den er selbst kennt — sie
|
|
// muss also aus jeder Schreibweise desselben Verzeichnisses gleich herauskommen.
|
|
var a = Path.Combine(_root, "Instances", "Test");
|
|
var b = Path.Combine(_root, "Instances", "Unterwegs", "..", "Test");
|
|
|
|
InstanceEndpoint.PipeName(a).ShouldBe(InstanceEndpoint.PipeName(b));
|
|
InstanceEndpoint.SocketPath(a).ShouldBe(InstanceEndpoint.SocketPath(b));
|
|
}
|
|
|
|
// ─── Aufräumen ───
|
|
|
|
/// <summary>
|
|
/// Eine liegengebliebene Socketdatei darf den nächsten Start nicht verhindern.
|
|
///
|
|
/// <para>Ein Unix-Domain-Socket überlebt den Prozess, der ihn angelegt hat. Nach
|
|
/// einem harten Ende — genau dem Fall aus #529 — bekäme der nächste Start
|
|
/// <c>EADDRINUSE</c> von einem Prozess, den es nicht mehr gibt.</para>
|
|
/// </summary>
|
|
[SocketFileFact]
|
|
public async Task Eine_liegengebliebene_Socketdatei_haelt_den_Start_nicht_auf()
|
|
{
|
|
await using var host = NeuerHost(mitWeboberflaeche: false);
|
|
|
|
var leiche = InstanceEndpoint.SocketPath(host.InstancePath);
|
|
File.WriteAllText(leiche, "Rest eines abgestuerzten Prozesses");
|
|
|
|
await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance);
|
|
web.ShouldNotBeNull();
|
|
|
|
using var client = InstanceEndpoint.CreateClient(host.InstancePath);
|
|
(await client.GetAsync("/api/info")).EnsureSuccessStatusCode();
|
|
}
|
|
|
|
[SocketFileFact]
|
|
public async Task Beim_Beenden_bleibt_keine_Socketdatei_liegen()
|
|
{
|
|
await using var host = NeuerHost(mitWeboberflaeche: false);
|
|
|
|
var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance);
|
|
web.ShouldNotBeNull();
|
|
|
|
await web!.DisposeAsync();
|
|
|
|
File.Exists(InstanceEndpoint.SocketPath(host.InstancePath)).ShouldBeFalse();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Die Steuerung des Instanzprozesses: beenden und Fenster nach vorn holen
|
|
/// (#528, Stufen 3 und 5).
|
|
///
|
|
/// <para><b>Beides gibt es ausschließlich über den Socket.</b> Über TCP existieren die
|
|
/// Endpunkte nicht — auch mit gültigem Token nicht, und sie antworten dort mit 404 statt
|
|
/// mit 403: Was es nicht gibt, muss man auch nicht erklären. Die Lebensdauer einer Instanz
|
|
/// gehört dem Instanzmanager; eine Browsersitzung soll den Prozess, der sie bedient, weder
|
|
/// beenden noch sein Fenster aufreißen können.</para>
|
|
/// </summary>
|
|
public sealed class InstanceControlTests : IDisposable
|
|
{
|
|
private readonly string _root = Path.Combine(
|
|
Path.GetTempPath(), "clawd-tests", "ctl-" + Guid.NewGuid().ToString("N")[..8]);
|
|
|
|
private const string Token = "ein-hinreichend-langes-testtoken";
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Directory.Delete(_root, recursive: true); }
|
|
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
|
}
|
|
|
|
private AppHost NeuerHost(int port)
|
|
{
|
|
var instancesDir = Path.Combine(_root, "Instances");
|
|
var instancePath = Path.Combine(instancesDir, "Test");
|
|
Directory.CreateDirectory(instancePath);
|
|
|
|
return new AppHost
|
|
{
|
|
Settings = new SettingsManager(_root),
|
|
Directories = new InstanceDirectoryManager(instancesDir),
|
|
Instance = new InstanceConfig
|
|
{
|
|
InstanceName = "Test",
|
|
WorkingDirectory = instancePath,
|
|
WebUiEnabled = true,
|
|
WebUiBinding = "loopback",
|
|
WebUiToken = Token,
|
|
WebServerPort = port
|
|
},
|
|
InstancePath = instancePath,
|
|
LogDirectory = Path.Combine(instancePath, "Logs"),
|
|
LoggerFactory = NullLoggerFactory.Instance,
|
|
Tools = new ToolRegistry()
|
|
};
|
|
}
|
|
|
|
private static int FreierPort()
|
|
{
|
|
using var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
|
probe.Start();
|
|
var port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port;
|
|
probe.Stop();
|
|
return port;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ueber_den_Socket_kommt_die_Bitte_um_das_Fenster_an()
|
|
{
|
|
await using var host = NeuerHost(FreierPort());
|
|
await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance);
|
|
|
|
web.ShouldNotBeNull();
|
|
|
|
var gebeten = 0;
|
|
web!.Control.FocusRequested += () => Interlocked.Increment(ref gebeten);
|
|
|
|
using var client = InstanceEndpoint.CreateClient(host.InstancePath);
|
|
(await client.PostAsync("/api/instanz/fenster", content: null)).EnsureSuccessStatusCode();
|
|
|
|
gebeten.ShouldBe(1);
|
|
|
|
// Und beliebig oft: Jeder Wechsel auf diese Instanz ist einer.
|
|
(await client.PostAsync("/api/instanz/fenster", content: null)).EnsureSuccessStatusCode();
|
|
gebeten.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ueber_TCP_gibt_es_die_Steuerung_nicht()
|
|
{
|
|
var port = FreierPort();
|
|
|
|
await using var host = NeuerHost(port);
|
|
await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance);
|
|
|
|
web.ShouldNotBeNull();
|
|
web!.Url.ShouldNotBeNull();
|
|
|
|
var gebeten = false;
|
|
web.Control.FocusRequested += () => gebeten = true;
|
|
|
|
using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") };
|
|
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {Token}");
|
|
|
|
// Mit gueltigem Token - und trotzdem nicht vorhanden.
|
|
(await client.PostAsync("/api/instanz/fenster", content: null)).StatusCode
|
|
.ShouldBe(System.Net.HttpStatusCode.NotFound);
|
|
|
|
(await client.PostAsync("/api/instanz/beenden", content: null)).StatusCode
|
|
.ShouldBe(System.Net.HttpStatusCode.NotFound);
|
|
|
|
gebeten.ShouldBeFalse();
|
|
}
|
|
}
|