fix(launcher): Campaign LA close LA2 review findings
This commit is contained in:
parent
000ea979d5
commit
1c5e66c05b
8 changed files with 346 additions and 58 deletions
|
|
@ -47,8 +47,9 @@ internal sealed record HeadlessSessionDescriptor
|
|||
public string Account { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: ABSENT (<see langword="null"/>) for normal play
|
||||
/// sessions; <see cref="HeadlessSessionMode.Probe"/> for the LA2 probe
|
||||
/// Campaign LA slice LA2: the JSON field is ABSENT for normal play
|
||||
/// sessions (explicit JSON <c>null</c> is invalid);
|
||||
/// <see cref="HeadlessSessionMode.Probe"/> for the LA2 probe
|
||||
/// (connect → characterList → graceful disconnect, never EnterWorld) —
|
||||
/// the pinned launch-contract schema's <c>mode</c> field
|
||||
/// (<c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1/LA2).
|
||||
|
|
|
|||
|
|
@ -97,10 +97,15 @@ internal static class HeadlessConfigurationLoader
|
|||
|
||||
string fullPath = Path.GetFullPath(path);
|
||||
using FileStream stream = File.OpenRead(fullPath);
|
||||
using JsonDocument document = JsonDocument.Parse(
|
||||
stream,
|
||||
new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = false,
|
||||
CommentHandling = JsonCommentHandling.Disallow,
|
||||
});
|
||||
HeadlessConfiguration? configuration =
|
||||
JsonSerializer.Deserialize<HeadlessConfiguration>(
|
||||
stream,
|
||||
Options);
|
||||
document.RootElement.Deserialize<HeadlessConfiguration>(Options);
|
||||
|
||||
if (configuration is null)
|
||||
{
|
||||
|
|
@ -123,9 +128,12 @@ internal static class HeadlessConfigurationLoader
|
|||
|
||||
ValidateContent(configuration.Process?.Content);
|
||||
|
||||
JsonElement sessionsElement =
|
||||
document.RootElement.GetProperty("sessions");
|
||||
var sessionIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
var credentialReferences = new HashSet<string>(
|
||||
StringComparer.Ordinal);
|
||||
int sessionIndex = 0;
|
||||
foreach (HeadlessSessionDescriptor? session in configuration.Sessions)
|
||||
{
|
||||
if (session is null
|
||||
|
|
@ -141,7 +149,7 @@ internal static class HeadlessConfigurationLoader
|
|||
$"Duplicate session id '{session.Id}'.");
|
||||
}
|
||||
|
||||
ValidateSession(session);
|
||||
ValidateSession(session, sessionsElement[sessionIndex]);
|
||||
string credentialKey =
|
||||
$"{session.Credential.Provider}:{session.Credential.Reference}";
|
||||
if (!credentialReferences.Add(credentialKey))
|
||||
|
|
@ -149,6 +157,7 @@ internal static class HeadlessConfigurationLoader
|
|||
throw new HeadlessConfigurationException(
|
||||
$"Credential reference for session '{session.Id}' is already in use.");
|
||||
}
|
||||
sessionIndex++;
|
||||
}
|
||||
|
||||
return configuration;
|
||||
|
|
@ -166,7 +175,9 @@ internal static class HeadlessConfigurationLoader
|
|||
}
|
||||
}
|
||||
|
||||
private static void ValidateSession(HeadlessSessionDescriptor session)
|
||||
private static void ValidateSession(
|
||||
HeadlessSessionDescriptor session,
|
||||
JsonElement sessionElement)
|
||||
{
|
||||
if (session.Endpoint is null
|
||||
|| string.IsNullOrWhiteSpace(session.Endpoint.Host)
|
||||
|
|
@ -182,7 +193,7 @@ internal static class HeadlessConfigurationLoader
|
|||
$"Session '{session.Id}' requires a non-empty account.");
|
||||
}
|
||||
|
||||
ValidateModeShape(session);
|
||||
ValidateModeShape(session, sessionElement);
|
||||
|
||||
if (session.Credential is null
|
||||
|| string.IsNullOrWhiteSpace(session.Credential.Reference))
|
||||
|
|
@ -214,17 +225,37 @@ internal static class HeadlessConfigurationLoader
|
|||
/// that also declares a selector or a policy, rather than silently
|
||||
/// ignoring them.
|
||||
/// </summary>
|
||||
private static void ValidateModeShape(HeadlessSessionDescriptor session)
|
||||
private static void ValidateModeShape(
|
||||
HeadlessSessionDescriptor session,
|
||||
JsonElement sessionElement)
|
||||
{
|
||||
bool hasMode = sessionElement.TryGetProperty(
|
||||
"mode",
|
||||
out JsonElement modeElement);
|
||||
bool hasCharacter = sessionElement.TryGetProperty(
|
||||
"character",
|
||||
out JsonElement characterElement);
|
||||
bool hasPolicy = sessionElement.TryGetProperty(
|
||||
"policy",
|
||||
out JsonElement policyElement);
|
||||
|
||||
RejectExplicitNull(session.Id, "mode", hasMode, modeElement);
|
||||
RejectExplicitNull(
|
||||
session.Id,
|
||||
"character",
|
||||
hasCharacter,
|
||||
characterElement);
|
||||
RejectExplicitNull(session.Id, "policy", hasPolicy, policyElement);
|
||||
|
||||
if (session.Mode == HeadlessSessionMode.Probe)
|
||||
{
|
||||
if (session.Character is not null)
|
||||
if (hasCharacter)
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
$"Session '{session.Id}' has mode \"probe\" and must omit "
|
||||
+ "'character' — a probe never selects a character.");
|
||||
}
|
||||
if (session.Policy is not null)
|
||||
if (hasPolicy)
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
$"Session '{session.Id}' has mode \"probe\" and must omit "
|
||||
|
|
@ -233,6 +264,12 @@ internal static class HeadlessConfigurationLoader
|
|||
return;
|
||||
}
|
||||
|
||||
if (hasMode)
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
$"Session '{session.Id}' is normal play and must omit 'mode'.");
|
||||
}
|
||||
|
||||
if (session.Character is null)
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
|
|
@ -259,6 +296,29 @@ internal static class HeadlessConfigurationLoader
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA LA2 review fix: the pinned launch contract distinguishes
|
||||
/// an omitted conditional field from a field explicitly authored as JSON
|
||||
/// <c>null</c>. Nullable CLR properties cannot retain that distinction, so
|
||||
/// validation also consumes the already-parsed strict JSON shape. The
|
||||
/// typed serializer still owns unknown-member, enum, and value-type
|
||||
/// enforcement; this check adds presence semantics without weakening any
|
||||
/// of those gates.
|
||||
/// </summary>
|
||||
private static void RejectExplicitNull(
|
||||
string sessionId,
|
||||
string propertyName,
|
||||
bool isPresent,
|
||||
JsonElement value)
|
||||
{
|
||||
if (isPresent && value.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
$"Session '{sessionId}' field '{propertyName}' cannot be null; "
|
||||
+ "supply a value when allowed or omit the field.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: validates the four new optional per-session
|
||||
/// fields shared with the App session-config reader (see
|
||||
|
|
|
|||
|
|
@ -120,6 +120,15 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
/// rework of the shared-stdout diagnostics writer.
|
||||
/// </summary>
|
||||
private readonly SessionStatusWriter _statusWriter;
|
||||
/// <summary>
|
||||
/// Campaign LA LA2 review fix: the actual result returned by the process
|
||||
/// start attempt. A configured probe mode is only intent; terminal status
|
||||
/// may claim <c>reason:"probe"</c> after this records
|
||||
/// <see cref="RuntimeSessionStartStatus.ProbeComplete"/>. Any other
|
||||
/// non-connected result maps to the same connection-error code returned by
|
||||
/// <see cref="HeadlessProcessHost"/>.
|
||||
/// </summary>
|
||||
private RuntimeSessionStartStatus? _startOutcome;
|
||||
/// <summary>Guards <see cref="Stop"/>'s <c>disconnected</c> status event
|
||||
/// so a Stop() on a session that never actually reached Connected (e.g.
|
||||
/// disposing a fresh, never-started host) does not report a spurious
|
||||
|
|
@ -465,7 +474,10 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
// Campaign LA slice LA1: "started" = session host start — the
|
||||
// earliest point this session actually attempts to connect.
|
||||
_statusWriter.Started(_descriptor.Id);
|
||||
return Commands.Session.Start(Runtime.Generation);
|
||||
RuntimeSessionStartResult result =
|
||||
Commands.Session.Start(Runtime.Generation);
|
||||
_startOutcome = result.Status;
|
||||
return result;
|
||||
}
|
||||
|
||||
internal RuntimeSessionStartResult Reconnect() =>
|
||||
|
|
@ -647,18 +659,16 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
_stoppedGeneration);
|
||||
// Campaign LA slice LA1: "exited" = terminal — the sole
|
||||
// point every disposal path (graceful and post-
|
||||
// quarantine) converges on. LA2: a probe session that
|
||||
// never faulted reports reason "probe" here instead of
|
||||
// "disposed" — the pinned contract's exit event for a
|
||||
// successful probe.
|
||||
// quarantine) converges on. LA2: only an actual
|
||||
// ProbeComplete start outcome reports reason "probe";
|
||||
// configured probe intent cannot turn a failed start into
|
||||
// a successful terminal event.
|
||||
(int exitCode, string exitReason) =
|
||||
ResolveTerminalStatus();
|
||||
_statusWriter.Exited(
|
||||
_descriptor.Id,
|
||||
_faulted ? 1 : 0,
|
||||
_faulted
|
||||
? "fault"
|
||||
: _descriptor.Mode == HeadlessSessionMode.Probe
|
||||
? "probe"
|
||||
: "disposed");
|
||||
exitCode,
|
||||
exitReason);
|
||||
_disposeStage++;
|
||||
_disposed = true;
|
||||
break;
|
||||
|
|
@ -669,6 +679,33 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Produces the same terminal classification the owning process host uses.
|
||||
/// Descriptor mode never participates: only an observed ProbeComplete may
|
||||
/// report a successful probe. The surrounding disposal stage and LA1's
|
||||
/// terminal/idempotent <see cref="SessionStatusWriter"/> make this event
|
||||
/// exact-once even when disposal is retried.
|
||||
/// </summary>
|
||||
private (int Code, string Reason) ResolveTerminalStatus()
|
||||
{
|
||||
if (_faulted)
|
||||
{
|
||||
return (
|
||||
(int)HeadlessExitCode.RuntimeError,
|
||||
"runtime-fault");
|
||||
}
|
||||
|
||||
return _startOutcome switch
|
||||
{
|
||||
RuntimeSessionStartStatus.ProbeComplete =>
|
||||
((int)HeadlessExitCode.Success, "probe"),
|
||||
null or RuntimeSessionStartStatus.Connected =>
|
||||
((int)HeadlessExitCode.Success, "graceful"),
|
||||
_ =>
|
||||
((int)HeadlessExitCode.ConnectionError, "connection-error"),
|
||||
};
|
||||
}
|
||||
|
||||
private RuntimeSessionStartResult StartCore(
|
||||
RuntimeGenerationToken expectedGeneration,
|
||||
bool reconnect)
|
||||
|
|
|
|||
|
|
@ -656,16 +656,13 @@ public sealed class LiveSessionController
|
|||
}
|
||||
|
||||
// Campaign LA slice LA2: the probe short-circuit lands here —
|
||||
// right after the roster report, before TrySelectCharacter ever
|
||||
// runs — so a probe session never reaches selection,
|
||||
// ApplySelectedCharacter, or EnterWorld. This mirrors the
|
||||
// NoCharacters early-exit immediately below (same StopCore
|
||||
// teardown), the deliberate difference being the returned status
|
||||
// is a SUCCESS, not a failure. Non-probe callers (options.Probe
|
||||
// is false by default) fall straight through to the unchanged
|
||||
// selection/enter path below — byte-identical to pre-LA2
|
||||
// behavior.
|
||||
if (options.Probe)
|
||||
// only after a real CharacterList was returned and its roster was
|
||||
// reported, before TrySelectCharacter ever runs. A missing
|
||||
// CharacterList falls through to the existing NoCharacters
|
||||
// non-success path below; connectivity by itself is not a
|
||||
// successful character-roster probe. Non-probe callers continue
|
||||
// through the unchanged selection/enter path.
|
||||
if (options.Probe && characters is not null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"live: probe complete — disconnecting before EnterWorld");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue