feat(launcher): LU9/LU10 — stop logs out for real, sessions read plainly, logout lands on character select
Some checks failed
CI / linux-portable (push) Failing after 2m23s
CI / windows-gate (push) Successful in 5m30s
CI / release (push) Has been skipped

Five things from the user's gate.

STOP NOW ACTUALLY LOGS OUT. The UI gave the client five seconds and then
killed it. That is not enough for a graphical client to send its logout, wait
for the server to acknowledge, and tear down a mapped 28 GB world — so Stop
routinely ended in a kill, which sends the server nothing, which is exactly
what leaves the account held. Thirty seconds now, with the kill still there as
a genuine last resort, and the status says "Logging out…".

THE SERVER-SIDE HOLD IS MODELLED INSTEAD OF DISCOVERED. A session that ends
without the host running its own teardown may leave the account logged in
server-side for minutes. Launching again inside that window does not queue or
retry — it fails with a bare "CharacterList not received", which reads as a
broken launcher rather than a busy server. The orchestrator now records whether
each session ended gracefully (the host reported its own exit AND exited zero —
a killed or crashed child can satisfy neither) and refuses that account for
three minutes afterwards, saying how many seconds are left. A graceful exit
never starts a hold.

READABLE TERMINAL TEXT. "Exited: connection-error (code 5)" becomes "Could not
reach the server — the server may hold this account for a few minutes";
"Exited: process-exit (code 0)" becomes "Exited gracefully — logged out
cleanly". Live sessions still show the host's own status line, which is the
most informative thing available while one is running.

COLUMN HEADERS on the sessions list — ACCOUNT / CHARACTER / STATUS / DETAIL,
sharing the row template's widths so they stay aligned.

LOGOUT LANDS ON THE CHARACTER SCREEN (LU10). The toolbar X was already wired
correctly: IndicatorBarController's EndCharacterSessionButtonId 0x100000FA runs
retail's EndCharacterSession, and LiveSessionController's logout transaction
already ends by resetting the world generation and calling
CharacterSelectionState.Begin. What was missing is where that lands: the
retained UI built its character-selection and character-creation bindings only
when NO character selector was supplied, so a launcher-started session logged
out into a client with no screen to return to. The selector decides how a
session STARTS; it must not decide whether the select screen EXISTS. Both
binding sets are now unconditional.

The composition test that pinned the old gate is updated to pin the new
contract — the retained UI must not branch on the selector at all — rather than
being deleted.

App 5380 passed, Launcher.Core 336, Launcher 76, Headless 169. Not pushed; the
user is testing locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-19 21:27:26 +02:00
parent 18bbd37779
commit 6ab5d8ce0f
7 changed files with 234 additions and 32 deletions

View file

@ -1023,30 +1023,44 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
Keyboard: new KeyboardRuntimeBindings(
d.InputDispatcher,
d.KeyBindingsFilePath),
CharacterSelection: d.Options.LiveCharacterSelector is null
? new CharacterSelectionRuntimeBindings(
() => late.GameRuntime.CharacterSelection,
late.GameRuntime.CharacterSelectionHighlight,
late.GameRuntime.CharacterSelectionEnter,
late.GameRuntime.CharacterSelectionRequestDelete,
late.GameRuntime.CharacterSelectionConfirmDelete,
late.GameRuntime.CharacterSelectionRestore,
late.GameRuntime.CharacterSelectionCancel,
// Campaign LA gate round 2 finding 1: the SAME
// window-close path GameplayInputCommandController's
// Escape fallback uses (IGameplayWindowCommands.Close
// /GameplayWindowCommands wrap this same d.Window.Close
// delegate) — no separate exit path.
d.Window.Close)
: null,
// LU10: built UNCONDITIONALLY, including when the launcher
// supplied a character selector. The selector only decides how
// this session STARTS — straight into the world instead of
// pausing at the select screen. It must not decide whether the
// select screen EXISTS, because the player can come back to it:
// the toolbar's X (IndicatorBarController's
// EndCharacterSessionButtonId 0x100000FA) runs retail's
// EndCharacterSession, and LiveSessionController's logout
// transaction ends by resetting the world generation and
// calling CharacterSelectionState.Begin — i.e. it hands control
// to exactly these bindings. Gating them on the selector meant a
// launcher-started session logged out into a client with
// nowhere to land.
CharacterSelection: new CharacterSelectionRuntimeBindings(
() => late.GameRuntime.CharacterSelection,
late.GameRuntime.CharacterSelectionHighlight,
late.GameRuntime.CharacterSelectionEnter,
late.GameRuntime.CharacterSelectionRequestDelete,
late.GameRuntime.CharacterSelectionConfirmDelete,
late.GameRuntime.CharacterSelectionRestore,
late.GameRuntime.CharacterSelectionCancel,
// Campaign LA gate round 2 finding 1: the SAME
// window-close path GameplayInputCommandController's
// Escape fallback uses (IGameplayWindowCommands.Close
// /GameplayWindowCommands wrap this same d.Window.Close
// delegate) — no separate exit path.
d.Window.Close),
// Campaign CC slice CC4: same late-bound generation-capturing
// seam as CharacterSelection above. RequestExit here is a
// plain presentation action (closing the chargen screen and
// letting character-management's own Tick keep re-drawing
// itself underneath — see CharacterCreationUiController's
// OnExit doc), NOT a Runtime command or a window-close.
CharacterCreation: d.Options.LiveCharacterSelector is null
? new CharacterCreationRuntimeBindings(
// LU10: unconditional for the same reason as CharacterSelection
// above — a player who logs out back to the select screen can
// create a character from there, so the screen behind it must
// exist regardless of how this session started.
CharacterCreation: new CharacterCreationRuntimeBindings(
() => late.GameRuntime.CharacterCreation,
late.GameRuntime.CharacterCreationSelectHeritage,
late.GameRuntime.CharacterCreationSelectGender,
@ -1076,8 +1090,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
RandomizeAppearance: late.GameRuntime.CharacterCreationRandomizeAppearance,
RandomizeClothing: late.GameRuntime.CharacterCreationRandomizeClothing,
GetSkillScore: chargenSkillScoreResolver.Resolve,
OpenOnStart: d.Options.OpenCharacterCreationOnStart)
: null);
OpenOnStart: d.Options.OpenCharacterCreationOnStart));
RetailUiRuntime runtime = lease.Mount(
() => RetailUiRuntime.CreateUninitialized(bindings));
checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted);

View file

@ -15,6 +15,11 @@ namespace AcDream.Launcher.Core.Orchestration;
/// </summary>
public sealed class LauncherOrchestrator : ILauncherOrchestrator
{
/// <summary>How long the server may keep an account logged in after a
/// client dies without sending a logout. Observed against ACE at three
/// minutes and more; see GetReconnectHoldLocked.</summary>
private static readonly TimeSpan ServerSessionHold = TimeSpan.FromMinutes(3);
private const string FirstRunRequired =
"Client content is not configured. Complete the first-run setup before launching.";
@ -142,14 +147,62 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
ThrowIfDisposed();
_ = FindAccountLocked(serverName, accountName);
ManagedActivity? active = FindActiveActivityLocked(serverName, accountName);
return active is null
? LauncherCapability.Available
: LauncherCapability.Unavailable(
if (active is not null)
{
return LauncherCapability.Unavailable(
$"Stop the running {active.Kind.ToString().ToLowerInvariant()} "
+ "for this account before starting another activity.");
}
return GetReconnectHoldLocked(serverName, accountName);
}
}
/// <summary>
/// LU9. A client that shuts itself down sends the server a logout and the
/// account frees in a few seconds. One that is killed or crashes sends
/// nothing, and the server keeps the account logged in until its own
/// timeout — observed at three minutes and more against ACE. Logging in
/// during that window does not queue or retry; it fails with a bare
/// "CharacterList not received", which reads as "the launcher is broken"
/// rather than "the server is still holding your last session".
///
/// <para>So the launcher holds the account itself for that window and says
/// how long is left. This is the server's constraint made visible, not a
/// retry or a grace period hiding one: the moment the hold expires the
/// account is offered again, and a graceful exit never starts one.</para>
/// </summary>
private LauncherCapability GetReconnectHoldLocked(
string serverName,
string accountName)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
ManagedActivity? ungraceful = _activities
.Where(activity =>
activity.IsTerminal
&& !activity.ExitedGracefully
&& activity.TerminalAt is not null
&& string.Equals(activity.ServerName, serverName, StringComparison.Ordinal)
&& string.Equals(activity.AccountName, accountName, StringComparison.Ordinal))
.OrderByDescending(activity => activity.TerminalAt)
.FirstOrDefault();
if (ungraceful?.TerminalAt is not { } terminalAt)
{
return LauncherCapability.Available;
}
TimeSpan remaining = ServerSessionHold - (now - terminalAt);
if (remaining <= TimeSpan.Zero)
{
return LauncherCapability.Available;
}
int seconds = (int)Math.Ceiling(remaining.TotalSeconds);
return LauncherCapability.Unavailable(
$"The last session for this account did not log out cleanly, so the "
+ $"server may still be holding it. Try again in {seconds} s.");
}
public LauncherCapability GetProbeCapability(string serverName, string accountName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(serverName);
@ -838,6 +891,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
{
case ExitedStatusEvent exited:
activity.ExitCode ??= exited.Code;
activity.HostReportedExit = true;
activity.ExitReason ??= exited.Reason;
activity.HostTerminalStatus ??=
$"Exited: {exited.Reason} (code {exited.Code}).";
if (activity.State == LauncherActivityState.Exited)
@ -904,6 +959,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
case ExitedStatusEvent exited:
activity.State = LauncherActivityState.Exited;
activity.ExitCode = exited.Code;
activity.HostReportedExit = true;
activity.ExitReason = exited.Reason;
activity.HostTerminalStatus =
$"Exited: {exited.Reason} (code {exited.Code}).";
activity.Status = activity.HostTerminalStatus;
@ -1289,7 +1346,26 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
public DateTimeOffset CreatedAt { get; }
public LauncherActivityState State { get; set; } = LauncherActivityState.Starting;
private LauncherActivityState _state = LauncherActivityState.Starting;
/// <summary>
/// LU9: stamps <see cref="TerminalAt"/> on the FIRST transition into a
/// terminal state. Every terminal path in this file assigns through
/// here, so the server-side hold cannot be started from one site and
/// forgotten at another.
/// </summary>
public LauncherActivityState State
{
get => _state;
set
{
_state = value;
if (IsTerminal)
{
TerminalAt ??= DateTimeOffset.UtcNow;
}
}
}
public string Status { get; set; }
@ -1299,6 +1375,21 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
public string? HostTerminalStatus { get; set; }
/// <summary>LU9: the host's own terminal reason token, set only when
/// the host actually reported "exited" — so null means it never ran
/// its own teardown.</summary>
public string? ExitReason { get; set; }
/// <summary>LU9: the host reported its own terminal event, as opposed
/// to the launcher merely observing the process disappear.</summary>
public bool HostReportedExit { get; set; }
/// <summary>LU9: when this activity first became terminal, which is
/// when the server-side hold starts counting.</summary>
public DateTimeOffset? TerminalAt { get; set; }
public bool ExitedGracefully => HostReportedExit && ExitCode == 0;
public ILauncherProcessSupervisor? Supervisor { get; set; }
public EventHandler<LauncherSessionState>? SupervisorStateHandler { get; set; }
@ -1332,7 +1423,13 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
Status,
ExitCode,
Error,
CreatedAt);
CreatedAt,
ExitReason,
// Graceful means the HOST ran its own shutdown and exited
// clean, which is exactly the path that sends the server a
// logout. A killed or crashed child never reports "exited"
// and never exits zero, so it cannot be mistaken for one.
ExitedGracefully);
}
private sealed class StartRequest(

View file

@ -61,7 +61,17 @@ public sealed record LauncherSessionSnapshot(
string Status,
int? ExitCode,
string? Error,
DateTimeOffset CreatedAt)
DateTimeOffset CreatedAt,
/// <summary>LU9: the host's own machine-readable terminal reason
/// ("process-exit", "connection-error", "probe", ...), or null when the
/// host never got to report one — which itself means it did not shut
/// itself down.</summary>
string? ExitReason = null,
/// <summary>LU9: the host ran its OWN teardown and exited zero, so it
/// sent the server a logout. When this is false after a terminal state,
/// the server may still be holding the session — see
/// <see cref="LauncherOrchestrator"/>'s reconnect hold.</summary>
bool ExitedGracefully = false)
{
public bool IsActive => State is not (
LauncherActivityState.Exited

View file

@ -227,14 +227,27 @@
</Border>
<Border Grid.Column="2" Grid.Row="2" Classes="card">
<Grid RowDefinitions="Auto,*">
<Grid RowDefinitions="Auto,Auto,*">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="Sessions" Classes="section" />
<Button Grid.Column="1"
Content="Clear finished"
Command="{Binding ClearFinishedSessionsCommand}" />
</Grid>
<ScrollViewer Grid.Row="1" Margin="0,10,0,0">
<!-- LU9: column headers, using the same widths as the row template
below so they stay lined up. -->
<Grid Grid.Row="1"
ColumnDefinitions="2*,2*,130,3*,Auto"
Margin="4,10,4,4">
<TextBlock Text="ACCOUNT" Classes="muted" FontSize="11" FontWeight="SemiBold" />
<TextBlock Grid.Column="1" Text="CHARACTER"
Classes="muted" FontSize="11" FontWeight="SemiBold" />
<TextBlock Grid.Column="2" Text="STATUS"
Classes="muted" FontSize="11" FontWeight="SemiBold" />
<TextBlock Grid.Column="3" Text="DETAIL"
Classes="muted" FontSize="11" FontWeight="SemiBold" />
</Grid>
<ScrollViewer Grid.Row="2" Margin="0,0,0,0">
<ItemsControl ItemsSource="{Binding Sessions}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:LauncherSessionRowViewModel">

View file

@ -28,7 +28,7 @@ public sealed class LauncherSessionRowViewModel
Server = snapshot.ServerName;
Character = DescribeCharacter(snapshot);
State = DescribeState(snapshot);
Status = snapshot.Status;
Status = DescribeStatus(snapshot);
Error = snapshot.Error;
IsActive = snapshot.IsActive;
StopCommand = new AsyncRelayCommand(
@ -64,6 +64,52 @@ public sealed class LauncherSessionRowViewModel
public void NotifyCommandState() => StopCommand.NotifyCanExecuteChanged();
/// <summary>
/// LU9. A finished session used to report the host's own machine tokens —
/// "Exited: process-exit (code 0)", "Exited: connection-error (code 5)" —
/// which say nothing to a player about the thing they actually care about:
/// whether the client logged out properly, and therefore whether they can
/// log straight back in.
///
/// <para>While a session is alive the host's own status line is the most
/// informative thing available, so it is kept. Once it is over, the row
/// answers that question in a sentence.</para>
/// </summary>
private static string DescribeStatus(LauncherSessionSnapshot snapshot)
{
if (snapshot.IsActive)
{
return snapshot.Status;
}
if (snapshot.State == LauncherActivityState.Cancelled)
{
return "Cancelled before it started.";
}
if (snapshot.ExitedGracefully)
{
return snapshot.Kind == LauncherActivityKind.Probe
? "Finished reading characters."
: "Exited gracefully — logged out cleanly.";
}
// Not graceful: the host never reported its own exit, or reported a
// failure. The server may still be holding the account, and saying so
// here is what stops the next login failure being a mystery.
string detail = snapshot.ExitReason switch
{
"connection-error" => "Could not reach the server",
"credential-error" => "The account or password was rejected",
"configuration-error" => "The session configuration was rejected",
"usage-error" => "The client rejected how it was started",
null or "" => "Crashed",
_ => "Stopped unexpectedly",
};
return $"{detail} — the server may hold this account for a few minutes.";
}
private static string DescribeCharacter(LauncherSessionSnapshot snapshot)
{
if (snapshot.Kind == LauncherActivityKind.Probe)

View file

@ -11,6 +11,16 @@ namespace AcDream.Launcher.ViewModels;
public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
{
private readonly ILauncherOrchestrator _orchestrator;
/// <summary>
/// LU9: how long Stop waits for the client to log out BEFORE killing it.
/// This used to be five seconds, which is not enough for a graphical
/// client to send its logout, wait for the server to acknowledge, and tear
/// down a mapped 28 GB world — so pressing Stop routinely ended in a kill,
/// the server kept holding the account, and the next login failed. The
/// kill is still there; it is now genuinely a last resort.
/// </summary>
private static readonly TimeSpan GracefulStopTimeout = TimeSpan.FromSeconds(30);
private readonly IUiDispatcher _dispatcher;
private readonly ILauncherInstaller _installer;
private LauncherStateSnapshot? _snapshot;
@ -787,12 +797,12 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
_operationCancellation = cancellation;
IsBusy = true;
LastError = null;
OperationStatus = "Stopping session…";
OperationStatus = "Logging out…";
try
{
await _orchestrator.StopSessionAsync(
sessionId,
TimeSpan.FromSeconds(5),
GracefulStopTimeout,
cancellation.Token)
.ConfigureAwait(true);
OperationStatus = "Stop requested.";

View file

@ -183,11 +183,19 @@ public sealed class SessionPlayerCompositionTests
call => call.Target.DeclaringType == typeof(CharacterList)
&& call.Target.Name == nameof(CharacterList.TrySelectFirstAvailable));
// LU10: the retained UI must NOT branch on the selector. The selector
// decides how a session STARTS (above — straight into the world instead
// of pausing at the select screen); it must not decide whether the
// select screen EXISTS. Retail's EndCharacterSession — the toolbar X —
// ends by resetting the world generation and calling
// CharacterSelectionState.Begin, so a launcher-started session that
// logs out lands on exactly these bindings. While they were gated on
// the selector, it landed on nothing.
MethodInfo retainedUi = typeof(RetailInteractionRetainedUiCompositionFactory)
.GetMethod(nameof(
RetailInteractionRetainedUiCompositionFactory.CreateRetainedUi))!;
IReadOnlyList<CompiledCall> uiCalls = CompiledCallGraph.Read(retainedUi);
Assert.Contains(
Assert.DoesNotContain(
uiCalls,
call => call.Target.DeclaringType == typeof(RuntimeOptions)
&& call.Target.Name == "get_LiveCharacterSelector");
@ -196,6 +204,11 @@ public sealed class SessionPlayerCompositionTests
call => call.Target.DeclaringType
== typeof(CharacterSelectionRuntimeBindings)
&& call.Target.IsConstructor);
Assert.Contains(
uiCalls,
call => call.Target.DeclaringType
== typeof(AcDream.App.UI.Layout.CharacterCreationRuntimeBindings)
&& call.Target.IsConstructor);
}
private sealed class RetryBinding(