From 724e0bc8d8e803adf72f30a1445147445be3db99 Mon Sep 17 00:00:00 2001 From: cneicy Date: Wed, 26 Aug 2026 01:17:39 +0800 Subject: [PATCH] feat(eventbus): add platform runtimes and benchmarks Add the Entities NativeQueue adapter, standalone .NET runtime and source generator, reproducible smoke coverage, and Unity benchmark assets for EventBus 2.0. --- .gitignore | 13 + Assets/Modules/ShrinkEventBus.Entities.meta | 8 + .../Modules/ShrinkEventBus.Entities/README.md | 14 + .../ShrinkEventBus.Entities/README.md.meta | 7 + .../ShrinkEventBus.Entities/Runtime.meta | 8 + .../Runtime/ShrinkEcsEventQueue.cs | 53 ++ .../Runtime/ShrinkEcsEventQueue.cs.meta | 11 + .../ShrinkEventBus.Entities.Runtime.asmdef | 13 + ...hrinkEventBus.Entities.Runtime.asmdef.meta | 7 + .../ShrinkEventBus.Entities/Tests.meta | 8 + .../Tests/ShrinkEcsEventQueueTests.cs | 76 +++ .../Tests/ShrinkEcsEventQueueTests.cs.meta | 11 + .../ShrinkEventBus.Entities.Tests.asmdef | 20 + .../ShrinkEventBus.Entities.Tests.asmdef.meta | 7 + .../ShrinkEventBus.Entities/package.json | 16 + .../ShrinkEventBus.Entities/package.json.meta | 7 + .../Modules/ShrinkEventBus/Benchmark-2.0.txt | 72 +++ .../ShrinkEventBus/Benchmark-2.0.txt.meta | 7 + Assets/Modules/ShrinkEventBus/Benchmark.meta | 8 + .../Benchmark/ShrinkEventBus.Benchmark.asmdef | 13 + .../ShrinkEventBus.Benchmark.asmdef.meta | 7 + .../Benchmark/ShrinkEventBusBenchmark.cs | 282 ++++++++++ .../Benchmark/ShrinkEventBusBenchmark.cs.meta | 11 + .../ShrinkEventBus/Tests.PlayMode.meta | 8 + DotNet/README.md | 11 + DotNet/ShrinkEventBus.Core/Schedulers.cs | 205 ++++++++ .../ShrinkEventBus.Core.csproj | 16 + DotNet/ShrinkEventBus.Core/ShrinkEventBus.cs | 494 ++++++++++++++++++ .../ShrinkEventBus.Generator.csproj | 15 + .../ShrinkEventBusGenerator.cs | 309 +++++++++++ DotNet/ShrinkEventBus.Smoke/Program.cs | 62 +++ .../ShrinkEventBus.Smoke.csproj | 14 + 32 files changed, 1813 insertions(+) create mode 100644 Assets/Modules/ShrinkEventBus.Entities.meta create mode 100644 Assets/Modules/ShrinkEventBus.Entities/README.md create mode 100644 Assets/Modules/ShrinkEventBus.Entities/README.md.meta create mode 100644 Assets/Modules/ShrinkEventBus.Entities/Runtime.meta create mode 100644 Assets/Modules/ShrinkEventBus.Entities/Runtime/ShrinkEcsEventQueue.cs create mode 100644 Assets/Modules/ShrinkEventBus.Entities/Runtime/ShrinkEcsEventQueue.cs.meta create mode 100644 Assets/Modules/ShrinkEventBus.Entities/Runtime/ShrinkEventBus.Entities.Runtime.asmdef create mode 100644 Assets/Modules/ShrinkEventBus.Entities/Runtime/ShrinkEventBus.Entities.Runtime.asmdef.meta create mode 100644 Assets/Modules/ShrinkEventBus.Entities/Tests.meta create mode 100644 Assets/Modules/ShrinkEventBus.Entities/Tests/ShrinkEcsEventQueueTests.cs create mode 100644 Assets/Modules/ShrinkEventBus.Entities/Tests/ShrinkEcsEventQueueTests.cs.meta create mode 100644 Assets/Modules/ShrinkEventBus.Entities/Tests/ShrinkEventBus.Entities.Tests.asmdef create mode 100644 Assets/Modules/ShrinkEventBus.Entities/Tests/ShrinkEventBus.Entities.Tests.asmdef.meta create mode 100644 Assets/Modules/ShrinkEventBus.Entities/package.json create mode 100644 Assets/Modules/ShrinkEventBus.Entities/package.json.meta create mode 100644 Assets/Modules/ShrinkEventBus/Benchmark-2.0.txt create mode 100644 Assets/Modules/ShrinkEventBus/Benchmark-2.0.txt.meta create mode 100644 Assets/Modules/ShrinkEventBus/Benchmark.meta create mode 100644 Assets/Modules/ShrinkEventBus/Benchmark/ShrinkEventBus.Benchmark.asmdef create mode 100644 Assets/Modules/ShrinkEventBus/Benchmark/ShrinkEventBus.Benchmark.asmdef.meta create mode 100644 Assets/Modules/ShrinkEventBus/Benchmark/ShrinkEventBusBenchmark.cs create mode 100644 Assets/Modules/ShrinkEventBus/Benchmark/ShrinkEventBusBenchmark.cs.meta create mode 100644 Assets/Modules/ShrinkEventBus/Tests.PlayMode.meta create mode 100644 DotNet/README.md create mode 100644 DotNet/ShrinkEventBus.Core/Schedulers.cs create mode 100644 DotNet/ShrinkEventBus.Core/ShrinkEventBus.Core.csproj create mode 100644 DotNet/ShrinkEventBus.Core/ShrinkEventBus.cs create mode 100644 DotNet/ShrinkEventBus.Generator/ShrinkEventBus.Generator.csproj create mode 100644 DotNet/ShrinkEventBus.Generator/ShrinkEventBusGenerator.cs create mode 100644 DotNet/ShrinkEventBus.Smoke/Program.cs create mode 100644 DotNet/ShrinkEventBus.Smoke/ShrinkEventBus.Smoke.csproj diff --git a/.gitignore b/.gitignore index 29b263a..118097c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ /[Ll]ibrary/ /[Tt]emp/ /[Oo]bj/ +/DotNet/**/[Bb]in/ +/DotNet/**/[Oo]bj/ +/TestResults/ /[Bb]uild/ /[Bb]uilds/ /[Ll]ogs/ @@ -56,6 +59,16 @@ ExportedObj/ *.opendb *.VC.db +# Tracked source project for rebuilding ShrinkModFramework external DLL fixtures. +!Tools/ShrinkModFixtureBuilder/ShrinkModFixtureBuilder.csproj +Tools/ShrinkModFixtureBuilder/bin/ +Tools/ShrinkModFixtureBuilder/obj/ + +# Tracked standalone .NET projects for ShrinkEventBus runtime and generator validation. +!DotNet/ShrinkEventBus.Core/ShrinkEventBus.Core.csproj +!DotNet/ShrinkEventBus.Generator/ShrinkEventBus.Generator.csproj +!DotNet/ShrinkEventBus.Smoke/ShrinkEventBus.Smoke.csproj + # Unity3D generated meta files *.pidb.meta *.pdb.meta diff --git a/Assets/Modules/ShrinkEventBus.Entities.meta b/Assets/Modules/ShrinkEventBus.Entities.meta new file mode 100644 index 0000000..64f187f --- /dev/null +++ b/Assets/Modules/ShrinkEventBus.Entities.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2c9ad00f02a38c749b89748791709246 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkEventBus.Entities/README.md b/Assets/Modules/ShrinkEventBus.Entities/README.md new file mode 100644 index 0000000..f652cd7 --- /dev/null +++ b/Assets/Modules/ShrinkEventBus.Entities/README.md @@ -0,0 +1,14 @@ +# ShrinkEventBus Entities + +`com.cneicy.shrink-eventbus-entities` 是可选 ECS/Burst 适配包。它不创建第二套事件总线,而是让 Burst Job 把 `unmanaged IShrinkEvent` 写入 `NativeQueue`,随后由主线程或 ECS playback system 发布到指定的 `IShrinkEventBus`。 + +```csharp +using var queue = new ShrinkEcsEventQueue(); +var writer = queue.Writer; + +// Burst Job 中:writer.Post(in eventData); +// Playback 阶段: +queue.Playback(worldBus); +``` + +限制:Burst 端只负责事实事件生产,不能直接执行托管 handler、UniTask、取消或结果裁决。 diff --git a/Assets/Modules/ShrinkEventBus.Entities/README.md.meta b/Assets/Modules/ShrinkEventBus.Entities/README.md.meta new file mode 100644 index 0000000..534a178 --- /dev/null +++ b/Assets/Modules/ShrinkEventBus.Entities/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 02173172caac46a6bb3e586a95c6f8e1 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkEventBus.Entities/Runtime.meta b/Assets/Modules/ShrinkEventBus.Entities/Runtime.meta new file mode 100644 index 0000000..ac1547d --- /dev/null +++ b/Assets/Modules/ShrinkEventBus.Entities/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3c58d5bbaaf44a3dba31fe63ab32fd22 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkEventBus.Entities/Runtime/ShrinkEcsEventQueue.cs b/Assets/Modules/ShrinkEventBus.Entities/Runtime/ShrinkEcsEventQueue.cs new file mode 100644 index 0000000..f565932 --- /dev/null +++ b/Assets/Modules/ShrinkEventBus.Entities/Runtime/ShrinkEcsEventQueue.cs @@ -0,0 +1,53 @@ +#nullable enable + +using System; +using ShrinkEventBus; +using Unity.Collections; + +namespace ShrinkEventBus.Entities +{ + /// Burst-safe producer queue; playback enters the regular Bus instance. + public sealed class ShrinkEcsEventQueue : IDisposable where TEvent : unmanaged, IShrinkEvent + { + private NativeQueue _queue; + + public ShrinkEcsEventQueue(Allocator allocator = Allocator.Persistent) + { + _queue = new NativeQueue(allocator); + Writer = new ShrinkEcsEventWriter(_queue.AsParallelWriter()); + } + + public ShrinkEcsEventWriter Writer { get; } + + public int Playback(IShrinkEventBus bus) + { + if (bus == null) + throw new ArgumentNullException(nameof(bus)); + var count = 0; + while (_queue.TryDequeue(out var eventData)) + { + bus.Post(in eventData); + count++; + } + return count; + } + + public void Dispose() + { + if (_queue.IsCreated) + _queue.Dispose(); + } + } + + public readonly struct ShrinkEcsEventWriter where TEvent : unmanaged, IShrinkEvent + { + private readonly NativeQueue.ParallelWriter _writer; + + internal ShrinkEcsEventWriter(NativeQueue.ParallelWriter writer) + { + _writer = writer; + } + + public void Post(in TEvent eventData) => _writer.Enqueue(eventData); + } +} diff --git a/Assets/Modules/ShrinkEventBus.Entities/Runtime/ShrinkEcsEventQueue.cs.meta b/Assets/Modules/ShrinkEventBus.Entities/Runtime/ShrinkEcsEventQueue.cs.meta new file mode 100644 index 0000000..c9b1378 --- /dev/null +++ b/Assets/Modules/ShrinkEventBus.Entities/Runtime/ShrinkEcsEventQueue.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 05a2db7ffefb4841bdcd63d8fd3ac6fe +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkEventBus.Entities/Runtime/ShrinkEventBus.Entities.Runtime.asmdef b/Assets/Modules/ShrinkEventBus.Entities/Runtime/ShrinkEventBus.Entities.Runtime.asmdef new file mode 100644 index 0000000..803d21d --- /dev/null +++ b/Assets/Modules/ShrinkEventBus.Entities/Runtime/ShrinkEventBus.Entities.Runtime.asmdef @@ -0,0 +1,13 @@ +{ + "name": "ShrinkEventBus.Entities.Runtime", + "rootNamespace": "ShrinkEventBus.Entities", + "references": [ + "ShrinkEventBus.Runtime", + "Unity.Collections", + "Unity.Entities" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": true, + "autoReferenced": false +} diff --git a/Assets/Modules/ShrinkEventBus.Entities/Runtime/ShrinkEventBus.Entities.Runtime.asmdef.meta b/Assets/Modules/ShrinkEventBus.Entities/Runtime/ShrinkEventBus.Entities.Runtime.asmdef.meta new file mode 100644 index 0000000..4b345dd --- /dev/null +++ b/Assets/Modules/ShrinkEventBus.Entities/Runtime/ShrinkEventBus.Entities.Runtime.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9e31f017584a4a04992bb6fa7538cb2f +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkEventBus.Entities/Tests.meta b/Assets/Modules/ShrinkEventBus.Entities/Tests.meta new file mode 100644 index 0000000..32d1019 --- /dev/null +++ b/Assets/Modules/ShrinkEventBus.Entities/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 28ac62d48f4f4ba296c67b9b5985d39c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkEventBus.Entities/Tests/ShrinkEcsEventQueueTests.cs b/Assets/Modules/ShrinkEventBus.Entities/Tests/ShrinkEcsEventQueueTests.cs new file mode 100644 index 0000000..170f9f9 --- /dev/null +++ b/Assets/Modules/ShrinkEventBus.Entities/Tests/ShrinkEcsEventQueueTests.cs @@ -0,0 +1,76 @@ +#nullable enable + +using System; +using NUnit.Framework; +using Unity.Burst; +using Unity.Collections; +using Unity.Jobs; + +namespace ShrinkEventBus.Entities.Tests +{ + internal struct EcsPingEvent : IShrinkEvent + { + public int Value; + } + + internal sealed class EcsPingSubscriber : IShrinkGeneratedSubscriber + { + public int Sum { get; private set; } + + public IDisposable AttachGenerated(IShrinkBusResolver resolver, ShrinkBusKey? defaultBus = null) + { + return ShrinkGeneratedBinding.Subscribe(resolver, defaultBus, + string.Empty, this, OnPing, ShrinkEventPriority.Normal, 0, false); + } + + private void OnPing(EcsPingEvent value) => Sum += value.Value; + } + + [BurstCompile] + internal struct PostPingJob : IJob + { + public ShrinkEcsEventWriter Writer; + + public void Execute() + { + Writer.Post(new EcsPingEvent { Value = 11 }); + } + } + + public sealed class ShrinkEcsEventQueueTests + { + [Test] + public void NativeWriterPlaybackUsesSameBusChannel() + { + using var host = new ShrinkEventBusHost(); + var bus = host.CreateBus(ShrinkBusKey.World("tests"), ShrinkBusOptions.Inline()); + var subscriber = new EcsPingSubscriber(); + using var binding = host.Attach(subscriber, ShrinkBusKey.World("tests")); + using var queue = new ShrinkEcsEventQueue(Allocator.TempJob); + + queue.Writer.Post(new EcsPingEvent { Value = 2 }); + queue.Writer.Post(new EcsPingEvent { Value = 5 }); + var played = queue.Playback(bus); + + Assert.AreEqual(2, played); + Assert.AreEqual(7, subscriber.Sum); + } + + [Test] + public void BurstJobWritesThenPlaybackUsesManagedBus() + { + using var host = new ShrinkEventBusHost(); + var bus = host.CreateBus(ShrinkBusKey.World("burst-tests"), ShrinkBusOptions.Inline()); + var subscriber = new EcsPingSubscriber(); + using var binding = host.Attach(subscriber, ShrinkBusKey.World("burst-tests")); + using var queue = new ShrinkEcsEventQueue(Allocator.TempJob); + var job = new PostPingJob { Writer = queue.Writer }; + + job.Schedule().Complete(); + var played = queue.Playback(bus); + + Assert.AreEqual(1, played); + Assert.AreEqual(11, subscriber.Sum); + } + } +} diff --git a/Assets/Modules/ShrinkEventBus.Entities/Tests/ShrinkEcsEventQueueTests.cs.meta b/Assets/Modules/ShrinkEventBus.Entities/Tests/ShrinkEcsEventQueueTests.cs.meta new file mode 100644 index 0000000..8db7711 --- /dev/null +++ b/Assets/Modules/ShrinkEventBus.Entities/Tests/ShrinkEcsEventQueueTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d3be15e44ff9405e947ecaaac7905527 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkEventBus.Entities/Tests/ShrinkEventBus.Entities.Tests.asmdef b/Assets/Modules/ShrinkEventBus.Entities/Tests/ShrinkEventBus.Entities.Tests.asmdef new file mode 100644 index 0000000..98a9b4b --- /dev/null +++ b/Assets/Modules/ShrinkEventBus.Entities/Tests/ShrinkEventBus.Entities.Tests.asmdef @@ -0,0 +1,20 @@ +{ + "name": "ShrinkEventBus.Entities.Tests", + "rootNamespace": "ShrinkEventBus.Entities.Tests", + "references": [ + "ShrinkEventBus.Entities.Runtime", + "ShrinkEventBus.Runtime", + "Unity.Burst", + "Unity.Collections", + "Unity.Jobs", + "UniTask", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" + ], + "includePlatforms": ["Editor"], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "autoReferenced": false, + "defineConstraints": ["UNITY_INCLUDE_TESTS"] +} diff --git a/Assets/Modules/ShrinkEventBus.Entities/Tests/ShrinkEventBus.Entities.Tests.asmdef.meta b/Assets/Modules/ShrinkEventBus.Entities/Tests/ShrinkEventBus.Entities.Tests.asmdef.meta new file mode 100644 index 0000000..655caca --- /dev/null +++ b/Assets/Modules/ShrinkEventBus.Entities/Tests/ShrinkEventBus.Entities.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9ef47a51870047fda3659747535d685a +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkEventBus.Entities/package.json b/Assets/Modules/ShrinkEventBus.Entities/package.json new file mode 100644 index 0000000..b2fc3b6 --- /dev/null +++ b/Assets/Modules/ShrinkEventBus.Entities/package.json @@ -0,0 +1,16 @@ +{ + "name": "com.cneicy.shrink-eventbus-entities", + "version": "0.1.0", + "displayName": "ShrinkEventBus Entities", + "description": "Burst-safe NativeQueue writer and playback adapter for ShrinkEventBus.", + "unity": "2022.3", + "dependencies": { + "com.cneicy.shrink-eventbus": "2.0.0", + "com.unity.entities": "1.0.11" + }, + "keywords": ["eventbus", "entities", "burst", "ecs"], + "author": { + "name": "cneicy", + "url": "https://github.com/cneicy" + } +} diff --git a/Assets/Modules/ShrinkEventBus.Entities/package.json.meta b/Assets/Modules/ShrinkEventBus.Entities/package.json.meta new file mode 100644 index 0000000..d52a2a4 --- /dev/null +++ b/Assets/Modules/ShrinkEventBus.Entities/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 908d154d9ac34f55817ba92f073b3aa8 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkEventBus/Benchmark-2.0.txt b/Assets/Modules/ShrinkEventBus/Benchmark-2.0.txt new file mode 100644 index 0000000..78cc2c7 --- /dev/null +++ b/Assets/Modules/ShrinkEventBus/Benchmark-2.0.txt @@ -0,0 +1,72 @@ +ShrinkEventBus 2.0 Benchmark Report +=================================== + +Date: 2026-08-24 +Unity: 2022.3.62f3 +Platform: WindowsEditor, Play Mode +Host: independent ShrinkEventBusHost, Inline scheduler +Diagnostics: independent Host; detailed global capture does not observe it +Final run: iter=10,000,000 mass=1,000,000 reg=5,000, three rounds + +Median results +-------------- + +Post / 0 handlers + 129,282,649 ops/s + +Post / 1 handler + 67,249,586 ops/s + +Post / 8 handlers, struct payload + 35,024,798 ops/s + +Post / 8 handlers, class payload + 25,321,199 ops/s + +Post / 30 handlers + 10,823,612 ops/s + +Post / canceled skips 10 + 9,029,517 ops/s + +PostAsync / 1 async handler + 5,909,446 ops/s + +Generated Attach + Dispose + 674,291 ops/s + +Struct hot path allocation + 0 B / 10,000,000 Post operations + +Comparison +---------- + +Supplied MessagePipe chart, class payload, 8 handlers: + 25,639,260 publish/s + +ShrinkEventBus 2.0, class payload, 8 handlers: + 25,321,199 publish/s median + +ShrinkEventBus 2.0, struct payload, 8 handlers: + 35,024,798 publish/s median + +Supplied ShrinkEventBus 1.3.0 sample: + 0 handlers: 8,971,378 -> 129,282,649 ops/s + 1 handler: 6,132,242 -> 67,249,586 ops/s + 30 handlers: 258,805 -> 10,823,612 ops/s + canceled skips 10: 1,894,776 -> 9,029,517 ops/s + 1 async handler: 1,168,634 -> 5,909,446 ops/s + instance scan/register: 76,353 -> 674,291 generated Attach/s + +Implementation notes +-------------------- + +- Inline buses use a dedicated implementation so the JIT can inline the channel dispatch path. +- Non-cancelable, all-sync channels build a priority-ordered multicast dispatcher when subscriptions change. +- ShrinkPostResult stores Accepted/Handled/Canceled/Failure in one 32-bit value. +- Generic bus slots and handler snapshots are published with release semantics and read without a publish lock. +- Mixed sync/async channels, cancelable events, queued schedulers and PostAsync retain the full semantic path. + +Results are local editor measurements, not cross-device guarantees. The supplied MessagePipe chart was +produced by a different runtime and machine; the class-payload comparison only establishes the same +order of throughput. Recheck on the target Player and hardware before release. diff --git a/Assets/Modules/ShrinkEventBus/Benchmark-2.0.txt.meta b/Assets/Modules/ShrinkEventBus/Benchmark-2.0.txt.meta new file mode 100644 index 0000000..7a514e0 --- /dev/null +++ b/Assets/Modules/ShrinkEventBus/Benchmark-2.0.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6e379caaacb945699a297286798bc4e2 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkEventBus/Benchmark.meta b/Assets/Modules/ShrinkEventBus/Benchmark.meta new file mode 100644 index 0000000..631f9ea --- /dev/null +++ b/Assets/Modules/ShrinkEventBus/Benchmark.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2cc73e57d6ec4457a425843f734b6a70 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkEventBus/Benchmark/ShrinkEventBus.Benchmark.asmdef b/Assets/Modules/ShrinkEventBus/Benchmark/ShrinkEventBus.Benchmark.asmdef new file mode 100644 index 0000000..aacb28f --- /dev/null +++ b/Assets/Modules/ShrinkEventBus/Benchmark/ShrinkEventBus.Benchmark.asmdef @@ -0,0 +1,13 @@ +{ + "name": "ShrinkEventBus.Benchmark", + "rootNamespace": "ShrinkEventBus.Benchmark", + "references": [ + "ShrinkEventBus.Runtime", + "UniTask" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "autoReferenced": true +} diff --git a/Assets/Modules/ShrinkEventBus/Benchmark/ShrinkEventBus.Benchmark.asmdef.meta b/Assets/Modules/ShrinkEventBus/Benchmark/ShrinkEventBus.Benchmark.asmdef.meta new file mode 100644 index 0000000..2afe56e --- /dev/null +++ b/Assets/Modules/ShrinkEventBus/Benchmark/ShrinkEventBus.Benchmark.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4488cfe64b374f30b8081ce2f10b1ed5 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkEventBus/Benchmark/ShrinkEventBusBenchmark.cs b/Assets/Modules/ShrinkEventBus/Benchmark/ShrinkEventBusBenchmark.cs new file mode 100644 index 0000000..8bf96e0 --- /dev/null +++ b/Assets/Modules/ShrinkEventBus/Benchmark/ShrinkEventBusBenchmark.cs @@ -0,0 +1,282 @@ +#nullable enable + +using System; +using System.Diagnostics; +using System.Text; +using System.Threading; +using Cysharp.Threading.Tasks; +using UnityEngine; + +namespace ShrinkEventBus.Benchmark +{ + public static class ShrinkEventBusBenchmark + { + private readonly struct BenchmarkEvent : IShrinkEvent + { + public BenchmarkEvent(int value) => Value = value; + public int Value { get; } + } + + private sealed class BenchmarkClassEvent : IShrinkEvent + { + public int Value { get; set; } + } + + private sealed class CanceledBenchmarkEvent : IShrinkCancelableEvent + { + public bool IsCanceled { get; private set; } = true; + public void SetCanceled(bool value) => IsCanceled = value; + } + + [ShrinkEventSubscriber] + private sealed class OneHandler + { + [ShrinkSubscribe(Bus = "benchmark:one")] + private void H01(BenchmarkEvent value) => Consume(value); + } + + [ShrinkEventSubscriber] + private sealed class EightHandlers + { + [ShrinkSubscribe(Bus = "benchmark:eight")] private void H01(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:eight")] private void H02(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:eight")] private void H03(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:eight")] private void H04(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:eight")] private void H05(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:eight")] private void H06(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:eight")] private void H07(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:eight")] private void H08(BenchmarkEvent value) => Consume(value); + } + + [ShrinkEventSubscriber] + private sealed class EightClassHandlers + { + [ShrinkSubscribe(Bus = "benchmark:eight-class")] private void H01(BenchmarkClassEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:eight-class")] private void H02(BenchmarkClassEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:eight-class")] private void H03(BenchmarkClassEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:eight-class")] private void H04(BenchmarkClassEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:eight-class")] private void H05(BenchmarkClassEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:eight-class")] private void H06(BenchmarkClassEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:eight-class")] private void H07(BenchmarkClassEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:eight-class")] private void H08(BenchmarkClassEvent value) => Consume(value); + } + + [ShrinkEventSubscriber] + private sealed class ThirtyHandlers + { + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H01(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H02(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H03(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H04(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H05(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H06(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H07(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H08(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H09(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H10(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H11(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H12(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H13(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H14(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H15(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H16(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H17(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H18(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H19(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H20(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H21(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H22(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H23(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H24(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H25(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H26(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H27(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H28(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H29(BenchmarkEvent value) => Consume(value); + [ShrinkSubscribe(Bus = "benchmark:thirty")] private void H30(BenchmarkEvent value) => Consume(value); + } + + [ShrinkEventSubscriber] + private sealed class CanceledHandlers + { + [ShrinkSubscribe(Bus = "benchmark:canceled")] private void H01(CanceledBenchmarkEvent value) => _sink++; + [ShrinkSubscribe(Bus = "benchmark:canceled")] private void H02(CanceledBenchmarkEvent value) => _sink++; + [ShrinkSubscribe(Bus = "benchmark:canceled")] private void H03(CanceledBenchmarkEvent value) => _sink++; + [ShrinkSubscribe(Bus = "benchmark:canceled")] private void H04(CanceledBenchmarkEvent value) => _sink++; + [ShrinkSubscribe(Bus = "benchmark:canceled")] private void H05(CanceledBenchmarkEvent value) => _sink++; + [ShrinkSubscribe(Bus = "benchmark:canceled")] private void H06(CanceledBenchmarkEvent value) => _sink++; + [ShrinkSubscribe(Bus = "benchmark:canceled")] private void H07(CanceledBenchmarkEvent value) => _sink++; + [ShrinkSubscribe(Bus = "benchmark:canceled")] private void H08(CanceledBenchmarkEvent value) => _sink++; + [ShrinkSubscribe(Bus = "benchmark:canceled")] private void H09(CanceledBenchmarkEvent value) => _sink++; + [ShrinkSubscribe(Bus = "benchmark:canceled")] private void H10(CanceledBenchmarkEvent value) => _sink++; + } + + [ShrinkEventSubscriber] + private sealed class AsyncHandler + { + [ShrinkSubscribe(Bus = "benchmark:async")] + private UniTask Handle(BenchmarkEvent value, CancellationToken cancellationToken) + { + Consume(value); + return UniTask.CompletedTask; + } + } + + [ShrinkEventSubscriber] + private sealed class RegistrationHandler + { + [ShrinkSubscribe(Bus = "benchmark:registration")] + private void Handle(BenchmarkEvent value) => Consume(value); + } + + private static int _sink; + + public static string Run(int iterations = 1_000_000, int massIterations = 10_000, + int registrationIterations = 5_000) + { + var keys = new[] + { + new ShrinkBusKey("benchmark", "empty"), + new ShrinkBusKey("benchmark", "one"), + new ShrinkBusKey("benchmark", "eight"), + new ShrinkBusKey("benchmark", "eight-class"), + new ShrinkBusKey("benchmark", "thirty"), + new ShrinkBusKey("benchmark", "canceled"), + new ShrinkBusKey("benchmark", "async"), + new ShrinkBusKey("benchmark", "registration") + }; + using var host = new ShrinkEventBusHost(); + var empty = host.CreateBus(keys[0], ShrinkBusOptions.Inline()); + var one = host.CreateBus(keys[1], ShrinkBusOptions.Inline()); + var eight = host.CreateBus(keys[2], ShrinkBusOptions.Inline()); + var eightClass = host.CreateBus(keys[3], ShrinkBusOptions.Inline()); + var thirty = host.CreateBus(keys[4], ShrinkBusOptions.Inline()); + var canceled = host.CreateBus(keys[5], ShrinkBusOptions.Inline()); + var asyncBus = host.CreateBus(keys[6], ShrinkBusOptions.Inline()); + host.CreateBus(keys[7], ShrinkBusOptions.Inline()); + + using var oneBinding = host.Attach(new OneHandler()); + using var eightBinding = host.Attach(new EightHandlers()); + using var eightClassBinding = host.Attach(new EightClassHandlers()); + using var thirtyBinding = host.Attach(new ThirtyHandlers()); + using var canceledBinding = host.Attach(new CanceledHandlers()); + using var asyncBinding = host.Attach(new AsyncHandler()); + + var report = new StringBuilder(2048); + report.AppendLine("ShrinkEventBus 2.0 Benchmark Report"); + report.AppendLine($"iter={iterations} mass={massIterations} reg={registrationIterations}"); + report.AppendLine($"Unity {Application.unityVersion}, {Application.platform}, PlayMode={Application.isPlaying}"); + report.AppendLine(); + + var value = new BenchmarkEvent(1); + var classValue = new BenchmarkClassEvent { Value = 1 }; + var canceledValue = new CanceledBenchmarkEvent(); + Warmup(empty, one, eight, eightClass, thirty, canceled, + value, classValue, canceledValue); + + MeasurePost(report, "Post / 0 handlers", iterations, empty, in value); + MeasurePost(report, "Post / 1 handler", iterations, one, in value); + MeasurePost(report, "Post / 8 handlers", massIterations, eight, in value); + MeasurePost(report, "Post / 8 handlers class", massIterations, eightClass, in classValue); + MeasurePost(report, "Post / 30 handlers", massIterations, thirty, in value); + MeasurePost(report, "Post / canceled skips 10", massIterations, + canceled, in canceledValue); + MeasureAsync(report, "PostAsync / 1 async handler", massIterations, + () => asyncBus.PostAsync(value)); + Measure(report, "Generated Attach + Dispose", registrationIterations, () => + { + host.Attach(new RegistrationHandler()).Dispose(); + }); + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < iterations; i++) + one.Post(in value); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + report.AppendLine($"Post / struct hot path allocation : {allocated} B / {iterations} ops"); + report.AppendLine($"sink={_sink}"); + + var result = report.ToString(); + UnityEngine.Debug.Log(result); + return result; + } + + private static void Warmup(IShrinkEventBus empty, IShrinkEventBus one, + IShrinkEventBus eight, IShrinkEventBus eightClass, IShrinkEventBus thirty, + IShrinkEventBus canceled, BenchmarkEvent value, BenchmarkClassEvent classValue, + CanceledBenchmarkEvent canceledValue) + { + for (var i = 0; i < 10_000; i++) + { + empty.Post(in value); + one.Post(in value); + eight.Post(in value); + eightClass.Post(classValue); + thirty.Post(in value); + canceled.Post(canceledValue); + } + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + + private static void Measure(StringBuilder report, string name, int iterations, Action action) + { + var gen0 = GC.CollectionCount(0); + var beforeBytes = GC.GetAllocatedBytesForCurrentThread(); + var stopwatch = Stopwatch.StartNew(); + for (var i = 0; i < iterations; i++) + action(); + stopwatch.Stop(); + AppendResult(report, name, iterations, stopwatch.Elapsed.TotalMilliseconds, + GC.GetAllocatedBytesForCurrentThread() - beforeBytes, + GC.CollectionCount(0) - gen0); + } + + private static void MeasurePost(StringBuilder report, string name, int iterations, + IShrinkEventBus bus, in TEvent eventData) where TEvent : IShrinkEvent + { + var gen0 = GC.CollectionCount(0); + var beforeBytes = GC.GetAllocatedBytesForCurrentThread(); + var stopwatch = Stopwatch.StartNew(); + for (var i = 0; i < iterations; i++) + bus.Post(in eventData); + stopwatch.Stop(); + AppendResult(report, name, iterations, stopwatch.Elapsed.TotalMilliseconds, + GC.GetAllocatedBytesForCurrentThread() - beforeBytes, + GC.CollectionCount(0) - gen0); + } + + private static void MeasureAsync(StringBuilder report, string name, int iterations, + Func> action) + { + var gen0 = GC.CollectionCount(0); + var beforeBytes = GC.GetAllocatedBytesForCurrentThread(); + var stopwatch = Stopwatch.StartNew(); + for (var i = 0; i < iterations; i++) + action().GetAwaiter().GetResult(); + stopwatch.Stop(); + AppendResult(report, name, iterations, stopwatch.Elapsed.TotalMilliseconds, + GC.GetAllocatedBytesForCurrentThread() - beforeBytes, + GC.CollectionCount(0) - gen0); + } + + private static void AppendResult(StringBuilder report, string name, int iterations, + double milliseconds, long allocatedBytes, int gen0Collections) + { + var microseconds = milliseconds * 1000d / iterations; + var throughput = iterations / (milliseconds / 1000d); + report.AppendLine($"{name,-34} {milliseconds,10:F3} ms {microseconds,9:F4} us/op " + + $"{throughput,12:F0} ops/s alloc={allocatedBytes} B gen0={gen0Collections}"); + } + + private static void Consume(BenchmarkEvent value) + { + _sink = unchecked(_sink + value.Value); + } + + private static void Consume(BenchmarkClassEvent value) + { + _sink = unchecked(_sink + value.Value); + } + } +} diff --git a/Assets/Modules/ShrinkEventBus/Benchmark/ShrinkEventBusBenchmark.cs.meta b/Assets/Modules/ShrinkEventBus/Benchmark/ShrinkEventBusBenchmark.cs.meta new file mode 100644 index 0000000..3cdcbae --- /dev/null +++ b/Assets/Modules/ShrinkEventBus/Benchmark/ShrinkEventBusBenchmark.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 76b9239259544977bd27221d3607aed1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Modules/ShrinkEventBus/Tests.PlayMode.meta b/Assets/Modules/ShrinkEventBus/Tests.PlayMode.meta new file mode 100644 index 0000000..fe2876c --- /dev/null +++ b/Assets/Modules/ShrinkEventBus/Tests.PlayMode.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a41c52d2d40c42479d1f1dfd2d35ae3c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/DotNet/README.md b/DotNet/README.md new file mode 100644 index 0000000..b6c81bd --- /dev/null +++ b/DotNet/README.md @@ -0,0 +1,11 @@ +# ShrinkEventBus for .NET + +- `ShrinkEventBus.Core`:`netstandard2.1` ValueTask 运行时,不引用 UnityEngine 或 UniTask。 +- `ShrinkEventBus.Generator`:Roslyn incremental generator,为 `partial` 普通 C# subscriber 生成强类型绑定。 +- `ShrinkEventBus.Smoke`:`net8.0` 烟测,覆盖多 Bus 隔离、Inline 和 DedicatedThread。 + +```bash +dotnet build DotNet/ShrinkEventBus.Core/ShrinkEventBus.Core.csproj -c Release +dotnet build DotNet/ShrinkEventBus.Generator/ShrinkEventBus.Generator.csproj -c Release +dotnet run --project DotNet/ShrinkEventBus.Smoke/ShrinkEventBus.Smoke.csproj -c Release +``` diff --git a/DotNet/ShrinkEventBus.Core/Schedulers.cs b/DotNet/ShrinkEventBus.Core/Schedulers.cs new file mode 100644 index 0000000..43b812b --- /dev/null +++ b/DotNet/ShrinkEventBus.Core/Schedulers.cs @@ -0,0 +1,205 @@ +#nullable enable + +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; + +namespace ShrinkEventBus +{ + internal interface IDotNetScheduler : IDisposable + { + bool IsOnSchedulerThread { get; } + bool TryPost(Action action); + ValueTask PostAsync(Func action, CancellationToken cancellationToken); + } + + internal sealed class QueueFullException : InvalidOperationException { } + + internal static class DotNetSchedulerFactory + { + public static IDotNetScheduler Create(string name, ShrinkBusOptions options) + { + return options.Scheduler switch + { + ShrinkBusSchedulerKind.MainThread => new SynchronizationContextScheduler(options), + ShrinkBusSchedulerKind.DedicatedThread => new DedicatedScheduler(name, options), + ShrinkBusSchedulerKind.TaskPool => new TaskPoolScheduler(options), + _ => new InlineScheduler() + }; + } + } + + internal sealed class InlineScheduler : IDotNetScheduler + { + public bool IsOnSchedulerThread => true; + public bool TryPost(Action action) { action(); return true; } + public ValueTask PostAsync(Func action, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return action(); + } + public void Dispose() { } + } + + internal sealed class SynchronizationContextScheduler : IDotNetScheduler + { + private readonly SynchronizationContext _context; + private readonly int _threadId; + private int _disposed; + + public SynchronizationContextScheduler(ShrinkBusOptions options) + { + _context = SynchronizationContext.Current ?? throw new InvalidOperationException( + "MainThread scheduler requires a current SynchronizationContext in a pure .NET host."); + _threadId = Thread.CurrentThread.ManagedThreadId; + } + + public bool IsOnSchedulerThread => Thread.CurrentThread.ManagedThreadId == _threadId; + + public bool TryPost(Action action) + { + if (Volatile.Read(ref _disposed) != 0) + return false; + if (IsOnSchedulerThread) + action(); + else + _context.Post(_ => action(), null); + return true; + } + + public ValueTask PostAsync(Func action, CancellationToken cancellationToken) + { + if (IsOnSchedulerThread) + return action(); + return new ValueTask(PostCoreAsync(action, cancellationToken)); + } + + private Task PostCoreAsync(Func action, CancellationToken cancellationToken) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _context.Post(async _ => + { + try { cancellationToken.ThrowIfCancellationRequested(); await action(); completion.SetResult(true); } + catch (OperationCanceledException) { completion.SetCanceled(); } + catch (Exception ex) { completion.SetException(ex); } + }, null); + return completion.Task; + } + + public void Dispose() => Interlocked.Exchange(ref _disposed, 1); + } + + internal sealed class TaskPoolScheduler : IDotNetScheduler + { + private readonly SemaphoreSlim _concurrency; + private readonly int _capacity; + private int _pending; + private int _disposed; + + public TaskPoolScheduler(ShrinkBusOptions options) + { + var concurrency = options.DispatchMode == ShrinkDispatchMode.Ordered ? 1 : options.MaxConcurrency; + _concurrency = new SemaphoreSlim(concurrency, concurrency); + _capacity = options.QueueCapacity; + } + + public bool IsOnSchedulerThread => false; + + public bool TryPost(Action action) + { + if (Volatile.Read(ref _disposed) != 0 || Interlocked.Increment(ref _pending) > _capacity) + { + Interlocked.Decrement(ref _pending); + return false; + } + _ = Task.Run(async () => + { + await _concurrency.WaitAsync().ConfigureAwait(false); + try { action(); } + finally { _concurrency.Release(); Interlocked.Decrement(ref _pending); } + }); + return true; + } + + public async ValueTask PostAsync(Func action, CancellationToken cancellationToken) + { + if (Volatile.Read(ref _disposed) != 0 || Interlocked.Increment(ref _pending) > _capacity) + { + Interlocked.Decrement(ref _pending); + throw new QueueFullException(); + } + await _concurrency.WaitAsync(cancellationToken).ConfigureAwait(false); + try { await action().ConfigureAwait(false); } + finally { _concurrency.Release(); Interlocked.Decrement(ref _pending); } + } + + public void Dispose() + { + Interlocked.Exchange(ref _disposed, 1); + _concurrency.Dispose(); + } + } + + internal sealed class DedicatedScheduler : IDotNetScheduler + { + private sealed class Work + { + public Func Action = null!; + public TaskCompletionSource? Completion; + } + + private readonly BlockingCollection _queue; + private readonly Thread _thread; + private int _threadId; + private int _disposed; + + public DedicatedScheduler(string name, ShrinkBusOptions options) + { + _queue = new BlockingCollection(options.QueueCapacity); + _thread = new Thread(Run) { IsBackground = true, Name = $"ShrinkBus:{name}" }; + _thread.Start(); + } + + public bool IsOnSchedulerThread => Thread.CurrentThread.ManagedThreadId == Volatile.Read(ref _threadId); + + public bool TryPost(Action action) + { + if (IsOnSchedulerThread) { action(); return true; } + return Volatile.Read(ref _disposed) == 0 && _queue.TryAdd(new Work + { + Action = () => { action(); return default; } + }); + } + + public ValueTask PostAsync(Func action, CancellationToken cancellationToken) + { + if (IsOnSchedulerThread) + return action(); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (Volatile.Read(ref _disposed) != 0 || !_queue.TryAdd(new Work + { Action = action, Completion = completion })) + throw new QueueFullException(); + return new ValueTask(completion.Task); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + _queue.CompleteAdding(); + } + + private void Run() + { + Volatile.Write(ref _threadId, Thread.CurrentThread.ManagedThreadId); + foreach (var work in _queue.GetConsumingEnumerable()) + { + try { work.Action().AsTask().GetAwaiter().GetResult(); work.Completion?.SetResult(true); } + catch (OperationCanceledException) { work.Completion?.SetCanceled(); } + catch (Exception ex) { work.Completion?.SetException(ex); } + } + _queue.Dispose(); + } + } +} diff --git a/DotNet/ShrinkEventBus.Core/ShrinkEventBus.Core.csproj b/DotNet/ShrinkEventBus.Core/ShrinkEventBus.Core.csproj new file mode 100644 index 0000000..30aa6d7 --- /dev/null +++ b/DotNet/ShrinkEventBus.Core/ShrinkEventBus.Core.csproj @@ -0,0 +1,16 @@ + + + netstandard2.1 + latest + enable + disable + ShrinkEventBus.Core + ShrinkEventBus + + + + + + diff --git a/DotNet/ShrinkEventBus.Core/ShrinkEventBus.cs b/DotNet/ShrinkEventBus.Core/ShrinkEventBus.cs new file mode 100644 index 0000000..9f65345 --- /dev/null +++ b/DotNet/ShrinkEventBus.Core/ShrinkEventBus.cs @@ -0,0 +1,494 @@ +#nullable enable + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace ShrinkEventBus +{ + public delegate ValueTask ShrinkAsyncEventHandler(TEvent eventData, + CancellationToken cancellationToken) where TEvent : IShrinkEvent; + + public interface IShrinkEventBus : IDisposable + { + ShrinkBusKey Key { get; } + ShrinkBusOptions Options { get; } + ShrinkPostResult Post(in TEvent eventData) where TEvent : IShrinkEvent; + ValueTask PostAsync(TEvent eventData, + CancellationToken cancellationToken = default) where TEvent : IShrinkEvent; + IDisposable Attach(object target); + } + + public interface IShrinkBusResolver + { + IShrinkEventBus GetBus(ShrinkBusKey key); + bool TryGetBus(ShrinkBusKey key, out IShrinkEventBus bus); + } + + public interface IShrinkGeneratedSubscriber + { + IDisposable AttachGenerated(IShrinkBusResolver resolver, ShrinkBusKey? defaultBus = null); + } + + public sealed class ShrinkEventBinding : IDisposable + { + private readonly List _items = new List(); + private bool _disposed; + + public void Add(IDisposable item) + { + if (item == null) + throw new ArgumentNullException(nameof(item)); + if (_disposed) + { + item.Dispose(); + throw new ObjectDisposedException(nameof(ShrinkEventBinding)); + } + _items.Add(item); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + for (var i = _items.Count - 1; i >= 0; i--) + _items[i].Dispose(); + _items.Clear(); + } + } + + public static class ShrinkStaticBindingRegistry + { + internal sealed class Entry + { + public long Id; + public ShrinkBusKey Key; + public Func Factory = null!; + } + + private static readonly object Gate = new object(); + private static readonly List Entries = new List(); + private static readonly List> Hosts = + new List>(); + private static long _nextId; + + public static void Register(ShrinkBusKey key, + Func factory) + { + if (factory == null) + throw new ArgumentNullException(nameof(factory)); + + ShrinkEventBusHost[] hosts; + Entry entry; + lock (Gate) + { + entry = new Entry + { + Id = Interlocked.Increment(ref _nextId), + Key = key, + Factory = factory + }; + Entries.Add(entry); + hosts = LiveHostsLocked(); + } + + for (var i = 0; i < hosts.Length; i++) + hosts[i].TryAttachStatic(entry); + } + + internal static void TrackHost(ShrinkEventBusHost host) + { + lock (Gate) + { + LiveHostsLocked(); + Hosts.Add(new WeakReference(host)); + } + } + + internal static Entry[] GetEntries(ShrinkBusKey key) + { + lock (Gate) + return Entries.FindAll(item => item.Key == key).ToArray(); + } + + private static ShrinkEventBusHost[] LiveHostsLocked() + { + var result = new List(Hosts.Count); + for (var i = Hosts.Count - 1; i >= 0; i--) + { + if (Hosts[i].TryGetTarget(out var host)) + result.Add(host); + else + Hosts.RemoveAt(i); + } + return result.ToArray(); + } + } + + public sealed class ShrinkEventBusHost : IShrinkBusResolver, IDisposable + { + private readonly ConcurrentDictionary _buses = + new ConcurrentDictionary(); + private readonly object _staticGate = new object(); + private readonly Dictionary _staticBindings = + new Dictionary(); + private bool _disposed; + + public ShrinkEventBusHost() + { + ShrinkStaticBindingRegistry.TrackHost(this); + } + + public IShrinkEventBus CreateBus(ShrinkBusKey key, ShrinkBusOptions options) + { + if (_disposed) + throw new ObjectDisposedException(nameof(ShrinkEventBusHost)); + var bus = new DotNetShrinkEventBus(key, options); + if (!_buses.TryAdd(key, bus)) + { + bus.Dispose(); + throw new InvalidOperationException($"Bus '{key}' is already registered."); + } + var entries = ShrinkStaticBindingRegistry.GetEntries(key); + for (var i = 0; i < entries.Length; i++) + TryAttachStatic(entries[i]); + return bus; + } + + public IShrinkEventBus GetBus(ShrinkBusKey key) + { + if (_buses.TryGetValue(key, out var bus)) + return bus; + throw new KeyNotFoundException($"Bus '{key}' is not registered."); + } + + public bool TryGetBus(ShrinkBusKey key, out IShrinkEventBus bus) => + _buses.TryGetValue(key, out bus!); + + public IDisposable Attach(object target, ShrinkBusKey? defaultBus = null) + { + if (target is not IShrinkGeneratedSubscriber generated) + throw new InvalidOperationException( + $"Type {target?.GetType().FullName ?? ""} has no generated event binding."); + return generated.AttachGenerated(this, defaultBus); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + lock (_staticGate) + { + foreach (var binding in _staticBindings.Values) + binding.Dispose(); + _staticBindings.Clear(); + } + foreach (var bus in _buses.Values) + bus.Dispose(); + _buses.Clear(); + } + + internal void TryAttachStatic(ShrinkStaticBindingRegistry.Entry entry) + { + lock (_staticGate) + { + if (_disposed || _staticBindings.ContainsKey(entry.Id) || !_buses.ContainsKey(entry.Key)) + return; + _staticBindings.Add(entry.Id, entry.Factory(this)); + } + } + } + + public static class ShrinkGeneratedBinding + { + public static IDisposable Subscribe(IShrinkBusResolver resolver, ShrinkBusKey? defaultBus, + string? configuredBus, object? owner, Action handler, ShrinkEventPriority priority, + int numericPriority, bool receiveCanceled) where TEvent : IShrinkEvent + { + return Resolve(resolver, defaultBus, configuredBus).Subscribe( + owner, handler, priority, numericPriority, receiveCanceled); + } + + public static IDisposable SubscribeAsync(IShrinkBusResolver resolver, ShrinkBusKey? defaultBus, + string? configuredBus, object? owner, ShrinkAsyncEventHandler handler, + ShrinkEventPriority priority, int numericPriority, bool receiveCanceled) where TEvent : IShrinkEvent + { + return Resolve(resolver, defaultBus, configuredBus).SubscribeAsync( + owner, handler, priority, numericPriority, receiveCanceled); + } + + private static DotNetShrinkEventBus Resolve(IShrinkBusResolver resolver, ShrinkBusKey? defaultBus, + string? configuredBus) + { + var key = string.IsNullOrWhiteSpace(configuredBus) + ? defaultBus ?? ShrinkBusKey.Game + : ShrinkBusKey.Parse(configuredBus); + return resolver.GetBus(key) as DotNetShrinkEventBus + ?? throw new InvalidOperationException("Generated bindings require the built-in bus implementation."); + } + } + + internal sealed class DotNetShrinkEventBus : IShrinkEventBus + { + private readonly ConcurrentDictionary _channels = + new ConcurrentDictionary(); + private readonly IDotNetScheduler _scheduler; + private long _nextSubscriptionId; + private bool _disposed; + + public DotNetShrinkEventBus(ShrinkBusKey key, ShrinkBusOptions options) + { + Key = key; + Options = (options ?? throw new ArgumentNullException(nameof(options))).CloneValidated(); + _scheduler = DotNetSchedulerFactory.Create(key.Id, Options); + } + + public ShrinkBusKey Key { get; } + public ShrinkBusOptions Options { get; } + + public ShrinkPostResult Post(in TEvent eventData) where TEvent : IShrinkEvent + { + if (_disposed) + return ShrinkPostResult.Rejected(ShrinkPostFailure.BusStopped); + var copied = eventData; + if (_scheduler.IsOnSchedulerThread) + return Channel().Post(copied); + + return _scheduler.TryPost(() => Channel().Post(copied)) + ? new ShrinkPostResult(true, false, false) + : ShrinkPostResult.Rejected(ShrinkPostFailure.QueueFull); + } + + public async ValueTask PostAsync(TEvent eventData, + CancellationToken cancellationToken = default) where TEvent : IShrinkEvent + { + if (_disposed) + return ShrinkPostResult.Rejected(ShrinkPostFailure.BusStopped); + ShrinkPostResult result = default; + try + { + await _scheduler.PostAsync(async () => + { + result = await Channel().PostAsync(eventData, Options.DispatchMode, cancellationToken) + .ConfigureAwait(false); + }, cancellationToken).ConfigureAwait(false); + return result; + } + catch (QueueFullException) + { + return ShrinkPostResult.Rejected(ShrinkPostFailure.QueueFull); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return new ShrinkPostResult(true, false, false, ShrinkPostFailure.Canceled); + } + } + + public IDisposable Attach(object target) + { + if (target is not IShrinkGeneratedSubscriber generated) + throw new InvalidOperationException( + $"Type {target?.GetType().FullName ?? ""} has no generated event binding."); + return generated.AttachGenerated(new SingleBusResolver(this), Key); + } + + public IDisposable Subscribe(object? owner, Action handler, + ShrinkEventPriority priority, int numericPriority, bool receiveCanceled) where TEvent : IShrinkEvent + { + return Channel().Add(Interlocked.Increment(ref _nextSubscriptionId), owner, + handler, null, priority, numericPriority, receiveCanceled); + } + + public IDisposable SubscribeAsync(object? owner, ShrinkAsyncEventHandler handler, + ShrinkEventPriority priority, int numericPriority, bool receiveCanceled) where TEvent : IShrinkEvent + { + return Channel().Add(Interlocked.Increment(ref _nextSubscriptionId), owner, + null, handler, priority, numericPriority, receiveCanceled); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _scheduler.Dispose(); + foreach (var channel in _channels.Values) + channel.Clear(); + _channels.Clear(); + } + + private EventChannel Channel() where TEvent : IShrinkEvent => + (EventChannel)_channels.GetOrAdd(typeof(TEvent), _ => new EventChannel()); + + private sealed class SingleBusResolver : IShrinkBusResolver + { + private readonly IShrinkEventBus _bus; + public SingleBusResolver(IShrinkEventBus bus) => _bus = bus; + public IShrinkEventBus GetBus(ShrinkBusKey key) => key == _bus.Key + ? _bus + : throw new KeyNotFoundException($"Bus '{key}' is not available."); + public bool TryGetBus(ShrinkBusKey key, out IShrinkEventBus bus) + { + bus = _bus; + return key == _bus.Key; + } + } + } + + internal interface IEventChannel + { + void Clear(); + } + + internal sealed class EventChannel : IEventChannel where TEvent : IShrinkEvent + { + private sealed class Entry + { + public long Id = 0; + public object? Owner; + public Action? Sync; + public ShrinkAsyncEventHandler? Async; + public ShrinkEventPriority Priority; + public int NumericPriority; + public bool ReceiveCanceled; + } + + private readonly object _gate = new object(); + private readonly List _entries = new List(); + private Entry[] _snapshot = Array.Empty(); + + public IDisposable Add(long id, object? owner, Action? sync, + ShrinkAsyncEventHandler? asyncHandler, ShrinkEventPriority priority, + int numericPriority, bool receiveCanceled) + { + lock (_gate) + { + _entries.Add(new Entry + { + Id = id, + Owner = owner, + Sync = sync, + Async = asyncHandler, + Priority = priority, + NumericPriority = numericPriority, + ReceiveCanceled = receiveCanceled + }); + Rebuild(); + } + return new Subscription(this, id); + } + + public ShrinkPostResult Post(TEvent eventData) + { + var handled = false; + var snapshot = _snapshot; + for (var i = 0; i < snapshot.Length; i++) + { + var entry = snapshot[i]; + if (IsCanceled(eventData) && !entry.ReceiveCanceled) + continue; + if (entry.Sync != null) + entry.Sync(eventData); + else if (entry.Async != null) + _ = entry.Async(eventData, CancellationToken.None).AsTask(); + else + continue; + handled = true; + } + return ShrinkPostResult.Completed(handled, IsCanceled(eventData)); + } + + public async ValueTask PostAsync(TEvent eventData, ShrinkDispatchMode mode, + CancellationToken cancellationToken) + { + var handled = false; + var snapshot = _snapshot; + if (mode == ShrinkDispatchMode.Parallel) + { + var tasks = new List(); + for (var i = 0; i < snapshot.Length; i++) + { + var entry = snapshot[i]; + if (IsCanceled(eventData) && !entry.ReceiveCanceled) + continue; + if (entry.Sync != null) + entry.Sync(eventData); + else if (entry.Async != null) + tasks.Add(entry.Async(eventData, cancellationToken).AsTask()); + else + continue; + handled = true; + } + if (tasks.Count > 0) + await Task.WhenAll(tasks).ConfigureAwait(false); + } + else + { + for (var i = 0; i < snapshot.Length; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + var entry = snapshot[i]; + if (IsCanceled(eventData) && !entry.ReceiveCanceled) + continue; + if (entry.Sync != null) + entry.Sync(eventData); + else if (entry.Async != null) + await entry.Async(eventData, cancellationToken).ConfigureAwait(false); + else + continue; + handled = true; + } + } + return ShrinkPostResult.Completed(handled, IsCanceled(eventData)); + } + + public void Clear() + { + lock (_gate) + { + _entries.Clear(); + _snapshot = Array.Empty(); + } + } + + private void Remove(long id) + { + lock (_gate) + { + _entries.RemoveAll(item => item.Id == id); + Rebuild(); + } + } + + private void Rebuild() + { + _entries.Sort((left, right) => + { + var leftValue = left.NumericPriority == 0 ? (int)left.Priority * 1000 : -left.NumericPriority; + var rightValue = right.NumericPriority == 0 ? (int)right.Priority * 1000 : -right.NumericPriority; + var result = leftValue.CompareTo(rightValue); + return result != 0 ? result : left.Id.CompareTo(right.Id); + }); + _snapshot = _entries.ToArray(); + } + + private static bool IsCanceled(TEvent value) => + value is IShrinkCancelableEvent cancelable && cancelable.IsCanceled; + + private sealed class Subscription : IDisposable + { + private EventChannel? _owner; + private readonly long _id; + public Subscription(EventChannel owner, long id) + { + _owner = owner; + _id = id; + } + public void Dispose() => Interlocked.Exchange(ref _owner, null)?.Remove(_id); + } + } +} diff --git a/DotNet/ShrinkEventBus.Generator/ShrinkEventBus.Generator.csproj b/DotNet/ShrinkEventBus.Generator/ShrinkEventBus.Generator.csproj new file mode 100644 index 0000000..815b205 --- /dev/null +++ b/DotNet/ShrinkEventBus.Generator/ShrinkEventBus.Generator.csproj @@ -0,0 +1,15 @@ + + + netstandard2.0 + latest + enable + disable + ShrinkEventBus.Generator + ShrinkEventBus.Generator + false + $(NoWarn);RS2008 + + + + + diff --git a/DotNet/ShrinkEventBus.Generator/ShrinkEventBusGenerator.cs b/DotNet/ShrinkEventBus.Generator/ShrinkEventBusGenerator.cs new file mode 100644 index 0000000..673f266 --- /dev/null +++ b/DotNet/ShrinkEventBus.Generator/ShrinkEventBusGenerator.cs @@ -0,0 +1,309 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace ShrinkEventBus.Generator +{ + [Generator] + public sealed class ShrinkEventBusGenerator : IIncrementalGenerator + { + private const string SubscriberAttribute = "ShrinkEventBus.ShrinkEventSubscriberAttribute"; + private const string SubscribeAttribute = "ShrinkEventBus.ShrinkSubscribeAttribute"; + + private static readonly DiagnosticDescriptor PartialRequired = new DiagnosticDescriptor( + "SHRINKEVENT001", "Subscriber must be partial", + "Subscriber type '{0}' must be partial so ShrinkEventBus can generate a reflection-free binding", + "ShrinkEventBus", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor UnsupportedHandler = new DiagnosticDescriptor( + "SHRINKEVENT002", "Unsupported handler signature", + "Handler '{0}' must be void(TEvent), UniTask(TEvent), UniTask(TEvent, CancellationToken), or ValueTask(TEvent, CancellationToken)", + "ShrinkEventBus", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor TopLevelRequired = new DiagnosticDescriptor( + "SHRINKEVENT003", "Top-level subscriber required", + "Subscriber type '{0}' must be top-level in the current generator version", + "ShrinkEventBus", DiagnosticSeverity.Error, true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterSourceOutput(context.CompilationProvider, + static (sourceContext, compilation) => + { + if (compilation.GetTypeByMetadataName( + "System.Runtime.CompilerServices.ModuleInitializerAttribute") == null) + { + sourceContext.AddSource("ShrinkEventBus.ModuleInitializerAttribute.g.cs", + SourceText.From( + "namespace System.Runtime.CompilerServices { [global::System.AttributeUsage(global::System.AttributeTargets.Method, Inherited = false)] internal sealed class ModuleInitializerAttribute : global::System.Attribute { } }", + Encoding.UTF8)); + } + }); + + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + SubscriberAttribute, + static (node, _) => node is ClassDeclarationSyntax, + static (syntaxContext, _) => (INamedTypeSymbol)syntaxContext.TargetSymbol); + + context.RegisterSourceOutput(candidates.Collect(), Generate); + } + + private static void Generate(SourceProductionContext context, + ImmutableArray candidates) + { + foreach (var type in candidates) + GenerateType(context, type); + } + + private static void GenerateType(SourceProductionContext context, INamedTypeSymbol type) + { + if (type.ContainingType != null) + { + context.ReportDiagnostic(Diagnostic.Create(TopLevelRequired, + type.Locations.FirstOrDefault(), type.ToDisplayString())); + return; + } + + var isPartial = type.DeclaringSyntaxReferences + .Select(reference => reference.GetSyntax()) + .OfType() + .Any(declaration => declaration.Modifiers.Any(SyntaxKind.PartialKeyword)); + if (!isPartial) + { + context.ReportDiagnostic(Diagnostic.Create(PartialRequired, + type.Locations.FirstOrDefault(), type.ToDisplayString())); + return; + } + + var handlers = new List(); + foreach (var method in type.GetMembers().OfType()) + { + var attribute = method.GetAttributes().FirstOrDefault(item => + item.AttributeClass?.ToDisplayString() == SubscribeAttribute); + if (attribute == null) + continue; + + if (!TryCreateHandler(method, attribute, type.IsStatic, out var handler)) + { + context.ReportDiagnostic(Diagnostic.Create(UnsupportedHandler, + method.Locations.FirstOrDefault(), method.ToDisplayString())); + continue; + } + handlers.Add(handler); + } + + var subscriber = type.GetAttributes().First(item => + item.AttributeClass?.ToDisplayString() == SubscriberAttribute); + var defaultBus = ReadString(subscriber, "DefaultBus"); + var source = type.IsStatic + ? BuildStaticSource(type, handlers, defaultBus) + : BuildSource(type, handlers, defaultBus); + var hint = type.ToDisplayString().Replace('.', '_').Replace('+', '_') + ".ShrinkEvents.g.cs"; + context.AddSource(hint, SourceText.From(source, Encoding.UTF8)); + } + + private static bool TryCreateHandler(IMethodSymbol method, AttributeData attribute, + bool staticSubscriber, + out HandlerModel model) + { + model = default; + if (method.IsStatic != staticSubscriber || method.Parameters.Length == 0 || method.Parameters.Length > 2) + return false; + + var eventType = method.Parameters[0].Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var returnType = method.ReturnType.ToDisplayString(); + var isSync = method.ReturnsVoid && method.Parameters.Length == 1; + var isValueTask = returnType == "System.Threading.Tasks.ValueTask" && + method.Parameters.Length == 2 && + method.Parameters[1].Type.ToDisplayString() == "System.Threading.CancellationToken"; + var isUniTask = returnType == "Cysharp.Threading.Tasks.UniTask"; + var isUniTaskLegacy = isUniTask && method.Parameters.Length == 1; + var isUniTaskCancelable = isUniTask && method.Parameters.Length == 2 && + method.Parameters[1].Type.ToDisplayString() == "System.Threading.CancellationToken"; + if (!isSync && !isValueTask && !isUniTaskLegacy && !isUniTaskCancelable) + return false; + + var bindingMethod = isSync + ? "Subscribe" + : isUniTaskLegacy ? "SubscribeAsyncLegacy" : "SubscribeAsync"; + + model = new HandlerModel( + method.Name, + eventType, + bindingMethod, + ReadString(attribute, "Bus"), + ReadInt(attribute, "Priority", 2), + ReadInt(attribute, "NumericPriority", 0), + ReadBool(attribute, "ReceiveCanceled", false)); + return true; + } + + private static string BuildSource(INamedTypeSymbol type, IReadOnlyList handlers, + string defaultBus) + { + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + if (!type.ContainingNamespace.IsGlobalNamespace) + { + builder.Append("namespace ").Append(type.ContainingNamespace.ToDisplayString()).AppendLine(); + builder.AppendLine("{"); + } + + var accessibility = type.DeclaredAccessibility switch + { + Accessibility.Public => "public ", + Accessibility.Internal => "internal ", + _ => string.Empty + }; + builder.Append(" ").Append(accessibility).Append("partial class ") + .Append(type.Name).AppendLine(" : global::ShrinkEventBus.IShrinkGeneratedSubscriber"); + builder.AppendLine(" {"); + builder.AppendLine(" global::System.IDisposable global::ShrinkEventBus.IShrinkGeneratedSubscriber.AttachGenerated("); + builder.AppendLine(" global::ShrinkEventBus.IShrinkBusResolver resolver,"); + builder.AppendLine(" global::ShrinkEventBus.ShrinkBusKey? defaultBus)"); + builder.AppendLine(" {"); + builder.AppendLine(" var binding = new global::ShrinkEventBus.ShrinkEventBinding();"); + + foreach (var handler in handlers) + { + var bus = string.IsNullOrWhiteSpace(handler.Bus) ? defaultBus : handler.Bus; + builder.Append(" binding.Add(global::ShrinkEventBus.ShrinkGeneratedBinding.") + .Append(handler.BindingMethod).Append('<').Append(handler.EventType).AppendLine(">("); + builder.Append(" resolver, defaultBus, ") + .Append(ToLiteral(bus)).AppendLine(", this,"); + builder.Append(" this.").Append(handler.MethodName) + .Append(", (global::ShrinkEventBus.ShrinkEventPriority)") + .Append(handler.Priority).Append(", ").Append(handler.NumericPriority).Append(", ") + .Append(handler.ReceiveCanceled ? "true" : "false").AppendLine("));"); + } + + builder.AppendLine(" return binding;"); + builder.AppendLine(" }"); + builder.AppendLine(" }"); + if (!type.ContainingNamespace.IsGlobalNamespace) + builder.AppendLine("}"); + return builder.ToString(); + } + + private static string BuildStaticSource(INamedTypeSymbol type, + IReadOnlyList handlers, string defaultBus) + { + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + if (!type.ContainingNamespace.IsGlobalNamespace) + { + builder.Append("namespace ").Append(type.ContainingNamespace.ToDisplayString()).AppendLine(); + builder.AppendLine("{"); + } + + var accessibility = type.DeclaredAccessibility switch + { + Accessibility.Public => "public ", + Accessibility.Internal => "internal ", + _ => string.Empty + }; + builder.Append(" ").Append(accessibility).Append("static partial class ") + .Append(type.Name).AppendLine(); + builder.AppendLine(" {"); + builder.AppendLine(" [global::System.Runtime.CompilerServices.ModuleInitializer]"); + builder.AppendLine(" internal static void ShrinkEventBus_RegisterStaticBindings()"); + builder.AppendLine(" {"); + for (var i = 0; i < handlers.Count; i++) + { + var bus = string.IsNullOrWhiteSpace(handlers[i].Bus) ? defaultBus : handlers[i].Bus; + builder.Append(" global::ShrinkEventBus.ShrinkStaticBindingRegistry.Register(") + .Append("global::ShrinkEventBus.ShrinkBusKey.Parse(") + .Append(ToLiteral(bus)).Append("), ShrinkEventBus_Bind_").Append(i).AppendLine(");"); + } + builder.AppendLine(" }"); + + for (var i = 0; i < handlers.Count; i++) + { + var handler = handlers[i]; + var bus = string.IsNullOrWhiteSpace(handler.Bus) ? defaultBus : handler.Bus; + builder.Append(" private static global::System.IDisposable ShrinkEventBus_Bind_") + .Append(i).AppendLine("(global::ShrinkEventBus.IShrinkBusResolver resolver)"); + builder.AppendLine(" {"); + builder.Append(" return global::ShrinkEventBus.ShrinkGeneratedBinding.") + .Append(handler.BindingMethod).Append('<').Append(handler.EventType).AppendLine(">("); + builder.Append(" resolver, null, ").Append(ToLiteral(bus)) + .AppendLine(", null,"); + builder.Append(" ").Append(handler.MethodName) + .Append(", (global::ShrinkEventBus.ShrinkEventPriority)") + .Append(handler.Priority).Append(", ").Append(handler.NumericPriority).Append(", ") + .Append(handler.ReceiveCanceled ? "true" : "false").AppendLine(");"); + builder.AppendLine(" }"); + } + + builder.AppendLine(" }"); + if (!type.ContainingNamespace.IsGlobalNamespace) + builder.AppendLine("}"); + return builder.ToString(); + } + + private static string ReadString(AttributeData attribute, string name) + { + foreach (var pair in attribute.NamedArguments) + { + if (pair.Key == name) + return pair.Value.Value as string ?? string.Empty; + } + return string.Empty; + } + + private static int ReadInt(AttributeData attribute, string name, int defaultValue) + { + foreach (var pair in attribute.NamedArguments) + { + if (pair.Key == name && pair.Value.Value != null) + return Convert.ToInt32(pair.Value.Value); + } + return defaultValue; + } + + private static bool ReadBool(AttributeData attribute, string name, bool defaultValue) + { + foreach (var pair in attribute.NamedArguments) + { + if (pair.Key == name && pair.Value.Value is bool value) + return value; + } + return defaultValue; + } + + private static string ToLiteral(string value) => SymbolDisplay.FormatLiteral(value ?? string.Empty, true); + + private readonly struct HandlerModel + { + public HandlerModel(string methodName, string eventType, string bindingMethod, string bus, + int priority, int numericPriority, bool receiveCanceled) + { + MethodName = methodName; + EventType = eventType; + BindingMethod = bindingMethod; + Bus = bus; + Priority = priority; + NumericPriority = numericPriority; + ReceiveCanceled = receiveCanceled; + } + + public string MethodName { get; } + public string EventType { get; } + public string BindingMethod { get; } + public string Bus { get; } + public int Priority { get; } + public int NumericPriority { get; } + public bool ReceiveCanceled { get; } + } + } +} diff --git a/DotNet/ShrinkEventBus.Smoke/Program.cs b/DotNet/ShrinkEventBus.Smoke/Program.cs new file mode 100644 index 0000000..f2233e5 --- /dev/null +++ b/DotNet/ShrinkEventBus.Smoke/Program.cs @@ -0,0 +1,62 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using ShrinkEventBus; + +internal readonly struct PingEvent : IShrinkEvent +{ + public PingEvent(int value) => Value = value; + public int Value { get; } +} + +[ShrinkEventSubscriber] +internal sealed partial class SmokeSubscriber +{ + public int Sum { get; private set; } + public int ThreadId { get; private set; } + + [ShrinkSubscribe] + private void OnPing(PingEvent evt) + { + Sum += evt.Value; + ThreadId = Thread.CurrentThread.ManagedThreadId; + } +} + +[ShrinkEventSubscriber(DefaultBus = "game")] +internal static partial class StaticSmokeSubscriber +{ + public static int Sum { get; set; } + + [ShrinkSubscribe] + private static void OnPing(PingEvent evt) => Sum += evt.Value; +} + +internal static class Program +{ + private static async Task Main() + { + using var host = new ShrinkEventBusHost(); + var game = host.CreateBus(ShrinkBusKey.Game, ShrinkBusOptions.Inline()); + var workerKey = new ShrinkBusKey("worker", "smoke"); + var worker = host.CreateBus(workerKey, ShrinkBusOptions.DedicatedThread()); + var gameSubscriber = new SmokeSubscriber(); + var workerSubscriber = new SmokeSubscriber(); + var callerThread = Thread.CurrentThread.ManagedThreadId; + + using var gameBinding = host.Attach(gameSubscriber, ShrinkBusKey.Game); + using var workerBinding = host.Attach(workerSubscriber, workerKey); + game.Post(new PingEvent(2)); + await worker.PostAsync(new PingEvent(3)); + + if (gameSubscriber.Sum != 2 || workerSubscriber.Sum != 3 || StaticSmokeSubscriber.Sum != 2 || + workerSubscriber.ThreadId == callerThread) + { + Console.Error.WriteLine("FAIL"); + return 1; + } + + Console.WriteLine("PASS"); + return 0; + } +} diff --git a/DotNet/ShrinkEventBus.Smoke/ShrinkEventBus.Smoke.csproj b/DotNet/ShrinkEventBus.Smoke/ShrinkEventBus.Smoke.csproj new file mode 100644 index 0000000..c66b9e7 --- /dev/null +++ b/DotNet/ShrinkEventBus.Smoke/ShrinkEventBus.Smoke.csproj @@ -0,0 +1,14 @@ + + + Exe + net8.0 + latest + enable + disable + + + + + +