Skip to content

Commit 6d71ea6

Browse files
Add api to handle unhandled async rules exceptions (#4727)
* Fixes #4725 Add a new interface IUnhandledAsyncRuleExceptionHandler so a user can inspect and do somethign with exceptions raised by async rules. * Add docs * Add missing assignment line. * Update Source/tests/Csla.test/ValidationRules/AsyncRuleTests.cs Fix typo Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Source/Csla/Rules/BusinessRules.cs Add missing doc Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix typo in class name * Prevent test hos crash during test --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 98774d5 commit 6d71ea6

11 files changed

+248
-8
lines changed

Source/Csla/Configuration/ConfigurationExtensions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// <summary>Implement extension methods for base .NET configuration</summary>
77
//-----------------------------------------------------------------------
88
using Csla.DataPortalClient;
9+
using Csla.Rules;
910
using Csla.Runtime;
1011
using Microsoft.Extensions.DependencyInjection;
1112
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -58,6 +59,8 @@ public static IServiceCollection AddCsla(this IServiceCollection services, Actio
5859
services.AddScoped(typeof(IDataPortalCache), cslaOptions.DataPortalOptions.DataPortalClientOptions.DataPortalCacheType);
5960
cslaOptions.AddRequiredDataPortalServices(services);
6061

62+
services.AddScoped(typeof(IUnhandledAsyncRuleExceptionHandler), cslaOptions.UnhandledAsyncRuleExceptionHandlerType);
63+
6164
// Default to using LocalProxy and local data portal
6265
var proxyInit = services.Any(i => i.ServiceType == typeof(IDataPortalProxy));
6366
if (!proxyInit)

Source/Csla/Configuration/Fluent/CslaOptions.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
using System.Diagnostics.CodeAnalysis;
1010
using Csla.Core;
11+
using Csla.Rules;
1112
using Microsoft.Extensions.DependencyInjection;
1213

1314
namespace Csla.Configuration
@@ -50,6 +51,22 @@ public CslaOptions UseContextManager<T>() where T : IContextManager
5051
/// </summary>
5152
public Type? ContextManagerType { get; private set; }
5253

54+
/// <summary>
55+
/// Sets the type for the <see cref="IUnhandledAsyncRuleExceptionHandler"/> to be used.
56+
/// </summary>
57+
/// <typeparam name="T">The type to register for <see cref="IUnhandledAsyncRuleExceptionHandler"/>.</typeparam>
58+
/// <returns>This instance.</returns>
59+
public CslaOptions UseUnhandledAsyncRuleExceptionHandler<T>() where T : IUnhandledAsyncRuleExceptionHandler
60+
{
61+
UnhandledAsyncRuleExceptionHandlerType = typeof(T);
62+
return this;
63+
}
64+
65+
/// <summary>
66+
/// Gets the type used for the <see cref="IUnhandledAsyncRuleExceptionHandler"/>.
67+
/// </summary>
68+
public Type UnhandledAsyncRuleExceptionHandlerType { get; private set; } = typeof(DontObserveUnhandledAsyncRuleExceptionHandler);
69+
5370
/// <summary>
5471
/// Sets a value indicating whether CSLA
5572
/// should fallback to using reflection instead of

Source/Csla/Core/BusinessBase.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1086,7 +1086,7 @@ protected BusinessRules BusinessRules
10861086
get
10871087
{
10881088
if (_businessRules == null)
1089-
_businessRules = new BusinessRules(ApplicationContext, this);
1089+
_businessRules = new BusinessRules(ApplicationContext, this, ApplicationContext.GetRequiredService<IUnhandledAsyncRuleExceptionHandler>());
10901090
else if (_businessRules.Target == null)
10911091
_businessRules.SetTarget(this);
10921092
return _businessRules;

Source/Csla/Rules/BusinessRules.cs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,13 @@ public BusinessRules()
3535
/// </summary>
3636
/// <param name="applicationContext"></param>
3737
/// <param name="target">Target business object.</param>
38+
/// <param name="unhandledAsyncRuleExceptionHandler">Handler for unhandled exceptions that occur during asynchronous rule execution.</param>
3839
/// <exception cref="ArgumentNullException"><paramref name="applicationContext"/> or <paramref name="target"/> is <see langword="null"/>.</exception>
39-
public BusinessRules(ApplicationContext applicationContext, IHostRules target)
40+
public BusinessRules(ApplicationContext applicationContext, IHostRules target, IUnhandledAsyncRuleExceptionHandler unhandledAsyncRuleExceptionHandler)
4041
{
4142
_applicationContext = applicationContext ?? throw new ArgumentNullException(nameof(applicationContext));
4243
_target = target ?? throw new ArgumentNullException(nameof(target));
44+
_unhandledAsyncRuleExceptionHandler = unhandledAsyncRuleExceptionHandler ?? throw new ArgumentNullException(nameof(unhandledAsyncRuleExceptionHandler));
4345
}
4446

4547
#if NET9_0_OR_GREATER
@@ -49,6 +51,8 @@ public BusinessRules(ApplicationContext applicationContext, IHostRules target)
4951
[NonSerialized]
5052
private object _syncRoot = new();
5153
#endif
54+
[NonSerialized]
55+
private readonly IUnhandledAsyncRuleExceptionHandler _unhandledAsyncRuleExceptionHandler;
5256

5357
private ApplicationContext _applicationContext;
5458

@@ -1120,7 +1124,7 @@ private RunRulesResult RunRules(IEnumerable<IBusinessRuleBase> rules, bool casca
11201124
if (rule is IBusinessRule syncRule)
11211125
syncRule.Execute(context);
11221126
else if (rule is IBusinessRuleAsync asyncRule)
1123-
RunAsyncRule(asyncRule, context);
1127+
RunAsyncRule(asyncRule, context, _unhandledAsyncRuleExceptionHandler);
11241128
else
11251129
throw new ArgumentOutOfRangeException(rule.GetType().FullName);
11261130
}
@@ -1151,12 +1155,16 @@ private RunRulesResult RunRules(IEnumerable<IBusinessRuleBase> rules, bool casca
11511155
return new RunRulesResult(affectedProperties, dirtyProperties);
11521156
}
11531157

1154-
private static async void RunAsyncRule(IBusinessRuleAsync asyncRule, IRuleContext context)
1158+
private static async void RunAsyncRule(IBusinessRuleAsync asyncRule, IRuleContext context, IUnhandledAsyncRuleExceptionHandler unhandledAsyncRuleExceptionHandler)
11551159
{
11561160
try
11571161
{
11581162
await asyncRule.ExecuteAsync(context);
11591163
}
1164+
catch (Exception exc) when (unhandledAsyncRuleExceptionHandler.CanHandle(exc, asyncRule))
1165+
{
1166+
await unhandledAsyncRuleExceptionHandler.Handle(exc, asyncRule, context);
1167+
}
11601168
finally
11611169
{
11621170
context.Complete();
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace Csla.Rules;
2+
3+
internal class DontObserveUnhandledAsyncRuleExceptionHandler : IUnhandledAsyncRuleExceptionHandler
4+
{
5+
public bool CanHandle(Exception exception, IBusinessRuleBase executingRule) => false;
6+
public ValueTask Handle(Exception exception, IBusinessRuleBase executingRule, IRuleContext ruleContext) => throw new NotSupportedException();
7+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
namespace Csla.Rules;
2+
3+
/// <summary>
4+
/// Represents an interface for handling exceptions raised by <see cref="IBusinessRuleAsync"/>.
5+
/// </summary>
6+
public interface IUnhandledAsyncRuleExceptionHandler
7+
{
8+
/// <summary>
9+
/// Checks whether the given <paramref name="exception"/> and <paramref name="executingRule"/> can be handled.
10+
/// </summary>
11+
/// <param name="exception">The unhandled <see cref="Exception"/>.</param>
12+
/// <param name="executingRule">The rule causing <paramref name="exception"/>.</param>
13+
/// <returns><see langword="true"/> if the exception can be handled. Otherwise <see langword="false"/>.</returns>
14+
bool CanHandle(Exception exception, IBusinessRuleBase executingRule);
15+
16+
/// <summary>
17+
/// Handles the raised <paramref name="exception"/>.
18+
/// </summary>
19+
/// <param name="exception">The unhandled <see cref="Exception"/>.</param>
20+
/// <param name="executingRule">The rule causing <paramref name="exception"/>.</param>
21+
/// <param name="ruleContext">The associated <see cref="IRuleContext"/> to this rule run.</param>
22+
/// <returns><see cref="ValueTask"/></returns>
23+
ValueTask Handle(Exception exception, IBusinessRuleBase executingRule, IRuleContext ruleContext);
24+
}

Source/tests/Csla.test/Csla.Tests.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@
6666
</ItemGroup>
6767

6868
<ItemGroup>
69+
<ProjectReference Include="..\..\Csla.Generators\cs\AutoImplementProperties\Csla.Generator.AutoImplementProperties.Attributes.CSharp\Csla.Generator.AutoImplementProperties.Attributes.CSharp.csproj" ReferenceOutputAssembly="true" OutputItemType="Analyzer" PrivateAssets="all" />
70+
<ProjectReference Include="..\..\Csla.Generators\cs\AutoImplementProperties\Csla.Generator.AutoImplementProperties.CSharp\Csla.Generator.AutoImplementProperties.CSharp.csproj" ReferenceOutputAssembly="false" OutputItemType="Analyzer" />
6971
<ProjectReference Include="..\Csla.TestHelpers\Csla.TestHelpers.csproj" />
7072
<ProjectReference Include="..\..\Csla\Csla.csproj" />
7173
</ItemGroup>

Source/tests/Csla.test/ValidationRules/AsyncRuleTests.cs

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
// <summary>This only works on Silverlight because when run through NUnit it is not running</summary>
77
//-----------------------------------------------------------------------
88

9+
using Csla.Rules;
910
using Csla.TestHelpers;
10-
using FluentAssertions.Execution;
1111
using FluentAssertions;
12+
using FluentAssertions.Execution;
13+
using Microsoft.Extensions.DependencyInjection;
1214
using Microsoft.VisualStudio.TestTools.UnitTesting;
1315

1416
namespace Csla.Test.ValidationRules
@@ -152,11 +154,88 @@ public async Task MyTestMethod()
152154
await har.WaitForIdle();
153155

154156
var affectedProperties = await har.CheckRulesForPropertyAsyncAwait();
155-
using (new AssertionScope())
157+
using (new AssertionScope())
156158
{
157159
har.AsyncAwait.Should().Be("abc");
158160
affectedProperties.Should().ContainSingle().Which.Should().Be(nameof(AsyncRuleRoot.AsyncAwait));
159161
}
160162
}
163+
164+
[TestMethod($"When an async rule throws an exception the framework must invoke {nameof(IUnhandledAsyncRuleExceptionHandler)}.{nameof(IUnhandledAsyncRuleExceptionHandler.CanHandle)}.")]
165+
public async Task AsyncRuleException_Testcase01()
166+
{
167+
var diContext = CreateDIContextForAsyncRuleExceptions();
168+
169+
var unhandledExceptionHandler = (TestUnhandledAsyncRuleExceptionHandler)diContext.ServiceProvider.GetRequiredService<IUnhandledAsyncRuleExceptionHandler>();
170+
unhandledExceptionHandler.CanHandleResult = true; // Otherwise the test host process will be crashed
171+
172+
var cp = diContext.CreateDataPortal<DelayedAsyncRuleExceptionRoot>();
173+
var bo = await cp.CreateAsync();
174+
175+
var tcs = new TaskCompletionSource();
176+
unhandledExceptionHandler.CanHandleInspector = (_, _) => tcs.SetResult();
177+
178+
await ForceThreadSwitch(bo, TimeSpan.FromMilliseconds(25));
179+
180+
await tcs.Task.WaitAsync(TimeSpan.FromMilliseconds(150));
181+
}
182+
183+
[TestMethod($"When an async rules exception can be handled by {nameof(IUnhandledAsyncRuleExceptionHandler)} it must invoke {nameof(IUnhandledAsyncRuleExceptionHandler)}.{nameof(IUnhandledAsyncRuleExceptionHandler.Handle)}.")]
184+
public async Task AsyncRuleException_Testcase02()
185+
{
186+
var diContext = CreateDIContextForAsyncRuleExceptions();
187+
188+
var unhandledExceptionHandler = (TestUnhandledAsyncRuleExceptionHandler)diContext.ServiceProvider.GetRequiredService<IUnhandledAsyncRuleExceptionHandler>();
189+
unhandledExceptionHandler.CanHandleResult = true; // Otherwise the test host process will be crashed
190+
191+
var cp = diContext.CreateDataPortal<DelayedAsyncRuleExceptionRoot>();
192+
var bo = await cp.CreateAsync();
193+
194+
bool canHandleInvoked = false;
195+
unhandledExceptionHandler.HandleInspector = (_, _, _) => canHandleInvoked = true;
196+
197+
await ForceThreadSwitch(bo, TimeSpan.FromMilliseconds(25));
198+
199+
await Task.Delay(TimeSpan.FromMilliseconds(150));
200+
201+
canHandleInvoked.Should().BeTrue();
202+
}
203+
204+
[Ignore("This test can be run but will crash the test host, so this is failing any CI build. But for completness it's here and can be run if necessary.")]
205+
[TestMethod($"When the default {nameof(IUnhandledAsyncRuleExceptionHandler)} is used the exception must be handled by a global unhandled exception handler (for this test it's the {nameof(AppDomain)}.{nameof(AppDomain.CurrentDomain)}.{nameof(AppDomain.CurrentDomain.UnhandledException)} event).")]
206+
public async Task AsyncRuleException_Testcase03()
207+
{
208+
var diContext = CreateDIContextForAsyncRuleExceptions();
209+
210+
bool unobservedTaskExceptionFound = false;
211+
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
212+
try
213+
{
214+
var unhandledExceptionHandler = (TestUnhandledAsyncRuleExceptionHandler)diContext.ServiceProvider.GetRequiredService<IUnhandledAsyncRuleExceptionHandler>();
215+
unhandledExceptionHandler.CanHandleResult = false;
216+
var cp = diContext.CreateDataPortal<DelayedAsyncRuleExceptionRoot>();
217+
var bo = await cp.CreateAsync();
218+
219+
await ForceThreadSwitch(bo, TimeSpan.FromMilliseconds(25));
220+
221+
await Task.Delay(TimeSpan.FromMilliseconds(150));
222+
223+
unobservedTaskExceptionFound.Should().BeTrue();
224+
}
225+
finally
226+
{
227+
AppDomain.CurrentDomain.UnhandledException -= CurrentDomain_UnhandledException;
228+
}
229+
230+
void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) => unobservedTaskExceptionFound = true;
231+
}
232+
233+
234+
private static TestDIContext CreateDIContextForAsyncRuleExceptions() => TestDIContextFactory.CreateDefaultContext(services => services.AddSingleton<IUnhandledAsyncRuleExceptionHandler, TestUnhandledAsyncRuleExceptionHandler>());
235+
private static async Task ForceThreadSwitch(DelayedAsyncRuleExceptionRoot root, TimeSpan exceptionDelay)
236+
{
237+
await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
238+
root.ExceptionDelay = exceptionDelay;
239+
}
161240
}
162241
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
//-----------------------------------------------------------------------
2+
// <copyright file="DelayedAsyncRuleExceptionRoot.cs" company="Marimer LLC">
3+
// Copyright (c) Marimer LLC. All rights reserved.
4+
// Website: https://cslanet.com
5+
// </copyright>
6+
// <summary>This only works on Silverlight because when run through NUnit it is not running</summary>
7+
//-----------------------------------------------------------------------
8+
9+
using Csla.Core;
10+
using Csla.Rules;
11+
12+
namespace Csla.Test.ValidationRules
13+
{
14+
[CslaImplementProperties]
15+
internal partial class DelayedAsyncRuleExceptionRoot : BusinessBase<DelayedAsyncRuleExceptionRoot>
16+
{
17+
public partial TimeSpan ExceptionDelay { get; set; }
18+
19+
[Create, RunLocal]
20+
private void Create()
21+
{
22+
_ = this;
23+
}
24+
25+
protected override void AddBusinessRules()
26+
{
27+
base.AddBusinessRules();
28+
BusinessRules.AddRule(new RaiseExceptionRuleAsync(ExceptionDelayProperty));
29+
}
30+
31+
32+
private class RaiseExceptionRuleAsync : BusinessRuleAsync
33+
{
34+
public RaiseExceptionRuleAsync(IPropertyInfo primaryProperty) : base(primaryProperty)
35+
{
36+
InputProperties.Add(primaryProperty);
37+
}
38+
39+
protected override async Task ExecuteAsync(IRuleContext context)
40+
{
41+
var delay = context.GetInputValue<TimeSpan>(PrimaryProperty);
42+
43+
await Task.Delay(delay);
44+
45+
throw new InvalidOperationException("This is a test exception");
46+
}
47+
}
48+
}
49+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
//-----------------------------------------------------------------------
2+
// <copyright file="TestUnhandledAsyncRuleExceptionHandler.cs" company="Marimer LLC">
3+
// Copyright (c) Marimer LLC. All rights reserved.
4+
// Website: https://cslanet.com
5+
// </copyright>
6+
// <summary>This only works on Silverlight because when run through NUnit it is not running</summary>
7+
//-----------------------------------------------------------------------
8+
9+
using Csla.Rules;
10+
11+
namespace Csla.Test.ValidationRules
12+
{
13+
internal class TestUnhandledAsyncRuleExceptionHandler : IUnhandledAsyncRuleExceptionHandler
14+
{
15+
public bool CanHandleResult { get; set; } = true;
16+
17+
public Action<Exception, IBusinessRuleBase> CanHandleInspector { get; set; }
18+
19+
public bool CanHandle(Exception exception, IBusinessRuleBase executingRule)
20+
{
21+
CanHandleInspector?.Invoke(exception, executingRule);
22+
return CanHandleResult;
23+
}
24+
25+
26+
public Action<Exception, IBusinessRuleBase, IRuleContext> HandleInspector { get; set; }
27+
public ValueTask Handle(Exception exception, IBusinessRuleBase executingRule, IRuleContext ruleContext)
28+
{
29+
HandleInspector?.Invoke(exception, executingRule, ruleContext);
30+
return ValueTask.CompletedTask;
31+
}
32+
}
33+
}

0 commit comments

Comments
 (0)