Skip to content

Commit ffc7b7b

Browse files
authored
Merge branch 'main' into wangbill/autochunk
2 parents 95013b7 + 321c9e5 commit ffc7b7b

6 files changed

Lines changed: 448 additions & 28 deletions

File tree

src/Analyzers/Activities/MatchingInputOutputTypeActivityAnalyzer.cs

Lines changed: 155 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ public override void Initialize(AnalysisContext context)
7575
IMethodSymbol taskActivityRunAsync = knownSymbols.TaskActivityBase.GetMembers("RunAsync").OfType<IMethodSymbol>().Single();
7676
INamedTypeSymbol voidSymbol = context.Compilation.GetSpecialType(SpecialType.System_Void);
7777

78+
// Get common DI types that should not be treated as activity input
79+
INamedTypeSymbol? functionContextSymbol = context.Compilation.GetTypeByMetadataName("Microsoft.Azure.Functions.Worker.FunctionContext");
80+
7881
// Search for Activity invocations
7982
ConcurrentBag<ActivityInvocation> invocations = [];
8083
context.RegisterOperationAction(
@@ -161,6 +164,12 @@ public override void Initialize(AnalysisContext context)
161164
return;
162165
}
163166

167+
// If the parameter is FunctionContext, skip validation for this activity (it's a DI parameter, not real input)
168+
if (functionContextSymbol != null && SymbolEqualityComparer.Default.Equals(inputParam.Type, functionContextSymbol))
169+
{
170+
return;
171+
}
172+
164173
ITypeSymbol? inputType = inputParam.Type;
165174

166175
ITypeSymbol? outputType = methodSymbol.ReturnType;
@@ -306,7 +315,8 @@ public override void Initialize(AnalysisContext context)
306315
continue;
307316
}
308317

309-
if (!SymbolEqualityComparer.Default.Equals(invocation.InputType, activity.InputType))
318+
// Check input type compatibility
319+
if (!AreTypesCompatible(ctx.Compilation, invocation.InputType, activity.InputType))
310320
{
311321
string actual = invocation.InputType?.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat) ?? "none";
312322
string expected = activity.InputType?.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat) ?? "none";
@@ -316,7 +326,8 @@ public override void Initialize(AnalysisContext context)
316326
ctx.ReportDiagnostic(diagnostic);
317327
}
318328

319-
if (!SymbolEqualityComparer.Default.Equals(invocation.OutputType, activity.OutputType))
329+
// Check output type compatibility
330+
if (!AreTypesCompatible(ctx.Compilation, activity.OutputType, invocation.OutputType))
320331
{
321332
string actual = invocation.OutputType?.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat) ?? "none";
322333
string expected = activity.OutputType?.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat) ?? "none";
@@ -330,6 +341,148 @@ public override void Initialize(AnalysisContext context)
330341
});
331342
}
332343

344+
/// <summary>
345+
/// Checks if the source type is compatible with (can be assigned to) the target type.
346+
/// This handles polymorphism, interface implementation, inheritance, and collection type compatibility.
347+
/// </summary>
348+
static bool AreTypesCompatible(Compilation compilation, ITypeSymbol? sourceType, ITypeSymbol? targetType)
349+
{
350+
// Both null = compatible (no input/output on both sides)
351+
if (sourceType == null && targetType == null)
352+
{
353+
return true;
354+
}
355+
356+
// Special case: null (no input/output provided) can be passed to explicitly nullable parameters
357+
// This handles nullable value types (int?) and nullable reference types (string?)
358+
if (sourceType == null && targetType != null)
359+
{
360+
// Check if target is a nullable value type (Nullable<T>)
361+
if (targetType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T)
362+
{
363+
return true;
364+
}
365+
366+
// Check if target is a nullable reference type (string?)
367+
if (targetType.NullableAnnotation == NullableAnnotation.Annotated)
368+
{
369+
return true;
370+
}
371+
372+
// Not nullable, so null input is incompatible
373+
return false;
374+
}
375+
376+
// If targetType is null but sourceType is not, they're incompatible
377+
if (targetType == null && sourceType != null)
378+
{
379+
return false;
380+
}
381+
382+
// Check if types are exactly equal
383+
if (SymbolEqualityComparer.Default.Equals(sourceType, targetType))
384+
{
385+
return true;
386+
}
387+
388+
// Check if source type can be converted to target type (handles inheritance, interface implementation, etc.)
389+
// At this point, both sourceType and targetType are guaranteed to be non-null
390+
Conversion conversion = compilation.ClassifyConversion(sourceType!, targetType!);
391+
if (conversion.IsImplicit || conversion.IsIdentity)
392+
{
393+
return true;
394+
}
395+
396+
// Special handling for collection types since ClassifyConversion doesn't always recognize
397+
// generic interface implementations (e.g., List<T> to IReadOnlyList<T>)
398+
// At this point, both sourceType and targetType are guaranteed to be non-null
399+
if (IsCollectionTypeCompatible(sourceType!, targetType!))
400+
{
401+
return true;
402+
}
403+
404+
return false;
405+
}
406+
407+
/// <summary>
408+
/// Checks if the source collection type is compatible with the target collection type.
409+
/// Handles common scenarios like List to IReadOnlyList, arrays to IEnumerable, etc.
410+
/// </summary>
411+
static bool IsCollectionTypeCompatible(ITypeSymbol sourceType, ITypeSymbol targetType)
412+
{
413+
// Check if source is an array and target is a collection interface
414+
if (sourceType is IArrayTypeSymbol sourceArray && targetType is INamedTypeSymbol targetNamed)
415+
{
416+
return IsArrayCompatibleWithCollectionInterface(sourceArray, targetNamed);
417+
}
418+
419+
// Both must be generic named types
420+
if (sourceType is not INamedTypeSymbol sourceNamed || targetType is not INamedTypeSymbol targetNamedType)
421+
{
422+
return false;
423+
}
424+
425+
// Both must be generic types with the same type arguments
426+
if (!sourceNamed.IsGenericType || !targetNamedType.IsGenericType)
427+
{
428+
return false;
429+
}
430+
431+
if (sourceNamed.TypeArguments.Length != targetNamedType.TypeArguments.Length)
432+
{
433+
return false;
434+
}
435+
436+
// Check if type arguments are compatible (could be different but compatible types)
437+
for (int i = 0; i < sourceNamed.TypeArguments.Length; i++)
438+
{
439+
if (!SymbolEqualityComparer.Default.Equals(sourceNamed.TypeArguments[i], targetNamedType.TypeArguments[i]))
440+
{
441+
// Type arguments must match exactly for collections (we don't support covariance/contravariance here)
442+
return false;
443+
}
444+
}
445+
446+
// Check if source type implements or derives from target type
447+
// This handles: List<T> → IReadOnlyList<T>, List<T> → IEnumerable<T>, etc.
448+
return ImplementsInterface(sourceNamed, targetNamedType);
449+
}
450+
451+
/// <summary>
452+
/// Checks if an array type is compatible with a collection interface.
453+
/// </summary>
454+
static bool IsArrayCompatibleWithCollectionInterface(IArrayTypeSymbol arrayType, INamedTypeSymbol targetInterface)
455+
{
456+
if (!targetInterface.IsGenericType || targetInterface.TypeArguments.Length != 1)
457+
{
458+
return false;
459+
}
460+
461+
// Check if array element type matches the generic type argument
462+
if (!SymbolEqualityComparer.Default.Equals(arrayType.ElementType, targetInterface.TypeArguments[0]))
463+
{
464+
return false;
465+
}
466+
467+
// Array implements: IEnumerable<T>, ICollection<T>, IList<T>, IReadOnlyCollection<T>, IReadOnlyList<T>
468+
string targetName = targetInterface.OriginalDefinition.ToDisplayString();
469+
return targetName == "System.Collections.Generic.IEnumerable<T>" ||
470+
targetName == "System.Collections.Generic.ICollection<T>" ||
471+
targetName == "System.Collections.Generic.IList<T>" ||
472+
targetName == "System.Collections.Generic.IReadOnlyCollection<T>" ||
473+
targetName == "System.Collections.Generic.IReadOnlyList<T>";
474+
}
475+
476+
/// <summary>
477+
/// Checks if the source type implements the target interface.
478+
/// </summary>
479+
static bool ImplementsInterface(INamedTypeSymbol sourceType, INamedTypeSymbol targetInterface)
480+
{
481+
// Check all interfaces implemented by the source type
482+
return sourceType.AllInterfaces.Any(@interface =>
483+
SymbolEqualityComparer.Default.Equals(@interface.OriginalDefinition, targetInterface.OriginalDefinition));
484+
}
485+
333486
struct ActivityInvocation
334487
{
335488
public string Name { get; set; }

src/Worker/Core/Shims/TaskOrchestrationContextWrapper.EventSource.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ interface IEventSource
2121
/// </summary>
2222
Type EventType { get; }
2323

24+
/// <summary>
25+
/// Gets or sets the next event source in the stack (for LIFO ordering).
26+
/// </summary>
27+
IEventSource? Next { get; set; }
28+
2429
/// <summary>
2530
/// Tries to set the result on tcs.
2631
/// </summary>
@@ -33,6 +38,9 @@ class EventTaskCompletionSource<T> : TaskCompletionSource<T>, IEventSource
3338
/// <inheritdoc/>
3439
public Type EventType => typeof(T);
3540

41+
/// <inheritdoc/>
42+
public IEventSource? Next { get; set; }
43+
3644
/// <inheritdoc/>
3745
void IEventSource.TrySetResult(object result) => this.TrySetResult((T)result);
3846
}

src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ namespace Microsoft.DurableTask.Worker.Shims;
1818
/// </summary>
1919
sealed partial class TaskOrchestrationContextWrapper : TaskOrchestrationContext
2020
{
21-
readonly Dictionary<string, Queue<IEventSource>> externalEventSources = new(StringComparer.OrdinalIgnoreCase);
21+
// We use a stack (a custom implementation using a single-linked list) to make it easier for users
22+
// to abandon external events that they no longer care about. The common case is a Task.WhenAny in a loop.
23+
// Events are assigned to the most recent (top of stack) waiter, which naturally avoids issues with cancelled waiters.
24+
readonly Dictionary<string, IEventSource> externalEventSources = new(StringComparer.OrdinalIgnoreCase);
2225
readonly NamedQueue<string> externalEventBuffer = new();
2326
readonly OrchestrationContext innerContext;
2427
readonly OrchestrationInvocationContext invocationContext;
@@ -280,29 +283,33 @@ public override async Task CreateTimer(DateTime fireAt, CancellationToken cancel
280283
/// <inheritdoc/>
281284
public override Task<T> WaitForExternalEvent<T>(string eventName, CancellationToken cancellationToken = default)
282285
{
283-
// Return immediately if this external event has already arrived.
284-
if (this.externalEventBuffer.TryTake(eventName, out string? bufferedEventPayload))
285-
{
286-
return Task.FromResult(this.DataConverter.Deserialize<T>(bufferedEventPayload));
287-
}
288-
289286
// Create a task completion source that will be set when the external event arrives.
290287
EventTaskCompletionSource<T> eventSource = new();
291-
if (this.externalEventSources.TryGetValue(eventName, out Queue<IEventSource>? existing))
288+
289+
// Set up the stack for listening to external events (LIFO - Last In First Out)
290+
// New waiters are added to the top of the stack, so they get events first.
291+
// This makes it easier for users to abandon external events they no longer care about.
292+
// The common case is a Task.WhenAny in a loop.
293+
if (this.externalEventSources.TryGetValue(eventName, out IEventSource? existing))
292294
{
293-
if (existing.Count > 0 && existing.Peek().EventType != typeof(T))
295+
if (existing.EventType != typeof(T))
294296
{
295297
throw new ArgumentException("Events with the same name must have the same type argument. Expected"
296-
+ $" {existing.Peek().GetType().FullName} but was requested {typeof(T).FullName}.");
298+
+ $" {existing.EventType.FullName} but was requested {typeof(T).FullName}.");
297299
}
298300

299-
existing.Enqueue(eventSource);
301+
// Add new waiter to the top of the stack
302+
eventSource.Next = existing;
300303
}
301-
else
304+
305+
// New waiter becomes the top of the stack
306+
this.externalEventSources[eventName] = eventSource;
307+
308+
// Check the buffer to see if any events came in before the orchestrator was listening
309+
if (this.externalEventBuffer.TryTake(eventName, out string? bufferedEvent))
302310
{
303-
Queue<IEventSource> eventSourceQueue = new();
304-
eventSourceQueue.Enqueue(eventSource);
305-
this.externalEventSources.Add(eventName, eventSourceQueue);
311+
// We can complete the event right away, since we already have an event's input
312+
this.CompleteExternalEvent(eventName, bufferedEvent);
306313
}
307314

308315
// TODO: this needs to be tracked and disposed appropriately.
@@ -416,11 +423,23 @@ internal void ExitCriticalSectionIfNeeded()
416423
/// <param name="rawEventPayload">The serialized event payload.</param>
417424
internal void CompleteExternalEvent(string eventName, string rawEventPayload)
418425
{
419-
if (this.externalEventSources.TryGetValue(eventName, out Queue<IEventSource>? waiters))
426+
if (this.externalEventSources.TryGetValue(eventName, out IEventSource? waiter))
420427
{
421-
object? value;
428+
// Get the waiter at the top of the stack (most recent waiter)
429+
// If we're going to raise an event we should remove it from the pending collection
430+
// because otherwise WaitForExternalEvent() will always find one with this key and run infinitely.
431+
IEventSource? next = waiter.Next;
432+
if (next == null)
433+
{
434+
this.externalEventSources.Remove(eventName);
435+
}
436+
else
437+
{
438+
// Next waiter becomes the new top of the stack
439+
this.externalEventSources[eventName] = next;
440+
}
422441

423-
IEventSource waiter = waiters.Dequeue();
442+
object? value;
424443
if (waiter.EventType == typeof(OperationResult))
425444
{
426445
// use the framework-defined deserialization for entity responses, not the application-defined data converter,
@@ -432,12 +451,6 @@ internal void CompleteExternalEvent(string eventName, string rawEventPayload)
432451
value = this.DataConverter.Deserialize(rawEventPayload, waiter.EventType);
433452
}
434453

435-
// Events are completed in FIFO order. Remove the key if the last event was delivered.
436-
if (waiters.Count == 0)
437-
{
438-
this.externalEventSources.Remove(eventName);
439-
}
440-
441454
waiter.TrySetResult(value);
442455
}
443456
else

test/Analyzers.Tests/Activities/MatchingInputOutputTypeActivityAnalyzerTests.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,6 @@ async Task Method(TaskOrchestrationContext context)
406406
await VerifyCS.VerifyDurableTaskAnalyzerAsync(code);
407407
}
408408

409-
410409
static DiagnosticResult BuildInputDiagnostic()
411410
{
412411
return VerifyCS.Diagnostic(MatchingInputOutputTypeActivityAnalyzer.InputArgumentTypeMismatchDiagnosticId);

0 commit comments

Comments
 (0)