acdream/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs
Erik 8bc458fb88 feat(audio): Campaign A slice A3 — the server sound channel (0xF750)
acdream never parsed retail's Sound event, so every server-driven cue was
silent: melee hits and wounds, wield/unwield, pickup/drop, lockpicking,
lifestone bind, spell resist, trap triggers, item mana depletion.

SoundEvent parses the 16-byte message (guid, SoundType, f32 volume) whose
layout three oracles agree on: retail CM_Physics::DispatchSB_SoundEvent
@0x006AC760 reading buf+4/+8/+0xC, ACE's GameMessageSound at declared
length 16, and holtburger's PlaySoundData.

Playback reuses EntityEffectController's existing per-guid queue rather
than adding a second one, because retail routes sounds through the SAME
CObjectMaint blob queue as F754/F755: an event for a guid the client does
not know yet is parked and drained by HandleCreateObject, so a creature
that spawns and immediately grunts still grunts. Dropping it — the
obvious alternative — would silently lose the cue. Sound joins Direct and
Typed as a third PendingEffect kind so one readiness edge releases the
whole mixed stream in order.

AudioHookSink.PlayServerSound reproduces two decoded asymmetries with the
animation-hook path: the sound plays at the WIRE volume and the
SoundTable entry's volume is ignored (the hook path does the opposite),
while the entry's probability still gates it and its priority still
drives eviction. An object with no SoundTable plays nothing, matching
CPhysicsObj::play_sound @0x0050F460's early return.

The no-window host parses and discards, exactly as it does for F754/F755
— sound is presentation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:07:23 +02:00

643 lines
27 KiB
C#

using AcDream.Core.Chat;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Player;
using AcDream.Core.Properties;
using AcDream.Core.Social;
using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Session;
public sealed record LiveEntitySessionSink(
Action<WorldSession.EntitySpawn> Spawned,
Action<DeleteObject.Parsed> Deleted,
Action<PickupEvent.Parsed> PickedUp,
Action<WorldSession.EntityMotionUpdate> MotionUpdated,
Action<WorldSession.EntityPositionUpdate> PositionUpdated,
Action<VectorUpdate.Parsed> VectorUpdated,
Action<SetState.Parsed> StateUpdated,
Action<ParentEvent.Parsed> ParentUpdated,
Action<uint> TeleportStarted,
Action<ObjDescEvent.Parsed> AppearanceUpdated,
Action<PlayPhysicsScript> PlayPhysicsScript,
Action<PlayPhysicsScriptType> PlayPhysicsScriptType,
Action<SoundEvent> SoundEvent);
public sealed record LiveEnvironmentSessionSink(
Action<uint> EnvironChanged,
Action<double> ServerTimeUpdated);
public sealed record LiveInventorySessionBindings(
ClientObjectTable Objects,
Func<uint> PlayerGuid,
Action<IReadOnlyList<ShortcutEntry>>? OnShortcuts,
Action<uint>? OnUseDone,
ItemManaState? ItemMana,
ExternalContainerState? ExternalContainers,
Action<AppraiseInfoParser.Parsed>? OnAppraisal = null,
// Slice 5.3: the vendor browse session owner. Trailing/optional so every
// existing positional caller (Headless) compiles unchanged.
VendorState? Vendor = null);
public sealed record LiveCharacterSessionBindings(
CombatState Combat,
RuntimeCharacterState Character,
Func<uint, IReadOnlyDictionary<uint, uint>, uint>? ResolveSkillFormulaBonus,
Action<int, int>? OnSkillsUpdated,
Action<GameEvents.CharacterConfirmationRequest>? OnConfirmationRequest,
Action<GameEvents.CharacterConfirmationDone>? OnConfirmationDone,
Func<double>? ClientTime,
// Campaign P Slice P1 (2026-07-30): fires after MovementSkills' burden,
// stamina, OR (vitae/enchantment-adjusted) skill values change mid-
// session — the reactive re-apply-to-the-live-controller seam, mirroring
// OnSkillsUpdated's existing shape. Optional/nullable so every existing
// caller (including Headless's OnSkillsUpdated: null pattern) compiles
// unchanged.
Action? OnMovementStatsUpdated = null);
public sealed record LiveSocialSessionBindings(
ChatLog Chat,
TurbineChatState TurbineChat,
FriendsState? Friends,
SquelchState? Squelch);
/// <summary>
/// Owns every inbound subscription for one exact live session. Domain state
/// remains in the supplied sinks; this class owns only routing and teardown.
/// </summary>
public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
{
private readonly LiveSessionSubscriptionSet _subscriptions = new();
private readonly Action<int>? _constructionCheckpoint;
private readonly WorldSession _session;
private readonly LiveEntitySessionSink _entities;
private readonly LiveEnvironmentSessionSink _environment;
private readonly LiveInventorySessionBindings _inventory;
private readonly LiveCharacterSessionBindings _character;
private readonly LiveSocialSessionBindings _social;
private int _constructionStep;
private int _accepting;
private int _lifecycleState; // 0 created, 1 attaching, 2 attached, 3 disposed
public LiveSessionEventRouter(
WorldSession session,
LiveEntitySessionSink entities,
LiveEnvironmentSessionSink environment,
LiveInventorySessionBindings inventory,
LiveCharacterSessionBindings character,
LiveSocialSessionBindings social,
Action<int>? constructionCheckpoint = null)
{
ArgumentNullException.ThrowIfNull(session);
Validate(entities, environment, inventory, character, social);
_session = session;
_entities = entities;
_environment = environment;
_inventory = inventory;
_character = character;
_social = social;
_constructionCheckpoint = constructionCheckpoint;
}
public void Attach()
{
if (Interlocked.CompareExchange(ref _lifecycleState, 1, 0) != 0)
throw new InvalidOperationException(
"Live-session event routing can only attach once.");
Interlocked.Exchange(ref _accepting, 1);
WorldSession session = _session;
LiveEntitySessionSink entities = _entities;
LiveEnvironmentSessionSink environment = _environment;
LiveInventorySessionBindings inventory = _inventory;
LiveCharacterSessionBindings character = _character;
LiveSocialSessionBindings social = _social;
try
{
// Preserve the shipped pre-Connect registration order. Property
// state is installed before lifecycle packets can be dispatched.
_subscriptions.Add(ObjectTableWiring.Wire(
session,
inventory.Objects,
inventory.PlayerGuid,
character.Character.LocalPlayer,
IsAccepting));
ConstructionCheckpoint();
_subscriptions.Add(CombatStateWiring.Wire(
session,
character.Combat,
IsAccepting));
ConstructionCheckpoint();
Subscribe(h => session.EntitySpawned += h, h => session.EntitySpawned -= h, entities.Spawned);
Subscribe(h => session.EntityDeleted += h, h => session.EntityDeleted -= h, entities.Deleted);
Subscribe(h => session.EntityPickedUp += h, h => session.EntityPickedUp -= h, entities.PickedUp);
Subscribe(h => session.MotionUpdated += h, h => session.MotionUpdated -= h, entities.MotionUpdated);
Subscribe(h => session.PositionUpdated += h, h => session.PositionUpdated -= h, entities.PositionUpdated);
Subscribe(h => session.VectorUpdated += h, h => session.VectorUpdated -= h, entities.VectorUpdated);
Subscribe(h => session.StateUpdated += h, h => session.StateUpdated -= h, entities.StateUpdated);
Subscribe(h => session.ParentUpdated += h, h => session.ParentUpdated -= h, entities.ParentUpdated);
Subscribe(h => session.TeleportStarted += h, h => session.TeleportStarted -= h, entities.TeleportStarted);
Subscribe(h => session.AppearanceUpdated += h, h => session.AppearanceUpdated -= h, entities.AppearanceUpdated);
Subscribe(
h => session.PlayPhysicsScriptReceived += h,
h => session.PlayPhysicsScriptReceived -= h,
entities.PlayPhysicsScript);
Subscribe(
h => session.PlayPhysicsScriptTypeReceived += h,
h => session.PlayPhysicsScriptTypeReceived -= h,
entities.PlayPhysicsScriptType);
Subscribe(
h => session.SoundEventReceived += h,
h => session.SoundEventReceived -= h,
entities.SoundEvent);
Subscribe(h => session.EnvironChanged += h, h => session.EnvironChanged -= h, environment.EnvironChanged);
Subscribe(h => session.ServerTimeUpdated += h, h => session.ServerTimeUpdated -= h, environment.ServerTimeUpdated);
_subscriptions.Add(GameEventWiring.WireAll(
session.GameEvents,
inventory.Objects,
character.Combat,
character.Character.Spellbook,
social.Chat,
character.Character.LocalPlayer,
social.TurbineChat,
onSkillsUpdated: (runSkill, jumpSkill) =>
{
// Campaign P Slice P1 (2026-07-30): route the PD/skill
// base through the vitae/enchantment-adjusted recompute
// (CEnchantmentRegistry::EnchantSkill) instead of writing
// MovementSkills directly — see the pseudocode doc §9.
character.Character.UpdateMovementSkillBase(
runSkill,
jumpSkill);
character.OnSkillsUpdated?.Invoke(runSkill, jumpSkill);
character.OnMovementStatsUpdated?.Invoke();
},
resolveSkillFormulaBonus: character.ResolveSkillFormulaBonus,
onShortcuts: inventory.OnShortcuts,
playerGuid: inventory.PlayerGuid,
onUseDone: inventory.OnUseDone,
onAppraisal: inventory.OnAppraisal,
itemMana: inventory.ItemMana,
onConfirmationRequest: character.OnConfirmationRequest,
onConfirmationDone: character.OnConfirmationDone,
friends: social.Friends,
squelch: social.Squelch,
onDesiredComponents: null,
onCharacterOptions: character.Character.Options.Replace,
clientTime: character.ClientTime,
externalContainers: inventory.ExternalContainers,
vendor: inventory.Vendor,
accepting: IsAccepting));
ConstructionCheckpoint();
// Campaign P Slice P1 (2026-07-30): burden recompute triggers —
// the SAME event set IndicatorBarController.UpdateBurden already
// reacts to (Strength + augmentation property 0xE6 +
// EncumbranceVal property 5, falling back to SumCarriedBurden).
// See the pseudocode doc §9. Campaign P Slice P3 (2026-07-30)
// rides the SAME triggers for the player's own PWD bitfield
// (PK/PKLite/Impenetrable) and PlayerKillerStatus/
// LastPkAttackTimestamp — all live on the SAME ClientObject row.
SubscribeToRecompute<ClientObject>(
h => inventory.Objects.ObjectAdded += h,
h => inventory.Objects.ObjectAdded -= h,
() => RecomputePlayerQualities(inventory, character));
SubscribeToRecompute<ClientObject>(
h => inventory.Objects.ObjectUpdated += h,
h => inventory.Objects.ObjectUpdated -= h,
() => RecomputePlayerQualities(inventory, character));
SubscribeToRecompute<ClientObject>(
h => inventory.Objects.ObjectRemoved += h,
h => inventory.Objects.ObjectRemoved -= h,
() => RecomputePlayerQualities(inventory, character));
SubscribeToRecompute<ClientObjectMove>(
h => inventory.Objects.ObjectMoved += h,
h => inventory.Objects.ObjectMoved -= h,
() => RecomputePlayerQualities(inventory, character));
SubscribeToRecompute<uint>(
h => inventory.Objects.ContainerContentsReplaced += h,
h => inventory.Objects.ContainerContentsReplaced -= h,
() => RecomputePlayerQualities(inventory, character));
SubscribeParameterless(
h => inventory.Objects.Cleared += h,
h => inventory.Objects.Cleared -= h,
() => RecomputePlayerQualities(inventory, character));
Subscribe<LocalPlayerState.AttributeKind>(
h => character.Character.LocalPlayer.AttributeChanged += h,
h => character.Character.LocalPlayer.AttributeChanged -= h,
kind =>
{
if (kind == LocalPlayerState.AttributeKind.Strength)
RecomputeBurden(inventory, character);
});
SubscribeParameterless(
h => character.Character.Spellbook.EnchantmentsChanged += h,
h => character.Character.Spellbook.EnchantmentsChanged -= h,
() => RecomputeBurden(inventory, character));
// Current-stamina push — CACQualities::InqRunRate/InqJumpVelocity's
// stamina==0 effective-skill-zeroing gate (pseudocode doc §5).
Subscribe<LocalPlayerState.VitalKind>(
h => character.Character.LocalPlayer.Changed += h,
h => character.Character.LocalPlayer.Changed -= h,
kind => RecomputeStamina(kind, character));
_subscriptions.Add(new CombatChatTranslator(
character.Combat,
social.Chat,
IsAccepting));
ConstructionCheckpoint();
Subscribe<HearSpeech.Parsed>(h => session.SpeechHeard += h, h => session.SpeechHeard -= h, speech =>
social.Chat.OnLocalSpeech(
speech.SenderName,
speech.Text,
speech.SenderGuid,
speech.IsRanged));
Subscribe<ServerMessage.Parsed>(
h => session.ServerMessageReceived += h,
h => session.ServerMessageReceived -= h,
message => social.Chat.OnSystemMessage(message.Message, message.ChatType));
Subscribe<EmoteText.Parsed>(h => session.EmoteHeard += h, h => session.EmoteHeard -= h, emote =>
social.Chat.OnEmote(emote.SenderName, emote.Text, emote.SenderGuid));
Subscribe<SoulEmote.Parsed>(h => session.SoulEmoteHeard += h, h => session.SoulEmoteHeard -= h, emote =>
social.Chat.OnSoulEmote(emote.SenderName, emote.Text, emote.SenderGuid));
Subscribe<PlayerKilled.Parsed>(
h => session.PlayerKilledReceived += h,
h => session.PlayerKilledReceived -= h,
killed => social.Chat.OnPlayerKilled(
killed.DeathMessage,
killed.VictimGuid,
killed.KillerGuid));
Subscribe<TurbineChat.Parsed>(
h => session.TurbineChatReceived += h,
h => session.TurbineChatReceived -= h,
parsed => RouteTurbineChat(social.Chat, parsed));
Subscribe<PrivateUpdateVital.ParsedFull>(h => session.VitalUpdated += h, h => session.VitalUpdated -= h, vital =>
character.Character.LocalPlayer.OnVitalUpdate(
vital.VitalId,
vital.Ranks,
vital.Start,
vital.Xp,
vital.Current));
Subscribe<PrivateUpdateVital.ParsedCurrent>(
h => session.VitalCurrentUpdated += h,
h => session.VitalCurrentUpdated -= h,
vital => character.Character.LocalPlayer.OnVitalCurrent(
vital.VitalId,
vital.Current));
if (Interlocked.CompareExchange(ref _lifecycleState, 2, 1) != 1)
throw new ObjectDisposedException(nameof(LiveSessionEventRouter));
}
catch
{
Interlocked.Exchange(ref _accepting, 0);
Interlocked.Exchange(ref _lifecycleState, 3);
throw;
}
}
public bool Accepting => IsAccepting();
public void Dispose()
{
Interlocked.Exchange(ref _accepting, 0);
Interlocked.Exchange(ref _lifecycleState, 3);
_subscriptions.Dispose();
}
private void Subscribe<T>(
Action<Action<T>> attach,
Action<Action<T>> detach,
Action<T> sink)
{
Action<T> handler = value =>
{
if (Volatile.Read(ref _accepting) != 0)
sink(value);
};
attach(handler);
// Add assumes cleanup ownership before it can call an external detach.
// If the set is already closing, it retains/retries that exact edge;
// calling detach again here would replay a successful removal.
_subscriptions.Add(() => detach(handler));
ConstructionCheckpoint();
}
/// <summary>
/// Campaign P Slice P1 (2026-07-30): a payload-typed event whose ONLY
/// job is "something relevant changed, recompute" — thin wrapper over
/// <see cref="Subscribe{T}"/> that discards the payload.
/// </summary>
private void SubscribeToRecompute<T>(
Action<Action<T>> attach,
Action<Action<T>> detach,
Action recompute) =>
Subscribe(attach, detach, (T _) => recompute());
/// <summary>
/// Campaign P Slice P1 (2026-07-30): the parameterless-event analogue of
/// <see cref="Subscribe{T}"/> (<c>ClientObjectTable.Cleared</c> carries
/// no payload).
/// </summary>
private void SubscribeParameterless(
Action<Action> attach,
Action<Action> detach,
Action sink)
{
Action handler = () =>
{
if (Volatile.Read(ref _accepting) != 0)
sink();
};
attach(handler);
_subscriptions.Add(() => detach(handler));
ConstructionCheckpoint();
}
/// <summary>
/// Campaign P Slice P1 (2026-07-30): retail <c>CACQualities::InqLoad</c>
/// equivalent (Strength + augmentation property 0xE6 + EncumbranceVal
/// property 5, falling back to the summed carried burden) — the SAME
/// input assembly <c>IndicatorBarController.UpdateBurden</c> /
/// <c>InventoryController.RefreshBurden</c> already use for the burden
/// HUD. See the pseudocode doc §2/§9.
/// </summary>
private static void RecomputeBurden(
LiveInventorySessionBindings inventory,
LiveCharacterSessionBindings character,
bool notify = true)
{
uint player = inventory.PlayerGuid();
ClientObject? playerObject = inventory.Objects.Get(player);
int strength = character.Character.LocalPlayer
.GetEffectiveAttribute(LocalPlayerState.AttributeKind.Strength) ?? 0;
int aug = playerObject?.Properties.GetInt(
(uint)PropertyInt.AugmentationIncreasedCarryingCapacity) ?? 0;
int capacity = EncumbranceSystem.EncumbranceCapacity(strength, aug);
int burden = playerObject is not null
&& playerObject.Properties.Ints.TryGetValue(
(uint)PropertyInt.EncumbranceVal, out int wireBurden)
? wireBurden
: inventory.Objects.SumCarriedBurden(player);
float load = EncumbranceSystem.Load(capacity, burden);
character.Character.MovementSkills.UpdateBurden(load);
if (notify)
character.OnMovementStatsUpdated?.Invoke();
}
private static void RecomputePlayerQualities(
LiveInventorySessionBindings inventory,
LiveCharacterSessionBindings character)
{
RecomputeBurden(inventory, character, notify: false);
RecomputePvpStatus(inventory, character, notify: false);
uint player = inventory.PlayerGuid();
PropertyBundle properties = inventory.Objects.Get(player)?.Properties
?? character.Character.LocalPlayer.Properties;
character.Character.UpdateMovementSkillAugmentations(
PlayerSkillMath.AugmentationBonuses.FromProperties(properties));
character.OnMovementStatsUpdated?.Invoke();
}
/// <summary>
/// TS-23 (Campaign P Slice P3, 2026-07-30): pushes the local player's
/// own <c>PublicWeenieDesc._bitfield</c> (PK/PKLite/Impenetrable
/// collision-exemption bits, already parsed at CreateObject time — see
/// <c>CreateObject.cs</c>'s <c>objectDescriptionFlags</c> read) and the
/// raw <c>PlayerKillerStatus</c>(0x86)/<c>LastPkAttackTimestamp</c>(0x91)
/// pair (retail <c>CACQualities::JumpStaminaCost</c>'s PK-timer bump)
/// into <see cref="RuntimeMovementSkillState"/>. Rides the SAME
/// ClientObject add/update/move/clear events <see cref="RecomputeBurden"/>
/// already reacts to — both live on the player's own row.
/// </summary>
private static void RecomputePvpStatus(
LiveInventorySessionBindings inventory,
LiveCharacterSessionBindings character,
bool notify = true)
{
uint player = inventory.PlayerGuid();
ClientObject? playerObject = inventory.Objects.Get(player);
uint bitfield = playerObject?.PublicWeenieBitfield ?? 0u;
int pkStatus = playerObject?.Properties.Ints.TryGetValue(
(uint)PropertyInt.PlayerKillerStatus, out int wirePkStatus) == true
? wirePkStatus
: -1;
float? lastPkAttackTimestamp =
playerObject?.Properties.Floats.TryGetValue(
(uint)PropertyFloat.LastPkAttackTimestamp, out double wireTimestamp) == true
? (float)wireTimestamp
: null;
character.Character.MovementSkills.UpdateOwnPwdBitfield(bitfield);
character.Character.MovementSkills.UpdatePlayerKillerStatus(
pkStatus,
lastPkAttackTimestamp);
if (notify)
character.OnMovementStatsUpdated?.Invoke();
}
/// <summary>
/// Campaign P Slice P1 (2026-07-30): pushes current-stamina vital
/// changes into <see cref="RuntimeMovementSkillState"/> — feeds
/// <c>CACQualities::InqRunRate</c>/<c>InqJumpVelocity</c>'s stamina==0
/// effective-skill-zeroing gate (pseudocode doc §5).
/// </summary>
private static void RecomputeStamina(
LocalPlayerState.VitalKind kind,
LiveCharacterSessionBindings character)
{
if (kind != LocalPlayerState.VitalKind.Stamina) return;
if (character.Character.LocalPlayer.Get(LocalPlayerState.VitalKind.Stamina)
is not LocalPlayerState.VitalSnapshot stamina)
{
return;
}
character.Character.MovementSkills.UpdateStamina((int)stamina.Current);
character.OnMovementStatsUpdated?.Invoke();
}
private void ConstructionCheckpoint() =>
_constructionCheckpoint?.Invoke(++_constructionStep);
private bool IsAccepting() => Volatile.Read(ref _accepting) != 0;
private static void RouteTurbineChat(ChatLog chat, TurbineChat.Parsed parsed)
{
if (parsed.Body is not TurbineChat.Payload.EventSendToRoom message)
return;
chat.OnChannelBroadcast(
message.RoomId,
message.SenderName,
message.Message,
TurbineChatDisplayNames.Resolve(message.RoomId, message.ChatType));
}
private static void Validate(
LiveEntitySessionSink entities,
LiveEnvironmentSessionSink environment,
LiveInventorySessionBindings inventory,
LiveCharacterSessionBindings character,
LiveSocialSessionBindings social)
{
ArgumentNullException.ThrowIfNull(entities);
ArgumentNullException.ThrowIfNull(environment);
ArgumentNullException.ThrowIfNull(inventory);
ArgumentNullException.ThrowIfNull(character);
ArgumentNullException.ThrowIfNull(social);
ArgumentNullException.ThrowIfNull(entities.Spawned);
ArgumentNullException.ThrowIfNull(entities.Deleted);
ArgumentNullException.ThrowIfNull(entities.PickedUp);
ArgumentNullException.ThrowIfNull(entities.MotionUpdated);
ArgumentNullException.ThrowIfNull(entities.PositionUpdated);
ArgumentNullException.ThrowIfNull(entities.VectorUpdated);
ArgumentNullException.ThrowIfNull(entities.StateUpdated);
ArgumentNullException.ThrowIfNull(entities.ParentUpdated);
ArgumentNullException.ThrowIfNull(entities.TeleportStarted);
ArgumentNullException.ThrowIfNull(entities.AppearanceUpdated);
ArgumentNullException.ThrowIfNull(entities.PlayPhysicsScript);
ArgumentNullException.ThrowIfNull(entities.PlayPhysicsScriptType);
ArgumentNullException.ThrowIfNull(entities.SoundEvent);
ArgumentNullException.ThrowIfNull(environment.EnvironChanged);
ArgumentNullException.ThrowIfNull(environment.ServerTimeUpdated);
ArgumentNullException.ThrowIfNull(inventory.Objects);
ArgumentNullException.ThrowIfNull(inventory.PlayerGuid);
ArgumentNullException.ThrowIfNull(character.Combat);
ArgumentNullException.ThrowIfNull(character.Character);
ArgumentNullException.ThrowIfNull(social.Chat);
ArgumentNullException.ThrowIfNull(social.TurbineChat);
}
}
internal sealed class LiveSessionSubscriptionSet : IDisposable
{
private readonly object _gate = new();
private readonly List<RetryableSubscription> _subscriptions = [];
private bool _disposeRequested;
public void Add(IDisposable subscription)
{
ArgumentNullException.ThrowIfNull(subscription);
AddRetained(new RetryableSubscription(subscription.Dispose));
}
public void Add(Action unsubscribe)
{
ArgumentNullException.ThrowIfNull(unsubscribe);
AddRetained(new RetryableSubscription(unsubscribe));
}
private void AddRetained(RetryableSubscription retained)
{
bool disposeNow;
lock (_gate)
{
disposeNow = _disposeRequested;
_subscriptions.Add(retained);
}
if (!disposeNow)
return;
retained.Dispose();
throw new ObjectDisposedException(nameof(LiveSessionSubscriptionSet));
}
public void Dispose()
{
RetryableSubscription[] subscriptions;
lock (_gate)
{
_disposeRequested = true;
subscriptions = _subscriptions.ToArray();
}
List<Exception>? errors = null;
for (int index = subscriptions.Length - 1; index >= 0; index--)
{
try
{
subscriptions[index].Dispose();
}
catch (Exception error)
{
(errors ??= []).Add(error);
}
}
if (errors is not null)
throw new AggregateException(
"one or more live-session subscriptions failed to detach",
errors);
}
private sealed class RetryableSubscription(Action dispose) : IDisposable
{
private readonly object _gate = new();
private Action? _dispose = dispose;
private bool _executing;
private int _executingThreadId;
public void Dispose()
{
Action? operation;
int threadId = Environment.CurrentManagedThreadId;
lock (_gate)
{
while (_executing)
{
if (_executingThreadId == threadId)
{
throw new InvalidOperationException(
"Live-session subscription cleanup cannot complete reentrantly.");
}
Monitor.Wait(_gate);
}
operation = _dispose;
if (operation is null)
return;
_executing = true;
_executingThreadId = threadId;
}
try
{
operation();
}
catch
{
CompleteAttempt(succeeded: false);
throw;
}
CompleteAttempt(succeeded: true);
}
private void CompleteAttempt(bool succeeded)
{
lock (_gate)
{
if (succeeded)
_dispose = null;
_executing = false;
_executingThreadId = 0;
Monitor.PulseAll(_gate);
}
}
}
}