refactor(net): own live session composition

Extract reset, selection, entered-world, and route construction behind LiveSessionHost while preserving the sole LiveSessionController authority. Retain partial route and subscription cleanup for retry, and replace the embedded ACE-only shortcut with the exact named-retail unsigned skill formula.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 10:36:06 +02:00
parent 18d4b999de
commit 557eb7ef6b
22 changed files with 1430 additions and 236 deletions

View file

@ -11,7 +11,7 @@ public sealed class GameWindowLiveSessionOwnershipTests
BindingFlags.Instance | BindingFlags.NonPublic;
[Fact]
public void GameWindowRetainsOnlyTheLifecycleControllerSessionOwner()
public void GameWindowRetainsControllerAndFocusedHostButNoMirroredSession()
{
FieldInfo[] fields = typeof(GameWindow).GetFields(PrivateInstance);
@ -19,8 +19,13 @@ public sealed class GameWindowLiveSessionOwnershipTests
fields,
field => field.Name == "_liveSessionController"
&& field.FieldType == typeof(LiveSessionController));
Assert.Contains(
fields,
field => field.Name == "_liveSessionHost"
&& field.FieldType == typeof(LiveSessionHost));
Assert.DoesNotContain(fields, field => field.Name == "_liveSession");
Assert.DoesNotContain(fields, field => field.FieldType == typeof(WorldSession));
Assert.DoesNotContain(fields, field => field.FieldType == typeof(LiveSessionResetPlan));
Assert.DoesNotContain(fields, field => field.Name == "_liveSessionEvents");
Assert.DoesNotContain(fields, field => field.Name == "_liveSessionCommands");
}
@ -30,6 +35,9 @@ public sealed class GameWindowLiveSessionOwnershipTests
[InlineData("ClearInboundEntityState")]
[InlineData("WireLiveSessionEvents")]
[InlineData("DisposeLiveSessionRouting")]
[InlineData("CreateLiveSessionBinding")]
[InlineData("ApplyLiveSessionSelection")]
[InlineData("ApplyLiveSessionEnteredWorld")]
public void DisplacedLifecycleBodiesAreAbsent(string methodName)
{
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateInstance));

View file

@ -13,6 +13,33 @@ namespace AcDream.App.Tests.Net;
public sealed class LiveSessionEventRouterTests
{
[Fact]
public void CreatedRouterPublishesNoHandlersUntilExplicitAttach()
{
using var session = NewSession();
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
var router = new LiveSessionEventRouter(
session,
new LiveEntitySessionSink(
_ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { },
_ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }),
new LiveEnvironmentSessionSink(_ => { }, _ => { }),
NewInventoryBindings(),
NewCharacterBindings(),
NewSocialBindings());
AssertSessionHandlerCounts(session, multiplier: 0);
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
router.Attach();
AssertSessionHandlerCounts(session, multiplier: 1);
Assert.True(session.GameEvents.RegisteredHandlerCount > baselineGameEvents);
router.Dispose();
AssertSessionHandlerCounts(session, multiplier: 0);
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
}
[Fact]
public void Dispose_DetachesExactHandlersAndSilencesCopiedDelegates()
{
@ -157,33 +184,46 @@ public sealed class LiveSessionEventRouterTests
Counters counters,
Action<int>? constructionCheckpoint = null,
CombatState? combat = null,
ChatLog? chat = null) => new(
session,
new LiveEntitySessionSink(
Spawned: _ => { },
Deleted: _ => { },
PickedUp: _ => { },
MotionUpdated: _ => { },
PositionUpdated: _ => { },
VectorUpdated: _ => { },
StateUpdated: _ => { },
ParentUpdated: _ => { },
TeleportStarted: _ => counters.Teleport++,
AppearanceUpdated: _ => { },
PlayPhysicsScript: _ => { },
PlayPhysicsScriptType: _ => { }),
new LiveEnvironmentSessionSink(
EnvironChanged: _ => { },
ServerTimeUpdated: _ =>
{
if (counters.ThrowOnServerTime)
throw new InvalidOperationException("injected sink failure");
counters.ServerTime++;
}),
NewInventoryBindings(),
NewCharacterBindings(combat),
NewSocialBindings(chat),
constructionCheckpoint);
ChatLog? chat = null)
{
var router = new LiveSessionEventRouter(
session,
new LiveEntitySessionSink(
Spawned: _ => { },
Deleted: _ => { },
PickedUp: _ => { },
MotionUpdated: _ => { },
PositionUpdated: _ => { },
VectorUpdated: _ => { },
StateUpdated: _ => { },
ParentUpdated: _ => { },
TeleportStarted: _ => counters.Teleport++,
AppearanceUpdated: _ => { },
PlayPhysicsScript: _ => { },
PlayPhysicsScriptType: _ => { }),
new LiveEnvironmentSessionSink(
EnvironChanged: _ => { },
ServerTimeUpdated: _ =>
{
if (counters.ThrowOnServerTime)
throw new InvalidOperationException("injected sink failure");
counters.ServerTime++;
}),
NewInventoryBindings(),
NewCharacterBindings(combat),
NewSocialBindings(chat),
constructionCheckpoint);
try
{
router.Attach();
return router;
}
catch
{
router.Dispose();
throw;
}
}
private static LiveInventorySessionBindings NewInventoryBindings() => new(
new ClientObjectTable(),

View file

@ -0,0 +1,391 @@
using System.Net;
using AcDream.App.Net;
using AcDream.App.Rendering;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
namespace AcDream.App.Tests.Net;
public sealed class LiveSessionHostTests
{
[Fact]
public void HostOwnsRouteSelectionAndEntryOrderingWithoutMirroringSession()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var controller = new LiveSessionController(operations);
var events = new TestEventRouting(calls);
var commands = new TestCommandRouting(calls);
LiveSessionHost host = CreateHost(
controller,
calls,
_ =>
{
calls.Add("events");
return events;
},
_ =>
{
calls.Add("commands");
return commands;
});
LiveSessionStartResult result = host.Start(LiveOptions());
Assert.Equal(LiveSessionStartStatus.Connected, result.Status);
Assert.Equal(
[
"reset", "resolve", "create", "events", "attach-events", "commands",
"connecting", "connect", "connected",
"player:1342177282", "vitals:1342177282",
"chat:1342177282", "persistent:1342177282",
"vanish:1342177282", "clear-combat", "enter:1",
"activate", "active:Ready", "restore-layout",
"sync-toolbar", "load-settings:Ready", "arm-auto-entry",
],
calls);
Assert.Same(controller.CurrentSession, host.CurrentSession);
Assert.Same(commands, host.Commands);
Assert.True(host.IsInWorld);
controller.Dispose();
}
[Fact]
public void DisabledAndMissingCredentialStartsExecuteTheCanonicalResetPlan()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var controller = new LiveSessionController(operations);
LiveSessionHost host = CreateHost(
controller,
calls,
_ => throw new InvalidOperationException("must not bind"),
_ => throw new InvalidOperationException("must not bind"));
Assert.Equal(
LiveSessionStartStatus.Disabled,
host.Start(LiveOptions(live: false)).Status);
Assert.Equal(
LiveSessionStartStatus.MissingCredentials,
host.Start(LiveOptions(user: null)).Status);
Assert.Equal(["reset", "reset"], calls);
Assert.Null(host.CurrentSession);
Assert.False(host.IsInWorld);
}
[Fact]
public void CommandFactoryFailureRollsBackTheAlreadyAttachedEventRoute()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var controller = new LiveSessionController(operations);
var events = new TestEventRouting(calls);
LiveSessionHost host = CreateHost(
controller,
calls,
_ => events,
_ => throw new InvalidOperationException("command failure"));
LiveSessionStartResult result = host.Start(LiveOptions());
Assert.Equal(LiveSessionStartStatus.Failed, result.Status);
Assert.IsType<InvalidOperationException>(result.Error);
Assert.Equal(1, events.DisposeCount);
Assert.Contains("dispose-events", calls);
Assert.Null(host.CurrentSession);
}
[Fact]
public void AttachFailureRetainsThePublishedRouteUntilRollbackConverges()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var controller = new LiveSessionController(operations);
var firstEvents = new TestEventRouting(calls)
{
AttachFailuresRemaining = 1,
FailuresRemaining = 1,
};
int eventFactoryCount = 0;
int commandFactoryCount = 0;
LiveSessionHost host = CreateHost(
controller,
calls,
_ => eventFactoryCount++ == 0
? firstEvents
: new TestEventRouting(calls),
_ =>
{
commandFactoryCount++;
return new TestCommandRouting(calls);
});
LiveSessionStartResult failed = host.Start(LiveOptions());
Assert.Equal(LiveSessionStartStatus.Failed, failed.Status);
Assert.Equal(1, firstEvents.AttachCount);
Assert.Equal(2, firstEvents.DisposeCount);
Assert.Equal(0, commandFactoryCount);
LiveSessionStartResult retry = host.Start(LiveOptions());
Assert.Equal(LiveSessionStartStatus.Connected, retry.Status);
Assert.Equal(2, eventFactoryCount);
Assert.Equal(1, commandFactoryCount);
controller.Dispose();
}
[Fact]
public void TransientRollbackFailureIsReportedRetriedAndConvergesBeforeNextStart()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var controller = new LiveSessionController(operations);
var firstEvents = new TestEventRouting(calls) { FailuresRemaining = 1 };
int eventFactoryCount = 0;
bool failCommands = true;
LiveSessionHost host = CreateHost(
controller,
calls,
_ => eventFactoryCount++ == 0
? firstEvents
: new TestEventRouting(calls),
_ =>
{
if (failCommands)
{
failCommands = false;
throw new InvalidOperationException("command failure");
}
return new TestCommandRouting(calls);
});
LiveSessionStartResult failed = host.Start(LiveOptions());
AggregateException error = Assert.IsType<AggregateException>(failed.Error);
Assert.Equal(2, error.InnerExceptions.Count);
Assert.Contains(error.InnerExceptions, e => e.Message == "command failure");
Assert.Contains(error.InnerExceptions, e => e.Message == "event cleanup failure");
Assert.Equal(2, firstEvents.DisposeCount);
LiveSessionStartResult retry = host.Start(LiveOptions());
Assert.Equal(LiveSessionStartStatus.Connected, retry.Status);
Assert.Equal(2, eventFactoryCount);
Assert.Equal(2, operations.Sessions.Count);
controller.Dispose();
}
[Fact]
public void PersistentRollbackFailureBlocksEveryLaterGeneration()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var controller = new LiveSessionController(operations);
var events = new TestEventRouting(calls)
{
FailuresRemaining = int.MaxValue,
};
int eventFactoryCount = 0;
int commandFactoryCount = 0;
LiveSessionHost host = CreateHost(
controller,
calls,
_ =>
{
eventFactoryCount++;
return events;
},
_ =>
{
commandFactoryCount++;
throw new InvalidOperationException("command failure");
});
LiveSessionStartResult first = host.Start(LiveOptions());
LiveSessionStartResult second = host.Start(LiveOptions());
Assert.Equal(LiveSessionStartStatus.Failed, first.Status);
Assert.Equal(LiveSessionStartStatus.Failed, second.Status);
Assert.Equal(3, events.DisposeCount);
Assert.Equal(1, eventFactoryCount);
Assert.Equal(1, commandFactoryCount);
Assert.Single(operations.Sessions);
Assert.Null(host.CurrentSession);
}
private static LiveSessionHost CreateHost(
LiveSessionController controller,
List<string> calls,
Func<WorldSession, ILiveSessionEventRouting> createEvents,
Func<WorldSession, ILiveSessionCommandRouting> createCommands) =>
new(controller, new LiveSessionHostBindings(
Routing: new(createEvents, createCommands),
Reset: ResetBindings(() => calls.Add("reset")),
Selection: new(
id => calls.Add($"player:{id}"),
id => calls.Add($"vitals:{id}"),
id => calls.Add($"chat:{id}"),
id => calls.Add($"persistent:{id}"),
id => calls.Add($"vanish:{id}"),
() => calls.Add("clear-combat")),
EnteredWorld: new(
name => calls.Add($"active:{name}"),
() => calls.Add("restore-layout"),
() => calls.Add("sync-toolbar"),
name => calls.Add($"load-settings:{name}"),
() => calls.Add("arm-auto-entry")),
Connecting: (_, _, _) => calls.Add("connecting"),
Connected: () => calls.Add("connected")));
private static LiveSessionResetBindings ResetBindings(Action reset)
{
Action noop = static () => { };
return new LiveSessionResetBindings
{
MouseCapture = reset,
PlayerPresentation = noop,
TeleportTransit = noop,
SessionDialogs = noop,
ChatCommandTargets = noop,
SettingsCharacterContext = noop,
EquippedChildren = noop,
ExternalContainer = noop,
InteractionAndSelection = noop,
SelectionPresentation = noop,
ObjectTable = noop,
Spellbook = noop,
MagicRuntime = noop,
CombatAttack = noop,
CombatState = noop,
ItemMana = noop,
LocalPlayer = noop,
Friends = noop,
Squelch = noop,
TurbineChat = noop,
ParticleVisibility = noop,
InboundEventFifo = noop,
LiveLiveness = noop,
LiveRuntime = noop,
SessionIdentity = noop,
RemoteTeleport = noop,
NetworkEffects = noop,
AnimationHookFrames = noop,
LivePresentation = noop,
RemoteMovementDiagnostics = noop,
};
}
private static RuntimeOptions LiveOptions(bool live = true, string? user = "user")
{
var environment = new Dictionary<string, string?>
{
["ACDREAM_LIVE"] = live ? "1" : "0",
["ACDREAM_TEST_HOST"] = "127.0.0.1",
["ACDREAM_TEST_PORT"] = "9000",
["ACDREAM_TEST_USER"] = user,
["ACDREAM_TEST_PASS"] = "password",
};
return RuntimeOptions.Parse("dat", environment.GetValueOrDefault);
}
private sealed class TestEventRouting(List<string> calls)
: ILiveSessionEventRouting
{
public int FailuresRemaining { get; set; }
public int AttachFailuresRemaining { get; set; }
public int AttachCount { get; private set; }
public int DisposeCount { get; private set; }
public void Attach()
{
AttachCount++;
calls.Add("attach-events");
if (AttachFailuresRemaining > 0)
{
AttachFailuresRemaining--;
throw new InvalidOperationException("event attach failure");
}
}
public void Dispose()
{
DisposeCount++;
calls.Add("dispose-events");
if (FailuresRemaining > 0)
{
FailuresRemaining--;
throw new InvalidOperationException("event cleanup failure");
}
}
}
private sealed class TestCommandRouting(List<string> calls)
: ILiveSessionCommandRouting
{
public void Activate() => calls.Add("activate");
public void Publish<T>(T command) where T : notnull { }
public void Dispose() => calls.Add("dispose-commands");
}
private sealed class TestOperations(List<string> calls) : ILiveSessionOperations
{
public List<WorldSession> Sessions { get; } = [];
public IPEndPoint ResolveEndpoint(string host, int port)
{
calls.Add("resolve");
return new IPEndPoint(IPAddress.Loopback, port);
}
public WorldSession CreateSession(IPEndPoint endpoint)
{
calls.Add("create");
var session = new WorldSession(endpoint, new TestTransport());
Sessions.Add(session);
return session;
}
public void Connect(WorldSession session, string user, string password) =>
calls.Add("connect");
public CharacterList.Parsed? GetCharacters(WorldSession session) => new(
0u,
[
new CharacterList.Character(0x50000001u, "Grey", 10u),
new CharacterList.Character(0x50000002u, "Ready", 0u),
],
[],
11,
"Canonical",
true,
true);
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
calls.Add($"enter:{activeCharacterIndex}");
public void Tick(WorldSession session) { }
public void DisposeSession(WorldSession session)
{
calls.Add("dispose-session");
session.Dispose();
}
}
private sealed class TestTransport : IWorldSessionTransport
{
public void Send(ReadOnlySpan<byte> datagram) { }
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from)
{
from = null;
return null;
}
public void Dispose() { }
}
}

View file

@ -0,0 +1,60 @@
using AcDream.App.Net;
namespace AcDream.App.Tests.Net;
public sealed class LiveSessionSubscriptionSetTests
{
[Fact]
public void Dispose_RetriesOnlyFailedEdgesUntilTheyConverge()
{
var order = new List<int>();
var subscriptions = new LiveSessionSubscriptionSet();
subscriptions.Add(() => order.Add(1));
int secondFailures = 1;
subscriptions.Add(() =>
{
order.Add(2);
if (secondFailures-- > 0)
throw new InvalidOperationException("second failed");
});
int thirdFailures = 1;
subscriptions.Add(() =>
{
order.Add(3);
if (thirdFailures-- > 0)
throw new IOException("third failed");
});
AggregateException error = Assert.Throws<AggregateException>(
subscriptions.Dispose);
Assert.Equal([3, 2, 1], order);
Assert.Collection(
error.InnerExceptions,
exception => Assert.IsType<IOException>(exception),
exception => Assert.IsType<InvalidOperationException>(exception));
subscriptions.Dispose();
subscriptions.Dispose();
Assert.Equal([3, 2, 1, 3, 2], order);
}
[Fact]
public void Dispose_PersistentFailureNeverReplaysSuccessfulEdges()
{
var order = new List<int>();
var subscriptions = new LiveSessionSubscriptionSet();
subscriptions.Add(() => order.Add(1));
subscriptions.Add(() =>
{
order.Add(2);
throw new InvalidOperationException("persistent");
});
Assert.Throws<AggregateException>(subscriptions.Dispose);
Assert.Throws<AggregateException>(subscriptions.Dispose);
Assert.Equal([2, 1, 2], order);
}
}

View file

@ -0,0 +1,112 @@
using AcDream.App.Net;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.App.Tests.Net;
public sealed class RetailSkillFormulaTests
{
[Fact]
public void ZeroDivisorIsTheOnlyFormulaFailureGate()
{
SkillFormula invalid = Formula(w: 7, x: 1, y: 1, z: 0);
Assert.False(RetailSkillFormula.TryCalculate(invalid, 10u, 20u, out uint invalidResult));
Assert.Equal(0u, invalidResult);
SkillFormula zeroX = Formula(w: 7, x: 0, y: 2, z: 3);
Assert.True(RetailSkillFormula.TryCalculate(zeroX, 10u, 4u, out uint validResult));
Assert.Equal(5u, validResult);
}
[Theory]
[InlineData(9u, 4u, 2u)]
[InlineData(10u, 4u, 3u)]
[InlineData(11u, 4u, 3u)]
[InlineData(12u, 4u, 3u)]
public void DivisionRoundsToNearestWithExactHalvesUp(
uint numerator,
uint divisor,
uint expected)
{
SkillFormula formula = Formula(w: 0, x: 1, y: 0, z: divisor);
Assert.True(RetailSkillFormula.TryCalculate(
formula,
numerator,
0u,
out uint result));
Assert.Equal(expected, result);
}
[Fact]
public void AdditiveWordIsInsideTheNumerator()
{
SkillFormula formula = Formula(w: 3, x: 1, y: 1, z: 4);
Assert.True(RetailSkillFormula.TryCalculate(formula, 4u, 2u, out uint result));
Assert.Equal(2u, result);
}
[Fact]
public void NumeratorUsesUnsignedThirtyTwoBitWrap()
{
SkillFormula formula = Formula(w: 1, x: 2, y: 0, z: 2);
Assert.True(RetailSkillFormula.TryCalculate(
formula,
uint.MaxValue,
0u,
out uint result));
Assert.Equal(0x80000000u, result);
}
[Fact]
public void SignedDatStorageIsReinterpretedAsRetailUnsignedWords()
{
SkillFormula formula = Formula(w: -1, x: 0, y: 0, z: 1);
Assert.True(RetailSkillFormula.TryCalculate(formula, 0u, 0u, out uint result));
Assert.Equal(uint.MaxValue, result);
}
[Fact]
public void LiveResolverLooksUpTheDatFormulaAndTreatsMissingAttributesAsZero()
{
const uint skillId = 0x345u;
const uint firstAttributeId = 1u;
const uint secondAttributeId = 2u;
var skillTable = new SkillTable();
skillTable.Skills.Add((SkillId)skillId, new SkillBase
{
Formula = new SkillFormula
{
AdditiveBonus = 1,
Attribute1Multiplier = 1,
Attribute2Multiplier = 2,
Divisor = 3,
Attribute1 = (AttributeId)firstAttributeId,
Attribute2 = (AttributeId)secondAttributeId,
},
});
var resolver = new LiveSkillCreditResolver(skillTable);
uint result = resolver.Resolve(
skillId,
new Dictionary<uint, uint> { [firstAttributeId] = 8u });
Assert.Equal(3u, result);
Assert.Equal(0u, resolver.Resolve(0x999u, new Dictionary<uint, uint>()));
}
private static SkillFormula Formula(int w, int x, int y, uint z) => new()
{
AdditiveBonus = w,
Attribute1Multiplier = x,
Attribute2Multiplier = y,
Divisor = unchecked((int)z),
};
}

View file

@ -48,7 +48,7 @@ public sealed class GameWindowSlice8BoundaryTests
{
string body = MethodBody(
"private void OnLoad()",
"private AcDream.App.Net.LiveSessionLifecycleHost");
"private AcDream.App.Net.LiveSessionHost");
AssertAppearsInOrder(
body,
@ -71,9 +71,10 @@ public sealed class GameWindowSlice8BoundaryTests
"_liveEntities = new AcDream.App.World.LiveEntityRuntime(",
"_renderFrameOrchestrator =",
"_updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(",
"_liveSessionController.Start(");
Assert.Equal(1, CountOccurrences(body, "_liveSessionController.Start("));
int start = body.IndexOf("_liveSessionController.Start(", StringComparison.Ordinal);
"_liveSessionHost = CreateLiveSessionHost();",
"_liveSessionHost.Start(_options)");
Assert.Equal(1, CountOccurrences(body, "_liveSessionHost.Start(_options)"));
int start = body.IndexOf("_liveSessionHost.Start(_options)", StringComparison.Ordinal);
string postStart = body[start..];
Assert.Contains("switch (liveStart.Status)", postStart, StringComparison.Ordinal);
Assert.Empty(System.Text.RegularExpressions.Regex.Matches(

View file

@ -18,20 +18,24 @@ public sealed class SubscriptionSetTests
}
[Fact]
public void Dispose_AttemptsEveryReleaseAndAggregatesFailures()
public void Dispose_RetriesOnlyFailedReleasesUntilTheyConverge()
{
var order = new List<int>();
var subscriptions = new SubscriptionSet();
subscriptions.Add(() => order.Add(1));
int secondFailures = 1;
subscriptions.Add(() =>
{
order.Add(2);
throw new InvalidOperationException("second failed");
if (secondFailures-- > 0)
throw new InvalidOperationException("second failed");
});
int thirdFailures = 1;
subscriptions.Add(() =>
{
order.Add(3);
throw new IOException("third failed");
if (thirdFailures-- > 0)
throw new IOException("third failed");
});
AggregateException error = Assert.Throws<AggregateException>(
@ -42,6 +46,28 @@ public sealed class SubscriptionSetTests
error.InnerExceptions,
exception => Assert.IsType<IOException>(exception),
exception => Assert.IsType<InvalidOperationException>(exception));
subscriptions.Dispose();
subscriptions.Dispose();
Assert.Equal([3, 2, 1, 3, 2], order);
}
[Fact]
public void Dispose_PersistentFailureNeverReplaysSuccessfulReleases()
{
var order = new List<int>();
var subscriptions = new SubscriptionSet();
subscriptions.Add(() => order.Add(1));
subscriptions.Add(() =>
{
order.Add(2);
throw new InvalidOperationException("persistent");
});
Assert.Throws<AggregateException>(subscriptions.Dispose);
Assert.Throws<AggregateException>(subscriptions.Dispose);
Assert.Equal([2, 1, 2], order);
}
}