Priority events

Safety And Threading

Handle exceptions, cancellation, off-thread subscriptions, and mutation during invoke.

  • Exceptions
  • Cancellation
  • Threading
  • Re-entrancy

This page covers failure handling, cancellation, off-thread behavior, and mutation during dispatch.

Exception Handling

Each priority event has an ExceptionMode property.

CombatBus.Tick.ExceptionMode = AzExceptionMode.Isolate;

ExceptionMode is applied by the InvokeSafe family. Normal Invoke and InvokeFast do not isolate handler exceptions.

Available modes:

Isolate

Continue after handler exceptions. This is the default and safest general mode.

PropagateAggregate

Selects the shared aggregate mode. In the current priority-event runtime, rely on Exceptions and FirstException; do not depend on AllExceptions being populated for every priority invocation path.

Abort

Stop on first exception. Useful for fail-fast critical flows.

Silent

Selects the shared silent mode value. Current priority safe invocation still records counts and may call configured exception handlers, so do not use this as a privacy or redaction boundary.

Recommended Default

Use Isolate unless you have a specific reason not to.

CombatBus.Tick.ExceptionMode = AzExceptionMode.Isolate;

Then inspect failures through InvokeSafe:

AzInvokeResult result = CombatBus.Tick.InvokeSafe();

if (result.HasExceptions)
{
    Debug.LogError(result.ToDetailedString(includeStackTraces: true));
}

Abort for Critical Startup

StartupBus.BeforeGameStart.ExceptionMode = AzExceptionMode.Abort;

AzInvokeResult result = StartupBus.BeforeGameStart.InvokeSafe();
result.ThrowIfFailed();

Validation Result Check

ValidationBus.Validate.ExceptionMode = AzExceptionMode.PropagateAggregate;

AzInvokeResult result = ValidationBus.Validate.InvokeSafe();

if (result.HasExceptions)
{
    Debug.LogError(result.ToDetailedString(includeStackTraces: true));
}

AllExceptions is optional result data. For current priority events, write validation code against HasExceptions, Exceptions, and FirstException unless you have verified that the exact invocation path you use populates the aggregate list.

Global Exception Handler

AzPriorityEventConfig.GlobalExceptionHandler lets you centralize exception reporting.

AzPriorityEventConfig.GlobalExceptionHandler = (exception, eventInstance, context) =>
{
    Debug.LogError($"[{context}] {exception}");
};

AzPriorityEventConfig.MaxAggregateExceptions is the shared limit for aggregate exception lists when a runtime path populates them.

AzPriorityEventConfig.MaxAggregateExceptions = 32;

AzPriorityEventConfig.DefaultMode exists in the shared configuration surface, but the current priority event constructors initialize ExceptionMode to AzExceptionMode.Isolate. Set ExceptionMode on each event you want to configure explicitly.

AzInvokeResult

InvokeSafe returns AzInvokeResult.

Useful members:

Invoked

Number of handlers attempted.

Exceptions

Number of handler exceptions.

FirstException

First exception, if details are available.

AllExceptions

Optional aggregate exception list. Do not assume current priority safe invocation always populates it.

Success

True when Exceptions == 0.

HasExceptions

True when Exceptions > 0.

IsEmpty

True when no handlers were invoked.

SuccessRate

Ratio of successful handler calls.

ToDetailedString(...)

Diagnostic multiline summary.

ThrowIfFailed()

Throws the first available exception or a generic failure.

ThrowAggregateIfFailed(...)

Throws an AggregateException if any handler failed.

Example:

AzInvokeResult result = CombatBus.HealthChanged.InvokeSafe(currentHealth);

if (result.IsEmpty)
{
    Debug.LogWarning("No health listeners are registered.");
}
else if (!result.Success)
{
    Debug.LogError(result.ToDetailedString(includeStackTraces: true));
}

Combine multiple results:

AzInvokeResult combined =
    CombatBus.Tick.InvokeSafe() +
    CombatBus.HealthChanged.InvokeSafe(currentHealth);

Debug.Log(combined.ToString());

Cancellation

Priority invocation methods accept optional cancellation settings:

evt.Invoke(
    cancelMode: AzCancelMode.Soft,
    perHandlerChecks: false,
    ct: cancellationToken);

AzCancelMode values:

None

Ignore the cancellation token.

Soft

Exit early when cancellation is observed. Does not throw.

Hard

Throw OperationCanceledException when cancellation is observed.

By default, cancellation is checked between slots. If you pass perHandlerChecks: true, cancellation can also be checked between handlers within a slot. That is more responsive but slower.

AzCancelInfo cancelInfo;

CombatBus.Tick.Invoke(
    out cancelInfo,
    cancelMode: AzCancelMode.Soft,
    perHandlerChecks: true,
    ct: cancellationToken);

if (cancelInfo.WasCanceled)
{
    Debug.Log($"Canceled at slot index {cancelInfo.SlotIndex}, handler index {cancelInfo.HandlerIndex}");
}

SlotIndex is zero-based in AzCancelInfo. Slot 1 reports index 0, slot 9 reports index 8.

Off-Thread Subscription Behavior

AzPriorityEvent supports off-thread subscription and token operations by queuing operations for the main thread.

Important rules:

  • Invoke, InvokeFast, InvokeSlots, and other mask-based full paths should be called on the main thread.
  • Off-thread Subscribe queues the subscription.
  • If Subscribe is called off-thread, the returned AzEventToken is AzEventToken.Invalid because the real slot is not bound yet.
  • Queued operations are drained on a later main-thread invocation path.
  • Token Dispose, Pause, and Resume called off-thread are queued.

Example:

using System.Threading.Tasks;

public sealed class WorkerSubscriptionExample
{
    public void StartWorker()
    {
        Task.Run(() =>
        {
            AzEventToken token = CombatBus.Tick.Subscribe(OnWorkerRequestedTick, slot: 7);

            // Off-thread Subscribe returns Invalid because the real binding is pending.
            // Do not put this token in a bag.
            Debug.Log(token.IsValid); // false
        });
    }

    private void OnWorkerRequestedTick()
    {
    }
}

For normal user code, prefer creating subscriptions on the Unity main thread. Off-thread subscription support is for advanced producer scenarios where queuing is intentional.

Off-Thread Cleanup Caveat

If you use an off-thread pending subscription and dispose the pending token before it binds, cleanup is by delegate.

That means stable method groups are much safer than throwaway lambdas:

// Better for queued cleanup:
Task.Run(() => CombatBus.Tick.Subscribe(OnTick, slot: 7));

// Harder to reason about:
Task.Run(() => CombatBus.Tick.Subscribe(() => Debug.Log("Tick"), slot: 7));

Re-entrancy and Mutation During Invoke

Priority events are designed to tolerate mutation during invocation. Subscribing, pausing, resuming, or disposing during an active invoke does not corrupt the traversal.

Newly added subscriptions are not something you should depend on for immediate same-dispatch execution. Think of subscription changes as taking effect for a later stable dispatch.

private void First()
{
    CombatBus.Tick.Subscribe(Second, slot: 5);
}

CombatBus.Tick.Subscribe(First, slot: 5);
CombatBus.Tick.Invoke(); // First runs. Do not rely on Second running in this same invoke.
CombatBus.Tick.Invoke(); // Second can run on a later invoke.