refactor(runtime): move session lifetime and ordered transport
Move the canonical WorldSession generation, connect/enter/tick/stop transaction, inbound subscription owner, and retryable teardown acknowledgements into AcDream.Runtime. Keep App as a borrowing graphical host with a single inertable command projection and no mirrored session state. Validated by 79 Runtime tests, 3,776 App tests with three existing skips, the Release solution build, and 8,428 complete Release tests with five existing skips. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
ecc4816c5a
commit
7593078774
37 changed files with 884 additions and 355 deletions
|
|
@ -1,651 +0,0 @@
|
|||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.App.Update;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
||||
namespace AcDream.App.Net;
|
||||
|
||||
internal enum LiveSessionStartStatus
|
||||
{
|
||||
Disabled,
|
||||
MissingCredentials,
|
||||
NoCharacters,
|
||||
Connected,
|
||||
Deferred,
|
||||
Failed,
|
||||
}
|
||||
|
||||
internal sealed record LiveSessionCharacterSelection(
|
||||
int ActiveIndex,
|
||||
uint CharacterId,
|
||||
string CharacterName,
|
||||
string AccountName);
|
||||
|
||||
internal sealed record LiveSessionStartResult(
|
||||
LiveSessionStartStatus Status,
|
||||
LiveSessionCharacterSelection? Selection = null,
|
||||
Exception? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// Narrow App composition boundary for one exact WorldSession generation.
|
||||
/// The host owns domain sinks and presentation state; the controller owns the
|
||||
/// connect/enter/stop transaction and never reaches into GameWindow state.
|
||||
/// </summary>
|
||||
internal interface ILiveSessionLifecycleHost
|
||||
{
|
||||
LiveSessionBinding BindSession(WorldSession session);
|
||||
void ResetSessionState();
|
||||
void ReportConnecting(string host, int port, string user);
|
||||
void ReportConnected();
|
||||
void ApplySelectedCharacter(LiveSessionCharacterSelection selection);
|
||||
void ApplyEnteredWorld(LiveSessionCharacterSelection selection);
|
||||
void DetachSession(WorldSession session);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns routing and commands for one exact session. Teardown is ordered and
|
||||
/// retryable: outbound commands become inert before inbound subscriptions are
|
||||
/// detached.
|
||||
/// </summary>
|
||||
internal sealed class LiveSessionBinding : IDisposable
|
||||
{
|
||||
private readonly Action _activateCommands;
|
||||
private readonly Action _deactivateCommands;
|
||||
private readonly Action _detachEvents;
|
||||
private bool _commandsDeactivated;
|
||||
private bool _eventsDetached;
|
||||
private bool _commandsActivated;
|
||||
|
||||
public LiveSessionBinding(
|
||||
WorldSession session,
|
||||
ICommandBus commands,
|
||||
Action activateCommands,
|
||||
Action deactivateCommands,
|
||||
Action detachEvents)
|
||||
{
|
||||
Session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
Commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
||||
_activateCommands = activateCommands ?? throw new ArgumentNullException(nameof(activateCommands));
|
||||
_deactivateCommands = deactivateCommands ?? throw new ArgumentNullException(nameof(deactivateCommands));
|
||||
_detachEvents = detachEvents ?? throw new ArgumentNullException(nameof(detachEvents));
|
||||
}
|
||||
|
||||
public WorldSession Session { get; }
|
||||
public ICommandBus Commands { get; }
|
||||
|
||||
public void ActivateCommands()
|
||||
{
|
||||
if (_commandsDeactivated || _eventsDetached)
|
||||
throw new ObjectDisposedException(nameof(LiveSessionBinding));
|
||||
if (_commandsActivated)
|
||||
return;
|
||||
_activateCommands();
|
||||
if (_commandsDeactivated || _eventsDetached)
|
||||
return;
|
||||
_commandsActivated = true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_commandsDeactivated)
|
||||
{
|
||||
_deactivateCommands();
|
||||
_commandsDeactivated = true;
|
||||
}
|
||||
if (!_eventsDetached)
|
||||
{
|
||||
_detachEvents();
|
||||
_eventsDetached = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal interface ILiveSessionOperations
|
||||
{
|
||||
IPEndPoint ResolveEndpoint(string host, int port);
|
||||
WorldSession CreateSession(IPEndPoint endpoint);
|
||||
void Connect(WorldSession session, string user, string password);
|
||||
CharacterList.Parsed? GetCharacters(WorldSession session);
|
||||
void EnterWorld(WorldSession session, int activeCharacterIndex);
|
||||
void Tick(WorldSession session);
|
||||
void DisposeSession(WorldSession session);
|
||||
}
|
||||
|
||||
internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations
|
||||
{
|
||||
public static ProductionLiveSessionOperations Instance { get; } = new();
|
||||
|
||||
private ProductionLiveSessionOperations() { }
|
||||
|
||||
public IPEndPoint ResolveEndpoint(string host, int port)
|
||||
{
|
||||
IPAddress ip;
|
||||
if (!IPAddress.TryParse(host, out ip!))
|
||||
{
|
||||
IPAddress[] addresses = Dns.GetHostAddresses(host);
|
||||
ip = Array.Find(
|
||||
addresses,
|
||||
static address => address.AddressFamily == AddressFamily.InterNetwork)
|
||||
?? (addresses.Length != 0
|
||||
? addresses[0]
|
||||
: throw new InvalidOperationException(
|
||||
$"DNS resolved no addresses for '{host}'"));
|
||||
Console.WriteLine($"live: resolved {host} → {ip}");
|
||||
}
|
||||
return new IPEndPoint(ip, port);
|
||||
}
|
||||
|
||||
public WorldSession CreateSession(IPEndPoint endpoint) => new(endpoint);
|
||||
|
||||
public void Connect(WorldSession session, string user, string password) =>
|
||||
session.Connect(user, password);
|
||||
|
||||
public CharacterList.Parsed? GetCharacters(WorldSession session) => session.Characters;
|
||||
|
||||
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
|
||||
session.EnterWorld(activeCharacterIndex);
|
||||
|
||||
public void Tick(WorldSession session) => session.Tick();
|
||||
|
||||
public void DisposeSession(WorldSession session) => session.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sole owner of the App lifetime for a live WorldSession: endpoint resolution,
|
||||
/// complete pre-Connect routing, character validation/entry, active command
|
||||
/// publication, exact-generation Tick, graceful replacement, and convergent
|
||||
/// teardown. Retail wire behavior remains inside WorldSession.
|
||||
/// </summary>
|
||||
internal sealed class LiveSessionController
|
||||
: IDisposable,
|
||||
ILiveSessionFramePhase,
|
||||
ILiveInWorldSource,
|
||||
ILiveWorldSessionSource,
|
||||
ILiveUiSessionTarget
|
||||
{
|
||||
private sealed class SessionScope(
|
||||
WorldSession session,
|
||||
ILiveSessionLifecycleHost host)
|
||||
{
|
||||
private int _teardownStage;
|
||||
|
||||
public WorldSession Session { get; } = session;
|
||||
public ILiveSessionLifecycleHost Host { get; } = host;
|
||||
public LiveSessionBinding? Binding { get; set; }
|
||||
public bool HostAttached { get; set; }
|
||||
|
||||
public void DrainTeardown(ILiveSessionOperations operations)
|
||||
{
|
||||
if (_teardownStage == 0)
|
||||
{
|
||||
Binding?.Dispose();
|
||||
_teardownStage = 1;
|
||||
}
|
||||
if (_teardownStage == 1)
|
||||
{
|
||||
operations.DisposeSession(Session);
|
||||
_teardownStage = 2;
|
||||
}
|
||||
if (_teardownStage == 2)
|
||||
{
|
||||
if (HostAttached)
|
||||
Host.DetachSession(Session);
|
||||
_teardownStage = 3;
|
||||
}
|
||||
if (_teardownStage == 3)
|
||||
{
|
||||
Host.ResetSessionState();
|
||||
_teardownStage = 4;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsTeardownComplete => _teardownStage == 4;
|
||||
}
|
||||
|
||||
private enum PendingKind
|
||||
{
|
||||
Stop,
|
||||
Reconnect,
|
||||
Dispose,
|
||||
}
|
||||
|
||||
private sealed record PendingOperation(
|
||||
PendingKind Kind,
|
||||
RuntimeOptions? Options = null,
|
||||
ILiveSessionLifecycleHost? Host = null);
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly ILiveSessionOperations _operations;
|
||||
private SessionScope? _scope;
|
||||
private SessionScope? _retiredScope;
|
||||
private ILiveSessionLifecycleHost? _pendingInitialResetHost;
|
||||
private PendingOperation? _pendingOperation;
|
||||
private int _operationDepth;
|
||||
private bool _inWorld;
|
||||
private bool _disposeRequested;
|
||||
private bool _disposed;
|
||||
private ulong _generation;
|
||||
private LiveSessionCharacterSelection? _activeSelection;
|
||||
|
||||
public LiveSessionController()
|
||||
: this(ProductionLiveSessionOperations.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
internal LiveSessionController(ILiveSessionOperations operations)
|
||||
{
|
||||
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
|
||||
}
|
||||
|
||||
public WorldSession? CurrentSession
|
||||
{
|
||||
get { lock (_gate) return _scope?.Session; }
|
||||
}
|
||||
|
||||
public ICommandBus Commands
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
return _inWorld && _scope?.Binding is { } binding
|
||||
? binding.Commands
|
||||
: NullCommandBus.Instance;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsInWorld
|
||||
{
|
||||
get { lock (_gate) return _inWorld; }
|
||||
}
|
||||
|
||||
public ulong SessionGeneration
|
||||
{
|
||||
get { lock (_gate) return _generation; }
|
||||
}
|
||||
|
||||
internal bool IsDisposalComplete
|
||||
{
|
||||
get { lock (_gate) return _disposed; }
|
||||
}
|
||||
|
||||
internal LiveSessionStartResult Start(
|
||||
RuntimeOptions options,
|
||||
ILiveSessionLifecycleHost host)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
lock (_gate)
|
||||
{
|
||||
ThrowIfDisposing();
|
||||
if (_operationDepth != 0)
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
if (_inWorld)
|
||||
return ConnectedResult();
|
||||
return RunTopLevel(() => StartCore(options, host, resetHost: true));
|
||||
}
|
||||
}
|
||||
|
||||
internal LiveSessionStartResult Reconnect(
|
||||
RuntimeOptions options,
|
||||
ILiveSessionLifecycleHost host)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
lock (_gate)
|
||||
{
|
||||
ThrowIfDisposing();
|
||||
if (_operationDepth != 0)
|
||||
{
|
||||
Schedule(new PendingOperation(PendingKind.Reconnect, options, host));
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
}
|
||||
return RunTopLevel(() => ReconnectCore(options, host));
|
||||
}
|
||||
}
|
||||
|
||||
internal void Stop()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
if (_operationDepth != 0)
|
||||
{
|
||||
Schedule(new PendingOperation(PendingKind.Stop));
|
||||
return;
|
||||
}
|
||||
RunTopLevel(StopCore);
|
||||
}
|
||||
}
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
if (!_inWorld || _scope is null || _operationDepth != 0)
|
||||
return;
|
||||
|
||||
RunTopLevel(() =>
|
||||
{
|
||||
SessionScope scope = _scope;
|
||||
ulong generation = _generation;
|
||||
try
|
||||
{
|
||||
_operations.Tick(scope.Session);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return;
|
||||
}
|
||||
catch (Exception tickError)
|
||||
{
|
||||
Exception error = StopAfterFailure(tickError);
|
||||
if (ReferenceEquals(error, tickError))
|
||||
throw;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposeRequested = true;
|
||||
if (_operationDepth != 0)
|
||||
{
|
||||
Schedule(new PendingOperation(PendingKind.Dispose));
|
||||
return;
|
||||
}
|
||||
|
||||
RunTopLevel(DisposeCore);
|
||||
}
|
||||
}
|
||||
|
||||
private LiveSessionStartResult ReconnectCore(
|
||||
RuntimeOptions options,
|
||||
ILiveSessionLifecycleHost host)
|
||||
{
|
||||
ILiveSessionLifecycleHost? oldHost =
|
||||
_scope?.Host ?? _retiredScope?.Host ?? _pendingInitialResetHost;
|
||||
ulong generationBeforeStop = _generation;
|
||||
try
|
||||
{
|
||||
StopCore();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Failed, Error: error);
|
||||
}
|
||||
if (_generation != unchecked(generationBeforeStop + 1))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
return StartCore(options, host, resetHost: !ReferenceEquals(oldHost, host));
|
||||
}
|
||||
|
||||
private LiveSessionStartResult StartCore(
|
||||
RuntimeOptions options,
|
||||
ILiveSessionLifecycleHost host,
|
||||
bool resetHost)
|
||||
{
|
||||
ulong generation = ++_generation;
|
||||
try
|
||||
{
|
||||
DrainRetiredScope();
|
||||
if (_generation != generation)
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
if (resetHost)
|
||||
ResetHostBeforeStart(host, generation);
|
||||
if (_generation != generation)
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Failed, Error: error);
|
||||
}
|
||||
|
||||
if (!options.LiveMode)
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Disabled);
|
||||
if (string.IsNullOrEmpty(options.LiveUser) || string.IsNullOrEmpty(options.LivePass))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.MissingCredentials);
|
||||
|
||||
SessionScope? scope = null;
|
||||
try
|
||||
{
|
||||
IPEndPoint endpoint = _operations.ResolveEndpoint(options.LiveHost, options.LivePort);
|
||||
if (_generation != generation)
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
Console.WriteLine($"live: connecting to {endpoint} as {options.LiveUser}");
|
||||
WorldSession session = _operations.CreateSession(endpoint);
|
||||
scope = new SessionScope(session, host);
|
||||
_scope = scope;
|
||||
_inWorld = false;
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
LiveSessionBinding binding = host.BindSession(session);
|
||||
scope.Binding = binding;
|
||||
scope.HostAttached = true;
|
||||
if (!ReferenceEquals(binding.Session, session))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The live-session host returned a binding for a different session.");
|
||||
}
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
host.ReportConnecting(options.LiveHost, options.LivePort, options.LiveUser);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
_operations.Connect(session, options.LiveUser, options.LivePass);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
host.ReportConnected();
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
CharacterList.Parsed? characters = _operations.GetCharacters(session);
|
||||
if (characters is null
|
||||
|| !CharacterList.TrySelectFirstAvailable(characters, out CharacterList.Selection selected))
|
||||
{
|
||||
Console.WriteLine("live: no available characters on account; disconnecting");
|
||||
StopCore();
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.NoCharacters);
|
||||
}
|
||||
|
||||
var selection = new LiveSessionCharacterSelection(
|
||||
selected.ActiveIndex,
|
||||
selected.Character.Id,
|
||||
selected.Character.Name,
|
||||
characters.AccountName);
|
||||
host.ApplySelectedCharacter(selection);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
Console.WriteLine(
|
||||
$"live: entering world as 0x{selection.CharacterId:X8} {selection.CharacterName}");
|
||||
_operations.EnterWorld(session, selection.ActiveIndex);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
binding.ActivateCommands();
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
_inWorld = true;
|
||||
_activeSelection = selection;
|
||||
host.ApplyEnteredWorld(selection);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
Console.WriteLine("live: in world — CreateObject stream active");
|
||||
return new LiveSessionStartResult(
|
||||
LiveSessionStartStatus.Connected,
|
||||
selection);
|
||||
}
|
||||
catch (Exception startError)
|
||||
{
|
||||
return new LiveSessionStartResult(
|
||||
LiveSessionStartStatus.Failed,
|
||||
Error: StopAfterFailure(startError));
|
||||
}
|
||||
}
|
||||
|
||||
private Exception StopAfterFailure(Exception operationError)
|
||||
{
|
||||
try
|
||||
{
|
||||
StopCore();
|
||||
return operationError;
|
||||
}
|
||||
catch (Exception cleanupError)
|
||||
{
|
||||
return new AggregateException(
|
||||
"Live-session operation and cleanup both failed.",
|
||||
operationError,
|
||||
cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
private void StopCore()
|
||||
{
|
||||
++_generation;
|
||||
_inWorld = false;
|
||||
_activeSelection = null;
|
||||
if (_scope is { } scope)
|
||||
{
|
||||
_scope = null;
|
||||
if (_retiredScope is not null && !ReferenceEquals(_retiredScope, scope))
|
||||
throw new InvalidOperationException(
|
||||
"A second live-session scope cannot retire before the first converges.");
|
||||
_retiredScope = scope;
|
||||
}
|
||||
DrainRetiredScope();
|
||||
DrainPendingInitialReset();
|
||||
}
|
||||
|
||||
private void DrainRetiredScope()
|
||||
{
|
||||
if (_retiredScope is not { } retired)
|
||||
return;
|
||||
retired.DrainTeardown(_operations);
|
||||
if (retired.IsTeardownComplete)
|
||||
_retiredScope = null;
|
||||
}
|
||||
|
||||
private void ResetHostBeforeStart(
|
||||
ILiveSessionLifecycleHost host,
|
||||
ulong generation)
|
||||
{
|
||||
bool requestedHostAlreadyReset = false;
|
||||
if (_pendingInitialResetHost is { } pending)
|
||||
{
|
||||
pending.ResetSessionState();
|
||||
_pendingInitialResetHost = null;
|
||||
requestedHostAlreadyReset = ReferenceEquals(pending, host);
|
||||
}
|
||||
if (requestedHostAlreadyReset || _generation != generation)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
host.ResetSessionState();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_pendingInitialResetHost = host;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void DrainPendingInitialReset()
|
||||
{
|
||||
if (_pendingInitialResetHost is not { } host)
|
||||
return;
|
||||
host.ResetSessionState();
|
||||
_pendingInitialResetHost = null;
|
||||
}
|
||||
|
||||
private void Schedule(PendingOperation operation)
|
||||
{
|
||||
if (_pendingOperation?.Kind == PendingKind.Dispose)
|
||||
return;
|
||||
_pendingOperation = operation;
|
||||
++_generation;
|
||||
_inWorld = false;
|
||||
_scope?.Binding?.Dispose();
|
||||
}
|
||||
|
||||
private T RunTopLevel<T>(Func<T> operation)
|
||||
{
|
||||
_operationDepth++;
|
||||
try
|
||||
{
|
||||
return operation();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_operationDepth--;
|
||||
if (_operationDepth == 0)
|
||||
DrainPendingOperations();
|
||||
}
|
||||
}
|
||||
|
||||
private void RunTopLevel(Action operation) =>
|
||||
RunTopLevel(() =>
|
||||
{
|
||||
operation();
|
||||
return true;
|
||||
});
|
||||
|
||||
private void DrainPendingOperations()
|
||||
{
|
||||
while (_pendingOperation is { } pending)
|
||||
{
|
||||
_pendingOperation = null;
|
||||
_operationDepth++;
|
||||
try
|
||||
{
|
||||
switch (pending.Kind)
|
||||
{
|
||||
case PendingKind.Stop:
|
||||
StopCore();
|
||||
break;
|
||||
case PendingKind.Reconnect:
|
||||
_ = ReconnectCore(pending.Options!, pending.Host!);
|
||||
break;
|
||||
case PendingKind.Dispose:
|
||||
DisposeCore();
|
||||
break;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_operationDepth--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DisposeCore()
|
||||
{
|
||||
StopCore();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private bool IsCurrent(SessionScope scope, ulong generation) =>
|
||||
ReferenceEquals(_scope, scope) && _generation == generation;
|
||||
|
||||
private LiveSessionStartResult ConnectedResult()
|
||||
=> new(LiveSessionStartStatus.Connected, _activeSelection);
|
||||
|
||||
private void ThrowIfDisposing()
|
||||
{
|
||||
if (_disposeRequested || _disposed)
|
||||
throw new ObjectDisposedException(nameof(LiveSessionController));
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue