Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt). Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e): Properties, Record-Parameter, Enum-Member sowie die Parameter der Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
146 lines
5.8 KiB
C#
146 lines
5.8 KiB
C#
using System.Runtime.InteropServices;
|
|
using ClawdDotNet.App;
|
|
using ClawdDotNet.App.Services;
|
|
using ClawdDotNet.Licensing;
|
|
using ClawdDotNet.Web;
|
|
using ClawdDotNet.Web.Endpoints;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace ClawdDotNet.Desktop;
|
|
|
|
/// <summary>
|
|
/// An instance without a window (#528, stage 2).
|
|
///
|
|
/// <para><b>What for, if every instance keeps its window anyway (E-M2).</b> Because the
|
|
/// instance process thereby proves that it does not <em>need</em> one. A server without a
|
|
/// screen session, a systemd service, a test run in the build — all three need exactly
|
|
/// that, and every accidental dependency on the user interface shows up here immediately,
|
|
/// instead of only on the target machine.</para>
|
|
///
|
|
/// <para><b>What is not asked here is not asked at all.</b> A service without a
|
|
/// session that opens an input window at startup hangs forever without anyone
|
|
/// ever getting to see the question. The instance therefore comes from the argument, the license
|
|
/// via <see cref="NonInteractiveLicensePrompt"/>, and the Telegram login stays
|
|
/// off — <c>Callbacks.TelegramLogin</c> is null, and that is the answer here, not an
|
|
/// omission.</para>
|
|
///
|
|
/// <para><b>Under Windows you see little of it.</b> The executable is a
|
|
/// <c>WinExe</c> and has no console; the messages below then go nowhere. What
|
|
/// matters is in the instance log anyway. The target platform is Linux, and there it is
|
|
/// an ordinary console application.</para>
|
|
/// </summary>
|
|
internal static class HeadlessRun
|
|
{
|
|
private const int ExitOk = 0;
|
|
private const int ExitNoInstance = 2;
|
|
private const int ExitStartFailed = 3;
|
|
private const int ExitNoEndpoint = 4;
|
|
|
|
public static async Task<int> RunAsync(string[] args)
|
|
{
|
|
var instancePath = CommandLine.ReadInstancePath(args);
|
|
|
|
if (string.IsNullOrWhiteSpace(instancePath) || !Directory.Exists(instancePath))
|
|
{
|
|
await Console.Error.WriteLineAsync(
|
|
$"Ohne Fenster gibt es keinen Auswahldialog. Erwartet wird "
|
|
+ $"{CommandLine.InstanceArgument} <Instanzverzeichnis>"
|
|
+ (instancePath is null ? "." : $" — '{instancePath}' gibt es nicht."));
|
|
|
|
return ExitNoInstance;
|
|
}
|
|
|
|
var (host, error) = await AppHost.StartAsync(new AppHost.Callbacks
|
|
{
|
|
SelectInstance = _ => Task.FromResult<string?>(instancePath),
|
|
|
|
// Does not ask, but reports what to do. In managed mode (E-M3)
|
|
// the local check is dropped; the manager checks centrally.
|
|
License = new NonInteractiveLicensePrompt(Console.Error.WriteLine),
|
|
IsManaged = CommandLine.IsManaged(args)
|
|
});
|
|
|
|
if (host is null)
|
|
{
|
|
// No error text means abort with an announcement - the license prompt has already written its
|
|
// message itself.
|
|
if (error is not null) await Console.Error.WriteLineAsync(error);
|
|
return ExitStartFailed;
|
|
}
|
|
|
|
await using (host)
|
|
{
|
|
var logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Headless");
|
|
|
|
var web = InstanceHttpHost.TryStart(host, host.LoggerFactory);
|
|
|
|
if (web is null)
|
|
{
|
|
// Without a socket a headless instance cannot be addressed - so nothing
|
|
// you should let run. With a window that would be different: there the
|
|
// user interface remains as access.
|
|
logger.LogError("Kein Zugang: Die HTTP-Fläche ist nicht hochgekommen.");
|
|
return ExitNoEndpoint;
|
|
}
|
|
|
|
await using (web)
|
|
{
|
|
Console.WriteLine(web.Endpoint);
|
|
logger.LogInformation(
|
|
"Instanz {Name} läuft ohne Fenster auf {Endpunkt}",
|
|
host.Instance.InstanceName, web.Endpoint);
|
|
|
|
await WaitForShutdownAsync(logger, web.Control);
|
|
|
|
logger.LogInformation("Instanz {Name} wird beendet.", host.Instance.InstanceName);
|
|
}
|
|
}
|
|
|
|
return ExitOk;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Waits until someone says stop.
|
|
///
|
|
/// <para>Four ways, because four senders come into question: the request of the instance manager
|
|
/// via the socket (the normal case), <c>SIGTERM</c> from systemd, <c>SIGINT</c> from the
|
|
/// terminal, and <see cref="AppDomain.ProcessExit"/> as the last opportunity.</para>
|
|
///
|
|
/// <para><b>The signal is caught, not let through</b>
|
|
/// (<c>context.Cancel = true</c>). Otherwise the runtime would tear down the process while
|
|
/// the orderly shutdown is still running — and exactly then the write buffer
|
|
/// of the log is flushed. An end without these lines is the state from #529.</para>
|
|
/// </summary>
|
|
private static async Task WaitForShutdownAsync(ILogger logger, InstanceControl control)
|
|
{
|
|
var conclusion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
void Shutdown(string reason)
|
|
{
|
|
if (conclusion.TrySetResult())
|
|
logger.LogInformation("Beendigung angefordert ({Grund}).", reason);
|
|
}
|
|
|
|
// The instance manager, via the socket. The usual way, as soon as it exists:
|
|
// it asks instead of shooting - an instance process holds an open database
|
|
// and running agents, and both want to be brought to an end.
|
|
control.ShutdownRequested += () => Shutdown("Instanzmanager");
|
|
|
|
using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
|
|
{
|
|
context.Cancel = true;
|
|
Shutdown("SIGTERM");
|
|
});
|
|
|
|
using var sigint = PosixSignalRegistration.Create(PosixSignal.SIGINT, context =>
|
|
{
|
|
context.Cancel = true;
|
|
Shutdown("SIGINT");
|
|
});
|
|
|
|
AppDomain.CurrentDomain.ProcessExit += (_, _) => Shutdown("ProcessExit");
|
|
|
|
await conclusion.Task;
|
|
}
|
|
}
|