diff --git a/build/ControlPanelVersion.props b/build/ControlPanelVersion.props
index f6f147afdc..02d75e7142 100644
--- a/build/ControlPanelVersion.props
+++ b/build/ControlPanelVersion.props
@@ -1,6 +1,6 @@
- 4.26.0
+ 4.26.2
diff --git a/build/Version.props b/build/Version.props
index 78ae02ffda..e3a0b04745 100644
--- a/build/Version.props
+++ b/build/Version.props
@@ -8,7 +8,7 @@
10.0.0
7.0.0
13.0.0
- 16.0.0
+ 15.0.0
6.6.2
5.6.2
1.4.0
diff --git a/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj b/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj
index 3573822c52..e499846add 100644
--- a/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj
+++ b/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj
@@ -27,7 +27,7 @@
-
+
diff --git a/src/Tgstation.Server.Client/ApiClient.cs b/src/Tgstation.Server.Client/ApiClient.cs
index 28c5611bdd..4f6be11b5a 100644
--- a/src/Tgstation.Server.Client/ApiClient.cs
+++ b/src/Tgstation.Server.Client/ApiClient.cs
@@ -372,13 +372,15 @@ namespace Tgstation.Server.Client
retryPolicy ??= new InfiniteThirtySecondMaxRetryPolicy();
+ var wrappedPolicy = new ApiClientTokenRefreshRetryPolicy(this, retryPolicy);
+
HubConnection? hubConnection = null;
var hubConnectionBuilder = new HubConnectionBuilder()
.AddNewtonsoftJsonProtocol(options =>
{
options.PayloadSerializerSettings = SerializerSettings;
})
- .WithAutomaticReconnect(retryPolicy)
+ .WithAutomaticReconnect(wrappedPolicy)
.WithUrl(
new Uri(Url, Routes.JobsHub),
HttpTransportType.ServerSentEvents,
@@ -480,6 +482,9 @@ namespace Tgstation.Server.Client
if (content == null && (method == HttpMethod.Post || method == HttpMethod.Put))
throw new InvalidOperationException("content cannot be null for POST or PUT!");
+ if (disposed)
+ throw new ObjectDisposedException(nameof(ApiClient));
+
HttpResponseMessage response;
var fullUri = new Uri(Url, route);
var serializerSettings = SerializerSettings;
diff --git a/src/Tgstation.Server.Client/ApiClientTokenRefreshRetryPolicy.cs b/src/Tgstation.Server.Client/ApiClientTokenRefreshRetryPolicy.cs
new file mode 100644
index 0000000000..34556c24ec
--- /dev/null
+++ b/src/Tgstation.Server.Client/ApiClientTokenRefreshRetryPolicy.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Threading;
+
+using Microsoft.AspNetCore.SignalR.Client;
+
+namespace Tgstation.Server.Client
+{
+ ///
+ /// A that attempts to refresh a given 's token on the first disconnect.
+ ///
+ sealed class ApiClientTokenRefreshRetryPolicy : IRetryPolicy
+ {
+ ///
+ /// The backing .
+ ///
+ readonly ApiClient apiClient;
+
+ ///
+ /// The wrapped .
+ ///
+ readonly IRetryPolicy wrappedPolicy;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The value of .
+ /// The value of .
+ public ApiClientTokenRefreshRetryPolicy(ApiClient apiClient, IRetryPolicy wrappedPolicy)
+ {
+ this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient));
+ this.wrappedPolicy = wrappedPolicy ?? throw new ArgumentNullException(nameof(wrappedPolicy));
+ }
+
+ ///
+ public TimeSpan? NextRetryDelay(RetryContext retryContext)
+ {
+ if (retryContext == null)
+ throw new ArgumentNullException(nameof(retryContext));
+
+ if (retryContext.PreviousRetryCount == 0)
+ AttemptTokenRefresh();
+
+ return wrappedPolicy.NextRetryDelay(retryContext);
+ }
+
+ ///
+ /// Attempt to refresh the s token asynchronously.
+ ///
+ async void AttemptTokenRefresh()
+ {
+ try
+ {
+ await apiClient.RefreshToken(CancellationToken.None);
+ }
+ catch
+ {
+ // intentionally ignored
+ }
+ }
+ }
+}
diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs
index 42869e8a0e..d730b9de10 100644
--- a/src/Tgstation.Server.Host/Core/Application.cs
+++ b/src/Tgstation.Server.Host/Core/Application.cs
@@ -403,7 +403,9 @@ namespace Tgstation.Server.Host.Core
services.AddSingleton(provider => provider.GetRequiredService());
services.AddSingleton(provider => provider.GetRequiredService());
services.AddSingleton(x => x.GetRequiredService());
- services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(provider => provider.GetRequiredService());
+ services.AddSingleton(x => x.GetRequiredService()); // bit of a hack, but we need this to load immediated
services.AddSingleton();
services.AddSingleton(x => x.GetRequiredService());
diff --git a/src/Tgstation.Server.Host/Jobs/JobsHubGroupMapper.cs b/src/Tgstation.Server.Host/Jobs/JobsHubGroupMapper.cs
index 77ccf99b0b..719138e6ad 100644
--- a/src/Tgstation.Server.Host/Jobs/JobsHubGroupMapper.cs
+++ b/src/Tgstation.Server.Host/Jobs/JobsHubGroupMapper.cs
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Tgstation.Server.Api.Hubs;
@@ -19,7 +20,7 @@ namespace Tgstation.Server.Host.Jobs
///
/// Handles mapping groups for the .
///
- sealed class JobsHubGroupMapper : IPermissionsUpdateNotifyee
+ sealed class JobsHubGroupMapper : IPermissionsUpdateNotifyee, IHostedService
{
///
/// The for the .
@@ -96,6 +97,12 @@ namespace Tgstation.Server.Host.Jobs
cancellationToken);
}
+ ///
+ public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ ///
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
///
/// Implementation of .
///
diff --git a/src/Tgstation.Server.Host/Utils/SignalR/ComprehensiveHubContext.cs b/src/Tgstation.Server.Host/Utils/SignalR/ComprehensiveHubContext.cs
index 7999908052..3892aad56c 100644
--- a/src/Tgstation.Server.Host/Utils/SignalR/ComprehensiveHubContext.cs
+++ b/src/Tgstation.Server.Host/Utils/SignalR/ComprehensiveHubContext.cs
@@ -83,12 +83,13 @@ namespace Tgstation.Server.Host.Utils.SignalR
userId,
context.ConnectionId);
- var mappingTask = OnConnectionMapGroups(
+ var mappingTask = OnConnectionMapGroups?.Invoke(
authenticationContext,
mappedGroups => Task.WhenAll(
mappedGroups.Select(
group => hub.Groups.AddToGroupAsync(context.ConnectionId, group, cancellationToken))),
- cancellationToken);
+ cancellationToken)
+ ?? ValueTask.CompletedTask;
userConnections.AddOrUpdate(
userId,
_ => new Dictionary