Instanzmanager Stufe 5: Wechseln heisst nach vorn holen (#528, E-M2)
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>
This commit is contained in:
@@ -118,4 +118,16 @@ internal sealed class FakeChannel : IInstanceChannel
|
||||
StopRequests++;
|
||||
return Task.FromResult(StopRequestArrives);
|
||||
}
|
||||
|
||||
/// <summary>Welche Instanzen gebeten wurden, ihr Fenster nach vorn zu holen.</summary>
|
||||
public List<string> FocusRequests { get; } = [];
|
||||
|
||||
/// <summary>Ob die Instanz ein Fenster hat, das nach vorn kommen kann.</summary>
|
||||
public bool HasWindow { get; set; } = true;
|
||||
|
||||
public Task<bool> RequestFocusAsync(string instanceDirectory, CancellationToken ct = default)
|
||||
{
|
||||
FocusRequests.Add(Path.GetFullPath(instanceDirectory));
|
||||
return Task.FromResult(HasWindow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,3 +217,93 @@ public sealed class ManagerCoreTests : IAsyncDisposable
|
||||
(await manager.StartInstanceAsync(pfad)).ShouldBe(StartRefusal.DidNotStart);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der Wechsel, wie E-M2 ihn meint (#528, Stufe 5).
|
||||
///
|
||||
/// <para><b>Vorher hiess „Instanz wechseln":</b> Der laufende Prozess fährt sich herunter
|
||||
/// und startet sich mit einer anderen Instanz neu. Wer wechselte, beendete zwangsläufig
|
||||
/// die Instanz, aus der heraus er es tat — und genau dabei hat sich am 2026-09-08 die
|
||||
/// Anwendung aufgehängt (#529). Jetzt laufen alle, und es kommt nur eine nach vorn.</para>
|
||||
/// </summary>
|
||||
public sealed class InstanceFocusTests : IAsyncDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(
|
||||
Path.GetTempPath(), "clawd-tests", "focus-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
|
||||
private readonly FakeLauncher _launcher = new();
|
||||
private readonly FakeChannel _kanal = new();
|
||||
private readonly InstanceDirectoryManager _verzeichnisse;
|
||||
|
||||
private static readonly SupervisionOptions Options = new(
|
||||
ProbeInterval: TimeSpan.FromMilliseconds(50),
|
||||
StartTimeout: TimeSpan.FromMilliseconds(600),
|
||||
SilentChecksBeforeAlarm: 3,
|
||||
StopGrace: TimeSpan.FromMilliseconds(300));
|
||||
|
||||
public InstanceFocusTests()
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
|
||||
}
|
||||
|
||||
private InstanceRegistry Register() => new(
|
||||
_verzeichnisse, _launcher, _kanal, NullLoggerFactory.Instance, Options);
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
try { Directory.Delete(_root, recursive: true); }
|
||||
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_laufende_Instanz_wird_nach_vorn_geholt()
|
||||
{
|
||||
await using var register = Register();
|
||||
var pfad = register.Create("Alpha");
|
||||
|
||||
_kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-1", "Alpha", "0.1.2");
|
||||
await register.StartAsync(pfad);
|
||||
|
||||
(await register.FocusAsync(pfad)).ShouldBeTrue();
|
||||
|
||||
_kanal.FocusRequests.ShouldBe([Path.GetFullPath(pfad)]);
|
||||
|
||||
// Und vor allem: Es wurde nichts beendet und nichts neu gestartet.
|
||||
_launcher.Starts.ShouldBe(1);
|
||||
_kanal.StopRequests.ShouldBe(0);
|
||||
_launcher.Last!.HasExited.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Eine_Instanz_die_nicht_laeuft_hat_kein_Fenster()
|
||||
{
|
||||
await using var register = Register();
|
||||
var pfad = register.Create("Alpha");
|
||||
|
||||
(await register.FocusAsync(pfad)).ShouldBeFalse();
|
||||
|
||||
// Nicht einmal gefragt: Wer nicht laeuft, antwortet auch nicht.
|
||||
_kanal.FocusRequests.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine kopflose Instanz hat nichts nach vorn zu holen — und sagt das, statt Erfolg
|
||||
/// zu melden.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Eine_kopflose_Instanz_meldet_dass_sie_kein_Fenster_hat()
|
||||
{
|
||||
await using var register = Register();
|
||||
var pfad = register.Create("Alpha");
|
||||
|
||||
_kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-1", "Alpha", "0.1.2");
|
||||
await register.StartAsync(pfad);
|
||||
|
||||
_kanal.HasWindow = false;
|
||||
|
||||
(await register.FocusAsync(pfad)).ShouldBeFalse();
|
||||
_kanal.FocusRequests.ShouldHaveSingleItem();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -187,3 +188,110 @@ public sealed class InstanceSocketTests : IDisposable
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user