Skip to content

[Events] Run an external event created outside a Revit callback as the add-in of its handler - #135

Merged
Nice3point merged 12 commits into
developfrom
events/134-bind-external-events-to-add-in
Oct 31, 2026
Merged

Nice3point merged 12 commits into
developfrom
events/134-bind-external-events-to-add-in

Conversation

@Nice3point

@Nice3point Nice3point commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

Summary of the Pull Request

What is this about:

Runs the handler of an external event created outside a Revit callback as the add-in that uses the event.

Description:

What changed

  • ExternalApplication.OnStartup and ExternalCommand.Execute capture the active add-in for their assembly.
  • An external event created inside a Revit callback captures the active add-in for its handler assembly.
  • An external event created outside any callback opens a native AddInDataScope with the captured add-in around ExternalEvent.Create.
  • Revit binds every raise of such an event to the captured add-in.
  • The handler assembly is the assembly of the delegate target, of the declaring type of a static delegate method, or of an ExternalEventHandler subclass.
  • The toolkit event types pass their delegate to a private protected constructor of ExternalEventHandler.
  • The test project declares a RevitClient add-in with a vendor through Nice3point.TUnit.Revit x.1.8 and TUnit 1.76.45.
  • RegisterAddInAssembly(Assembly) extends UIControlledApplication and UIApplication. Inside a Revit callback it captures the active add-in for the assembly and replaces an earlier capture. Outside a callback it throws InvalidOperationException.
  • The package ships build/Nice3point.Revit.Toolkit.targets. The _RevitToolkitCollectAddInProjectReferences target passes the project references of the project, transitive ones included, to the compiler.
  • AddInAssemblyGenerator declares an internal attribute type in the assembly and one [assembly: AddInAssembly("…")] per project reference. The output compiles with C# 7.3.
  • The first capture of an assembly reads its AddInAssembly attributes by full name. A listed assembly is matched by name at its first external event created outside a callback.
// <auto-generated/>
[assembly: global::Nice3point.Revit.Toolkit.Generated.AddInAssemblyAttribute("MyAddIn.Core")]
[assembly: global::Nice3point.Revit.Toolkit.Generated.AddInAssemblyAttribute("MyAddIn.UI")]

namespace Nice3point.Revit.Toolkit.Generated
{
    internal sealed class AddInAssemblyAttribute : global::System.Attribute { … }
}
Add-in Code to write
ExternalApplication or ExternalCommand with project references None
Raw IExternalApplication application.RegisterAddInAssembly(typeof(Application).Assembly)
An assembly loaded with Assembly.LoadFrom RegisterAddInAssembly for that assembly

The native side of Revit 2023 to 2027, read from RevitAPIUI, RevitAPIFoundation, and Utility:

Fact Consequence for the toolkit
ExternalEvent.Create registers the handler for the add-in on top of the add-in data stack The add-in of an event is fixed at its creation
registerAddInData registers nothing on an empty stack An event created outside a callback runs with no add-in
The add-in data stack is a ThreadLocalPointer A scope on a background thread leaves the Revit thread untouched
An empty stack yields the null GUID before 2027 and E42FF806-491D-4B17-9AFB-EA051D5EBB76 in 2027 The toolkit checks ArrStack<AddInData>::empty(getAddInDataStack()), as Revit does
AddInData and AddInDataScope take 24 and 16 bytes in 2020 to 2022, 24 and 2 in 2023 to 2026, 56 and 56 in 2027 The toolkit reads the sizes from the metadata of the native types
The 2027 AddInDataScope keeps an analytics timer in its own memory The scope buffer is a stackalloc of the creating frame

Where to look

  • A toolkit in the default AssemblyLoadContext or on .NET Framework keys the captured add-in by assembly. A toolkit in the context of an isolated add-in keeps one add-in.
  • A shared toolkit binds an event created outside a callback for an assembly that ran inside a callback before, for a project reference of such an assembly, and for a registered assembly. A package reference or a DLL referenced by path keeps the previous behavior.
  • The name match is lazy. The capture loads no listed assembly, and a name that never loads, such as a library merged into the main assembly, costs one dictionary entry.
  • An unmatched assembly stores the count of listed names. A later miss reads its name again only after another capture lists more names.
  • Names match case-insensitively, as the runtime compares simple names. Only assemblies of the default AssemblyLoadContext match: an isolated add-in can load its own assembly with a listed name.
  • A failed read of the attributes lists no names. The capture never fails the startup of an add-in.
  • An event created before the registration of its assembly keeps the add-in Revit bound it to.
  • An implicit capture keeps the first add-in of a listed name. A registration replaces it.
  • The attribute type is declared in each assembly, not in the toolkit. An older toolkit loaded first into the shared context can't break code that reads every attribute of the assembly.
  • Each assembly has one StrongBox<nint> in the table. A capture publishes its copy with Interlocked.CompareExchange, and a registration replaces it with Volatile.Write. A lock guards the listed names only.
  • A replaced copy of the add-in data is never freed: a concurrent event can read it.
  • The target uses %(Filename) of a project reference. The compiler names an assembly after its output file, TargetName included.
  • The MSBuild target, item, and property names take the _RevitToolkit prefix. A target of the same name in a consumer build replaces the package target.
  • The owner of a delegate is its target type first. Delegate.Method costs 70–600 ns and 160 bytes on every new delegate. The target costs 9–24 ns with no allocation.
  • A closed extension method delegate reports the assembly of its first argument.
  • An event created inside a callback stays bound to the active add-in. An add-in whose application and commands carry different add-in ids keeps the id of the running command.
  • The binding turns off when a Revit version lacks a member of the expected signature. The stack members of RevitAPIFoundation 2020 to 2022 are unverified.
  • A capture keeps 24 or 56 bytes of add-in data for the life of the process.

How verified

  • Build: every configuration from Release.R20 to Release.R27, zero errors.
  • Tests of Nice3point.Revit.Toolkit.Tests on Revit 2023, 2024, 2025, and 2027: 124 passed on each.
  • Tests of the source generator and the analyzers: 181 and 40 passed.
  • After RegisterAddInAssembly and the generator: 132 of 132 Revit 2027 tests and 192 of 192 source generator tests passed. ListedAddInAssemblies implements the name matching without native calls, and its tests run on the Revit thread. The package contains build/Nice3point.Revit.Toolkit.targets.
  • A net48 consumer with LangVersion 7.3 and TreatWarningsAsErrors builds with the package targets and the generator.
  • The live sessions below include a transitive project reference with TargetName set apart from AssemblyName.
  • The Revit test runner loads the toolkit isolated, and the shared path has no automated test. Two live Revit 2027 sessions with two add-ins in the default AssemblyLoadContext, each built against the package targets and the generator, gave the same result:
Scenario, event created on a thread-pool thread Add-in in the handler
ExternalApplication, project reference, no registration code The add-in
Raw IExternalApplication, project reference, after registration The add-in
Raw IExternalApplication, transitive project reference The add-in
Assembly loaded with LoadFrom and registered The add-in
Assembly loaded with LoadFrom, not registered null
Event created before the registration null
RegisterAddInAssembly on a thread-pool thread InvalidOperationException
  • With the binding turned off, the four tests that create the event outside a callback fail. The vendor write returns Writing of Entities of this Schema is not allowed to the current add-in.
  • A temporary test measured each operation inside Revit. The toolkit code adds no managed allocation.
Operation Revit 2023 Revit 2025 Revit 2027
Stack check 19 ns 18 ns 14 ns
Scope push and pop with the captured add-in data 93 ns 66 ns 326 ns
Scope push and pop through a registered gcroot 280 ns 294 ns 612 ns
ExternalEvent.Create and Dispose, for scale 1186 ns 1104 ns 1174 ns
  • Not covered: Revit 2020 to 2022, which aren't installed.
  • Not covered: Revit 2026, where every test of develop fails too. The installed RevitAPI is 26.5, and the tests request 26.4.10.

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 bug 🐛 An unexpected issue that highlights incorrect behavior external commands 🚪 ExternalCommand, ExternalApplication, and external events context 🧭 Revit API context, dialog and failure suppression labels Oct 30, 2026
@Nice3point

Copy link
Copy Markdown
Owner Author

Adds RegisterAddInAssembly, an explicit registration that binds the external events of an additional assembly to the active add-in, on top of the implicit capture of this pull request.

The proposal removes nothing from the current change: AddInContext, the capture in ExternalApplication and ExternalCommand, and the binding at the creation of an event.
The registration writes to the same AddInContext registry.
The evidence comes from live probes in Revit 2027, metadata of RevitAPIUI.dll 2023 to 2027, and benchmarks.

The concept

Revit binds an external event to the add-in on top of the add-in data stack of the creating thread, at ExternalEvent.Create.
Revit pushes the data of an add-in around every call into its code: OnStartup, a command, the handler of a subscribed event, the handler of a bound external event.
Outside such a call the stack is empty.
In Revit 2027 ActiveAddInId returns E42FF806-491D-4B17-9AFB-EA051D5EBB76 there, and the handler of an event created there sees null.

The current change copies the active add-in data inside a callback and pushes the copy when an event is created outside one.
A toolkit shared by several add-ins keys the copy by the assembly that declares the handler.
A library of an add-in that is first loaded outside a callback has no copy, and its events run with no add-in, as before this pull request:

Add-in shape Handler declared in Event created on Add-in in the handler
ExternalApplication, shared toolkit The main assembly A thread-pool thread, a dispatcher callback The add-in
Two ExternalApplication add-ins, one shared toolkit DLL Each main assembly A thread-pool thread Each its own add-in
ExternalApplication, shared toolkit A library first loaded on a thread-pool thread A thread-pool thread null
Isolated add-in The main assembly or a library A thread-pool thread, a dispatcher callback The add-in
Raw IExternalApplication, one event created in OnStartup The main assembly A thread-pool thread, a dispatcher callback The add-in
Raw IExternalApplication, no event inside a callback The main assembly A thread-pool thread, a dispatcher callback null

RegisterAddInAssembly closes the null rows.

The design

public static void RegisterAddInAssembly(this UIControlledApplication application, Assembly assembly);
public static void RegisterAddInAssembly(this UIApplication application, Assembly assembly);
// OnStartup of ExternalApplication, or of a raw IExternalApplication
Application.RegisterAddInAssembly(typeof(OpeningStore).Assembly);
Situation Behavior
Called inside a Revit callback Copies the active add-in data for the assembly. An external event whose handler is declared in the assembly runs as that add-in, wherever the event is created.
Called outside a Revit callback Throws InvalidOperationException.
The assembly has an implicit capture The registration replaces it. The implicit capture never replaces an entry.
The Revit version exposes no member of the expected signature Does nothing, as the rest of AddInContext.
The toolkit runs isolated in the AssemblyLoadContext of one add-in Writes the toolkit assembly key, the entry the isolated capture uses.
Application.RegisterAddInAssembly(assembly)        extension, inside OnStartup or Execute
  └─ AddInContext.Register(assembly)               internal
       ├─ IsSupported is false → return
       ├─ IsAddInActive() is false → InvalidOperationException
       ├─ copies the active add-in data under CaptureLock
       └─ CapturedAddIns[owner] = copy             owner: the assembly, or the toolkit assembly when isolated

new AsyncExternalEvent(...), later, on any thread  the existing path of this pull request
  └─ ExternalEventHandler → AddInContext.CreateExternalEvent → FindOwner → scope → ExternalEvent.Create

Name and placement

The rules come from AddApplicationPart(this IMvcBuilder builder, Assembly assembly), the ASP.NET Core answer to the same problem: code of an application in more than one assembly.

Rule from ASP.NET Core Result
An extension on the object of the composition root, not a static class An extension on UIControlledApplication and UIApplication. Raw interfaces and toolkit base classes both receive them.
The verb follows the receiver: a builder uses Add* The receiver is a Revit type, whose verb for the same action is Register* (RegisterDockablePane).
The noun names the concept, not the consumer: ApplicationPart, not ControllerAssembly The assembly becomes part of the add-in, and the manifest names the DLL of an add-in <Assembly>: AddInAssembly.
One overload with Assembly No generic overload.
Rejected candidate Reason
RevitContext.RegisterAddInAssembly A static global API, callable outside the composition root.
A static member of ExternalEventHandler or ExternalEvent Reachable through every derived type, generic ones included. ExternalEvent collides with Autodesk.Revit.UI.ExternalEvent.
Bind* Means configuration binding in .NET and parameter binding in Revit.
Capture*, RegisterAddInContext Name the mechanism, not the result.
RegisterExternalEventAssembly, RegisterExternalEventsSource Name the consumer. EventSource is the tracing type of .NET.
RegisterAssembly Doesn't say what the assembly becomes.

Alternatives evaluated

Alternative Probe result Decision
Push the add-in data around the handler code, not around ExternalEvent.Create The handler code sees the add-in. Revit registers the event with no add-in. Rejected
Create the event later inside the handler of a bound "creator" event, public API only Bound in 15 of 15 attempts. The first Raise takes 0.9–1.8 ms against 0.2–0.9 ms. Rejected: Raise before the creation needs an emulated result, and the coverage equals the current change.
Create the event later in an Idling handler An idle Revit raises no Idling for 8 to 30 s. Rejected
A generated [ModuleInitializer] that captures the add-in The initializer of the main assembly runs inside the startup of the add-in. A library captures only when first loaded inside a callback. Rejected: an older toolkit in the shared context fails the initializer and stops the add-in from loading.
An assembly attribute with the add-in ID and the vendor ID Works with no capture. Rejected: the values come from user code, not from Revit, and duplicate the manifest.
Find the add-in of an assembly in the add-in registry of Revit No exported member maps an assembly to an add-in. Rejected
Read the .addin manifests Not probed. Rejected: misses application bundles, LoadAddIn, and assemblies with no location.

Measurements

Lookup of the captured add-in, BenchmarkDotNet ShortRun:

Path .NET 8 .NET 10 Allocated
Isolated, static field of the first revision ~0 ns ~0 ns —
Isolated, ConditionalWeakTable of the current revision 4.9 ns 4.7 ns —
Shared, ConditionalWeakTable with a holder class 4.9 ns 4.6 ns —
Shared, ConditionalWeakTable with StrongBox<nint> 5.0 ns 5.1 ns —

ExternalEvent.Create with Dispose takes 1104–1186 ns (the measurements in the pull request body), and the lookup adds under 0.5 %.

Where to look

  • ConditionalWeakTable on .NET Framework 4.8 has no AddOrUpdate. The replacement is Remove and Add under CaptureLock.
  • A replaced native copy is never freed. CreateExternalEvent reads the pointer outside the lock, and freeing it races with a scope on another thread.
  • The extension blocks need C# 14 on every configuration from Release.R20 to Release.R27.

Open questions

  • The shared path of AddInContext has no automated test. The live probes in Revit 2027 cover it.
  • IExternalCommand.Execute, Idling, DocumentChanged, and IUpdater.Execute were not probed live.
  • No probe or test covers Revit 2020 to 2022.

@Nice3point

Copy link
Copy Markdown
Owner Author

Proposes a second step after RegisterAddInAssembly: the toolkit generates the registration of every project reference of an add-in, and the users of the base classes write no registration code.

The step follows the ASP.NET Core model: MVC generates an [assembly: ApplicationPart("…")] attribute in the entry assembly for each dependency, and the part manager reads the attributes at startup.
RegisterAddInAssembly from the previous comment is the primitive for raw interfaces and assemblies loaded at run time.

The design

  1. MSBuild targets in the toolkit package collect the project references of the project, transitive ones included, and pass them to the compiler as AddInProjectReferences.
  2. A generator in Nice3point.Revit.Toolkit.SourceGenerators emits one attribute per project reference:
// <auto-generated/>
[assembly: global::Nice3point.Revit.Toolkit.External.AddInAssembly("MyAddIn.Core")]
[assembly: global::Nice3point.Revit.Toolkit.External.AddInAssembly("MyAddIn.Services")]
[assembly: global::Nice3point.Revit.Toolkit.External.AddInAssembly("MyAddIn.UI")]
  1. The capture of a root assembly in ExternalApplication and ExternalCommand, and RegisterAddInAssembly(root), read the attributes of the root once and register the listed assemblies with the same add-in data.
Add-in Code to write
ExternalApplication or ExternalCommand None
Raw IExternalApplication application.RegisterAddInAssembly(typeof(Application).Assembly) once in OnStartup
An assembly loaded with Assembly.LoadFrom at run time RegisterAddInAssembly for that assembly

The MSBuild target:

<ItemGroup>
  <CompilerVisibleProperty Include="AddInProjectReferences" />
</ItemGroup>
<Target Name="CollectAddInProjectReferences" AfterTargets="ResolveAssemblyReferences" BeforeTargets="GenerateMSBuildEditorConfigFileShouldRun">
  <PropertyGroup>
    <AddInProjectReferences>@(ReferencePath->WithMetadataValue('ReferenceSourceTarget', 'ProjectReference')->'%(Filename)', ',')</AddInProjectReferences>
  </PropertyGroup>
</Target>

How verified

A prototype generator and the target above ran against an add-in with this reference graph:

Assembly Reference from the add-in In the generated list
GenDirect Project reference Yes
GenMid Project reference Yes
GenLib Project reference of GenMid, transitive Yes
GenThirdParty DLL referenced by path No

Live, Revit 2027, a raw IExternalApplication in the shared context, two runs with the same result.
OnStartup read the generated attributes and captured the add-in for each listed assembly.
Each library then created an event on a thread-pool thread:

Event declared in Add-in in the handler
GenDirect The add-in
GenMid The add-in
GenLib The add-in
GenThirdParty null, as before this pull request

Where to look

  • A global analyzer config value ends at ;. The first prototype received one name out of three, and the target joins the names with ,.
  • The compiler receives reference assemblies of project references, and a reference assembly omits internal types. A library that uses the toolkit only inside internal code shows no reference to Nice3point.Revit.Toolkit in metadata. The generator therefore lists every project reference: an assembly with no external events costs one registry entry.
  • Package and path references are not listed. A third-party library that several add-ins load in the shared context runs its events with no add-in, unless an add-in registers it with RegisterAddInAssembly.
  • The prototype loads every listed assembly at startup. The implementation can match the name at the first registry miss instead: it loads nothing at startup and ignores names that don't exist at run time, such as the libraries an assembly merger folds into the main assembly.

Open questions

  • The lazy match by name is not probed live.
  • %(Filename) equals the assembly name only when AssemblyName matches the file name of the project output.
  • The .targets file reaches consumers through the build and buildTransitive folders of the package, and the generator needs its .Roslyn414 twin.

@Nice3point
Nice3point marked this pull request as ready for review October 31, 2026 15:22
Copilot AI balanced review requested due to automatic review settings October 31, 2026 15:22

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. Try requesting a review at a lower effort level.

@Nice3point
Nice3point merged commit 9cf0542 into develop Oct 31, 2026
1 check passed
@Nice3point
Nice3point deleted the events/134-bind-external-events-to-add-in branch October 31, 2026 15:22
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 context 🧭 Revit API context, dialog and failure suppression external commands 🚪 ExternalCommand, ExternalApplication, and external events

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An external event created outside an add-in callback runs its handler with no active add-in

2 participants