Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,53 @@
# 2027.0.3

### External Events

- `ExternalEvent<T>` and the asynchronous external events execute each queued raise with its own argument. Each `RaiseAsync()` task completes with the result or exception of its handler. Previously, concurrent raises overwrote the argument and left earlier tasks incomplete.
- Requests refused with `ExternalEventRequest.Denied` or `ExternalEventRequest.TimedOut` skip the handler. `Raise(argument)` returns the refusal; `RaiseAsync()` returns a task faulted with `InvalidOperationException`.
- New `RaiseAsync()` overloads accept a `CancellationToken` to cancel a pending execution. Token-aware constructors pass the token to the handler for cooperative cancellation during execution.

### ExternalEvent Source Generator

- A trailing `CancellationToken` parameter receives the token of `RaiseAsync()` and is excluded from the event arguments. Synchronous events pass `CancellationToken.None`.
- Generated `RaiseAsync()` extension methods include an overload with a `CancellationToken`.

Pass a cancellation token to the generated event to cancel a pending execution or stop the handler cooperatively:

```c#
public partial class MyRevitContext
{
[ExternalEvent]
private void DeleteWindows(UIApplication application, ICollection<ElementId> elementIds, CancellationToken cancellationToken)
{
var document = application.ActiveUIDocument.Document;
using var transaction = new Transaction(document, "Delete windows");
transaction.Start();

foreach (var elementId in elementIds)
{
cancellationToken.ThrowIfCancellationRequested();
document.Delete(elementId);
}

transaction.Commit();
}

private async Task DeleteWindowsAsync(ICollection<ElementId> elementIds)
{
using var cancellationSource = new CancellationTokenSource(TimeSpan.FromSeconds(10));

try
{
await DeleteWindowsAsyncEvent.RaiseAsync(elementIds, cancellationSource.Token);
}
catch (OperationCanceledException)
{
//The execution was canceled; uncommitted changes are rolled back
}
}
}
```

# 2027.0.2

Performance optimizations for Revit 2027:
Expand All @@ -7,9 +57,9 @@ Performance optimizations for Revit 2027:

# 2027.0.1

- `AsyncExternalEvent.RaiseAsync()` with `ExternalEventOptions.AllowDirectInvocation` returns a faulted task when the handler throws inside the Revit API context. The exception was previously lost and the task completed successfully.
### External Events

# 2027.0.0
- `AsyncExternalEvent.RaiseAsync()` with `ExternalEventOptions.AllowDirectInvocation` returns a faulted task when the handler throws inside the Revit API context. The exception was previously lost and the task completed successfully.

### Compiler Tooling Fixes

Expand All @@ -25,6 +75,8 @@ Performance optimizations for Revit 2027:
- Generated event properties use `System.Threading.Lock` when the consumer supports C# 13 and provides the type; other targets use an object lock.
- Unsupported signatures report `RVTTK0006`; conflicting generated member names report `RVTTK0007`.

# 2027.0.0

### External Events

New family of external event types replacing legacy `ActionEventHandler`, `AsyncEventHandler`:
Expand Down
98 changes: 98 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Package included by default in [Revit Templates](https://github.com/Nice3point/R
* [AsyncExternalEvent\<T>](#asyncexternaleventt)
* [AsyncRequestExternalEvent\<TResult>](#asyncrequestexternaleventtresult)
* [AsyncRequestExternalEvent\<T, TResult>](#asyncrequestexternaleventt-tresult)
* [Cancellation](#cancellation)
* [ExternalEventOptions](#externaleventoptions)
* [ExternalEvent attribute](#externalevent-attribute)
* [Context](#context)
Expand Down Expand Up @@ -200,6 +201,15 @@ so you can create them anywhere in your code and on any thread.
Use `Raise()` when the caller does not need to wait, or `RaiseAsync()` when it needs completion or a return value.
For generated event properties, see the [ExternalEvent attribute](#externalevent-attribute).

Several callers can share one event, such as two modeless windows, or a background service and a window.
Each `Raise(argument)` and `RaiseAsync()` call runs the handler once with its own argument, in the order of the calls.
Each `RaiseAsync()` task completes with the result or the exception of its own run.
`ExternalEvent` without an argument runs once for all waiting raises, the same way Revit runs a raised event.

A raise that Revit refuses with `Denied` or `TimedOut` never runs.
`Raise(argument)` returns that result to the caller.
The `RaiseAsync()` task fails with an `InvalidOperationException`.

#### ExternalEvent

A synchronous external event that queues the handler via the Revit external event mechanism.
Expand Down Expand Up @@ -355,6 +365,57 @@ private async Task<ElementId, bool> DeleteWindowAsync(ElementId elementId)
> Await asynchronous events. Blocking a pending event with `.Wait()`, `.Result`, or `.GetAwaiter().GetResult()` on the Revit main thread prevents Revit from processing it and can deadlock.
> The event handler itself remains synchronous; perform asynchronous I/O before or after raising the event.

#### Cancellation

Every `RaiseAsync()` method has an overload that accepts a `CancellationToken`.
Pass a cancellation token to cancel a pending execution while Revit is busy with a modal dialog or a running command:

```c#
private async Task DeleteWindowAsync(ElementId elementId)
{
using var cancellationSource = new CancellationTokenSource(TimeSpan.FromSeconds(10));

try
{
await _deleteWindowAsyncEvent.RaiseAsync(elementId, cancellationSource.Token);
}
catch (OperationCanceledException)
{
//The pending execution was canceled
}
}
```

Cancellation before execution cancels the task without running the delegate.
Cancellation during execution does not interrupt the delegate; the task completes with its result or exception.
Other queued calls are unaffected.

To stop a long-running delegate, accept the token as its last parameter:

```c#
private readonly AsyncExternalEvent<ICollection<ElementId>> _deleteWindowsAsyncEvent = new((application, elementIds, cancellationToken) =>
{
var document = application.ActiveUIDocument.Document;
using var transaction = new Transaction(document, "Delete windows");
transaction.Start();

foreach (var elementId in elementIds)
{
cancellationToken.ThrowIfCancellationRequested();
document.Delete(elementId);
}

transaction.Commit();
});
```

When the token is canceled, `ThrowIfCancellationRequested()` stops the delegate and cancels the task.
Disposing the uncommitted transaction rolls back the changes.

The delegate observes a cancellation requested from another thread, such as a timeout or a remote client.
A cancel button of a modeless window runs on the Revit thread.
Its cancellation takes effect only after the delegate returns.

#### ExternalEventOptions

You can configure the behavior of external events using `ExternalEventOptions`.
Expand Down Expand Up @@ -562,6 +623,11 @@ public static partial class MyViewModelExtensions
{
return externalEvent.RaiseAsync(new MyViewModel.CreateRoomArgs(level, coordinate));
}

public static global::System.Threading.Tasks.Task<Room> RaiseAsync(this IAsyncRequestExternalEvent<MyViewModel.CreateRoomArgs, Room> externalEvent, Level level, UV coordinate, global::System.Threading.CancellationToken cancellationToken)
{
return externalEvent.RaiseAsync(new MyViewModel.CreateRoomArgs(level, coordinate), cancellationToken);
}
}
```

Expand All @@ -570,6 +636,38 @@ If initialization throws, the next access retries it.

</details>

**Methods with CancellationToken parameter**

Declare a `CancellationToken` as the last parameter to receive the token passed to `RaiseAsync()`.
The generator excludes it from the event arguments:

```c#
[ExternalEvent]
private void DeleteWindows(UIApplication application, ICollection<ElementId> elementIds, CancellationToken cancellationToken)
{
var document = application.ActiveUIDocument.Document;
using var transaction = new Transaction(document, "Delete windows");
transaction.Start();

foreach (var elementId in elementIds)
{
cancellationToken.ThrowIfCancellationRequested();
document.Delete(elementId);
}

transaction.Commit();
}

// Generates:
// IExternalEvent<ICollection<ElementId>> DeleteWindowsEvent
// IAsyncExternalEvent<ICollection<ElementId>> DeleteWindowsAsyncEvent
```

Call `await DeleteWindowsAsyncEvent.RaiseAsync(elementIds, cancellationToken)` to pass the token to the handler.
`DeleteWindowsEvent.Raise(elementIds)` passes `CancellationToken.None`.
A `CancellationToken` in any other position is a regular argument.
See [Cancellation](#cancellation) for cancellation behavior and exception handling.

**Enabling direct invocation**

Set `AllowDirectInvocation` to execute the handler directly when Revit is already in API mode.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
; Shipped analyzer releases
; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md

## Release 2027.0.1

### New Rules

Rule ID | Category | Severity | Notes
-----------|------------------------|----------|-----------------------------------------------------------------------------
RVTTK0006 | ExternalEventGenerator | Error | Reports signatures that cannot be represented by generated external events.
RVTTK0007 | ExternalEventGenerator | Error | Reports generated member names that conflict with another declaration.

## Release 2027.0.0

### New Rules
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,2 @@
; Unshipped analyzer release
; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md

### New Rules

Rule ID | Category | Severity | Notes
-----------|------------------------|----------|-----------------------------------------------------------------------------
RVTTK0006 | ExternalEventGenerator | Error | Reports signatures that cannot be represented by generated external events.
RVTTK0007 | ExternalEventGenerator | Error | Reports generated member names that conflict with another declaration.
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@ internal static class ExternalEventExtensionContainer
{
public static (string ClassAccessibility, string? ConflictingName) Resolve(IMethodSymbol method, CancellationToken cancellationToken)
{
var hasUiApplicationParameter = method.Parameters.Length > 0 &&
method.Parameters[0].Type.WithNullableAnnotation(NullableAnnotation.NotAnnotated).ToDisplayString() == ExternalEventTypeNames.UiApplication.WithoutGlobalPrefix;

if (method.Parameters.Length - (hasUiApplicationParameter ? 1 : 0) <= 1)
if (ExternalEventParameters.GetArgumentParameters(method).Count() <= 1)
{
return ("public", null);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ internal sealed class ExternalEventExtensionSignature

private readonly Dictionary<ISymbol, string> _typeParameterNames = new(SymbolEqualityComparer.Default);

public ExternalEventExtensionDefinition Create(IMethodSymbol method, bool hasUiApplicationParameter, CancellationToken cancellationToken)
public ExternalEventExtensionDefinition Create(IMethodSymbol method, CancellationToken cancellationToken)
{
var containingTypes = new Stack<INamedTypeSymbol>();
for (var containingType = method.ContainingType; containingType is not null; containingType = containingType.ContainingType)
Expand All @@ -24,7 +24,8 @@ public ExternalEventExtensionDefinition Create(IMethodSymbol method, bool hasUiA

var reservedParameterNames = new HashSet<string>(method.Parameters.Select(static parameter => parameter.Name), StringComparer.Ordinal)
{
"externalEvent"
"externalEvent",
"cancellationToken"
};

var typeParameters = ImmutableArray.CreateBuilder<string>();
Expand Down Expand Up @@ -54,8 +55,7 @@ public ExternalEventExtensionDefinition Create(IMethodSymbol method, bool hasUiA
typeParameters.ToImmutable(), constraints.ToString(),
$"{Format(method.ContainingType, TypeFormat)}.{method.Name}Args",
method.ReturnsVoid ? null : Format(method.ReturnType, TypeFormat),
method.Parameters
.Skip(hasUiApplicationParameter ? 1 : 0)
ExternalEventParameters.GetArgumentParameters(method)
.Select(parameter => Format(parameter.Type, TypeFormat))
.ToImmutableArray());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,16 +119,13 @@ private static bool ValidateMethod(IMethodSymbol methodSymbol, INamedTypeSymbol

private static string[] GetGeneratedMemberNames(IMethodSymbol method)
{
var hasUiApplicationParameter = method.Parameters.Length > 0 &&
method.Parameters[0].Type.ToDisplayString(NullableFlowFormat) == ExternalEventTypeNames.UiApplication.WithoutGlobalPrefix;

var generatedMemberNames = new List<string> { $"{method.Name}AsyncEvent" };
if (method.ReturnsVoid)
{
generatedMemberNames.Add($"{method.Name}Event");
}

if (method.Parameters.Length - (hasUiApplicationParameter ? 1 : 0) > 1)
if (ExternalEventParameters.GetArgumentParameters(method).Count() > 1)
{
generatedMemberNames.Add($"{method.Name}Args");
}
Expand All @@ -140,9 +137,11 @@ private static ExternalEventDefinition CreateEventDefinition(IMethodSymbol metho
{
var allowDirectInvocation = attribute.GetNamedArgument("AllowDirectInvocation", fallback: false);

var (hasUiApplicationParameter, extraParameters) = ClassifyParameters(methodSymbol);
var hasUiApplicationParameter = ExternalEventParameters.HasUiApplicationParameter(methodSymbol);
var hasCancellationTokenParameter = ExternalEventParameters.HasCancellationTokenParameter(methodSymbol);
var extraParameters = ClassifyParameters(methodSymbol);
var (returnsVoid, returnType) = ExtractReturnType(methodSymbol);
var delegateType = BuildDelegateType(methodSymbol, hasUiApplicationParameter, returnsVoid, returnType, extraParameters);
var delegateType = BuildDelegateType(methodSymbol, returnsVoid, returnType);

var typeHierarchy = GetTypeHierarchy(methodSymbol, cancellationToken);
var containingNamespace = GetNamespace(methodSymbol.ContainingType);
Expand All @@ -156,10 +155,11 @@ private static ExternalEventDefinition CreateEventDefinition(IMethodSymbol metho
returnType,
delegateType,
hasUiApplicationParameter,
hasCancellationTokenParameter,
allowDirectInvocation,
typeHierarchy,
extraParameters,
new ExternalEventExtensionSignature().Create(methodSymbol, hasUiApplicationParameter, cancellationToken),
new ExternalEventExtensionSignature().Create(methodSymbol, cancellationToken),
methodSymbol.ContainingType.GetMembers().Select(static member => member.Name).ToImmutableArray());
}

Expand Down Expand Up @@ -252,29 +252,20 @@ private static bool HasDuplicateOverloads(IMethodSymbol methodSymbol, INamedType
return false;
}

private static (bool HasUiApplicationParameter, EquatableArray<ExternalEventParameter> ExtraParameters) ClassifyParameters(IMethodSymbol methodSymbol)
private static EquatableArray<ExternalEventParameter> ClassifyParameters(IMethodSymbol methodSymbol)
{
var parameters = methodSymbol.Parameters;
var hasUiApplicationParameter = false;
var extraParameters = ImmutableArray.CreateBuilder<ExternalEventParameter>();
var recordPropertyNames = GetReservedRecordPropertyNames(methodSymbol);

for (var parameterIndex = 0; parameterIndex < parameters.Length; parameterIndex++)
foreach (var parameter in ExternalEventParameters.GetArgumentParameters(methodSymbol))
{
var parameter = parameters[parameterIndex];
if (parameterIndex == 0 && parameter.Type.ToDisplayString(NullableFlowFormat) == ExternalEventTypeNames.UiApplication.WithoutGlobalPrefix)
{
hasUiApplicationParameter = true;
continue;
}

var propertyName = char.ToUpperInvariant(parameter.Name[0]) + parameter.Name[1..];
propertyName = CSharpIdentifier.Reserve(propertyName, recordPropertyNames);
var parameterTypeFullyQualified = parameter.Type.GetFullyQualifiedNameWithNullabilityAnnotations();
extraParameters.Add(new ExternalEventParameter(parameter.Name, parameterTypeFullyQualified, propertyName));
}

return (hasUiApplicationParameter, extraParameters.ToImmutable());
return extraParameters.ToImmutable();
}

private static HashSet<string> GetReservedRecordPropertyNames(IMethodSymbol methodSymbol)
Expand Down Expand Up @@ -302,23 +293,11 @@ private static (bool IsVoidReturn, string? ReturnTypeFullyQualified) ExtractRetu
return (false, methodSymbol.ReturnType.GetFullyQualifiedNameWithNullabilityAnnotations());
}

private static string BuildDelegateType(
IMethodSymbol methodSymbol,
bool hasUiApplicationParameter,
bool returnsVoid,
string? fullyQualifiedReturnType,
EquatableArray<ExternalEventParameter> extraParameters)
private static string BuildDelegateType(IMethodSymbol methodSymbol, bool returnsVoid, string? fullyQualifiedReturnType)
{
var parameterTypes = new List<string>();
if (hasUiApplicationParameter)
{
parameterTypes.Add(methodSymbol.Parameters[0].Type.GetFullyQualifiedNameWithNullabilityAnnotations());
}

foreach (var parameter in extraParameters)
{
parameterTypes.Add(parameter.FullyQualifiedType);
}
var parameterTypes = methodSymbol.Parameters
.Select(static parameter => parameter.Type.GetFullyQualifiedNameWithNullabilityAnnotations())
.ToList();

if (returnsVoid)
{
Expand Down
Loading