diff --git a/CHANGELOG.md b/CHANGELOG.md index 92490e0..96f6bcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ 本文件记录**用户可见**的变化。版本号遵循[语义化版本](https://semver.org/lang/zh-CN/) `..`。 +## 2.0.0-beta4 + +> 本批仍是 `2.0.0` 的预发布——`2.0.0` 正式版保持未发布。 + +主题:**逻辑组件能拿到项目声明周期的取消令牌**。 + +### 破坏性变更 + +- **`ILogicet.ProcessAsync` 增加取消令牌**:签名由 `Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel)` + 变为 `Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel, CancellationToken ct = default)`(`LogicetBase` 的抽象方法同步修改)。 + - **调用方不用改代码**:`ct` 是可选参数,`logicet.ProcessAsync(entry, ch)` 照旧编译,取值为 `default`。 + - **实现方要改签名**:实现 `ILogicet`、或继承 `LogicetBase` 并 override 该方法的代码必须补上参数;二进制层面同样是破坏性的, + **旧插件 dll 需要重编**(与 beta3 的迁移动作相同)。 + - **`ct` 的语义是项目的生命周期**:`TagsProjectCtrl.StopAsync()` 时取消,**不是**"单轮超时"——框架不会为某一轮单独取消, + 同一入口的每一轮拿到的都是同一个令牌。停机时想尽快收手请用 `if (ct.IsCancellationRequested) return;`; + **不要**在 `ProcessAsync` 里抛 `OperationCanceledException`,它会被运行器当成一次失败(触发 `RunnerCrashed`、按策略断开重连)。 + "回调必须尽快返回"的约束不因新增令牌而放松。 + - **影响面**:只调库、不实现/继承逻辑组件的终端 App **编译与行为都不变**。 +- 示例与 AOT 冒烟测试(`samples/Samples.Plugin1`、`samples/StdUnit.Tags.Web`、`samples/WpfDemo`、`tests/AotSmokeTest.LogicetPlugins`)已按新签名更新。 + ## 2.0.0-beta3 > 本批仍是 `2.0.0` 的预发布——`2.0.0` 正式版保持未发布。 @@ -58,7 +78,7 @@ 统一为"读取失败 / 写入失败"(区域信息已经含在地址里);写入失败的消息同样带上了长度。 - 依赖:**`StdUnit.Sharp7` 升到 0.8.1**(多目标 `net6.0` / `netstandard2.0` / `netstandard2.1`)。因此 `net8.0` 侧取 `net6.0` 资产。 -## 2.0.0 +## 2.0.0-alpha > `2026-10-10`发布`2.0.0-alpha`。使能语义相关改动自 `2.0.0-alpha` 起可用;AOT 支持相关改动自 `2.0.0-beta` 起可用;S7 读写路径的破坏性改造自 `2.0.0-beta2` 起可用。 diff --git "a/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/0. \350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214.md" "b/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/0. \350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214.md" index 0f44c37..34dc304 100644 --- "a/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/0. \350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214.md" +++ "b/docs/\350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214/0. \350\256\276\350\256\241\345\206\263\347\255\226\344\270\216\350\276\271\347\225\214.md" @@ -56,6 +56,7 @@ API 细节见教程站点()。 ### 线程模型与伸缩 - 循环内的回调(`ILogicet.ProcessAsync`、`TagGrpWriteIntent`)**必须尽快返回**:不要 `.Result` / `.Wait()` / `Thread.Sleep`,也不要写成"等待外部事件"的形态,否则与 `WriteIntent` 构成环路等待死锁(见[外部意图](../外部意图.md))。 +- 这两个回调拿到的 `ct` 是**项目生命周期令牌**(`StopAsync` 时取消),**不是**单轮超时。停机时想尽快收手就用 `if (ct.IsCancellationRequested) return;`;**不要**抛 `OperationCanceledException`——轮询器会把它当成一次崩溃(触发 `RunnerCrashed` 并按策略断开重连)。也不能借它把长任务搬进回调。 - 底层是同步实现的驱动(S7 / ComScanner)会实际占住线程;入口数量上到几十个时请评估线程池与设备吞吐。当前**不设并行度上限,也没有调度器扩展点**。 不过这两点并不是什么实际问题。在工厂,有3种使用场景: diff --git a/samples/Samples.Plugin1/Handle1Snap.cs b/samples/Samples.Plugin1/Handle1Snap.cs index 86a7c79..ea74499 100644 --- a/samples/Samples.Plugin1/Handle1Snap.cs +++ b/samples/Samples.Plugin1/Handle1Snap.cs @@ -34,7 +34,7 @@ public override bool MatchEntry(ITagGrp entry) return true; } - public override Task ProcessAsync(ITagGrp entry, ITagChannel? channel) + public override Task ProcessAsync(ITagGrp entry, ITagChannel? channel, CancellationToken ct) { var hasReq = this._inBtnReset.GetTagValue(); var hasAck = _outLedRed.GetTagValue(); diff --git a/samples/StdUnit.Tags.Web/Logicets/HandleSnap11.cs b/samples/StdUnit.Tags.Web/Logicets/HandleSnap11.cs index 21d65bb..ab6f2f2 100644 --- a/samples/StdUnit.Tags.Web/Logicets/HandleSnap11.cs +++ b/samples/StdUnit.Tags.Web/Logicets/HandleSnap11.cs @@ -32,7 +32,7 @@ public override bool MatchEntry(ITagGrp entry) return entry.TagName() == "g4"; } - public override Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel) + public override Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel, CancellationToken ct) { var hasReq = this._reqTag.GetTagValue(); var hasAck = _ackTag.GetTagValue() == true; diff --git a/samples/StdUnit.Tags.Web/Logicets/HandleSnap12.cs b/samples/StdUnit.Tags.Web/Logicets/HandleSnap12.cs index aa7e313..387754b 100644 --- a/samples/StdUnit.Tags.Web/Logicets/HandleSnap12.cs +++ b/samples/StdUnit.Tags.Web/Logicets/HandleSnap12.cs @@ -42,7 +42,7 @@ public override bool MatchEntry(ITagGrp entry) private int i = 0; - public override Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel) + public override Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel, CancellationToken ct) { var letgo = this._btnLetGo.GetTagValue(); var red = this._ledRed.GetTagValue(); diff --git a/samples/StdUnit.Tags.Web/Logicets/HandleSnap13.cs b/samples/StdUnit.Tags.Web/Logicets/HandleSnap13.cs index f1259dd..eefc049 100644 --- a/samples/StdUnit.Tags.Web/Logicets/HandleSnap13.cs +++ b/samples/StdUnit.Tags.Web/Logicets/HandleSnap13.cs @@ -42,7 +42,7 @@ public override bool MatchEntry(ITagGrp entry) private int i = 0; - public override Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel) + public override Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel, CancellationToken ct) { var letgo = this._btnLetGo.GetTagValue(); var red = this._ledRed.GetTagValue(); diff --git a/samples/StdUnit.Tags.Web/Logicets/HandleSnap14.cs b/samples/StdUnit.Tags.Web/Logicets/HandleSnap14.cs index db32f4d..060f65f 100644 --- a/samples/StdUnit.Tags.Web/Logicets/HandleSnap14.cs +++ b/samples/StdUnit.Tags.Web/Logicets/HandleSnap14.cs @@ -27,7 +27,7 @@ public override bool MatchEntry(ITagGrp entry) } - public override Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel) + public override Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel, CancellationToken ct) { _reqFlag.Value = true; return Task.CompletedTask; diff --git a/samples/WpfDemo/Tags/Logicets/HeartBeatLogicet.cs b/samples/WpfDemo/Tags/Logicets/HeartBeatLogicet.cs index ee6e637..fb2e567 100644 --- a/samples/WpfDemo/Tags/Logicets/HeartBeatLogicet.cs +++ b/samples/WpfDemo/Tags/Logicets/HeartBeatLogicet.cs @@ -34,7 +34,7 @@ public HeartBeatLogicet(ITagsProject project, ILogger logger) public override bool MatchEntry(ITagGrp entry) => entry.TagName() == "IoBox"; - public override Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel) + public override Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel, CancellationToken ct) { _heartAck.Value = _heartReq.Value; diff --git a/src/StdUnit.Tags.Core/Plugins/ILogicet.cs b/src/StdUnit.Tags.Core/Plugins/ILogicet.cs index 7b62ab5..9e1f526 100644 --- a/src/StdUnit.Tags.Core/Plugins/ILogicet.cs +++ b/src/StdUnit.Tags.Core/Plugins/ILogicet.cs @@ -32,6 +32,15 @@ public interface ILogicet /// /// 处理 /// + /// 当前入口测点组 + /// 入口的主通道( 的解析结果,可能为 null) + /// + /// 项目的生命周期令牌:项目停止()时取消。
+ /// 它不是"单轮超时"令牌——框架不会为某一轮单独取消,同一入口每一轮拿到的都是同一个令牌。
+ /// 建议用 if (ct.IsCancellationRequested) return; 提前结束本轮;不要在这里抛 + /// ,它会被运行器当成一次失败(触发 并按策略断开重连)。
+ /// 不要缓存它、也不要每轮 Register;它也不能让本方法变成"可以慢慢跑"——本方法仍必须尽快返回。 + /// /// - Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel); + Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel, CancellationToken ct = default); } diff --git a/src/StdUnit.Tags.Core/Plugins/LogicetBase.cs b/src/StdUnit.Tags.Core/Plugins/LogicetBase.cs index 0735d5f..ff3a0a8 100644 --- a/src/StdUnit.Tags.Core/Plugins/LogicetBase.cs +++ b/src/StdUnit.Tags.Core/Plugins/LogicetBase.cs @@ -38,5 +38,5 @@ public LogicetBase(ITagsProject project) public virtual bool Enabled { get; set; } = true; /// - public abstract Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel); + public abstract Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel, CancellationToken ct = default); } diff --git a/src/StdUnit.Tags.Core/Projects/TagsProject.cs b/src/StdUnit.Tags.Core/Projects/TagsProject.cs index cb1149e..cdf81b9 100644 --- a/src/StdUnit.Tags.Core/Projects/TagsProject.cs +++ b/src/StdUnit.Tags.Core/Projects/TagsProject.cs @@ -243,7 +243,7 @@ public virtual Task RunAsync(CancellationToken ct) { continue; } - await l.ProcessAsync(entry, ch); + await l.ProcessAsync(entry, ch, ct); } }; runner.RunnerCrashed += RunnerCrashed; diff --git a/tests/AotSmokeTest.LogicetPlugins/Program.cs b/tests/AotSmokeTest.LogicetPlugins/Program.cs index 4b79152..c676b18 100644 --- a/tests/AotSmokeTest.LogicetPlugins/Program.cs +++ b/tests/AotSmokeTest.LogicetPlugins/Program.cs @@ -166,5 +166,5 @@ public DemoLogicet(ITagsProject project) public override bool MatchEntry(ITagGrp entry) => entry.TagName() == "g1"; - public override Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel) => Task.CompletedTask; + public override Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel, CancellationToken ct) => Task.CompletedTask; } diff --git a/tests/StdUnit.Tags.Tests/Core/Logicets/LogicetCancellationTests.cs b/tests/StdUnit.Tags.Tests/Core/Logicets/LogicetCancellationTests.cs new file mode 100644 index 0000000..a3a3cf4 --- /dev/null +++ b/tests/StdUnit.Tags.Tests/Core/Logicets/LogicetCancellationTests.cs @@ -0,0 +1,95 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using StdUnit.Tags.Tests.Fakes; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace StdUnit.Tags.Tests.Core.Logicets; + +/// +/// 验证 把项目的生命周期令牌透传给 +/// 。 +/// +public class LogicetCancellationTests +{ + private readonly ServiceProvider _root; + + public LogicetCancellationTests() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTagsProjectServices(b => + { + b.AddFakedSupport(); + }); + + _root = services.BuildServiceProvider(); + } + + /// + /// 记录收到的令牌,并在本次处理中触发取消,让轮询循环尽快退出。 + /// + private sealed class CapturingLogicet : LogicetBase + { + private readonly Action _onProcessed; + + public CapturingLogicet(ITagsProject project, Action onProcessed) : base(project) + { + _onProcessed = onProcessed; + } + + public override int Order => 0; + + public override bool MatchEntry(ITagGrp entry) => true; + + public CancellationToken CapturedToken { get; private set; } + + public override Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel, CancellationToken ct) + { + CapturedToken = ct; + _onProcessed(); + return Task.CompletedTask; + } + } + + [Fact] + public async Task RunAsync_PassesLifecycleTokenToLogicet() + { + // Arrange + using var scope = _root.CreateScope(); + var sp = scope.ServiceProvider; + var factory = sp.GetRequiredService(); + + var channel = new XElement("Channel", + new XAttribute("name", "fake"), + new XAttribute("driver", "fake")); + var entry = new XElement("TagGrp", + new XAttribute("name", "g1"), + new XAttribute("isEntry", true), + new XAttribute("isEnabled", true), + new XAttribute("channel", "fake"), + new XAttribute("scanInterval", 0)); + + using var proj = factory.Create(TestPaths.Fixture("S7Tags"), new XElement("Project", channel, entry)); + + using var cts = new CancellationTokenSource(); + var logicet = new CapturingLogicet(proj, cts.Cancel); + proj.Logicets.Add(logicet); + + // Act + try + { + await proj.RunAsync(cts.Token); + } + catch (OperationCanceledException) + { + // 取消后轮询循环以 OCE 退出,这是当前设计(见 TagGrpRunnerTests.RunUntilCancelled)。 + } + + // Assert + Assert.True(logicet.CapturedToken.CanBeCanceled, "ProcessAsync 应收到项目的生命周期令牌"); + Assert.Equal(cts.Token, logicet.CapturedToken); + } +} diff --git a/tests/StdUnit.Tags.Tests/Core/Logicets/LogicetPluginLifecycleTests.cs b/tests/StdUnit.Tags.Tests/Core/Logicets/LogicetPluginLifecycleTests.cs index 9f25c33..47182a2 100644 --- a/tests/StdUnit.Tags.Tests/Core/Logicets/LogicetPluginLifecycleTests.cs +++ b/tests/StdUnit.Tags.Tests/Core/Logicets/LogicetPluginLifecycleTests.cs @@ -147,7 +147,7 @@ public override bool MatchEntry(ITagGrp entry) return entry.TagName() == "g1"; } - public override Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel) + public override Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel, CancellationToken ct) { return Task.CompletedTask; } diff --git a/tests/StdUnit.Tags.Tests/Core/Logicets/ProjectAddLogicetTests.cs b/tests/StdUnit.Tags.Tests/Core/Logicets/ProjectAddLogicetTests.cs index 064f857..e9b18d9 100644 --- a/tests/StdUnit.Tags.Tests/Core/Logicets/ProjectAddLogicetTests.cs +++ b/tests/StdUnit.Tags.Tests/Core/Logicets/ProjectAddLogicetTests.cs @@ -35,7 +35,7 @@ public SimpleLogicet(ITagsProject project) } public bool MatchEntry(ITagGrp entry) => true; - public Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel) => Task.CompletedTask; + public Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel, CancellationToken ct) => Task.CompletedTask; } /// @@ -55,7 +55,7 @@ public ThrowingLogicet(ITagsProject project) } public bool MatchEntry(ITagGrp entry) => false; - public Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel) => Task.CompletedTask; + public Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel, CancellationToken ct) => Task.CompletedTask; } /// @@ -167,7 +167,7 @@ public ProbeLogicet(ITagsProject project) : base(project) { } public override bool MatchEntry(ITagGrp entry) => false; - public override Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel) => Task.CompletedTask; + public override Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel, CancellationToken ct) => Task.CompletedTask; } [Fact]