diff --git a/SecRandom.Core.Tests/ClassIslandIpcConnectionTests.cs b/SecRandom.Core.Tests/ClassIslandIpcConnectionTests.cs new file mode 100644 index 000000000..55efce53b --- /dev/null +++ b/SecRandom.Core.Tests/ClassIslandIpcConnectionTests.cs @@ -0,0 +1,65 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using SecRandom.Services.Linkage; + +namespace SecRandom.Core.Tests; + +/// +/// ClassIsland IPC 门槛与等待策略:通知服务必须通过插件版本门槛(旧插件不满足通知契约), +/// 退避期内不能继续空等,否则内置回退通知与抽取前的 QuickDraw 窗口会被拖到十秒之后。 +/// +public sealed class ClassIslandIpcConnectionTests +{ + [Theory] + [InlineData("Yes", "1.2.0.0", true)] + [InlineData("Yes", "1.3.0.0", true)] + [InlineData("Yes", "1.1.0.0", false)] + [InlineData("Yes", null, false)] + [InlineData("No", "1.2.0.0", false)] + [InlineData("yes", "1.2.0.0", false)] + public void IsNotificationServiceUsable_GatesPluginVersion(string? isAlive, string? version, bool expected) + { + var pluginVersion = version is null ? null : Version.Parse(version); + + Assert.Equal(expected, ClassIslandIpcConnection.IsNotificationServiceUsable(isAlive, pluginVersion)); + } + + [Fact] + public async Task GetLessonsServiceAsync_ReturnsImmediatelyDuringBackoff() + { + var connection = CreateConnectionInBackoff(); + + var elapsed = await MeasureAsync(() => connection.GetLessonsServiceAsync(TestContext.Current.CancellationToken)); + + Assert.True(elapsed < TimeSpan.FromSeconds(1), $"退避期内不应继续等待:{elapsed}"); + } + + [Fact] + public async Task GetNotificationServiceAsync_ReturnsImmediatelyDuringBackoff() + { + var connection = CreateConnectionInBackoff(); + + var elapsed = await MeasureAsync(() => connection.GetNotificationServiceAsync(TestContext.Current.CancellationToken)); + + Assert.True(elapsed < TimeSpan.FromSeconds(1), $"退避期内不应继续等待:{elapsed}"); + } + + private static ClassIslandIpcConnection CreateConnectionInBackoff() + { + var connection = new ClassIslandIpcConnection(NullLogger.Instance); + var field = typeof(ClassIslandIpcConnection).GetField( + "_nextConnectAttempt", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("ClassIslandIpcConnection._nextConnectAttempt 字段已改名。"); + field.SetValue(connection, DateTimeOffset.UtcNow.AddMinutes(5)); + return connection; + } + + private static async Task MeasureAsync(Func action) + { + var start = DateTime.UtcNow; + await action(); + return DateTime.UtcNow - start; + } +} diff --git a/SecRandom.Core.Tests/ClassIslandScheduleSourceTests.cs b/SecRandom.Core.Tests/ClassIslandScheduleSourceTests.cs index 23b3b862e..f5280f093 100644 --- a/SecRandom.Core.Tests/ClassIslandScheduleSourceTests.cs +++ b/SecRandom.Core.Tests/ClassIslandScheduleSourceTests.cs @@ -16,9 +16,17 @@ namespace SecRandom.Core.Tests; /// -/// ClassIsland 联动状态映射。ClassIsland 只在当前时间落在上课/课间时间点内时才把 -/// IsLessonConfirmed 置为 true,因此放学(AfterSchool)与开课前(None)必然是 false, -/// 但它们同样是数据源明确给出的非上课时段,不能被当作“状态不可信”而放行(issue #278)。 +/// ClassIsland 联动的状态映射与刷新语义。 +/// +/// ClassIsland 只在当前时间落在上课/课间时间点内时才把 IsLessonConfirmed 置为 true, +/// 因此放学(AfterSchool)与开课前(None)必然是 false,但它们同样是数据源明确给出的非上课时段, +/// 不能被当作“状态不可信”而放行(issue #278)。 +/// +/// +/// 刷新时必须始终写入新快照、只在语义发生变化时通知订阅者:倒计时字段每秒都在变, +/// 按全量相等判断会自激刷新(issue #274),而完全丢弃这些字段又会让课前解禁、课后禁用延迟 +/// 与课前重置读到陈旧的倒计时。 +/// /// public sealed class ClassIslandScheduleSourceTests { @@ -38,99 +46,126 @@ public void MapCurrentState_TreatsEveryNonClassStateAsConfirmedBreak( [Fact] public async Task AfterSchool_IsConfirmedNonClassTime() { - var result = await EvaluateAsync(lessons => lessons.CurrentState = TimeState.AfterSchool); + var harness = new LinkageHarness(); + harness.Lessons.CurrentState = TimeState.AfterSchool; + await harness.RefreshAsync(); - Assert.True(result.Snapshot.IsAvailable, $"error={result.Snapshot.Error}"); - Assert.Equal(CourseTimeState.Breaking, result.Snapshot.State); - Assert.True(result.Restricted); + Assert.True(harness.Snapshot.IsAvailable, $"error={harness.Snapshot.Error}"); + Assert.Equal(CourseTimeState.Breaking, harness.Snapshot.State); + Assert.True(harness.Restricted); } [Fact] public async Task BeforeFirstClass_IsConfirmedNonClassTime() { - var result = await EvaluateAsync(lessons => - { - lessons.CurrentState = TimeState.None; - lessons.OnClassLeftTime = TimeSpan.FromMinutes(40); - }); + var harness = new LinkageHarness(); + harness.Lessons.CurrentState = TimeState.None; + harness.Lessons.OnClassLeftTime = TimeSpan.FromMinutes(40); + await harness.RefreshAsync(); - Assert.True(result.Snapshot.IsAvailable, $"error={result.Snapshot.Error}"); - Assert.Equal(TimeSpan.FromMinutes(40), result.Snapshot.TimeUntilNextCourse); - Assert.True(result.Restricted); + Assert.True(harness.Snapshot.IsAvailable, $"error={harness.Snapshot.Error}"); + Assert.Equal(TimeSpan.FromMinutes(40), harness.Snapshot.TimeUntilNextCourse); + Assert.True(harness.Restricted); } [Fact] public async Task BeforeFirstClass_InsidePreClassEnableWindow_IsPermitted() { - var result = await EvaluateAsync( - lessons => - { - lessons.CurrentState = TimeState.None; - lessons.OnClassLeftTime = TimeSpan.FromSeconds(10); - }, - preClassEnableSeconds: 300); - - Assert.True(result.Snapshot.IsAvailable, $"error={result.Snapshot.Error}"); - Assert.False(result.Restricted); + var harness = new LinkageHarness(preClassEnableSeconds: 300); + harness.Lessons.CurrentState = TimeState.None; + harness.Lessons.OnClassLeftTime = TimeSpan.FromSeconds(10); + await harness.RefreshAsync(); + + Assert.True(harness.Snapshot.IsAvailable, $"error={harness.Snapshot.Error}"); + Assert.False(harness.Restricted); } [Fact] public async Task AfterSchool_InsidePostClassDisableDelay_IsPermitted() { var now = DateTime.Now; - var result = await EvaluateAsync( - lessons => - { - lessons.CurrentState = TimeState.OnClass; - lessons.CurrentTimeLayoutItem = ClassTime(now, TimeSpan.FromMinutes(50), TimeSpan.FromMinutes(5)); - lessons.OnBreakingTimeLeftTime = TimeSpan.FromMinutes(5); - }, - postClassDelaySeconds: 600, - beforeSecondRefresh: lessons => - { - lessons.CurrentState = TimeState.AfterSchool; - lessons.CurrentTimeLayoutItem = TimeLayoutItem.Empty; - lessons.OnBreakingTimeLeftTime = TimeSpan.Zero; - }); - - Assert.True(result.Snapshot.IsAvailable, $"error={result.Snapshot.Error}"); - Assert.False(result.Restricted); + var harness = new LinkageHarness(postClassDelaySeconds: 600); + harness.Lessons.CurrentState = TimeState.OnClass; + harness.Lessons.CurrentTimeLayoutItem = ClassTime(now, TimeSpan.FromMinutes(50), TimeSpan.FromMinutes(5)); + harness.Lessons.OnBreakingTimeLeftTime = TimeSpan.FromMinutes(5); + await harness.RefreshAsync(); + + harness.Lessons.CurrentState = TimeState.AfterSchool; + harness.Lessons.CurrentTimeLayoutItem = TimeLayoutItem.Empty; + harness.Lessons.OnBreakingTimeLeftTime = TimeSpan.Zero; + await harness.RefreshAsync(); + + Assert.True(harness.Snapshot.IsAvailable, $"error={harness.Snapshot.Error}"); + Assert.False(harness.Restricted); } [Fact] public async Task OnClass_IsNotRestricted() { - var result = await EvaluateAsync(lessons => lessons.CurrentState = TimeState.OnClass); + var harness = new LinkageHarness(); + harness.Lessons.CurrentState = TimeState.OnClass; + await harness.RefreshAsync(); - Assert.True(result.Snapshot.IsAvailable, $"error={result.Snapshot.Error}"); - Assert.Equal(CourseTimeState.OnClass, result.Snapshot.State); - Assert.False(result.Restricted); + Assert.True(harness.Snapshot.IsAvailable, $"error={harness.Snapshot.Error}"); + Assert.Equal(CourseTimeState.OnClass, harness.Snapshot.State); + Assert.False(harness.Restricted); } [Fact] public async Task Break_OutsidePreClassEnableWindow_IsRestricted() { - var result = await EvaluateAsync(lessons => - { - lessons.CurrentState = TimeState.Breaking; - lessons.OnClassLeftTime = TimeSpan.FromMinutes(40); - }); + var harness = new LinkageHarness(); + harness.Lessons.CurrentState = TimeState.Breaking; + harness.Lessons.OnClassLeftTime = TimeSpan.FromMinutes(40); + await harness.RefreshAsync(); - Assert.True(result.Snapshot.IsAvailable, $"error={result.Snapshot.Error}"); - Assert.True(result.Restricted); + Assert.True(harness.Snapshot.IsAvailable, $"error={harness.Snapshot.Error}"); + Assert.True(harness.Restricted); } [Fact] public async Task ScheduleNotLoaded_StaysPermissive() { - var result = await EvaluateAsync(lessons => - { - lessons.IsClassPlanLoaded = false; - lessons.CurrentState = TimeState.None; - }); + var harness = new LinkageHarness(); + harness.Lessons.IsClassPlanLoaded = false; + harness.Lessons.CurrentState = TimeState.None; + await harness.RefreshAsync(); + + Assert.False(harness.Snapshot.IsAvailable); + Assert.False(harness.Restricted); + } + + [Fact] + public async Task Refresh_WhenOnlyCountdownChanges_UpdatesSnapshotWithoutNotifying() + { + var harness = new LinkageHarness(); + harness.Lessons.CurrentState = TimeState.Breaking; + harness.Lessons.OnClassLeftTime = TimeSpan.FromMinutes(40); + await harness.RefreshAsync(); + var notifications = harness.StateChangedCount; - Assert.False(result.Snapshot.IsAvailable); - Assert.False(result.Restricted); + harness.Lessons.OnClassLeftTime = TimeSpan.FromMinutes(10); + await harness.RefreshAsync(); + + Assert.Equal(notifications, harness.StateChangedCount); + Assert.Equal(TimeSpan.FromMinutes(10), harness.Snapshot.TimeUntilNextCourse); + } + + [Fact] + public async Task Refresh_WhenPreClassEnableWindowOpens_UnrestrictsWithoutNotifying() + { + var harness = new LinkageHarness(preClassEnableSeconds: 300); + harness.Lessons.CurrentState = TimeState.Breaking; + harness.Lessons.OnClassLeftTime = TimeSpan.FromMinutes(40); + await harness.RefreshAsync(); + Assert.True(harness.Restricted); + var notifications = harness.StateChangedCount; + + harness.Lessons.OnClassLeftTime = TimeSpan.FromSeconds(10); + await harness.RefreshAsync(); + + Assert.False(harness.Restricted); + Assert.Equal(notifications, harness.StateChangedCount); } private static TimeLayoutItem ClassTime(DateTime now, TimeSpan startedAgo, TimeSpan endedAgo) => new() @@ -140,50 +175,49 @@ public async Task ScheduleNotLoaded_StaysPermissive() EndTime = now.TimeOfDay - endedAgo }; - private static async Task EvaluateAsync( - Action configure, - int preClassEnableSeconds = 0, - int postClassDelaySeconds = 0, - Action? beforeSecondRefresh = null) + private sealed class LinkageHarness { - var config = new MainConfigModel(); - config.LinkageSettings.DataSource = LinkageDataSource.ClassIsland; - config.LinkageSettings.InstantDrawDisable = true; - config.LinkageSettings.PreClassEnableTime = preClassEnableSeconds; - config.LinkageSettings.PostClassDisableDelay = postClassDelaySeconds; - - var handler = new MainConfigHandler( - NullLogger.Instance, - new TestConfigService(config)); - var store = new FakeCsesScheduleStore(); - var classIsland = new ClassIslandScheduleSource(NullLogger.Instance); - var lessons = new FakeLessons(); - configure(lessons); - // ClassIslandScheduleSource 只在首次读取时建立 IPC 连接,预先注入假实现即可在无 ClassIsland 的 - // 环境中验证状态映射;字段改名时这里的异常会直接失败,提醒同步测试。 - var field = typeof(ClassIslandScheduleSource).GetField( - "_lessons", BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new InvalidOperationException("ClassIslandScheduleSource._lessons 字段已改名。"); - field.SetValue(classIsland, lessons); - - var service = new CourseLinkageService( - handler, - store, - new CsesScheduleSource(store), - classIsland, - NullLogger.Instance); - - await service.RefreshAsync(); - if (beforeSecondRefresh is not null) + public LinkageHarness( + int preClassEnableSeconds = 0, + int postClassDelaySeconds = 0, + bool instantDrawDisable = true) { - beforeSecondRefresh(lessons); - await service.RefreshAsync(); + var config = new MainConfigModel(); + config.LinkageSettings.DataSource = LinkageDataSource.ClassIsland; + config.LinkageSettings.InstantDrawDisable = instantDrawDisable; + config.LinkageSettings.PreClassEnableTime = preClassEnableSeconds; + config.LinkageSettings.PostClassDisableDelay = postClassDelaySeconds; + + var handler = new MainConfigHandler( + NullLogger.Instance, + new TestConfigService(config)); + var store = new FakeCsesScheduleStore(); + var connection = new ClassIslandIpcConnection(NullLogger.Instance); + // 预先注入假的课程服务:真实实现只在尚未连接时才去建立 IPC,这样就能在没有 + // ClassIsland 的环境里驱动完整的状态映射;字段改名时这里会直接失败,提醒同步测试。 + var field = typeof(ClassIslandIpcConnection).GetField( + "_lessonsService", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("ClassIslandIpcConnection._lessonsService 字段已改名。"); + field.SetValue(connection, Lessons); + + var source = new ClassIslandScheduleSource(connection, NullLogger.Instance); + Service = new CourseLinkageService( + handler, + store, + new CsesScheduleSource(store), + source, + NullLogger.Instance); + Service.StateChanged += (_, _) => StateChangedCount++; } - return new LinkageResult(service.Snapshot, service.IsConfirmedNonClassTime); - } + public FakeLessons Lessons { get; } = new(); + public CourseLinkageService Service { get; } + public int StateChangedCount { get; private set; } + public CourseScheduleSnapshot Snapshot => Service.Snapshot; + public bool Restricted => Service.IsConfirmedNonClassTime; - private sealed record LinkageResult(CourseScheduleSnapshot Snapshot, bool Restricted); + public Task RefreshAsync() => Service.RefreshAsync(); + } /// /// 复刻 ClassIsland LessonsService.ProcessLessons() 的上报结果:只有落在上课/课间时间点内时 diff --git a/SecRandom.Core.Tests/PluginDrawServiceTests.cs b/SecRandom.Core.Tests/PluginDrawServiceTests.cs index 4445b8c6a..b19145dcb 100644 --- a/SecRandom.Core.Tests/PluginDrawServiceTests.cs +++ b/SecRandom.Core.Tests/PluginDrawServiceTests.cs @@ -119,6 +119,7 @@ private static ServiceProvider CreateProvider(bool allowAuthorization) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(_ => new StubSecurityService(allowAuthorization)); diff --git a/SecRandom/App.axaml.cs b/SecRandom/App.axaml.cs index 7a33b9344..ed2aae6c5 100644 --- a/SecRandom/App.axaml.cs +++ b/SecRandom/App.axaml.cs @@ -1085,6 +1085,7 @@ private void BuildHost(IPlatformServiceRoot platform) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/SecRandom/Services/Linkage/ClassIslandIpcConnection.cs b/SecRandom/Services/Linkage/ClassIslandIpcConnection.cs new file mode 100644 index 000000000..c15d10028 --- /dev/null +++ b/SecRandom/Services/Linkage/ClassIslandIpcConnection.cs @@ -0,0 +1,372 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using ClassIsland.Shared.IPC; +using ClassIsland.Shared.IPC.Abstractions.Services; +using dotnetCampus.Ipc.CompilerServices.GeneratedProxies; +using dotnetCampus.Ipc.Pipes; +using Microsoft.Extensions.Logging; +using SecRandom4Ci.Interface.Services; + +namespace SecRandom.Services.Linkage; + +/// +/// ClassIsland IPC 的唯一连接入口。课程联动与 SecRandom4Ci 通知共用同一条 , +/// 避免两个服务各建一条管道并各自订阅广播(issue #274 的 CPU 自激来源之一)。 +/// +public sealed class ClassIslandIpcConnection : IDisposable +{ + private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan JsonRouteReadyDelay = TimeSpan.FromSeconds(1); + private static readonly TimeSpan IpcCallTimeout = TimeSpan.FromSeconds(1); + private static readonly TimeSpan LessonsWaitTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan NotificationWaitTimeout = TimeSpan.FromSeconds(1); + private static readonly TimeSpan MinRetryDelay = TimeSpan.FromSeconds(5); + private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromMinutes(5); + private static readonly TimeSpan ReconnectDelay = TimeSpan.FromMilliseconds(500); + private static readonly Version MinimumPluginVersion = new(1, 2, 0, 0); + + private readonly ILogger _logger; + private readonly SemaphoreSlim _connectionGate = new(1, 1); + private readonly object _stateLock = new(); + + private IpcClient? _client; + private IPublicLessonsService? _lessonsService; + private ISecRandomService? _notificationService; + private DateTimeOffset _nextConnectAttempt = DateTimeOffset.MinValue; + private TimeSpan _currentRetryDelay = MinRetryDelay; + private bool _isDisposed; + private volatile int _connectionState; // 0=disconnected, 1=connecting + + public IPublicLessonsService? LessonsService + { + get + { + lock (_stateLock) + { + return _lessonsService; + } + } + } + + public ISecRandomService? NotificationService + { + get + { + lock (_stateLock) + { + return _notificationService; + } + } + } + + public bool IsConnected + { + get + { + lock (_stateLock) + { + return _lessonsService is not null; + } + } + } + + public event EventHandler? StateChanged; + + public ClassIslandIpcConnection(ILogger logger) + { + _logger = logger; + } + + public Task GetLessonsServiceAsync(CancellationToken cancellationToken = default) + => WaitForLessonsServiceAsync(LessonsWaitTimeout, cancellationToken); + + /// + /// 通知发送路径只等一个很短的窗口:拿不到就立刻走内置回退。等待时间过长会把内置通知 + /// (以及抽取前就该打开的 QuickDraw 结果窗口)拖到十秒之后。 + /// + public async Task GetNotificationServiceAsync(CancellationToken cancellationToken = default) + { + var service = NotificationService; + if (service is not null) + return service; + + await WaitForLessonsServiceAsync(NotificationWaitTimeout, cancellationToken).ConfigureAwait(false); + return NotificationService; + } + + private async Task WaitForLessonsServiceAsync( + TimeSpan waitTimeout, + CancellationToken cancellationToken) + { + var service = LessonsService; + if (service is not null) + return service; + + // 退避期内直接返回:否则每次调用都要白等一整个超时窗口 + if (_isDisposed || DateTimeOffset.UtcNow < NextConnectAttempt) + return null; + + if (Interlocked.CompareExchange(ref _connectionState, 1, 0) == 0 && !_isDisposed) + _ = Task.Run(() => TryConnectAsync()); + + var deadline = DateTimeOffset.UtcNow + waitTimeout; + while (DateTimeOffset.UtcNow < deadline) + { + service = LessonsService; + if (service is not null) + return service; + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + + return null; + } + + private DateTimeOffset NextConnectAttempt + { + get + { + lock (_stateLock) + { + return _nextConnectAttempt; + } + } + set + { + lock (_stateLock) + { + _nextConnectAttempt = value; + } + } + } + + private async Task TryConnectAsync() + { + try + { + await EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false); + } + catch (Exception exception) + { + _logger.LogDebug(exception, "连接 ClassIsland IPC 时发生未处理异常。"); + } + finally + { + Interlocked.Exchange(ref _connectionState, 0); + } + } + + private async Task EnsureConnectedAsync(CancellationToken cancellationToken) + { + if (_isDisposed || DateTimeOffset.UtcNow < NextConnectAttempt) + return false; + + await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (IsConnected) + return true; + + if (_isDisposed || DateTimeOffset.UtcNow < NextConnectAttempt) + return false; + + var client = new IpcClient(); + + // 只订阅生命周期事件。ClassIsland 只在状态变化时广播 CurrentTimeStateChanged, + // 每秒变化的是它自己的主计时器;倒计时由刷新时按需读取,不需要额外订阅。 + client.JsonIpcProvider.AddNotifyHandler(IpcRoutedNotifyIds.OnClassNotifyId, OnClassIslandStateChanged); + client.JsonIpcProvider.AddNotifyHandler(IpcRoutedNotifyIds.OnBreakingTimeNotifyId, OnClassIslandStateChanged); + client.JsonIpcProvider.AddNotifyHandler(IpcRoutedNotifyIds.OnAfterSchoolNotifyId, OnClassIslandStateChanged); + + try + { + await client.Connect().WaitAsync(ConnectTimeout, cancellationToken).ConfigureAwait(false); + await Task.Delay(JsonRouteReadyDelay, cancellationToken).ConfigureAwait(false); + + if (client.PeerProxy is null) + { + DisposeClient(client); + ScheduleRetry(); + return false; + } + + // Handle connection broken - on the PEER, not the provider + client.PeerProxy!.PeerConnectionBroken += (_, _) => OnPeerConnectionBroken(); + + var lessons = GeneratedIpcFactory.CreateIpcProxy(client.Provider, client.PeerProxy); + + // 探测课程服务可用性;属性读取也带超时,避免 ClassIsland 卡住时连接流程被拖死 + var lessonsProbe = await TryInvokeWithTimeoutAsync(() => lessons.IsTimerRunning, IpcCallTimeout) + .ConfigureAwait(false); + var lessonsWork = lessonsProbe.Success; + + // SecRandom4Ci 插件为可选依赖,但必须通过版本门槛(旧插件不满足通知契约) + ISecRandomService? notification = null; + try + { + notification = client.Provider.CreateIpcProxy(client.PeerProxy); + var aliveProbe = await TryInvokeWithTimeoutAsync(notification.IsAlive, IpcCallTimeout) + .ConfigureAwait(false); + var versionProbe = await TryInvokeWithTimeoutAsync(notification.GetPluginVersion, IpcCallTimeout) + .ConfigureAwait(false); + var isAlive = aliveProbe.Success ? aliveProbe.Value : null; + var pluginVersion = versionProbe.Success ? versionProbe.Value : null; + if (!IsNotificationServiceUsable(isAlive, pluginVersion)) + { + _logger.LogDebug( + "SecRandom4Ci 插件不可用或版本低于 {MinimumPluginVersion}:IsAlive={IsAlive},版本={PluginVersion}。", + MinimumPluginVersion, isAlive, pluginVersion); + notification = null; + } + } + catch (Exception exception) + { + _logger.LogDebug(exception, "获取 SecRandom4Ci 通知服务失败。"); + notification = null; + } + + if (!lessonsWork) + { + _logger.LogDebug("ClassIsland IPC 连接成功但课程服务不可用。"); + DisposeClient(client); + ScheduleRetry(); + return false; + } + + lock (_stateLock) + { + _client = client; + _lessonsService = lessons; + _notificationService = notification; + } + NextConnectAttempt = DateTimeOffset.MinValue; + _currentRetryDelay = MinRetryDelay; + + if (notification is not null) + { + _logger.LogInformation("已连接到 ClassIsland IPC:管道={PipeName},SecRandom4Ci 插件可用。", IpcClient.PipeName); + } + else + { + _logger.LogInformation("已连接到 ClassIsland IPC:管道={PipeName},仅课程联动可用(未安装 SecRandom4Ci 插件)。", IpcClient.PipeName); + } + + StateChanged?.Invoke(this, EventArgs.Empty); + return true; + } + catch (Exception exception) + { + _logger.LogDebug(exception, "连接 ClassIsland IPC 失败,将在 {RetryDelay} 后重试。", _currentRetryDelay); + DisposeClient(client); + ScheduleRetry(); + return false; + } + } + finally + { + _connectionGate.Release(); + } + } + + /// + /// 通知服务是否可用:插件必须自报存活,并且版本不低于通知契约的最低要求。 + /// + internal static bool IsNotificationServiceUsable(string? isAlive, Version? pluginVersion) + => string.Equals(isAlive, "Yes", StringComparison.Ordinal) + && pluginVersion is not null + && pluginVersion >= MinimumPluginVersion; + + /// + /// 同步 IPC 调用不能无限等待:ClassIsland 卡住时按超时返回失败,让调用方走“不可用”分支。 + /// + private static async Task<(bool Success, T Value)> TryInvokeWithTimeoutAsync(Func invoke, TimeSpan timeout) + { + try + { + return (true, await Task.Run(invoke).WaitAsync(timeout).ConfigureAwait(false)); + } + catch (Exception) + { + return (false, default!); + } + } + + private void OnPeerConnectionBroken() + { + if (_isDisposed) + return; + + _logger.LogDebug("ClassIsland IPC 连接已断开,将尝试重连。"); + InvalidateConnection(); + // 断开后允许立即重连,但留一点间隔,避免与 ClassIsland 的广播/管道清理抢时序 + NextConnectAttempt = DateTimeOffset.MinValue; + _currentRetryDelay = MinRetryDelay; + + _ = Task.Run(async () => + { + try + { + await Task.Delay(ReconnectDelay).ConfigureAwait(false); + if (!_isDisposed) + await TryConnectAsync().ConfigureAwait(false); + } + catch (Exception exception) + { + _logger.LogDebug(exception, "ClassIsland IPC 重连失败,等待下一次刷新重试。"); + } + }); + + StateChanged?.Invoke(this, EventArgs.Empty); + } + + private void OnClassIslandStateChanged() + { + if (_isDisposed) + return; + + StateChanged?.Invoke(this, EventArgs.Empty); + } + + private void InvalidateConnection() + { + IpcClient? client; + lock (_stateLock) + { + client = _client; + _client = null; + _lessonsService = null; + _notificationService = null; + } + DisposeClient(client); + } + + private static void DisposeClient(IpcClient? client) + { + if (client is null) + return; + + try + { + client.Provider.Dispose(); + } + catch (Exception) + { + } + } + + private void ScheduleRetry() + { + NextConnectAttempt = DateTimeOffset.UtcNow.Add(_currentRetryDelay); + _currentRetryDelay = TimeSpan.FromSeconds(Math.Min(_currentRetryDelay.TotalSeconds * 2, MaxRetryDelay.TotalSeconds)); + } + + public void Dispose() + { + if (_isDisposed) + return; + + _isDisposed = true; + InvalidateConnection(); + // 不释放 _connectionGate:进行中的等待/连接可能仍持有它 + } +} diff --git a/SecRandom/Services/Linkage/ClassIslandScheduleSource.cs b/SecRandom/Services/Linkage/ClassIslandScheduleSource.cs index 6923a24f3..d00bd8589 100644 --- a/SecRandom/Services/Linkage/ClassIslandScheduleSource.cs +++ b/SecRandom/Services/Linkage/ClassIslandScheduleSource.cs @@ -2,34 +2,33 @@ using System.Threading; using System.Threading.Tasks; using ClassIsland.Shared.Enums; -using ClassIsland.Shared.IPC; using ClassIsland.Shared.IPC.Abstractions.Services; -using dotnetCampus.Ipc.CompilerServices.GeneratedProxies; -using dotnetCampus.Ipc.Pipes; using Microsoft.Extensions.Logging; using SecRandom.Core.Models.Linkage; namespace SecRandom.Services.Linkage; -public sealed class ClassIslandScheduleSource(ILogger logger) : ICourseScheduleSource +public sealed class ClassIslandScheduleSource : ICourseScheduleSource { - private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5); - private static readonly TimeSpan JsonRouteReadyDelay = TimeSpan.FromSeconds(1); - private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(5); - private readonly SemaphoreSlim _connectionGate = new(1, 1); - private IpcClient? _client; - private IPublicLessonsService? _lessons; + private readonly ClassIslandIpcConnection _ipcConnection; + private readonly ILogger _logger; private string _lastKnownCourseName = string.Empty; private DateOnly? _lastKnownCourseDate; private DateTime? _lastKnownCourseEnd; - private DateTimeOffset _nextConnectAttempt = DateTimeOffset.MinValue; public string SourceName => "ClassIsland"; public event EventHandler? StateChanged; + public ClassIslandScheduleSource(ClassIslandIpcConnection ipcConnection, ILogger logger) + { + _ipcConnection = ipcConnection; + _logger = logger; + _ipcConnection.StateChanged += (_, _) => StateChanged?.Invoke(this, EventArgs.Empty); + } + public async Task GetSnapshotAsync(CancellationToken cancellationToken = default) { - var lessons = await GetLessonsAsync(cancellationToken).ConfigureAwait(false); + var lessons = await _ipcConnection.GetLessonsServiceAsync(cancellationToken).ConfigureAwait(false); if (lessons is null) return CourseScheduleSnapshot.Unavailable(SourceName, ScheduleErrorCodes.ClassIslandUnavailable); @@ -91,6 +90,7 @@ public async Task GetSnapshotAsync(CancellationToken can lastEnd.TimeOfDay <= now.TimeOfDay ? (TimeSpan?)(now - lastEnd) : null; + // Version 只包含稳定的定位信息(时间点索引 + 状态),不含倒计时字段:联动的语义比较依赖它 return new CourseScheduleSnapshot( true, state, @@ -105,89 +105,11 @@ public async Task GetSnapshotAsync(CancellationToken can } catch (Exception exception) { - logger.LogDebug(exception, "读取 ClassIsland 日程状态失败。"); - InvalidateConnection(); + _logger.LogDebug(exception, "读取 ClassIsland 日程状态失败。"); return CourseScheduleSnapshot.Unavailable(SourceName, ScheduleErrorCodes.ClassIslandReadFailed); } } - private async Task GetLessonsAsync(CancellationToken cancellationToken) - { - if (_lessons is not null) - return _lessons; - if (DateTimeOffset.UtcNow < _nextConnectAttempt) - return null; - - await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - if (_lessons is not null) - return _lessons; - if (DateTimeOffset.UtcNow < _nextConnectAttempt) - return null; - - var client = new IpcClient(); - client.JsonIpcProvider.AddNotifyHandler(IpcRoutedNotifyIds.OnClassNotifyId, OnClassIslandStateChanged); - client.JsonIpcProvider.AddNotifyHandler(IpcRoutedNotifyIds.OnBreakingTimeNotifyId, OnClassIslandStateChanged); - client.JsonIpcProvider.AddNotifyHandler(IpcRoutedNotifyIds.OnAfterSchoolNotifyId, OnClassIslandStateChanged); - client.JsonIpcProvider.AddNotifyHandler(IpcRoutedNotifyIds.CurrentTimeStateChangedNotifyId, OnClassIslandStateChanged); - await client.Connect().WaitAsync(ConnectTimeout, cancellationToken).ConfigureAwait(false); - // ClassIsland establishes its JSON routed peer asynchronously after the transport connection. - await Task.Delay(JsonRouteReadyDelay, cancellationToken).ConfigureAwait(false); - if (client.PeerProxy is null) - { - DisposeClient(client); - ScheduleRetry(); - return null; - } - - _client = client; - _lessons = GeneratedIpcFactory.CreateIpcProxy(client.Provider, client.PeerProxy); - _nextConnectAttempt = DateTimeOffset.MinValue; - logger.LogInformation("已连接到 ClassIsland IPC:管道={PipeName}。", IpcClient.PipeName); - return _lessons; - } - catch (Exception exception) - { - logger.LogDebug(exception, "连接 ClassIsland IPC 失败,将在 {RetryDelay} 后重试。", RetryDelay); - InvalidateConnection(); - ScheduleRetry(); - return null; - } - finally - { - _connectionGate.Release(); - } - } - - private void OnClassIslandStateChanged() - { - StateChanged?.Invoke(this, EventArgs.Empty); - } - - private void InvalidateConnection() - { - _lessons = null; - DisposeClient(_client); - _client = null; - } - - private static void DisposeClient(IpcClient? client) - { - try - { - client?.Provider.Dispose(); - } - catch (Exception) - { - } - } - - private void ScheduleRetry() - { - _nextConnectAttempt = DateTimeOffset.UtcNow.Add(RetryDelay); - } - private static string NormalizeSubjectName(string? name) { var normalized = name?.Trim() ?? string.Empty; diff --git a/SecRandom/Services/Linkage/CourseLinkageService.cs b/SecRandom/Services/Linkage/CourseLinkageService.cs index f1a690300..4028630ce 100644 --- a/SecRandom/Services/Linkage/CourseLinkageService.cs +++ b/SecRandom/Services/Linkage/CourseLinkageService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; @@ -58,9 +58,15 @@ public async Task RefreshAsync(CancellationToken cancellationToken = default) var next = source is null ? CourseScheduleSnapshot.Unavailable("Off") : await source.GetSnapshotAsync(cancellationToken).ConfigureAwait(false); - if (Equals(_snapshot, next)) - return; + + // 语义比较只用来决定“是否通知订阅者”:倒计时字段每秒都在变,按 record 全量相等判断会不断 + // 触发 StateChanged,把 CourseLinkageHostedService 变成自激刷新循环(issue #274)。 + // 但快照本身必须始终更新 —— 课前解禁、课后禁用延迟、课前重置和刷新调度都只从这里读倒计时。 + var stateChangedSemantically = !SnapshotsEqual(_snapshot, next); _snapshot = next; + if (!stateChangedSemantically) + return; + stateChanged = true; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -71,11 +77,8 @@ public async Task RefreshAsync(CancellationToken cancellationToken = default) { _logger.LogWarning(exception, "刷新课程联动状态失败。"); var unavailable = CourseScheduleSnapshot.Unavailable("Unknown", exception.Message); - if (!Equals(_snapshot, unavailable)) - { - _snapshot = unavailable; - stateChanged = true; - } + stateChanged = !SnapshotsEqual(_snapshot, unavailable); + _snapshot = unavailable; } finally { @@ -86,6 +89,18 @@ public async Task RefreshAsync(CancellationToken cancellationToken = default) NotifyStateChanged(); } + private static bool SnapshotsEqual(CourseScheduleSnapshot a, CourseScheduleSnapshot b) + { + return a.IsAvailable == b.IsAvailable + && a.State == b.State + && a.Source == b.Source + && a.Version == b.Version + && a.CurrentCourse?.Name == b.CurrentCourse?.Name + && a.PreviousCourse?.Name == b.PreviousCourse?.Name + && a.NextCourse?.Name == b.NextCourse?.Name + && a.Error == b.Error; + } + public bool IsConfirmedBreakTime => _snapshot.IsAvailable && _snapshot.State == CourseTimeState.Breaking && !IsWithinEnableWindow(_snapshot); @@ -126,8 +141,9 @@ public string GetSubjectFilter() public TimeSpan GetNextRefreshDelay() { + // Only poll when completely unavailable - otherwise rely on events if (Settings.DataSource == LinkageDataSource.ClassIsland && !_snapshot.IsAvailable) - return TimeSpan.FromSeconds(5); + return TimeSpan.FromMinutes(5); List candidates = []; if (_snapshot.TimeUntilNextCourse is { } untilNext && untilNext > TimeSpan.Zero) diff --git a/SecRandom/Services/Notification/NotificationService.cs b/SecRandom/Services/Notification/NotificationService.cs index 32b34b2fa..dd3b608cb 100644 --- a/SecRandom/Services/Notification/NotificationService.cs +++ b/SecRandom/Services/Notification/NotificationService.cs @@ -1,11 +1,11 @@ using Avalonia.Threading; using ClassIsland.Shared.IPC; -using dotnetCampus.Ipc.CompilerServices.GeneratedProxies; using Microsoft.Extensions.Logging; using SecRandom.Core.Enums; using SecRandom.Core.Models.SubConfigs; using SecRandom.Core.Services.Config; using SecRandom.Core.Services.Draw; +using SecRandom.Services.Linkage; using SecRandom.Shared.Models.Profile; using SecRandom4Ci.Interface.Enums; using SecRandom4Ci.Interface.Models; @@ -16,26 +16,21 @@ namespace SecRandom.Services.Notification; public sealed class NotificationService : IDisposable { - private static readonly Version MinimumPluginVersion = new(1, 2, 0, 0); - private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(1); private static readonly TimeSpan InvocationTimeout = TimeSpan.FromSeconds(1); - private static readonly TimeSpan JsonRouteReadyDelay = TimeSpan.FromMilliseconds(100); - private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(5); private readonly MainConfigHandler _configHandler; private readonly ILogger _logger; + private readonly ClassIslandIpcConnection _ipcConnection; private readonly CryptoRandomSource _previewRandom = new(); private readonly SemaphoreSlim _sendGate = new(1, 1); - private IpcClient? _classIslandClient; - private ISecRandomService? _classIslandService; - private DateTimeOffset _nextClassIslandConnectAttempt = DateTimeOffset.MinValue; private bool _quickDrawBuiltInPreviewActive; private bool _isDisposed; - public NotificationService(MainConfigHandler configHandler, ILogger logger) + public NotificationService(MainConfigHandler configHandler, ILogger logger, ClassIslandIpcConnection ipcConnection) { _configHandler = configHandler; _logger = logger; + _ipcConnection = ipcConnection; } public void QueueStudents( @@ -270,93 +265,73 @@ private async Task SendToClassIslandAsync(NotificationData notification, Action? if (_isDisposed) return; - var service = await GetClassIslandServiceAsync().ConfigureAwait(false); + var service = await _ipcConnection.GetNotificationServiceAsync().ConfigureAwait(false); if (service is null) { builtInFallback?.Invoke(); return; } - if (!string.Equals( - await InvokeClassIslandAsync(service.IsAlive).ConfigureAwait(false), - "Yes", - StringComparison.Ordinal)) + string? isAlive = null; + try + { + isAlive = await InvokeClassIslandAsync(service.IsAlive).ConfigureAwait(false); + } + catch (AggregateException aggEx) when (aggEx.InnerExceptions.Count == 1) + { + _logger.LogDebug(aggEx.InnerExceptions[0], "SecRandom4Ci 通知服务 IsAlive 调用失败。"); + } + catch (dotnetCampus.Ipc.Exceptions.IpcPeerConnectionBrokenException) + { + // Peer disconnected during IsAlive check + } + catch (Exception exception) + { + _logger.LogDebug(exception, "SecRandom4Ci 通知服务 IsAlive 调用失败。"); + } + + if (!string.Equals(isAlive, "Yes", StringComparison.Ordinal)) { _logger.LogDebug("SecRandom4Ci 通知服务未响应。"); - InvalidateClassIslandConnection(); - ScheduleClassIslandRetry(); builtInFallback?.Invoke(); return; } - await InvokeClassIslandAsync(() => service.ShowNotification(notification)).ConfigureAwait(false); - } - catch (Exception exception) - { - _logger.LogDebug(exception, "通过 SecRandom4Ci 插件发送 ClassIsland 通知失败。"); - InvalidateClassIslandConnection(); - ScheduleClassIslandRetry(); - builtInFallback?.Invoke(); - } - finally - { - _sendGate.Release(); - } - } - - private async Task GetClassIslandServiceAsync() - { - if (_isDisposed) - return null; - if (_classIslandService is not null) - return _classIslandService; - if (DateTimeOffset.UtcNow < _nextClassIslandConnectAttempt) - return null; - - IpcClient? client = null; - try - { - client = new IpcClient(); - await client.Connect().WaitAsync(ConnectTimeout).ConfigureAwait(false); - // ClassIsland initializes the JSON routed peer after the named pipe connects. - await Task.Delay(JsonRouteReadyDelay).ConfigureAwait(false); - if (client.PeerProxy is null) + try { - DisposeClient(client); - ScheduleClassIslandRetry(); - return null; + await InvokeClassIslandAsync(() => service.ShowNotification(notification)).ConfigureAwait(false); } - - var service = client.Provider.CreateIpcProxy(client.PeerProxy); - var pluginVersion = await InvokeClassIslandAsync(service.GetPluginVersion).ConfigureAwait(false); - if (pluginVersion is null || pluginVersion < MinimumPluginVersion || - !string.Equals( - await InvokeClassIslandAsync(service.IsAlive).ConfigureAwait(false), - "Yes", - StringComparison.Ordinal)) + catch (AggregateException aggEx) when (aggEx.InnerExceptions.Count == 1) { - _logger.LogDebug("SecRandom4Ci 插件不可用或版本低于 {MinimumPluginVersion}。", MinimumPluginVersion); - DisposeClient(client); - ScheduleClassIslandRetry(); - return null; + _logger.LogDebug(aggEx.InnerExceptions[0], "通过 SecRandom4Ci 插件发送 ClassIsland 通知失败。"); + builtInFallback?.Invoke(); + } + catch (dotnetCampus.Ipc.Exceptions.IpcPeerConnectionBrokenException) + { + // Peer disconnected during notification - trigger fallback silently + builtInFallback?.Invoke(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "通过 SecRandom4Ci 插件发送 ClassIsland 通知失败。"); + builtInFallback?.Invoke(); } - - _classIslandClient = client; - _classIslandService = service; - _nextClassIslandConnectAttempt = DateTimeOffset.MinValue; - _logger.LogInformation("已连接到 ClassIsland IPC,SecRandom4Ci {PluginVersion} 通知服务可用。", pluginVersion); - return _classIslandService; } - catch (Exception exception) + finally { - _logger.LogDebug(exception, "连接 ClassIsland IPC 的 SecRandom4Ci 通知服务失败。"); - DisposeClient(client); - InvalidateClassIslandConnection(); - ScheduleClassIslandRetry(); - return null; + _sendGate.Release(); } } + /// + /// SecRandom4Ci 的代理方法是同步 IPC 调用,ClassIsland 卡住时不能无限等待。 + /// + private static Task InvokeClassIslandAsync(Func invoke) + => Task.Run(invoke).WaitAsync(InvocationTimeout); + + private static Task InvokeClassIslandAsync(Action invoke) + => Task.Run(invoke).WaitAsync(InvocationTimeout); + private static NotificationItem CreateStudentItem(ProfileStudent student) { return new NotificationItem @@ -396,42 +371,6 @@ private List DrawPreviewItems(List candidates, int drawCount) return candidates.Take(count).ToList(); } - private void InvalidateClassIslandConnection() - { - _classIslandService = null; - DisposeClient(_classIslandClient); - _classIslandClient = null; - } - - private static void DisposeClient(IpcClient? client) - { - if (client is null) - return; - - try - { - client.Provider.Dispose(); - } - catch (Exception) - { - } - } - - private static Task InvokeClassIslandAsync(Func invoke) - { - return Task.Run(invoke).WaitAsync(InvocationTimeout); - } - - private static Task InvokeClassIslandAsync(Action invoke) - { - return Task.Run(invoke).WaitAsync(InvocationTimeout); - } - - private void ScheduleClassIslandRetry() - { - _nextClassIslandConnectAttempt = DateTimeOffset.UtcNow.Add(RetryDelay); - } - private static ResultType GetResultType(NotificationSettingsType type) { return type switch @@ -467,6 +406,5 @@ private static string DisplayValue(string primary, string fallback) public void Dispose() { _isDisposed = true; - InvalidateClassIslandConnection(); } }