diff --git a/QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs b/QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs index 7f106c2ea..befd994d1 100644 --- a/QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs +++ b/QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs @@ -323,38 +323,72 @@ public async Task RunAsync_ExecutesCorrectly() } [TestMethod] - public void Worker_RunWorkerCompleted_HandlesCompletionCorrectly() + public async System.Threading.Tasks.Task Worker_RunWorkerCompleted_HandlesCompletionCorrectly() { - // Arrange - UiThread.Init(false); - var mockFormViewer = new Mock(); - mockFormViewer.SetupAllProperties(); - mockFormViewer.SetupProperty(m => m.ItemsPerLoadEnabled, false); - mockFormViewer.SetupProperty(m => m.SkipButtonEnabled, false); - _controller - .GetType() - .GetField( - "_formViewer", - System.Reflection.BindingFlags.NonPublic - | System.Reflection.BindingFlags.Instance - ) - .SetValue(_controller, mockFormViewer.Object); + // Arrange: install a dispatcher belonging to a thread that actually pumps, instead of + // calling UiThread.Init() on the MSTest worker. Init() now rejects a non-STA caller, + // and the old arrangement additionally left the test order-dependent on whichever + // thread had consumed the initialization latch first. + var host = new QuickFiler.Test.TestSupport.WinFormsPumpHost(); + UiThreadDispatcherTransaction transaction = null; + try + { + // Dispatcher.FromThread is a lookup that never creates a dispatcher, so the + // dispatcher must first be created on the pump thread itself; resolving it before + // that returns null and Install(null) would leave UiThread._dispatcher unset. + Thread pumpThread = await host.InvokeAsync(() => + { + System.Windows.Threading.Dispatcher.CurrentDispatcher.Should().NotBeNull(); + return System.Threading.Thread.CurrentThread; + }) + .ConfigureAwait(false); + + System.Windows.Threading.Dispatcher pumpDispatcher = + System.Windows.Threading.Dispatcher.FromThread(pumpThread); + pumpDispatcher + .Should() + .NotBeNull( + because: "the dispatcher was created on the pump thread by the call above" + ); + + transaction = + await QuickFiler.Controllers.Tests.UiThreadDispatcherFixture.BeginTransactionAsync(); + transaction.Install(pumpDispatcher); + + var mockFormViewer = new Mock(); + mockFormViewer.SetupAllProperties(); + mockFormViewer.SetupProperty(m => m.ItemsPerLoadEnabled, false); + mockFormViewer.SetupProperty(m => m.SkipButtonEnabled, false); + _controller + .GetType() + .GetField( + "_formViewer", + System.Reflection.BindingFlags.NonPublic + | System.Reflection.BindingFlags.Instance + ) + .SetValue(_controller, mockFormViewer.Object); - var eventArgs = new RunWorkerCompletedEventArgs(null, null, false); + var eventArgs = new RunWorkerCompletedEventArgs(null, null, false); - // Act - _controller - .GetType() - .GetMethod( - "Worker_RunWorkerCompleted", - System.Reflection.BindingFlags.NonPublic - | System.Reflection.BindingFlags.Instance - ) - .Invoke(_controller, new object[] { null, eventArgs }); + // Act + _controller + .GetType() + .GetMethod( + "Worker_RunWorkerCompleted", + System.Reflection.BindingFlags.NonPublic + | System.Reflection.BindingFlags.Instance + ) + .Invoke(_controller, new object[] { null, eventArgs }); - // Assert - Assert.IsTrue(mockFormViewer.Object.ItemsPerLoadEnabled); - Assert.IsTrue(mockFormViewer.Object.SkipButtonEnabled); + // Assert + Assert.IsTrue(mockFormViewer.Object.ItemsPerLoadEnabled); + Assert.IsTrue(mockFormViewer.Object.SkipButtonEnabled); + } + finally + { + transaction?.Dispose(); + await host.StopAsync().ConfigureAwait(false); + } } } } diff --git a/UtilitiesCS.Test/EmailIntelligence/FilterOlFoldersViewer_Tests.cs b/UtilitiesCS.Test/EmailIntelligence/FilterOlFoldersViewer_Tests.cs index 75bdfc9ec..4fef18518 100644 --- a/UtilitiesCS.Test/EmailIntelligence/FilterOlFoldersViewer_Tests.cs +++ b/UtilitiesCS.Test/EmailIntelligence/FilterOlFoldersViewer_Tests.cs @@ -24,6 +24,7 @@ namespace UtilitiesCS.Test.EmailIntelligence /// is set to a synthetic snapshot so that SetupTree() does not hit COM. /// [STATestClass] + [DoNotParallelize] public class FilterOlFoldersViewer_Tests { // --------------------------------------------------------------------------- diff --git a/UtilitiesCS.Test/EmailIntelligence/FolderRemapViewer_Tests.cs b/UtilitiesCS.Test/EmailIntelligence/FolderRemapViewer_Tests.cs index c3c1270d8..c32535b0d 100644 --- a/UtilitiesCS.Test/EmailIntelligence/FolderRemapViewer_Tests.cs +++ b/UtilitiesCS.Test/EmailIntelligence/FolderRemapViewer_Tests.cs @@ -24,6 +24,7 @@ namespace UtilitiesCS.Test.EmailIntelligence /// and _mappings2 are set via reflection to avoid COM access. /// [STATestClass] + [DoNotParallelize] public class FolderRemapViewer_Tests { // --------------------------------------------------------------------------- diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs index 4ea5a09b9..09d1c11ea 100644 --- a/UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs +++ b/UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs @@ -16,6 +16,7 @@ namespace UtilitiesCS.Test.OutlookObjects.Folder { [TestClass] + [DoNotParallelize] public class FolderPredictorTests { [TestMethod] diff --git a/UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs b/UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs new file mode 100644 index 000000000..df0b58c73 --- /dev/null +++ b/UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs @@ -0,0 +1,209 @@ +using System; +using System.Reflection; +using System.Threading; +using System.Windows.Threading; +using FluentAssertions; +using UtilitiesCS; +using UtilitiesCS.Threading; + +namespace UtilitiesCS.Test +{ + /// + /// Snapshots every process-global static UiThread owns, resets them all through that + /// type's internal ResetForTesting hook, and restores the captured values on disposal. + /// + /// + /// This type is deliberately not internally synchronized. It performs an unguarded + /// read-then-write against process-global statics, so two tests entering a scope concurrently + /// would interleave and one would restore values the other had already replaced. Serialization + /// of writers is provided instead by [DoNotParallelize] on every consuming test class. A + /// future caller must not assume this type is thread-safe: adding a new consuming test class + /// requires adding that attribute to the class as well. + /// + /// Reflection is required because InternalsVisibleTo exposes internal members only; it + /// does not expose private ones, and every controlled field is private. Centralising the + /// reflection here means each field name appears in exactly one place in this assembly. + /// +#nullable enable annotations + internal sealed class UiThreadStateScope : IDisposable + { + private static readonly FieldInfo InitializedInfo = Resolve("_initialized"); + private static readonly FieldInfo UiSyncContextInfo = Resolve("_uiSyncContext"); + private static readonly FieldInfo AutoScaleFactorInfo = Resolve("_autoScaleFactor"); + private static readonly FieldInfo UiThreadIdInfo = Resolve("_uiThreadId"); + private static readonly FieldInfo DispatcherInfo = Resolve("_dispatcher"); + private static readonly FieldInfo SyncContextFormInfo = Resolve("_syncContextForm"); + private static readonly FieldInfo ThreadMonitorInfo = Resolve("_threadMonitor"); + private static readonly FieldInfo MonitorUiThreadInfo = Resolve("_monitorUiThread"); + private static readonly FieldInfo OnLockupDetectedInfo = Resolve("_onLockupDetected"); + private static readonly FieldInfo MonitorTimeProviderInfo = Resolve("_monitorTimeProvider"); + private static readonly FieldInfo LockupThresholdInfo = Resolve( + "_lockupAttributionThresholdMs" + ); + + private readonly object?[] _priorValues; + private readonly Func _priorFactory; + private bool _disposed; + + private UiThreadStateScope(object?[] priorValues, Func priorFactory) + { + _priorValues = priorValues; + _priorFactory = priorFactory; + } + + /// + /// The field-info objects this scope controls, in the order their prior values are + /// captured and restored. + /// + private static FieldInfo[] ControlledFields => + new[] + { + InitializedInfo, + UiSyncContextInfo, + AutoScaleFactorInfo, + UiThreadIdInfo, + DispatcherInfo, + SyncContextFormInfo, + ThreadMonitorInfo, + MonitorUiThreadInfo, + OnLockupDetectedInfo, + MonitorTimeProviderInfo, + LockupThresholdInfo, + }; + + /// + /// Captures every controlled field plus UiThread.SyncContextFormFactory, resets the + /// statics, and returns a scope that restores the captured values when disposed. + /// + /// A scope whose disposal restores the captured prior state. + internal static UiThreadStateScope Enter() + { + FieldInfo[] fields = ControlledFields; + var prior = new object?[fields.Length]; + for (int i = 0; i < fields.Length; i++) + { + prior[i] = fields[i].GetValue(null); + } + + Func priorFactory = UiThread.SyncContextFormFactory; + UiThread.ResetForTesting(); + return new UiThreadStateScope(prior, priorFactory); + } + + /// Reads UiThread._monitorUiThread without going through a property. + internal static bool MonitorUiThread => (bool)MonitorUiThreadInfo.GetValue(null); + + /// Reads UiThread._onLockupDetected without going through a property. + internal static Action? OnLockupDetected => + (Action?)OnLockupDetectedInfo.GetValue(null); + + /// Reads UiThread._monitorTimeProvider without going through a property. + internal static TimeProvider? MonitorTimeProvider => + (TimeProvider?)MonitorTimeProviderInfo.GetValue(null); + + /// Reads UiThread._lockupAttributionThresholdMs without going through a property. + internal static int LockupAttributionThresholdMs => (int)LockupThresholdInfo.GetValue(null); + + /// + /// Reads UiThread._uiSyncContext directly. + /// + /// + /// The UiThread.UiSyncContext property lazily calls Init() when the field is + /// null, so a test that needs to observe the uninitialized state cannot use the property. + /// + internal static SynchronizationContext? UiSyncContextField => + (SynchronizationContext?)UiSyncContextInfo.GetValue(null); + + /// + /// Reads UiThread._autoScaleFactor directly, for the same reason as + /// . + /// + internal static System.Drawing.SizeF? AutoScaleFactorField => + (System.Drawing.SizeF?)AutoScaleFactorInfo.GetValue(null); + + /// Reads UiThread._uiThreadId directly. + internal static int UiThreadIdField => (int)UiThreadIdInfo.GetValue(null); + + /// + /// Reads UiThread._dispatcher directly. + /// + /// + /// The UiThread.Dispatcher property throws when the field is unset, so a test that + /// needs to observe the uninitialized state cannot use the property. + /// + internal static Dispatcher? DispatcherField => (Dispatcher?)DispatcherInfo.GetValue(null); + + /// Reads UiThread._syncContextForm directly. + internal static IUiCaptureSource? SyncContextFormField => + (IUiCaptureSource?)SyncContextFormInfo.GetValue(null); + + /// Reads UiThread._threadMonitor directly. + internal static ThreadMonitor? ThreadMonitorField => + (ThreadMonitor?)ThreadMonitorInfo.GetValue(null); + + /// + /// Installs a value into UiThread._uiSyncContext for the remainder of this scope. + /// + /// The value to install; may be null. + internal static void SetUiSyncContext(SynchronizationContext? value) => + UiSyncContextInfo.SetValue(null, value); + + /// + /// Installs a value into UiThread._uiThreadId for the remainder of this scope. + /// + /// The managed thread id to install. + internal static void SetUiThreadId(int value) => UiThreadIdInfo.SetValue(null, value); + + /// + /// Installs a value into UiThread._dispatcher for the remainder of this scope. + /// + /// The dispatcher to install; may be null. + internal static void SetDispatcher(Dispatcher? value) => + DispatcherInfo.SetValue(null, value); + + /// + /// Restores every captured value, including a captured null, and restores the factory. + /// + /// + /// Each captured prior is written back unconditionally rather than being tested for null + /// first: a null prior is a real state that must be restored, and skipping the write for it + /// would leak an installed value into every later test on the same process-global static. + /// A second call is a no-op. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + FieldInfo[] fields = ControlledFields; + for (int i = 0; i < fields.Length; i++) + { + fields[i].SetValue(null, _priorValues[i]); + } + + UiThread.SyncContextFormFactory = _priorFactory; + _disposed = true; + } + + private static FieldInfo Resolve(string fieldName) + { + FieldInfo field = typeof(UiThread).GetField( + fieldName, + BindingFlags.NonPublic | BindingFlags.Static + ); + field + .Should() + .NotBeNull( + because: "UiThread.{0} backing field must exist for UiThreadStateScope to " + + "control it; a rename must fail loudly here rather than degrade to a " + + "silent no-op that restores nothing and still passes", + fieldName + ); + return field; + } + } + +#nullable restore annotations +} diff --git a/UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs b/UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs new file mode 100644 index 000000000..73fd32f3b --- /dev/null +++ b/UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs @@ -0,0 +1,460 @@ +using System; +using System.Threading; +using System.Windows.Forms; +using System.Windows.Threading; +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UtilitiesCS.Threading; + +namespace UtilitiesCS.Test.Threading +{ + /// + /// Stands in for SyncContextForm so UiThread.Initialize() can be driven, and + /// made to fail, without a live WinForms form or an STA host. + /// + /// + /// It deliberately does not derive from , because + /// UtilitiesCS.Test/NoLiveFormInTestAssemblyTests.cs asserts this assembly compiles no + /// Form-derived type. is process-global because the + /// factory is; each consuming class is [DoNotParallelize] and resets it. + /// + internal sealed class FakeUiCaptureSource : IUiCaptureSource + { + /// The message a capture failure carries, so a test can assert on it. + internal const string CaptureFailureMessage = + "FakeUiCaptureSource was configured to fail during CaptureUiVariables()."; + + /// The deterministic auto-scale factor this fake reports. + internal static readonly System.Drawing.SizeF DeterministicAutoScaleFactor = + new System.Drawing.SizeF(2f, 3f); + private static int _constructionCount; + + internal FakeUiCaptureSource() + { + Interlocked.Increment(ref _constructionCount); + } + + /// Gets how many instances have been constructed since the last reset. + internal static int ConstructionCount => Volatile.Read(ref _constructionCount); + + /// Sets the construction counter back to zero. + internal static void ResetConstructionCount() => Volatile.Write(ref _constructionCount, 0); + + /// Gets or sets whether capture throws instead of assigning the four values. + internal bool ThrowOnCapture { get; set; } + + /// + /// Gets or sets the dispatcher this fake reports after a successful capture. A test + /// supplies one owned by a host it shuts down, leaving none on a pooled worker. + /// + internal Dispatcher DispatcherToCapture { get; set; } + + public bool ShowInTaskbar { get; set; } + public FormWindowState WindowState { get; set; } + public SynchronizationContext UiSyncContext { get; private set; } + public System.Drawing.SizeF FormAutoScaleFactor { get; private set; } + public Dispatcher UiDispatcher { get; private set; } + public int UiThreadId { get; private set; } + + public void Show() { } + + public void Hide() { } + + public void CaptureUiVariables() + { + if (ThrowOnCapture) + { + throw new InvalidOperationException(CaptureFailureMessage); + } + + UiSyncContext = new SynchronizationContext(); + FormAutoScaleFactor = DeterministicAutoScaleFactor; + UiDispatcher = DispatcherToCapture; + UiThreadId = Thread.CurrentThread.ManagedThreadId; + } + } + + /// Runs a delegate on a dedicated thread in a chosen apartment. + internal static class ApartmentThreadRunner + { + /// Runs the delegate on a dedicated thread in that apartment. + /// The apartment state to set before starting the thread. + /// The delegate to run. + /// The thrown exception, or null when it completed normally. + internal static Exception RunOnThread(ApartmentState apartment, Action action) + { + Exception captured = null; + var thread = new Thread(() => + { + try + { + action(); + } + catch (Exception ex) + { + captured = ex; + } + }); + thread.IsBackground = true; + thread.SetApartmentState(apartment); + thread.Start(); + thread.Join(); + return captured; + } + + /// Starts an STA thread that waits on the gate, then calls UiThread.Init(). + /// The gate both racers wait on. + /// The started thread, for the caller to join. + internal static Thread StartStaInitWaiter(ManualResetEventSlim gate) + { + var thread = new Thread(() => + { + gate.Wait(); + try + { + UiThread.Init(); + } + catch (InvalidOperationException) + { + // The measured quantity is the factory invocation count, not a racer's outcome. + } + }); + thread.IsBackground = true; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + return thread; + } + } + + /// Owns a dedicated STA thread running a dispatcher frame, shut down on disposal. + /// + /// A dedicated thread is required rather than the pooled worker's ambient dispatcher, which + /// would never be shut down. See the fuller remarks in UiThread_Dispatcher_Tests. + /// + internal sealed class SharedStaDispatcherHost : IDisposable + { + private readonly AutoResetEvent _ready = new AutoResetEvent(false); + private readonly Thread _thread; + + internal SharedStaDispatcherHost() + { + _thread = new Thread(() => + { + Dispatcher = System.Windows.Threading.Dispatcher.CurrentDispatcher; + _ready.Set(); + System.Windows.Threading.Dispatcher.Run(); + }); + _thread.IsBackground = true; + _thread.SetApartmentState(ApartmentState.STA); + _thread.Start(); + _ready.WaitOne(); + } + + /// Gets the dispatcher captured on the owned STA thread. + internal Dispatcher Dispatcher { get; private set; } + + public void Dispose() + { + Dispatcher.BeginInvokeShutdown(DispatcherPriority.Send); + _thread.Join(); + _ready.Dispose(); + } + } + + /// + /// Regression coverage for issue #787 (AC1): UiThread.Init() must reject a caller whose + /// apartment state is not , before it mutates any global. + /// + /// + /// The boundary is == STA rather than != MTA. + /// is not directly constructible under MSTest on this + /// host and is recorded as untested; the equality-shaped boundary rejects it regardless. + /// + [TestClass] + [DoNotParallelize] + public class UiThreadInitApartmentContract_Tests + { + [TestMethod] + public void Init_OnMtaThread_ThrowsInvalidOperationExceptionNamingTheObservedApartmentState() + { + // Arrange: the Act runs on a dedicated MTA thread, never on the ambient worker, whose + // apartment was measured as STA here; an ambient test would assert nothing about MTA. + using (UiThreadStateScope.Enter()) + { + UiThread.SyncContextFormFactory = () => new FakeUiCaptureSource(); + + // Act + Exception observed = ApartmentThreadRunner.RunOnThread( + ApartmentState.MTA, + () => UiThread.Init() + ); + + // Assert + observed + .Should() + .BeOfType() + .Which.Message.Should() + .StartWith(UiThread.NonStaInitMessagePrefix) + .And.Contain("MTA"); + } + } + + [TestMethod] + public void Init_OnMtaThread_CapturesNoGlobalStateAndLeavesMonitoringConfigurationUnchanged() + { + // Arrange: the scope reset every static, so the values it installed are the declared + // initial values that a rejected Init() must leave untouched. + using (UiThreadStateScope.Enter()) + { + UiThread.SyncContextFormFactory = () => new FakeUiCaptureSource(); + var clock = new FakeTimeProvider(); + Action callback = _ => { }; + + // Act: on a dedicated MTA thread, for the reason recorded on the case above. + Exception observed = ApartmentThreadRunner.RunOnThread( + ApartmentState.MTA, + () => + UiThread.Init( + monitorUiThread: true, + onLockupDetected: callback, + timeProvider: clock, + lockupAttributionThresholdMs: 1234 + ) + ); + + // Assert: it was rejected, and the four monitoring fields and the four capture + // fields are unchanged. + observed.Should().BeOfType(); + UiThreadStateScope.MonitorUiThread.Should().BeFalse(); + UiThreadStateScope.OnLockupDetected.Should().BeNull(); + UiThreadStateScope.MonitorTimeProvider.Should().BeNull(); + UiThreadStateScope.LockupAttributionThresholdMs.Should().Be(5000); + UiThreadStateScope.UiSyncContextField.Should().BeNull(); + UiThreadStateScope.AutoScaleFactorField.Should().BeNull(); + UiThreadStateScope.UiThreadIdField.Should().Be(-1); + UiThreadStateScope.DispatcherField.Should().BeNull(); + } + } + + [STATestMethod] + public void Init_OnStaThread_DoesNotThrowAndPopulatesAllFourCaptureFields() + { + // Arrange + Thread.CurrentThread.GetApartmentState().Should().Be(ApartmentState.STA); + using (UiThreadStateScope.Enter()) + using (var host = new SharedStaDispatcherHost()) + { + FakeUiCaptureSource fake = null; + UiThread.SyncContextFormFactory = () => + fake = new FakeUiCaptureSource { DispatcherToCapture = host.Dispatcher }; + + // Act + Action act = () => UiThread.Init(); + + // Assert + act.Should().NotThrow(); + fake.Should().NotBeNull(); + UiThreadStateScope.UiSyncContextField.Should().BeSameAs(fake.UiSyncContext); + UiThreadStateScope + .AutoScaleFactorField.Should() + .Be(FakeUiCaptureSource.DeterministicAutoScaleFactor); + UiThreadStateScope.UiThreadIdField.Should().Be(fake.UiThreadId); + UiThreadStateScope.DispatcherField.Should().BeSameAs(host.Dispatcher); + } + } + + [TestMethod] + public void Init_ApartmentBoundaryIsStaEqualityNotMtaInequality_RejectsFromMtaAndAcceptsFromSta() + { + // Arrange + using (UiThreadStateScope.Enter()) + using (var host = new SharedStaDispatcherHost()) + { + UiThread.SyncContextFormFactory = () => + new FakeUiCaptureSource { DispatcherToCapture = host.Dispatcher }; + + // Act: drive the same call from both apartments. + Exception mtaOutcome = ApartmentThreadRunner.RunOnThread( + ApartmentState.MTA, + () => UiThread.Init() + ); + Exception staOutcome = ApartmentThreadRunner.RunOnThread( + ApartmentState.STA, + () => UiThread.Init() + ); + + // Assert: the boundary admits STA and rejects everything else, so it is an + // equality test against STA rather than an inequality test against MTA. + mtaOutcome.Should().BeOfType(); + staOutcome.Should().BeNull(); + } + } + } + + /// + /// Regression coverage for issue #788 (AC2): a failed Initialize() must not record + /// initialization, so a later UiThread.Init() from an STA caller retries and succeeds. + /// + /// + /// The class is [STATestClass] because Initialize() must succeed here. The + /// anti-regression assertion is a factory invocation count, never a wall-clock duration, which + /// would be a timing hack and is prohibited by repository policy. + /// + [STATestClass] + [DoNotParallelize] + public class UiThreadInitRetryContract_Tests + { + [TestMethod] + public void Init_WhenFirstInitializeThrows_SecondInitWithWorkingFactorySucceedsAndPopulatesAllFourCaptureFields() + { + // Arrange: the first factory fails during capture, the second succeeds. + using (UiThreadStateScope.Enter()) + using (var host = new SharedStaDispatcherHost()) + { + UiThread.SyncContextFormFactory = () => + new FakeUiCaptureSource { ThrowOnCapture = true }; + Action failing = () => UiThread.Init(); + failing.Should().Throw(); + + FakeUiCaptureSource working = null; + UiThread.SyncContextFormFactory = () => + working = new FakeUiCaptureSource { DispatcherToCapture = host.Dispatcher }; + + // Act + Action retry = () => UiThread.Init(); + + // Assert: the retry ran Initialize() and captured all four values. + retry.Should().NotThrow(); + working.Should().NotBeNull(); + UiThreadStateScope.UiSyncContextField.Should().BeSameAs(working.UiSyncContext); + UiThreadStateScope + .AutoScaleFactorField.Should() + .Be(FakeUiCaptureSource.DeterministicAutoScaleFactor); + UiThreadStateScope.UiThreadIdField.Should().Be(working.UiThreadId); + UiThreadStateScope.DispatcherField.Should().BeSameAs(host.Dispatcher); + } + } + + [TestMethod] + public void Init_WhenInitializeThrows_LeavesAllFourCaptureFieldsUnset() + { + // Arrange + using (UiThreadStateScope.Enter()) + { + UiThread.SyncContextFormFactory = () => + new FakeUiCaptureSource { ThrowOnCapture = true }; + + // Act + Action act = () => UiThread.Init(); + + // Assert: the exception propagates and nothing was captured. + act.Should() + .Throw() + .WithMessage(FakeUiCaptureSource.CaptureFailureMessage); + UiThreadStateScope.UiSyncContextField.Should().BeNull(); + UiThreadStateScope.AutoScaleFactorField.Should().BeNull(); + UiThreadStateScope.UiThreadIdField.Should().Be(-1); + UiThreadStateScope.DispatcherField.Should().BeNull(); + } + } + + [TestMethod] + public void AutoScaleFactor_ReadFromMtaThreadAfterAFailedInit_ThrowsAndDoesNotReEnterTheFactory() + { + // Arrange: fail the first Init(), then record how many capture objects were built. + using (UiThreadStateScope.Enter()) + { + FakeUiCaptureSource.ResetConstructionCount(); + UiThread.SyncContextFormFactory = () => + new FakeUiCaptureSource { ThrowOnCapture = true }; + Action failing = () => UiThread.Init(); + failing.Should().Throw(); + int countAfterFailedInit = FakeUiCaptureSource.ConstructionCount; + + // Act: read the lazy accessor from an MTA thread, where AC1 must reject it. + Exception observed = ApartmentThreadRunner.RunOnThread( + ApartmentState.MTA, + () => _ = UiThread.AutoScaleFactor + ); + + // Assert: it threw the apartment exception and built no further capture object, + // which is the #782 retry storm expressed as an invocation count. + observed + .Should() + .BeOfType() + .Which.Message.Should() + .StartWith(UiThread.NonStaInitMessagePrefix); + FakeUiCaptureSource.ConstructionCount.Should().Be(countAfterFailedInit); + } + } + + [TestMethod] + public void Init_CalledConcurrentlyFromTwoStaThreads_InvokesTheFactoryExactlyOnce() + { + // Arrange + using (UiThreadStateScope.Enter()) + using (var host = new SharedStaDispatcherHost()) + using (var gate = new ManualResetEventSlim(false)) + { + FakeUiCaptureSource.ResetConstructionCount(); + UiThread.SyncContextFormFactory = () => + new FakeUiCaptureSource { DispatcherToCapture = host.Dispatcher }; + + // Act: two STA callers race the first initialization behind one gate. + Thread first = ApartmentThreadRunner.StartStaInitWaiter(gate); + Thread second = ApartmentThreadRunner.StartStaInitWaiter(gate); + gate.Set(); + first.Join(); + second.Join(); + + // Assert: exactly one of them was admitted into Initialize(). + FakeUiCaptureSource.ConstructionCount.Should().Be(1); + } + } + + [TestMethod] + public void Init_WithMonitorUiThreadEnabled_ConstructsAndRunsTheThreadMonitorWithTheInjectedTimeProvider() + { + // Arrange: the fake clock is never advanced, so the timer ThreadMonitor.Run() creates + // never fires and the test leaves no live watchdog. + using (UiThreadStateScope.Enter()) + using (var host = new SharedStaDispatcherHost()) + { + var clock = new FakeTimeProvider(); + UiThread.SyncContextFormFactory = () => + new FakeUiCaptureSource { DispatcherToCapture = host.Dispatcher }; + + // Act + Action act = () => UiThread.Init(monitorUiThread: true, timeProvider: clock); + + // Assert: the monitor branch ran and installed a monitor. + act.Should().NotThrow(); + UiThreadStateScope.ThreadMonitorField.Should().NotBeNull(); + UiThreadStateScope.MonitorTimeProvider.Should().BeSameAs(clock); + } + } + + [TestMethod] + public void UiSyncContext_ReadWithNullBackingFieldFromStaThread_InitializesThroughTheLazyPath() + { + // Arrange: the scope already cleared _uiSyncContext, which is the state the lazy branch + // of the UiSyncContext getter has never been measured in. + using (UiThreadStateScope.Enter()) + using (var host = new SharedStaDispatcherHost()) + { + UiThreadStateScope.UiSyncContextField.Should().BeNull(); + FakeUiCaptureSource captured = null; + UiThread.SyncContextFormFactory = () => + captured = new FakeUiCaptureSource { DispatcherToCapture = host.Dispatcher }; + + // Act + SynchronizationContext observed = UiThread.UiSyncContext; + + // Assert: the value came from the fake, so the lazy path ran Initialize(). + captured.Should().NotBeNull(); + observed.Should().BeSameAs(captured.UiSyncContext); + } + } + } +} diff --git a/UtilitiesCS.Test/Threading/UiThread_Tests.cs b/UtilitiesCS.Test/Threading/UiThread_Tests.cs index dcdd1489d..bfedf7cbe 100644 --- a/UtilitiesCS.Test/Threading/UiThread_Tests.cs +++ b/UtilitiesCS.Test/Threading/UiThread_Tests.cs @@ -1,5 +1,6 @@ using System; using System.Threading; +using System.Windows.Forms; using System.Windows.Threading; using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -7,6 +8,7 @@ namespace UtilitiesCS.Test.Threading { [TestClass] + [DoNotParallelize] public class SynchronizationContextAwaiter_Tests { [TestMethod] @@ -87,6 +89,214 @@ public void OnCompleted_PostsCallbackToContext() postedCallback.Should().NotBeNull(); } + [TestMethod] + public void IsCompleted_WhenAmbientContextIsTheCapturedInstance_ReturnsTrue() + { + // Arrange: the ambient context is the very instance the awaiter captured. + using (UiThreadStateScope.Enter()) + { + var context = new SynchronizationContext(); + SynchronizationContext prior = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(context); + try + { + // Act + bool result = new UiThread.SynchronizationContextAwaiter(context).IsCompleted; + + // Assert: the reference fast path admits it before any static is read. + result.Should().BeTrue(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(prior); + } + } + } + + [TestMethod] + public void IsCompleted_WhenAmbientContextIsNullAndCapturedContextIsNotNull_ReturnsFalse() + { + // Arrange: no ambient context, so there is nothing to resume onto. Continuing inline + // here would break TaskScheduler.FromCurrentSynchronizationContext() at the two + // WebView2 setup sites in QfcItemController.ViewerSetup and EfcItemController. + using (UiThreadStateScope.Enter()) + { + SynchronizationContext prior = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + try + { + // Act + bool result = new UiThread.SynchronizationContextAwaiter( + new SynchronizationContext() + ).IsCompleted; + + // Assert + result.Should().BeFalse(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(prior); + } + } + } + + [TestMethod] + public void IsCompleted_WhenUiThreadIdIsTheMinusOneSentinel_ReturnsFalse() + { + // Arrange: the scope reset _uiThreadId to the pre-Init() sentinel. + using (UiThreadStateScope.Enter()) + { + UiThreadStateScope.UiThreadIdField.Should().Be(-1); + var ambient = new SynchronizationContext(); + SynchronizationContext prior = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(ambient); + try + { + // Act + bool result = new UiThread.SynchronizationContextAwaiter( + new SynchronizationContext() + ).IsCompleted; + + // Assert + result.Should().BeFalse(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(prior); + } + } + } + + [TestMethod] + public void IsCompleted_OnOwningUiThreadWithADispatcherContextCapturedInsideAnInvoke_ReturnsTrue() + { + // Arrange: make the host thread the owning UI thread and its dispatcher the UI one. + using (UiThreadStateScope.Enter()) + using (var host = new StaDispatcherHost()) + { + UiThreadStateScope.SetUiThreadId( + host.Dispatcher.Invoke(() => Thread.CurrentThread.ManagedThreadId) + ); + UiThreadStateScope.SetDispatcher(host.Dispatcher); + + // Act: capture the dispatcher context inside an Invoke, restore a different + // ambient, and evaluate on that same host thread outside any dispatcher operation. + bool result = host.Dispatcher.Invoke(() => + { + SynchronizationContext captured = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(new SynchronizationContext()); + try + { + return new UiThread.SynchronizationContextAwaiter(captured).IsCompleted; + } + finally + { + SynchronizationContext.SetSynchronizationContext(captured); + } + }); + + // Assert: a dispatcher context is UI-owned on the thread whose dispatcher is the + // UI dispatcher, so the continuation may run inline. + result.Should().BeTrue(); + } + } + + [TestMethod] + public void IsCompleted_WhenTheDispatcherContextBelongsToADifferentThreadsDispatcher_ReturnsFalse() + { + // Arrange: the evaluating thread owns the UI thread id but a different host owns the + // UI dispatcher, so this thread's dispatcher is not the UI dispatcher. + using (UiThreadStateScope.Enter()) + using (var owner = new StaDispatcherHost()) + using (var other = new StaDispatcherHost()) + { + SynchronizationContext foreignDispatcherContext = other.Dispatcher.Invoke(() => + SynchronizationContext.Current + ); + UiThreadStateScope.SetDispatcher(other.Dispatcher); + + // Act + bool result = owner.Dispatcher.Invoke(() => + { + UiThreadStateScope.SetUiThreadId(Thread.CurrentThread.ManagedThreadId); + SynchronizationContext captured = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(new SynchronizationContext()); + try + { + return new UiThread.SynchronizationContextAwaiter( + foreignDispatcherContext + ).IsCompleted; + } + finally + { + SynchronizationContext.SetSynchronizationContext(captured); + } + }); + + // Assert + result.Should().BeFalse(); + } + } + + [TestMethod] + public void IsCompleted_WithAForeignWindowsFormsContextWhileUiThreadIdMatches_ReturnsFalse() + { + // Arrange: a WindowsFormsSynchronizationContext that is neither the captured UI context + // nor a dispatcher context, evaluated while the owning thread id does match. This pins + // the reason a bare owning-thread-identity predicate was rejected, and guards the + // WinFormsPumpHostTests failure mode. + using (UiThreadStateScope.Enter()) + using (var host = new StaDispatcherHost()) + { + // Act + bool result = host.Dispatcher.Invoke(() => + { + UiThreadStateScope.SetUiThreadId(Thread.CurrentThread.ManagedThreadId); + using (var foreign = new WindowsFormsSynchronizationContext()) + { + SynchronizationContext captured = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext( + new SynchronizationContext() + ); + try + { + return new UiThread.SynchronizationContextAwaiter(foreign).IsCompleted; + } + finally + { + SynchronizationContext.SetSynchronizationContext(captured); + } + } + }); + + // Assert + result.Should().BeFalse(); + } + } + + [TestMethod] + public void IsCompleted_OnDefaultAwaiterOnAContextFreeThread_ReturnsTrue() + { + // Arrange: the default instance has a null captured context, and this thread has none. + using (UiThreadStateScope.Enter()) + { + SynchronizationContext prior = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + try + { + // Act + bool result = default(UiThread.SynchronizationContextAwaiter).IsCompleted; + + // Assert: unchanged from the pre-change behaviour of this default instance. + result.Should().BeTrue(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(prior); + } + } + } + private class TestSynchronizationContext : SynchronizationContext { private readonly Action _onPost; @@ -101,6 +311,39 @@ public override void Post(SendOrPostCallback d, object state) _onPost?.Invoke((Action)state); } } + + /// + /// Owns a dedicated STA thread running a real dispatcher frame. See the fuller remarks on + /// the copy nested in UiThread_Dispatcher_Tests below for why it is required. + /// + private sealed class StaDispatcherHost : IDisposable + { + private readonly AutoResetEvent _ready = new AutoResetEvent(false); + private readonly Thread _thread; + + public StaDispatcherHost() + { + _thread = new Thread(() => + { + Dispatcher = System.Windows.Threading.Dispatcher.CurrentDispatcher; + _ready.Set(); + System.Windows.Threading.Dispatcher.Run(); + }); + _thread.IsBackground = true; + _thread.SetApartmentState(ApartmentState.STA); + _thread.Start(); + _ready.WaitOne(); + } + + public Dispatcher Dispatcher { get; private set; } + + public void Dispose() + { + Dispatcher.BeginInvokeShutdown(DispatcherPriority.Send); + _thread.Join(); + _ready.Dispose(); + } + } } /// diff --git a/UtilitiesCS.Test/UtilitiesCS.Test.csproj b/UtilitiesCS.Test/UtilitiesCS.Test.csproj index 79ec432e2..006616116 100644 --- a/UtilitiesCS.Test/UtilitiesCS.Test.csproj +++ b/UtilitiesCS.Test/UtilitiesCS.Test.csproj @@ -74,6 +74,7 @@ + @@ -501,6 +502,7 @@ + diff --git a/UtilitiesCS/Threading/IUiCaptureSource.cs b/UtilitiesCS/Threading/IUiCaptureSource.cs new file mode 100644 index 000000000..f44c5c5d4 --- /dev/null +++ b/UtilitiesCS/Threading/IUiCaptureSource.cs @@ -0,0 +1,50 @@ +#nullable enable +using System.Threading; +using System.Windows.Forms; +using System.Windows.Threading; + +namespace UtilitiesCS.Threading +{ + /// + /// The narrow surface uses to capture the UI thread's synchronization + /// context, auto-scale factor, dispatcher and managed thread id during initialization. + /// + /// + /// The interface exists so initialization can be driven by a test double instead of a live + /// WinForms form. It declares exactly the members UiThread.Initialize() consumes and + /// nothing more; SyncContextForm satisfies it without gaining a member, because the four + /// capture properties and are already declared on it and the + /// remaining four members are inherited from . + /// + internal interface IUiCaptureSource + { + /// Gets or sets whether the capture object appears in the taskbar. + bool ShowInTaskbar { get; set; } + + /// Gets or sets the capture object's window state. + FormWindowState WindowState { get; set; } + + /// Displays the capture object, which is what realizes its UI context. + void Show(); + + /// Hides the capture object once its values have been read. + void Hide(); + + /// + /// Reads the four capture values from the calling thread into the properties below. + /// + void CaptureUiVariables(); + + /// Gets the synchronization context captured by . + SynchronizationContext UiSyncContext { get; } + + /// Gets the auto-scale factor captured by . + System.Drawing.SizeF FormAutoScaleFactor { get; } + + /// Gets the dispatcher captured by . + Dispatcher UiDispatcher { get; } + + /// Gets the managed thread id captured by . + int UiThreadId { get; } + } +} diff --git a/UtilitiesCS/Threading/SyncContextForm.cs b/UtilitiesCS/Threading/SyncContextForm.cs index d1cbf4a61..b21031e82 100644 --- a/UtilitiesCS/Threading/SyncContextForm.cs +++ b/UtilitiesCS/Threading/SyncContextForm.cs @@ -13,7 +13,7 @@ namespace QuickFiler.Viewers { - public partial class SyncContextForm : Form + public partial class SyncContextForm : Form, UtilitiesCS.Threading.IUiCaptureSource { public SyncContextForm() { diff --git a/UtilitiesCS/Threading/UiThread.cs b/UtilitiesCS/Threading/UiThread.cs index 7f0f79eab..6b768af8f 100644 --- a/UtilitiesCS/Threading/UiThread.cs +++ b/UtilitiesCS/Threading/UiThread.cs @@ -23,6 +23,16 @@ public static void Init( int lockupAttributionThresholdMs = 5000 ) { + // The precondition is the first statement rather than a sibling of the latch read: + // the four monitoring assignments below mutate process-global configuration on every + // call regardless of the latch, so a non-STA caller would otherwise poison them even + // when Initialize() never runs. + ApartmentState apartment = Thread.CurrentThread.GetApartmentState(); + if (apartment != ApartmentState.STA) + { + throw new InvalidOperationException(NonStaInitMessage(apartment)); + } + _monitorUiThread = monitorUiThread; if (onLockupDetected is not null) { @@ -33,9 +43,19 @@ public static void Init( _monitorTimeProvider = timeProvider; } _lockupAttributionThresholdMs = lockupAttributionThresholdMs; - if (_loaded.CheckAndSetFirstCall) + + // The flag is set after Initialize() returns, not before it runs, so a failed first + // attempt leaves it false and a later call from an STA thread retries. The lock + // additionally serializes concurrent first attempts, which the previous + // Interlocked.Exchange latch never did. + lock (InitLock) { + if (_initialized) + { + return; + } Initialize(); + _initialized = true; } } @@ -43,12 +63,13 @@ public static void Init( private static Action? _onLockupDetected; private static TimeProvider? _monitorTimeProvider; private static int _lockupAttributionThresholdMs = 5000; - private static ThreadSafeSingleShotGuard _loaded = new ThreadSafeSingleShotGuard(); + private static readonly object InitLock = new object(); + private static bool _initialized; private static void Initialize() { // Create a hidden form to initialize the synchronization context - _syncContextForm = new SyncContextForm(); + _syncContextForm = SyncContextFormFactory(); _syncContextForm.ShowInTaskbar = false; _syncContextForm.WindowState = FormWindowState.Minimized; _syncContextForm.Show(); @@ -78,7 +99,41 @@ private static void Initialize() _syncContextForm.Hide(); } - private static SyncContextForm? _syncContextForm; + private static IUiCaptureSource? _syncContextForm; + + /// + /// Supplies the capture object reads the UI values from. Tests + /// replace it so initialization can be driven, and made to fail, without a live form. + /// + internal static Func SyncContextFormFactory { get; set; } = + DefaultSyncContextFormFactory; + + private static IUiCaptureSource DefaultSyncContextFormFactory() => new SyncContextForm(); + + /// + /// Restores every process-global static this type owns to its declared initial value. + /// + /// + /// Test-only. The statics are process-wide for the whole test assembly, so a test that + /// drives initialization through a failure would otherwise change the premise of every + /// later test in that process. This method is not thread-safe; serialization is provided + /// by [DoNotParallelize] on every consuming test class. + /// + internal static void ResetForTesting() + { + _initialized = false; + _uiSyncContext = null; + _dispatcher = null; + _autoScaleFactor = null; + _syncContextForm = null; + _threadMonitor = null; + _uiThreadId = -1; + _monitorUiThread = false; + _onLockupDetected = null; + _monitorTimeProvider = null; + _lockupAttributionThresholdMs = 5000; + SyncContextFormFactory = DefaultSyncContextFormFactory; + } #region UI Thread Synchronization @@ -97,7 +152,42 @@ public SynchronizationContextAwaiter(SynchronizationContext? context) _context = context; } - public bool IsCompleted => _context == SynchronizationContext.Current; + public bool IsCompleted + { + get + { + SynchronizationContext? ambient = SynchronizationContext.Current; + if (ReferenceEquals(_context, ambient)) + { + return true; + } + // A null ambient context means there is nothing to resume onto: continuing + // inline would break TaskScheduler.FromCurrentSynchronizationContext() at the + // two WebView2 setup sites. + if (ambient is null) + { + return false; + } + if (_uiThreadId == -1 || _uiThreadId != Thread.CurrentThread.ManagedThreadId) + { + return false; + } + // The persistent UI context captured at Init() time. + if (ReferenceEquals(_context, _uiSyncContext)) + { + return true; + } + // A dispatcher context is UI-owned only when this thread's dispatcher is the + // UI dispatcher. The spelling is fully qualified because inside this nested + // struct the simple name Dispatcher also names the enclosing type's static + // property of the same name. + return _context is DispatcherSynchronizationContext + && ReferenceEquals( + System.Windows.Threading.Dispatcher.FromThread(Thread.CurrentThread), + _dispatcher + ); + } + } public void OnCompleted(Action continuation) => _context.Post(_postCallback, continuation); @@ -135,6 +225,14 @@ public static int UiThreadId internal const string DispatcherNotInitializedMessage = "The UI dispatcher has not been captured. Call UiThread.Init() on the UI (STA) thread during host startup before reading UiThread.Dispatcher."; + // Split from the formatted message so a test can assert the stable text without pinning + // how the ApartmentState enum renders. + internal const string NonStaInitMessagePrefix = + "UiThread.Init() must be called on the UI (STA) thread during host startup. Observed apartment state: "; + + private static string NonStaInitMessage(ApartmentState observed) => + NonStaInitMessagePrefix + observed; + /// /// Gets the dispatcher captured from the UI (STA) thread during host startup. /// diff --git a/UtilitiesCS/UtilitiesCS.csproj b/UtilitiesCS/UtilitiesCS.csproj index f17145977..443198efd 100644 --- a/UtilitiesCS/UtilitiesCS.csproj +++ b/UtilitiesCS/UtilitiesCS.csproj @@ -1097,6 +1097,7 @@ + Form diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/code-review.2026-09-08T01-35.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/code-review.2026-09-08T01-35.md new file mode 100644 index 000000000..95c296805 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/code-review.2026-09-08T01-35.md @@ -0,0 +1,358 @@ +# Code Review — issue #809, `uithread-init-contract-residuals-784-787-788` + +- Artifact timestamp: 2026-09-08T01-35 +- Base `04a54e681bd21e841e124c016df30672ee701b75` to head `ef431e6a`; 12 files, +1134/-34 +- Reviewer mode: read-only inspection of the head tree, the supplied patch, the 44 committed evidence + artifacts, and both Cobertura documents. No file was modified. + +**Blocking findings: 0.** + +## 1. Overall assessment + +The change is small, well-bounded, and matches the approved specification closely. The three defects +are fixed in the order and shape the spec prescribes, the two test seams are the minimum needed, and +the delivery does not widen scope: `ThreadSafeSingleShotGuard` is retained, no `InternalsVisibleTo` +grant is added, no `.runsettings` apartment setting is changed, and the two reflected field names +`_uiSyncContext` and `_dispatcher` survive intact for the four external reflection consumers. + +The evidence discipline is above average. The executor discovered mid-execution that its own +apartment premise was wrong, recorded the falsifying measurement verbatim, re-authored the two +affected tests, and re-measured fail-before against a restored Phase 2 tree rather than leaving the +first pass standing. That is the correct handling of an invalidated premise. + +## 2. Q1 — Is the new `IsCompleted` predicate safe? + +**Verdict: the predicate honours the governing constraint. It does not evade it.** + +The constraint, verified by the reviewer verbatim at `QuickFiler/Viewers/BreadcrumbUiDispatcher.cs:263-272`: + +> Bare owner-thread identity must never substitute here: a continuation resumed after +> `ConfigureAwait(false)` can be scheduled onto a recycled thread-pool thread whose managed thread ID +> equals the captured owner thread ID, which would run UI work inline and complete the returned task +> without any post ever crossing the captured context. + +The delivered predicate at `UtilitiesCS/Threading/UiThread.cs:155-190` has exactly three ways to +return `true`: + +1. `ReferenceEquals(_context, ambient)` — an ambient reference match to the captured context. This is + the strictest possible boundary and is identical to the pre-change behaviour. +2. `ReferenceEquals(_context, _uiSyncContext)`, reached only after the thread-id guard. +3. `_context is DispatcherSynchronizationContext && ReferenceEquals(Dispatcher.FromThread(Thread.CurrentThread), _dispatcher)`, + reached only after the thread-id guard. + +Thread identity is a **necessary but never sufficient** condition on branches 2 and 3. Each of those +branches additionally requires a reference match against an object captured at `Init()` time — the UI +synchronization context on branch 2, the UI dispatcher on branch 3. There is no path on which +`_uiThreadId == Thread.CurrentThread.ManagedThreadId` alone produces `true`. That is precisely what +the Breadcrumb rule forbids, and it is not what this predicate does. + +Branch 3 is additionally self-securing. `Dispatcher.FromThread` returns the dispatcher bound to the +argument thread, and a `Dispatcher` is permanently affine to the thread that created it. A recycled +thread-pool thread cannot be handed the UI thread's dispatcher, so branch 3 stays false on any thread +other than the true owner regardless of what `_uiThreadId` holds. Branch 3 therefore does not depend +on the thread-id guard at all for correctness. + +### The residual the caller identified + +Branch 2 is the one branch whose only thread-locating evidence is `_uiThreadId`. The residual is real: +if the thread that ran `Init()` has exited and the runtime later reuses its managed thread id for a +different thread, then on that thread, with a non-null ambient context that is not `_context`, and +with `_context` being the still-referenced `_uiSyncContext` object, branch 2 returns `true` and the +continuation runs inline off the UI thread. + +**Reachable in production: no.** Managed thread ids are unique among live threads; reuse requires the +original thread to have terminated. The only production caller of `Init()` is `TaskMaster/ThisAddIn.cs:35` +on the Outlook main STA thread, which lives for the process lifetime. While that thread is alive no +other thread can carry its id, so branch 2's guard is exact for the whole period in which the predicate +is ever evaluated. Reaching the residual in production would require the Outlook UI thread to die and +the add-in to keep running, at which point inline continuation ordering is not the failure that +matters. + +**Reachable in tests: not as delivered, and not by accident.** Every test that writes `_uiThreadId` +does so inside `UiThreadStateScope`, which restores the prior value on disposal (`UiThreadStateScope.cs:320-335`), +and the four production statics are additionally reset at scope entry. Three further conditions would +have to coincide inside one scope: a dead host thread whose id was recycled to the evaluating thread, +`_uiSyncContext` installed and identical to the awaited context, and a third non-null ambient context. +No test in this delivery constructs that arrangement, and the coverage data confirms it: lines 177-178 +have zero hits. + +**Unreachable by the ConfigureAwait(false) mechanism specifically.** That mechanism resumes a +continuation on a pool thread. Pool threads normally carry a null ambient `SynchronizationContext`, in +which case the `ambient is null` early return at line 167 answers `false` before the thread-id guard is +ever evaluated. For the ambient to be non-null on such a thread, some other captured context must be +installed, in which case branch 1 would have already matched if that context were `_context`. + +**Recommended hardening (not required for merge).** Adding the dispatcher-identity test to branch 2 +would remove the residual entirely at no behavioural cost, because on the true UI thread +`Dispatcher.FromThread(Thread.CurrentThread)` already returns `_dispatcher`: + +```csharp +if (ReferenceEquals(_context, _uiSyncContext) + && ReferenceEquals(System.Windows.Threading.Dispatcher.FromThread(Thread.CurrentThread), _dispatcher)) +{ + return true; +} +``` + +This is worth an issue, not a remediation cycle. + +### `WinFormsPumpHostTests` confirmation + +`QuickFiler.Test/TestSupport/WinFormsPumpHostTests.cs:181-204` (`AwaitingSyncContext_FromTheTestThread_ResumesOnThePumpThread`) +still passes, twice, recorded in `p4-t3-quickfiler-tests.md` with durations `00:00:00.0030778` and a +second pass after the Phase 4 rebuild. The reason it passes is structural rather than incidental: the +awaited context is the pump host's `WindowsFormsSynchronizationContext`, which is neither reference-equal +to `_uiSyncContext` nor a `DispatcherSynchronizationContext`, so branches 2 and 3 both fail and the +continuation posts to the pump thread as the assertion requires. The delivery additionally removed the +`UiThread.Init(false)` call from `QfcHomeControllerRunAsyncTests.cs:329` that made this failure mode +possible at all, so the test no longer depends on execution order within the assembly. A dedicated +regression guard, `IsCompleted_WithAForeignWindowsFormsContextWhileUiThreadIdMatches_ReturnsFalse`, +pins the reason in `UtilitiesCS.Test` where it is cheap to run. + +## 3. Q2 — Is the `[P0-T15]` apartment inference sound? + +**Verdict: the inference is unsound. The `MTA_` label is unsupported and most likely wrong. The AC2 +design argument nevertheless holds. Grade the labelling risk Medium and the design risk nil.** + +`p0-t15-mta-synccontextform-measurement.md:42` reasons: "The test is declared with a plain `[TestMethod]` +at `:325` on a class carrying no `[STATestClass]`, so the executing apartment is the MSTest default, +which research R4 established as MTA from three independent in-tree sources." That is an inference from +a premise, not a measurement. Nothing in the run read `Thread.CurrentThread.GetApartmentState()`. + +The same delivery then falsified the premise. `p2-t10-fail-before.md` quotes the verbatim TRX message +`Expected Thread.CurrentThread.GetApartmentState() to be ApartmentState.MTA {value: 1}, but found +ApartmentState.STA {value: 0}.` from a plain `[TestMethod]` on a plain `[TestClass]`. + +The caller's question is whether the `/Tests:` single-test selection makes the executor's stated +mechanism inapplicable. It does — and that is the point, because the reviewer's reading of the tree is +that the executor's stated mechanism is not the operative one: + +- The `[P0-T15]` command passed **no `/Settings:` argument**. No runsettings applied. +- No `.runsettings` anywhere in this repository sets `ExecutionThreadApartmentState`; the reviewer + grepped all five (`TaskMaster.runsettings`, `scripts/vscode/TaskMaster.cli.runsettings`, + `UtilitiesCS.Test/test.runsettings`, `TaskTree.Test/coverage.tasktree.runsettings`, + `TaskVisualization.Test/coverage.runsettings`). `UtilitiesCS.Test/test.runsettings` is an empty + `` carrying only a comment. +- `UtilitiesCS.Test/Properties/AssemblyInfo.cs:17-20` carries `[assembly: Parallelize(Workers = 0, + Scope = ExecutionScope.ClassLevel)]`, so that assembly parallelizes even with no runsettings. + `QuickFiler.Test` carries **no** assembly-level `Parallelize` attribute, so with no runsettings its + tests do not parallelize at all. + +That yields one explanation covering both observations without needing bucket-sharing: tests dispatched +to the MSTest parallel worker pool run on thread-pool threads and are MTA, while tests that run on the +main test-execution thread — the `[DoNotParallelize]` serial bucket, or every test when parallelization +is off — inherit that thread's apartment, and the vstest execution thread on .NET Framework is STA +unless `ExecutionThreadApartmentState` says otherwise. Under that explanation the `[P0-T15]` run, being +a single test in a non-parallelizing assembly with no runsettings, executed on the main thread and was +**STA**. + +Consequences: + +- The `MTA_INITIALIZE_OUTCOME: COMPLETED` token is mislabelled. What was measured is that + `new SyncContextForm(); Show();` completes on the vstest main execution thread, whose apartment was + not read. +- The refutation in `p6-t4-ac2-regression-reconciliation.md` — "the #782 mechanism narrative is refuted + on this host" — does not follow. The #782 narrative requires a throw on an MTA thread; a successful + run on an STA thread says nothing about it. The narrative's status reverts to UNKNOWN, which is where + decision D5 found it. +- `p6-t13-closure-summary.md` section 6 propagates the narrower mechanism to future planners. Its + operational advice ("a test that needs a caller of a known apartment must create a dedicated thread + and set the apartment explicitly") is correct and is what the re-authored tests now do; only the + stated cause is doubtful. + +### Does the `[P6-T4]` "safe whichever value was measured" argument hold? + +**Yes, and the reviewer verified it structurally rather than accepting it.** The argument is that the +AC1 precondition makes the expensive, potentially-throwing body of `Initialize()` unreachable from any +non-STA caller, so no retry storm can originate on a thread-pool thread whatever `SyncContextForm` +does there. Verified against the head tree: + +- `Init()` reads `GetApartmentState()` and throws at `UiThread.cs:30-34`, before the four monitoring + assignments and before `lock (InitLock)`. +- `Initialize()` is called from exactly one place, `UiThread.cs:57`, inside that lock. +- The only other entries are the two lazy getters, `UiSyncContext` at `UiThread.cs:207-210` and + `AutoScaleFactor` at `UiThread.cs:281-284`, both of which call `Init()` and therefore hit the + precondition first. +- `Dispatcher` at `UiThread.cs:251-269` does not call `Init()`, as its `` states. + +So a non-STA reader costs one `GetApartmentState()` and a throw. The argument is sound independently of +the measurement, exactly as decision D5 required. This is why F1 is an evidence defect rather than a +code defect, and why it is non-blocking. + +Additionally, the AC2 anti-retry-storm test does not rest on the ambient apartment at all: it drives +its Act through `ApartmentThreadRunner.RunOnThread(ApartmentState.MTA, ...)`, which calls +`SetApartmentState` on a dedicated thread before `Start()`. Its apartment is set, not inherited. + +## 4. Q3 — Are the two re-authored Phase 2 tests still genuine fail-before evidence? + +**Verdict: yes. The re-measured fail-before is admissible, the failures are attributable to the +defects, and no test was weakened.** + +Four checks were performed. + +**The first pass is correctly disqualified for two rows, and only two.** The verbatim messages in +`p2-t10-fail-before.md` show `Init_OnMtaThread_ThrowsInvalidOperationExceptionNamingTheObservedApartmentState` +failing on its Arrange premise (`...but found ApartmentState.STA`) — it never reached the Act — and +`Init_OnMtaThread_CapturesNoGlobalStateAndLeavesMonitoringConfigurationUnchanged` reaching the Act on +an STA thread, where the AC1 precondition correctly does not throw, so it would have stayed red after +the fix. Both disqualifications are correct. The other four rows already drove their Act on dedicated +threads with explicitly set apartments and are unaffected, which the reviewer confirmed against the +delivered test source. + +**The re-measurement is against the right tree.** `UiThread.cs` and `UiThreadStateScope.cs` were +restored to their committed Phase 2 state at `f7294d71`, the solution was rebuilt with `/t:Rebuild`, and +the identical `TestCaseFilter` was re-run. `REMEASURED_EXIT_CODE: 1` with `ExpectedExitCode: 1` under the +`[expect-fail]` convention in `.claude/skills/atomic-plan-contract/SKILL.md`. Counters are 22 total, 22 +executed, 16 passed, 6 failed, derived skipped 0. + +**The failures are defect failures, not harness or compilation failures.** A compilation failure would +have produced zero discovered tests, not 22 discovered with 16 green. The 16 green rows include the +five pre-existing awaiter tests and the four new tests that were expected to be green pre-fix, each with +a stated reason. The six red rows carry assertion messages, not infrastructure exceptions: +`Expected observed to be System.InvalidOperationException, but found ` on the three apartment +rows, `Expected working not to be ` on the retry row, `Expected InvalidOperationException.Message +... but found ` on the anti-storm row, and `Expected result to be True, but found False` on the +awaiter row. Each maps to the specific defect: no precondition existed, the latch was consumed before +`Initialize()`, and the predicate compared by reference. `found ` on the apartment rows is the +positive signature that `Init()` returned normally from a genuine MTA caller. + +**No test was weakened.** The remedy moved the Act onto `ApartmentThreadRunner.RunOnThread(ApartmentState.MTA, ...)`. +Neither method was renamed, none was added or removed, the discovered total stayed at 22 across both +passes, and each method asserts the same contract — an `InvalidOperationException` whose message starts +with `NonStaInitMessagePrefix`, and the eight unchanged fields. This is a strengthening: the pre-change +form asserted the contract against a caller whose apartment was assumed, the post-change form asserts it +against one whose apartment is set. The pass-after run (`p3-t6-pass-after.md`) is 22/22 at exit 0 with +all six named rows green. + +One process note: an earlier `p3t6.trx` pass-after attempt reported 20 passed and 2 failed. That is +recorded rather than discarded, and the two failures are the same defective premise. Retaining the +superseded pass in the artifact is the right call and made this review verifiable. + +## 5. Q4 — Are the two residual coverage gaps acceptable? + +**Verdict: not blocking. Both are acceptable, for different reasons.** + +Reviewer-verified uncovered set at head, recomputed from `coverage/809-p5-final.cobertura.xml`: +`38, 39, 40, 177, 178`. File total 121/126 = 96.03%. Member total for `IsCompleted` 18/20 = 90.00%. + +**`UiThread.cs:38-40`** — the body of `if (onLockupDetected is not null) { _onLockupDetected = onLockupDetected; }`. +The guard condition at line 37 is covered; only the assignment is unreached, because the single test +that supplies a callback supplies it precisely to prove that a rejected `Init()` performs no assignment. +This is a three-line, no-logic assignment on a path whose semantics are asserted from the negative side. +Acceptable. + +**`UiThread.cs:177-178`** — the true arm of `ReferenceEquals(_context, _uiSyncContext)`. The caller is +right that this is the delivery's highest-risk branch and its least-covered one, and that combination +deserves an explicit answer rather than a percentage. It is acceptable for four reasons, taken +together rather than individually: + +1. **The condition is exercised; only the arm is not.** Line 176 is covered with hits and evaluates + false in `IsCompleted_OnOwningUiThreadWithADispatcherContextCapturedInsideAnInvoke_ReturnsTrue`, + which falls through it to the dispatcher clause. The clause is not dead code that was never + compiled into a reachable path — it is reached and evaluated, and the false outcome is asserted. +2. **The uncovered arm is `return true;`.** It contains no state mutation, no call, and no branch. The + failure mode of an untested `return true` is a wrong answer, and the wrong answer it could give is + exactly the F3 residual, which is unreachable in production for the reasons in Q1. +3. **The adjacent risk is covered from the other side.** The reason branch 2 exists — that a UI-owned + context should not force a hop — is asserted through the dispatcher branch, and the reason it must + not over-admit is asserted by `IsCompleted_WithAForeignWindowsFormsContextWhileUiThreadIdMatches_ReturnsFalse` + and `IsCompleted_WhenTheDispatcherContextBelongsToADifferentThreadsDispatcher_ReturnsFalse`. The + over-admission failure mode is therefore pinned even though this specific arm is not. +4. **It meets the governing floors.** The member sits at exactly 90.00%, at the `CLAUDE.md` new-code + floor; changed-line coverage is 95.83%; the file is 96.03% against an 80% floor and a 76.83% + baseline. There is no threshold under either the `CLAUDE.md` or the `.claude/rules` reading that + this delivery fails on this file. + +The gap is closable by one test that installs `_uiSyncContext`, installs `_uiThreadId` for a host +thread, sets a third context ambient, and awaits the installed instance from that thread. That is a +small addition and both `p6-t1` and `p6-t2` already name it. Recommended as a follow-up issue rather +than a remediation cycle, and it should be bundled with the F3 hardening, because hardening branch 2 +changes what the new test must assert. + +## 6. Coverage exclusion policy + +**No first-party production path is excluded. PASS.** + +- Repository-root `coverage.config` excludes seven third-party module path patterns: Deedle, FSharp, + Castle.Core, FluentAssertions, Moq, Microsoft.Testing, MSTest. All are packages, none is a production + source path. +- The derived `coverage/809-effective-coverage.config` is byte-identical to that file plus exactly one + appended entry, `.*\.Test\.dll$`, verified by direct comparison. It matches + test assemblies by output filename and cannot match a production assembly, since no first-party + production assembly is named `*.Test.dll`. +- These are module-path exclusions in an instrumentation settings file, not `exclude` entries pointing + at source paths, so the prohibited category in `.claude/rules/general-unit-test.md` does not arise. +- No `[ExcludeFromCodeCoverage]` attribute was added anywhere in the diff. The one such attribute + mentioned in the closure summary, on `ThreadMonitor.PingAndAwaitDiagnosticWindow()`, is pre-existing + and outside the Write Set. +- Cross-check on the effect of the `.Test.dll` exclusion: the first-party packages enumerated in the + Cobertura document are the nine production assemblies, and no `*.Test` package appears, which is the + intended outcome of `UT2`'s instruction to keep test files out of the metric. + +## 7. Design and implementation notes + +Strengths worth recording: + +- **The precondition is placed where the spec argues it must be.** It is the first statement of + `Init()`, ahead of the four monitoring assignments that previously executed on every call regardless + of the latch. `Init_OnMtaThread_CapturesNoGlobalStateAndLeavesMonitoringConfigurationUnchanged` + asserts all eight fields, so the placement is pinned rather than incidental. +- **The retry fix also closes a pre-existing race.** `lock (InitLock)` removes the #782 finding C04 + window in which a second caller could observe the `Interlocked.Exchange` latch consumed and read a + half-populated static set. `Init_CalledConcurrentlyFromTwoStaThreads_InvokesTheFactoryExactlyOnce` + covers it. +- **The interface is minimal and satisfied without change.** `IUiCaptureSource` declares nine members; + `SyncContextForm` gains only the declaration. Implementing directly rather than adding an adapter + type is the correct simplicity-first call and is stated as such. +- **The predicate reads private fields rather than the properties, deliberately.** Reading + `UiSyncContext` would call `Init()`, which now throws off the UI thread, and reading `Dispatcher` + throws when unset. A boolean query must be total and side-effect-free. The code comment at 180-183 + also records why the dispatcher type is fully qualified inside the nested struct. +- **The `QfcHomeControllerRunAsyncTests` reconciliation removes an order dependency rather than + papering over it.** The dispatcher is created on the pump thread first, then resolved with + `Dispatcher.FromThread`, with a comment explaining that resolving it earlier would return null and + `Install(null)` would leave the static unset. The `finally` disposes the transaction and stops the + host on every path. + +Observations, none blocking, none requiring rework before merge: + +- **O1 — `Initialize()` runs while `InitLock` is held**, and `Initialize()` constructs and `Show()`s a + WinForms form and may start a `ThreadMonitor`. Holding a lock across UI work is a deadlock shape in + general. It is safe here because the lock is private, is taken on exactly one path, runs once per + process on the host STA thread during startup, and nothing inside `Initialize()` waits on another + thread that could want the lock. Worth a comment rather than a change. +- **O2 — `ResetForTesting()` drops `_syncContextForm` without hiding or disposing it.** With a real + `SyncContextForm` that would leak a live hidden window. It is test-only and every consumer installs a + fake, so nothing leaks today; a caller who forgot to install a factory first would be the exposure. +- **O3 — `UiThreadStateScope.Resolve` asserts inside a static field initializer.** A field rename + surfaces as `TypeInitializationException` wrapping the FluentAssertions message rather than as a + clean failure. The intent — fail loudly rather than degrade to a silent no-op restore — is right and + is documented at `UiThreadStateScope.cs:344-350`; only the diagnostic shape is imperfect. +- **O4 — the predicate reads three non-volatile statics without synchronization** while `Init()` writes + them under a lock. A stale read yields `false`, which posts, which is the conservative and correct + fallback, so this is safe by construction rather than by accident. The sibling `Dispatcher` property + documents the same concern at `UiThread.cs:255-257`. +- **O5 — `FakeUiCaptureSource.ConstructionCount` is process-global mutable state.** Documented as such, + reset by each consuming test, and every consuming class is `[DoNotParallelize]`. The invocation-count + assertion it supports is the right choice over any duration-based assertion. +- **O6 — file size.** `FolderPredictorTests.cs` is 1067 lines at head against a 500-line limit that + admits no exception for test code (Finding F4 in the policy audit). The delivery adds one attribute + line to it. Non-blocking; recommend a follow-up issue to split the file. +- **O7 — the two `Assert.IsTrue` calls retained in the amended `QuickFiler.Test` method** are + pre-existing and were correctly left alone; converting them would have widened the diff for no gain. + +## 8. Recommendations + +1. Correct `MTA_INITIALIZE_OUTCOME` in `p0-t15-mta-synccontextform-measurement.md` and the Disposition A + conclusion in `p6-t4-ac2-regression-reconciliation.md` to state that the apartment was not measured, + and revert the status of the #782 narrative to UNKNOWN. The AC2 design argument stands unchanged and + should be retained verbatim. +2. Correct the mechanism sentence in `p6-t13-closure-summary.md` section 6, keeping the operational + advice. +3. Open one issue covering the branch-2 hardening in `IsCompleted` and the test that closes lines + 177-178, since the hardening changes what that test asserts. +4. Open one issue for splitting `FolderPredictorTests.cs` under the 500-line limit. +5. Promote the remaining follow-up candidates in `p6-t13` section 5 into real issues before the feature + folder is merged, so they survive. + +None of the five is a precondition for merge. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t10-analyzer-build.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t10-analyzer-build.md new file mode 100644 index 000000000..d2e6eb545 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t10-analyzer-build.md @@ -0,0 +1,22 @@ +# [P0-T10] Baseline analyzer build + +Timestamp: 2026-09-08T00-31 + +Command: `& $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true` + +`$msbuild` was resolved with the vswhere form the plan's standing conventions state, and resolved to the Visual Studio 18 Community MSBuild. Only the executable token is resolved by path; the argument list is character-for-character the list `CLAUDE.md` states. + +EXIT_CODE: 0 + +BASELINE_PROJECT_COUNT: 18 + +Output Summary: the two trailing summary lines, verbatim: + +``` + 0 Warning(s) + 0 Error(s) +``` + +`BASELINE_PROJECT_COUNT` is the count of build-output lines of the arrow form ` -> \bin\Debug\` in the normal-verbosity file log. + +`/t:Rebuild` was used rather than `/t:Build`. MSBuild's up-to-date check does not invalidate on a command-line `/p:` change, so a warm `/t:Build` returns exit 0 with `CoreCompile` skipped on every project and runs no analyzers; a `/t:Build` result would not be admissible evidence for this gate. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t11-nullable-build.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t11-nullable-build.md new file mode 100644 index 000000000..a24f3e87b --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t11-nullable-build.md @@ -0,0 +1,16 @@ +# [P0-T11] Baseline nullable build + +Timestamp: 2026-09-08T00-34 + +Command: `& $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true` + +EXIT_CODE: 0 + +Output Summary: the two trailing summary lines, verbatim: + +``` + 0 Warning(s) + 0 Error(s) +``` + +`/p:Nullable=enable` was not passed, because no project in this repository carries a `` element and there is no `Directory.Build.props`, so that property is a solution-wide opt-in that would conscript every file which has never adopted the `#nullable enable` pragma; CI omits it deliberately and omitting it loses no enforcement over any file that has opted in. `/t:Rebuild` was used rather than `/t:Build` for the reason recorded in [P0-T10]. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t12-vstest.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t12-vstest.md new file mode 100644 index 000000000..e1aefb2e3 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t12-vstest.md @@ -0,0 +1,35 @@ +# [P0-T12] Baseline full-suite test run (no coverage) + +Timestamp: 2026-09-08T00-40 + +Command: `& $vstest` followed by the nine-assembly list and the full-suite switch set with `` `p0t12`: + +``` +& $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll SVGControl.Test\bin\Debug\SVGControl.Test.dll Tags.Test\bin\Debug\Tags.Test.dll TaskMaster.Test\bin\Debug\TaskMaster.Test.dll TaskTree.Test\bin\Debug\TaskTree.Test.dll TaskVisualization.Test\bin\Debug\TaskVisualization.Test.dll ToDoModel.Test\bin\Debug\ToDoModel.Test.dll UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll VBFunctions.Test\bin\Debug\VBFunctions.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx;LogFileName=p0t12.trx' '/ResultsDirectory:TestResults\809-p0t12' '/Blame:CollectHangDump;TestTimeout=5min;HangDumpType=None' '/TestCaseFilter:TestCategory!=LiveOutlook&FullyQualifiedName!~HelperClasses.ShellUtilities_Tests&FullyQualifiedName!~HelperClasses.ShellUtilitiesStatic_Tests&FullyQualifiedName!~HelperClasses.SysImageListHelperTests&FullyQualifiedName!~EmailIntelligence.OSBrowser_Tests' +``` + +`$vstest` was resolved by the vswhere form the standing conventions state and resolved to the Visual Studio 18 Community `vstest.console.exe`. `TestResults\809-p0t12` was created with `New-Item -ItemType Directory -Force` before the run. + +EXIT_CODE: 0 + +BASELINE_TOTAL_TESTS: 7120 + +Output Summary: + +``` +Test Run Successful. +Total tests: 7120 + Passed: 7120 +``` + +The console printed no `Failed:` line and no `Skipped:` line, which is the success-case output shape for this runner. + +TRX selected: `p0t12.trx`, `LastWriteTimeUtc` `2026-09-08T04:19:46.3498332Z`, selected as the most recently modified `.trx` under `TestResults\809-p0t12` by `Get-ChildItem -Path -Filter *.trx | Sort-Object LastWriteTimeUtc | Select-Object -Last 1`. + +TRX `ResultSummary/Counters` attributes: `total` 7120, `executed` 7120, `passed` 7120, `failed` 0. + +The `failed` attribute is 0. + +SKIPPED_DERIVED: 0 + +`SKIPPED_DERIVED` is the TRX `total` attribute minus the TRX `executed` attribute. The TRX `notExecuted` attribute is not used, because the TRX logger writes it as `0` regardless of what the run did. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t13-coverage.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t13-coverage.md new file mode 100644 index 000000000..e1ca1f8df --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t13-coverage.md @@ -0,0 +1,48 @@ +# [P0-T13] Baseline coverage collection + +Timestamp: 2026-09-08T00-43 + +Command: + +``` +dotnet-coverage collect --output coverage\809-p0-baseline.cobertura.xml --output-format cobertura --settings coverage\809-effective-coverage.config -- $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll SVGControl.Test\bin\Debug\SVGControl.Test.dll Tags.Test\bin\Debug\Tags.Test.dll TaskMaster.Test\bin\Debug\TaskMaster.Test.dll TaskTree.Test\bin\Debug\TaskTree.Test.dll TaskVisualization.Test\bin\Debug\TaskVisualization.Test.dll ToDoModel.Test\bin\Debug\ToDoModel.Test.dll UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll VBFunctions.Test\bin\Debug\VBFunctions.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx;LogFileName=p0t13.trx' '/ResultsDirectory:TestResults\809-p0t13' '/Blame:CollectHangDump;TestTimeout=5min;HangDumpType=None' '/TestCaseFilter:TestCategory!=LiveOutlook&FullyQualifiedName!~HelperClasses.ShellUtilities_Tests&FullyQualifiedName!~HelperClasses.ShellUtilitiesStatic_Tests&FullyQualifiedName!~HelperClasses.SysImageListHelperTests&FullyQualifiedName!~EmailIntelligence.OSBrowser_Tests' +``` + +The derived settings file `coverage\809-effective-coverage.config` was built by loading repository-root `coverage.config` and appending one `.*\.Test\.dll$` to `/Configuration/CodeCoverage/ModulePaths/Exclude`. `coverage/` is matched by `.gitignore:144`, so neither the settings file nor the Cobertura document enters the tree; the numeric findings recorded here are the retained evidence. + +EXIT_CODE: 0 + +## Counting method (reproduced by name) + +The selection is the all-descendant `.//line` selection over each first-party ``, and only that one. The two narrower selections `classes/class/lines/line` and `classes/class/methods/method/lines/line` are **rejected by name and were not substituted**. A `` counts as covered when its `hits` attribute is greater than zero. Branch figures are summed from the `(numerator/denominator)` pair inside each `condition-coverage` attribute over the same line set. `GetAttribute` was used rather than property access so a `` lacking an attribute yields an empty string instead of throwing under `Set-StrictMode`. + +Cobertura `` elements produced by `dotnet-coverage` carry `line-rate` and `branch-rate` but carry no `lines-covered`, `lines-valid`, `branches-covered` or `branches-valid` attributes, so those four figures are aggregated from `` elements and the denominator depends entirely on the selection. + +The first-party allowlist is the nine production assembly names `Tags`, `ToDoModel`, `TaskVisualization`, `UtilitiesCS`, `QuickFiler`, `TaskTree`, `TaskMaster`, `SVGControl`, `VBFunctions`. + +## Output Summary + +Test run: `Test Run Successful.`, `Total tests: 7120`, ` Passed: 7120`. TRX selected `p0t13.trx`, `LastWriteTimeUtc` `2026-09-08T04:21:12.8174136Z`; `failed` 0; `total` minus `executed` is 0. + +BASELINE_FIRSTPARTY_LINES_COVERED: 113361 +BASELINE_FIRSTPARTY_LINES_VALID: 134023 +BASELINE_FIRSTPARTY_BRANCHES_COVERED: 26880 +BASELINE_FIRSTPARTY_BRANCHES_VALID: 33880 +BASELINE_FIRSTPARTY_LINE_PCT: 84.58 +BASELINE_FIRSTPARTY_BRANCH_PCT: 79.34 + +Per-package breakdown, same selection: + +| Package | Lines covered | Lines valid | Line % | Branches covered | Branches valid | Branch % | +|---|---|---|---|---|---|---| +| QuickFiler | 20549 | 25572 | 80.36 | 4892 | 6330 | 77.28 | +| UtilitiesCS | 79008 | 88958 | 88.81 | 18652 | 22414 | 83.22 | +| TaskVisualization | 2899 | 3230 | 89.75 | 666 | 800 | 83.25 | +| SVGControl | 1757 | 3712 | 47.33 | 600 | 1276 | 47.02 | +| ToDoModel | 2193 | 3819 | 57.42 | 496 | 1016 | 48.82 | +| Tags | 1428 | 1540 | 92.73 | 348 | 380 | 91.58 | +| TaskMaster | 4927 | 6564 | 75.06 | 1038 | 1460 | 71.10 | +| TaskTree | 592 | 620 | 95.48 | 188 | 204 | 92.16 | +| VBFunctions | 8 | 8 | 100.00 | 0 | 0 | 0.00 | + +These are locally-filtered nine-assembly figures, not CI figures. `/EnableCodeCoverage` was not passed: `scripts/vscode/TaskMaster.cli.runsettings` carries no data collector, and `scripts/vscode/Invoke-MSTestWithCoverage.ps1:19-26` records that the omission is deliberate because the outer `dotnet-coverage` instrumentation and the built-in Code Coverage collector conflict. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t14-uithread-file-coverage.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t14-uithread-file-coverage.md new file mode 100644 index 000000000..8ff663edd --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t14-uithread-file-coverage.md @@ -0,0 +1,26 @@ +# [P0-T14] Baseline per-file coverage of `UtilitiesCS/Threading/UiThread.cs` + +Timestamp: 2026-09-08T00-45 + +Command: the pinned per-file lookup applied to `coverage\809-p0-baseline.cobertura.xml`, the document [P0-T13] wrote. The lookup selects `` elements whose `filename` attribute ends with the backslash suffix `UtilitiesCS\Threading\UiThread.cs`, takes `.//line` under each, counts each line number once, and treats a line number as covered when any matching element for that file carries `hits` greater than zero. A line number that matches no element is not executable and is excluded from both numerator and denominator. The `filename` attribute in this document carries an absolute path with backslash separators; a forward-slash match returns zero rows. + +EXIT_CODE: 0 + +BASELINE_UITHREAD_LINES_COVERED: 63 +BASELINE_UITHREAD_LINES_VALID: 82 +BASELINE_UITHREAD_LINE_PCT: 76.83 +BASELINE_UITHREAD_UNCOVERED_LINES: 28,29,30,32,33,34,67,68,69,70,71,72,73,74,75,76,118,119,120 + +Output Summary: 63 of 82 executable line numbers covered, 76.83%, with 19 uncovered line numbers. + +## Relation to the figure quoted in `spec.md` + +`spec.md` carries a figure of **76.83% line and 65.00% branch** quoted from the #782 records (`spec.md:367`, restating `docs/features/active/2026-09-05-pr-778-post-merge-review-residuals-782/evidence/other/r1-r2-maintainer-disposition.2026-09-06T00-15.md:74-83`). **This task's own measured figure is the baseline of record for [P6-T1].** The two are not required to agree, because the #782 figure was produced by an unrecorded selection. + +As measured, they do agree on the line percentage and on the uncovered-line set: this run reproduces 76.83% and the identical 19-line list `28,29,30,32,33,34,67,68,69,70,71,72,73,74,75,76,118,119,120`. The agreement is recorded as an observation, not as a requirement. + +## Exclusion check + +UITHREAD_COVERAGE_EXCLUSION_APPLIES: false + +Neither `UiThread` nor any nested type carries `[ExcludeFromCodeCoverage]`: a search of `UtilitiesCS/Threading/UiThread.cs` for that attribute returned zero matches. Repository-root `coverage.config` excludes only third-party module paths (`Deedle`, `FSharp`, `Castle.Core`, `FluentAssertions`, `Moq`, `Microsoft.Testing`, `MSTest`), so no exclusion applies to this file. The derived settings file used for the run adds one further exclude, `.*\.Test\.dll$`, which excludes test assemblies rather than any production file. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t16-line-counts.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t16-line-counts.md new file mode 100644 index 000000000..57ce567d0 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t16-line-counts.md @@ -0,0 +1,33 @@ +# [P0-T16] Baseline line counts of the pre-existing Write Set source files + +Timestamp: 2026-09-08T00-49 + +Command: `git ls-files -- ` followed by `(Get-Content -LiteralPath ).Count` for each of them. + +EXIT_CODE: 0 + +## Line-count idiom (reproduced by name) + +A file's line count is `(Get-Content -LiteralPath ).Count`, which counts physical lines. The idiom `Get-Content -LiteralPath | Measure-Object -Line` is **rejected by name and was not substituted**: `Measure-Object -Line` splits its input with `RemoveEmptyEntries` and therefore counts non-blank lines only, understating a file by exactly its blank-line count. The 500-line limit in `.claude/rules/general-code-change.md` and the 495-line ceiling in [P2-T9] are physical-line limits. + +All seven paths were confirmed tracked by `git ls-files`, which listed all seven. + +## Output Summary + +BASELINE_LINES UtilitiesCS/Threading/UiThread.cs 195 +BASELINE_LINES UtilitiesCS/Threading/SyncContextForm.cs 50 +BASELINE_LINES UtilitiesCS.Test/Threading/UiThread_Tests.cs 215 +BASELINE_LINES UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs 1066 +BASELINE_LINES UtilitiesCS.Test/EmailIntelligence/FolderRemapViewer_Tests.cs 156 +BASELINE_LINES UtilitiesCS.Test/EmailIntelligence/FilterOlFoldersViewer_Tests.cs 272 +BASELINE_LINES QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs 360 + +## Divergence check + +The three counts the plan states are observed exactly: `UtilitiesCS/Threading/UiThread.cs` 195, `UtilitiesCS.Test/Threading/UiThread_Tests.cs` 215, and `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs` 360. No `BASELINE_LINE_COUNT_DIVERGENCE:` line is recorded, because no observed count differs from the value the plan states. + +Consequences that follow from the observed values: + +- The [P2-T8] budget of 275 added lines is measured against the 215-line `UtilitiesCS.Test/Threading/UiThread_Tests.cs` baseline recorded here. 275 added lines on that baseline finish at 490, five lines under the 495-line ceiling [P2-T9] gates. The baseline did not diverge, so the permitted added-line count does not move. +- The [P2-T1] figure of 460 is a whole-file ceiling for `UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs`, a file this delivery creates. No baseline is subtracted from it and it is not among the seven paths measured here. +- `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs` measures 1066 lines, matching the figure [P4-T5] quotes. It already exceeds the 500-line limit before this delivery touches it, and the only change this delivery makes to it is the single attribute line [P2-T7] adds, so [P4-T5] will classify it under `PRE_EXISTING_FILES_OVER_500:` rather than under `FILES_OVER_500_INTRODUCED:`. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t17-design-preconditions.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t17-design-preconditions.md new file mode 100644 index 000000000..435e781d7 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t17-design-preconditions.md @@ -0,0 +1,69 @@ +# [P0-T17] Design preconditions re-derived against the current tree + +Timestamp: 2026-09-08T00-51 + +Each of the five preconditions below was re-derived directly against the working tree in this run, not read from a cited artifact. + +## 1. The `InternalsVisibleTo` grant + +File: `UtilitiesCS/Properties/AssemblyInfo.cs`. Lines 18 through 20 read: + +``` +18 : [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] +19 : [assembly: InternalsVisibleTo("UtilitiesCS.Test")] +20 : [assembly: InternalsVisibleTo("ToDoModel.Test")] +``` + +Line 19 carries `[assembly: InternalsVisibleTo("UtilitiesCS.Test")]` as stated. A search of that file for `QuickFiler.Test` returned 0 matches, so no line of it names `QuickFiler.Test`. + +Observed result: **as stated.** Any `internal` seam added to `UiThread` by this delivery is reachable from `UtilitiesCS.Test` and is not reachable from `QuickFiler.Test`, which is why decision D3 adds no grant and every AC4 test lives in `UtilitiesCS.Test`. + +## 2. Default apartment state under MSTest + +File: `UtilitiesCS.Test/test.runsettings`, read in full: + +```xml + + + +``` + +The file is `` with a comment stating that global STA execution is intentionally disabled. + +Search performed: `ExecutionThreadApartmentState` over `**/*.runsettings`. Result: **no match**. + +Observed result: **as stated.** A plain `[TestMethod]` therefore runs MTA, so it supplies the AC1 rejection case directly, and `[STATestMethod]` / `[STATestClass]` supplies the acceptance case. + +## 3. The no-live-Form assertion in `UtilitiesCS.Test` + +File: `UtilitiesCS.Test/NoLiveFormInTestAssemblyTests.cs`, line 17: + +``` +public void ExecutingAssembly_ContainsNoFormDerivedType() +``` + +Observed result: **as stated.** No type added to `UtilitiesCS.Test` by this delivery may derive from `System.Windows.Forms.Form`. This binds `FakeUiCaptureSource` in [P2-T1] and `UiThreadStateScope` in [P1-T7]. + +## 4. `ThreadSafeSingleShotGuard` consumers + +Search performed: `ThreadSafeSingleShotGuard` over `**/*.cs`. Result: **23 files**, enumerated: + +`UtilitiesCS/Threading/UiThread.cs`; `UtilitiesCS/Threading/ThreadSafeSingleShotGuard.cs`; `UtilitiesCS/Threading/ProgressTracker.cs`; `UtilitiesCS/Threading/IdleAsyncQueue.cs`; `UtilitiesCS/Threading/IdleActionQueue.cs`; `UtilitiesCS/Threading/ApplicationIdleTimer.cs`; `UtilitiesCS/ReusableTypeClasses/TimedActions/TimedBatchAction.cs`; `UtilitiesCS/ReusableTypeClasses/TimedActions/TimedAsyncTask.cs`; `UtilitiesCS/ReusableTypeClasses/SerializableNew/Concurrent/ScDictionary.cs`; `UtilitiesCS/ReusableTypeClasses/Serializable/Concurrent/ScBag.cs`; `UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializableBase.cs`; `UtilitiesCS/ReusableTypeClasses/NewSmartSerializable/SmartSerializable.cs`; `UtilitiesCS/ReusableTypeClasses/Concurrent/Observable/Collection/ConcurrentObservableCollection.Serialization.cs`; `UtilitiesCS/OutlookObjects/MailItem/MailItemHelper.cs`; `UtilitiesCS/EmailIntelligence/Flags/FlagConsolidator.cs`; `UtilitiesCS/EmailIntelligence/Flags/FlagClassNoItem.cs`; `UtilitiesCS/EmailIntelligence/Bayesian/CorpusInherit.cs`; `UtilitiesCS/EmailIntelligence/Bayesian/BayesianClassifierShared.cs`; `UtilitiesCS.Test/Threading/ThreadSafeSingleShotGuard_Tests.cs`; `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs`; `UtilitiesCS.Test/Threading/IdleActionQueue_Tests.cs`; `TaskVisualization/FlagChangeTrainingQueue.cs`; `TaskMaster.Test/AppGlobals/ApplicationGlobalsTests.cs`. + +Observed result: **as stated, 23 files.** Removing `UiThread.cs` from that set still leaves 22 files referencing the type, which is why decision D2 retains it rather than deleting it. + +## 5. Legacy non-SDK projects with explicit compile items + +`UtilitiesCS/UtilitiesCS.csproj:1112` carries ``. +`UtilitiesCS/UtilitiesCS.csproj:1110` carries ``, which is the sibling item [P1-T2] adds beside. +`UtilitiesCS.Test/UtilitiesCS.Test.csproj:503` carries ``. +`UtilitiesCS.Test/UtilitiesCS.Test.csproj:76` carries ``, which is the sibling item [P1-T8] adds beside. + +Observed result: **as stated.** Both projects are legacy non-SDK `packages.config` projects with explicit compile items, so a source file that is not listed does not compile and its tests silently do not exist. + +## Result + +DESIGN_PRECONDITION_FAILURES: 0 diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t2-requirements-read.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t2-requirements-read.md new file mode 100644 index 000000000..46af1c1a4 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t2-requirements-read.md @@ -0,0 +1,28 @@ +# [P0-T2] Requirements documents read and acceptance-criteria inventory + +Timestamp: 2026-09-08T00-14 + +## Documents read in full + +- `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/spec.md` (501 lines) +- `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/issue.md` (81 lines) +- `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/research/research.2026-09-07T20-20.md` (806 lines) + +## Acceptance-criteria source + +Work mode is `full-bug`, persisted at `issue.md:12` as `- Work Mode: full-bug` and restated at `spec.md:9`. Under `.claude/skills/acceptance-criteria-tracking/SKILL.md`, `full-bug` resolves to `spec.md` **only**. `spec.md` is therefore the sole acceptance-criteria source for this delivery. `user-story.md` is intentionally absent. + +`issue.md` carries its own `## Acceptance Criteria` section holding AC1 through AC4, reproduced verbatim from it into `spec.md`. That section is a mirror, not a second source; [P6-T11] keeps it consistent and adds no criterion. + +## Inventory — six identifiers from the `## Acceptance Criteria` section of `spec.md` (lines 458 through 465) + +- `AC1` — "`Init()` throws a named `InvalidOperationException` when called from a non-STA thread, before" +- `AC2` — "A failed `Initialize()` does not consume the latch; a subsequent `Init()` retries" +- `AC3` — "`SynchronizationContextAwaiter.IsCompleted` returns true on the owning UI thread regardless of ambient context" +- `AC4` — "Unit tests cover STA/MTA rejection, latch re-arm after throw, and awaiter inline-vs-post" +- `AC5` — "An evidence artifact under this feature folder's `evidence/other/` directory records a measurement" +- `AC6` — "A coverage report produced by `vstest.console.exe ... /EnableCodeCoverage` and stored under this" + +Each quotation is the first twelve words of that criterion's clause as written in `spec.md`. All six lines are currently unchecked (`- [ ] AC:`). + +`spec.md:467` records that AC1 through AC4 are reproduced verbatim from `issue.md` and that AC5 and AC6 are added by the specification for the decision-D5 measurement and the coverage uplift. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t3-base-ref.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t3-base-ref.md new file mode 100644 index 000000000..2f7e019c3 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t3-base-ref.md @@ -0,0 +1,31 @@ +# [P0-T3] Base ref anchor + +Timestamp: 2026-09-08T00-15 + +Command: `git merge-base origin/main HEAD`; `git tag pre-809-base `; `git rev-parse pre-809-base`; `git rev-parse --verify pre-809-base`; `git diff --name-only pre-809-base HEAD -- `; `git status --porcelain --untracked-files=all` + +EXIT_CODE: 0 + +BASE_TAG: pre-809-base +BASE_SHA: 04a54e681bd21e841e124c016df30672ee701b75 + +`git rev-parse --verify pre-809-base` exited 0. `git rev-parse pre-809-base` printed the 40-character sha above, which is identical to the `git merge-base origin/main HEAD` output. + +INHERITED_WRITE_SET_PATHS: +``` +``` + +The `git diff --name-only pre-809-base HEAD` span over the nine pre-existing Write Set paths returned no lines. Those nine paths are the twelve-path Write Set less the three files this delivery creates (`UtilitiesCS/Threading/IUiCaptureSource.cs`, `UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs`, `UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs`). The empty result establishes that no Write Set file changed between the base and `HEAD`, so the "exactly N changed lines" gates in [P1-T2], [P1-T3], [P2-T7] and [P6-T2] isolate this delivery's edits. + +BASELINE_WORKTREE_STATUS: +``` + M docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/plan.2026-09-07T20-14.md +?? docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t2-requirements-read.md +?? docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/phase0-instructions-read.md +``` + +No line of that porcelain output names a path under `UtilitiesCS/`, `UtilitiesCS.Test/` or `QuickFiler.Test/`. The three lines present are this plan's own check-off marks for [P0-T1] and [P0-T2] and the two Phase 0 evidence artifacts those tasks wrote; none is a source edit. The executor therefore starts from a worktree carrying no uncommitted or untracked source change of its own, which is what makes the later per-file "exactly N changed lines" gates attributable to this delivery. + +The porcelain span is a required companion to the name-listing diff above rather than a duplicate of it: an anchored `--name-only` diff enumerates tracked changes only and is blind to an untracked file. + +Output Summary: `pre-809-base` created at `04a54e681bd21e841e124c016df30672ee701b75`, verified resolvable. Inherited Write Set diff is empty (0 paths). Baseline worktree carries no source-path modification. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t4-sdk-install.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t4-sdk-install.md new file mode 100644 index 000000000..f429e7ef5 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t4-sdk-install.md @@ -0,0 +1,28 @@ +# [P0-T4] Repository-pinned .NET SDK install + +Timestamp: 2026-09-08T00-18 + +Command: `pwsh -NoProfile -ExecutionPolicy Bypass -File ./scripts/vscode/Install-RepoDotNetSdk.ps1` + +EXIT_CODE: 0 + +Output Summary: + +The installer printed: + +``` +Downloading .NET SDK 8.0.205 from https://builds.dotnet.microsoft.com/dotnet/Sdk/8.0.205/dotnet-sdk-8.0.205-win-x64.zip... +Installed repo-local .NET SDK 8.0.205 to \.dotnet-sdk. +``` + +After the SDK preamble, `dotnet --version` printed the single verbatim line: + +``` +8.0.205 +``` + +`.dotnet-sdk/dotnet.exe` exists (`Test-Path` returned `True`). + +`git status --porcelain --untracked-files=all` does not list `.dotnet-sdk/`, because `.gitignore:350` carries `.dotnet*/`. The only paths listed are this plan file and the three Phase 0 evidence artifacts written so far. + +The host was `pwsh` 7, not Windows PowerShell 5.1. This task ran before any `dotnet` or `msbuild` command in the plan; the worktree contained neither `.dotnet-sdk/` nor `packages/` on entry. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t5-nuget-restore.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t5-nuget-restore.md new file mode 100644 index 000000000..6df3e381d --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t5-nuget-restore.md @@ -0,0 +1,26 @@ +# [P0-T5] NuGet package restore + +Timestamp: 2026-09-08T00-20 + +Command: `pwsh -NoProfile -ExecutionPolicy Bypass -File ./scripts/vscode/Invoke-Restore.ps1 -SolutionPath TaskMaster.sln -Configuration Debug -Platform "Any CPU"` + +EXIT_CODE: 0 + +Output Summary: + +PACKAGE_DIRECTORY_COUNT: 172 + +The restore reported: + +``` +Installed: + 172 package(s) to packages.config projects +Build succeeded. + 0 Warning(s) + 0 Error(s) +Time Elapsed 00:00:04.18 +``` + +The `PACKAGE_DIRECTORY_COUNT` value is the integer produced by `(Get-ChildItem -Path 'packages' -Directory).Count` and is greater than 0. `packages/` is matched by `.gitignore:191` `**/[Pp]ackages/*` and is therefore invisible to `Glob` and to `Grep`; the count was taken with `Get-ChildItem`, which is not gitignore-aware. It agrees with the restore's own installed-package figure. + +This task is load-bearing because every project in this solution declares an `EnsureNuGetPackageBuildImports` target whose `` fires at `BeforeTargets="PrepareForBuild"` (`UtilitiesCS/UtilitiesCS.csproj:1286`), so MSBuild hard-fails without `packages/`. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t6-tool-restore.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t6-tool-restore.md new file mode 100644 index 000000000..f0799bfd9 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t6-tool-restore.md @@ -0,0 +1,25 @@ +# [P0-T6] CSharpier manifest tool restore + +Timestamp: 2026-09-08T00-21 + +Command: `dotnet tool restore` (run from the repository root after the SDK preamble) + +EXIT_CODE: 0 + +Output Summary: + +`dotnet tool restore` printed: + +``` +Tool 'csharpier' (version '1.2.6') was restored. Available commands: csharpier + +Restore was successful. +``` + +The whole output of `dotnet tool run csharpier --version`: + +``` +1.2.6 +``` + +That output begins with `1.2.6`, which is the version `dotnet-tools.json` pins at repository root. The assertion is on the leading version token rather than on the whole line, because a dotnet tool may append a build-metadata suffix to its informational version; the observed line is recorded in full above and carries no suffix. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t7-analyzer-parity.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t7-analyzer-parity.md new file mode 100644 index 000000000..2ac931d5e --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t7-analyzer-parity.md @@ -0,0 +1,49 @@ +# [P0-T7] Analyzer version parity between csproj and packages.config + +Timestamp: 2026-09-08T00-23 + +Command: for each of `UtilitiesCS/UtilitiesCS.csproj`, `UtilitiesCS.Test/UtilitiesCS.Test.csproj` and `QuickFiler.Test/QuickFiler.Test.csproj`, load the project XML, extract every `` path, parse the id and version out of the `packages\` path segment, and compare against the `id`/`version` pair of the matching `` element in that project's sibling `packages.config`. + +EXIT_CODE: 0 + +ANALYZER_PARITY_MISMATCH_COUNT: 0 + +Output Summary: 31 `` items across the three projects. Every item's csproj path version equals the `packages.config` version for the same package id. No divergence. + +| Project | Analyzer id | csproj version | packages.config version | Result | +|---|---|---|---|---| +| `UtilitiesCS/UtilitiesCS.csproj` | Meziantou.Analyzer | 3.0.203 | 3.0.203 | MATCH | +| `UtilitiesCS/UtilitiesCS.csproj` | Roslynator.Analyzers | 5.0.0 | 5.0.0 | MATCH | +| `UtilitiesCS/UtilitiesCS.csproj` | Roslynator.Analyzers | 5.0.0 | 5.0.0 | MATCH | +| `UtilitiesCS/UtilitiesCS.csproj` | Roslynator.Analyzers | 5.0.0 | 5.0.0 | MATCH | +| `UtilitiesCS/UtilitiesCS.csproj` | Roslynator.Analyzers | 5.0.0 | 5.0.0 | MATCH | +| `UtilitiesCS/UtilitiesCS.csproj` | AsyncFixer | 2.1.0 | 2.1.0 | MATCH | +| `UtilitiesCS/UtilitiesCS.csproj` | Microsoft.CodeAnalysis.BannedApiAnalyzers | 5.6.0 | 5.6.0 | MATCH | +| `UtilitiesCS/UtilitiesCS.csproj` | Microsoft.CodeAnalysis.BannedApiAnalyzers | 5.6.0 | 5.6.0 | MATCH | +| `UtilitiesCS/UtilitiesCS.csproj` | SonarAnalyzer.CSharp | 10.33.0.1635 | 10.33.0.1635 | MATCH | +| `UtilitiesCS.Test/UtilitiesCS.Test.csproj` | MSTest.Analyzers | 4.4.0 | 4.4.0 | MATCH | +| `UtilitiesCS.Test/UtilitiesCS.Test.csproj` | MSTest.Analyzers | 4.4.0 | 4.4.0 | MATCH | +| `UtilitiesCS.Test/UtilitiesCS.Test.csproj` | SonarAnalyzer.CSharp | 10.33.0.1635 | 10.33.0.1635 | MATCH | +| `UtilitiesCS.Test/UtilitiesCS.Test.csproj` | Meziantou.Analyzer | 3.0.203 | 3.0.203 | MATCH | +| `UtilitiesCS.Test/UtilitiesCS.Test.csproj` | Roslynator.Analyzers | 5.0.0 | 5.0.0 | MATCH | +| `UtilitiesCS.Test/UtilitiesCS.Test.csproj` | Roslynator.Analyzers | 5.0.0 | 5.0.0 | MATCH | +| `UtilitiesCS.Test/UtilitiesCS.Test.csproj` | Roslynator.Analyzers | 5.0.0 | 5.0.0 | MATCH | +| `UtilitiesCS.Test/UtilitiesCS.Test.csproj` | Roslynator.Analyzers | 5.0.0 | 5.0.0 | MATCH | +| `UtilitiesCS.Test/UtilitiesCS.Test.csproj` | AsyncFixer | 2.1.0 | 2.1.0 | MATCH | +| `UtilitiesCS.Test/UtilitiesCS.Test.csproj` | Microsoft.CodeAnalysis.BannedApiAnalyzers | 5.6.0 | 5.6.0 | MATCH | +| `UtilitiesCS.Test/UtilitiesCS.Test.csproj` | Microsoft.CodeAnalysis.BannedApiAnalyzers | 5.6.0 | 5.6.0 | MATCH | +| `QuickFiler.Test/QuickFiler.Test.csproj` | MSTest.Analyzers | 4.4.0 | 4.4.0 | MATCH | +| `QuickFiler.Test/QuickFiler.Test.csproj` | MSTest.Analyzers | 4.4.0 | 4.4.0 | MATCH | +| `QuickFiler.Test/QuickFiler.Test.csproj` | SonarAnalyzer.CSharp | 10.33.0.1635 | 10.33.0.1635 | MATCH | +| `QuickFiler.Test/QuickFiler.Test.csproj` | Meziantou.Analyzer | 3.0.203 | 3.0.203 | MATCH | +| `QuickFiler.Test/QuickFiler.Test.csproj` | Roslynator.Analyzers | 5.0.0 | 5.0.0 | MATCH | +| `QuickFiler.Test/QuickFiler.Test.csproj` | Roslynator.Analyzers | 5.0.0 | 5.0.0 | MATCH | +| `QuickFiler.Test/QuickFiler.Test.csproj` | Roslynator.Analyzers | 5.0.0 | 5.0.0 | MATCH | +| `QuickFiler.Test/QuickFiler.Test.csproj` | Roslynator.Analyzers | 5.0.0 | 5.0.0 | MATCH | +| `QuickFiler.Test/QuickFiler.Test.csproj` | AsyncFixer | 2.1.0 | 2.1.0 | MATCH | +| `QuickFiler.Test/QuickFiler.Test.csproj` | Microsoft.CodeAnalysis.BannedApiAnalyzers | 5.6.0 | 5.6.0 | MATCH | +| `QuickFiler.Test/QuickFiler.Test.csproj` | Microsoft.CodeAnalysis.BannedApiAnalyzers | 5.6.0 | 5.6.0 | MATCH | + +Repeated rows for the same id are separate `` items pointing at different analyzer DLLs of the same package (for example the `roslyn4.7` and code-fix assemblies of Roslynator). + +This is a verification, not an assertion of any particular version: a Dependabot bump can move both sides together, and only a divergence between the two sides is a defect. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t8-dotnet-coverage.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t8-dotnet-coverage.md new file mode 100644 index 000000000..81c760ff8 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t8-dotnet-coverage.md @@ -0,0 +1,22 @@ +# [P0-T8] `dotnet-coverage` global tool availability + +Timestamp: 2026-09-08T00-24 + +Command: `Get-Command dotnet-coverage -ErrorAction SilentlyContinue`, then `dotnet-coverage --version` + +EXIT_CODE: 0 + +DOTNET_COVERAGE_PRESENT_BEFORE: true +DOTNET_COVERAGE_INSTALLED_BY_THIS_TASK: false + +Output Summary: + +`Get-Command dotnet-coverage` resolved to a global tool under the user profile's `.dotnet\tools` directory, so the conditional `dotnet tool install --global dotnet-coverage` branch was not taken. + +The verbatim single line printed by `dotnet-coverage --version`: + +``` +18.10.0+f4cc39224845ffa74bf246c9da2399d50e5d6342 +``` + +Both entry states converge on the same end state; the version line is present, so the task is complete. This matters because `scripts/vscode/Invoke-MSTestWithCoverage.ps1:292-294` throws when the tool is absent, and every coverage task in this plan invokes it directly. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t9-csharpier-check.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t9-csharpier-check.md new file mode 100644 index 000000000..ec6f98975 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t9-csharpier-check.md @@ -0,0 +1,17 @@ +# [P0-T9] Baseline CSharpier check + +Timestamp: 2026-09-08T00-25 + +Command: `dotnet tool run csharpier check .` (run after the SDK preamble) + +EXIT_CODE: 0 + +BASELINE_CHECKED_FILES: 1608 + +Output Summary: the tool printed its count line and reported no unformatted file. + +``` +Checked 1608 files in 7446ms. +``` + +This is a read-only invocation, so its exit code distinguishes a clean tree from a drifted one on its own. [P5-T2] derives its expected value from the `BASELINE_CHECKED_FILES:` line above rather than from any figure tabled in the plan. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/phase0-instructions-read.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/phase0-instructions-read.md new file mode 100644 index 000000000..7bbb2ee48 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/phase0-instructions-read.md @@ -0,0 +1,38 @@ +# Phase 0 — Instructions Read ([P0-T1]) + +Timestamp: 2026-09-08T00-12 + +Policy Order: The four ordered documents required by `.claude/skills/policy-compliance-order/SKILL.md` were read in this order: + +1. `CLAUDE.md` +2. `.claude/rules/general-code-change.md` +3. `.claude/rules/general-unit-test.md` +4. `.claude/rules/csharp.md` (the language-specific rule for the C# files in scope) + +## Files read (all ten) + +- `CLAUDE.md` +- `.claude/rules/general-code-change.md` +- `.claude/rules/general-unit-test.md` +- `.claude/rules/csharp.md` +- `.claude/rules/quality-tiers.md` +- `.claude/rules/tonality.md` +- `.claude/rules/plan-acceptance-gates.md` +- `.claude/skills/atomic-plan-contract/SKILL.md` +- `.claude/skills/evidence-and-timestamp-conventions/SKILL.md` +- `.claude/skills/acceptance-criteria-tracking/SKILL.md` + +The additional file `.claude/skills/policy-compliance-order/SKILL.md` was also read, because it is the document that defines the required order above. + +## Constraints carried into execution + +- C# toolchain order is format, lint, type-check, test, restarting from step 1 on any failure or any auto-fix (`CLAUDE.md`, `.claude/rules/csharp.md`). +- `dotnet format` is prohibited; formatting is CSharpier through `dotnet tool run` (`.claude/rules/csharp.md` item 1). +- Both MSBuild gates use `/t:Rebuild`; `/p:Nullable=enable` is not added (`.claude/rules/csharp.md` items 2 and 3). +- No production, test, or reusable script file may exceed 500 physical lines (`.claude/rules/general-code-change.md`, "File Size Limit"). +- Tests use MSTest, Moq and FluentAssertions; no temporary files; no `Thread.Sleep`, `Task.Delay` or wall-clock waits in test code (`.claude/rules/csharp.md`, `.claude/rules/general-unit-test.md`). +- Repository-wide line coverage floor is 80% per `CLAUDE.md`, which takes precedence over the 85% figure in `.claude/rules/general-unit-test.md` under the precedence order in `.claude/skills/policy-compliance-order/SKILL.md`. New modules, classes and methods target 90%. +- Policy documents under `.claude/rules/` must not be modified. +- All evidence resolves under `/evidence//`; nothing under `artifacts/` (`.claude/skills/evidence-and-timestamp-conventions/SKILL.md`). +- Acceptance criteria are checked off one at a time in `spec.md` only, work mode being `full-bug` (`.claude/skills/acceptance-criteria-tracking/SKILL.md`). +- Tone in every artifact is factual and neutral; no humor, hyperbole or decorative metaphor (`.claude/rules/tonality.md`). diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/other/p0-t15-mta-synccontextform-measurement.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/other/p0-t15-mta-synccontextform-measurement.md new file mode 100644 index 000000000..7ef51c84b --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/other/p0-t15-mta-synccontextform-measurement.md @@ -0,0 +1,94 @@ +# [P0-T15] Decision-D5 measurement — does `new SyncContextForm(); Show();` throw on an MTA thread on this host? + +Timestamp: 2026-09-08T00-47 + +Command: + +``` +& $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll '/Tests:Worker_RunWorkerCompleted_HandlesCompletionCorrectly' '/InIsolation' '/Logger:trx;LogFileName=p0t15.trx' '/ResultsDirectory:TestResults\809-p0t15' +``` + +`/Tests:` and `/TestCaseFilter:` are mutually exclusive in vstest and the four stalling shell-icon classes live in `UtilitiesCS.Test`, so no filter was passed here. + +EXIT_CODE: 0 +ExpectedExitCode: 0 + +`ExpectedExitCode:` is set to the observed value because this task **measures** rather than gates. Either observed outcome is a valid result of the measurement, so declaring an expectation other than the observed one would convert a measurement into a gate that decision D5 does not authorise. + +MTA_INITIALIZE_OUTCOME: COMPLETED + +> **CORRECTION (2026-09-08, added by the orchestrator after feature review).** The `MTA_` prefix on the +> token above is **not established and is most likely wrong**. Read the correction section at the end +> of this file before relying on any claim made here. The token is left in place rather than rewritten +> because the approved plan requires exactly one `MTA_INITIALIZE_OUTCOME:` line valued `COMPLETED` or +> `THREW`, and neither value can express "apartment not established". What this run measured is that +> `new SyncContextForm(); Show();` completed on the vstest main execution thread, whose apartment was +> never read. + +## Output Summary + +``` +Test Run Successful. +Total tests: 1 + Passed: 1 +``` + +TRX selected: `p0t15.trx`, `LastWriteTimeUtc` `2026-09-08T04:23:02.2600330Z`, selected as the most recently modified `.trx` under `TestResults\809-p0t15`. + +TRX `ResultSummary/Counters`: `total` 1, `executed` 1, `passed` 1, `failed` 0. + +| Fully-qualified test | Outcome | Duration | +|---|---|---| +| `QuickFiler.Controllers.Tests.QfcHomeControllerRunAsyncTests.Worker_RunWorkerCompleted_HandlesCompletionCorrectly` | Passed | 00:00:00.3959381 | + +The TRX carries no `Message` and no `StackTrace` element for that result, because the result is `Passed`. The value of `MTA_INITIALIZE_OUTCOME` is `COMPLETED`, so no exception type or message is recorded; that record is required only on the `THREW` branch. + +## The inference + +The inference is exact. In a single-test run no earlier test can have consumed the latch at `UtilitiesCS/Threading/UiThread.cs:36`, so `UiThread.Init(false)` at `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs:329` necessarily entered `Initialize()`. The Act at `:346-353` invokes `QfcHomeController.Worker_RunWorkerCompleted`, whose body reaches `UiThread.Dispatcher.Invoke(...)`; `UiThread.Dispatcher` throws when `_dispatcher` is null. The two assertions at `:356-357` therefore hold only if `Initialize()` ran to completion on the MTA MSTest worker, which requires `new SyncContextForm()` at `UiThread.cs:51` and `Show()` at `:54` not to have thrown. + +The test is declared with a plain `[TestMethod]` at `:325` on a class carrying no `[STATestClass]`, so the executing apartment is the MSTest default, which research R4 established as MTA from three independent in-tree sources. + +Both assertions passed, so `new SyncContextForm(); Show();` **completed without throwing on an MTA thread on this execution host**. + +## Consequence for the #782 narrative + +The recorded #782 mechanism requires `new SyncContextForm(); Show();` to throw on a non-STA thread. This measurement shows that it does not throw on this host, so **that mechanism narrative is not reproducible as stated on this execution host**. [P6-T4] carries the reconciliation and records the disposition; the AC2 "reproduce the #782 regression scenario as a test" clause is discharged there by the forced-throw scenario driven through the factory seam rather than by the narrative. + +This is a measurement of one host at one point in time. It refutes the narrative's necessary precondition on this host; it does not establish what was observed on the host where #782 was recorded. + +## Correction: the apartment of this run was never established + +Timestamp: 2026-09-08T04-10. Added by the orchestrator after the feature review of this delivery, and +verified independently against the tree before being written here. + +**The inference recorded above is unsound, and the conclusion drawn from it is withdrawn.** Nothing in +this run read `Thread.CurrentThread.GetApartmentState()`. The apartment was inferred from research R4, +and *this same delivery falsified R4 by direct measurement*: +`../regression-testing/p2-t10-fail-before.md` quotes the verbatim TRX message +`Expected Thread.CurrentThread.GetApartmentState() to be ApartmentState.MTA {value: 1}, but found ApartmentState.STA {value: 0}.` +observed from a plain `[TestMethod]` on a plain `[TestClass]`. + +Two facts settle why the `/Tests:` single-test selection does not rescue the inference. Both were +verified directly against the tree by the orchestrator rather than accepted from the review: + +1. `UtilitiesCS.Test/Properties/AssemblyInfo.cs:18` carries the only assembly-level + `[assembly: Parallelize(...)]` in this repository. `QuickFiler.Test` carries none, so with no + `/Settings:` passed — and this run passed none — that assembly does not parallelize at all. +2. No `.runsettings` anywhere in the repository sets `ExecutionThreadApartmentState`. + +The consequence is that a test in a non-parallelizing assembly runs on the vstest main execution +thread, which on .NET Framework is STA unless overridden. Under that explanation **this run executed +STA**, no MTA measurement was taken, and the refutation of the #782 mechanism narrative does not +follow: a successful run on an STA thread says nothing about whether the construction throws on an MTA +thread. + +**The status of the #782 narrative therefore reverts to UNKNOWN**, which is where decision D5 found it. +Acceptance criterion AC5 in `spec.md` has been unchecked accordingly. + +This correction does not affect the delivered code. The AC2 design argument recorded in +`p6-t4-ac2-regression-reconciliation.md` was verified structurally by the review and holds whichever +value a real measurement would produce, because the AC1 precondition makes the potentially-throwing +body of `Initialize()` unreachable from any non-STA caller. Settling the measurement requires reverting +`UtilitiesCS/Threading/UiThread.cs` to its pre-fix state and re-running the probe on a thread whose +apartment is explicitly set, which is follow-up work rather than a defect in this delivery. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/other/p6-t12-untracked-output-check.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/other/p6-t12-untracked-output-check.md new file mode 100644 index 000000000..afc9f282d --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/other/p6-t12-untracked-output-check.md @@ -0,0 +1,40 @@ +# [P6-T12] No test-results file and no coverage document entered the tree + +Timestamp: 2026-09-08T03-14 + +Command: `git status --porcelain --untracked-files=all` + +Command: `git ls-files -- TestResults coverage` + +Command: `git diff --name-only pre-809-base -- '*.trx' '*.cobertura.xml' '*.coverage'` + +EXIT_CODE: 0 + +## `git ls-files -- TestResults coverage`, verbatim + +``` +coverage/.gitkeep +``` + +That output is exactly the single line `coverage/.gitkeep` and nothing else. `TestResults/` matches the `[Tt]est[Rr]esult*/` entry at `.gitignore:39`, and the contents of `coverage/` match `.gitignore:144` `coverage/*`, while `.gitignore:145` exempts the tracked `coverage/.gitkeep`. Both the derived settings file `coverage\809-effective-coverage.config` and the two Cobertura documents `coverage\809-p0-baseline.cobertura.xml` and `coverage\809-p5-final.cobertura.xml` are therefore invisible to git, as are the twelve results directories under `TestResults\`. + +## `git diff --name-only pre-809-base -- '*.trx' '*.cobertura.xml' '*.coverage'` + +The span returned no lines. + +DELIVERY_ADDED_RESULTS_FILE_COUNT: 0 + +A repository-wide `git ls-files -- '*.trx' '*.cobertura.xml' '*.coverage'` is deliberately not used here: earlier feature folders under `docs/features/active/` committed their own `.trx` and `.cobertura.xml` evidence, so that spelling would enumerate hundreds of pre-existing tracked paths and a count asserted over it to be `0` could never pass. Anchoring the diff to `pre-809-base` restricts the count to paths this delivery added, which is the quantity the gate is about. + +## `git status --porcelain --untracked-files=all`, verbatim + +``` + M docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/issue.md + M docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/spec.md +?? docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/other/p6-t4-ac2-regression-reconciliation.md +?? docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p6-t1-uithread-file-coverage.md +?? docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p6-t2-changed-line-coverage.md +?? docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p6-t3-aggregate-coverage.md +``` + +No line of that output names a path under `TestResults/` or under `coverage/`. The two modified paths are the acceptance-criteria check-offs [P6-T5] through [P6-T11] wrote, and the four untracked paths are Phase 6 evidence artifacts that [P6-T15] commits. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/other/p6-t13-closure-summary.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/other/p6-t13-closure-summary.md new file mode 100644 index 000000000..3abb212e9 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/other/p6-t13-closure-summary.md @@ -0,0 +1,78 @@ +# [P6-T13] Closure summary and residual risks + +Timestamp: 2026-09-08T03-16 + +## 1. The six acceptance criteria, their check-off state and their evidence + +Work mode is `full-bug`, so `spec.md` is the sole acceptance-criteria source. All six are checked off. + +| AC | State | Evidence | +|---|---|---| +| AC1 | `- [x]` | `evidence/regression-testing/p3-t6-pass-after.md` for the apartment-rejection tests; `evidence/qa-gates/p4-t3-quickfiler-tests.md` for the reconciled MTA caller; implementation in [P3-T1] and [P4-T1] | +| AC2 | `- [x]` | `evidence/regression-testing/p3-t6-pass-after.md` for the retry test; `evidence/other/p6-t4-ac2-regression-reconciliation.md` for the regression-scenario clause; implementation in [P3-T2] | +| AC3 | `- [x]` | `evidence/regression-testing/p3-t6-pass-after.md` for the seven awaiter cases; `evidence/qa-gates/p4-t3-quickfiler-tests.md` for the `EfcFormControllerTests` and `WinFormsPumpHostTests` rows; implementation in [P3-T4] | +| AC4 | `- [x]` | `evidence/qa-gates/p5-t5-tests-coverage.md` for the seventeen added tests in the discovered total; `evidence/qa-gates/p4-t4-utilitiescs-tests.md` for their outcomes; fake-dispatcher seam in [P1-T4], [P1-T6] and [P1-T7] | +| AC5 | `- [x]` | `evidence/other/p0-t15-mta-synccontextform-measurement.md`; `evidence/other/p6-t4-ac2-regression-reconciliation.md`; the three repetition artifacts `evidence/qa-gates/p5-t6-tryaddvalues-rep1.md`, `...-rep2.md`, `...-rep3.md` | +| AC6 | `- [x]` | `evidence/qa-gates/p5-t5-tests-coverage.md`; `evidence/qa-gates/p6-t1-uithread-file-coverage.md`; `evidence/qa-gates/p6-t2-changed-line-coverage.md`; `evidence/qa-gates/p6-t3-aggregate-coverage.md` | + +AC1 through AC4 are additionally mirrored into the `## Acceptance Criteria` section of `issue.md` by [P6-T11]. `issue.md` carries no AC5 or AC6, so no line was added to it. + +### Two facts the AC4 citation must record + +- **No test added by this delivery requires a live Outlook process.** The three files this delivery creates or extends in `UtilitiesCS.Test` reference no `Microsoft.Office.Interop` type and carry no `TestCategory` attribute, so none is excluded by the `TestCategory!=LiveOutlook` filter and none needs a host. Verified by search over `UiThreadInitContract_Tests.cs`, `UiThread_Tests.cs` and `UiThreadStateScope.cs`: 0 matches for `Microsoft.Office.Interop` and 0 for `TestCategory`. +- **Every new type in `UtilitiesCS.Test` was verified non-`Form`-derived.** `UtilitiesCS.Test.NoLiveFormInTestAssemblyTests.ExecutingAssembly_ContainsNoFormDerivedType` reports `Passed` in the [P5-T5] full-suite TRX, duration `00:00:00.0121231`. The new types are `FakeUiCaptureSource`, `ApartmentThreadRunner`, `SharedStaDispatcherHost`, the two contract test classes, the private nested `StaDispatcherHost` in `SynchronizationContextAwaiter_Tests`, and `UiThreadStateScope`. + +### The AC6 collector substitution + +AC6-COLLECTOR-SUBSTITUTION: coverage was collected by `dotnet-coverage collect ... -- vstest.console.exe ...` rather than by `vstest.console.exe ... /EnableCodeCoverage`, which is what the literal wording of AC6 names. `scripts/vscode/TaskMaster.cli.runsettings` carries no data collector, and `scripts/vscode/Invoke-MSTestWithCoverage.ps1:19-26` records that the omission is deliberate because the outer `dotnet-coverage` instrumentation and the built-in Code Coverage collector conflict. The substitution is stated rather than silently adopted. + +## 2. Residual ordering risk at production await sites + +Research R6 enumerated eleven production await sites at which the AC3 predicate change can alter execution ordering, and found **no existing test asserts ordering at any of them**. The suite therefore cannot detect an ordering regression at these sites. This is recorded as **residual**, not as covered. + +The four highest-consequence sites, named individually: + +- `QuickFiler/Controllers/EfcFormController.cs:877` — `ActionCancelAsync`; `Close()` then `Cleanup()` would run before already-queued UI work rather than after it. +- `QuickFiler/Controllers/QfcCollectionController.cs:782` — `RemoveControlsAsync`; a `TlpLayout` toggle and a row removal would run before queued layout work. +- `QuickFiler/Controllers/QfcItemController.ViewerSetup.cs:67` — a `TaskScheduler.FromCurrentSynchronizationContext()` site; the resulting scheduler would target the persistent WinForms context instead of the dispatcher context. +- `QuickFiler/Controllers/EfcItemController.cs:201` — the second `TaskScheduler.FromCurrentSynchronizationContext()` site, same shape. + +The predicate's `ambient is null` early return is the guard that keeps the two `TaskScheduler` sites from throwing `InvalidOperationException`, and `SynchronizationContextAwaiter_Tests.IsCompleted_WhenAmbientContextIsNullAndCapturedContextIsNotNull_ReturnsFalse` pins it. + +## 3. Production sites that gain a possible new throw + +Four sites, each unreachable in production after `TaskMaster/ThisAddIn.cs:35` runs on the Outlook STA: + +- `TaskMaster/AppGlobals/AppOlObjects.cs:367` — reachable off the UI thread by construction, because the enclosing branch at `:364` is entered only when the caller is off it. The new throw is strictly better than today's behaviour there, which constructs a `SyncContextForm` on the worker and performs the COM read on the wrong apartment, the exact failure the comment at `:361-363` says it is preventing. +- `UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs:179` — reachable wherever the predictor runs; no existing test hits it with a null backing field. +- `UtilitiesCS/EmailIntelligence/OlFolderTools/FolderRemap/FolderRemapViewer.cs:40` and `UtilitiesCS/EmailIntelligence/OlFolderTools/FilterOlFolders/FilterOlFoldersViewer.cs:79` — only for a hypothetical off-UI-thread caller of `SetController`. Both in-repo drivers are `[STATestClass]` and both still pass. +- `UtilitiesCS/Threading/ThreadMonitor.cs:143` — **recorded for completeness rather than as a regression risk.** It reads `UiThread.UiSyncContext` inside `PingAndAwaitDiagnosticWindow()`, declared at `UtilitiesCS/Threading/ThreadMonitor.cs:138` and carrying `[ExcludeFromCodeCoverage]` at `:137`. No test in this repository reaches that member; its only in-repository call site is `UtilitiesCS/Threading/ThreadMonitor.cs:109`. Furthermore `ThreadMonitor` is constructed only inside `Initialize()`, after `_uiSyncContext` has been assigned, so the field is never null on that path and the getter never calls `Init()`. + +## 4. Manual live-host verification, reported separately + +Not an acceptance criterion and not performed by this delivery: QuickFiler launch, item load, and breadcrumb open on a live Outlook host, confirming no change in observable UI behaviour and no new keyboard-focus regressions after #677 and #796. This is the residual that the automated suite cannot close, per section 2. + +## 5. Follow-up candidates, not in this delivery + +- Routing `QfcHomeController` through `IUiDispatcher`. The seam exists at `UtilitiesCS/Threading/IUiDispatcher.cs` and `UtilitiesCS/Threading/WpfUiDispatcher.cs` and `QfcItemController` already consumes it, but `QuickFiler/Controllers/QfcHomeController.cs:360` is not routed through it. Research R5 records this as larger than the adopted option for the same benefit. +- Reconciling the 80% versus 85% coverage-floor divergence between `CLAUDE.md` and `.claude/rules/general-unit-test.md`. `CLAUDE.md` governs under the precedence order in `.claude/skills/policy-compliance-order/SKILL.md`, and the divergence was recorded rather than resolved. +- Closing #784, #787 and #788 with a pointer to #809. +- Correcting the GitHub issue body for #809, which still carries the superseded bare-owning-thread-identity sentence that `issue.md:59` has already corrected locally. That sentence is unsafe: a continuation resumed after `ConfigureAwait(false)` can land on a recycled thread-pool thread whose managed id equals the owner's, and `QuickFiler/Viewers/BreadcrumbUiDispatcher.cs:263-272` records the opposite rule. +- The pre-existing 500-line overrun in `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs`, which `evidence/qa-gates/p4-t5-line-counts.md` records under `PRE_EXISTING_FILES_OVER_500:` at a baseline of 1066 lines. This delivery increases it by exactly the one `[DoNotParallelize]` attribute line [P2-T7] adds, to 1067. +- The two-line coverage residual at `UtilitiesCS/Threading/UiThread.cs:177-178`, the body of the `ReferenceEquals(_context, _uiSyncContext)` clause of the new predicate, which no case in this delivery reaches. Closing it needs one further awaiter test that installs `_uiSyncContext` and awaits that same instance from the owning thread while a different context is ambient. `evidence/qa-gates/p6-t1-uithread-file-coverage.md` and `evidence/qa-gates/p6-t2-changed-line-coverage.md` both record it; the `IsCompleted` member still meets the 90% floor exactly at 90.00%. +- The three-line coverage residual at `UtilitiesCS/Threading/UiThread.cs:38-40`, the body of the `onLockupDetected` guard. No test in this delivery passes a non-null `onLockupDetected` on a path that reaches the assignment, because the one test that supplies a callback supplies it to assert that a rejected `Init()` does not perform the assignment. + +## 6. Environmental finding recorded for future planners + +Research R4 concluded that a plain `[TestMethod]` runs MTA in this repository, and `UtilitiesCS.Test/test.runsettings` does record that global STA execution is intentionally disabled. **That premise does not hold for every scheduling arrangement.** Plain `[TestMethod]` cases were measured running on an **STA** thread; `evidence/regression-testing/p2-t10-fail-before.md` records the measurement verbatim and the correction it forced. + +**The operational rule below is the load-bearing part of this section and it is confirmed. A test that needs a caller of a known apartment must create a dedicated thread and set the apartment explicitly, rather than relying on the ambient worker.** + +**Correction (2026-09-08, orchestrator, after feature review).** An earlier revision of this section attributed the STA observation to a `[TestClass] [DoNotParallelize]` class sharing the serial execution bucket with an `[STATestClass] [DoNotParallelize]` class. **That stated cause is not established and should not be relied on.** A simpler explanation covers the same observation and two further tree-verified facts: + +- `UtilitiesCS.Test/Properties/AssemblyInfo.cs:18` carries the only assembly-level `[assembly: Parallelize(...)]` in this repository. No other test assembly has one, so an assembly invoked without a `/Settings:` runsettings does not parallelize at all. +- No `.runsettings` anywhere in the repository sets `ExecutionThreadApartmentState`. + +Tests dispatched to the MSTest parallel worker pool run on thread-pool threads and are MTA. Tests that run on the main test-execution thread — the `[DoNotParallelize]` serial bucket, or every test in an assembly where parallelization is off — inherit that thread's apartment, and the vstest execution thread on .NET Framework is STA unless `ExecutionThreadApartmentState` overrides it. Bucket-sharing with an `[STATestClass]` is not required for the effect. + +A consequence for this delivery is recorded in the correction sections of `p0-t15-mta-synccontextform-measurement.md` and `p6-t4-ac2-regression-reconciliation.md`: the `[P0-T15]` probe most likely ran STA, so it took no MTA measurement, and the status of the #782 mechanism narrative reverts to UNKNOWN. Acceptance criterion AC5 is unchecked in `spec.md` for that reason. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/other/p6-t14-artifact-sanitisation.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/other/p6-t14-artifact-sanitisation.md new file mode 100644 index 000000000..804d5c569 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/other/p6-t14-artifact-sanitisation.md @@ -0,0 +1,28 @@ +# [P6-T14] Evidence-artifact sanitisation scan + +Timestamp: 2026-09-08T03-19 + +Command: for each file under `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/`, search its content case-insensitively for three tokens: the run-time-derived account token produced by `Split-Path -Leaf $env:USERPROFILE`, the run-time-derived machine token `$env:COMPUTERNAME`, and the literal drive-and-users path prefix formed from `C:`, a backslash, `Users` and a backslash. + +The two host tokens are recorded here **by their derivation rather than by their value**, so this artifact does not itself introduce the tokens it exists to exclude. The account token was confirmed non-empty at 9 characters and the machine token was confirmed set, so neither search was vacuous. The scan script is exempt from its own scan because it names the tokens only as variables and is held in the session scratchpad, outside the evidence tree. + +EXIT_CODE: 0 + +SCANNED_ARTIFACT_COUNT: 43 + +HOST_TOKEN_HIT_COUNT: 0 + +No hit was found, so no enumeration by file and line follows. + +The scanned set is every file under the feature folder's `evidence/` tree at the time of the scan, across the sub-paths `baseline/`, `qa-gates/`, `regression-testing/` and `other/`. It explicitly includes `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/other/p6-t12-untracked-output-check.md` and `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/other/p6-t13-closure-summary.md`, both confirmed present in the enumeration. + +This task runs after every other artifact-writing task in the plan, because a scan cannot cover a file that does not yet exist when it runs. The only artifact it cannot cover on the first pass is this one, which the second pass below covers. + +Output Summary: 43 evidence artifacts scanned for three host tokens; zero hits. + +## Second pass + +The identical scan was re-run over this artifact alone, after the first pass had written it. + +SECOND_PASS_SCANNED_ARTIFACT_COUNT: 1 +SECOND_PASS_HOST_TOKEN_HIT_COUNT: 0 diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/other/p6-t4-ac2-regression-reconciliation.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/other/p6-t4-ac2-regression-reconciliation.md new file mode 100644 index 000000000..6c70c00ef --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/other/p6-t4-ac2-regression-reconciliation.md @@ -0,0 +1,46 @@ +# [P6-T4] Reconciliation of the AC2 regression clause against the decision-D5 measurement + +Timestamp: 2026-09-08T03-08 + +Source read: `MTA_INITIALIZE_OUTCOME:` in `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/other/p0-t15-mta-synccontextform-measurement.md`. + +Measured value: **`COMPLETED`**. + +> **CORRECTION (2026-09-08, added by the orchestrator after feature review).** The refutation asserted +> in Disposition A below is **withdrawn**. The `[P0-T15]` run never read the executing thread's +> apartment state, and this same delivery falsified the research premise the `MTA` label rested on, so +> that run most likely executed STA and no MTA measurement was taken. The status of the #782 mechanism +> narrative is **UNKNOWN**, not refuted. The two tree-verified facts behind this are recorded in the +> correction section of `p0-t15-mta-synccontextform-measurement.md`. +> +> The section "Why the AC2 design is safe whichever value was measured" below is **unaffected**. The +> feature review verified it structurally against the head tree, and it was written from the outset to +> stand independently of the measured value, which is what decision D5 required. The delivered code and +> tests need no change. `spec.md` AC5 has been unchecked because its measurement clause is not +> established. + +## Disposition A + +The measured value is `COMPLETED`, so Disposition A applies and Disposition B is not recorded. + +**The #782 mechanism narrative is refuted on this host.** That narrative requires `new SyncContextForm(); Show();` to throw when executed on an MTA thread; the measurement shows that it does not. [P0-T15] established this by running `QuickFiler.Controllers.Tests.QfcHomeControllerRunAsyncTests.Worker_RunWorkerCompleted_HandlesCompletionCorrectly` as a single test against the unmodified tree, where no earlier test can have consumed the latch at `UtilitiesCS/Threading/UiThread.cs:36`, so the two assertions at `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs:356-357` could hold only if `Initialize()` ran to completion on the MTA MSTest worker. The test passed. + +This is a measurement of one host at one point in time. It refutes the narrative's necessary precondition here; it does not establish what was observed on the host where #782 was recorded. + +**The AC2 clause "the #782 regression scenario is reproduced as a test" is discharged by the forced-throw scenario driven through the factory seam.** Because the recorded mechanism is not reproducible on this host, a test that claimed to reproduce it literally would assert nothing about a real failure mode and would be vacuous. The clause is instead satisfied by making `Initialize()` fail deterministically through `UiThread.SyncContextFormFactory`, which is the seam [P1-T4] introduced for exactly this purpose. + +The anti-retry-storm test is: + +`UtilitiesCS.Test.Threading.UiThreadInitRetryContract_Tests.AutoScaleFactor_ReadFromMtaThreadAfterAFailedInit_ThrowsAndDoesNotReEnterTheFactory` + +Its mechanism is an **invocation count**. It fails the first `Init()` through a throwing fake, records `FakeUiCaptureSource.ConstructionCount`, then reads `UiThread.AutoScaleFactor` from a dedicated MTA thread and asserts two things: that the read threw an `InvalidOperationException` whose message starts with `UiThread.NonStaInitMessagePrefix`, and that the construction count did not increase. The second assertion is the direct statement of "no retry storm": the storm the #782 record describes is repeated construction of the capture object, and the count is the number of times that construction happened. + +**The assertion is an invocation count and never a wall-clock duration.** A duration assertion would be a timing hack, is prohibited by `.claude/rules/csharp.md` under Prohibited Behaviors and by the determinism requirements of `.claude/rules/general-unit-test.md`, and would be sensitive to host speed. The invocation count is deterministic on any host. + +Fail-before and pass-after evidence for that test is recorded in `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/regression-testing/p2-t10-fail-before.md` and `.../p3-t6-pass-after.md`. It failed against the pre-fix tree with `Expected InvalidOperationException.Message to be System.InvalidOperationException, but found .` and passes after the fix. + +## Why the AC2 design is safe whichever value was measured + +The AC1 precondition makes `new SyncContextForm()` at `UtilitiesCS/Threading/UiThread.cs:51`, now line 72 after this delivery's edits, **unreachable from any non-STA caller**. `Initialize()` is reachable from exactly four places: the two direct `Init()` calls and the two lazy getters. With the apartment check as the first statement of `Init()`, a non-STA reader of either lazy getter fails at one `GetApartmentState()` read and a `throw`, and never reaches the capture-object construction or `Show()`. The expensive, potentially-throwing body is therefore unreachable from any thread-pool thread, which is where thread-pool starvation would have to originate. On the STA thread itself `Initialize()` succeeds, verified by the two `[STATestClass]` viewer tests recorded in `.../evidence/qa-gates/p4-t4-utilitiescs-tests.md`, so no retry loop engages there either. The `lock (InitLock)` additionally serializes concurrent first attempts, which the previous `Interlocked.Exchange` latch never did, closing the pre-existing #782 finding C04 race. + +That argument stands independently of whether the recorded #782 mechanism was ever real: it removes the mechanism if the mechanism exists, and costs nothing if it does not. This is why decision D5 required the narrative to be measured rather than assumed, and why the delivery does not depend on the outcome of that measurement. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p1-t10-builds.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p1-t10-builds.md new file mode 100644 index 000000000..6a559d307 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p1-t10-builds.md @@ -0,0 +1,33 @@ +# [P1-T10] Phase 1 builds — analyzer and nullable + +Timestamp: 2026-09-08T01-06 + +Command: `& $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true` + +Command: `& $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true` + +EXIT_CODE: 0 + +Both builds returned 0. + +## Output Summary + +Analyzer build: + +``` + 0 Warning(s) + 0 Error(s) +``` + +Nullable build: + +``` + 0 Warning(s) + 0 Error(s) +``` + +Build-output arrow-line count for the analyzer build: **18**. The `BASELINE_PROJECT_COUNT:` recorded by [P0-T10] is **18**. The two are **equal**, which is expected because Phase 1 adds no project and removes none. + +Both builds used `/t:Rebuild` rather than `/t:Build`, and neither passed `/p:Nullable=enable`. + +The seam therefore compiles and introduces no analyzer diagnostic and no nullable diagnostic. `UiThread.cs` carries `#nullable enable` at line 1, so its nullable-flow diagnostics are promoted to errors by the second build; the new `IUiCaptureSource.cs` carries the same pragma at its line 1. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p1-t11-seam-regression.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p1-t11-seam-regression.md new file mode 100644 index 000000000..ae2b53ae4 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p1-t11-seam-regression.md @@ -0,0 +1,32 @@ +# [P1-T11] Phase 1 seam regression — the `UtilitiesCS.Test` classes that touch `UiThread` state + +Timestamp: 2026-09-08T01-11 + +Command: + +``` +& $vstest UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll '/InIsolation' '/Logger:trx;LogFileName=p1t11.trx' '/ResultsDirectory:TestResults\809-p1t11' '/TestCaseFilter:FullyQualifiedName~UtilitiesCS.Test.Threading.SynchronizationContextAwaiter_Tests|FullyQualifiedName~UtilitiesCS.Test.Threading.UiThread_Dispatcher_Tests|FullyQualifiedName~UtilitiesCS.Test.OutlookObjects.Folder.WpfDispatcherYieldTests|FullyQualifiedName~UtilitiesCS.Test.EmailIntelligence.FolderRemapViewer_Tests|FullyQualifiedName~UtilitiesCS.Test.EmailIntelligence.FilterOlFoldersViewer_Tests|FullyQualifiedName~UtilitiesCS.Test.OutlookObjects.Folder.FolderPredictorTests|FullyQualifiedName~UtilitiesCS.Test.Threading.IdleAsyncQueue_Tests' +``` + +EXIT_CODE: 0 + +## Output Summary + +``` +Test Run Successful. +Total tests: 65 +``` + +The run also printed ` Passed: 65` and printed no `Failed:` line and no `Skipped:` line. + +TRX selected: `p1t11.trx`, `LastWriteTimeUtc` `2026-09-08T04:31:03.0717030Z`. + +TRX `ResultSummary/Counters`: `total` 65, `executed` 65, `passed` 65, `failed` 0. + +The `failed` attribute is 0. + +SKIPPED_DERIVED: 0 + +All 65 discovered methods across the seven filtered classes reported outcome `Passed`. The five pre-existing `SynchronizationContextAwaiter_Tests` methods (`Constructor_NullContext_ThrowsArgumentNullException`, `IsCompleted_WhenContextIsNotCurrent_ReturnsFalse`, `IsCompleted_WhenContextMatchesCurrent_ReturnsTrue`, `GetResult_DoesNotThrow`, `OnCompleted_PostsCallbackToContext`) and both `UiThread_Dispatcher_Tests` methods passed, as did the two viewer tests that drive `Init()` to success and the `FolderPredictorTests` reflection test. + +Phase 1 introduced the factory seam, the message constant, the reset hook and the interface without changing any observable behaviour of `Init()`, `Initialize()` or `IsCompleted`. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p1-t9-format.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p1-t9-format.md new file mode 100644 index 000000000..af1ac58cb --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p1-t9-format.md @@ -0,0 +1,57 @@ +# [P1-T9] Phase 1 formatting + +Timestamp: 2026-09-08T01-02 + +Command, in this order, after the SDK preamble: `git status --porcelain --untracked-files=all` (before-image); `dotnet tool run csharpier format .`; `git status --porcelain --untracked-files=all` (after-image); `dotnet tool run csharpier check .` + +EXIT_CODE: 0 + +## Output Summary + +`csharpier format` is write-mode and exits 0 whether or not it rewrote a file, so the exit code alone decides nothing. The before-and-after tree comparison and the read-only check run are the observations that decide this gate. + +Before-image: + +``` + M UtilitiesCS.Test/UtilitiesCS.Test.csproj + M UtilitiesCS/Threading/SyncContextForm.cs + M UtilitiesCS/Threading/UiThread.cs + M UtilitiesCS/UtilitiesCS.csproj +?? UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs +?? UtilitiesCS/Threading/IUiCaptureSource.cs +``` + +Formatter output, verbatim: + +``` +Formatted 1610 files in 6698ms. +``` + +After-image: + +``` + M UtilitiesCS.Test/UtilitiesCS.Test.csproj + M UtilitiesCS/Threading/SyncContextForm.cs + M UtilitiesCS/Threading/UiThread.cs + M UtilitiesCS/UtilitiesCS.csproj +?? UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs +?? UtilitiesCS/Threading/IUiCaptureSource.cs +``` + +Comparison: the two images are identical. **No path appears in the after-image that does not appear in the before-image**, so this task is not re-run. Every path in both images is a Phase 1 Write Set file. + +The `Formatted 1610 files` figure is CSharpier's processed-file count, not its rewritten-file count; it rose from the 1608 of the [P0-T9] baseline by exactly the two `.cs` files Phase 1 creates. The formatter did rewrite whitespace inside two already-listed files (`UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs` collapsed two wrapped expression bodies onto single lines, moving it from 211 to 209 physical lines), which porcelain cannot show because those files were already listed as modified or untracked before the run. The read-only check below is what establishes that no drift remains. + +`csharpier check .` exited 0 with: + +``` +Checked 1610 files in 6770ms. +``` + +Post-format physical line counts of the three files Phase 1 touched or created, taken with the pinned idiom `(Get-Content -LiteralPath ).Count`: + +| Path | Lines | +|---|---| +| `UtilitiesCS/Threading/UiThread.cs` | 234 | +| `UtilitiesCS/Threading/IUiCaptureSource.cs` | 50 | +| `UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs` | 209 | diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p2-t9-format-and-build.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p2-t9-format-and-build.md new file mode 100644 index 000000000..4e74e18db --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p2-t9-format-and-build.md @@ -0,0 +1,71 @@ +# [P2-T9] Phase 2 format and build + +Timestamp: 2026-09-08T01-30 + +Command: `git status --porcelain --untracked-files=all` (before-image) + +Command: `dotnet tool run csharpier format .` + +Command: `git status --porcelain --untracked-files=all` (after-image) + +Command: `dotnet tool run csharpier check .` + +Command: `& $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true` + +EXIT_CODE: 0 + +## Output Summary + +Before-image: + +``` + M UtilitiesCS.Test/EmailIntelligence/FilterOlFoldersViewer_Tests.cs + M UtilitiesCS.Test/EmailIntelligence/FolderRemapViewer_Tests.cs + M UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs + M UtilitiesCS.Test/Threading/UiThread_Tests.cs + M UtilitiesCS.Test/UtilitiesCS.Test.csproj +?? UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs +``` + +Formatter output: + +``` +Formatted 1611 files in 3249ms. +``` + +After-image: + +``` + M UtilitiesCS.Test/EmailIntelligence/FilterOlFoldersViewer_Tests.cs + M UtilitiesCS.Test/EmailIntelligence/FolderRemapViewer_Tests.cs + M UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs + M UtilitiesCS.Test/Threading/UiThread_Tests.cs + M UtilitiesCS.Test/UtilitiesCS.Test.csproj +?? UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs +``` + +The two images are identical; no path differs between them. Every path in both is a Phase 2 Write Set file. The Phase 1 production paths are absent from both because Phase 1 was committed at `09bb952d`. The processed-file count rose from 1610 to 1611 by the one `.cs` file Phase 2 creates. + +Read-only check: + +``` +Checked 1611 files in 6127ms. +``` + +Nullable build: + +``` + 0 Warning(s) + 0 Error(s) +``` + +## File sizes after the formatter run + +Measured with the pinned idiom `(Get-Content -LiteralPath ).Count`. + +PHASE2_LINES UtilitiesCS.Test/Threading/UiThread_Tests.cs 458 +PHASE2_LINES UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs 458 + +PHASE2_FILES_OVER_495: 0 + +`UtilitiesCS.Test/Threading/UiThread_Tests.cs` finishes at 458 against the 215-line baseline [P0-T16] recorded, so 243 lines were added against the 275-line budget [P2-T8] states, and the file is 37 lines below the 495-line ceiling. `UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs` is 458 lines against the 460-line whole-file budget [P2-T1] states. Measuring here rather than only at [P4-T5] is what makes an overrun correctable while the tests are still being written. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p3-t5-format-and-builds.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p3-t5-format-and-builds.md new file mode 100644 index 000000000..2850430d9 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p3-t5-format-and-builds.md @@ -0,0 +1,101 @@ +# [P3-T5] Phase 3 format and builds + +Timestamp: 2026-09-08T01-56 + +Command: `git status --porcelain --untracked-files=all` (before-image) + +Command: `dotnet tool run csharpier format .` + +Command: `git status --porcelain --untracked-files=all` (after-image) + +Command: `dotnet tool run csharpier check .` + +Command: `& $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true` + +Command: `& $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true` + +EXIT_CODE: 0 + +## Output Summary + +Before-image: + +``` + M UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs + M UtilitiesCS/Threading/UiThread.cs +``` + +Formatter output: + +``` +Formatted 1611 files in 3882ms. +``` + +After-image: + +``` + M UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs + M UtilitiesCS/Threading/UiThread.cs +``` + +The two images are identical. Both listed paths are Phase 3 Write Set files. The formatter did rewrite one region inside `UtilitiesCS/Threading/UiThread.cs`, collapsing the wrapped `_uiThreadId` guard of the new predicate onto a single line, which porcelain cannot show because the file was already listed as modified; the read-only check below is what establishes that no drift remains. + +Read-only check: + +``` +Checked 1611 files in 6180ms. +``` + +Analyzer build: + +``` + 0 Warning(s) + 0 Error(s) +``` + +Nullable build: + +``` + 0 Warning(s) + 0 Error(s) +``` + +Both builds used `/t:Rebuild`, and neither passed `/p:Nullable=enable`. `UtilitiesCS/Threading/UiThread.cs` carries `#nullable enable` at line 1, so its nullable-flow diagnostics were promoted to errors by the second build and none was produced by the three fixes. + +## Second pass, run after the [P3-T6] test correction + +PASS_2_TIMESTAMP: 2026-09-08T02-06 + +[P3-T6] found that two of the Phase 2 regression tests carried a defective ambient-apartment premise, and re-authoring them changed `UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs`. A file change requires the toolchain loop to restart, so the whole sequence above was re-run against the corrected tree. The two production-file restorations that the re-measurement performed were both reverted before this pass, and the pass therefore observes the Phase 3 tree. + +PASS_2_BEFORE_IMAGE: + +``` + M UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs + M UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs + M UtilitiesCS/Threading/UiThread.cs +?? docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p3-t5-format-and-builds.md +``` + +PASS_2_FORMATTER_OUTPUT: `Formatted 1611 files in 3009ms.` + +PASS_2_AFTER_IMAGE: + +``` + M UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs + M UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs + M UtilitiesCS/Threading/UiThread.cs +?? docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p3-t5-format-and-builds.md +``` + +The two images are identical. The one untracked path is this artifact, written by the first pass. + +PASS_2_FORMAT_CHECK: `Checked 1611 files in 6348ms.`, exit 0. + +PASS_2_ANALYZER_BUILD: exit 0, ` 0 Warning(s)` and ` 0 Error(s)`. + +PASS_2_NULLABLE_BUILD: exit 0, ` 0 Warning(s)` and ` 0 Error(s)`. + +PASS_2_EXIT_CODE: 0 + +This second pass is the state [P3-T6] was run against. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p4-t2-format-and-builds.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p4-t2-format-and-builds.md new file mode 100644 index 000000000..2e38a13f3 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p4-t2-format-and-builds.md @@ -0,0 +1,93 @@ +# [P4-T2] Phase 4 format and builds + +Timestamp: 2026-09-08T02-18 + +Command: `git status --porcelain --untracked-files=all` (before-image) + +Command: `dotnet tool run csharpier format .` + +Command: `git status --porcelain --untracked-files=all` (after-image) + +Command: `dotnet tool run csharpier check .` + +Command: `& $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true` + +Command: `& $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true` + +EXIT_CODE: 0 + +## Output Summary + +Before-image: + +``` + M QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs +``` + +Formatter output: + +``` +Formatted 1611 files in 3149ms. +``` + +After-image: + +``` + M QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs +``` + +The two images are identical. The one listed path is the sole Phase 4 Write Set file. Phases 1 through 3 were committed at `09bb952d`, `f7294d71` and `21126792`, which is why no earlier path appears. + +Read-only check: + +``` +Checked 1611 files in 5963ms. +``` + +Analyzer build: + +``` + 0 Warning(s) + 0 Error(s) +``` + +Nullable build: + +``` + 0 Warning(s) + 0 Error(s) +``` + +Both builds used `/t:Rebuild`, and neither passed `/p:Nullable=enable`. + +## Second pass, run after the [P4-T5] line-budget trim + +PASS_2_TIMESTAMP: 2026-09-08T02-30 + +[P4-T5] measured `UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs` at 468 physical lines, 8 above the 460-line authoring budget [P2-T1] states. The overshoot was introduced by the [P3-T6] correction, which replaced the ambient-apartment arrangement in two methods with a dedicated-MTA-thread arrangement. Documentation comments in that file were trimmed to bring it back to exactly 460 lines. No executable statement and no assertion was changed by the trim, and every acceptance token of [P2-T1] through [P2-T5] still returns its required count. + +A file change requires the toolchain loop to restart, so the whole sequence above was re-run. + +PASS_2_BEFORE_IMAGE: + +``` + M QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs + M UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs +?? docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p4-t2-format-and-builds.md +?? docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p4-t3-quickfiler-tests.md +?? docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p4-t4-utilitiescs-tests.md +``` + +PASS_2_FORMATTER_OUTPUT: `Formatted 1611 files in 3826ms.` + +PASS_2_AFTER_IMAGE: byte-identical to the before-image above. The three untracked paths are this artifact and the two the sibling Phase 4 tasks wrote. + +PASS_2_FORMAT_CHECK: `Checked 1611 files in 6599ms.`, exit 0. + +PASS_2_ANALYZER_BUILD: exit 0, ` 0 Warning(s)` and ` 0 Error(s)`. + +PASS_2_NULLABLE_BUILD: exit 0, ` 0 Warning(s)` and ` 0 Error(s)`. + +PASS_2_EXIT_CODE: 0 + +[P4-T3] and [P4-T4] were both re-run against this state and both remained green; each records its own second pass. This second pass is the last formatter run of Phase 4, and it is the state [P4-T5] measures. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p4-t3-quickfiler-tests.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p4-t3-quickfiler-tests.md new file mode 100644 index 000000000..3453ee4f7 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p4-t3-quickfiler-tests.md @@ -0,0 +1,53 @@ +# [P4-T3] `QuickFiler.Test` methods this delivery can affect + +Timestamp: 2026-09-08T02-21 + +Command: + +``` +& $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll '/InIsolation' '/Logger:trx;LogFileName=p4t3.trx' '/ResultsDirectory:TestResults\809-p4t3' '/TestCaseFilter:FullyQualifiedName~QuickFiler.Controllers.Tests.QfcHomeControllerRunAsyncTests|FullyQualifiedName~QuickFiler.Test.TestSupport.WinFormsPumpHostTests|FullyQualifiedName~QuickFiler.Controllers.Tests.EfcFormControllerTests|FullyQualifiedName~QuickFiler.Controllers.Tests.QfcItemController_UiThreadDispatcherFixtureTests|FullyQualifiedName~QuickFiler.Helper_Classes.Tests.EmailMoveMonitorTests' +``` + +The five filter operands are the fully-qualified type names as declared, re-derived from their declarations rather than inferred from file paths, because a filter operand that matches no type selects zero tests silently. All five matched: the run discovered 70 tests spread across all five classes. + +EXIT_CODE: 0 + +## Output Summary + +``` +Test Run Successful. +Total tests: 70 +``` + +The run also printed ` Passed: 70` and printed no `Failed:` line and no `Skipped:` line. + +TRX selected: `p4t3.trx`, `LastWriteTimeUtc` `2026-09-08T04:56:02.5648507Z`. + +TRX `ResultSummary/Counters`: `total` 70, `executed` 70, `passed` 70, `failed` 0. + +The `failed` attribute is 0. + +SKIPPED_DERIVED: 0 + +## The four individually named rows + +| Fully-qualified test | Outcome | Duration | +|---|---|---| +| `QuickFiler.Controllers.Tests.QfcHomeControllerRunAsyncTests.Worker_RunWorkerCompleted_HandlesCompletionCorrectly` | Passed | 00:00:00.0955621 | +| `QuickFiler.Test.TestSupport.WinFormsPumpHostTests.AwaitingSyncContext_FromTheTestThread_ResumesOnThePumpThread` | Passed | 00:00:00.0030778 | +| `QuickFiler.Test.TestSupport.WinFormsPumpHostTests.BothMarshalRoutes_WpfDispatcherAndSyncContext_ExecuteOnThePumpThread` | Passed | 00:00:00.0058242 | +| `QuickFiler.Controllers.Tests.EfcFormControllerTests.ActionDeleteAsync_AwaitedTwice_LeavesExactlyOneTrashRowInFolderRows` | Passed | 00:00:00.0033845 | + +## Second pass, run after the [P4-T5] line-budget trim + +PASS_2_TIMESTAMP: 2026-09-08T02-31 + +The trim recorded in `p4-t2-format-and-builds.md` changed a `UtilitiesCS.Test` file and therefore triggered a solution rebuild, which rebuilds `QuickFiler.Test` as well. This task was re-run unchanged against that rebuild. + +PASS_2_EXIT_CODE: 0 + +PASS_2_OUTPUT_SUMMARY: `Test Run Successful.`, `Total tests: 70`, ` Passed: 70`. TRX selected `p4t3b.trx`, `LastWriteTimeUtc` `2026-09-08T05:00:59.1988644Z`; counters `total` 70, `executed` 70, `passed` 70, `failed` 0; derived skipped count 0. All four individually named rows were `Passed` again, `Worker_RunWorkerCompleted_HandlesCompletionCorrectly` in 00:00:00.0913172. + +--- + +The first is the reconciled MTA caller: it no longer calls `UiThread.Init(false)` and instead installs a pumping dispatcher through the existing `UiThreadDispatcherFixture` transaction, so it is order-independent. The two `WinFormsPumpHostTests` rows are the pair that a bare owning-thread-identity predicate would have broken; both still post to the pump thread under the adopted predicate, because the awaited context is a `WindowsFormsSynchronizationContext` that is neither `_uiSyncContext` nor a `DispatcherSynchronizationContext`. The `EfcFormControllerTests` row injects a bare `new SynchronizationContext()` and still posts, because the ambient context on the MSTest thread is null and the predicate's `ambient is null` early return keeps it false. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p4-t4-utilitiescs-tests.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p4-t4-utilitiescs-tests.md new file mode 100644 index 000000000..c97c97bcd --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p4-t4-utilitiescs-tests.md @@ -0,0 +1,56 @@ +# [P4-T4] `UtilitiesCS.Test` methods research R7 enumerated as at risk + +Timestamp: 2026-09-08T02-23 + +Command: identical in shape to [P1-T11] except for the log file name and the results directory, with the class filter extended by the two new contract classes. + +``` +& $vstest UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll '/InIsolation' '/Logger:trx;LogFileName=p4t4.trx' '/ResultsDirectory:TestResults\809-p4t4' '/TestCaseFilter:FullyQualifiedName~UtilitiesCS.Test.Threading.SynchronizationContextAwaiter_Tests|FullyQualifiedName~UtilitiesCS.Test.Threading.UiThread_Dispatcher_Tests|FullyQualifiedName~UtilitiesCS.Test.OutlookObjects.Folder.WpfDispatcherYieldTests|FullyQualifiedName~UtilitiesCS.Test.EmailIntelligence.FolderRemapViewer_Tests|FullyQualifiedName~UtilitiesCS.Test.EmailIntelligence.FilterOlFoldersViewer_Tests|FullyQualifiedName~UtilitiesCS.Test.OutlookObjects.Folder.FolderPredictorTests|FullyQualifiedName~UtilitiesCS.Test.Threading.IdleAsyncQueue_Tests|FullyQualifiedName~UtilitiesCS.Test.Threading.UiThreadInitApartmentContract_Tests|FullyQualifiedName~UtilitiesCS.Test.Threading.UiThreadInitRetryContract_Tests' +``` + +EXIT_CODE: 0 + +## Output Summary + +``` +Test Run Successful. +Total tests: 82 + Passed: 82 +``` + +TRX selected: `p4t4.trx`, `LastWriteTimeUtc` `2026-09-08T04:56:47.5018833Z`. + +TRX `ResultSummary/Counters`: `total` 82, `executed` 82, `passed` 82, `failed` 0. + +The `failed` attribute is 0. + +SKIPPED_DERIVED: 0 + +The 82 discovered tests are the 65 the [P1-T11] filter selected plus the 17 this delivery adds. + +## The six individually named rows + +| Fully-qualified test | Outcome | Duration | +|---|---|---| +| `UtilitiesCS.Test.Threading.UiThread_Dispatcher_Tests.Dispatcher_WhenBackingFieldIsNull_ThrowsInvalidOperationExceptionNamingInitialize` | Passed | 00:00:00.0448694 | +| `UtilitiesCS.Test.Threading.UiThread_Dispatcher_Tests.Dispatcher_WhenBackingFieldIsPopulated_ReturnsThatSameInstance` | Passed | 00:00:00.0028518 | +| `UtilitiesCS.Test.OutlookObjects.Folder.WpfDispatcherYieldTests.YieldAsync_WithoutDispatcher_RemainsStrict` | Passed | 00:00:00.0025348 | +| `UtilitiesCS.Test.OutlookObjects.Folder.FolderPredictorTests.EnterUiContextAsync_WhenUiSyncContextPostsSynchronously_CompletesUsingDefaultAction` | Passed | 00:00:00.0015010 | +| `UtilitiesCS.Test.EmailIntelligence.FolderRemapViewer_Tests.SetController_WithSyntheticController_ConfiguresTreeDelegates` | Passed | 00:00:00.0062163 | +| `UtilitiesCS.Test.EmailIntelligence.FilterOlFoldersViewer_Tests.SetController_WithSyntheticController_ConfiguresBothTreeDelegates` | Passed | 00:00:00.3815654 | + +## Second pass, run after the [P4-T5] line-budget trim + +PASS_2_TIMESTAMP: 2026-09-08T02-31 + +The trim recorded in `p4-t2-format-and-builds.md` changed `UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs`, one of the files this filter covers, so this task was re-run unchanged. + +PASS_2_EXIT_CODE: 0 + +PASS_2_OUTPUT_SUMMARY: `Test Run Successful.`, `Total tests: 82`, ` Passed: 82`. TRX selected `p4t4b.trx`, `LastWriteTimeUtc` `2026-09-08T05:01:12.9542131Z`; counters `total` 82, `executed` 82, `passed` 82, `failed` 0; derived skipped count 0. Every one of the six individually named rows was `Passed` again. + +--- + +The method name `Dispatcher_WhenBackingFieldIsNull_ThrowsInvalidOperationExceptionNamingInitialize` carries a deliberately inaccurate suffix and was not renamed, because its fully-qualified name is quoted inside a committed `TestCaseFilter` evidence artifact. + +The two viewer tests are the in-repo pair that actually drive `Init()` through to a successful `Initialize()`. Both remain `[STATestClass]` and both now additionally carry `[DoNotParallelize]`; both still assert `NotThrow`, so the AC1 precondition does not reject the STA path they exercise. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p4-t5-line-counts.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p4-t5-line-counts.md new file mode 100644 index 000000000..4b1f8ba44 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p4-t5-line-counts.md @@ -0,0 +1,52 @@ +# [P4-T5] 500-line file-size audit across the Write Set source files + +Timestamp: 2026-09-08T02-33 + +Command: `(Get-Content -LiteralPath ).Count`, the pinned line-count idiom, for each of the ten audited source files. The idiom `Get-Content -LiteralPath | Measure-Object -Line` is rejected by name and was not substituted, because it counts non-blank lines only and would understate a file by its blank-line count. + +Measured after the last formatter run of Phase 4, which is the second pass recorded in `p4-t2-format-and-builds.md`. + +EXIT_CODE: 0 + +## Post-change counts, with the recorded baseline beside each pre-existing file + +| Path | `POSTCHANGE_LINES` | `BASELINE_LINES` from [P0-T16] | +|---|---|---| +| `UtilitiesCS/Threading/UiThread.cs` | 293 | 195 | +| `UtilitiesCS/Threading/IUiCaptureSource.cs` | 50 | created by this delivery | +| `UtilitiesCS/Threading/SyncContextForm.cs` | 50 | 50 | +| `UtilitiesCS.Test/Threading/UiThread_Tests.cs` | 458 | 215 | +| `UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs` | 460 | created by this delivery | +| `UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs` | 209 | created by this delivery | +| `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs` | 394 | 360 | +| `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs` | 1067 | 1066 | +| `UtilitiesCS.Test/EmailIntelligence/FolderRemapViewer_Tests.cs` | 157 | 156 | +| `UtilitiesCS.Test/EmailIntelligence/FilterOlFoldersViewer_Tests.cs` | 273 | 272 | + +Seven of the ten files pre-existed; the three without a baseline are the files this delivery creates. + +## Classification + +PRE_EXISTING_FILES_OVER_500: 1 + +The set is every audited file whose `BASELINE_LINES` value already exceeds 500. It contains exactly one member: + +- `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs`, baseline 1066. + +The figure quoted in the plan for that file is 1066 and the value [P0-T16] recorded is 1066, so the two agree and the classification is not disturbed. This delivery changes that file by exactly the single `[DoNotParallelize]` attribute line [P2-T7] adds, taking it from 1066 to 1067. It is over the limit both before and after, and the delivery increases the overrun by one line. + +FILES_OVER_500_INTRODUCED: 0 + +No audited file whose post-change count exceeds 500 lies outside the pre-existing set. The largest file this delivery creates or grows below that set is `UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs` at 460 lines, 40 under the limit; `UtilitiesCS.Test/Threading/UiThread_Tests.cs` is 458, and `UtilitiesCS/Threading/UiThread.cs` is 293, 207 under the limit and comfortably inside the roughly 305 lines of headroom research R8 recorded. + +The two `.csproj` files are excluded from this audit, because the 500-line limit governs production code, test code and reusable scripts, and a project file is none of those. + +## Budget reconciliation + +`UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs` measured 468 lines when this task first ran, 8 above the 460-line whole-file authoring budget [P2-T1] states. The overshoot came from the [P3-T6] correction, which replaced the ambient-apartment arrangement in two methods with a dedicated-MTA-thread arrangement. Documentation comments in that file were trimmed and the toolchain restarted; the file now measures exactly 460, so the authoring budget is met. The trim changed no executable statement and no assertion, and every acceptance token of [P2-T1] through [P2-T5] still returns its required count. + +`UtilitiesCS.Test/Threading/UiThread_Tests.cs` finishes at 458 against the 215-line baseline, so 243 lines were added against the 275-line budget [P2-T8] states, and it sits 37 lines below the 495-line ceiling [P2-T9] gates. + +## Outcome + +`FILES_OVER_500_INTRODUCED:` is `0`, so no remediation-required outcome arises from this audit and no file outside the Write Set was created. The one pre-existing overrun is carried to [P6-T13] as a follow-up candidate; it is not a remediation-required outcome of this delivery. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t1-format.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t1-format.md new file mode 100644 index 000000000..5a53d511f --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t1-format.md @@ -0,0 +1,35 @@ +# [P5-T1] Final QC loop, step 1 — formatting + +Timestamp: 2026-09-08T02-36 + +Command: `git status --porcelain --untracked-files=all` (before-image); `dotnet tool run csharpier format .`; `git status --porcelain --untracked-files=all` (after-image) + +EXIT_CODE: 0 + +## Output Summary + +The exit code cannot distinguish a clean run from a repairing one, and the `Formatted files` figure is CSharpier's processed-file count rather than its rewritten-file count, so the before-and-after tree comparison is the observation that decides this gate. + +Before-image, verbatim: + +``` +``` + +Formatter output, verbatim: + +``` +Formatted 1611 files in 3222ms. +``` + +After-image, verbatim: + +``` +``` + +Both images are empty and are therefore byte-identical. Every Write Set change was committed at `fd22abf2` before this pass ran, so a clean tree is the expected before-image; the formatter rewrote nothing, which is what the identical after-image establishes. + +PASS RESULT: CLEAN + +No differing path exists, so no diff hunk is recorded, nothing needed committing, and the loop did not restart from this task. + +TOOLCHAIN_LOOP_PASS: 1 diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t2-format-check.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t2-format-check.md new file mode 100644 index 000000000..d2967be6c --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t2-format-check.md @@ -0,0 +1,31 @@ +# [P5-T2] Final QC loop, step 1 verification — formatting check + +Timestamp: 2026-09-08T02-37 + +Command: `dotnet tool run csharpier check .` (run after the SDK preamble) + +EXIT_CODE: 0 + +## Output Summary + +``` +Checked 1611 files in 6632ms. +``` + +## Arithmetic + +The baseline value is located in `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t9-csharpier-check.md` by its `BASELINE_CHECKED_FILES:` token. + +| Quantity | Value | +|---|---| +| `BASELINE_CHECKED_FILES:` recorded by [P0-T9] | 1608 | +| Files this delivery creates | 3 | +| Expected checked-file count | 1611 | +| Observed checked-file count | 1611 | +| Difference | 0 | + +The plus-three is the three files this delivery creates: `UtilitiesCS/Threading/IUiCaptureSource.cs`, `UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs`, and `UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs`. The expected value is derived from the recorded token rather than from any figure tabled in the plan, so a baseline correction would propagate without editing the task. + +`coverage/` and `TestResults/` are git-ignored and CSharpier 1.2.6 honours `.gitignore`, so neither the derived coverage settings file nor any results tree enters this count. + +TOOLCHAIN_LOOP_PASS: 1 diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t3-analyzer-build.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t3-analyzer-build.md new file mode 100644 index 000000000..8a4d28d41 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t3-analyzer-build.md @@ -0,0 +1,29 @@ +# [P5-T3] Final QC loop, step 2 — linting by .NET analyzers + +Timestamp: 2026-09-08T02-40 + +Command: `& $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true` + +EXIT_CODE: 0 + +## Output Summary + +``` + 0 Warning(s) + 0 Error(s) +``` + +## Project-count comparison + +The baseline value is located in `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t10-analyzer-build.md` by its `BASELINE_PROJECT_COUNT:` token. + +| Quantity | Value | +|---|---| +| `BASELINE_PROJECT_COUNT:` recorded by [P0-T10] | 18 | +| Build-output arrow-line count observed here | 18 | + +The two counts are equal, which is required: this delivery adds no project and removes none. + +`/t:Rebuild` was used rather than `/t:Build`, because MSBuild's up-to-date check does not invalidate on a command-line `/p:` change and a warm `/t:Build` would return exit 0 with `CoreCompile` skipped on every project and no analyzer run at all. + +TOOLCHAIN_LOOP_PASS: 1 diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t4-nullable-build.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t4-nullable-build.md new file mode 100644 index 000000000..a8cea1d92 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t4-nullable-build.md @@ -0,0 +1,18 @@ +# [P5-T4] Final QC loop, step 3 — type checking by nullable analysis + +Timestamp: 2026-09-08T02-44 + +Command: `& $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true` + +EXIT_CODE: 0 + +## Output Summary + +``` + 0 Warning(s) + 0 Error(s) +``` + +`/p:Nullable=enable` was not passed, and `/t:Rebuild` was used rather than `/t:Build`. `/p:Nullable=enable` is omitted because no project in this repository carries a `` element and there is no `Directory.Build.props`, so the property is a solution-wide opt-in that would conscript every file which has never adopted the `#nullable enable` pragma, and CI omits it deliberately; omitting it loses no enforcement over any file that has opted in, and `UtilitiesCS/Threading/UiThread.cs` and `UtilitiesCS/Threading/IUiCaptureSource.cs` both carry the pragma at line 1. `/t:Build` is not used because MSBuild's up-to-date check does not invalidate on a command-line `/p:` change, so a warm `/t:Build` would return exit 0 with `CoreCompile` skipped on every project and would run no nullable-flow analysis at all. + +TOOLCHAIN_LOOP_PASS: 1 diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t5-tests-coverage.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t5-tests-coverage.md new file mode 100644 index 000000000..93541f853 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t5-tests-coverage.md @@ -0,0 +1,84 @@ +# [P5-T5] Final QC loop, step 4 — the full nine-assembly suite with coverage + +Timestamp: 2026-09-08T02-50 + +Command: + +``` +dotnet-coverage collect --output coverage\809-p5-final.cobertura.xml --output-format cobertura --settings coverage\809-effective-coverage.config -- $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll SVGControl.Test\bin\Debug\SVGControl.Test.dll Tags.Test\bin\Debug\Tags.Test.dll TaskMaster.Test\bin\Debug\TaskMaster.Test.dll TaskTree.Test\bin\Debug\TaskTree.Test.dll TaskVisualization.Test\bin\Debug\TaskVisualization.Test.dll ToDoModel.Test\bin\Debug\ToDoModel.Test.dll UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll VBFunctions.Test\bin\Debug\VBFunctions.Test.dll '/Settings:scripts\vscode\TaskMaster.cli.runsettings' '/InIsolation' '/Logger:trx;LogFileName=p5t5.trx' '/ResultsDirectory:TestResults\809-p5t5' '/Blame:CollectHangDump;TestTimeout=5min;HangDumpType=None' '/TestCaseFilter:TestCategory!=LiveOutlook&FullyQualifiedName!~HelperClasses.ShellUtilities_Tests&FullyQualifiedName!~HelperClasses.ShellUtilitiesStatic_Tests&FullyQualifiedName!~HelperClasses.SysImageListHelperTests&FullyQualifiedName!~EmailIntelligence.OSBrowser_Tests' +``` + +The derived settings file `coverage\809-effective-coverage.config` was rebuilt exactly as [P0-T13] builds it: repository-root `coverage.config` loaded, one `.*\.Test\.dll$` appended to `/Configuration/CodeCoverage/ModulePaths/Exclude`, saved under `coverage\`. + +EXIT_CODE: 0 + +## Output Summary + +``` +Test Run Successful. +Total tests: 7137 + Passed: 7137 +``` + +TRX selected: `p5t5.trx`, `LastWriteTimeUtc` `2026-09-08T05:05:32.6088790Z`. + +TRX `ResultSummary/Counters`: `total` 7137, `executed` 7137, `passed` 7137, `failed` 0. + +The `failed` attribute is 0. + +SKIPPED_DERIVED: 0 + +## Discovered-total comparison + +The baseline value is located in `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t12-vstest.md` by its `BASELINE_TOTAL_TESTS:` token. + +| Quantity | Value | +|---|---| +| `BASELINE_TOTAL_TESTS:` recorded by [P0-T12] | 7120 | +| Test methods this delivery adds | 17 | +| Expected discovered total | 7137 | +| Observed discovered total | 7137 | +| Difference | 0 | + +The seventeen are four in `UiThreadInitApartmentContract_Tests` from [P2-T2], six in `UiThreadInitRetryContract_Tests` being four from [P2-T3] plus one from [P2-T4] plus one from [P2-T5], and seven in `SynchronizationContextAwaiter_Tests` from [P2-T8]. This delivery removes no test, and [P4-T1] changed the signature of one existing method without renaming it, so the total is baseline plus seventeen exactly. + +## First-party coverage, pinned counting method + +The selection is the all-descendant `.//line` selection over each first-party ``, and only that one; the two narrower selections `classes/class/lines/line` and `classes/class/methods/method/lines/line` are rejected by name and were not substituted. A `` counts as covered when `hits` is greater than zero. Branch figures are summed from the `(numerator/denominator)` pair inside each `condition-coverage` attribute over the same line set. The allowlist is the nine production assembly names. + +FINAL_FIRSTPARTY_LINES_COVERED: 113481 +FINAL_FIRSTPARTY_LINES_VALID: 134111 +FINAL_FIRSTPARTY_BRANCHES_COVERED: 26920 +FINAL_FIRSTPARTY_BRANCHES_VALID: 33912 +FINAL_FIRSTPARTY_LINE_PCT: 84.62 +FINAL_FIRSTPARTY_BRANCH_PCT: 79.38 + +Per-package breakdown, same selection: + +| Package | Lines covered | Lines valid | Line % | Branches covered | Branches valid | Branch % | +|---|---|---|---|---|---|---| +| QuickFiler | 20545 | 25572 | 80.34 | 4890 | 6330 | 77.25 | +| UtilitiesCS | 79132 | 89046 | 88.87 | 18694 | 22446 | 83.28 | +| TaskVisualization | 2899 | 3230 | 89.75 | 666 | 800 | 83.25 | +| SVGControl | 1757 | 3712 | 47.33 | 600 | 1276 | 47.02 | +| ToDoModel | 2193 | 3819 | 57.42 | 496 | 1016 | 48.82 | +| Tags | 1428 | 1540 | 92.73 | 348 | 380 | 91.58 | +| TaskMaster | 4927 | 6564 | 75.06 | 1038 | 1460 | 71.10 | +| TaskTree | 592 | 620 | 95.48 | 188 | 204 | 92.16 | +| VBFunctions | 8 | 8 | 100.00 | 0 | 0 | 0.00 | + +These are locally-filtered nine-assembly figures, not CI figures. + +## The AC5-named test, recorded individually by name + +| Fully-qualified test | Outcome | Duration | +|---|---|---| +| `UtilitiesCS.Test.Extensions.DictionaryExtensions_Tests.TryAddValuesAsync_UpdatesExistingValue` | Passed | 00:00:18.3738732 | + +The AC5 clause naming that test names the full-suite run as the place it is recorded, and an aggregate pass count does not carry the name, so the row is reproduced here. Its duration of 18.4 seconds is consistent with the documented issue #780 wall-clock behaviour of this test; it passed. The three dedicated repetitions are [P5-T6] and are separate from this row. + +## Collector substitution + +`/EnableCodeCoverage` was not passed. `scripts/vscode/TaskMaster.cli.runsettings` carries no data collector, and `scripts/vscode/Invoke-MSTestWithCoverage.ps1:19-26` records that the omission is deliberate because the outer `dotnet-coverage` instrumentation and the built-in Code Coverage collector conflict. Coverage was collected by `dotnet-coverage collect ... -- vstest.console.exe ...` instead. [P6-T10] records this as `AC6-COLLECTOR-SUBSTITUTION`. + +TOOLCHAIN_LOOP_PASS: 1 diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t6-tryaddvalues-rep1.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t6-tryaddvalues-rep1.md new file mode 100644 index 000000000..58b0fd362 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t6-tryaddvalues-rep1.md @@ -0,0 +1,27 @@ +# [P5-T6] `TryAddValuesAsync_UpdatesExistingValue`, repetition 1 of 3 + +Timestamp: 2026-09-08T02-53 + +Command: + +``` +& $vstest UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll '/Tests:TryAddValuesAsync_UpdatesExistingValue' '/InIsolation' '/Logger:trx;LogFileName=p5t6r1.trx' '/ResultsDirectory:TestResults\809-p5t6r1' +``` + +EXIT_CODE: 0 + +## Output Summary + +``` +Test Run Successful. +Total tests: 1 + Passed: 1 +``` + +TRX selected: `p5t6r1.trx`, `LastWriteTimeUtc` `2026-09-08T05:06:32.3141759Z`. Counters: `total` 1, `executed` 1, `passed` 1, `failed` 0. + +| Fully-qualified test | Outcome | Duration | +|---|---|---| +| `UtilitiesCS.Test.Extensions.DictionaryExtensions_Tests.TryAddValuesAsync_UpdatesExistingValue` | Passed | 00:00:00.0763918 | + +That test is the documented issue #780 intermittent flake with an identical `TaskCanceledException` signature, which is why decision D5 requires three repetitions before any single failure of it could be attributed to this delivery. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t6-tryaddvalues-rep2.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t6-tryaddvalues-rep2.md new file mode 100644 index 000000000..d8a6cdb2c --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t6-tryaddvalues-rep2.md @@ -0,0 +1,27 @@ +# [P5-T6] `TryAddValuesAsync_UpdatesExistingValue`, repetition 2 of 3 + +Timestamp: 2026-09-08T02-53 + +Command: + +``` +& $vstest UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll '/Tests:TryAddValuesAsync_UpdatesExistingValue' '/InIsolation' '/Logger:trx;LogFileName=p5t6r2.trx' '/ResultsDirectory:TestResults\809-p5t6r2' +``` + +EXIT_CODE: 0 + +## Output Summary + +``` +Test Run Successful. +Total tests: 1 + Passed: 1 +``` + +TRX selected: `p5t6r2.trx`, `LastWriteTimeUtc` `2026-09-08T05:06:40.3777362Z`. Counters: `total` 1, `executed` 1, `passed` 1, `failed` 0. + +| Fully-qualified test | Outcome | Duration | +|---|---|---| +| `UtilitiesCS.Test.Extensions.DictionaryExtensions_Tests.TryAddValuesAsync_UpdatesExistingValue` | Passed | 00:00:00.0758015 | + +That test is the documented issue #780 intermittent flake with an identical `TaskCanceledException` signature, which is why decision D5 requires three repetitions before any single failure of it could be attributed to this delivery. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t6-tryaddvalues-rep3.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t6-tryaddvalues-rep3.md new file mode 100644 index 000000000..e17ea2522 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t6-tryaddvalues-rep3.md @@ -0,0 +1,39 @@ +# [P5-T6] `TryAddValuesAsync_UpdatesExistingValue`, repetition 3 of 3 + +Timestamp: 2026-09-08T02-53 + +Command: + +``` +& $vstest UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll '/Tests:TryAddValuesAsync_UpdatesExistingValue' '/InIsolation' '/Logger:trx;LogFileName=p5t6r3.trx' '/ResultsDirectory:TestResults\809-p5t6r3' +``` + +EXIT_CODE: 0 + +## Output Summary + +``` +Test Run Successful. +Total tests: 1 + Passed: 1 +``` + +TRX selected: `p5t6r3.trx`, `LastWriteTimeUtc` `2026-09-08T05:06:48.6971808Z`. Counters: `total` 1, `executed` 1, `passed` 1, `failed` 0. + +| Fully-qualified test | Outcome | Duration | +|---|---|---| +| `UtilitiesCS.Test.Extensions.DictionaryExtensions_Tests.TryAddValuesAsync_UpdatesExistingValue` | Passed | 00:00:00.0821186 | + +## Repetition tally across all three artifacts + +| Repetition | Artifact | Outcome | Duration | +|---|---|---|---| +| 1 | `p5-t6-tryaddvalues-rep1.md` | Passed | 00:00:00.0763918 | +| 2 | `p5-t6-tryaddvalues-rep2.md` | Passed | 00:00:00.0758015 | +| 3 | `p5-t6-tryaddvalues-rep3.md` | Passed | 00:00:00.0821186 | + +REPETITIONS_PASSED: 3 + +Three of three repetitions recorded `Passed`, so `ATTRIBUTION-REVIEW-REQUIRED` is not written and the delivery is not reported as remediation-required on this criterion. AC5 may be checked off on this evidence together with the [P0-T15] measurement and the [P6-T4] reconciliation. + +The same test additionally appears in the [P5-T5] full-suite run with outcome `Passed` and a duration of `00:00:18.3738732`, which is the wall-clock behaviour issue #780 documents when the machine is under the load of a full nine-assembly run. It passed there too, so four independent observations of this test in this delivery are all green. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t7-loop-closure.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t7-loop-closure.md new file mode 100644 index 000000000..bad8c63e8 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t7-loop-closure.md @@ -0,0 +1,29 @@ +# [P5-T7] Closure of the final QC toolchain loop + +Timestamp: 2026-09-08T02-55 + +## Pass table + +The loop has four steps and five artifacts, because step 1 is recorded twice: once for the write-mode formatter run and once for its read-only check. + +| Pass | Step 1 format | Step 1 check | Step 2 lint | Step 3 type-check | Step 4 test with coverage | +|---|---|---|---|---|---| +| 1 | `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t1-format.md` | `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t2-format-check.md` | `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t3-analyzer-build.md` | `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t4-nullable-build.md` | `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t5-tests-coverage.md` | + +## Final row + +Pass 1 completed all four steps without a failure and without any file change. + +- Step 1 exited 0 and the before-and-after porcelain images recorded in that pass's `p5-t1-format.md` are byte-identical: both are empty, because every Write Set change had been committed at `fd22abf2` before the pass began. That byte-identity is the evidence that the formatter changed no file, which the exit code alone cannot establish for a write-mode command. +- Step 1's read-only check exited 0 at 1611 checked files, the recorded baseline of 1608 plus the three files this delivery creates. +- Step 2 exited 0 with ` 0 Warning(s)` and ` 0 Error(s)`, and its build-output arrow-line count of 18 equals the recorded baseline project count. +- Step 3 exited 0 with ` 0 Warning(s)` and ` 0 Error(s)`. +- Step 4 exited 0 with 7137 of 7137 passed, `failed` 0 and a derived skipped count of 0, the recorded baseline total of 7120 plus exactly the seventeen tests this delivery adds. + +No step failed and no step changed a file, so the loop did not restart and there is no second pass. + +TOOLCHAIN_LOOP_CLEAN_PASS: 1 + +## Note on earlier restarts + +Three toolchain restarts occurred before Phase 5 began, each triggered by a file change during its own phase rather than by a Phase 5 step: the [P3-T5] second pass after the [P3-T6] test correction, and the [P4-T2] second pass after the [P4-T5] line-budget trim, with [P4-T3] and [P4-T4] re-run alongside it. Each is recorded in its own artifact. They are not passes of this loop and are not listed in the table above, which covers only the Phase 5 final QC loop. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p6-t1-uithread-file-coverage.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p6-t1-uithread-file-coverage.md new file mode 100644 index 000000000..71f5a84bd --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p6-t1-uithread-file-coverage.md @@ -0,0 +1,83 @@ +# [P6-T1] Per-file line coverage of `UtilitiesCS/Threading/UiThread.cs` against the Phase 0 baseline + +Timestamp: 2026-09-08T02-58 + +Command: the pinned per-file lookup applied to `coverage\809-p5-final.cobertura.xml`, the document [P5-T5] wrote. The lookup selects `` elements whose `filename` attribute ends with the backslash suffix `UtilitiesCS\Threading\UiThread.cs`, takes `.//line` under each, counts each line number once, and treats a line number as covered when any matching element carries `hits` greater than zero. A line number that matches no element is not executable and is excluded from both numerator and denominator. + +EXIT_CODE: 0 + +## Figures + +FINAL_UITHREAD_LINES_COVERED: 121 +FINAL_UITHREAD_LINES_VALID: 126 +FINAL_UITHREAD_LINE_PCT: 96.03 +FINAL_UITHREAD_UNCOVERED_LINES: 38,39,40,177,178 + +The baseline value, quoted from `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t14-uithread-file-coverage.md`: + +BASELINE_UITHREAD_LINE_PCT: 76.83 + +| Quantity | Baseline | Final | +|---|---|---| +| Lines covered | 63 | 121 | +| Lines valid | 82 | 126 | +| Line % | 76.83 | 96.03 | +| Uncovered line count | 19 | 5 | + +## Both gate conditions + +1. `FINAL_UITHREAD_LINE_PCT` is `96.03`, which is at least `80.00`. That floor is the `CLAUDE.md` figure, which takes precedence over the `.claude/rules/general-unit-test.md` figure per the precedence order in `.claude/skills/policy-compliance-order/SKILL.md`. **Met**, with 16.03 percentage points of margin. +2. `FINAL_UITHREAD_LINE_PCT` `96.03` is strictly greater than `BASELINE_UITHREAD_LINE_PCT` `76.83`. **Met**, an increase of 19.20 percentage points. + +## Each baseline-uncovered line number, evaluated against the final document + +The file grew from 195 to 293 physical lines, so a baseline line **number** does not address the same source construct in the final document. The table below is the literal per-number lookup the task asks for; the construct-level reconciliation that follows it is what carries the meaning. + +| Baseline uncovered line number | Status in the final document | Text now at that number | +|---|---|---| +| 28 | not executable | `// call regardless of the latch, so a non-STA caller would otherwise poison them even` | +| 29 | not executable | `// when Initialize() never runs.` | +| 30 | covered | `ApartmentState apartment = Thread.CurrentThread.GetApartmentState();` | +| 32 | covered | `{` | +| 33 | covered | `throw new InvalidOperationException(NonStaInitMessage(apartment));` | +| 34 | not executable | `}` | +| 67 | not executable | `private static bool _initialized;` | +| 68 | not executable | (blank) | +| 69 | not executable | `private static void Initialize()` | +| 70 | covered | `{` | +| 71 | not executable | `// Create a hidden form to initialize the synchronization context` | +| 72 | covered | `_syncContextForm = SyncContextFormFactory();` | +| 73 | covered | `_syncContextForm.ShowInTaskbar = false;` | +| 74 | covered | `_syncContextForm.WindowState = FormWindowState.Minimized;` | +| 75 | covered | `_syncContextForm.Show();` | +| 76 | not executable | (blank) | +| 118 | not executable | `/// drives initialization through a failure would otherwise change the premise of every` | +| 119 | not executable | `/// later test in that process. This method is not thread-safe; serialization is provided` | +| 120 | not executable | `/// by [DoNotParallelize] on every consuming test class.` | + +No baseline line number is uncovered in the final document. + +## Construct-level reconciliation, with the covering test named + +| Baseline uncovered construct | Baseline lines | Final lines | Covered now | Test that covers it | +|---|---|---|---|---| +| `if (onLockupDetected is not null) { _onLockupDetected = onLockupDetected; }` body | 28, 29, 30 | 38, 39, 40 | **No** | none; see below | +| `if (timeProvider is not null) { _monitorTimeProvider = timeProvider; }` body | 32, 33, 34 | 42, 43, 44 | Yes | `UiThreadInitRetryContract_Tests.Init_WithMonitorUiThreadEnabled_ConstructsAndRunsTheThreadMonitorWithTheInjectedTimeProvider`, which passes `timeProvider: clock` | +| The `if (_monitorUiThread)` `ThreadMonitor` construction and `Run()` | 67 through 76 | 87 through 97 | Yes | the same test, which passes `monitorUiThread: true` and asserts `ThreadMonitorField` is non-null | +| The lazy `if (_uiSyncContext is null) { Init(); }` block | 118, 119, 120 | 207 through 210 | Yes | `UiThreadInitRetryContract_Tests.UiSyncContext_ReadWithNullBackingFieldFromStaThread_InitializesThroughTheLazyPath` | + +Seventeen of the nineteen baseline-uncovered source lines are covered by this delivery. The residual three, the body of the `onLockupDetected` guard now at lines 38 through 40, remain uncovered because no test in this delivery passes a non-null `onLockupDetected` argument on a path that reaches the assignment: the one test that supplies a callback, `Init_OnMtaThread_CapturesNoGlobalStateAndLeavesMonitoringConfigurationUnchanged`, supplies it precisely to assert that a rejected `Init()` does **not** perform that assignment, so the AC1 precondition throws before line 37 is evaluated. + +## The two remaining uncovered lines + +| Line | Text | Reason | +|---|---|---| +| 38 | `{` | Body of the `onLockupDetected` guard, as above. | +| 39 | `_onLockupDetected = onLockupDetected;` | Same. | +| 40 | `}` | Same. | +| 177 | `{` | Body of the `ReferenceEquals(_context, _uiSyncContext)` clause of the new predicate, at line 176. | +| 178 | `return true;` | Same. | + +The predicate clause at 176 through 178 is reached only when the caller stands on the owning UI thread with a non-null ambient context that is not the captured context, and the captured context is the persistent `_uiSyncContext`. The AC3 case that installs `_uiThreadId` for the host thread, `IsCompleted_OnOwningUiThreadWithADispatcherContextCapturedInsideAnInvoke_ReturnsTrue`, captures a `DispatcherSynchronizationContext` and so falls through to the dispatcher clause instead; no case in this delivery installs `_uiSyncContext` and then awaits that same instance from the owning thread while a different context is ambient. This is a two-line gap in a five-line residual and is recorded rather than closed, because closing it is a further test rather than a defect in the fix. It is carried to [P6-T13]. + +The five uncovered lines are 3.97% of the 126 executable lines, against a floor that permits 20%. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p6-t2-changed-line-coverage.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p6-t2-changed-line-coverage.md new file mode 100644 index 000000000..fdf998ccd --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p6-t2-changed-line-coverage.md @@ -0,0 +1,56 @@ +# [P6-T2] Changed-line coverage over the three production Write Set files + +Timestamp: 2026-09-08T03-03 + +Command: `git add -N UtilitiesCS/Threading/IUiCaptureSource.cs`; then `git diff pre-809-base -- UtilitiesCS/Threading/UiThread.cs UtilitiesCS/Threading/SyncContextForm.cs UtilitiesCS/Threading/IUiCaptureSource.cs`; together with `git status --porcelain --untracked-files=all -- UtilitiesCS/Threading/`. + +EXIT_CODE: 0 + +The diff is anchored to `pre-809-base` rather than to `HEAD`, so it enumerates every line this delivery added to these three files regardless of which phase commit carried it. The `git add -N` span makes a newly created interface file visible to the diff; it was a no-op here because the phase-boundary commits had already tracked the file, and the companion `git status --porcelain --untracked-files=all -- UtilitiesCS/Threading/` returned no lines, confirming that no file under that directory is untracked or uncommitted. The anchored diff reported `154 insertions(+), 6 deletions(-)` across the three files. + +Added lines were mapped to their post-change line numbers from the hunk headers, then looked up with the pinned per-file lookup against `coverage\809-p5-final.cobertura.xml`. A changed line number is counted once and is treated as covered when any matching element carries `hits` greater than zero. A line number that matches no element is not executable and is excluded from both numerator and denominator. + +## Per-file table + +| File | Added lines | Executable | Covered | Uncovered | Percentage | +|---|---|---|---|---|---| +| `UtilitiesCS/Threading/UiThread.cs` | 103 | 48 | 46 | 2 | 95.83 | +| `UtilitiesCS/Threading/SyncContextForm.cs` | 1 | 0 | 0 | 0 | no executable added line | +| `UtilitiesCS/Threading/IUiCaptureSource.cs` | 50 | 0 | 0 | 0 | no executable line; interface declaration only | +| **Total** | **154** | **48** | **46** | **2** | **95.83** | + +CHANGED_LINE_COVERAGE= 95.83 + +That figure is at least `90.00`. + +UNCOVERED_ENUMERATION_COUNT= 2 + +- `UtilitiesCS/Threading/UiThread.cs:177` — `{` +- `UtilitiesCS/Threading/UiThread.cs:178` — `return true;` + +Both are the body of the `ReferenceEquals(_context, _uiSyncContext)` clause of the replaced predicate, whose condition sits at line 176. + +## Per-member table + +This is the mechanical form of the AC6 clause requiring each newly added member at 90% or better. Every new member of this delivery consists entirely of added lines in these three files, so the added-line set is exactly the new-member set plus the replaced `IsCompleted` body. + +| Member | Lines | Executable | Covered | Uncovered | Percentage | +|---|---|---|---|---|---| +| `IUiCaptureSource` (whole file) | 1 through 50 | 0 | 0 | 0 | declaration only, no executable line, recorded as such | +| `UiThread.Init` apartment precondition and `InitLock` region | 26 through 67 | 10 | 10 | 0 | 100.00 | +| `UiThread.SyncContextFormFactory` | 104 through 109 | 1 | 1 | 0 | 100.00 | +| `UiThread.DefaultSyncContextFormFactory` | 111 | 1 | 1 | 0 | 100.00 | +| `UiThread.ResetForTesting` | 113 through 136 | 14 | 14 | 0 | 100.00 | +| `SynchronizationContextAwaiter.IsCompleted` (replaced body) | 155 through 190 | 20 | 18 | 2 | 90.00 | +| `UiThread.NonStaInitMessagePrefix` | 229 through 232 | 0 | 0 | 0 | constant, no executable line, recorded as such | +| `UiThread.NonStaInitMessage` | 233 through 234 | 1 | 1 | 0 | 100.00 | + +Every row whose executable count is greater than zero shows a percentage of at least `90.00`. + +One further added executable line falls outside every member range above and is included in the file and total figures: `UtilitiesCS/Threading/UiThread.cs:72`, `_syncContextForm = SyncContextFormFactory();`, which is the rewritten call site inside the pre-existing `Initialize()` method rather than a new member. It is covered. + +The two uncovered lines belong to the `IsCompleted` row, which is therefore recorded here with the reason they are uncovered and the test that leaves them so. The clause at 176 through 178 is reached only when the caller stands on the owning UI thread, the ambient context is non-null and is not the captured context, and the captured context is the persistent `_uiSyncContext`. `SynchronizationContextAwaiter_Tests.IsCompleted_OnOwningUiThreadWithADispatcherContextCapturedInsideAnInvoke_ReturnsTrue` is the case that installs `_uiThreadId` for the owning host thread, but it captures a `DispatcherSynchronizationContext`, so it falls through to the dispatcher clause at 184 through 188 rather than returning at 178. No case in this delivery installs `_uiSyncContext` and then awaits that same instance from the owning thread while a different context is ambient. The row still meets the 90% floor exactly. + +## No changed line can lose coverage + +Every changed line in these three files is either an added line, which is measured above, or a deleted line. A deleted line has no coverage to lose, because it no longer exists in the post-change file and is therefore absent from both the numerator and the denominator of any post-change measurement. There is no third category: `git diff` classifies every changed line as one or the other. The no-regression obligation over changed lines is therefore discharged by the added-line measurement alone, and the file-level figures in `p6-t1-uithread-file-coverage.md` corroborate it, rising from 76.83% to 96.03%. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p6-t3-aggregate-coverage.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p6-t3-aggregate-coverage.md new file mode 100644 index 000000000..3c91b62b3 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p6-t3-aggregate-coverage.md @@ -0,0 +1,47 @@ +# [P6-T3] Aggregate first-party coverage against the Phase 0 baseline + +Timestamp: 2026-09-08T03-06 + +Command: read `BASELINE_FIRSTPARTY_LINES_VALID:` from `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/baseline/p0-t13-coverage.md` and `FINAL_FIRSTPARTY_LINES_VALID:` from `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/qa-gates/p5-t5-tests-coverage.md`, then compare the two aggregates produced by the pinned counting method. + +EXIT_CODE: 0 + +## Denominator comparability test + +| Quantity | Value | +|---|---| +| `BASELINE_FIRSTPARTY_LINES_VALID:` | 134023 | +| `FINAL_FIRSTPARTY_LINES_VALID:` | 134111 | +| Absolute difference | 88 | + +DENOMINATOR_DELTA_PCT: 0.0657 + +That is the absolute difference expressed as a percentage of the baseline denominator, to four decimal places. It is at most `1.0000`, so Outcome A applies. + +## Outcome A + +COVERAGE COMPARISON: COMPARABLE + +Both gate conditions are evaluated below. + +| Quantity | Baseline | Final | Change | Permitted floor | Met | +|---|---|---|---|---|---| +| First-party line % | 84.58 | 84.62 | +0.04 | at least 84.08, the baseline less 0.50 percentage points | Yes | +| First-party branch % | 79.34 | 79.38 | +0.04 | at least 78.84, the baseline less 0.50 percentage points | Yes | + +`FINAL_FIRSTPARTY_LINE_PCT` of 84.62 is at least `BASELINE_FIRSTPARTY_LINE_PCT` of 84.58 minus 0.50 percentage points. `FINAL_FIRSTPARTY_BRANCH_PCT` of 79.38 is at least `BASELINE_FIRSTPARTY_BRANCH_PCT` of 79.34 minus 0.50 percentage points. Both aggregates rose rather than fell, so the no-regression obligation is met with margin rather than by tolerance. + +## Supporting figures + +| Quantity | Baseline | Final | +|---|---|---| +| First-party lines covered | 113361 | 113481 | +| First-party lines valid | 134023 | 134111 | +| First-party branches covered | 26880 | 26920 | +| First-party branches valid | 33880 | 33912 | + +The denominator grew by 88 lines and 32 branches, which is consistent with the 154 lines this delivery added to three production files, of which 48 are executable, plus the compiler-generated lines the new lambda and property members contribute. The numerator grew by 120 lines and 40 branches, which exceeds the denominator growth and is why both percentages rose. + +Both figures were produced by the pinned counting method: the all-descendant `.//line` selection over each first-party ``, with the two narrower selections rejected by name; the same nine-name first-party allowlist; and the same `condition-coverage` branch summation. Baseline and final are therefore commensurable by construction, and the denominator test above confirms it numerically. + +Outcome B is not recorded, because `DENOMINATOR_DELTA_PCT` did not exceed `1.0000`. Both outcomes are gated; neither is a waiver. The file-scoped measurements in `p6-t1-uithread-file-coverage.md` and `p6-t2-changed-line-coverage.md` stand independently and corroborate this result rather than substituting for it. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/regression-testing/p2-t10-fail-before.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/regression-testing/p2-t10-fail-before.md new file mode 100644 index 000000000..b10123a8c --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/regression-testing/p2-t10-fail-before.md @@ -0,0 +1,156 @@ +# [P2-T10] Fail-before evidence for the Phase 2 regression tests + +Timestamp: 2026-09-08T01-44 + +Command: + +``` +& $vstest UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll '/InIsolation' '/Logger:trx;LogFileName=p2t10.trx' '/ResultsDirectory:TestResults\809-p2t10' '/TestCaseFilter:FullyQualifiedName~UtilitiesCS.Test.Threading.UiThreadInitApartmentContract_Tests|FullyQualifiedName~UtilitiesCS.Test.Threading.UiThreadInitRetryContract_Tests|FullyQualifiedName~UtilitiesCS.Test.Threading.SynchronizationContextAwaiter_Tests' +``` + +EXIT_CODE: 1 +ExpectedExitCode: 1 + +This task is tagged `[expect-fail]`. A failing run is the expected outcome for this task only; the formatting, analyzer and nullable gates in [P2-T9] all passed before it ran. + +## Output Summary + +``` +Total tests: 22 + Passed: 16 + Failed: 6 +Test Run Failed. +``` + +TRX selected: `p2t10.trx`, `LastWriteTimeUtc` `2026-09-08T04:44:09.3224896Z`. + +TRX `ResultSummary/Counters`: `total` 22, `executed` 22, `passed` 16, `failed` 6. + +SKIPPED_DERIVED: 0 + +Twenty-two is the count the plan derives: four in `UiThreadInitApartmentContract_Tests` from [P2-T2], six in `UiThreadInitRetryContract_Tests` from [P2-T3], [P2-T4] and [P2-T5], and twelve in `SynchronizationContextAwaiter_Tests`, being the five that existed before this delivery plus the seven [P2-T8] added. + +## One row per test method discovered in the three classes + +| Fully-qualified test | Outcome | +|---|---| +| `UiThreadInitApartmentContract_Tests.Init_OnMtaThread_ThrowsInvalidOperationExceptionNamingTheObservedApartmentState` | Failed | +| `UiThreadInitApartmentContract_Tests.Init_OnMtaThread_CapturesNoGlobalStateAndLeavesMonitoringConfigurationUnchanged` | Failed | +| `UiThreadInitApartmentContract_Tests.Init_OnStaThread_DoesNotThrowAndPopulatesAllFourCaptureFields` | Passed | +| `UiThreadInitApartmentContract_Tests.Init_ApartmentBoundaryIsStaEqualityNotMtaInequality_RejectsFromMtaAndAcceptsFromSta` | Failed | +| `UiThreadInitRetryContract_Tests.Init_WhenFirstInitializeThrows_SecondInitWithWorkingFactorySucceedsAndPopulatesAllFourCaptureFields` | Failed | +| `UiThreadInitRetryContract_Tests.Init_WhenInitializeThrows_LeavesAllFourCaptureFieldsUnset` | Passed | +| `UiThreadInitRetryContract_Tests.AutoScaleFactor_ReadFromMtaThreadAfterAFailedInit_ThrowsAndDoesNotReEnterTheFactory` | Failed | +| `UiThreadInitRetryContract_Tests.Init_CalledConcurrentlyFromTwoStaThreads_InvokesTheFactoryExactlyOnce` | Passed | +| `UiThreadInitRetryContract_Tests.Init_WithMonitorUiThreadEnabled_ConstructsAndRunsTheThreadMonitorWithTheInjectedTimeProvider` | Passed | +| `UiThreadInitRetryContract_Tests.UiSyncContext_ReadWithNullBackingFieldFromStaThread_InitializesThroughTheLazyPath` | Passed | +| `SynchronizationContextAwaiter_Tests.Constructor_NullContext_ThrowsArgumentNullException` | Passed | +| `SynchronizationContextAwaiter_Tests.IsCompleted_WhenContextIsNotCurrent_ReturnsFalse` | Passed | +| `SynchronizationContextAwaiter_Tests.IsCompleted_WhenContextMatchesCurrent_ReturnsTrue` | Passed | +| `SynchronizationContextAwaiter_Tests.GetResult_DoesNotThrow` | Passed | +| `SynchronizationContextAwaiter_Tests.OnCompleted_PostsCallbackToContext` | Passed | +| `SynchronizationContextAwaiter_Tests.IsCompleted_WhenAmbientContextIsTheCapturedInstance_ReturnsTrue` | Passed | +| `SynchronizationContextAwaiter_Tests.IsCompleted_WhenAmbientContextIsNullAndCapturedContextIsNotNull_ReturnsFalse` | Passed | +| `SynchronizationContextAwaiter_Tests.IsCompleted_WhenUiThreadIdIsTheMinusOneSentinel_ReturnsFalse` | Passed | +| `SynchronizationContextAwaiter_Tests.IsCompleted_OnOwningUiThreadWithADispatcherContextCapturedInsideAnInvoke_ReturnsTrue` | Failed | +| `SynchronizationContextAwaiter_Tests.IsCompleted_WhenTheDispatcherContextBelongsToADifferentThreadsDispatcher_ReturnsFalse` | Passed | +| `SynchronizationContextAwaiter_Tests.IsCompleted_WithAForeignWindowsFormsContextWhileUiThreadIdMatches_ReturnsFalse` | Passed | +| `SynchronizationContextAwaiter_Tests.IsCompleted_OnDefaultAwaiterOnAContextFreeThread_ReturnsTrue` | Passed | + +## The set of `Failed` rows is exactly the six the plan names + +1. `UiThreadInitApartmentContract_Tests.Init_OnMtaThread_ThrowsInvalidOperationExceptionNamingTheObservedApartmentState` — red because `Init()` carries no apartment precondition today, so it does not throw from MTA. +2. `UiThreadInitApartmentContract_Tests.Init_OnMtaThread_CapturesNoGlobalStateAndLeavesMonitoringConfigurationUnchanged` — red for the same reason: `Init()` does not throw, and the four monitoring-configuration assignments at `UtilitiesCS/Threading/UiThread.cs:26-35` run on every call ahead of the latch, so the fields do not stay at their reset values. +3. `UiThreadInitApartmentContract_Tests.Init_ApartmentBoundaryIsStaEqualityNotMtaInequality_RejectsFromMtaAndAcceptsFromSta` — red because the MTA leg records no exception; there is no boundary today. +4. `UiThreadInitRetryContract_Tests.Init_WhenFirstInitializeThrows_SecondInitWithWorkingFactorySucceedsAndPopulatesAllFourCaptureFields` — red because the latch at `UiThread.cs:36` is consumed before `Initialize()` runs, so the retry is a silent no-op and the four capture fields stay unset. +5. `UiThreadInitRetryContract_Tests.AutoScaleFactor_ReadFromMtaThreadAfterAFailedInit_ThrowsAndDoesNotReEnterTheFactory` — red because the later lazy read raises no apartment exception: `Init()` is a no-op on the consumed latch and the accessor falls back to `SizeF(1f, 1f)`. +6. `SynchronizationContextAwaiter_Tests.IsCompleted_OnOwningUiThreadWithADispatcherContextCapturedInsideAnInvoke_ReturnsTrue` — red because `UiThread.cs:100` compares contexts by reference, so a dispatcher context evaluated against a different ambient returns false. + +No further row is `Failed`, and none of the six is absent. + +## Why the remaining sixteen are already green + +Sixteen is twenty-two minus six. + +- `Init_OnStaThread_DoesNotThrowAndPopulatesAllFourCaptureFields` — `Init()` already succeeds from STA and already captures all four values through the Phase 1 factory seam. +- `Init_WhenInitializeThrows_LeavesAllFourCaptureFieldsUnset` — a throwing `Initialize()` already propagates without assigning any capture field; only the retry is broken today, not the failure itself. +- `Init_CalledConcurrentlyFromTwoStaThreads_InvokesTheFactoryExactlyOnce` — the existing `Interlocked.Exchange` latch already admits exactly one caller. The Phase 3 lock preserves that and additionally closes the C04 race the latch never covered. +- `Init_WithMonitorUiThreadEnabled_ConstructsAndRunsTheThreadMonitorWithTheInjectedTimeProvider` — the monitor branch already runs when `Initialize()` succeeds; the Phase 1 factory seam is what made it reachable from a test, and no Phase 3 change is required for it. +- `UiSyncContext_ReadWithNullBackingFieldFromStaThread_InitializesThroughTheLazyPath` — the lazy branch already works when the latch is unconsumed, which the reset scope guarantees; it was simply never exercised with a null field before. +- The five pre-existing `SynchronizationContextAwaiter_Tests` methods — unchanged behaviour, asserted before this delivery and still asserted. +- `IsCompleted_WhenAmbientContextIsTheCapturedInstance_ReturnsTrue` — the reference fast path is the current implementation. +- `IsCompleted_WhenAmbientContextIsNullAndCapturedContextIsNotNull_ReturnsFalse` — a non-null context is not reference-equal to a null ambient today either. +- `IsCompleted_WhenUiThreadIdIsTheMinusOneSentinel_ReturnsFalse` — reference inequality already returns false; the Phase 3 predicate returns false through the sentinel guard instead. +- `IsCompleted_WhenTheDispatcherContextBelongsToADifferentThreadsDispatcher_ReturnsFalse` — reference inequality already returns false; after the fix the deciding clause becomes the dispatcher comparison. +- `IsCompleted_WithAForeignWindowsFormsContextWhileUiThreadIdMatches_ReturnsFalse` — reference inequality already returns false; after the fix the deciding clause becomes the type test, which is the regression guard for the `WinFormsPumpHostTests` failure mode. +- `IsCompleted_OnDefaultAwaiterOnAContextFreeThread_ReturnsTrue` — `null == null` is true today and `ReferenceEquals(null, null)` is true after the fix, so the default-instance behaviour is unchanged. + +Every assertion in the six failing tests is synchronous, so no async boundary can swallow the failure. + +--- + +# Correction and re-measurement (recorded during [P3-T6]) + +CORRECTION_TIMESTAMP: 2026-09-08T02-05 + +The first pass recorded above is superseded in part. Reading the verbatim failure messages out of `p2t10.trx` during [P3-T6] showed that **two of the six rows failed for a reason other than the defect they were written to expose**, so the first pass is not admissible fail-before evidence for those two. The remaining four rows are unaffected and stand as recorded. + +## Verbatim failure messages from the first pass (`p2t10.trx`) + +| Fully-qualified test | Message | +|---|---| +| `Init_OnMtaThread_ThrowsInvalidOperationExceptionNamingTheObservedApartmentState` | `Expected Thread.CurrentThread.GetApartmentState() to be ApartmentState.MTA {value: 1}, but found ApartmentState.STA {value: 0}.` | +| `Init_OnMtaThread_CapturesNoGlobalStateAndLeavesMonitoringConfigurationUnchanged` | `Expected a to be thrown, but no exception was thrown.` | +| `Init_ApartmentBoundaryIsStaEqualityNotMtaInequality_RejectsFromMtaAndAcceptsFromSta` | `Expected mtaOutcome to be System.InvalidOperationException, but found .` | +| `Init_WhenFirstInitializeThrows_SecondInitWithWorkingFactorySucceedsAndPopulatesAllFourCaptureFields` | `Expected working not to be .` | +| `AutoScaleFactor_ReadFromMtaThreadAfterAFailedInit_ThrowsAndDoesNotReEnterTheFactory` | `Expected InvalidOperationException.Message to be System.InvalidOperationException, but found .` | +| `IsCompleted_OnOwningUiThreadWithADispatcherContextCapturedInsideAnInvoke_ReturnsTrue` | `Expected result to be True, but found False.` | + +## The measured environmental fact + +The first message states it directly: the MSTest worker executing the plain `[TestMethod]` cases of `UiThreadInitApartmentContract_Tests` reported `ApartmentState.STA`, not `ApartmentState.MTA`. + +Research R4 concluded that a plain `[TestMethod]` runs MTA, and `UtilitiesCS.Test/test.runsettings` does record that global STA execution is intentionally disabled. That premise does not hold for this scheduling arrangement: `UiThreadInitRetryContract_Tests` is `[STATestClass]` and both classes are `[DoNotParallelize]`, so they share one serial execution thread, and the thread that serial bucket runs on was created STA. The ambient apartment of a plain `[TestMethod]` is therefore not a reliable source of an MTA caller in this file. + +The consequence for the two affected rows: + +- `Init_OnMtaThread_ThrowsInvalidOperationExceptionNamingTheObservedApartmentState` failed on its own Arrange premise and never reached the Act, so it measured nothing about `Init()`. +- `Init_OnMtaThread_CapturesNoGlobalStateAndLeavesMonitoringConfigurationUnchanged` did reach the Act, but on an STA thread, where the AC1 precondition correctly does **not** throw. It would therefore have stayed red after the fix, for a reason unrelated to the defect. + +The four other rows were unaffected because each already drove its Act on a dedicated thread with an explicitly set apartment. + +## Remedy + +Both methods were re-authored to run the Act on a dedicated MTA thread through `ApartmentThreadRunner.RunOnThread(ApartmentState.MTA, ...)`, which is the mechanism the four unaffected rows already use and which the first pass measured as producing a genuine MTA caller. Neither method was renamed, no method was added or removed, and no assertion was weakened: each now asserts the same contract against a caller whose apartment is known rather than assumed. + +## Re-measured fail-before + +The re-measurement restored `UtilitiesCS/Threading/UiThread.cs` and `UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs` to their committed Phase 2 state at `f7294d71`, rebuilt the solution with `/t:Rebuild`, and ran the identical filter against the re-authored tests. + +Command: + +``` +& $vstest UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll '/InIsolation' '/Logger:trx;LogFileName=p2t10b.trx' '/ResultsDirectory:TestResults\809-p2t10b' '/TestCaseFilter:FullyQualifiedName~UtilitiesCS.Test.Threading.UiThreadInitApartmentContract_Tests|FullyQualifiedName~UtilitiesCS.Test.Threading.UiThreadInitRetryContract_Tests|FullyQualifiedName~UtilitiesCS.Test.Threading.SynchronizationContextAwaiter_Tests' +``` + +REMEASURED_EXIT_CODE: 1 +REMEASURED_EXPECTED_EXIT_CODE: 1 + +TRX selected: `p2t10b.trx`, `LastWriteTimeUtc` `2026-09-08T04:50:49.5639685Z`. Counters: `total` 22, `executed` 22, `passed` 16, `failed` 6. Derived skipped count: 0. + +The set of `Failed` rows is **the same six**, no more and no fewer, and every one now fails on the defect: + +| Fully-qualified test | Re-measured message | +|---|---| +| `Init_OnMtaThread_ThrowsInvalidOperationExceptionNamingTheObservedApartmentState` | `Expected observed to be System.InvalidOperationException, but found .` | +| `Init_OnMtaThread_CapturesNoGlobalStateAndLeavesMonitoringConfigurationUnchanged` | `Expected observed to be System.InvalidOperationException, but found .` | +| `Init_ApartmentBoundaryIsStaEqualityNotMtaInequality_RejectsFromMtaAndAcceptsFromSta` | `Expected mtaOutcome to be System.InvalidOperationException, but found .` | +| `Init_WhenFirstInitializeThrows_SecondInitWithWorkingFactorySucceedsAndPopulatesAllFourCaptureFields` | `Expected working not to be .` | +| `AutoScaleFactor_ReadFromMtaThreadAfterAFailedInit_ThrowsAndDoesNotReEnterTheFactory` | `Expected InvalidOperationException.Message to be System.InvalidOperationException, but found .` | +| `IsCompleted_OnOwningUiThreadWithADispatcherContextCapturedInsideAnInvoke_ReturnsTrue` | `Expected result to be True, but found False.` | + +`found ` on the first three is the AC1 defect: pre-fix, `Init()` from a genuine MTA thread returns normally instead of throwing. The remaining sixteen rows were `Passed`, unchanged from the first pass. + +`UtilitiesCS/Threading/UiThread.cs` and `UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs` were restored to their Phase 3 state immediately afterwards, the solution was rebuilt, and [P3-T6] was re-run; that run is recorded in `p3-t6-pass-after.md`. + +**This re-measurement is the fail-before evidence of record for all six tests.** The first pass is retained above rather than deleted, because it is what surfaced the environmental fact. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/regression-testing/p3-t6-pass-after.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/regression-testing/p3-t6-pass-after.md new file mode 100644 index 000000000..7aa54047e --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/regression-testing/p3-t6-pass-after.md @@ -0,0 +1,65 @@ +# [P3-T6] Pass-after evidence for the Phase 2 regression tests + +Timestamp: 2026-09-08T02-08 + +Command: identical to [P2-T10] except for the log file name and the results directory. + +``` +& $vstest UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll '/InIsolation' '/Logger:trx;LogFileName=p3t6b.trx' '/ResultsDirectory:TestResults\809-p3t6b' '/TestCaseFilter:FullyQualifiedName~UtilitiesCS.Test.Threading.UiThreadInitApartmentContract_Tests|FullyQualifiedName~UtilitiesCS.Test.Threading.UiThreadInitRetryContract_Tests|FullyQualifiedName~UtilitiesCS.Test.Threading.SynchronizationContextAwaiter_Tests' +``` + +EXIT_CODE: 0 + +## Output Summary + +``` +Test Run Successful. +Total tests: 22 + Passed: 22 +``` + +TRX selected: `p3t6b.trx`, `LastWriteTimeUtc` `2026-09-08T04:52:22.2023361Z`. + +TRX `ResultSummary/Counters`: `total` 22, `executed` 22, `passed` 22, `failed` 0. + +SKIPPED_DERIVED: 0 + +The count of rows whose outcome is `Failed` is **0**. + +## One row per test method, same table shape as the fail-before artifact + +| Fully-qualified test | Outcome | +|---|---| +| `UiThreadInitApartmentContract_Tests.Init_OnMtaThread_ThrowsInvalidOperationExceptionNamingTheObservedApartmentState` | Passed | +| `UiThreadInitApartmentContract_Tests.Init_OnMtaThread_CapturesNoGlobalStateAndLeavesMonitoringConfigurationUnchanged` | Passed | +| `UiThreadInitApartmentContract_Tests.Init_OnStaThread_DoesNotThrowAndPopulatesAllFourCaptureFields` | Passed | +| `UiThreadInitApartmentContract_Tests.Init_ApartmentBoundaryIsStaEqualityNotMtaInequality_RejectsFromMtaAndAcceptsFromSta` | Passed | +| `UiThreadInitRetryContract_Tests.Init_WhenFirstInitializeThrows_SecondInitWithWorkingFactorySucceedsAndPopulatesAllFourCaptureFields` | Passed | +| `UiThreadInitRetryContract_Tests.Init_WhenInitializeThrows_LeavesAllFourCaptureFieldsUnset` | Passed | +| `UiThreadInitRetryContract_Tests.AutoScaleFactor_ReadFromMtaThreadAfterAFailedInit_ThrowsAndDoesNotReEnterTheFactory` | Passed | +| `UiThreadInitRetryContract_Tests.Init_CalledConcurrentlyFromTwoStaThreads_InvokesTheFactoryExactlyOnce` | Passed | +| `UiThreadInitRetryContract_Tests.Init_WithMonitorUiThreadEnabled_ConstructsAndRunsTheThreadMonitorWithTheInjectedTimeProvider` | Passed | +| `UiThreadInitRetryContract_Tests.UiSyncContext_ReadWithNullBackingFieldFromStaThread_InitializesThroughTheLazyPath` | Passed | +| `SynchronizationContextAwaiter_Tests.Constructor_NullContext_ThrowsArgumentNullException` | Passed | +| `SynchronizationContextAwaiter_Tests.IsCompleted_WhenContextIsNotCurrent_ReturnsFalse` | Passed | +| `SynchronizationContextAwaiter_Tests.IsCompleted_WhenContextMatchesCurrent_ReturnsTrue` | Passed | +| `SynchronizationContextAwaiter_Tests.GetResult_DoesNotThrow` | Passed | +| `SynchronizationContextAwaiter_Tests.OnCompleted_PostsCallbackToContext` | Passed | +| `SynchronizationContextAwaiter_Tests.IsCompleted_WhenAmbientContextIsTheCapturedInstance_ReturnsTrue` | Passed | +| `SynchronizationContextAwaiter_Tests.IsCompleted_WhenAmbientContextIsNullAndCapturedContextIsNotNull_ReturnsFalse` | Passed | +| `SynchronizationContextAwaiter_Tests.IsCompleted_WhenUiThreadIdIsTheMinusOneSentinel_ReturnsFalse` | Passed | +| `SynchronizationContextAwaiter_Tests.IsCompleted_OnOwningUiThreadWithADispatcherContextCapturedInsideAnInvoke_ReturnsTrue` | Passed | +| `SynchronizationContextAwaiter_Tests.IsCompleted_WhenTheDispatcherContextBelongsToADifferentThreadsDispatcher_ReturnsFalse` | Passed | +| `SynchronizationContextAwaiter_Tests.IsCompleted_WithAForeignWindowsFormsContextWhileUiThreadIdMatches_ReturnsFalse` | Passed | +| `SynchronizationContextAwaiter_Tests.IsCompleted_OnDefaultAwaiterOnAContextFreeThread_ReturnsTrue` | Passed | + +## The six fail-before rows are each present with outcome `Passed` + +1. `UiThreadInitApartmentContract_Tests.Init_OnMtaThread_ThrowsInvalidOperationExceptionNamingTheObservedApartmentState` — Passed +2. `UiThreadInitApartmentContract_Tests.Init_OnMtaThread_CapturesNoGlobalStateAndLeavesMonitoringConfigurationUnchanged` — Passed +3. `UiThreadInitApartmentContract_Tests.Init_ApartmentBoundaryIsStaEqualityNotMtaInequality_RejectsFromMtaAndAcceptsFromSta` — Passed +4. `UiThreadInitRetryContract_Tests.Init_WhenFirstInitializeThrows_SecondInitWithWorkingFactorySucceedsAndPopulatesAllFourCaptureFields` — Passed +5. `UiThreadInitRetryContract_Tests.AutoScaleFactor_ReadFromMtaThreadAfterAFailedInit_ThrowsAndDoesNotReEnterTheFactory` — Passed +6. `SynchronizationContextAwaiter_Tests.IsCompleted_OnOwningUiThreadWithADispatcherContextCapturedInsideAnInvoke_ReturnsTrue` — Passed + +The fail-before evidence of record for all six is the re-measured pass recorded under the heading `Correction and re-measurement (recorded during [P3-T6])` in `p2-t10-fail-before.md`. An earlier attempt at this task, logged as `p3t6.trx`, reported 20 passed and 2 failed; the two failures were the defective ambient-apartment premise in the two `Init_OnMtaThread_...` methods, not a failure of the fix, and that finding is what drove the correction recorded there. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/feature-audit.2026-09-08T01-35.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/feature-audit.2026-09-08T01-35.md new file mode 100644 index 000000000..040f3f583 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/feature-audit.2026-09-08T01-35.md @@ -0,0 +1,122 @@ +# Feature Audit — issue #809, `uithread-init-contract-residuals-784-787-788` + +- Artifact timestamp: 2026-09-08T01-35 +- Work mode: `full-bug` (marker at `issue.md:12`). **`spec.md` is the sole acceptance-criteria source.** + `user-story.md` is intentionally absent; `issue.md` carries a mirrored AC1-AC4 block that is not the + authoritative source and was not used to derive verdicts. +- Baseline: `04a54e681bd21e841e124c016df30672ee701b75`. Head: `ef431e6a`. +- Plan: `plan.2026-09-07T20-14.md`, 7 phases, 72 tasks, 72 checked, 0 unchecked (verified by count). + +**Blocking findings: 0.** + +## AC evaluation table + +| AC | Verdict | Basis | +|---|---|---| +| AC1 | PASS | Precondition present as the first statement of `Init()` at `UtilitiesCS/Threading/UiThread.cs:30-34`, ahead of the four monitoring assignments at `:36-45` and of `lock (InitLock)` at `:51`. Message constant at `:230-234`. Three tests assert it from a dedicated MTA thread, all red before and green after. The MTA caller at `QfcHomeControllerRunAsyncTests.cs:329` is removed and replaced with a pumping-dispatcher transaction; that test passes twice in `p4-t3-quickfiler-tests.md`. | +| AC2 | PASS | `_initialized` is set at `UiThread.cs:58`, after `Initialize()` returns at `:57`; a throw propagates with the flag false. `Init_WhenFirstInitializeThrows_SecondInitWithWorkingFactorySucceedsAndPopulatesAllFourCaptureFields` was red before (`Expected working not to be `) and is green after. The "#782 regression scenario reproduced as a test" clause is discharged by the forced-throw scenario plus the invocation-count anti-storm test, with the substitution reasoned in `p6-t4`. The reviewer verified the underlying design argument structurally against the head tree (see code review §3). The `lock` additionally closes the pre-existing C04 race, covered by the two-racer test. | +| AC3 | PASS | Predicate replaced at `UiThread.cs:155-190`. `IsCompleted_OnOwningUiThreadWithADispatcherContextCapturedInsideAnInvoke_ReturnsTrue` is the defect test: red before (`Expected result to be True, but found False`), green after. Six sibling cases pin the false paths, including the foreign-WinForms-context guard. Ordering-sensitive callers still pass: both `WinFormsPumpHostTests` marshal tests and `EfcFormControllerTests.ActionDeleteAsync_AwaitedTwice_...`, each recorded green in two passes. The predicate honours the `BreadcrumbUiDispatcher.cs:263-272` constraint — see code review §2. | +| AC4 | PASS | 17 tests added; discovered total moves 7120 → 7137, difference 0 against expectation. Seams are `SyncContextFormFactory` and `ResetForTesting()`, wrapped by `UiThreadStateScope`. STA/MTA rejection, retry-after-throw, and inline-vs-post are each covered. No live Outlook host: zero `Microsoft.Office.Interop` references and zero `TestCategory` attributes in the three touched test files, and `NoLiveFormInTestAssemblyTests` passes, so no new `Form`-derived type exists in `UtilitiesCS.Test`. | +| AC5 | **PARTIAL** | The second and third clauses are met: `p5-t6-tryaddvalues-rep1/2/3.md` record `REPETITIONS_PASSED: 3` and the full-suite row for `TryAddValuesAsync_UpdatesExistingValue` is `Passed`, so no single failure is attributed to this delivery. The first clause is not established. `evidence/other/p0-t15-mta-synccontextform-measurement.md:42` labels the run `MTA` by inference from research R4, and `p2-t10-fail-before.md` records the direct measurement that falsifies that premise. The reviewer's analysis (code review §3) is that the run most likely executed STA, in which case no MTA measurement was taken and the stated refutation of the #782 narrative in `p6-t4-ac2-regression-reconciliation.md` is unsupported. Non-blocking: the AC2 design and its tests do not depend on the value, which decision D5 required and the reviewer verified structurally. Remedy is an artifact correction, not code or test rework. | +| AC6 | **PARTIAL** | Every measurable clause is met and was independently recomputed by the reviewer from the raw Cobertura documents rather than read from the artifacts: `UiThread.cs` line coverage 121/126 = **96.03%**, above the 80% `CLAUDE.md` floor and above the 76.83% baseline (63/82, also reviewer-recomputed); each newly added member at or above 90%, worst row `SynchronizationContextAwaiter.IsCompleted` at 18/20 = **90.00%**; changed-line coverage 46/48 = 95.83%; no changed line loses coverage, since every changed line is added or deleted and the added set is measured. Two literal clauses are unmet: the report was produced by `dotnet-coverage collect` rather than `vstest.console.exe ... /EnableCodeCoverage`, and the raw report is git-ignored under `coverage/` rather than stored under `evidence/qa-gates/`, where three derived markdown artifacts are stored instead. See the adjudication below. Non-blocking. | + +Legend: PASS = delivered and verified; PARTIAL = substance delivered with a stated clause unmet; +FAIL = not delivered; UNVERIFIED = evidence unavailable. No AC is FAIL or UNVERIFIED. + +## Adjudication of the AC6 collector substitution + +The substitution is **legitimate as an instrumentation route** and makes AC6 unmet **only as literally +worded**. Reasoning: + +- The reason recorded is verifiable in the tree. `scripts/vscode/TaskMaster.cli.runsettings` carries + only an `` block and no data collector, and + `scripts/vscode/Invoke-MSTestWithCoverage.ps1:19-26` states that the omission is deliberate because + the outer `dotnet-coverage` instrumentation and the built-in Code Coverage collector conflict. The + reviewer read both files. +- `dotnet-coverage` and `/EnableCodeCoverage` drive the same Microsoft coverage engine. The substitution + changes the invocation shape and the output format, not the measurement, and it yields Cobertura + directly rather than a `.coverage` binary requiring a later conversion step. The delivery therefore + produced strictly more auditable evidence than the literal command would have. +- The substitution was declared as `AC6-COLLECTOR-SUBSTITUTION` in two artifacts rather than adopted + silently, which is the behaviour the policy expects when a named command cannot be used as written. +- Note that `CLAUDE.md` CUT3 step 4 also names `/EnableCodeCoverage`, so the deviation is from the + policy's toolchain wording as well as from the AC's. It is the same deviation, not two. + +On the storage clause: the raw 18 MB Cobertura documents are deliberately kept out of git +(`p6-t12-untracked-output-check.md` shows `coverage/` holds only a tracked `.gitkeep`, and +`DELIVERY_ADDED_RESULTS_FILE_COUNT: 0`). That is the better engineering choice — committing them would +leave unreachable multi-megabyte blobs in history — but it means the artifact AC6 names is not where +AC6 says it will be. What is under `evidence/qa-gates/` is `p6-t1`, `p6-t2` and `p6-t3`, which record +the derived figures with their counting method pinned. The reviewer was able to reproduce every figure +from the ignored documents, so nothing is unverifiable in practice. + +**Recommendation:** the maintainer may reasonably elect to treat AC6 as satisfied and record the +deviation, in which case the verdict becomes PASS. This review does not recommend remediation. What it +does recommend is reconciling the AC wording (and `CLAUDE.md` CUT3 step 4) with the collection route the +repository actually uses, so the next delivery does not have to declare the same substitution. + +## Baseline comparison + +| Quantity | Baseline | Head | Source of the head figure | +|---|---|---|---| +| `UiThread.cs` line coverage | 76.83% (63/82) | 96.03% (121/126) | Reviewer-recomputed from both Cobertura documents | +| `UiThread.cs` uncovered lines | 19 | 5 (`38,39,40,177,178`) | Reviewer-recomputed; matches the artifacts exactly | +| First-party line coverage | 84.58% | 84.62% | Reviewer-recomputed: 56248/66471 | +| First-party branch coverage | 79.34% | 79.38% reported; 77.03% reviewer-recomputed | Method difference explained in policy audit F7; both clear 75% | +| Discovered tests | 7120 | 7137 (+17) | `p5-t5`, reconciles to the 17 added methods counted in the patch | +| Test failures | 0 | 0 | `p5-t5` TRX counters | +| csharpier checked files | 1608 | 1611 (+3) | `p5-t2`, reconciles to the three new files | +| msbuild projects | 18 | 18 | `p5-t3` | +| `FolderPredictorTests.cs` lines | 1066 | 1067 | Reviewer-verified with `awk 'END{print NR}'` | + +Coverage did not regress on any measured axis. The delivery closes 17 of the 19 baseline-uncovered +lines in the file in scope and adds no new uncovered construct other than the two-line predicate arm +discussed in the code review. + +## Scope conformance + +The head tree matches the Write Set in `spec.md` exactly: four production files (three source plus one +`.csproj`) and eight test files (six source plus one `.csproj`, plus the three one-attribute +additions the Write Set amendment adds). No file outside the Write Set was touched. The declared +non-goals were all honoured: `ThreadSafeSingleShotGuard` is retained and only `UiThread`'s own use of it +is removed; no `IUiDispatcher` routing was added to `QfcHomeController`; no `InternalsVisibleTo` grant +was added to `QuickFiler.Test`; no `.runsettings` apartment setting was changed; +`Dispatcher_WhenBackingFieldIsNull_ThrowsInvalidOperationExceptionNamingInitialize` keeps its +deliberately inaccurate name; and the reflected field names `_uiSyncContext` and `_dispatcher` are +unchanged. + +## Residual risks carried forward (not defects in this delivery) + +1. Eleven production await sites can change execution ordering under the new predicate, and no test + asserts ordering at any of them. Recorded as residual by the delivery, which is correct. Live-host + verification is explicitly not an acceptance criterion. +2. Four production sites gain a possible new throw. All are unreachable in production once + `ThisAddIn.cs:35` has run on the Outlook STA; at `AppOlObjects.cs:367` the new throw is an + improvement on the current behaviour. +3. The stale-`_uiThreadId` residual in the second true branch of `IsCompleted` (code review §2), + unreachable in production. +4. `FolderPredictorTests.cs` remains 567 lines over the 500-line limit. + +## AC check-off handling + +All six criteria are already `- [x]` in `spec.md` and AC1-AC4 are mirrored `- [x]` in `issue.md`. Per +the reviewer's instructions for this run, **no checkbox was modified**. Under +`.claude/skills/acceptance-criteria-tracking`, a PARTIAL verdict would ordinarily leave the item +unchecked, so the check-off state of AC5 and AC6 is reported here for the maintainer to adjudicate +rather than changed unilaterally. The reviewer's position is that AC6 is a wording reconciliation and +AC5 is an artifact correction; neither warrants unchecking if the corrections in the code review's +recommendations 1 and 2 are made. + +### Acceptance Criteria Status + +- Source: `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/spec.md` +- Total AC items: 6 +- Checked off (delivered): 6 +- Remaining (unchecked): 0 +- Items remaining: none. Two checked items are graded PARTIAL by this review — AC5 (the MTA measurement + clause is not established) and AC6 (collector and storage-location wording) — and both are + non-blocking. + +## Verdict + +**PASS. 0 blocking findings. No remediation cycle is required.** diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/issue.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/issue.md new file mode 100644 index 000000000..32af77d51 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/issue.md @@ -0,0 +1,80 @@ +# uithread-init-contract-residuals-784-787-788 (Issue #809) + +- Date captured: 2026-09-07 +- Author: Dan Moisan +- Status: Promoted -> docs/features/active/uithread-init-contract-residuals-784-787-788/ (Issue #809) + +> Automation note: Keep the section headings below unchanged; the promotion tooling maps each of them into the GitHub bug issue template. + +- Issue: #809 +- Issue URL: https://github.com/drmoisan/TaskMaster/issues/809 +- Last Updated: 2026-09-08 +- Work Mode: full-bug + +## Summary + +Consolidates three findings on one file, `UtilitiesCS/Threading/UiThread.cs`, that were filed separately as #784, #787, and #788 after the #781 and #782 reviews. (1) `Init()` accepts a non-STA caller and installs that worker's non-pumping dispatcher and context into set-once process-global state (#787). (2) `Init()` consumes its single-shot latch before `Initialize()` runs, so a failed first attempt can never be retried, and the naive re-arm was measured to regress in #782 (#788). (3) `SynchronizationContextAwaiter.IsCompleted` compares contexts by reference, so any context captured inside a WPF dispatcher operation always posts instead of continuing inline on the UI thread (#784). All three touch the same initialization and awaiter code and should ship as one change with one test suite. + +## Environment + +- OS/version: Windows 11 Pro 10.0.26200 +- Runtime: .NET Framework 4.8 VSTO add-in hosted by Outlook desktop; `main` at `04a54e68` +- Command/flags used: `vstest.console.exe /InIsolation`; runtime probe in the #781 feature folder (`evidence/other/dispatcher-synccontext-probe.2026-09-05T10-40.md`) +- Data source or fixture: `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs:329` (MTA caller of `UiThread.Init(false)`) + +## Steps to Reproduce + +1. #787: call `UiThread.Init(false)` from an MTA thread (the in-repo instance is the test at `QfcHomeControllerRunAsyncTests.cs:329`). It returns normally and every later `UiThread.Dispatcher` / `UiSyncContext` / `UiThreadId` read marshals onto a thread with no message loop. +2. #788: arrange for `Initialize()` to throw (headless or non-STA), call `Init()`, fix the condition, call `Init()` again. The second call is a no-op because `_loaded.CheckAndSetFirstCall` at `UiThread.cs:36` was consumed before `Initialize()` ran. +3. #784: construct an `ItemViewer` through `ItemViewerQueue.Dequeue` (inside `UiThread.Dispatcher.Invoke`, so `UiSyncContext` is a `DispatcherSynchronizationContext`), then on the UI thread evaluate `viewer.UiSyncContext.GetAwaiter().IsCompleted`. It is `false`, so the continuation posts instead of running inline. + +## Expected Behavior + +- `Init()` rejects a non-STA caller with a named `InvalidOperationException` before capturing anything. +- A failed `Initialize()` leaves the latch re-armed so a later `Init()` retries, without reintroducing the regression #782 measured. +- `IsCompleted` is true when the caller already runs on the owning UI thread, regardless of which `SynchronizationContext` instance is ambient. + +## Actual Behavior + +See the three reproduction steps. #787 succeeds silently and poisons the globals for the process lifetime; #788 leaves `UiThread.Dispatcher` throwing an exception that names `Init()` as the remedy while `Init()` is a no-op; #784 adds one queued hop per await and changes ordering relative to already-queued UI work. + +## Logs / Screenshots + +- [x] Attached minimal logs or screenshot +- Snippet: `UtilitiesCS/Threading/UiThread.cs` line 100 (verified 2026-09-05): `public bool IsCompleted => _context == SynchronizationContext.Current;` (reference comparison). Probe result: `Invoke ctx == outer ambient : False` on .NET Framework 4.8 STA. #787 and #788 are missing-precondition and ordering defects with no diagnostic output. + +## Impact / Severity + +- [ ] Blocker +- [ ] High +- [x] Medium +- [ ] Low + +Medium, carried from #787: in production `ThisAddIn.cs:35-40` is the only `Init()` caller and runs on the Outlook STA, so the hazards are reachable today only from test code, but a worker-thread read of the lazy accessors before startup completes would poison the process. #784 and #788 are Low individually. + +## Suspected Cause / Notes + +- #787: no `Thread.CurrentThread.GetApartmentState() == ApartmentState.STA` check in `Init()` or `Initialize()`; `CaptureUiVariables()` reads `SynchronizationContext.Current`, `AutoScaleFactor`, `Dispatcher.CurrentDispatcher`, and the managed thread id from the caller unconditionally. +- #788: latch consumed at `UiThread.cs:36` before `Initialize()`; the naive fix (re-arm on throw) was applied and withdrawn in #782 after measuring a reproducible regression. Read the #782 feature folder before choosing the fix. +- #784: reference equality on `SynchronizationContext` is too strict for a context captured inside a dispatcher operation. CORRECTION (2026-09-07, recorded during preparation): an earlier draft of this note proposed bare owning-thread identity (`UiThread.UiThreadId == Thread.CurrentThread.ManagedThreadId`) and attributed it to #781. That attribution is wrong and the predicate is unsafe. `QuickFiler/Viewers/BreadcrumbUiDispatcher.cs:263-272` records the opposite rule: when a context was captured it is the authoritative boundary, and bare owner-thread identity must never substitute, because a continuation resumed after `ConfigureAwait(false)` can be scheduled onto a recycled thread-pool thread whose managed id equals the owner's. A bare-id predicate would also break `QuickFiler.Test/TestSupport/WinFormsPumpHostTests.cs:183-199`, which awaits a foreign `WindowsFormsSynchronizationContext` from the MSTest thread and asserts the continuation lands on the pump thread. The correct predicate keeps reference equality as its fast path and admits only contexts that are demonstrably UI-owned while the caller stands on the owning UI thread; see `research/research.2026-09-07T20-20.md` section R6. Note that the GitHub issue body for #809 still carries the original incorrect sentence. +- Superseded issues: #784, #787, #788 (close with a pointer to this issue). + +## Acceptance Criteria + +- [x] AC1: `Init()` throws a named `InvalidOperationException` when called from a non-STA thread, before any global is captured; the MTA test caller at `QfcHomeControllerRunAsyncTests.cs:329` is corrected or given an STA host. +- [x] AC2: A failed `Initialize()` does not consume the latch; a subsequent `Init()` retries and succeeds. The #782 regression scenario is reproduced as a test and passes with the chosen design. +- [x] AC3: `SynchronizationContextAwaiter.IsCompleted` returns true on the owning UI thread regardless of ambient context instance, and false elsewhere; ordering-sensitive callers in `ItemViewer` and `EfcFormController` still pass their existing tests. +- [x] AC4: Unit tests cover STA/MTA rejection, latch re-arm after throw, and awaiter inline-vs-post decisions with a fake dispatcher seam; no real Outlook host. + +## Proposed Fix / Validation Ideas + +Validation: + +- [ ] Unit coverage areas: `UiThread.Init`, `Initialize`, `CaptureUiVariables`, `SynchronizationContextAwaiter`. +- [ ] Integration scenario to retest: full nine-assembly `/InIsolation` run; QuickFiler launch, item load, and breadcrumb open on a live host. +- [ ] Manual verification notes: no change in observable UI behavior; verify no new keyboard-focus regressions after #677/#796. + +## Next Step + +- [ ] Promote to GitHub issue (bug-report template) +- [ ] Move to active fix folder / branch diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/plan.2026-09-07T20-14.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/plan.2026-09-07T20-14.md new file mode 100644 index 000000000..1db6a033a --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/plan.2026-09-07T20-14.md @@ -0,0 +1,311 @@ +# 2026-09-07-uithread-init-contract-residuals-784-787-788 (Plan) + +- **Issue:** #809 +- **Parent (optional):** none +- **Owner:** drmoisan +- **Last Updated:** 2026-09-07T23-58 +- **Status:** Ready for preflight (revision rounds 1 through 4 applied) +- **Version:** 1.0 +- **Work Mode:** full-bug — `spec.md` is the sole acceptance-criteria source (AC1 through AC6) +- **Spec of record:** `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/spec.md` +- **Research of record:** `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/research/research.2026-09-07T20-20.md` + +**Fail-closed evidence rule:** Every baseline artifact task, final-QA artifact task, and coverage-comparison task below is mandatory. If any required baseline artifact, QA artifact, or coverage-comparison artifact is missing or is missing a required field, the audit verdict must be BLOCKED or INCOMPLETE, never PASS. + +**Evidence accounting rule:** Every evidence-producing task names its artifact path. Do not check a task off without its artifact. Every command-step artifact carries `Timestamp:`, `Command:`, `EXIT_CODE:` and `Output Summary:`. Baseline and final-QC test artifacts additionally carry numeric coverage headline values. + +**Evidence location (non-overridable):** every evidence path below resolves under `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence//` where `` is one of `baseline`, `regression-testing`, `qa-gates`, `issue-updates`, `other`, `remediation-baseline`. `evidence/coverage/` is not canonical; coverage output is recorded under `evidence/qa-gates/`. Nothing is written under `artifacts/`. + +**Path abbreviation used below.** `FEATURE` denotes `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809`. Every artifact path written as `FEATURE/evidence/...` is that literal directory prefix followed by the stated remainder. This abbreviation is a reading convenience only; the executor writes the fully expanded repository-relative path. + +**Diff anchors.** Source-file diffs are anchored to `pre-809-base`, the tag [P0-T3] creates at the merge-base of `origin/main` and `HEAD`. No file in the production or test Write Set changed between that base and `HEAD`, so a two-dot diff against the tag isolates this delivery's edits exactly. The feature-folder documents are the exception: `spec.md`, `issue.md`, the research artifact and this plan were all committed after that base, so a diff against the tag would render each as a wholly added file. Diffs over those four paths are anchored to `HEAD` instead. + +--- + +## Standing execution conventions + +These conventions apply to every command-bearing task and are stated once rather than repeated. + +**SDK preamble.** Every `dotnet` invocation is preceded, in the same shell statement group, by: + +```powershell +$env:DOTNET_ROOT = (Resolve-Path '.dotnet-sdk').Path +$env:PATH = "$env:DOTNET_ROOT;$env:PATH" +``` + +`global.json` pins `sdk.version` `8.0.205` with `paths` `[".dotnet-sdk", "$host$"]`, so the host SDK alone cannot satisfy the pin. + +**MSBuild resolution.** `msbuild` is not assumed to be on `PATH` in a fresh worktree. Every MSBuild invocation resolves the executable first, using the same resolution `scripts/vscode/Invoke-Restore.ps1:22-30` uses: + +```powershell +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +$msbuild = & $vswhere -latest -requires Microsoft.Component.MSBuild -find 'MSBuild\**\Bin\MSBuild.exe' | + Select-Object -First 1 +``` + +The argument list that follows `$msbuild` is character-for-character the argument list `CLAUDE.md` states. Only the executable token is resolved by path. + +**vstest resolution.** Every vstest invocation resolves the executable with: + +```powershell +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +$vstest = & $vswhere -latest -products * -find Common7\IDE\Extensions\TestPlatform\vstest.console.exe | + Select-Object -First 1 +``` + +**`/t:Rebuild`, never `/t:Build`.** MSBuild's up-to-date check does not invalidate on a command-line `/p:` change, so a warm `/t:Build` returns exit 0 with `CoreCompile` skipped on every project and runs no analyzers and no nullable analysis. A `/t:Build` result is not admissible evidence for either build gate in this plan. + +**`/p:Nullable=enable` is never added.** No project in this repository carries a `` element and there is no `Directory.Build.props`, so the property is a solution-wide opt-in that conscripts every file that has never adopted the pragma. CI omits it deliberately. + +**`dotnet format` is never used.** Formatting is CSharpier only, always through `dotnet tool run` so the manifest-pinned 1.2.6 is used. + +**Semicolon quoting.** `/Blame:` and `/Logger:` switch values contain `;`, which PowerShell treats as a statement separator. Every such switch is written inside single quotes. + +**Nine-assembly test-assembly list.** Every full-suite run uses exactly these nine paths, in this order: + +```text +QuickFiler.Test\bin\Debug\QuickFiler.Test.dll +SVGControl.Test\bin\Debug\SVGControl.Test.dll +Tags.Test\bin\Debug\Tags.Test.dll +TaskMaster.Test\bin\Debug\TaskMaster.Test.dll +TaskTree.Test\bin\Debug\TaskTree.Test.dll +TaskVisualization.Test\bin\Debug\TaskVisualization.Test.dll +ToDoModel.Test\bin\Debug\ToDoModel.Test.dll +UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll +VBFunctions.Test\bin\Debug\VBFunctions.Test.dll +``` + +**Skipped-count derivation.** The vstest TRX logger populates only the `total`, `executed`, `passed` and `failed` attributes of `ResultSummary/Counters`. Every other counter attribute, `notExecuted` included, is written as `0` regardless of what the run did, so an acceptance condition asserting that `notExecuted` is `0` returns the same result however the run behaved. Every task below that records a skipped count records instead a line `SKIPPED_DERIVED:` whose value is the TRX `total` attribute minus the TRX `executed` attribute, and asserts that derived value. The console is not the source either: on an all-green run vstest prints `Test Run Successful.`, `Total tests:`, `Passed:` and `Total time:` and prints no `Failed:` line and no `Skipped:` line at all. + +**Full-suite switch set.** Every full-suite run additionally passes, each token quoted as written: + +```text +'/Settings:scripts\vscode\TaskMaster.cli.runsettings' +'/InIsolation' +'/Logger:trx;LogFileName=.trx' +'/ResultsDirectory:TestResults\809-' +'/Blame:CollectHangDump;TestTimeout=5min;HangDumpType=None' +'/TestCaseFilter:TestCategory!=LiveOutlook&FullyQualifiedName!~HelperClasses.ShellUtilities_Tests&FullyQualifiedName!~HelperClasses.ShellUtilitiesStatic_Tests&FullyQualifiedName!~HelperClasses.SysImageListHelperTests&FullyQualifiedName!~EmailIntelligence.OSBrowser_Tests' +``` + +The four excluded classes issue `SHGetFileInfo` with `SHGFI_ICON`, which stalls process-wide on this workstation and hangs the test host. The stall reproduces against `origin/main`, so it is environmental and CI covers those classes. `TestResults/` matches the `[Tt]est[Rr]esult*/` entry at `.gitignore:39`, so no `.trx` written under it can enter the tree; the `runUser` and `computerName` attributes a `.trx` carries therefore never reach a tracked path. + +**TRX selection under a re-run.** vstest never overwrites a results file, so re-running a task into its own `/ResultsDirectory:` leaves more than one `.trx` there. Every task that reads a TRX reads the most recently modified `.trx` under that task's results directory, selected by `Get-ChildItem -Path -Filter *.trx | Sort-Object LastWriteTimeUtc | Select-Object -Last 1`, and records the selected file name and its `LastWriteTimeUtc` in the artifact. Where Phase 5 appends a new numbered pass section, each pass section records the file it selected. + +**Coverage collector.** Coverage is collected by `dotnet-coverage collect ... -- ...` and `/EnableCodeCoverage` is not passed. `scripts/vscode/TaskMaster.cli.runsettings` carries no data collector, and `scripts/vscode/Invoke-MSTestWithCoverage.ps1:19-26` records that the omission is deliberate because the outer `dotnet-coverage` instrumentation and the built-in Code Coverage collector conflict. This is a stated substitution against the literal wording of AC6 and is recorded as such in [P6-T10], which is the AC6 check-off task. [P6-T9] checks off AC5 and carries no collector record. + +**Results directories are created before use.** `TestResults/` does not exist in a freshly created worktree; it is absent by design, matching `[Tt]est[Rr]esult*/` at `.gitignore:39`. `coverage/` does exist, because `.gitignore:144` ignores only the directory's contents through `coverage/*` while `.gitignore:145` exempts `coverage/.gitkeep`, which is tracked and is therefore materialised by `git worktree add`; the directory is present but empty of coverage output. Every task that writes into either directory first runs `New-Item -ItemType Directory -Force -Path coverage` or `New-Item -ItemType Directory -Force -Path `. `-Force` makes the call idempotent, so it neither fails on the directory that already exists nor clears the contents of one that does. + +**Coverage counting method (pinned; every coverage task in this plan reproduces it).** Cobertura `` elements produced by `dotnet-coverage` carry `line-rate` and `branch-rate` but carry no `lines-covered`, `lines-valid`, `branches-covered` or `branches-valid` attributes, so those four figures are aggregated from `` elements and the denominator depends entirely on the selection. **The selection is the all-descendant `.//line` selection over each first-party ``, and only that one.** The two narrower selections `classes/class/lines/line` and `classes/class/methods/method/lines/line` are rejected by name and must not be substituted. A `` counts as covered when its `hits` attribute is greater than zero. Branch figures are summed from the `(numerator/denominator)` pair inside each `condition-coverage` attribute over the same line set. The first-party allowlist is the nine production assembly names `Tags`, `ToDoModel`, `TaskVisualization`, `UtilitiesCS`, `QuickFiler`, `TaskTree`, `TaskMaster`, `SVGControl`, `VBFunctions`. The aggregation snippet is: + +```powershell +$doc = New-Object System.Xml.XmlDocument +$doc.Load((Resolve-Path -LiteralPath $CoberturaPath).Path) +$firstParty = @('Tags','ToDoModel','TaskVisualization','UtilitiesCS','QuickFiler','TaskTree','TaskMaster','SVGControl','VBFunctions') +$lc = 0; $lv = 0; $bc = 0; $bv = 0 +foreach ($pkg in $doc.SelectNodes('/coverage/packages/package')) { + if ($firstParty -notcontains $pkg.GetAttribute('name')) { continue } + foreach ($ln in $pkg.SelectNodes('.//line')) { + $lv++ + $h = $ln.GetAttribute('hits') + if ($h -and [int]$h -gt 0) { $lc++ } + $cc = $ln.GetAttribute('condition-coverage') + if ($cc -and $cc -match '\((\d+)/(\d+)\)') { $bc += [int]$Matches[1]; $bv += [int]$Matches[2] } + } +} +"LINES_COVERED=$lc LINES_VALID=$lv BRANCHES_COVERED=$bc BRANCHES_VALID=$bv" +``` + +`GetAttribute` is used rather than property access so a `` lacking an attribute yields an empty string instead of throwing under `Set-StrictMode`. + +**Per-file coverage lookup (pinned).** The `filename` attribute in this document carries an absolute path with backslash separators. A forward-slash match returns zero rows. Every per-file lookup therefore selects `` elements whose `filename` attribute ends with the backslash suffix `UtilitiesCS\Threading\UiThread.cs`, takes `.//line` under each, counts each line number once, and treats a line number as covered when any matching element for that file carries `hits` greater than zero. A line number that matches no element is not executable and is excluded from both numerator and denominator. + +**Line-count idiom (pinned; every line-count task in this plan reproduces it).** A file's line count is `(Get-Content -LiteralPath ).Count`, which counts physical lines. **The idiom `Get-Content -LiteralPath | Measure-Object -Line` is rejected by name and must not be substituted.** `Measure-Object -Line` splits its input with `RemoveEmptyEntries` and therefore counts non-blank lines only, understating a file by exactly its blank-line count. `docs/features/archive/2026-08-08-ribbon-engine-toggle-state-guards-505/evidence/qa-gates/file-size-audit.2026-08-08T21-34.md` records both measurements side by side for an earlier delivery in this repository and states that the 500-line cap is evaluated against the physical count; the largest gap in that table is `TaskMaster.Test/Ribbon/EngineToggleStateCoordinatorTests.cs` at 392 non-blank against 459 physical. The 500-line limit in `.claude/rules/general-code-change.md` and the 495-line ceiling in [P2-T9] are physical-line limits, so a gate measured with the rejected idiom can report zero overruns on a file that is sixty or more lines over the cap. Baseline and post-change counts use this one idiom throughout, so every before-and-after comparison in this plan is commensurable. + +**Write Set (from `spec.md`; no other repository file is modified or added by an implementation task).** + +Production: `UtilitiesCS/Threading/UiThread.cs`; `UtilitiesCS/Threading/IUiCaptureSource.cs` (new); `UtilitiesCS/Threading/SyncContextForm.cs`; `UtilitiesCS/UtilitiesCS.csproj`. + +Test: `UtilitiesCS.Test/Threading/UiThread_Tests.cs`; `UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs` (new); `UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs` (new); `UtilitiesCS.Test/UtilitiesCS.Test.csproj`; `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs`; `UtilitiesCS.Test/EmailIntelligence/FolderRemapViewer_Tests.cs`; `UtilitiesCS.Test/EmailIntelligence/FilterOlFoldersViewer_Tests.cs`; `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs`. The three additional test files are amended by a single attribute line each, for the reason stated in [P2-T7]. + +Documentation and evidence: this plan file, `spec.md`, `issue.md`, and artifacts under `FEATURE/evidence/`. + +**Settled decisions carried from `spec.md`.** D1 `Init()` rejects any apartment state that is not `ApartmentState.STA`. D2 `ThreadSafeSingleShotGuard` is retained as a type; 23 files in this tree reference it. D3 no new `InternalsVisibleTo` grant. D4 the MTA caller is reconciled by removing `UiThread.Init(false)` and installing a pumping dispatcher. D5 the #782 narrative is measured, not assumed. D6 the census of record is 56 live sites across 31 production files. + +--- + +### Phase 0 — Baseline, bootstrap and policy reads + +- [x] [P0-T1] Read the policy documents in the order `.claude/skills/policy-compliance-order/SKILL.md` states — `CLAUDE.md`, then `.claude/rules/general-code-change.md`, then `.claude/rules/general-unit-test.md`, then `.claude/rules/csharp.md` — and additionally `.claude/rules/quality-tiers.md`, `.claude/rules/tonality.md`, `.claude/rules/plan-acceptance-gates.md`, `.claude/skills/atomic-plan-contract/SKILL.md`, `.claude/skills/evidence-and-timestamp-conventions/SKILL.md` and `.claude/skills/acceptance-criteria-tracking/SKILL.md`. Acceptance: `FEATURE/evidence/baseline/phase0-instructions-read.md` exists and carries `Timestamp:`, `Policy Order:` naming the four ordered documents, and an explicit bulleted list naming all ten files read. + +- [x] [P0-T2] Read the three requirements documents in full — `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/spec.md`, `.../issue.md`, `.../research/research.2026-09-07T20-20.md` — and record the acceptance-criteria inventory. Acceptance: `FEATURE/evidence/baseline/p0-t2-requirements-read.md` exists, carries `Timestamp:`, records that `spec.md` is the sole acceptance-criteria source under work mode `full-bug`, and lists exactly six identifiers `AC1 AC2 AC3 AC4 AC5 AC6` each with the first twelve words of its clause quoted from the `## Acceptance Criteria` section of `spec.md`. + +- [x] [P0-T3] Anchor the base commit by creating the tag `pre-809-base` at `git merge-base origin/main HEAD`, so every later diff in this plan has an explicit ref operand. Command: `git merge-base origin/main HEAD`, then `git tag pre-809-base `, then `git rev-parse pre-809-base`. Acceptance: `FEATURE/evidence/baseline/p0-t3-base-ref.md` exists with `Timestamp:`, `Command:`, `EXIT_CODE: 0`, `Output Summary:`; the artifact carries a line `BASE_TAG: pre-809-base` and a line `BASE_SHA:` followed by the 40-character output of `git rev-parse pre-809-base`; and `git rev-parse --verify pre-809-base` exits 0; and the artifact carries a line `INHERITED_WRITE_SET_PATHS:` followed by the output of `git diff --name-only pre-809-base HEAD -- UtilitiesCS/Threading/UiThread.cs UtilitiesCS/Threading/SyncContextForm.cs UtilitiesCS/UtilitiesCS.csproj UtilitiesCS.Test/Threading/UiThread_Tests.cs UtilitiesCS.Test/UtilitiesCS.Test.csproj UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs UtilitiesCS.Test/EmailIntelligence/FolderRemapViewer_Tests.cs UtilitiesCS.Test/EmailIntelligence/FilterOlFoldersViewer_Tests.cs QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs`, which must be empty. Those nine paths are the twelve-path Write Set less the three files this delivery creates. A non-empty result means the base has moved and the "exactly N changed lines" gates in [P1-T2], [P1-T3], [P2-T7] and [P6-T2] no longer isolate this delivery's edits. The task additionally runs `git status --porcelain --untracked-files=all` and records its verbatim output under a line `BASELINE_WORKTREE_STATUS:`, which must produce no line naming a path under `UtilitiesCS/`, `UtilitiesCS.Test/` or `QuickFiler.Test/`. The porcelain span is a required companion to the name-listing diff above rather than a duplicate of it: an anchored `--name-only` diff enumerates tracked changes only and is blind to an untracked file, so the two together establish both that no Write Set file changed between the base and `HEAD` and that the executor starts from a worktree carrying no uncommitted or untracked source edit of its own. Running it here, before any task edits a source file, is what makes the later per-file "exactly N changed lines" gates attributable to this delivery. + +- [x] [P0-T4] Install the repository-pinned .NET SDK into `.dotnet-sdk/` by running `pwsh -NoProfile -ExecutionPolicy Bypass -File ./scripts/vscode/Install-RepoDotNetSdk.ps1` from the repository root. The host must be `pwsh` 7 rather than Windows PowerShell 5.1; the script does not run correctly under 5.1 in this repository. This task runs before any `dotnet` or `msbuild` command in this plan, because a freshly created agent worktree contains neither `.dotnet-sdk/` nor `packages/` and every C# gate would otherwise fail for an environmental reason. Acceptance: `FEATURE/evidence/baseline/p0-t4-sdk-install.md` exists with the four schema fields; `Output Summary:` records the verbatim single line printed by `dotnet --version` after the SDK preamble, and that line is `8.0.205`; `.dotnet-sdk/dotnet.exe` exists; and `.dotnet-sdk/` does not appear in `git status --porcelain --untracked-files=all` because `.gitignore:350` carries `.dotnet*/`. + +- [x] [P0-T5] Restore NuGet packages for the solution by running `pwsh -NoProfile -ExecutionPolicy Bypass -File ./scripts/vscode/Invoke-Restore.ps1 -SolutionPath TaskMaster.sln -Configuration Debug -Platform "Any CPU"` from the repository root. Every project in this solution declares an `EnsureNuGetPackageBuildImports` target whose `` fires at `BeforeTargets="PrepareForBuild"` (`UtilitiesCS/UtilitiesCS.csproj:1286`), so MSBuild hard-fails without `packages/`. Acceptance: `FEATURE/evidence/baseline/p0-t5-nuget-restore.md` exists with the four schema fields and `EXIT_CODE: 0`; `Output Summary:` records the integer produced by `(Get-ChildItem -Path 'packages' -Directory).Count` on a line spelled `PACKAGE_DIRECTORY_COUNT:` followed by that integer, and that integer is greater than 0. `packages/` is matched by `.gitignore:191` `**/[Pp]ackages/*` and is therefore invisible to `Glob` and to `Grep`; the count is taken with `Get-ChildItem`, which is not gitignore-aware. + +- [x] [P0-T6] Restore the CSharpier manifest tool by running `dotnet tool restore` from the repository root after the SDK preamble. Acceptance: `FEATURE/evidence/baseline/p0-t6-tool-restore.md` exists with the four schema fields and `EXIT_CODE: 0`; `Output Summary:` records verbatim, in a fenced block, the whole output of `dotnet tool run csharpier --version`, and that output begins with `1.2.6`, which is the version `dotnet-tools.json` pins at repository root. The assertion is on the leading version token rather than on the whole line, because a dotnet tool may append a build-metadata suffix to its informational version and the pinned version is the only part of it this gate is checking. The artifact records the observed line in full so a later reader sees which form the tool emitted. + +- [x] [P0-T7] Verify analyzer version parity between each in-scope project file and its sibling `packages.config`, for the three projects this delivery compiles into: `UtilitiesCS/UtilitiesCS.csproj`, `UtilitiesCS.Test/UtilitiesCS.Test.csproj`, `QuickFiler.Test/QuickFiler.Test.csproj`. For each project, extract every `` path, parse the id and version out of the path segment, and compare against the `id`/`version` pair of the matching `` element in that project's `packages.config`. Acceptance: `FEATURE/evidence/baseline/p0-t7-analyzer-parity.md` exists with the four schema fields; it carries one table row per `` item across the three projects giving the project, the analyzer id, the csproj version and the `packages.config` version; and it carries a line `ANALYZER_PARITY_MISMATCH_COUNT:` followed by an integer. The task is complete only when that integer is `0`. This is a verification, not an assertion of any particular version: a Dependabot bump can move both sides together, and only a divergence between the two sides is a defect. + +- [x] [P0-T8] Ensure the `dotnet-coverage` global tool is resolvable, because `scripts/vscode/Invoke-MSTestWithCoverage.ps1:292-294` throws when it is absent and every coverage task in this plan invokes it directly. Run `Get-Command dotnet-coverage -ErrorAction SilentlyContinue`; when it resolves to nothing, run `dotnet tool install --global dotnet-coverage` and re-probe. Acceptance: `FEATURE/evidence/baseline/p0-t8-dotnet-coverage.md` exists with the four schema fields; it carries a line `DOTNET_COVERAGE_PRESENT_BEFORE:` valued `true` or `false`, a line `DOTNET_COVERAGE_INSTALLED_BY_THIS_TASK:` valued `true` or `false`, and `Output Summary:` records the verbatim single line printed by `dotnet-coverage --version`. Both entry states converge on the same end state and the task is complete only when that version line is present. + +- [x] [P0-T9] Capture the baseline CSharpier check. Command, after the SDK preamble: `dotnet tool run csharpier check .`. Acceptance: `FEATURE/evidence/baseline/p0-t9-csharpier-check.md` exists with the four schema fields and `EXIT_CODE: 0`; `Output Summary:` reproduces the tool's printed count line verbatim in a fenced block, which on a successful run has the shape `Checked files in ms.`; and the artifact carries a line `BASELINE_CHECKED_FILES:` followed by that `` as a bare integer with no surrounding text. [P5-T2] derives its expected value from that line rather than from any figure tabled in this plan. + +- [x] [P0-T10] Capture the baseline analyzer build. Command, after MSBuild resolution: `& $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true`. Acceptance: `FEATURE/evidence/baseline/p0-t10-analyzer-build.md` exists with the four schema fields and `EXIT_CODE: 0`; `Output Summary:` reproduces the two trailing summary lines verbatim in a fenced block, which on a successful run are ` 0 Warning(s)` and ` 0 Error(s)`; and the artifact carries a line `BASELINE_PROJECT_COUNT:` followed by the count of build-output lines of the arrow form ` -> \bin\Debug\` as a bare integer. + +- [x] [P0-T11] Capture the baseline nullable build. Command, after MSBuild resolution: `& $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true`. Acceptance: `FEATURE/evidence/baseline/p0-t11-nullable-build.md` exists with the four schema fields and `EXIT_CODE: 0`; `Output Summary:` reproduces the two trailing summary lines verbatim in a fenced block, which on a successful run are ` 0 Warning(s)` and ` 0 Error(s)`; and the artifact records, in one sentence, that `/p:Nullable=enable` was not passed and why. + +- [x] [P0-T12] Capture the baseline full-suite test run without coverage, using the nine-assembly list and the full-suite switch set with `` `p0t12`. Acceptance: `FEATURE/evidence/baseline/p0-t12-vstest.md` exists with the four schema fields and `EXIT_CODE: 0`; `Output Summary:` reproduces the `Test Run Successful.`, `Total tests: ` and ` Passed: ` lines verbatim in a fenced block; the artifact carries a line `BASELINE_TOTAL_TESTS:` followed by `` as a bare integer; and the TRX `ResultSummary/Counters` `failed` attribute is recorded as an explicit numeral, which must be `0`, and a line `SKIPPED_DERIVED:` is recorded whose value is the TRX `total` attribute minus the TRX `executed` attribute and which must also be `0`. + +- [x] [P0-T13] Capture the baseline coverage collection. Build the derived settings by loading repository-root `coverage.config`, appending one `.*\.Test\.dll$` to `/Configuration/CodeCoverage/ModulePaths/Exclude`, and saving it to `coverage\809-effective-coverage.config`. Then run `dotnet-coverage collect --output coverage\809-p0-baseline.cobertura.xml --output-format cobertura --settings coverage\809-effective-coverage.config -- $vstest` followed by the nine-assembly list and the full-suite switch set with `` `p0t13`. `coverage/` is matched by `.gitignore:144`, so neither the settings file nor the Cobertura document enters the tree; the numeric findings recorded in this artifact are the retained evidence. Acceptance: `FEATURE/evidence/baseline/p0-t13-coverage.md` exists with the four schema fields and `EXIT_CODE: 0`; it reproduces the pinned counting method by name including the two rejected selections; and `Output Summary:` carries the four bare-integer lines `BASELINE_FIRSTPARTY_LINES_COVERED:`, `BASELINE_FIRSTPARTY_LINES_VALID:`, `BASELINE_FIRSTPARTY_BRANCHES_COVERED:`, `BASELINE_FIRSTPARTY_BRANCHES_VALID:` produced by the pinned aggregation snippet, together with the derived `BASELINE_FIRSTPARTY_LINE_PCT:` and `BASELINE_FIRSTPARTY_BRANCH_PCT:` to two decimal places. + +- [x] [P0-T14] Measure the baseline per-file coverage of `UtilitiesCS/Threading/UiThread.cs` from the document `coverage\809-p0-baseline.cobertura.xml` written by [P0-T13], using the pinned per-file lookup. Acceptance: `FEATURE/evidence/baseline/p0-t14-uithread-file-coverage.md` exists with the four schema fields; it carries the four bare lines `BASELINE_UITHREAD_LINES_COVERED:`, `BASELINE_UITHREAD_LINES_VALID:`, `BASELINE_UITHREAD_LINE_PCT:` to two decimal places, and `BASELINE_UITHREAD_UNCOVERED_LINES:` giving the ascending comma-separated list of uncovered line numbers; and it records that `spec.md` carries a figure of 76.83% line and 65.00% branch quoted from the #782 records, states that this task's own measured figure is the baseline of record for [P6-T1], and states that the two are not required to agree because the #782 figure was produced by an unrecorded selection. Neither `UiThread` nor any nested type carries `[ExcludeFromCodeCoverage]` and repository-root `coverage.config` excludes only third-party module paths, so no exclusion applies to this file; the artifact records that check as `UITHREAD_COVERAGE_EXCLUSION_APPLIES: false`. + +- [x] [P0-T15] Measure, on this execution host, whether `new SyncContextForm(); Show();` throws when executed on an MTA thread — decision D5, research open question 1. The measurement is the single-test run of `QuickFiler.Controllers.Tests.QfcHomeControllerRunAsyncTests.Worker_RunWorkerCompleted_HandlesCompletionCorrectly` against the unmodified tree. Command: after vstest resolution, `& $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll '/Tests:Worker_RunWorkerCompleted_HandlesCompletionCorrectly' '/InIsolation' '/Logger:trx;LogFileName=p0t15.trx' '/ResultsDirectory:TestResults\809-p0t15'`. `/Tests:` and `/TestCaseFilter:` are mutually exclusive in vstest and the four stalling shell-icon classes live in `UtilitiesCS.Test`, so no filter is passed here. The inference is exact: in a single-test run no earlier test can have consumed the latch at `UtilitiesCS/Threading/UiThread.cs:36`, so the assertions at `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs:356-357` can hold only if `Initialize()` ran to completion on the MTA MSTest worker, which requires `new SyncContextForm()` at `UiThread.cs:51` and `Show()` at `:54` not to have thrown. Acceptance: `FEATURE/evidence/other/p0-t15-mta-synccontextform-measurement.md` exists with `Timestamp:`, `Command:`, `EXIT_CODE:`, `ExpectedExitCode:` set to the observed exit code, and `Output Summary:`; it carries exactly one line `MTA_INITIALIZE_OUTCOME:` valued `COMPLETED` or `THREW`; it records the TRX outcome and duration for that one fully-qualified test name; and when the value is `THREW` it additionally records the exception type and message read from the TRX. `ExpectedExitCode:` is set to the observed value because this task measures rather than gates, and the reason is stated in the artifact. + +- [x] [P0-T16] Record the baseline line count of every pre-existing Write Set source file, so [P4-T5] can gate the 500-line limit against a recorded starting point. Command: `git ls-files -- UtilitiesCS/Threading/UiThread.cs UtilitiesCS/Threading/SyncContextForm.cs UtilitiesCS.Test/Threading/UiThread_Tests.cs UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs UtilitiesCS.Test/EmailIntelligence/FolderRemapViewer_Tests.cs UtilitiesCS.Test/EmailIntelligence/FilterOlFoldersViewer_Tests.cs QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs` followed by `(Get-Content -LiteralPath ).Count` for each of those seven paths, using the pinned line-count idiom. Acceptance: `FEATURE/evidence/baseline/p0-t16-line-counts.md` exists with the four schema fields and carries one `BASELINE_LINES ` line for each of the seven paths, including `BASELINE_LINES UtilitiesCS/Threading/UiThread.cs 195`, `BASELINE_LINES UtilitiesCS.Test/Threading/UiThread_Tests.cs 215`, and `BASELINE_LINES QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs 360`. If any of those three observed counts differs from the value written here, record the observed value and add a line `BASELINE_LINE_COUNT_DIVERGENCE:` naming the file; the recorded observation is authoritative. The two authoring budgets stated in [P2-T1] and [P2-T8] stand in different relations to it. The [P2-T8] figure of 275 is an added-line budget measured against the `UtilitiesCS.Test/Threading/UiThread_Tests.cs` baseline this task records, and it must keep that file at or below the 495-line ceiling [P2-T9] gates; the two are not the same quantity, because 275 added lines on the 215-line baseline finish at 490 and leave five lines of headroom under that ceiling deliberately. If the observed baseline for that file diverges from 215, the permitted added-line count moves by the same amount in the opposite direction, so the finished file lands at 490 either way. The [P2-T1] figure of 460 is a whole-file ceiling for `UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs`, a file this delivery creates: no baseline is subtracted from it, it is not among the seven paths this task measures, and a divergence recorded here does not move it. + +- [x] [P0-T17] Re-derive and record the five design preconditions this plan depends on, each against the current tree rather than against a cited artifact. (1) `UtilitiesCS/Properties/AssemblyInfo.cs:19` carries `[assembly: InternalsVisibleTo("UtilitiesCS.Test")]` and no line of that file names `QuickFiler.Test`. (2) `UtilitiesCS.Test/test.runsettings` is `` with a comment stating global STA execution is intentionally disabled, and a search of `**/*.runsettings` for `ExecutionThreadApartmentState` returns no match, so a plain `[TestMethod]` runs MTA. (3) `UtilitiesCS.Test/NoLiveFormInTestAssemblyTests.cs` declares `ExecutingAssembly_ContainsNoFormDerivedType`, so no type added to `UtilitiesCS.Test` by this delivery may derive from `System.Windows.Forms.Form`. (4) A search of `**/*.cs` for `ThreadSafeSingleShotGuard` returns 23 files, which is why decision D2 retains the type. (5) `UtilitiesCS/UtilitiesCS.csproj:1112` carries `` and `UtilitiesCS.Test/UtilitiesCS.Test.csproj:503` carries ``, confirming both projects are legacy non-SDK projects with explicit compile items. Acceptance: `FEATURE/evidence/baseline/p0-t17-design-preconditions.md` exists with `Timestamp:` and five numbered sections, each naming the file, the line or the search performed, and the observed result; and it carries a line `DESIGN_PRECONDITION_FAILURES:` followed by an integer that must be `0`. + +--- + +### Phase 1 — Testability seam + +Phase 1 introduces the seam only. It changes no observable behaviour of `Init()`, `Initialize()` or `IsCompleted`, so every gate in this phase is expected green and the Phase 2 regression tests remain red until Phase 3. + +- [x] [P1-T1] Create `UtilitiesCS/Threading/IUiCaptureSource.cs` declaring `internal interface IUiCaptureSource` in namespace `UtilitiesCS.Threading`, with `#nullable enable` on line 1. The interface exposes exactly the members `Initialize()` uses: `bool ShowInTaskbar { get; set; }`, `FormWindowState WindowState { get; set; }`, `void Show()`, `void Hide()`, `void CaptureUiVariables()`, `SynchronizationContext UiSyncContext { get; }`, `System.Drawing.SizeF FormAutoScaleFactor { get; }`, `Dispatcher UiDispatcher { get; }`, `int UiThreadId { get; }`. Required using directives: `System.Threading`, `System.Windows.Forms`, `System.Windows.Threading`. Acceptance: the file exists; `git status --porcelain --untracked-files=all` lists it; the file is at most 80 lines as measured by the pinned line-count idiom `(Get-Content -LiteralPath ).Count`; and it declares no type deriving from `Form`. + +- [x] [P1-T2] Add `` to `UtilitiesCS/UtilitiesCS.csproj`, in the same `ItemGroup` that already carries `` at line 1110 and `` at line 1112. This project is a legacy non-SDK `packages.config` project, so a source file that is not listed does not compile. Acceptance: `git status --porcelain -- UtilitiesCS/UtilitiesCS.csproj` lists the file as modified, `git diff pre-809-base -- UtilitiesCS/UtilitiesCS.csproj` shows exactly one added line, and a search of `UtilitiesCS/UtilitiesCS.csproj` for the single-line token `Threading\IUiCaptureSource.cs` returns exactly one match. The diff is anchored to `pre-809-base` and compares the working tree against that ref rather than against `HEAD`, because the edit is not committed until [P6-T15]. The plan quotes that token verbatim here so the executor creates it. + +- [x] [P1-T3] Change the declaration at `UtilitiesCS/Threading/SyncContextForm.cs:16` from `public partial class SyncContextForm : Form` to `public partial class SyncContextForm : Form, UtilitiesCS.Threading.IUiCaptureSource`, adding no member. `FormAutoScaleFactor` (`:24`), `UiSyncContext` (`:28`), `UiDispatcher` (`:30`), `UiThreadId` (`:32`) and `CaptureUiVariables()` (`:34`) are already declared with public getters, and `ShowInTaskbar`, `WindowState`, `Show()` and `Hide()` are inherited from `Form`, so the type satisfies the interface without gaining a member. A public type implementing an internal interface is legal C#. Acceptance: a search of `UtilitiesCS/Threading/SyncContextForm.cs` for the single-line token `IUiCaptureSource` returns exactly one match; `git status --porcelain -- UtilitiesCS/Threading/SyncContextForm.cs` lists the file as modified; and `git diff pre-809-base -- UtilitiesCS/Threading/SyncContextForm.cs` shows exactly one changed line, which is the declaration. + +- [x] [P1-T4] In `UtilitiesCS/Threading/UiThread.cs`, introduce the capture-object factory seam. Retype the field at `:81` from `private static SyncContextForm? _syncContextForm;` to `private static IUiCaptureSource? _syncContextForm;`. Add `internal static Func SyncContextFormFactory { get; set; } = DefaultSyncContextFormFactory;` together with `private static IUiCaptureSource DefaultSyncContextFormFactory() => new SyncContextForm();`. Replace `new SyncContextForm()` at `:51` with `SyncContextFormFactory();`. Retain the existing `using UtilitiesCS.Threading;` directive at `:13`, which is still required by `ThreadMonitor`, `LockupAttribution` and the new `IUiCaptureSource`, and retain `using QuickFiler.Viewers;` at `:12`, still required by `DefaultSyncContextFormFactory`. The delegate-factory shape is precedented at `QuickFiler/Helper Classes/ItemViewerQueue.cs:11-27`. Acceptance: a search of `UtilitiesCS/Threading/UiThread.cs` for the single-line token `SyncContextFormFactory` returns at least three matches; a search of the same file for the single-line token `new SyncContextForm()` returns exactly one match, which is inside `DefaultSyncContextFormFactory`; and the file still carries `#nullable enable` on line 1. + +- [x] [P1-T5] In `UtilitiesCS/Threading/UiThread.cs`, declare the AC1 message constant ahead of the AC1 behaviour, so the Phase 2 regression tests compile before Phase 3 changes any behaviour. Add, as a sibling of `DispatcherNotInitializedMessage` at `:135-136`, `internal const string NonStaInitMessagePrefix` whose value is `"UiThread.Init() must be called on the UI (STA) thread during host startup. Observed apartment state: "`. Declare the constant only: do not add the `NonStaInitMessage(ApartmentState)` formatter and do not touch `Init()` in this task, both of which belong to [P3-T1]. The constant is `internal`, so it is reachable from `UtilitiesCS.Test` through the grant at `UtilitiesCS/Properties/AssemblyInfo.cs:19`, and an unreferenced `internal const` produces no unused-member diagnostic. Without this declaration-only step the Phase 2 tests would reference a symbol that does not exist yet, the whole `UtilitiesCS.Test` assembly would fail to compile, and the fail-before run in [P2-T10] would report a build error instead of the five behavioural failures it exists to record. Acceptance: a search of `UtilitiesCS/Threading/UiThread.cs` for the single-line token `NonStaInitMessagePrefix` returns exactly one match, and a search of the same file for the single-line token `NonStaInitMessage(` returns zero matches. The plan quotes both tokens verbatim here so the executor creates the first and withholds the second. + +- [x] [P1-T6] In `UtilitiesCS/Threading/UiThread.cs`, add `internal static void ResetForTesting()` which assigns a fresh `ThreadSafeSingleShotGuard` to `_loaded`, sets `_uiSyncContext`, `_dispatcher`, `_autoScaleFactor`, `_syncContextForm` and `_threadMonitor` to `null`, sets `_uiThreadId` to `-1`, sets `_monitorUiThread` to `false`, sets `_onLockupDetected` and `_monitorTimeProvider` to `null`, sets `_lockupAttributionThresholdMs` to `5000`, and restores `SyncContextFormFactory` to `DefaultSyncContextFormFactory`. The `internal ... ForTesting()` idiom is precedented at `QuickFiler/Helper Classes/ItemViewerQueue.cs:69-91`. [P3-T3] replaces the `_loaded` assignment when `_loaded` is removed. Acceptance: a search of `UtilitiesCS/Threading/UiThread.cs` for the single-line token `internal static void ResetForTesting()` returns exactly one match. The plan quotes that token verbatim here so the executor creates it. + +- [x] [P1-T7] Create `UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs` declaring `internal sealed class UiThreadStateScope : IDisposable` in namespace `UtilitiesCS.Test`, modelled on `UtilitiesCS.Test/TestHelpers/UiThreadDispatcherScope.cs`. It resolves one `private static readonly FieldInfo` per private static field it controls — `_loaded`, `_uiSyncContext`, `_autoScaleFactor`, `_uiThreadId`, `_dispatcher`, `_syncContextForm`, `_threadMonitor`, `_monitorUiThread`, `_onLockupDetected`, `_monitorTimeProvider`, `_lockupAttributionThresholdMs` — each through a resolver that asserts the field is non-null with a stated reason, so a rename of any of them raises `TypeInitializationException` on first use rather than degrading to a silent no-op. It exposes a static factory that snapshots every controlled field plus `UiThread.SyncContextFormFactory`, calls `UiThread.ResetForTesting()`, and returns a scope whose `Dispose()` writes every captured value back unconditionally, including a captured null. It additionally exposes read-only `internal static` accessors named `MonitorUiThread`, `OnLockupDetected`, `MonitorTimeProvider`, `LockupAttributionThresholdMs`, `UiSyncContextField`, `AutoScaleFactorField`, `UiThreadIdField`, `DispatcherField`, `SyncContextFormField`, `ThreadMonitorField`, so a test can observe the fields without going through the properties, two of which lazily call `Init()` and one of which throws when unset. The type carries an XML `` stating it is not thread-safe and that serialization is provided by `[DoNotParallelize]` on every consuming class. Acceptance: the file exists and is listed by `git status --porcelain --untracked-files=all`; it is at most 300 lines as measured by the pinned line-count idiom `(Get-Content -LiteralPath ).Count`; it declares no type deriving from `Form`; and a search of the file for the single-line token `UiThread.ResetForTesting()` returns exactly one match. + +- [x] [P1-T8] Add `` to `UtilitiesCS.Test/UtilitiesCS.Test.csproj`, in the same `ItemGroup` that already carries `` at line 76. Acceptance: a search of `UtilitiesCS.Test/UtilitiesCS.Test.csproj` for the single-line token `TestHelpers\UiThreadStateScope.cs` returns exactly one match, and `git status --porcelain -- UtilitiesCS.Test/UtilitiesCS.Test.csproj` lists the file as modified. The plan quotes that token verbatim here so the executor creates it. + +- [x] [P1-T9] Format the Phase 1 files and confirm the whole tree is formatter-clean. Commands, after the SDK preamble and in this order: `git status --porcelain --untracked-files=all` recorded as the before-image; `dotnet tool run csharpier format .`; `git status --porcelain --untracked-files=all` recorded as the after-image; `dotnet tool run csharpier check .`. `csharpier format` is write-mode and exits 0 whether or not it rewrote a file, so the exit code alone decides nothing. Acceptance: `FEATURE/evidence/qa-gates/p1-t9-format.md` exists with the four schema fields; `Output Summary:` reproduces the formatter's verbatim `Formatted files in ms.` line and both porcelain images in fenced blocks; the two images are compared and every path that appears in the after-image but not the before-image is listed with the reason; and the `csharpier check .` run exits 0 with its verbatim `Checked files in ms.` line recorded. If the after-image contains a path the before-image does not and that path is not a Phase 1 Write Set file, re-run this task from the first command. + +- [x] [P1-T10] Build the solution twice to prove the seam compiles and introduces no analyzer or nullable diagnostic. Commands, after MSBuild resolution: `& $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true`, then `& $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true`. Acceptance: `FEATURE/evidence/qa-gates/p1-t10-builds.md` exists with `Timestamp:`, both `Command:` strings, `EXIT_CODE: 0` for the combined step, and `Output Summary:` reproducing ` 0 Warning(s)` and ` 0 Error(s)` verbatim for each of the two builds; and the artifact records the build-output arrow-line count for the analyzer build and states whether it equals the `BASELINE_PROJECT_COUNT:` recorded by [P0-T10]. + +- [x] [P1-T11] Prove the seam changed no existing behaviour, by running the `UtilitiesCS.Test` classes that touch `UiThread` state. Command: after vstest resolution, `& $vstest UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll '/InIsolation' '/Logger:trx;LogFileName=p1t11.trx' '/ResultsDirectory:TestResults\809-p1t11' '/TestCaseFilter:FullyQualifiedName~UtilitiesCS.Test.Threading.SynchronizationContextAwaiter_Tests|FullyQualifiedName~UtilitiesCS.Test.Threading.UiThread_Dispatcher_Tests|FullyQualifiedName~UtilitiesCS.Test.OutlookObjects.Folder.WpfDispatcherYieldTests|FullyQualifiedName~UtilitiesCS.Test.EmailIntelligence.FolderRemapViewer_Tests|FullyQualifiedName~UtilitiesCS.Test.EmailIntelligence.FilterOlFoldersViewer_Tests|FullyQualifiedName~UtilitiesCS.Test.OutlookObjects.Folder.FolderPredictorTests|FullyQualifiedName~UtilitiesCS.Test.Threading.IdleAsyncQueue_Tests'`. Acceptance: `FEATURE/evidence/qa-gates/p1-t11-seam-regression.md` exists with the four schema fields and `EXIT_CODE: 0`; `Output Summary:` reproduces the `Test Run Successful.` and `Total tests:` lines verbatim; and the TRX `ResultSummary/Counters` `failed` attribute is recorded as an explicit numeral, which must be `0`, and a line `SKIPPED_DERIVED:` is recorded whose value is the TRX `total` attribute minus the TRX `executed` attribute and which must also be `0`. + +--- + +### Phase 2 — Regression tests written to fail against current behaviour + +- [x] [P2-T1] Create `UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs` in namespace `UtilitiesCS.Test.Threading`, containing an `internal sealed class FakeUiCaptureSource : UtilitiesCS.Threading.IUiCaptureSource` at namespace level. The fake is a plain class and must not derive from `System.Windows.Forms.Form`, because `UtilitiesCS.Test/NoLiveFormInTestAssemblyTests.cs` asserts that this assembly compiles no `Form`-derived type. It exposes a settable `ThrowOnCapture` flag whose `CaptureUiVariables()` throws `InvalidOperationException` when set, records an invocation counter for construction through the factory, and otherwise assigns deterministic values to the four capture properties. Author `UiThreadInitContract_Tests.cs` and the ten cases [P2-T2] through [P2-T5] add to it within a budget of 460 lines for the whole file, under the same two authoring rules [P2-T8] states. Acceptance: the file exists and is listed by `git status --porcelain --untracked-files=all`; a search of the file for the single-line token `class FakeUiCaptureSource` returns exactly one match; and the file declares no type deriving from `Form`. The plan quotes that token verbatim here so the executor creates it. + +- [x] [P2-T2] Add the AC1 apartment-state cases to `UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs` as a `[TestClass] [DoNotParallelize]` class named `UiThreadInitApartmentContract_Tests`, every Act wrapped in a `UiThreadStateScope`. The four methods are: `Init_OnMtaThread_ThrowsInvalidOperationExceptionNamingTheObservedApartmentState`, a plain `[TestMethod]` asserting the message starts with `UiThread.NonStaInitMessagePrefix` and contains `MTA`; `Init_OnMtaThread_CapturesNoGlobalStateAndLeavesMonitoringConfigurationUnchanged`, a plain `[TestMethod]` reading the four monitoring fields and the four capture fields back through the scope accessors and asserting each is unchanged from the value the scope installed; `Init_OnStaThread_DoesNotThrowAndPopulatesAllFourCaptureFields`, an `[STATestMethod]` driving `Init()` through a `FakeUiCaptureSource` factory; and `Init_ApartmentBoundaryIsStaEqualityNotMtaInequality_RejectsFromMtaAndAcceptsFromSta`, which drives one MTA thread and one STA thread and asserts the boundary is `== STA`. Decision D1 rejects any apartment state that is not `ApartmentState.STA`; `ApartmentState.Unknown` is not directly constructible under MSTest on this host, so it is recorded as untested in the test's XML summary rather than asserted. Acceptance: a search of the file for the single-line token `UiThreadInitApartmentContract_Tests` returns exactly one match, and a search for the single-line token `[DoNotParallelize]` returns at least one match. The plan quotes both tokens verbatim here so the executor creates them. + +- [x] [P2-T3] Add the AC2 retry cases to `UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs` as an `[STATestClass] [DoNotParallelize]` class named `UiThreadInitRetryContract_Tests`, every Act wrapped in a `UiThreadStateScope`. The four methods are: `Init_WhenFirstInitializeThrows_SecondInitWithWorkingFactorySucceedsAndPopulatesAllFourCaptureFields`; `Init_WhenInitializeThrows_LeavesAllFourCaptureFieldsUnset`; `AutoScaleFactor_ReadFromMtaThreadAfterAFailedInit_ThrowsAndDoesNotReEnterTheFactory`, which asserts the AC1 `InvalidOperationException` and asserts the factory invocation counter did not increase — expressed as an invocation count and never as a wall-clock duration, because a duration assertion would be a timing hack and is prohibited; and `Init_CalledConcurrentlyFromTwoStaThreads_InvokesTheFactoryExactlyOnce`, which covers the serializing lock and the pre-existing #782 finding C04 race. Acceptance: a search of the file for the single-line token `UiThreadInitRetryContract_Tests` returns exactly one match, and a search for the single-line token `DoesNotReEnterTheFactory` returns exactly one match. The plan quotes both tokens verbatim here so the executor creates them. + +- [x] [P2-T4] Add the AC2 monitor-branch coverage case to `UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs`, in the `[STATestClass] [DoNotParallelize]` class `UiThreadInitRetryContract_Tests`, named `Init_WithMonitorUiThreadEnabled_ConstructsAndRunsTheThreadMonitorWithTheInjectedTimeProvider`. It calls `Init(monitorUiThread: true, timeProvider: )` through a `FakeUiCaptureSource` factory and asserts the `_threadMonitor` field is non-null through the `UiThreadStateScope.ThreadMonitorField` accessor that [P1-T7] declares. `FakeTimeProvider` comes from `Microsoft.Extensions.TimeProvider.Testing`, already referenced by `UtilitiesCS.Test/packages.config:91` and already used at `UtilitiesCS.Test/Threading/ThreadMonitorTests.cs`. The fake clock is never advanced, so the timer `ThreadMonitor.Run()` creates never fires and the test leaves no live watchdog. This case is what makes `UtilitiesCS/Threading/UiThread.cs:66-76` reachable. That region is among the lines `FEATURE/evidence/baseline/p0-t14-uithread-file-coverage.md` records under `BASELINE_UITHREAD_UNCOVERED_LINES:`; the number of uncovered lines it accounts for is read from that recorded list rather than asserted here. Acceptance: a search of the file for the single-line token `ConstructsAndRunsTheThreadMonitorWithTheInjectedTimeProvider` returns exactly one match. The plan quotes that token verbatim here so the executor creates it. + +- [x] [P2-T5] Add the AC2 lazy-path case to `UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs`, in the `[STATestClass] [DoNotParallelize]` class `UiThreadInitRetryContract_Tests`, named `UiSyncContext_ReadWithNullBackingFieldFromStaThread_InitializesThroughTheLazyPath`. It clears `_uiSyncContext` through the scope, installs a `FakeUiCaptureSource` factory, reads `UiThread.UiSyncContext`, and asserts the value came from the fake. This case is what makes `UtilitiesCS/Threading/UiThread.cs:117-120` reachable, a region research R1 records as never having executed with a null field in a measured run. That region is among the lines `FEATURE/evidence/baseline/p0-t14-uithread-file-coverage.md` records under `BASELINE_UITHREAD_UNCOVERED_LINES:`; the number of uncovered lines it accounts for is read from that recorded list rather than asserted here. Acceptance: a search of the file for the single-line token `InitializesThroughTheLazyPath` returns exactly one match. The plan quotes that token verbatim here so the executor creates it. + +- [x] [P2-T6] Add `` to `UtilitiesCS.Test/UtilitiesCS.Test.csproj`, in the same `ItemGroup` that already carries `` at line 503. A new test file that is not listed compiles into nothing and its tests silently do not exist, which would make AC4 appear satisfied when it is not. Acceptance: a search of `UtilitiesCS.Test/UtilitiesCS.Test.csproj` for the single-line token `Threading\UiThreadInitContract_Tests.cs` returns exactly one match. The plan quotes that token verbatim here so the executor creates it. + +- [x] [P2-T7] Add `[DoNotParallelize]` to four test-class declarations, so that after this delivery no class that reads or writes a `UiThread` static remains in the parallel bucket. The four are `SynchronizationContextAwaiter_Tests` at `UtilitiesCS.Test/Threading/UiThread_Tests.cs:9-10`, `FolderPredictorTests` at `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs:19`, `FolderRemapViewer_Tests` at `UtilitiesCS.Test/EmailIntelligence/FolderRemapViewer_Tests.cs:27`, and `FilterOlFoldersViewer_Tests` at `UtilitiesCS.Test/EmailIntelligence/FilterOlFoldersViewer_Tests.cs:27`. On the two `[STATestClass]` declarations the new attribute is added as a separate line beneath the existing one; neither `[STATestClass]` is removed or replaced. The reason the attribute must be applied to all four rather than only to the writers this delivery adds: under the `` element that `scripts/vscode/TaskMaster.cli.runsettings:4-7` declares with `0` and `ClassLevel`, which that file imposes on every assembly in the [P0-T12], [P0-T13] and [P5-T5] runs, those being the three runs in this plan that pass `/Settings:scripts\vscode\TaskMaster.cli.runsettings`, the serial and parallel buckets overlap in wall-clock time, so `[DoNotParallelize]` on one class does not stop a writer in another class of the same assembly. This was measured in this repository on issue #292, CI run 29046195330, where a `[DoNotParallelize]` reader observed a value written by classes still executing in the parallel bucket. `FolderPredictorTests` writes `UiThread._uiSyncContext` by reflection at `:479`; the two viewer classes drive `Init()` to success transitively and so write all four capture statics; and the [P2-T8] cases install `_uiThreadId`, `_uiSyncContext` and `_dispatcher`. `UtilitiesCS.Test/TestHelpers/UiThreadDispatcherScope.cs:19-25` states that serialization of writers is provided by that attribute rather than by internal synchronization. `UiThread_Dispatcher_Tests` already carries it at `:129` and is not touched. Acceptance: `git status --porcelain -- UtilitiesCS.Test/Threading/UiThread_Tests.cs UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs UtilitiesCS.Test/EmailIntelligence/FolderRemapViewer_Tests.cs UtilitiesCS.Test/EmailIntelligence/FilterOlFoldersViewer_Tests.cs` lists all four files as modified; `git diff pre-809-base -- UtilitiesCS.Test/Threading/UiThread_Tests.cs UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs UtilitiesCS.Test/EmailIntelligence/FolderRemapViewer_Tests.cs UtilitiesCS.Test/EmailIntelligence/FilterOlFoldersViewer_Tests.cs` shows exactly four added lines, one per file, each carrying the attribute and no other change; and the two lines immediately preceding `public class SynchronizationContextAwaiter_Tests` are `[TestClass]` and `[DoNotParallelize]`. The diff is anchored to `pre-809-base` and compares the working tree against that ref rather than against `HEAD`, because the edit is not committed until [P6-T15]. + +- [x] [P2-T8] Add the seven AC3 awaiter cases to `SynchronizationContextAwaiter_Tests` in `UtilitiesCS.Test/Threading/UiThread_Tests.cs`, together with a `private sealed class StaDispatcherHost : IDisposable` nested in that class, modelled on the one nested in `UiThread_Dispatcher_Tests` at `:186-213`, which owns a dedicated STA thread, runs `Dispatcher.Run()`, and shuts down with `BeginInvokeShutdown` plus `Join`. A private nested copy per test class is the established convention in this repository; ten such copies exist. Every Act that touches a `UiThread` static is wrapped in a `UiThreadStateScope`. The seven methods are: `IsCompleted_WhenAmbientContextIsTheCapturedInstance_ReturnsTrue`; `IsCompleted_WhenAmbientContextIsNullAndCapturedContextIsNotNull_ReturnsFalse`, which protects the two `TaskScheduler.FromCurrentSynchronizationContext()` sites at `QuickFiler/Controllers/QfcItemController.ViewerSetup.cs:67` and `QuickFiler/Controllers/EfcItemController.cs:201`; `IsCompleted_WhenUiThreadIdIsTheMinusOneSentinel_ReturnsFalse`; `IsCompleted_OnOwningUiThreadWithADispatcherContextCapturedInsideAnInvoke_ReturnsTrue`, which installs `_uiThreadId` and `_dispatcher` for the STA host thread, captures a context inside `dispatcher.Invoke(...)`, restores the ambient context, and evaluates the predicate on that same host thread; `IsCompleted_WhenTheDispatcherContextBelongsToADifferentThreadsDispatcher_ReturnsFalse`; `IsCompleted_WithAForeignWindowsFormsContextWhileUiThreadIdMatches_ReturnsFalse`, which pins the reason a bare owning-thread-identity predicate was rejected and guards the `QuickFiler.Test/TestSupport/WinFormsPumpHostTests.cs:183` and `:218` failure mode; and `IsCompleted_OnDefaultAwaiterOnAContextFreeThread_ReturnsTrue`. Author the seven cases and the nested host within a budget of 275 added lines, so `UtilitiesCS.Test/Threading/UiThread_Tests.cs` finishes at or below 495 lines against the 215-line baseline [P0-T16] records. Two authoring rules hold that budget: the seven methods carry no XML `` block, their intent being carried by the method name plus one inline comment per Arrange, Act and Assert section, which is the convention the five existing methods of this class already follow at `:12-88`; and the nested `StaDispatcherHost` carries a two-line `` that cross-references the fuller `` on the copy at `:173-185` rather than restating it. Acceptance: a search of `UtilitiesCS.Test/Threading/UiThread_Tests.cs` for the single-line token `IsCompleted_OnOwningUiThreadWithADispatcherContextCapturedInsideAnInvoke_ReturnsTrue` returns exactly one match, and a search for the single-line token `IsCompleted_WithAForeignWindowsFormsContextWhileUiThreadIdMatches_ReturnsFalse` returns exactly one match. The plan quotes both tokens verbatim here so the executor creates them. The five existing methods of this class must remain present and unrenamed. + +- [x] [P2-T9] Format and build so the Phase 2 tests compile before they are run. Commands, in this order: the SDK preamble; `git status --porcelain --untracked-files=all` as the before-image; `dotnet tool run csharpier format .`; `git status --porcelain --untracked-files=all` as the after-image; `dotnet tool run csharpier check .`; then, after MSBuild resolution, `& $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true`. Acceptance: `FEATURE/evidence/qa-gates/p2-t9-format-and-build.md` exists with `Timestamp:`, each `Command:`, `EXIT_CODE: 0` for the combined step, and `Output Summary:` reproducing the formatter's `Formatted files in ms.` line, both porcelain images, the `Checked files in ms.` line, and the build's ` 0 Warning(s)` and ` 0 Error(s)` lines, all verbatim in fenced blocks. The artifact additionally carries one `PHASE2_LINES ` line for `UtilitiesCS.Test/Threading/UiThread_Tests.cs` and for `UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs`, measured with the pinned line-count idiom `(Get-Content -LiteralPath ).Count` after the formatter run, and a line `PHASE2_FILES_OVER_495:` followed by an integer that must be `0`. Measuring here rather than only at [P4-T5] is what makes an overrun correctable while the tests are still being written, instead of after every implementation and QA task has run. The two porcelain images are compared and every differing path is listed with its reason. + +- [x] [P2-T10] [expect-fail] Run the two new test classes and the amended awaiter class and record the fail-before evidence. Command: after vstest resolution, `& $vstest UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll '/InIsolation' '/Logger:trx;LogFileName=p2t10.trx' '/ResultsDirectory:TestResults\809-p2t10' '/TestCaseFilter:FullyQualifiedName~UtilitiesCS.Test.Threading.UiThreadInitApartmentContract_Tests|FullyQualifiedName~UtilitiesCS.Test.Threading.UiThreadInitRetryContract_Tests|FullyQualifiedName~UtilitiesCS.Test.Threading.SynchronizationContextAwaiter_Tests'`. Acceptance: `FEATURE/evidence/regression-testing/p2-t10-fail-before.md` exists with `Timestamp:`, `Command:`, `EXIT_CODE:` carrying the observed non-zero value, `ExpectedExitCode: 1`, and `Output Summary:`; the artifact carries a table with one row per test method discovered in the three classes, giving the fully-qualified name and the TRX outcome; and the set of rows whose outcome is `Failed` is exactly these six, no more and no fewer: `UiThreadInitApartmentContract_Tests.Init_OnMtaThread_ThrowsInvalidOperationExceptionNamingTheObservedApartmentState`, `UiThreadInitApartmentContract_Tests.Init_OnMtaThread_CapturesNoGlobalStateAndLeavesMonitoringConfigurationUnchanged`, `UiThreadInitApartmentContract_Tests.Init_ApartmentBoundaryIsStaEqualityNotMtaInequality_RejectsFromMtaAndAcceptsFromSta`, `UiThreadInitRetryContract_Tests.Init_WhenFirstInitializeThrows_SecondInitWithWorkingFactorySucceedsAndPopulatesAllFourCaptureFields`, `UiThreadInitRetryContract_Tests.AutoScaleFactor_ReadFromMtaThreadAfterAFailedInit_ThrowsAndDoesNotReEnterTheFactory`, and `SynchronizationContextAwaiter_Tests.IsCompleted_OnOwningUiThreadWithADispatcherContextCapturedInsideAnInvoke_ReturnsTrue`. Each of the six is red for a stated reason recorded in the artifact: the first three because `Init()` carries no apartment precondition today, so it neither throws from MTA nor leaves the four monitoring fields unchanged; the fourth and fifth because the latch at `UtilitiesCS/Threading/UiThread.cs:36` is consumed before `Initialize()` runs, so a retry after a failure is a silent no-op and a later lazy read raises no apartment exception; and the sixth because `UiThread.cs:100` compares contexts by reference. The remaining sixteen methods in the three classes are expected `Passed` at this point and the artifact records each with the reason it is already green. Sixteen is twenty-two minus six: the three classes together declare twenty-two methods, being four in `UiThreadInitApartmentContract_Tests` from [P2-T2], six in `UiThreadInitRetryContract_Tests` from [P2-T3], [P2-T4] and [P2-T5], and twelve in `SynchronizationContextAwaiter_Tests`, the five that exist today at `UtilitiesCS.Test/Threading/UiThread_Tests.cs:13`, `:23`, `:37`, `:61` and `:75` plus the seven [P2-T8] adds. Every assertion in the six is synchronous, so no async boundary can swallow the failure. + +--- + +### Phase 3 — The three production fixes + +- [x] [P3-T1] Apply the AC1 apartment-state precondition in `UtilitiesCS/Threading/UiThread.cs`. Insert as the **first statement** of `Init()`, ahead of the four monitoring-configuration assignments at `:26-35` and ahead of the latch read at `:36`, a read of `Thread.CurrentThread.GetApartmentState()` into a local followed by a `throw new InvalidOperationException(NonStaInitMessage(apartment));` when that value is not `ApartmentState.STA`. Add `private static string NonStaInitMessage(ApartmentState observed) => NonStaInitMessagePrefix + observed;`, consuming the `internal const string NonStaInitMessagePrefix` that [P1-T5] already declared; do not re-declare that constant. Splitting the constant from the formatted message is what lets a test assert the stable text without pinning the enum's rendering. Placement ahead of `:26` is load-bearing rather than cosmetic: those four assignments mutate process-global monitoring configuration on every call regardless of the latch. Do not change `DispatcherNotInitializedMessage` at `:135-136`; the literal `UiThread.Init()` must survive inside it because `UtilitiesCS.Test/OutlookObjects/Folder/WpfDispatcherYieldTests.cs:196` asserts on that substring rather than on the constant. Acceptance: a search of `UtilitiesCS/Threading/UiThread.cs` for the single-line token `NonStaInitMessagePrefix` returns exactly two matches, one being the [P1-T5] declaration and one being the formatter's use; a search of the same file for the single-line token `NonStaInitMessage(` returns exactly two matches, the declaration and the call from `Init()`; and a search for the single-line token `GetApartmentState()` returns exactly one match. The plan quotes all three tokens verbatim here so the executor creates them. + +- [x] [P3-T2] Apply the AC2 success-recorded initialization flag in `UtilitiesCS/Threading/UiThread.cs`. Add `private static readonly object InitLock = new object();` and `private static bool _initialized;`. Replace the `if (_loaded.CheckAndSetFirstCall) { Initialize(); }` block at `:36-39` with a `lock (InitLock)` region that returns early when `_initialized` is true, otherwise calls `Initialize()` and only then sets `_initialized` to true. Remove the field `private static ThreadSafeSingleShotGuard _loaded = ...` at `:46`. Do **not** remove the `using UtilitiesCS.Threading;` directive at `:13`, which remains required by `ThreadMonitor`, `LockupAttribution` and `IUiCaptureSource`. Do **not** delete or modify `UtilitiesCS/Threading/ThreadSafeSingleShotGuard.cs`; decision D2 retains the type and 23 files in this tree reference it. Acceptance: a search of `UtilitiesCS/Threading/UiThread.cs` for the single-line token `private static ThreadSafeSingleShotGuard _loaded` returns zero matches, the field declaration at `:46` having been removed; a search of the same file for the single-line token `ThreadSafeSingleShotGuard` returns exactly one match, which is the `_loaded` reassignment inside the `ResetForTesting()` body that [P1-T6] added and that [P3-T3] replaces, so the file does not reach zero matches until that later task; a search of the same file for the single-line token `lock (InitLock)` returns exactly one match; a search of the same file for the single-line token `using UtilitiesCS.Threading;` returns exactly one match; and `git ls-files --error-unmatch UtilitiesCS/Threading/ThreadSafeSingleShotGuard.cs` exits 0. The plan quotes the `lock (InitLock)` token verbatim here so the executor creates it. + +- [x] [P3-T3] Update `UiThread.ResetForTesting()` in `UtilitiesCS/Threading/UiThread.cs` to set `_initialized` to `false` in place of the `_loaded` reassignment that [P1-T6] added and that [P3-T2] deliberately left in the file, and update `UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs` to snapshot and restore `_initialized` in place of `_loaded`. The scope's field resolver must continue to assert each resolved `FieldInfo` is non-null. Acceptance: a search of `UtilitiesCS/Threading/UiThread.cs` for the single-line token `_initialized` returns at least three matches; a search of the same file for the single-line token `ThreadSafeSingleShotGuard` returns zero matches, which is the point at which [P3-T2]'s removal of the type from this file is complete; a search of the same file for the single-line token `_loaded` returns zero matches; a search of `UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs` for the single-line token `_loaded` returns zero matches; and a search of the same test-helper file for the single-line token `_initialized` returns at least one match. + +- [x] [P3-T4] Apply the AC3 awaiter predicate in `UtilitiesCS/Threading/UiThread.cs`, replacing the expression-bodied `IsCompleted` at `:100` with the block form given in `spec.md` under "#784 — the awaiter predicate". The predicate reads a local `SynchronizationContext? ambient = SynchronizationContext.Current;`, returns true on `ReferenceEquals(_context, ambient)`, returns false when `ambient is null`, returns false when `_uiThreadId == -1` or `_uiThreadId != Thread.CurrentThread.ManagedThreadId`, returns true on `ReferenceEquals(_context, _uiSyncContext)`, and otherwise returns whether `_context is DispatcherSynchronizationContext` and the current thread's dispatcher is `_dispatcher`. Two implementation details are load-bearing. First, the predicate reads the private statics `_uiThreadId`, `_uiSyncContext` and `_dispatcher` directly and must not read the `UiSyncContext` or `Dispatcher` properties: the former lazily calls `Init()` at `:117-120`, which under the [P3-T1] precondition would throw when the predicate is evaluated off the UI thread, and the latter throws whenever the backing field is unset. Second, the dispatcher lookup is written as `System.Windows.Threading.Dispatcher.FromThread(Thread.CurrentThread)` in fully-qualified form, because inside the nested `SynchronizationContextAwaiter` the simple name `Dispatcher` is also the enclosing type's static property of the same name and the fully-qualified spelling removes that ambiguity. Add the two explanatory comments `spec.md` gives, stating why a null ambient returns false and why a dispatcher context is UI-owned only when this thread's dispatcher is the UI dispatcher. Add no field to the struct, so `default(SynchronizationContextAwaiter)` behaviour is unchanged. Acceptance: a search of `UtilitiesCS/Threading/UiThread.cs` for the single-line token `System.Windows.Threading.Dispatcher.FromThread` returns exactly one match, and a search of the same file for the single-line token `_context == SynchronizationContext.Current` returns zero matches. The plan quotes the first token verbatim here so the executor creates it. + +- [x] [P3-T5] Format and build after the three fixes. Commands, in this order: the SDK preamble; `git status --porcelain --untracked-files=all` as the before-image; `dotnet tool run csharpier format .`; `git status --porcelain --untracked-files=all` as the after-image; `dotnet tool run csharpier check .`; then, after MSBuild resolution, the analyzer build and then the nullable build, both with `/t:Rebuild`. `UiThread.cs` carries `#nullable enable` at line 1, so its nullable-flow diagnostics are promoted to errors by the second build. Acceptance: `FEATURE/evidence/qa-gates/p3-t5-format-and-builds.md` exists with `Timestamp:`, each `Command:`, `EXIT_CODE: 0` for the combined step, and `Output Summary:` reproducing verbatim the `Formatted files in ms.` line, both porcelain images, the `Checked files in ms.` line, and ` 0 Warning(s)` plus ` 0 Error(s)` for each of the two builds. + +- [x] [P3-T6] Re-run the [P2-T10] command unchanged and record the pass-after evidence. Command: identical to [P2-T10] except `LogFileName=p3t6.trx` and `/ResultsDirectory:TestResults\809-p3t6`. Acceptance: `FEATURE/evidence/regression-testing/p3-t6-pass-after.md` exists with the four schema fields and `EXIT_CODE: 0`; it carries the same one-row-per-test-method table shape as `FEATURE/evidence/regression-testing/p2-t10-fail-before.md`; the count of rows whose outcome is `Failed` is `0`; and the six fully-qualified names [P2-T10] recorded as `Failed` are each present in this table with outcome `Passed`. + +--- + +### Phase 4 — Reconcile the MTA caller and the remaining affected tests + +- [x] [P4-T1] Apply decision D4 to `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs`. Remove the `UiThread.Init(false);` call at `:329`. Change the signature of `Worker_RunWorkerCompleted_HandlesCompletionCorrectly` at `:326` from `public void` to `public async System.Threading.Tasks.Task`, keeping the plain `[TestMethod]` at `:325` and adding no apartment attribute. In the Arrange block, construct a `QuickFiler.Test.TestSupport.WinFormsPumpHost`, then create the WPF dispatcher on the pump thread and return that thread in one call: `Thread pumpThread = await host.InvokeAsync(() => { System.Windows.Threading.Dispatcher.CurrentDispatcher.Should().NotBeNull(); return System.Threading.Thread.CurrentThread; }).ConfigureAwait(false);`. Only then resolve `System.Windows.Threading.Dispatcher pumpDispatcher = System.Windows.Threading.Dispatcher.FromThread(pumpThread);` and assert it is non-null. The order is required rather than stylistic: `Dispatcher.FromThread` is a lookup that never creates a dispatcher, so resolving it before any `Dispatcher.CurrentDispatcher` call has run on the pump thread returns `null`, `Install(null)` would leave `UiThread._dispatcher` unset, and the Act would raise `InvalidOperationException` from `UiThread.Dispatcher`. `QuickFiler.Test/TestSupport/WinFormsPumpHostTests.cs:224-238` is the in-tree instance of the correct order. Then obtain a transaction with `var transaction = await QuickFiler.Controllers.Tests.UiThreadDispatcherFixture.BeginTransactionAsync();` and install the resolved dispatcher with `transaction.Install(pumpDispatcher);`. `Install` is an instance member of `UiThreadDispatcherTransaction` at `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs:242-254`, not a static. Dispose the transaction and stop the host in a `finally` block so both run on every exit path including a failing assertion. Option A (`[STATestMethod]` on the existing method) is rejected: it preserves the order-dependency, can convert it into a test hang, and leaves a never-shut-down `Dispatcher` on a pooled MSTest STA worker. Option C (the fixture's `EnsureDispatcher()`) is rejected because its parked dispatcher at `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs:149-177` never runs a frame and `Invoke` would block forever. The route is sound because `QuickFiler.Test/TestSupport/WinFormsPumpHostTests.cs:218-255` already asserts that a WPF dispatcher created on the pump thread executes work on that thread, serviced by the WinForms message loop. Do not add `[DoNotParallelize]` to the class: it is declared `partial` across four files (`QfcHomeControllerRunAsyncTests.cs:24`, `QfcHomeControllerRunAsyncHighConfidenceTests.cs:16`, `.Part2.cs:25`, `.Part3.cs:23`), three of which are outside the Write Set, and the fixture's `TransactionGate` already serializes install-to-restore transactions across this assembly. Acceptance: a search of `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs` for the single-line token `UiThread.Init(false);` returns zero matches; a search of the same file for the single-line token `WinFormsPumpHost` returns at least one match; a search of the same file for the single-line token `BeginTransactionAsync` returns exactly one match; a search of the same file for the single-line token `Dispatcher.CurrentDispatcher` returns exactly one match, which is inside the `host.InvokeAsync` lambda; and a search of the same file for the single-line token `Dispatcher.FromThread(pumpThread)` returns exactly one match, which follows that lambda. The assertions at the end of that method, `Assert.IsTrue(mockFormViewer.Object.ItemsPerLoadEnabled);` and `Assert.IsTrue(mockFormViewer.Object.SkipButtonEnabled);`, must remain present and unchanged. + +- [x] [P4-T2] Format and build after the Phase 4 test edit, using the same command sequence and the same success-case output expectations as [P3-T5]. Acceptance: `FEATURE/evidence/qa-gates/p4-t2-format-and-builds.md` exists with `Timestamp:`, each `Command:`, `EXIT_CODE: 0`, and `Output Summary:` reproducing verbatim the `Formatted files in ms.` line, both porcelain images, the `Checked files in ms.` line, and ` 0 Warning(s)` plus ` 0 Error(s)` for each of the two builds. + +- [x] [P4-T3] Run the `QuickFiler.Test` methods this delivery can affect and record their outcomes. Command: after vstest resolution, `& $vstest QuickFiler.Test\bin\Debug\QuickFiler.Test.dll '/InIsolation' '/Logger:trx;LogFileName=p4t3.trx' '/ResultsDirectory:TestResults\809-p4t3' '/TestCaseFilter:FullyQualifiedName~QuickFiler.Controllers.Tests.QfcHomeControllerRunAsyncTests|FullyQualifiedName~QuickFiler.Test.TestSupport.WinFormsPumpHostTests|FullyQualifiedName~QuickFiler.Controllers.Tests.EfcFormControllerTests|FullyQualifiedName~QuickFiler.Controllers.Tests.QfcItemController_UiThreadDispatcherFixtureTests|FullyQualifiedName~QuickFiler.Helper_Classes.Tests.EmailMoveMonitorTests'`. The five filter operands are the fully-qualified type names as declared: `QuickFiler.Controllers.Tests` at `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs:21`, `QuickFiler.Test.TestSupport` at `QuickFiler.Test/TestSupport/WinFormsPumpHostTests.cs:8`, `QuickFiler.Controllers.Tests` at `QuickFiler.Test/Controllers/EfcFormControllerTests.cs:14`, `QuickFiler.Controllers.Tests.QfcItemController_UiThreadDispatcherFixtureTests` at `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs:8` and `:31`, and `QuickFiler.Helper_Classes.Tests.EmailMoveMonitorTests` at `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs:15` and `:25`. A filter operand that matches no type selects zero tests silently, so each was re-derived from its declaration rather than inferred from its file path. Acceptance: `FEATURE/evidence/qa-gates/p4-t3-quickfiler-tests.md` exists with the four schema fields and `EXIT_CODE: 0`; `Output Summary:` reproduces the `Test Run Successful.` and `Total tests:` lines verbatim; the TRX `ResultSummary/Counters` `failed` attribute is recorded as an explicit numeral, which must be `0`, and a line `SKIPPED_DERIVED:` is recorded whose value is the TRX `total` attribute minus the TRX `executed` attribute and which must also be `0`; and the artifact records the outcome row for each of these four fully-qualified names individually: `QuickFiler.Controllers.Tests.QfcHomeControllerRunAsyncTests.Worker_RunWorkerCompleted_HandlesCompletionCorrectly`, `QuickFiler.Test.TestSupport.WinFormsPumpHostTests.AwaitingSyncContext_FromTheTestThread_ResumesOnThePumpThread`, `QuickFiler.Test.TestSupport.WinFormsPumpHostTests.BothMarshalRoutes_WpfDispatcherAndSyncContext_ExecuteOnThePumpThread`, and `QuickFiler.Controllers.Tests.EfcFormControllerTests.ActionDeleteAsync_AwaitedTwice_LeavesExactlyOneTrashRowInFolderRows`, each `Passed`. + +- [x] [P4-T4] Run the `UtilitiesCS.Test` methods research R7 enumerated as at risk and record their outcomes. Command: identical in shape to [P1-T11] except `LogFileName=p4t4.trx` and `/ResultsDirectory:TestResults\809-p4t4`, with the class filter extended to include `UtilitiesCS.Test.Threading.UiThreadInitApartmentContract_Tests` and `UtilitiesCS.Test.Threading.UiThreadInitRetryContract_Tests`. Acceptance: `FEATURE/evidence/qa-gates/p4-t4-utilitiescs-tests.md` exists with the four schema fields and `EXIT_CODE: 0`; the TRX `ResultSummary/Counters` `failed` attribute is recorded as an explicit numeral, which must be `0`, and a line `SKIPPED_DERIVED:` is recorded whose value is the TRX `total` attribute minus the TRX `executed` attribute and which must also be `0`; and the artifact records the outcome row for each of these six fully-qualified names individually, each `Passed`: `UtilitiesCS.Test.Threading.UiThread_Dispatcher_Tests.Dispatcher_WhenBackingFieldIsNull_ThrowsInvalidOperationExceptionNamingInitialize`, `UtilitiesCS.Test.Threading.UiThread_Dispatcher_Tests.Dispatcher_WhenBackingFieldIsPopulated_ReturnsThatSameInstance`, `UtilitiesCS.Test.OutlookObjects.Folder.WpfDispatcherYieldTests.YieldAsync_WithoutDispatcher_RemainsStrict`, `UtilitiesCS.Test.OutlookObjects.Folder.FolderPredictorTests.EnterUiContextAsync_WhenUiSyncContextPostsSynchronously_CompletesUsingDefaultAction`, `UtilitiesCS.Test.EmailIntelligence.FolderRemapViewer_Tests.SetController_WithSyntheticController_ConfiguresTreeDelegates`, and `UtilitiesCS.Test.EmailIntelligence.FilterOlFoldersViewer_Tests.SetController_WithSyntheticController_ConfiguresBothTreeDelegates`. The method name `Dispatcher_WhenBackingFieldIsNull_ThrowsInvalidOperationExceptionNamingInitialize` carries a deliberately inaccurate suffix and must not be renamed, because its fully-qualified name is quoted inside a committed `TestCaseFilter` evidence artifact. + +- [x] [P4-T5] Audit the 500-line file-size limit across every Write Set source file, after the last formatter run of Phase 4. Command: `(Get-Content -LiteralPath ).Count`, the pinned line-count idiom, for each of `UtilitiesCS/Threading/UiThread.cs`, `UtilitiesCS/Threading/IUiCaptureSource.cs`, `UtilitiesCS/Threading/SyncContextForm.cs`, `UtilitiesCS.Test/Threading/UiThread_Tests.cs`, `UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs`, `UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs`, `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs`, `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs`, `UtilitiesCS.Test/EmailIntelligence/FolderRemapViewer_Tests.cs`, `UtilitiesCS.Test/EmailIntelligence/FilterOlFoldersViewer_Tests.cs`. Acceptance: `FEATURE/evidence/qa-gates/p4-t5-line-counts.md` exists with the four schema fields; it carries one `POSTCHANGE_LINES ` line per file with the matching `BASELINE_LINES` value from `FEATURE/evidence/baseline/p0-t16-line-counts.md` beside it for the seven pre-existing files, which are the ten audited files less the three this delivery creates; it carries a line `PRE_EXISTING_FILES_OVER_500:` followed by an integer and by the enumeration of each file in that set, the set being every audited file whose `BASELINE_LINES` value recorded by [P0-T16] already exceeds 500; and it carries a line `FILES_OVER_500_INTRODUCED:` followed by an integer that must be `0`, being the count of audited files whose post-change count exceeds 500 and which are not in that pre-existing set. One pre-existing overrun is expected. `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs` measured 1066 lines at authoring time, and the only change this delivery makes to it is the single attribute line [P2-T7] adds and gates, so it is over the limit both before and after. If the value [P0-T16] records for that file differs from 1066, the recorded observation is authoritative and the classification does not move, because the classification is derived from the recorded baseline rather than from the figure quoted here. The two `.csproj` files are excluded from this audit because the 500-line limit governs production code, test code and reusable scripts, and a project file is none of those. If `FILES_OVER_500_INTRODUCED:` is greater than `0`, do not create a file outside the Write Set: `spec.md` declares the Write Set authoritative, so a delivery-introduced overrun is a remediation-required outcome to be reported rather than resolved by adding a file. A pre-existing overrun is recorded here and carried to [P6-T13] as a follow-up candidate; it is not a remediation-required outcome of this delivery. + +--- + +### Phase 5 — Final QC loop + +The four steps run in the stated order. If any step fails or any step changes a file, restart from [P5-T1]. Each restart appends a new numbered pass section to the same artifact rather than overwriting the previous pass, so the passes sit side by side. + +**Schema fields are written once per artifact.** Where a restart appends a new numbered pass section to an existing Phase 5 artifact, the appended section MUST NOT repeat the schema fields `Timestamp:`, `Command:`, `EXIT_CODE:` or `Output Summary:`. Those four are written once, by the pass that finally closes the loop clean, and the artifact carries them at the top. Each appended pass section records its own result under distinct field names, for example `PASS_2_EXIT_CODE:` and `PASS_2_OUTPUT_SUMMARY:`. The reason is that the evidence collector takes the FIRST occurrence of a duplicated schema field, so an artifact that repeated `EXIT_CODE:` per pass would render the failed first pass as the artifact's result even after the loop closed clean. + +- [x] [P5-T1] Step 1, formatting. Commands, after the SDK preamble: `git status --porcelain --untracked-files=all` recorded as the before-image; `dotnet tool run csharpier format .`; `git status --porcelain --untracked-files=all` recorded as the after-image. The exit code cannot distinguish a clean run from a repairing one, and the `Formatted files` figure is CSharpier's processed-file count rather than its rewritten-file count, so the before-and-after tree comparison is the observation that decides this gate. Acceptance: `FEATURE/evidence/qa-gates/p5-t1-format.md` exists with the four schema fields and `EXIT_CODE: 0`; `Output Summary:` reproduces the verbatim `Formatted files in ms.` line and both porcelain images in fenced blocks; and the pass is recorded as CLEAN only when the two images are byte-identical. When they are not, the artifact records the differing paths and the diff hunk for each, the changed files are committed, and the loop restarts from this task. A restart appends its pass section under the distinct field names the Phase 5 schema-field convention above defines and does not repeat the four schema fields, which the closing pass writes once at the top of this artifact. + +- [x] [P5-T2] Step 1 verification, formatting check. Command, after the SDK preamble: `dotnet tool run csharpier check .`. Acceptance: `FEATURE/evidence/qa-gates/p5-t2-format-check.md` exists with the four schema fields and `EXIT_CODE: 0`; `Output Summary:` reproduces the verbatim `Checked files in ms.` line; and the artifact carries an arithmetic section comparing the observed `` against the value located in `FEATURE/evidence/baseline/p0-t9-csharpier-check.md` by its `BASELINE_CHECKED_FILES:` token, with the expected value being that recorded integer plus exactly `3`. The plus-three is the three files this delivery creates: `UtilitiesCS/Threading/IUiCaptureSource.cs`, `UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs`, and `UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs`. The expected value is derived from the recorded token rather than from any figure tabled in this plan, so a baseline correction propagates without editing this task. `coverage/` and `TestResults/` are git-ignored and CSharpier 1.2.6 honours `.gitignore`, so neither the derived coverage settings file nor any results tree enters this count. + +- [x] [P5-T3] Step 2, linting by .NET analyzers. Command, after MSBuild resolution: `& $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true`. Acceptance: `FEATURE/evidence/qa-gates/p5-t3-analyzer-build.md` exists with the four schema fields and `EXIT_CODE: 0`; `Output Summary:` reproduces ` 0 Warning(s)` and ` 0 Error(s)` verbatim; and the artifact records the build-output arrow-line count and compares it against the value located in `FEATURE/evidence/baseline/p0-t10-analyzer-build.md` by its `BASELINE_PROJECT_COUNT:` token. This delivery adds no project and removes none, so the two counts must be equal. + +- [x] [P5-T4] Step 3, type checking by nullable analysis. Command, after MSBuild resolution: `& $msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true`. Acceptance: `FEATURE/evidence/qa-gates/p5-t4-nullable-build.md` exists with the four schema fields and `EXIT_CODE: 0`; `Output Summary:` reproduces ` 0 Warning(s)` and ` 0 Error(s)` verbatim; and the artifact states in one sentence that `/p:Nullable=enable` was not passed and that `/t:Rebuild` rather than `/t:Build` was used, with the reason for each. + +- [x] [P5-T5] Step 4, the full nine-assembly suite with coverage. Build the derived settings to `coverage\809-effective-coverage.config` exactly as [P0-T13] does, then run `dotnet-coverage collect --output coverage\809-p5-final.cobertura.xml --output-format cobertura --settings coverage\809-effective-coverage.config -- $vstest` followed by the nine-assembly list and the full-suite switch set with `` `p5t5`. Acceptance: `FEATURE/evidence/qa-gates/p5-t5-tests-coverage.md` exists with the four schema fields and `EXIT_CODE: 0`; `Output Summary:` reproduces the `Test Run Successful.`, `Total tests: ` and ` Passed: ` lines verbatim; the TRX `ResultSummary/Counters` `failed` attribute is recorded as an explicit numeral, which must be `0`, and a line `SKIPPED_DERIVED:` is recorded whose value is the TRX `total` attribute minus the TRX `executed` attribute and which must also be `0`; the observed `` is compared against the value located in `FEATURE/evidence/baseline/p0-t12-vstest.md` by its `BASELINE_TOTAL_TESTS:` token, with the expected value being that recorded integer plus exactly `17`, the count of test methods this delivery adds — four in `UiThreadInitApartmentContract_Tests` from [P2-T2], six in `UiThreadInitRetryContract_Tests` being four from [P2-T3] plus one from [P2-T4] plus one from [P2-T5], and seven in `SynchronizationContextAwaiter_Tests` from [P2-T8]; this delivery removes no test, and [P4-T1] changes the signature of one existing method without renaming it, so the total is baseline plus seventeen exactly; and the artifact carries the four bare-integer lines `FINAL_FIRSTPARTY_LINES_COVERED:`, `FINAL_FIRSTPARTY_LINES_VALID:`, `FINAL_FIRSTPARTY_BRANCHES_COVERED:`, `FINAL_FIRSTPARTY_BRANCHES_VALID:` produced by the pinned aggregation snippet, together with `FINAL_FIRSTPARTY_LINE_PCT:` and `FINAL_FIRSTPARTY_BRANCH_PCT:` to two decimal places and a first-party per-package breakdown table; and the artifact records, by name from the selected TRX, the individual result row for `UtilitiesCS.Test.Extensions.DictionaryExtensions_Tests.TryAddValuesAsync_UpdatesExistingValue`, giving its outcome and duration, because the AC5 clause naming that test names the full-suite run as the place it is recorded and an aggregate pass count does not carry the name. The three dedicated repetitions of that test are [P5-T6] and are separate from this row. The artifact additionally records that `/EnableCodeCoverage` was not passed and why. These are locally-filtered nine-assembly figures, not CI figures, and the artifact states so. + +- [x] [P5-T6] Run `UtilitiesCS.Test.Extensions.DictionaryExtensions_Tests.TryAddValuesAsync_UpdatesExistingValue` three times and record each repetition, as decision D5 requires. Command, repeated three times with `` taking the values `1`, `2` and `3`: after vstest resolution, `& $vstest UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll '/Tests:TryAddValuesAsync_UpdatesExistingValue' '/InIsolation' '/Logger:trx;LogFileName=p5t6r.trx' '/ResultsDirectory:TestResults\809-p5t6r'`. Acceptance: the three artifacts `FEATURE/evidence/qa-gates/p5-t6-tryaddvalues-rep1.md`, `...-rep2.md` and `...-rep3.md` each exist with the four schema fields and each records the TRX outcome and duration for that one fully-qualified test name; and the count of repetitions whose outcome is `Passed` is `3`. That test is the documented issue #780 intermittent flake with an identical `TaskCanceledException` signature, so a single failure is not sufficient evidence of a regression. If fewer than three repetitions pass, do not check AC5 off: write `ATTRIBUTION-REVIEW-REQUIRED` into each of the three artifacts together with the observed outcomes, and report the delivery as remediation-required rather than as PASS. + +- [x] [P5-T7] Record the closure of the toolchain loop. Acceptance: `FEATURE/evidence/qa-gates/p5-t7-loop-closure.md` exists with `Timestamp:` and a table with one row per loop pass, each row naming the pass number and the five artifact paths [P5-T1] through [P5-T5] wrote for that pass, being `FEATURE/evidence/qa-gates/p5-t1-format.md`, `FEATURE/evidence/qa-gates/p5-t2-format-check.md`, `FEATURE/evidence/qa-gates/p5-t3-analyzer-build.md`, `FEATURE/evidence/qa-gates/p5-t4-nullable-build.md` and `FEATURE/evidence/qa-gates/p5-t5-tests-coverage.md`; the loop has four steps and five artifacts because step 1 is recorded twice, once for the write-mode formatter run and once for its read-only check; the final row records that all four steps completed without a failure and without any file change, evidenced by the byte-identical before-and-after porcelain images recorded in that pass's `p5-t1-format.md` section; and the artifact carries a line `TOOLCHAIN_LOOP_CLEAN_PASS: `. + +--- + +### Phase 6 — Coverage comparison, acceptance-criteria check-off and closure + +- [x] [P6-T1] Compare the per-file line coverage of `UtilitiesCS/Threading/UiThread.cs` against the Phase 0 baseline, using the pinned per-file lookup against `coverage\809-p5-final.cobertura.xml`. Acceptance: `FEATURE/evidence/qa-gates/p6-t1-uithread-file-coverage.md` exists with the four schema fields; it carries `FINAL_UITHREAD_LINES_COVERED:`, `FINAL_UITHREAD_LINES_VALID:`, `FINAL_UITHREAD_LINE_PCT:` to two decimal places, and `FINAL_UITHREAD_UNCOVERED_LINES:` as an ascending comma-separated list; it quotes the `BASELINE_UITHREAD_LINE_PCT:` value from `FEATURE/evidence/baseline/p0-t14-uithread-file-coverage.md` beside it; and both of these hold: `FINAL_UITHREAD_LINE_PCT` is at least `80.00`, which is the `CLAUDE.md` floor that takes precedence over the `.claude/rules/general-unit-test.md` floor per the precedence order in `.claude/skills/policy-compliance-order/SKILL.md`; and `FINAL_UITHREAD_LINE_PCT` is strictly greater than `BASELINE_UITHREAD_LINE_PCT`. The artifact additionally records, for each line number the baseline listed as uncovered, whether it is covered now, and names the test that covers it. + +- [x] [P6-T2] Measure changed-line coverage over the three production Write Set files. Commands: `git add -N UtilitiesCS/Threading/IUiCaptureSource.cs`, then `git diff pre-809-base -- UtilitiesCS/Threading/UiThread.cs UtilitiesCS/Threading/SyncContextForm.cs UtilitiesCS/Threading/IUiCaptureSource.cs`, together with `git status --porcelain --untracked-files=all -- UtilitiesCS/Threading/` so a path that is not yet committed is still enumerated. The diff is anchored to `pre-809-base` and compares the working tree against that ref rather than against `HEAD`, because [P6-T15] has not yet committed; the `git add -N` span is what makes the newly created interface file visible to the diff at all. Map every added line to its post-change line number from the hunk headers, then look each up with the pinned per-file lookup, counting a changed line number once and treating it as covered when any matching element carries `hits` greater than zero. A line number that matches no element is not executable and is excluded from both numerator and denominator. Acceptance: `FEATURE/evidence/qa-gates/p6-t2-changed-line-coverage.md` exists with the four schema fields; it carries a per-file table of added, executable, covered and uncovered counts; it carries `CHANGED_LINE_COVERAGE=` followed by a percentage to two decimal places that is at least `90.00`; and it carries `UNCOVERED_ENUMERATION_COUNT=` followed by an integer together with the explicit enumeration of any uncovered changed line; it additionally carries a per-member table with one row for each member this delivery adds — `IUiCaptureSource` (declaration only, no executable line, recorded as such), `UiThread.SyncContextFormFactory`, `UiThread.DefaultSyncContextFormFactory`, `UiThread.NonStaInitMessagePrefix` (constant, no executable line), `UiThread.NonStaInitMessage`, `UiThread.ResetForTesting`, the `InitLock` and `_initialized` region of `UiThread.Init`, and the replaced `SynchronizationContextAwaiter.IsCompleted` body — each row giving that member's executable, covered and uncovered added-line counts and its percentage to two decimal places; and every row whose executable count is greater than zero shows a percentage of at least `90.00`, or is recorded with the named test that leaves it uncovered and the reason. This measurement is the mechanical form of the AC6 clause requiring each newly added member at 90% or better: every new member of this delivery consists entirely of added lines in these three files, so the added-line set is exactly the new-member set plus the replaced `IsCompleted` body. No changed line can lose coverage, because every changed line in these files is either an added line measured here or a deleted line, and a deleted line has no coverage to lose; the artifact records that reasoning. + +- [x] [P6-T3] Compare the aggregate first-party coverage against the Phase 0 baseline, with an explicit comparability test on the denominators. Read `BASELINE_FIRSTPARTY_LINES_VALID:` from `FEATURE/evidence/baseline/p0-t13-coverage.md` and `FINAL_FIRSTPARTY_LINES_VALID:` from `FEATURE/evidence/qa-gates/p5-t5-tests-coverage.md`. Acceptance: `FEATURE/evidence/qa-gates/p6-t3-aggregate-coverage.md` exists with the four schema fields and carries a line `DENOMINATOR_DELTA_PCT:` giving the absolute difference between the two denominators as a percentage of the baseline denominator, to four decimal places, followed by exactly one of two recorded outcomes. Outcome A, when `DENOMINATOR_DELTA_PCT` is at most `1.0000`: the artifact records `COVERAGE COMPARISON: COMPARABLE` and both of these hold — `FINAL_FIRSTPARTY_LINE_PCT` is at least `BASELINE_FIRSTPARTY_LINE_PCT` minus `0.50` percentage points, and `FINAL_FIRSTPARTY_BRANCH_PCT` is at least `BASELINE_FIRSTPARTY_BRANCH_PCT` minus `0.50` percentage points. Outcome B, when `DENOMINATOR_DELTA_PCT` exceeds `1.0000`: the artifact records `COVERAGE COMPARISON: NOT COMPARABLE` together with both denominators and the reason, and the no-regression obligation is discharged instead by the two file-scoped measurements in [P6-T1] and [P6-T2], both of which the artifact must cite by path and by recorded figure. Both outcomes are gated; neither is a waiver. + +- [x] [P6-T4] Reconcile the AC2 regression clause against the decision-D5 measurement. Read the `MTA_INITIALIZE_OUTCOME:` value from `FEATURE/evidence/other/p0-t15-mta-synccontextform-measurement.md`. Acceptance: `FEATURE/evidence/other/p6-t4-ac2-regression-reconciliation.md` exists with `Timestamp:` and records exactly one of two dispositions. Disposition A, when the measured value is `COMPLETED`: the artifact records that the #782 mechanism narrative is refuted on this host, because that narrative requires `new SyncContextForm(); Show();` to throw on an MTA thread and the measurement shows it does not; and it records that the AC2 clause "the #782 regression scenario is reproduced as a test" is discharged by the forced-throw scenario driven through the factory seam, naming `UiThreadInitRetryContract_Tests.AutoScaleFactor_ReadFromMtaThreadAfterAFailedInit_ThrowsAndDoesNotReEnterTheFactory` as the anti-retry-storm test and its invocation-count assertion as the mechanism. Disposition B, when the measured value is `THREW`: the artifact records the exception type, records that the #782 narrative is corroborated, and records that the same forced-throw test additionally covers the real failure mode. In both dispositions the artifact records that the AC1 precondition makes `new SyncContextForm()` at `UtilitiesCS/Threading/UiThread.cs:51` unreachable from any non-STA caller, which is why the AC2 design is safe whichever value was measured, and that the assertion is an invocation count rather than a wall-clock duration. + +- [x] [P6-T5] Check off AC1 in the `## Acceptance Criteria` section of `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/spec.md`, changing `- [ ] AC1:` to `- [x] AC1:` and leaving the clause text unchanged. Acceptance: the AC1 line begins `- [x] AC1:`; and the evidence cited in the task's completion note is `FEATURE/evidence/regression-testing/p3-t6-pass-after.md` for the two apartment-rejection tests, `FEATURE/evidence/qa-gates/p4-t3-quickfiler-tests.md` for the reconciled MTA caller, and the [P3-T1] and [P4-T1] tasks for the implementation. Check off exactly this one criterion in this task. + +- [x] [P6-T6] Check off AC2 in the `## Acceptance Criteria` section of `spec.md`, changing `- [ ] AC2:` to `- [x] AC2:` and leaving the clause text unchanged. Acceptance: the AC2 line begins `- [x] AC2:`; and the cited evidence is `FEATURE/evidence/regression-testing/p3-t6-pass-after.md` for the retry test, `FEATURE/evidence/other/p6-t4-ac2-regression-reconciliation.md` for the regression-scenario clause, and the [P3-T2] task for the implementation. Check off exactly this one criterion in this task. + +- [x] [P6-T7] Check off AC3 in the `## Acceptance Criteria` section of `spec.md`, changing `- [ ] AC3:` to `- [x] AC3:` and leaving the clause text unchanged. Acceptance: the AC3 line begins `- [x] AC3:`; and the cited evidence is `FEATURE/evidence/regression-testing/p3-t6-pass-after.md` for the seven awaiter cases, `FEATURE/evidence/qa-gates/p4-t3-quickfiler-tests.md` for the `EfcFormControllerTests` and `WinFormsPumpHostTests` rows, and the [P3-T4] task for the implementation. Check off exactly this one criterion in this task. + +- [x] [P6-T8] Check off AC4 in the `## Acceptance Criteria` section of `spec.md`, changing `- [ ] AC4:` to `- [x] AC4:` and leaving the clause text unchanged. Acceptance: the AC4 line begins `- [x] AC4:`; and the cited evidence is `FEATURE/evidence/qa-gates/p5-t5-tests-coverage.md` for the seventeen added tests appearing in the discovered total, being four from [P2-T2], six from [P2-T3], [P2-T4] and [P2-T5], and seven from [P2-T8], `FEATURE/evidence/qa-gates/p4-t4-utilitiescs-tests.md` for their outcomes, and the [P1-T4], [P1-T6] and [P1-T7] tasks for the fake-dispatcher seam. The artifact citation must also record that no test added by this delivery requires a live Outlook process and that every new type in `UtilitiesCS.Test` was verified non-`Form`-derived by `UtilitiesCS.Test/NoLiveFormInTestAssemblyTests.cs`. Check off exactly this one criterion in this task. + +- [x] [P6-T9] Check off AC5 in the `## Acceptance Criteria` section of `spec.md`, changing `- [ ] AC5:` to `- [x] AC5:` and leaving the clause text unchanged. Acceptance: the AC5 line begins `- [x] AC5:`; and the cited evidence is `FEATURE/evidence/other/p0-t15-mta-synccontextform-measurement.md` for the MTA construction measurement, `FEATURE/evidence/other/p6-t4-ac2-regression-reconciliation.md` for the justification of the AC2 test against that measured result, and the three artifacts `FEATURE/evidence/qa-gates/p5-t6-tryaddvalues-rep1.md`, `...-rep2.md` and `...-rep3.md` for the three repetitions. This criterion may not be checked off when fewer than three repetitions recorded `Passed`. Check off exactly this one criterion in this task. + +- [x] [P6-T10] Check off AC6 in the `## Acceptance Criteria` section of `spec.md`, changing `- [ ] AC6:` to `- [x] AC6:` and leaving the clause text unchanged. Acceptance: the AC6 line begins `- [x] AC6:`; the cited evidence is `FEATURE/evidence/qa-gates/p5-t5-tests-coverage.md` for the coverage run, `FEATURE/evidence/qa-gates/p6-t1-uithread-file-coverage.md` for the per-file figure, `FEATURE/evidence/qa-gates/p6-t2-changed-line-coverage.md` for the newly added members and the changed lines, and `FEATURE/evidence/qa-gates/p6-t3-aggregate-coverage.md` for the aggregate comparison; and the completion note records `AC6-COLLECTOR-SUBSTITUTION` stating that coverage was collected by `dotnet-coverage collect ... -- vstest.console.exe ...` rather than by `vstest.console.exe ... /EnableCodeCoverage`, because `scripts/vscode/TaskMaster.cli.runsettings` carries no data collector and `scripts/vscode/Invoke-MSTestWithCoverage.ps1:19-26` records that the omission is deliberate since the two collectors conflict. Check off exactly this one criterion in this task. + +- [x] [P6-T11] Mirror the AC1 through AC4 check-off into the `## Acceptance Criteria` section of `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/issue.md`, changing each of the four `- [ ] AC` prefixes to `- [x] AC` and leaving every clause text unchanged. `spec.md` remains the sole acceptance-criteria source under work mode `full-bug`; this mirror keeps the requirements document consistent with it and adds no criterion. `issue.md` carries no AC5 or AC6, so no line is added to it. Acceptance: all four AC lines in `issue.md` begin `- [x] AC`; `git status --porcelain -- docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/issue.md` lists the file as modified; and `git diff HEAD -- docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/issue.md` shows exactly four changed lines. This diff is anchored to `HEAD` rather than to `pre-809-base`, unlike every source-file diff in this plan. `issue.md` did not exist at `pre-809-base`: the merge-base of `origin/main` and `HEAD` predates the commit that added this feature folder, so a diff against `pre-809-base` renders the whole file as added and cannot report four changed lines. `issue.md` is already committed at `HEAD` and its only uncommitted change at this point is the four check-off marks, so `HEAD` is the ref that isolates them. `HEAD` remains an explicit ref operand, so the diff is not the ambient worktree-against-index comparison. + +- [x] [P6-T12] Verify that no test-results file and no coverage document entered the tree through this delivery. Commands, in this order: `git status --porcelain --untracked-files=all`; then `git ls-files -- TestResults coverage`; then `git diff --name-only pre-809-base -- '*.trx' '*.cobertura.xml' '*.coverage'`. `TestResults/` matches `.gitignore:39` and the contents of `coverage/` match `.gitignore:144`, while `.gitignore:145` exempts the tracked `coverage/.gitkeep`, so the second command is expected to return that one path and nothing else. A repository-wide `git ls-files -- '*.trx' '*.cobertura.xml' '*.coverage'` is deliberately not used: earlier feature folders under `docs/features/active/` committed their own `.trx` and `.cobertura.xml` evidence, so that spelling enumerates hundreds of pre-existing tracked paths and a count asserted over it to be `0` could never pass. Acceptance: `FEATURE/evidence/other/p6-t12-untracked-output-check.md` exists with the four schema fields; it reproduces the output of `git ls-files -- TestResults coverage` verbatim and that output is exactly the single line `coverage/.gitkeep`; it carries `DELIVERY_ADDED_RESULTS_FILE_COUNT:` followed by an integer that must be `0`, that integer being the number of lines the anchored `git diff --name-only` span returns; and it reproduces the porcelain output verbatim, with no line of that output naming a path under `TestResults/` or `coverage/`. + +- [x] [P6-T13] Record the closure summary and the residual risks that the automated suite does not close. Acceptance: `FEATURE/evidence/other/p6-t13-closure-summary.md` exists with `Timestamp:` and records, each in its own section: the six acceptance criteria with their check-off state and their evidence paths; the residual ordering risk that research R6 enumerated at eleven production await sites where no existing test asserts ordering, named individually as `QuickFiler/Controllers/EfcFormController.cs:877`, `QuickFiler/Controllers/QfcCollectionController.cs:782` and the two `TaskScheduler.FromCurrentSynchronizationContext()` sites at `QuickFiler/Controllers/QfcItemController.ViewerSetup.cs:67` and `QuickFiler/Controllers/EfcItemController.cs:201`, stated as residual rather than as covered; the four production sites that gain a possible new throw, `TaskMaster/AppGlobals/AppOlObjects.cs:367`, `UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs:179`, `UtilitiesCS/Threading/ThreadMonitor.cs:143` and the hypothetical off-UI-thread callers of `UtilitiesCS/EmailIntelligence/OlFolderTools/FolderRemap/FolderRemapViewer.cs:40` and `.../FilterOlFolders/FilterOlFoldersViewer.cs:79`, each unreachable in production after `TaskMaster/ThisAddIn.cs:35` runs; the `ThreadMonitor.cs:143` site is recorded for completeness rather than as a regression risk, because it reads `UiThread.UiSyncContext` inside `PingAndAwaitDiagnosticWindow()`, declared at `UtilitiesCS/Threading/ThreadMonitor.cs:138` and carrying `[ExcludeFromCodeCoverage]` at `:137`, and no test in this repository reaches that member, its only in-repository call site being `UtilitiesCS/Threading/ThreadMonitor.cs:109`; the manual live-host verification that is reported separately and is not an acceptance criterion, being QuickFiler launch, item load and breadcrumb open; and the follow-up candidates that are not in this delivery, being the routing of `QfcHomeController` through `IUiDispatcher`, the reconciliation of the 80% versus 85% coverage-floor divergence between `CLAUDE.md` and `.claude/rules/general-unit-test.md`, the closure of #784, #787 and #788 with a pointer to #809, the correction of the GitHub issue body for #809 which still carries the superseded bare-owning-thread-identity sentence that `issue.md:59` has already corrected locally, and the pre-existing 500-line overrun in `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs` that [P4-T5] records under `PRE_EXISTING_FILES_OVER_500:`, which this delivery increases by exactly the one attribute line [P2-T7] adds. + +- [x] [P6-T14] Verify that no evidence artifact this delivery wrote contains an absolute host path or a host account token. This task runs after every other artifact-writing task in this plan, because a scan cannot cover a file that does not yet exist when it runs. Command: for each file under `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/evidence/`, search its content for the run-time-derived account token produced by `Split-Path -Leaf $env:USERPROFILE`, for the run-time-derived machine token `$env:COMPUTERNAME`, and for the literal `C:\Users\`. The search script itself is exempt from its own scan because it names the tokens only as variables. Acceptance: `FEATURE/evidence/other/p6-t14-artifact-sanitisation.md` exists with the four schema fields; it carries `SCANNED_ARTIFACT_COUNT:` followed by an integer greater than `0`; the scanned set explicitly includes `FEATURE/evidence/other/p6-t12-untracked-output-check.md` and `FEATURE/evidence/other/p6-t13-closure-summary.md`; and it carries `HOST_TOKEN_HIT_COUNT:` followed by an integer that must be `0`, together with the enumeration of any hit by file and line. After writing that artifact, re-run the identical scan over this artifact alone and append a section headed `Second pass` carrying `SECOND_PASS_SCANNED_ARTIFACT_COUNT:` valued `1` and `SECOND_PASS_HOST_TOKEN_HIT_COUNT:` valued `0`. The appended section introduces new field names and does not rename, qualify or duplicate the schema fields `Timestamp:`, `Command:`, `EXIT_CODE:` and `Output Summary:`, which are written once by the first pass. The artifact records the two tokens by their derivation rather than by their value. + +- [x] [P6-T15] Commit every evidence artifact and every Write Set change, and verify the working tree is clean afterwards. Commands: `git add docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809` and the twelve Write Set paths explicitly by path, never `git add -A`, so no unrelated untracked file is swept onto this branch; then `git commit`; then `git status --porcelain --untracked-files=all`. Acceptance: `git ls-files --error-unmatch` exits 0 for every artifact path this plan names, enumerated explicitly in the completion note; `git status --porcelain --untracked-files=all` produces no line naming a path under `UtilitiesCS/`, under `UtilitiesCS.Test/` or under `QuickFiler.Test/`; it produces no line naming a path under `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/` other than at most one ` M` line for `plan.2026-09-07T20-14.md`, whose only uncommitted content is the check-off marks of this phase's tasks and which [P6-T16] commits; and it produces no line naming a path under `.claude/`, because `.claude/agent-memory/` is tracked in this repository and this delivery must not carry any change to it. + +- [x] [P6-T16] Commit the plan-file check-off marks that [P6-T15] could not carry, because a task's check-off is written after that task's own commit. Commands: `git add docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/plan.2026-09-07T20-14.md`, then `git commit`, then `git diff HEAD -- docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/plan.2026-09-07T20-14.md`. Acceptance: `git log -1 --name-only --pretty=format:` lists exactly one path, the plan file; and `git diff HEAD -- docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/plan.2026-09-07T20-14.md` shows exactly one changed line, the check-off of this task itself. That single residual is a fixpoint of the check-off protocol rather than a defect: no task can commit the mark that records its own completion. The completion note states it explicitly and the orchestrator carries it in the branch's next commit. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/policy-audit.2026-09-08T01-35.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/policy-audit.2026-09-08T01-35.md new file mode 100644 index 000000000..a2e52ff79 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/policy-audit.2026-09-08T01-35.md @@ -0,0 +1,339 @@ +# Policy Audit — issue #809, `uithread-init-contract-residuals-784-787-788` + +- Artifact timestamp: 2026-09-08T01-35 (local, EDT / UTC-4) +- Component: `UtilitiesCS/Threading` (UiThread initialization contract and awaiter predicate) +- Feature folder: `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809` +- Work mode: `full-bug` (marker read from `issue.md:12`) — `spec.md` is the sole acceptance-criteria source +- Base ref: `04a54e681bd21e841e124c016df30672ee701b75`; branch head `ef431e6a` (9 commits) +- Reviewer verification mode: read-only. No source file, plan, policy document, or orchestration state was modified. + +## Template provenance + +The `policy-audit-template-usage` skill requires the template to be resolved through +`mcp__drm-copilot__resolve_policy_audit_template_asset`. No MCP tool is exposed to this reviewer +session, so the template could not be resolved and `mcp__drm-copilot__validate_orchestration_artifacts` +could not be run. This artifact is hand-authored and preserves all twelve canonical major headings +required by that skill. The artifact is not marked BLOCKED, because every substantive section below +is backed by direct verification against the tree and the committed evidence. + +## Rejected Scope Narrowing + +None detected. The caller supplied a diff restricted to `UtilitiesCS/`, `UtilitiesCS.Test/` and +`QuickFiler.Test/`, but also supplied the anchored changed-path set and stated that exactly twelve +paths changed outside the feature folder. The restriction therefore describes the full branch diff +rather than narrowing it, and the audit below covers the whole set. The caller's instruction to treat +`spec.md` rather than `issue.md` as the AC source is the correct `full-bug` behaviour under +`acceptance-criteria-tracking`, not a narrowing. + +Recorded constraint on independent verification: the caller prohibited git invocation in this +session. The twelve-path changed set and the commit list are therefore taken from the caller's +pre-computed patch and path list rather than recomputed by the reviewer. Every claim that could be +checked against the working tree, the committed evidence artifacts, and the Cobertura documents was +checked directly and is reported as verified below. + +## Evidence Location Compliance + +- Canonical scheme required by `.claude/skills/evidence-and-timestamp-conventions/SKILL.md` is + `/evidence//`. The delivery wrote 44 artifacts under `evidence/baseline/`, + `evidence/qa-gates/`, `evidence/regression-testing/` and `evidence/other/`. All four are canonical + sub-paths. **PASS**. +- Forbidden `artifacts/` sub-paths (`artifacts/baselines/`, `artifacts/baseline/`, `artifacts/qa/`, + `artifacts/qa-gates/`, `artifacts/evidence/`, `artifacts/coverage/`, `artifacts/regression-testing/`, + `artifacts/post-change/`): none exists in the worktree. `artifacts/` contains only `orchestration/` + and pre-existing `pr_body_*` files, none of which this branch added. **PASS**. +- `validate_evidence_locations.py` is not present in this repository, so the scripted scan could not + be run; the equivalent check was performed by directory enumeration. No `EVIDENCE_LOCATION_OVERRIDE_REJECTED` + condition arose. + +## Executive Summary + +The delivery fixes three defects in `UtilitiesCS/Threading/UiThread.cs` (#787 apartment precondition, +#788 latch-before-`Initialize()`, #784 reference-equality awaiter predicate), adds one narrow internal +interface, two test-only seams, and 17 tests. All four toolchain gates are green in a single final +pass, the full nine-assembly suite is 7137/7137, and the coverage of the file in scope rises from +76.83% to 96.03% line. + +Every headline figure the executor reported was re-derived by this reviewer from the raw Cobertura +documents rather than accepted from the artifacts. All of them reproduce exactly. + +**Blocking findings: 0.** + +Two acceptance criteria are graded PARTIAL rather than PASS, both for evidence-labelling or +literal-wording reasons rather than for defects in the delivered code: + +- AC5: the `[P0-T15]` apartment label `MTA_INITIALIZE_OUTCOME` rests on an inference that this same + delivery later falsified by direct measurement. The run almost certainly executed STA, in which case + no MTA measurement was taken and the stated refutation of the #782 narrative is unsupported. The + AC2 design does not depend on the result, so this is an evidence defect, not a code defect. +- AC6: coverage was collected with `dotnet-coverage collect` rather than the `/EnableCodeCoverage` + collector the criterion names, and the raw Cobertura document is git-ignored rather than stored under + `evidence/qa-gates/`. Every measurable clause of AC6 is met and independently verified. + +## 1. General Unit Test Policy Compliance + +| Requirement (`.claude/rules/general-unit-test.md`) | Verdict | Evidence | +|---|---|---| +| Independence | PASS | Every new test wraps its Act in `UiThreadStateScope`, which snapshots all eleven `UiThread` statics plus the factory and restores them on disposal, including captured nulls (`UiThreadStateScope.cs:320-335`). | +| Isolation | PASS | Each method targets one behaviour; the three classes that mutate `UiThread` statics carry `[DoNotParallelize]`, as do the three pre-existing writer classes the delivery amended. | +| Fast execution | PASS | `p4-t4-utilitiescs-tests.md` and `p4-t3-quickfiler-tests.md` record sub-second durations for all named rows. | +| Determinism | PASS | No `Thread.Sleep`, no `Task.Delay`, no wall-clock assertion. The anti-retry-storm assertion is a factory invocation count (`UiThreadInitContract_Tests.cs:750`). `FakeTimeProvider` is injected where a clock is needed and is never advanced. | +| Readability | PASS | Arrange/Act/Assert comments on every added method; names state the scenario and expected outcome. | +| No external dependencies | PASS | Zero `Microsoft.Office.Interop` references in the three touched test files; no network, DB, or process. | +| No temporary files | PASS | No file creation in any added test. | +| Test file location mirrors source | PASS | `UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs` mirrors `UtilitiesCS/Threading/`; the helper lives under `UtilitiesCS.Test/TestHelpers/`. | +| Coverage exclusion policy — no production path excluded | PASS | `coverage.config` excludes only third-party module paths (Deedle, FSharp, Castle.Core, FluentAssertions, Moq, Microsoft.Testing, MSTest). The derived `coverage/809-effective-coverage.config` appends exactly one entry, `.*\.Test\.dll$`, which matches test assemblies only. No first-party production path is excluded, and no `[ExcludeFromCodeCoverage]` was added by this delivery. | +| Scenario completeness | PASS | Positive (STA accept), negative (MTA reject), boundary (`_uiThreadId == -1` sentinel, null ambient), error handling (throwing capture source), concurrency (two racing STA callers), state transition (failed-then-successful retry). | + +Determinism infrastructure note: `SharedStaDispatcherHost` and `StaDispatcherHost` both signal readiness +through an `AutoResetEvent` and shut down through `BeginInvokeShutdown` + `Join`, so no test leaves a +live dispatcher on a pooled worker. This is the hazard #782 finding C10 removed, and the delivery does +not reintroduce it. + +## 2. General Code Change Policy Compliance + +| Requirement (`.claude/rules/general-code-change.md`) | Verdict | Evidence | +|---|---|---| +| Simplicity first | PASS | The predicate is a flat sequence of guarded returns; the retry fix is a `lock` plus a `bool`. `IUiCaptureSource` declares exactly the nine members `Initialize()` consumes and nothing more. | +| Reusability | PASS | The reflection over `UiThread` private statics is centralised in one helper so each field name appears once per assembly (`UiThreadStateScope.cs:162-173`). | +| Extensibility / no public break | PASS | `UiThread`'s public surface is unchanged. The two new members are `internal`. `SyncContextForm` gains an interface and no member. | +| Separation of concerns | PASS | The interface is what removes the WinForms dependency from the initialization path under test. | +| Mandatory toolchain loop | PASS | Format, lint, type-check, test completed in one clean pass (`p5-t1` through `p5-t5`, closure at `p5-t7`). Three earlier restarts are recorded and attributed to their own phases. | +| 500-line file limit | FAIL (non-blocking) | `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs` measures 1067 lines at head (reviewer-verified with `awk 'END{print NR}'`), against a 1066-line baseline. See Finding F4. | +| Error handling — fail fast | PASS | The new precondition throws `InvalidOperationException` with a named prefix and the observed apartment. No exception is swallowed; a throwing `Initialize()` still propagates. | +| Logging | PASS | No ad-hoc console output added. The decision not to add a logger is stated in `spec.md` and `UiThread` has none today. | +| Naming | PASS | `PascalCase` types/members, `camelCase` locals, `_camelCase` private statics, matching the file's existing style. | +| Dependencies | PASS | No package added, removed, or upgraded. | +| I/O boundaries | PASS | The only I/O-ish dependency (a WinForms form) is now behind `IUiCaptureSource`. | + +## 3. Language-Specific Code Change Policy Compliance (C#) + +| Requirement (`CLAUDE.md` C#1–C#7) | Verdict | Evidence | +|---|---|---| +| CSharpier formatting, pinned version, via `dotnet tool run` | PASS | `p5-t1-format.md` records byte-identical before/after `git status --porcelain --untracked-files=all` images (both empty) and `Formatted 1611 files`; `p5-t2-format-check.md` records `dotnet tool run csharpier check .` exit 0 at `Checked 1611 files`. The 1611 reconciles to the recorded `BASELINE_CHECKED_FILES: 1608` plus the three files this delivery creates — the reviewer confirmed exactly three new files in the changed-path set. | +| .NET analyzers via `/t:Rebuild` | PASS | `p5-t3-analyzer-build.md`: exit 0, `0 Warning(s)`, `0 Error(s)`, 18 projects, equal to the recorded baseline project count. `/t:Rebuild` used, with the correct rationale recorded (a warm `/t:Build` skips `CoreCompile` and runs no analyzer). | +| Nullable type-check via `/t:Rebuild /p:TreatWarningsAsErrors=true`, without `/p:Nullable=enable` | PASS | `p5-t4-nullable-build.md`: exit 0, `0 Warning(s)`, `0 Error(s)`. Both new/changed production files carry `#nullable enable` at line 1 (verified: `UiThread.cs:1`, `IUiCaptureSource.cs:1`), so they are inside the per-file opt-in the gate enforces. | +| Null-safety by default | PASS | `_syncContextForm` is `IUiCaptureSource?`; `ambient` is `SynchronizationContext?`; the `-1` sentinel is checked explicitly. | +| net48 constraints (no `init`, `record`, `record struct`) | PASS | No new value type; the new type is an interface. | +| Legacy `packages.config` `` requirement | PASS | `UtilitiesCS.csproj` gains `Threading\IUiCaptureSource.cs`; `UtilitiesCS.Test.csproj` gains both new test files. The discovery-side confirmation required by `spec.md` is present: the discovered total rose by exactly 17. | +| Suppressions | PASS | No suppression, no `#pragma warning disable`, no `[ExcludeFromCodeCoverage]` added. | + +## 4. Language-Specific Unit Test Policy Compliance (C#) + +| Requirement (`CLAUDE.md` CUT1–CUT3) | Verdict | Evidence | +|---|---|---| +| MSTest framework | PASS | `[TestClass]`, `[TestMethod]`, `[STATestClass]`, `[STATestMethod]`, `[DoNotParallelize]` only. | +| Moq for mocking | PASS | The one mock in the changed `QuickFiler.Test` method is `Mock`. The `UtilitiesCS.Test` doubles are hand-written fakes, which is appropriate for a type that must control apartment and dispatcher identity. | +| FluentAssertions preferred | PASS | Every new assertion uses FluentAssertions. The two pre-existing `Assert.IsTrue` calls in the amended `QuickFiler.Test` method were carried unchanged, which is the correct minimal-diff choice. | +| No `Form`-derived type in `UtilitiesCS.Test` | PASS | `FakeUiCaptureSource` implements `IUiCaptureSource` without deriving from `Form`; `NoLiveFormInTestAssemblyTests.ExecutingAssembly_ContainsNoFormDerivedType` reports `Passed` in the final full-suite TRX. | + +## 5. Test Coverage Detail + +All figures in this section were recomputed by the reviewer directly from +`coverage/809-p5-final.cobertura.xml` and `coverage/809-p0-baseline.cobertura.xml` using a class-level +`lines/line` selection with de-duplication by line number. They are not quoted from the executor's +artifacts. + +### Coverage floor precedence (resolved explicitly) + +`.claude/skills/policy-compliance-order/SKILL.md` places `CLAUDE.md` first and `.claude/rules/general-unit-test.md` +third. `CLAUDE.md` UT2 states a repository-wide floor of >= 80% line with >= 90% for newly added +modules, classes and methods. `.claude/rules/general-unit-test.md` and `.claude/rules/quality-tiers.md` +state a uniform >= 85% line and >= 75% branch. The two are unreconciled. **This audit applies the +`CLAUDE.md` floors (80% line, 90% new code) as governing**, which is also what AC6 names, and reports +the `.claude/rules` figures alongside so the divergence is visible rather than resolved by preference. + +### Per-language rows + +- C# line coverage, file in scope `UtilitiesCS/Threading/UiThread.cs`: 121 of 126 executable lines, 96.03% — PASS against the governing 80% floor and PASS against the 85% figure as well. Reviewer-recomputed; matches `FINAL_UITHREAD_LINE_PCT: 96.03` exactly. +- C# line coverage, baseline for the same file: 63 of 82, 76.83% — reviewer-recomputed from the baseline Cobertura; matches `BASELINE_UITHREAD_LINE_PCT: 76.83` exactly. The delivery raises it by 19.20 points, so the no-regression obligation is met with margin, not tolerance. +- C# changed-line coverage across the three production Write Set files: 46 of 48 executable added lines, 95.83% — PASS against the 90% new-code floor. +- C# newly added members, worst row `SynchronizationContextAwaiter.IsCompleted` at 18 of 20 executable lines, 90.00% — PASS at exactly the 90% new-code floor, reviewer-recomputed over source lines 155-190. Every other new member measures 100%. +- C# repo-wide first-party line coverage, nine assemblies: 56248 of 66471, 84.62% — PASS against the governing `CLAUDE.md` 80% floor; that same figure would be a FAIL against the 85% line floor in `.claude/rules/quality-tiers.md`, which the precedence order does not make governing. The executor reported 84.62%; the reviewer's independent de-duplicated computation returns 84.62%. +- C# repo-wide first-party branch coverage: 13062 of 16956, 77.03% (reviewer-computed, de-duplicated) — PASS against the 75% branch floor. The executor reported 79.38% using an all-descendant selection that double-counts method-level line rows; both figures clear the floor, so the verdict is unaffected. See Finding F7. +- C# canonical coverage artifact `artifacts/csharp/coverage.xml`: FAIL — the canonical path does not exist in this worktree. Disposition is non-blocking: the equivalent evidence, two full Cobertura documents produced by the same Microsoft coverage engine, is present at `coverage/809-p0-baseline.cobertura.xml` and `coverage/809-p5-final.cobertura.xml`, and every figure in this section was independently derived from them by the reviewer. See Finding F6. +- PowerShell coverage: PASS — zero `.ps1` and `.psm1` files changed on this branch across the twelve-path changed set, so no PowerShell coverage obligation arises and no FAIL condition was found. Pester measures command and line coverage only, so no branch figure applies to it in any case. +- Python coverage: PASS — zero `.py` files changed on this branch, so no Python coverage obligation arises and no FAIL condition was found. +- TypeScript coverage: PASS — zero `.ts` and `.tsx` files changed on this branch, so no TypeScript coverage obligation arises and no FAIL condition was found. + +### Residual uncovered lines in the file under change + +Reviewer-verified uncovered set at head: `38, 39, 40, 177, 178`. Nothing else. + +| Lines | Construct | Assessment | +|---|---|---| +| 38-40 | Body of `if (onLockupDetected is not null)` in `Init()` | The one test that supplies a callback supplies it to prove a rejected `Init()` performs no assignment, so the precondition throws first. Three lines, no branch left unevaluated at the condition itself (line 37 is covered). Non-blocking. | +| 177-178 | Body of `ReferenceEquals(_context, _uiSyncContext)` in the new predicate | The condition at line 176 is covered and evaluated false; only the true-arm body is unreached. This is the delivery's highest-risk branch and its least-covered one. See Finding F2 and the Q4 adjudication in the code review. Non-blocking. | + +## 6. Test Execution Metrics + +| Metric | Value | Verification | +|---|---|---| +| Full nine-assembly suite | 7137 total / 7137 passed / 0 failed, `SKIPPED_DERIVED: 0` | `p5-t5-tests-coverage.md`, TRX `p5t5.trx` counters quoted with `LastWriteTimeUtc` | +| Baseline total | 7120 | `p0-t12-vstest.md` token `BASELINE_TOTAL_TESTS:` | +| Delta | +17, matching the 17 methods added (4 + 6 + 7) | Reviewer counted the added `[TestMethod]`/`[STATestMethod]` declarations in the patch: 4 in `UiThreadInitApartmentContract_Tests`, 6 in `UiThreadInitRetryContract_Tests`, 7 in `SynchronizationContextAwaiter_Tests`. Exact. | +| Targeted `UtilitiesCS.Test` run | 82/82 passed, two passes | `p4-t4-utilitiescs-tests.md` | +| Targeted `QuickFiler.Test` run | 70/70 passed, two passes | `p4-t3-quickfiler-tests.md` | +| Fail-before | 22 discovered / 16 passed / 6 failed, exit 1, `[expect-fail]` | `p2-t10-fail-before.md`, re-measured pass of record | +| Pass-after | 22/22, exit 0 | `p3-t6-pass-after.md` | +| #780 flake control | 3 dedicated repetitions plus the full-suite row, all `Passed` | `p5-t6-tryaddvalues-rep1/2/3.md`, `REPETITIONS_PASSED: 3` | +| Toolchain clean pass | `TOOLCHAIN_LOOP_CLEAN_PASS: 1` | `p5-t7-loop-closure.md` | + +## 7. Code Quality Checks + +| Gate | Result | Command recorded | +|---|---|---| +| Format | exit 0, tree unchanged | `dotnet tool run csharpier format .` then `dotnet tool run csharpier check .` | +| Lint | exit 0, 0 warnings, 0 errors, 18 projects | `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true` | +| Type-check | exit 0, 0 warnings, 0 errors | `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true` | +| Test | exit 0, 7137/7137 | `dotnet-coverage collect ... -- vstest.console.exe ... /InIsolation` — see the AC6 collector deviation in Finding F5 | +| Repository hygiene | `DELIVERY_ADDED_RESULTS_FILE_COUNT: 0` | `p6-t12-untracked-output-check.md`; reviewer confirmed `coverage/` holds only `.gitkeep` as a tracked path and that the two 18 MB Cobertura documents are git-ignored, so no large binary blob enters history | +| Host-token sanitisation | 43 artifacts scanned, 0 hits, plus a second pass over the scanner's own artifact | `p6-t14-artifact-sanitisation.md` | + +## 8. Gaps and Exceptions + +### F1 — `[P0-T15]` apartment inference is unsound (Medium, non-blocking) + +`evidence/other/p0-t15-mta-synccontextform-measurement.md` records `MTA_INITIALIZE_OUTCOME: COMPLETED` +and infers the executing apartment was MTA from research R4's premise that a plain `[TestMethod]` runs +MTA. The same delivery falsified that premise: `p2-t10-fail-before.md` records the verbatim TRX message +`Expected Thread.CurrentThread.GetApartmentState() to be ApartmentState.MTA {value: 1}, but found +ApartmentState.STA {value: 0}.` Full reasoning is in the code review under Q2. The reviewer's +determination is that the `[P0-T15]` run most likely executed **STA**, in which case no MTA measurement +exists and the refutation of the #782 narrative is unsupported. The AC2 design is unaffected. Recorded +against AC5. + +### F2 — the highest-risk branch is the least-covered (Low, non-blocking) + +`UiThread.cs:177-178` is the true arm of `ReferenceEquals(_context, _uiSyncContext)`, which is exactly +the clause with the stale-thread-id residual described in F3. Adjudicated in the code review under Q4. + +### F3 — stale `_uiThreadId` residual in the new predicate (Low, non-blocking) + +Adjudicated in the code review under Q1. Unreachable in production; not reached by any test; a +hardening recommendation is recorded. + +### F4 — 500-line limit exceeded at head in a touched test file (FAIL-level, non-blocking) + +`UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs` is 1067 lines at head against a +1066-line baseline. The rule in `.claude/rules/general-code-change.md` admits no exception for test +code. The overrun is pre-existing at 566 lines over the limit; this delivery adds exactly one +`[DoNotParallelize]` attribute line, which `spec.md`'s Write Set amendment establishes as necessary for +the shared-static serialization guarantee to hold at all. Splitting a 1066-line unrelated test file is +outside the approved Write Set and would be a large unrelated change. Disposition: non-blocking, with a +follow-up issue recommended. + +### F5 — AC6 literal wording deviations (Low, non-blocking) + +Two clauses of AC6 are not met literally: the collector (`dotnet-coverage collect` substituted for +`/EnableCodeCoverage`, documented as `AC6-COLLECTOR-SUBSTITUTION`) and the storage location (the raw +Cobertura document is git-ignored under `coverage/` rather than stored under `evidence/qa-gates/`; +what is stored there are three markdown artifacts recording figures derived from it). Adjudicated in +the feature audit. + +### F6 — canonical C# coverage artifact absent (procedural FAIL, non-blocking) + +Recorded in section 5. Not a code defect. + +### F7 — aggregate branch-coverage measurement method (Low, non-blocking) + +`p6-t3-aggregate-coverage.md` and `p5-t5-tests-coverage.md` compute branch coverage by summing +`condition-coverage` over an all-descendant `.//line` selection, which counts method-level rows in +addition to class-level rows. The reviewer's de-duplicated computation returns 77.03% where the +artifacts report 79.38%. Line percentage is unaffected (both 84.62%) because the duplication is +close to proportional on lines. Both figures clear the 75% floor, so no verdict changes. The +comparison to baseline remains valid because both sides used the same method. + +### F8 — evidence artifact `Timestamp:` headers run ahead of wall clock (Informational) + +Declared timestamps are 1.5 to 2 hours later than the artifacts' own file mtimes (for example +`p5-t5-tests-coverage.md` declares `2026-09-08T02-50` with mtime `2026-09-08 01:06:21 -0400`, and +`p6-t13` declares `03-16` with mtime `01:14`). The `LastWriteTimeUtc` values the artifacts quote for +their TRX files do reconcile with the mtimes at UTC-4, so the runs themselves are corroborated and +nothing appears fabricated; only the human-authored header stamps drift. The +`evidence-and-timestamp-conventions` skill fixes the format but not the source clock, so this is an +accuracy observation rather than a rule violation. + +### F9 — the published environmental finding names a mechanism the tree does not support (Low, non-blocking) + +`p6-t13-closure-summary.md` section 6 tells future planners that a plain `[TestMethod]` runs STA +"when a `[TestClass] [DoNotParallelize]` class shares the serial execution bucket with an +`[STATestClass] [DoNotParallelize]` class". The reviewer's reading of the tree supports a simpler and +broader explanation, given in the code review under Q2. Since that note is written for future +planners, the wrong mechanism can propagate; a correction is recommended. + +### F10 — follow-up candidates exist only as prose (Process, non-blocking) + +`p6-t13-closure-summary.md` section 5 lists six follow-up candidates, including the two coverage +residuals, the 500-line overrun, and the `IUiDispatcher` routing. Prose in a feature folder does not +survive merge. Recommend promoting the durable ones through the promotion lifecycle into real issues. + +### Accepted, pre-existing, or out-of-delivery items (not findings) + +- The 80% versus 85% coverage-floor divergence between `CLAUDE.md` and `.claude/rules` is a standing + governance conflict, recorded in `spec.md` and resolved here by the documented precedence order. +- The eleven production await sites at which the predicate change can alter ordering have no ordering + test today. The delivery records this as residual rather than covered, which is the correct + disposition; live-host verification is explicitly outside the acceptance criteria. + +## 9. Summary of Changes + +12 files, 1134 insertions, 34 deletions, across 9 commits. + +Production (4 files): +- `UtilitiesCS/Threading/UiThread.cs` — STA precondition as the first statement of `Init()`; `lock (InitLock)` plus a success-recorded `_initialized` flag replacing `ThreadSafeSingleShotGuard`; `SyncContextFormFactory` and `ResetForTesting()` seams; the replaced `IsCompleted` predicate; the `NonStaInitMessagePrefix` constant. +- `UtilitiesCS/Threading/IUiCaptureSource.cs` — new internal interface, 9 members, no executable line. +- `UtilitiesCS/Threading/SyncContextForm.cs` — declaration only. +- `UtilitiesCS/UtilitiesCS.csproj` — one ``. + +Test (8 files): two new files (`UiThreadInitContract_Tests.cs`, `UiThreadStateScope.cs`), 243 added +lines in `UiThread_Tests.cs`, the reconciled `QfcHomeControllerRunAsyncTests` method, three +one-attribute additions, and one `` pair. + +## 10. Compliance Verdict + +**PASS with 0 blocking findings.** + +Ten findings are recorded; all are non-blocking. Two acceptance criteria (AC5, AC6) are graded PARTIAL +for evidence-labelling and literal-wording reasons and are detailed in the feature audit. No +remediation-inputs artifact is produced, because no finding requires code, test, or plan rework before +merge. The recommended actions are: correct the `[P0-T15]` apartment label and the `p6-t13` section 6 +mechanism note, and promote the durable follow-ups to issues. + +## Appendix A: Test Inventory + +Added (17): + +`UiThreadInitApartmentContract_Tests` (4): `Init_OnMtaThread_ThrowsInvalidOperationExceptionNamingTheObservedApartmentState`, +`Init_OnMtaThread_CapturesNoGlobalStateAndLeavesMonitoringConfigurationUnchanged`, +`Init_OnStaThread_DoesNotThrowAndPopulatesAllFourCaptureFields`, +`Init_ApartmentBoundaryIsStaEqualityNotMtaInequality_RejectsFromMtaAndAcceptsFromSta`. + +`UiThreadInitRetryContract_Tests` (6): `Init_WhenFirstInitializeThrows_SecondInitWithWorkingFactorySucceedsAndPopulatesAllFourCaptureFields`, +`Init_WhenInitializeThrows_LeavesAllFourCaptureFieldsUnset`, +`AutoScaleFactor_ReadFromMtaThreadAfterAFailedInit_ThrowsAndDoesNotReEnterTheFactory`, +`Init_CalledConcurrentlyFromTwoStaThreads_InvokesTheFactoryExactlyOnce`, +`Init_WithMonitorUiThreadEnabled_ConstructsAndRunsTheThreadMonitorWithTheInjectedTimeProvider`, +`UiSyncContext_ReadWithNullBackingFieldFromStaThread_InitializesThroughTheLazyPath`. + +`SynchronizationContextAwaiter_Tests` (7): `IsCompleted_WhenAmbientContextIsTheCapturedInstance_ReturnsTrue`, +`IsCompleted_WhenAmbientContextIsNullAndCapturedContextIsNotNull_ReturnsFalse`, +`IsCompleted_WhenUiThreadIdIsTheMinusOneSentinel_ReturnsFalse`, +`IsCompleted_OnOwningUiThreadWithADispatcherContextCapturedInsideAnInvoke_ReturnsTrue`, +`IsCompleted_WhenTheDispatcherContextBelongsToADifferentThreadsDispatcher_ReturnsFalse`, +`IsCompleted_WithAForeignWindowsFormsContextWhileUiThreadIdMatches_ReturnsFalse`, +`IsCompleted_OnDefaultAwaiterOnAContextFreeThread_ReturnsTrue`. + +Modified (1): `QfcHomeControllerRunAsyncTests.Worker_RunWorkerCompleted_HandlesCompletionCorrectly`, +signature changed to `async Task`, not renamed. + +Pinned regression guards re-run and green: both `WinFormsPumpHostTests` marshal tests, +`EfcFormControllerTests.ActionDeleteAsync_AwaitedTwice_LeavesExactlyOneTrashRowInFolderRows`, +both `UiThread_Dispatcher_Tests` methods, `WpfDispatcherYieldTests`, `FolderPredictorTests` +reflection test, and both `[STATestClass]` viewer tests. + +## Appendix B: Toolchain Commands Reference + +1. `dotnet tool run csharpier format .` / `dotnet tool run csharpier check .` +2. `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true` +3. `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true` +4. `dotnet-coverage collect --output coverage\809-p5-final.cobertura.xml --output-format cobertura --settings coverage\809-effective-coverage.config -- vstest.console.exe /Settings:scripts\vscode\TaskMaster.cli.runsettings /InIsolation` — substituted for `vstest.console.exe ... /EnableCodeCoverage` per `AC6-COLLECTOR-SUBSTITUTION`; see Finding F5. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/research/research.2026-09-07T20-20.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/research/research.2026-09-07T20-20.md new file mode 100644 index 000000000..6774db636 --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/research/research.2026-09-07T20-20.md @@ -0,0 +1,805 @@ +# Research — UiThread init-contract residuals (#809, superseding #784 / #787 / #788) + +Timestamp: 2026-09-07T20-20 + +All paths below are repository-relative. Every line number was read in this run against the working +tree unless the claim is explicitly marked `CARRIED FROM PROMPT, NOT RE-VERIFIED` or `UNKNOWN`. + +## Verification of the prompt's premises + +| Premise stated in the delegation prompt | Status | +|---|---| +| `Init()` at `UtilitiesCS/Threading/UiThread.cs:19-40`, latch read at `:36`, no apartment check | **VERIFIED.** `Init()` occupies lines 19-40; `if (_loaded.CheckAndSetFirstCall)` is line 36; neither `Init()` nor `Initialize()` reads `Thread.CurrentThread.GetApartmentState()`. | +| `Initialize()` at `:48-79` constructs `SyncContextForm`, captures four globals | **VERIFIED.** `new SyncContextForm()` at `:51`, `Show()` at `:54`, `CaptureUiVariables()` at `:57`, assignments at `:58-61`, `Hide()` at `:78`. | +| `ThreadSafeSingleShotGuard.CheckAndSetFirstCall` is an `Interlocked.Exchange` at `UtilitiesCS/Threading/ThreadSafeSingleShotGuard.cs:24-27` | **VERIFIED**, exact lines 24-27, body `Interlocked.Exchange(ref _state, CALLED) == NOTCALLED`. | +| `UiThread.cs:100` is `public bool IsCompleted => _context == SynchronizationContext.Current;` | **VERIFIED** verbatim at line 100. | +| Both lazy getters still call `Init()` | **VERIFIED.** `UiSyncContext` getter: `UiThread.cs:117-120`. `AutoScaleFactor` getter: `UiThread.cs:183-186`, with the `SizeF(1f, 1f)` fallback at `:187`. (The #782 artifacts cite `:128-131` and `:194-197`; those were the line numbers before that delivery's edits. The behaviour is unchanged; only the line numbers moved.) | +| `DictionaryExtensions.cs` line 177 declares the 500 ms budget | **VERIFIED.** `UtilitiesCS/Extensions/DictionaryExtensions.cs:177` is `linkedTS.CancelAfter(500);`, inside `TryAddValuesAsync` (`:169-180`), whose only work is `Task.Run(() => dictionary.TryAddValues(key, value), linkedTS.Token)` at `:179`. | +| `UiThread.cs` carries `#nullable enable` | **VERIFIED**, line 1. | +| Target is .NET Framework, no `init`/`record` | **VERIFIED indirectly**: `UtilitiesCS.Test/packages.config:146-147` pins `targetFramework="net481"`. Not re-derived from `UtilitiesCS.csproj`. | + +### The #780 ambiguity (asked for explicitly) + +`UtilitiesCS.Test.Extensions.DictionaryExtensions_Tests.TryAddValuesAsync_UpdatesExistingValue` +(`UtilitiesCS.Test/Extensions/DictionaryExtensions_Tests.cs:236-249`) **is** the test tracked as the +independent flake under issue #780. `docs/features/potential/promoted/2026-09-04-tryaddvaluesasync-wall-clock-timeout-flaky.md` +names the same fully-qualified test (`:39-41`), the same exception type `TaskCanceledException`, and +the same signature — "the failing test alone took 21 s" (`:47`) — as the #782 regression record. + +The #782 record **does** address the ambiguity, but weakly. `docs/features/active/2026-09-05-pr-778-post-merge-review-residuals-782/evidence/other/code-review.2026-09-05T23-00.md:94-100` +records one run with the re-arm line (5179/5180, that test failing at 21 s), one run without it +(5180/5180), and the branch base at 6992/6992 before and after. `evidence/qa-gates/p1-t9-phase1-tests.md:70-82` +adds one subsequent clean run and concludes the failure was "delivery-attributable rather than the +issue #780 flake". + +Assessment: the attribution rests on a **single with/without pair** against a test that is +independently documented as intermittent with an identical failure signature. It is plausible but +not statistically established. This matters because the entire "do not re-arm the latch" constraint +depends on it. See R1 for a second, independent reason to doubt the recorded mechanism. + +The test body itself touches no `UiThread` member — it is a pure thread-pool canary +(`ConcurrentDictionary`, `Task.Run`, 500 ms `CancelAfter`). + +--- + +## R1 — Interaction between AC1 and AC2 + +### The exact reachable surface + +`Init()` is reachable from only three places in the entire tree. Two are direct calls and one is the +pair of lazy getters: + +| Path | Site | Apartment | +|---|---|---| +| Direct | `TaskMaster/ThisAddIn.cs:35-40` (`ThisAddIn_Startup`) | Outlook STA | +| Direct | `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs:329` | MSTest default (see R4) | +| Lazy | `UiThread.cs:119` inside the `UiSyncContext` getter | caller's | +| Lazy | `UiThread.cs:185` inside the `AutoScaleFactor` getter | caller's | + +`UiThread.Dispatcher` never calls `Init()` (`UiThread.cs:153-171`; the `` at `:141-148` +states the asymmetry deliberately). So the AC1 blast radius is bounded by exactly **six** lazy-read +sites in production plus the two direct calls: + +`UiThread.UiSyncContext` readers (3): +- `UtilitiesCS/Threading/ThreadMonitor.cs:143` +- `TaskMaster/AppGlobals/AppOlObjects.cs:367` +- `UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs:179` + +`UiThread.AutoScaleFactor` readers (3): +- `TaskMaster/ThisAddIn.cs:114` +- `UtilitiesCS/EmailIntelligence/OlFolderTools/FolderRemap/FolderRemapViewer.cs:40` +- `UtilitiesCS/EmailIntelligence/OlFolderTools/FilterOlFolders/FilterOlFoldersViewer.cs:79` + +### Testing the hypothesis + +**The hypothesis is CORRECT as a mechanism statement, and it is stronger than stated: with AC1 in +place, the expensive path (`new SyncContextForm()` + `Show()`) is unreachable from any non-STA +caller, so a re-armed latch cannot produce repeated WinForms construction on a worker thread.** The +apartment check is a constant-time read of `Thread.CurrentThread.GetApartmentState()` followed by a +throw; the starvation mechanism the #782 artifacts describe requires the form construction, which no +longer runs. + +However — and this is the finding the planner most needs — **I could not confirm that the recorded +#782 mechanism was ever real**, for two independent reasons: + +1. **Every in-repo path that reaches the lazy `AutoScaleFactor` getter in a test run is already + STA-hosted.** `UtilitiesCS.Test/EmailIntelligence/FolderRemapViewer_Tests.cs:26-27` carries + `[STATestClass]`, and its `SetController_WithSyntheticController_ConfiguresTreeDelegates` + (`:117-131`) calls `viewer.SetController(controller)`, which reaches `SetupRenderer` → + `UiThread.AutoScaleFactor` (`FolderRemapViewer.cs:38-40`) and asserts `act.Should().NotThrow()`. + `UtilitiesCS.Test/EmailIntelligence/FilterOlFoldersViewer_Tests.cs:26-27` is likewise + `[STATestClass]` with the same shape at `:39-51`. On an STA thread `Initialize()` succeeds, the + latch is consumed once, and no catch ever fires. + +2. **I found no test that reaches the lazy `UiSyncContext` getter with a null backing field.** The + #782 remediation record `evidence/other/r1-r2-maintainer-disposition.2026-09-06T00-15.md:74-76` + quotes the measured uncovered-line set for `UiThread.cs` as + `28,29,30,32,33,34,67,68,69,70,71,72,73,74,75,76,118,119,120` — lines 118-120 are exactly the + `if (_uiSyncContext is null) { Init(); }` block. That block is **uncovered**, i.e. never executed + with a null field in a measured run. `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs:461-487` + installs a context into `UiThread._uiSyncContext` by reflection before exercising + `FolderPredictor.EnterUiContextAsyncAction`, so it bypasses the lazy path entirely. + +3. **The #788 entry names `TaskMaster/AppGlobals/AppOlObjects.cs:367` and `TaskMaster/ThisAddIn.cs:114` + as "the readers that make `TaskMaster.Test` the assembly where this surfaces" + (`docs/features/potential/promoted/2026-09-05-uithread-init-latch-not-rearmed-after-failed-initialize.md:62`). + I searched `TaskMaster.Test` for `AppOlObjects`, `UserEmailAddress`, `ResolveCurrentUser`, + `SetUpBrightIdeasSettings` and found no test that drives either reader** (`TaskMaster.Test` + exercises `AppOlObjects.TryGetSmtpAddress`, `ReadJunkPotentialSetting`, + `EmitPerStoreInboxAttribution`, and the folder-tree service; not the email-address resolver). + `ThisAddIn.SetUpBrightIdeasSettings` is a private method on a VSTO type with no test caller. + +Taken together: the #782 mechanism narrative requires `Initialize()` to throw at least once in that +test run, and I can find no path in the current tree where it does. Either the tree changed since +`b95a5252`, or the observed 21-second `TaskCanceledException` was the #780 flake after all. + +**Conclusion for the planner.** Do not build the design on the assumption that the #782 mechanism is +real, and do not build it on the assumption that it is fake. AC1 removes the mechanism *if it +exists*, which is enough to make the AC2 design safe either way. But AC2's own requirement — "the +#782 regression scenario is reproduced as a test and passes with the chosen design" — cannot be +discharged by pointing at the #782 artifacts; it needs a Phase 0 probe that establishes, by +measurement, whether `Initialize()` throws on an MTA thread in this repository's test host. That one +fact decides whether the AC2 test is a real regression test or a vacuous one. + +### The cost of AC1: sites that would newly throw + +With an STA precondition evaluated before the latch, a non-STA read of `UiThread.UiSyncContext` or +`UiThread.AutoScaleFactor` **whose backing field is still null** throws `InvalidOperationException` +where today it either silently captures a worker's non-pumping context or returns the +`SizeF(1f, 1f)` fallback (`UiThread.cs:187`). Site-by-site: + +| Site | Can it run off the STA thread? | New throw? | +|---|---|---| +| `UtilitiesCS/Threading/ThreadMonitor.cs:143` | **Yes.** Reached from `Tick()` (`:105`), which is a `TimeProvider.CreateTimer` callback started in `Run()` (`:93-102`) — a thread-pool thread. | **No.** `ThreadMonitor` is constructed only at `UiThread.cs:68-74`, inside `Initialize()`, *after* `UiSyncContext` is assigned at `:58`. `_uiSyncContext` is guaranteed non-null on this path, so the getter never calls `Init()`. | +| `TaskMaster/AppGlobals/AppOlObjects.cs:367` | **Yes, by construction.** The enclosing branch is entered only when `Thread.CurrentThread.ManagedThreadId != UiThread.UiThreadId` (`:364`). | **Yes, if `_uiSyncContext` is null.** In production `ThisAddIn.cs:35` runs first, so the field is populated and no throw occurs. In any headless/unit context it would throw. Today it instead constructs a `SyncContextForm` on the worker and then `Send`s inline on that worker, i.e. it performs the COM read on the wrong apartment — the exact failure `:361-363` says it is preventing. **The AC1 throw is strictly better than the current silent-wrong behaviour here.** | +| `UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs:179` | **Yes.** `EnterUiContextAsyncAction` is a static default delegate invoked from wherever the predictor runs. | **Yes, if `_uiSyncContext` is null.** No existing test hits it with a null field (`FolderPredictorTests.cs:479` pre-installs the field). | +| `TaskMaster/ThisAddIn.cs:114` | No. `SetUpBrightIdeasSettings` is called from `Application_Startup` (`:61`) on the Outlook STA, after `Init()` at `:35`. | No. | +| `UtilitiesCS/.../FolderRemapViewer.cs:40` | Only if a caller invokes `SetController` off the UI thread. | **No in the current test suite** — the only driver is `[STATestClass] FolderRemapViewer_Tests`. | +| `UtilitiesCS/.../FilterOlFoldersViewer.cs:79` | Same. | **No in the current test suite** — `[STATestClass] FilterOlFoldersViewer_Tests`. | + +Net: **three production sites gain a possible new throw** (`AppOlObjects.cs:367`, +`FolderPredictor.cs:179`, and — only in a hypothetical off-UI-thread caller — +`FolderRemapViewer.cs:40` / `FilterOlFoldersViewer.cs:79`), and **zero existing tests are affected**, +because every in-repo lazy-read driver is STA-hosted. The one in-repo caller that AC1 definitively +breaks is `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs:329` (see R5). + +--- + +## R2 — Live-read census + +Method: `Grep` over `**/*.cs` for the regular expression +`UiThread\.(UiSyncContext|AutoScaleFactor|Dispatcher|UiThreadId|Init)`. This matches both the bare +`UiThread.` form and the fully-qualified `UtilitiesCS.UiThread.` form. Excluded from the "live" count: +XML documentation, ordinary comments, commented-out code, and the exception-message literal at +`UiThread.cs:136`. A second `Grep` for `using static .*UiThread` returned no matches, so there is no +unqualified read anywhere; the regex is exhaustive for this family. + +### Production — 56 live sites across 31 files + +| File | Live lines | Count | Can it run off the Outlook STA? Evidence | +|---|---|---|---| +| `TaskMaster/ThisAddIn.cs` | 35 (`Init`), 114 (`AutoScaleFactor`), 227 (`Dispatcher`) | 3 | No. `ThisAddIn_Startup` / `Application_Startup` are VSTO host events on the STA. | +| `TaskMaster/Ribbon/RibbonViewer.EngineCommands.cs` | 71, 114 | 2 | No. Ribbon `onAction` callbacks are raised by Office on the STA. `Dispatcher` read only — never triggers `Init()`. | +| `TaskMaster/AppGlobals/AppOlObjects.FolderTreeService.cs` | 344 | 1 | Possibly. `UiThread.Dispatcher.CheckAccess()` — `Dispatcher` read, no `Init()`; throws today if uninitialized. | +| `TaskMaster/AppGlobals/AppOlObjects.cs` | 364 (`UiThreadId`), 367 (`UiSyncContext`) | 2 | **Yes, by construction** — `:364` gates the branch on being *off* the UI thread. | +| `TaskMaster/AppGlobals/ApplicationGlobals.cs` | 293 | 1 | `Dispatcher` read for a `DispatcherTimer` heartbeat; started from startup on the STA. | +| `UtilitiesCS/HelperClasses/ToolTips/QfcTipsDetails.cs` | 254, 277 | 2 | Yes (`await ... InvokeAsync`), `Dispatcher` only. | +| `UtilitiesCS/Threading/ThreadMonitor.cs` | 143 | 1 | **Yes** — thread-pool timer callback (`:96-101`, `:105`). `UiSyncContext`. | +| `UtilitiesCS/Threading/ProgressTrackerPane.cs` | 13, 16 | 2 | `Dispatcher` only. | +| `UtilitiesCS/Threading/ProgressTrackerAsync.cs` | 33 | 1 | `Dispatcher` only. | +| `UtilitiesCS/Threading/ProgressTracker.cs` | 33 | 1 | `Dispatcher` only. | +| `UtilitiesCS/HelperClasses/ThemeHelpers/ThemeControlGroup.cs` | 218, 222 | 2 | `Dispatcher` only. | +| `UtilitiesCS/Threading/WpfUiDispatcher.cs` | 25 | 1 | Lazy provider `() => UiThread.Dispatcher`; evaluated on the caller's thread. `Dispatcher` only. | +| `UtilitiesCS/Threading/IdleAsyncQueue.cs` | 72 | 1 | Yes (async continuation). `Dispatcher` only. | +| `UtilitiesCS/Threading/IdleActionQueue.cs` | 78 | 1 | Yes (async continuation). `Dispatcher` only. | +| `UtilitiesCS/HelperClasses/SegmentStopWatch.cs` | 24 | 1 | Yes — constructor runs anywhere. `UiThreadId` is a plain field read; **never triggers `Init()`**. | +| `UtilitiesCS/EmailIntelligence/.../FolderRemapViewer.cs` | 40 | 1 | Caller-dependent. `AutoScaleFactor` — **can trigger `Init()`**. | +| `UtilitiesCS/EmailIntelligence/.../FilterOlFoldersViewer.cs` | 79 | 1 | Caller-dependent. `AutoScaleFactor` — **can trigger `Init()`**. | +| `UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs` | 179 | 1 | Yes. `UiSyncContext` — **can trigger `Init()`**. | +| `UtilitiesCS/OutlookObjects/Folder/WpfDispatcherYield.cs` | 46 | 1 | Lazy fallback provider. `Dispatcher` only. | +| `QuickFiler/Helper Classes/ItemViewerQueue.cs` | 21, 27, 88, 90 | 4 | Yes (queue schedulers). `Dispatcher` only. | +| `QuickFiler/Helper Classes/EfcViewerQueue.cs` | 20, 67 | 2 | Yes. `Dispatcher` only. | +| `QuickFiler/Helper Classes/ConversationResolver.Loading.cs` | 150, 320 | 2 | Yes (`await`ed loaders). `Dispatcher` only. | +| `QuickFiler/Helper Classes/EmailMoveMonitor.cs` | 44 | 1 | Default `_marshalToSta`; runs on the monitor's thread. `Dispatcher` only. | +| `QuickFiler/Controllers/QfcQueue.cs` | 476, 484, 492 | 3 | Yes. `Dispatcher` only. | +| `QuickFiler/Controllers/QfcHomeController.cs` | 360 | 1 | **Yes** — `Worker_RunWorkerCompleted` is a `BackgroundWorker` completion handler. `Dispatcher` only. | +| `QuickFiler/Controllers/QfcFormController.EventHandlers.cs` | 276, 319, 324 | 3 | Yes. `Dispatcher` only. | +| `QuickFiler/Controllers/QfcFormController.Actions.cs` | 255 | 1 | Yes. `Dispatcher` only. | +| `QuickFiler/Controllers/QfcCollectionController.cs` | 951, 982, 1210, 1220, 1238, 1256, 1333 | 7 | Yes. `Dispatcher` only. | +| `QuickFiler/Controllers/KeyboardHandler.cs` | 362, 370, 401 | 3 | Yes. `Dispatcher` only. | +| `QuickFiler/Controllers/EfcItemController.cs` | 998, 1007 | 2 | Yes. `Dispatcher` only. | +| `QuickFiler/Controllers/EfcHomeController.cs` | 297 | 1 | Yes. `Dispatcher` only. | + +Excluded as non-live (documentation, comments, commented-out code, message literal): +`TaskMaster/ThisAddIn.cs:190`; `TaskMaster/Ribbon/RibbonViewer.EngineCommands.cs:54,93`; +`TaskMaster/Ribbon/EngineToggleStateCoordinator.cs:42`; `TaskMaster/AppGlobals/ApplicationGlobals.cs:159,271`; +`UtilitiesCS/Threading/UiThread.cs:136`; `UtilitiesCS/Threading/SyncContextForm.cs:26`; +`UtilitiesCS/Threading/IUiDispatcher.cs:11`; `UtilitiesCS/OutlookObjects/Folder/WpfDispatcherYield.cs:57,65`; +`UtilitiesCS/HelperClasses/ThemeHelpers/Theme.cs:441`; `QuickFiler/Helper Classes/EmailMoveMonitor.cs:38`; +`QuickFiler/Controllers/QfcQueue.cs:502`; `QuickFiler/Controllers/QfcHomeController.Iteration.cs:31`; +`QuickFiler/Controllers/QfcCollectionController.cs:933`. + +### Test — 6 live sites across 3 files + +| File | Line | What | Thread | +|---|---|---|---| +| `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs` | 329 | `UiThread.Init(false)` | MSTest worker; plain `[TestMethod]` (`:325-326`) on a class with no `[STATestClass]` — see R4/R5 | +| `UtilitiesCS.Test/Threading/UiThread_Tests.cs` | 139, 144, 165 | `UiThread.Dispatcher` read + `DispatcherNotInitializedMessage` | MSTest worker | +| `UtilitiesCS.Test/OutlookObjects/Folder/WpfDispatcherYieldTests.cs` | 136 | `DispatcherNotInitializedMessage` | MSTest worker | + +`QuickFiler.Test/Controllers/QfcHomeControllerTests.cs:170` is a commented-out `UiThread.Init(false);` +and is excluded. All other test-file matches are comments or XML documentation. + +### Comparison with the #782 figure + +The #782 delivery adopted **49 live reads across 25 production files**, measured at tag +`pre-782-base` (`docs/features/active/2026-09-05-pr-778-post-merge-review-residuals-782/evidence/other/code-review.2026-09-05T23-00.md:156-160`). + +**My count does not agree: 56 live sites across 31 production files.** The divergence is +7 sites and ++6 files. I cannot decompose it precisely because the #782 artifact publishes the figure but not its +member set (the same limitation it records against the PR #778 review body's "26 files"). Two +contributing factors are identifiable: (a) my count includes the one production `UiThread.Init(` call +at `TaskMaster/ThisAddIn.cs:35`, which the phrase "live *reads*" may have excluded — subtracting it +gives 55; (b) the tree has moved since `pre-782-base` (for example `QuickFiler/Controllers/QfcHomeController.cs` +gained its issue-#791 `Cleanup()` region at `:370-379`, and `QuickFiler.Test`/`QuickFiler` have taken +several deliveries since). The planner should treat 56/31 as the current figure and should not +restate 49/25. + +--- + +## R3 — Testability seams + +### `SyncContextForm` public surface + +`UtilitiesCS/Threading/SyncContextForm.cs` — file is 50 lines; namespace is `QuickFiler.Viewers` but +it compiles into the **`UtilitiesCS`** assembly (`UtilitiesCS/UtilitiesCS.csproj:1100-1103`, with the +`.resx` at `:1213-1214`). Declaration at `:16`: `public partial class SyncContextForm : Form`. + +| Member | Line | Signature | +|---|---|---| +| `SyncContextForm()` | 18-22 | `public SyncContextForm()` — calls `InitializeComponent()` | +| `FormAutoScaleFactor` | 24 | `public System.Drawing.SizeF FormAutoScaleFactor { get; private set; }` | +| `UiSyncContext` | 28 | `public SynchronizationContext UiSyncContext { get; private set; } = null!;` | +| `UiDispatcher` | 30 | `public Dispatcher UiDispatcher { get; private set; } = null!;` | +| `UiThreadId` | 32 | `public int UiThreadId { get; private set; }` | +| `CaptureUiVariables()` | 34-40 | `public void CaptureUiVariables()` — assigns `SynchronizationContext.Current`, `this.AutoScaleFactor`, `Dispatcher.CurrentDispatcher`, `Thread.CurrentThread.ManagedThreadId` | + +Everything else `Initialize()` uses (`ShowInTaskbar`, `WindowState`, `Show()`, `Hide()`) is inherited +from `Form`. + +### Seam options for driving a FAILING `Initialize()` + +| Option | Verdict | Evidence / consequence | +|---|---|---| +| **Injectable factory delegate for the capture object** (`internal static Func SyncContextFormFactory { get; set; }` on `UiThread`, defaulting to the real form) | **RECOMMENDED** | Directly precedented in this repo: `QuickFiler/Helper Classes/ItemViewerQueue.cs:11-27` (`internal static Func ProductionViewerFactory { get; set; }` plus three scheduler delegates and `ResetProductionCoreDefaultsForTesting()` at `:83-91`), and `UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs:161-184` (four `internal static` settable delegates, restored in a `finally` by `FolderPredictorTests.cs:451-458`). A throwing factory makes `Initialize()` fail deterministically with no form, no STA host, no timing. | +| **Interface extracted from `SyncContextForm`** | Workable, heavier | Requires an interface covering `CaptureUiVariables`, the four capture properties, and `ShowInTaskbar`/`WindowState`/`Show`/`Hide`. It changes the public shape of a `Form`-derived type and adds a second production type for one test need. The factory option subsumes it if the factory's return type is a small interface rather than `SyncContextForm` itself. | +| **Injectable apartment-state provider** | Not required for AC1, useful for AC2 | Not needed to test AC1 rejection — MSTest's default apartment is already MTA (R4), so a plain `[TestMethod]` *is* the MTA case and `[STATestMethod]` *is* the STA case. It *is* useful to test "STA caller whose `Initialize()` throws" without needing the factory to distinguish; but the factory alone already covers that. Adding both is redundant. | +| **Internal test hook with `InternalsVisibleTo`** | **Available today** | `UtilitiesCS/Properties/AssemblyInfo.cs:18-20` grants `InternalsVisibleTo` to `DynamicProxyGenAssembly2`, **`UtilitiesCS.Test`**, and `ToDoModel.Test`. Two further duplicate grants to `UtilitiesCS.Test` exist at `UtilitiesCS/HelperClasses/Tokenizer.cs:11` and `UtilitiesCS/OutlookObjects/Item/OlItemSummary.cs:10`. **`QuickFiler.Test` is NOT granted** (the commented-out attempt is at `UtilitiesCS/HelperClasses/ToolTips/QfcTipsDetails.cs:15`, and `UtilitiesCS.Test/TestHelpers/UiThreadDispatcherScope.cs:27-29` states the same conclusion). Consequence: any `internal` seam on `UiThread` is reachable from `UtilitiesCS.Test` only. All AC4 tests must therefore live in `UtilitiesCS.Test`. | + +### Resetting `UiThread`'s process-global statics between MSTest tests + +`UtilitiesCS.Test/Threading/UiThread_Tests.cs` (215 lines, read in full) contains two test classes: + +- `SynchronizationContextAwaiter_Tests` (`:9-104`) — five `[TestMethod]`s. It does **not** carry + `[DoNotParallelize]` and does **not** reset any `UiThread` static. Its one context mutation + (`SetSynchronizationContext` at `:42`) is restored in a `finally` at `:54-57`. +- `UiThread_Dispatcher_Tests` (`:128-214`) — `[TestClass]` + `[DoNotParallelize]` (`:129`), two + `[TestMethod]`s, plus a private `StaDispatcherHost` (`:186-213`) that owns a real STA thread + running `Dispatcher.Run()` and shuts it down with `BeginInvokeShutdown` + `Join` on dispose. + It controls **only `_dispatcher`**, and does so through `UiThreadDispatcherScope`. + +Existing static-reset machinery found by grepping `**/*.cs` for the literal field names +`"_loaded"`, `"_uiSyncContext"`, `"_autoScaleFactor"`, `"_syncContextForm"`, `"_uiThreadId"`, +`"_dispatcher"` — exactly four hits: + +| Site | Field | Notes | +|---|---|---| +| `UtilitiesCS.Test/TestHelpers/UiThreadDispatcherScope.cs:117` | `_dispatcher` | Install/restore scope; explicitly documented as **not** thread-safe, relying on `[DoNotParallelize]` on every consuming class (`:19-25`). | +| `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs:136` | `_dispatcher` | The QuickFiler.Test equivalent, with a two-lock design (`FieldLock` + `TransactionGate`, `:31-32`) and a parked never-pumping STA dispatcher (`:149-177`). | +| `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs:470` | `_uiSyncContext` | Reads the prior value, installs an `ImmediateSynchronizationContext`, restores in `finally` (`:475-486`). | +| `QuickFiler.Test/Controllers/QfcHomeControllerPropertyTests.cs:335` | `_uiSyncContext` | **Not `UiThread`** — this is an *instance* field of `QfcHomeController` (`BindingFlags.Instance`, `:337`). Irrelevant. | + +**No test anywhere resets `_loaded`, `_autoScaleFactor`, `_uiThreadId`, or `_syncContextForm`.** That +gap is the principal AC4 obstacle: a test that drives `Init()` through a failure consumes the +process-global latch for every later test in the assembly. + +Recommended shape (see `## Recommended design`): an `internal static void ResetForTesting()` on +`UiThread` that assigns a fresh `ThreadSafeSingleShotGuard` and nulls the five capture fields, +paired with a `UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs` snapshot/restore `IDisposable` +modelled on `UiThreadDispatcherScope`, and `[DoNotParallelize]` on every consuming test class. The +`internal ... ForTesting()` idiom is already repo-precedented at +`QuickFiler/Helper Classes/ItemViewerQueue.cs:69-91`. + +--- + +## R4 — Apartment state in MSTest in this repository + +**MSTest's default apartment state for this repository's runs is MTA.** Determined from three +independent pieces of in-tree evidence: + +1. `UtilitiesCS.Test/test.runsettings` is a 6-line file whose entire content is a comment plus an + empty ``. The comment (`:2-5`) reads verbatim: *"Global STA execution is + intentionally disabled. Tests that require an STA apartment must opt in with MSTest's + STATestMethod or STATestClass attributes so the rest of the suite can run under the default + threading model and participate in parallel execution."* +2. No `.runsettings` in the tree sets `ExecutionThreadApartmentState`. A `Grep` for that token over + `**/*.{cs,runsettings,config,xml}` returned only prose in archived feature specs + (`docs/features/archive/2026-07-09-taskvisualization-core-testability-refactor-297/spec.md:384`, + `.../2026-07-09-tagcontroller-testability-refactor-293/spec.md:334`, + `.../2026-07-09-tasktree-testability-refactor-296/plan.2026-07-09T16-07.md:114`), each describing + assembly-wide STA as an option **not** taken. The two runsettings actually used — + `TaskMaster.runsettings` and `scripts/vscode/TaskMaster.cli.runsettings` — configure only + `` and coverage. +3. The #782 delivery record describes the ambient MSTest worker as MTA in two places: + `evidence/other/code-review.2026-09-05T23-00.md:48` ("the sentinel now comes from a shut-down STA + host instead of the pooled MTA worker") and `research/research.2026-09-05T16-10.md:899`, which + classifies `QfcHomeControllerRunAsyncTests.cs:329` as MTA on exactly this reasoning. + +### The two concrete mechanisms available + +**To run a test body on an STA thread:** `[STATestClass]` or `[STATestMethod]` from +`MSTest.TestFramework` 4.4.0 (`UtilitiesCS.Test/packages.config:146-147`; +`UtilitiesCS.Test/UtilitiesCS.Test.csproj:761-765`). Widely used already — +`UtilitiesCS.Test/Threading/ProgressViewer_Tests.cs:30`, `.../ProgressPane_Tests.cs:28`, +`.../Dialogs/MyBox_Tests.cs:25`, `.../EmailIntelligence/FolderRemapViewer_Tests.cs:26`, +`.../EmailIntelligence/FilterOlFoldersViewer_Tests.cs:26`, `.../Extensions/WinFormsExtensions_Tests.cs` +(twelve `[STATestMethod]`s), `Tags.Test/*.StaTests.cs`. + +**To run a delegate on an STA thread from an MTA test:** a dedicated thread with +`SetApartmentState(ApartmentState.STA)` before `Start()`. There are at least a dozen in-repo hosts; +the ones closest to this work are: +- `UtilitiesCS.Test/Threading/UiThread_Tests.cs:186-213` — `StaDispatcherHost`, runs `Dispatcher.Run()`, + shuts down deterministically. +- `UtilitiesCS.Test/Threading/WpfUiDispatcherTests.cs:185` and + `UtilitiesCS.Test/OutlookObjects/Folder/WpfDispatcherYieldTests.cs:241` — sibling copies. +- `QuickFiler.Test/TestSupport/WinFormsPumpHost.cs:51-69` — a full WinForms `Application.Run` + message pump on an STA thread (issue #230), the only in-repo host that actually pumps WinForms. + It lives in `QuickFiler.Test`, not `UtilitiesCS.Test`. + +**To run a delegate on an MTA thread:** do nothing — a plain `[TestMethod]` already is MTA. If a +specific test class must be STA-hosted but still needs an MTA Act, spawn a `Thread` and leave its +apartment at the default (`ApartmentState.MTA`) and `Join()` it, which is the shape the #787 potential +entry itself proposes (`docs/features/potential/promoted/2026-09-05-uithread-init-accepts-non-sta-callers.md:76`). + +**Mechanism the plan should use for AC4:** put the STA-rejection cases in an `[STATestClass]` and the +MTA-rejection cases in a plain `[TestClass]`, both in `UtilitiesCS.Test`, both `[DoNotParallelize]`, +both wrapping their Act in the `UiThreadStateScope` from R3. Do not introduce an +`ExecutionThreadApartmentState` runsettings change — `test.runsettings:2-5` records that decision and +reversing it would put the whole assembly on STA and remove class-level parallelism. + +--- + +## R5 — The MTA test caller that must be reconciled + +**Exact call:** `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs:329` — +`UiThread.Init(false);` + +**Enclosing test:** `public void Worker_RunWorkerCompleted_HandlesCompletionCorrectly()`, declared at +`:326` under a plain `[TestMethod]` at `:325`. It is the last method in the file (file ends at `:361`). +The call is the first line of the Arrange block. + +**Why the test calls it.** The Act (`:346-353`) invokes the private +`QfcHomeController.Worker_RunWorkerCompleted` by reflection with a non-cancelled, non-faulted +`RunWorkerCompletedEventArgs` (`:343`). That drives the `else` branch of +`QuickFiler/Controllers/QfcHomeController.cs:357-367`, whose body is +`UiThread.Dispatcher.Invoke(() => { _formViewer.ItemsPerLoadEnabled = true; _formViewer.SkipButtonEnabled = true; });` +at `:360-364`. `UiThread.Dispatcher` throws `InvalidOperationException` when `_dispatcher` is null +(`UiThread.cs:159-167`), so the test needs the static populated. `UiThread.Init(false)` is the +cheapest way it found to do that. + +**What it depends on afterwards.** The two assertions at `:356-357` +(`mockFormViewer.Object.ItemsPerLoadEnabled` and `.SkipButtonEnabled` both true) require that the +lambda passed to `Dispatcher.Invoke` **actually executed before `Invoke` returned**. That holds today +only because `Init()` captured `Dispatcher.CurrentDispatcher` on the *same* MSTest worker thread, so +`CheckAccess()` is true and `Invoke` runs the delegate inline. There is no message pump anywhere in +this test. This is a latent order-dependency: if any earlier test in the same process had already +consumed `_loaded` (or installed a `_dispatcher` belonging to a different, non-pumping thread), this +`Invoke` would either use a stale dispatcher or block. `UtilitiesCS.Test/Threading/UiThread_Tests.cs:151-156` +documents the reciprocal hazard from the other side. + +### Reconciliation options + +| Option | Consequence | +|---|---| +| **A. Add `[STATestMethod]` to `:325`** (or `[STATestClass]` to the class) | Minimal edit; `Init()` then passes the apartment check. **But it does not remove the order-dependency and may make it worse.** If `_loaded` was already consumed on some other thread, `Init()` becomes a no-op and `UiThread.Dispatcher` returns a dispatcher belonging to a foreign thread; `Invoke` would then post to a dispatcher with no running frame and **block until the test times out**. It also leaves a never-shut-down `Dispatcher` on a pooled MSTest STA worker, which is precisely the hazard finding C10 of #782 removed from `UiThread_Tests.cs` (`code-review.2026-09-05T23-00.md:48`; rationale quoted at `UiThread_Tests.cs:178-185`). | +| **B. Remove the `Init()` call and install a pumped dispatcher for the test's duration** — `RECOMMENDED` | `QuickFiler.Test` already owns the machinery: `QfcItemController.UiThreadDispatcherFixture.Exchange(...)` / `BeginTransactionAsync()` + `UiThreadDispatcherTransaction.Install(...)` (`QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs:55-63`, `:122-126`, `:242-254`) atomically swaps `UiThread._dispatcher` and restores it on dispose. Install a dispatcher from a **pumping** STA host — the `StaDispatcherHost` shape at `UtilitiesCS.Test/Threading/UiThread_Tests.cs:186-213` (which calls `Dispatcher.Run()`), replicated in `QuickFiler.Test`, or `WinFormsPumpHost` (`QuickFiler.Test/TestSupport/WinFormsPumpHost.cs`). `Dispatcher.Invoke` from the MSTest thread then marshals to the host thread, runs the lambda, and returns synchronously, so the two assertions at `:356-357` still hold. This removes the `UiThread.Init` dependency entirely and makes the test order-independent. | +| **C. Do not use the fixture's `EnsureDispatcher()`** | Explicitly rejected: its parked dispatcher never runs a frame (`UiThreadDispatcherFixture.cs:143-177`), so `Dispatcher.Invoke` from the MSTest thread would block forever. | +| **D. Inject an `IUiDispatcher` into `QfcHomeController`** | The seam exists (`UtilitiesCS/Threading/IUiDispatcher.cs`, `WpfUiDispatcher.cs`) and `QfcItemController` already uses it (`QuickFiler.Test/Controllers/QfcItemController.SeamDispatcherTests.cs:21`). But `QfcHomeController.cs:360` is not routed through it, so this is a production change to a file outside the issue's stated scope. Larger than option B for the same benefit. | + +**Assertions affected:** only `:356` and `:357`. Nothing else in the test method reads `UiThread`. +Option B preserves both; option A preserves both only when the latch happens to be unconsumed. + +--- + +## R6 — The awaiter fix and its ordering-sensitive callers + +### The #781 precedent — and it says the opposite of what #809's notes assume + +The breadcrumb UI-boundary guard is `QuickFiler/Viewers/BreadcrumbUiDispatcher.IsCurrentBoundary()` +at `QuickFiler/Viewers/BreadcrumbUiDispatcher.cs:255-278`. Quoted verbatim, `:263-272`: + +```csharp +// When a context was captured it is the authoritative boundary, so only an ambient +// reference match to that exact context proves the caller is on it. Bare owner-thread +// identity must never substitute here: a continuation resumed after +// ConfigureAwait(false) can be scheduled onto a recycled thread-pool thread whose +// managed thread ID equals the captured owner thread ID, which would run UI work inline +// and complete the returned task without any post ever crossing the captured context. +if (_context != null) +{ + return ReferenceEquals(SynchronizationContext.Current, _context); +} +``` + +Thread identity is used **only** on the `_context == null` path, which +`CreateForCurrentThreadTests()` (`:62-65`) constructs for host-neutral tests +(`:276-277`). The related `DispatchValue` guard is stricter still — `:164-166`: +`ReferenceEquals(_executingDispatcher, this)`, with the comment *"Ambient context and thread identity +do not survive awaits."* + +**Therefore the #809 `## Suspected Cause / Notes` claim at `issue.md:59` — that +`UiThread.UiThreadId == Thread.CurrentThread.ManagedThreadId` is "the same ownership test #781 adopted +for the breadcrumb UI-boundary guard" — is FALSE.** #781 adopted reference equality and explicitly +rejected bare thread identity, in a comment written for exactly this reason. The planner must not +cite #781 as precedent for a thread-id-only predicate. + +The runtime probe is at +`docs/features/active/2026-09-05-breadcrumb-ui-boundary-guard-rejects-dispatcher-built-viewers-781/evidence/other/dispatcher-synccontext-probe.2026-09-05T10-40.md`. +Its recorded output (`:36-45`) on .NET Framework 4.8 STA: + +``` +ReuseDispatcherSynchronizationContextInstance: True +outer ambient type : System.Windows.Forms.WindowsFormsSynchronizationContext +inside Dispatcher.Invoke type : System.Windows.Threading.DispatcherSynchronizationContext +Invoke ctx == outer ambient : False +Invoke#1 ctx == Invoke#2 ctx : True +InvokeAsync ctx == Invoke#1 : True +ambient after ops == outer : True +``` + +Two consequences the issue does not draw out. First, `Invoke#1 ctx == Invoke#2 ctx : True` means the +defect **does not reproduce inside a dispatcher operation**: a viewer's captured +`DispatcherSynchronizationContext` *is* reference-equal to the ambient one during any later dispatcher +operation on the same dispatcher, so `IsCompleted` is already true there today. The defect is confined +to awaits taken on the UI thread *outside* a dispatcher operation. Second, +`ambient after ops == outer : True` means the WinForms context is restored afterwards, so the +persistent UI context and the dispatcher context are two distinct, both-valid UI-owned instances. + +### Awaiter callers (every live site) + +Search: `Grep` over `**/*.cs` for `await (UiThread\.)?UiSyncContext|UiSyncContext\.GetAwaiter|await .*SyncContext;`. +Note that no caller anywhere awaits `UiThread.UiSyncContext` itself; every production caller awaits a +**viewer-level** `UiSyncContext` property, which is captured in the viewer constructor +(`QuickFiler/Viewers/ItemViewer.cs:26`, `.../EfcViewer.cs:26`, `.../QfcFormViewer.cs:23`, +`.../ItemViewerExpanded.cs:21`, `.../QfcItemViewer.cs:24`, `.../QfcItemViewerExpanded.cs:24` — all +`_context = SynchronizationContext.Current;`). + +| Site | Ordering change if `IsCompleted` becomes true on the UI thread | Existing test asserting the ordering | +|---|---|---| +| `QuickFiler/Controllers/QfcItemController.ViewerSetup.cs:64` | **Yes, materially.** `:67` immediately calls `TaskScheduler.FromCurrentSynchronizationContext()`. Today the continuation runs inside a posted callback whose ambient context is the *dispatcher* context; inline, the ambient would be the persistent WinForms context. The resulting `TaskScheduler` targets a different (but still UI-affine) context. **A predicate that returns true when `SynchronizationContext.Current` is null would make this line throw `InvalidOperationException`.** | None found. | +| `QuickFiler/Controllers/EfcItemController.cs:191` | Same shape — `TaskScheduler.FromCurrentSynchronizationContext()` at `:201`. | None found. | +| `QuickFiler/Controllers/QfcItemController.ViewerSetup.cs:287` | Removes one queued hop. | None found. | +| `QuickFiler/Controllers/EfcItemController.cs:1104` | Removes one queued hop. | None found. | +| `QuickFiler/Controllers/EfcFormController.cs:877` (`ActionCancelAsync`) | Inline instead of queued: `_formViewer.Close()` at `:879` then `Cleanup()` at `:880` would run before already-queued UI work rather than after it. | None found for `EfcFormController.ActionCancelAsync`. (`QuickFiler.Test/Controllers/QfcFormControllerCancelTeardownTests.cs:213` and `:247` assert ordering, but for **`QfcFormController`**, which does not use this awaiter.) | +| `QuickFiler/Controllers/EfcFormController.cs:911` (`ActionDeleteAsync`) | Inline instead of queued before `ApplyDeleteGesture()`. | `QuickFiler.Test/Controllers/EfcFormControllerTests.cs:392` `ActionDeleteAsync_AwaitedTwice_LeavesExactlyOneTrashRowInFolderRows`. It injects a bare `new SynchronizationContext()` into `_context` (`:401`) and asserts only the resulting `_folderRows`, not ordering. **Unaffected under the recommended predicate** — ambient on the MSTest thread is null, so the predicate returns false and the continuation posts to the thread pool exactly as today. | +| `QuickFiler/Controllers/EfcFormController.cs:927`, `:953` (`CreateFolderAsync`) | Inline instead of queued around `Hide()` / `Dispose()` / `Cleanup()`. | None found. | +| `QuickFiler/Controllers/EfcFormController.cs:1264` | Removes one queued hop. | None found. | +| `QuickFiler/Controllers/QfcCollectionController.cs:782` (`RemoveControlsAsync`) | Inline: the `TlpLayout` toggle at `:784-785` and `TableLayoutHelper.RemoveSpecificRow` at `:788` would run before already-queued layout work. | None found asserting ordering. | +| `QuickFiler/Controllers/QfcCollectionController.cs:2018` | Removes one queued hop. | None found. | +| `QuickFiler/Viewers/WebView2BreadcrumbHost.cs:263` | Inline: `CreateEnvironmentAsync` / `EnsureCoreWebView2Async` at `:265-269` start sooner. | None found. | +| `QuickFiler.Test/TestSupport/WinFormsPumpHostTests.cs:190` | **BREAKS under a thread-id-only predicate.** See below. | `AwaitingSyncContext_FromTheTestThread_ResumesOnThePumpThread` (`:183`) asserts `Thread.CurrentThread.ManagedThreadId == host.ThreadId` after the await (`:191-199`). | +| `QuickFiler.Test/TestSupport/WinFormsPumpHostTests.cs:245` | Same exposure. | `BothMarshalRoutes_WpfDispatcherAndSyncContext_ExecuteOnThePumpThread` (`:218`), assertion at `:255`. | + +Commented-out awaiter sites, excluded: `QuickFiler/Controllers/EfcFormController.cs:1040`, +`QuickFiler/Controllers/QfcItemController.cs:316`, `UtilitiesCS/Threading/IdleActionQueue.cs:79`. + +`ItemViewerQueue.Dequeue` (`QuickFiler/Helper Classes/ItemViewerQueue.cs:46-55`) does **not** await a +context. Its relevance is upstream: it dequeues through `ViewerQueueCore` whose production schedulers +are `UiThread.Dispatcher.InvokeAsync` / `.Invoke` (`:21`, `:27`, `:88`, `:90`), so an `ItemViewer` +built through it runs its constructor — and therefore `_context = SynchronizationContext.Current` +(`QuickFiler/Viewers/ItemViewer.cs:26`) — inside a dispatcher operation. That is the whole mechanism +of #784. `ItemViewer` carries `[ExcludeFromCodeCoverage]` at `QuickFiler/Viewers/ItemViewer.cs:20`. + +### Evaluating the proposed predicate `UiThread.UiThreadId == Thread.CurrentThread.ManagedThreadId` + +**(a) Awaiter over a NON-UI context, caller on the UI thread — the proposed predicate is WRONG, and +an existing test proves it.** `QuickFiler.Test/TestSupport/WinFormsPumpHostTests.cs:183-199` awaits +`host.SyncContext`, a `WindowsFormsSynchronizationContext` belonging to the pump thread +(`WinFormsPumpHost.cs:303-307`), from the MSTest thread, and asserts the continuation resumes on +`host.ThreadId`. If any earlier test in the `QuickFiler.Test` process has run `UiThread.Init()` on +the pooled MSTest worker — which +`QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs:329` does today, and which +`UtilitiesCS.Test/Threading/UiThread_Tests.cs:151-156` documents as an observed cross-class effect — +then `UiThread.UiThreadId` equals the MSTest worker's id, the bare predicate returns `true`, the +continuation runs **inline on the MSTest thread**, and the assertion at `:194-199` fails. This is not +hypothetical: it is the same reasoning `BreadcrumbUiDispatcher.cs:263-268` records. + +**(b) `UiThreadId` still `-1`.** `_uiThreadId = -1` (`UiThread.cs:133`). `Thread.ManagedThreadId` is +never negative, so a bare comparison is *safe* in this case — it simply returns false. The +consequence is a silent behaviour cliff rather than a bug: before `Init()` the awaiter always posts; +after `Init()` it may continue inline. Any predicate should still guard the sentinel explicitly so +the intent is legible. + +**(c) Second STA thread.** Its `ManagedThreadId` differs from `_uiThreadId`, so the predicate returns +false and the continuation posts. Correct, but for the wrong reason — the predicate would also return +false for a *legitimately* matching context on that thread, which is the today behaviour and is safe. + +**Recommended predicate.** Keep reference equality as the fast path, then admit exactly two +additional UI-owned cases, and only while actually standing on the owning UI thread with a non-null +ambient context. Because `SynchronizationContextAwaiter` is nested inside `UiThread`, it can read the +private statics `_uiThreadId`, `_uiSyncContext` and `_dispatcher` **directly**; it must not read the +`UiSyncContext` or `Dispatcher` *properties*, because the first lazily calls `Init()` (which under +AC1 would throw from a worker thread) and the second throws when unset. + +```csharp +public bool IsCompleted +{ + get + { + SynchronizationContext? ambient = SynchronizationContext.Current; + if (ReferenceEquals(_context, ambient)) + { + return true; + } + // A null ambient context means there is nothing to resume onto: continuing inline would + // break TaskScheduler.FromCurrentSynchronizationContext() at the two WebView2 setup sites. + if (ambient is null) + { + return false; + } + if (_uiThreadId == -1 || _uiThreadId != Thread.CurrentThread.ManagedThreadId) + { + return false; + } + // The persistent UI context captured at Init() time. + if (ReferenceEquals(_context, _uiSyncContext)) + { + return true; + } + // A dispatcher context is UI-owned only when this thread's dispatcher is the UI dispatcher. + return _context is DispatcherSynchronizationContext + && ReferenceEquals(Dispatcher.FromThread(Thread.CurrentThread), _dispatcher); + } +} +``` + +Why not resolve the `DispatcherSynchronizationContext`'s own dispatcher: .NET Framework 4.8's +`System.Windows.Threading.DispatcherSynchronizationContext` exposes no public `Dispatcher` property, +so the owning dispatcher can only be reached by reflection, which is not acceptable in production. +`Dispatcher.FromThread` is the reflection-free substitute; it returns `null` rather than creating a +dispatcher, so it is side-effect-free. + +Case-by-case: (a) `host.SyncContext` is a `WindowsFormsSynchronizationContext`, not +`_uiSyncContext` and not a `DispatcherSynchronizationContext`, so both `WinFormsPumpHostTests` +assertions still see `false` and still post — **the predicate is safe for the existing tests**; +(b) the `-1` sentinel is checked explicitly; (c) a second STA thread fails the id check. + +**`default(SynchronizationContextAwaiter)` under this proposal.** No field is added, so the struct's +default state is unchanged: `_context` is `null`. Today `IsCompleted` evaluates +`null == SynchronizationContext.Current`, which is `true` on a thread with no ambient context and +`false` otherwise; the recommended predicate's first clause, +`ReferenceEquals(null, ambient)`, produces exactly the same two answers, and the `ambient is null` +early-return covers the remaining branch. `OnCompleted` still `NullReferenceException`s on a default +instance, as it does today (`UiThread.cs:102-103`). **There is no default-state regression, and this +is a positive reason to prefer a field-free predicate over "store the owning thread id in the awaiter +at construction time."** The construction-time capture is also unsound on its own terms: `GetAwaiter()` +(`UiThread.cs:108-111`) runs at the `await` site on the *awaiting* thread, not on the thread where the +context was captured, so a captured id would record the wrong thread. + +--- + +## R7 — Existing tests that must keep passing + +| File | Test methods at risk | Which change touches them | +|---|---|---| +| `UtilitiesCS.Test/Threading/UiThread_Tests.cs` (`SynchronizationContextAwaiter_Tests`, `:9-104`) | `Constructor_NullContext_ThrowsArgumentNullException` (`:13`); `IsCompleted_WhenContextIsNotCurrent_ReturnsFalse` (`:23`); `IsCompleted_WhenContextMatchesCurrent_ReturnsTrue` (`:37`); `GetResult_DoesNotThrow` (`:61`); `OnCompleted_PostsCallbackToContext` (`:75`) | **#784.** `:23` uses a bare `new SynchronizationContext()` with no ambient context installed — under the recommended predicate the `ambient is null` early-return keeps it `false`. `:37` installs the same instance as ambient (`:42`) so the reference fast path keeps it `true`. Both still pass. **Neither class carries `[DoNotParallelize]`; if AC4 adds latch-mutating tests to this file, the attribute must be added.** | +| `UtilitiesCS.Test/Threading/UiThread_Tests.cs` (`UiThread_Dispatcher_Tests`, `:128-214`) | `Dispatcher_WhenBackingFieldIsNull_ThrowsInvalidOperationExceptionNamingInitialize` (`:133`); `Dispatcher_WhenBackingFieldIsPopulated_ReturnsThatSameInstance` (`:149`) | **#787/#788.** `:149-156` explicitly documents its dependence on `QfcHomeControllerRunAsyncTests` calling `UiThread.Init(false)`; changing that call (R5) changes the premise of this comment, though not the assertion, because the test installs a known null prior at `:157`. The method name's `NamingInitialize` suffix is deliberately inaccurate and **must not be renamed** — `code-review.2026-09-05T23-00.md:145-153` records that its fully-qualified name is quoted inside a committed `TestCaseFilter` evidence artifact. | +| `UtilitiesCS.Test/OutlookObjects/Folder/WpfDispatcherYieldTests.cs` | `YieldAsync_WithoutDispatcher_RemainsStrict` — asserts `.WithMessage(UiThread.DispatcherNotInitializedMessage)` at `:136`; a second assertion at `:196` is `observedException.Message.Should().Contain("UiThread.Init()")` | **#787/#788.** If the shipped fix changes `DispatcherNotInitializedMessage` (`UiThread.cs:135-136`), the `:136` assertion moves with the constant but the `:196` substring assertion does **not** — the literal `UiThread.Init()` must survive in the message text. | +| `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` | The `ForceDispatcherNull` region (`:137-171`, `:225-245`) forces `UiThread.Dispatcher` to null through `UiThreadDispatcherScope` | **#787/#788.** Any change to the `Dispatcher` accessor or its message. | +| `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs` | `EnterUiContextAsync_WhenUiSyncContextPostsSynchronously_CompletesUsingDefaultAction` (`:462`) — installs `UiThread._uiSyncContext` by reflection at `:479`, restores at `:485` | **#787.** It never leaves the field null, so the lazy `Init()` is not reached. It would break only if the fix changed the field name. | +| `UtilitiesCS.Test/EmailIntelligence/FolderRemapViewer_Tests.cs` (`[STATestClass]`, `:26`) | `SetController_WithSyntheticController_ConfiguresTreeDelegates` (`:118`) — reaches `UiThread.AutoScaleFactor` and asserts `NotThrow` | **#787/#788.** This is the in-repo test that actually drives `Init()` → `Initialize()` to success. Under AC1 it must remain STA. | +| `UtilitiesCS.Test/EmailIntelligence/FilterOlFoldersViewer_Tests.cs` (`[STATestClass]`, `:26`) | `SetController_WithSyntheticController_ConfiguresBothTreeDelegates` (`:39`) | Same. | +| `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs` | `Worker_RunWorkerCompleted_HandlesCompletionCorrectly` (`:326`) | **#787.** The one caller AC1 breaks. See R5. | +| `QuickFiler.Test/TestSupport/WinFormsPumpHostTests.cs` | `AwaitingSyncContext_FromTheTestThread_ResumesOnThePumpThread` (`:183`); `BothMarshalRoutes_WpfDispatcherAndSyncContext_ExecuteOnThePumpThread` (`:218`) | **#784.** These are the two tests that a thread-id-only predicate would break. See R6(a). | +| `QuickFiler.Test/Controllers/EfcFormControllerTests.cs` | `ActionDeleteAsync_AwaitedTwice_LeavesExactlyOneTrashRowInFolderRows` (`:392`) | **#784.** Injects `new SynchronizationContext()` into `EfcViewer._context` (`:401`); still posts under the recommended predicate. | +| `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` | Snapshots/restores `UiThread.Dispatcher` through the QuickFiler fixture (`:27-50`) | **#788** (only if the latch design touches `_dispatcher`). | +| `QuickFiler.Test/Controllers/QfcItemController.InitializationTests.Part2.cs`, `.TestSupport.cs`, `.SeamDispatcherTests.cs`, `.UiThreadDispatcherFixture.cs`, `WpfUiDispatcherTests.cs` | All consumers of the `UiThread._dispatcher` reflection fixture | **#788** (same condition). | + +--- + +## R8 — Coverage and file size + +- **`UtilitiesCS/Threading/UiThread.cs` is 195 lines.** The 500-line repository limit + (`.claude/rules/general-code-change.md`, "File Size Limit") leaves roughly 305 lines of headroom, + so all three fixes can land in this one file without a partial-class split. +- **Owning test assembly: `UtilitiesCS.Test`.** `UtilitiesCS/UtilitiesCS.csproj:1112` compiles + `Threading\UiThread.cs`; `UtilitiesCS.Test/UtilitiesCS.Test.csproj:503` compiles + `Threading\UiThread_Tests.cs` and `:76` compiles `TestHelpers\UiThreadDispatcherScope.cs`. Both are + **legacy (non-SDK) csproj files with explicit `` items**, so any new test file or + new production file must be added to the csproj by hand. +- **Neither `UiThread` nor any nested type carries `[ExcludeFromCodeCoverage]`.** A `Grep` for + `ExcludeFromCodeCoverage` over `UtilitiesCS/Threading` returns hits only in `ThreadMonitor.cs` + (`:92`, `:104`, `:137`, `:201`) and a documentation mention in `LockupStallDecider.cs:49`. + `SyncContextForm.cs` carries none either. `coverage.config` (repo root, 24 lines) excludes only + third-party module paths (Deedle, FSharp, Castle.Core, FluentAssertions, Moq, Microsoft.Testing, + MSTest) — **no first-party exclusion applies to `UiThread.cs`.** +- **Measured baseline for this file**, quoted from + `docs/features/active/2026-09-05-pr-778-post-merge-review-residuals-782/evidence/other/r1-r2-maintainer-disposition.2026-09-06T00-15.md:74-83`: + 19 uncovered lines — `28,29,30,32,33,34,67,68,69,70,71,72,73,74,75,76,118,119,120` — for + **76.83% line / 65.00% branch**, below the 80% modified-file trigger floor. The maintainer + **WAIVED** raising it in #782 and recorded that doing so needs a seam extraction on the + `ThreadMonitor` block at `:67-76`, "the same class of change already carved out to issues #787 and + #788" (`:85-93`). #809 is that carve-out, so the coverage floor is now in scope for this delivery: + the R3 factory seam covers `:67-76` (the `_monitorUiThread` branch) and AC4's tests cover + `:28-34` (the two null-guard branches of `Init()`) and `:118-120` (the lazy `UiSyncContext` path). + All 19 currently-uncovered lines are reachable through the recommended design. + +--- + +## Recommended design + +### #787 — STA precondition on `Init()` + +**Change.** Insert a precondition as the **first statement** of `Init()` (`UiThread.cs:19-40`), before +the four field assignments at `:26-35` and before the latch read at `:36`: + +```csharp +ApartmentState apartment = Thread.CurrentThread.GetApartmentState(); +if (apartment != ApartmentState.STA) +{ + throw new InvalidOperationException(NonStaInitMessage(apartment)); +} +``` + +with a sibling of the existing message constant: + +```csharp +internal const string NonStaInitMessagePrefix = + "UiThread.Init() must be called on the UI (STA) thread during host startup. Observed apartment state: "; + +private static string NonStaInitMessage(ApartmentState observed) => + NonStaInitMessagePrefix + observed; +``` + +Placing it before `:26` matters: the four assignments at `:26-35` mutate process-global monitoring +configuration even when the latch is already consumed, so a non-STA caller currently poisons +`_monitorUiThread`, `_onLockupDetected`, `_monitorTimeProvider`, and `_lockupAttributionThresholdMs` +regardless of the latch. AC1 says "before any global is captured"; that includes these four. + +`internal const` matches `DispatcherNotInitializedMessage` (`UiThread.cs:135-136`) and is reachable +from `UtilitiesCS.Test` via `UtilitiesCS/Properties/AssemblyInfo.cs:19`. Split the constant from the +formatted message so a test can assert the stable prefix without pinning the enum rendering. + +**Seam.** None required — MSTest's default apartment is MTA (R4), so a plain `[TestMethod]` supplies +the rejection case directly and `[STATestMethod]` supplies the acceptance case. + +**Tests (`UtilitiesCS.Test/Threading/`, new file, `[DoNotParallelize]`).** +1. Plain `[TestMethod]`: `Init()` throws `InvalidOperationException` whose message starts with + `NonStaInitMessagePrefix` and contains `MTA`. +2. Plain `[TestMethod]`: after the throw, `UiThread._loaded` is still unconsumed and the four + monitoring fields are unchanged (read through the R3 reset scope). +3. `[STATestMethod]`: `Init()` from an STA thread does not throw and populates all four capture + fields. +4. Reconcile `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs:329` per R5 option B. + +### #788 — retry after a failed `Initialize()`, without re-arming the shared latch + +**Change.** Replace the single-shot guard's role in `Init()` with a *success-recorded* latch plus a +serializing lock, so the latch is set only after `Initialize()` returns: + +```csharp +private static readonly object InitLock = new object(); +private static bool _initialized; + +// inside Init(), after the STA precondition and the four assignments: +lock (InitLock) +{ + if (_initialized) + { + return; + } + Initialize(); + _initialized = true; +} +``` + +`ThreadSafeSingleShotGuard` is retired from `UiThread` (the type stays; it has other consumers to be +confirmed by the planner). A failed `Initialize()` propagates with `_initialized` still `false`, so a +later `Init()` retries — which is exactly AC2. + +**Why this does not reintroduce the #782 regression.** The #782 mechanism, as recorded, is: a +re-armed latch makes *every subsequent read of `UiSyncContext` or `AutoScaleFactor`* re-enter +`Initialize()`, reconstruct and `Show()` a WinForms `SyncContextForm`, and throw again, starving the +thread pool. That mechanism has two necessary preconditions, and **the #787 precondition removes the +second one on every thread where the starvation could occur.** Verified in R1: the only ways to reach +`Initialize()` are the two direct `Init()` calls and the two lazy getters at `UiThread.cs:119` and +`:185`. With the STA check as the first statement of `Init()`, a non-STA reader of either lazy getter +now fails at a single `GetApartmentState()` read and a `throw` — it never reaches `new +SyncContextForm()` at `:51` or `Show()` at `:54`. The expensive-and-throwing body is therefore +unreachable from any thread-pool thread, which is where thread-pool starvation would have to +originate. On the STA thread itself, `Initialize()` succeeds (verified in R1 against +`FolderRemapViewer_Tests.cs:118-131` and `FilterOlFoldersViewer_Tests.cs:39-51`), so the retry loop +never engages there either. The `lock (InitLock)` additionally serializes concurrent first attempts, +which the `Interlocked.Exchange` latch never did — `code-review.2026-09-05T23-00.md:42` records that +pre-existing race as no-action finding C04. + +Two residual obligations the planner must carry, because R1 could not close them: +- The recorded mechanism requires `Initialize()` to throw somewhere in the test run, and I found no + in-tree path where it does (R1, points 1-3). **Add a Phase 0 measurement** that establishes, on + this host, whether `new SyncContextForm(); Show();` throws on an MTA thread. If it does not, the + #782 regression narrative is refuted and the AC2 "reproduce the #782 scenario" clause must be + restated as "reproduce a forced-throw scenario through the R3 factory seam". +- Regardless of the outcome, run the full nine-assembly suite and record + `TryAddValuesAsync_UpdatesExistingValue` explicitly, per + `docs/features/potential/promoted/2026-09-05-uithread-init-latch-not-rearmed-after-failed-initialize.md:78`. + Because that test is the documented #780 flake, a **single** failure is not sufficient evidence of + regression; require at least three repetitions before attributing it. + +**Seam.** `internal static Func SyncContextFormFactory { get; set; }` on `UiThread`, +defaulting to `() => new SyncContextFormAdapter()`, where `IUiCaptureSource` exposes +`ShowInTaskbar`/`WindowState`/`Show()`/`Hide()`/`CaptureUiVariables()` and the four capture +properties from `SyncContextForm` (R3). Pattern precedent: `ItemViewerQueue.ProductionViewerFactory` +(`QuickFiler/Helper Classes/ItemViewerQueue.cs:11-27`, reset at `:83-91`) and +`FolderPredictor`'s four static delegates (`UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs:161-184`). +Plus `internal static void ResetForTesting()` on `UiThread` clearing `_initialized`, +`_uiSyncContext`, `_autoScaleFactor`, `_uiThreadId` (to `-1`), `_dispatcher`, `_syncContextForm`, +and restoring the default factory. + +**Tests (`UtilitiesCS.Test`, `[STATestClass]` where `Initialize()` must succeed, `[DoNotParallelize]` +everywhere, all wrapped in a new `UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs`).** +1. Factory throws → `Init()` propagates; a second `Init()` with a working factory succeeds and + populates all four fields. (AC2 core.) +2. Factory throws → the four capture fields are all still unset afterwards. +3. Factory throws → a subsequent read of `AutoScaleFactor` from an **MTA** thread throws the AC1 + `InvalidOperationException` rather than re-entering the factory; assert the factory invocation + count did not increase. **This is the direct AC2 anti-regression test**: it asserts the absence of + the #782 retry storm as an invocation count rather than as a wall-clock duration. +4. Two concurrent STA `Init()` calls invoke the factory exactly once. + +### #784 — `IsCompleted` on the owning UI thread + +**Change.** Replace `UiThread.cs:100` with the block given in R6, reading the private statics +`_uiThreadId`, `_uiSyncContext`, `_dispatcher` directly rather than through their properties, and +adding `using System.Windows.Threading;` (already present, `UiThread.cs:11`). + +**Seam.** None in production. Tests need only `SynchronizationContext.SetSynchronizationContext` (the +pattern already at `UiThread_Tests.cs:42`), an STA host with a real dispatcher (`StaDispatcherHost`, +`UiThread_Tests.cs:186-213`), and the R3 reset scope to control `_uiThreadId` / `_uiSyncContext` / +`_dispatcher`. + +**Tests (`UtilitiesCS.Test`, extending `SynchronizationContextAwaiter_Tests`, which must gain +`[DoNotParallelize]` once it mutates `UiThread` statics).** +1. Reference match with a non-null ambient → `true` (preserves the existing `:37` behaviour). +2. Ambient is `null`, `_context` non-null → `false` (protects + `TaskScheduler.FromCurrentSynchronizationContext()` at `QfcItemController.ViewerSetup.cs:67` and + `EfcItemController.cs:201`). +3. `_uiThreadId == -1` → `false`. +4. On an STA host thread: install `_uiThreadId`/`_dispatcher` for that thread, capture a context + inside `dispatcher.Invoke(...)`, restore the WinForms ambient, then assert `IsCompleted == true`. + **This is the AC3 defect test.** +5. Same arrangement, but the captured dispatcher context belongs to a *different* thread's + dispatcher → `false`. +6. A foreign `WindowsFormsSynchronizationContext` while `_uiThreadId` equals the current thread id → + `false`. **This is the regression guard for the `WinFormsPumpHostTests` failure mode in R6(a).** +7. `default(SynchronizationContextAwaiter).IsCompleted` on a context-free thread → `true`, matching + today's behaviour. + +**Additionally:** amend the issue's `## Suspected Cause / Notes` bullet at `issue.md:59`. It cites +#781 as precedent for a thread-id predicate; `BreadcrumbUiDispatcher.cs:263-272` records the opposite +rule. Leaving that sentence standing invites a future planner to implement the predicate that R6(a) +shows breaks two existing tests. + +--- + +## Open questions for the planner + +1. **UNKNOWN — does `new SyncContextForm(); Show();` throw on an MTA thread in this test host?** + This is the single fact the entire #782 constraint rests on, and it cannot be established by + reading. Searched: `UtilitiesCS/Threading/SyncContextForm.cs` and `.Designer.cs` (no OLE-requiring + control), `UtilitiesCS/Threading/UiThread.cs`, and every `.Test` file matching `UiThread.` or + `AutoScaleFactor`. Two in-tree records point opposite ways: + `UtilitiesCS.Test/Threading/UiThread_Tests.cs:151-156` states that `QfcHomeControllerRunAsyncTests` + calling `UiThread.Init(false)` "populates the same process-global static" (implying success on an + MTA worker), while the #782 mechanism requires a throw. Resolve by measurement in Phase 0. +2. **UNKNOWN — the member set behind the #782 figure "49 live reads across 25 production files".** + My independent census gives 56 across 31 (R2). The #782 artifacts publish the figure but not the + list, so the +7/+6 divergence cannot be decomposed. Searched: + `docs/features/active/2026-09-05-pr-778-post-merge-review-residuals-782/**` for `49`, `re-arm`, + `SyncContextForm`, and `TryAddValuesAsync`. +3. **UNKNOWN — whether `ThreadSafeSingleShotGuard` has consumers outside `UiThread`.** I read + `UtilitiesCS/Threading/ThreadSafeSingleShotGuard.cs` in full but did not enumerate its callers, so + I cannot say whether retiring it from `UiThread` leaves the type orphaned. Search + `**/*.cs` for `ThreadSafeSingleShotGuard` before deciding whether to delete or retain it. +4. **UNKNOWN — whether `ApartmentState` on `Thread.CurrentThread.GetApartmentState()` can return + `Unknown` in this host.** `.NET Framework` documents `ApartmentState.Unknown` for threads whose + apartment has not been set. The recommended precondition rejects anything that is not `STA`, which + is the conservative reading of AC1, but the planner should confirm the intent is "reject `MTA` and + `Unknown`" rather than "reject `MTA` only". +5. **Open decision — is `QuickFiler.Test` to be granted `InternalsVisibleTo`?** It currently is not + (`UtilitiesCS/Properties/AssemblyInfo.cs:18-20`). If AC4's tests must observe the new + `internal` factory or `ResetForTesting()` from `QuickFiler.Test`, a new grant is needed; the + recommended design avoids this by placing every AC4 test in `UtilitiesCS.Test`. +6. **Open decision — R5 option A versus option B.** Option A (`[STATestMethod]`) is a one-line edit + but preserves an order-dependency that can convert into a test hang; option B removes the + dependency but touches `QuickFiler.Test` fixture wiring. The recommendation is B; the planner + should confirm the added scope is acceptable. diff --git a/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/spec.md b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/spec.md new file mode 100644 index 000000000..2d44315fe --- /dev/null +++ b/docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/spec.md @@ -0,0 +1,500 @@ +# 2026-09-07-uithread-init-contract-residuals-784-787-788 (Spec) + +- **Issue:** #809 +- **Parent (optional):** none +- **Owner:** drmoisan +- **Last Updated:** 2026-09-07T20-40 +- **Status:** Draft +- **Version:** 0.2 +- **Work Mode:** full-bug (this file is the sole acceptance-criteria source; `user-story.md` is intentionally absent) +- **Research of record:** `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/research/research.2026-09-07T20-20.md` + +## Context +Consolidates three findings on one file, `UtilitiesCS/Threading/UiThread.cs`, that were filed separately as #784, #787, and #788 after the #781 and #782 reviews. (1) `Init()` accepts a non-STA caller and installs that worker's non-pumping dispatcher and context into set-once process-global state (#787). (2) `Init()` consumes its single-shot latch before `Initialize()` runs, so a failed first attempt can never be retried, and the naive re-arm was measured to regress in #782 (#788). (3) `SynchronizationContextAwaiter.IsCompleted` compares contexts by reference, so any context captured inside a WPF dispatcher operation always posts instead of continuing inline on the UI thread (#784). All three touch the same initialization and awaiter code and should ship as one change with one test suite. + +Environment: +- OS/version: Windows 11 Pro 10.0.26200 +- Runtime: .NET Framework 4.8 VSTO add-in hosted by Outlook desktop; `main` at `04a54e68` +- Command/flags used: `vstest.console.exe /InIsolation`; runtime probe in the #781 feature folder (`evidence/other/dispatcher-synccontext-probe.2026-09-05T10-40.md`) +- Data source or fixture: `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs:329` (MTA caller of `UiThread.Init(false)`) + +Impact / Severity: +- [ ] Blocker +- [ ] High +- [x] Medium +- [ ] Low + +Medium, carried from #787: in production `ThisAddIn.cs:35-40` is the only `Init()` caller and runs on the Outlook STA, so the hazards are reachable today only from test code, but a worker-thread read of the lazy accessors before startup completes would poison the process. #784 and #788 are Low individually. + + +## Repro & Evidence +Steps to Reproduce: +1. #787: call `UiThread.Init(false)` from an MTA thread (the in-repo instance is the test at `QfcHomeControllerRunAsyncTests.cs:329`). It returns normally and every later `UiThread.Dispatcher` / `UiSyncContext` / `UiThreadId` read marshals onto a thread with no message loop. +2. #788: arrange for `Initialize()` to throw (headless or non-STA), call `Init()`, fix the condition, call `Init()` again. The second call is a no-op because `_loaded.CheckAndSetFirstCall` at `UiThread.cs:36` was consumed before `Initialize()` ran. +3. #784: construct an `ItemViewer` through `ItemViewerQueue.Dequeue` (inside `UiThread.Dispatcher.Invoke`, so `UiSyncContext` is a `DispatcherSynchronizationContext`), then on the UI thread evaluate `viewer.UiSyncContext.GetAwaiter().IsCompleted`. It is `false`, so the continuation posts instead of running inline. + +Expected: +- `Init()` rejects a non-STA caller with a named `InvalidOperationException` before capturing anything. +- A failed `Initialize()` leaves the latch re-armed so a later `Init()` retries, without reintroducing the regression #782 measured. +- `IsCompleted` is true when the caller already runs on the owning UI thread, regardless of which `SynchronizationContext` instance is ambient. + +Actual: +See the three reproduction steps. #787 succeeds silently and poisons the globals for the process lifetime; #788 leaves `UiThread.Dispatcher` throwing an exception that names `Init()` as the remedy while `Init()` is a no-op; #784 adds one queued hop per await and changes ordering relative to already-queued UI work. + +Logs / Screenshots: +- [x] Attached minimal logs or screenshot +- Snippet: `UtilitiesCS/Threading/UiThread.cs` line 100 (verified 2026-09-05): `public bool IsCompleted => _context == SynchronizationContext.Current;` (reference comparison). Probe result: `Invoke ctx == outer ambient : False` on .NET Framework 4.8 STA. #787 and #788 are missing-precondition and ordering defects with no diagnostic output. + +Research-verified refinement of the #784 repro (research section R6, from the #781 probe output): the probe also records `Invoke#1 ctx == Invoke#2 ctx : True` and `ambient after ops == outer : True`. Two consequences follow. First, the defect does **not** reproduce while execution is inside a dispatcher operation on the same dispatcher, because the viewer's captured `DispatcherSynchronizationContext` is reference-equal to the ambient one there; the defect is confined to awaits taken on the UI thread **outside** a dispatcher operation. Second, the persistent WinForms context and the dispatcher context are two distinct, both-valid UI-owned instances on the same thread. The repro step above remains correct as written; the refinement narrows where the fix changes behaviour. + + +## Scope & Non-Goals + +### Write Set (authoritative) + +These are the only files this delivery modifies or adds. Every other repository path cited in this document is a read-only citation supporting an argument, not a change target. + +Production: +- `UtilitiesCS/Threading/UiThread.cs` — all three defect fixes plus the test seams. +- `UtilitiesCS/Threading/IUiCaptureSource.cs` — new narrow interface for the capture object (see Proposed Fix). +- `UtilitiesCS/Threading/SyncContextForm.cs` — declaration gains `IUiCaptureSource`; no new members (all six are already declared or inherited). +- `UtilitiesCS/UtilitiesCS.csproj` — explicit `` for the new interface file. This is a legacy, non-SDK, `packages.config` project (research R8), so a new file that is not listed does not compile. + +Test: +- `UtilitiesCS.Test/Threading/UiThread_Tests.cs` — awaiter cases, plus `[DoNotParallelize]` on any class that mutates `UiThread` statics. +- `UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs` — new; the AC1 and AC2 cases. +- `UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs` — new; snapshot/restore of the process-global statics. +- `UtilitiesCS.Test/UtilitiesCS.Test.csproj` — explicit `` for the two new test files (same legacy-project constraint). +- `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs` — gains `[DoNotParallelize]` only; no other change. +- `UtilitiesCS.Test/EmailIntelligence/FolderRemapViewer_Tests.cs` — gains `[DoNotParallelize]` only; no other change. +- `UtilitiesCS.Test/EmailIntelligence/FilterOlFoldersViewer_Tests.cs` — gains `[DoNotParallelize]` only; no other change. +- `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs` — the MTA `Init()` caller, reconciled per decision D4. + +Write Set amendment (2026-09-07, recorded during preparation preflight round 1). The three +`[DoNotParallelize]`-only test files above were added to this list after preflight established that +they are unprotected writers of the `UiThread` process-global statics: `FolderPredictorTests` writes +`UiThread._uiSyncContext` by reflection at `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs:479`, +and the two `[STATestClass]` viewer test classes drive `Init()` to success transitively and so write +all four capture fields. None of the three carries `[DoNotParallelize]` today. Under the MSTest +parallelization that `scripts/vscode/TaskMaster.cli.runsettings:4-7` configures — the element form +`` carrying `0` and `ClassLevel`, not an attribute +form — the serial and parallel buckets overlap in wall-clock +time, so marking only the classes this delivery adds does not stop a writer in another class of the +same assembly; the guarantee holds only when no writer of the shared static remains in the parallel +bucket. The collision does not exist today, because the currently marked class owns only `_dispatcher`; +it is introduced by this delivery, which clears and rewrites the remaining statics. The amendment is +therefore a consequence of this change rather than pre-existing debt, and each of the three files +gains exactly one attribute line. + +- In scope: + - The three defects #787, #788, and #784, all resident in `UtilitiesCS/Threading/UiThread.cs`. + - The minimum test seams required to exercise them without a live Outlook host: an injectable capture-object factory and a test-only static reset. + - Raising the measured coverage of `UtilitiesCS/Threading/UiThread.cs` above the CLAUDE.md floor. Research R8 records that #782 waived this uplift and identified #809 as the carve-out that would deliver it, and that all 19 currently-uncovered lines are reachable through the recommended seam. + +- Out of scope / non-goals: + - **Deleting `ThreadSafeSingleShotGuard` (decision D2, settled).** Only UiThread's own use of the type at `UtilitiesCS/Threading/UiThread.cs:46` is removed. The type is retained. Its consumers were enumerated across the repository by the maintainer at approximately twenty sites, including UtilitiesCS/ReusableTypeClasses/TimedActions/TimedBatchAction.cs:45, UtilitiesCS/Threading/IdleActionQueue.cs:55, UtilitiesCS/Threading/ApplicationIdleTimer.cs:444-445, UtilitiesCS/OutlookObjects/MailItem/MailItemHelper.cs:240-242, and TaskVisualization/FlagChangeTrainingQueue.cs:32. Deleting it would be a large unrelated change. (This closes research open question 3.) + - **Injecting an `IUiDispatcher` into `QfcHomeController` (research R5 option D).** The seam exists at UtilitiesCS/Threading/IUiDispatcher.cs and UtilitiesCS/Threading/WpfUiDispatcher.cs and QfcItemController already consumes it, but QuickFiler/Controllers/QfcHomeController.cs:360 is not routed through it. Routing it would be a production change outside this issue's stated scope, and research R5 records that it is larger than option B for the same benefit. + - **Granting `InternalsVisibleTo` to `QuickFiler.Test` (decision D3, settled).** No new grant is added. Every AC4 test lives in `UtilitiesCS.Test`, which already holds the grant at `UtilitiesCS/Properties/AssemblyInfo.cs:18-20`. QuickFiler.Test must not be granted access to UtilitiesCS internals. (This closes research open question 5.) + - **Assembly-wide STA execution.** No `ExecutionThreadApartmentState` change to any `.runsettings`. `UtilitiesCS.Test/test.runsettings:2-5` records the standing decision that global STA is intentionally disabled and that STA is opt-in per class or method; reversing it would remove class-level parallelism across the whole assembly. + - **Renaming `Dispatcher_WhenBackingFieldIsNull_ThrowsInvalidOperationExceptionNamingInitialize`.** Research R7 records that this method's fully-qualified name is quoted inside a committed `TestCaseFilter` evidence artifact, so the deliberately inaccurate `NamingInitialize` suffix must survive. + - Changing the observable behaviour of any of the 56 live `UiThread` read sites other than through the three fixed defects. + - Any change requiring a live Outlook process to verify. + +- Explicitly excluded systems, integrations, or datasets: + - Outlook Interop, Microsoft Graph, the classifier engines, and all persistence paths. This delivery touches only in-process threading state. + - `ThreadMonitor` behaviour. The `_monitorUiThread` block at `UiThread.cs:66-76` becomes reachable in tests through the new factory seam, but its logic is not changed. + +### Blast radius (measured read census, decision D6) + +Research R2 measured **56 live `UiThread` read/call sites across 31 production files** in this run, by `Grep` over `**/*.cs` for `UiThread\.(UiSyncContext|AutoScaleFactor|Dispatcher|UiThreadId|Init)`, with a second `Grep` confirming no `using static` alias exists, and with documentation, comments, commented-out code, and the message literal at `UiThread.cs:136` excluded. The test-side figure is 6 live sites across 3 files. + +The #782 delivery adopted **49 live reads across 25 production files** at tag `pre-782-base`. That earlier figure is not restated here as current. The +7/+6 divergence **cannot be decomposed**, because the #782 artifact publishes the figure but not its member set. Two contributing factors are identifiable but not sufficient to account for the gap: the current count includes the one production `UiThread.Init(` call, which "live reads" may have excluded, and the tree has taken several deliveries since that tag. Treat 56/31 as the current figure. (This leaves research open question 2 UNKNOWN and unresolvable from in-tree artifacts.) + +Only 8 of the 56 sites can reach `Init()` at all: the two direct calls (`TaskMaster/ThisAddIn.cs:35`, `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs:329`) and the six lazy reads of `UiSyncContext` or `AutoScaleFactor`. All remaining sites read `Dispatcher`, `UiThreadId`, or an already-populated field, and `UiThread.Dispatcher` never calls `Init()` (`UiThread.cs:153-171`). + + +## Root Cause Analysis + +### #787 — `Init()` accepts a non-STA caller + +Neither `Init()` (`UiThread.cs:19-40`) nor `Initialize()` (`UiThread.cs:48-79`) reads `Thread.CurrentThread.GetApartmentState()` (verified in research). `SyncContextForm.CaptureUiVariables()` (`UtilitiesCS/Threading/SyncContextForm.cs:34-40`) unconditionally reads `SynchronizationContext.Current`, `this.AutoScaleFactor`, `Dispatcher.CurrentDispatcher`, and `Thread.CurrentThread.ManagedThreadId` from whatever thread called it, and `Initialize()` copies all four into set-once process-global statics at `UiThread.cs:58-61`. A worker-thread caller therefore installs a dispatcher with no message loop and a non-UI context for the remaining process lifetime. + +A second, separable part of the same defect: the four monitoring-configuration assignments at `UiThread.cs:26-35` execute on **every** `Init()` call, before the latch is read at `:36`. A non-STA caller mutates `_monitorUiThread`, `_onLockupDetected`, `_monitorTimeProvider`, and `_lockupAttributionThresholdMs` even when the latch is already consumed and `Initialize()` will not run. AC1's phrase "before any global is captured" covers these four. + +### #788 — the latch is consumed before `Initialize()` runs + +`if (_loaded.CheckAndSetFirstCall)` at `UiThread.cs:36` is backed by `Interlocked.Exchange(ref _state, CALLED) == NOTCALLED` (`UtilitiesCS/Threading/ThreadSafeSingleShotGuard.cs:24-27`, verified). The latch transitions on the *attempt*, not on the *outcome*. If `Initialize()` throws, the exception propagates, the statics stay null, and every later `Init()` is a silent no-op. `UiThread.Dispatcher` then throws a message that names `UiThread.Init()` as the remedy (`UiThread.cs:135-136`) while `Init()` cannot help. + +The naive repair — re-arming the latch in a `catch` — was applied and withdrawn in #782 after a measured regression. The status of that measurement is addressed in the Proposed Fix under "Why the AC2 design does not reintroduce the #782 regression" and in decision D5; it is not assumed true here. + +A separate, pre-existing weakness in the same code: `Interlocked.Exchange` admits a race in which a second caller observes the latch consumed and returns while the first caller is still inside `Initialize()`, so the second caller can read a half-populated static set. #782 recorded this as no-action finding C04. + +### #784 — `IsCompleted` uses reference equality + +`UiThread.cs:100` is `public bool IsCompleted => _context == SynchronizationContext.Current;`. `SynchronizationContext` does not overload `==`, so this is reference identity. A viewer that is constructed inside `UiThread.Dispatcher.Invoke` captures a `DispatcherSynchronizationContext` (`QuickFiler/Viewers/ItemViewer.cs:26` and its five siblings all execute `_context = SynchronizationContext.Current;` in the constructor). Later, on the UI thread but outside a dispatcher operation, the ambient context is the persistent `WindowsFormsSynchronizationContext`, which is a different instance, so `IsCompleted` is false and every `await` on that context takes a queued hop. + +**CORRECTION (recorded 2026-09-07; supersedes the sentence carried in the seeded template and in the original GitHub issue body).** An earlier draft of this analysis asserted that the correct predicate is bare owning-thread identity, `UiThread.UiThreadId == Thread.CurrentThread.ManagedThreadId`, and attributed that test to #781. **That attribution is false and the predicate is unsafe.** `QuickFiler/Viewers/BreadcrumbUiDispatcher.cs:263-272` records the opposite rule verbatim: when a context was captured it is the authoritative boundary, and bare owner-thread identity must never substitute, because a continuation resumed after `ConfigureAwait(false)` can be scheduled onto a recycled thread-pool thread whose managed thread id equals the owner's. #781 used thread identity only on the `_context == null` path constructed for host-neutral tests, and its sibling `DispatchValue` guard at `:164-166` is stricter still. Independently, a bare-id predicate breaks a live test: `QuickFiler.Test/TestSupport/WinFormsPumpHostTests.cs:183-199` awaits a foreign `WindowsFormsSynchronizationContext` from the MSTest thread and asserts the continuation resumes on the pump thread, and if any earlier test in that process has run `UiThread.Init()` on the pooled MSTest worker — which `QfcHomeControllerRunAsyncTests.cs:329` does today — the bare predicate returns true, the continuation runs inline on the MSTest thread, and the assertion fails. The corrected note is already recorded in `issue.md`. The predicate this specification adopts is the field-free one in the Proposed Fix. + +Superseded issues: #784, #787, #788 (close with a pointer to #809). + + +## Proposed Fix + +### Design summary (what changes where): + +Three changes, all in `UtilitiesCS/Threading/UiThread.cs`, plus one narrow new interface and two test-only seams. + +1. **#787** — an apartment-state precondition inserted as the **first statement** of `Init()`, ahead of the four monitoring assignments and ahead of the latch. +2. **#788** — the single-shot guard is replaced *inside `UiThread` only* by a success-recorded flag guarded by a serializing lock, so the flag is set after `Initialize()` returns rather than before it runs. `ThreadSafeSingleShotGuard` itself is retained (decision D2). +3. **#784** — `IsCompleted` is replaced by a field-free predicate that keeps reference equality as its fast path and additionally admits exactly two demonstrably UI-owned cases while the caller stands on the owning UI thread. + +### Boundaries and invariants to preserve: + +- **`default(SynchronizationContextAwaiter)` behaviour is unchanged.** The new predicate adds no field to the struct, so the default instance still has `_context == null`. Today `IsCompleted` evaluates `null == SynchronizationContext.Current`, which is true on a context-free thread and false otherwise; the new predicate's first clause `ReferenceEquals(_context, ambient)` produces the same answer on the first branch and the `ambient is null` early return covers the second. `OnCompleted` still throws `NullReferenceException` on a default instance, exactly as at `UiThread.cs:102-103` today. This is a positive reason to prefer a field-free predicate: a construction-time capture of the owning thread id would also be unsound, because `GetAwaiter()` (`UiThread.cs:108-111`) runs at the `await` site on the awaiting thread, not on the thread where the context was captured. +- **A null ambient context must continue to return `false`.** Two call sites take `TaskScheduler.FromCurrentSynchronizationContext()` immediately after the await — `QuickFiler/Controllers/QfcItemController.ViewerSetup.cs:64` with the scheduler at `:67`, and `QuickFiler/Controllers/EfcItemController.cs:191` with the scheduler at `:201`. That factory throws `InvalidOperationException` when the ambient context is null. A predicate that returned true with a null ambient would continue inline on a thread with no context and break both sites. +- **Both `WinFormsPumpHostTests` assertions must continue to pass**: `AwaitingSyncContext_FromTheTestThread_ResumesOnThePumpThread` (`QuickFiler.Test/TestSupport/WinFormsPumpHostTests.cs:183`, assertion at `:191-199`) and `BothMarshalRoutes_WpfDispatcherAndSyncContext_ExecuteOnThePumpThread` (`:218`, assertion at `:255`). Under the adopted predicate the awaited context is a `WindowsFormsSynchronizationContext` that is neither `_uiSyncContext` nor a `DispatcherSynchronizationContext`, so the predicate returns false and the continuation posts to the pump thread exactly as today. +- **`UiThread.Dispatcher` remains deliberately non-lazy.** The `` at `UiThread.cs:141-148` states the asymmetry on purpose. Do not add an `Init()` call to that accessor. +- **The literal `UiThread.Init()` must survive inside `DispatcherNotInitializedMessage`.** `UtilitiesCS.Test/OutlookObjects/Folder/WpfDispatcherYieldTests.cs:196` asserts on that substring rather than on the constant, so the assertion does not move if the constant is edited. +- **Field names `_uiSyncContext` and `_dispatcher` must not change.** Four existing test helpers reach them by reflection (research R3): `UtilitiesCS.Test/TestHelpers/UiThreadDispatcherScope.cs:117`, `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs:136`, and `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs:470`. +- **The 500-line file limit.** `UiThread.cs` is 195 lines, leaving roughly 305 lines of headroom; no partial-class split is required. + +### Dependencies or blocked work: + +None external. One internal ordering dependency: the AC2 design's safety argument depends on the AC1 precondition being present and being the first statement of `Init()`. Ship them together; do not land the latch change alone. + +### Implementation strategy (what changes, not sequencing): + +#### Files/modules to change: + +See the Write Set above. No other file is modified. + +#### Functions/classes/CLI commands impacted: + +- `UiThread.Init(bool, Action?, TimeProvider?, int)` — gains the precondition and the lock. +- `UiThread.Initialize()` — obtains its capture object from the factory instead of constructing `SyncContextForm` directly. +- `UiThread.SynchronizationContextAwaiter.IsCompleted` — replaced. +- `SyncContextForm` — declaration gains `IUiCaptureSource`. +- New: `IUiCaptureSource`, `UiThread.SyncContextFormFactory`, `UiThread.ResetForTesting()`, `UiThread.NonStaInitMessagePrefix`. + +#### Data flow and validation changes: + +**#787 — the STA precondition.** Insert as the first statement of `Init()`, before the assignments at `UiThread.cs:26-35` and before the latch read at `:36`: + +```csharp +ApartmentState apartment = Thread.CurrentThread.GetApartmentState(); +if (apartment != ApartmentState.STA) +{ + throw new InvalidOperationException(NonStaInitMessage(apartment)); +} +``` + +with a sibling of the existing message constant: + +```csharp +internal const string NonStaInitMessagePrefix = + "UiThread.Init() must be called on the UI (STA) thread during host startup. Observed apartment state: "; + +private static string NonStaInitMessage(ApartmentState observed) => + NonStaInitMessagePrefix + observed; +``` + +`internal const` matches the visibility of `DispatcherNotInitializedMessage` (`UiThread.cs:135-136`) and is reachable from `UtilitiesCS.Test` through the existing grant. The prefix is split from the formatted message so a test can assert the stable text without pinning the enum's rendering. + +Placement ahead of `:26` is load-bearing, not cosmetic: those four assignments mutate process-global monitoring configuration on every call regardless of the latch, so a non-STA caller poisons them today even when `Initialize()` never runs. + +**Decision D1 (settled): the rejection is `!= STA`.** The precondition rejects any apartment state that is not `ApartmentState.STA`, which rejects both `ApartmentState.MTA` and `ApartmentState.Unknown`. AC1 says "non-STA thread" and `!= STA` is the reading that matches it. This closes research open question 4; the design does not depend on whether `Unknown` is actually observable on this host. + +**#788 — retry after a failed `Initialize()`.** Replace the latch's role in `Init()` with a success-recorded flag plus a serializing lock, so the flag is set only after `Initialize()` returns: + +```csharp +private static readonly object InitLock = new object(); +private static bool _initialized; + +// inside Init(), after the STA precondition and the four assignments: +lock (InitLock) +{ + if (_initialized) + { + return; + } + Initialize(); + _initialized = true; +} +``` + +A failed `Initialize()` propagates with `_initialized` still `false`, so a later `Init()` retries. `_loaded` and the `using`-level dependency on `ThreadSafeSingleShotGuard` are removed from this file only; the type is retained (decision D2). The lock additionally closes the pre-existing C04 race, which `Interlocked.Exchange` never covered. + +**#784 — the awaiter predicate.** Replace `UiThread.cs:100` with the block below, reproduced from research section R6: + +```csharp +public bool IsCompleted +{ + get + { + SynchronizationContext? ambient = SynchronizationContext.Current; + if (ReferenceEquals(_context, ambient)) + { + return true; + } + // A null ambient context means there is nothing to resume onto: continuing inline would + // break TaskScheduler.FromCurrentSynchronizationContext() at the two WebView2 setup sites. + if (ambient is null) + { + return false; + } + if (_uiThreadId == -1 || _uiThreadId != Thread.CurrentThread.ManagedThreadId) + { + return false; + } + // The persistent UI context captured at Init() time. + if (ReferenceEquals(_context, _uiSyncContext)) + { + return true; + } + // A dispatcher context is UI-owned only when this thread's dispatcher is the UI dispatcher. + return _context is DispatcherSynchronizationContext + && ReferenceEquals(Dispatcher.FromThread(Thread.CurrentThread), _dispatcher); + } +} +``` + +`using System.Windows.Threading;` is already present at `UiThread.cs:11`. + +**Why this predicate reads the private statics directly.** `SynchronizationContextAwaiter` is nested inside `UiThread`, so it can read `_uiThreadId`, `_uiSyncContext`, and `_dispatcher` as fields. It **must** do so rather than read the `UiSyncContext` or `Dispatcher` properties, for two distinct reasons: the `UiSyncContext` property lazily calls `Init()` (`UiThread.cs:117-120`), which under the AC1 precondition would now **throw** when the predicate is evaluated from a worker thread, turning a boolean query into an exception; and the `Dispatcher` property throws `InvalidOperationException` whenever the backing field is unset (`UiThread.cs:160-167`). A predicate must be side-effect-free and total, so it reads the fields. + +**Why not resolve the dispatcher context's own dispatcher.** .NET Framework 4.8's `System.Windows.Threading.DispatcherSynchronizationContext` exposes no public `Dispatcher` property, so its owning dispatcher is reachable only by reflection, which is not acceptable in production code. `Dispatcher.FromThread` is the reflection-free substitute and is side-effect-free: it returns `null` rather than creating a dispatcher. + +**The `-1` sentinel.** `_uiThreadId` is initialised to `-1` (`UiThread.cs:133`) and `Thread.ManagedThreadId` is never negative, so a bare comparison would already be safe. The sentinel is nevertheless checked explicitly so the pre-`Init()` behaviour is legible rather than incidental. + +#### Error handling and logging updates: + +- One new exception type/site: `InvalidOperationException` from `Init()` on a non-STA caller, carrying `NonStaInitMessagePrefix` plus the observed apartment state. No logging is added; `UiThread` has no logger today and the failure is a startup contract violation that must be loud rather than recorded. +- No exception is swallowed. A throwing `Initialize()` continues to propagate unchanged; the only difference is that `_initialized` remains false. +- Three production sites gain a *possible* new throw where they previously behaved silently (research R1): `TaskMaster/AppGlobals/AppOlObjects.cs:367`, `UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs:179`, and — only for a hypothetical off-UI-thread caller — `UtilitiesCS/EmailIntelligence/OlFolderTools/FolderRemap/FolderRemapViewer.cs:40` and `UtilitiesCS/EmailIntelligence/OlFolderTools/FilterOlFolders/FilterOlFoldersViewer.cs:79`. Each throws only when the backing field is still null. In production `ThisAddIn.cs:35` runs first, so the field is populated and none of them throws. At `AppOlObjects.cs:367` the new throw is strictly better than today's behaviour: the enclosing branch is entered only when the caller is *off* the UI thread (`:364`), so today it constructs a `SyncContextForm` on the worker and performs the COM read on the wrong apartment, which is the exact failure the comment at `:361-363` says it is preventing. +- Research R1 verified that **zero existing tests are affected** by these new throws, because every in-repo lazy-read driver is STA-hosted. The single in-repo caller AC1 definitively breaks is the MTA `Init()` call handled by decision D4 below. + +#### Rollback/feature-flag considerations (if applicable): + +None. No feature flag is introduced. The change is a single revert of `UtilitiesCS/Threading/UiThread.cs` plus its test files if a defect is found post-merge. A flag would be counterproductive here: the precondition's value is that it is unconditional. + +### Why the AC2 design does not reintroduce the #782 regression + +The #782 mechanism, as recorded, is: a re-armed latch makes every subsequent read of `UiSyncContext` or `AutoScaleFactor` re-enter `Initialize()`, reconstruct and `Show()` a WinForms `SyncContextForm`, throw again, and starve the thread pool. + +Research R1 verified the complete reachable surface of `Initialize()`. It is reachable from exactly four places: the two direct `Init()` calls (`TaskMaster/ThisAddIn.cs:35`, `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs:329`) and the two lazy getters at `UiThread.cs:119` and `UiThread.cs:185`. `UiThread.Dispatcher` never calls `Init()`. With the AC1 precondition as the **first** statement of `Init()`, a non-STA reader of either lazy getter fails at one `GetApartmentState()` read and a `throw`; it never reaches `new SyncContextForm()` at `:51` or `Show()` at `:54`. The expensive, potentially-throwing body is therefore unreachable from any thread-pool thread, which is where thread-pool starvation would have to originate. On the STA thread itself, `Initialize()` succeeds — verified against `UtilitiesCS.Test/EmailIntelligence/FolderRemapViewer_Tests.cs:118-131` and `UtilitiesCS.Test/EmailIntelligence/FilterOlFoldersViewer_Tests.cs:39-51`, both `[STATestClass]`, both asserting `NotThrow` — so no retry loop engages there either. The `lock (InitLock)` additionally serializes concurrent first attempts. + +**Decision D5 (settled): the #782 narrative is to be measured, not assumed.** The argument above stands independently of whether the recorded #782 mechanism was ever real. It removes the mechanism if the mechanism exists, and costs nothing if it does not. That independence is deliberate, because research R1 could not confirm the narrative and recorded three reasons to doubt it: + +1. Every in-repo path that reaches the lazy `AutoScaleFactor` getter in a test run is already STA-hosted, so `Initialize()` succeeds and no catch fires. +2. No test reaches the lazy `UiSyncContext` getter with a null backing field. The #782 record's own measured uncovered-line set for `UiThread.cs` includes `118,119,120`, which is exactly the `if (_uiSyncContext is null) { Init(); }` block, so that block was never executed with a null field in a measured run. +3. The readers #788 named as the surfacing path have no test driver: a search of `TaskMaster.Test` for `AppOlObjects`, `UserEmailAddress`, `ResolveCurrentUser`, and `SetUpBrightIdeasSettings` found no test exercising either reader, and `ThisAddIn.SetUpBrightIdeasSettings` is a private method on a VSTO type with no test caller. + +Separately, the regression test that #782 attributed the failure to — `UtilitiesCS.Test.Extensions.DictionaryExtensions_Tests.TryAddValuesAsync_UpdatesExistingValue` — is the same fully-qualified test that is independently documented as an intermittent flake under issue #780, with the same `TaskCanceledException` type and the same "21 s" signature. The #782 attribution rests on a single with/without run pair against that test. It is plausible; it is not statistically established. + +Two obligations follow, and they are binding on the plan: + +- **Measure it.** Establish by measurement on the execution host whether `new SyncContextForm(); Show();` throws when executed on an MTA thread. This is research open question 1 and it is UNKNOWN; it cannot be settled by reading, and two in-tree records point in opposite directions (`UtilitiesCS.Test/Threading/UiThread_Tests.cs:151-156` implies success on an MTA worker, while the #782 mechanism requires a throw). If it does not throw, the #782 narrative is refuted and AC2's "the #782 regression scenario is reproduced as a test" clause is discharged instead by a forced-throw scenario driven through the factory seam. Record the measurement under the feature folder's `evidence/other/` directory. +- **Do not attribute a single flake.** Run the full suite and record `TryAddValuesAsync_UpdatesExistingValue` explicitly. Because that test is the documented #780 flake, **a single failure is not sufficient evidence of a regression; at least three repetitions are required before attributing it** to this delivery. + +### Technical specifications (interfaces/contracts): + +#### Behavioural contract for `Init()` + +**Precondition.** The calling thread's apartment state must be `ApartmentState.STA`. Any other value, including `MTA` and `Unknown`, is rejected with `InvalidOperationException` whose message begins with `NonStaInitMessagePrefix` and ends with the observed apartment state (decision D1). + +**On rejection, nothing is captured.** Because the precondition is the first statement, none of `_monitorUiThread`, `_onLockupDetected`, `_monitorTimeProvider`, or `_lockupAttributionThresholdMs` is assigned; the initialization flag is not set; `Initialize()` does not run; no `SyncContextForm` is constructed or shown; and `_uiSyncContext`, `_autoScaleFactor`, `_uiThreadId`, and `_dispatcher` are left exactly as they were. + +**On acceptance, what is captured.** The four monitoring-configuration values are assigned from the arguments. Then, under `InitLock`, if initialization has not yet succeeded, `Initialize()` runs and captures four values from the capture object: `UiSyncContext`, `AutoScaleFactor`, `UiThreadId`, and `Dispatcher` (`UiThread.cs:58-61`). If `monitorUiThread` is true, a `ThreadMonitor` is constructed and run against the calling thread. + +**Retry semantics.** The initialization flag is set only after `Initialize()` returns normally. If `Initialize()` throws, the exception propagates to the caller, the flag stays false, and a subsequent `Init()` from an STA thread retries the full initialization. Once the flag is set, every later `Init()` performs the apartment check and the four monitoring assignments and then returns without re-initializing — so the four monitoring values remain settable after initialization, matching today's behaviour. Concurrent first attempts are serialized by `InitLock`; exactly one of them runs `Initialize()`. + +**Idempotence and thread affinity.** `Init()` is idempotent with respect to the captured UI state after the first success. It is not idempotent with respect to the monitoring configuration, which is by design and unchanged. + +#### Inputs/outputs and formats: + +`IUiCaptureSource` (new, `internal`, in `UtilitiesCS/Threading/IUiCaptureSource.cs`) exposes exactly the members `Initialize()` uses today: `bool ShowInTaskbar { get; set; }`, `FormWindowState WindowState { get; set; }`, `void Show()`, `void Hide()`, `void CaptureUiVariables()`, and the four read-only capture properties `SynchronizationContext UiSyncContext`, `System.Drawing.SizeF FormAutoScaleFactor`, `Dispatcher UiDispatcher`, and `int UiThreadId`. `SyncContextForm` already declares all four capture properties and `CaptureUiVariables()` (`UtilitiesCS/Threading/SyncContextForm.cs:24-40`) and inherits the other four members from `Form`, so it satisfies the interface without gaining a member. Research recommended a separate adapter type; implementing the interface directly on `SyncContextForm` is preferred here under the simplicity-first design principle, because it adds no second production type for a single test need. + +`UiThread.SyncContextFormFactory` is `internal static Func { get; set; }` defaulting to `() => new SyncContextForm()`. `Initialize()` calls it instead of `new SyncContextForm()` at `:51`, and `_syncContextForm` becomes `IUiCaptureSource?`. This shape is directly precedented in the repository by `QuickFiler/Helper Classes/ItemViewerQueue.cs:11-27` (`internal static Func ProductionViewerFactory`, reset at `:83-91`) and by the four `internal static` delegates on `UtilitiesCS/OutlookObjects/Folder/FolderPredictor.cs:161-184`. + +`UiThread.ResetForTesting()` is `internal static void`. It clears `_initialized`, `_uiSyncContext`, `_autoScaleFactor`, `_dispatcher`, and `_syncContextForm`, sets `_uiThreadId` back to `-1`, and restores the default factory. The `internal ... ForTesting()` idiom is repo-precedented at `QuickFiler/Helper Classes/ItemViewerQueue.cs:69-91`. + +#### Required configuration keys and defaults: + +None. No configuration key, environment variable, or settings entry is added. + +#### Backward-compatibility expectations: + +- The public surface of `UiThread` is unchanged. `Init`, `UiSyncContext`, `UiThreadId`, `Dispatcher`, `AutoScaleFactor`, `GetAwaiter`, and `SynchronizationContextAwaiter` keep their existing signatures and accessibility. The two new members are `internal`. +- `SyncContextForm` gains an interface on an `internal` type in its declaration; no member is added, removed, or changed. +- The only intentional behaviour change for existing callers is the three fixed defects. `Init()` from a non-STA thread changes from silent success to a thrown exception; this is the point of AC1. + +#### Performance constraints (latency/throughput/memory): + +- `Init()` gains one `GetApartmentState()` read and one uncontended `lock` acquisition on a path that runs once per process. Not measurable. +- `IsCompleted` gains, in the worst case, one type test and one `Dispatcher.FromThread` call. It is evaluated once per `await` on a `SynchronizationContext`. The reference fast path is unchanged for the common case. `Dispatcher.FromThread` is a lookup, not a construction. +- The intended net effect is a *reduction* in queued UI hops at the sites listed under Risks; no site gains a hop. + + +## Assumptions, Constraints, Dependencies + +- Assumptions (environment, data, access): + - MSTest's default apartment for this repository's runs is MTA, so a plain `[TestMethod]` supplies the AC1 rejection case and `[STATestMethod]` supplies the acceptance case. Research R4 established this from three independent in-tree sources: `UtilitiesCS.Test/test.runsettings:2-5` states that global STA is intentionally disabled; no `.runsettings` in the tree sets `ExecutionThreadApartmentState`; and the #782 records describe the ambient MSTest worker as MTA in two places. + - `Dispatcher.FromThread` returns the UI dispatcher on the owning UI thread after `Init()` has succeeded, because `Initialize()` captures `Dispatcher.CurrentDispatcher` from that same thread (`UtilitiesCS/Threading/SyncContextForm.cs:34-40`). This is a carried inference from the capture code, not a measured result. + - Whether `new SyncContextForm(); Show();` throws on an MTA thread is **UNKNOWN** and must be measured (decision D5). + - The member set behind the #782 figure of 49/25 is **UNKNOWN** and cannot be recovered from the published artifacts (decision D6). +- Constraints (budget, performance, compatibility): + - Target framework is .NET Framework 4.8. There is no `IsExternalInit` polyfill, so `init` accessors, `record`, and `record struct` fail to compile with CS0518. Any new value type must be a plain `readonly struct`; any new reference type must be an ordinary class or interface. + - `UiThread.cs` carries `#nullable enable` at line 1, so nullable-flow diagnostics on this file become build errors under the `/p:TreatWarningsAsErrors=true` gate. The nullable annotations on `IUiCaptureSource` and on `_syncContextForm` must be exact. + - `UtilitiesCS.csproj` and `UtilitiesCS.Test.csproj` are legacy non-SDK `packages.config` projects with explicit `` items. A new file that is not added to the project does not compile and its tests silently do not exist. + - MSTest, Moq, and FluentAssertions only. Test files live in the matching `*.Test` project mirroring the production layout; colocation in the production tree is prohibited. + - Creation or use of temporary files in tests is prohibited without exception. + - Every acceptance criterion must be verifiable without a live Outlook process. + - New analyzer diagnostics must not appear. The repository's analyzer severities are held at `suggestion` precisely because `TreatWarningsAsErrors` would otherwise promote them. +- External dependencies (services, libraries, releases): + - None. No package is added, removed, or upgraded. + +### Coverage-threshold divergence (recorded, not silently resolved) + +The repository states two different line-coverage floors: + +| Source | Line floor | Branch floor | New-code target | +|---|---|---|---| +| `CLAUDE.md` (General Unit Test Policy, UT2) | >= 80% | not stated | >= 90% for new modules, classes, methods | +| `.claude/rules/general-unit-test.md` and `.claude/rules/quality-tiers.md` | >= 85% | >= 75% | not stated | + +`.claude/skills/policy-compliance-order/SKILL.md` places `CLAUDE.md` first in the precedence order, ahead of `.claude/rules/general-unit-test.md`. **`CLAUDE.md` therefore governs: the applicable floors for this delivery are >= 80% line and >= 90% for members newly added.** The divergence is recorded here rather than resolved by preference; reconciling the two documents is a separate governance change and is not in this delivery's scope. + +Measured baseline for the file in scope, quoted by research R8 from the #782 evidence: **76.83% line, 65.00% branch, with 19 uncovered lines** — `28,29,30,32,33,34,67,68,69,70,71,72,73,74,75,76,118,119,120`. #782 waived raising it and recorded that doing so needs the seam extraction "already carved out to issues #787 and #788". This delivery is that carve-out. All 19 lines are reachable through the design above: the factory seam covers `67-76` (the `_monitorUiThread` branch), the AC1 and AC2 tests cover `28-34` (the two null-guard branches of `Init()`), and the lazy-path test covers `118-120`. Neither `UiThread` nor any nested type carries `[ExcludeFromCodeCoverage]`, and `coverage.config` excludes only third-party module paths, so no exclusion applies to this file. + + +## Data / API / Config Impact +- User-facing or API changes: none. No public signature changes; no UI, CLI, ribbon, or settings surface is touched. The two new members are `internal`. +- Data or migration considerations: none. No persisted data, schema, or settings file is read or written. +- Logging/telemetry updates (if any): none. See "Error handling and logging updates" for why no logger is introduced. +- Compatibility notes (CLI flags, config schemas, versioning): none. No `.runsettings`, `coverage.config`, `packages.config`, `.editorconfig`, or `BannedSymbols.txt` change. Two `.csproj` files change only by gaining `` items for new source files. + + +## Test Strategy + +All tests run without a live Outlook process. All new tests live in `UtilitiesCS.Test`, which holds the existing `InternalsVisibleTo` grant (decision D3); the one `QuickFiler.Test` edit uses only public and already-reflected surface. + +### Seams + +1. **`UiThread.SyncContextFormFactory`** — an injectable `Func` that lets a test drive a *failing* `Initialize()` deterministically, with no form, no STA host, and no timing dependency. It also makes the `_monitorUiThread` block at `UiThread.cs:66-76` reachable, which is what closes 10 of the 19 uncovered lines. +2. **`UiThread.ResetForTesting()`** — restores the process-global statics. This is the principal AC4 obstacle today: research R3 found that **no test anywhere resets `_loaded`, `_autoScaleFactor`, `_uiThreadId`, or `_syncContextForm`**, so a test that drives `Init()` through a failure would consume the process-global latch for every later test in the assembly. Wrap it in a new `UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs` snapshot/restore `IDisposable`, modelled on the existing `UtilitiesCS.Test/TestHelpers/UiThreadDispatcherScope.cs`. + +### Apartment-state mechanism + +Use the two mechanisms MSTest already gives this repository (research R4): + +- **STA test body**: `[STATestClass]` or `[STATestMethod]` from `MSTest.TestFramework` 4.4.0, already used at `UtilitiesCS.Test/Threading/ProgressViewer_Tests.cs:30`, `UtilitiesCS.Test/EmailIntelligence/FolderRemapViewer_Tests.cs:26`, and roughly a dozen other sites. +- **MTA test body**: a plain `[TestMethod]`. No attribute is needed; the default already is MTA. +- **STA delegate from an MTA test**: a dedicated `Thread` with `SetApartmentState(ApartmentState.STA)` before `Start()`, the shape of `StaDispatcherHost` at `UtilitiesCS.Test/Threading/UiThread_Tests.cs:186-213`, which runs `Dispatcher.Run()` and shuts down deterministically with `BeginInvokeShutdown` plus `Join`. + +Do **not** change `ExecutionThreadApartmentState` in any `.runsettings`. + +### `[DoNotParallelize]` requirements + +Every test class that mutates any `UiThread` static must carry `[DoNotParallelize]`, because the statics are process-global for the whole test assembly and the reset scopes are explicitly documented as not thread-safe (`UtilitiesCS.Test/TestHelpers/UiThreadDispatcherScope.cs:19-25`). Specifically: + +- `UiThread_Dispatcher_Tests` already carries it (`UtilitiesCS.Test/Threading/UiThread_Tests.cs:129`) — unchanged. +- `SynchronizationContextAwaiter_Tests` (`UtilitiesCS.Test/Threading/UiThread_Tests.cs:9-104`) does **not** carry it today and does not currently mutate `UiThread` statics. The new AC3 cases install `_uiThreadId`, `_uiSyncContext`, and `_dispatcher`, so the attribute **must be added** as part of this delivery. +- The new `UiThreadInitContract_Tests` classes carry it from the start. + +### Tests per defect + +**#787 / AC1 — apartment-state rejection.** +1. Plain `[TestMethod]` (MTA): `Init()` throws `InvalidOperationException` whose message starts with `NonStaInitMessagePrefix` and names the observed apartment state. +2. Plain `[TestMethod]` (MTA): after the throw, initialization has not been recorded and the four monitoring-configuration fields are unchanged, read back through the reset scope. This is the direct test of "before any global is captured". +3. `[STATestMethod]`: `Init()` from an STA thread does not throw and populates all four capture fields. +4. `[STATestMethod]`: a caller whose apartment is `Unknown` is not directly constructible in MSTest; instead assert the predicate shape by driving an MTA thread and an STA thread and confirming the boundary is `== STA` rather than `!= MTA`. Where the `Unknown` case cannot be produced on this host, record it as untested rather than asserting it. + +**#788 / AC2 — retry after a failed `Initialize()`.** All in `UtilitiesCS.Test`, `[STATestClass]` where `Initialize()` must succeed, `[DoNotParallelize]` throughout, every Act wrapped in `UiThreadStateScope`. +1. Factory throws, so `Init()` propagates; a second `Init()` with a working factory succeeds and populates all four capture fields. This is the AC2 core case. +2. Factory throws; the four capture fields are all still unset afterwards. +3. Factory throws; a subsequent read of `AutoScaleFactor` from an **MTA** thread throws the AC1 `InvalidOperationException` rather than re-entering the factory. **Assert this as a factory invocation count that did not increase, not as a wall-clock duration.** This is the direct anti-regression test for the #782 retry storm, and expressing it as an invocation count makes it deterministic and immune to host speed; a duration-based assertion would be a timing hack and is prohibited. +4. Two concurrent STA `Init()` calls invoke the factory exactly once, covering the serializing lock and the pre-existing C04 race. + +**#784 / AC3 — the awaiter predicate.** Extending `SynchronizationContextAwaiter_Tests`. +1. Reference match with a non-null ambient context returns `true`, preserving the existing behaviour asserted at `UiThread_Tests.cs:37`. +2. Ambient context is `null` and `_context` is non-null returns `false`, protecting the two `TaskScheduler.FromCurrentSynchronizationContext()` sites. +3. `_uiThreadId == -1` returns `false`. +4. On an STA host thread: install `_uiThreadId` and `_dispatcher` for that thread, capture a context inside `dispatcher.Invoke(...)`, restore the WinForms ambient, then assert `IsCompleted == true`. **This is the AC3 defect test** — it is the only case that fails against the current code. +5. Same arrangement but the captured dispatcher context belongs to a different thread's dispatcher: `false`. +6. A foreign `WindowsFormsSynchronizationContext` while `_uiThreadId` equals the current thread id: `false`. **This is the regression guard for the `WinFormsPumpHostTests` failure mode**; it pins the reason the bare-id predicate was rejected. +7. `default(SynchronizationContextAwaiter).IsCompleted` on a context-free thread returns `true`, matching today's behaviour. + +**Decision D4 (settled) — reconciling the MTA test caller.** Remove the `UiThread.Init(false)` call at `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs:329` and install a pumping dispatcher for the test's duration through the existing `QuickFiler.Test` machinery: `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs` (`Exchange(...)` / `BeginTransactionAsync()` plus `UiThreadDispatcherTransaction.Install(...)`, `:55-63`, `:122-126`, `:242-254`), supplied with a dispatcher from a host that actually pumps — `QuickFiler.Test/TestSupport/WinFormsPumpHost.cs` (whose WPF dispatcher route is already asserted to execute on the pump thread by `BothMarshalRoutes_WpfDispatcherAndSyncContext_ExecuteOnThePumpThread`), or a `Dispatcher.Run()` host of the `StaDispatcherHost` shape. + +This is research option B. **Option A (`[STATestMethod]` on the existing method) is rejected** for two recorded reasons: it preserves the order-dependency rather than removing it, and can convert it into a test hang — if `_loaded` was already consumed on another thread, `Init()` becomes a no-op, `UiThread.Dispatcher` returns a foreign thread's dispatcher, and `Invoke` posts to a dispatcher with no running frame and blocks until timeout; and it leaves a never-shut-down `Dispatcher` on a pooled MSTest STA worker, which is precisely the hazard #782 finding C10 removed from `UiThread_Tests.cs`. Option C (the fixture's `EnsureDispatcher()`) is rejected because its parked dispatcher never runs a frame and `Invoke` would block forever. Option D is out of scope, above. + +Only two assertions in that test depend on `UiThread` — `:356` and `:357`, both requiring that the lambda passed to `Dispatcher.Invoke` executed before `Invoke` returned. Option B preserves both, because `Invoke` from the MSTest thread marshals to the pumping host thread, runs the lambda, and returns synchronously. + +### Existing tests that must keep passing + +Research R7 enumerated these. Each must be re-run and reported: + +- `UtilitiesCS.Test/Threading/UiThread_Tests.cs` — all five `SynchronizationContextAwaiter_Tests` methods and both `UiThread_Dispatcher_Tests` methods. `IsCompleted_WhenContextIsNotCurrent_ReturnsFalse` (`:23`) stays false through the `ambient is null` early return; `IsCompleted_WhenContextMatchesCurrent_ReturnsTrue` (`:37`) stays true through the reference fast path. +- `UtilitiesCS.Test/OutlookObjects/Folder/WpfDispatcherYieldTests.cs` — the `DispatcherNotInitializedMessage` assertion at `:136` and the `UiThread.Init()` substring assertion at `:196`. +- `UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs` — the `ForceDispatcherNull` region (`:137-171`, `:225-245`). +- `UtilitiesCS.Test/OutlookObjects/Folder/FolderPredictorTests.cs:462` — reflects on `_uiSyncContext`; breaks only if that field is renamed, which this design does not do. +- `UtilitiesCS.Test/EmailIntelligence/FolderRemapViewer_Tests.cs:118` and `UtilitiesCS.Test/EmailIntelligence/FilterOlFoldersViewer_Tests.cs:39` — the two in-repo tests that actually drive `Init()` to success. Both must remain `[STATestClass]`. +- `QuickFiler.Test/TestSupport/WinFormsPumpHostTests.cs:183` and `:218` — the two tests a bare-id predicate would break. +- `QuickFiler.Test/Controllers/EfcFormControllerTests.cs:392` — injects a bare `new SynchronizationContext()`; the ambient on the MSTest thread is null, so it still posts. +- `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` and the five `QfcItemController` fixture consumers — all `UiThread._dispatcher` reflection consumers. + +### Coverage impact and targets + +Target `UtilitiesCS/Threading/UiThread.cs` at >= 80% line coverage per `CLAUDE.md`, up from the 76.83% line / 65.00% branch baseline, and each newly added member at >= 90%. Produce the coverage report with the repository's `vstest.console.exe ... /EnableCodeCoverage` command and store it under the feature folder's `evidence/qa-gates/` directory. `evidence/qa-gates/` is the canonical location for QA gate output under `.claude/skills/evidence-and-timestamp-conventions/SKILL.md`; `evidence/coverage/` is not one of the canonical evidence sub-paths and must not be used. + +### Validation + +- Unit coverage areas: `UiThread.Init`, `UiThread.Initialize`, `SyncContextForm.CaptureUiVariables`, `UiThread.SynchronizationContextAwaiter`. +- Integration scenario to retest: full nine-assembly `/InIsolation` run, with `TryAddValuesAsync_UpdatesExistingValue` recorded explicitly across at least three repetitions per decision D5. +- Manual verification on a live host (not an acceptance criterion; reported separately): QuickFiler launch, item load, and breadcrumb open. Confirm no change in observable UI behaviour and no new keyboard-focus regressions after #677 and #796. +- Toolchain commands, run in order and restarted from step 1 on any failure or auto-fix: `dotnet tool run csharpier format .` (verify with `dotnet tool run csharpier check .`); `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true`; `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true`; `vstest.console.exe /EnableCodeCoverage`. + + +## Acceptance Criteria + +- [x] AC1: `Init()` throws a named `InvalidOperationException` when called from a non-STA thread, before any global is captured; the MTA test caller at `QfcHomeControllerRunAsyncTests.cs:329` is corrected or given an STA host. +- [x] AC2: A failed `Initialize()` does not consume the latch; a subsequent `Init()` retries and succeeds. The #782 regression scenario is reproduced as a test and passes with the chosen design. +- [x] AC3: `SynchronizationContextAwaiter.IsCompleted` returns true on the owning UI thread regardless of ambient context instance, and false elsewhere; ordering-sensitive callers in `ItemViewer` and `EfcFormController` still pass their existing tests. +- [x] AC4: Unit tests cover STA/MTA rejection, latch re-arm after throw, and awaiter inline-vs-post decisions with a fake dispatcher seam; no real Outlook host. +- [ ] AC5: An evidence artifact under this feature folder's `evidence/other/` directory records a measurement of whether `new SyncContextForm(); Show();` throws when executed on an MTA thread on the execution host, and the AC2 regression test is justified against that measured result rather than against the #782 narrative. The full-suite run records `UtilitiesCS.Test.Extensions.DictionaryExtensions_Tests.TryAddValuesAsync_UpdatesExistingValue` across at least three repetitions, and no single failure of that test is attributed to this delivery. +- [x] AC6: A coverage report produced by `vstest.console.exe ... /EnableCodeCoverage` and stored under this feature folder's `evidence/qa-gates/` directory shows `UtilitiesCS/Threading/UiThread.cs` at >= 80% line coverage (the `CLAUDE.md` floor, which takes precedence per `.claude/skills/policy-compliance-order/SKILL.md`), above the 76.83% baseline, with each member newly added by this delivery at >= 90% and no changed line losing coverage. + +AC1 through AC4 are reproduced verbatim from the `## Acceptance Criteria` section of `issue.md`. AC5 and AC6 are added by this specification for outcomes the research established that AC1 through AC4 do not cover: the required measurement behind AC2's #782 clause, and the coverage uplift that #782 waived and explicitly carved out to this issue. + + +## Risks & Mitigations + +- Technical or operational risks: + - **Process-global static state shared across an entire test assembly.** `UiThread`'s captured fields, the initialization flag, and the factory are process-wide for the whole `UtilitiesCS.Test` run. A test that mutates any of them changes the premise of every later test in that process, and research R3 found that no existing test resets four of those fields at all. Concretely, `UtilitiesCS.Test/Threading/UiThread_Tests.cs:151-156` already documents an observed cross-class effect from `QfcHomeControllerRunAsyncTests` populating the same static from a different assembly's run. + - **Order-dependency in the current tests.** `QfcHomeControllerRunAsyncTests.cs:326` passes today only because `Init()` captured `Dispatcher.CurrentDispatcher` on the same MSTest worker that later calls `Invoke`, so `CheckAccess()` is true and the delegate runs inline. There is no message pump anywhere in that test. If an earlier test had consumed the latch or installed a foreign dispatcher, the `Invoke` would use a stale dispatcher or block. + - **AC3 changes execution ordering at eleven production await sites.** Research R6 enumerated them and found **no existing test asserts ordering at any of them**, so the suite cannot detect an ordering regression. The highest-consequence sites are `QuickFiler/Controllers/EfcFormController.cs:877` (`Close()` then `Cleanup()` would run before already-queued UI work rather than after it), `QuickFiler/Controllers/QfcCollectionController.cs:782` (a `TlpLayout` toggle and a row removal would run before queued layout work), and the two `TaskScheduler.FromCurrentSynchronizationContext()` sites where the resulting scheduler would target the persistent WinForms context instead of the dispatcher context. + - **The AC2 regression test may be vacuous.** If `Initialize()` cannot be made to throw on this host by any in-tree path, a test that claims to reproduce the #782 scenario would assert nothing about the real failure mode. + - **Three production sites gain a new throw**, all currently unreachable in tests and unreachable in production after `ThisAddIn.cs:35` runs, but reachable in any future headless or worker-thread caller. + - **Legacy csproj items are easy to omit.** A new test file that is not added to `UtilitiesCS.Test.csproj` compiles into nothing and its tests silently do not exist, which would make AC4 appear satisfied when it is not. + +- Mitigations and rollbacks: + - Introduce `ResetForTesting()` plus a `UiThreadStateScope` snapshot/restore `IDisposable`, and require `[DoNotParallelize]` on every class that mutates a `UiThread` static. This is the direct mitigation for the shared-state risk and is a precondition of AC4, not an optional extra. + - Apply decision D4: remove the `Init()` dependency from `QfcHomeControllerRunAsyncTests` entirely and install a pumping dispatcher scoped to the test. This makes the test order-independent rather than merely making it pass. + - For the ordering risk, add the AC3 regression guard that pins a foreign `WindowsFormsSynchronizationContext` to `false`, and confirm on a live host that QuickFiler launch, item load, and breadcrumb open are unchanged. This is a residual risk that the automated suite cannot fully close; state it as residual in the review rather than as covered. + - For the vacuity risk, apply decision D5: measure first, then restate the AC2 test in terms of the measured result — a forced-throw scenario driven through the factory seam if the #782 narrative is refuted. + - Verify the two `.csproj` edits by confirming the new tests appear in the vstest discovery output, not by inspecting the project files alone. + - Rollback is a revert of the Write Set. No data migration, no feature flag, and no configuration change has to be undone. + + +## Rollout & Follow-up +- Release/rollout steps: ships in the ordinary add-in build. No staged rollout, no flag, no configuration change. The change is inert until `ThisAddIn_Startup` calls `Init()`, which it already does on the Outlook STA. +- Post-fix monitoring or clean-up tasks: + - Close #784, #787, and #788 with a pointer to #809. + - Correct the GitHub issue body for #809, which still carries the original incorrect bare-owning-thread-identity sentence. The copy in `issue.md` has already been corrected. + - If the decision-D5 measurement refutes the #782 narrative, record that outcome against the #782 feature folder so a future reader does not re-derive the withdrawn constraint from the original artifacts. + - Follow-up candidates, not in this delivery: routing `QfcHomeController` through `IUiDispatcher`; reconciling the 80% versus 85% coverage-floor divergence between `CLAUDE.md` and `.claude/rules/general-unit-test.md`; and the ordering-assertion gap at the eleven awaiter call sites, none of which has an ordering test today. +- Links: + - Issue: https://github.com/drmoisan/TaskMaster/issues/809 + - Superseded: #784, #787, #788. Related: #781 (breadcrumb UI-boundary guard), #782 (PR 778 post-merge residuals), #780 (`TryAddValuesAsync` flake). + - Research of record: `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/research/research.2026-09-07T20-20.md` + - Requirements of record: `docs/features/active/2026-09-07-uithread-init-contract-residuals-784-787-788-809/issue.md`