ci/woodpecker/push/main-amd64-build Pipeline was successful
Methods, roles and binary channels are declared in one table per plane (node and peer). RpcRouter checks direction, kind and role when handlers are registered, when messages arrive (fleet_protocol_violation, disconnect after repeated violations) and before sending. NodeConnection is split by responsibility.
267 lines
9.9 KiB
C#
267 lines
9.9 KiB
C#
using Fleet.Core;
|
|
using Fleet.Hosting;
|
|
|
|
namespace Fleet.Tests;
|
|
|
|
public sealed class RestartScheduleTests
|
|
{
|
|
private static RestartSchedule Parse(string text)
|
|
{
|
|
Assert.True(RestartSchedule.TryParse(text, out RestartSchedule? schedule), text);
|
|
return schedule!;
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("04:00")]
|
|
[InlineData("04:00Z")]
|
|
[InlineData("04:00+08:00")]
|
|
[InlineData("23:59-05:30")]
|
|
[InlineData(" 00:00 ")]
|
|
public void Parse_AcceptsTimeWithOptionalOffset(string text) => Parse(text);
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData("4:00")]
|
|
[InlineData("24:00")]
|
|
[InlineData("04:60")]
|
|
[InlineData("04:00+8:00")]
|
|
[InlineData("04:00+15:00")]
|
|
[InlineData("04:00 UTC")]
|
|
[InlineData("0400")]
|
|
[InlineData("04:00+08:60")]
|
|
public void Parse_RejectsEverythingElse(string text) => Assert.False(RestartSchedule.TryParse(text, out _));
|
|
|
|
[Fact]
|
|
public void NextAfter_IsStrictlyLater_AndRollsToTomorrow()
|
|
{
|
|
RestartSchedule utc = Parse("04:00");
|
|
Assert.Equal(new DateTimeOffset(2026, 10, 7, 4, 0, 0, TimeSpan.Zero), utc.NextAfter(new DateTimeOffset(2026, 10, 7, 3, 59, 59, TimeSpan.Zero)));
|
|
Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), utc.NextAfter(new DateTimeOffset(2026, 10, 7, 4, 0, 0, TimeSpan.Zero)));
|
|
Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), utc.NextAfter(new DateTimeOffset(2026, 10, 7, 20, 0, 0, TimeSpan.Zero)));
|
|
}
|
|
|
|
[Fact]
|
|
public void NextAfter_UsesTheWrittenOffset()
|
|
{
|
|
// 04:00+08:00 就是 UTC 前一天 20:00
|
|
RestartSchedule china = Parse("04:00+08:00");
|
|
Assert.Equal(new DateTimeOffset(2026, 10, 6, 20, 0, 0, TimeSpan.Zero), china.NextAfter(new DateTimeOffset(2026, 10, 6, 19, 0, 0, TimeSpan.Zero)));
|
|
Assert.Equal(new DateTimeOffset(2026, 10, 7, 20, 0, 0, TimeSpan.Zero), china.NextAfter(new DateTimeOffset(2026, 10, 6, 20, 0, 0, TimeSpan.Zero)));
|
|
}
|
|
|
|
[Fact]
|
|
public void NextAfter_WithMinimumUptime_DoesNotRestartAgainRightAfterARestart()
|
|
{
|
|
RestartSchedule schedule = Parse("04:00");
|
|
// 04:00:03 重启后启动:加上最短运行时间再算,下一次是明天,而不是几秒后
|
|
var started = new DateTimeOffset(2026, 10, 7, 4, 0, 3, TimeSpan.Zero);
|
|
Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), schedule.NextAfter(started + RestartSchedule.MinimumUptime));
|
|
// 03:55 启动:离 04:00 只有 5 分钟,不够最短运行时间,顺延到明天
|
|
var early = new DateTimeOffset(2026, 10, 7, 3, 55, 0, TimeSpan.Zero);
|
|
Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), schedule.NextAfter(early + RestartSchedule.MinimumUptime));
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_ReportsABadValueInsteadOfIgnoringIt()
|
|
{
|
|
Assert.Null(RestartSchedule.Resolve(null));
|
|
Assert.Null(RestartSchedule.Resolve(" "));
|
|
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() => RestartSchedule.Resolve("tomorrow"));
|
|
Assert.Contains("restart_at", error.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ServiceShell_StopsAtTheScheduledTime()
|
|
{
|
|
var time = new ImmediateTimeProvider(new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.Zero));
|
|
var log = new ListLog();
|
|
bool stoppedByToken = false;
|
|
|
|
// 业务循环只在被取消时返回;定时重启到点以后令牌被取消,RunAsync 正常返回
|
|
await ServiceShell.RunAsync(async ct =>
|
|
{
|
|
try
|
|
{
|
|
await Task.Delay(Timeout.Infinite, ct);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
stoppedByToken = true;
|
|
throw;
|
|
}
|
|
}, log, new RestartController(Parse("04:00")), time).WaitAsync(TimeSpan.FromSeconds(10));
|
|
|
|
Assert.True(stoppedByToken);
|
|
Assert.Contains(log.Lines, l => l.Contains("scheduled_restart", StringComparison.Ordinal));
|
|
Assert.Contains(log.Lines, l => l.Contains("restart scheduled", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ServiceShell_PicksUpAScheduleThePanelSendsLater()
|
|
{
|
|
var time = new ImmediateTimeProvider(new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.Zero));
|
|
var log = new ListLog();
|
|
using var controller = new RestartController();
|
|
|
|
Task shell = ServiceShell.RunAsync(ct => Task.Delay(Timeout.Infinite, ct), log, controller, time);
|
|
await Task.Delay(300);
|
|
Assert.False(shell.IsCompleted, "no schedule yet: the node keeps running");
|
|
|
|
Assert.True(controller.SetRemote("04:30+08:00"));
|
|
await shell.WaitAsync(TimeSpan.FromSeconds(10));
|
|
Assert.Contains(log.Lines, l => l.Contains("scheduled_restart", StringComparison.Ordinal));
|
|
Assert.Contains(log.Lines, l => l.Contains("source=panel", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ServiceShell_ClearingTheScheduleCancelsTheRestart()
|
|
{
|
|
var time = new ManualTimeProvider(new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.Zero));
|
|
var log = new ListLog();
|
|
using var controller = new RestartController();
|
|
|
|
controller.SetRemote("04:30+08:00");
|
|
|
|
Task shell = ServiceShell.RunAsync(ct => Task.Delay(Timeout.Infinite, ct), log, controller, time);
|
|
await Task.Delay(300);
|
|
controller.SetRemote(null); // 面板关掉了
|
|
await Task.Delay(300);
|
|
Assert.False(shell.IsCompleted);
|
|
Assert.Contains(log.Lines, l => l.Contains("restart scheduled", StringComparison.Ordinal));
|
|
Assert.DoesNotContain(log.Lines, l => l.Contains("scheduled_restart", StringComparison.Ordinal));
|
|
// 清理:把进程停掉
|
|
Assert.True(controller.SetRemote("04:30+08:00"));
|
|
// 新的计时器是异步注册的:反复推进,直到进程退出
|
|
for (int i = 0; i < 100 && !shell.IsCompleted; i++)
|
|
{
|
|
time.Advance();
|
|
await Task.Delay(50);
|
|
}
|
|
|
|
await shell.WaitAsync(TimeSpan.FromSeconds(10));
|
|
}
|
|
|
|
[Fact]
|
|
public void Controller_LocalSettingWinsOverThePanel_AndBadTextIsRejected()
|
|
{
|
|
using var controller = new RestartController(Parse("04:00"));
|
|
Assert.True(controller.IsLocal);
|
|
Assert.True(controller.SetRemote("05:00"));
|
|
Assert.Equal(new DateTimeOffset(2026, 10, 7, 4, 0, 0, TimeSpan.Zero), controller.Current!.NextAfter(new DateTimeOffset(2026, 10, 7, 0, 0, 0, TimeSpan.Zero)));
|
|
|
|
using var remoteOnly = new RestartController();
|
|
Assert.Null(remoteOnly.Current);
|
|
Assert.False(remoteOnly.SetRemote("whenever"));
|
|
Assert.Null(remoteOnly.Current);
|
|
Assert.True(remoteOnly.SetRemote("05:00"));
|
|
Assert.NotNull(remoteOnly.Current);
|
|
Assert.True(remoteOnly.SetRemote(null));
|
|
Assert.Null(remoteOnly.Current);
|
|
}
|
|
|
|
/// <summary>计时器马上触发:用来验证「到点」之后的行为,不用真的等。</summary>
|
|
private sealed class ImmediateTimeProvider : TimeProvider
|
|
{
|
|
private readonly DateTimeOffset _now;
|
|
|
|
public ImmediateTimeProvider(DateTimeOffset now) => _now = now;
|
|
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
|
|
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
|
|
{
|
|
// TrimHeapAsync 这类每 30 秒一次的循环也会走到这里;只让「很长」的延迟(定时重启,至少 10 分钟)马上触发
|
|
var timer = new ImmediateTimer(callback, state);
|
|
if (dueTime >= RestartSchedule.MinimumUptime)
|
|
{
|
|
ThreadPool.QueueUserWorkItem(_ => timer.Fire());
|
|
}
|
|
|
|
return timer;
|
|
}
|
|
}
|
|
|
|
/// <summary>计时器只在 <see cref="Advance"/> 时触发:用来验证「还没到点」时不会退出。</summary>
|
|
private sealed class ManualTimeProvider : TimeProvider
|
|
{
|
|
private readonly DateTimeOffset _now;
|
|
private readonly List<ImmediateTimer> _timers = [];
|
|
|
|
public ManualTimeProvider(DateTimeOffset now) => _now = now;
|
|
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
|
|
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
|
|
{
|
|
var timer = new ImmediateTimer(callback, state);
|
|
lock (_timers)
|
|
{
|
|
_timers.Add(timer);
|
|
}
|
|
|
|
return timer;
|
|
}
|
|
|
|
public void Advance()
|
|
{
|
|
ImmediateTimer[] due;
|
|
lock (_timers)
|
|
{
|
|
due = [.. _timers];
|
|
_timers.Clear();
|
|
}
|
|
|
|
foreach (ImmediateTimer timer in due)
|
|
{
|
|
timer.Fire();
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class ImmediateTimer : ITimer
|
|
{
|
|
private readonly TimerCallback _callback;
|
|
private readonly object? _state;
|
|
|
|
public ImmediateTimer(TimerCallback callback, object? state)
|
|
{
|
|
_callback = callback;
|
|
_state = state;
|
|
}
|
|
|
|
public void Fire() => _callback(_state);
|
|
|
|
public bool Change(TimeSpan dueTime, TimeSpan period) => true;
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
|
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
}
|
|
|
|
private sealed class ListLog : INodeLog
|
|
{
|
|
private readonly List<string> _lines = [];
|
|
|
|
public IReadOnlyList<string> Lines
|
|
{
|
|
get
|
|
{
|
|
lock (_lines)
|
|
{
|
|
return [.. _lines];
|
|
}
|
|
}
|
|
}
|
|
|
|
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields)
|
|
{
|
|
lock (_lines)
|
|
{
|
|
_lines.Add(message + " " + string.Join(' ', fields.Select(f => f.Key + "=" + f.Value)));
|
|
}
|
|
}
|
|
}
|
|
}
|