Files
RichardandClaude Opus 5 8686d13999 Instanzmanager Stufe 2: Kestrel auf dem Socket, Instanz kopflos (#528)
Eine Instanz ist jetzt ohne Port ansprechbar. InstanceEndpoint liefert die
Adresse: unter Linux <Instanz>/manager.sock, unter Windows eine Named Pipe,
deren Name aus einem SHA-256-Fingerabdruck des Instanzpfades entsteht. Zwei
Instanzen bekommen damit zwangslaeufig zwei Adressen, ohne dass jemand
etwas vergeben muss - genau der Unterschied zur Vorbelegung 7001, um die
sich zwei Instanzen stillschweigend streiten.

Der Socket ist immer da, auch ohne Weboberflaeche. Er gehoert nicht zu
#494, sondern ist die Steuerflaeche der Instanz: webUiEnabled schaltet den
TCP-Zugang, nicht den Socket. WebUiHost heisst deshalb jetzt
InstanceHttpHost - ein Kestrel, zwei Zugaenge.

Ueber TCP gilt Token-Pflicht, ueber den Socket nicht: Dort steht die Grenze
am Dateisystem (0600 unter Linux, CurrentUserOnly bei der Named Pipe,
explizit gesetzt statt auf die Vorgabe vertraut). Wer die Socketdatei
oeffnen darf, sieht ohnehin in das Verzeichnis, in dem alles liegt, was ein
Token schuetzen wuerde.

Woran die beiden Zugaenge auseinandergehalten werden, ist der Punkt, an dem
ein Irrtum teuer waere. Bei Socket-Verbindungen stehen in
HttpContext.Connection keine IP-Adressen - daraus zu schliessen "keine IP,
also Socket" waere eine Vermutung an der Stelle, an der ein Torwaechter
steht, und sie irrt in die falsche Richtung: Im Zweifel liesse sie eine
TCP-Anfrage ohne Token durch. Der Socket-Zuhoerer haengt deshalb jeder
Verbindung selbst eine Marke an (LocalTransport, Verbindungs-
Zwischenschicht ueber ListenOptions.Use); wo sie fehlt, gilt Token-Pflicht.

Ein belegter Port kostet nur noch die Weboberflaeche. Vorher gab TryStart
in dem Fall null zurueck und die ganze HTTP-Flaeche war weg. Seit dem
Socket ist das die falsche Rangfolge: Ihn zu verlieren, weil eine zweite
Instanz laeuft, hiesse die Instanz unerreichbar zu machen - in genau dem
Fall, fuer den es den Manager gibt.

Kopfloser Betrieb ueber --headless --instance <Pfad>. Die Verzweigung sitzt
in Program.Main vor BuildAvaloniaApp, damit auf einem Server ohne
Bildschirmsitzung kein Fenstersystem angefasst wird. Gefragt wird nichts:
Instanz aus dem Argument, Lizenz ueber das bereits vorhandene
NonInteractiveLicensePrompt, Telegram-Anmeldung aus.

Nachgemessen, nicht angenommen: Kopflose Instanz auf Instance-Test
gestartet, kein Fenster, und eine rohe HTTP/1.1-Anfrage aus einem fremden
Prozess (PowerShell, direkt auf die Pipe) bekam 200 OK auf /api/info, ohne
Token. Das ist die Abnahmebedingung fuer Stufe 2.

Korrektur zur eigenen Ankuendigung: webServerPort entfaellt nicht mit
Stufe 2, sondern mit Stufe 4. Solange kein Manager die Weboberflaeche
traegt, hiesse Wegnehmen, sie ersatzlos abzuschalten.

Zwei Luecken bleiben offen und gehoeren auf die Linux-Testmaschine: Die
Socketdatei-Faelle sind unter Windows gegenstandslos und werden dort mit
sichtbarer Begruendung uebersprungen - der Unix-Domain-Socket selbst ist
auf der Entwicklungsmaschine also ungeprueft, obwohl er die Zielplattform
ist. Und das geordnete Ende auf SIGTERM liess sich hier nicht ausloesen:
Windows kennt das Signal fuer einen fensterlosen Prozess nicht.

Build ohne Warnungen, 714 Tests gruen, 8 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:08:23 +02:00

163 lines
6.1 KiB
C#

using System.Diagnostics;
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.App.Settings;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Instances;
using ClawdDotNet.Core.Tools;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Web.Tests;
/// <summary>
/// Die Weboberfläche muss sich zügig beenden lassen — auch mit offenen Ereignisströmen.
///
/// <para><b>Der Anlass.</b> Ein Ereignisstrom endet nie von selbst. Beim geordneten
/// Herunterfahren legt der Browser nicht auf, also feuert <c>RequestAborted</c> nicht,
/// und Kestrel wartet geduldig darauf, dass die Anfrage fertig wird. Erst nach der Frist
/// von <c>StopAsync</c> wurde abgeschnitten: fünf Sekunden, in denen die Anwendung stand.
/// Sichtbar wurde es beim Instanzwechsel — zwischen „Wechseln" und dem Neustart lag jedes
/// Mal diese Pause, sobald irgendwo ein Browserfenster offen war.</para>
///
/// <para>Die Ströme hängen jetzt zusätzlich an <c>ApplicationStopping</c>. Dieser Test
/// hält die Zusage fest, damit sie nicht beim nächsten Umbau still verlorengeht.</para>
/// </summary>
public sealed class ShutdownTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(), "clawd-tests", "web-" + Guid.NewGuid().ToString("N")[..8]);
private const string Token = "ein-hinreichend-langes-testtoken";
/// <summary>
/// Grosszuegig bemessen: Gemessen wird gegen die Frist von StopAsync (fuenf
/// Sekunden). Bleibt der Test unter zwei, ist die Zusage eingehalten, ohne dass ein
/// langsamer Bauserver ihn rot faerbt.
/// </summary>
private static readonly TimeSpan Grenze = TimeSpan.FromSeconds(2);
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",
WebUiEnabled = true,
WebUiBinding = "loopback",
WebUiToken = Token,
WebServerPort = port
},
InstancePath = instancePath,
LogDirectory = Path.Combine(_root, "Logs"),
LoggerFactory = NullLoggerFactory.Instance,
Tools = new ToolRegistry()
};
}
/// <summary>
/// Ein freier Port vom Betriebssystem. Eine feste Zahl liesse zwei Testlaeufe
/// nebeneinander scheitern — und genau solche Fehler sucht man am laengsten.
/// </summary>
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 Beenden_wartet_nicht_auf_einen_offenen_Ereignisstrom()
{
var port = FreierPort();
await using var host = NeuerHost(port);
var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance);
web.ShouldNotBeNull();
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {Token}");
// Den Strom wirklich oeffnen und die erste Zeile abwarten: Erst dann haelt
// Kestrel die Anfrage, und erst dann prueft der Test das, was er pruefen will.
using var antwort = await client.GetAsync(
$"http://127.0.0.1:{port}/events?token={Token}",
HttpCompletionOption.ResponseHeadersRead);
antwort.EnsureSuccessStatusCode();
await using var strom = await antwort.Content.ReadAsStreamAsync();
using var leser = new StreamReader(strom);
(await leser.ReadLineAsync()).ShouldBe(": bereit");
var uhr = Stopwatch.StartNew();
await web!.DisposeAsync();
uhr.Stop();
uhr.Elapsed.ShouldBeLessThan(Grenze,
$"Das Beenden dauerte {uhr.Elapsed.TotalSeconds:0.0}s — der Ereignisstrom "
+ "hat das Herunterfahren nicht bemerkt.");
}
[Fact]
public async Task Beenden_geht_auch_ohne_offene_Verbindung_zuegig()
{
var port = FreierPort();
await using var host = NeuerHost(port);
var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance);
web.ShouldNotBeNull();
var uhr = Stopwatch.StartNew();
await web!.DisposeAsync();
uhr.Stop();
uhr.Elapsed.ShouldBeLessThan(Grenze);
}
/// <summary>
/// Ein belegter Port kostet die Weboberfläche, nicht die Instanz.
///
/// <para>Vorher gab <c>TryStart</c> hier <c>null</c> zurück, und damit war die
/// gesamte HTTP-Fläche weg. Seit der Socket dazugehört, ist das die falsche
/// Rangfolge: Über ihn wird der Instanzmanager die Instanz erreichen, und ihn wegen
/// eines belegten Ports zu verlieren hieße, die Instanz unerreichbar zu machen, weil
/// eine zweite schon läuft — also genau in dem Fall, für den es den Manager
/// gibt.</para>
/// </summary>
[Fact]
public async Task Ein_belegter_Port_kostet_nur_die_Weboberflaeche()
{
var port = FreierPort();
using var blockierer = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, port);
blockierer.Start();
await using var host = NeuerHost(port);
await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance);
web.ShouldNotBeNull();
web!.Url.ShouldBeNull("Die Weboberfläche konnte nicht binden — das gehört gesagt.");
// Der Socket traegt trotzdem.
using var client = InstanceEndpoint.CreateClient(host.InstancePath);
(await client.GetAsync("/api/info")).EnsureSuccessStatusCode();
}
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
}
}