test(login): own the loop context instead of pumping the fixture's

EventLoopContext pins itself to the thread that constructed it and refuses
ExecuteTasks from any other. The fixture builds one on whichever thread built
the fixture, and xUnit gives no guarantee a test method runs on that thread,
even inside a sequential collection -- so pumping it was a coin flip. It came
up heads locally and on most CI images, and tails on CentOS 10.

Each pumping test now constructs its own context, which makes the guard pass
by construction rather than by luck, and restores the original afterwards.
Both tests share one helper, since both need the real queue rather than
ComputeInline.
This commit is contained in:
Kamron Batman 2026-08-08 23:14:12 -07:00
parent 9e347e9364
commit 76d943c678
No known key found for this signature in database
GPG key ID: 7D81DF26D9A5D94A

View file

@ -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);
/// <summary>
/// Enqueues work, then pumps the loop context until <paramref name="complete"/> or the deadline.
///
/// The context pins itself to the thread that constructed it and refuses <c>ExecuteTasks</c>
/// 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.
/// </summary>
private static void PumpUntil(Action enqueue, Func<bool> 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"));