Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@

本文件记录**用户可见**的变化。版本号遵循[语义化版本](https://semver.org/lang/zh-CN/) `<major>.<minor>.<patch>`。

## 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` 正式版保持未发布。
Expand Down Expand Up @@ -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` 起可用。

Expand Down
1 change: 1 addition & 0 deletions docs/设计决策与边界/0. 设计决策与边界.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ API 细节见教程站点(<http://tags.doc.stdunit.com>)。
### 线程模型与伸缩

- 循环内的回调(`ILogicet.ProcessAsync`、`TagGrpWriteIntent`)**必须尽快返回**:不要 `.Result` / `.Wait()` / `Thread.Sleep`,也不要写成"等待外部事件"的形态,否则与 `WriteIntent` 构成环路等待死锁(见[外部意图](../外部意图.md))。
- 这两个回调拿到的 `ct` 是**项目生命周期令牌**(`StopAsync` 时取消),**不是**单轮超时。停机时想尽快收手就用 `if (ct.IsCancellationRequested) return;`;**不要**抛 `OperationCanceledException`——轮询器会把它当成一次崩溃(触发 `RunnerCrashed` 并按策略断开重连)。也不能借它把长任务搬进回调。
- 底层是同步实现的驱动(S7 / ComScanner)会实际占住线程;入口数量上到几十个时请评估线程池与设备吞吐。当前**不设并行度上限,也没有调度器扩展点**。

不过这两点并不是什么实际问题。在工厂,有3种使用场景:
Expand Down
2 changes: 1 addition & 1 deletion samples/Samples.Plugin1/Handle1Snap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>();
var hasAck = _outLedRed.GetTagValue<bool>();
Expand Down
2 changes: 1 addition & 1 deletion samples/StdUnit.Tags.Web/Logicets/HandleSnap11.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>();
var hasAck = _ackTag.GetTagValue<bool?>() == true;
Expand Down
2 changes: 1 addition & 1 deletion samples/StdUnit.Tags.Web/Logicets/HandleSnap12.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>();
var red = this._ledRed.GetTagValue<bool>();
Expand Down
2 changes: 1 addition & 1 deletion samples/StdUnit.Tags.Web/Logicets/HandleSnap13.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>();
var red = this._ledRed.GetTagValue<bool>();
Expand Down
2 changes: 1 addition & 1 deletion samples/StdUnit.Tags.Web/Logicets/HandleSnap14.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion samples/WpfDemo/Tags/Logicets/HeartBeatLogicet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public HeartBeatLogicet(ITagsProject project, ILogger<HeartBeatLogicet> 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;

Expand Down
11 changes: 10 additions & 1 deletion src/StdUnit.Tags.Core/Plugins/ILogicet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ public interface ILogicet
/// <summary>
/// 处理
/// </summary>
/// <param name="entry">当前入口测点组</param>
/// <param name="thisChannel">入口的<b>主通道</b>(<see cref="ITagGrpExtensions.SearchChannel"/> 的解析结果,可能为 null)</param>
/// <param name="ct">
/// 项目的<b>生命周期</b>令牌:项目停止(<see cref="ITagsProjectCtrl.StopAsync"/>)时取消。<br/>
/// 它<b>不是</b>"单轮超时"令牌——框架不会为某一轮单独取消,同一入口每一轮拿到的都是同一个令牌。<br/>
/// 建议用 <c>if (ct.IsCancellationRequested) return;</c> 提前结束本轮;<b>不要</b>在这里抛
/// <see cref="OperationCanceledException"/>,它会被运行器当成一次失败(触发 <see cref="ITagsProject.RunnerCrashed"/> 并按策略断开重连)。<br/>
/// 不要缓存它、也不要每轮 <c>Register</c>;它也不能让本方法变成"可以慢慢跑"——本方法仍必须尽快返回。
/// </param>
/// <returns></returns>
Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel);
Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel, CancellationToken ct = default);
}
2 changes: 1 addition & 1 deletion src/StdUnit.Tags.Core/Plugins/LogicetBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,5 @@ public LogicetBase(ITagsProject project)
public virtual bool Enabled { get; set; } = true;

/// <inheritdoc/>
public abstract Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel);
public abstract Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel, CancellationToken ct = default);
}
2 changes: 1 addition & 1 deletion src/StdUnit.Tags.Core/Projects/TagsProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion tests/AotSmokeTest.LogicetPlugins/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
95 changes: 95 additions & 0 deletions tests/StdUnit.Tags.Tests/Core/Logicets/LogicetCancellationTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 验证 <see cref="ITagsProject.RunAsync(CancellationToken)"/> 把项目的生命周期令牌透传给
/// <see cref="ILogicet.ProcessAsync(ITagGrp, ITagChannel?, CancellationToken)"/>。
/// </summary>
public class LogicetCancellationTests
{
private readonly ServiceProvider _root;

public LogicetCancellationTests()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddTagsProjectServices(b =>
{
b.AddFakedSupport();
});

_root = services.BuildServiceProvider();
}

/// <summary>
/// 记录收到的令牌,并在本次处理中触发取消,让轮询循环尽快退出。
/// </summary>
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<ITagsProjectFactory>();

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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/// <summary>
Expand All @@ -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;
}

/// <summary>
Expand Down Expand Up @@ -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]
Expand Down
Loading