refactor!: rename the project from Monitor to MoShiro [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
Namespaces, assemblies, solution, binaries, images, systemd units, config paths, environment variables, protocol methods and the Linux user are all renamed. Breaking: existing databases, node identities and installed units must be recreated.
This commit is contained in:
1 parent
27ac7fb787
commit
2e146d4da7
309 files changed
+1565
-1572
No files matched your search
@@ -0,0 +1,248 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>
|
||||
/// 数据面会话:<c>session_id</c> 和密钥(由点对点握手里的 ECDH 派生)。两端的数据面包都用它认证。
|
||||
/// Agent 的会话还带重放窗口等状态,所以是可以继承的。
|
||||
/// </summary>
|
||||
public class DataSession
|
||||
{
|
||||
public DataSession(long sessionId, byte[] key)
|
||||
{
|
||||
SessionId = sessionId;
|
||||
Key = key;
|
||||
}
|
||||
|
||||
public long SessionId { get; }
|
||||
|
||||
public byte[] Key { get; }
|
||||
|
||||
/// <summary>对端最近一次通过认证的 UDP 源地址(监听方把单向数据发往这里;拨号方不需要)。</summary>
|
||||
public virtual IPEndPoint? UdpEndpoint => null;
|
||||
|
||||
/// <summary>TCP 首帧的随机数只能用一次。拨号方不接受连接,所以默认放行。</summary>
|
||||
public virtual bool UseNonce(ReadOnlySpan<byte> nonce) => true;
|
||||
}
|
||||
|
||||
public interface IDataSessions
|
||||
{
|
||||
DataSession? Find(long sessionId);
|
||||
}
|
||||
|
||||
/// <summary>只有一个会话(拨号方:Probe 与某个 Agent 的会话)。</summary>
|
||||
public sealed class SingleSession : IDataSessions
|
||||
{
|
||||
private readonly DataSession _session;
|
||||
|
||||
public SingleSession(DataSession session)
|
||||
{
|
||||
_session = session;
|
||||
}
|
||||
|
||||
public DataSession? Find(long sessionId) => sessionId == _session.SessionId ? _session : null;
|
||||
}
|
||||
|
||||
public enum Admission
|
||||
{
|
||||
Accepted,
|
||||
Invalid,
|
||||
RateLimited,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 监听方的准入策略(安全边界):重放窗口、令牌桶限速、源地址学习。积木本身不含任何策略;
|
||||
/// 拨号方不需要,只做 MAC 校验。任何一步失败都是静默丢弃并计数,不回复。
|
||||
/// </summary>
|
||||
public interface IAdmission
|
||||
{
|
||||
/// <summary>UDP 包:<paramref name="source"/> 是认证通过的源地址(实现可以据此学习 NAT 重绑定)。</summary>
|
||||
Admission AdmitPacket(DataSession session, in UdpHeader header, SocketAddress source, long nowUs);
|
||||
|
||||
/// <summary>TCP 回显的一条消息(只限速)。</summary>
|
||||
bool AdmitMessage(DataSession session, uint streamId, long nowUs);
|
||||
}
|
||||
|
||||
/// <summary>数据面计数(只有计数,没有主机信息):用于心跳和面板上的节点状态。</summary>
|
||||
public sealed class DataPlaneCounters
|
||||
{
|
||||
private long _packetsIn;
|
||||
private long _packetsOut;
|
||||
private long _invalid;
|
||||
private long _rateLimited;
|
||||
|
||||
public long PacketsIn => Interlocked.Read(ref _packetsIn);
|
||||
|
||||
public long PacketsOut => Interlocked.Read(ref _packetsOut);
|
||||
|
||||
public long DroppedInvalid => Interlocked.Read(ref _invalid);
|
||||
|
||||
public long DroppedRateLimited => Interlocked.Read(ref _rateLimited);
|
||||
|
||||
public void PacketIn() => Interlocked.Increment(ref _packetsIn);
|
||||
|
||||
public void PacketOut() => Interlocked.Increment(ref _packetsOut);
|
||||
|
||||
public void DropInvalid() => Interlocked.Increment(ref _invalid);
|
||||
|
||||
public void DropRateLimited() => Interlocked.Increment(ref _rateLimited);
|
||||
}
|
||||
|
||||
/// <summary>一个列块属于线路任务还是按需测试的某个阶段。</summary>
|
||||
public sealed record FragmentScope(TaskId? Task, TestId? Test, int? Phase);
|
||||
|
||||
/// <summary>
|
||||
/// 积木交出一侧的行的地方。Probe:放进发给 Agent 的发件箱(列块);Agent 自己运行的积木(目标任务):直接盖章上传。
|
||||
/// </summary>
|
||||
public interface IFragmentSink
|
||||
{
|
||||
void Ship(string kind, FragmentScope scope, IReadOnlyList<RawStream> streams, IReadOnlyDictionary<string, JsonElement>? parameters, long startUtcMs, int durationMs, int port);
|
||||
}
|
||||
|
||||
public enum FlushMode
|
||||
{
|
||||
/// <summary>只交出已经稳定的行(回显类等过宽限期的)。</summary>
|
||||
Settled,
|
||||
|
||||
/// <summary>交出全部(阶段结束或角色停止)。</summary>
|
||||
All,
|
||||
}
|
||||
|
||||
/// <summary>积木无法继续(例如监听方等不到对端的地址):由编排者决定整个测试怎么结束。</summary>
|
||||
public sealed class BlockFaultException : Exception
|
||||
{
|
||||
public BlockFaultException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 一个运行中的积木角色。同一种采集方法的两端是一对角色(拨号方 = Probe,监听方 = Agent),写在同一个文件里,
|
||||
/// 共用包的构造、校验、行的记录;它们只产生<b>原始列</b>,不做任何统计,也不知道上层的方案或协议。
|
||||
/// </summary>
|
||||
public interface IBlock
|
||||
{
|
||||
/// <summary>运行到取消(回显、保持)或到自己结束(单向数据流的发送端)。</summary>
|
||||
Task RunAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// 拨号方把已完成的行切成列块交给 <see cref="IFragmentSink"/>,返回块数;监听方返回 0(它的行留在 <see cref="Records"/> 里等合并)。
|
||||
/// <paramref name="peerProduced"/>:对端是行的主导方时,对端产生的行数(单向数据流的发送包数)。
|
||||
/// </summary>
|
||||
int Flush(FlushMode mode, long peerProduced = 0);
|
||||
|
||||
/// <summary>这一侧作为主导方产生的行数(发出的包数)。</summary>
|
||||
long Produced { get; }
|
||||
|
||||
/// <summary>这一侧为合并保留的按键记录;没有则为 null。</summary>
|
||||
RowStore? Records(string streamName);
|
||||
}
|
||||
|
||||
/// <summary>运行一个积木所需的全部参数:Kind、已经校验并补全默认值的参数、流编号、端口、列块归属。</summary>
|
||||
public sealed class BlockSpec
|
||||
{
|
||||
public BlockSpec(KindSpec kind, ParamValues parameters, uint streamId, int port, FragmentScope scope)
|
||||
{
|
||||
Kind = kind;
|
||||
Params = parameters;
|
||||
StreamId = streamId;
|
||||
Port = port;
|
||||
Scope = scope;
|
||||
}
|
||||
|
||||
public KindSpec Kind { get; }
|
||||
|
||||
public ParamValues Params { get; }
|
||||
|
||||
public uint StreamId { get; }
|
||||
|
||||
public int Port { get; }
|
||||
|
||||
public FragmentScope Scope { get; }
|
||||
|
||||
public string KindId => Kind.Id;
|
||||
|
||||
public long Int(string name) => Params.GetInt(name);
|
||||
|
||||
public bool Bool(string name) => Params.GetBool(name);
|
||||
|
||||
/// <summary>参数或端口变化时调和器重启这个积木(不含流编号:Agent 的流编号每次重建都会重新分配)。</summary>
|
||||
public string ConfigSignature => KindId + "|" + Port + "|" +
|
||||
string.Join(',', Params.Values.OrderBy(p => p.Key, StringComparer.Ordinal).Select(p => p.Key + "=" + p.Value.GetRawText()));
|
||||
|
||||
/// <summary>参数、端口或流编号变化时重启:Probe 据此判断 Agent 下发的任务有没有变(Agent 重启后流编号会变)。</summary>
|
||||
public string Signature => StreamId + "|" + ConfigSignature;
|
||||
|
||||
/// <summary>校验参数(补全默认值)后构造;Kind 不存在或参数非法返回 null。</summary>
|
||||
public static BlockSpec? Create(string kindId, IReadOnlyDictionary<string, JsonElement> parameters, uint streamId, int port, FragmentScope scope)
|
||||
{
|
||||
KindSpec? kind = Kinds.Find(kindId);
|
||||
if (kind is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ParamResolution resolved = ParamResolver.Resolve(kind.Params, parameters);
|
||||
return resolved.IsValid ? new BlockSpec(kind, resolved.Values!, streamId, port, scope) : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>积木运行的环境:时钟、套接字、对端地址、列块出口。拨号方和监听方各自组装自己用得到的部分。</summary>
|
||||
public sealed class BlockEnv
|
||||
{
|
||||
public required MonoClock Clock { get; init; }
|
||||
|
||||
public required INodeLog Log { get; init; }
|
||||
|
||||
/// <summary>当前的数据面会话(拨号方固定;监听方是 Agent 当前的 Probe 会话,可能没有)。</summary>
|
||||
public required Func<DataSession?> Session { get; init; }
|
||||
|
||||
public UdpMux? Udp { get; init; }
|
||||
|
||||
/// <summary>监听方的准入策略(限速等);拨号方为 null。</summary>
|
||||
public IAdmission? Admission { get; init; }
|
||||
|
||||
public DataPlaneCounters Counters { get; init; } = new();
|
||||
|
||||
/// <summary>监听方:接受 TCP 数据连接。</summary>
|
||||
public TcpAcceptor? TcpListen { get; init; }
|
||||
|
||||
/// <summary>拨号方:对端(Agent,或目标任务的目标地址)。</summary>
|
||||
public IPAddress? PeerAddress { get; init; }
|
||||
|
||||
/// <summary>拨号方:对端 TLS 证书的 SPKI 指纹;null 表示不校验证书(目标任务,目标是任意地址)。</summary>
|
||||
public byte[]? PeerSpkiSha256 { get; init; }
|
||||
|
||||
/// <summary>拨号方:列块出口。</summary>
|
||||
public IFragmentSink? Fragments { get; init; }
|
||||
|
||||
/// <summary>本地硬上限:批量传输(tcp_bulk)发送端的码率(Mbit/s)。批量传输会尽可能占满链路,这是防止把小机器打满的保险。</summary>
|
||||
public double MaxBulkMbps { get; init; } = 100;
|
||||
|
||||
/// <summary>本地硬上限:单向数据流接收缓冲的包速率上界。</summary>
|
||||
public long MaxPacketsPerSecond { get; init; } = 20_000;
|
||||
|
||||
public TimeProvider Time => Clock.Time;
|
||||
|
||||
/// <summary>同一套环境,但绑定到一个固定的数据面会话(Agent 为每个 Probe 会话各起一组监听方积木)。</summary>
|
||||
public BlockEnv WithSession(DataSession session) =>
|
||||
new()
|
||||
{
|
||||
Clock = Clock,
|
||||
Log = Log,
|
||||
Session = () => session,
|
||||
Udp = Udp,
|
||||
Admission = Admission,
|
||||
Counters = Counters,
|
||||
TcpListen = TcpListen,
|
||||
PeerAddress = PeerAddress,
|
||||
PeerSpkiSha256 = PeerSpkiSha256,
|
||||
Fragments = Fragments,
|
||||
MaxBulkMbps = MaxBulkMbps,
|
||||
MaxPacketsPerSecond = MaxPacketsPerSecond,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>节点的单调时钟(微秒):t1…t4、t_send、t_recv 都用它;Probe 和 Agent 各自一个,块头里的时钟锚点把它对应到 UTC。</summary>
|
||||
public sealed class MonoClock
|
||||
{
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public MonoClock(TimeProvider time)
|
||||
{
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public TimeProvider Time => _time;
|
||||
|
||||
public long NowUs()
|
||||
{
|
||||
long ticks = _time.GetTimestamp();
|
||||
// 先除后乘会丢精度,先乘再除在 64 位内不会溢出(频率通常是 1e9 或 1e7)
|
||||
return (ticks / _time.TimestampFrequency * 1_000_000) + (ticks % _time.TimestampFrequency * 1_000_000 / _time.TimestampFrequency);
|
||||
}
|
||||
|
||||
public ClockAnchor Anchor() => new(NowUs(), _time.GetUtcNow().ToUnixTimeMilliseconds());
|
||||
|
||||
/// <summary>单调时钟读数 <paramref name="thenUs"/> 对应的 UTC 毫秒(只用于块的粗略起点,精确时间由块头的时钟锚点换算)。</summary>
|
||||
public long UtcMsAt(long thenUs) => _time.GetUtcNow().ToUnixTimeMilliseconds() - (Math.Max(0, NowUs() - thenUs) / 1000);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>
|
||||
/// 一侧记录的行:只含这一侧拥有的列(见 <see cref="StreamSpec.ColumnsOf"/>),按连接键(通常是 <c>seq</c>)索引的环形缓冲,
|
||||
/// 每列一个 long 数组,热路径不分配。Agent 用它记录 t2/t3 或发送行,等 Probe 的列块到达后按键合并;
|
||||
/// Probe 用它记录 t1/t4 或 t_recv,到点切成列块交出去。行被后来的键覆盖(环绕)就丢弃,这只会发生在一侧长时间没有交出行的时候。
|
||||
/// </summary>
|
||||
public sealed class RowStore
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly StreamSpec _spec;
|
||||
private readonly IReadOnlyList<ColumnSpec> _columns;
|
||||
private readonly ColumnSpec? _keyColumn;
|
||||
private readonly int _capacity;
|
||||
private readonly long[] _keys;
|
||||
private readonly long[][] _values;
|
||||
private readonly bool[]?[] _valid;
|
||||
private long _next;
|
||||
private long _drained;
|
||||
|
||||
public RowStore(StreamSpec spec, Side owner, int capacity)
|
||||
{
|
||||
_spec = spec;
|
||||
_columns = spec.ColumnsOf(owner);
|
||||
_keyColumn = spec.Key is null ? null : spec.Columns.First(c => c.Name == spec.Key);
|
||||
_capacity = Math.Max(16, capacity);
|
||||
_keys = new long[_capacity];
|
||||
Array.Fill(_keys, -1);
|
||||
_values = new long[_columns.Count][];
|
||||
_valid = new bool[]?[_columns.Count];
|
||||
for (int i = 0; i < _columns.Count; i++)
|
||||
{
|
||||
_values[i] = new long[_capacity];
|
||||
_valid[i] = _columns[i].Nullable ? new bool[_capacity] : null;
|
||||
}
|
||||
}
|
||||
|
||||
public string Name => _spec.Name;
|
||||
|
||||
/// <summary>下一个自动分配的键(也是已经追加的行数)。</summary>
|
||||
public long NextKey
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int ColumnIndex(string name)
|
||||
{
|
||||
for (int i = 0; i < _columns.Count; i++)
|
||||
{
|
||||
if (_columns[i].Name == name)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ArgumentException($"column {name} is not recorded on this side of stream {_spec.Name}", nameof(name));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清空所有行。一个新的数据面会话开始时,监听方必须清掉上一个会话留下的记录:Probe 重启之后序号从 0 重新开始,
|
||||
/// 旧会话里同一个序号的 t2/t3 还留着的话,会被当成新会话里这个包"上行已经到达",真正的上行丢失就被算成了下行丢失。
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Array.Fill(_keys, -1);
|
||||
_next = 0;
|
||||
_drained = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>取出上次取出之后追加的所有行(只用于两侧各自记录完整行的 union 流:每一行只交出一次)。</summary>
|
||||
public RawStream Drain(bool includeKey)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
RawStream rows = Cut(_drained, _next, includeKey);
|
||||
_drained = _next;
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>追加一行,键自动递增(从 0 开始,与 <c>seq</c> 一致)。</summary>
|
||||
public long Append()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
long key = _next++;
|
||||
Claim(key);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
public void Set(long key, int column, long value)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
int slot = Claim(key);
|
||||
_values[column][slot] = value;
|
||||
if (_valid[column] is { } valid)
|
||||
{
|
||||
valid[slot] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>一次写入这一侧的所有列(顺序同 <see cref="StreamSpec.ColumnsOf"/>)。</summary>
|
||||
public void SetRow(long key, ReadOnlySpan<long> values)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
int slot = Claim(key);
|
||||
for (int i = 0; i < _columns.Count; i++)
|
||||
{
|
||||
_values[i][slot] = values[i];
|
||||
if (_valid[i] is { } valid)
|
||||
{
|
||||
valid[slot] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool Contains(long key)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return key >= 0 && _keys[(int)(key % _capacity)] == key;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>读一个值;行不存在或该列没有设置(可空列)返回 false。</summary>
|
||||
public bool TryGet(long key, int column, out long value)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
int slot = (int)(key % _capacity);
|
||||
value = 0;
|
||||
if (key < 0 || _keys[slot] != key || (_valid[column] is { } valid && !valid[slot]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = _values[column][slot];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsSet(long key, int column) => TryGet(key, column, out _);
|
||||
|
||||
/// <summary>
|
||||
/// 取出键在 [<paramref name="fromKey"/>, <paramref name="toKey"/>) 内、仍然存在的行(按键升序),不删除。
|
||||
/// 列按声明顺序排列;<paramref name="includeKey"/> 时带上连接键那一列。
|
||||
/// </summary>
|
||||
public RawStream Cut(long fromKey, long toKey, bool includeKey)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var slots = new List<int>();
|
||||
var keys = new List<long>();
|
||||
for (long key = Math.Max(0, fromKey); key < toKey; key++)
|
||||
{
|
||||
int slot = (int)(key % _capacity);
|
||||
if (_keys[slot] == key)
|
||||
{
|
||||
slots.Add(slot);
|
||||
keys.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
var columns = new List<RawColumn>();
|
||||
foreach (ColumnSpec spec in _spec.Columns)
|
||||
{
|
||||
if (includeKey && _keyColumn is not null && spec.Name == _keyColumn.Name)
|
||||
{
|
||||
columns.Add(new RawColumn(spec.Name, spec.Type, spec.Enc, spec.Nullable, spec.Role, [.. keys], null));
|
||||
continue;
|
||||
}
|
||||
|
||||
int index = -1;
|
||||
for (int i = 0; i < _columns.Count; i++)
|
||||
{
|
||||
if (_columns[i].Name == spec.Name)
|
||||
{
|
||||
index = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (index < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
long[] values = new long[slots.Count];
|
||||
bool[]? valid = spec.Nullable ? new bool[slots.Count] : null;
|
||||
for (int r = 0; r < slots.Count; r++)
|
||||
{
|
||||
values[r] = _values[index][slots[r]];
|
||||
if (valid is not null)
|
||||
{
|
||||
valid[r] = _valid[index]![slots[r]];
|
||||
}
|
||||
}
|
||||
|
||||
columns.Add(new RawColumn(spec.Name, spec.Type, spec.Enc, spec.Nullable, spec.Role, values, valid));
|
||||
}
|
||||
|
||||
return new RawStream(_spec.Name, slots.Count, columns);
|
||||
}
|
||||
}
|
||||
|
||||
private int Claim(long key)
|
||||
{
|
||||
int slot = (int)(key % _capacity);
|
||||
if (_keys[slot] != key)
|
||||
{
|
||||
_keys[slot] = key;
|
||||
for (int i = 0; i < _columns.Count; i++)
|
||||
{
|
||||
_values[i][slot] = 0;
|
||||
if (_valid[i] is { } valid)
|
||||
{
|
||||
valid[slot] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return slot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>一个运行中的积木:自己的取消令牌和后台任务。Probe 和 Agent 共用。</summary>
|
||||
public sealed class RunningBlock : IDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
private RunningBlock(IBlock block, string signature)
|
||||
{
|
||||
Block = block;
|
||||
Signature = signature;
|
||||
Completion = Task.CompletedTask;
|
||||
}
|
||||
|
||||
public IBlock Block { get; }
|
||||
|
||||
/// <summary>参数、端口或流编号变化时调和器据此重启积木。</summary>
|
||||
public string Signature { get; }
|
||||
|
||||
/// <summary>积木自己结束(单向数据流的发送端)或失败(<see cref="BlockFaultException"/>)时完成;被 <see cref="StopAsync"/> 停止时正常完成。</summary>
|
||||
public Task Completion { get; private set; }
|
||||
|
||||
public static RunningBlock Start(IBlock block, string signature)
|
||||
{
|
||||
var running = new RunningBlock(block, signature);
|
||||
running.Completion = Task.Run(() => block.RunAsync(running._cts.Token), CancellationToken.None);
|
||||
return running;
|
||||
}
|
||||
|
||||
/// <summary>停止积木并等它退出(最多 5 秒)。</summary>
|
||||
public async Task StopAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
try
|
||||
{
|
||||
await Completion.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException or BlockFaultException)
|
||||
{
|
||||
// 没有及时退出或者已经失败:不再等
|
||||
}
|
||||
|
||||
Dispose();
|
||||
}
|
||||
|
||||
public void Dispose() => _cts.Dispose();
|
||||
}
|
||||
Reference in new issue
Block a user