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();
}
}