diff --git a/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs b/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs
index d25af1d46..5972e5eb9 100644
--- a/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs
+++ b/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs
@@ -1,4 +1,5 @@
using System;
+using System.Threading;
using Server.Accounting;
using Server.Accounting.Security;
using Xunit;
@@ -19,6 +20,42 @@ public class PasswordWorkerTests : IDisposable
private static Account CreateAccount(string username) =>
Accounts.GetAccount(username) as Account ?? new Account(username, Password);
+ ///
+ /// Enqueues work, then pumps the loop context until or the deadline.
+ ///
+ /// The context pins itself to the thread that constructed it and refuses ExecuteTasks
+ /// from any other. The fixture's belongs to whichever thread built the fixture, and xUnit gives
+ /// no guarantee that a test method runs on that thread even inside a sequential collection --
+ /// so this owns one for the duration and puts the original back. Pumping the fixture's context
+ /// passed locally and failed on CI.
+ ///
+ private static void PumpUntil(Action enqueue, Func complete, int timeoutSeconds = 20)
+ {
+ var original = Core.LoopContext;
+ var owned = new EventLoopContext();
+ Core.LoopContext = owned;
+
+ try
+ {
+ enqueue();
+
+ var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
+
+ while (!complete() && DateTime.UtcNow < deadline)
+ {
+ owned.ExecuteTasks();
+ Thread.Sleep(5);
+ }
+
+ // Anything that landed between the last pump and the final check.
+ owned.ExecuteTasks();
+ }
+ finally
+ {
+ Core.LoopContext = original;
+ }
+ }
+
private static PasswordJob JobFor(Account account, string submitted) =>
new()
{
@@ -49,15 +86,7 @@ public class PasswordWorkerTests : IDisposable
OnComplete = (_, outcome) => applied = outcome.Hash != null
};
- Assert.True(PasswordWorker.TryEnqueue(job));
-
- // The worker posts its result to the loop context, which no loop is pumping here.
- var deadline = DateTime.UtcNow.AddSeconds(10);
- while (!applied && DateTime.UtcNow < deadline)
- {
- Core.LoopContext.ExecuteTasks();
- System.Threading.Thread.Sleep(5);
- }
+ PumpUntil(() => Assert.True(PasswordWorker.TryEnqueue(job)), () => applied);
Assert.True(applied);
Assert.True(account.CheckPassword("a-queued-password"));
@@ -146,27 +175,27 @@ public class PasswordWorkerTests : IDisposable
var account = CreateAccount("offloop-two-writes-user");
var done = 0;
- for (var i = 1; i <= 2; i++)
- {
- Assert.True(
- PasswordWorker.TryEnqueue(
- new PasswordJob
- {
- Account = account,
- HashPhrase = account.GetRehashPhrase($"password-{i}"),
- TargetAlgorithm = AccountSecurity.CurrentAlgorithm,
- OnComplete = (_, _) => done++
- }
- )
- );
- }
-
- var deadline = DateTime.UtcNow.AddSeconds(20);
- while (done < 2 && DateTime.UtcNow < deadline)
- {
- Core.LoopContext.ExecuteTasks();
- System.Threading.Thread.Sleep(5);
- }
+ PumpUntil(
+ () =>
+ {
+ for (var i = 1; i <= 2; i++)
+ {
+ Assert.True(
+ PasswordWorker.TryEnqueue(
+ new PasswordJob
+ {
+ Account = account,
+ HashPhrase = account.GetRehashPhrase($"password-{i}"),
+ StoredAlgorithm = account.PasswordAlgorithm,
+ TargetAlgorithm = AccountSecurity.CurrentAlgorithm,
+ OnComplete = (_, _) => done++
+ }
+ )
+ );
+ }
+ },
+ () => done >= 2
+ );
Assert.Equal(2, done);
Assert.True(account.CheckPassword("password-2"));