diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 8ac33865..35e8424f 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -54,7 +54,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -65,7 +65,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -79,4 +79,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 0a30c6f8..0ce6f60c 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -24,7 +24,7 @@ jobs: - os: ${{ !contains(github.event.head_commit.message, 'windows') && !github.event.pull_request && 'windows-latest' }} runs-on: ${{ matrix.os }} - timeout-minutes: 20 + timeout-minutes: 30 steps: - name: Checkout source @@ -38,6 +38,8 @@ jobs: dotnet-version: | 8.0.x 9.0.x + cache: true + cache-dependency-path: '**/*.csproj' - name: Install libmsquic (Linux) if: runner.os == 'Linux' @@ -53,7 +55,18 @@ jobs: run: dotnet build --no-restore - name: Test - run: dotnet test --no-build --verbosity normal --logger "trx;LogFileName=test-results.trx" + run: dotnet test --no-build --verbosity normal --logger "trx;LogFileName=test-results.trx" --blame-hang-timeout 180s --blame-hang-dump-type mini + + - name: Upload test artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.os }} + path: | + **/TestResults/**/*.trx + **/TestResults/**/*.dmp + retention-days: 3 + if-no-files-found: warn - name: Update Pantry badge if: always() && github.event_name == 'push' diff --git a/CoreRemoting.Tests/CoreRemoting.Tests.csproj b/CoreRemoting.Tests/CoreRemoting.Tests.csproj index 6c55192d..e35de1f6 100644 --- a/CoreRemoting.Tests/CoreRemoting.Tests.csproj +++ b/CoreRemoting.Tests/CoreRemoting.Tests.csproj @@ -11,8 +11,6 @@ - - @@ -24,6 +22,7 @@ + @@ -79,6 +78,9 @@ DicTests.cs + + EventDispatcherTests.cs + RpcTests.cs diff --git a/CoreRemoting.Tests/InvokerTests.cs b/CoreRemoting.Tests/DelegateInvokerTests.cs similarity index 91% rename from CoreRemoting.Tests/InvokerTests.cs rename to CoreRemoting.Tests/DelegateInvokerTests.cs index 8af3a9ec..d247043b 100644 --- a/CoreRemoting.Tests/InvokerTests.cs +++ b/CoreRemoting.Tests/DelegateInvokerTests.cs @@ -15,14 +15,14 @@ namespace CoreRemoting.Tests; [Collection("CoreRemoting")] -public class InvokerTests : IClassFixture, IDisposable +public class DelegateInvokerTests : IClassFixture, IDisposable { private readonly ServerFixture _serverFixture; private readonly ITestOutputHelper _testOutputHelper; - public InvokerTests(ServerFixture serverFixture, ITestOutputHelper testOutputHelper) + public DelegateInvokerTests(ServerFixture serverFixture, ITestOutputHelper testOutputHelper) { - EventStub.DelegateInvoker = DelegateInvoker; + ServiceEventHub.DelegateInvoker = DelegateInvoker; _serverFixture = serverFixture; _testOutputHelper = testOutputHelper; @@ -102,6 +102,6 @@ public async Task Event_order_depends_on_the_delegate_invoker() public void Dispose() { // reset the invoker to default - EventStub.DelegateInvoker = null; + ServiceEventHub.DelegateInvoker = null; } } \ No newline at end of file diff --git a/CoreRemoting.Tests/InvokerTestsSafe.cs b/CoreRemoting.Tests/DelegateInvokerTestsSafe.cs similarity index 55% rename from CoreRemoting.Tests/InvokerTestsSafe.cs rename to CoreRemoting.Tests/DelegateInvokerTestsSafe.cs index 373d577a..9ad7fe2d 100644 --- a/CoreRemoting.Tests/InvokerTestsSafe.cs +++ b/CoreRemoting.Tests/DelegateInvokerTestsSafe.cs @@ -1,12 +1,11 @@ using CoreRemoting.RemoteDelegates; -using CoreRemoting.Threading; using Xunit.Abstractions; namespace CoreRemoting.Tests; -public class InvokerTestsSafe : InvokerTests +public class DelegateInvokerTestsSafe : DelegateInvokerTests { - public InvokerTestsSafe(ServerFixture serverFixture, ITestOutputHelper testOutputHelper) : base(serverFixture, testOutputHelper) + public DelegateInvokerTestsSafe(ServerFixture serverFixture, ITestOutputHelper testOutputHelper) : base(serverFixture, testOutputHelper) { } diff --git a/CoreRemoting.Tests/DicTests.cs b/CoreRemoting.Tests/DicTests.cs index 05c896b0..d896ca0d 100644 --- a/CoreRemoting.Tests/DicTests.cs +++ b/CoreRemoting.Tests/DicTests.cs @@ -198,4 +198,52 @@ void RegisterFactory() Console.WriteLine($"Registration threads: {t.Keys.Count}"); Assert.True(t.Count > 1); } + + [Fact] + public void Container_resolves_itself() + { + var c = Container; + + var resolved = c.GetService(); + Assert.NotNull(resolved); + Assert.Same(c, resolved); + } + + [Fact] + public void Container_can_be_injected_into_service() + { + var c = Container; + c.RegisterService(ServiceLifetime.SingleCall); + + var svc = c.GetService(); + Assert.NotNull(svc); + Assert.Same(c, svc.Container); + } + + [Fact] + public void Injected_container_exposes_service_registrations() + { + var c = Container; + c.RegisterService(ServiceLifetime.Singleton); + c.RegisterService(ServiceLifetime.SingleCall); + + Assert.True(c.IsRegistered()); + Assert.True(c.IsRegistered()); + + var svc = c.GetService(); + Assert.NotNull(svc); + Assert.Same(c, svc.Container); + + // verify that injected container can resolve other service registration + Assert.True(svc.Container.IsRegistered()); + + var registration = svc.Container.GetServiceRegistration(typeof(ITestService).FullName); + Assert.NotNull(registration); + + var ifaceType = svc.Container.GetServiceInterfaceType(typeof(ITestService).FullName); + Assert.Equal(typeof(ITestService), ifaceType); + + var all = svc.Container.GetServiceRegistrations().ToList(); + Assert.Contains(all, r => r.ServiceName == typeof(ITestService).FullName); + } } diff --git a/CoreRemoting.Tests/Encryption/HkdfTests.cs b/CoreRemoting.Tests/Encryption/HkdfTests.cs index 7ad2862b..9bc3cb1d 100644 --- a/CoreRemoting.Tests/Encryption/HkdfTests.cs +++ b/CoreRemoting.Tests/Encryption/HkdfTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Security.Cryptography; using System.Text; +using CoreRemoting.Encryption; using Xunit; namespace CoreRemoting.Tests.Encryption; diff --git a/CoreRemoting.Tests/EventStubTests.cs b/CoreRemoting.Tests/EventStubTests.cs deleted file mode 100644 index a95cf42b..00000000 --- a/CoreRemoting.Tests/EventStubTests.cs +++ /dev/null @@ -1,353 +0,0 @@ -using System; -using System.ComponentModel; -using System.Linq; -using System.Threading.Tasks; -using CoreRemoting.RemoteDelegates; -using CoreRemoting.Threading; -using CoreRemoting.Toolbox; -using Xunit; - -namespace CoreRemoting.Tests; - -public class EventStubTests -{ - public interface ISampleInterface - { - string FireHandlers(int argument); - - event EventHandler SimpleEvent; - - event EventHandler CancelEvent; - - Action ActionDelegate { get; set; } - - Func FuncDelegate { get; set; } - - int SimpleEventHandlerCount { get; } - } - - public interface ISampleDescendant1 : ISampleInterface - { - event EventHandler NewEvent; - - Action NewDelegate { get; set; } - } - - public interface ISampleDescendant2 : ISampleDescendant1, ISampleInterface - { - event EventHandler NewCancelEvent; - } - - public class SampleService : ISampleInterface - { - public string FireHandlers(int argument) - { - if (SimpleEvent != null) - { - SimpleEvent(this, EventArgs.Empty); - } - - if (CancelEvent != null) - { - CancelEvent(this, new CancelEventArgs()); - } - - if (ActionDelegate != null) - { - ActionDelegate(); - } - - if (FuncDelegate != null) - { - return FuncDelegate(argument); - } - - return null; - } - - public event EventHandler SimpleEvent; - - public event EventHandler CancelEvent; - - public Action ActionDelegate { get; set; } - - public Func FuncDelegate { get; set; } - - public int SimpleEventHandlerCount - { - get { return EventStub.GetHandlerCount(SimpleEvent); } - } - } - - [Fact] - public void EventStub_Contains_Events_And_Delegates() - { - var eventStub = new EventStub(typeof(ISampleInterface)); - Assert.NotNull(eventStub[nameof(ISampleInterface.SimpleEvent)]); - Assert.NotNull(eventStub[nameof(ISampleInterface.CancelEvent)]); - Assert.NotNull(eventStub[nameof(ISampleInterface.ActionDelegate)]); - Assert.NotNull(eventStub[nameof(ISampleInterface.FuncDelegate)]); - } - - [Fact] - public void EventStub_Contains_Inherited_Events_And_Delegates() - { - var eventStub = new EventStub(typeof(ISampleDescendant2)); - Assert.NotNull(eventStub[nameof(ISampleDescendant2.NewCancelEvent)]); - Assert.NotNull(eventStub[nameof(ISampleDescendant2.NewEvent)]); - Assert.NotNull(eventStub[nameof(ISampleDescendant2.NewDelegate)]); - Assert.NotNull(eventStub[nameof(ISampleDescendant2.SimpleEvent)]); - Assert.NotNull(eventStub[nameof(ISampleDescendant2.CancelEvent)]); - Assert.NotNull(eventStub[nameof(ISampleDescendant2.ActionDelegate)]); - Assert.NotNull(eventStub[nameof(ISampleDescendant2.FuncDelegate)]); - } - - [Fact] - public void EventStub_Delegates_Have_Same_Types_As_Their_Originals() - { - var eventStub = new EventStub(typeof(ISampleInterface)); - Assert.IsType(eventStub[nameof(ISampleInterface.SimpleEvent)]); - Assert.IsType>(eventStub[nameof(ISampleInterface.CancelEvent)]); - Assert.IsType(eventStub[nameof(ISampleInterface.ActionDelegate)]); - Assert.IsType>(eventStub[nameof(ISampleInterface.FuncDelegate)]); - } - - [Fact] - public async Task EventStub_Simple_Handle_Tests() - { - // add the first handler - var eventStub = new EventStub(typeof(ISampleInterface)); - var fired = new AsyncCounter(); - eventStub.AddHandler(nameof(ISampleInterface.SimpleEvent), new EventHandler((sender, args) => fired++)); - - // check if it is called - var handler = (EventHandler)eventStub[nameof(ISampleInterface.SimpleEvent)]; - handler(this, EventArgs.Empty); - Assert.Equal(1, await fired.WaitForValue(1).Timeout(1)); - - // add the second handler - var firedAgain = new AsyncCounter(); - var tempHandler = new EventHandler((sender, args) => firedAgain++); - eventStub.AddHandler(nameof(ISampleInterface.SimpleEvent), tempHandler); - - // check if it is called - handler(this, EventArgs.Empty); - Assert.Equal(2, await fired.WaitForValue(2).Timeout(1)); - Assert.Equal(1, await firedAgain.WaitForValue(1).Timeout(1)); - - // remove the second handler - eventStub.RemoveHandler(nameof(ISampleInterface.SimpleEvent), tempHandler); - - // check if it is not called - handler(this, EventArgs.Empty); - Assert.Equal(3, await fired.WaitForValue(3).Timeout(1)); - Assert.False(await firedAgain.WaitForValue(2).Expire(0.1)); - } - - [Fact] - public async Task EventStub_Cancel_Event_Tests() - { - // add the first handler - var eventStub = new EventStub(typeof(ISampleInterface)); - var fired = new AsyncCounter(); - eventStub.AddHandler(nameof(ISampleInterface.CancelEvent), new EventHandler((sender, args) => fired++)); - - // check if it is called - var handler = (EventHandler)eventStub[nameof(ISampleInterface.CancelEvent)]; - handler(this, new CancelEventArgs()); - Assert.Equal(1, await fired.WaitForValue(1).Timeout(1)); - - // add the second handler - var firedAgain = new AsyncCounter(); - var tempHandler = new EventHandler((sender, args) => firedAgain++); - eventStub.AddHandler(nameof(ISampleInterface.CancelEvent), tempHandler); - - // check if it is called - handler(this, new CancelEventArgs()); - Assert.Equal(2, await fired.WaitForValue(2).Timeout(1)); - Assert.Equal(1, await firedAgain.WaitForValue(1).Timeout(1)); - - // remove the second handler - eventStub.RemoveHandler(nameof(ISampleInterface.CancelEvent), tempHandler); - - // check if it is not called - handler(this, new CancelEventArgs()); - Assert.Equal(2, await fired.WaitForValue(2).Timeout(1)); - Assert.False(await firedAgain.WaitForValue(2).Expire(0.1)); - } - - [Fact] - public async Task EventStub_ActionDelegateTests() - { - // add the first handler - var eventStub = new EventStub(typeof(ISampleInterface)); - var fired = new AsyncCounter(); - eventStub.AddHandler(nameof(ISampleInterface.ActionDelegate), new Action(() => fired++)); - - // check if it is called - var handler = (Action)eventStub[nameof(ISampleInterface.ActionDelegate)]; - handler(); - Assert.Equal(1, await fired.WaitForValue(1).Timeout(1)); - - // add the second handler - var firedAgain = new AsyncCounter(); - var tempHandler = new Action(() => firedAgain++); - eventStub.AddHandler(nameof(ISampleInterface.ActionDelegate), tempHandler); - - // check if it is called - handler(); - Assert.Equal(2, await fired.WaitForValue(2).Timeout(1)); - Assert.Equal(1, await firedAgain.WaitForValue(1).Timeout(1)); - - // remove the second handler - eventStub.RemoveHandler(nameof(ISampleInterface.ActionDelegate), tempHandler); - - // check if it is not called - handler(); - Assert.Equal(3, await fired.WaitForValue(3).Timeout(1)); - Assert.False(await firedAgain.WaitForValue(2).Expire(0.1)); - } - - [Fact] - public async Task EventStub_FuncDelegateTests() - { - // add the first handler - var eventStub = new EventStub(typeof(ISampleInterface)); - var fired = new AsyncCounter(); - eventStub.AddHandler(nameof(ISampleInterface.FuncDelegate), new Func(a => - { - fired++; - return a.ToString(); // discarded - })); - - // check if it is called - var handler = (Func)eventStub[nameof(ISampleInterface.FuncDelegate)]; - var result = handler(123); - Assert.Equal(1, await fired.WaitForValue(1).Timeout(1)); - Assert.Null(result); // Assert.Equal("123", result); - - // add the second handler - var firedAgain = new AsyncCounter(); - var tempHandler = new Func(a => - { - firedAgain++; - return a.ToString(); // discarded - }); - - eventStub.AddHandler(nameof(ISampleInterface.FuncDelegate), tempHandler); - - // check if it is called - result = handler(321); - Assert.Equal(2, await fired.WaitForValue(2).Timeout(1)); - Assert.Equal(1, await firedAgain.WaitForValue(1).Timeout(1)); - Assert.Null(result); // Assert.Equal("123", result); - - // remove the second handler - eventStub.RemoveHandler(nameof(ISampleInterface.FuncDelegate), tempHandler); - - // check if it is not called - result = handler(0); - Assert.Equal(3, await fired.WaitForValue(3).Timeout(1)); - Assert.False(await firedAgain.WaitForValue(2).Expire(0.1)); - Assert.Null(result); // Assert.Equal("0", result); - } - - [Fact] - public async Task EventStub_WireUnwireTests() - { - var eventStub = new EventStub(typeof(ISampleInterface)); - var simpleEventFired = new AsyncCounter(); - var cancelEventFired = new AsyncCounter(); - var actionFired = new AsyncCounter(); - var funcFired = new AsyncCounter(); - - // add event handlers - eventStub.AddHandler(nameof(ISampleInterface.SimpleEvent), new EventHandler((sender, args) => simpleEventFired++)); - eventStub.AddHandler(nameof(ISampleInterface.CancelEvent), new EventHandler((sender, args) => cancelEventFired++)); - eventStub.AddHandler(nameof(ISampleInterface.ActionDelegate), new Action(() => actionFired++)); - eventStub.AddHandler(nameof(ISampleInterface.FuncDelegate), new Func(a => { funcFired++; return a.ToString(); })); - eventStub.AddHandler(nameof(ISampleInterface.FuncDelegate), new Func(a => { funcFired++; return (a * 2).ToString(); })); - - // wire up events - var component = new SampleService(); - eventStub.WireTo(component); - - // test if it works - var result = component.FireHandlers(102030); - Assert.Null(result); // Assert.Equal("204060", result); - - // check if all events were fired - var results = await Task.WhenAll( - simpleEventFired.WaitForValue(1), - cancelEventFired.WaitForValue(1), - actionFired.WaitForValue(1), - funcFired.WaitForValue(2)).Timeout(1); - - Assert.Equal(5, results.Sum()); - - // unwire - eventStub.UnwireFrom(component); - - // test if it works - result = component.FireHandlers(123); - - // event handlers shoundn't fire anymore - Assert.Null(result); - Assert.False(await simpleEventFired.WaitForValue(2).Expire(0.1)); - Assert.False(await cancelEventFired.WaitForValue(2).Expire(0.1)); - Assert.False(await actionFired.WaitForValue(2).Expire(0.1)); - Assert.False(await funcFired.WaitForValue(3).Expire(0.1)); - } - - [Fact] - public void EventStub_Handler_Count_Tests() - { - var eventStub = new EventStub(typeof(ISampleInterface)); - var sampleService = new SampleService(); - - eventStub.WireTo(sampleService); - Assert.Equal(0, sampleService.SimpleEventHandlerCount); - - eventStub.AddHandler(nameof(ISampleInterface.SimpleEvent), new EventHandler((s, e) => { })); - Assert.Equal(1, sampleService.SimpleEventHandlerCount); - - var handler = new EventHandler((s, e) => { }); - - eventStub.AddHandler(nameof(ISampleInterface.SimpleEvent), handler); - Assert.Equal(2, sampleService.SimpleEventHandlerCount); - - eventStub.RemoveHandler(nameof(ISampleInterface.SimpleEvent), handler); - Assert.Equal(1, sampleService.SimpleEventHandlerCount); - } - - [Fact] - public void EventStub_doesnt_throw_if_no_subscriptions_exist() - { - var eventStub = new EventStub(typeof(ISampleInterface)); - var sampleService = new SampleService(); - eventStub.WireTo(sampleService); - sampleService.FireHandlers(1); - } - - [Fact] - public void MethodInfo_can_represent_subscription_or_unsubscription() - { - var method = typeof(ISampleInterface).GetMethod("add_SimpleEvent"); - Assert.NotNull(method); - Assert.True(method.IsEventAccessor(out var eventName, out var subscription)); - Assert.Equal(nameof(ISampleInterface.SimpleEvent), eventName); - Assert.True(subscription); - - method = typeof(ISampleInterface).GetMethod("remove_CancelEvent"); - Assert.NotNull(method); - Assert.True(method.IsEventAccessor(out eventName, out subscription)); - Assert.Equal(nameof(ISampleInterface.CancelEvent), eventName); - Assert.False(subscription); - - method = typeof(ISampleInterface).GetMethod(nameof(ISampleInterface.FireHandlers)); - Assert.NotNull(method); - Assert.False(method.IsEventAccessor(out eventName, out subscription)); - Assert.Null(eventName); - Assert.False(subscription); - } -} diff --git a/CoreRemoting.Tests/RemoteDelegates/ClientDelegateRegistryTests.cs b/CoreRemoting.Tests/RemoteDelegates/ClientDelegateRegistryTests.cs new file mode 100644 index 00000000..355d51f6 --- /dev/null +++ b/CoreRemoting.Tests/RemoteDelegates/ClientDelegateRegistryTests.cs @@ -0,0 +1,347 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading.Tasks; +using CoreRemoting.RemoteDelegates; +using Xunit; + +namespace CoreRemoting.Tests.RemoteDelegates; + +public class ClientDelegateRegistryTests +{ + private sealed class FakeProxy + { + } + + [Fact] + public void Register_returns_non_empty_guid() + { + var registry = new ClientDelegateRegistry(); + Action handler = () => { }; + var proxy = new FakeProxy(); + + var key = registry.RegisterClientDelegate(handler, proxy); + + Assert.NotEqual(Guid.Empty, key); + } + + [Fact] + public void Register_throws_on_null_delegate() + { + var registry = new ClientDelegateRegistry(); + + Assert.Throws(() => + registry.RegisterClientDelegate(null, new FakeProxy())); + } + + [Fact] + public void Register_throws_on_null_service_proxy() + { + var registry = new ClientDelegateRegistry(); + Action handler = () => { }; + + Assert.Throws(() => + registry.RegisterClientDelegate(handler, null)); + } + + [Fact] + public void Register_same_delegate_for_same_proxy_returns_same_guid() + { + var registry = new ClientDelegateRegistry(); + Action handler = () => { }; + var proxy = new FakeProxy(); + + var key1 = registry.RegisterClientDelegate(handler, proxy); + var key2 = registry.RegisterClientDelegate(handler, proxy); + + Assert.Equal(key1, key2); + } + + [Fact] + public void Register_same_delegate_for_different_proxies_returns_different_guids() + { + // The same delegate subscribed to events of two different proxies + // must be tracked as two independent registrations. + var registry = new ClientDelegateRegistry(); + Action handler = () => { }; + var proxy1 = new FakeProxy(); + var proxy2 = new FakeProxy(); + + var key1 = registry.RegisterClientDelegate(handler, proxy1); + var key2 = registry.RegisterClientDelegate(handler, proxy2); + + Assert.NotEqual(key1, key2); + } + + [Fact] + public void Register_different_delegates_returns_different_guids() + { + var registry = new ClientDelegateRegistry(); + Action handler1 = () => { }; + Action handler2 = () => { }; + var proxy = new FakeProxy(); + + var key1 = registry.RegisterClientDelegate(handler1, proxy); + var key2 = registry.RegisterClientDelegate(handler2, proxy); + + Assert.NotEqual(key1, key2); + } + + [Fact] + public void Register_different_delegates_of_same_method_returns_different_guids() + { + var registry = new ClientDelegateRegistry(); + var target1 = new CallbackTarget(); + var target2 = new CallbackTarget(); + var proxy = new FakeProxy(); + + // Same method name, different targets -> different delegates. + var key1 = registry.RegisterClientDelegate(target1.OnEvent, proxy); + var key2 = registry.RegisterClientDelegate(target2.OnEvent, proxy); + + Assert.NotEqual(key1, key2); + } + + [Fact] + public void GetDelegateByHandlerKey_returns_registered_delegate() + { + var registry = new ClientDelegateRegistry(); + Action handler = () => { }; + var proxy = new FakeProxy(); + + var key = registry.RegisterClientDelegate(handler, proxy); + var retrieved = registry.GetDelegateByHandlerKey(key); + + Assert.Same(handler, retrieved); + } + + [Fact] + public void GetDelegateByHandlerKey_returns_null_for_unknown_key() + { + var registry = new ClientDelegateRegistry(); + + var retrieved = registry.GetDelegateByHandlerKey(Guid.NewGuid()); + + Assert.Null(retrieved); + } + + [Fact] + public void UnregisterClientDelegatesOfServiceProxy_removes_only_matching_ones() + { + var registry = new ClientDelegateRegistry(); + var proxy1 = new FakeProxy(); + var proxy2 = new FakeProxy(); + + Action handler1 = () => { }; + Action handler2 = () => { }; + Action handler3 = () => { }; + + var key1 = registry.RegisterClientDelegate(handler1, proxy1); + var key2 = registry.RegisterClientDelegate(handler2, proxy1); + var key3 = registry.RegisterClientDelegate(handler3, proxy2); + + var removed = registry.UnregisterClientDelegatesOfServiceProxy(proxy1); + + Assert.Equal(2, removed.Length); + Assert.Contains(key1, removed); + Assert.Contains(key2, removed); + Assert.DoesNotContain(key3, removed); + + Assert.Null(registry.GetDelegateByHandlerKey(key1)); + Assert.Null(registry.GetDelegateByHandlerKey(key2)); + Assert.Same(handler3, registry.GetDelegateByHandlerKey(key3)); + } + + [Fact] + public void UnregisterClientDelegatesOfServiceProxy_keeps_same_delegate_on_other_proxy() + { + // Regression test: the same delegate subscribed to two proxies must + // survive the shutdown of one of them. + var registry = new ClientDelegateRegistry(); + var proxy1 = new FakeProxy(); + var proxy2 = new FakeProxy(); + + Action handler = () => { }; + + var key1 = registry.RegisterClientDelegate(handler, proxy1); + var key2 = registry.RegisterClientDelegate(handler, proxy2); + + var removed = registry.UnregisterClientDelegatesOfServiceProxy(proxy1); + + Assert.Equal([key1], removed); + Assert.Null(registry.GetDelegateByHandlerKey(key1)); + Assert.Same(handler, registry.GetDelegateByHandlerKey(key2)); + } + + [Fact] + public void UnregisterClientDelegatesOfServiceProxy_with_null_is_noop() + { + var registry = new ClientDelegateRegistry(); + Action handler = () => { }; + var proxy = new FakeProxy(); + + var key = registry.RegisterClientDelegate(handler, proxy); + var removed = registry.UnregisterClientDelegatesOfServiceProxy(null); + + Assert.Empty(removed); + Assert.Same(handler, registry.GetDelegateByHandlerKey(key)); + } + + [Fact] + public void UnregisterClientDelegatesOfServiceProxy_for_unknown_proxy_is_noop() + { + var registry = new ClientDelegateRegistry(); + Action handler = () => { }; + var proxy = new FakeProxy(); + + var key = registry.RegisterClientDelegate(handler, proxy); + var removed = registry.UnregisterClientDelegatesOfServiceProxy(new FakeProxy()); + + Assert.Empty(removed); + Assert.Same(handler, registry.GetDelegateByHandlerKey(key)); + } + + [Fact] + public void UnregisterClientDelegatesOfServiceProxy_returns_empty_for_empty_registry() + { + var registry = new ClientDelegateRegistry(); + + var removed = registry.UnregisterClientDelegatesOfServiceProxy(new FakeProxy()); + + Assert.Empty(removed); + } + + [Fact] + public void UnregisterClientDelegatesOfServiceProxy_allows_reregistration_with_new_key() + { + // After unregistering, the reverse index entry must be cleared too, + // so the same delegate + proxy pair produces a fresh handler key. + var registry = new ClientDelegateRegistry(); + Action handler = () => { }; + var proxy = new FakeProxy(); + + var key1 = registry.RegisterClientDelegate(handler, proxy); + + var removed = registry.UnregisterClientDelegatesOfServiceProxy(proxy); + Assert.Equal([key1], removed); + + var key2 = registry.RegisterClientDelegate(handler, proxy); + + Assert.NotEqual(key1, key2); + Assert.Same(handler, registry.GetDelegateByHandlerKey(key2)); + } + + [Fact] + public void UnregisterClientDelegatesOfServiceProxy_returns_all_keys_for_multicast_subscriptions() + { + // Same proxy, many distinct delegates (multicast-style setup): all keys + // must be returned, so the caller can send them to the server in one batch. + var registry = new ClientDelegateRegistry(); + var proxy = new FakeProxy(); + + Action handler1 = () => { }; + Action handler2 = () => { }; + Action handler3 = () => { }; + + var key1 = registry.RegisterClientDelegate(handler1, proxy); + var key2 = registry.RegisterClientDelegate(handler2, proxy); + var key3 = registry.RegisterClientDelegate(handler3, proxy); + + var removed = registry.UnregisterClientDelegatesOfServiceProxy(proxy); + + Assert.Equal(3, removed.Length); + Assert.Contains(key1, removed); + Assert.Contains(key2, removed); + Assert.Contains(key3, removed); + } + + [Fact] + public async Task UnregisterClientDelegatesOfServiceProxy_concurrent_with_register_does_not_throw() + { + // Regression smoke test: a shutdown racing with a fresh subscription must + // not corrupt either dictionary. The exact outcome (how many survive) is + // unspecified — this test only asserts that neither side crashes and + // that after everything settles, the removed keys are no longer resolvable. + var registry = new ClientDelegateRegistry(); + var proxy = new FakeProxy(); + + var unregistered = new ConcurrentBag(); + + var registers = Enumerable.Range(0, 50).Select(_ => Task.Run(() => + { + Action handler = () => { }; + registry.RegisterClientDelegate(handler, proxy); + })); + + var unregisters = Enumerable.Range(0, 50).Select(_ => Task.Run(() => + { + foreach (var key in registry.UnregisterClientDelegatesOfServiceProxy(proxy)) + unregistered.Add(key); + })); + + await Task.WhenAll(registers.Concat(unregisters)); + + foreach (var key in unregistered) + Assert.Null(registry.GetDelegateByHandlerKey(key)); + } + + [Fact] + public void Clear_removes_all_registrations() + { + var registry = new ClientDelegateRegistry(); + var proxy = new FakeProxy(); + + Action handler1 = () => { }; + Action handler2 = () => { }; + + var key1 = registry.RegisterClientDelegate(handler1, proxy); + var key2 = registry.RegisterClientDelegate(handler2, proxy); + + registry.Clear(); + + Assert.Null(registry.GetDelegateByHandlerKey(key1)); + Assert.Null(registry.GetDelegateByHandlerKey(key2)); + } + + [Fact] + public async Task Register_same_delegate_concurrently_returns_same_guid() + { + var registry = new ClientDelegateRegistry(); + Action handler = () => { }; + var proxy = new FakeProxy(); + var keys = new ConcurrentBag(); + + await Task.WhenAll( + Enumerable.Range(0, 100).Select(_ => Task.Run(() => + keys.Add(registry.RegisterClientDelegate(handler, proxy))))); + + Assert.Single(keys.Distinct()); + } + + [Fact] + public async Task Register_same_delegate_concurrently_for_two_proxies_returns_two_guids() + { + var registry = new ClientDelegateRegistry(); + Action handler = () => { }; + var proxy1 = new FakeProxy(); + var proxy2 = new FakeProxy(); + var keys = new ConcurrentBag(); + + await Task.WhenAll( + Enumerable.Range(0, 50).SelectMany(_ => new[] + { + Task.Run(() => keys.Add(registry.RegisterClientDelegate(handler, proxy1))), + Task.Run(() => keys.Add(registry.RegisterClientDelegate(handler, proxy2))), + })); + + // Two distinct guids, 50 occurrences each. + Assert.Equal(2, keys.Distinct().Count()); + } + + private sealed class CallbackTarget + { + public void OnEvent() + { + } + } +} diff --git a/CoreRemoting.Tests/RemoteDelegates/DelegateExtensionsTests.cs b/CoreRemoting.Tests/RemoteDelegates/DelegateExtensionsTests.cs new file mode 100644 index 00000000..b3163102 --- /dev/null +++ b/CoreRemoting.Tests/RemoteDelegates/DelegateExtensionsTests.cs @@ -0,0 +1,69 @@ +using System; +using CoreRemoting.RemoteDelegates; +using CoreRemoting.Toolbox; +using Xunit; + +namespace CoreRemoting.Tests.RemoteDelegates; + +public class DelegateExtensionsTests +{ + [Fact] + public void IsDisposed_returns_true_for_null() + { + Delegate del = null; + + Assert.True(del.IsDisposed()); + } + + [Fact] + public void IsDisposed_returns_false_for_regular_delegate() + { + Action del = () => { }; + + Assert.False(del.IsDisposed()); + } + + [Fact] + public void IsDisposed_returns_false_for_multicast_delegate() + { + Action a = () => { }; + Action b = () => { }; + var combined = a + b; + + Assert.False(combined.IsDisposed()); + } + + [Fact] + public void IsDisposed_returns_false_for_live_proxy_delegate() + { + var factory = new DelegateProxyFactory(); + var proxy = factory.Create(typeof(Action), _ => null); + + Assert.False(proxy.ProxiedDelegate.IsDisposed()); + } + + [Fact] + public void IsDisposed_returns_true_for_disposed_proxy_delegate() + { + var factory = new DelegateProxyFactory(); + var proxy = factory.Create(typeof(Action), _ => null); + var del = proxy.ProxiedDelegate; + + proxy.Dispose(); + + Assert.True(del.IsDisposed()); + } + + [Fact] + public void IsDisposed_returns_false_for_combined_delegate_with_proxied_part() + { + // multicast containing a proxy — conservatively reported as not disposed + var factory = new DelegateProxyFactory(); + var proxy = factory.Create(typeof(Action), _ => null); + Action regular = () => { }; + + var combined = (Action)proxy.ProxiedDelegate + regular; + + Assert.False(combined.IsDisposed()); + } +} diff --git a/CoreRemoting.Tests/RemoteDelegates/DelegateProxyTests.cs b/CoreRemoting.Tests/RemoteDelegates/DelegateProxyTests.cs new file mode 100644 index 00000000..25929d8c --- /dev/null +++ b/CoreRemoting.Tests/RemoteDelegates/DelegateProxyTests.cs @@ -0,0 +1,600 @@ +using System; +using System.Threading.Tasks; +using CoreRemoting.RemoteDelegates; +using Xunit; + +namespace CoreRemoting.Tests.RemoteDelegates; + +public class DelegateProxyTests +{ + // --- Factory validation --- + + [Fact] + public void Create_throws_on_null_delegate_type() + { + var factory = new DelegateProxyFactory(); + + Assert.Throws(() => factory.Create(null, _ => null)); + } + + [Fact] + public void Create_throws_on_non_delegate_type() + { + var factory = new DelegateProxyFactory(); + + Assert.Throws(() => factory.Create(typeof(string), _ => null)); + } + + [Fact] + public void Create_throws_on_null_handler() + { + var factory = new DelegateProxyFactory(); + + Assert.Throws(() => factory.Create(typeof(Action), null)); + } + + // --- Delegate shape --- + + [Fact] + public void ProxiedDelegate_has_correct_type() + { + var factory = new DelegateProxyFactory(); + var proxy = factory.Create(typeof(EventHandler), _ => null); + + Assert.IsType(proxy.ProxiedDelegate); + } + + [Fact] + public void ProxiedDelegate_target_is_the_proxy_instance() + { + var factory = new DelegateProxyFactory(); + var proxy = factory.Create(typeof(Action), _ => null); + + Assert.Same(proxy, proxy.ProxiedDelegate.Target); + } + + // --- Argument passing --- + + [Fact] + public void ProxiedDelegate_invokes_handler_with_empty_array_for_no_params() + { + var factory = new DelegateProxyFactory(); + object[] captured = null; + var proxy = factory.Create(typeof(Action), args => { captured = args; return null; }); + + ((Action)proxy.ProxiedDelegate)(); + + Assert.NotNull(captured); + Assert.Empty(captured); + } + + [Fact] + public void ProxiedDelegate_passes_arguments_in_order() + { + var factory = new DelegateProxyFactory(); + object[] captured = null; + var proxy = factory.Create(typeof(Action), args => { captured = args; return null; }); + + ((Action)proxy.ProxiedDelegate)(42, "hello", true); + + Assert.NotNull(captured); + Assert.Equal(3, captured.Length); + Assert.Equal(42, captured[0]); + Assert.Equal("hello", captured[1]); + Assert.Equal(true, captured[2]); + } + + [Fact] + public void ProxiedDelegate_boxes_value_type_arguments() + { + var factory = new DelegateProxyFactory(); + object captured = null; + var proxy = factory.Create(typeof(Action), args => { captured = args[0]; return null; }); + + ((Action)proxy.ProxiedDelegate)(42); + + Assert.IsType(captured); + Assert.Equal(42, captured); + } + + [Fact] + public void ProxiedDelegate_preserves_null_arguments() + { + var factory = new DelegateProxyFactory(); + object captured = "sentinel"; + var proxy = factory.Create(typeof(Action), args => { captured = args[0]; return null; }); + + ((Action)proxy.ProxiedDelegate)(null); + + Assert.Null(captured); + } + + // --- Return value passing --- + + [Fact] + public void ProxiedDelegate_returns_handler_value_for_reference_return() + { + var factory = new DelegateProxyFactory(); + var proxy = factory.Create(typeof(Func), _ => "hello"); + + var result = ((Func)proxy.ProxiedDelegate)(); + + Assert.Equal("hello", result); + } + + [Fact] + public void ProxiedDelegate_unboxes_value_type_return_value() + { + var factory = new DelegateProxyFactory(); + var proxy = factory.Create(typeof(Func), _ => 42); + + var result = ((Func)proxy.ProxiedDelegate)(); + + Assert.IsType(result); + Assert.Equal(42, result); + } + + [Fact] + public void ProxiedDelegate_throws_when_handler_returns_wrong_type_for_value_return() + { + var factory = new DelegateProxyFactory(); + var proxy = factory.Create(typeof(Func), _ => "not an int"); + + Assert.Throws(() => ((Func)proxy.ProxiedDelegate)()); + } + + [Fact] + public async Task ProxiedDelegate_supports_async_returns() + { + var factory = new DelegateProxyFactory(); + var proxy = factory.Create(typeof(Func>), _ => Task.FromResult("hello")); + + var result = await ((Func>)proxy.ProxiedDelegate)(); + + Assert.Equal("hello", result); + } + + // --- Independence --- + + [Fact] + public void Multiple_proxies_from_same_factory_are_independent() + { + var factory = new DelegateProxyFactory(); + var invokedA = false; + var invokedB = false; + var proxyA = factory.Create(typeof(Action), _ => { invokedA = true; return null; }); + var proxyB = factory.Create(typeof(Action), _ => { invokedB = true; return null; }); + + ((Action)proxyA.ProxiedDelegate)(); + + Assert.True(invokedA); + Assert.False(invokedB); + } + + [Fact] + public void Proxies_of_different_types_can_coexist() + { + var factory = new DelegateProxyFactory(); + var proxy1 = factory.Create(typeof(Action), _ => null); + var proxy2 = factory.Create(typeof(Func), _ => 42); + var proxy3 = factory.Create(typeof(EventHandler), _ => null); + + Assert.IsType(proxy1.ProxiedDelegate); + Assert.IsType>(proxy2.ProxiedDelegate); + Assert.IsType(proxy3.ProxiedDelegate); + } + + // --- Dispose behavior --- + + [Fact] + public void Dispose_clears_proxied_delegate() + { + var factory = new DelegateProxyFactory(); + var proxy = factory.Create(typeof(Action), _ => null); + + proxy.Dispose(); + + Assert.Null(proxy.ProxiedDelegate); + } + + // --- Dispose: no-op behavior --- + + [Fact] + public void Invoking_void_delegate_after_dispose_is_noop() + { + var factory = new DelegateProxyFactory(); + var invoked = false; + var proxy = factory.Create(typeof(Action), _ => { invoked = true; return null; }); + var del = (Action)proxy.ProxiedDelegate; + del(); + Assert.True(invoked); + + invoked = false; + proxy.Dispose(); + + var ex = Record.Exception(() => del()); + + Assert.Null(ex); + Assert.False(invoked); // handler must not be called after dispose + } + + [Fact] + public void Invoking_value_returning_delegate_after_dispose_returns_default() + { + var factory = new DelegateProxyFactory(); + var proxy = factory.Create(typeof(Func), _ => 42); + var del = (Func)proxy.ProxiedDelegate; + Assert.Equal(42, del()); + + proxy.Dispose(); + + Assert.Equal(0, del()); + } + + [Fact] + public void Invoking_reference_returning_delegate_after_dispose_returns_null() + { + var factory = new DelegateProxyFactory(); + var proxy = factory.Create(typeof(Func), _ => "hello"); + var del = (Func)proxy.ProxiedDelegate; + Assert.Equal("hello", del()); + + proxy.Dispose(); + + Assert.Null(del()); + } + + [Fact] + public void Invoking_nullable_returning_delegate_after_dispose_returns_null() + { + var factory = new DelegateProxyFactory(); + var proxy = factory.Create(typeof(Func), _ => 42); + var del = (Func)proxy.ProxiedDelegate; + Assert.Equal(42, del()); + + proxy.Dispose(); + + Assert.False(del().HasValue); + } + + // --- IsDisposed --- + + [Fact] + public void IsDisposed_is_false_before_dispose() + { + var factory = new DelegateProxyFactory(); + var proxy = factory.Create(typeof(Action), _ => null); + + Assert.False(proxy.IsDisposed); + } + + [Fact] + public void IsDisposed_is_true_after_dispose() + { + var factory = new DelegateProxyFactory(); + var proxy = factory.Create(typeof(Action), _ => null); + + proxy.Dispose(); + + Assert.True(proxy.IsDisposed); + } + + [Fact] + public void Dispose_can_be_called_multiple_times() + { + var factory = new DelegateProxyFactory(); + var proxy = factory.Create(typeof(Action), _ => null); + + proxy.Dispose(); + proxy.Dispose(); + proxy.Dispose(); + } + + // --- Exception propagation --- + + [Fact] + public void ProxiedDelegate_propagates_handler_exception_without_wrapping() + { + var factory = new DelegateProxyFactory(); + var proxy = factory.Create(typeof(Action), _ => throw new InvalidOperationException("boom")); + + var ex = Assert.Throws(() => ((Action)proxy.ProxiedDelegate)()); + + Assert.Equal("boom", ex.Message); + } + + [Fact] + public void ProxiedDelegate_propagates_handler_exception_for_value_return_without_wrapping() + { + var factory = new DelegateProxyFactory(); + var proxy = factory.Create(typeof(Func), _ => throw new InvalidOperationException("boom")); + + var ex = Assert.Throws(() => ((Func)proxy.ProxiedDelegate)()); + + Assert.Equal("boom", ex.Message); + } + + // --- Repeated invocation --- + + [Fact] + public void ProxiedDelegate_can_be_invoked_multiple_times() + { + var factory = new DelegateProxyFactory(); + var count = 0; + var proxy = factory.Create(typeof(Action), _ => { count++; return null; }); + var del = (Action)proxy.ProxiedDelegate; + + del(); + del(); + del(); + + Assert.Equal(3, count); + } + + // --- Reference return: null value --- + + [Fact] + public void ProxiedDelegate_returns_null_for_reference_return() + { + var factory = new DelegateProxyFactory(); + var proxy = factory.Create(typeof(Func), _ => null); + + Assert.Null(((Func)proxy.ProxiedDelegate)()); + } + + // --- Nullable --- + + [Fact] + public void ProxiedDelegate_unboxes_nullable_value_return() + { + var factory = new DelegateProxyFactory(); + var proxy = factory.Create(typeof(Func), _ => 42); + + var result = ((Func)proxy.ProxiedDelegate)(); + + Assert.True(result.HasValue); + Assert.Equal(42, result.Value); + } + + [Fact] + public void ProxiedDelegate_returns_null_nullable_for_null_handler_result() + { + var factory = new DelegateProxyFactory(); + var proxy = factory.Create(typeof(Func), _ => null); + + var result = ((Func)proxy.ProxiedDelegate)(); + + Assert.False(result.HasValue); + } + + // --- Enum --- + + [Fact] + public void ProxiedDelegate_boxes_enum_arguments() + { + var factory = new DelegateProxyFactory(); + object captured = null; + var proxy = factory.Create(typeof(Action), args => { captured = args[0]; return null; }); + + ((Action)proxy.ProxiedDelegate)(DayOfWeek.Wednesday); + + Assert.Equal(DayOfWeek.Wednesday, captured); + } + + [Fact] + public void ProxiedDelegate_unboxes_enum_return() + { + var factory = new DelegateProxyFactory(); + var proxy = factory.Create(typeof(Func), _ => DayOfWeek.Wednesday); + + var result = ((Func)proxy.ProxiedDelegate)(); + + Assert.Equal(DayOfWeek.Wednesday, result); + } + + // --- Non-generic Task identity --- + + [Fact] + public async Task ProxiedDelegate_returns_task_as_is() + { + var factory = new DelegateProxyFactory(); + var tcs = new TaskCompletionSource(); + var proxy = factory.Create(typeof(Func), _ => tcs.Task); + + var returnedTask = ((Func)proxy.ProxiedDelegate)(); + + Assert.Same(tcs.Task, returnedTask); + + tcs.SetResult(true); + await returnedTask; + } + + // --- Reference-type interface parameter --- + + [Fact] + public void ProxiedDelegate_passes_interface_arguments_without_boxing() + { + var factory = new DelegateProxyFactory(); + object captured = null; + var disposable = new System.IO.MemoryStream(); + var proxy = factory.Create(typeof(Action), args => { captured = args[0]; return null; }); + + ((Action)proxy.ProxiedDelegate)(disposable); + + Assert.Same(disposable, captured); + } + + // --- Edge Cases and IL Coverage --- + + [Fact] + public void ProxiedDelegate_pops_handler_value_when_delegate_return_is_void() + { + var factory = new DelegateProxyFactory(); + // Delegate is Action (void), but the handler returns a string. + // This ensures the IL emits OpCodes.Pop correctly without stack corruption. + var proxy = factory.Create(typeof(Action), _ => "this value should be popped"); + var del = (Action)proxy.ProxiedDelegate; + + var ex = Record.Exception(() => del()); + + Assert.Null(ex); + } + + [Fact] + public void ProxiedDelegate_handles_many_arguments_correctly() + { + var factory = new DelegateProxyFactory(); + object[] captured = null; + // Delegate with 8 arguments of different types to test index generation and array population. + var proxy = factory.Create(typeof(Action), + args => { captured = args; return null; }); + + ((Action)proxy.ProxiedDelegate) + (1, "2", true, 4.0, '5', 6, 7, 8L); + + Assert.NotNull(captured); + Assert.Equal(8, captured.Length); + Assert.Equal(1, captured[0]); + Assert.Equal("2", captured[1]); + Assert.Equal(8L, captured[7]); + } + + [Fact] + public void ProxiedDelegate_boxes_and_unboxes_complex_value_types() + { + var factory = new DelegateProxyFactory(); + var originalGuid = Guid.NewGuid(); + object capturedArg = null; + + var proxy = factory.Create(typeof(Action), args => { capturedArg = args[0]; return null; }); + ((Action)proxy.ProxiedDelegate)(originalGuid); + + Assert.IsType(capturedArg); + Assert.Equal(originalGuid, capturedArg); + + var returnProxy = factory.Create(typeof(Func), _ => originalGuid); + var result = ((Func)returnProxy.ProxiedDelegate)(); + + Assert.Equal(originalGuid, result); + } + + [Fact] + public void ProxiedDelegate_preserves_array_instance_argument() + { + var factory = new DelegateProxyFactory(); + object[] captured = null; + var proxy = factory.Create(typeof(Action), args => { captured = args; return null; }); + + var myArray = new object[] { 1, 2, 3 }; + ((Action)proxy.ProxiedDelegate)("test", myArray); + + Assert.NotNull(captured); + Assert.Equal(2, captured.Length); + Assert.Equal("test", captured[0]); + Assert.Same(myArray, captured[1]); // Must be the exact same array instance. + } + + [Fact] + public void ProxiedDelegate_can_be_combined_with_other_delegates() + { + var factory = new DelegateProxyFactory(); + var proxyInvoked = false; + var regularInvoked = false; + + var proxy = factory.Create(typeof(Action), _ => { proxyInvoked = true; return null; }); + Action regularAction = () => regularInvoked = true; + + // Combine the proxy and a regular delegate (multicast). + Action combined = (Action)proxy.ProxiedDelegate + regularAction; + + combined(); + + Assert.True(proxyInvoked); + Assert.True(regularInvoked); + } + + // --- Unsupported signatures: ref/out/in parameters --- + + [Fact] + public void Create_throws_on_delegate_with_ref_parameter() + { + var factory = new DelegateProxyFactory(); + + Assert.Throws(() => + factory.Create(typeof(RefAction), _ => null)); + } + + [Fact] + public void Create_throws_on_delegate_with_out_parameter() + { + var factory = new DelegateProxyFactory(); + + Assert.Throws(() => + factory.Create(typeof(OutAction), _ => null)); + } + + [Fact] + public void Create_throws_on_delegate_with_in_parameter() + { + var factory = new DelegateProxyFactory(); + + Assert.Throws(() => + factory.Create(typeof(InAction), _ => null)); + } + + // --- Unsupported signatures: open generic delegate types --- + + [Fact] + public void Create_throws_on_open_generic_delegate_type() + { + var factory = new DelegateProxyFactory(); + + Assert.Throws(() => + factory.Create(typeof(Action<>), _ => null)); + } + + // Helper delegate types for unsupported signature tests + + private delegate void RefAction(ref int value); + private delegate void OutAction(out int value); + private delegate void InAction(in int value); + + // --- Dynamic method caching --- + + [Fact] + public void Factory_reuses_dynamic_method_for_same_delegate_type() + { + var factory = new DelegateProxyFactory(); + + var proxy1 = (DelegateProxy)factory.Create(typeof(Action), _ => null); + var proxy2 = (DelegateProxy)factory.Create(typeof(Action), _ => null); + + Assert.Same(proxy1.DynamicMethod, proxy2.DynamicMethod); + } + + [Fact] + public void Factory_caches_dynamic_method_even_when_handlers_differ() + { + var factory = new DelegateProxyFactory(); + + var proxy1 = (DelegateProxy)factory.Create(typeof(Action), _ => null); + var proxy2 = (DelegateProxy)factory.Create(typeof(Action), _ => { throw new InvalidOperationException(); }); + + Assert.Same(proxy1.DynamicMethod, proxy2.DynamicMethod); + Assert.NotSame(proxy1, proxy2); + } + + [Fact] + public void Factory_creates_separate_dynamic_methods_for_different_delegate_types() + { + var factory = new DelegateProxyFactory(); + + var action = (DelegateProxy)factory.Create(typeof(Action), _ => null); + var funcInt = (DelegateProxy)factory.Create(typeof(Func), _ => null); + var eventHandler = (DelegateProxy)factory.Create(typeof(EventHandler), _ => null); + + Assert.NotSame(action.DynamicMethod, funcInt.DynamicMethod); + Assert.NotSame(action.DynamicMethod, eventHandler.DynamicMethod); + Assert.NotSame(funcInt.DynamicMethod, eventHandler.DynamicMethod); + } +} diff --git a/CoreRemoting.Tests/RemoteDelegates/EventDispatcherTests.cs b/CoreRemoting.Tests/RemoteDelegates/EventDispatcherTests.cs new file mode 100644 index 00000000..296fd3f6 --- /dev/null +++ b/CoreRemoting.Tests/RemoteDelegates/EventDispatcherTests.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using CoreRemoting.Channels; +using CoreRemoting.Channels.Null; +using CoreRemoting.RemoteDelegates; +using CoreRemoting.Tests.Tools; +using CoreRemoting.Threading; +using CoreRemoting.Toolbox; +using Xunit; +using Xunit.Abstractions; + +namespace CoreRemoting.Tests.RemoteDelegates; + +public partial class EventDispatcherTests : RpcTests_Base +{ + public EventDispatcherTests(ServerFixture serverFixture, ITestOutputHelper testOutputHelper) + : base(serverFixture, testOutputHelper) + { + } + + protected override IServerChannel ServerChannel => new NullServerChannel(); + + protected override IClientChannel ClientChannel => new NullClientChannel(); + + // Used to test the "unregistered service" path. Not registered anywhere. + public interface INotRegisteredService + { + event Action SomeEvent; + } + + private IEventDispatcher GetDispatcher() => + _serverFixture.Server.ServiceRegistry.GetService(); + + private RemotingClient CreateConnectedClient() + { + var client = new RemotingClient(new ClientConfig + { + ConnectionTimeout = 0, + InvocationTimeout = 0, + SendTimeout = 0, + Channel = ClientChannel, + MessageEncryption = false, + ServerPort = _serverFixture.Server.Config.NetworkPort, + }); + + client.Connect(); + return client; + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait(false) in test method", Justification = "")] + public async Task Dispatcher_invokes_event_via_generic_overload() + { + using var ctx = ValidationSyncContext.Install(); + + using var client = CreateConnectedClient(); + var proxy = client.CreateProxy(); + + var fired = new AsyncCounter(); + proxy.ServiceEvent += () => fired++; + + // Ensure the service and its event hub are materialized on the server. + proxy.Echo("warmup"); + + GetDispatcher().Invoke(s => nameof(s.ServiceEvent)); + + await fired.WaitForValue(1).Timeout(1).ConfigureAwait(false); + CheckServerErrorCount(); + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait(false) in test method", Justification = "")] + public async Task Dispatcher_invokes_event_via_string_overload() + { + using var ctx = ValidationSyncContext.Install(); + + using var client = CreateConnectedClient(); + var proxy = client.CreateProxy(); + + var fired = new AsyncCounter(); + proxy.ServiceEvent += () => fired++; + + proxy.Echo("warmup"); + + GetDispatcher().Invoke( + typeof(ITestService).FullName, + nameof(ITestService.ServiceEvent)); + + await fired.WaitForValue(1).Timeout(1).ConfigureAwait(false); + CheckServerErrorCount(); + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait(false) in test method", Justification = "")] + public async Task Dispatcher_passes_arguments_to_event_handlers() + { + using var ctx = ValidationSyncContext.Install(); + + using var client = CreateConnectedClient(); + var proxy = client.CreateProxy(); + + object received = null; + var fired = new AsyncCounter(); + + proxy.CustomDelegateEvent += sender => + { + received = sender; + fired++; + }; + + proxy.Echo("warmup"); + + GetDispatcher().Invoke( + s => nameof(s.CustomDelegateEvent), "hello"); + + await fired.WaitForValue(1).Timeout(1).ConfigureAwait(false); + Assert.Equal("hello", received); + CheckServerErrorCount(); + } + + [Theory] + [InlineData("TestService_Singleton_Service")] + [InlineData("TestService_Singleton_Factory")] + [InlineData("TestService_SingleCall_Service")] + [InlineData("TestService_SingleCall_Factory")] + [InlineData("TestService_Scoped_Service")] + [InlineData("TestService_Scoped_Factory")] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait(false) in test method", Justification = "")] + public async Task Dispatcher_can_invoke_events_for_any_service_lifetime(string serviceName) + { + using var ctx = ValidationSyncContext.Install(); + + using var client = CreateConnectedClient(); + var proxy = client.CreateProxy(serviceName); + + var fired = new AsyncCounter(); + proxy.ServiceEvent += () => fired++; + + proxy.Echo("warmup"); + + GetDispatcher().Invoke(serviceName, nameof(ITestService.ServiceEvent)); + + await fired.WaitForValue(1).Timeout(1).ConfigureAwait(false); + CheckServerErrorCount(); + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait(false) in test method", Justification = "")] + public async Task Dispatcher_doesnt_throw_if_there_are_no_subscribers() + { + using var ctx = ValidationSyncContext.Install(); + + using var client = CreateConnectedClient(); + var proxy = client.CreateProxy(); + + // No subscribers. Warmup materializes the service and its event hub. + proxy.Echo("warmup"); + + GetDispatcher().Invoke(s => nameof(s.ServiceEvent)); + + // Give the server a moment to settle + await Task.Delay(100).ConfigureAwait(false); + + CheckServerErrorCount(); + } + + [Fact] + public void Dispatcher_throws_for_unregistered_service() + { + using var ctx = ValidationSyncContext.Install(); + + using var client = CreateConnectedClient(); + + var dispatcher = GetDispatcher(); + + Assert.Throws(() => + dispatcher.Invoke(s => nameof(s.SomeEvent))); + } + + [Fact] + public void Dispatcher_throws_for_unregistered_event() + { + using var ctx = ValidationSyncContext.Install(); + + using var client = CreateConnectedClient(); + var proxy = client.CreateProxy(); + + // Subscribe and warmup so the event hub definitely exists on the server. + proxy.ServiceEvent += () => { }; + proxy.Echo("warmup"); + + var dispatcher = GetDispatcher(); + + Assert.Throws(() => + dispatcher.Invoke(s => "NoSuchEvent")); + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait(false) in test method", Justification = "")] + public async Task Service_can_inject_dispatcher_and_relay_another_service_event() + { + using var ctx = ValidationSyncContext.Install(); + + using var client = CreateConnectedClient(); + var testProxy = client.CreateProxy(); + var relayProxy = client.CreateProxy(); + + var fired = new AsyncCounter(); + testProxy.ServiceEvent += () => fired++; + + // Materialize the target service on the server before relaying its event. + testProxy.Echo("warmup"); + + relayProxy.RelayTestServiceEvent(); + + await fired.WaitForValue(1).Timeout(1).ConfigureAwait(false); + CheckServerErrorCount(); + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait(false) in test method", Justification = "")] + public async Task Service_can_inject_dispatcher_and_relay_event_by_name() + { + using var ctx = ValidationSyncContext.Install(); + + using var client = CreateConnectedClient(); + var testProxy = client.CreateProxy(); + var relayProxy = client.CreateProxy(); + + var fired = new AsyncCounter(); + testProxy.ServiceEvent += () => fired++; + + testProxy.Echo("warmup"); + + relayProxy.RelayTestServiceEventByName(); + + await fired.WaitForValue(1).Timeout(1).ConfigureAwait(false); + CheckServerErrorCount(); + } +} \ No newline at end of file diff --git a/CoreRemoting.Tests/RemoteDelegates/EventDispatcherTests_Channels.cs b/CoreRemoting.Tests/RemoteDelegates/EventDispatcherTests_Channels.cs new file mode 100644 index 00000000..f76579c6 --- /dev/null +++ b/CoreRemoting.Tests/RemoteDelegates/EventDispatcherTests_Channels.cs @@ -0,0 +1,31 @@ +using CoreRemoting.Channels; +using CoreRemoting.Channels.Quic; +using CoreRemoting.Channels.Tcp; +using CoreRemoting.Channels.Websocket; +using Xunit.Abstractions; + +namespace CoreRemoting.Tests.RemoteDelegates; + +public partial class EventDispatcherTests_WebSockets(ServerFixture serverFixture, ITestOutputHelper testOutputHelper) + : EventDispatcherTests(serverFixture, testOutputHelper) +{ + protected override IServerChannel ServerChannel => new WebsocketServerChannel(); + + protected override IClientChannel ClientChannel => new WebsocketClientChannel(); +} + +public partial class EventDispatcherTests_Quic(ServerFixture serverFixture, ITestOutputHelper testOutputHelper) + : EventDispatcherTests(serverFixture, testOutputHelper) +{ + protected override IServerChannel ServerChannel => new QuicServerChannel(); + + protected override IClientChannel ClientChannel => new QuicClientChannel(); +} + +public partial class EventDispatcherTests_Tcp(ServerFixture serverFixture, ITestOutputHelper testOutputHelper) + : EventDispatcherTests(serverFixture, testOutputHelper) +{ + protected override IServerChannel ServerChannel => new TcpServerChannel(); + + protected override IClientChannel ClientChannel => new TcpClientChannel(); +} diff --git a/CoreRemoting.Tests/RemoteDelegates/ServiceEventHubTests.cs b/CoreRemoting.Tests/RemoteDelegates/ServiceEventHubTests.cs new file mode 100644 index 00000000..adfdeb78 --- /dev/null +++ b/CoreRemoting.Tests/RemoteDelegates/ServiceEventHubTests.cs @@ -0,0 +1,585 @@ +using System; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using CoreRemoting.RemoteDelegates; +using CoreRemoting.Threading; +using CoreRemoting.Toolbox; +using Xunit; +using static CoreRemoting.RemoteDelegates.ServiceEventHub; + +namespace CoreRemoting.Tests.RemoteDelegates; + +public class ServiceEventHubTests +{ + public interface ISampleInterface + { + string FireHandlers(int argument); + + event EventHandler SimpleEvent; + + event EventHandler CancelEvent; + + Action ActionDelegate { get; set; } + + Func FuncDelegate { get; set; } + } + + public interface ISampleDescendant1 : ISampleInterface + { + event EventHandler NewEvent; + + Action NewDelegate { get; set; } + } + + public interface ISampleDescendant2 : ISampleDescendant1, ISampleInterface + { + event EventHandler NewCancelEvent; + } + + public interface ISampleUnrelated + { + event EventHandler SomeOtherEvent; + } + + public class SampleService : ISampleInterface + { + public string FireHandlers(int argument) + { + if (SimpleEvent != null) + { + SimpleEvent(this, EventArgs.Empty); + } + + if (CancelEvent != null) + { + CancelEvent(this, new CancelEventArgs()); + } + + if (ActionDelegate != null) + { + ActionDelegate(); + } + + if (FuncDelegate != null) + { + return FuncDelegate(argument); + } + + return null; + } + + public event EventHandler SimpleEvent; + + public event EventHandler CancelEvent; + + public Action ActionDelegate { get; set; } + + public Func FuncDelegate { get; set; } + } + + [Fact] + public void ServiceEventHub_Contains_Events_And_Delegates() + { + var eventHub = new ServiceEventHub(typeof(ISampleInterface)); + Assert.NotNull(eventHub[nameof(ISampleInterface.SimpleEvent)]); + Assert.NotNull(eventHub[nameof(ISampleInterface.CancelEvent)]); + Assert.NotNull(eventHub[nameof(ISampleInterface.ActionDelegate)]); + Assert.NotNull(eventHub[nameof(ISampleInterface.FuncDelegate)]); + } + + [Fact] + public void ServiceEventHub_Contains_Inherited_Events_And_Delegates() + { + var eventHub = new ServiceEventHub(typeof(ISampleDescendant2)); + Assert.NotNull(eventHub[nameof(ISampleDescendant2.NewCancelEvent)]); + Assert.NotNull(eventHub[nameof(ISampleDescendant2.NewEvent)]); + Assert.NotNull(eventHub[nameof(ISampleDescendant2.NewDelegate)]); + Assert.NotNull(eventHub[nameof(ISampleDescendant2.SimpleEvent)]); + Assert.NotNull(eventHub[nameof(ISampleDescendant2.CancelEvent)]); + Assert.NotNull(eventHub[nameof(ISampleDescendant2.ActionDelegate)]); + Assert.NotNull(eventHub[nameof(ISampleDescendant2.FuncDelegate)]); + } + + [Fact] + public void ServiceEventHub_Delegates_Have_Same_Types_As_Their_Originals() + { + var eventHub = new ServiceEventHub(typeof(ISampleInterface)); + Assert.IsType(eventHub[nameof(ISampleInterface.SimpleEvent)]); + Assert.IsType>(eventHub[nameof(ISampleInterface.CancelEvent)]); + Assert.IsType(eventHub[nameof(ISampleInterface.ActionDelegate)]); + Assert.IsType>(eventHub[nameof(ISampleInterface.FuncDelegate)]); + } + + [Fact] + public async Task ServiceEventHub_Simple_Handle_Tests() + { + // add the first handler + var eventHub = new ServiceEventHub(typeof(ISampleInterface)); + var fired = new AsyncCounter(); + eventHub.AddHandler(nameof(ISampleInterface.SimpleEvent), Guid.NewGuid(), Guid.Empty, new EventHandler((sender, args) => fired++)); + + // check if it is called + var handler = (EventHandler)eventHub[nameof(ISampleInterface.SimpleEvent)]; + handler(this, EventArgs.Empty); + Assert.Equal(1, await fired.WaitForValue(1).Timeout(1)); + + // add the second handler + var firedAgain = new AsyncCounter(); + var secondKey = Guid.NewGuid(); + var tempHandler = new EventHandler((sender, args) => firedAgain++); + eventHub.AddHandler(nameof(ISampleInterface.SimpleEvent), secondKey, Guid.Empty, tempHandler); + + // check if it is called + handler(this, EventArgs.Empty); + Assert.Equal(2, await fired.WaitForValue(2).Timeout(1)); + Assert.Equal(1, await firedAgain.WaitForValue(1).Timeout(1)); + + // remove the second handler + eventHub.RemoveHandler(nameof(ISampleInterface.SimpleEvent), secondKey); + + // check if it is not called + handler(this, EventArgs.Empty); + Assert.Equal(3, await fired.WaitForValue(3).Timeout(1)); + Assert.False(await firedAgain.WaitForValue(2).Expire(0.1)); + } + + [Fact] + public async Task ServiceEventHub_Cancel_Event_Tests() + { + // add the first handler + var eventHub = new ServiceEventHub(typeof(ISampleInterface)); + var fired = new AsyncCounter(); + eventHub.AddHandler(nameof(ISampleInterface.CancelEvent), Guid.NewGuid(), Guid.Empty, new EventHandler((sender, args) => fired++)); + + // check if it is called + var handler = (EventHandler)eventHub[nameof(ISampleInterface.CancelEvent)]; + handler(this, new CancelEventArgs()); + Assert.Equal(1, await fired.WaitForValue(1).Timeout(1)); + + // add the second handler + var firedAgain = new AsyncCounter(); + var secondKey = Guid.NewGuid(); + var tempHandler = new EventHandler((sender, args) => firedAgain++); + eventHub.AddHandler(nameof(ISampleInterface.CancelEvent), secondKey, Guid.Empty, tempHandler); + + // check if it is called + handler(this, new CancelEventArgs()); + Assert.Equal(2, await fired.WaitForValue(2).Timeout(1)); + Assert.Equal(1, await firedAgain.WaitForValue(1).Timeout(1)); + + // remove the second handler + eventHub.RemoveHandler(nameof(ISampleInterface.CancelEvent), secondKey); + + // check if it is not called + handler(this, new CancelEventArgs()); + Assert.Equal(3, await fired.WaitForValue(3).Timeout(1)); + Assert.False(await firedAgain.WaitForValue(2).Expire(0.1)); + } + + [Fact] + public async Task ServiceEventHub_ActionDelegateTests() + { + // add the first handler + var eventHub = new ServiceEventHub(typeof(ISampleInterface)); + var fired = new AsyncCounter(); + eventHub.AddHandler(nameof(ISampleInterface.ActionDelegate), Guid.NewGuid(), Guid.Empty, new Action(() => fired++)); + + // check if it is called + var handler = (Action)eventHub[nameof(ISampleInterface.ActionDelegate)]; + handler(); + Assert.Equal(1, await fired.WaitForValue(1).Timeout(1)); + + // add the second handler + var firedAgain = new AsyncCounter(); + var secondKey = Guid.NewGuid(); + var tempHandler = new Action(() => firedAgain++); + eventHub.AddHandler(nameof(ISampleInterface.ActionDelegate), secondKey, Guid.Empty, tempHandler); + + // check if it is called + handler(); + Assert.Equal(2, await fired.WaitForValue(2).Timeout(1)); + Assert.Equal(1, await firedAgain.WaitForValue(1).Timeout(1)); + + // remove the second handler + eventHub.RemoveHandler(nameof(ISampleInterface.ActionDelegate), secondKey); + + // check if it is not called + handler(); + Assert.Equal(3, await fired.WaitForValue(3).Timeout(1)); + Assert.False(await firedAgain.WaitForValue(2).Expire(0.1)); + } + + [Fact] + public async Task ServiceEventHub_FuncDelegateTests() + { + // add the first handler + var eventHub = new ServiceEventHub(typeof(ISampleInterface)); + var fired = new AsyncCounter(); + eventHub.AddHandler(nameof(ISampleInterface.FuncDelegate), Guid.NewGuid(), Guid.Empty, new Func(a => + { + fired++; + return a.ToString(); // discarded + })); + + // check if it is called + var handler = (Func)eventHub[nameof(ISampleInterface.FuncDelegate)]; + var result = handler(123); + Assert.Equal(1, await fired.WaitForValue(1).Timeout(1)); + Assert.Null(result); // Assert.Equal("123", result); + + // add the second handler + var firedAgain = new AsyncCounter(); + var secondKey = Guid.NewGuid(); + var tempHandler = new Func(a => + { + firedAgain++; + return a.ToString(); // discarded + }); + + eventHub.AddHandler(nameof(ISampleInterface.FuncDelegate), secondKey, Guid.Empty, tempHandler); + + // check if it is called + result = handler(321); + Assert.Equal(2, await fired.WaitForValue(2).Timeout(1)); + Assert.Equal(1, await firedAgain.WaitForValue(1).Timeout(1)); + Assert.Null(result); // Assert.Equal("123", result); + + // remove the second handler + eventHub.RemoveHandler(nameof(ISampleInterface.FuncDelegate), secondKey); + + // check if it is not called + result = handler(0); + Assert.Equal(3, await fired.WaitForValue(3).Timeout(1)); + Assert.False(await firedAgain.WaitForValue(2).Expire(0.1)); + Assert.Null(result); // Assert.Equal("0", result); + } + + [Fact] + public async Task ServiceEventHub_WireUnwireTests() + { + var eventHub = new ServiceEventHub(typeof(ISampleInterface)); + var simpleEventFired = new AsyncCounter(); + var cancelEventFired = new AsyncCounter(); + var actionFired = new AsyncCounter(); + var funcFired = new AsyncCounter(); + + // add event handlers + eventHub.AddHandler(nameof(ISampleInterface.SimpleEvent), Guid.NewGuid(), Guid.Empty, new EventHandler((sender, args) => simpleEventFired++)); + eventHub.AddHandler(nameof(ISampleInterface.CancelEvent), Guid.NewGuid(), Guid.Empty, new EventHandler((sender, args) => cancelEventFired++)); + eventHub.AddHandler(nameof(ISampleInterface.ActionDelegate), Guid.NewGuid(), Guid.Empty, new Action(() => actionFired++)); + eventHub.AddHandler(nameof(ISampleInterface.FuncDelegate), Guid.NewGuid(), Guid.Empty, new Func(a => { funcFired++; return a.ToString(); })); + eventHub.AddHandler(nameof(ISampleInterface.FuncDelegate), Guid.NewGuid(), Guid.Empty, new Func(a => { funcFired++; return (a * 2).ToString(); })); + + // wire up events + var component = new SampleService(); + eventHub.WireTo(component); + + // test if it works + var result = component.FireHandlers(102030); + Assert.Null(result); // Assert.Equal("204060", result); + + // check if all events were fired + var results = await Task.WhenAll( + simpleEventFired.WaitForValue(1), + cancelEventFired.WaitForValue(1), + actionFired.WaitForValue(1), + funcFired.WaitForValue(2)).Timeout(1); + + Assert.Equal(5, results.Sum()); + + // unwire + eventHub.UnwireFrom(component); + + // test if it works + result = component.FireHandlers(123); + + // event handlers shoundn't fire anymore + Assert.Null(result); + Assert.False(await simpleEventFired.WaitForValue(2).Expire(0.1)); + Assert.False(await cancelEventFired.WaitForValue(2).Expire(0.1)); + Assert.False(await actionFired.WaitForValue(2).Expire(0.1)); + Assert.False(await funcFired.WaitForValue(3).Expire(0.1)); + } + + [Fact] + public void ServiceEventHub_Handler_Count_Tests() + { + var eventHub = new ServiceEventHub(typeof(ISampleInterface)); + var sampleService = new SampleService(); + + eventHub.WireTo(sampleService); + Assert.Equal(0, eventHub.GetHandlerCount(nameof(ISampleInterface.SimpleEvent))); + + eventHub.AddHandler(nameof(ISampleInterface.SimpleEvent), Guid.NewGuid(), Guid.Empty, new EventHandler((s, e) => { })); + Assert.Equal(1, eventHub.GetHandlerCount(nameof(ISampleInterface.SimpleEvent))); + + var secondKey = Guid.NewGuid(); + var handler = new EventHandler((s, e) => { }); + + eventHub.AddHandler(nameof(ISampleInterface.SimpleEvent), secondKey, Guid.Empty, handler); + Assert.Equal(2, eventHub.GetHandlerCount(nameof(ISampleInterface.SimpleEvent))); + + eventHub.RemoveHandler(nameof(ISampleInterface.SimpleEvent), secondKey); + Assert.Equal(1, eventHub.GetHandlerCount(nameof(ISampleInterface.SimpleEvent))); + } + + [Fact] + public void ServiceEventHub_doesnt_throw_if_no_subscriptions_exist() + { + var eventHub = new ServiceEventHub(typeof(ISampleInterface)); + var sampleService = new SampleService(); + eventHub.WireTo(sampleService); + sampleService.FireHandlers(1); + } + + // --- RemoveHandlers tests --- + + [Fact] + public void RemoveHandlers_removes_known_key() + { + var eventHub = new ServiceEventHub(typeof(ISampleInterface)); + var key = Guid.NewGuid(); + + eventHub.AddHandler(nameof(ISampleInterface.SimpleEvent), key, Guid.Empty, new EventHandler((s, e) => { })); + Assert.Equal(1, eventHub.GetHandlerCount(nameof(ISampleInterface.SimpleEvent))); + + Assert.Equal(1, eventHub.RemoveHandlers([key])); + Assert.Equal(0, eventHub.GetHandlerCount(nameof(ISampleInterface.SimpleEvent))); + } + + [Fact] + public void RemoveHandlers_is_idempotent() + { + var eventHub = new ServiceEventHub(typeof(ISampleInterface)); + var key = Guid.NewGuid(); + + eventHub.AddHandler(nameof(ISampleInterface.SimpleEvent), key, Guid.Empty, new EventHandler((s, e) => { })); + + Assert.Equal(1, eventHub.RemoveHandlers([key])); + Assert.Equal(0, eventHub.RemoveHandlers([key])); + Assert.Equal(0, eventHub.RemoveHandlers([key])); + Assert.Equal(0, eventHub.GetHandlerCount(nameof(ISampleInterface.SimpleEvent))); + } + + [Fact] + public void RemoveHandlers_ignores_unknown_key() + { + var eventHub = new ServiceEventHub(typeof(ISampleInterface)); + + Assert.Equal(0, eventHub.RemoveHandlers([Guid.NewGuid()])); + } + + [Fact] + public void RemoveHandlers_handles_empty_input() + { + var eventHub = new ServiceEventHub(typeof(ISampleInterface)); + + Assert.Equal(0, eventHub.RemoveHandlers([])); + Assert.Equal(0, eventHub.RemoveHandlers(null)); + } + + [Fact] + public void RemoveHandlers_removes_all_occurrences_of_multicast_key() + { + var eventHub = new ServiceEventHub(typeof(ISampleInterface)); + var key = Guid.NewGuid(); + var handler = new EventHandler((s, e) => { }); + + // multicast: the same key and the same delegate added three times + eventHub.AddHandler(nameof(ISampleInterface.SimpleEvent), key, Guid.Empty, handler); + eventHub.AddHandler(nameof(ISampleInterface.SimpleEvent), key, Guid.Empty, handler); + eventHub.AddHandler(nameof(ISampleInterface.SimpleEvent), key, Guid.Empty, handler); + + Assert.Equal(3, eventHub.GetHandlerCount(nameof(ISampleInterface.SimpleEvent))); + + // RemoveHandlers is used for proxy shutdown / nack and must remove + // ALL handlers registered under the key, not just one (unlike RemoveHandler) + Assert.Equal(3, eventHub.RemoveHandlers([key])); + Assert.Equal(0, eventHub.GetHandlerCount(nameof(ISampleInterface.SimpleEvent))); + } + + [Fact] + public void RemoveHandlers_searches_across_multiple_events() + { + var eventHub = new ServiceEventHub(typeof(ISampleInterface)); + var key1 = Guid.NewGuid(); + var key2 = Guid.NewGuid(); + + eventHub.AddHandler(nameof(ISampleInterface.SimpleEvent), key1, Guid.Empty, new EventHandler((s, e) => { })); + eventHub.AddHandler(nameof(ISampleInterface.ActionDelegate), key2, Guid.Empty, new Action(() => { })); + + Assert.Equal(2, eventHub.RemoveHandlers([key1, key2])); + Assert.Equal(0, eventHub.GetHandlerCount(nameof(ISampleInterface.SimpleEvent))); + Assert.Equal(0, eventHub.GetHandlerCount(nameof(ISampleInterface.ActionDelegate))); + } + + [Fact] + public void RemoveHandlers_removes_only_matching_keys() + { + var eventHub = new ServiceEventHub(typeof(ISampleInterface)); + var kept = Guid.NewGuid(); + var removed = Guid.NewGuid(); + + eventHub.AddHandler(nameof(ISampleInterface.SimpleEvent), kept, Guid.Empty, new EventHandler((s, e) => { })); + eventHub.AddHandler(nameof(ISampleInterface.SimpleEvent), removed, Guid.Empty, new EventHandler((s, e) => { })); + + // only the matching key is removed; the unrelated guid in the same + // call is ignored + Assert.Equal(1, eventHub.RemoveHandlers([removed, Guid.NewGuid()])); + Assert.Equal(1, eventHub.GetHandlerCount(nameof(ISampleInterface.SimpleEvent))); + + // the kept key is still removable + Assert.Equal(1, eventHub.RemoveHandlers([kept])); + Assert.Equal(0, eventHub.GetHandlerCount(nameof(ISampleInterface.SimpleEvent))); + } + + // --- MethodInfo / TypedEventHub tests --- + + [Fact] + public void MethodInfo_can_represent_subscription_or_unsubscription() + { + var method = typeof(ISampleInterface).GetMethod("add_SimpleEvent"); + Assert.NotNull(method); + Assert.True(method.IsEventAccessor(out var eventName, out var subscription)); + Assert.Equal(nameof(ISampleInterface.SimpleEvent), eventName); + Assert.True(subscription); + + method = typeof(ISampleInterface).GetMethod("remove_CancelEvent"); + Assert.NotNull(method); + Assert.True(method.IsEventAccessor(out eventName, out subscription)); + Assert.Equal(nameof(ISampleInterface.CancelEvent), eventName); + Assert.False(subscription); + + method = typeof(ISampleInterface).GetMethod(nameof(ISampleInterface.FireHandlers)); + Assert.NotNull(method); + Assert.False(method.IsEventAccessor(out eventName, out subscription)); + Assert.Null(eventName); + Assert.False(subscription); + } + + [Fact] + public async Task TypedEventHub_can_invoke_events() + { + var simpleEventFired = new AsyncCounter(); + void simpleHandler(object sender, EventArgs args) => + simpleEventFired.Increment(); + var simpleEventHandler = (EventHandler)simpleHandler; + + // add event handler + var typedHub = new TypedEventHub(); + var key = Guid.NewGuid(); + typedHub.AddHandler(s => nameof(s.SimpleEvent), key, Guid.Empty, simpleEventHandler); + + // invoke event handler + typedHub.Invoke(s => nameof(s.SimpleEvent), this, EventArgs.Empty); + await simpleEventFired.WaitForValue(1).Timeout(1); + + // invoke by string name + typedHub.Invoke(nameof(ISampleInterface.SimpleEvent), this, EventArgs.Empty); + await simpleEventFired.WaitForValue(2).Timeout(1); + + // remove event handler + typedHub.RemoveHandler(s => nameof(s.SimpleEvent), key); + await Assert.ThrowsAsync(async () => + await simpleEventFired.WaitForValue(3).Timeout(1)); + } + + // --- TypedEventHub wrapper tests --- + + [Fact] + public void TypedEventHub_creates_its_own_hub() + { + var typedHub = new TypedEventHub(); + + Assert.NotNull(typedHub.Hub); + Assert.Equal(typeof(ISampleInterface), typedHub.Hub.InterfaceType); + } + + [Fact] + public void TypedEventHub_wraps_existing_hub() + { + var eventHub = new ServiceEventHub(typeof(ISampleInterface)); + var typedHub = new TypedEventHub(eventHub); + + Assert.Same(eventHub, typedHub.Hub); + Assert.Same( + eventHub[nameof(ISampleInterface.SimpleEvent)], + typedHub[nameof(ISampleInterface.SimpleEvent)]); + } + + [Fact] + public void TypedEventHub_created_from_hub_shares_state_with_it() + { + var eventHub = new ServiceEventHub(typeof(ISampleInterface)); + var typedHub = eventHub.CreateTyped(); + + Assert.Same(eventHub, typedHub.Hub); + + // adding via the typed hub is visible via the original hub + var key = Guid.NewGuid(); + var handler = new EventHandler((s, e) => { }); + typedHub.AddHandler(nameof(ISampleInterface.SimpleEvent), key, Guid.Empty, handler); + + Assert.Equal(1, eventHub.GetHandlerCount(nameof(ISampleInterface.SimpleEvent))); + + // removing via the original hub is visible via the typed hub + eventHub.RemoveHandler(nameof(ISampleInterface.SimpleEvent), key); + Assert.Equal(0, eventHub.GetHandlerCount(nameof(ISampleInterface.SimpleEvent))); + } + + [Fact] + public async Task TypedEventHub_invokes_events_on_wrapped_hub() + { + var eventHub = new ServiceEventHub(typeof(ISampleInterface)); + var typedHub = eventHub.CreateTyped(); + + var fired = new AsyncCounter(); + typedHub.AddHandler( + nameof(ISampleInterface.SimpleEvent), + Guid.NewGuid(), + Guid.Empty, + new EventHandler((s, e) => fired++)); + + typedHub.Invoke(nameof(ISampleInterface.SimpleEvent), this, EventArgs.Empty); + Assert.Equal(1, await fired.WaitForValue(1).Timeout(1)); + + // now wire the original hub to a component and fire events through it + var component = new SampleService(); + eventHub.WireTo(component); + component.FireHandlers(0); + Assert.Equal(2, await fired.WaitForValue(2).Timeout(1)); + } + + [Fact] + public void TypedEventHub_rejects_incompatible_hub() + { + var eventHub = new ServiceEventHub(typeof(ISampleInterface)); + + Assert.Throws(() => + new TypedEventHub(eventHub)); + } + + [Fact] + public void TypedEventHub_accepts_hub_of_derived_interface() + { + // hub built for a descendant interface, wrapper for the ancestor + var eventHub = new ServiceEventHub(typeof(ISampleDescendant2)); + var typedHub = new TypedEventHub(eventHub); + + Assert.Same(eventHub, typedHub.Hub); + Assert.NotNull(typedHub[nameof(ISampleInterface.SimpleEvent)]); + } + + [Fact] + public void TypedEventHub_treats_null_hub_as_fresh_hub() + { + var typedHub1 = new TypedEventHub(); + var typedHub2 = new TypedEventHub(null); + + Assert.NotNull(typedHub1.Hub); + Assert.NotNull(typedHub2.Hub); + Assert.NotSame(typedHub1.Hub, typedHub2.Hub); + Assert.Equal(typeof(ISampleInterface), typedHub2.Hub.InterfaceType); + } +} diff --git a/CoreRemoting.Tests/RemoteDelegates/SubscriptionTrackerTests.cs b/CoreRemoting.Tests/RemoteDelegates/SubscriptionTrackerTests.cs new file mode 100644 index 00000000..693b7200 --- /dev/null +++ b/CoreRemoting.Tests/RemoteDelegates/SubscriptionTrackerTests.cs @@ -0,0 +1,468 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CoreRemoting.RemoteDelegates; +using Xunit; + +namespace CoreRemoting.Tests.RemoteDelegates; + +public class SubscriptionTrackerTests +{ + [Fact] + public void New_tracker_is_empty() + { + var tracker = new SubscriptionTracker(); + + Assert.Equal(0, tracker.Count); + Assert.Equal("0:0000000000000000-0000000000000000", tracker.Checksum); + } + + [Fact] + public void Add_single_increments_count() + { + var tracker = new SubscriptionTracker(); + + tracker.Add(Guid.NewGuid()); + + Assert.Equal(1, tracker.Count); + } + + [Fact] + public void Add_multiple_increments_count_by_their_number() + { + var tracker = new SubscriptionTracker(); + + tracker.Add([Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]); + + Assert.Equal(3, tracker.Count); + } + + [Fact] + public void Remove_single_decrements_count() + { + var tracker = new SubscriptionTracker(); + var key = Guid.NewGuid(); + + tracker.Add(key); + tracker.Remove(key); + + Assert.Equal(0, tracker.Count); + } + + [Fact] + public void Remove_multiple_decrements_count_by_their_number() + { + var tracker = new SubscriptionTracker(); + + tracker.Remove([Guid.NewGuid(), Guid.NewGuid()]); + + Assert.Equal(-2, tracker.Count); + } + + [Fact] + public void Adding_same_key_twice_counts_twice() + { + // Multicast semantics: += on the same handler produces two subscriptions. + var tracker = new SubscriptionTracker(); + var key = Guid.NewGuid(); + + tracker.Add(key); + tracker.Add(key); + + Assert.Equal(2, tracker.Count); + } + + [Fact] + public void Removing_same_key_twice_counts_twice() + { + var tracker = new SubscriptionTracker(); + var key = Guid.NewGuid(); + + tracker.Add(key); + tracker.Add(key); + tracker.Remove(key); + tracker.Remove(key); + + Assert.Equal(0, tracker.Count); + } + + [Fact] + public void Removing_unsubscribed_key_goes_negative() + { + // -= on a handler that was never subscribed is a legal no-op for .NET events, + // but it must be reflected by the tracker so that both endpoints stay in sync. + var tracker = new SubscriptionTracker(); + + tracker.Remove(Guid.NewGuid()); + + Assert.Equal(-1, tracker.Count); + } + + [Fact] + public void Add_then_remove_same_key_returns_to_initial_checksum() + { + var tracker = new SubscriptionTracker(); + var key = Guid.NewGuid(); + + var before = tracker.Checksum; + + tracker.Add(key); + tracker.Remove(key); + + Assert.Equal(before, tracker.Checksum); + Assert.Equal(0, tracker.Count); + } + + [Fact] + public void Checksum_changes_when_subscriptions_change() + { + var tracker = new SubscriptionTracker(); + + var empty = tracker.Checksum; + tracker.Add(Guid.NewGuid()); + var one = tracker.Checksum; + + Assert.NotEqual(empty, one); + } + + [Fact] + public void Checksum_is_deterministic_for_same_operations() + { + var key1 = Guid.NewGuid(); + var key2 = Guid.NewGuid(); + var key3 = Guid.NewGuid(); + + var tracker1 = new SubscriptionTracker(); + tracker1.Add(key1); + tracker1.Add(key2); + tracker1.Add(key3); + + var tracker2 = new SubscriptionTracker(); + tracker2.Add(key1); + tracker2.Add(key2); + tracker2.Add(key3); + + Assert.Equal(tracker1.Checksum, tracker2.Checksum); + } + + [Fact] + public void Checksum_is_order_independent() + { + var key1 = Guid.NewGuid(); + var key2 = Guid.NewGuid(); + var key3 = Guid.NewGuid(); + + var tracker1 = new SubscriptionTracker(); + tracker1.Add(key1); + tracker1.Add(key2); + tracker1.Add(key3); + + var tracker2 = new SubscriptionTracker(); + tracker2.Add(key3); + tracker2.Add(key1); + tracker2.Add(key2); + + Assert.Equal(tracker1.Checksum, tracker2.Checksum); + } + + [Fact] + public void Checksum_differs_for_single_vs_duplicate_subscription() + { + // This is the key property that distinguishes an operation-counting + // tracker from a set-based one. + var key = Guid.NewGuid(); + + var tracker1 = new SubscriptionTracker(); + tracker1.Add(key); + + var tracker2 = new SubscriptionTracker(); + tracker2.Add(key); + tracker2.Add(key); + + Assert.NotEqual(tracker1.Checksum, tracker2.Checksum); + } + + [Fact] + public void Checksum_format_is_count_colon_hex_pair() + { + var tracker = new SubscriptionTracker(); + tracker.Add(Guid.NewGuid()); + + var checksum = tracker.Checksum; + + Assert.StartsWith("1:", checksum); + + var parts = checksum.Split(':'); + Assert.Equal(2, parts.Length); + + var halves = parts[1].Split('-'); + Assert.Equal(2, halves.Length); + Assert.Equal(16, halves[0].Length); + Assert.Equal(16, halves[1].Length); + } + + // --- Golden checksum tests ------------------------------------------------- + // + // These pin the checksum format and the arithmetic to fixed inputs. + // All bytes of the chosen Guids are 0xFF or 0x00, so each half reads as + // either -1 or 0 regardless of how Guid.ToByteArray and BitConverter + // are implemented. Any change to the algorithm (XOR instead of Add, + // swapped halves, different format) will fail these tests. + + [Fact] + public void Checksum_is_stable_for_repeated_add_of_ones() + { + var allOnes = new Guid("ffffffff-ffff-ffff-ffff-ffffffffffff"); + + var tracker = new SubscriptionTracker(); + + Assert.Equal("0:0000000000000000-0000000000000000", tracker.Checksum); + + tracker.Add(allOnes); + Assert.Equal("1:ffffffffffffffff-ffffffffffffffff", tracker.Checksum); + + tracker.Add(allOnes); + Assert.Equal("2:fffffffffffffffe-fffffffffffffffe", tracker.Checksum); + + tracker.Add(allOnes); + Assert.Equal("3:fffffffffffffffd-fffffffffffffffd", tracker.Checksum); + + tracker.Remove(allOnes); + Assert.Equal("2:fffffffffffffffe-fffffffffffffffe", tracker.Checksum); + + tracker.Reset(); + Assert.Equal("0:0000000000000000-0000000000000000", tracker.Checksum); + } + + [Fact] + public void Checksum_tracks_sequence_of_operations() + { + // These Guids are chosen so that each half varies independently: + // a -> lo = -1, hi = -1 + // b -> lo = -1, hi = 0 + // c -> lo = 0, hi = -1 + var a = new Guid("ffffffff-ffff-ffff-ffff-ffffffffffff"); + var b = new Guid("ffffffff-ffff-ffff-0000-000000000000"); + var c = new Guid("00000000-0000-0000-ffff-ffffffffffff"); + + var tracker = new SubscriptionTracker(); + + Assert.Equal("0:0000000000000000-0000000000000000", tracker.Checksum); + + tracker.Add(a); + Assert.Equal("1:ffffffffffffffff-ffffffffffffffff", tracker.Checksum); + + tracker.Add(b); + Assert.Equal("2:fffffffffffffffe-ffffffffffffffff", tracker.Checksum); + + tracker.Add(c); + Assert.Equal("3:fffffffffffffffe-fffffffffffffffe", tracker.Checksum); + + tracker.Remove(b); + Assert.Equal("2:ffffffffffffffff-fffffffffffffffe", tracker.Checksum); + + tracker.Remove(a); + Assert.Equal("1:0000000000000000-ffffffffffffffff", tracker.Checksum); + + tracker.Remove(c); + Assert.Equal("0:0000000000000000-0000000000000000", tracker.Checksum); + } + + // --- Null and empty inputs ----------------------------------------------- + + [Fact] + public void Add_null_enumeration_is_noop() + { + var tracker = new SubscriptionTracker(); + + tracker.Add(null); + + Assert.Equal(0, tracker.Count); + } + + [Fact] + public void Remove_null_enumeration_is_noop() + { + var tracker = new SubscriptionTracker(); + + tracker.Remove(null); + + Assert.Equal(0, tracker.Count); + } + + [Fact] + public void Add_empty_array_is_noop() + { + var tracker = new SubscriptionTracker(); + + tracker.Add([]); + + Assert.Equal(0, tracker.Count); + } + + [Fact] + public void Remove_empty_array_is_noop() + { + var tracker = new SubscriptionTracker(); + + tracker.Remove([]); + + Assert.Equal(0, tracker.Count); + } + + // --- Reset --------------------------------------------------------------- + + [Fact] + public void Reset_clears_state() + { + var tracker = new SubscriptionTracker(); + tracker.Add(Guid.NewGuid()); + tracker.Add(Guid.NewGuid()); + + tracker.Reset(); + + Assert.Equal(0, tracker.Count); + Assert.Equal("0:0000000000000000-0000000000000000", tracker.Checksum); + } + + [Fact] + public void Reset_with_null_clears_state() + { + var tracker = new SubscriptionTracker(); + tracker.Add(Guid.NewGuid()); + + tracker.Reset(null); + + Assert.Equal(0, tracker.Count); + } + + [Fact] + public void Reset_with_seed_replaces_state() + { + var tracker = new SubscriptionTracker(); + tracker.Add(Guid.NewGuid()); + tracker.Add(Guid.NewGuid()); + tracker.Add(Guid.NewGuid()); + + var key1 = Guid.NewGuid(); + var key2 = Guid.NewGuid(); + tracker.Reset([key1, key2]); + + Assert.Equal(2, tracker.Count); + + var reference = new SubscriptionTracker(); + reference.Add(key1); + reference.Add(key2); + Assert.Equal(reference.Checksum, tracker.Checksum); + } + + [Fact] + public void Reset_with_duplicates_counts_each_occurrence() + { + // Unlike a set-based tracker, our operation-counting tracker must + // preserve duplicate occurrences in Reset, because the server side + // invokes a handler as many times as it was subscribed. + var key = Guid.NewGuid(); + var tracker = new SubscriptionTracker(); + + tracker.Reset([key, key, key]); + + Assert.Equal(3, tracker.Count); + + var reference = new SubscriptionTracker(); + reference.Add(key); + reference.Add(key); + reference.Add(key); + + Assert.Equal(reference.Checksum, tracker.Checksum); + } + + // --- Concurrency --------------------------------------------------------- + + [Fact] + public async Task Count_can_be_read_concurrently_with_mutations() + { + var tracker = new SubscriptionTracker(); + var stop = false; + + var reader = Task.Run(() => + { + while (!stop) + { + // Just touch both properties; we don't assert exact values. + _ = tracker.Count; + _ = tracker.Checksum; + } + }); + + for (var i = 0; i < 1000; i++) + tracker.Add(Guid.NewGuid()); + + stop = true; + await reader; + + Assert.Equal(1000, tracker.Count); + } + + [Fact] + public async Task Concurrent_adds_result_in_expected_count() + { + var tracker = new SubscriptionTracker(); + + await Task.WhenAll( + Enumerable.Range(0, 1000).Select(_ => Task.Run(() => + tracker.Add(Guid.NewGuid())))); + + Assert.Equal(1000, tracker.Count); + } + + [Fact] + public async Task Concurrent_adds_and_removes_of_same_key_are_consistent() + { + var tracker = new SubscriptionTracker(); + var key = Guid.NewGuid(); + + // 500 adds and 500 removes of the same key, in any interleaving. + // The counter should be 0 and the checksum should match the initial state. + var initial = tracker.Checksum; + + await Task.WhenAll( + Enumerable.Range(0, 500).SelectMany(_ => new[] + { + Task.Run(() => tracker.Add(key)), + Task.Run(() => tracker.Remove(key)), + })); + + Assert.Equal(0, tracker.Count); + Assert.Equal(initial, tracker.Checksum); + } + + [Fact] + public async Task Concurrent_mutations_keep_count_and_sum_consistent() + { + // The invariant we care about: after all mutations settle, the + // checksum matches a fresh tracker that applied the same net ops. + var key1 = Guid.NewGuid(); + var key2 = Guid.NewGuid(); + var key3 = Guid.NewGuid(); + + var tracker = new SubscriptionTracker(); + + await Task.WhenAll( + Enumerable.Range(0, 100).Select(_ => Task.Run(() => tracker.Add(key1))) + .Concat(Enumerable.Range(0, 100).Select(_ => Task.Run(() => tracker.Add(key2)))) + .Concat(Enumerable.Range(0, 50).Select(_ => Task.Run(() => tracker.Add(key3)))) + .Concat(Enumerable.Range(0, 30).Select(_ => Task.Run(() => tracker.Remove(key1))))); + + // 100 + 100 + 50 - 30 = 220 + Assert.Equal(220, tracker.Count); + + var reference = new SubscriptionTracker(); + for (var i = 0; i < 70; i++) reference.Add(key1); + for (var i = 0; i < 100; i++) reference.Add(key2); + for (var i = 0; i < 50; i++) reference.Add(key3); + + Assert.Equal(reference.Checksum, tracker.Checksum); + } +} \ No newline at end of file diff --git a/CoreRemoting.Tests/RpcTests.cs b/CoreRemoting.Tests/RpcTests.cs index bed050d3..0ad2c0ac 100644 --- a/CoreRemoting.Tests/RpcTests.cs +++ b/CoreRemoting.Tests/RpcTests.cs @@ -3,12 +3,10 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Runtime.CompilerServices; using System.Security; using System.Threading; using System.Threading.Tasks; using CoreRemoting.Authentication; -using CoreRemoting.Channels; using CoreRemoting.Channels.NamedPipe; using CoreRemoting.RemoteDelegates; using CoreRemoting.Serialization; @@ -21,50 +19,13 @@ namespace CoreRemoting.Tests; -[Collection("CoreRemoting")] -public class RpcTests : IClassFixture +public partial class RpcTests : RpcTests_Base { - protected readonly ServerFixture _serverFixture; - protected readonly ITestOutputHelper _testOutputHelper; - protected bool _remoteServiceCalled; - - protected virtual IServerChannel ServerChannel => null; - - protected virtual IClientChannel ClientChannel => null; - public RpcTests(ServerFixture serverFixture, ITestOutputHelper testOutputHelper) - { - _serverFixture = serverFixture; - _testOutputHelper = testOutputHelper; - - // Allow derived test classes to tweak server configuration before server start - ConfigureServer(_serverFixture.ServerConfig); - - _serverFixture.TestService.TestMethodFake = arg => - { - _remoteServiceCalled = true; - return arg; - }; - - _serverFixture.Start(ServerChannel); - - // setup event handler invoker - EventStub.DelegateInvoker = DelegateInvoker; - } - - /// - /// Gives derived tests a chance to configure the server before it starts. - /// - /// Server configuration object that will be used to create the server. - protected virtual void ConfigureServer(ServerConfig config) + : base(serverFixture, testOutputHelper) { } - /// - /// Gets the delegate invoker to be used for event handler tests. - /// - protected virtual IDelegateInvoker DelegateInvoker => new SafeDynamicInvoker(); - [Fact] public void ValidationSyncContext_is_installed() { @@ -73,23 +34,6 @@ public void ValidationSyncContext_is_installed() Assert.IsType(SynchronizationContext.Current); } - private void CheckServerErrorCount() - { - try - { - Assert.Equal(0, _serverFixture.ServerErrorCount); - } - finally - { - if (_serverFixture.ServerErrorCount != 0) - { - var msg = $"LastServerError: {_serverFixture.LastServerError}"; - _testOutputHelper.WriteLine(msg); - Console.WriteLine(msg); - } - } - } - [Fact] public void Call_on_Proxy_should_be_invoked_on_remote_service() { @@ -245,52 +189,6 @@ void ClientAction() CheckServerErrorCount(); } - [Fact] - public async Task Delegate_invoked_on_server_should_callback_client() - { - string argumentFromServer = null; - var serverCalled = new TaskCompletionSource(); - - void ClientAction() - { - using var ctx = ValidationSyncContext.Install(); - - try - { - using var client = new RemotingClient( - new ClientConfig() - { - ConnectionTimeout = 0, - Channel = ClientChannel, - MessageEncryption = false, - ServerPort = _serverFixture.Server.Config.NetworkPort, - }); - - client.Connect(); - - var proxy = client.CreateProxy(); - proxy.TestMethodWithDelegateArg(arg => - { - argumentFromServer = arg; - serverCalled.TrySetResult(true); - }); - } - catch (Exception e) - { - _testOutputHelper.WriteLine(e.ToString()); - throw; - } - } - - var clientThread = new Thread(ClientAction); - clientThread.Start(); - clientThread.Join(); - - await serverCalled.Task.Timeout(1); - Assert.Equal("test", argumentFromServer); - CheckServerErrorCount(); - } - [Fact] public async Task Call_on_Proxy_should_be_executed_asynchronously() { @@ -373,60 +271,6 @@ public void Component_lifetime_matches_the_expectation(string serviceName) Assert.Equal(sameInstance, proxy.CheckLastSavedInstance()); } - [Theory] - [InlineData("TestService_Singleton_Service")] - [InlineData("TestService_Singleton_Factory")] - [InlineData("TestService_SingleCall_Service")] - [InlineData("TestService_SingleCall_Factory")] - [InlineData("TestService_Scoped_Service")] - [InlineData("TestService_Scoped_Factory")] - public virtual void Events_should_work_remotely(string serviceName) - { - using var ctx = ValidationSyncContext.Install(); - - var serviceEventCallCount = 0; - var customDelegateEventCallCount = 0; - - using var client = new RemotingClient( - new ClientConfig() - { - ConnectionTimeout = 0, - SendTimeout = 0, - Channel = ClientChannel, - MessageEncryption = false, - ServerPort = _serverFixture.Server.Config.NetworkPort, - }); - - client.Connect(); - - var proxy = client.CreateProxy(serviceName); - - var serviceEventResetEvent = new ManualResetEventSlim(initialState: false); - var customDelegateEventResetEvent = new ManualResetEventSlim(initialState: false); - - proxy.ServiceEvent += () => - { - Interlocked.Increment(ref serviceEventCallCount); - serviceEventResetEvent.Set(); - }; - - proxy.CustomDelegateEvent += _ => - { - Interlocked.Increment(ref customDelegateEventCallCount); - customDelegateEventResetEvent.Set(); - }; - - proxy.FireServiceEvent(); - proxy.FireCustomDelegateEvent(); - - serviceEventResetEvent.Wait(1000); - customDelegateEventResetEvent.Wait(1000); - - Assert.Equal(1, serviceEventCallCount); - Assert.Equal(1, customDelegateEventCallCount); - CheckServerErrorCount(); - } - [Fact] public void External_types_should_work_as_remote_service_parameters() { @@ -670,9 +514,9 @@ public async Task ErrorAsync_method_throws_Exception() var proxy = client.CreateProxy(); var ex = (await Assert.ThrowsAsync(async () => - { - await proxy.ErrorAsync(nameof(ErrorAsync_method_throws_Exception)).ConfigureAwait(false); - }) + { + await proxy.ErrorAsync(nameof(ErrorAsync_method_throws_Exception)).ConfigureAwait(false); + }) .ConfigureAwait(false)).GetInnermostException(); Assert.NotNull(ex); @@ -818,6 +662,49 @@ void AfterCall(object sender, ServerRpcContext ctx) } } + [Fact] + public void AfterCall_event_handler_can_substitute_result() + { + void AfterCall(object sender, ServerRpcContext ctx) + { + if (ctx.Exception != null) + return; + + if (ctx.MethodCallMessage.MethodName != "Echo") + return; + + ctx.ReturnValue = "[substituted]"; + } + + using var ctx = ValidationSyncContext.Install(); + _serverFixture.Server.AfterCall += AfterCall; + + try + { + using var client = new RemotingClient(new ClientConfig() + { + ConnectionTimeout = 0, + InvocationTimeout = 0, + SendTimeout = 0, + Channel = ClientChannel, + MessageEncryption = false, + ServerPort = _serverFixture.Server.Config.NetworkPort, + }); + + client.Connect(); + + var proxy = client.CreateProxy(); + var result = proxy.Echo("Hello"); + + Assert.Equal("[substituted]", result); + } + finally + { + _serverFixture.ServerErrorCount = 0; + _serverFixture.Server.AfterCall -= AfterCall; + } + } + [Fact] public void Failing_component_constructor_throws_RemoteInvocationException() { @@ -842,70 +729,6 @@ public void Failing_component_constructor_throws_RemoteInvocationException() Assert.Contains("FailingService", ex.Message); } - // Fun fact: - // - ServerConfig.MessageEncryption setting doesn't affect anything! - // - RemotingServer accepts both encrypted and unencrypted connections. - - [Fact] - [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] - public async Task Disposed_client_subscription_doesnt_break_other_clients() - { - using var ctx = ValidationSyncContext.Install(); - - async Task Roundtrip(bool encryption) - { - try - { - RemotingClient CreateClient() => new RemotingClient(new ClientConfig() - { - Channel = ClientChannel, - ServerPort = _serverFixture.Server.Config.NetworkPort, - MessageEncryption = encryption, - ConnectionTimeout = encryption ? 0 : 5, - KeySize = 1024 - }); - - using var client1 = CreateClient(); - using var client2 = CreateClient(); - - client1.Connect(); - client2.Connect(); - - var proxy1 = client1.CreateProxy(); - var fired1 = new TaskCompletionSource(); - proxy1.ServiceEvent += () => fired1.TrySetResult(true); - - var proxy2 = client2.CreateProxy(); - var fired2 = new TaskCompletionSource(); - proxy2.ServiceEvent += () => fired2.TrySetResult(true); - - // early disposal, proxy1 subscription isn't canceled - client1.Disconnect(); - - proxy2.FireServiceEvent(); - Assert.True(await fired2.Task.ConfigureAwait(false)); - Assert.True(fired2.Task.IsCompleted); - Assert.False(fired1.Task.IsCompleted); - } - finally - { - // reset the error counter for other tests - _serverFixture.ServerErrorCount = 0; - } - } - - // works! - await Roundtrip(encryption: false).ConfigureAwait(false); - - // For NamedPipe channel, skip encrypted roundtrip due to known hanging issue - var isNamedPipe = ClientChannel is NamedPipeClientChannel || ServerChannel is NamedPipeServerChannel; - if (!isNamedPipe) - { - // encrypted roundtrip validated for other channels - await Roundtrip(encryption: true).ConfigureAwait(false); - } - } - [Fact] public void DataTable_roundtrip_works_issue60() { @@ -1081,6 +904,109 @@ void AfterCall(object sender, ServerRpcContext e) => } } + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "Not applicable")] + public async Task BeforeCall_event_handler_can_cancel_method_call() + { + var beforeCallFired = new AsyncCounter(); + var afterCallFired = new AsyncCounter(); + var remoteServiceCalled = false; + + void BeforeCall(object sender, ServerRpcContext e) + { + beforeCallFired++; + + if (e.MethodCallMessage.MethodName == "TestMethod") + e.Cancel = true; + } + + void AfterCall(object sender, ServerRpcContext e) => + afterCallFired++; + + using var ctx = ValidationSyncContext.Install(); + _serverFixture.Server.BeforeCall += BeforeCall; + _serverFixture.Server.AfterCall += AfterCall; + + try + { + _serverFixture.TestService.TestMethodFake = arg => + { + remoteServiceCalled = true; + return arg; + }; + + using var client = new RemotingClient(new ClientConfig() + { + ConnectionTimeout = 0, + InvocationTimeout = 0, + SendTimeout = 0, + Channel = ClientChannel, + MessageEncryption = false, + ServerPort = _serverFixture.Server.Config.NetworkPort, + }); + + client.Connect(); + + var proxy = client.CreateProxy(); + var ex = Assert.Throws(() => + proxy.TestMethod("Hello")); + + Assert.Contains("cancel", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.False(remoteServiceCalled); + + await beforeCallFired[1].Timeout(1).ConfigureAwait(false); + await afterCallFired[1].Timeout(1).ConfigureAwait(false); + } + finally + { + _serverFixture.ServerErrorCount = 0; + _serverFixture.Server.AfterCall -= AfterCall; + _serverFixture.Server.BeforeCall -= BeforeCall; + } + } + + [Fact] + public void BeforeCall_event_handler_can_cancel_with_custom_exception() + { + void BeforeCall(object sender, ServerRpcContext e) + { + if (e.MethodCallMessage.MethodName == "TestMethod") + { + e.Cancel = true; + e.Exception = new InvalidOperationException("Custom reason"); + } + } + + using var ctx = ValidationSyncContext.Install(); + _serverFixture.Server.BeforeCall += BeforeCall; + + try + { + using var client = new RemotingClient(new ClientConfig() + { + ConnectionTimeout = 0, + InvocationTimeout = 0, + SendTimeout = 0, + Channel = ClientChannel, + MessageEncryption = false, + ServerPort = _serverFixture.Server.Config.NetworkPort, + }); + + client.Connect(); + + var proxy = client.CreateProxy(); + var ex = Assert.Throws(() => + proxy.TestMethod("Hello")); + + Assert.Contains("Custom reason", ex.ToString()); + } + finally + { + _serverFixture.ServerErrorCount = 0; + _serverFixture.Server.BeforeCall -= BeforeCall; + } + } + [Fact] public void BeginCall_event_handler_can_intercept_and_cancel_method_calls() { @@ -1433,6 +1359,54 @@ public void Scoped_service_is_resolved_within_the_remote_call_scope() Assert.NotEqual(proxy.ScopedServiceInstanceId, proxy.ScopedServiceInstanceId); } + [Fact] + public void Hidden_system_service_can_be_imported_on_server() + { + using var ctx = ValidationSyncContext.Install(); + + using var client = new RemotingClient(new ClientConfig() + { + ConnectionTimeout = 0, + InvocationTimeout = 0, + SendTimeout = 0, + MessageEncryption = false, + Channel = ClientChannel, + ServerPort = _serverFixture.Server.Config.NetworkPort, + }); + + client.Connect(); + + // service IDelegateProxyFactory is hidden and is only + // available on server as a dependency of a public service + var proxy = client.CreateProxy(); + Assert.True(proxy.IsImported); + } + + [Fact] + public void Hidden_system_service_cannot_be_called_from_client() + { + using var ctx = ValidationSyncContext.Install(); + + using var client = new RemotingClient(new ClientConfig() + { + ConnectionTimeout = 0, + InvocationTimeout = 0, + SendTimeout = 0, + MessageEncryption = false, + Channel = ClientChannel, + ServerPort = _serverFixture.Server.Config.NetworkPort, + }); + + client.Connect(); + + // service IDelegateProxyFactory is hidden and cannot be called remotely + var proxy = client.CreateProxy(); + var ex = Assert.Throws(() => + proxy.Create(typeof(Action), null)); + + Assert.Contains("called remotely", ex.Message); + } + [Fact] [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "Not applicable")] public async Task Logon_and_logoff_events_are_triggered() @@ -1916,6 +1890,4 @@ public void NonDeserializableObject_TriggersException() _serverFixture.ServerErrorCount = 0; } } - - -} \ No newline at end of file +} diff --git a/CoreRemoting.Tests/RpcTests_Base.cs b/CoreRemoting.Tests/RpcTests_Base.cs new file mode 100644 index 00000000..1415f263 --- /dev/null +++ b/CoreRemoting.Tests/RpcTests_Base.cs @@ -0,0 +1,89 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using CoreRemoting.RemoteDelegates; +using CoreRemoting.Channels; +using Xunit; +using Xunit.Abstractions; + +namespace CoreRemoting.Tests; + +[Collection("CoreRemoting")] +public abstract class RpcTests_Base : IClassFixture +{ + protected readonly ServerFixture _serverFixture; + protected readonly ITestOutputHelper _testOutputHelper; + protected bool _remoteServiceCalled; + + protected virtual IServerChannel ServerChannel => null; + + protected virtual IClientChannel ClientChannel => null; + + protected RpcTests_Base(ServerFixture serverFixture, ITestOutputHelper testOutputHelper) + { + _serverFixture = serverFixture; + _testOutputHelper = testOutputHelper; + + ConfigureServer(_serverFixture.ServerConfig); + + _serverFixture.TestService.TestMethodFake = arg => + { + _remoteServiceCalled = true; + return arg; + }; + + _serverFixture.Start(ServerChannel); + + ServiceEventHub.DelegateInvoker = DelegateInvoker; + } + + /// + /// Gives derived tests a chance to configure the server before it starts. + /// + /// Server configuration object that will be used to create the server. + protected virtual void ConfigureServer(ServerConfig config) + { + } + + /// + /// Gets the delegate invoker to be used for event handler tests. + /// + protected virtual IDelegateInvoker DelegateInvoker => new SafeDynamicInvoker(); + + protected void CheckServerErrorCount() + { + try + { + Assert.Equal(0, _serverFixture.ServerErrorCount); + } + finally + { + if (_serverFixture.ServerErrorCount != 0) + { + var msg = $"LastServerError: {_serverFixture.LastServerError}"; + _testOutputHelper.WriteLine(msg); + Console.WriteLine(msg); + } + } + } + + /// + /// Waits until the given condition becomes true, or throws . + /// Intended for asserting asynchronous server-side effects that occur after + /// a client call has already returned. + /// + /// Condition to poll. + /// Maximum time to wait. + protected static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + var stopwatch = Stopwatch.StartNew(); + + while (!condition()) + { + if (stopwatch.Elapsed > timeout) + throw new TimeoutException(); + + await Task.Delay(20).ConfigureAwait(false); + } + } +} diff --git a/CoreRemoting.Tests/RpcTests_Events.cs b/CoreRemoting.Tests/RpcTests_Events.cs new file mode 100644 index 00000000..6b852327 --- /dev/null +++ b/CoreRemoting.Tests/RpcTests_Events.cs @@ -0,0 +1,1071 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using CoreRemoting.RemoteDelegates; +using CoreRemoting.Tests.Tools; +using CoreRemoting.Threading; +using CoreRemoting.Toolbox; +using Xunit; + +namespace CoreRemoting.Tests; + +public partial class RpcTests +{ + private async Task GetServerTrackerAsync(RemotingClient client) + { + var sessionId = client.SessionId ?? throw new InvalidOperationException("Client has no active session."); + var sessionRepository = _serverFixture.Server.SessionRepository as SessionRepository; + var session = await sessionRepository.GetSession(sessionId).ConfigureAwait(false); + return session.SubscriptionTracker; + } + + [Fact] + public async Task Delegate_invoked_on_server_should_callback_client() + { + string argumentFromServer = null; + var serverCalled = new TaskCompletionSource(); + + void ClientAction() + { + using var ctx = ValidationSyncContext.Install(); + + try + { + using var client = new RemotingClient( + new ClientConfig() + { + ConnectionTimeout = 0, + Channel = ClientChannel, + MessageEncryption = false, + ServerPort = _serverFixture.Server.Config.NetworkPort, + }); + + client.Connect(); + + var proxy = client.CreateProxy(); + proxy.TestMethodWithDelegateArg(arg => + { + argumentFromServer = arg; + serverCalled.TrySetResult(true); + }); + } + catch (Exception e) + { + _testOutputHelper.WriteLine(e.ToString()); + throw; + } + } + + var clientThread = new Thread(ClientAction); + clientThread.Start(); + clientThread.Join(); + + await serverCalled.Task.Timeout(1); + Assert.Equal("test", argumentFromServer); + CheckServerErrorCount(); + } + + [Theory] + [InlineData("TestService_Singleton_Service")] + [InlineData("TestService_Singleton_Factory")] + [InlineData("TestService_SingleCall_Service")] + [InlineData("TestService_SingleCall_Factory")] + [InlineData("TestService_Scoped_Service")] + [InlineData("TestService_Scoped_Factory")] + public virtual void Events_should_work_remotely(string serviceName) + { + using var ctx = ValidationSyncContext.Install(); + + var serviceEventCallCount = 0; + var customDelegateEventCallCount = 0; + + using var client = new RemotingClient( + new ClientConfig() + { + ConnectionTimeout = 0, + SendTimeout = 0, + Channel = ClientChannel, + MessageEncryption = false, + ServerPort = _serverFixture.Server.Config.NetworkPort, + }); + + client.Connect(); + + var proxy = client.CreateProxy(serviceName); + + var serviceEventResetEvent = new ManualResetEventSlim(initialState: false); + var customDelegateEventResetEvent = new ManualResetEventSlim(initialState: false); + + proxy.ServiceEvent += () => + { + Interlocked.Increment(ref serviceEventCallCount); + serviceEventResetEvent.Set(); + }; + + proxy.CustomDelegateEvent += _ => + { + Interlocked.Increment(ref customDelegateEventCallCount); + customDelegateEventResetEvent.Set(); + }; + + proxy.FireServiceEvent(); + proxy.FireCustomDelegateEvent(); + + serviceEventResetEvent.Wait(1000); + customDelegateEventResetEvent.Wait(1000); + + Assert.Equal(1, serviceEventCallCount); + Assert.Equal(1, customDelegateEventCallCount); + CheckServerErrorCount(); + } + + [Theory] + [InlineData("TestService_Singleton_Service", true)] + [InlineData("TestService_Singleton_Service", false)] + [InlineData("TestService_Singleton_Factory", true)] + [InlineData("TestService_Singleton_Factory", false)] + [InlineData("TestService_SingleCall_Service", true)] + [InlineData("TestService_SingleCall_Service", false)] + [InlineData("TestService_SingleCall_Factory", true)] + [InlineData("TestService_SingleCall_Factory", false)] + [InlineData("TestService_Scoped_Service", true)] + [InlineData("TestService_Scoped_Service", false)] + [InlineData("TestService_Scoped_Factory", true)] + [InlineData("TestService_Scoped_Factory", false)] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task Multicast_subscription_semantics_are_preserved(string serviceName, bool sameHandler) + { + using var ctx = ValidationSyncContext.Install(); + + using var client = new RemotingClient(new ClientConfig() + { + ConnectionTimeout = 0, + InvocationTimeout = 0, + SendTimeout = 0, + Channel = ClientChannel, + MessageEncryption = false, + ServerPort = _serverFixture.Server.Config.NetworkPort, + }); + + client.Connect(); + + var serverTracker = await GetServerTrackerAsync(client); + var proxy = client.CreateProxy(serviceName); + var fired = new AsyncCounter(); + + // Two lambda expressions at different source positions compile to + // two distinct methods, so their delegates are not equal. When + // sameHandler is true, all three variables point to the same delegate. + var handler1 = () => { fired++; }; + var handler2 = sameHandler ? handler1 : () => fired++; + var handler3 = sameHandler ? handler1 : () => fired++; + + // Three subscriptions -> three invocations per fire. + proxy.ServiceEvent += handler1; + proxy.ServiceEvent += handler2; + proxy.ServiceEvent += handler3; + Assert.Equal(3, client.SubscriptionTracker.Count); + Assert.Equal(client.SubscriptionTracker.Checksum, serverTracker.Checksum); + + proxy.FireServiceEvent(); + await fired.WaitForValue(3).Timeout(1).ConfigureAwait(false); + Assert.Equal(3, fired.Value); + + // Remove one subscription -> two invocations per fire. + proxy.ServiceEvent -= handler1; + Assert.Equal(2, client.SubscriptionTracker.Count); + Assert.Equal(client.SubscriptionTracker.Checksum, serverTracker.Checksum); + + proxy.FireServiceEvent(); + await fired.WaitForValue(5).Timeout(1).ConfigureAwait(false); + Assert.Equal(5, fired.Value); + + // Remove one more -> one invocation per fire. + proxy.ServiceEvent -= handler2; + Assert.Equal(1, client.SubscriptionTracker.Count); + Assert.Equal(client.SubscriptionTracker.Checksum, serverTracker.Checksum); + + proxy.FireServiceEvent(); + await fired.WaitForValue(6).Timeout(1).ConfigureAwait(false); + Assert.Equal(6, fired.Value); + + // Remove the last one -> no invocations. + proxy.ServiceEvent -= handler3; + Assert.Equal(0, client.SubscriptionTracker.Count); + Assert.Equal(client.SubscriptionTracker.Checksum, serverTracker.Checksum); + + proxy.FireServiceEvent(); + Assert.False(await fired.WaitForValue(7).Expire(0.5).ConfigureAwait(false)); + Assert.Equal(6, fired.Value); + + CheckServerErrorCount(); + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task Shutting_down_one_proxy_doesnt_break_subscription_on_another() + { + using var ctx = ValidationSyncContext.Install(); + + using var client = new RemotingClient(new ClientConfig() + { + ConnectionTimeout = 0, + InvocationTimeout = 0, + SendTimeout = 0, + Channel = ClientChannel, + MessageEncryption = false, + ServerPort = _serverFixture.Server.Config.NetworkPort, + }); + + client.Connect(); + + var serverTracker = await GetServerTrackerAsync(client); + var proxy1 = client.CreateProxy("TestService_Singleton_Service"); + var proxy2 = client.CreateProxy("TestService_Singleton_Factory"); + + var fired = new AsyncCounter(); + Action handler = () => { fired++; }; + + // Same delegate subscribed to events of two different proxies. + proxy1.ServiceEvent += handler; + proxy2.ServiceEvent += handler; + + Assert.Equal(2, client.SubscriptionTracker.Count); + Assert.Equal(client.SubscriptionTracker.Checksum, serverTracker.Checksum); + + // Both subscriptions are live. + proxy1.FireServiceEvent(); + await fired.WaitForValue(1).Timeout(1).ConfigureAwait(false); + Assert.Equal(1, fired.Value); + + proxy2.FireServiceEvent(); + await fired.WaitForValue(2).Timeout(1).ConfigureAwait(false); + Assert.Equal(2, fired.Value); + + var hub1 = _serverFixture.Server.ServiceRegistry + .GetServiceRegistration("TestService_Singleton_Service").EventHub; + var hub2 = _serverFixture.Server.ServiceRegistry + .GetServiceRegistration("TestService_Singleton_Factory").EventHub; + + Assert.Equal(1, hub1.GetHandlerCount(nameof(ITestService.ServiceEvent))); + Assert.Equal(1, hub2.GetHandlerCount(nameof(ITestService.ServiceEvent))); + + // Shut down the first proxy. Only its own subscription must be + // unregistered. The subscription made through the second proxy + // must survive. + client.ShutdownProxy(proxy1); + + // SubscriptionTracker is bypassed by ShutdownProxy on both sides, + // so both trackers remain stale and stay equal. This is intentional. + Assert.Equal(client.SubscriptionTracker.Checksum, serverTracker.Checksum); + + // The actual server-side hub for proxy1 must lose the handler + // (fire-and-forget, wait for it). + await WaitUntilAsync( + () => hub1.GetHandlerCount(nameof(ITestService.ServiceEvent)) == 0, + timeout: TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + // hub2 (proxy2) is untouched. + Assert.Equal(1, hub2.GetHandlerCount(nameof(ITestService.ServiceEvent))); + + // And proxy2 still fires correctly. + proxy2.FireServiceEvent(); + await fired.WaitForValue(3).Timeout(1).ConfigureAwait(false); + Assert.Equal(3, fired.Value); + + CheckServerErrorCount(); + } + + // Fun fact: + // - ServerConfig.MessageEncryption setting doesn't affect anything! + // - RemotingServer accepts both encrypted and unencrypted connections. + + [Theory] + [InlineData(false)] + [InlineData(true)] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task Disposed_client_subscription_doesnt_break_other_clients(bool encryption) + { + using var ctx = ValidationSyncContext.Install(); + + try + { + RemotingClient CreateClient() => new(new() + { + Channel = ClientChannel, + ServerPort = _serverFixture.Server.Config.NetworkPort, + MessageEncryption = encryption, + ConnectionTimeout = encryption ? 0 : 5, + KeySize = 1024 + }); + + using var client1 = CreateClient(); + using var client2 = CreateClient(); + + client1.Connect(); + client2.Connect(); + + var proxy1 = client1.CreateProxy(); + var fired1 = new TaskCompletionSource(); + proxy1.ServiceEvent += () => fired1.TrySetResult(true); + + var proxy2 = client2.CreateProxy(); + var fired2 = new TaskCompletionSource(); + proxy2.ServiceEvent += () => fired2.TrySetResult(true); + + // Early disposal: proxy1 subscription isn't canceled, but only client2 + // must receive the event. + client1.Disconnect(); + + proxy2.FireServiceEvent(); + Assert.True(await fired2.Task.ConfigureAwait(false)); + Assert.True(fired2.Task.IsCompleted); + Assert.False(fired1.Task.IsCompleted); + } + finally + { + // Reset the error counter for other tests. + _serverFixture.ServerErrorCount = 0; + } + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task Subscription_trackers_stay_in_sync() + { + using var ctx = ValidationSyncContext.Install(); + + using var client = new RemotingClient(new ClientConfig() + { + ConnectionTimeout = 0, + InvocationTimeout = 0, + SendTimeout = 0, + Channel = ClientChannel, + MessageEncryption = false, + ServerPort = _serverFixture.Server.Config.NetworkPort, + }); + + client.Connect(); + + var serverTracker = await GetServerTrackerAsync(client); + + Assert.Equal(client.SubscriptionTracker.Checksum, serverTracker.Checksum); + + var proxy1 = client.CreateProxy("TestService_Singleton_Service"); + var proxy2 = client.CreateProxy("TestService_Singleton_Factory"); + + Action handler = () => { }; + + proxy1.ServiceEvent += handler; + Assert.Equal(client.SubscriptionTracker.Checksum, serverTracker.Checksum); + + // Same delegate on a different proxy counts as a second operation. + proxy2.ServiceEvent += handler; + Assert.Equal(client.SubscriptionTracker.Checksum, serverTracker.Checksum); + Assert.Equal(2, client.SubscriptionTracker.Count); + + proxy1.ServiceEvent -= handler; + Assert.Equal(client.SubscriptionTracker.Checksum, serverTracker.Checksum); + + proxy2.ServiceEvent -= handler; + Assert.Equal(client.SubscriptionTracker.Checksum, serverTracker.Checksum); + Assert.Equal(0, client.SubscriptionTracker.Count); + + CheckServerErrorCount(); + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task Subscription_tracker_is_isolated_per_client() + { + using var ctx = ValidationSyncContext.Install(); + + using var client1 = new RemotingClient(new ClientConfig() + { + ConnectionTimeout = 0, + InvocationTimeout = 0, + SendTimeout = 0, + Channel = ClientChannel, + MessageEncryption = false, + ServerPort = _serverFixture.Server.Config.NetworkPort, + }); + + using var client2 = new RemotingClient(new ClientConfig() + { + ConnectionTimeout = 0, + InvocationTimeout = 0, + SendTimeout = 0, + Channel = ClientChannel, + MessageEncryption = false, + ServerPort = _serverFixture.Server.Config.NetworkPort, + }); + + client1.Connect(); + client2.Connect(); + + var serverTracker1 = await GetServerTrackerAsync(client1); + var serverTracker2 = await GetServerTrackerAsync(client2); + + var proxy1 = client1.CreateProxy(); + var proxy2 = client2.CreateProxy(); + + Action handler = () => { }; + + proxy1.ServiceEvent += handler; + proxy1.ServiceEvent += handler; + proxy2.ServiceEvent += handler; + + Assert.Equal(2, client1.SubscriptionTracker.Count); + Assert.Equal(1, client2.SubscriptionTracker.Count); + + Assert.Equal(client1.SubscriptionTracker.Checksum, serverTracker1.Checksum); + Assert.Equal(client2.SubscriptionTracker.Checksum, serverTracker2.Checksum); + Assert.NotEqual(serverTracker1.Checksum, serverTracker2.Checksum); + + CheckServerErrorCount(); + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task Subscription_trackers_ignore_non_event_delegate_arguments() + { + using var ctx = ValidationSyncContext.Install(); + + using var client = new RemotingClient(new ClientConfig() + { + ConnectionTimeout = 0, + InvocationTimeout = 0, + SendTimeout = 0, + Channel = ClientChannel, + MessageEncryption = false, + ServerPort = _serverFixture.Server.Config.NetworkPort, + }); + + client.Connect(); + + var serverTracker = await GetServerTrackerAsync(client); + var proxy = client.CreateProxy(); + + // Regular method that takes a delegate, not an event accessor. + proxy.TestMethodWithDelegateArg(_ => { }); + + Assert.Equal(0, client.SubscriptionTracker.Count); + Assert.Equal(0, serverTracker.Count); + Assert.Equal(client.SubscriptionTracker.Checksum, serverTracker.Checksum); + + CheckServerErrorCount(); + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task Subscription_trackers_can_detect_when_client_and_server_subscriptions_are_out_of_sync() + { + void BeforeCall(object sender, ServerRpcContext e) + { + if (e.MethodCallMessage.MethodName == "add_ServiceEvent") + e.Cancel = true; + } + + using var ctx = ValidationSyncContext.Install(); + _serverFixture.Server.BeforeCall += BeforeCall; + + try + { + using var client = new RemotingClient(new ClientConfig() + { + ConnectionTimeout = 0, + InvocationTimeout = 0, + SendTimeout = 0, + Channel = ClientChannel, + MessageEncryption = false, + ServerPort = _serverFixture.Server.Config.NetworkPort, + }); + + client.Connect(); + + var serverTracker = await GetServerTrackerAsync(client); + var proxy = client.CreateProxy("TestService_Singleton_Service"); + + Action handler = () => { }; + + // Client tracker is updated before the RPC is sent. + // Server tracker is updated only after BeforeCall, which is rejected. + // The client sees an error, but its tracker has already counted the add. + Assert.Throws(() => { proxy.ServiceEvent += handler; }); + + Assert.Equal(1, client.SubscriptionTracker.Count); + Assert.Equal(0, serverTracker.Count); + Assert.NotEqual(client.SubscriptionTracker.Checksum, serverTracker.Checksum); + + CheckServerErrorCount(); + } + finally + { + _serverFixture.ServerErrorCount = 0; + _serverFixture.Server.BeforeCall -= BeforeCall; + } + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task Disconnecting_client_removes_all_subscriptions_on_server() + { + using var ctx = ValidationSyncContext.Install(); + using var client = CreateConnectedClient(); + + const string serviceName = "TestService_Singleton_Service"; + var proxy = client.CreateProxy(serviceName); + proxy.ServiceEvent += () => { }; + + var registration = _serverFixture.Server.ServiceRegistry.GetServiceRegistration(serviceName); + var hub = registration.EventHub; + + Assert.Equal(1, hub.GetHandlerCount(nameof(ITestService.ServiceEvent))); + + await client.DisconnectAsync().ConfigureAwait(false); + + await WaitUntilAsync( + () => hub.GetHandlerCount(nameof(ITestService.ServiceEvent)) == 0, + timeout: TimeSpan.FromSeconds(20)).ConfigureAwait(false); + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task Disconnect_does_not_block_on_in_flight_goodbye_counter() + { + const int largeDisposeTimeout = 5000; + const int expectedDisposeWindow = 1; + + var config = _serverFixture.Server.Config; + var oldTimeout = config.WaitTimeForCurrentlyProcessedMessagesOnDispose; + config.WaitTimeForCurrentlyProcessedMessagesOnDispose = largeDisposeTimeout; + + try + { + using var ctx = ValidationSyncContext.Install(); + using var client = CreateConnectedClient(); + + var sessionId = client.SessionId!.Value; + var sessionRepo = (SessionRepository)_serverFixture.Server.SessionRepository; + + // the server must register the session before sending the handshake, + // so a connected client always observes its session in the repository + var session = await sessionRepo.GetSession(sessionId).ConfigureAwait(false); + Assert.NotNull(session); + + var beforeDisposeFired = new TaskCompletionSource(); + session.BeforeDispose += () => beforeDisposeFired.TrySetResult(true); + + client.Disconnect(); + + // if goodbye is still counted as in-flight when dispose starts, + // dispose waits the whole WaitTimeForCurrentlyProcessedMessagesOnDispose + // and BeforeDispose fires only after that timeout expires + await beforeDisposeFired.Task.Timeout(expectedDisposeWindow).ConfigureAwait(false); + } + finally + { + config.WaitTimeForCurrentlyProcessedMessagesOnDispose = oldTimeout; + } + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task Shutting_down_proxy_unsubscribes_from_server_side_event() + { + using var ctx = ValidationSyncContext.Install(); + + using var client = new RemotingClient(new ClientConfig() + { + ConnectionTimeout = 0, + InvocationTimeout = 0, + SendTimeout = 0, + Channel = ClientChannel, + MessageEncryption = false, + ServerPort = _serverFixture.Server.Config.NetworkPort, + }); + + client.Connect(); + + var proxy = client.CreateProxy("TestService_Singleton_Service"); + var fired = new AsyncCounter(); + Action handler = () => { fired++; }; + + proxy.ServiceEvent += handler; + + // The subscription is live. + proxy.FireServiceEvent(); + await fired.WaitForValue(1).Timeout(1).ConfigureAwait(false); + Assert.Equal(1, fired.Value); + + // Shutting down the proxy must unsubscribe from the server side as well. + client.ShutdownProxy(proxy); + + // Fire the event again. The server must not invoke the dead subscription, + // so the client-side handler must not be called. + var serverProxy = client.CreateProxy("TestService_Singleton_Service"); + serverProxy.FireServiceEvent(); + + // the handler is not invoked because the proxy was shut down. + Assert.False(await fired.WaitForValue(2).Expire(0.5).ConfigureAwait(false)); + Assert.Equal(1, fired.Value); + + CheckServerErrorCount(); + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task Shutting_down_proxy_unsubscribes_from_server_side_event_sync() + { + using var ctx = ValidationSyncContext.Install(); + + using var client = new RemotingClient(new ClientConfig() + { + ConnectionTimeout = 0, + InvocationTimeout = 0, + SendTimeout = 0, + Channel = ClientChannel, + MessageEncryption = false, + ServerPort = _serverFixture.Server.Config.NetworkPort, + }); + + client.Connect(); + + const string serviceName = "TestService_Singleton_Service"; + var proxy = client.CreateProxy(serviceName); + var fired = new AsyncCounter(); + Action handler = () => { fired++; }; + + proxy.ServiceEvent += handler; + + proxy.FireServiceEvent(); + await fired.WaitForValue(1).Timeout(1).ConfigureAwait(false); + Assert.Equal(1, fired.Value); + + // Server side holds one subscription. + var registration = _serverFixture.Server.ServiceRegistry.GetServiceRegistration(serviceName); + var hub = registration.EventHub; + + Assert.Equal(1, hub.GetHandlerCount(nameof(ITestService.ServiceEvent))); + + // ShutdownProxy fires the server-side removal asynchronously: wait for it. + client.ShutdownProxy(proxy); + + await WaitUntilAsync( + () => hub.GetHandlerCount(nameof(ITestService.ServiceEvent)) == 0, + timeout: TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + CheckServerErrorCount(); + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task Shutting_down_proxy_unsubscribes_from_server_side_event_async() + { + using var ctx = ValidationSyncContext.Install(); + + using var client = new RemotingClient(new ClientConfig() + { + ConnectionTimeout = 0, + InvocationTimeout = 0, + SendTimeout = 0, + Channel = ClientChannel, + MessageEncryption = false, + ServerPort = _serverFixture.Server.Config.NetworkPort, + }); + + client.Connect(); + + const string serviceName = "TestService_Singleton_Service"; + var proxy = client.CreateProxy(serviceName); + var fired = new AsyncCounter(); + Action handler = () => { fired++; }; + + proxy.ServiceEvent += handler; + + proxy.FireServiceEvent(); + await fired.WaitForValue(1).Timeout(1).ConfigureAwait(false); + Assert.Equal(1, fired.Value); + + // Server side holds one subscription. + var registration = _serverFixture.Server.ServiceRegistry.GetServiceRegistration(serviceName); + var hub = registration.EventHub; + + Assert.Equal(1, hub.GetHandlerCount(nameof(ITestService.ServiceEvent))); + + // ShutdownProxyAsync awaits the server-side removal: deterministic, + // no WaitUntilAsync needed. + await client.ShutdownProxyAsync(proxy).Timeout(5).ConfigureAwait(false); + + Assert.Equal(0, hub.GetHandlerCount(nameof(ITestService.ServiceEvent))); + Assert.Equal(0, client.PendingUnsubscribeCount); + + CheckServerErrorCount(); + } + + // --- ScheduleServerUnsubscribe --- + + [Fact] + public async Task ScheduleServerUnsubscribe_with_empty_input_is_noop() + { + using var client = CreateConnectedClient(); + + await client.ScheduleServerUnsubscribe([]).Timeout(1); + await client.ScheduleServerUnsubscribe(null).Timeout(1); + + Assert.Equal(0, client.PendingUnsubscribeCount); + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task ScheduleServerUnsubscribe_removes_subscription_on_server() + { + using var ctx = ValidationSyncContext.Install(); + using var client = CreateConnectedClient(); + + const string serviceName = "TestService_Singleton_Service"; + var proxy = client.CreateProxy(serviceName); + Action handler = () => { }; + proxy.ServiceEvent += handler; + + var registration = _serverFixture.Server.ServiceRegistry + .GetServiceRegistration(serviceName); + var hub = registration.EventHub; + + Assert.Equal(1, hub.GetHandlerCount(nameof(ITestService.ServiceEvent))); + + // Unregister locally to capture keys WITHOUT notifying the server — + // the server still holds the subscription until ScheduleServerUnsubscribe runs. + // Use the interceptor (not the Castle proxy) — that is what the registry stores. + var interceptor = RemotingClient.FindServiceProxy(proxy); + Assert.NotNull(interceptor); + + var keys = client.ClientDelegateRegistry + .UnregisterClientDelegatesOfServiceProxy(interceptor); + Assert.Single(keys); + + await client.ScheduleServerUnsubscribe(keys).Timeout(5).ConfigureAwait(false); + + await WaitUntilAsync( + () => hub.GetHandlerCount(nameof(ITestService.ServiceEvent)) == 0, + timeout: TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + CheckServerErrorCount(); + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task ScheduleServerUnsubscribe_is_noop_when_disconnected() + { + var client = CreateConnectedClient(); + + try + { + client.Disconnect(); + + var errors = new List(); + client.ErrorHandler = ex => errors.Add(ex); + + await client.ScheduleServerUnsubscribe([Guid.NewGuid()]).Timeout(1).ConfigureAwait(false); + + Assert.Empty(errors); + Assert.Equal(0, client.PendingUnsubscribeCount); + } + finally + { + client.Dispose(); + } + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task ScheduleServerUnsubscribe_cleans_pending_map_after_completion() + { + using var client = CreateConnectedClient(); + + // No real subscription needed: RemoveHandlers is idempotent on the server + // (unknown keys return 0), and this test only verifies the client-side + // pending-map cleanup. + var key = Guid.NewGuid(); + + await client.ScheduleServerUnsubscribe([key]).Timeout(5).ConfigureAwait(false); + + Assert.Equal(0, client.PendingUnsubscribeCount); + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task Invoke_for_unknown_handler_triggers_server_side_removal() + { + using var ctx = ValidationSyncContext.Install(); + using var client = CreateConnectedClient(); + + const string serviceName = "TestService_Singleton_Service"; + var proxy = client.CreateProxy(serviceName); + var fired = new AsyncCounter(); + Action handler = () => { fired++; }; + proxy.ServiceEvent += handler; + + var registration = _serverFixture.Server.ServiceRegistry + .GetServiceRegistration(serviceName); + var hub = registration.EventHub; + + Assert.Equal(1, hub.GetHandlerCount(nameof(ITestService.ServiceEvent))); + + // Drop the handler on the client only: the server still holds the + // subscription and doesn't know it's dead yet. + var interceptor = RemotingClient.FindServiceProxy(proxy); + Assert.NotNull(interceptor); + + var keys = client.ClientDelegateRegistry + .UnregisterClientDelegatesOfServiceProxy(interceptor); + Assert.Single(keys); + + // Sanity check: the server-side hub is still intact at this point. + Assert.Equal(1, hub.GetHandlerCount(nameof(ITestService.ServiceEvent))); + + // Fire the event: the server sends an "invoke" for the dead handler, + // the client fails to resolve it locally, and schedules the nack. + var serverProxy = client.CreateProxy(serviceName); + serverProxy.FireServiceEvent(); + + // The nack is fire-and-forget, so the server-side cleanup is + // eventually consistent: wait for it. + await WaitUntilAsync( + () => hub.GetHandlerCount(nameof(ITestService.ServiceEvent)) == 0, + timeout: TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + // The dead handler was never invoked locally. + Assert.Equal(0, fired.Value); + + CheckServerErrorCount(); + } + + // --- ShutdownProxy idempotency --- + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task ShutdownProxy_is_idempotent() + { + using var ctx = ValidationSyncContext.Install(); + using var client = CreateConnectedClient(); + + const string serviceName = "TestService_Singleton_Service"; + var proxy = client.CreateProxy(serviceName); + Action handler = () => { }; + proxy.ServiceEvent += handler; + + var registration = _serverFixture.Server.ServiceRegistry + .GetServiceRegistration(serviceName); + var hub = registration.EventHub; + + // First call unregisters and fires the RPC. Second call must be a + // no-op: registry already empty, Shutdown already invoked. + client.ShutdownProxy(proxy); + client.ShutdownProxy(proxy); + + await WaitUntilAsync( + () => hub.GetHandlerCount(nameof(ITestService.ServiceEvent)) == 0, + timeout: TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + Assert.Equal(0, hub.GetHandlerCount(nameof(ITestService.ServiceEvent))); + CheckServerErrorCount(); + } + + // --- Delegate argument disposal --- + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task ShutdownProxyAsync_marks_delegate_argument_as_disposed() + { + using var ctx = ValidationSyncContext.Install(); + using var client = CreateConnectedClient(); + + _serverFixture.TestService.LastReceivedDelegate = null; + + // default registration resolves to _serverFixture.TestService + var proxy = client.CreateProxy(); + proxy.TestMethodWithDelegateArg(_ => { }); + + var stored = _serverFixture.TestService.LastReceivedDelegate; + Assert.NotNull(stored); + Assert.False(stored.IsDisposed()); + + var sessionId = client.SessionId!.Value; + var sessionRepo = (SessionRepository)_serverFixture.Server.SessionRepository; + var session = await sessionRepo.GetSession(sessionId).ConfigureAwait(false); + + Assert.Equal(1, session._delegateProxyCache?.Count ?? 0); + + await client.ShutdownProxyAsync(proxy).ConfigureAwait(false); + + Assert.True(stored.IsDisposed()); + Assert.Equal(0, session._delegateProxyCache?.Count ?? 0); + + // invoking a disposed delegate is a silent no-op + var ex = Record.Exception(() => stored("x")); + Assert.Null(ex); + + CheckServerErrorCount(); + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task ShutdownProxy_marks_delegate_argument_as_disposed() + { + using var ctx = ValidationSyncContext.Install(); + using var client = CreateConnectedClient(); + + _serverFixture.TestService.LastReceivedDelegate = null; + + var proxy = client.CreateProxy(); + proxy.TestMethodWithDelegateArg(_ => { }); + + var stored = _serverFixture.TestService.LastReceivedDelegate; + Assert.NotNull(stored); + Assert.False(stored.IsDisposed()); + + client.ShutdownProxy(proxy); + + await WaitUntilAsync( + () => stored.IsDisposed(), + timeout: TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + CheckServerErrorCount(); + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task Dispose_of_client_marks_delegate_argument_as_disposed() + { + using var ctx = ValidationSyncContext.Install(); + + _serverFixture.TestService.LastReceivedDelegate = null; + + var client = CreateConnectedClient(); + var proxy = client.CreateProxy(); + proxy.TestMethodWithDelegateArg(_ => { }); + + var stored = _serverFixture.TestService.LastReceivedDelegate; + Assert.NotNull(stored); + Assert.False(stored.IsDisposed()); + + await client.DisposeAsync().ConfigureAwait(false); + + await WaitUntilAsync( + () => stored.IsDisposed(), + timeout: TimeSpan.FromSeconds(20)).ConfigureAwait(false); + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task Event_unsubscribe_does_not_dispose_delegate_argument() + { + using var ctx = ValidationSyncContext.Install(); + using var client = CreateClient(); + await client.ConnectAsync().ConfigureAwait(false); + + _serverFixture.TestService.LastReceivedDelegate = null; + + var proxy = client.CreateProxy(); + + // subscribe + unsubscribe an event — must not touch delegate argument cache + Action handler = () => { }; + proxy.ServiceEvent += handler; + proxy.ServiceEvent -= handler; + + // now pass a plain delegate argument + proxy.TestMethodWithDelegateArg(_ => { }); + var stored = _serverFixture.TestService.LastReceivedDelegate; + Assert.NotNull(stored); + + // -= above must not have invalidated the plain delegate argument + Assert.False(stored.IsDisposed()); + + CheckServerErrorCount(); + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task ShutdownProxy_does_not_affect_other_clients_delegate_arguments() + { + using var ctx = ValidationSyncContext.Install(); + + _serverFixture.TestService.LastReceivedDelegate = null; + + using var client1 = CreateConnectedClient(); + using var client2 = CreateConnectedClient(); + + var proxy1 = client1.CreateProxy(); + proxy1.TestMethodWithDelegateArg(_ => { }); + var stored1 = _serverFixture.TestService.LastReceivedDelegate; + Assert.NotNull(stored1); + + var proxy2 = client2.CreateProxy(); + proxy2.TestMethodWithDelegateArg(_ => { }); + var stored2 = _serverFixture.TestService.LastReceivedDelegate; + Assert.NotNull(stored2); + Assert.NotSame(stored1, stored2); + + await client1.ShutdownProxyAsync(proxy1).ConfigureAwait(false); + + Assert.True(stored1.IsDisposed()); + Assert.False(stored2.IsDisposed()); + + CheckServerErrorCount(); + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task Server_component_can_use_IsDisposed_method_to_filter_out_outdated_callbacks() + { + using var ctx = ValidationSyncContext.Install(); + + _serverFixture.TestService.LastReceivedDelegate = null; + + using var client1 = CreateConnectedClient(); + var client2 = CreateConnectedClient(); + + var proxy1 = client1.CreateProxy(); + proxy1.TestMethodWithDelegateArg(_ => { }); + + var stored1 = _serverFixture.TestService.LastReceivedDelegate; + Assert.NotNull(stored1); + + var proxy2 = client2.CreateProxy(); + proxy2.TestMethodWithDelegateArg(_ => { }); + + var stored2 = _serverFixture.TestService.LastReceivedDelegate; + Assert.NotNull(stored2); + + try + { + await client1.DisposeAsync().ConfigureAwait(false); + + await WaitUntilAsync( + stored1.IsDisposed, + timeout: TimeSpan.FromSeconds(20)) + .ConfigureAwait(false); + + // user code pattern: invoke + cleanup + var callbacks = new List> { stored1, stored2 }; + + foreach (var cb in callbacks) + cb("hello"); // dead -> silent no-op + + callbacks.RemoveAll(cb => cb.IsDisposed()); + + Assert.Single(callbacks); + Assert.Same(stored2, callbacks[0]); + Assert.False(stored2.IsDisposed()); + } + finally + { + client2.Dispose(); + } + + CheckServerErrorCount(); + } +} diff --git a/CoreRemoting.Tests/RpcTests_ServiceInvoker.cs b/CoreRemoting.Tests/RpcTests_ServiceInvoker.cs new file mode 100644 index 00000000..10861f4b --- /dev/null +++ b/CoreRemoting.Tests/RpcTests_ServiceInvoker.cs @@ -0,0 +1,316 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using CoreRemoting.DependencyInjection; +using CoreRemoting.Serialization; +using CoreRemoting.Tests.Tools; +using CoreRemoting.Threading; +using CoreRemoting.Toolbox; +using Xunit; + +namespace CoreRemoting.Tests; + +public partial class RpcTests +{ + internal interface IInternalEchoService + { + string Echo(string text); + + int Sum(params int[] values); + } + + private class InternalEchoService : IInternalEchoService + { + public string Echo(string text) => text; + + public int Sum(params int[] values) => + values?.Sum() ?? 0; + } + + private interface IPrivateEchoService + { + string Echo(string text); + + int Sum(params int[] values); + } + + private class PrivateEchoService : IPrivateEchoService + { + public string Echo(string text) => text; + + public int Sum(params int[] values) => + values?.Sum() ?? 0; + } + + [Fact] + public void CreateProxy_doesnt_work_with_internal_interface() + { + using var client = CreateConnectedClient(); + + // Castle DynamicProxy cannot generate a proxy for an internal interface + // unless the declaring assembly has InternalsVisibleTo("DynamicProxyGenAssembly2"). + // ServiceInvoker exists precisely to bypass this limitation. + var ex = Assert.Throws(() => client.CreateProxy()); + Assert.Contains("not accessible", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CreateProxy_doesnt_work_with_private_interface() + { + using var client = CreateConnectedClient(); + + // Castle DynamicProxy cannot generate a proxy for a private nested interface: + // the generated proxy assembly has no way to reference it, and InternalsVisibleTo + // doesn't help because the type is not even internal. + var ex = Assert.Throws(() => client.CreateProxy()); + Assert.Contains("not accessible", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ServiceInvoker_works_with_internal_interface() + { + _serverFixture.Server.ServiceRegistry.RegisterService( + () => new InternalEchoService(), + ServiceLifetime.Singleton); + + using var client = CreateConnectedClient(); + + using var invoker = client.CreateInvoker(); + + Assert.Equal("test", invoker.Invoke(s => s.Echo("test"))); + Assert.Equal(6, invoker.Invoke(s => s.Sum(1, 2, 3))); + Assert.Equal(0, invoker.Invoke(s => s.Sum())); + + CheckServerErrorCount(); + } + + [Fact] + public void ServiceInvoker_works_with_private_interface() + { + _serverFixture.Server.ServiceRegistry.RegisterService( + () => new PrivateEchoService(), + ServiceLifetime.Singleton); + + using var client = CreateConnectedClient(); + + using var invoker = client.CreateInvoker(); + + Assert.Equal("test", invoker.Invoke(s => s.Echo("test"))); + Assert.Equal(6, invoker.Invoke(s => s.Sum(1, 2, 3))); + Assert.Equal(0, invoker.Invoke(s => s.Sum())); + + CheckServerErrorCount(); + } + + [Fact] + public void ServiceInvoker_throws_ObjectDisposedException_after_Dispose() + { + using var client = CreateConnectedClient(); + + var invoker = client.CreateInvoker(); + invoker.Dispose(); + + Assert.Throws(() => invoker.Invoke(s => s.Echo("test"))); + } + + [Fact] + public void ServiceInvoker_propagates_remote_exception() + { + using var client = CreateConnectedClient(); + using var invoker = client.CreateInvoker(); + + try + { + var ex = Assert.Throws(() => + invoker.Invoke(s => s.Error("boom"))); + + Assert.Equal("boom", ex.GetInnermostException().Message); + } + finally + { + _serverFixture.ServerErrorCount = 0; + } + } + + [Fact] + public void ServiceInvoker_supports_oneway_methods() + { + using var client = CreateConnectedClient(); + using var invoker = client.CreateInvoker(); + + invoker.Invoke(s => s.OneWayMethod()); + + CheckServerErrorCount(); + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task ServiceInvoker_supports_delegate_arguments() + { + using var ctx = ValidationSyncContext.Install(); + using var client = CreateConnectedClient(); + using var invoker = client.CreateInvoker(); + + string received = null; + var called = new AsyncCounter(); + + Action handler = arg => + { + received = arg; + called++; + }; + + invoker.Invoke(s => s.TestMethodWithDelegateArg(handler)); + + await called.WaitForValue(1).Timeout(1).ConfigureAwait(false); + + Assert.Equal("test", received); + CheckServerErrorCount(); + } + + [Theory] + [InlineData("TestService_Singleton_Service")] + [InlineData("TestService_Singleton_Factory")] + [InlineData("TestService_SingleCall_Service")] + [InlineData("TestService_SingleCall_Factory")] + [InlineData("TestService_Scoped_Service")] + [InlineData("TestService_Scoped_Factory")] + public void ServiceInvoker_sync_value(string serviceName) + { + using var client = CreateConnectedClient(); + using var invoker = client.CreateInvoker(serviceName); + + var result = invoker.Invoke(s => s.Echo("test")); + + Assert.Equal("test", result); + CheckServerErrorCount(); + } + + [Theory] + [InlineData("TestService_Singleton_Service")] + [InlineData("TestService_Singleton_Factory")] + [InlineData("TestService_SingleCall_Service")] + [InlineData("TestService_SingleCall_Factory")] + [InlineData("TestService_Scoped_Service")] + [InlineData("TestService_Scoped_Factory")] + public void ServiceInvoker_sync_void(string serviceName) + { + using var client = CreateConnectedClient(); + using var invoker = client.CreateInvoker(serviceName); + + invoker.Invoke(s => s.SaveLastInstance()); + + // same instance check will succeed only for singletons + var result = invoker.Invoke(s => s.CheckLastSavedInstance()); + var isSingleton = serviceName.Contains("Singleton"); + Assert.Equal(isSingleton, result); + + CheckServerErrorCount(); + } + + [Theory] + [InlineData("TestService_Singleton_Service")] + [InlineData("TestService_Singleton_Factory")] + [InlineData("TestService_SingleCall_Service")] + [InlineData("TestService_SingleCall_Factory")] + [InlineData("TestService_Scoped_Service")] + [InlineData("TestService_Scoped_Factory")] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task ServiceInvoker_Task_of_T(string serviceName) + { + using var ctx = ValidationSyncContext.Install(); + using var client = CreateConnectedClient(); + using var invoker = client.CreateInvoker(serviceName); + + var result = await invoker.Invoke(s => s.EchoAsync("test")).ConfigureAwait(false); + + Assert.Equal("test", result); + CheckServerErrorCount(); + } + + [Theory] + [InlineData("TestService_Singleton_Service")] + [InlineData("TestService_Singleton_Factory")] + [InlineData("TestService_SingleCall_Service")] + [InlineData("TestService_SingleCall_Factory")] + [InlineData("TestService_Scoped_Service")] + [InlineData("TestService_Scoped_Factory")] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task ServiceInvoker_ValueTask_of_T(string serviceName) + { + using var ctx = ValidationSyncContext.Install(); + using var client = CreateConnectedClient(); + using var invoker = client.CreateInvoker(serviceName); + + var result = await invoker.Invoke(s => s.EchoValueTaskAsync("test")).ConfigureAwait(false); + + Assert.Equal("test", result); + CheckServerErrorCount(); + } + + [Theory] + [InlineData("TestService_Singleton_Service")] + [InlineData("TestService_Singleton_Factory")] + [InlineData("TestService_SingleCall_Service")] + [InlineData("TestService_SingleCall_Factory")] + [InlineData("TestService_Scoped_Service")] + [InlineData("TestService_Scoped_Factory")] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task ServiceInvoker_Task(string serviceName) + { + using var ctx = ValidationSyncContext.Install(); + using var client = CreateConnectedClient(); + using var invoker = client.CreateInvoker(serviceName); + + await invoker.Invoke(s => s.NoopAsync()).ConfigureAwait(false); + + CheckServerErrorCount(); + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait in test method", Justification = "")] + public async Task ServiceInvoker_DisposeAsync_is_idempotent() + { + using var ctx = ValidationSyncContext.Install(); + using var client = CreateConnectedClient(); + + var invoker = client.CreateInvoker(); + + // No subscriptions: DisposeAsync must be a no-op, not throw. + await invoker.DisposeAsync().AsTask().Timeout(1).ConfigureAwait(false); + + // Second call: also no-op (via GC.SuppressFinalize + already-null _client). + await invoker.DisposeAsync().AsTask().Timeout(1).ConfigureAwait(false); + + // Invoker is now disposed. + Assert.Throws(() => invoker.Invoke(s => s.Echo("test"))); + } + + [Fact] + public void ServiceInvoker_implements_IAsyncDisposable() + { + using var client = CreateConnectedClient(); + var invoker = client.CreateInvoker(); + + Assert.IsAssignableFrom(invoker); + invoker.Dispose(); + } + + private RemotingClient CreateClient() => new(new() + { + ConnectionTimeout = 0, + InvocationTimeout = 0, + SendTimeout = 0, + Channel = ClientChannel, + MessageEncryption = false, + ServerPort = _serverFixture.Server.Config.NetworkPort, + }); + + private RemotingClient CreateConnectedClient() + { + var client = CreateClient(); + client.Connect(); + return client; + } +} diff --git a/CoreRemoting.Tests/ServerFixture.cs b/CoreRemoting.Tests/ServerFixture.cs index 59bd90c3..4fa65c87 100644 --- a/CoreRemoting.Tests/ServerFixture.cs +++ b/CoreRemoting.Tests/ServerFixture.cs @@ -62,6 +62,7 @@ public ServerFixture() factoryDelegate: () => new TestService(), lifetime: ServiceLifetime.Scoped, serviceName: "TestService_Scoped_Factory"); + container.RegisterService(); // Service for async tests container.RegisterService( @@ -96,6 +97,7 @@ public ServerFixture() lifetime: ServiceLifetime.Scoped); container.RegisterService( lifetime: ServiceLifetime.SingleCall); + container.RegisterService(); } }; @@ -119,7 +121,7 @@ public void Start(IServerChannel channel = null) try { - Server.Start(); + Server.Start(); } catch (System.Net.Sockets.SocketException e) when (e.Message == "Address already in use") { diff --git a/CoreRemoting.Tests/SessionTests.cs b/CoreRemoting.Tests/SessionTests.cs index 5d4afae0..0fa2c722 100644 --- a/CoreRemoting.Tests/SessionTests.cs +++ b/CoreRemoting.Tests/SessionTests.cs @@ -170,6 +170,143 @@ public void RemotingSession_Dispose_should_disconnect_client() client.Dispose(); } + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait(false) in test method", Justification = "")] + public async Task Concurrent_DisposeAsync_calls_should_enter_only_once() + { + using var ctx = ValidationSyncContext.Install(); + + var capturedSession = default(RemotingSession); + + _serverFixture.TestService.TestMethodFake = _ => + { + capturedSession = RemotingSession.Current; + return null; + }; + + using var client = new RemotingClient(new ClientConfig() + { + ConnectionTimeout = 0, + MessageEncryption = false, + SendTimeout = 0, + ServerPort = _serverFixture.Server.Config.NetworkPort, + Channel = ClientChannel + }); + + await client.ConnectAsync().ConfigureAwait(false); + var proxy = client.CreateProxy(); + proxy.TestMethod(null); + + Assert.NotNull(capturedSession); + + try + { + var enterCount = 0; + capturedSession.BeforeDispose += () => + Interlocked.Increment(ref enterCount); + + // without atomic TrySet, concurrent entries can run the dispose body more than once + using var startGate = new ManualResetEventSlim(false); + + var disposeTasks = Enumerable.Range(0, 16) + .Select(_ => Task.Run(() => + { + startGate.Wait(); + return capturedSession.DisposeAsync().AsTask(); + })) + .ToArray(); + + startGate.Set(); + + await Task.WhenAll(disposeTasks) + .ConfigureAwait(false); + + Assert.Equal(1, enterCount); + Assert.True(capturedSession.IsDisposing); + } + finally + { + await TryRemoveSessionAsync(capturedSession) + .ConfigureAwait(false); + } + } + + [Fact] + [SuppressMessage("Usage", "xUnit1030:Do not call ConfigureAwait(false) in test method", Justification = "")] + public async Task Sequential_DisposeAsync_calls_should_be_idempotent() + { + using var ctx = ValidationSyncContext.Install(); + + var capturedSession = default(RemotingSession); + + _serverFixture.TestService.TestMethodFake = _ => + { + capturedSession = RemotingSession.Current; + return null; + }; + + using var client = new RemotingClient(new ClientConfig() + { + ConnectionTimeout = 0, + MessageEncryption = false, + SendTimeout = 0, + ServerPort = _serverFixture.Server.Config.NetworkPort, + Channel = ClientChannel + }); + + await client.ConnectAsync().ConfigureAwait(false); + var proxy = client.CreateProxy(); + proxy.TestMethod(null); + + Assert.NotNull(capturedSession); + Assert.False(capturedSession.IsDisposing); + + try + { + var enterCount = 0; + capturedSession.BeforeDispose += () => + Interlocked.Increment(ref enterCount); + + await capturedSession.DisposeAsync().ConfigureAwait(false); + await capturedSession.DisposeAsync().ConfigureAwait(false); + await capturedSession.DisposeAsync().ConfigureAwait(false); + + Assert.Equal(1, enterCount); + Assert.True(capturedSession.IsDisposing); + } + finally + { + await TryRemoveSessionAsync(capturedSession) + .ConfigureAwait(false); + } + } + + /// + /// Best-effort removes the captured session from the repository so it doesn't + /// leak into other tests sharing the ServerFixture. Safe to call on an already + /// disposed session: RemoveSession's internal DisposeAsync call is a no-op + /// thanks to the AtomicBool.TrySet guard. + /// + private async Task TryRemoveSessionAsync(RemotingSession session) + { + if (session == null) + return; + + try + { + await _serverFixture.Server.SessionRepository + .RemoveSession(session.SessionId) + .ConfigureAwait(false); + } + catch + { + // session may already be gone + } + + _serverFixture.ServerErrorCount = 0; + _serverFixture.LastServerError = null; + } + [Fact] public void RemotingSession_should_be_accessible_to_the_component_constructor() { diff --git a/CoreRemoting.Tests/SessionTests_NullChannel.cs b/CoreRemoting.Tests/SessionTests_NullChannel.cs new file mode 100644 index 00000000..2a537a89 --- /dev/null +++ b/CoreRemoting.Tests/SessionTests_NullChannel.cs @@ -0,0 +1,15 @@ +using CoreRemoting.Channels; +using CoreRemoting.Channels.Null; + +namespace CoreRemoting.Tests.Sessions; + +public class SessionTests_NullChannel : SessionTests +{ + protected override IServerChannel ServerChannel => new NullServerChannel(); + + protected override IClientChannel ClientChannel => new NullClientChannel(); + + public SessionTests_NullChannel(ServerFixture serverFixture) : base(serverFixture) + { + } +} \ No newline at end of file diff --git a/CoreRemoting.Tests/SrpNegotiatedKeyTests.cs b/CoreRemoting.Tests/SrpNegotiatedKeyTests.cs index 73726771..c6714ee8 100644 --- a/CoreRemoting.Tests/SrpNegotiatedKeyTests.cs +++ b/CoreRemoting.Tests/SrpNegotiatedKeyTests.cs @@ -6,6 +6,7 @@ using CoreRemoting.Authentication; using CoreRemoting.Authentication.SecureRemotePassword; using CoreRemoting.DependencyInjection; +using CoreRemoting.Encryption; using CoreRemoting.Tests.Tools; using SecureRemotePassword; using Xunit; diff --git a/CoreRemoting.Tests/Threading/AtomicBoolTests.cs b/CoreRemoting.Tests/Threading/AtomicBoolTests.cs new file mode 100644 index 00000000..b4a6c226 --- /dev/null +++ b/CoreRemoting.Tests/Threading/AtomicBoolTests.cs @@ -0,0 +1,290 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CoreRemoting.Threading; +using Xunit; + +namespace CoreRemoting.Tests.Threading; + +public class AtomicBoolTests +{ + [Fact] + public void Default_value_is_false() + { + var flag = new AtomicBool(); + + Assert.False(flag.Value); + } + + [Fact] + public void Set_makes_value_true() + { + var flag = new AtomicBool(); + + flag.Set(); + + Assert.True(flag.Value); + } + + [Fact] + public void Set_is_idempotent() + { + var flag = new AtomicBool(); + + flag.Set(); + flag.Set(); + flag.Set(); + + Assert.True(flag.Value); + } + + [Fact] + public void Reset_makes_value_false() + { + var flag = new AtomicBool(); + flag.Set(); + + flag.Reset(); + + Assert.False(flag.Value); + } + + [Fact] + public void Reset_on_fresh_flag_is_noop() + { + var flag = new AtomicBool(); + + flag.Reset(); + + Assert.False(flag.Value); + } + + [Fact] + public void Reset_is_idempotent() + { + var flag = new AtomicBool(); + + flag.Reset(); + flag.Reset(); + flag.Reset(); + + Assert.False(flag.Value); + } + + [Fact] + public void TrySet_returns_true_on_first_call() + { + var flag = new AtomicBool(); + + Assert.True(flag.TrySet()); + Assert.True(flag.Value); + } + + [Fact] + public void TrySet_returns_false_on_second_call() + { + var flag = new AtomicBool(); + + Assert.True(flag.TrySet()); + Assert.False(flag.TrySet()); + Assert.True(flag.Value); + } + + [Fact] + public void TrySet_after_Set_returns_false() + { + var flag = new AtomicBool(); + flag.Set(); + + Assert.False(flag.TrySet()); + Assert.True(flag.Value); + } + + [Fact] + public void TrySet_returns_true_again_after_clear() + { + var flag = new AtomicBool(); + flag.TrySet(); + flag.Reset(); + + Assert.True(flag.TrySet()); + Assert.True(flag.Value); + } + + [Fact] + public async Task Concurrent_TrySet_leaves_exactly_one_winner() + { + var flag = new AtomicBool(); + var winners = 0; + + await Task.WhenAll( + Enumerable.Range(0, 100).Select(_ => Task.Run(() => + { + if (flag.TrySet()) + Interlocked.Increment(ref winners); + }))); + + Assert.Equal(1, winners); + Assert.True(flag.Value); + } + + [Fact] + public async Task Concurrent_access_does_not_throw() + { + var flag = new AtomicBool(); + var stop = false; + + var setter = Task.Run(() => + { + while (!Volatile.Read(ref stop)) + flag.Set(); + }); + + var clearer = Task.Run(() => + { + while (!Volatile.Read(ref stop)) + flag.Reset(); + }); + + var trySetter = Task.Run(() => + { + while (!Volatile.Read(ref stop)) + flag.TrySet(); + }); + + var reader = Task.Run(() => + { + while (!Volatile.Read(ref stop)) + _ = flag.Value; + }); + + await Task.Delay(100); + Volatile.Write(ref stop, true); + await Task.WhenAll(setter, clearer, trySetter, reader); + } + + [Fact] + public void TryReset_returns_false_on_fresh_flag() + { + var flag = new AtomicBool(); + + Assert.False(flag.TryReset()); + Assert.False(flag.Value); + } + + [Fact] + public void TryReset_returns_true_after_set() + { + var flag = new AtomicBool(); + flag.Set(); + + Assert.True(flag.TryReset()); + Assert.False(flag.Value); + } + + [Fact] + public void TryReset_returns_false_on_second_call() + { + var flag = new AtomicBool(); + flag.Set(); + + Assert.True(flag.TryReset()); + Assert.False(flag.TryReset()); + Assert.False(flag.Value); + } + + [Fact] + public void TryReset_after_try_set_returns_true() + { + var flag = new AtomicBool(); + flag.TrySet(); + + Assert.True(flag.TryReset()); + Assert.False(flag.Value); + } + + [Fact] + public async Task Concurrent_TryReset_leaves_exactly_one_winner() + { + var flag = new AtomicBool(); + flag.Set(); + var winners = 0; + + await Task.WhenAll( + Enumerable.Range(0, 100).Select(_ => Task.Run(() => + { + if (flag.TryReset()) + Interlocked.Increment(ref winners); + }))); + + Assert.Equal(1, winners); + Assert.False(flag.Value); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Set_with_bool_sets_expected_value(bool value) + { + var flag = new AtomicBool(); + + flag.Set(value); + + Assert.Equal(value, flag.Value); + } + + [Fact] + public void Set_with_bool_overrides_previous_value() + { + var flag = new AtomicBool(); + + flag.Set(true); + flag.Set(false); + Assert.False(flag.Value); + + flag.Set(true); + Assert.True(flag.Value); + } + + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + public void Set_with_bool_can_toggle(bool first, bool second) + { + var flag = new AtomicBool(); + + flag.Set(first); + flag.Set(second); + + Assert.Equal(second, flag.Value); + } + + [Fact] + public async Task Concurrent_Set_with_bool_does_not_throw() + { + var flag = new AtomicBool(); + var stop = false; + + var setter = Task.Run(() => + { + while (!Volatile.Read(ref stop)) + flag.Set(true); + }); + + var clearer = Task.Run(() => + { + while (!Volatile.Read(ref stop)) + flag.Set(false); + }); + + var reader = Task.Run(() => + { + while (!Volatile.Read(ref stop)) + _ = flag.Value; + }); + + await Task.Delay(100); + Volatile.Write(ref stop, true); + await Task.WhenAll(setter, clearer, reader); + } +} \ No newline at end of file diff --git a/CoreRemoting.Tests/Tools/DryIocContainerAdapter.cs b/CoreRemoting.Tests/Tools/DryIocContainerAdapter.cs index be16ac4b..8b28da36 100644 --- a/CoreRemoting.Tests/Tools/DryIocContainerAdapter.cs +++ b/CoreRemoting.Tests/Tools/DryIocContainerAdapter.cs @@ -36,7 +36,7 @@ public override void Dispose() public override IEnumerable GetAllRegisteredTypes() => []; // TODO public override bool IsRegistered(string serviceName = "") => - RootContainer.IsRegistered(serviceName); + RootContainer.IsRegistered(GetKey(serviceName)); private static object GetKey(string serviceName) => GetKey(typeof(TServiceInterface), serviceName); @@ -63,7 +63,7 @@ protected override object ResolveServiceFromContainer(ServiceRegistration regist { var service = Container.Resolve(registration.InterfaceType ?? registration.ImplementationType, serviceKey: GetKey(registration.InterfaceType ?? registration.ImplementationType, registration.ServiceName)); - registration.EventStub.WireTo(service); + registration.EventHub.WireTo(service); return service; } diff --git a/CoreRemoting.Tests/Tools/EventRelay.cs b/CoreRemoting.Tests/Tools/EventRelay.cs new file mode 100644 index 00000000..7ac1d57b --- /dev/null +++ b/CoreRemoting.Tests/Tools/EventRelay.cs @@ -0,0 +1,12 @@ +using CoreRemoting.RemoteDelegates; + +namespace CoreRemoting.Tests.Tools; + +public class EventRelay(IEventDispatcher dispatcher) : IEventRelay +{ + public void RelayTestServiceEvent() => + dispatcher.Invoke(s => nameof(s.ServiceEvent)); + + public void RelayTestServiceEventByName() => + dispatcher.Invoke(typeof(ITestService).FullName, nameof(ITestService.ServiceEvent)); +} diff --git a/CoreRemoting.Tests/Tools/IEventRelay.cs b/CoreRemoting.Tests/Tools/IEventRelay.cs new file mode 100644 index 00000000..972c3cfb --- /dev/null +++ b/CoreRemoting.Tests/Tools/IEventRelay.cs @@ -0,0 +1,8 @@ +namespace CoreRemoting.Tests.Tools; + +public interface IEventRelay +{ + void RelayTestServiceEvent(); + + void RelayTestServiceEventByName(); +} diff --git a/CoreRemoting.Tests/Tools/IServiceWithHiddenDeps.cs b/CoreRemoting.Tests/Tools/IServiceWithHiddenDeps.cs new file mode 100644 index 00000000..3804bcad --- /dev/null +++ b/CoreRemoting.Tests/Tools/IServiceWithHiddenDeps.cs @@ -0,0 +1,8 @@ +using System; + +namespace CoreRemoting.Tests.Tools; + +public interface IServiceWithHiddenDeps +{ + bool IsImported { get; } +} diff --git a/CoreRemoting.Tests/Tools/ITestService.cs b/CoreRemoting.Tests/Tools/ITestService.cs index f441a4b8..4aadc21c 100644 --- a/CoreRemoting.Tests/Tools/ITestService.cs +++ b/CoreRemoting.Tests/Tools/ITestService.cs @@ -43,6 +43,12 @@ public interface ITestService : IBaseService Task ErrorAsync(string text); + Task EchoAsync(string text); + + ValueTask EchoValueTaskAsync(string text); + + Task NoopAsync(); + void NonSerializableError(string text, params object[] data); object NonSerializableReturnValue(string text); diff --git a/CoreRemoting.Tests/Tools/ServiceWithContainer.cs b/CoreRemoting.Tests/Tools/ServiceWithContainer.cs new file mode 100644 index 00000000..1f23c09f --- /dev/null +++ b/CoreRemoting.Tests/Tools/ServiceWithContainer.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CoreRemoting.DependencyInjection; + +namespace CoreRemoting.Tests.Tools; +public interface IServiceWithContainer +{ + IDependencyInjectionContainer Container { get; } +} + +public class ServiceWithContainer : IServiceWithContainer +{ + public IDependencyInjectionContainer Container { get; } + + public ServiceWithContainer(IDependencyInjectionContainer container) + { + Container = container; + } +} \ No newline at end of file diff --git a/CoreRemoting.Tests/Tools/ServiceWithHiddenDeps.cs b/CoreRemoting.Tests/Tools/ServiceWithHiddenDeps.cs new file mode 100644 index 00000000..38a93d36 --- /dev/null +++ b/CoreRemoting.Tests/Tools/ServiceWithHiddenDeps.cs @@ -0,0 +1,21 @@ +using System; +using CoreRemoting.RemoteDelegates; + +namespace CoreRemoting.Tests.Tools; + +public class ServiceWithHiddenDeps(IDelegateProxyFactory hiddenService) : IServiceWithHiddenDeps +{ + public bool IsImported + { + get + { + // check that hidden service is imported + if (hiddenService == null) + return false; + + // check that it actually works + var test = hiddenService.Create(typeof(Action), _ => null); + return test != null; + } + } +} diff --git a/CoreRemoting.Tests/Tools/TestService.cs b/CoreRemoting.Tests/Tools/TestService.cs index 3fc8ea4b..4bad81cf 100644 --- a/CoreRemoting.Tests/Tools/TestService.cs +++ b/CoreRemoting.Tests/Tools/TestService.cs @@ -35,8 +35,11 @@ public object LongRunnigTestMethod(int timeout) return TestMethodFake?.Invoke(timeout); } + public Action LastReceivedDelegate { get; set; } + public void TestMethodWithDelegateArg(Action callback) { + LastReceivedDelegate = callback; callback("test"); } @@ -108,6 +111,15 @@ public async Task ErrorAsync(string text) Error(text); } + public Task EchoAsync(string text) => + Task.FromResult(text); + + public ValueTask EchoValueTaskAsync(string text) => + new(text); + + public Task NoopAsync() => + Task.CompletedTask; + private class NonSerializable : Exception { public NonSerializable(string message) @@ -130,7 +142,7 @@ public class NonSerializableObject(string text) { // This field contains a delegate, which NeoBinary cannot serialize by default private readonly Func _throwingDelegate = () => throw new Exception(text); - + public string Text => throw new Exception(text); } diff --git a/CoreRemoting/ClassicRemotingApi/ConfigSection/ClientInstanceConfigElement.cs b/CoreRemoting/ClassicRemotingApi/ConfigSection/ClientInstanceConfigElement.cs index 7e7ed668..cd95ff4a 100644 --- a/CoreRemoting/ClassicRemotingApi/ConfigSection/ClientInstanceConfigElement.cs +++ b/CoreRemoting/ClassicRemotingApi/ConfigSection/ClientInstanceConfigElement.cs @@ -16,7 +16,7 @@ public string UniqueInstanceName get => (string)base["uniqueInstanceName"]; set => base["uniqueInstanceName"] = value; } - + /// /// Gets or sets the server hostname. /// @@ -26,7 +26,7 @@ public string ServerHostName get => (string)base["serverHostName"]; set => base["serverHostName"] = value; } - + /// /// Gets or sets the server network port. /// @@ -37,7 +37,7 @@ public int ServerPort get => (int)base["serverPort"]; set => base["serverPort"] = value; } - + /// /// Gets or sets the RSA key size for message encryption. /// @@ -47,7 +47,7 @@ public int KeySize get => (int)base["keySize"]; set => base["keySize"] = value; } - + /// /// Gets or sets the name of the serializer which should be used by the client instance. /// @@ -57,7 +57,7 @@ public string Serializer get => (string)base["serializer"]; set => base["serializer"] = value; } - + /// /// Gets or sets the type of the client channel which should be used for communication. /// @@ -67,7 +67,7 @@ public string Channel get => (string)base["channel"]; set => base["channel"] = value; } - + /// /// Gets or set whether messages should be encrypted or not. /// @@ -77,7 +77,7 @@ public bool MessageEncryption get => (bool)base["messageEncryption"]; set => base["messageEncryption"] = value; } - + /// /// Gets or set whether this is the default client. /// @@ -87,7 +87,7 @@ public bool IsDefault get => (bool)base["isDefault"]; set => base["isDefault"] = value; } - + /// /// Gets or sets the connection timeout in seconds (0 means infinite). /// @@ -97,7 +97,7 @@ public int ConnectionTimeout get => (int)base["connectionTimeout"]; set => base["connectionTimeout"] = value; } - + /// /// Gets or sets the authentication timeout in seconds (0 means infinite). /// diff --git a/CoreRemoting/ClassicRemotingApi/ConfigSection/ClientInstanceConfigElementCollection.cs b/CoreRemoting/ClassicRemotingApi/ConfigSection/ClientInstanceConfigElementCollection.cs index 8a94fb29..e98c31e7 100644 --- a/CoreRemoting/ClassicRemotingApi/ConfigSection/ClientInstanceConfigElementCollection.cs +++ b/CoreRemoting/ClassicRemotingApi/ConfigSection/ClientInstanceConfigElementCollection.cs @@ -56,6 +56,6 @@ protected override ConfigurationElement CreateNewElement() protected override object GetElementKey(ConfigurationElement element) { return ((ClientInstanceConfigElement) element).UniqueInstanceName; - } + } } } \ No newline at end of file diff --git a/CoreRemoting/ClassicRemotingApi/ConfigSection/ConfigSectionExtensionMethods.cs b/CoreRemoting/ClassicRemotingApi/ConfigSection/ConfigSectionExtensionMethods.cs index ae5286a2..6136fe98 100644 --- a/CoreRemoting/ClassicRemotingApi/ConfigSection/ConfigSectionExtensionMethods.cs +++ b/CoreRemoting/ClassicRemotingApi/ConfigSection/ConfigSectionExtensionMethods.cs @@ -54,7 +54,7 @@ public static ServerConfig ToServerConfig( { Channel = CreateServerChannelFromConfigName(configElement.Channel), Serializer = CreateSerializerAdapterFromConfigName(configElement.Serializer), - AuthenticationProvider = + AuthenticationProvider = string.IsNullOrWhiteSpace(configElement.AuthenticationProvider) ? null : (IAuthenticationProvider)Activator.CreateInstance(GetTypeFromConfigString(configElement.AuthenticationProvider)), @@ -98,7 +98,7 @@ public static ClientConfig ToClientConfig( return clientConfig; } - + /// /// Gets a type from a string that contains type name and assembly name. /// @@ -126,10 +126,10 @@ private static Type GetTypeFromConfigString(string assemblyAndTypeConfigString) /// Server channel private static IServerChannel CreateServerChannelFromConfigName(string channelTypeName) { - var websocketServerChannelShortcuts = + var websocketServerChannelShortcuts = new[] {"ws", "websocket"}; - var tcpServerChannelShortcuts = + var tcpServerChannelShortcuts = new[] {"tcp"}; if (websocketServerChannelShortcuts.Contains(channelTypeName.ToLower())) @@ -139,10 +139,10 @@ private static IServerChannel CreateServerChannelFromConfigName(string channelTy return new TcpServerChannel(); var channelType = GetTypeFromConfigString(channelTypeName); - + return (IServerChannel) Activator.CreateInstance(channelType); } - + /// /// Creates a client channel from a type string. /// @@ -150,23 +150,23 @@ private static IServerChannel CreateServerChannelFromConfigName(string channelTy /// Client channel private static IClientChannel CreateClientChannelFromConfigName(string channelTypeName) { - var websocketClientChannelShortcuts = + var websocketClientChannelShortcuts = new[] {"ws", "websocket"}; - var tcpClientChannelShortcuts = + var tcpClientChannelShortcuts = new[] {"tcp"}; if (websocketClientChannelShortcuts.Contains(channelTypeName.ToLower())) return new WebsocketClientChannel(); - + if (tcpClientChannelShortcuts.Contains(channelTypeName.ToLower())) return new TcpClientChannel(); var channelType = GetTypeFromConfigString(channelTypeName); - + return (IClientChannel) Activator.CreateInstance(channelType); } - + /// /// Creates a serializer adapter from a type string. /// @@ -174,10 +174,10 @@ private static IClientChannel CreateClientChannelFromConfigName(string channelTy /// private static ISerializerAdapter CreateSerializerAdapterFromConfigName(string serializerName) { - var binarySerializerShortcuts = + var binarySerializerShortcuts = new[] { - "binary", + "binary", "binaryformatter", "binaryserializer" }; @@ -188,15 +188,15 @@ private static ISerializerAdapter CreateSerializerAdapterFromConfigName(string s "neobinary", "neobinaryserializer" }; - + var bsonSerializerShortcuts = new[] { - "bson", - "bsonformatter", - "bsonserializer", + "bson", + "bsonformatter", + "bsonserializer", }; - + if (binarySerializerShortcuts.Contains(serializerName.ToLower())) { // Load BinarySerializerAdapter from separate assembly @@ -210,9 +210,9 @@ private static ISerializerAdapter CreateSerializerAdapterFromConfigName(string s if (neoBinarySerializerShortcuts.Contains(serializerName.ToLower())) return new NeoBinarySerializerAdapter(); - + var serializerAdapterType = GetTypeFromConfigString(serializerName); - + return (ISerializerAdapter) Activator.CreateInstance(serializerAdapterType); } } diff --git a/CoreRemoting/ClassicRemotingApi/ConfigSection/CoreRemotingConfigSection.cs b/CoreRemoting/ClassicRemotingApi/ConfigSection/CoreRemotingConfigSection.cs index efda456c..5a1e45a2 100644 --- a/CoreRemoting/ClassicRemotingApi/ConfigSection/CoreRemotingConfigSection.cs +++ b/CoreRemoting/ClassicRemotingApi/ConfigSection/CoreRemotingConfigSection.cs @@ -13,23 +13,23 @@ public class CoreRemotingConfigSection : ConfigurationSection /// [ConfigurationProperty("serverInstances", IsRequired = false)] [ConfigurationCollection(typeof(ServerInstanceConfigElementCollection))] - public ServerInstanceConfigElementCollection ServerInstances => + public ServerInstanceConfigElementCollection ServerInstances => (ServerInstanceConfigElementCollection)this["serverInstances"]; - + /// /// Gets a collection of configured CoreRemoting services. /// [ConfigurationProperty("services", IsRequired = false)] [ConfigurationCollection(typeof(WellKnownServiceConfigElementCollection))] - public WellKnownServiceConfigElementCollection Services => + public WellKnownServiceConfigElementCollection Services => (WellKnownServiceConfigElementCollection)this["services"]; - + /// /// Gets a collection of configured CoreRemoting client instances. /// [ConfigurationProperty("clientInstances", IsRequired = false)] [ConfigurationCollection(typeof(ClientInstanceConfigElementCollection))] - public ClientInstanceConfigElementCollection ClientInstances => + public ClientInstanceConfigElementCollection ClientInstances => (ClientInstanceConfigElementCollection)this["clientInstances"]; } } \ No newline at end of file diff --git a/CoreRemoting/ClassicRemotingApi/ConfigSection/ServerInstanceConfigElement.cs b/CoreRemoting/ClassicRemotingApi/ConfigSection/ServerInstanceConfigElement.cs index 100f6780..bd0c6b7e 100644 --- a/CoreRemoting/ClassicRemotingApi/ConfigSection/ServerInstanceConfigElement.cs +++ b/CoreRemoting/ClassicRemotingApi/ConfigSection/ServerInstanceConfigElement.cs @@ -16,7 +16,7 @@ public string UniqueInstanceName get => (string)base["uniqueInstanceName"]; set => base["uniqueInstanceName"] = value; } - + /// /// Gets or sets the hostname the server instance is bound to. /// @@ -26,7 +26,7 @@ public string HostName get => (string)base["hostName"]; set => base["hostName"] = value; } - + /// /// Gets or sets the network port on which the server instance is listening on. /// @@ -37,7 +37,7 @@ public int NetworkPort get => (int)base["networkPort"]; set => base["networkPort"] = value; } - + /// /// Gets or sets the RSA key size for message encryption. /// @@ -47,7 +47,7 @@ public int KeySize get => (int)base["keySize"]; set => base["keySize"] = value; } - + /// /// Gets or sets the name of the serializer which should be used by the server instance. /// @@ -57,7 +57,7 @@ public string Serializer get => (string)base["serializer"]; set => base["serializer"] = value; } - + /// /// Gets or sets the type of the server channel which should be used for communication. /// @@ -67,7 +67,7 @@ public string Channel get => (string)base["channel"]; set => base["channel"] = value; } - + /// /// Gets or sets the type of authentication provider which should be used to authenticate client credentials. /// @@ -77,7 +77,7 @@ public string AuthenticationProvider get => (string)base["authenticationProvider"]; set => base["authenticationProvider"] = value; } - + /// /// Gets or sets whether authentication is required or not. /// @@ -87,7 +87,7 @@ public bool AuthenticationRequired get => (bool)base["authenticationRequired"]; set => base["authenticationRequired"] = value; } - + /// /// Gets or set whether messages should be encrypted or not. /// @@ -97,7 +97,7 @@ public bool MessageEncryption get => (bool)base["messageEncryption"]; set => base["messageEncryption"] = value; } - + /// /// Gets or set whether this is the default server. /// diff --git a/CoreRemoting/ClassicRemotingApi/ConfigSection/WellKnownServiceConfigElement.cs b/CoreRemoting/ClassicRemotingApi/ConfigSection/WellKnownServiceConfigElement.cs index e587efe9..f0bd505c 100644 --- a/CoreRemoting/ClassicRemotingApi/ConfigSection/WellKnownServiceConfigElement.cs +++ b/CoreRemoting/ClassicRemotingApi/ConfigSection/WellKnownServiceConfigElement.cs @@ -17,7 +17,7 @@ public string InterfaceAssemblyName get => (string)base["interfaceAssemblyName"]; set => base["interfaceAssemblyName"] = value; } - + /// /// Get or sets the type name of the service interface. /// @@ -27,9 +27,9 @@ public string InterfaceTypeName get => (string)base["interfaceTypeName"]; set => base["interfaceTypeName"] = value; } - + /// - /// Get or sets the assembly of the service's implementation assembly. + /// Get or sets the assembly of the service's implementation assembly. /// [ConfigurationProperty("implementationAssemblyName", IsRequired = true)] public string ImplementationAssemblyName @@ -37,7 +37,7 @@ public string ImplementationAssemblyName get => (string)base["implementationAssemblyName"]; set => base["implementationAssemblyName"] = value; } - + /// /// Gets or sets the implementation type of the service. /// @@ -47,7 +47,7 @@ public string ImplementationTypeName get => (string)base["implementationTypeName"]; set => base["implementationTypeName"] = value; } - + /// /// Gets or sets the service's lifetime. /// @@ -57,7 +57,7 @@ public ServiceLifetime Lifetime get => (ServiceLifetime)base["lifetime"]; set => base["lifetime"] = value; } - + /// /// Gets or sets the unique service name. /// @@ -67,7 +67,7 @@ public string ServiceName get => (string)base["serviceName"]; set => base["serviceName"] = value; } - + /// /// Gets or sets the unique name of the server hosting this service. /// diff --git a/CoreRemoting/ClassicRemotingApi/RemotingConfiguration.cs b/CoreRemoting/ClassicRemotingApi/RemotingConfiguration.cs index bf1f9770..18af1d21 100644 --- a/CoreRemoting/ClassicRemotingApi/RemotingConfiguration.cs +++ b/CoreRemoting/ClassicRemotingApi/RemotingConfiguration.cs @@ -27,7 +27,7 @@ public static class RemotingConfiguration /// Gets a list of currently registered CoreRemoting client instances. /// public static IRemotingClient[] RegisteredClientInstances => RemotingClient.ActiveClientInstances.ToArray(); - + /// /// Gets a registered server instance by its unique name. /// @@ -44,7 +44,7 @@ public static IRemotingServer GetRegisteredServer(string uniqueServerInstanceNam .FirstOrDefault(instance => instance.Config.UniqueServerInstanceName.Equals(uniqueServerInstanceName)); } - + /// /// Gets a registered client instance by its unique name. /// @@ -72,7 +72,7 @@ public static string RegisterServer(ServerConfig config) var server = new RemotingServer(config); return server.Config.UniqueServerInstanceName; } - + /// /// Registers a new CoreRemoting client instance and returns it's unique instance name. /// @@ -131,15 +131,15 @@ public static void RegisterWellKnownServiceType(WellKnownServiceTypeEntry entry) var interfaceAssembly = Assembly.Load(entry.InterfaceAssemblyName); var interfaceType = interfaceAssembly.GetType(entry.InterfaceTypeName); - + var implementationAssembly = Assembly.Load(entry.ImplementationAssemblyName); var implementationType = implementationAssembly.GetType(entry.ImplementationTypeName); RegisterWellKnownServiceType( - interfaceType, - implementationType, - entry.Lifetime, - entry.ServiceName, + interfaceType, + implementationType, + entry.Lifetime, + entry.ServiceName, entry.UniqueServerInstanceName); } @@ -158,11 +158,11 @@ public static void RegisterWellKnownServiceType( string serviceName = "", string uniqueServerInstanceName = "") { - var server = - string.IsNullOrWhiteSpace(uniqueServerInstanceName) + var server = + string.IsNullOrWhiteSpace(uniqueServerInstanceName) ? RemotingServer.DefaultRemotingServer : GetRegisteredServer(uniqueServerInstanceName); - + var container = server.ServiceRegistry; var registerServiceMethod = @@ -172,7 +172,7 @@ public static void RegisterWellKnownServiceType( } /// - /// Applies CoreRemoting server configuration from config file. + /// Applies CoreRemoting server configuration from config file. /// /// Path to XML configuration file (Default EXE configuration file will be used, if empty) /// Optional credentials for authentication @@ -186,7 +186,7 @@ public static void Configure(string fileName = "", Credential[] credentials = nu fileMap: new ExeConfigurationFileMap {ExeConfigFilename = fileName}, userLevel: ConfigurationUserLevel.None); - var configSection = (CoreRemotingConfigSection) + var configSection = (CoreRemotingConfigSection) configuration.Sections["coreRemoting"]; foreach (ServerInstanceConfigElement serverInstanceConfig in configSection.ServerInstances) @@ -195,13 +195,13 @@ public static void Configure(string fileName = "", Credential[] credentials = nu var server = new RemotingServer(serverConfig); server.Start(); } - + foreach (WellKnownServiceConfigElement serviceConfigElement in configSection.Services) { var entry = serviceConfigElement.ToWellKnownServiceTypeEntry(); RegisterWellKnownServiceType(entry); } - + foreach (ClientInstanceConfigElement clientInstanceConfig in configSection.ClientInstances) { var clientConfig = clientInstanceConfig.ToClientConfig(); diff --git a/CoreRemoting/ClassicRemotingApi/RemotingServices.cs b/CoreRemoting/ClassicRemotingApi/RemotingServices.cs index 067d3513..c998043f 100644 --- a/CoreRemoting/ClassicRemotingApi/RemotingServices.cs +++ b/CoreRemoting/ClassicRemotingApi/RemotingServices.cs @@ -40,18 +40,18 @@ public static bool IsTransparentProxy(object proxy) /// Unique server instance name /// Service name public static string Marshal( - object serviceInstance, + object serviceInstance, string serviceName, - Type interfaceType, + Type interfaceType, string uniqueServerInstanceName = "") { - var server = - string.IsNullOrWhiteSpace(uniqueServerInstanceName) + var server = + string.IsNullOrWhiteSpace(uniqueServerInstanceName) ? RemotingServer.DefaultRemotingServer : RemotingConfiguration.GetRegisteredServer(uniqueServerInstanceName); - + var container = server.ServiceRegistry; - + var registerServiceMethod = container.GetRegisterServiceMethodForServiceInstance(interfaceType); @@ -60,16 +60,17 @@ public static string Marshal( var genericFunc = typeof(Func<>); var factoryFunc = genericFunc.MakeGenericType(interfaceType); - var factoryFuncProxy = - new DelegateProxy(factoryFunc, _ => serviceInstance); - - registerServiceMethod.Invoke(container, new object[] - { + + var delegateProxyFactory = container.GetService(); + var factoryFuncProxy = delegateProxyFactory.Create(factoryFunc, _ => serviceInstance); + + registerServiceMethod.Invoke(container, + [ factoryFuncProxy.ProxiedDelegate, - ServiceLifetime.Singleton, + ServiceLifetime.Singleton, serviceName, false - }); + ]); return serviceName; } diff --git a/CoreRemoting/ClassicRemotingApi/WellKnownServiceTypeEntry.cs b/CoreRemoting/ClassicRemotingApi/WellKnownServiceTypeEntry.cs index fd148d23..00c19bae 100644 --- a/CoreRemoting/ClassicRemotingApi/WellKnownServiceTypeEntry.cs +++ b/CoreRemoting/ClassicRemotingApi/WellKnownServiceTypeEntry.cs @@ -18,7 +18,7 @@ public class WellKnownServiceTypeEntry /// Unique service name (Full name of the interface type is used, when left blank) /// Unique instance name of the host server (default server is used, if left blank) public WellKnownServiceTypeEntry( - string interfaceAssemblyName, + string interfaceAssemblyName, string interfaceTypeName, string implementationAssemblyName, string implementationTypeName, @@ -34,37 +34,37 @@ public WellKnownServiceTypeEntry( ServiceName = serviceName; UniqueServerInstanceName = uniqueServerInstanceName; } - + /// /// Gets or sets the unique instance name of the host server. /// public string UniqueServerInstanceName { get; set; } - + /// /// Gets or sets the implementation assembly name. /// public string ImplementationAssemblyName { get; set; } - + /// /// Gets or sets the implementation type name. /// public string ImplementationTypeName { get; set; } - + /// /// Gets or sets the interface assembly name. /// public string InterfaceAssemblyName { get; set; } - + /// /// Gets or sets the interface type name. /// public string InterfaceTypeName { get; set; } - + /// /// Gets or sets the service's lifetime. /// public ServiceLifetime Lifetime { get; set; } - + /// /// Gets or sets the unique service name (Full name of interface type is used, if left blank). /// diff --git a/CoreRemoting/ClientConfig.cs b/CoreRemoting/ClientConfig.cs index eeac0730..03d89f3e 100644 --- a/CoreRemoting/ClientConfig.cs +++ b/CoreRemoting/ClientConfig.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using CoreRemoting.Authentication; using CoreRemoting.Channels; +using CoreRemoting.Encryption; using CoreRemoting.Serialization; namespace CoreRemoting; diff --git a/CoreRemoting/CoreRemoting.csproj b/CoreRemoting/CoreRemoting.csproj index 23726be2..80ed3726 100644 --- a/CoreRemoting/CoreRemoting.csproj +++ b/CoreRemoting/CoreRemoting.csproj @@ -36,6 +36,10 @@ bin\Release\CoreRemoting.xml + + + + diff --git a/CoreRemoting/DependencyInjection/CastleWindsorDependencyInjectionContainer.cs b/CoreRemoting/DependencyInjection/CastleWindsorDependencyInjectionContainer.cs index ffff9264..9e9a96ed 100644 --- a/CoreRemoting/DependencyInjection/CastleWindsorDependencyInjectionContainer.cs +++ b/CoreRemoting/DependencyInjection/CastleWindsorDependencyInjectionContainer.cs @@ -29,7 +29,7 @@ public CastleWindsorDependencyInjectionContainer() protected override object ResolveServiceFromContainer(ServiceRegistration registration) { var service = _container.Resolve(key: registration.ServiceName, service: registration.InterfaceType); - registration.EventStub.WireTo(service); + registration.EventHub.WireTo(service); return service; } @@ -42,7 +42,7 @@ protected override object ResolveServiceFromContainer(ServiceRegistration regist protected override TServiceInterface ResolveServiceFromContainer(ServiceRegistration registration) { var service = _container.Resolve(key: registration.ServiceName); - registration.EventStub.WireTo(service); + registration.EventHub.WireTo(service); return service; } diff --git a/CoreRemoting/DependencyInjection/DependencyInjectionContainerBase.cs b/CoreRemoting/DependencyInjection/DependencyInjectionContainerBase.cs index 6701e279..e962451a 100644 --- a/CoreRemoting/DependencyInjection/DependencyInjectionContainerBase.cs +++ b/CoreRemoting/DependencyInjection/DependencyInjectionContainerBase.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Threading; namespace CoreRemoting.DependencyInjection; @@ -11,6 +12,7 @@ namespace CoreRemoting.DependencyInjection; public abstract class DependencyInjectionContainerBase : IDependencyInjectionContainer { private readonly ConcurrentDictionary _serviceNameRegistry; + private int _selfRegistered; /// /// Creates a new instance. @@ -20,6 +22,17 @@ protected DependencyInjectionContainerBase() _serviceNameRegistry = new ConcurrentDictionary(); } + private void EnsureSelfRegistered() + { + if (Interlocked.CompareExchange(ref _selfRegistered, 1, 0) != 0) + return; + + RegisterService( + () => this, + ServiceLifetime.Singleton, + asHiddenSystemService: true); + } + /// /// Gets registration information about all registered services. /// @@ -40,6 +53,8 @@ public IEnumerable GetServiceRegistrations(bool includeHidd /// Service registration public ServiceRegistration GetServiceRegistration(string serviceName) { + EnsureSelfRegistered(); + if (!_serviceNameRegistry.TryGetValue(serviceName, out ServiceRegistration registration)) throw new KeyNotFoundException($"No service named '{serviceName}' is registered."); @@ -141,6 +156,8 @@ public void RegisterService( where TServiceInterface : class where TServiceImpl : class, TServiceInterface { + EnsureSelfRegistered(); + var serviceInterfaceType = typeof(TServiceInterface); if (string.IsNullOrWhiteSpace(serviceName)) @@ -177,6 +194,8 @@ public void RegisterService( bool asHiddenSystemService = false) where TServiceInterface : class { + EnsureSelfRegistered(); + var serviceInterfaceType = typeof(TServiceInterface); if (string.IsNullOrWhiteSpace(serviceName)) diff --git a/CoreRemoting/DependencyInjection/MicrosoftDependencyInjectionContainer.cs b/CoreRemoting/DependencyInjection/MicrosoftDependencyInjectionContainer.cs index 13eedc69..4fa501b3 100644 --- a/CoreRemoting/DependencyInjection/MicrosoftDependencyInjectionContainer.cs +++ b/CoreRemoting/DependencyInjection/MicrosoftDependencyInjectionContainer.cs @@ -43,7 +43,7 @@ protected override object ResolveServiceFromContainer(ServiceRegistration regist { ThrowExceptionIfCustomServiceName(registration.ServiceName, registration.InterfaceType); var service = _serviceProvider.GetRequiredService(registration.InterfaceType); - registration.EventStub.WireTo(service); + registration.EventHub.WireTo(service); return service; } @@ -59,7 +59,7 @@ protected override TServiceInterface ResolveServiceFromContainer(); - registration.EventStub.WireTo(service); + registration.EventHub.WireTo(service); return service; } diff --git a/CoreRemoting/DependencyInjection/ServiceRegistration.cs b/CoreRemoting/DependencyInjection/ServiceRegistration.cs index 34d94815..81568b98 100644 --- a/CoreRemoting/DependencyInjection/ServiceRegistration.cs +++ b/CoreRemoting/DependencyInjection/ServiceRegistration.cs @@ -39,7 +39,7 @@ public ServiceRegistration( ServiceLifetime = serviceLifetime; Factory = factory; IsHiddenSystemService = isHiddenSystemService; - EventStub = new EventStub(interfaceType); + EventHub = new ServiceEventHub(interfaceType); } /// @@ -80,5 +80,5 @@ public ServiceRegistration( /// /// Holds all remote event handlers for the component. /// - public EventStub EventStub { get; } + public ServiceEventHub EventHub { get; } } \ No newline at end of file diff --git a/CoreRemoting/Encryption/Hkdf.cs b/CoreRemoting/Encryption/Hkdf.cs index d51a2196..7a8cf30f 100644 --- a/CoreRemoting/Encryption/Hkdf.cs +++ b/CoreRemoting/Encryption/Hkdf.cs @@ -2,6 +2,8 @@ using System.Security.Cryptography; using System.Text; +namespace CoreRemoting.Encryption; + /// /// Provides ready-to-use instances for common HMAC algorithms, /// as well as extension methods for convenient key derivation. diff --git a/CoreRemoting/Encryption/HkdfHmac.cs b/CoreRemoting/Encryption/HkdfHmac.cs index 44b10ee0..db16a1e3 100644 --- a/CoreRemoting/Encryption/HkdfHmac.cs +++ b/CoreRemoting/Encryption/HkdfHmac.cs @@ -1,6 +1,8 @@ using System; using System.Security.Cryptography; +namespace CoreRemoting.Encryption; + /// /// HKDF (RFC 5869) implementation parameterized by HMAC type. /// diff --git a/CoreRemoting/Encryption/IHkdfProvider.cs b/CoreRemoting/Encryption/IHkdfProvider.cs index 9ae2f889..c744a456 100644 --- a/CoreRemoting/Encryption/IHkdfProvider.cs +++ b/CoreRemoting/Encryption/IHkdfProvider.cs @@ -1,4 +1,6 @@ -/// +namespace CoreRemoting.Encryption; + +/// /// Interface for HKDF (RFC 5869) key derivation providers. /// public interface IHkdfProvider diff --git a/CoreRemoting/IRemotingClient.cs b/CoreRemoting/IRemotingClient.cs index e27baa8b..c2720d88 100644 --- a/CoreRemoting/IRemotingClient.cs +++ b/CoreRemoting/IRemotingClient.cs @@ -77,6 +77,14 @@ public interface IRemotingClient : IAsyncDisposable, IDisposable /// Proxy object object CreateProxy(Type serviceInterfaceType, string serviceName = ""); + /// + /// Creates a service invoker that provides access to a remote service without generating a proxy. + /// + /// Type of the remote service contract (interface or class) + /// Unique name of the remote service + /// Service invoker object + IServiceInvoker CreateInvoker(string serviceName = "") where TService : class; + /// /// Shuts a specified service proxy down and frees resources. /// diff --git a/CoreRemoting/IServiceInvoker.cs b/CoreRemoting/IServiceInvoker.cs new file mode 100644 index 00000000..dd661398 --- /dev/null +++ b/CoreRemoting/IServiceInvoker.cs @@ -0,0 +1,32 @@ +using System; +using System.Linq.Expressions; + +namespace CoreRemoting; + +/// +/// Interface to be implemented by service invoker classes. +/// A service invoker provides access to a remote service without generating a proxy. +/// +/// Type of the remote service contract (interface or class) +public interface IServiceInvoker : IDisposable, IAsyncDisposable + where TService : class +{ + /// + /// Gets the name of the remote service. + /// + string ServiceName { get; } + + /// + /// Invokes a method on the remote service and returns its result. + /// + /// Expression that selects the method to invoke, including arguments + /// Declared return type of the invoked method + /// Result of the remote method invocation + TResult Invoke(Expression> call); + + /// + /// Invokes a void method on the remote service. + /// + /// Expression that selects the method to invoke, including arguments + void Invoke(Expression> call); +} diff --git a/CoreRemoting/RemoteDelegates/ClientDelegateInfo.cs b/CoreRemoting/RemoteDelegates/ClientDelegateInfo.cs index 5c48b813..9ceb18ef 100644 --- a/CoreRemoting/RemoteDelegates/ClientDelegateInfo.cs +++ b/CoreRemoting/RemoteDelegates/ClientDelegateInfo.cs @@ -1,31 +1,37 @@ using System; -namespace CoreRemoting.RemoteDelegates +namespace CoreRemoting.RemoteDelegates; + +/// +/// Pairs a client delegate with a service proxy instance. +/// +public class ClientDelegateInfo { /// - /// Pairs a client delegate with a service proxy instance. + /// Creates a new instance of the ClientDelegateInfo class. /// - public class ClientDelegateInfo + /// Client delegate + /// Proxy object of the remote service, which invokes the client delegate as callback + public ClientDelegateInfo(Delegate clientDelegate, object serviceProxy) { - /// - /// Creates a new instance of the ClientDelegateInfo class. - /// - /// Client delegate - /// Proxy object of the remote service, which invokes the client delegate as callback - public ClientDelegateInfo(Delegate clientDelegate, object serviceProxy) - { - ClientDelegate = clientDelegate; - ServiceProxy = serviceProxy; - } - - /// - /// Gets the client delegate. - /// - public Delegate ClientDelegate { get; } - - /// - /// Gets the service proxy. - /// - public object ServiceProxy { get; } + ClientDelegate = clientDelegate ?? throw new ArgumentNullException(nameof(clientDelegate)); + ServiceProxy = serviceProxy ?? throw new ArgumentNullException(nameof(serviceProxy)); + HandlerKey = Guid.NewGuid(); } + + /// + /// Gets the client delegate. + /// + public Delegate ClientDelegate { get; } + + /// + /// Gets the service proxy. + /// + public object ServiceProxy { get; } + + /// + /// Gets the unique handler key assigned to this subscription. + /// Assigned once at construction and never changes. + /// + public Guid HandlerKey { get; } } \ No newline at end of file diff --git a/CoreRemoting/RemoteDelegates/ClientDelegateRegistry.cs b/CoreRemoting/RemoteDelegates/ClientDelegateRegistry.cs index 0eeb9d7c..58a04ef2 100644 --- a/CoreRemoting/RemoteDelegates/ClientDelegateRegistry.cs +++ b/CoreRemoting/RemoteDelegates/ClientDelegateRegistry.cs @@ -1,130 +1,121 @@ using System; using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using System.Linq; +using System.Collections.Generic; -namespace CoreRemoting.RemoteDelegates +namespace CoreRemoting.RemoteDelegates; + +/// +/// Registry for client delegates paired with service proxies. +/// +/// +/// A subscription is identified by the pair (delegate, service proxy), not +/// by the delegate alone: the same delegate subscribed to events of two +/// different proxies produces two independent registrations, so shutting +/// down one proxy doesn't affect the other. +/// +public sealed class ClientDelegateRegistry { + private readonly ConcurrentDictionary _registry; + private readonly ConcurrentDictionary<(Delegate, object), Guid> _reverseIndex; + /// - /// Registry for client delegates paired with service proxies. + /// Creates a new instance of the ClientDelegateRegistry class. /// - public sealed class ClientDelegateRegistry + public ClientDelegateRegistry() { - private readonly ConcurrentDictionary _registry; + _registry = new ConcurrentDictionary(); + _reverseIndex = new ConcurrentDictionary<(Delegate, object), Guid>(); + } - /// - /// Creates a new instance of the ClientDelegateRegistry class. - /// - public ClientDelegateRegistry() - { - _registry = new ConcurrentDictionary(); - } + /// + /// Registers a client delegate as callback target for remote delegate invocation. + /// If the same delegate is already registered for the same service proxy, + /// returns the existing handler key. Different proxies yield different keys + /// even for the same delegate. + /// + /// Client delegate + /// Service proxy of the remote service + /// Unique handler key + /// Thrown if an argument is null + /// Thrown if a race condition occurs while adding the client delegate to the registry + public Guid RegisterClientDelegate(Delegate clientDelegate, object serviceProxy) + { + if (clientDelegate == null) + throw new ArgumentNullException(nameof(clientDelegate)); - /// - /// Registers a client delegate as callback target for remote delegate invocation. - /// - /// Client delegate - /// Service proxy of the remote service - /// Unique handler key - /// Thrown an argument is null - /// Thrown, if a race condition occurs while adding the client delegate to the registry - public Guid RegisterClientDelegate(Delegate clientDelegate, object serviceProxy) - { - if (clientDelegate == null) - throw new ArgumentNullException(nameof(clientDelegate)); + if (serviceProxy == null) + throw new ArgumentNullException(nameof(serviceProxy)); - if (serviceProxy == null) - throw new ArgumentNullException(nameof(serviceProxy)); - - var handlerKey = FindDelegate(clientDelegate); + var indexKey = (clientDelegate, serviceProxy); - if (handlerKey == Guid.Empty) - { - handlerKey = Guid.NewGuid(); + // Fast path: already registered for this proxy. + if (_reverseIndex.TryGetValue(indexKey, out var existing)) + return existing; - if (_registry.TryAdd(handlerKey, new ClientDelegateInfo(clientDelegate, serviceProxy))) - return handlerKey; - - throw new ApplicationException("Unable to add client delegate to registry. Possible race condition?"); - } + var info = new ClientDelegateInfo(clientDelegate, serviceProxy); - return handlerKey; - } + // Whoever wins the reverse-index race owns the registration. + var claimed = _reverseIndex.GetOrAdd(indexKey, info.HandlerKey); - /// - /// Finds a specified registered client delegate and returns its unique handler key. - /// - /// Client delegate - /// Unique handler key - [SuppressMessage("ReSharper", "PossibleMultipleEnumeration")] - [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] - public Guid FindDelegate(Delegate @delegate) - { - var foundHandlerKey = - from pair in _registry - let clientDelegateInfo = pair.Value - where clientDelegateInfo.ClientDelegate == @delegate - select pair.Key; - - return foundHandlerKey.Any() ? foundHandlerKey.First() : Guid.Empty; - } + if (claimed != info.HandlerKey) + return claimed; // lost the race, discard the freshly created info - /// - /// Gets a registered client delegate by its handler key. - /// - /// Unique handler key - /// Client delegate - public Delegate GetDelegateByHandlerKey(Guid handlerKey) + if (!_registry.TryAdd(info.HandlerKey, info)) { - return _registry.TryGetValue(handlerKey, out var value) ? value.ClientDelegate : null; + // Should never happen for a fresh Guid, but leave no dangling entry behind. + _reverseIndex.TryRemove(indexKey, out _); + throw new ApplicationException("Unable to add client delegate to registry. Possible race condition?"); } - /// - /// Unregisters all client delegates that are paired withe a specified service proxy. - /// - /// Service proxy - public void UnregisterClientDelegatesOfServiceProxy(object serviceProxy) + return info.HandlerKey; + } + + /// + /// Gets a registered client delegate by its handler key. + /// + /// Unique handler key + /// Client delegate, or null if not found + public Delegate GetDelegateByHandlerKey(Guid handlerKey) => + _registry.TryGetValue(handlerKey, out var value) ? value.ClientDelegate : null; + + /// + /// Unregisters all client delegates that are paired with a specified service proxy. + /// Subscriptions made through other proxies are not affected, even if they use + /// the same delegate instance. + /// + /// Service proxy + /// Handler keys that were unregistered. Empty array if none matched. + public Guid[] UnregisterClientDelegatesOfServiceProxy(object serviceProxy) + { + if (serviceProxy == null) + return []; + + // Linear scan over all registrations, runs once per proxy shutdown. + // The enumerator returned from the dictionary is safe to use + // concurrently with reads and writes to the dictionary. + var removed = new List(); + + foreach (var pair in _registry) { - foreach (var handlerKey in from pair in _registry - let clientDelegateInfo = pair.Value - where clientDelegateInfo.ServiceProxy == serviceProxy - select pair.Key) + if (pair.Value.ServiceProxy != serviceProxy) + continue; + + if (_registry.TryRemove(pair.Key, out var info)) { - _registry.TryRemove(handlerKey, out _); + _reverseIndex.TryRemove((info.ClientDelegate, info.ServiceProxy), out _); + removed.Add(pair.Key); } } - /// - /// Unregisters a specified client delegate by its handler key. - /// - /// Unique handler key - [SuppressMessage("ReSharper", "UnusedVariable")] - public void UnregisterClientDelegate(Guid handlerKey) - { - _registry.TryRemove(handlerKey, out var removedEntry); - } - - /// - /// Unregister a specified client delegate. - /// - /// Client delegate - [SuppressMessage("ReSharper", "UnusedVariable")] - public void UnregisterClientDelegate(Delegate clientDelegate) - { - var handlerKey = FindDelegate(clientDelegate); - - if (handlerKey == Guid.Empty) - return; - - _registry.TryRemove(handlerKey, out var removedEntry); - } + return removed.ToArray(); + } - /// - /// Clear the entire client delegate registry. - /// - public void Clear() - { - _registry.Clear(); - } + /// + /// Clears the entire client delegate registry. + /// + public void Clear() + { + _registry.Clear(); + _reverseIndex.Clear(); } -} \ No newline at end of file +} diff --git a/CoreRemoting/RemoteDelegates/DelegateProxy.cs b/CoreRemoting/RemoteDelegates/DelegateProxy.cs index e07bedfe..a397b85a 100644 --- a/CoreRemoting/RemoteDelegates/DelegateProxy.cs +++ b/CoreRemoting/RemoteDelegates/DelegateProxy.cs @@ -1,159 +1,76 @@ using System; -using System.Linq; -using System.Reflection; using System.Reflection.Emit; +using System.Threading; +using CoreRemoting.Toolbox; -namespace CoreRemoting.RemoteDelegates +namespace CoreRemoting.RemoteDelegates; + +/// +/// Proxy for intercepting calls on a specified delegate type. +/// +public sealed class DelegateProxy : IDelegateProxy { + private Func _callInterceptionHandler; + private readonly object _disposedReturnValue; + /// - /// Proxy for intercepting calls on a specified delegate type. + /// Creates a new instance of the DelegateProxy class. /// - public sealed class DelegateProxy : IDelegateProxy + /// Delegate type to be proxied + /// Function to be called when intercepting calls on the delegate + /// Dynamic method that provides the delegate invocation body + internal DelegateProxy(Type delegateType, Func callInterceptionHandler, DynamicMethod dynamicMethod) { - private Func _callInterceptionHandler; - - /// - /// Creates a new instance of the DelegateProxy class. - /// - /// Delegate type to be proxied - /// Function to be called when intercepting calls on the delegate - internal DelegateProxy(Type delegateType, Func callInterceptionHandler) - { - _callInterceptionHandler = - callInterceptionHandler ?? - throw new ArgumentNullException(nameof(callInterceptionHandler)); - - var interceptMethod = - GetType() - .GetMethod( - name: nameof(Intercept), - bindingAttr: BindingFlags.NonPublic | BindingFlags.InvokeMethod | BindingFlags.Instance); - - ProxiedDelegate = - CreateProxiedDelegate( - delegateType: delegateType, - interceptMethod: interceptMethod, - interceptor: this); - } - - /// - /// Gets the proxied delegate. - /// - public Delegate ProxiedDelegate { get; private set; } + _callInterceptionHandler = + callInterceptionHandler ?? + throw new ArgumentNullException(nameof(callInterceptionHandler)); - /// - /// Method called by delegate proxy, when the proxies delegate is called. - /// - /// Arguments passed to the proxied delegate by caller - /// Return value provided by call interception handler - private object Intercept(params object[] args) - { - // Redirect call to interception handler - return _callInterceptionHandler?.Invoke(args); - } + DynamicMethod = + dynamicMethod ?? + throw new ArgumentNullException(nameof(dynamicMethod)); - /// - /// Creates a delegate for intercepting calls on a specified delegate type. - /// - /// The delegate type to proxy - /// Method to call when intercepting calls on the proxied delegate - /// Object on which the intercept method is called - /// Proxied delegate - /// Thrown if any argument is null - /// Thrown if delegate type has no 'Invoke' method - /// Thrown if argument 'delegateType' is not a delegate - private Delegate CreateProxiedDelegate(Type delegateType, MethodInfo interceptMethod, object interceptor) - { - if (interceptMethod == null) - throw new ArgumentNullException(nameof(interceptMethod)); - - if (interceptor == null) - throw new ArgumentNullException(nameof(interceptor)); - - if (!typeof(Delegate).IsAssignableFrom(delegateType)) - throw new ArgumentException($"Specified type ({delegateType?.FullName}) must be a delegate type.", nameof(delegateType)); - - var interceptorObjectType = interceptor.GetType(); - var invokeMethod = delegateType.GetMethod("Invoke"); - - if (invokeMethod == null) - throw new NotSupportedException("Provided delegate type has no 'Invoke' method."); - - var parameterTypeList = - invokeMethod - .GetParameters() - .Select(p => p.ParameterType) - .ToList(); + // precompute the value returned by a disposed proxy for the delegate's + // return type (null for void/reference types, boxed default for value types) + _disposedReturnValue = + delegateType.GetMethod("Invoke")?.ReturnType.GetDefaultValue(); - var parameterCount = parameterTypeList.Count; - parameterTypeList.Insert(0, interceptorObjectType); - var parameterTypes = parameterTypeList.ToArray(); - - // Create dynamic method for delegate call interception - var delegateProxyMethod = - new DynamicMethod( - name: nameof(Intercept), - returnType: invokeMethod.ReturnType, - parameterTypes: parameterTypes, - owner: interceptorObjectType); - - // Create method body, declare local variable of type object[] - var ilGenerator = delegateProxyMethod.GetILGenerator(); - var argumentsArray = ilGenerator.DeclareLocal(typeof(object[])); - - // var args = new object[paramCount]; - ilGenerator.Emit(OpCodes.Nop); - ilGenerator.Emit(OpCodes.Ldc_I4, parameterCount); - ilGenerator.Emit(OpCodes.Newarr, typeof(object)); - ilGenerator.Emit(OpCodes.Stloc, argumentsArray); - - var index = 1; - - // Load method arguments one by one - foreach (var paramType in parameterTypes.Skip(1)) - { - // Load object[] array reference - ilGenerator.Emit(OpCodes.Ldloc, argumentsArray); - ilGenerator.Emit(OpCodes.Ldc_I4, index - 1); // Array index - ilGenerator.Emit(OpCodes.Ldarg, index++); // Method parameter index - - // Box parameter value, if it is a value type - if (typeof(ValueType).IsAssignableFrom(paramType)) - ilGenerator.Emit(OpCodes.Box, paramType); + ProxiedDelegate = dynamicMethod.CreateDelegate(delegateType, this); + } - // Store reference - ilGenerator.Emit(OpCodes.Stelem_Ref); - } + /// + /// Gets the proxied delegate. + /// + public Delegate ProxiedDelegate { get; private set; } - // Call intercept method and pass parameters as arguments array - ilGenerator.Emit(OpCodes.Ldarg_0); - ilGenerator.Emit(OpCodes.Ldloc, argumentsArray); // object[] args - ilGenerator.Emit(OpCodes.Call, interceptMethod); + /// + /// Gets the generated dynamic method that provides the delegate invocation body. + /// + internal DynamicMethod DynamicMethod { get; } - // Discard return value, if return type of proxied delegate is void - if (invokeMethod.ReturnType == typeof(void)) - ilGenerator.Emit(OpCodes.Pop); - else if (typeof(ValueType).IsAssignableFrom(invokeMethod.ReturnType)) // Unbox return value, if it is a value type - ilGenerator.Emit(OpCodes.Unbox_Any, invokeMethod.ReturnType); + /// + public bool IsDisposed => _callInterceptionHandler == null; - // Return the return value - ilGenerator.Emit(OpCodes.Ret); + /// + /// Method called by delegate proxy, when the proxies delegate is called. + /// Returns the default value of the delegate's return type when disposed. + /// + /// Arguments passed to the proxied delegate by caller + /// Return value provided by call interception handler + internal object Intercept(object[] args) + { + var handler = _callInterceptionHandler; - // Bake dynamic method and create a delegate of it - var result = - delegateProxyMethod.CreateDelegate(delegateType, interceptor); - - // Return as proxied delegate type - return result; - } + return handler == null + ? _disposedReturnValue + : handler(args); + } - /// - /// Frees managed resources. - /// - public void Dispose() - { - ProxiedDelegate = null; - _callInterceptionHandler = null; - } + /// + /// Frees managed resources. + /// + public void Dispose() + { + ProxiedDelegate = null; + _callInterceptionHandler = null; } -} \ No newline at end of file +} diff --git a/CoreRemoting/RemoteDelegates/DelegateProxyFactory.cs b/CoreRemoting/RemoteDelegates/DelegateProxyFactory.cs index 86d969fb..e84d90a2 100644 --- a/CoreRemoting/RemoteDelegates/DelegateProxyFactory.cs +++ b/CoreRemoting/RemoteDelegates/DelegateProxyFactory.cs @@ -1,21 +1,123 @@ using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; -namespace CoreRemoting.RemoteDelegates +namespace CoreRemoting.RemoteDelegates; + +/// +/// Factory component to create delegate proxy instances. +/// +public class DelegateProxyFactory : IDelegateProxyFactory { + private readonly ConcurrentDictionary _dynamicMethods = new(); + /// - /// Factory component to create delegate proxy instances. + /// Creates a proxy for the specified delegate type. /// - public class DelegateProxyFactory : IDelegateProxyFactory + /// Delegate type to be proxied + /// Function to be called when intercepting calls on the delegate + /// Delegate proxy + /// Thrown if any argument is null + /// Thrown if the type is not a delegate or is an open generic + /// Thrown if the delegate type is not supported + public IDelegateProxy Create(Type delegateType, Func callInterceptionHandler) + { + if (delegateType == null) + throw new ArgumentNullException(nameof(delegateType)); + + if (callInterceptionHandler == null) + throw new ArgumentNullException(nameof(callInterceptionHandler)); + + if (!typeof(Delegate).IsAssignableFrom(delegateType)) + throw new ArgumentException($"Specified type ({delegateType.FullName}) must be a delegate type.", nameof(delegateType)); + + if (delegateType.IsGenericTypeDefinition) + throw new ArgumentException($"Open generic delegate type '{delegateType}' is not supported.", nameof(delegateType)); + + var invokeMethod = delegateType.GetMethod("Invoke"); + + if (invokeMethod == null) + throw new NotSupportedException("Provided delegate type has no 'Invoke' method."); + + if (invokeMethod.GetParameters().Any(p => p.ParameterType.IsByRef)) + throw new NotSupportedException("Delegates with ref/out/in parameters are not supported."); + + var dynamicMethod = _dynamicMethods.GetOrAdd(delegateType, BuildDynamicMethod); + + return new DelegateProxy(delegateType, callInterceptionHandler, dynamicMethod); + } + + private static DynamicMethod BuildDynamicMethod(Type delegateType) { - /// - /// Creates a proxy for the specified delegate type. - /// - /// Delegate type to be proxied - /// Function to be called when intercepting calls on the delegate - /// Delegate proxy - public IDelegateProxy Create(Type delegateType, Func callInterceptionHandler) + var invokeMethod = delegateType.GetMethod("Invoke"); + var interceptorType = typeof(DelegateProxy); + + var interceptMethod = interceptorType.GetMethod( + name: nameof(DelegateProxy.Intercept), + bindingAttr: BindingFlags.NonPublic | BindingFlags.Instance); + + var parameterTypeList = + invokeMethod + .GetParameters() + .Select(p => p.ParameterType) + .ToList(); + + var parameterCount = parameterTypeList.Count; + parameterTypeList.Insert(0, interceptorType); + var parameterTypes = parameterTypeList.ToArray(); + + // Create dynamic method for delegate call interception + var delegateProxyMethod = + new DynamicMethod( + name: "Intercept", + returnType: invokeMethod.ReturnType, + parameterTypes: parameterTypes, + owner: interceptorType); + + // Create method body, declare local variable of type object[] + var ilGenerator = delegateProxyMethod.GetILGenerator(); + var argumentsArray = ilGenerator.DeclareLocal(typeof(object[])); + + // var args = new object[paramCount]; + ilGenerator.Emit(OpCodes.Nop); + ilGenerator.Emit(OpCodes.Ldc_I4, parameterCount); + ilGenerator.Emit(OpCodes.Newarr, typeof(object)); + ilGenerator.Emit(OpCodes.Stloc, argumentsArray); + + var index = 1; + + // Load method arguments one by one + foreach (var paramType in parameterTypes.Skip(1)) { - return new DelegateProxy(delegateType, callInterceptionHandler); + // Load object[] array reference + ilGenerator.Emit(OpCodes.Ldloc, argumentsArray); + ilGenerator.Emit(OpCodes.Ldc_I4, index - 1); // Array index + ilGenerator.Emit(OpCodes.Ldarg, index++); // Method parameter index + + // Box parameter value, if it is a value type + if (paramType.IsValueType) + ilGenerator.Emit(OpCodes.Box, paramType); + + // Store reference + ilGenerator.Emit(OpCodes.Stelem_Ref); } + + // Call intercept method and pass parameters as arguments array + ilGenerator.Emit(OpCodes.Ldarg_0); + ilGenerator.Emit(OpCodes.Ldloc, argumentsArray); // object[] args + ilGenerator.Emit(OpCodes.Call, interceptMethod); + + // Discard return value, if return type of proxied delegate is void + if (invokeMethod.ReturnType == typeof(void)) + ilGenerator.Emit(OpCodes.Pop); + else if (invokeMethod.ReturnType.IsValueType) // Unbox return value, if it is a value type + ilGenerator.Emit(OpCodes.Unbox_Any, invokeMethod.ReturnType); + + // Return the return value + ilGenerator.Emit(OpCodes.Ret); + + return delegateProxyMethod; } -} \ No newline at end of file +} diff --git a/CoreRemoting/RemoteDelegates/EventDispatcher.cs b/CoreRemoting/RemoteDelegates/EventDispatcher.cs new file mode 100644 index 00000000..3d1c822a --- /dev/null +++ b/CoreRemoting/RemoteDelegates/EventDispatcher.cs @@ -0,0 +1,41 @@ +using System; +using CoreRemoting.DependencyInjection; + +namespace CoreRemoting.RemoteDelegates; + +/// +/// Server-side event dispatcher. +/// +/// Dependency injection container. +public class EventDispatcher(IDependencyInjectionContainer container) : IEventDispatcher +{ + /// + /// Invokes the specified event with provided arguments. + /// + /// + /// A function that returns event name. Must not use its argument + /// (e.g. s => nameof(s.SimpleEvent)). + /// + /// Event arguments. + /// Service interface. + public void Invoke(Func getName, params object[] arguments) + where TServiceInterface : class + { + var serviceName = typeof(TServiceInterface).FullName; + var reg = container.GetServiceRegistration(serviceName); + var hub = reg.EventHub?.CreateTyped(); + hub?.Invoke(getName, arguments); + } + + /// + /// Invokes the specified event with provided arguments. + /// + /// Service name. + /// The event name. + /// Event arguments. + public void Invoke(string serviceName, string eventName, params object[] arguments) + { + var reg = container.GetServiceRegistration(serviceName); + reg.EventHub?.Invoke(eventName, arguments); + } +} diff --git a/CoreRemoting/RemoteDelegates/IDelegateProxy.cs b/CoreRemoting/RemoteDelegates/IDelegateProxy.cs index b6281129..8bb17618 100644 --- a/CoreRemoting/RemoteDelegates/IDelegateProxy.cs +++ b/CoreRemoting/RemoteDelegates/IDelegateProxy.cs @@ -1,15 +1,21 @@ using System; -namespace CoreRemoting.RemoteDelegates +namespace CoreRemoting.RemoteDelegates; + +/// +/// Interface to be implemented by delegate proxies. +/// +public interface IDelegateProxy : IDisposable { /// - /// Interface to be implemented by delegate proxies. + /// Gets the proxied delegate. + /// + Delegate ProxiedDelegate { get; } + + /// + /// Gets whether this proxy has been disposed. Once disposed, invoking + /// the proxied delegate is a no-op (returns the default value for the + /// delegate's return type). /// - public interface IDelegateProxy : IDisposable - { - /// - /// Gets the proxied delegate. - /// - Delegate ProxiedDelegate { get; } - } -} \ No newline at end of file + bool IsDisposed { get; } +} diff --git a/CoreRemoting/RemoteDelegates/IDelegateProxyFactory.cs b/CoreRemoting/RemoteDelegates/IDelegateProxyFactory.cs index 6d8e5daa..c0f58b96 100644 --- a/CoreRemoting/RemoteDelegates/IDelegateProxyFactory.cs +++ b/CoreRemoting/RemoteDelegates/IDelegateProxyFactory.cs @@ -1,18 +1,17 @@ using System; -namespace CoreRemoting.RemoteDelegates +namespace CoreRemoting.RemoteDelegates; + +/// +/// Interface to be implemented by delegate proxy factory components. +/// +public interface IDelegateProxyFactory { /// - /// Interface to be implemented by delegate proxy factory components. + /// Creates a proxy for the specified delegate type. /// - public interface IDelegateProxyFactory - { - /// - /// Creates a proxy for the specified delegate type. - /// - /// Delegate type to be proxied - /// Function to be called when intercepting calls on the delegate - /// Delegate proxy - IDelegateProxy Create(Type delegateType, Func callInterceptionHandler); - } + /// Delegate type to be proxied + /// Function to be called when intercepting calls on the delegate + /// Delegate proxy + IDelegateProxy Create(Type delegateType, Func callInterceptionHandler); } \ No newline at end of file diff --git a/CoreRemoting/RemoteDelegates/IEventDispatcher.cs b/CoreRemoting/RemoteDelegates/IEventDispatcher.cs new file mode 100644 index 00000000..c14890f0 --- /dev/null +++ b/CoreRemoting/RemoteDelegates/IEventDispatcher.cs @@ -0,0 +1,29 @@ +using System; + +namespace CoreRemoting.RemoteDelegates; + +/// +/// Server-side event dispatcher. +/// +public interface IEventDispatcher +{ + /// + /// Invokes the specified event with provided arguments. + /// + /// + /// A function that returns event name. Must not use its argument + /// (e.g. s => nameof(s.SimpleEvent)). + /// + /// Event arguments. + /// Service interface. + void Invoke(Func getName, params object[] arguments) + where TServiceInterface: class; + + /// + /// Invokes the specified event with provided arguments. + /// + /// Service name. + /// The event name. + /// Event arguments. + public void Invoke(string serviceName, string eventName, params object[] arguments); +} diff --git a/CoreRemoting/RemoteDelegates/IServerSubscriptionService.cs b/CoreRemoting/RemoteDelegates/IServerSubscriptionService.cs new file mode 100644 index 00000000..122efada --- /dev/null +++ b/CoreRemoting/RemoteDelegates/IServerSubscriptionService.cs @@ -0,0 +1,25 @@ +using System; +using System.Threading.Tasks; + +namespace CoreRemoting.RemoteDelegates; + +/// +/// Manages server-side event subscriptions for remote sessions. +/// +internal interface IServerSubscriptionService +{ + /// + /// Removes all handlers registered by the specified session. + /// + /// Session identifier. + /// Number of removed occurrences. + Task RemoveAllForSession(Guid sessionId); + + /// + /// Removes handlers with the given keys across all registered services. + /// Idempotent: unknown keys are silently ignored. + /// + /// Handler keys to remove. + /// Number of removed occurrences. + Task RemoveHandlers(Guid[] handlerKeys); +} \ No newline at end of file diff --git a/CoreRemoting/RemoteDelegates/RemoteDelegateInfo.cs b/CoreRemoting/RemoteDelegates/RemoteDelegateInfo.cs index 85badf1d..4fe138f5 100644 --- a/CoreRemoting/RemoteDelegates/RemoteDelegateInfo.cs +++ b/CoreRemoting/RemoteDelegates/RemoteDelegateInfo.cs @@ -1,40 +1,39 @@ using System; using System.Runtime.Serialization; -namespace CoreRemoting.RemoteDelegates +namespace CoreRemoting.RemoteDelegates; + +/// +/// Describes a remote delegate. +/// +[DataContract] +[Serializable] +public class RemoteDelegateInfo { + [DataMember] + private Guid _handlerKey; + + [DataMember] + private string _delegateTypeName; + /// - /// Describes a remote delegate. + /// Creates a new instance of the RemoteDelegateInfo class. /// - [DataContract] - [Serializable] - public class RemoteDelegateInfo + /// Unique handler key of the client delegate + /// Type name of the client delegate + public RemoteDelegateInfo(Guid handlerKey, string delegateTypeName) { - [DataMember] - private Guid _handlerKey; - - [DataMember] - private string _delegateTypeName; + _handlerKey = handlerKey; + _delegateTypeName = delegateTypeName; + } - /// - /// Creates a new instance of the RemoteDelegateInfo class. - /// - /// Unique handler key of the client delegate - /// Type name of the client delegate - public RemoteDelegateInfo(Guid handlerKey, string delegateTypeName) - { - _handlerKey = handlerKey; - _delegateTypeName = delegateTypeName; - } - - /// - /// Gets the handler key of the client delegate. - /// - public Guid HandlerKey => _handlerKey; + /// + /// Gets the handler key of the client delegate. + /// + public Guid HandlerKey => _handlerKey; - /// - /// Gets the type name of the client delegate. - /// - public string DelegateTypeName => _delegateTypeName; - } + /// + /// Gets the type name of the client delegate. + /// + public string DelegateTypeName => _delegateTypeName; } \ No newline at end of file diff --git a/CoreRemoting/RemoteDelegates/RemoteDelegateInvocationEventAggregator.cs b/CoreRemoting/RemoteDelegates/RemoteDelegateInvocationEventAggregator.cs index 95b0ee8e..9f8ddd30 100644 --- a/CoreRemoting/RemoteDelegates/RemoteDelegateInvocationEventAggregator.cs +++ b/CoreRemoting/RemoteDelegates/RemoteDelegateInvocationEventAggregator.cs @@ -1,45 +1,44 @@ using System; -namespace CoreRemoting.RemoteDelegates +namespace CoreRemoting.RemoteDelegates; + +/// +/// Event aggregator to fire event if remote delegate is invoked. +/// +internal class RemoteDelegateInvocationEventAggregator { /// - /// Event aggregator to fire event if remote delegate is invoked. + /// Creates a new instance of the RemoteDelegateInvocationEventAggregator class. + /// + /// Delegate type + /// Unique key to correlate the RPC call + /// Unique handler key to correlate the client delegate to be called, when remote delegate is invoked + /// Arguments of remote delegate invocation + internal delegate void RemoteDelegateInvocationNeededEventHandler( + Type delegateType, + Guid uniqueCallKey, + Guid handlerKey, + object[] remoteDelegateArguments); + + /// + /// Event: Fired on client side, when a remote delegate is invoked. + /// + internal event RemoteDelegateInvocationNeededEventHandler RemoteDelegateInvocationNeeded; + + /// + /// To be called on server side to invoke a remote delegate, which will callback the correlating client delegate. /// - internal class RemoteDelegateInvocationEventAggregator + /// Delegate type + /// Unique handle key of the client delegate + /// Arguments of remote delegate invocation + /// Return value provided by the client side callback + internal object InvokeRemoteDelegate(Type delegateType, Guid handlerKey, object[] remoteDelegateArguments) { - /// - /// Creates a new instance of the RemoteDelegateInvocationEventAggregator class. - /// - /// Delegate type - /// Unique key to correlate the RPC call - /// Unique handler key to correlate the client delegate to be called, when remote delegate is invoked - /// Arguments of remote delegate invocation - internal delegate void RemoteDelegateInvocationNeededEventHandler( - Type delegateType, - Guid uniqueCallKey, - Guid handlerKey, - object[] remoteDelegateArguments); - - /// - /// Event: Fired on client side, when a remote delegate is invoked. - /// - internal event RemoteDelegateInvocationNeededEventHandler RemoteDelegateInvocationNeeded; - - /// - /// To be called on server side to invoke a remote delegate, which will callback the correlating client delegate. - /// - /// Delegate type - /// Unique handle key of the client delegate - /// Arguments of remote delegate invocation - /// Return value provided by the client side callback - internal object InvokeRemoteDelegate(Type delegateType, Guid handlerKey, object[] remoteDelegateArguments) - { - RemoteDelegateInvocationNeeded?.Invoke( - delegateType: delegateType, - uniqueCallKey: Guid.NewGuid(), - handlerKey: handlerKey, - remoteDelegateArguments: remoteDelegateArguments); - return null; - } + RemoteDelegateInvocationNeeded?.Invoke( + delegateType: delegateType, + uniqueCallKey: Guid.NewGuid(), + handlerKey: handlerKey, + remoteDelegateArguments: remoteDelegateArguments); + return null; } } \ No newline at end of file diff --git a/CoreRemoting/RemoteDelegates/ServerSubscriptionService.cs b/CoreRemoting/RemoteDelegates/ServerSubscriptionService.cs new file mode 100644 index 00000000..83e534c0 --- /dev/null +++ b/CoreRemoting/RemoteDelegates/ServerSubscriptionService.cs @@ -0,0 +1,50 @@ +using System; +using System.Threading.Tasks; +using CoreRemoting.DependencyInjection; + +namespace CoreRemoting.RemoteDelegates; + +/// +/// Default implementation of . +/// +internal class ServerSubscriptionService : IServerSubscriptionService +{ + private readonly IDependencyInjectionContainer _container; + + /// + /// Creates a new instance of the ServerSubscriptionService class. + /// + /// Dependency injection container + public ServerSubscriptionService(IDependencyInjectionContainer container) + { + _container = container ?? throw new ArgumentNullException(nameof(container)); + } + + /// + public Task RemoveAllForSession(Guid sessionId) + { + var count = 0; + + foreach (var registration in _container.GetServiceRegistrations()) + count += registration.EventHub.RemoveAllForSession(sessionId); + + return Task.FromResult(count); + } + + /// + public Task RemoveHandlers(Guid[] handlerKeys) + { + if (handlerKeys is null or { Length: 0 }) + return Task.FromResult(0); + + var count = 0; + + foreach (var registration in _container.GetServiceRegistrations()) + count += registration.EventHub.RemoveHandlers(handlerKeys); + + // also dispose this session's delegate argument proxies + RemotingSession.Current?.RemoveDelegateProxies(handlerKeys); + + return Task.FromResult(count); + } +} diff --git a/CoreRemoting/RemoteDelegates/EventStub.cs b/CoreRemoting/RemoteDelegates/ServiceEventHub.cs similarity index 53% rename from CoreRemoting/RemoteDelegates/EventStub.cs rename to CoreRemoting/RemoteDelegates/ServiceEventHub.cs index 79e25f5b..f4fc3aef 100644 --- a/CoreRemoting/RemoteDelegates/EventStub.cs +++ b/CoreRemoting/RemoteDelegates/ServiceEventHub.cs @@ -9,15 +9,15 @@ namespace CoreRemoting.RemoteDelegates; /// -/// Event stub caches all event handlers for the single-call or scoped components. +/// Event hub caches all event handlers for the single-call or scoped components. /// -public class EventStub +public class ServiceEventHub { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// Type of the interface. - public EventStub(Type interfaceType) + public ServiceEventHub(Type interfaceType) { InterfaceType = interfaceType ?? throw new ArgumentNullException(nameof(interfaceType)); CreateDelegateHolders(); @@ -45,7 +45,7 @@ public static IDelegateInvoker DelegateInvoker public Type InterfaceType { get; private set; } /// - /// Gets or sets the with the specified event property name. + /// Gets the with the specified event property name. /// /// Name of the event or delegate property. public Delegate this[string propertyName] => DelegateHolders[propertyName].InvocationDelegate; @@ -140,14 +140,31 @@ public interface IDelegateHolder /// /// Adds the handler. /// + /// Unique handler key of the client delegate. + /// Session identifier of the subscriber. /// The handler. - void AddHandler(Delegate handler); + void AddHandler(Guid handlerKey, Guid sessionId, Delegate handler); /// - /// Removes the handler. + /// Removes a single occurrence of the handler with the specified key. /// - /// The handler. - void RemoveHandler(Delegate handler); + /// Unique handler key of the client delegate. + /// True if an entry was removed, otherwise false. + bool RemoveHandler(Guid handlerKey); + + /// + /// Removes all occurrences of the handler with the specified key. + /// + /// Unique handler key of the client delegate. + /// Number of removed occurrences. + int RemoveAllForKey(Guid handlerKey); + + /// + /// Removes all handlers registered by the specified session. + /// + /// Session identifier. + /// Number of removed occurrences. + int RemoveAllForSession(Guid sessionId); /// /// Gets the handler count. @@ -160,6 +177,9 @@ public interface IDelegateHolder /// private class DelegateHolder : IDelegateHolder { + private readonly ConcurrentDictionary _entries = new(); + private readonly object _syncRoot = new(); + public DelegateHolder(IDelegateInvoker delegateInvoker) { // create default return value for the delegate @@ -277,36 +297,91 @@ public static TDelegate BuildInstanceDelegate(MethodInfo dynamicInvok private object DynamicInvoke(object[] arguments) { - // run in non-blocking mode - DelegateInvoker.Invoke(Delegate, arguments); + // ConcurrentDictionary can be enumerated safely while it is modified, + // so no snapshot is taken and no lock is required here. + foreach (var entry in _entries) + { + // an entry may represent multiple subscriptions of the same handler key, + // which must be fired separately to preserve multicast semantics + for (var i = 0; i < entry.Value.Count; i++) + DelegateInvoker.Invoke(entry.Value.Handler, arguments); + } + return DefaultReturnValue; } - private T TypedDelegate { get; set; } - - private object DefaultReturnValue { get; set; } + private object DefaultReturnValue { get; } - private Delegate Delegate + public void AddHandler(Guid handlerKey, Guid sessionId, Delegate handler) { - get { return (Delegate)(object)TypedDelegate; } - set { TypedDelegate = (T)(object)value; } + lock (_syncRoot) + { + if (_entries.TryGetValue(handlerKey, out var existing)) + { + // the same handler key must always resolve to the same delegate + // (handler keys come from ClientDelegateRegistry, which guarantees this) + if (!ReferenceEquals(existing.Handler, handler)) + { + throw new InvalidOperationException( + $"Handler key {handlerKey} is already registered with a different delegate."); + } + + _entries[handlerKey] = existing with { Count = existing.Count + 1 }; + } + else + { + _entries[handlerKey] = new SubscriptionEntry(sessionId, handler, 1); + } + } } - private object syncRoot = new(); + public bool RemoveHandler(Guid handlerKey) + { + lock (_syncRoot) + { + if (!_entries.TryGetValue(handlerKey, out var existing)) + return false; + + if (existing.Count <= 1) + _entries.TryRemove(handlerKey, out _); + else + _entries[handlerKey] = existing with { Count = existing.Count - 1 }; + + return true; + } + } - public void AddHandler(Delegate handler) + public int RemoveAllForKey(Guid handlerKey) { - lock (syncRoot) + lock (_syncRoot) { - Delegate = Delegate.Combine(Delegate, handler); + if (!_entries.TryRemove(handlerKey, out var existing)) + return 0; + + return existing.Count; } } - public void RemoveHandler(Delegate handler) + public int RemoveAllForSession(Guid sessionId) { - lock (syncRoot) + lock (_syncRoot) { - Delegate = Delegate.Remove(Delegate, handler); + var removed = 0; + var keysToRemove = new List(); + + foreach (var pair in _entries) + { + if (pair.Value.SessionId != sessionId) + continue; + + keysToRemove.Add(pair.Key); + removed += pair.Value.Count; + } + + foreach (var key in keysToRemove) + _entries.TryRemove(key, out _); + + return removed; } } @@ -314,14 +389,16 @@ public int HandlerCount { get { - if (Delegate == null) - { - return 0; - } + var count = 0; - return Delegate.GetInvocationList().Length; + foreach (var entry in _entries) + count += entry.Value.Count; + + return count; } } + + private sealed record SubscriptionEntry(Guid SessionId, Delegate Handler, int Count); } private bool GetWiredTo(object instance) => @@ -414,47 +491,181 @@ public void UnwireFrom(object instance) /// Adds the handler for the given event. /// /// The name of the event or delegate property. + /// Unique handler key of the client delegate. + /// Session identifier of the subscriber. /// The handler. - public void AddHandler(string name, Delegate handler) + public void AddHandler(string name, Guid handlerKey, Guid sessionId, Delegate handler) { - DelegateHolders[name].AddHandler(handler); + DelegateHolders[name].AddHandler(handlerKey, sessionId, handler); } /// - /// Removes the handler for the given event. + /// Removes a single occurrence of the handler with the specified key. /// /// The name of the event or delegate property. - /// The handler. - public void RemoveHandler(string name, Delegate handler) + /// Unique handler key of the client delegate. + /// True if an entry was removed, otherwise false. + public bool RemoveHandler(string name, Guid handlerKey) { - DelegateHolders[name].RemoveHandler(handler); + return DelegateHolders[name].RemoveHandler(handlerKey); } /// - /// Gets the count of event handlers for the given event or delegate property. + /// Removes all handlers registered by the specified session. /// - /// The event handler. - public static int GetHandlerCount(Delegate handler) + /// Session identifier. + /// Number of removed occurrences. + public int RemoveAllForSession(Guid sessionId) { - if (handler == null) - { + var removed = 0; + + foreach (var holder in DelegateHolders.Values) + removed += holder.RemoveAllForSession(sessionId); + + return removed; + } + + /// + /// Removes all occurrences of handlers with the given keys across all + /// events and delegate properties of this hub. Idempotent: unknown keys + /// are silently ignored. + /// + /// Handler keys to remove. + /// Number of removed occurrences. + public int RemoveHandlers(IEnumerable handlerKeys) + { + if (handlerKeys == null) return 0; - } var count = 0; - foreach (var d in handler.GetInvocationList()) + + foreach (var holder in DelegateHolders.Values) + foreach (var key in handlerKeys) + count += holder.RemoveAllForKey(key); + + return count; + } + + /// + /// Gets the count of event handlers for the given event or delegate property. + /// + /// Name of the event or delegate property. + public int GetHandlerCount(string name) => + DelegateHolders[name].HandlerCount; + + /// + /// Invokes the specified event with provided arguments. + /// + public void Invoke(string eventName, params object[] arguments) => + DelegateInvoker.Invoke(this[eventName], arguments); + + /// + /// Creates a strong-typed wrapping this hub. + /// + /// Interface type. + public TypedEventHub CreateTyped() where TInterface : class => + new TypedEventHub(this); + + /// + /// Strong-typed wrapper around , for unit tests. + /// Does not own the hub — all calls are delegated to . + /// + /// Interface. + public sealed class TypedEventHub where TInterface : class + { + /// + /// Initializes a new instance of the . + /// Wraps the given , or creates a fresh + /// when is null. + /// + /// The event hub to wrap, or null to create a new one. + public TypedEventHub(ServiceEventHub hub = null) { - // check if it's a delegate holder - if (d.Target is IDelegateHolder holder) + Hub = hub ?? new ServiceEventHub(typeof(TInterface)); + + var expected = typeof(TInterface); + var actual = Hub.InterfaceType; + + if (!expected.IsAssignableFrom(actual) && !actual.IsAssignableFrom(expected)) { - count += holder.HandlerCount; - continue; + throw new ArgumentException( + $"Event hub for '{actual}' is not compatible with '{expected}'.", + nameof(hub)); } - - // it's an ordinary subscriber - count++; } - return count; + /// + /// Gets the wrapped event hub. + /// + public ServiceEventHub Hub { get; } + + /// + /// Gets the with the specified event or delegate property name. + /// + /// Name of the event or delegate property. + public Delegate this[string name] => Hub[name]; + + /// + /// Invokes the specified event with provided arguments. + /// + /// + /// A function that returns event name. Must not use its argument + /// (e.g. s => nameof(s.SimpleEvent)). + /// + /// Event arguments. + public void Invoke(Func getName, params object[] arguments) => + Hub.Invoke(getName(null), arguments); + + /// + /// Invokes the specified event with provided arguments. + /// + /// The event name. + /// Event arguments. + public void Invoke(string eventName, params object[] arguments) => + Hub.Invoke(eventName, arguments); + + /// + /// Adds the handler for the given event or delegate property. + /// + /// + /// A function that returns event name. Must not use its argument + /// (e.g. s => nameof(s.SimpleEvent)). + /// + /// Unique handler key of the client delegate. + /// Session identifier of the subscriber. + /// Event handler to add. + public void AddHandler(Func getName, Guid handlerKey, Guid sessionId, Delegate handler) => + Hub.AddHandler(getName(null), handlerKey, sessionId, handler); + + /// + /// Adds the handler for the given event or delegate property. + /// + /// The event name. + /// Unique handler key of the client delegate. + /// Session identifier of the subscriber. + /// Event handler to add. + public void AddHandler(string name, Guid handlerKey, Guid sessionId, Delegate handler) => + Hub.AddHandler(name, handlerKey, sessionId, handler); + + /// + /// Removes a single occurrence of the handler with the specified key. + /// + /// + /// A function that returns event name. Must not use its argument + /// (e.g. s => nameof(s.SimpleEvent)). + /// + /// Unique handler key of the client delegate. + /// True if an entry was removed, otherwise false. + public bool RemoveHandler(Func getName, Guid handlerKey) => + Hub.RemoveHandler(getName(null), handlerKey); + + /// + /// Removes a single occurrence of the handler with the specified key. + /// + /// The event name. + /// Unique handler key of the client delegate. + /// True if an entry was removed, otherwise false. + public bool RemoveHandler(string name, Guid handlerKey) => + Hub.RemoveHandler(name, handlerKey); } } diff --git a/CoreRemoting/RemoteDelegates/SubscriptionTracker.cs b/CoreRemoting/RemoteDelegates/SubscriptionTracker.cs new file mode 100644 index 00000000..5b97e253 --- /dev/null +++ b/CoreRemoting/RemoteDelegates/SubscriptionTracker.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using CoreRemoting.RpcMessaging; +using CoreRemoting.Toolbox; + +namespace CoreRemoting.RemoteDelegates; + +/// +/// Tracks subscriptions by their handler keys and provides a compact +/// checksum to compare subscription sets between client and server. +/// +/// +/// The tracker counts operations rather than tracking state, so multicast +/// semantics of .NET events are preserved: adding the same subscription +/// twice increments the counter twice, removing it twice decrements it +/// twice. A subscription that is added twice will be invoked twice by the +/// server, and the checksum reflects that. +/// +/// The checksum is a pair (count, sum) where sum is the 128-bit sum of +/// all handler keys accounted for. Both client and server use the same +/// algorithm, so a mismatch reliably indicates a lost operation. +/// +/// Count and sum are updated together under a lock to guarantee that a +/// checksum is never observed with a mismatched count/sum pair. +/// +/// Reading a Guid's halves via honors the host +/// byte order. All platforms currently supported by .NET are little-endian, +/// so the checksum is deterministic across the client/server pair. +/// +public sealed class SubscriptionTracker +{ + private readonly object _syncRoot = new(); + private int _count; + private long _sumLo; + private long _sumHi; + + /// + /// Gets the subscription operation count (adds minus removes). + /// + public int Count + { + get { lock (_syncRoot) return _count; } + } + + /// + /// Gets the checksum of the current subscription set. + /// + public string Checksum + { + get + { + lock (_syncRoot) + return Format(_count, _sumLo, _sumHi); + } + } + + /// + /// Adds a subscription by its handler key. + /// + /// Handler key of the subscription to add. + public void Add(Guid handlerKey) + { + lock (_syncRoot) + UpdateCore(handlerKey, +1); + } + + /// + /// Adds one or more subscriptions by their handler keys. + /// + /// Handler keys of the subscriptions to add. + public void Add(IEnumerable handlerKeys) + { + if (handlerKeys == null) + return; + + if (handlerKeys is Guid[] array && array.Length == 0) + return; + + lock (_syncRoot) + foreach (var key in handlerKeys) + UpdateCore(key, +1); + } + + /// + /// Removes a subscription by its handler key. + /// + /// Handler key of the subscription to remove. + public void Remove(Guid handlerKey) + { + lock (_syncRoot) + UpdateCore(handlerKey, -1); + } + + /// + /// Removes one or more subscriptions by their handler keys. + /// + /// Handler keys of the subscriptions to remove. + public void Remove(IEnumerable handlerKeys) + { + if (handlerKeys == null) + return; + + if (handlerKeys is Guid[] array && array.Length == 0) + return; + + lock (_syncRoot) + foreach (var key in handlerKeys) + UpdateCore(key, -1); + } + + /// + /// Resets the tracker, optionally seeding it with the given handler keys. + /// + /// Handler keys to seed, or null to clear. + public void Reset(IEnumerable handlerKeys = null) + { + lock (_syncRoot) + { + _count = 0; + _sumLo = 0; + _sumHi = 0; + + if (handlerKeys != null) + foreach (var key in handlerKeys) + UpdateCore(key, +1); + } + } + + /// + /// Applies a single update operation. Must be called under . + /// + /// Handler key of the subscription. + /// +1 to add, -1 to remove. + private void UpdateCore(Guid handlerKey, int sign) + { + var (lo, hi) = Parts(handlerKey); + _count += sign; + _sumLo = unchecked(_sumLo + sign * lo); + _sumHi = unchecked(_sumHi + sign * hi); + } + + private static string Format(int count, long lo, long hi) => + $"{count}:{unchecked((ulong)lo):x16}-{unchecked((ulong)hi):x16}"; + + private static (long Lo, long Hi) Parts(Guid handlerKey) + { + var bytes = handlerKey.ToByteArray(); + return (BitConverter.ToInt64(bytes, 0), BitConverter.ToInt64(bytes, 8)); + } + + /// + /// Extracts the handler key from the arguments of an event accessor call. + /// + /// Mapped method arguments from . + /// Handler key, or if not applicable. + public static Guid ExtractHandlerKey(object[] args) => + args is { Length: 1 } && args[0] is RemoteDelegateInfo info + ? info.HandlerKey + : Guid.Empty; + + /// + /// Applies an add or remove operation for an event accessor call. + /// + public void Track(MethodInfo method, Guid handlerKey) + { + if (handlerKey == Guid.Empty) + return; + + if (!method.IsEventAccessor(out _, out var isSubscription)) + return; + + if (isSubscription) + Add(handlerKey); + else + Remove(handlerKey); + } +} \ No newline at end of file diff --git a/CoreRemoting/RemotingClient.cs b/CoreRemoting/RemotingClient.cs index 0a36860c..08a6eabe 100644 --- a/CoreRemoting/RemotingClient.cs +++ b/CoreRemoting/RemotingClient.cs @@ -1,11 +1,11 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; using System.Security; -using System.Threading; using System.Threading.Tasks; using System.Timers; using Castle.DynamicProxy; @@ -35,13 +35,14 @@ public sealed class RemotingClient : IRemotingClient, IAuthenticationProvider private IRawMessageTransport _rawMessageTransport; private readonly ISessionKeyPair _keyPair; private readonly ClientDelegateRegistry _delegateRegistry; + private readonly SubscriptionTracker _subscriptionTracker; private readonly CancellationTokenSource _cancellationTokenSource; private readonly ClientConfig _config; private readonly AsyncLock _channelLock; private Dictionary _activeCalls; private readonly AsyncLock _activeCallsLock; private Guid _sessionId; - private bool _authenticationRequired; + private AtomicBool _authenticationRequired; private byte[] _sharedSecret; private int _sharedSecretLength; private readonly object _sessionLock; @@ -50,14 +51,14 @@ public sealed class RemotingClient : IRemotingClient, IAuthenticationProvider private TaskCompletionSource _authenticationResponseTaskSource; private TaskCompletionSource _authenticationCompletedTaskSource; private readonly AsyncManualResetEvent _goodbyeCompletedEvent; - private bool _isAuthenticated; - private bool _isDisposing; - private int _isConnected; - private const int _true = 1; - private const int _false = 0; + private AtomicBool _isAuthenticated; + private AtomicBool _isDisposing; + private AtomicBool _isConnected; private Timer _keepSessionAliveTimer; private byte[] _serverPublicKeyBlob; - private bool _sessionClosedByServer; + private AtomicBool _sessionClosedByServer; + private readonly Lazy> _subscriptionInvoker; + private readonly ConcurrentDictionary _pendingUnsubscribes = new(); // ReSharper disable once InconsistentNaming private static readonly ConcurrentDictionary _clientInstances = new(); @@ -77,7 +78,7 @@ private RemotingClient() { MethodCallMessageBuilder = new MethodCallMessageBuilder(); MessageEncryptionManager = new MessageEncryptionManager(); - _authenticationRequired = false; + _authenticationRequired.Reset(); _sharedSecret = null; _activeCalls = null; _activeCallsLock = new(); @@ -86,10 +87,12 @@ private RemotingClient() _currentlyPendingMessagesCounter = new(initialCount: 1); _cancellationTokenSource = new(); _delegateRegistry = new(); + _subscriptionTracker = new(); _handshakeCompletedTaskSource = new(); _authenticationResponseTaskSource = new(); _authenticationCompletedTaskSource = new(); _goodbyeCompletedEvent = new(); + _subscriptionInvoker = new(() => CreateInvoker()); } /// @@ -152,7 +155,7 @@ private void OnDisconnected() foreach (var activeCall in activeCalls) { - if (_sessionClosedByServer) + if (_sessionClosedByServer.Value) { // Session was closed gracefully by server. // Complete all pending calls without error to allow graceful shutdown of in-flight calls @@ -170,7 +173,7 @@ private void OnDisconnected() } // Reset the flag after handling disconnect - _sessionClosedByServer = false; + _sessionClosedByServer.Reset(); } #endregion @@ -202,6 +205,48 @@ private void OnDisconnected() /// internal ClientDelegateRegistry ClientDelegateRegistry => _delegateRegistry; + /// + /// Gets the client-side subscription tracker. + /// + internal SubscriptionTracker SubscriptionTracker => _subscriptionTracker; + + /// + /// Gets whether this client instance is being disposed. + /// This is a point-in-time snapshot for guard checks only. To enter the + /// dispose path, use _isDisposing.TrySet(); combining this property + /// with Set() is racy. + /// + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")] + [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] + internal bool IsDisposing => _isDisposing.Value; + + /// + /// Gets whether authentication of this client instance succeeded. + /// + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")] + [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] + internal bool IsAuthenticated => _isAuthenticated.Value; + + /// + /// Gets whether the server requested authentication for this connection. + /// + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")] + [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] + internal bool AuthenticationRequired => _authenticationRequired.Value; + + /// + /// Internal hook for background-operation failures. By default writes + /// to . Tests may replace it to assert failures. + /// + internal Action ErrorHandler { get; set; } = + ex => Trace.TraceWarning($"CoreRemoting client error: {ex}"); + + /// + /// Gets the number of subscriptions whose server-side removal is still + /// in flight. Exposed for tests only. + /// + internal int PendingUnsubscribeCount => _pendingUnsubscribes.Count; + /// /// Gets or sets the invocation timeout in milliseconds. /// @@ -234,7 +279,7 @@ public Guid? SessionId { get { - if (_isDisposing) + if (IsDisposing) return null; lock (_sessionLock) @@ -250,7 +295,7 @@ public Guid? ResumableSessionId { get { - if (_isDisposing) + if (IsDisposing) return null; lock (_sessionLock) @@ -281,6 +326,10 @@ public Guid? ResumableSessionId /// /// Gets whether the connection to the server is established or not. /// + /// + /// Note: this is the transport state, not the client's logical state — + /// see the internal flag for the latter. + /// public bool IsConnected => _channel?.IsConnected ?? false; /// @@ -290,7 +339,7 @@ public bool HasSession { get { - if (_isDisposing) + if (IsDisposing) return false; lock (_sessionLock) @@ -339,7 +388,7 @@ public async Task ConnectAsync() if (_channel == null) throw new RemotingException("No client channel configured."); - _isConnected = _true; + _isConnected.Set(); _goodbyeCompletedEvent.Reset(); using (await _activeCallsLock) @@ -349,7 +398,7 @@ public async Task ConnectAsync() // where the previous cycle's task sources are already completed and authentication must run again _handshakeCompletedTaskSource = new(); _authenticationCompletedTaskSource = new(); - _isAuthenticated = false; + _isAuthenticated.Reset(); Identity = null; await _channel.ConnectAsync() @@ -390,7 +439,7 @@ public void Disconnect(bool quiet = false) => /// When set to true, no goodbye message is sent to the server public async Task DisconnectAsync(bool quiet = false) { - if (Interlocked.Exchange(ref _isConnected, _false) == _true) + if (_isConnected.TryReset()) _currentlyPendingMessagesCounter.Signal(); await _currentlyPendingMessagesCounter.WaitAsync() @@ -580,7 +629,7 @@ private async Task AuthenticateAsync() if (_config.Credentials is null or { Length: 0 } && _config.Authenticator is null or DefaultAuthenticator) { - if (_authenticationRequired) + if (AuthenticationRequired) throw new SecurityException("Authentication is required. Please check credentials."); return; @@ -598,7 +647,7 @@ await Task.WhenAll(authResponse, _authenticationCompletedTaskSource.Task).Timeou throw new SecurityException("Authentication timeout.")) .ConfigureAwait(false); - if (!_isAuthenticated) + if (!IsAuthenticated) throw new SecurityException("Authentication failed. Please check credentials."); // if the authentication protocol negotiated a new shared key, re-key the session. @@ -631,7 +680,7 @@ private void OnMessage(byte[] rawMessage) // the Disconnected event (which fires synchronously from // WatsonTcp after the Shutdown message is received). if (string.Equals(message.MessageType, "session_closed", StringComparison.OrdinalIgnoreCase)) - _sessionClosedByServer = true; + _sessionClosedByServer.Set(); Task.Run(async () => { @@ -732,7 +781,7 @@ private void ProcessCompleteHandshakeMessage(WireMessage message) _sessionId = handshakeMessage.SessionId; _sharedSecret = MessageEncryption ? handshakeMessage.SharedSecret ?? _sessionId.ToByteArray() : null; _sharedSecretLength = _sharedSecret?.Length ?? 0; - _authenticationRequired = handshakeMessage.AuthenticationRequired; + _authenticationRequired.Set(handshakeMessage.AuthenticationRequired); } // server requires message encryption, but the client's connection is unencrypted @@ -785,8 +834,8 @@ private void ProcessAuthenticationResponseMessage(WireMessage message) if (authResponseMessage.IsCompleted) { - _isAuthenticated = authResponseMessage.IsAuthenticated; - Identity = _isAuthenticated ? authResponseMessage.AuthenticatedIdentity : null; + _isAuthenticated.Set(authResponseMessage.IsAuthenticated); + Identity = authResponseMessage.IsAuthenticated ? authResponseMessage.AuthenticatedIdentity : null; _authenticationCompletedTaskSource.TrySetResult(true); } } @@ -803,10 +852,69 @@ private void ProcessGoodbyeMessage(WireMessage message) => private Task ProcessSessionClosedMessage(WireMessage message) { // Mark that the session was closed by server to gracefully complete in-flight calls - _sessionClosedByServer = true; + _sessionClosedByServer.Set(); return DisconnectAsync(quiet: true); } + /// + /// Reports a background-operation failure through . + /// + private void OnError(Exception ex) => ErrorHandler?.Invoke(ex); + + /// + /// Notifies the server that the given handlers should be removed. + /// Awaitable; propagates RPC failures to the caller. + /// + internal async Task NotifyServerUnsubscribeAsync(Guid[] handlerKeys) + { + if (handlerKeys is null or { Length: 0 }) + return; + + if (!_isConnected.Value) + return; + + await _subscriptionInvoker.Value + .Invoke(s => s.RemoveHandlers(handlerKeys)) + .ConfigureAwait(false); + } + + /// + /// Schedules server-side removal of the given handlers. + /// Deduplicated on a best-effort basis: if a removal for the same key is + /// already in flight, the existing task is reused. Under a rare race two + /// RPCs may be issued for the same key — harmless, because + /// IServerSubscriptionService.RemoveHandlers is idempotent. + /// Errors are reported via , not propagated. + /// + internal Task ScheduleServerUnsubscribe(Guid[] handlerKeys) + { + if (handlerKeys is null or { Length: 0 }) + return Task.CompletedTask; + + var tasks = new Task[handlerKeys.Length]; + + for (var i = 0; i < handlerKeys.Length; i++) + tasks[i] = _pendingUnsubscribes.GetOrAdd(handlerKeys[i], StartUnsubscribe); + + return Task.WhenAll(tasks); + } + + private Task StartUnsubscribe(Guid key) => Task.Run(async () => + { + try + { + await NotifyServerUnsubscribeAsync([key]).ConfigureAwait(false); + } + catch (Exception ex) + { + OnError(ex); + } + finally + { + _pendingUnsubscribes.TryRemove(key, out _); + } + }); + /// /// Processes a remote delegate invocation message from server. /// @@ -825,11 +933,19 @@ private void ProcessRemoteDelegateInvocationMessage(WireMessage message) sharedSecret: _sharedSecret, sendersPublicKeyBlob: _serverPublicKeyBlob)); - var localDelegate = - _delegateRegistry.GetDelegateByHandlerKey(delegateInvocationMessage.HandlerKey); + var localDelegate = _delegateRegistry.GetDelegateByHandlerKey( + delegateInvocationMessage.HandlerKey); + + if (localDelegate == null) + { + // handler was unregistered locally, tell the server to drop + // inactive subscription, so it stops sending invocations for this key + _ = ScheduleServerUnsubscribe([delegateInvocationMessage.HandlerKey]); + return; + } - // Invoke local delegate with arguments from remote caller - EventStub.DelegateInvoker.Invoke(localDelegate, delegateInvocationMessage.DelegateArguments); + // invoke local delegate with arguments from remote caller + ServiceEventHub.DelegateInvoker.Invoke(localDelegate, delegateInvocationMessage.DelegateArguments); } /// @@ -937,7 +1053,7 @@ internal async Task InvokeRemoteMethod(MethodCallMessage metho var signalCount = oneWay ? 0 : 1; _currentlyPendingMessagesCounter.AddCount(signalCount); - if (_isConnected == _false) + if (!_isConnected.Value) { _currentlyPendingMessagesCounter.Signal(signalCount); throw new RemoteInvocationException("Client disconnected"); @@ -1029,14 +1145,56 @@ public object CreateProxy(ServiceReference serviceReference) return CreateProxy(serviceInterfaceType, serviceReference.ServiceName); } + /// + /// Creates a service invoker that provides access to a remote service without generating a proxy. + /// + /// Type of the remote service contract (interface or class) + /// Unique name of the remote service + /// Service invoker object + public IServiceInvoker CreateInvoker(string serviceName = "") + where TService : class => + new ServiceInvoker(this, serviceName); + /// /// Shuts a specified service proxy down and frees resources. /// + /// + /// The server-side removal of subscriptions registered through this proxy + /// is scheduled asynchronously (fire-and-forget). Callers that need to await + /// the server-side cleanup should use . + /// /// Proxy object that should be shut down public void ShutdownProxy(object serviceProxy) + { + var keys = ShutdownLocal(serviceProxy); + _ = ScheduleServerUnsubscribe(keys); + } + + /// + /// Shuts a specified service proxy down and frees resources, awaiting the + /// server-side removal of its subscriptions. + /// + /// + /// Unlike , this method awaits the RPC that tells + /// the server to drop the proxy's subscriptions. Use it when deterministic + /// cleanup is required (e.g. in tests or before returning a proxy to a pool). + /// + /// Proxy object that should be shut down + public async Task ShutdownProxyAsync(object serviceProxy) + { + var keys = ShutdownLocal(serviceProxy); + await NotifyServerUnsubscribeAsync(keys) + .ConfigureAwait(false); + } + + /// + /// Finds the CoreRemoting service proxy (interceptor) inside a Castle-generated + /// proxy object. Returns null if the object is not a CoreRemoting proxy. + /// + internal static IServiceProxy FindServiceProxy(object serviceProxy) { if (!ProxyUtil.IsProxy(serviceProxy)) - return; + return null; var proxyType = serviceProxy.GetType(); @@ -1045,16 +1203,29 @@ public void ShutdownProxy(object serviceProxy) BindingFlags.Instance | BindingFlags.NonPublic); if (hiddenInterceptorsField == null) - return; + return null; - var interceptors = hiddenInterceptorsField.GetValue(serviceProxy) as IInterceptor[]; + var interceptors = hiddenInterceptorsField.GetValue(serviceProxy) as IInterceptor[]; - var coreRemotingInterceptor = - (from interceptor in interceptors - where interceptor is IServiceProxy - select interceptor).FirstOrDefault(); + return interceptors? + .OfType() + .FirstOrDefault(); + } - ((IServiceProxy) coreRemotingInterceptor)?.Shutdown(); + /// + /// Performs local-only proxy shutdown: unregisters client delegates, calls + /// , and returns the unregistered keys + /// so the caller can schedule/await the server-side removal. + /// + private Guid[] ShutdownLocal(object serviceProxy) + { + var proxy = FindServiceProxy(serviceProxy); + if (proxy == null) + return []; + + var keys = _delegateRegistry.UnregisterClientDelegatesOfServiceProxy(proxy); + proxy.Shutdown(); + return keys; } #endregion @@ -1069,7 +1240,8 @@ where interceptor is IServiceProxy /// public async ValueTask DisposeAsync() { - _isDisposing = true; + if (!_isDisposing.TrySet()) + return; if (DefaultRemotingClient == this) DefaultRemotingClient = null; @@ -1087,6 +1259,11 @@ await DisconnectAsync() _delegateRegistry.Clear(); + if (_subscriptionInvoker.IsValueCreated) + { + _subscriptionInvoker.Value.Dispose(); + } + if (_rawMessageTransport != null) { _rawMessageTransport.ReceiveMessage -= OnMessage; diff --git a/CoreRemoting/RemotingClientExtensions.cs b/CoreRemoting/RemotingClientExtensions.cs new file mode 100644 index 00000000..0496acb2 --- /dev/null +++ b/CoreRemoting/RemotingClientExtensions.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using CoreRemoting.RemoteDelegates; +using CoreRemoting.RpcMessaging; +using CoreRemoting.Serialization; +using CoreRemoting.Serialization.Bson; +using CoreRemoting.Toolbox; +using Serialize.Linq.Extensions; +using Serialize.Linq.Nodes; + +namespace CoreRemoting; + +/// +/// Provides service call argument and return value mapping extensions for the CoreRemoting client. +/// +internal static class RemotingClientExtensions +{ + /// + /// Unmaps a return value from the specified method call result message. + /// + /// CoreRemoting client that produced the result + /// Method call result message + /// Unmapped return value + public static object UnmapReturnValue( + this RemotingClient client, + MethodCallResultMessage resultMessage) + { + // Unwrap an enveloped value + var returnValue = + resultMessage.IsReturnValueNull + ? null + : resultMessage.ReturnValue is Envelope returnValueEnvelope + ? returnValueEnvelope.Value + : resultMessage.ReturnValue; + + // Create a proxy to remote service, if return type is a service reference + if (returnValue is ServiceReference serviceReference) + returnValue = client.CreateProxy(serviceReference); + + // Restore LINQ expression, if return type is a serializable expression + if (returnValue is ExpressionNode exprNode) + returnValue = exprNode.ToExpression(); + + return returnValue; + } + + /// + /// Maps non serializable arguments into a serializable form. + /// + /// CoreRemoting client that owns the call + /// Object that is paired with client delegates for cleanup + /// Arguments to be mapped + /// Array of mapped arguments + public static object[] MapArguments( + this RemotingClient client, + object owner, + IEnumerable arguments) + { + return arguments + .Select(argument => + { + var type = argument?.GetType(); + + if (MapDelegateArgument(client, owner, type, argument, out var mappedArgument)) + return mappedArgument; + + if (MapLinqExpressionArgument(type, argument, out mappedArgument)) + return mappedArgument; + + return argument; + }) + .ToArray(); + } + + /// + /// Maps a delegate argument into a serializable RemoteDelegateInfo object. + /// + /// CoreRemoting client that owns the call + /// Object that is paired with client delegates for cleanup + /// Type of argument to be mapped + /// Argument to be wrapped + /// Out: mapped argument + /// True if mapping applied, otherwise false + private static bool MapDelegateArgument( + RemotingClient client, + object owner, + Type argumentType, + object argument, + out object mappedArgument) + { + if (argumentType == null || !typeof(Delegate).IsAssignableFrom(argumentType)) + { + mappedArgument = argument; + return false; + } + + var delegateReturnType = argumentType.GetMethod("Invoke")?.ReturnType; + + if (delegateReturnType != null && delegateReturnType != typeof(void)) + throw new NotSupportedException("Only void delegates are supported."); + + var remoteDelegateInfo = + new RemoteDelegateInfo( + handlerKey: client.ClientDelegateRegistry.RegisterClientDelegate((Delegate)argument, owner), + delegateTypeName: argumentType.FullName + ", " + argumentType.Assembly.GetName().Name); + + mappedArgument = remoteDelegateInfo; + return true; + } + + /// + /// Maps a LINQ expression argument into a serializable ExpressionNode object. + /// + /// Type of argument to be mapped + /// Argument to be wrapped + /// Out: mapped argument + /// True if mapping applied, otherwise false + private static bool MapLinqExpressionArgument( + Type argumentType, + object argument, + out object mappedArgument) + { + if (!argumentType.IsLinqExpressionType()) + { + mappedArgument = argument; + return false; + } + + var expression = (Expression)argument; + mappedArgument = expression.ToExpressionNode(); + + return true; + } +} diff --git a/CoreRemoting/RemotingServer.cs b/CoreRemoting/RemotingServer.cs index d08587cc..ec170b9c 100644 --- a/CoreRemoting/RemotingServer.cs +++ b/CoreRemoting/RemotingServer.cs @@ -69,6 +69,13 @@ public RemotingServer(ServerConfig config = null) lifetime: ServiceLifetime.Singleton, asHiddenSystemService: true); + _container.RegisterService( + lifetime: ServiceLifetime.Singleton, + asHiddenSystemService: true); + + _container.RegisterService( + lifetime: ServiceLifetime.Singleton); + _config.RegisterServicesAction?.Invoke(_container); Channel = _config.Channel ?? new TcpServerChannel(); @@ -162,6 +169,17 @@ public RemotingServer(ServerConfig config = null) internal void OnBeforeCall(ServerRpcContext serverRpcContext) { BeforeCall?.Invoke(this, serverRpcContext); + + if (serverRpcContext.Cancel) + { + var cancelEx = serverRpcContext.Exception ?? + new RemoteInvocationException($"Invocation canceled: { + serverRpcContext.MethodCallMessage.ServiceName}.{ + serverRpcContext.MethodCallMessage.MethodName}"); + + // rethrow the exception keeping the original stack trace + ExceptionDispatchInfo.Capture(cancelEx).Throw(); + } } /// diff --git a/CoreRemoting/RemotingSession.cs b/CoreRemoting/RemotingSession.cs index dd9f1609..48c4e13b 100644 --- a/CoreRemoting/RemotingSession.cs +++ b/CoreRemoting/RemotingSession.cs @@ -40,9 +40,9 @@ public sealed class RemotingSession : IAsyncDisposable private readonly string _clientAddress; private readonly RemoteDelegateInvocationEventAggregator _remoteDelegateInvocationEventAggregator; private IDelegateProxyFactory _delegateProxyFactory; - private ConcurrentDictionary _delegateProxyCache; + internal ConcurrentDictionary _delegateProxyCache; private bool _isAuthenticated; - private bool _isDisposing; + private AtomicBool _isDisposing; private DateTime _lastActivityTimestamp; private readonly AsyncCountdownEvent _currentlyProcessedMessagesCounter; @@ -52,6 +52,7 @@ public sealed class RemotingSession : IAsyncDisposable private int _lifecycleState; private readonly ConcurrentDictionary _sessionVariables = new(); + private readonly SubscriptionTracker _subscriptionTracker = new(); private static readonly AsyncLocal CurrentSession = new(); @@ -78,7 +79,7 @@ internal RemotingSession(bool messageEncryption, int keySize, int sharedKeySize, byte[] clientPublicKey, string clientAddress, IRemotingServer server, IRawMessageTransport rawMessageTransport) { - _isDisposing = false; + _isDisposing.Reset(); _currentlyProcessedMessagesCounter = new(initialCount: 1); _sessionId = Guid.NewGuid(); _lastActivityTimestamp = DateTime.Now; @@ -109,7 +110,7 @@ internal RemotingSession(bool messageEncryption, int keySize, async (_, uniqueCallKey, handlerKey, arguments) => { // handle graceful client disconnection - if (_isDisposing) + if (IsDisposing) return; var remoteDelegateInvocationMessage = @@ -226,7 +227,7 @@ private void OnErrorOccured(string errorMessage, Exception ex) /// True if the session has been parked (already parked or disposed, otherwise false). internal bool ParkSession() { - if (_isDisposing || + if (IsDisposing || Interlocked.CompareExchange(ref _lifecycleState, _stateParked, _stateActive) != _stateActive) return false; @@ -250,7 +251,7 @@ internal void AttachTransport(IRawMessageTransport newTransport) if (newTransport == null) throw new ArgumentNullException(nameof(newTransport)); - if (_isDisposing || + if (IsDisposing || Interlocked.CompareExchange(ref _lifecycleState, _stateActive, _stateParked) != _stateParked) throw new RemotingException("The session can't be resumed because it is not in a parked state."); @@ -315,6 +316,14 @@ internal void AttachTransport(IRawMessageTransport newTransport) [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] internal bool IsParked => Volatile.Read(ref _lifecycleState) == _stateParked; + /// + /// Gets whether this session is currently being disposed. + /// Use _isDisposing.TrySet() to enter the dispose path. + /// + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")] + [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] + internal bool IsDisposing => _isDisposing.Value; + /// /// Gets whether this session can be resumed by a client presenting the given public key. /// The key has to match exactly the public key of the original connection (hijack protection). @@ -323,7 +332,7 @@ internal void AttachTransport(IRawMessageTransport newTransport) /// Client's session signature to prove its authenticity internal bool CanBeResumedWith(byte[] clientPublicKeyBlob, byte[] sessionSignature) => IsParked && - !_isDisposing && + !IsDisposing && _clientPublicKeyBlob != null && clientPublicKeyBlob != null && _clientPublicKeyBlob.SequenceEqual(clientPublicKeyBlob) && @@ -337,6 +346,11 @@ internal bool CanBeResumedWith(byte[] clientPublicKeyBlob, byte[] sessionSignatu internal RemoteDelegateInvocationEventAggregator RemoteDelegateInvocation => _remoteDelegateInvocationEventAggregator; + /// + /// Gets the subscription tracker of this session. + /// + internal SubscriptionTracker SubscriptionTracker => _subscriptionTracker; + /// /// Gets component that does the raw message transport (send and receive). /// @@ -459,12 +473,13 @@ private void OnReceiveMessage(byte[] rawMessage) => Task.Run(async () => { _lastActivityTimestamp = DateTime.Now; - if (rawMessage == null || rawMessage.Length == 0 || _isDisposing) + if (rawMessage == null || rawMessage.Length == 0 || IsDisposing) return; _currentlyProcessedMessagesCounter.AddCount(1); CurrentSession.Value = this; + var stopped = false; try { @@ -479,7 +494,7 @@ private void OnReceiveMessage(byte[] rawMessage) => Task.Run(async () => await ProcessRpcMessage(message).ConfigureAwait(false); break; case "goodbye": - await ProcessGoodbyeMessage(message).ConfigureAwait(false); + stopped = await ProcessGoodbyeMessage(message).ConfigureAwait(false); break; default: OnErrorOccured("Invalid message type " + message.MessageType + ".", ex: null); @@ -496,13 +511,18 @@ private void OnReceiveMessage(byte[] rawMessage) => Task.Run(async () => CurrentSession.Value = null; } + + if (stopped) + await RemoveCurrentSession() + .ConfigureAwait(false); + }).ConfigureAwait(false); /// /// Processes a wire message that contains a goodbye message, which is sent from a client to close the session. /// /// Wire message from client - private async Task ProcessGoodbyeMessage(WireMessage request) + private async Task ProcessGoodbyeMessage(WireMessage request) { var goodbyeMessage = _server.Serializer @@ -514,7 +534,7 @@ private async Task ProcessGoodbyeMessage(WireMessage request) sendersPublicKeyBlob: _clientPublicKeyBlob)); if (goodbyeMessage.SessionId != _sessionId) - return; + return false; var resultMessage = _server.MessageEncryptionManager.CreateWireMessage( @@ -531,8 +551,7 @@ await _rawMessageTransport.SendMessageAsync( ((RemotingServer)_server).OnLogoff(); - await RemoveCurrentSession() - .ConfigureAwait(false); + return true; } /// @@ -565,20 +584,20 @@ private async Task ProcessAuthenticationRequestMessage(WireMessage request) // auth response to let it derive the very same new shared secret. A server in legacy key // derivation mode (or without message encryption) cannot re-key the session, so the negotiated // shared key is nulled here to keep both endpoints consistent. - var canRekey = - MessageEncryption && + var canRekey = + MessageEncryption && !_server.Config.UseLegacySessionKeyDerivation; - var negotiatedSharedKey = - canRekey + var negotiatedSharedKey = + canRekey ? authResponseMessage.NegotiatedSharedKey : null; // the negotiated shared key travels encrypted inside the auth response, so it does not leak // over the wire in plaintext. Keep it in the response when the server re-keys the session, // so the client can derive the very same new shared secret; null it otherwise. - authResponseMessage.NegotiatedSharedKey = - canRekey + authResponseMessage.NegotiatedSharedKey = + canRekey ? negotiatedSharedKey?.GetSerializableCopy() : null; @@ -646,6 +665,7 @@ private async Task ProcessRpcMessage(WireMessage request) var method = default(MethodInfo); var oneWay = false; var registration = default(ServiceRegistration); + var eventHandlerKey = default(Guid); try { @@ -661,6 +681,8 @@ private async Task ProcessRpcMessage(WireMessage request) out var parameterValues, out var parameterTypes); + eventHandlerKey = SubscriptionTracker.ExtractHandlerKey(parameterValues); + parameterValues = MapArguments(parameterValues, parameterTypes); serverRpcContext.MethodCallParameterValues = parameterValues; serverRpcContext.MethodCallParameterTypes = parameterTypes; @@ -671,11 +693,14 @@ private async Task ProcessRpcMessage(WireMessage request) throw new NetworkException("Session is not authenticated."); registration = _server.ServiceRegistry.GetServiceRegistration(callMessage.ServiceName); + if (registration.IsHiddenSystemService) + throw new NotSupportedException($"Service {registration.InterfaceType.Name} cannot be called remotely."); + var service = _server.ServiceRegistry.GetService(callMessage.ServiceName); var serviceInterfaceType = registration.InterfaceType; serverRpcContext.ServiceInstance = service; - serverRpcContext.EventStub = registration.EventStub; + serverRpcContext.EventHub = registration.EventHub; method = GetMethodInfo(callMessage, serviceInterfaceType, parameterTypes); if (method == null) @@ -707,19 +732,21 @@ private async Task ProcessRpcMessage(WireMessage request) _server.Serializer.Serialize(serverRpcContext.Exception); } - object result = null; - if (serverRpcContext.Exception == null) { + object result = null; + try { ((RemotingServer)_server).OnBeforeCall(serverRpcContext); if (method.IsEventAccessor(out var eventName, out var subscription)) { + _subscriptionTracker.Track(method, eventHandlerKey); + // event accessor is called - HandleEventSubscription(serverRpcContext.EventStub, - eventName, subscription, serverRpcContext.MethodCallParameterValues); + HandleEventSubscription(serverRpcContext.EventHub, + eventName, subscription, eventHandlerKey, serverRpcContext.MethodCallParameterValues); result = null; } else @@ -729,43 +756,24 @@ private async Task ProcessRpcMessage(WireMessage request) serverRpcContext.MethodCallParameterValues); } - var returnType = method.ReturnType; + // expose the raw (pre-transform) result to the AfterCall handler + serverRpcContext.ReturnValue = result; - if (result != null) - { - // Wait for result value if result is a Task - var task = await TryAwaitReturnValue(returnType, result); - if (task.isAwaited) - { - result = task.result; - } + // transform once and pre-build the message so the handler sees both forms + var transformedResult = + await TransformReturnValueAsync(method.ReturnType, result); - // After potential await, perform post-processing based on the actual value/type. - // This fixes cases when async methods return LINQ expressions (Task>), - // which previously weren't converted to ExpressionNode and caused serialization issues. - if (result is Expression expression) - { - result = expression.ToExpressionNode(); - } - else if (returnType.GetCustomAttribute() != null) - { - var isRegisteredService = - returnType.IsInterface && - _server.ServiceRegistry - .GetAllRegisteredTypes().Any(s => - returnType.AssemblyQualifiedName != null && - returnType.AssemblyQualifiedName.Equals(s.AssemblyQualifiedName)); - - if (!isRegisteredService) - { - throw new InvalidOperationException( - $"Type '{returnType.AssemblyQualifiedName}' is not a registered service."); - } - - result = new ServiceReference( - serviceInterfaceTypeName: returnType.FullName + ", " + returnType.Assembly.GetName().Name, - serviceName: returnType.FullName); - } + if (!oneWay) + { + serverRpcContext.MethodCallResultMessage = + _server + .MethodCallMessageBuilder + .BuildMethodCallResultMessage( + serializer: _server.Serializer, + uniqueCallKey: serverRpcContext.UniqueCallKey, + method: method, + args: serverRpcContext.MethodCallParameterValues, + returnValue: transformedResult); } } catch (Exception ex) @@ -786,22 +794,40 @@ private async Task ProcessRpcMessage(WireMessage request) _server.Serializer.Serialize(serverRpcContext.Exception); } - if (!oneWay && serverRpcContext.Exception == null) - { - serverRpcContext.MethodCallResultMessage = - _server - .MethodCallMessageBuilder - .BuildMethodCallResultMessage( - serializer: _server.Serializer, - uniqueCallKey: serverRpcContext.UniqueCallKey, - method: method, - args: serverRpcContext.MethodCallParameterValues, - returnValue: result); - } - if (serverRpcContext.Exception == null) + { ((RemotingServer)_server).OnAfterCall(serverRpcContext); + // rebuild the message only if the handler actually changed ReturnValue + if (!oneWay && !Equals(serverRpcContext.ReturnValue, result)) + { + try + { + var retransformedResult = + await TransformReturnValueAsync(method.ReturnType, serverRpcContext.ReturnValue); + + serverRpcContext.MethodCallResultMessage = + _server + .MethodCallMessageBuilder + .BuildMethodCallResultMessage( + serializer: _server.Serializer, + uniqueCallKey: serverRpcContext.UniqueCallKey, + method: method, + args: serverRpcContext.MethodCallParameterValues, + returnValue: retransformedResult); + } + catch (Exception ex) + { + serverRpcContext.Exception = + new RemoteInvocationException( + message: $"Failed to rebuild result after AfterCall: {ex.GetBaseException()?.Message ?? ex.Message}", + innerEx: ex.ToSerializable()); + + serializedResult = _server.Serializer.Serialize(serverRpcContext.Exception); + } + } + } + if (oneWay) return; @@ -883,24 +909,63 @@ await _rawMessageTransport.SendMessageAsync( return (false, null); } - private void HandleEventSubscription(EventStub eventStub, string eventName, bool subscription, object[] parameters) + /// + /// Transforms a raw method return value into the form expected by + /// : + /// awaits Task/ValueTask, converts LINQ expressions to ExpressionNode, + /// and replaces ReturnAsProxy results with ServiceReference. + /// + /// Declared return type of the invoked method. + /// Raw return value (pre-transform). + /// Transformed return value, or null. + private async Task TransformReturnValueAsync(Type returnType, object returnValue) + { + if (returnValue == null) + return null; + + var (isAwaited, awaited) = await TryAwaitReturnValue(returnType, returnValue); + if (isAwaited) + returnValue = awaited; + + if (returnValue is Expression expression) + return expression.ToExpressionNode(); + + if (returnType.GetCustomAttribute() != null) + { + var isRegisteredService = + returnType.IsInterface && + _server.ServiceRegistry + .GetAllRegisteredTypes().Any(s => + returnType.AssemblyQualifiedName != null && + returnType.AssemblyQualifiedName.Equals(s.AssemblyQualifiedName)); + + if (!isRegisteredService) + { + throw new InvalidOperationException( + $"Type '{returnType.AssemblyQualifiedName}' is not a registered service."); + } + + return new ServiceReference( + serviceInterfaceTypeName: returnType.FullName + ", " + returnType.Assembly.GetName().Name, + serviceName: returnType.FullName); + } + + return returnValue; + } + + private void HandleEventSubscription(ServiceEventHub eventHub, string eventName, bool subscription, Guid eventHandlerKey, object[] parameters) { if (parameters == null || parameters.Length != 1) - { return; - } var eventHandler = parameters[0] as Delegate; if (eventHandler == null) - { return; - } - - Action eventAccessor = subscription ? - eventStub.AddHandler : - eventStub.RemoveHandler; - eventAccessor(eventName, eventHandler); + if (subscription) + eventHub.AddHandler(eventName, eventHandlerKey, _sessionId, eventHandler); + else + eventHub.RemoveHandler(eventName, eventHandlerKey); } private MethodInfo GetMethodInfo(MethodCallMessage callMessage, Type serviceInterfaceType, Type[] parameterTypes) @@ -1046,6 +1111,35 @@ private bool MapLinqExpressionArgument(Type argumentType, object argument, out o return true; } + /// + /// Removes and disposes delegate proxies for the given handler keys. + /// Invoking a stored reference to a disposed proxy becomes a no-op. + /// + /// Handler keys to remove. + /// Number of delegate proxies removed. + internal int RemoveDelegateProxies(Guid[] handlerKeys) + { + if (handlerKeys is null or { Length: 0 }) + return 0; + + var cache = _delegateProxyCache; + if (cache == null) + return 0; + + var removed = 0; + + foreach (var key in handlerKeys) + { + if (cache.TryRemove(key, out var proxy)) + { + proxy.Dispose(); + removed++; + } + } + + return removed; + } + #endregion #region Close session @@ -1055,20 +1149,25 @@ private bool MapLinqExpressionArgument(Type argumentType, object argument, out o /// public void Close() { - if (_isDisposing) + if (IsDisposing) return; - // calling RemoveSession synchronously via RPC call produces a deadlock - // in Session.Dispose because the session would wait for the current - // RPC message processing to complete + // fire-and-forget: Close may be called from within an rpc handler, + // blocking here would deadlock on the in-flight message counter _ = RemoveCurrentSession(); } - private Task RemoveCurrentSession() => Task.Run(async () => + private async Task RemoveCurrentSession() { - // disposes the current session - await _server?.SessionRepository.RemoveSession(_sessionId); - }); + try + { + await _server.SessionRepository.RemoveSession(_sessionId); + } + catch (Exception ex) + { + OnErrorOccured("Error removing the current session.", ex); + } + } #endregion @@ -1079,11 +1178,9 @@ private Task RemoveCurrentSession() => Task.Run(async () => /// public async ValueTask DisposeAsync() { - if (_isDisposing) + if (!_isDisposing.TrySet()) return; - _isDisposing = true; - _rawMessageTransport.ReceiveMessage -= OnReceiveMessage; _rawMessageTransport.ErrorOccured -= OnErrorOccured; @@ -1092,6 +1189,11 @@ await _currentlyProcessedMessagesCounter.WaitAsync() .ExpireMs(_server.Config.WaitTimeForCurrentlyProcessedMessagesOnDispose) .ConfigureAwait(false); + // clean up remote subscriptions for the current session + await _server.ServiceRegistry.GetService() + .RemoveAllForSession(_sessionId) + .ConfigureAwait(false); + var wireMessage = _server.MessageEncryptionManager.CreateWireMessage( serializedMessage: [], @@ -1124,6 +1226,11 @@ await _rawMessageTransport.SendMessageAsync( _keyPair?.Dispose(); _delegateProxyFactory = null; + + if (_delegateProxyCache != null) + foreach (var pair in _delegateProxyCache) + pair.Value.Dispose(); + _delegateProxyCache.Clear(); _delegateProxyCache = null; _rawMessageTransport = null; diff --git a/CoreRemoting/RpcMessaging/MethodCallMessageBuilder.cs b/CoreRemoting/RpcMessaging/MethodCallMessageBuilder.cs index ed14487b..1239b4b7 100644 --- a/CoreRemoting/RpcMessaging/MethodCallMessageBuilder.cs +++ b/CoreRemoting/RpcMessaging/MethodCallMessageBuilder.cs @@ -31,14 +31,14 @@ public MethodCallMessage BuildMethodCallMessage( if (serializer == null) throw new ArgumentNullException(nameof(serializer)); - - args ??= new object[0]; + + args ??= []; var genericArgumentTypeNames = targetMethod.GetGenericArguments() .Select(arg => arg.FullName + "," + arg.Assembly.GetName().Name) .ToArray(); - + var message = new MethodCallMessage { ServiceName = remoteServiceName, @@ -47,7 +47,7 @@ public MethodCallMessage BuildMethodCallMessage( GenericArgumentTypeNames = genericArgumentTypeNames, CallContextSnapshot = CallContext.GetSnapshot() }; - + return message; } @@ -74,9 +74,9 @@ public IEnumerable BuildMethodParameterInfos( args.Length > parameterInfos.Length && i == parameterInfos.Length -1 && parameterInfos[i].GetCustomAttribute() != null; - + var paramArrayValues = new List(); - + if (useParamArray) { for (var j = i; j < args.Length; j++) @@ -96,7 +96,7 @@ public IEnumerable BuildMethodParameterInfos( : serializer.EnvelopeNeededForParameterSerialization ? new Envelope(arg) : arg; - + var typeForName = parameterInfo.ParameterType.IsByRef ? parameterInfo.ParameterType.GetElementType() : parameterInfo.ParameterType; yield return new MethodCallParameterMessage @@ -109,7 +109,7 @@ public IEnumerable BuildMethodParameterInfos( }; } } - + /// /// Builds a new method call result message. /// @@ -128,9 +128,9 @@ public MethodCallResultMessage BuildMethodCallResultMessage( { if (serializer == null) throw new ArgumentNullException(nameof(serializer)); - + var isReturnValueNull = returnValue == null; - + var parameterInfos = method.GetParameters(); var message = new MethodCallResultMessage @@ -143,15 +143,15 @@ public MethodCallResultMessage BuildMethodCallResultMessage( }; var outParameters = new List(); - + for (var i = 0; i < args.Length; i++) { var arg = args[i]; var parameterInfo = parameterInfos[i]; - + if (!parameterInfo.IsOut) continue; - + var isArgNull = arg == null; var typeToSerialize = parameterInfo.ParameterType.IsByRef ? parameterInfo.ParameterType.GetElementType() : parameterInfo.ParameterType; @@ -159,7 +159,7 @@ public MethodCallResultMessage BuildMethodCallResultMessage( serializer.EnvelopeNeededForParameterSerialization && arg is not Envelope ? serializer.Serialize(typeof(Envelope), new Envelope(arg)) : serializer.Serialize(typeToSerialize, arg); - + outParameters.Add( new MethodCallOutParameterMessage { @@ -171,7 +171,7 @@ public MethodCallResultMessage BuildMethodCallResultMessage( message.OutParameters = outParameters.ToArray(); message.CallContextSnapshot = CallContext.GetSnapshot(); - + return message; } } diff --git a/CoreRemoting/ServerConfig.cs b/CoreRemoting/ServerConfig.cs index 1d9fbab2..377c5d46 100644 --- a/CoreRemoting/ServerConfig.cs +++ b/CoreRemoting/ServerConfig.cs @@ -3,6 +3,7 @@ using CoreRemoting.Authentication; using CoreRemoting.Channels; using CoreRemoting.DependencyInjection; +using CoreRemoting.Encryption; using CoreRemoting.Serialization; namespace CoreRemoting; diff --git a/CoreRemoting/ServerRpcContext.cs b/CoreRemoting/ServerRpcContext.cs index 28aa9e97..cd42bfb8 100644 --- a/CoreRemoting/ServerRpcContext.cs +++ b/CoreRemoting/ServerRpcContext.cs @@ -20,6 +20,11 @@ public class ServerRpcContext /// public Exception Exception { get; set; } + /// + /// Gets or sets the raw return value of the invoked method. + /// + public object ReturnValue { get; set; } + /// /// Gets or sets a value whether the authentication is required. /// @@ -52,9 +57,9 @@ public class ServerRpcContext public MethodCallResultMessage MethodCallResultMessage { get; set; } /// - /// Gets or sets service event stub. + /// Gets or sets service event hub. /// - public EventStub EventStub { get; set; } + public ServiceEventHub EventHub { get; set; } /// /// Gets or sets the instance of the service, on which the method is called. diff --git a/CoreRemoting/ServiceInvoker.cs b/CoreRemoting/ServiceInvoker.cs new file mode 100644 index 00000000..f8da0317 --- /dev/null +++ b/CoreRemoting/ServiceInvoker.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Concurrent; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using CoreRemoting.RemoteDelegates; +using CoreRemoting.Toolbox; + +namespace CoreRemoting; + +/// +/// Implements a service invoker that provides access to a remote service without generating a proxy. +/// Remote method calls are described by LINQ expressions instead of a generated proxy interface. +/// +/// Type of the remote service contract (interface or class) +public sealed class ServiceInvoker : IServiceInvoker where TService : class +{ + private static readonly MethodInfo CastResultMethodDefinition = + new Func, Task>(CastResultAsync) + .Method.GetGenericMethodDefinition(); + + private static readonly ConcurrentDictionary, object>> CastResultCache = new(); + + private RemotingClient _client; + + /// + /// Creates a new instance of the ServiceInvoker class. + /// + /// CoreRemoting client to be used for client/server communication + /// Unique name of the remote service + public ServiceInvoker(RemotingClient client, string serviceName = "") + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + + ServiceName = + string.IsNullOrWhiteSpace(serviceName) + ? typeof(TService).FullName + : serviceName; + } + + /// + /// Finalizer. + /// + ~ServiceInvoker() + { + ShutdownLocal(); + } + + /// + /// Gets the name of the remote service. + /// + public string ServiceName { get; } + + private RemotingClient Client => + _client ?? throw new ObjectDisposedException(nameof(ServiceInvoker)); + + /// + public TResult Invoke(Expression> call) + { + var client = Client; + var (method, arguments) = ExtractMethodArguments(call); + return (TResult)InvokeCore(client, method, arguments); + } + + /// + public void Invoke(Expression> call) + { + var client = Client; + var (method, arguments) = ExtractMethodArguments(call); + InvokeCore(client, method, arguments).JustWait(); + } + + /// + public void Dispose() + { + ShutdownLocal(); + GC.SuppressFinalize(this); + } + + /// + public async ValueTask DisposeAsync() + { + await ShutdownAsync() + .ConfigureAwait(false); + + GC.SuppressFinalize(this); + } + + private void ShutdownLocal() + { + var client = _client; + _client = null; + + if (client != null) + { + var keys = client.ClientDelegateRegistry + .UnregisterClientDelegatesOfServiceProxy(this); + + _ = client.ScheduleServerUnsubscribe(keys); + } + } + + private async Task ShutdownAsync() + { + var client = _client; + _client = null; + + if (client != null) + { + var keys = client.ClientDelegateRegistry + .UnregisterClientDelegatesOfServiceProxy(this); + + await client.NotifyServerUnsubscribeAsync(keys) + .ConfigureAwait(false); + } + } + + private object InvokeCore(RemotingClient client, MethodInfo method, object[] arguments) + { + var type = typeof(TResult); + var task = InvokeCore(client, method, arguments); + + if (type == typeof(Task)) + return task; + + if (type == typeof(ValueTask)) + return new ValueTask(task); + + if (type.IsGenericType) + { + var definition = type.GetGenericTypeDefinition(); + + if (definition == typeof(Task<>)) + return CastResult(type.GetGenericArguments()[0], task); + + if (definition == typeof(ValueTask<>)) + return Activator.CreateInstance(type, CastResult(type.GetGenericArguments()[0], task)); + } + + return task.JustWait(); + } + + private async Task InvokeCore(RemotingClient client, MethodInfo method, object[] arguments) + { + var oneWay = method.GetCustomAttribute() != null; + + if (oneWay && method.ReturnType != typeof(void)) + throw new NotSupportedException("OneWay methods must not have a return type."); + + var mappedArguments = client.MapArguments(this, arguments); + + client.SubscriptionTracker.Track(method, + SubscriptionTracker.ExtractHandlerKey(mappedArguments)); + + var remoteMethodCallMessage = + client.MethodCallMessageBuilder.BuildMethodCallMessage( + serializer: client.Serializer, + remoteServiceName: ServiceName, + targetMethod: method, + args: mappedArguments); + + var clientRpcContext = + await client.InvokeRemoteMethod(remoteMethodCallMessage, oneWay) + .ConfigureAwait(false); + + if (clientRpcContext.Error) + throw clientRpcContext.RemoteException ?? new RemoteInvocationException(); + + var resultMessage = clientRpcContext.ResultMessage; + + if (resultMessage == null) + return null; + + var result = client.UnmapReturnValue(resultMessage); + + CallContext.RestoreFromSnapshot(resultMessage.CallContextSnapshot); + + return result; + } + + private static object CastResult(Type innerType, Task task) + { + var converter = CastResultCache.GetOrAdd(innerType, static type => + { + var method = CastResultMethodDefinition.MakeGenericMethod(type); + + var parameter = Expression.Parameter(typeof(Task), "task"); + var call = Expression.Call(method, parameter); + var boxed = Expression.Convert(call, typeof(object)); + + return Expression.Lambda, object>>(boxed, parameter).Compile(); + }); + + return converter(task); + } + + private static async Task CastResultAsync(Task task) => + (T)await task.ConfigureAwait(false); + + private static (MethodInfo Method, object[] Arguments) ExtractMethodArguments(LambdaExpression expression) + { + if (expression.Body is not MethodCallExpression call) + throw new InvalidOperationException("Expression must be a direct method call."); + + if (call.Method.IsDefined(typeof(ExtensionAttribute), false)) + throw new NotSupportedException("Extension methods are not supported."); + + var arguments = new object[call.Arguments.Count]; + + for (var i = 0; i < call.Arguments.Count; i++) + arguments[i] = Evaluate(call.Arguments[i]); + + return (call.Method, arguments); + } + + private static object Evaluate(Expression expression) => + expression is ConstantExpression constant + ? constant.Value + : Expression.Lambda(expression).Compile().DynamicInvoke(); +} diff --git a/CoreRemoting/ServiceProxy.cs b/CoreRemoting/ServiceProxy.cs index 8fb24fb5..01294936 100644 --- a/CoreRemoting/ServiceProxy.cs +++ b/CoreRemoting/ServiceProxy.cs @@ -1,18 +1,12 @@ using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Linq.Expressions; using System.Reflection; using System.Threading.Tasks; using Castle.DynamicProxy; using CoreRemoting.RemoteDelegates; -using CoreRemoting.RpcMessaging; using CoreRemoting.Serialization; using CoreRemoting.Serialization.Bson; -using CoreRemoting.Toolbox; -using Serialize.Linq.Extensions; -using Serialize.Linq.Nodes; using stakx.DynamicProxy; namespace CoreRemoting; @@ -56,10 +50,14 @@ public ServiceProxy(RemotingClient client, string serviceName = "") /// void IServiceProxy.Shutdown() { - if (_client != null) + var client = _client; + _client = null; + + if (client != null) { - _client.ClientDelegateRegistry.UnregisterClientDelegatesOfServiceProxy(this); - _client = null; + var keys = client.ClientDelegateRegistry + .UnregisterClientDelegatesOfServiceProxy(this); + _ = client.ScheduleServerUnsubscribe(keys); } GC.SuppressFinalize(this); @@ -93,7 +91,10 @@ protected override void Intercept(IInvocation invocation) if (oneWay && returnType != typeof(void)) throw new NotSupportedException("OneWay methods must not have a return type."); - var arguments = MapArguments(invocation.Arguments); + var arguments = _client.MapArguments(this, invocation.Arguments); + + _client.SubscriptionTracker.Track(method, + SubscriptionTracker.ExtractHandlerKey(arguments)); var remoteMethodCallMessage = _client.MethodCallMessageBuilder.BuildMethodCallMessage( @@ -102,9 +103,7 @@ protected override void Intercept(IInvocation invocation) targetMethod: method, args: arguments); - var sendTask = - _client.InvokeRemoteMethod(remoteMethodCallMessage, oneWay); - sendTask.ConfigureAwait(false); + var sendTask = _client.InvokeRemoteMethod(remoteMethodCallMessage, oneWay); if (!sendTask.Wait( _client.Config.SendTimeout == 0 @@ -161,7 +160,7 @@ protected override void Intercept(IInvocation invocation) } } - invocation.ReturnValue = UnmapReturnValue(resultMessage); + invocation.ReturnValue = _client.UnmapReturnValue(resultMessage); CallContext.RestoreFromSnapshot(resultMessage.CallContextSnapshot); } @@ -183,7 +182,7 @@ protected override async ValueTask InterceptAsync(IAsyncInvocation invocation) if (oneWay && returnType != typeof(void)) throw new NotSupportedException("OneWay methods must not have a return type."); - var arguments = MapArguments(invocation.Arguments); + var arguments = _client.MapArguments(this, invocation.Arguments); var remoteMethodCallMessage = _client.MethodCallMessageBuilder.BuildMethodCallMessage( @@ -210,107 +209,8 @@ await _client.InvokeRemoteMethod(remoteMethodCallMessage, oneWay) return; } - invocation.Result = UnmapReturnValue(resultMessage); + invocation.Result = _client.UnmapReturnValue(resultMessage); CallContext.RestoreFromSnapshot(resultMessage.CallContextSnapshot); } - - /// - /// Unmaps the return value back to its original type. - /// - /// Method call result message. - private object UnmapReturnValue(MethodCallResultMessage resultMessage) - { - // Unwrap an enveloped value - var returnValue = - resultMessage.IsReturnValueNull - ? null - : resultMessage.ReturnValue is Envelope returnValueEnvelope - ? returnValueEnvelope.Value - : resultMessage.ReturnValue; - - // Create a proxy to remote service, if return type is a service reference - if (returnValue is ServiceReference serviceReference) - returnValue = _client.CreateProxy(serviceReference); - - // Restore LINQ expression, if return type is a serializable expression - if (returnValue is ExpressionNode exprNode) - returnValue = exprNode.ToExpression(); - - return returnValue; - } - - /// - /// Maps a delegate argument into a serializable RemoteDelegateInfo object. - /// - /// Type of argument to be mapped - /// Argument to be wrapped - /// Out: Mapped argument - /// True if mapping applied, otherwise false - private bool MapDelegateArgument(Type argumentType, object argument, out object mappedArgument) - { - if (argumentType == null || !typeof(Delegate).IsAssignableFrom(argumentType)) - { - mappedArgument = argument; - return false; - } - - var delegateReturnType = argumentType.GetMethod("Invoke")?.ReturnType; - - if (delegateReturnType != null && delegateReturnType != typeof(void)) - throw new NotSupportedException("Only void delegates are supported."); - - var remoteDelegateInfo = - new RemoteDelegateInfo( - handlerKey: _client.ClientDelegateRegistry.RegisterClientDelegate((Delegate)argument, this), - delegateTypeName: argumentType.FullName + ", " + argumentType.Assembly.GetName().Name); - - mappedArgument = remoteDelegateInfo; - return true; - } - - /// - /// Maps a Linq expression argument into a serializable ExpressionNode object. - /// - /// Type of argument to be mapped - /// Argument to be wrapped - /// Out: Mapped argument - /// True if mapping applied, otherwise false - private static bool MapLinqExpressionArgument(Type argumentType, object argument, out object mappedArgument) - { - if (!argumentType.IsLinqExpressionType()) - { - mappedArgument = argument; - return false; - } - - var expression = (Expression)argument; - mappedArgument = expression.ToExpressionNode(); - - return true; - } - - /// - /// Maps non serializable arguments into a serializable form. - /// - /// Arguments - /// Array of arguments (includes mapped ones) - private object[] MapArguments(IEnumerable arguments) - { - var mappedArguments = - arguments.Select(argument => - { - var type = argument?.GetType(); - - if (MapDelegateArgument(type, argument, out var mappedArgument)) - return mappedArgument; - - if (MapLinqExpressionArgument(type, argument, out mappedArgument)) - return mappedArgument; - - return argument; - }).ToArray(); - - return mappedArguments; - } -} \ No newline at end of file +} diff --git a/CoreRemoting/SessionRepository.cs b/CoreRemoting/SessionRepository.cs index 173ac7e8..d697237e 100644 --- a/CoreRemoting/SessionRepository.cs +++ b/CoreRemoting/SessionRepository.cs @@ -105,11 +105,21 @@ public async Task CreateSession(bool messageEncryption, int sha server, rawMessageTransport); - await session.SendCompleteHandshakeMessageAsync() - .ConfigureAwait(false); - _sessions.TryAdd(session.SessionId, session); + try + { + await session.SendCompleteHandshakeMessageAsync() + .ConfigureAwait(false); + } + catch + { + await RemoveSession(session.SessionId) + .ConfigureAwait(false); + + throw; + } + return session; } diff --git a/CoreRemoting/Threading/AtomicBool.cs b/CoreRemoting/Threading/AtomicBool.cs new file mode 100644 index 00000000..7c273664 --- /dev/null +++ b/CoreRemoting/Threading/AtomicBool.cs @@ -0,0 +1,52 @@ +using System.Diagnostics; +using System.Threading; + +namespace CoreRemoting.Threading; + +/// +/// Thread-safe boolean flag. +/// All reads and writes are performed with acquire-release semantics. +/// +/// +/// This is a mutable value type. Always use it as a field, never copy +/// it into a local variable: methods called on a copy will not affect +/// the original field. +/// +[DebuggerDisplay("{Value}")] +public struct AtomicBool +{ + private int _value; + + /// + /// Gets a value indicating whether the flag is set. + /// + public bool Value => Volatile.Read(ref _value) != 0; + + /// + /// Sets the flag to true. + /// + public void Set() => Interlocked.Exchange(ref _value, 1); + + /// + /// Sets the flag to the given value. + /// + /// The value to set the flag to. + public void Set(bool value) => Interlocked.Exchange(ref _value, value ? 1 : 0); + + /// + /// Sets the flag to true and returns true + /// if it was false before, otherwise returns false. + /// + public bool TrySet() => Interlocked.Exchange(ref _value, 1) == 0; + + /// + /// Sets the flag to false. + /// + public void Reset() => Interlocked.Exchange(ref _value, 0); + + /// + /// Sets the flag to false and returns true + /// if it was true before, otherwise returns false. + /// + public bool TryReset() => Interlocked.Exchange(ref _value, 0) == 1; +} \ No newline at end of file diff --git a/CoreRemoting/Toolbox/Extensions.cs b/CoreRemoting/Toolbox/Extensions.cs index 7c745454..bf785786 100644 --- a/CoreRemoting/Toolbox/Extensions.cs +++ b/CoreRemoting/Toolbox/Extensions.cs @@ -2,10 +2,10 @@ using System.Collections.Concurrent; using System.Linq; using System.Linq.Expressions; -using System.Net; using System.Reflection; using System.Security.Cryptography; using CoreRemoting.Authentication; +using CoreRemoting.RemoteDelegates; namespace CoreRemoting.Toolbox; @@ -59,6 +59,25 @@ public static bool IsEventAccessor(this MethodInfo method, out string eventName, .IsAssignableFrom(parameters[0].ParameterType); } + /// + /// Checks whether the given delegate is a disposed CoreRemoting delegate proxy. + /// + /// + /// Returns true for null, true for a disposed CoreRemoting + /// proxy, and false for regular or multicast delegates. Proxy delegates + /// are not expected to participate in multicast delegates; for safety, a + /// multicast delegate is conservatively treated as not disposed. + /// + /// The delegate to check. + /// true if the delegate is null or a disposed CoreRemoting proxy. + public static bool IsDisposed(this Delegate @delegate) + { + if (@delegate == null) + return true; + + return @delegate.Target is IDelegateProxy proxy && proxy.IsDisposed; + } + private static ConcurrentDictionary DefaultValues = new(); /// diff --git a/CoreRemoting/Toolbox/IsExternalInit.cs b/CoreRemoting/Toolbox/IsExternalInit.cs new file mode 100644 index 00000000..716a7f94 --- /dev/null +++ b/CoreRemoting/Toolbox/IsExternalInit.cs @@ -0,0 +1,3 @@ +namespace System.Runtime.CompilerServices; + +internal static class IsExternalInit; \ No newline at end of file diff --git a/CoreRemoting/Toolbox/TaskExtensions.cs b/CoreRemoting/Toolbox/TaskExtensions.cs index f742c67c..5e5c2cfd 100644 --- a/CoreRemoting/Toolbox/TaskExtensions.cs +++ b/CoreRemoting/Toolbox/TaskExtensions.cs @@ -106,7 +106,7 @@ public static void JustWait(this Task task) => /// Waits for the task to complete, but doesn't wrap /// the exception in an . /// - public static void JustWait(this Task task) => + public static T JustWait(this Task task) => task.GetAwaiter().GetResult(); /// @@ -120,7 +120,6 @@ public static void JustWait(this ValueTask task) => /// Waits for the task to complete, but doesn't wrap /// the exception in an . /// - public static void JustWait(this ValueTask task) => + public static T JustWait(this ValueTask task) => task.GetAwaiter().GetResult(); - }