feat(headless): add the interactive console (reader, renderer, controller)
Console mode for the headless bot host, enabled by --console or ACDREAM_HEADLESS_CONSOLE=1 (default: on when stdin is a real terminal, off when redirected — a background reader thread blocked on ReadLine would otherwise sit idle against a script/CI pipe). - HeadlessConsoleInputReader: one background thread per console, reading lines into a ConcurrentQueue. It only ever enqueues — no dispatch code runs on it — so input queued while the session tick is busy is guaranteed to execute on the tick thread, never the reader thread, when drained. - HeadlessConsoleController: drains the queue via HeadlessSessionHost.ConsolePump (a new no-op-by-default tick hook), handles /quit (cancels a CancellationTokenSource) and /status (prints a caller-supplied status string), and routes everything else through SubmitConsoleLine. - HeadlessConsoleChatFormatter/HeadlessConsoleRenderer: render the K2 bot event stream (IRuntimeEventObserver — the same interface a bot policy subscribes) as bracket-labelled lines ([Tell] Bob: hi, [Fellowship] ..., [Local] ...) plus lifecycle/portal/rejected-command lines. A deliberate departure from ChatVM.FormatEntry's retail prose: Headless cannot reference AcDream.UI.Abstractions (the dependency- boundary test), so this is its own terminal-shaped rendering using the same channel-name strings, not a byte-for-byte port. - HeadlessProcessHost: attaches the console only for a single-session run (multi-session is out of scope for this cut per the plan), constructed AFTER every session's own credential resolution so the reader thread never races a StandardInput-provider password prompt on the same stdin stream. /quit's CancellationTokenSource is linked into the scheduler's run token, so it exits through the exact same graceful path an external Ctrl+C/SIGTERM already takes. Mutation check (reverted before commit): removing the reader thread's Enqueue call and removing _quitRequested.Cancel() each turn a still-to-be-added HeadlessConsoleTests case red. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
51f262b285
commit
a843b990a8
7 changed files with 432 additions and 6 deletions
|
|
@ -46,7 +46,8 @@ internal static class HeadlessEntryPoint
|
|||
TextReader standardInput,
|
||||
TextWriter output,
|
||||
TextWriter error,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
bool standardInputIsTerminal = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(arguments);
|
||||
ArgumentNullException.ThrowIfNull(standardInput);
|
||||
|
|
@ -74,13 +75,17 @@ internal static class HeadlessEntryPoint
|
|||
configuredPaths.Merge(commandLine.Paths));
|
||||
if (commandLine.Command == "run")
|
||||
{
|
||||
bool consoleEnabled = HeadlessConsoleOptions.Resolve(
|
||||
commandLine.Console,
|
||||
standardInputIsTerminal);
|
||||
using var host = new HeadlessProcessHost(
|
||||
configuration,
|
||||
paths,
|
||||
standardInput,
|
||||
output,
|
||||
directCredentials:
|
||||
commandLine.DirectCredentials);
|
||||
commandLine.DirectCredentials,
|
||||
consoleEnabled: consoleEnabled);
|
||||
return (int)host.RunAsync(cancellationToken)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
|
|
|
|||
58
src/AcDream.Headless/Hosting/HeadlessConsoleChatFormatter.cs
Normal file
58
src/AcDream.Headless/Hosting/HeadlessConsoleChatFormatter.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
using AcDream.Core.Chat;
|
||||
using AcDream.Runtime;
|
||||
|
||||
namespace AcDream.Headless.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Presentation for the console's rendered chat lines. A distinct, terminal-
|
||||
/// shaped format from the graphical <c>ChatVM.FormatEntry</c> retail prose
|
||||
/// (Headless cannot reference <c>AcDream.UI.Abstractions</c> — see the
|
||||
/// dependency-boundary test — and a script piping console output wants a
|
||||
/// stable, greppable "[Label] Sender: text" shape more than retail's exact
|
||||
/// sentence). It uses the SAME channel-name strings the graphical SpewBox
|
||||
/// shows (<see cref="RuntimeChatEntry.ChannelName"/>, "Tell", "Local") per
|
||||
/// the plan's requirement, just not the same sentence template.
|
||||
/// </summary>
|
||||
internal static class HeadlessConsoleChatFormatter
|
||||
{
|
||||
/// <summary>Formats one chat event for the console, or
|
||||
/// <see langword="null"/> when this kind renders nothing (there are
|
||||
/// none today — kept for forward compatibility with a future silent
|
||||
/// kind).</summary>
|
||||
internal static string? Format(in RuntimeChatEntry entry)
|
||||
{
|
||||
var kind = (ChatKind)entry.Kind;
|
||||
return kind switch
|
||||
{
|
||||
ChatKind.LocalSpeech or ChatKind.RangedSpeech =>
|
||||
$"[Local] {SpeakerLabel(entry.Sender)}: {entry.Text}",
|
||||
ChatKind.Channel =>
|
||||
$"[{ChannelLabel(entry)}] {SpeakerLabel(entry.Sender)}: {entry.Text}",
|
||||
ChatKind.Tell => FormatTell(entry),
|
||||
ChatKind.Emote or ChatKind.SoulEmote =>
|
||||
$"* {entry.Sender} {entry.Text}",
|
||||
ChatKind.Popup => $"[Popup] {entry.Text}",
|
||||
// System/Combat lines arrive pre-formatted (system messages,
|
||||
// combat translator output) — render bare, matching retail's own
|
||||
// no-prefix system-chat convention (Campaign CH user-gate round
|
||||
// 1, item B).
|
||||
_ => entry.Text,
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatTell(in RuntimeChatEntry entry) =>
|
||||
// SenderGuid != 0 is an incoming whisper (see ChatLog.OnTellReceived);
|
||||
// == 0 is our own outbound echo, where Sender carries the target
|
||||
// name (ChatLog.OnSelfSent). Both directions get the "[Tell]" label
|
||||
// the plan asks for; the "You -> " marker is what disambiguates an
|
||||
// outgoing tell from an incoming one in the bracket-label shape.
|
||||
entry.SenderGuid != 0
|
||||
? $"[Tell] {entry.Sender}: {entry.Text}"
|
||||
: $"[Tell] You -> {entry.Sender}: {entry.Text}";
|
||||
|
||||
private static string SpeakerLabel(string sender) =>
|
||||
string.IsNullOrEmpty(sender) || sender == "You" ? "You" : sender;
|
||||
|
||||
private static string ChannelLabel(in RuntimeChatEntry entry) =>
|
||||
string.IsNullOrEmpty(entry.ChannelName) ? "Channel" : entry.ChannelName;
|
||||
}
|
||||
96
src/AcDream.Headless/Hosting/HeadlessConsoleController.cs
Normal file
96
src/AcDream.Headless/Hosting/HeadlessConsoleController.cs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
using AcDream.Runtime.Chat;
|
||||
|
||||
namespace AcDream.Headless.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// The console's own orchestration: owns the background reader
|
||||
/// (<see cref="HeadlessConsoleInputReader"/>) and, once per session tick
|
||||
/// (<see cref="DrainDue"/>), drains every line queued since the last call
|
||||
/// and dispatches each one IN ORDER, on the calling thread — never the
|
||||
/// reader thread (Slice K's monotonic scheduler contract; see
|
||||
/// <see cref="HeadlessConsoleInputReader"/>'s own doc).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>/quit</c> and <c>/status</c> are console-only controls (the plan's
|
||||
/// "Control" section) — they never reach <see cref="ChatCommandRouter"/>,
|
||||
/// matching retail's own client-local commands. Every other line goes
|
||||
/// through <paramref name="submit"/>, which a production caller binds to
|
||||
/// <c>HeadlessSessionHost.SubmitConsoleLine</c> — the exact plugin-verb
|
||||
/// registry → client/server slash-command pipeline
|
||||
/// <c>LoginCommandSequence</c> and the graphical chat box both already use.
|
||||
/// </remarks>
|
||||
internal sealed class HeadlessConsoleController : IDisposable
|
||||
{
|
||||
private readonly HeadlessConsoleInputReader _reader;
|
||||
private readonly TextWriter _output;
|
||||
private readonly Func<string, SubmitOutcome> _submit;
|
||||
private readonly Func<string> _statusText;
|
||||
private readonly CancellationTokenSource _quitRequested;
|
||||
|
||||
internal HeadlessConsoleController(
|
||||
TextReader input,
|
||||
TextWriter output,
|
||||
Func<string, SubmitOutcome> submit,
|
||||
Func<string> statusText,
|
||||
CancellationTokenSource quitRequested)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
_output = output ?? throw new ArgumentNullException(nameof(output));
|
||||
_submit = submit ?? throw new ArgumentNullException(nameof(submit));
|
||||
_statusText = statusText ?? throw new ArgumentNullException(nameof(statusText));
|
||||
_quitRequested = quitRequested
|
||||
?? throw new ArgumentNullException(nameof(quitRequested));
|
||||
_reader = new HeadlessConsoleInputReader(input);
|
||||
}
|
||||
|
||||
/// <summary>Number of lines handled by the most recent
|
||||
/// <see cref="DrainDue"/> call — a test seam for the reader-thread
|
||||
/// ordering assertion.</summary>
|
||||
internal int LastDrainCount { get; private set; }
|
||||
|
||||
/// <summary>Test seam: lets a bounded-fixture test wait for the
|
||||
/// background reader thread to reach EOF before calling
|
||||
/// <see cref="DrainDue"/>, instead of sleeping or polling.</summary>
|
||||
internal HeadlessConsoleInputReader Reader => _reader;
|
||||
|
||||
internal void DrainDue()
|
||||
{
|
||||
int count = 0;
|
||||
while (_reader.TryDequeue(out string line))
|
||||
{
|
||||
Handle(line);
|
||||
count++;
|
||||
}
|
||||
LastDrainCount = count;
|
||||
}
|
||||
|
||||
private void Handle(string rawLine)
|
||||
{
|
||||
string trimmed = rawLine.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
return;
|
||||
|
||||
if (trimmed.Equals("/quit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
WriteLine("quitting (graceful logout)");
|
||||
_quitRequested.Cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed.Equals("/status", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
WriteLine(_statusText());
|
||||
return;
|
||||
}
|
||||
|
||||
_submit(rawLine);
|
||||
}
|
||||
|
||||
private void WriteLine(string text)
|
||||
{
|
||||
_output.WriteLine(text);
|
||||
_output.Flush();
|
||||
}
|
||||
|
||||
public void Dispose() => _reader.Dispose();
|
||||
}
|
||||
94
src/AcDream.Headless/Hosting/HeadlessConsoleInputReader.cs
Normal file
94
src/AcDream.Headless/Hosting/HeadlessConsoleInputReader.cs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
using System.Collections.Concurrent;
|
||||
|
||||
namespace AcDream.Headless.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Reads lines from a <see cref="TextReader"/> on one dedicated background
|
||||
/// thread and hands them to whoever drains <see cref="TryDequeue"/>. Slice K's
|
||||
/// scheduler contract binds every mutating call to one thread for a session's
|
||||
/// whole lifetime (#368 — collision generations refuse migration), so console
|
||||
/// input can never be executed from this thread: it only ever enqueues, and
|
||||
/// the session tick is the sole reader of <see cref="TryDequeue"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="TextReader.ReadLine"/> has no cancellable overload, so a real
|
||||
/// <c>Console.In</c> reader can be blocked on it when the process wants to
|
||||
/// exit. The thread is a background thread (does not keep the process alive)
|
||||
/// and <see cref="Dispose"/> only requests the loop stop at its next
|
||||
/// opportunity — it does not abort a pending read. A closed/EOF input (a
|
||||
/// piped fixture reaching its last line, or the real console's stdin handle
|
||||
/// closing) ends the loop on its own; <see cref="EndOfInput"/> lets a test
|
||||
/// wait for that deterministically instead of polling or sleeping.
|
||||
/// </remarks>
|
||||
internal sealed class HeadlessConsoleInputReader : IDisposable
|
||||
{
|
||||
private readonly TextReader _input;
|
||||
private readonly ConcurrentQueue<string> _queue = new();
|
||||
private readonly Thread _thread;
|
||||
private volatile bool _stopRequested;
|
||||
|
||||
internal HeadlessConsoleInputReader(TextReader input)
|
||||
{
|
||||
_input = input ?? throw new ArgumentNullException(nameof(input));
|
||||
_thread = new Thread(ReadLoop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "acdream-headless-console-reader",
|
||||
};
|
||||
_thread.Start();
|
||||
}
|
||||
|
||||
/// <summary>Set once the reader loop has returned (EOF or stop request).
|
||||
/// Tests wait on this instead of sleeping/polling for a deterministic
|
||||
/// "every line the fixture will ever produce has been enqueued" signal.
|
||||
/// </summary>
|
||||
internal ManualResetEventSlim EndOfInput { get; } = new(initialState: false);
|
||||
|
||||
/// <summary>Dequeues the next queued line in FIFO order, or returns
|
||||
/// <see langword="false"/> if none is queued yet. Never blocks.</summary>
|
||||
internal bool TryDequeue(out string line) => _queue.TryDequeue(out line!);
|
||||
|
||||
private void ReadLoop()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!_stopRequested)
|
||||
{
|
||||
string? line = _input.ReadLine();
|
||||
if (line is null)
|
||||
return;
|
||||
_queue.Enqueue(line);
|
||||
}
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// The input was disposed out from under a pending read (process
|
||||
// teardown racing the reader thread) — end the loop quietly,
|
||||
// same as EOF.
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A redirected stream can fail mid-read (e.g. a broken pipe).
|
||||
// Treat it the same as EOF rather than crashing the process.
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndOfInput.Set();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Requests the read loop stop at its next opportunity. Does
|
||||
/// not abort a <see cref="TextReader.ReadLine"/> already in progress —
|
||||
/// the thread is background, so it cannot block process exit.
|
||||
/// Deliberately does NOT dispose <see cref="EndOfInput"/>: the read
|
||||
/// loop's own <c>finally</c> sets it from the reader thread, and racing
|
||||
/// that against a Dispose() here (an unhandled
|
||||
/// <see cref="ObjectDisposedException"/> on a background thread
|
||||
/// terminates the process) is worse than leaking one small
|
||||
/// synchronization handle for the process's remaining lifetime.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_stopRequested = true;
|
||||
}
|
||||
}
|
||||
94
src/AcDream.Headless/Hosting/HeadlessConsoleRenderer.cs
Normal file
94
src/AcDream.Headless/Hosting/HeadlessConsoleRenderer.cs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
using AcDream.Runtime;
|
||||
|
||||
namespace AcDream.Headless.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// One presentation over the K2 bot event stream
|
||||
/// (<see cref="IRuntimeEventObserver"/>) — the SAME typed events a headless
|
||||
/// bot policy observes (<c>HeadlessBotPolicy.cs</c>) — rendered as plain
|
||||
/// lines. Every write goes through <see cref="WriteLine"/>, so a test can
|
||||
/// assert on exactly what a real console would have printed without a
|
||||
/// terminal.
|
||||
/// </summary>
|
||||
internal sealed class HeadlessConsoleRenderer : IRuntimeEventObserver
|
||||
{
|
||||
private const string Reset = "[0m";
|
||||
private const string Dim = "[2m";
|
||||
|
||||
private readonly TextWriter _output;
|
||||
private readonly bool _useColor;
|
||||
|
||||
internal HeadlessConsoleRenderer(TextWriter output, bool useColor)
|
||||
{
|
||||
_output = output ?? throw new ArgumentNullException(nameof(output));
|
||||
_useColor = useColor;
|
||||
}
|
||||
|
||||
public void OnChat(in RuntimeChatDelta delta)
|
||||
{
|
||||
string? line = HeadlessConsoleChatFormatter.Format(delta.Entry);
|
||||
if (!string.IsNullOrEmpty(line))
|
||||
WriteLine(line);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's transient "interface text" (SpewBox, <c>ClientLocal</c>
|
||||
/// type) never touches <see cref="RuntimeCommunicationState.Chat"/> —
|
||||
/// see <c>RuntimeCommunicationState.AddText</c> — so it never reaches
|
||||
/// <see cref="OnChat"/>. <see cref="HeadlessConsoleChatFeedback"/> calls
|
||||
/// this directly for the SAME text
|
||||
/// <c>ChatCommandRouter.Submit</c>'s <c>ShowInterfaceText</c> path would
|
||||
/// otherwise only enqueue into the polled <c>SpewBoxState</c>.
|
||||
/// </summary>
|
||||
internal void WriteInterfaceText(string text) => WriteLine(text);
|
||||
|
||||
public void OnLifecycle(in RuntimeLifecycleDelta delta)
|
||||
{
|
||||
switch (delta.Current)
|
||||
{
|
||||
case RuntimeLifecycleState.InWorld:
|
||||
WriteLine("entered world");
|
||||
break;
|
||||
case RuntimeLifecycleState.Stopping:
|
||||
WriteLine("disconnecting");
|
||||
break;
|
||||
case RuntimeLifecycleState.Faulted:
|
||||
WriteLine("session faulted");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void OnCommand(in RuntimeCommandDelta delta)
|
||||
{
|
||||
if (delta.Status == RuntimeCommandStatus.Rejected)
|
||||
WriteLine($"command rejected: {delta.Domain} {delta.Text}".TrimEnd());
|
||||
}
|
||||
|
||||
public void OnPortal(in RuntimePortalDelta delta)
|
||||
{
|
||||
if (delta.Portal.IsMaterialized)
|
||||
WriteLine($"portal -> cell 0x{delta.Portal.DestinationCell:X8}");
|
||||
}
|
||||
|
||||
public void OnEntity(in RuntimeEntityDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnInventory(in RuntimeInventoryDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnMovement(in RuntimeMovementDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnCombat(in RuntimeCombatDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
private void WriteLine(string text)
|
||||
{
|
||||
_output.WriteLine(_useColor ? Dim + text + Reset : text);
|
||||
_output.Flush();
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,16 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
private readonly HeadlessDiagnosticWriter _diagnostics;
|
||||
private readonly HeadlessProcessContentOwner? _content;
|
||||
private readonly HeadlessProcessResourceSampler _resources;
|
||||
/// <summary>
|
||||
/// Headless console (docs/plans/2026-09-07-headless-console.md): always
|
||||
/// created, cancelled only by <c>/quit</c> — linking it into the
|
||||
/// scheduler's run token below costs nothing when the console is
|
||||
/// disabled (it simply never fires) and keeps <see cref="RunOnUpdateThread"/>
|
||||
/// free of a console-shaped branch.
|
||||
/// </summary>
|
||||
private readonly CancellationTokenSource _consoleQuitRequested = new();
|
||||
private readonly HeadlessConsoleController? _console;
|
||||
private readonly IDisposable? _consoleRendererSubscription;
|
||||
private int _disposeIndex;
|
||||
private bool _disposed;
|
||||
|
||||
|
|
@ -26,7 +36,8 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
ILiveSessionOperations? sessionOperations = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
IHeadlessProcessContentFactory? contentFactory = null,
|
||||
HeadlessDirectCredentials? directCredentials = null)
|
||||
HeadlessDirectCredentials? directCredentials = null,
|
||||
bool consoleEnabled = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
|
@ -65,6 +76,8 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
paths.VtankProfilesDirectory);
|
||||
HeadlessProcessContentOwner? content = null;
|
||||
HeadlessProcessResourceSampler? resources = null;
|
||||
HeadlessConsoleController? console = null;
|
||||
IDisposable? consoleRendererSubscription = null;
|
||||
// FA6: constructed unconditionally — cheap, and every non-gate
|
||||
// session simply never reads or writes it (see the coordinator's
|
||||
// own class doc).
|
||||
|
|
@ -137,9 +150,39 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
_resources = resources;
|
||||
_content = content;
|
||||
_disposeIndex = _sessions.Length - 1;
|
||||
|
||||
// Headless console (docs/plans/2026-09-07-headless-console.md):
|
||||
// "Multi-session. Out of scope for the first cut" — attach only
|
||||
// to a single-session process. Constructed AFTER every
|
||||
// session's credential resolution above (which may itself read
|
||||
// a line from standardInput for a StandardInput-provider
|
||||
// credential) so the console's own reader thread never races a
|
||||
// password prompt for the same stream.
|
||||
if (consoleEnabled && _sessions.Length == 1)
|
||||
{
|
||||
HeadlessSessionHost session = _sessions[0];
|
||||
var renderer = new HeadlessConsoleRenderer(
|
||||
diagnostics,
|
||||
useColor: !System.Console.IsOutputRedirected);
|
||||
consoleRendererSubscription =
|
||||
session.Runtime.Subscribe(renderer);
|
||||
console = new HeadlessConsoleController(
|
||||
standardInput,
|
||||
diagnostics,
|
||||
line => session.SubmitConsoleLine(
|
||||
line,
|
||||
renderer.WriteInterfaceText),
|
||||
() => BuildStatusText(session),
|
||||
_consoleQuitRequested);
|
||||
session.ConsolePump = console.DrainDue;
|
||||
}
|
||||
_console = console;
|
||||
_consoleRendererSubscription = consoleRendererSubscription;
|
||||
}
|
||||
catch
|
||||
{
|
||||
console?.Dispose();
|
||||
consoleRendererSubscription?.Dispose();
|
||||
resources?.Dispose();
|
||||
for (int index = sessions.Count - 1; index >= 0; index--)
|
||||
sessions[index].Dispose();
|
||||
|
|
@ -148,6 +191,29 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>/status</c>: generation, position (or "unknown" without a live
|
||||
/// movement controller — a content-less host, or before the first
|
||||
/// accepted placement), and the plugin-visible macro state this host
|
||||
/// can actually observe today (loaded-plugin count — no plugin
|
||||
/// currently reports a richer status string; see the plan's "if the
|
||||
/// plugin reports one").
|
||||
/// </summary>
|
||||
private static string BuildStatusText(HeadlessSessionHost session)
|
||||
{
|
||||
RuntimeMovementSnapshot movement =
|
||||
session.Runtime.MovementOwner.Snapshot;
|
||||
string position = movement.HasController
|
||||
? $"cell=0x{movement.Position.ObjCellId:X8} "
|
||||
+ $"local=({movement.Position.Frame.Origin.X:F2},"
|
||||
+ $"{movement.Position.Frame.Origin.Y:F2},"
|
||||
+ $"{movement.Position.Frame.Origin.Z:F2})"
|
||||
: "unknown";
|
||||
return $"generation={session.Runtime.Generation.Value} "
|
||||
+ $"position={position} "
|
||||
+ $"plugins={session.Plugins.LoadedCount} loaded";
|
||||
}
|
||||
|
||||
internal HeadlessSessionHost Session => _sessions.Length == 1
|
||||
? _sessions[0]
|
||||
: throw new InvalidOperationException(
|
||||
|
|
@ -249,12 +315,21 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
_scheduler.CaptureSnapshot(),
|
||||
_content);
|
||||
|
||||
// Headless console: /quit cancels _consoleQuitRequested, which this
|
||||
// linked token propagates into the scheduler's own wait loop —
|
||||
// Run() returns normally (its loop condition simply goes false),
|
||||
// the SAME graceful-exit path an external Ctrl+C/SIGTERM already
|
||||
// takes. Linking costs nothing when the console never fires.
|
||||
using CancellationTokenSource linkedQuit =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken,
|
||||
_consoleQuitRequested.Token);
|
||||
try
|
||||
{
|
||||
_scheduler.Run(cancellationToken);
|
||||
_scheduler.Run(linkedQuit.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
when (cancellationToken.IsCancellationRequested)
|
||||
when (linkedQuit.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception error)
|
||||
|
|
@ -277,6 +352,9 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_console?.Dispose();
|
||||
_consoleRendererSubscription?.Dispose();
|
||||
_consoleQuitRequested.Dispose();
|
||||
while (_disposeIndex >= 0)
|
||||
{
|
||||
_sessions[_disposeIndex].Dispose();
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ try
|
|||
Console.In,
|
||||
Console.Out,
|
||||
Console.Error,
|
||||
cancellation.Token);
|
||||
cancellation.Token,
|
||||
standardInputIsTerminal: !Console.IsInputRedirected);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue