Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e137572
docs(bug-931): promote tests-depend-on-uncontrolled-environment into …
drmoisan Sep 29, 2026
372dc74
docs(bug-931): add research triaging Task.Run sites and the FileInfoW…
drmoisan Sep 29, 2026
74ab72c
docs(bug-931): write the full-bug spec with site triage, write set, a…
drmoisan Sep 29, 2026
1000daf
docs(bug-931): author the atomic plan for the thread-affinity and Fil…
drmoisan Sep 29, 2026
be26539
docs(bug-931): apply preflight round 1 revisions to the atomic plan
drmoisan Sep 29, 2026
d15af1c
docs(bug-931): apply preflight round 2 revisions to the atomic plan
drmoisan Sep 29, 2026
a8af624
docs(bug-931): apply preflight round 3 revisions to the atomic plan
drmoisan Sep 29, 2026
3c2fa88
docs(bug-931): apply preflight round 4 revisions to the atomic plan
drmoisan Sep 29, 2026
42c963a
docs(931): phase 0 baseline evidence for the uncontrolled-environment…
drmoisan Sep 29, 2026
5a7e1b4
docs(931): phase 1 fail-before exception dossier
drmoisan Sep 29, 2026
2956820
test(931): remove scheduler and file-handle dependence from breadcrum…
drmoisan Sep 29, 2026
9b7b73b
docs(931): phase 3 negative-control evidence
drmoisan Sep 29, 2026
633acf6
docs(931): evidence, acceptance check-off and plan state for the unco…
drmoisan Sep 29, 2026
55a50e9
Merge origin/main (c4ff0e2be) into bug/tests-depend-on-uncontrolled-e…
drmoisan Sep 29, 2026
ce744bb
docs(931): post-merge re-run of the final QC gates
drmoisan Sep 30, 2026
9624376
docs(931): feature review audit artifacts, zero blocking findings
drmoisan Sep 30, 2026
8e3c3b5
docs(931): replace a literal account-name token in two audit artifact…
drmoisan Sep 30, 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
2 changes: 2 additions & 0 deletions QuickFiler.Test/QuickFiler.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
<Compile Include="Viewers\ItemViewerSearchDismissalContractTests.cs" />
<Compile Include="Viewers\ItemViewerBreadcrumbLifecycleRegressionTests.cs" />
<Compile Include="Viewers\ItemViewerBreadcrumbThreadAffinityTests.cs" />
<Compile Include="Viewers\ItemViewerBreadcrumbThreadAffinityTests.Part2.cs" />
<Compile Include="Viewers\BreadcrumbDropDownOpenCoordinatorTests.cs" />
<Compile Include="Viewers\BreadcrumbDropDownOpenCoordinatorTests.Part2.cs" />
<Compile Include="Viewers\BreadcrumbDropDownOpenCoordinatorTests.Part3.cs" />
Expand Down Expand Up @@ -225,6 +226,7 @@
<Compile Include="Viewers\WebView2EnvironmentContractTests.cs" />
<Compile Include="Controllers\QfcQueueTests.cs" />
<Compile Include="TestSupport\WinFormsPumpHost.cs" />
<Compile Include="TestSupport\DedicatedWorkerThread.cs" />
<Compile Include="TestSupport\WinFormsPumpHostTests.cs" />
<Compile Include="NoLiveFormInTestAssemblyTests.cs" />
<Compile Include="Helper Classes\ConversationResolverTests.cs" />
Expand Down
48 changes: 48 additions & 0 deletions QuickFiler.Test/TestSupport/DedicatedWorkerThread.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System;
using System.Threading;

namespace QuickFiler.Test.TestSupport
{
/// <summary>
/// Runs a delegate on a dedicated, joined background thread for tests that must exercise a
/// thread-identity guard from a thread that is provably not the calling thread.
/// </summary>
/// <remarks>
/// Issue #900 and issue #931: a <c>Task.Run</c> work item is not guaranteed to run on a
/// thread other than the caller's, so it cannot stand in for a different thread in a
/// thread-identity test. A thread this method constructs is
/// distinct from every live thread by construction. The untimed <c>Join()</c> is a
/// completion wait on one bounded synchronous call, not a sleep or a wall-clock wait, and
/// the waiting thread and the waited-for thread are never both thread-pool workers, so
/// the wait cannot starve the pool under parallel execution. The helper asserts nothing
/// itself: each test states its own distinctness precondition inside its delegate so that
/// a failure names the guard under test rather than the helper.
/// </remarks>
internal static class DedicatedWorkerThread
{
/// <summary>
/// Runs <paramref name="action"/> on a dedicated background thread, joins it, and
/// returns the exception it threw, or <see langword="null"/> when it completed
/// normally.
/// </summary>
internal static Exception Run(Action action)
{
Exception captured = null;
var thread = new Thread(() =>
{
try
{
action();
}
catch (Exception error)
{
captured = error;
}
});
thread.IsBackground = true;
thread.Start();
thread.Join();
return captured;
}
}
}
29 changes: 27 additions & 2 deletions QuickFiler.Test/Viewers/BreadcrumbPopupBoundaryCoverageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Microsoft.Web.WebView2.Core;
using Moq;
using QuickFiler.Test.TestSupport;
using QuickFiler.Viewers;

namespace QuickFiler.Test.Viewers
Expand Down Expand Up @@ -49,14 +50,38 @@ public void Dispatcher_NullInputsAndThrowingSink_AreHandledByContract()
.NotThrow();
}

/// <summary>
/// An owner-only dispatcher (null context) reached from a thread that is not its owner
/// must report a marshalling failure and must not run the action.
/// </summary>
/// <remarks>
/// Issue #931: the worker is a dedicated thread created by
/// <c>DedicatedWorkerThread.Run</c>, never a <c>Task.Run</c> work item. A blocking wait on
/// a pool work item queued from a pool thread can run the delegate inline on the owner
/// thread, in which case the owner-thread-id branch of <c>IsCurrentBoundary()</c> admits
/// the call, the action runs, and the test fails spuriously. The delegate asserts it is
/// off the owner thread before it dispatches. The rejection path reports and returns a
/// completed task synchronously, so no task wait is needed.
/// </remarks>
[TestMethod]
public void Dispatcher_OwnerOnlyWorker_ReportsWithoutRunningAction()
{
var errors = new List<Exception>();
int ownerThreadId = Environment.CurrentManagedThreadId;
BreadcrumbUiDispatcher dispatcher = CreateOwnerOnlyDispatcher(errors.Add);
int executions = 0;
Task dispatch = Task.Run(() => dispatcher.Dispatch(() => executions++));
dispatch.GetAwaiter().GetResult();
Exception captured = DedicatedWorkerThread.Run(() =>
{
Environment
.CurrentManagedThreadId.Should()
.NotBe(
ownerThreadId,
"the dedicated worker thread must not be the owner thread the dispatcher "
+ "was built for, or the rejection path would never be reached"
);
dispatcher.Dispatch(() => executions++);
});
captured.Should().BeNull();
executions.Should().Be(0);
errors.Should().ContainSingle().Which.Message.Should().Contain("cannot marshal");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
using System;
using System.Drawing;
using System.Reflection;
using System.Windows.Threading;
using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using QuickFiler.Test.TestSupport;
using QuickFiler.Viewers;
using UtilitiesCS.OutlookObjects.Folder;

namespace QuickFiler.Test.Viewers
{
/// <summary>
/// Continuation partial of <see cref="ItemViewerBreadcrumbThreadAffinityTests"/> holding the
/// three cross-thread cases, each of which runs its guarded call on a dedicated thread
/// created by <see cref="DedicatedWorkerThread"/>. The owner-thread admission cases, the
/// shared <c>InertOperations</c> factory and the nested helper types live in the primary
/// partial so that each file stays under the 500-line limit (issue #931).
/// </summary>
public sealed partial class ItemViewerBreadcrumbThreadAffinityTests
{
/// <summary>
/// A genuine cross-thread call must still fail fast with a diagnostic naming the operation,
/// and must not be an <see cref="ObjectDisposedException"/>.
/// </summary>
/// <remarks>
/// Issue #900: the worker is a dedicated thread created by <c>DedicatedWorkerThread.Run</c>,
/// never a <c>Task.Run</c> work item. A work item queued from a thread-pool thread lands on
/// that thread's local queue, and a blocking wait on it can run the delegate inline on the
/// constructing thread, in which case <c>Dispatcher.CheckAccess()</c> is true and the guard
/// never throws. A thread object this test constructs is never the object that constructed
/// the viewer, so the precondition asserted inside the delegate holds by construction under
/// any scheduler, including the <c>Workers=0</c> class-level parallel run. The helper's
/// untimed <c>Thread.Join()</c> is a completion wait for one synchronous call on a dedicated
/// non-pool thread; unlike the previous blocking <c>GetResult()</c> shape it never parks a
/// thread-pool slot waiting on another thread-pool slot, so it adds no starvation risk under
/// parallel execution. <c>BeOfType</c> is an exact-type check, so the derived
/// <see cref="ObjectDisposedException"/> is excluded by it as well as by the explicit
/// <c>NotBeOfType</c> that documents the intent.
/// </remarks>
[TestMethod]
public void InitializeBreadcrumbPipeline_WorkerThread_ThrowsBoundaryDiagnostic()
{
// Arrange
using (var scope = new ViewerScope())
{
BreadcrumbPopupUiOperations operations = InertOperations();
var provider = new Mock<IFolderHierarchyProvider>(MockBehavior.Strict);

// Act
Exception captured = DedicatedWorkerThread.Run(() =>
{
bool isOwnerThread = scope.Viewer.UiDispatcher.CheckAccess();
isOwnerThread
.Should()
.BeFalse(
"the dedicated worker thread must not be the thread that constructed "
+ "the viewer, or the boundary assertion would pass vacuously"
);
scope.Viewer.InitializeBreadcrumbPipeline(provider.Object, operations);
});

// Assert
captured
.Should()
.NotBeNull(
"a worker thread is not the thread that constructed the viewer, so the "
+ "guard must throw rather than admit the call"
);
captured.Should().BeOfType<InvalidOperationException>();
captured.Message.Should().Contain("InitializeBreadcrumbPipeline");
captured.Should().NotBeOfType<ObjectDisposedException>();
}
}

/// <summary>
/// The same cross-thread contract on the three-argument <c>ConfigureBreadcrumbDropDown</c>
/// overload, whose guard is its first statement and therefore throws before any argument
/// check or control access.
/// </summary>
/// <remarks>
/// Issue #900: the worker is a dedicated thread created by <c>DedicatedWorkerThread.Run</c>
/// rather than a <c>Task.Run</c> work item, for the reason given on
/// <c>InitializeBreadcrumbPipeline_WorkerThread_ThrowsBoundaryDiagnostic</c>: a pool work
/// item can be inlined onto the constructing thread, and a thread this test creates cannot.
/// The precondition inside the delegate proves the call is off the owning thread before the
/// guarded member runs. The helper's untimed <c>Thread.Join()</c> waits for one synchronous
/// call on a non-pool thread and parks no thread-pool slot, so it is safe under the
/// <c>Workers=0</c> class-level parallel run.
/// </remarks>
[TestMethod]
public void ConfigureBreadcrumbDropDown_WorkerThread_ThrowsBoundaryDiagnostic()
{
// Arrange
using (var scope = new ViewerScope())
{
var host = new InertDropDownHost();

// Act
Exception captured = DedicatedWorkerThread.Run(() =>
{
bool isOwnerThread = scope.Viewer.UiDispatcher.CheckAccess();
isOwnerThread
.Should()
.BeFalse(
"the dedicated worker thread must not be the thread that constructed "
+ "the viewer, or the boundary assertion would pass vacuously"
);
scope.Viewer.ConfigureBreadcrumbDropDown(
host,
() => new Rectangle(0, 0, 10, 10),
() => new Rectangle(0, 0, 1920, 1040)
);
});

// Assert
captured
.Should()
.NotBeNull(
"a worker thread is not the thread that constructed the viewer, so the "
+ "guard must throw rather than admit the call"
);
captured.Should().BeOfType<InvalidOperationException>();
captured.Message.Should().Contain("ConfigureBreadcrumbDropDown");
captured.Should().NotBeOfType<ObjectDisposedException>();
}
}

/// <summary>
/// A viewer with no owning dispatcher stays inert, which is what keeps
/// <c>FormatterServices.GetUninitializedObject</c>-built viewers in other test files from
/// throwing. This is the only test covering the null-owner escape.
/// </summary>
/// <remarks>
/// Issue #931: the guarded call is made from a dedicated thread created by
/// <c>DedicatedWorkerThread.Run</c>, and the delegate asserts through the owner captured
/// before the dispatcher is cleared that it is not on the owner thread. The call is
/// therefore off the owner thread unconditionally, so the test discriminates against the
/// pre-#781 context-reference guard: that guard would read the non-null captured context,
/// find the worker's null ambient context different from it, and reject the call, whereas
/// the null-owner escape admits it. Seeding first and repeating the same provider are
/// still required: a first-time initialization under a null ambient context would throw
/// at <c>BreadcrumbUiDispatcher.CaptureCurrent()</c> regardless of the guard, and only the
/// already-initialized early return can witness the escape.
/// </remarks>
[TestMethod]
public void InitializeBreadcrumbPipeline_NullOwningDispatcher_DoesNotThrow()
{
// Arrange
using (var scope = new ViewerScope())
{
BreadcrumbPopupUiOperations operations = InertOperations();
var provider = new Mock<IFolderHierarchyProvider>(MockBehavior.Strict);
scope.Viewer.InitializeBreadcrumbPipeline(provider.Object, operations);
object before = scope.Viewer.BreadcrumbCoordinator;
Dispatcher owner = scope.Viewer.UiDispatcher;
owner.Should().NotBeNull("the viewer must own a dispatcher before it is cleared");
ClearViewerDispatcher(scope.Viewer);

// Act
Exception captured = DedicatedWorkerThread.Run(() =>
{
bool isOwnerThread = owner.CheckAccess();
isOwnerThread
.Should()
.BeFalse(
"the dedicated worker thread must not be the owner thread, or the "
+ "null-owner escape would be witnessed on the owner thread and "
+ "the test would pass vacuously"
);
scope.Viewer.InitializeBreadcrumbPipeline(provider.Object, operations);
});

// Assert
captured
.Should()
.BeNull(
"a viewer with no owning dispatcher has no boundary to enforce and must "
+ "stay inert"
);
scope.Viewer.BreadcrumbCoordinator.Should().BeSameAs(before);
}
}

/// <summary>
/// Assigns <see langword="null"/> to the viewer's private owning-dispatcher field, asserting
/// the field still exists so a rename fails the test loudly rather than silently.
/// </summary>
private static void ClearViewerDispatcher(QuickFiler.ItemViewer viewer)
{
FieldInfo field = typeof(QuickFiler.ItemViewer).GetField(
"_uiDispatcher",
BindingFlags.Instance | BindingFlags.NonPublic
);
field
.Should()
.NotBeNull("ItemViewer must still declare the private _uiDispatcher field");
field.SetValue(viewer, null);
}
}
}
Loading
Loading