Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
53d9752
docs(968): prepare focus-and-theme dispatcher pin fix (research, spec…
drmoisan Oct 2, 2026
d096f12
docs(968): record preflight round 1 result (revisions required, held)
drmoisan Oct 2, 2026
4c6de5e
docs(968): fold #972 and the liveness residual into scope; record the…
drmoisan Oct 3, 2026
87cca65
docs(968): research addendum and spec amendment 1.2 folding #972 and …
drmoisan Oct 3, 2026
ce51e29
docs(968): revise plan in place (round-1 deltas applied, #972 and liv…
drmoisan Oct 3, 2026
fd85053
docs(968): record preflight round 2 (revisions required) and apply it…
drmoisan Oct 3, 2026
9aea14c
docs(968): record preflight round 3 report (4 defects)
drmoisan Oct 3, 2026
d6a0079
docs(968): apply preflight round 3 deltas to the plan
drmoisan Oct 3, 2026
e792a0a
docs(968): record preflight round 4 report (3 defects)
drmoisan Oct 3, 2026
0fd8dd8
docs(968): apply preflight round 4 deltas to the plan
drmoisan Oct 3, 2026
814b038
docs(968): record preflight round 5 report (1 defect, plan not cleared)
drmoisan Oct 3, 2026
5ff5331
docs(968): apply preflight round-5 deltas 1a and 1b plus the multi-re…
drmoisan Oct 3, 2026
911558b
docs(968): record preflight round 6 ALL CLEAR and the preflight clear…
drmoisan Oct 3, 2026
74b7c6b
docs(968): record phase 0 baseline evidence
drmoisan Oct 3, 2026
b874ea3
fix(968): reference-count the UiThreadDispatcherFixture ensure pin, r…
drmoisan Oct 3, 2026
5699431
docs(968): record final QA evidence and acceptance check-offs
drmoisan Oct 3, 2026
7a2723c
docs(968): record the P8-T46 final-commit evidence and the AC22 orche…
drmoisan Oct 3, 2026
5570b33
Merge remote-tracking branch 'origin/main' into bug/focus-and-theme-t…
drmoisan Oct 3, 2026
78e24a6
docs(968): record the feature review artifacts (verdict AWAITING_CI o…
drmoisan Oct 3, 2026
0ef4ae7
docs(968): check off AC22 from PR 976 CI run 37120059960
drmoisan Oct 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
242 changes: 141 additions & 101 deletions QuickFiler.Test/Controllers/QfcDatamodelLivenessTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using Microsoft.Office.Interop.Outlook;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using QuickFiler.Test.TestSupport;
using UtilitiesCS;
using UtilitiesCS.ReusableTypeClasses;

Expand Down Expand Up @@ -44,21 +44,6 @@ private static void SetPrivateField(object target, string name, object value)
field.SetValue(target, value);
}

/// <summary>
/// Test-side worker whose <see cref="RaiseDoWork"/> raises <c>DoWork</c> synchronously on
/// the calling thread through the protected <c>OnDoWork</c>, so the privately subscribed
/// <c>Worker_DoWork</c> runs to its first incomplete await before <c>InitEmailQueue</c>
/// returns. Issue #950: this replaces the bounded waits on a thread-pool worker.
/// </summary>
private sealed class SynchronousBackgroundWorker : BackgroundWorker
{
public void RaiseDoWork() => OnDoWork(new DoWorkEventArgs(null));
}

/// <summary>The synchronous starter assigned to <c>QfcDatamodel.WorkerStarter</c>.</summary>
private static void StartSynchronously(BackgroundWorker worker) =>
((SynchronousBackgroundWorker)worker).RaiseDoWork();

/// <summary>
/// Queues posted continuations and runs them only on an explicit <see cref="Drain"/> call,
/// on the creating thread. Drain runs only work already queued, plus work that work queues,
Expand Down Expand Up @@ -105,14 +90,21 @@ private static IApplicationGlobals CreateHighConfidenceGlobals()
/// <c>sourceActive</c> signal consumed that dishonest value, so an empty queue was mistaken
/// for an exhausted one and the gate returned an early partial batch. The datamodel-owned
/// <c>volatile bool</c> flag makes the signal truthful.
/// <para>
/// Issue #968: every step waits on an explicit signal instead of a clock advance followed by
/// a scheduler yield. <see cref="ArmingFakeTimeProvider.Armed"/> proves the gate armed its
/// next wait; the dequeue task itself is the completion signal; the production awaits are
/// registered with no synchronization context installed, so the loader's completion clears
/// the flag inline and is read back before the final advance. No retry loop remains.
/// </para>
/// </summary>
[TestMethod]
public async Task DequeueNextItemGroupAsync_WhileLoaderStillProducing_KeepsPollingAfterWorkerIdle()
{
// Arrange
var model = CreateUninitializedDatamodel();
var fake = new FakeTimeProvider();
model.TimeProvider = fake;
var clock = new ArmingFakeTimeProvider();
model.TimeProvider = clock;
SetPrivateField(model, "_globals", CreateHighConfidenceGlobals());
SetPrivateField(model, "_masterQueue", new LockingLinkedList<MailItem>());

Expand All @@ -124,43 +116,59 @@ public async Task DequeueNextItemGroupAsync_WhileLoaderStillProducing_KeepsPolli
return await loaderRelease.Task;
};

var worker = new SynchronousBackgroundWorker();
model.WorkerStarter = StartSynchronously;
using (var worker = new SynchronousBackgroundWorker())
{
model.WorkerStarter = SynchronousBackgroundWorker.StartSynchronously;
Task<IList<MailItem>> pending;
using (NoSynchronizationContext())
{
// The issue #244 zero-batch short-circuit is COM-free and starts the worker
// through the issue #950 seam, which raises DoWork on this thread.
model.InitEmailQueue(0, worker);
loaderEntered
.Task.IsCompleted.Should()
.BeTrue(
"the synchronous starter must reach the injected RemainingEmailLoader"
);
pending = model.DequeueNextItemGroupAsync(1, 200);
}

// Act — the issue #244 zero-batch short-circuit is COM-free and starts the worker
// through the issue #950 seam, which raises DoWork on this thread.
model.InitEmailQueue(0, worker);
clock
.Armed.IsCompleted.Should()
.BeTrue(
"the gate arms its first empty-queue wait before the dequeue call returns"
);
clock.ReArm();

loaderEntered
.Task.IsCompleted.Should()
.BeTrue("the synchronous starter must reach the injected RemainingEmailLoader");

Task<IList<MailItem>> pending = model.DequeueNextItemGroupAsync(1, 200);
fake.Advance(TimeSpan.FromMilliseconds(200));
await Task.Yield();
fake.Advance(TimeSpan.FromMilliseconds(200));
await Task.Yield();

// Assert
pending
.IsCompleted.Should()
.BeFalse(
"the loader is still producing, so the gate must keep polling rather than treat "
+ "an empty queue as an exhausted source and return an early partial batch"
);
// Act — the first wait expires while the loader is still producing.
clock.Advance(TimeSpan.FromMilliseconds(200));
Task first = await Task.WhenAny(clock.Armed, pending);

// Cleanup — release the loader and let the dequeue drain on the honest signal.
loaderRelease.SetResult(true);
for (int i = 0; i < 20 && !pending.IsCompleted; i++)
{
fake.Advance(TimeSpan.FromMilliseconds(200));
await Task.Yield();
}
// Assert
first
.Should()
.BeSameAs(
clock.Armed,
"the loader is still producing, so the gate must arm a second wait rather than "
+ "treat an empty queue as an exhausted source and return an early partial batch"
);
pending.IsCompleted.Should().BeFalse("the gate re-armed instead of returning");

pending
.IsCompleted.Should()
.BeTrue("once the loader completes, the gate exits on genuine exhaustion");
(await pending).Should().BeEmpty();
// Cleanup — complete the loader; with no captured context its continuations run
// inline and clear the flag before this call returns.
using (NoSynchronizationContext())
{
loaderRelease.SetResult(true);
}

ReadLivenessFlag(model)
.Should()
.BeFalse("the loader's completion must clear the flag before the next poll");
clock.Advance(TimeSpan.FromMilliseconds(200));
(await pending)
.Should()
.BeEmpty("once the loader completes, the gate exits on genuine exhaustion");
}
}

/// <summary>Reads the issue #424 producer-liveness flag by reflection.</summary>
Expand All @@ -172,15 +180,36 @@ private static bool ReadLivenessFlag(QfcDatamodel model)
}

/// <summary>
/// Starts the worker with a <c>RemainingEmailLoader</c> held open by
/// Issue #968. Clears <see cref="SynchronizationContext.Current"/> on the calling thread for the
/// lifetime of the returned scope and restores the previous value on dispose, so every
/// production await registered inside the scope captures no context and its continuation runs
/// inline on the completing thread. The scope body must contain no <c>await</c>: the restore
/// has to run on the same thread that took the scope.
/// </summary>
private static IDisposable NoSynchronizationContext() => new SynchronizationContextScope();

private sealed class SynchronizationContextScope : IDisposable
{
private readonly SynchronizationContext _previous = SynchronizationContext.Current;

internal SynchronizationContextScope() =>
SynchronizationContext.SetSynchronizationContext(null);

public void Dispose() => SynchronizationContext.SetSynchronizationContext(_previous);
}

/// <summary>
/// Starts <paramref name="worker"/> with a <c>RemainingEmailLoader</c> held open by
/// <paramref name="release"/>. The issue #950 synchronous starter raises <c>DoWork</c> on
/// this thread, so by the time <c>InitEmailQueue</c> returns the async void
/// <c>Worker_DoWork</c> has entered the loader and returned at its first incomplete await.
/// <paramref name="release"/> runs its continuations asynchronously, so a test that has
/// installed <c>DrainableSynchronizationContext</c> observes the resumed loader only
/// through <c>Drain</c>, never inline inside <c>SetResult</c>.
/// through <c>Drain</c>, never inline inside <c>SetResult</c>. The caller owns and disposes
/// the worker (issue #968, folding issue #972 item 4).
/// </summary>
private static QfcDatamodel StartHeldOpenLoader(
SynchronousBackgroundWorker worker,
Func<TaskCompletionSource<bool>, Task<bool>> loaderBody,
out TaskCompletionSource<bool> release
)
Expand All @@ -198,8 +227,7 @@ out TaskCompletionSource<bool> release
return loaderBody(localRelease);
};

var worker = new SynchronousBackgroundWorker();
model.WorkerStarter = StartSynchronously;
model.WorkerStarter = SynchronousBackgroundWorker.StartSynchronously;
model.InitEmailQueue(0, worker);

entered
Expand All @@ -217,20 +245,24 @@ out TaskCompletionSource<bool> release
[TestMethod]
public void RemainingLoadActive_AcrossAsyncVoidFirstAwait_StaysTrueWhileLoaderProduces()
{
// Arrange / Act
QfcDatamodel model = StartHeldOpenLoader(
signal => signal.Task,
out TaskCompletionSource<bool> release
);

// Assert
ReadLivenessFlag(model)
.Should()
.BeTrue(
"the producer is still live even though the async void handler already returned"
using (var worker = new SynchronousBackgroundWorker())
{
// Arrange / Act
QfcDatamodel model = StartHeldOpenLoader(
worker,
signal => signal.Task,
out TaskCompletionSource<bool> release
);

release.SetResult(true);
// Assert
ReadLivenessFlag(model)
.Should()
.BeTrue(
"the producer is still live even though the async void handler already returned"
);

release.SetResult(true);
}
}

/// <summary>
Expand All @@ -246,22 +278,26 @@ public void RemainingLoadActive_AfterLoaderCompletes_BecomesFalse()
SynchronizationContext.SetSynchronizationContext(pump);
try
{
QfcDatamodel model = StartHeldOpenLoader(
signal => signal.Task,
out TaskCompletionSource<bool> release
);
ReadLivenessFlag(model).Should().BeTrue("the loader has not completed yet");

// Act
release.SetResult(true);
pump.Drain();

// Assert
ReadLivenessFlag(model)
.Should()
.BeFalse(
"the finally around the awaited loader must clear the flag once it completes"
using (var worker = new SynchronousBackgroundWorker())
{
QfcDatamodel model = StartHeldOpenLoader(
worker,
signal => signal.Task,
out TaskCompletionSource<bool> release
);
ReadLivenessFlag(model).Should().BeTrue("the loader has not completed yet");

// Act
release.SetResult(true);
pump.Drain();

// Assert
ReadLivenessFlag(model)
.Should()
.BeFalse(
"the finally around the awaited loader must clear the flag once it completes"
);
}
}
finally
{
Expand All @@ -282,26 +318,30 @@ public void RemainingLoadActive_WhenLoaderThrows_IsStillClearedByFinally()
SynchronizationContext.SetSynchronizationContext(pump);
try
{
QfcDatamodel model = StartHeldOpenLoader(
async signal =>
{
await signal.Task;
throw new InvalidOperationException("loader failed");
},
out TaskCompletionSource<bool> release
);
ReadLivenessFlag(model).Should().BeTrue("the loader has not failed yet");

// Act
release.SetResult(true);
pump.Drain();

// Assert
ReadLivenessFlag(model)
.Should()
.BeFalse(
"the finally must clear the flag on the throwing path too, or the gate would poll forever"
using (var worker = new SynchronousBackgroundWorker())
{
QfcDatamodel model = StartHeldOpenLoader(
worker,
async signal =>
{
await signal.Task;
throw new InvalidOperationException("loader failed");
},
out TaskCompletionSource<bool> release
);
ReadLivenessFlag(model).Should().BeTrue("the loader has not failed yet");

// Act
release.SetResult(true);
pump.Drain();

// Assert
ReadLivenessFlag(model)
.Should()
.BeFalse(
"the finally must clear the flag on the throwing path too, or the gate would poll forever"
);
}
}
finally
{
Expand Down
19 changes: 2 additions & 17 deletions QuickFiler.Test/Controllers/QfcDatamodelTeardownTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using Microsoft.Office.Interop.Outlook;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using QuickFiler.Test.TestSupport;
using UtilitiesCS;
using UtilitiesCS.ReusableTypeClasses;

Expand Down Expand Up @@ -56,22 +57,6 @@ private static object GetPrivateField(object target, string name)
return field.GetValue(target);
}

/// <summary>
/// Test-side worker whose <see cref="RaiseDoWork"/> raises <c>DoWork</c> synchronously on
/// the calling thread through the protected <c>OnDoWork</c>, so the privately subscribed
/// <c>Worker_DoWork</c> runs to its first incomplete await before <c>InitEmailQueue</c>
/// returns (issue #950). Duplicated per file, following the convention documented on
/// <c>QfcDatamodelLivenessTests</c>.
/// </summary>
private sealed class SynchronousBackgroundWorker : BackgroundWorker
{
public void RaiseDoWork() => OnDoWork(new DoWorkEventArgs(null));
}

/// <summary>The synchronous starter assigned to <c>QfcDatamodel.WorkerStarter</c>.</summary>
private static void StartSynchronously(BackgroundWorker worker) =>
((SynchronousBackgroundWorker)worker).RaiseDoWork();

/// <summary>
/// AC2, the reported crash. Once <c>Cleanup()</c> has nulled <c>_masterQueue</c> and
/// <c>_moveMonitor</c>, the still-running loader reached this method and constructed
Expand Down Expand Up @@ -219,7 +204,7 @@ public void Worker_DoWork_CapturesRemainingLoadTask()

using (var worker = new SynchronousBackgroundWorker())
{
model.WorkerStarter = StartSynchronously;
model.WorkerStarter = SynchronousBackgroundWorker.StartSynchronously;

// Act — the issue #244 zero-batch short-circuit is COM-free and starts the worker
// through the issue #950 seam, which raises DoWork on this thread, so
Expand Down
Loading
Loading