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
|
|
@ -478,7 +478,7 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny.
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched |
|
| LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched |
|
||||||
| LA1 | implemented; Opus review in flight | `db9ad53c` (mixed — see `e1322a06`) | review in flight | Runtime 1630 / Headless 126 / App 5025+3skip / Core.Net 905 green; Runtime+Headless green on WSL; shared fixture parsed by BOTH host readers |
|
| LA1 | implemented; Opus review in flight | `db9ad53c` (mixed — see `e1322a06`) | review in flight | Runtime 1630 / Headless 126 / App 5025+3skip / Core.Net 905 green; Runtime+Headless green on WSL; shared fixture parsed by BOTH host readers |
|
||||||
| LA2 | implementation complete; automated gates **GREEN**; Opus dual-lens review pending | `c6019424` + completion (this commit) | pending | Probe is roster-before-selection with graceful pre-world teardown and exit 0; normal play remains strict selector + `idle` policy. Release build green; Runtime 1,632/1,632 and Headless 141/141 on both Windows and Ubuntu/WSL |
|
| LA2 | review fix round complete; automated gates **GREEN**; narrow re-review pending | `c6019424`, `000ea979` + review fixes (this commit) | Opus dual-lens FIX FIRST; all 3 findings fixed, narrow re-review pending | ProbeComplete now requires a reported roster and is still before selection/EnterWorld; terminal status derives from the actual start outcome and matches failed-start process code 5; conditional config fields distinguish omission from explicit null without weakening strict JSON shape/type checks. Release build green on Windows and Ubuntu/WSL; Runtime 1,632/1,632 and Headless 149/149 on both |
|
||||||
| LA3 | review FIX FIRST; fix round in flight | `37d74e44` + fixes pending | Opus 2026-08-14: 12 findings — 1 CRITICAL (`"paths": {}` breaks App loader), probe composition owed, Stop→SIGKILL hazard, 0600 temp window | Contract text now COMMITTED into LA1 section (review process note); cross-assembly loader test owed at LA1+LA3 merge; CI lane addition at merge |
|
| LA3 | review FIX FIRST; fix round in flight | `37d74e44` + fixes pending | Opus 2026-08-14: 12 findings — 1 CRITICAL (`"paths": {}` breaks App loader), probe composition owed, Stop→SIGKILL hazard, 0600 temp window | Contract text now COMMITTED into LA1 section (review process note); cross-assembly loader test owed at LA1+LA3 merge; CI lane addition at merge |
|
||||||
| LA4 | — | | | |
|
| LA4 | — | | | |
|
||||||
| LA5 | — | | | |
|
| LA5 | — | | | |
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,9 @@ internal sealed record HeadlessSessionDescriptor
|
||||||
public string Account { get; init; } = string.Empty;
|
public string Account { get; init; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Campaign LA slice LA2: ABSENT (<see langword="null"/>) for normal play
|
/// Campaign LA slice LA2: the JSON field is ABSENT for normal play
|
||||||
/// sessions; <see cref="HeadlessSessionMode.Probe"/> for the LA2 probe
|
/// sessions (explicit JSON <c>null</c> is invalid);
|
||||||
|
/// <see cref="HeadlessSessionMode.Probe"/> for the LA2 probe
|
||||||
/// (connect → characterList → graceful disconnect, never EnterWorld) —
|
/// (connect → characterList → graceful disconnect, never EnterWorld) —
|
||||||
/// the pinned launch-contract schema's <c>mode</c> field
|
/// the pinned launch-contract schema's <c>mode</c> field
|
||||||
/// (<c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1/LA2).
|
/// (<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);
|
string fullPath = Path.GetFullPath(path);
|
||||||
using FileStream stream = File.OpenRead(fullPath);
|
using FileStream stream = File.OpenRead(fullPath);
|
||||||
|
using JsonDocument document = JsonDocument.Parse(
|
||||||
|
stream,
|
||||||
|
new JsonDocumentOptions
|
||||||
|
{
|
||||||
|
AllowTrailingCommas = false,
|
||||||
|
CommentHandling = JsonCommentHandling.Disallow,
|
||||||
|
});
|
||||||
HeadlessConfiguration? configuration =
|
HeadlessConfiguration? configuration =
|
||||||
JsonSerializer.Deserialize<HeadlessConfiguration>(
|
document.RootElement.Deserialize<HeadlessConfiguration>(Options);
|
||||||
stream,
|
|
||||||
Options);
|
|
||||||
|
|
||||||
if (configuration is null)
|
if (configuration is null)
|
||||||
{
|
{
|
||||||
|
|
@ -123,9 +128,12 @@ internal static class HeadlessConfigurationLoader
|
||||||
|
|
||||||
ValidateContent(configuration.Process?.Content);
|
ValidateContent(configuration.Process?.Content);
|
||||||
|
|
||||||
|
JsonElement sessionsElement =
|
||||||
|
document.RootElement.GetProperty("sessions");
|
||||||
var sessionIds = new HashSet<string>(StringComparer.Ordinal);
|
var sessionIds = new HashSet<string>(StringComparer.Ordinal);
|
||||||
var credentialReferences = new HashSet<string>(
|
var credentialReferences = new HashSet<string>(
|
||||||
StringComparer.Ordinal);
|
StringComparer.Ordinal);
|
||||||
|
int sessionIndex = 0;
|
||||||
foreach (HeadlessSessionDescriptor? session in configuration.Sessions)
|
foreach (HeadlessSessionDescriptor? session in configuration.Sessions)
|
||||||
{
|
{
|
||||||
if (session is null
|
if (session is null
|
||||||
|
|
@ -141,7 +149,7 @@ internal static class HeadlessConfigurationLoader
|
||||||
$"Duplicate session id '{session.Id}'.");
|
$"Duplicate session id '{session.Id}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
ValidateSession(session);
|
ValidateSession(session, sessionsElement[sessionIndex]);
|
||||||
string credentialKey =
|
string credentialKey =
|
||||||
$"{session.Credential.Provider}:{session.Credential.Reference}";
|
$"{session.Credential.Provider}:{session.Credential.Reference}";
|
||||||
if (!credentialReferences.Add(credentialKey))
|
if (!credentialReferences.Add(credentialKey))
|
||||||
|
|
@ -149,6 +157,7 @@ internal static class HeadlessConfigurationLoader
|
||||||
throw new HeadlessConfigurationException(
|
throw new HeadlessConfigurationException(
|
||||||
$"Credential reference for session '{session.Id}' is already in use.");
|
$"Credential reference for session '{session.Id}' is already in use.");
|
||||||
}
|
}
|
||||||
|
sessionIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return configuration;
|
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
|
if (session.Endpoint is null
|
||||||
|| string.IsNullOrWhiteSpace(session.Endpoint.Host)
|
|| string.IsNullOrWhiteSpace(session.Endpoint.Host)
|
||||||
|
|
@ -182,7 +193,7 @@ internal static class HeadlessConfigurationLoader
|
||||||
$"Session '{session.Id}' requires a non-empty account.");
|
$"Session '{session.Id}' requires a non-empty account.");
|
||||||
}
|
}
|
||||||
|
|
||||||
ValidateModeShape(session);
|
ValidateModeShape(session, sessionElement);
|
||||||
|
|
||||||
if (session.Credential is null
|
if (session.Credential is null
|
||||||
|| string.IsNullOrWhiteSpace(session.Credential.Reference))
|
|| string.IsNullOrWhiteSpace(session.Credential.Reference))
|
||||||
|
|
@ -214,17 +225,37 @@ internal static class HeadlessConfigurationLoader
|
||||||
/// that also declares a selector or a policy, rather than silently
|
/// that also declares a selector or a policy, rather than silently
|
||||||
/// ignoring them.
|
/// ignoring them.
|
||||||
/// </summary>
|
/// </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.Mode == HeadlessSessionMode.Probe)
|
||||||
{
|
{
|
||||||
if (session.Character is not null)
|
if (hasCharacter)
|
||||||
{
|
{
|
||||||
throw new HeadlessConfigurationException(
|
throw new HeadlessConfigurationException(
|
||||||
$"Session '{session.Id}' has mode \"probe\" and must omit "
|
$"Session '{session.Id}' has mode \"probe\" and must omit "
|
||||||
+ "'character' — a probe never selects a character.");
|
+ "'character' — a probe never selects a character.");
|
||||||
}
|
}
|
||||||
if (session.Policy is not null)
|
if (hasPolicy)
|
||||||
{
|
{
|
||||||
throw new HeadlessConfigurationException(
|
throw new HeadlessConfigurationException(
|
||||||
$"Session '{session.Id}' has mode \"probe\" and must omit "
|
$"Session '{session.Id}' has mode \"probe\" and must omit "
|
||||||
|
|
@ -233,6 +264,12 @@ internal static class HeadlessConfigurationLoader
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasMode)
|
||||||
|
{
|
||||||
|
throw new HeadlessConfigurationException(
|
||||||
|
$"Session '{session.Id}' is normal play and must omit 'mode'.");
|
||||||
|
}
|
||||||
|
|
||||||
if (session.Character is null)
|
if (session.Character is null)
|
||||||
{
|
{
|
||||||
throw new HeadlessConfigurationException(
|
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>
|
/// <summary>
|
||||||
/// Campaign LA slice LA1: validates the four new optional per-session
|
/// Campaign LA slice LA1: validates the four new optional per-session
|
||||||
/// fields shared with the App session-config reader (see
|
/// 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.
|
/// rework of the shared-stdout diagnostics writer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly SessionStatusWriter _statusWriter;
|
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
|
/// <summary>Guards <see cref="Stop"/>'s <c>disconnected</c> status event
|
||||||
/// so a Stop() on a session that never actually reached Connected (e.g.
|
/// so a Stop() on a session that never actually reached Connected (e.g.
|
||||||
/// disposing a fresh, never-started host) does not report a spurious
|
/// 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
|
// Campaign LA slice LA1: "started" = session host start — the
|
||||||
// earliest point this session actually attempts to connect.
|
// earliest point this session actually attempts to connect.
|
||||||
_statusWriter.Started(_descriptor.Id);
|
_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() =>
|
internal RuntimeSessionStartResult Reconnect() =>
|
||||||
|
|
@ -647,18 +659,16 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
_stoppedGeneration);
|
_stoppedGeneration);
|
||||||
// Campaign LA slice LA1: "exited" = terminal — the sole
|
// Campaign LA slice LA1: "exited" = terminal — the sole
|
||||||
// point every disposal path (graceful and post-
|
// point every disposal path (graceful and post-
|
||||||
// quarantine) converges on. LA2: a probe session that
|
// quarantine) converges on. LA2: only an actual
|
||||||
// never faulted reports reason "probe" here instead of
|
// ProbeComplete start outcome reports reason "probe";
|
||||||
// "disposed" — the pinned contract's exit event for a
|
// configured probe intent cannot turn a failed start into
|
||||||
// successful probe.
|
// a successful terminal event.
|
||||||
|
(int exitCode, string exitReason) =
|
||||||
|
ResolveTerminalStatus();
|
||||||
_statusWriter.Exited(
|
_statusWriter.Exited(
|
||||||
_descriptor.Id,
|
_descriptor.Id,
|
||||||
_faulted ? 1 : 0,
|
exitCode,
|
||||||
_faulted
|
exitReason);
|
||||||
? "fault"
|
|
||||||
: _descriptor.Mode == HeadlessSessionMode.Probe
|
|
||||||
? "probe"
|
|
||||||
: "disposed");
|
|
||||||
_disposeStage++;
|
_disposeStage++;
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
break;
|
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(
|
private RuntimeSessionStartResult StartCore(
|
||||||
RuntimeGenerationToken expectedGeneration,
|
RuntimeGenerationToken expectedGeneration,
|
||||||
bool reconnect)
|
bool reconnect)
|
||||||
|
|
|
||||||
|
|
@ -656,16 +656,13 @@ public sealed class LiveSessionController
|
||||||
}
|
}
|
||||||
|
|
||||||
// Campaign LA slice LA2: the probe short-circuit lands here —
|
// Campaign LA slice LA2: the probe short-circuit lands here —
|
||||||
// right after the roster report, before TrySelectCharacter ever
|
// only after a real CharacterList was returned and its roster was
|
||||||
// runs — so a probe session never reaches selection,
|
// reported, before TrySelectCharacter ever runs. A missing
|
||||||
// ApplySelectedCharacter, or EnterWorld. This mirrors the
|
// CharacterList falls through to the existing NoCharacters
|
||||||
// NoCharacters early-exit immediately below (same StopCore
|
// non-success path below; connectivity by itself is not a
|
||||||
// teardown), the deliberate difference being the returned status
|
// successful character-roster probe. Non-probe callers continue
|
||||||
// is a SUCCESS, not a failure. Non-probe callers (options.Probe
|
// through the unchanged selection/enter path.
|
||||||
// is false by default) fall straight through to the unchanged
|
if (options.Probe && characters is not null)
|
||||||
// selection/enter path below — byte-identical to pre-LA2
|
|
||||||
// behavior.
|
|
||||||
if (options.Probe)
|
|
||||||
{
|
{
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
"live: probe complete — disconnecting before EnterWorld");
|
"live: probe complete — disconnecting before EnterWorld");
|
||||||
|
|
|
||||||
|
|
@ -269,6 +269,105 @@ public sealed class HeadlessConfigurationLoaderTests
|
||||||
() => HeadlessConfigurationLoader.Load(file.Path));
|
() => HeadlessConfigurationLoader.Load(file.Path));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign LA LA2 review fix: the pinned contract is presence-aware.
|
||||||
|
/// Normal play omits mode and supplies character/policy; probe supplies
|
||||||
|
/// mode and omits character/policy. JSON null is not another spelling of
|
||||||
|
/// omission for any of those conditional fields.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false, "mode")]
|
||||||
|
[InlineData(false, "character")]
|
||||||
|
[InlineData(false, "policy")]
|
||||||
|
[InlineData(true, "character")]
|
||||||
|
[InlineData(true, "policy")]
|
||||||
|
public void ConditionalSessionFieldsRejectExplicitJsonNull(
|
||||||
|
bool probe,
|
||||||
|
string nullField)
|
||||||
|
{
|
||||||
|
string mode = probe
|
||||||
|
? "\"mode\":\"probe\","
|
||||||
|
: nullField == "mode"
|
||||||
|
? "\"mode\":null,"
|
||||||
|
: string.Empty;
|
||||||
|
string character = nullField == "character"
|
||||||
|
? "\"character\":null,"
|
||||||
|
: probe
|
||||||
|
? string.Empty
|
||||||
|
: "\"character\":{\"index\":0},";
|
||||||
|
string policy = nullField == "policy"
|
||||||
|
? "\"policy\":null,"
|
||||||
|
: probe
|
||||||
|
? string.Empty
|
||||||
|
: "\"policy\":{\"id\":\"idle\"},";
|
||||||
|
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
||||||
|
$$"""
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"id": "explicit-null",
|
||||||
|
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||||
|
"account": "account",
|
||||||
|
{{mode}}
|
||||||
|
{{character}}
|
||||||
|
{{policy}}
|
||||||
|
"credential": { "provider": "environment", "reference": "PASSWORD" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
HeadlessConfigurationException exception = Assert.Throws<
|
||||||
|
HeadlessConfigurationException>(
|
||||||
|
() => HeadlessConfigurationLoader.Load(file.Path));
|
||||||
|
|
||||||
|
Assert.Contains(
|
||||||
|
$"'{nullField}'",
|
||||||
|
exception.Message,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains(
|
||||||
|
"cannot be null",
|
||||||
|
exception.Message,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PresenceAwareValidationKeepsUnmappedMemberRejection()
|
||||||
|
{
|
||||||
|
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
||||||
|
ConfigurationWith(Session(
|
||||||
|
"bot",
|
||||||
|
"PASSWORD",
|
||||||
|
"\"notAContractField\":true")));
|
||||||
|
|
||||||
|
Assert.Throws<JsonException>(
|
||||||
|
() => HeadlessConfigurationLoader.Load(file.Path));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PresenceAwareValidationKeepsTypedValueRejection()
|
||||||
|
{
|
||||||
|
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"id": "wrong-type",
|
||||||
|
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||||
|
"account": "account",
|
||||||
|
"mode": { "value": "probe" },
|
||||||
|
"credential": { "provider": "environment", "reference": "PASSWORD" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
Assert.Throws<JsonException>(
|
||||||
|
() => HeadlessConfigurationLoader.Load(file.Path));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ProbeSessionDeclaringCharacterFailsLoadNamingTheField()
|
public void ProbeSessionDeclaringCharacterFailsLoadNamingTheField()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,85 @@ public sealed class HeadlessSessionHostTests
|
||||||
Assert.False(cancellation.IsCancellationRequested);
|
Assert.False(cancellation.IsCancellationRequested);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign LA LA2 review fix: configured probe intent is not proof of a
|
||||||
|
/// completed probe. If the connected session produces no CharacterList,
|
||||||
|
/// Runtime returns NoCharacters, the process returns ConnectionError, and
|
||||||
|
/// the sole terminal status event reports that same non-success instead of
|
||||||
|
/// the former false code-0/reason-probe pair.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeWithoutRosterReportsTheProcessConnectionErrorExactlyOnce()
|
||||||
|
{
|
||||||
|
string statusPath = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"acdream-headless-probe-no-roster-{Guid.NewGuid():N}.jsonl");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var configuration = new HeadlessConfiguration
|
||||||
|
{
|
||||||
|
Version = 1,
|
||||||
|
Sessions =
|
||||||
|
[
|
||||||
|
ProbeDescriptor(
|
||||||
|
provider: HeadlessCredentialProviderKind.StandardInput,
|
||||||
|
credentialReference: "probe-password",
|
||||||
|
statusFile: statusPath),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
var operations = new FixtureSessionOperations
|
||||||
|
{
|
||||||
|
Characters = null,
|
||||||
|
};
|
||||||
|
using var diagnostics = new StringWriter();
|
||||||
|
using var host = new HeadlessProcessHost(
|
||||||
|
configuration,
|
||||||
|
HeadlessPathSet.Resolve(new HeadlessPathOverrides()),
|
||||||
|
new System.IO.StringReader(
|
||||||
|
"probe-password" + Environment.NewLine),
|
||||||
|
diagnostics,
|
||||||
|
operations);
|
||||||
|
|
||||||
|
HeadlessExitCode result = await host.RunAsync(
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(HeadlessExitCode.ConnectionError, result);
|
||||||
|
Assert.Equal(0, operations.EnterWorldCallCount);
|
||||||
|
Assert.Equal(1, operations.DisposedSessionCount);
|
||||||
|
|
||||||
|
host.Dispose();
|
||||||
|
host.Dispose();
|
||||||
|
|
||||||
|
string[] lines = File.ReadAllLines(statusPath);
|
||||||
|
JsonElement[] events = lines
|
||||||
|
.Select(static line =>
|
||||||
|
JsonDocument.Parse(line).RootElement.Clone())
|
||||||
|
.ToArray();
|
||||||
|
Assert.Equal(
|
||||||
|
["started", "connected", "disconnected", "exited"],
|
||||||
|
events.Select(static item =>
|
||||||
|
item.GetProperty("e").GetString()));
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
events,
|
||||||
|
static item =>
|
||||||
|
item.GetProperty("e").GetString() == "characterList");
|
||||||
|
JsonElement exited = Assert.Single(
|
||||||
|
events,
|
||||||
|
static item => item.GetProperty("e").GetString() == "exited");
|
||||||
|
Assert.Equal(
|
||||||
|
(int)result,
|
||||||
|
exited.GetProperty("code").GetInt32());
|
||||||
|
Assert.Equal(
|
||||||
|
"connection-error",
|
||||||
|
exited.GetProperty("reason").GetString());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(statusPath))
|
||||||
|
File.Delete(statusPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Campaign LA slice LA2: a probe session completing must not tear down
|
/// Campaign LA slice LA2: a probe session completing must not tear down
|
||||||
/// a sibling play session sharing the same process — the process exit
|
/// a sibling play session sharing the same process — the process exit
|
||||||
|
|
@ -3019,6 +3098,23 @@ public sealed class HeadlessSessionHostTests
|
||||||
public int EnterWorldCallCount =>
|
public int EnterWorldCallCount =>
|
||||||
Volatile.Read(ref _enterWorldCallCount);
|
Volatile.Read(ref _enterWorldCallCount);
|
||||||
public int TickCallCount => Volatile.Read(ref _tickCallCount);
|
public int TickCallCount => Volatile.Read(ref _tickCallCount);
|
||||||
|
public CharacterList.Parsed? Characters { get; init; } = new(
|
||||||
|
0u,
|
||||||
|
[
|
||||||
|
new CharacterList.Character(
|
||||||
|
0x50000001u,
|
||||||
|
"Other",
|
||||||
|
0u),
|
||||||
|
new CharacterList.Character(
|
||||||
|
0x50000002u,
|
||||||
|
"Headless",
|
||||||
|
0u),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
11,
|
||||||
|
"account",
|
||||||
|
true,
|
||||||
|
true);
|
||||||
|
|
||||||
public IPEndPoint ResolveEndpoint(string host, int port) =>
|
public IPEndPoint ResolveEndpoint(string host, int port) =>
|
||||||
new(IPAddress.Loopback, port);
|
new(IPAddress.Loopback, port);
|
||||||
|
|
@ -3040,25 +3136,8 @@ public sealed class HeadlessSessionHostTests
|
||||||
LastPassword = password;
|
LastPassword = password;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CharacterList.Parsed GetCharacters(
|
public CharacterList.Parsed? GetCharacters(
|
||||||
WorldSession session) =>
|
WorldSession session) => Characters;
|
||||||
new(
|
|
||||||
0u,
|
|
||||||
[
|
|
||||||
new CharacterList.Character(
|
|
||||||
0x50000001u,
|
|
||||||
"Other",
|
|
||||||
0u),
|
|
||||||
new CharacterList.Character(
|
|
||||||
0x50000002u,
|
|
||||||
"Headless",
|
|
||||||
0u),
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
11,
|
|
||||||
"account",
|
|
||||||
true,
|
|
||||||
true);
|
|
||||||
|
|
||||||
public void EnterWorld(
|
public void EnterWorld(
|
||||||
WorldSession session,
|
WorldSession session,
|
||||||
|
|
|
||||||
|
|
@ -441,13 +441,13 @@ public sealed class LiveSessionControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The probe short-circuit fires even when the server never returns a
|
/// Campaign LA LA2 review fix: ProbeComplete proves a real CharacterList
|
||||||
/// CharacterList at all (GetCharacters returns null) — a probe is a
|
/// was received and reported, not merely that the socket connected. A
|
||||||
/// connectivity check, not itself a character-selection operation, so it
|
/// missing roster follows the existing NoCharacters non-success path and
|
||||||
/// must not fall through to the NoCharacters path.
|
/// still drains the exact pre-world teardown transaction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Start_ProbeWithoutCharacterListStillCompletesGracefully()
|
public void Start_ProbeWithoutCharacterListIsNonSuccessAndTearsDownGracefully()
|
||||||
{
|
{
|
||||||
var calls = new List<string>();
|
var calls = new List<string>();
|
||||||
var operations = new TestOperations(calls) { Characters = null };
|
var operations = new TestOperations(calls) { Characters = null };
|
||||||
|
|
@ -458,10 +458,25 @@ public sealed class LiveSessionControllerTests
|
||||||
LiveOptions(probe: true),
|
LiveOptions(probe: true),
|
||||||
host);
|
host);
|
||||||
|
|
||||||
Assert.Equal(LiveSessionStartStatus.ProbeComplete, result.Status);
|
Assert.Equal(LiveSessionStartStatus.NoCharacters, result.Status);
|
||||||
Assert.Empty(host.Rosters);
|
Assert.Empty(host.Rosters);
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"reset", "resolve", "create", "bind", "report-connecting",
|
||||||
|
"connect", "report-connected", "deactivate",
|
||||||
|
"detach-events", "dispose-session", "detach-session", "reset",
|
||||||
|
],
|
||||||
|
calls);
|
||||||
Assert.Equal(0, operations.EnterWorldCount);
|
Assert.Equal(0, operations.EnterWorldCount);
|
||||||
Assert.False(controller.IsInWorld);
|
Assert.False(controller.IsInWorld);
|
||||||
|
Assert.Null(controller.CurrentSession);
|
||||||
|
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
|
||||||
|
|
||||||
|
LiveSessionOwnershipSnapshot ownership = controller.CaptureOwnership();
|
||||||
|
Assert.Equal(RuntimeTeardownStage.Complete, ownership.LastTeardownStages);
|
||||||
|
Assert.False(ownership.HasActiveSession);
|
||||||
|
Assert.False(ownership.HasRetiredSession);
|
||||||
|
Assert.False(ownership.HasPendingOperation);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue