diff --git a/CHANGELOG.md b/CHANGELOG.md index c04c049..1969f53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,8 @@ - 写命令:`POST /stdunit.tags/value`(改测点值)、`POST /stdunit.tags/rescan`(标记未扫描)。 - 注册:`services.AddTagsStream()` / `app.MapTagsStream()`;多项目场景用 `AddKeyedTagsStream(key)` 注册具名流(路径形如 `/stdunit.tags/{key}/stream`)。 只允许改**路由前缀**(`app.MapTagsStream("/internal/tags")`),相对路径是线协议的一部分。 + - 启动时机:观测流在 `app.MapTagsStream()` 映射端点时启动(每路一次、幂等),**不注册成 `IHostedService`**, + 因此不依赖 Generic Host 的启动顺序;启动时会按 `ITagsProjectCtrl.Project` 对账,项目先跑起来也能补上(值流从下一轮生效)。 - **不内置任何鉴权**,暴露到非受信网络前请自行加策略(如 `app.MapTagsStream().RequireAuthorization()`)。 - **`AddKeyedTagsProjectCtrl(key)`**(`StdUnit.Tags`,`IServiceCollection` 与 `TagsProjectServiceBuilder` 上各一个): 以指定 key 注册 `ITagsProjectCtrl`;配合 `AddKeyedTagsStream(key)` 可以在一个宿主里同时监控多个项目。 diff --git a/codecov.yml b/codecov.yml index 4969d05..7ec55a9 100644 --- a/codecov.yml +++ b/codecov.yml @@ -71,9 +71,9 @@ component_management: paths: - "src/StdUnit.Tags.SimpleFiles/**" - component_id: stdunit_tags_webui - name: "WebUI" + name: "WebUi" paths: - - "src/StdUnit.Tags.WebUI/**" + - "src/StdUnit.Tags.WebUi/**" ignore: - "**/*.md" diff --git a/samples/WebMonitor/Tags/TagBackgroundService.cs b/samples/WebMonitor/Tags/TagBackgroundService.cs index e8586eb..ed68bcc 100644 --- a/samples/WebMonitor/Tags/TagBackgroundService.cs +++ b/samples/WebMonitor/Tags/TagBackgroundService.cs @@ -7,8 +7,8 @@ namespace WebMonitor.Tags; /// /// 启停两路测点项目,并跑一个只用于演示的值模拟器。
-/// 与模板 tags.web 的 TagBackgroundService 同一形状:轮询必须在这里(宿主启动之后)开始, -/// 观测流的 hub 才能先拿到"项目已启动"事件并挂上"轮次完成"回调。 +/// 与模板 tags.web 的 TagBackgroundService 同一形状:观测流的 hub 在 MapTagsStream() 时 +/// (Program.cs,早于本服务)就已经启动,所以这里只管轮询;哪一路先起来都不影响观测。 ///
internal sealed class TagBackgroundService : BackgroundService { diff --git a/src/StdUnit.Tags.WebUi/Core/Registry/TagsStreamEntry.cs b/src/StdUnit.Tags.WebUi/Core/Registry/TagsStreamEntry.cs index c61ac77..1440a13 100644 --- a/src/StdUnit.Tags.WebUi/Core/Registry/TagsStreamEntry.cs +++ b/src/StdUnit.Tags.WebUi/Core/Registry/TagsStreamEntry.cs @@ -2,16 +2,16 @@ namespace StdUnit.Tags.WebUi; /// /// 一路观测流:它观测的控制器、配置与 hub。
-/// 端点、命令、hosted service 都通过它拿到"同一个"hub —— 这是"接口服务 = hosted service = 端点看到的实例"的保证。 +/// 端点、命令、hub 启动点都通过它拿到"同一个"hub —— 这是"接口服务 = 端点看到的实例"的保证。 ///
internal sealed class TagsStreamEntry { - public TagsStreamEntry(string? key, TagsStreamOptions options, ITagsProjectCtrl ctrl, TagsStreamHostedService hub) + public TagsStreamEntry(string? key, TagsStreamOptions options, ITagsProjectCtrl ctrl, TagsStreamHub hub) { this.Key = key; this.Options = options; this.Ctrl = ctrl; - this.HostedService = hub; + this.Hub = hub; } public string? Key { get; } @@ -21,5 +21,5 @@ public TagsStreamEntry(string? key, TagsStreamOptions options, ITagsProjectCtrl /// 这一路流观测的控制器(写命令也用它,保证"看哪个项目就改哪个项目")。 public ITagsProjectCtrl Ctrl { get; } - internal TagsStreamHostedService HostedService { get; } + internal TagsStreamHub Hub { get; } } diff --git a/src/StdUnit.Tags.WebUi/Core/Registry/TagsStreamRegistry.cs b/src/StdUnit.Tags.WebUi/Core/Registry/TagsStreamRegistry.cs index 47cf86d..7be8706 100644 --- a/src/StdUnit.Tags.WebUi/Core/Registry/TagsStreamRegistry.cs +++ b/src/StdUnit.Tags.WebUi/Core/Registry/TagsStreamRegistry.cs @@ -5,7 +5,7 @@ namespace StdUnit.Tags.WebUi; /// /// 所有观测流的注册表:key(不具名用 null)→ 一路独立的 hub + 控制器 + 配置。
-/// 每个 key 恰好创建一个 hub,hosted service 与端点共用同一实例。 +/// 每个 key 恰好创建一个 hub,端点与 hub 启动点共用同一实例。 ///
internal sealed class TagsStreamRegistry { @@ -18,7 +18,7 @@ public TagsStreamRegistry(IServiceProvider services, IEnumerable>()); + var hub = new TagsStreamHub(ctrl, descriptor.Options, services.GetService>()); var entry = new TagsStreamEntry(descriptor.Key, descriptor.Options, ctrl, hub); this._entries.Add(entry); @@ -46,6 +46,18 @@ key is null public IReadOnlyList All => this._entries; + /// + /// 启动全部流的 hub(幂等)。这是观测流的唯一启动点,由 MapTagsStream() 在映射端点时调用: + /// 映射必然早于任何请求,也早于 StartPollAsync,所以不需要 Generic Host 的 IHostedService。 + /// + public void StartAll() + { + foreach (var entry in this._entries) + { + entry.Hub.Start(); + } + } + private static ITagsProjectCtrl ResolveCtrl(IServiceProvider services, string? key) { if (key is null) diff --git a/src/StdUnit.Tags.WebUi/Core/TagsStreamHub.cs b/src/StdUnit.Tags.WebUi/Core/TagsStreamHub.cs index e26d452..b15cb07 100644 --- a/src/StdUnit.Tags.WebUi/Core/TagsStreamHub.cs +++ b/src/StdUnit.Tags.WebUi/Core/TagsStreamHub.cs @@ -1,5 +1,4 @@ using System.Threading.Channels; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using StdUnit.Tags.WebUi.Internal; @@ -10,19 +9,25 @@ namespace StdUnit.Tags.WebUi; /// - 跟踪 的启停,维护每次启动的 epoch 与结构索引;
/// - 为每个入口用 挂一个"轮次完成"回调,做值 diff;
/// - 把结果序列化成 SSE 帧,扇出给各订阅者(每帧只序列化一次)。
-/// 同时是 (随宿主启停)与订阅者集合的持有者:订阅相关的成员只出现在内部的 +/// 同时是订阅者集合的持有者:订阅相关的成员只出现在内部的 /// 上,只服务同一程序集里的端点与 。 /// +/// 启动时机:由 MapTagsStream()()启动—— +/// 映射端点必然发生在任何请求之前,也早于 StartPollAsync,因此不依赖 Generic Host 的 +/// IHostedService 机制;启动后运行期的状态迁移一律以 为准 +/// (见 )。 +/// +/// /// 线程模型:轮次回调在轮询线程上执行,只做 O(改动的点) 的字典写入 + 一次 TryWrite, /// 不做序列化、不做 IO、不阻塞;序列化与扇出在单独的发送循环里,每个订阅者另有自己的写循环与有界队列。 /// 任何一环满了都只丢帧并触发重同步,不会反压到轮询。 /// /// -internal sealed class TagsStreamHostedService : ITagsStreamHub, IHostedService, IDisposable +internal sealed class TagsStreamHub : ITagsStreamHub, IDisposable { private readonly ITagsProjectCtrl _ctrl; private readonly TagsStreamOptions _options; - private readonly ILogger? _logger; + private readonly ILogger? _logger; /// 订阅者集合与状态发布。_transitionGate → _gate 是唯一允许的加锁顺序。 private readonly object _gate = new(); @@ -53,7 +58,7 @@ internal sealed class TagsStreamHostedService : ITagsStreamHub, IHostedService, /// 测点项目控制器(需由 AddTagsProjectServices 注册) /// 配置 /// 日志 - internal TagsStreamHostedService(ITagsProjectCtrl ctrl, TagsStreamOptions options, ILogger? logger = null) + internal TagsStreamHub(ITagsProjectCtrl ctrl, TagsStreamOptions options, ILogger? logger = null) { this._ctrl = ctrl ?? throw new ArgumentNullException(nameof(ctrl)); this._options = options ?? throw new ArgumentNullException(nameof(options)); @@ -87,12 +92,23 @@ internal TagsStreamHostedService(ITagsProjectCtrl ctrl, TagsStreamOptions option /// public long Epoch => this._state.Epoch; - /// - public Task StartAsync(CancellationToken cancellationToken) + /// + /// 幂等启动:订阅 、拉起发送循环与心跳循环, + /// 并按 对账(补上"启动之前项目就已经在跑"的索引与轮次回调)。
+ /// 由 MapTagsStream() 统一调用(); + /// 启动之后一直订阅到 /——项目停止时不能退订, + /// 否则接不到下一次"已启动"。 + /// + /// 同步:内部只有"订阅事件 + 起两个后台循环 + 一次对账",没有 IO,全部同步完成; + /// 所以刻意不返回 ——调用方(同步的 MapTagsStream)不必也不需要阻塞等待, + /// 从而不给"同步等待异步"留下死锁口子。 不同,它要等循环真正退出。 + /// + ///
+ public void Start() { if (Interlocked.Exchange(ref this._started, 1) != 0) { - return Task.CompletedTask; + return; } var cts = new CancellationTokenSource(); @@ -103,10 +119,12 @@ public Task StartAsync(CancellationToken cancellationToken) // 订阅之前项目可能就已经在跑(hub 启动得晚):先对一次账。 this.SyncWithController(); - return Task.CompletedTask; } - /// + /// + /// 停止观测:退订事件、取消并等待发送/心跳循环退出。
+ /// 正常停机由 DI 容器释放单例时的 兜底(只取消、不等待)。 + ///
public async Task StopAsync(CancellationToken cancellationToken) { this._ctrl.StartedOrStopped -= this.OnStartedOrStopped; diff --git a/src/StdUnit.Tags.WebUi/Sse/StreamEndpoint.cs b/src/StdUnit.Tags.WebUi/Sse/StreamEndpoint.cs index 8508897..009437a 100644 --- a/src/StdUnit.Tags.WebUi/Sse/StreamEndpoint.cs +++ b/src/StdUnit.Tags.WebUi/Sse/StreamEndpoint.cs @@ -26,7 +26,7 @@ public static async Task HandleAsync(HttpContext context) context.Response.Headers["X-Accel-Buffering"] = "no"; // nginx var entries = ParseEntries(context.Request.Query["entries"]); - using var piper = entry.HostedService.Subscribe(entries); + using var piper = entry.Hub.Subscribe(entries); try { diff --git a/src/StdUnit.Tags.WebUi/Tree/TreeEndpoint.cs b/src/StdUnit.Tags.WebUi/Tree/TreeEndpoint.cs index 03cb9eb..86ad559 100644 --- a/src/StdUnit.Tags.WebUi/Tree/TreeEndpoint.cs +++ b/src/StdUnit.Tags.WebUi/Tree/TreeEndpoint.cs @@ -18,7 +18,7 @@ public static async Task HandleAsync(HttpContext context) } // 自己序列化:不走 MVC / 无参序列化,AOT/裁剪下没有反射路径。 - var bytes = JsonSerializer.SerializeToUtf8Bytes(entry.HostedService.GetTree(), TagsStreamJsonContext.Default.TreeDto); + var bytes = JsonSerializer.SerializeToUtf8Bytes(entry.Hub.GetTree(), TagsStreamJsonContext.Default.TreeDto); context.Response.StatusCode = StatusCodes.Status200OK; context.Response.ContentType = "application/json; charset=utf-8"; diff --git a/src/StdUnit.Tags.WebUi/_Extensions_Endpoints.cs b/src/StdUnit.Tags.WebUi/_Extensions_Endpoints.cs index 25c062a..b690602 100644 --- a/src/StdUnit.Tags.WebUi/_Extensions_Endpoints.cs +++ b/src/StdUnit.Tags.WebUi/_Extensions_Endpoints.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; using StdUnit.Tags.WebUi.Internal; namespace StdUnit.Tags.WebUi; @@ -39,6 +40,7 @@ public static class TagsStreamExtensions_Endpoints /// 设计器元数据:GET {prefix}/catalog 把核心 + 驱动 schema 内省成 JSON, /// GET {prefix}/designer 就是用它渲染的设计器页面。两者都与具体观测流无关, /// 只有这一份、没有具名版本;只读,不涉及项目数据。 + ///
/// /// 端点构建器 /// @@ -48,6 +50,11 @@ public static class TagsStreamExtensions_Endpoints /// 路由组:可继续 RequireAuthorization() / WithTags() 等整组配置。 public static RouteGroupBuilder MapTagsStream(this IEndpointRouteBuilder endpoints, string prefix = EndpointConstants.DefaultPrefix) { + // 观测流的**唯一启动点**:映射端点必然早于任何请求,也早于 StartPollAsync, + // 所以 hub 不需要注册成 IHostedService,也不依赖 Generic Host 的启动顺序。 + // 只 map 不 AddTagsStream 时是 no-op(端点会在运行时回 404)。 + endpoints.ServiceProvider.GetService()?.StartAll(); + var group = endpoints.MapGroup(prefix.Length == 0 ? "/" : prefix); // 不具名流:{prefix}/tree|stream|value|rescan|ui diff --git a/src/StdUnit.Tags.WebUi/_Extensions_ServiceCollection.cs b/src/StdUnit.Tags.WebUi/_Extensions_ServiceCollection.cs index 85fea89..4f87be5 100644 --- a/src/StdUnit.Tags.WebUi/_Extensions_ServiceCollection.cs +++ b/src/StdUnit.Tags.WebUi/_Extensions_ServiceCollection.cs @@ -1,7 +1,6 @@ using StdUnit.Tags; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Hosting; using StdUnit.Tags.WebUi.Internal; @@ -18,10 +17,10 @@ public static class TagsStreamExtensions_ServiceCollection /// 端点落在 {prefix}/tree|stream|value|rescan|ui。
/// 一个容器里只能有一路不具名流;要多路请用 。
///
- /// 启动顺序:hub 以 注册,随宿主一起启动。它在启动时按 - /// 对账,所以晚启动也能补上:即使项目已在轮询, - /// 结构树与值流都正常(轮次回调运行期也能挂,从下一轮开始生效)。 - /// 想尽早拿到结构树,就把轮询放在宿主启动之后(例如在请求里/更晚注册的 hosted service 里 StartPollAsync)。 + /// 启动时机:hub 在 MapTagsStream() 映射端点时启动(那必然早于任何请求,也早于 StartPollAsync), + /// 不依赖 Generic Host 的 IHostedService。DI 注册本来就在 Build() 之前完成、映射在其后, + /// 所以这个顺序天然满足;启动时它会按 对账, + /// 项目已经在轮询也能补上(轮次回调运行期也能挂,从下一轮开始生效)。 /// /// 服务集合 /// 这一路流的配置回调 @@ -39,19 +38,12 @@ public static IServiceCollection AddTagsStream(this IServiceCollection services, services.AddSingleton(new TagsStreamDescriptor(key: null, options)); - // 同一实例注册为 IHostedService:用工厂而不是 AddHostedService()——后者会另开 - // IHostedService → T 的描述符再 new 一个,宿主启动的 hub 与端点看到的不是同一份数据。 - services.AddSingleton( - sp => sp.GetRequiredService() - .GetRequired(null) - .HostedService - ); - // 不具名那一路按 ITagsStreamHub 解析(具名那一路是 keyed 服务)。 + // 启动点是 MapTagsStream():见 TagsStreamRegistry.StartAll()。 services.TryAddSingleton( sp => sp.GetRequiredService() .GetRequired(null) - .HostedService + .Hub ); return services; @@ -60,7 +52,7 @@ public static IServiceCollection AddTagsStream(this IServiceCollection services, /// /// 注册一路具名观测流:观测由 AddKeyedTagsProjectCtrl(key) 注册的 , /// 端点落在 {prefix}/{key}/tree|stream|value|rescan|ui(监控页各一份)。
- /// 可与 并存;同一个 key 只能注册一路。启动顺序等约束同 。 + /// 可与 并存;同一个 key 只能注册一路。启动时机等约束同 。 ///
/// 服务集合 /// @@ -78,17 +70,12 @@ public static IServiceCollection AddKeyedTagsStream( this IServiceCollection ser services.AddSingleton(new TagsStreamDescriptor(projctrlKey, options)); - // 同一实例注册为 IHostedService / keyed ITagsStreamHub(原因见 AddTagsStream 里的注释)。 - services.AddSingleton( - sp => sp.GetRequiredService() - .GetRequired(projctrlKey) - .HostedService - ); + // 同一实例注册为 keyed ITagsStreamHub(原因见 AddTagsStream 里的注释)。 services.AddKeyedSingleton( projctrlKey, (sp, _) => sp.GetRequiredService() .GetRequired(projctrlKey) - .HostedService + .Hub ); return services; diff --git a/src/StdUnit.Tags.WebUi/_Extensions_TagsProjectServiceBuilder.cs b/src/StdUnit.Tags.WebUi/_Extensions_TagsProjectServiceBuilder.cs index eb67efc..0101602 100644 --- a/src/StdUnit.Tags.WebUi/_Extensions_TagsProjectServiceBuilder.cs +++ b/src/StdUnit.Tags.WebUi/_Extensions_TagsProjectServiceBuilder.cs @@ -13,9 +13,9 @@ public static class TagsStreamExtensions_TagsProjectServiceBuilder /// 和不具名的 关联;一个容器里最多只有一路不具名的观测流。 /// /// - /// 启动顺序:hub 以 IHostedService 注册,随宿主一起启动;它在启动时按 ITagsProjectCtrl.Project 对账, + /// 启动时机:hub 在 MapTagsStream() 映射端点时启动(早于任何请求,也早于 StartPollAsync), + /// 不依赖 Generic Host 的 IHostedService。它在启动时按 ITagsProjectCtrl.Project 对账, /// 所以晚启动也能补上——即使项目已在轮询,结构树与值流都正常(轮次回调运行期也能挂,从下一轮开始生效)。 - /// 想尽早拿到结构树,就把轮询放在宿主启动之后(例如在请求里/更晚注册的 hosted service 里 StartPollAsync)。 /// /// /// 这一路流的配置回调 @@ -33,7 +33,7 @@ public static TagsProjectServiceBuilder AddTagsStream(this TagsProjectServiceBui /// /// /// 时序约束同 : - /// hub 在启动时对账,晚启动也能补上,顺序只影响"多早开始观测"。 + /// hub 在 MapTagsStream() 时启动,端口映射发生在任何请求之前,所以顺序只影响"多早开始观测"。 /// /// /// diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/EndpointTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/EndpointTests.cs index b3cc302..1206288 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/EndpointTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/EndpointTests.cs @@ -412,10 +412,10 @@ public ITagsProject StartProject(XElement xml) return project; } - public TagsStreamHostedService StartHub(string? key = null) + public TagsStreamHub StartHub(string? key = null) { - var hub = this.Root.GetRequiredService().GetRequired(key).HostedService; - hub.StartAsync(default).GetAwaiter().GetResult(); + var hub = this.Root.GetRequiredService().GetRequired(key).Hub; + hub.Start(); return hub; } diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubEdgeCaseTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubEdgeCaseTests.cs index 55fbcba..07ff55f 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubEdgeCaseTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubEdgeCaseTests.cs @@ -11,7 +11,7 @@ namespace StdUnit.Tags.Tests.NetCoreOnly.WebUi; /// -/// 的边界行为:参数校验、启停幂等、无订阅者时的零开销、 +/// 的边界行为:参数校验、启停幂等、无订阅者时的零开销、 /// 心跳、按 拆帧、容器标志单独成帧, /// 以及 队列溢出时的"丢弃 + 重同步"(绝不反压)。 /// @@ -47,26 +47,26 @@ public void Ctor_RejectsNullAndOutOfRangeArguments() { var ok = Options(); - Assert.Throws(() => new TagsStreamHostedService(null!, ok)); - Assert.Throws(() => new TagsStreamHostedService(new FakeProjectCtrl(), null!)); + Assert.Throws(() => new TagsStreamHub(null!, ok)); + Assert.Throws(() => new TagsStreamHub(new FakeProjectCtrl(), null!)); Assert.Throws(() => - new TagsStreamHostedService(new FakeProjectCtrl(), new TagsStreamOptions { ReconcileInterval = TimeSpan.Zero })); + new TagsStreamHub(new FakeProjectCtrl(), new TagsStreamOptions { ReconcileInterval = TimeSpan.Zero })); Assert.Throws(() => - new TagsStreamHostedService(new FakeProjectCtrl(), new TagsStreamOptions { HeartbeatInterval = TimeSpan.Zero })); + new TagsStreamHub(new FakeProjectCtrl(), new TagsStreamOptions { HeartbeatInterval = TimeSpan.Zero })); Assert.Throws(() => - new TagsStreamHostedService(new FakeProjectCtrl(), new TagsStreamOptions { PiperQueueCapacity = 0 })); + new TagsStreamHub(new FakeProjectCtrl(), new TagsStreamOptions { PiperQueueCapacity = 0 })); Assert.Throws(() => - new TagsStreamHostedService(new FakeProjectCtrl(), new TagsStreamOptions { PendingQueueCapacity = 0 })); + new TagsStreamHub(new FakeProjectCtrl(), new TagsStreamOptions { PendingQueueCapacity = 0 })); } [Fact] public async Task StartAndStop_AreIdempotent() { - using var hub = new TagsStreamHostedService(new FakeProjectCtrl(), Options()); + using var hub = new TagsStreamHub(new FakeProjectCtrl(), Options()); - await hub.StartAsync(default); - await hub.StartAsync(default); // 第二次什么也不做 + hub.Start(); + hub.Start(); // 第二次什么也不做 await hub.StopAsync(default); await hub.StopAsync(default); // 第二次时 pump/heartbeat 已为 null } @@ -82,8 +82,8 @@ public async Task OnTurn_WithoutSubscribers_AndAfterStop_IsIgnored() var ctrl = new FakeProjectCtrl(); ctrl.RaiseStarted(project); - using var hub = new TagsStreamHostedService(ctrl, Options()); - await hub.StartAsync(default); + using var hub = new TagsStreamHub(ctrl, Options()); + hub.Start(); using var runCts = new CancellationTokenSource(); var run = project.RunAsync(runCts.Token); @@ -116,8 +116,8 @@ public async Task Heartbeat_SendsPingToSubscribers() var options = Options(); options.HeartbeatInterval = TimeSpan.FromMilliseconds(50); - using var hub = new TagsStreamHostedService(new FakeProjectCtrl(), options); - await hub.StartAsync(default); + using var hub = new TagsStreamHub(new FakeProjectCtrl(), options); + hub.Start(); using var output = new RecordingStream(); using var piperCts = new CancellationTokenSource(); @@ -157,8 +157,8 @@ public async Task Delta_IsSplitByMaxValuesPerFrame() var ctrl = new FakeProjectCtrl(); ctrl.RaiseStarted(project); - using var hub = new TagsStreamHostedService(ctrl, options); - await hub.StartAsync(default); + using var hub = new TagsStreamHub(ctrl, options); + hub.Start(); using var output = new RecordingStream(); using var piperCts = new CancellationTokenSource(); @@ -218,8 +218,8 @@ public async Task ContainerFlagChange_IsPublishedWithoutValues() var ctrl = new FakeProjectCtrl(); ctrl.RaiseStarted(project); - using var hub = new TagsStreamHostedService(ctrl, Options()); - await hub.StartAsync(default); + using var hub = new TagsStreamHub(ctrl, Options()); + hub.Start(); using var output = new RecordingStream(); using var piperCts = new CancellationTokenSource(); diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubTests.cs index e7949b8..bac41ba 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamHubTests.cs @@ -10,7 +10,7 @@ namespace StdUnit.Tags.Tests.NetCoreOnly.WebUi; /// -/// 的端到端行为:状态(Replay 1)、全量基线、增量、按入口过滤、迟到订阅者。
+/// 的端到端行为:状态(Replay 1)、全量基线、增量、按入口过滤、迟到订阅者。
/// 用真实的 TagsProject + 真实 TagGrpRunner(假驱动),只把 换成可控的假实现, /// 以便精确决定"什么时候启动/停止"。 ///
@@ -54,8 +54,8 @@ public async Task Hub_PushesStateBaselineAndDelta() { using var project = CreateSingleEntryProject(this._scope); var ctrl = new FakeProjectCtrl(); - using var hub = new TagsStreamHostedService(ctrl, FastOptions()); - await hub.StartAsync(default); + using var hub = new TagsStreamHub(ctrl, FastOptions()); + hub.Start(); using var output = new RecordingStream(); using var piperCts = new CancellationTokenSource(); @@ -102,8 +102,8 @@ public async Task Subscribe_FiltersByEntry() StreamTestContext.EntryGroup("g2", "fake2", StreamTestContext.Tag("a")))); var ctrl = new FakeProjectCtrl(); - using var hub = new TagsStreamHostedService(ctrl, FastOptions()); - await hub.StartAsync(default); + using var hub = new TagsStreamHub(ctrl, FastOptions()); + hub.Start(); using var output = new RecordingStream(); using var piperCts = new CancellationTokenSource(); @@ -135,8 +135,8 @@ public async Task SubscribeAfterStart_ReceivesBaselineImmediately() { using var project = CreateSingleEntryProject(this._scope); var ctrl = new FakeProjectCtrl(); - using var hub = new TagsStreamHostedService(ctrl, FastOptions()); - await hub.StartAsync(default); + using var hub = new TagsStreamHub(ctrl, FastOptions()); + hub.Start(); // 先启动(不跑轮询),再订阅:订阅者应立即拿到状态 + 全量基线。 ctrl.RaiseStarted(project); @@ -164,8 +164,8 @@ public async Task Hub_StartedAfterProject_StillAttachesTurnHandlers() using var runCts = new CancellationTokenSource(); var run = project.RunAsync(runCts.Token); - using var hub = new TagsStreamHostedService(ctrl, FastOptions()); - await hub.StartAsync(default); + using var hub = new TagsStreamHub(ctrl, FastOptions()); + hub.Start(); Assert.True(hub.GetTree().IsStarted); using var output = new RecordingStream(); diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamMappingTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamMappingTests.cs index 2cfb3e8..864b53f 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamMappingTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamMappingTests.cs @@ -2,11 +2,14 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using System.Xml.Linq; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using StdUnit.Tags.Tests.Fakes; using StdUnit.Tags.WebUi; using Xunit; @@ -72,6 +75,50 @@ public async Task MapTagsStream_PutsEveryEndpointUnderThePrefix(string prefix) } } + /// + /// MapTagsStream 是观测流的唯一启动点:映射之后(不需要宿主启动、也不需要任何请求) + /// hub 就已经在观测,并且能补上"映射之前项目就已经在跑"的状态。 + /// + [Fact] + public async Task MapTagsStream_StartsEveryHub() + { + var builder = WebApplication.CreateSlimBuilder(); + builder.Logging.ClearProviders(); + + var unnamed = new FakeProjectCtrl(); + var named = new FakeProjectCtrl(); + builder.Services.AddTagsProjectServices(b => + { + b.AddFakedSupport(); + b.AddFakedTagSupport(); + }); + builder.Services.AddSingleton(unnamed); // 后注册的覆盖默认实现 + builder.Services.AddKeyedSingleton("dev", named); + builder.Services.AddTagsStream(); + builder.Services.AddKeyedTagsStream("dev"); + + using var app = builder.Build(); + using var project = app.Services.GetRequiredService().Create( + StreamTestContext.ProjectRoot, + new XElement("Project", + StreamTestContext.Channel("fake"), + StreamTestContext.EntryGroup("g1", StreamTestContext.Tag("a")))); + + // 项目先跑起来,之后才挂端点。 + unnamed.RaiseStarted(project); + named.RaiseStarted(project); + + app.MapTagsStream(); // ← 唯一启动点 + + var hub = app.Services.GetRequiredService(); + Assert.True(hub.GetTree().IsStarted); + Assert.NotEmpty(hub.GetTree().Nodes); + + var devHub = app.Services.GetRequiredKeyedService("dev"); + Assert.True(devHub.GetTree().IsStarted); + Assert.NotEmpty(devHub.GetTree().Nodes); + } + [Theory] // 路径 → 期望的 (页面自己的 URL 去掉最后一段) [InlineData("/stdunit.tags/ui", "", "/stdunit.tags/")] diff --git a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamRegistrationTests.cs b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamRegistrationTests.cs index d249f48..5affab2 100644 --- a/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamRegistrationTests.cs +++ b/tests/StdUnit.Tags.Tests.NetCoreOnly/WebUi/TagsStreamRegistrationTests.cs @@ -3,7 +3,6 @@ using System.Threading.Tasks; using System.Xml.Linq; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using StdUnit.Tags.Tests.Fakes; using StdUnit.Tags.WebUi; using Xunit; @@ -11,8 +10,9 @@ namespace StdUnit.Tags.Tests.NetCoreOnly.WebUi; /// -/// AddTagsStream:对外只暴露 ,且它与 hosted service 是同一个实例; -/// 具名流用 AddKeyedTagsStream(key) 决定观测哪个 。 +/// AddTagsStream:对外只暴露 ,且它就是 registry 里那一路的同一个实例; +/// 具名流用 AddKeyedTagsStream(key) 决定观测哪个 。
+/// 启动点是 MapTagsStream()()——这里直接调 registry 模拟。 ///
public class TagsStreamRegistrationTests { @@ -51,8 +51,8 @@ public void AddTagsStream_ExposesOnlyTheInterfaceAndReusesTheSameInstance() var hub = provider.GetRequiredService(); Assert.Same(hub, provider.GetRequiredService()); // Singleton - // 同一个实例既是接口服务,又是随宿主启停的 IHostedService。 - Assert.Contains(provider.GetServices(), h => ReferenceEquals(h, hub)); + // 同一个实例:接口服务 = registry 里那一路(端点与启动点共用)。 + Assert.Same(hub, provider.GetRequiredService().GetRequired(null).Hub); Assert.Equal(0, hub.Epoch); var tree = hub.GetTree(); @@ -75,9 +75,10 @@ public async Task AddTagsStream_BindsToTheKeyedControllerWhenKeyIsGiven() using var project = CreateProject(provider); var hub = provider.GetRequiredKeyedService("dev"); - var hosted = Assert.Single(provider.GetServices()); - Assert.Same(hub, hosted); - await hosted.StartAsync(default); + var registry = provider.GetRequiredService(); + var entry = registry.GetRequired("dev"); + Assert.Same(hub, entry.Hub); + registry.StartAll(); try { // 不具名的那个被启动,本 hub 不应有任何反应(它绑的是 "dev")。 @@ -93,7 +94,7 @@ public async Task AddTagsStream_BindsToTheKeyedControllerWhenKeyIsGiven() } finally { - await hosted.StopAsync(default); + await entry.Hub.StopAsync(default); } } @@ -116,15 +117,11 @@ public async Task AddTagsStream_RegistersOneIndependentHubPerStream() var devHub = provider.GetRequiredKeyedService("dev"); Assert.NotSame(defaultHub, devHub); - // 两路各一个 hosted service,且都指向各自的 hub。 - var hosted = provider.GetServices().ToArray(); - Assert.Equal(2, hosted.Length); - Assert.Contains(hosted, h => ReferenceEquals(h, defaultHub)); - Assert.Contains(hosted, h => ReferenceEquals(h, devHub)); - foreach (var service in hosted) - { - await service.StartAsync(default); - } + // 两路各一个 hub,且接口服务指向各自的那一个。 + var registry = provider.GetRequiredService(); + Assert.Same(defaultHub, registry.GetRequired(null).Hub); + Assert.Same(devHub, registry.GetRequired("dev").Hub); + registry.StartAll(); try { @@ -139,9 +136,9 @@ public async Task AddTagsStream_RegistersOneIndependentHubPerStream() } finally { - foreach (var service in hosted) + foreach (var entry in registry.All) { - await service.StopAsync(default); + await entry.Hub.StopAsync(default); } } } @@ -230,8 +227,9 @@ public async Task AddTagsStream_BindsToTheUnnamedControllerByDefault() using var project = CreateProject(provider); var hub = provider.GetRequiredService(); - var hosted = Assert.Single(provider.GetServices()); - await hosted.StartAsync(default); + var registry = provider.GetRequiredService(); + var entry = registry.GetRequired(null); + registry.StartAll(); try { named.RaiseStarted(project); @@ -243,7 +241,7 @@ public async Task AddTagsStream_BindsToTheUnnamedControllerByDefault() } finally { - await hosted.StopAsync(default); + await entry.Hub.StopAsync(default); } } @@ -268,7 +266,7 @@ public void AddTagsStream_OnTheProjectBuilder_RegistersTheUnnamedStream() var hub = provider.GetRequiredService(); Assert.Same(hub, provider.GetRequiredService()); - Assert.Contains(provider.GetServices(), h => ReferenceEquals(h, hub)); + Assert.Same(hub, provider.GetRequiredService().GetRequired(null).Hub); // configure 落在这一路流自己的描述符上(不是被丢掉的临时对象)。 var descriptor = Assert.Single(provider.GetServices()); @@ -296,8 +294,7 @@ public void AddKeyedTagsStream_OnTheProjectBuilder_RegistersTheKeyedStream() using var provider = services.BuildServiceProvider(); var hub = provider.GetRequiredKeyedService("dev"); - var hosted = Assert.Single(provider.GetServices()); - Assert.Same(hub, hosted); + Assert.Same(hub, provider.GetRequiredService().GetRequired("dev").Hub); var descriptor = Assert.Single(provider.GetServices()); Assert.Equal("dev", descriptor.Key);