diff --git a/src/Tgstation.Server.Client.GraphQL/.graphqlrc.json b/src/Tgstation.Server.Client.GraphQL/.graphqlrc.json
index 0b651ad84f..a6148380bc 100644
--- a/src/Tgstation.Server.Client.GraphQL/.graphqlrc.json
+++ b/src/Tgstation.Server.Client.GraphQL/.graphqlrc.json
@@ -13,7 +13,7 @@
"transportProfiles": [
{
"default": "Http",
- "subscription": "WebSocket"
+ "subscription": "Http"
}
]
}
diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Subscriptions/SessionInvalidation.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Subscriptions/SessionInvalidation.graphql
new file mode 100644
index 0000000000..e7e5baf3de
--- /dev/null
+++ b/src/Tgstation.Server.Client.GraphQL/GQL/Subscriptions/SessionInvalidation.graphql
@@ -0,0 +1,3 @@
+subscription SessionInvalidation {
+ sessionInvalidated
+}
diff --git a/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs
index 178c44e713..0f46608df1 100644
--- a/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs
+++ b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs
@@ -124,19 +124,47 @@ namespace Tgstation.Server.Client.GraphQL
public virtual ValueTask DisposeAsync() => serviceProvider.DisposeAsync();
///
- public ValueTask> RunOperationAsync(Func>> queryExector, CancellationToken cancellationToken)
+ public ValueTask> RunOperationAsync(Func>> operationExecutor, CancellationToken cancellationToken)
where TResultData : class
{
- ArgumentNullException.ThrowIfNull(queryExector);
- return WrapAuthentication(queryExector, cancellationToken);
+ ArgumentNullException.ThrowIfNull(operationExecutor);
+ return WrapAuthentication(operationExecutor, cancellationToken);
}
///
- public ValueTask> RunOperation(Func>> queryExector, CancellationToken cancellationToken)
+ public ValueTask> RunOperation(Func>> operationExecutor, CancellationToken cancellationToken)
where TResultData : class
{
- ArgumentNullException.ThrowIfNull(queryExector);
- return WrapAuthentication(async localClient => await queryExector(localClient), cancellationToken);
+ ArgumentNullException.ThrowIfNull(operationExecutor);
+ return WrapAuthentication(async localClient => await operationExecutor(localClient), cancellationToken);
+ }
+
+ ///
+ public async ValueTask Subscribe(Func>> operationExecutor, IObserver> observer, CancellationToken cancellationToken)
+ where TResultData : class
+ {
+ ArgumentNullException.ThrowIfNull(operationExecutor);
+ ArgumentNullException.ThrowIfNull(observer);
+
+ var observable = operationExecutor(graphQLClient);
+
+ if (Authenticated)
+ {
+ var tuple = await bearerCredentialsTask.ConfigureAwait(false);
+ if (!tuple.HasValue)
+ ThrowOtherCallerFailedAuthException();
+
+ var (currentAuthHeader, expires) = tuple.Value;
+ if (expires <= DateTimeOffset.UtcNow)
+ currentAuthHeader = await Reauthenticate(currentAuthHeader, cancellationToken).ConfigureAwait(false);
+
+ setAuthenticationHeader(currentAuthHeader);
+ }
+
+ // maybe make this handle reauthentication one day
+ // but would need to check if lost auth results in complete events being sent
+ // if so, it can't be done
+ return observable.Subscribe(observer);
}
///
@@ -167,59 +195,6 @@ namespace Tgstation.Server.Client.GraphQL
if (!tuple.HasValue)
ThrowOtherCallerFailedAuthException();
- async ValueTask Reauthenticate(AuthenticationHeaderValue currentToken, CancellationToken cancellationToken)
- {
- if (!CanReauthenticate)
- throw new AuthenticationException("Authentication expired or invalid and cannot re-authenticate.");
-
- TaskCompletionSource<(AuthenticationHeaderValue Header, DateTime Exp)?>? tcs = null;
- do
- {
- var bearerCredentialsTaskLocal = bearerCredentialsTask;
- if (!bearerCredentialsTaskLocal!.IsCompleted)
- {
- var currentTuple = await bearerCredentialsTaskLocal.ConfigureAwait(false);
- if (!currentTuple.HasValue)
- ThrowOtherCallerFailedAuthException();
-
- return currentTuple.Value.Header;
- }
-
- lock (bearerCredentialsHeaderTaskLock!)
- {
- if (bearerCredentialsTask == bearerCredentialsTaskLocal)
- {
- var result = bearerCredentialsTaskLocal.Result;
- if (result?.Header != currentToken)
- {
- if (!result.HasValue)
- ThrowOtherCallerFailedAuthException();
-
- return result.Value.Header;
- }
-
- tcs = new TaskCompletionSource<(AuthenticationHeaderValue, DateTime)?>();
- bearerCredentialsTask = tcs.Task;
- }
- }
- }
- while (tcs == null);
-
- setAuthenticationHeader!(basicCredentialsHeader!);
- var loginResult = await graphQLClient.Login.ExecuteAsync(cancellationToken).ConfigureAwait(false);
- try
- {
- var tuple = await CreateCredentialsTuple(loginResult).ConfigureAwait(false);
- tcs.SetResult(tuple);
- return tuple.Header;
- }
- catch (AuthenticationException)
- {
- tcs.SetResult(null);
- throw;
- }
- }
-
var (currentAuthHeader, expires) = tuple.Value;
if (expires <= DateTimeOffset.UtcNow)
currentAuthHeader = await Reauthenticate(currentAuthHeader, cancellationToken).ConfigureAwait(false);
@@ -238,6 +213,65 @@ namespace Tgstation.Server.Client.GraphQL
return operationResult;
}
+ ///
+ /// Attempt to reauthenticate.
+ ///
+ /// The current for the bearer token.
+ /// The for the operation.
+ /// A resulting in the updated to use.
+ async ValueTask Reauthenticate(AuthenticationHeaderValue currentToken, CancellationToken cancellationToken)
+ {
+ if (!CanReauthenticate)
+ throw new AuthenticationException("Authentication expired or invalid and cannot re-authenticate.");
+
+ TaskCompletionSource<(AuthenticationHeaderValue Header, DateTime Exp)?>? tcs = null;
+ do
+ {
+ var bearerCredentialsTaskLocal = bearerCredentialsTask;
+ if (!bearerCredentialsTaskLocal!.IsCompleted)
+ {
+ var currentTuple = await bearerCredentialsTaskLocal.ConfigureAwait(false);
+ if (!currentTuple.HasValue)
+ ThrowOtherCallerFailedAuthException();
+
+ return currentTuple.Value.Header;
+ }
+
+ lock (bearerCredentialsHeaderTaskLock!)
+ {
+ if (bearerCredentialsTask == bearerCredentialsTaskLocal)
+ {
+ var result = bearerCredentialsTaskLocal.Result;
+ if (result?.Header != currentToken)
+ {
+ if (!result.HasValue)
+ ThrowOtherCallerFailedAuthException();
+
+ return result.Value.Header;
+ }
+
+ tcs = new TaskCompletionSource<(AuthenticationHeaderValue, DateTime)?>();
+ bearerCredentialsTask = tcs.Task;
+ }
+ }
+ }
+ while (tcs == null);
+
+ setAuthenticationHeader!(basicCredentialsHeader!);
+ var loginResult = await graphQLClient.Login.ExecuteAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ var tuple = await CreateCredentialsTuple(loginResult).ConfigureAwait(false);
+ tcs.SetResult(tuple);
+ return tuple.Header;
+ }
+ catch (AuthenticationException)
+ {
+ tcs.SetResult(null);
+ throw;
+ }
+ }
+
///
/// Attempt to create the for .
///
diff --git a/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs b/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs
index c20a606fa0..bddfeae618 100644
--- a/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs
+++ b/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs
@@ -32,5 +32,16 @@ namespace Tgstation.Server.Client.GraphQL
/// Thrown when automatic reauthentication fails.
ValueTask> RunOperation(Func>> operationExecutor, CancellationToken cancellationToken)
where TResultData : class;
+
+ ///
+ /// Subcribes to the GraphQL subscription indicated by .
+ ///
+ /// The of the 's .
+ /// A which initiates a single subscription on a given and returns a resulting in the .
+ /// The for s.
+ /// The for the operation.
+ /// A resulting in the representing the lifetime of the subscription.
+ ValueTask Subscribe(Func>> operationExecutor, IObserver> observer, CancellationToken cancellationToken)
+ where TResultData : class;
}
}
diff --git a/tests/Tgstation.Server.Tests/Live/HoldLastObserver.cs b/tests/Tgstation.Server.Tests/Live/HoldLastObserver.cs
new file mode 100644
index 0000000000..e5ee2b32d8
--- /dev/null
+++ b/tests/Tgstation.Server.Tests/Live/HoldLastObserver.cs
@@ -0,0 +1,34 @@
+using System;
+
+namespace Tgstation.Server.Tests.Live
+{
+ sealed class HoldLastObserver : IObserver
+ {
+ public bool Completed { get; private set; }
+
+ public Exception LastError { get; private set; }
+
+ public T LastValue { get; private set; }
+
+ public ulong ErrorCount { get; private set; }
+
+ public ulong ResultCount { get; private set; }
+
+ public void OnCompleted()
+ {
+ Completed = true;
+ }
+
+ public void OnError(Exception error)
+ {
+ ++ErrorCount;
+ LastError = error;
+ }
+
+ public void OnNext(T value)
+ {
+ ++ResultCount;
+ LastValue = value;
+ }
+ }
+}
diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs
index 93776764e6..6071e51d48 100644
--- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs
+++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs
@@ -1395,6 +1395,19 @@ namespace Tgstation.Server.Tests.Live
return result;
},
cancellationToken);
+
+ var testObserver = new HoldLastObserver>();
+ using var subscription = await unauthenticatedGraphQLClient.Subscribe(
+ gql => gql.SessionInvalidation.Watch(),
+ testObserver,
+ cancellationToken);
+
+ await Task.Delay(1000, cancellationToken);
+
+ Assert.AreEqual(0U, testObserver.ErrorCount);
+ Assert.AreEqual(1U, testObserver.ResultCount);
+ Assert.IsTrue(testObserver.LastValue.IsAuthenticationError());
+ Assert.IsTrue(testObserver.Completed);
}
async ValueTask CreateUserWithNoInstancePerms()
@@ -1416,6 +1429,8 @@ namespace Tgstation.Server.Tests.Live
return await CreateClient(server.RootUrl, createRequest.Name, createRequest.Password, false, cancellationToken);
}
+ var restartObserver = new HoldLastObserver>();
+ IDisposable restartSubscription;
var jobsHubTest = new JobsHubTests(firstAdminMultiClient, await CreateUserWithNoInstancePerms());
Task jobsHubTestTask;
{
@@ -1579,12 +1594,45 @@ namespace Tgstation.Server.Tests.Live
initialStaged = dd.StagedCompileJob.Id.Value;
initialSessionId = dd.SessionId.Value;
- jobsHubTest.ExpectShutdown();
- await firstAdminRestClient.Administration.Restart(cancellationToken);
+ // force a session refresh if necessary
+ await firstAdminMultiClient.GraphQLClient.RunQueryEnsureNoErrors(
+ gql => gql.ReadCurrentUser.ExecuteAsync(cancellationToken),
+ cancellationToken);
+
+ restartSubscription = await firstAdminMultiClient.GraphQLClient.Subscribe(
+ gql => gql.SessionInvalidation.Watch(),
+ restartObserver,
+ cancellationToken);
+
+ try
+ {
+ await Task.Delay(1000, cancellationToken);
+
+ jobsHubTest.ExpectShutdown();
+ await firstAdminRestClient.Administration.Restart(cancellationToken);
+ }
+ catch
+ {
+ restartSubscription.Dispose();
+ throw;
+ }
}
- await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken));
- Assert.IsTrue(serverTask.IsCompleted);
+ try
+ {
+ await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken));
+ Assert.IsTrue(serverTask.IsCompleted);
+
+ Assert.AreEqual(0U, restartObserver.ErrorCount);
+ Assert.AreEqual(1U, restartObserver.ResultCount);
+ restartObserver.LastValue.EnsureNoErrors();
+ Assert.IsTrue(restartObserver.Completed);
+ Assert.AreEqual(SessionInvalidationReason.ServerShutdown, restartObserver.LastValue.Data.SessionInvalidated);
+ }
+ finally
+ {
+ restartSubscription.Dispose();
+ }
// test the reattach message queueing
// for the code coverage really...