Skip to content

[Events] Run every raise of an external event and cancel a pending raise - #133

Merged
Nice3point merged 14 commits into
developfrom
events/131-132-queue-and-cancel-raises
Oct 24, 2026
Merged

Nice3point merged 14 commits into
developfrom
events/131-132-queue-and-cancel-raises

Conversation

@Nice3point

@Nice3point Nice3point commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Summary of the Pull Request

What is this about:

Every raise of an external event runs its handler with its own argument, and a caller can withdraw a pending RaiseAsync with a token.

Description:

What changed

  • ExternalEvent<T> queues the argument of every Raise(argument) and runs the handler once per argument, in the order of the raises.
  • The four asynchronous events queue every RaiseAsync call with its own task.
  • Execute runs one queued raise per Revit event cycle and raises the event again while raises remain.
  • ExternalEvent without an argument is unchanged: Revit runs it once for all raises that wait.
  • Raise() inherited from ExternalEventHandler queues a separate execution on the asynchronous events and on ExternalEvent<T>. Events with an argument use the last supplied argument, as in 2027.0.2. The guides leave this call out; the XML documentation of the overrides describes it.
  • A raise that Revit refuses with Denied or TimedOut never runs. Raise(argument) returns that result, and the RaiseAsync task fails with an InvalidOperationException.
  • The four interfaces and classes declare RaiseAsync(CancellationToken) next to RaiseAsync(), which passes CancellationToken.None.
  • New constructors accept a handler of the shape (UIApplication, [T,] CancellationToken).
  • The generator recognizes a trailing CancellationToken parameter of the handler and generates the async extension method for several arguments as a pair of overloads.
  • The library exposes its internals to Nice3point.Revit.Toolkit.Tests.
  • RVTTK0006 and RVTTK0007 move to the 2027.0.1 release of AnalyzerReleases.Shipped.md, and the compiler tooling fixes move to the 2027.0.1 section of the changelog. Tag 2027.0.0 contains neither.

The token affects a raise as follows:

Token canceled Handler Task
Before the call Doesn't run, nothing is raised Canceled at once
Before Revit executes the raise Doesn't run Canceled at once
While the handler runs Runs to its end Its result
The handler throws OperationCanceledException for it Stops Canceled

With AllowDirectInvocation in API mode the token is checked once, before the handler runs.

Generated code for void Run(UIApplication application, int quantity, string label, CancellationToken cancellationToken):

new AsyncExternalEvent<RunArgs>((application, args, cancellationToken) =>
{
    Run(application, args.Quantity, args.Label, cancellationToken);
});

public static Task RaiseAsync(this IAsyncExternalEvent<Model.RunArgs> externalEvent, int quantity, string label)
{
    return externalEvent.RaiseAsync(new Model.RunArgs(quantity, label));
}

public static Task RaiseAsync(this IAsyncExternalEvent<Model.RunArgs> externalEvent, int quantity, string label, CancellationToken cancellationToken)
{
    return externalEvent.RaiseAsync(new Model.RunArgs(quantity, label), cancellationToken);
}

The synchronous RunEvent passes CancellationToken.None to the handler.

Where to look

  • The queue lives in two internal types, AsyncExternalEventCore<TArgument, TResult> and ExternalEventCore<TArgument>. They receive the Revit Raise as a delegate, and the event classes keep only the direct invocation and the last argument for Raise().
  • The cores follow System.Threading.Channels:
    • VoidResult stands for the absent argument or result;
    • a ConcurrentQueue is checked with IsEmpty;
    • each operation has one completion reservation (TryReserveCompletion, DangerousSetResult);
    • the dequeue skips operations that are already reserved.
  • An operation queued by Raise() has no task. Its handler exception leaves Execute through the filter when (operation.Task is not null), and Revit catches it.
  • The event classes override Raise(), and the cores receive base.Raise. The compiler emits ldftn ExternalEventHandler::Raise() for all 14 delegates. The cores call the Revit raise without virtual dispatch, and an override never calls itself.
  • One raise per event cycle is backed by a probe in Revit 2027: a raise inside Execute returns Accepted and triggers the next Execute, at about 1.3 ms per cycle. Draining the whole queue in one Execute was the alternative; it holds the Revit UI for the batch and loops forever when a handler raises its own event.
  • Execute raises the event again before it runs the handler. A handler exception doesn't stall the queue.
  • An OperationCanceledException cancels the task only for the token of the raise, the same rule as Task.Run. Any other OperationCanceledException faults the task.
  • Token-aware constructors exist only in the full shape (UIApplication, [T,] CancellationToken). An Action<CancellationToken> constructor would make an existing untyped lambda x => … ambiguous with Action<UIApplication>.
  • The probe didn't reproduce a refused raise: a handler that throws leaves later raises Accepted, and 2400 concurrent raises returned no TimedOut. The refusal paths are covered with a fake Raise in the core tests, through InternalsVisibleTo.

How verified

  • Build: the library in Release.R20 to Release.R27, the generator, and its .Roslyn414 twin, with zero errors and zero compiler warnings.
  • Revit 2027: 118 tests passed, zero failed.
  • Revit 2024 on .NET Framework 4.8: the 73 external event and core tests passed.
  • Compiler tooling: 221 tests passed.
  • The new queue tests fail against the previous implementation.
  • A scratch consumer project compiles the generated events of every token signature against the built library.
  • Probes in Revit 2027, not committed: a modal window in a handler holds every pending raise until it closes, a timer cancellation is visible inside a running handler after 209 ms, and a cancellation from the Revit UI thread is visible only after the handler returns.
  • Not covered: the Revit 2025 and 2026 test runs.

Quality Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings

@Nice3point Nice3point added feature request 📬 A request for new changes to improve functionality bug 🐛 An unexpected issue that highlights incorrect behavior external commands 🚪 ExternalCommand, ExternalApplication, and external events source-generators 🏗️ Roslyn source generators labels Oct 23, 2026
@Nice3point
Nice3point marked this pull request as ready for review October 24, 2026 10:17
Copilot AI balanced review requested due to automatic review settings October 24, 2026 10:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@Nice3point
Nice3point merged commit d8907d5 into develop Oct 24, 2026
1 check passed
@Nice3point
Nice3point deleted the events/131-132-queue-and-cancel-raises branch October 24, 2026 10:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug 🐛 An unexpected issue that highlights incorrect behavior external commands 🚪 ExternalCommand, ExternalApplication, and external events feature request 📬 A request for new changes to improve functionality source-generators 🏗️ Roslyn source generators

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cancel a pending RaiseAsync of an async external event Concurrent raises of one external event lose all but the last

2 participants