From 5822285a8841df294858eed5db995d604c0c884d Mon Sep 17 00:00:00 2001 From: justme Date: Thu, 22 Oct 2026 05:28:29 +0800 Subject: [PATCH] fleet: require a minimum version of the peer at the handshake [CI] MoShiro__MinAgentVersion makes the backend refuse agents below it at hello (fleet_version_required); fleet.min_server_version or MOSHIRO_MIN_SERVER_VERSION makes the agent drop a backend below it. Either side waits five seconds and shakes hands again, because the other one may be updating. --- backend/agent/src/MoShiro.Agent/AgentApp.cs | 1 + .../agent/src/MoShiro.Agent/AgentConfig.cs | 4 + backend/fleet/src/Fleet.Core/ErrorCode.cs | 3 + backend/fleet/src/Fleet.Core/NodeVersions.cs | 87 +++++++++++++++++++ backend/fleet/src/Fleet.Link/FleetLink.cs | 24 ++++- backend/fleet/src/Fleet.Rpc/FleetMethods.cs | 4 + .../FleetServerOptions.cs | 9 ++ .../src/Fleet.Server.Link/FleetLinkHost.cs | 6 +- .../fleet/src/Fleet.Server.Link/FleetLog.cs | 3 + .../Fleet.Server.Link/FleetServerBuilder.cs | 3 +- .../NodeConnection.Receive.cs | 16 +++- .../tests/Fleet.Tests/NodeVersionsTests.cs | 21 +++++ .../BackendOptions.cs | 3 + .../GatewayServiceCollectionExtensions.cs | 9 ++ .../MoShiro.Backend.Tests/AgentHarness.cs | 8 +- .../VersionRequirementTests.cs | 72 +++++++++++++++ 16 files changed, 266 insertions(+), 7 deletions(-) create mode 100644 backend/fleet/src/Fleet.Core/NodeVersions.cs create mode 100644 backend/fleet/tests/Fleet.Tests/NodeVersionsTests.cs create mode 100644 backend/tests/MoShiro.Backend.Tests/VersionRequirementTests.cs diff --git a/backend/agent/src/MoShiro.Agent/AgentApp.cs b/backend/agent/src/MoShiro.Agent/AgentApp.cs index 614de02..fa8fc38 100644 --- a/backend/agent/src/MoShiro.Agent/AgentApp.cs +++ b/backend/agent/src/MoShiro.Agent/AgentApp.cs @@ -353,6 +353,7 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable ServerVerifier = _trust, NodeId = _identity.NodeId, NodeVersion = Version, + MinServerVersion = () => Environment.GetEnvironmentVariable("MOSHIRO_MIN_SERVER_VERSION") is { Length: > 0 } required ? required : _config.Fleet.MinServerVersion, Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', _listening ? BlockCatalog.AgentKinds : BlockCatalog.TargetKinds)], HelloMeta = HelloMeta, HeartbeatExtra = HeartbeatExtra, diff --git a/backend/agent/src/MoShiro.Agent/AgentConfig.cs b/backend/agent/src/MoShiro.Agent/AgentConfig.cs index 129acb3..16d4447 100644 --- a/backend/agent/src/MoShiro.Agent/AgentConfig.cs +++ b/backend/agent/src/MoShiro.Agent/AgentConfig.cs @@ -40,6 +40,10 @@ public sealed class FleetSection /// 节点自己要求有属主(passkey):没有就不运行。见 OwnerGuard。 [JsonPropertyName("require_owner")] public bool RequireOwner { get; set; } = true; + + /// 面板至少要是哪个版本才继续连接(如 0.4.2-dev.80);空 = 不要求。不够就不发任何东西,5 秒后重新握手(面板可能正在更新)。环境变量 MOSHIRO_MIN_SERVER_VERSION 优先。 + [JsonPropertyName("min_server_version")] + public string? MinServerVersion { get; set; } } public sealed class ListenSection diff --git a/backend/fleet/src/Fleet.Core/ErrorCode.cs b/backend/fleet/src/Fleet.Core/ErrorCode.cs index b80e184..b58337e 100644 --- a/backend/fleet/src/Fleet.Core/ErrorCode.cs +++ b/backend/fleet/src/Fleet.Core/ErrorCode.cs @@ -27,4 +27,7 @@ public static class FleetErrors public static readonly ErrorCode JoinTokenExpired = new("fleet_join_token_expired"); public static readonly ErrorCode ProofInvalid = new("fleet_proof_invalid"); public static readonly ErrorCode FrameTooLarge = new("fleet_frame_too_large"); + + /// 对方的版本低于要求的最低版本:等一会儿(对方可能正在更新)再握手。 + public static readonly ErrorCode VersionRequired = new("fleet_version_required"); } diff --git a/backend/fleet/src/Fleet.Core/NodeVersions.cs b/backend/fleet/src/Fleet.Core/NodeVersions.cs new file mode 100644 index 0000000..2e17895 --- /dev/null +++ b/backend/fleet/src/Fleet.Core/NodeVersions.cs @@ -0,0 +1,87 @@ +using System.Globalization; + +namespace Fleet.Core; + +/// +/// 版本号比较,给「要求的最低版本」用。认得 主.次.修订,可以带预发布后缀;开发构建 0.4.2-dev.73-5245f03 按 dev.73 的序号比较, +/// 提交哈希不参与比较。同一个主次修订里,正式版高于任何预发布版。认不出来的版本号不拒绝(返回 false),免得一个奇怪的版本字符串把节点挡在门外。 +/// +public static class NodeVersions +{ + private readonly record struct Parsed(int Major, int Minor, int Patch, bool Prerelease, long Dev, string Suffix); + + /// 低于 返回 true;任何一个为空或无法解析返回 false。 + public static bool IsBelow(string? actual, string? required) + { + if (string.IsNullOrWhiteSpace(required) || string.IsNullOrWhiteSpace(actual)) + { + return false; + } + + if (!TryParse(actual, out Parsed have) || !TryParse(required, out Parsed need)) + { + return false; + } + + return Compare(have, need) < 0; + } + + private static int Compare(Parsed a, Parsed b) + { + int core = (a.Major, a.Minor, a.Patch).CompareTo((b.Major, b.Minor, b.Patch)); + if (core != 0) + { + return core; + } + + if (a.Prerelease != b.Prerelease) + { + return a.Prerelease ? -1 : 1; // 正式版高于预发布版 + } + + if (!a.Prerelease) + { + return 0; + } + + int dev = a.Dev.CompareTo(b.Dev); + return dev != 0 ? dev : 0; + } + + private static bool TryParse(string text, out Parsed parsed) + { + parsed = default; + string value = text.Trim().TrimStart('v'); + int plus = value.IndexOf('+', StringComparison.Ordinal); + if (plus >= 0) + { + value = value[..plus]; + } + + int dash = value.IndexOf('-', StringComparison.Ordinal); + string core = dash < 0 ? value : value[..dash]; + string suffix = dash < 0 ? "" : value[(dash + 1)..]; + string[] parts = core.Split('.'); + if (parts.Length != 3 + || !int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out int major) + || !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out int minor) + || !int.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out int patch)) + { + return false; + } + + long dev = 0; + if (suffix.StartsWith("dev.", StringComparison.Ordinal)) + { + string number = suffix[4..]; + int end = number.IndexOf('-', StringComparison.Ordinal); + if (!long.TryParse(end < 0 ? number : number[..end], NumberStyles.None, CultureInfo.InvariantCulture, out dev)) + { + return false; + } + } + + parsed = new Parsed(major, minor, patch, suffix.Length > 0, dev, suffix); + return true; + } +} diff --git a/backend/fleet/src/Fleet.Link/FleetLink.cs b/backend/fleet/src/Fleet.Link/FleetLink.cs index 8b61b19..d6c8248 100644 --- a/backend/fleet/src/Fleet.Link/FleetLink.cs +++ b/backend/fleet/src/Fleet.Link/FleetLink.cs @@ -52,6 +52,9 @@ public sealed class FleetLinkOptions public string NodeVersion { get; init; } = "0.0.0"; + /// 服务端至少要是哪个版本(每次握手重新取);空表示不要求。不够就关掉这条连接,等 再握手,服务端可能正在更新。 + public Func? MinServerVersion { get; init; } + /// 框架之外的能力,例如 moshiro.v1 与 moshiro.kinds:udp_echo,tcp_echo。 public IReadOnlyList Capabilities { get; init; } = []; @@ -100,7 +103,11 @@ public sealed class FleetLink private volatile RpcSession? _session; private volatile bool _stopped; + /// 版本不够时重新握手的间隔:固定,不退避(对方更新完马上就能连上)。 + public static readonly TimeSpan VersionRetry = TimeSpan.FromSeconds(5); + private static readonly TimeSpan s_tcpTimeout = TimeSpan.FromSeconds(4); + private TimeSpan? _fixedRetry; private int _failedConnects; public FleetLink(FleetLinkOptions options, RpcRouter router, IEnumerable handlers, Outbox outbox, TimeProvider time, INodeLog log) @@ -166,7 +173,8 @@ public sealed class FleetLink return; } - TimeSpan delay = _options.Backoff.Next(); + TimeSpan delay = _fixedRetry ?? _options.Backoff.Next(); + _fixedRetry = null; _log.Warn("link down, retrying", ("err", reason), ("retry_ms", (long)delay.TotalMilliseconds)); await Task.Delay(delay, _time, ct); } @@ -263,7 +271,19 @@ public sealed class FleetLink Result helloResult = await session.RequestAsync(FleetMethods.Hello, hello, FleetRpcContext.Default.HelloParams, FleetRpcContext.Default.HelloResult, ct); if (!helloResult.IsSuccess) { - return "hello failed: " + helloResult.Error.Code; + if (helloResult.Error.Code == FleetErrors.VersionRequired) + { + _fixedRetry = VersionRetry; // 我们的版本低于服务端要求的:等自己更新,每 5 秒再试一次 + } + + return "hello failed: " + helloResult.Error.Code + (_fixedRetry is null ? "" : ": " + helloResult.Error.Message); + } + + string? requiredServer = _options.MinServerVersion?.Invoke(); + if (NodeVersions.IsBelow(helloResult.Value.ServerVersion, requiredServer)) + { + _fixedRetry = VersionRetry; // 服务端版本低于我们要求的(它可能正在更新):不发任何东西,5 秒后重新握手 + return $"server version {helloResult.Value.ServerVersion} is below the required {requiredServer}"; } Outbox.ResetSent(); diff --git a/backend/fleet/src/Fleet.Rpc/FleetMethods.cs b/backend/fleet/src/Fleet.Rpc/FleetMethods.cs index 4a37c5b..7acc08c 100644 --- a/backend/fleet/src/Fleet.Rpc/FleetMethods.cs +++ b/backend/fleet/src/Fleet.Rpc/FleetMethods.cs @@ -104,6 +104,10 @@ public sealed class HelloResult /// 节点时钟 − 服务端时钟(毫秒,未扣除网络往返)。 [JsonPropertyName("clock_offset_ms")] public long ClockOffsetMs { get; set; } + + /// 服务端的版本:节点要求了最低服务端版本时,用它判断。 + [JsonPropertyName("server_version")] + public string? ServerVersion { get; set; } } public sealed class HeartbeatParams diff --git a/backend/fleet/src/Fleet.Server.Abstractions/FleetServerOptions.cs b/backend/fleet/src/Fleet.Server.Abstractions/FleetServerOptions.cs index bca0653..e2122d2 100644 --- a/backend/fleet/src/Fleet.Server.Abstractions/FleetServerOptions.cs +++ b/backend/fleet/src/Fleet.Server.Abstractions/FleetServerOptions.cs @@ -22,3 +22,12 @@ public sealed class FleetServerOptions /// 节点违反协议(方法没有声明、方向、类型或角色不对)的次数上限;超过就断开这条连接。 public int MaxViolations { get; set; } = 20; } + +/// 握手时的版本要求:服务端的版本(告诉节点),以及节点至少要是哪个版本。每次握手都重新取,所以改配置不用重启。 +public interface IFleetVersionPolicy +{ + string? ServerVersion { get; } + + /// 空表示不要求。 + string? MinNodeVersion { get; } +} diff --git a/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs b/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs index 2f5bd95..5c5646b 100644 --- a/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs +++ b/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs @@ -24,8 +24,10 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier IEnumerable inboxes, IEnumerable sinks, FleetRouter router, - IServerIdentity? serverIdentity = null) + IServerIdentity? serverIdentity = null, + IFleetVersionPolicy? versionPolicy = null) { + VersionPolicy = versionPolicy; ServerIdentity = serverIdentity; Store = store; Options = options; @@ -43,6 +45,8 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier /// 服务器的身份密钥;没有时节点连接用旧的明文认证。 internal IServerIdentity? ServerIdentity { get; } + internal IFleetVersionPolicy? VersionPolicy { get; } + internal FleetServerOptions Options { get; } internal TimeProvider Time { get; } diff --git a/backend/fleet/src/Fleet.Server.Link/FleetLog.cs b/backend/fleet/src/Fleet.Server.Link/FleetLog.cs index 10963c9..97e0cec 100644 --- a/backend/fleet/src/Fleet.Server.Link/FleetLog.cs +++ b/backend/fleet/src/Fleet.Server.Link/FleetLog.cs @@ -10,6 +10,9 @@ internal static partial class FleetLog [LoggerMessage(Level = LogLevel.Information, Message = "node disconnected node={Node} reason={Reason}")] public static partial void Disconnected(ILogger logger, string node, string reason); + [LoggerMessage(Level = LogLevel.Warning, Message = "node below the required version node={Node} version={Version} required={Required}")] + public static partial void VersionTooOld(ILogger logger, string node, string version, string required); + [LoggerMessage(Level = LogLevel.Warning, Message = "node auth rejected err={Err}")] public static partial void AuthRejected(ILogger logger, string err); diff --git a/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs b/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs index 4220999..8a7d6e0 100644 --- a/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs +++ b/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs @@ -110,7 +110,8 @@ public static class FleetServerExtensions provider.GetServices(), provider.GetServices(), provider.GetRequiredService(), - provider.GetService())); + provider.GetService(), + provider.GetService())); services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(provider => provider.GetRequiredService()); return builder; diff --git a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Receive.cs b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Receive.cs index 333d9a8..3a2d3c6 100644 --- a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Receive.cs +++ b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Receive.cs @@ -151,6 +151,20 @@ internal sealed partial class NodeConnection return; } + string? minVersion = _host.VersionPolicy?.MinNodeVersion; + if (NodeVersions.IsBelow(hello.NodeVersion, minVersion)) + { + // 版本不够:不登记在线,告诉节点要求的版本,关闭连接。节点等几秒(它可能正在更新)再握手。 + FleetLog.VersionTooOld(_host.Logger, _node!.Id.Value, hello.NodeVersion, minVersion!); + if (message.Id is not null) + { + await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.VersionRequired, $"node version {hello.NodeVersion} is below the required {minVersion}")), ct); + } + + await CloseAsync("version required"); + return; + } + long nowMs = _host.Time.GetUtcNow().ToUnixTimeMilliseconds(); _session = _session! with { @@ -169,7 +183,7 @@ internal sealed partial class NodeConnection await SendAsync( RpcMessage.Success( message.Id.Value, - new HelloResult { ServerTimeMs = nowMs, ClockOffsetMs = hello.NowUtcMs - nowMs }, + new HelloResult { ServerTimeMs = nowMs, ClockOffsetMs = hello.NowUtcMs - nowMs, ServerVersion = _host.VersionPolicy?.ServerVersion }, FleetRpcContext.Default.HelloResult), ct); } diff --git a/backend/fleet/tests/Fleet.Tests/NodeVersionsTests.cs b/backend/fleet/tests/Fleet.Tests/NodeVersionsTests.cs new file mode 100644 index 0000000..edded19 --- /dev/null +++ b/backend/fleet/tests/Fleet.Tests/NodeVersionsTests.cs @@ -0,0 +1,21 @@ +using Fleet.Core; + +namespace Fleet.Tests; + +public sealed class NodeVersionsTests +{ + [Theory] + [InlineData("0.4.2-dev.73-5245f03", "0.4.2-dev.74", true)] + [InlineData("0.4.2-dev.73-5245f03", "0.4.2-dev.73-abcdef0", false)] // 哈希不参与比较 + [InlineData("0.4.2-dev.100-aaaaaaa", "0.4.2-dev.99", false)] // 序号按数字比,不是按字符串 + [InlineData("0.4.2-dev.999", "0.4.2", true)] // 正式版高于预发布版 + [InlineData("0.4.2", "0.4.2-dev.5", false)] + [InlineData("0.4.1", "0.4.2-dev.1", true)] + [InlineData("v0.5.0", "0.4.9", false)] + [InlineData("0.4.2-dev.73-5245f03", "", false)] // 没有要求 + [InlineData("", "0.4.2", false)] // 认不出来的版本号不拒绝 + [InlineData("not-a-version", "0.4.2", false)] + [InlineData("0.4.2", "garbage", false)] + public void IsBelow_ComparesCoreThenReleaseThenTheDevCounter(string actual, string required, bool expected) => + Assert.Equal(expected, NodeVersions.IsBelow(actual, required)); +} diff --git a/backend/src/MoShiro.Backend.Application/BackendOptions.cs b/backend/src/MoShiro.Backend.Application/BackendOptions.cs index 36e5dc5..603272e 100644 --- a/backend/src/MoShiro.Backend.Application/BackendOptions.cs +++ b/backend/src/MoShiro.Backend.Application/BackendOptions.cs @@ -60,6 +60,9 @@ public sealed class BackendOptions public int LoginAttemptsPerMinute { get; set; } = 10; + /// Agent 至少要是哪个版本才能连上(如 0.4.2-dev.80);空 = 不要求。低于它的 Agent 握手时被拒绝,5 秒后重试(它可能正在更新)。环境变量 MoShiro__MinAgentVersion。 + public string? MinAgentVersion { get; set; } + public int RawRetentionDays { get; set; } = 30; public int TestRawRetentionDays { get; set; } = 90; diff --git a/backend/src/MoShiro.Backend.Gateway/GatewayServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Gateway/GatewayServiceCollectionExtensions.cs index c3bb59c..78867e1 100644 --- a/backend/src/MoShiro.Backend.Gateway/GatewayServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Gateway/GatewayServiceCollectionExtensions.cs @@ -16,6 +16,7 @@ public static class GatewayServiceCollectionExtensions public static IServiceCollection AddMoShiroGateway(this IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); services.AddHostedService(); services.AddFleetEnrollment(); services.AddFleetServer(options => options.NodeIdPrefix = AgentIds.Prefix) @@ -44,3 +45,11 @@ public static class GatewayServiceCollectionExtensions (context, parameters, ct) => services.GetRequiredService().OnStatusAsync(context.Node.Id, parameters, ct)); } } + +/// 握手时的版本要求:告诉 Agent 面板的版本,并要求 Agent 不低于配置的最低版本。 +internal sealed class BackendVersionPolicy(BackendOptions options) : IFleetVersionPolicy +{ + public string? ServerVersion => BuildInfo.Version; + + public string? MinNodeVersion => string.IsNullOrWhiteSpace(options.MinAgentVersion) ? null : options.MinAgentVersion; +} diff --git a/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs b/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs index 271bd35..d82c0af 100644 --- a/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs +++ b/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs @@ -42,7 +42,7 @@ public sealed class AgentHarness : IAsyncDisposable public byte[] TlsPublicKey => _identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo(); /// 带信任根固定的加入码(码、创世指纹、属主):节点按固定了创世和属主的方式注册。 - public static async Task StartAsync(BackendFixture backend, Action? configure = null, string? name = null, (string Code, string Genesis, string[] Controllers)? pin = null) + public static async Task StartAsync(BackendFixture backend, Action? configure = null, string? name = null, (string Code, string Genesis, string[] Controllers)? pin = null, bool waitForConnection = true) { string directory = Path.Combine(Path.GetTempPath(), "moshiro-agent-" + Guid.NewGuid().ToString("N")); string token = pin?.Code ?? await backend.CreateJoinTokenAsync(); @@ -71,7 +71,11 @@ public sealed class AgentHarness : IAsyncDisposable } await app.Ready; - await BackendFixture.EventuallyAsync(() => Task.FromResult(app.Link.IsConnected), because: "the agent should connect to the backend"); + if (waitForConnection) + { + await BackendFixture.EventuallyAsync(() => Task.FromResult(app.Link.IsConnected), because: "the agent should connect to the backend"); + } + return harness; } diff --git a/backend/tests/MoShiro.Backend.Tests/VersionRequirementTests.cs b/backend/tests/MoShiro.Backend.Tests/VersionRequirementTests.cs new file mode 100644 index 0000000..e3e7ac3 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/VersionRequirementTests.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using Fleet.Core; +using Fleet.Rpc; +using Microsoft.Extensions.DependencyInjection; +using MoShiro.Backend.Application; +using MoShiro.TestSupport; + +namespace MoShiro.Backend.Tests; + +public sealed class VersionRequirementTests : IClassFixture +{ + private readonly BackendFixture _backend; + + public VersionRequirementTests(BackendFixture backend) + { + _backend = backend; + } + + [Fact] + public async Task AnAgentBelowTheRequiredVersion_IsRejectedAtHello_AndGetsInOnceTheRequirementIsMet() + { + BackendOptions options = _backend.App.Services.GetRequiredService(); + options.MinAgentVersion = "9.0.0"; // 模拟器的版本是 0.0.1-sim + try + { + await using AgentSimulator old = await _backend.ConnectAgentAsync(hello: false); + var hello = new HelloParams { NodeVersion = "0.0.1-sim", NowUtcMs = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() }; + RpcMessage reply = await old.RequestRawAsync(FleetMethods.Hello, JsonSerializer.SerializeToElement(hello, FleetRpcContext.Default.HelloParams)); + Assert.Equal(FleetErrors.VersionRequired.Value, reply.Error!.Data!.Code); + Assert.Contains("9.0.0", reply.Error.Message); + } + finally + { + options.MinAgentVersion = null; // 共用的后端:恢复,别影响别的测试 + } + + await using AgentSimulator ok = await _backend.ConnectAgentAsync(); + Assert.True(_backend.App.Services.GetRequiredService().IsOnline(ok.NodeId)); + } + + [Fact] + [Trait("Category", "Slow")] + public async Task AnAgentTheBackendRejectedForItsVersion_TriesAgainEveryFiveSeconds_AndComesOnlineWhenAccepted() + { + BackendOptions options = _backend.App.Services.GetRequiredService(); + IAgentGateway gateway = _backend.App.Services.GetRequiredService(); + options.MinAgentVersion = "999.0.0"; + try + { + await using AgentHarness agent = await AgentHarness.StartAsync(_backend, waitForConnection: false); + await Task.Delay(TimeSpan.FromSeconds(2)); + Assert.False(gateway.IsOnline(agent.NodeId)); + + options.MinAgentVersion = null; // 通过了:下一次握手(最迟 5 秒)就上线 + await BackendFixture.EventuallyAsync(() => Task.FromResult(gateway.IsOnline(agent.NodeId)), TimeSpan.FromSeconds(12)); + } + finally + { + options.MinAgentVersion = null; + } + } + + [Fact] + [Trait("Category", "Slow")] + public async Task AnAgentThatRequiresANewerBackend_DoesNotGoOnline_AndKeepsChecking() + { + IAgentGateway gateway = _backend.App.Services.GetRequiredService(); + await using AgentHarness agent = await AgentHarness.StartAsync(_backend, config => config.Fleet.MinServerVersion = "999.0.0", waitForConnection: false); + await Task.Delay(TimeSpan.FromSeconds(7)); // 至少重试过一次 + Assert.False(gateway.IsOnline(agent.NodeId)); + } +}