From f3fb721d5914b14fe6848b165194e1994124a8ce Mon Sep 17 00:00:00 2001 From: S D Date: Mon, 26 Oct 2026 19:37:18 +0000 Subject: [PATCH 1/2] Keep clipboard inspection off the UI thread and audit resource lifetimes --- docs/long-running-hang-audit.md | 146 ++++++++++++++++++ scripts/inspect-hang.ps1 | 96 ++++++++++++ .../Clipboard/ClipboardInspectionReader.cs | 37 +++++ .../Clipboard/ClipboardMonitor.cs | 20 +-- .../ExplorerContextMenuRegistration.cs | 58 ++++--- .../Tray/TrayIconController.cs | 7 +- .../Views/AboutWindow.xaml.cs | 2 +- .../ShellExtensionTests.cpp | 130 +++++++++++++++- .../ClipboardInspectionReaderTests.cs | 143 +++++++++++++++++ .../ExplorerRegistrationValueTests.cs | 57 +++++++ .../ExternalDiffLauncherTests.cs | 100 ++++++++++++ .../TrayIconControllerTests.cs | 39 +++++ 12 files changed, 798 insertions(+), 37 deletions(-) create mode 100644 docs/long-running-hang-audit.md create mode 100644 scripts/inspect-hang.ps1 create mode 100644 src/ClipDiff.Windows/Clipboard/ClipboardInspectionReader.cs create mode 100644 tests/ClipDiff.Windows.Tests/ClipboardInspectionReaderTests.cs create mode 100644 tests/ClipDiff.Windows.Tests/ExplorerRegistrationValueTests.cs create mode 100644 tests/ClipDiff.Windows.Tests/ExternalDiffLauncherTests.cs create mode 100644 tests/ClipDiff.Windows.Tests/TrayIconControllerTests.cs diff --git a/docs/long-running-hang-audit.md b/docs/long-running-hang-audit.md new file mode 100644 index 0000000..667986d --- /dev/null +++ b/docs/long-running-hang-audit.md @@ -0,0 +1,146 @@ +# Long-running tray hang: ownership and blocking-call audit + +Audited baseline: `1f47957a9e63a98bd369d8348e23eabca72a548d`, matching +[Actions run 39242061366](https://github.com/stuartd/ClipboardDiff/actions/runs/39242061366). +The reported symptom was an unresponsive notification-area icon/menu after several +days on a work laptop. The process had not been restarted. No stack or resource +sample from that process was available during this review, so the cause of that +particular incident remains unconfirmed. + +## Findings and changes + +| Finding | Impact and correction | +| --- | --- | +| Clipboard inspection called `GetClipboardData` on the UI thread. | This API can ask another application to render delayed clipboard data and wait for it. The async capture method did not prevent this: its semaphore await often completed synchronously, and inspection happened before its next await. Inspection now runs on a worker. Opening, inspecting, and closing the clipboard stay on that same worker, and the monitor holds its existing gate until the native call returns. Cancellation rejects the result without abandoning an open clipboard or starting overlapping native reads. Privacy inspection still precedes retrieving text or paths. | +| Explorer registration reported a change on every refresh. | `UpdatePresentation` runs after clipboard observations and when opening the tray menu. The previous code rewrote identical association values and reported successful pair registration as a change, causing repeated `SHCNE_ASSOCCHANGED` calls from the UI thread. Registration now compares both the value and registry type, writes only changes, and distinguishes pair readiness from an actual change. Deleted/changed values still get repaired, and cross-session reconciliation remains active. This is avoidable shell work, rather than evidence of a C++ memory leak. | +| Rebuilding the viewer submenu cleared items without disposing them. | `ToolStripItemCollection.Clear` removes items but does not dispose them. Selecting/changing a viewer repeatedly could leave removed items awaiting GC and unmanaged resource cleanup. Each old item is now disposed before its replacement is created. This is an operation-specific disposal defect; merely leaving the app idle does not exercise this path. | +| About-window links discarded `Process.Start`'s returned `Process`. | The wrapper is now disposed. This closes owned process resources without terminating the browser. This only occurs when clicking About links and is unlikely to explain an idle hang. | + +Microsoft documents the rendering callback in +[Clipboard Operations](https://learn.microsoft.com/windows/win32/dataxchg/clipboard-operations#delayed-rendering). +Moving the read protects the UI even when a producer blocks. It cannot cancel +an in-progress Win32 call or prove that delayed rendering caused this incident. + +## Native C++ ownership + +The DLL is loaded into **Explorer**, not the ClipDiff process. I found no unmatched +allocation, COM reference, event handle, or menu ownership in its production paths. +That is a source-review result, not a Windows soak-test result. + +| Native path | Ownership and release | +| --- | --- | +| `IsClipDiffReady` | Each successful `OpenEventW` is followed by a zero-time wait and `CloseHandle`. No persistent per-query handle. | +| `IsFilePair` | Shell item arrays and individual items are held in `ComPtr`; early failures unwind both. The loop is bounded to two items. The method requests attributes, not display names or file contents. | +| `ContextMenu::Initialize` | Releases the previous selection before accepting another. Retains at most one data object per handler. | +| `ContextMenu::InvokeCommand` | A valid invocation swaps selection into a local `ComPtr`. Paused/unready/invalid selection, activation failure, drag-enter rejection, and drop completion all release it. An unknown verb deliberately leaves the still-usable menu selection intact. | +| Handler destruction | Resets selection and decrements the module reference. All successful `QueryInterface` calls take a matching reference; final `Release` deletes the handler. | +| Class factory | Creation and failed-interface paths release temporary objects. Factory lifetime and balanced `LockServer` calls are reflected in `DllCanUnloadNow`. | +| Menu items | `InsertMenuItemW` inserts a text item into the caller's menu. The Shell owns that `HMENU`; the extension must not destroy it. No extension-owned menu bitmap, brush, window, timer, or worker thread exists. | + +Added Windows native regression checks for 10,000 factory/handler lifetimes, +module unload readiness, process-handle counts, and 1,000 selection replacements. +A reference-counted wrapper around the real Shell data object checks release on +reinitialization, handler destruction, failed invocation, and successful invocation. + +The native calls that can still wait are Shell metadata queries, COM activation, +and the calls to the out-of-process drop target. A blocked ClipDiff UI can therefore +also block an Explorer invocation. The ready event indicates that the server was +registered; it is not a UI-health heartbeat. Retaining a handler while its client +still owns it, or Explorer keeping a DLL loaded, is not by itself a leak. + +## Managed ownership ledger + +| Component | Result | +| --- | --- | +| Application instance mutex | Held for process lifetime; released/disposed on exit. A second normal launch exits without repairing or foregrounding an existing hung instance. | +| Hidden message window | Removes its hook and disposes `HwndSource`. Monitor and hotkey callbacks unsubscribe before window disposal. | +| Clipboard listener | Unregisters on disposal. Retry delays are finite and only run after updates; there is no periodic multi-day timer. | +| Clipboard reads | `CloseClipboard` and `GlobalUnlock` are in `finally` paths. Clipboard-owned global memory is correctly borrowed, not freed. The UI blocking defect is fixed as described above. | +| Protected clipboard writes | Failed `GlobalAlloc`/`GlobalLock`/`SetClipboardData` paths free untransferred memory. Successful `SetClipboardData` transfers ownership to Windows; freeing it there would be wrong. Clipboard closure is unconditional. | +| Read cancellation/semaphore | Superseded cancellation sources are cancelled/disposed. At most one native inspection owns the monitor's gate. Shutdown may finish before an already-running native call returns; its worker still closes the clipboard. | +| Copied files | Streams are `await using`, including empty, oversized, decoding failure, and cancellation exits. | +| Shortcut COM objects | A locally created ShellLink wrapper is final-released in `finally`. No `Resolve`, launch, or mutation is used. | +| Explorer COM server | Revokes class registration, releases owned IUnknown pointers, and balances successful COM initialization. Created targets release temporary IUnknown pointers. | +| Incoming COM drop data | Every successfully obtained `STGMEDIUM` reaches `ReleaseStgMedium`, including invalid counts/types and path failures. Incoming RCWs are managed by the interop runtime; force-releasing a potentially shared incoming wrapper would be unsafe. | +| Explorer pipe server/client | Streams, message deadlines, listener shutdown source, and slot semaphore have cleanup paths. Pending receipts are bounded to four; old completed tasks are removed. Handler completion is not awaited while accepting the next message. | +| Shared registration | Registry keys use `using`; owner/gate leases are released, with deletion on close. Lock acquisition has a 500 ms deadline, not an indefinite wait. Stale-owner reconciliation is tested. | +| External viewer processes | Exit events are detached; process wrappers and workspaces are disposed by an interlocked, one-shot cleanup. Failed starts and disposal have cleanup paths. Delayed cleanup retains a completed comparison for three seconds intentionally. | +| External temporary files | Exclusive owner leases protect other sessions. Files are made writable before best-effort deletion; cleanup releases the lease. Windows may prevent deleting a file still held by a viewer. | +| Tray and drawing | Owned icon, notification icon, menu, and per-paint pens are disposed. The shared system-icon fallback is correctly not disposed. Replaced menu-item disposal is fixed. | +| WPF windows and documents | One diff window and one About window are reused intentionally; final shutdown closes them. Undo is disabled in diff text boxes. The active document is replaced/cleared; there is no application collection of past documents. | +| History/comparison tasks | History retains two entries. The last displayed diff and its comparison input can intentionally outlive those history entries. Generation checks reject superseded results; cancellation sources are scoped to each comparison. | +| Settings/startup/tool discovery | Registry and file APIs close their resources. No periodic registry scan or growing cache of viewer discoveries is present. | + +## Remaining risks to distinguish with the hung process + +- File probes, file opens, and ShellLink loading run off the UI thread, but a + synchronous operation that ignores cancellation can outlive its request. + Repeated inaccessible network paths can accumulate blocked workers until the + operating system returns. This is distinct from a missing stream disposal; + many thread-pool stacks in those calls would support this explanation. +- The UI still performs local registration/settings work, external-viewer launch + and temporary-file creation, and explicit clipboard writes. Storage, antivirus, + Shell callbacks, or unavailable installation paths can delay those operations. +- Very large single-line comparisons can spend substantial time creating and + measuring WPF rich-text documents. Core search budgets do not bound WPF layout + cost. This needs a Windows UI workload or stack to diagnose. +- A hidden modal dialog, a stale icon after process exit, and an Explorer hang + are different cases. Process presence, wait chains, and stacks help separate them. + +These are remaining code paths, not additional confirmed causes. There is no +uptime or weekend-specific trigger in the resource ownership reviewed here. + +## Validation + +- 96 core tests and 13 stress-workload tests passed, with no skips or failures. +- 215 portable application tests passed in a local linked-source harness at + `artifacts/audit/PortableAudit.Tests.csproj`. This runs the actual portable + production classes and existing/new tests under `net10.0`, without loading WPF + or substituting native API stubs. The blocked-renderer test uses a controlled + blocking delegate; it is not a real Windows clipboard integration test. +- The Release managed solution, including the Windows test assembly, compiled + with zero warnings/errors. +- The native DLL and native test executable were cross-compiled with MinGW as + an additional C++ check. This is not the supported MSVC release build, and + neither native binary was executed on Linux. +- The hang-inspection PowerShell script parses successfully. Its Windows + process-inspection behavior has not been executed on this machine. + +The new Windows registry, tray-menu, and native lifetime tests still need to run +on Windows. Use the existing workflow or, from a normal Windows PowerShell with +ClipDiff closed: + +```powershell +./scripts/build-shell-extension.ps1 -Test +dotnet test ClipDiff.Windows.sln --configuration Release +dotnet test tests/ClipDiff.Stress.Tests --configuration Release +``` + +Then test the tray while a synthetic producer delays rendering, check ordinary +clipboard capture/privacy behavior, repeat viewer selection, and exercise the +actual Explorer menu. No Windows functional-readiness claim is made here. + +## Inspect the existing hang before restarting + +Run on the affected laptop: + +```powershell +./scripts/inspect-hang.ps1 +``` + +This only reports process/thread identifiers, CPU/memory/handle/GUI counts and +wait states. It does not read the clipboard, fetch text or file paths, collect +window titles or command lines, write a dump, or restart anything. Supply +`-ClipDiffProcessId ` for a dotnet-hosted development launch. `Responding` +alone is not conclusive for a tray app. + +Use Task Manager's Details tab to **Analyze wait chain** for that PID. If the +Microsoft `dotnet-stack` tool is already available, the script prints its +`report --process-id` command; the report contains managed method stacks without +locals or clipboard values. If collection stalls, cancel it. Native symbols may +be needed to identify a specific underlying Win32 wait. + +Do not collect a process/heap dump for this review: it could persist captured +clipboard values. Start with the read-only inspection; the existing stress tool +overwrites the clipboard with synthetic data and is for subsequent patched-build +testing, after the original hang evidence has been captured. diff --git a/scripts/inspect-hang.ps1 b/scripts/inspect-hang.ps1 new file mode 100644 index 0000000..b3d22dc --- /dev/null +++ b/scripts/inspect-hang.ps1 @@ -0,0 +1,96 @@ +[CmdletBinding()] +param( + [int]$ClipDiffProcessId = 0 +) + +$ErrorActionPreference = 'Stop' + +if ($env:OS -ne 'Windows_NT') +{ + throw 'Run this read-only inspection on the Windows desktop where ClipDiff is unresponsive.' +} + +# Do not request clipboard data, window titles, command lines, selected paths, +# settings contents, or memory dumps. This script does not restart any process. +if (-not ('ClipDiffHangInspection' -as [type])) +{ + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +public static class ClipDiffHangInspection +{ + [DllImport("user32.dll", SetLastError = true)] + public static extern uint GetGuiResources(IntPtr process, uint flags); +} +'@ +} + +if ($ClipDiffProcessId -gt 0) +{ + $targets = @(Get-Process -Id $ClipDiffProcessId) +} +else +{ + $targets = @(Get-Process -Name ClipDiff -ErrorAction SilentlyContinue) +} + +if ($targets.Count -eq 0) +{ + Write-Host 'No ClipDiff process was found. For a dotnet-hosted launch, supply -ClipDiffProcessId.' + return +} + +Write-Host "Inspection UTC: $([DateTime]::UtcNow.ToString('O'))" + +foreach ($target in $targets) +{ + try + { + $target.Refresh() + [pscustomobject]@{ + ProcessId = $target.Id + SessionId = $target.SessionId + StartedUtc = $target.StartTime.ToUniversalTime().ToString('O') + Responding = $target.Responding + CpuSeconds = $target.TotalProcessorTime.TotalSeconds + PrivateMiB = [Math]::Round($target.PrivateMemorySize64 / 1MB, 1) + WorkingSetMiB = [Math]::Round($target.WorkingSet64 / 1MB, 1) + Handles = $target.HandleCount + Threads = $target.Threads.Count + GdiObjects = [ClipDiffHangInspection]::GetGuiResources($target.Handle, 0) + UserObjects = [ClipDiffHangInspection]::GetGuiResources($target.Handle, 1) + } | Format-List + + $threadRows = foreach ($thread in $target.Threads) + { + try + { + [pscustomobject]@{ + ThreadId = $thread.Id + State = $thread.ThreadState + WaitReason = $(if ($thread.ThreadState -eq 'Wait') { $thread.WaitReason } else { $null }) + CpuSeconds = $thread.TotalProcessorTime.TotalSeconds + } + } + finally + { + $thread.Dispose() + } + } + + $threadRows | Format-Table -AutoSize + + Write-Host "For a wait chain: Task Manager > Details > process $($target.Id) > Analyze wait chain." + Write-Host "If dotnet-stack is installed: dotnet-stack report --process-id $($target.Id)" + Write-Host 'Managed stacks contain method names, not clipboard values. Cancel collection if it stalls.' + Write-Host 'Responding is only a hint for a tray application; a wait chain/stack is stronger evidence.' + } + catch + { + Write-Host "Could not inspect process $($target.Id): $($_.Exception.GetType().Name). It may have exited or denied access." + } + finally + { + $target.Dispose() + } +} diff --git a/src/ClipDiff.Windows/Clipboard/ClipboardInspectionReader.cs b/src/ClipDiff.Windows/Clipboard/ClipboardInspectionReader.cs new file mode 100644 index 0000000..ae5ba64 --- /dev/null +++ b/src/ClipDiff.Windows/Clipboard/ClipboardInspectionReader.cs @@ -0,0 +1,37 @@ +namespace ClipDiff.Windows.Clipboard; + +internal sealed class ClipboardInspectionReader( + Func tryOpen, + Action close, + Func inspect) +{ + public Task ReadAsync( + uint sequenceNumber, + DateTimeOffset observedAt, + CancellationToken cancellationToken) + { + // GetClipboardData can wait for another process to render a format. Keep + // Open/Inspect/Close on one worker thread, away from the UI message loop. + // The monitor retains its read gate until this completes: cancellation + // must not start another native read while this one owns the clipboard. + return Task.Run(() => + { + if (!tryOpen()) + { + return null; + } + + try + { + cancellationToken.ThrowIfCancellationRequested(); + var inspection = inspect(sequenceNumber, observedAt); + cancellationToken.ThrowIfCancellationRequested(); + return inspection; + } + finally + { + close(); + } + }, cancellationToken); + } +} diff --git a/src/ClipDiff.Windows/Clipboard/ClipboardMonitor.cs b/src/ClipDiff.Windows/Clipboard/ClipboardMonitor.cs index 45bbcee..bd30d45 100644 --- a/src/ClipDiff.Windows/Clipboard/ClipboardMonitor.cs +++ b/src/ClipDiff.Windows/Clipboard/ClipboardMonitor.cs @@ -16,6 +16,7 @@ internal sealed class ClipboardMonitor : IDisposable private readonly NativeMessageWindow nativeMessageWindow; private readonly NativeClipboard nativeClipboard; private readonly ClipboardPrivacyInspector clipboardPrivacyInspector; + private readonly ClipboardInspectionReader clipboardInspectionReader; private readonly ClipboardFileObservationReader clipboardFileObservationReader = new(); private readonly SemaphoreSlim readGate = new(1, 1); private CancellationTokenSource? pendingRead; @@ -34,6 +35,10 @@ public ClipboardMonitor(NativeMessageWindow messageWindow) RegisterFormat("CanIncludeInClipboardHistory"), RegisterFormat("CanUploadToCloudClipboard")); clipboardPrivacyInspector = new ClipboardPrivacyInspector(nativeClipboard, formats); + clipboardInspectionReader = new ClipboardInspectionReader( + () => nativeClipboard.TryOpen(nint.Zero), + nativeClipboard.Close, + clipboardPrivacyInspector.Inspect); baselineSequence = NativeMethods.GetClipboardSequenceNumber(); latestRequestedSequence = baselineSequence; @@ -155,19 +160,8 @@ private async Task ProcessSequenceAsync(uint expectedSequence, CancellationToken return; } - ClipboardInspection? inspection = null; - - if (nativeClipboard.TryOpen(nativeMessageWindow.Handle)) - { - try - { - inspection = clipboardPrivacyInspector.Inspect(expectedSequence, DateTimeOffset.Now); - } - finally - { - nativeClipboard.Close(); - } - } + var inspection = await clipboardInspectionReader.ReadAsync( + expectedSequence, DateTimeOffset.Now, cancellationToken); var observation = inspection switch { diff --git a/src/ClipDiff.Windows/Explorer/ExplorerContextMenuRegistration.cs b/src/ClipDiff.Windows/Explorer/ExplorerContextMenuRegistration.cs index 1e5f3ea..b5ecdc0 100644 --- a/src/ClipDiff.Windows/Explorer/ExplorerContextMenuRegistration.cs +++ b/src/ClipDiff.Windows/Explorer/ExplorerContextMenuRegistration.cs @@ -170,15 +170,16 @@ private bool ApplySelection(ExplorerRegistrationSelection selection) // The old static two-file verb has no live owner in this registration scheme. changed |= TryRemoveOwnedKey(PairVerbKeyPath); - pairRegistered = selection.Pair is not null && TryRegisterPair(selection.Pair); if (selection.Pair is null) { + pairRegistered = false; changed |= TryRemoveOwnedPairRegistration(); } else { - changed |= pairRegistered; + pairRegistered = TryRegisterPair(selection.Pair, out var pairChanged); + changed |= pairChanged; } return changed; @@ -201,22 +202,23 @@ private bool TryRegisterSingle(string displayName, ExplorerRegistrationClaim cla return false; } - commandKey.SetValue(null, claim.SingleCommandLine, RegistryValueKind.String); - verbKey.SetValue(null, displayName, RegistryValueKind.String); - verbKey.SetValue("Icon", $"{Quote(claim.IconPath)},0", RegistryValueKind.String); - verbKey.SetValue("MultiSelectModel", "Single", RegistryValueKind.String); - verbKey.SetValue(OwnerValueName, OwnerValue, RegistryValueKind.String); - return true; + var changed = SetStringValue(commandKey, null, claim.SingleCommandLine); + changed |= SetStringValue(verbKey, null, displayName); + changed |= SetStringValue(verbKey, "Icon", $"{Quote(claim.IconPath)},0"); + changed |= SetStringValue(verbKey, "MultiSelectModel", "Single"); + changed |= SetStringValue(verbKey, OwnerValueName, OwnerValue); + return changed; } catch (Exception exception) when (IsRegistryException(exception)) { - TryRemoveOwnedSingleRegistration(); - return false; + return TryRemoveOwnedSingleRegistration(); } } - private bool TryRegisterPair(ExplorerRegistrationClaim claim) + private bool TryRegisterPair(ExplorerRegistrationClaim claim, out bool changed) { + changed = false; + if (claim.ComServerCommandLine.Length == 0 || !File.Exists(claim.ExtensionPath) || !Environment.Is64BitProcess) { return false; @@ -225,26 +227,26 @@ private bool TryRegisterPair(ExplorerRegistrationClaim claim) try { using var classKey = Registry.CurrentUser.CreateSubKey(ClassKeyPath, writable: true); - classKey.SetValue(OwnerValueName, OwnerValue, RegistryValueKind.String); + changed |= SetStringValue(classKey, OwnerValueName, OwnerValue); using var localServerKey = Registry.CurrentUser.CreateSubKey(LocalServerKeyPath, writable: true); - classKey.SetValue(null, "ClipDiff Explorer comparison", RegistryValueKind.String); - localServerKey.SetValue(null, claim.ComServerCommandLine, RegistryValueKind.String); + changed |= SetStringValue(classKey, null, "ClipDiff Explorer comparison"); + changed |= SetStringValue(localServerKey, null, claim.ComServerCommandLine); using var extensionClassKey = Registry.CurrentUser.CreateSubKey(ExtensionClassKeyPath, writable: true); - extensionClassKey.SetValue(OwnerValueName, OwnerValue, RegistryValueKind.String); - extensionClassKey.SetValue(null, "ClipDiff selection menu", RegistryValueKind.String); + changed |= SetStringValue(extensionClassKey, OwnerValueName, OwnerValue); + changed |= SetStringValue(extensionClassKey, null, "ClipDiff selection menu"); using var extensionServerKey = Registry.CurrentUser.CreateSubKey(ExtensionServerKeyPath, writable: true); - extensionServerKey.SetValue(null, claim.ExtensionPath, RegistryValueKind.String); - extensionServerKey.SetValue("ThreadingModel", "Apartment", RegistryValueKind.String); + changed |= SetStringValue(extensionServerKey, null, claim.ExtensionPath); + changed |= SetStringValue(extensionServerKey, "ThreadingModel", "Apartment"); using var handlerKey = Registry.CurrentUser.CreateSubKey(PairHandlerKeyPath, writable: true); - handlerKey.SetValue(OwnerValueName, OwnerValue, RegistryValueKind.String); - handlerKey.SetValue(null, ExtensionClassId, RegistryValueKind.String); + changed |= SetStringValue(handlerKey, OwnerValueName, OwnerValue); + changed |= SetStringValue(handlerKey, null, ExtensionClassId); return true; } catch (Exception exception) when (IsRegistryException(exception)) { - TryRemoveOwnedPairRegistration(); + changed |= TryRemoveOwnedPairRegistration(); return false; } } @@ -325,6 +327,20 @@ private static bool TryRemoveOwnedKey(string keyPath, string? commandKeyPath = n } } + internal static bool SetStringValue(RegistryKey key, string? valueName, string value) + { + // Clipboard updates and opening the tray menu also reconcile registration. + // An unchanged registration must not flush Explorer's association caches. + if (key.GetValue(valueName, null, RegistryValueOptions.DoNotExpandEnvironmentNames) is string existing && + existing == value && key.GetValueKind(valueName) == RegistryValueKind.String) + { + return false; + } + + key.SetValue(valueName, value, RegistryValueKind.String); + return true; + } + private static bool IsRegistryException(Exception exception) => exception is IOException or UnauthorizedAccessException or SecurityException or PlatformNotSupportedException; diff --git a/src/ClipDiff.Windows/Tray/TrayIconController.cs b/src/ClipDiff.Windows/Tray/TrayIconController.cs index be4951c..f0c2d38 100644 --- a/src/ClipDiff.Windows/Tray/TrayIconController.cs +++ b/src/ClipDiff.Windows/Tray/TrayIconController.cs @@ -163,7 +163,12 @@ private static string EntryPreview(ClipboardEntry? entry, string? fileLabel) private void RebuildDiffViewerMenu() { - diffViewerItem.DropDownItems.Clear(); + // Removing an item does not dispose it. Release its native menu resources + // and event subscriptions before replacing the viewer choices. + while (diffViewerItem.DropDownItems.Count > 0) + { + diffViewerItem.DropDownItems[0].Dispose(); + } var builtIn = new Forms.ToolStripMenuItem("Built-in viewer") { diff --git a/src/ClipDiff.Windows/Views/AboutWindow.xaml.cs b/src/ClipDiff.Windows/Views/AboutWindow.xaml.cs index f186dfc..11a8bbd 100644 --- a/src/ClipDiff.Windows/Views/AboutWindow.xaml.cs +++ b/src/ClipDiff.Windows/Views/AboutWindow.xaml.cs @@ -56,7 +56,7 @@ private void OnLinkRequestNavigate(object sender, RequestNavigateEventArgs args) { try { - Process.Start(new ProcessStartInfo(args.Uri.AbsoluteUri) { UseShellExecute = true }); + using var process = Process.Start(new ProcessStartInfo(args.Uri.AbsoluteUri) { UseShellExecute = true }); } catch (Exception exception) when (exception is Win32Exception or InvalidOperationException or NotSupportedException) { diff --git a/tests/ClipDiff.ShellExtension.Tests/ShellExtensionTests.cpp b/tests/ClipDiff.ShellExtension.Tests/ShellExtensionTests.cpp index 1767b7b..451f029 100644 --- a/tests/ClipDiff.ShellExtension.Tests/ShellExtensionTests.cpp +++ b/tests/ClipDiff.ShellExtension.Tests/ShellExtensionTests.cpp @@ -465,6 +465,107 @@ namespace return handler; } + struct DataLifetime + { + std::atomic references{1}; + bool destroyed = false; + }; + + class TrackedData final : public IDataObject + { + ComPtr inner; + DataLifetime& lifetime; + + ~TrackedData() { lifetime.destroyed = true; } + + public: + TrackedData(IDataObject* data, DataLifetime& state) : inner(data), lifetime(state) {} + + IFACEMETHODIMP QueryInterface(REFIID iid, void** result) override + { + if (!result) + { + return E_POINTER; + } + + *result = nullptr; + + if (iid != IID_IUnknown && iid != IID_IDataObject) + { + return E_NOINTERFACE; + } + + *result = static_cast(this); + AddRef(); + return S_OK; + } + + IFACEMETHODIMP_(ULONG) AddRef() override { return ++lifetime.references; } + + IFACEMETHODIMP_(ULONG) Release() override + { + const auto remaining = --lifetime.references; + + if (!remaining) + { + delete this; + } + + return remaining; + } + + IFACEMETHODIMP GetData(FORMATETC* format, STGMEDIUM* medium) override { return inner->GetData(format, medium); } + IFACEMETHODIMP GetDataHere(FORMATETC* format, STGMEDIUM* medium) override { return inner->GetDataHere(format, medium); } + IFACEMETHODIMP QueryGetData(FORMATETC* format) override { return inner->QueryGetData(format); } + IFACEMETHODIMP GetCanonicalFormatEtc(FORMATETC* input, FORMATETC* output) override { return inner->GetCanonicalFormatEtc(input, output); } + IFACEMETHODIMP SetData(FORMATETC* format, STGMEDIUM* medium, BOOL release) override { return inner->SetData(format, medium, release); } + IFACEMETHODIMP EnumFormatEtc(DWORD direction, IEnumFORMATETC** formats) override { return inner->EnumFormatEtc(direction, formats); } + IFACEMETHODIMP DAdvise(FORMATETC* format, DWORD flags, IAdviseSink* sink, DWORD* connection) override { return inner->DAdvise(format, flags, sink, connection); } + IFACEMETHODIMP DUnadvise(DWORD connection) override { return inner->DUnadvise(connection); } + IFACEMETHODIMP EnumDAdvise(IEnumSTATDATA** connections) override { return inner->EnumDAdvise(connections); } + }; + + void VerifyModuleLifetime(HMODULE library) + { + BeginCase("native factory, handler and event-handle lifetime (10000 cycles)"); + const auto getFactory = reinterpret_cast( + GetProcAddress(library, "DllGetClassObject")); + const auto canUnload = reinterpret_cast(GetProcAddress(library, "DllCanUnloadNow")); + Check(getFactory && canUnload, "Missing COM lifetime exports."); + Check(canUnload() == S_OK, "Freshly loaded module retains a COM reference."); + DWORD handlesBefore = 0; + CheckWin32(GetProcessHandleCount(GetCurrentProcess(), &handlesBefore), "Cannot count native test handles."); + + for (int iteration = 0; iteration < 10000; ++iteration) + { + ComPtr factory; + CheckHr(getFactory(CLSID_ClipDiffShellExtension, IID_PPV_ARGS(&factory)), "Cannot create lifetime-test factory."); + Check(canUnload() == S_FALSE, "Live factory does not retain the module."); + + { + auto handler = CreateHandler(factory.Get(), nullptr); + ComPtr context; + CheckHr(handler.As(&context), "Lifetime-test handler lacks IContextMenu."); + CheckHr(handler->Initialize(nullptr, nullptr, nullptr), "Repeated empty initialization failed."); + } + + void* unsupported = nullptr; + Check(factory->CreateInstance(nullptr, IID_IStream, &unsupported) == E_NOINTERFACE && !unsupported, + "Unsupported-interface creation must release its temporary handler."); + Check(factory->CreateInstance(factory.Get(), IID_IUnknown, &unsupported) == CLASS_E_NOAGGREGATION && !unsupported, + "Aggregation rejection must not allocate a handler."); + CheckHr(factory->LockServer(TRUE), "Cannot lock native module."); + CheckHr(factory->LockServer(FALSE), "Cannot unlock native module."); + factory.Reset(); + Check(canUnload() == S_OK, "Factory or handler reference leaked after release."); + } + + DWORD handlesAfter = 0; + CheckWin32(GetProcessHandleCount(GetCurrentProcess(), &handlesAfter), "Cannot recount native test handles."); + std::cout << " Native process handles: before=" << handlesBefore << ", after=" << handlesAfter << '\n'; + Check(handlesAfter <= handlesBefore + 8, "Readiness-event handles accumulate across menu lifetimes."); + } + void Verify(const fs::path& directory, IClassFactory* factory, HANDLE ready) { struct Case { const char* label; std::vector names; bool visible; }; @@ -515,7 +616,10 @@ namespace BeginCase("pair fixture identity and order"); Selection pair(directory, {L"old file.txt", L"new 雪.cmd"}); - auto data = pair.Data(); + auto originalData = pair.Data(); + DataLifetime lifetime; + ComPtr data; + data.Attach(new TrackedData(originalData.Get(), lifetime)); // The Shell can expand short directory names in GetTempPath's result. Verify fixture identity // and then compare delivery with the exact paths/order supplied by the real Shell data object. std::vector suppliedPaths; @@ -523,6 +627,25 @@ namespace Check(suppliedPaths.size() == 2 && fs::equivalent(suppliedPaths[0], directory / L"old file.txt") && fs::equivalent(suppliedPaths[1], directory / L"new 雪.cmd"), "Shell fixture order or identity is wrong."); auto handler = CreateHandler(factory, data.Get()); + Check(lifetime.references == 2, "Handler must retain exactly one selection reference."); + BeginCase("selection replacement and destruction reference balance"); + + for (int iteration = 0; iteration < 1000; ++iteration) + { + CheckHr(handler->Initialize(nullptr, data.Get(), nullptr), "Repeated selection initialization failed."); + Check(lifetime.references == 2, "Selection references accumulate during reinitialization."); + } + + CheckHr(handler->Initialize(nullptr, nullptr, nullptr), "Clearing selection failed."); + Check(lifetime.references == 1, "Reinitialization did not release the previous selection."); + + { + auto temporary = CreateHandler(factory, data.Get()); + Check(lifetime.references == 2, "Temporary handler did not retain selection."); + } + + Check(lifetime.references == 1, "Handler destruction did not release selection."); + CheckHr(handler->Initialize(nullptr, data.Get(), nullptr), "Restoring selection failed."); ComPtr context; CheckHr(handler.As(&context), "Missing menu interface."); BeginCase("default-action exclusion"); @@ -537,6 +660,7 @@ namespace "Existing handler must hide while monitoring is paused."); CMINVOKECOMMANDINFO command{sizeof(command)}; Check(FAILED(context->InvokeCommand(&command)), "Cached invocation must fail while paused."); + Check(lifetime.references == 1, "Failed invocation did not release selection."); BeginCase("resume, command ID allocation and canonical verb"); CheckWin32(SetEvent(ready), "Cannot set readiness event for resume."); CheckHr(handler->Initialize(nullptr, data.Get(), nullptr), "Resume initialization failed."); @@ -578,10 +702,13 @@ namespace unicode.lpVerbW = ClipDiffVerb; CheckHr(context->InvokeCommand(reinterpret_cast(&unicode)), "Unicode verb failed."); Check(receiver->drops == 2, "Unicode verb was not delivered."); + Check(lifetime.references == 1, "Successful invocation did not release selection."); Check(FAILED(context->InvokeCommand(reinterpret_cast(&unicode))), "Selection retained after invocation."); } catch (...) { CoRevokeClassObject(cookie); throw; } CoRevokeClassObject(cookie); + data.Reset(); + Check(lifetime.destroyed, "Selection object remains alive after all owners released it."); } } @@ -620,6 +747,7 @@ int wmain(int argc, wchar_t** argv) const fs::path dll = fs::absolute(argv[1]); library = LoadLibraryW(dll.c_str()); CheckWin32(library != nullptr, "Cannot load extension DLL."); + VerifyModuleLifetime(library); const auto getFactory = reinterpret_cast(GetProcAddress(library, "DllGetClassObject")); CheckWin32(getFactory != nullptr, "Missing class factory export."); ComPtr factory; diff --git a/tests/ClipDiff.Windows.Tests/ClipboardInspectionReaderTests.cs b/tests/ClipDiff.Windows.Tests/ClipboardInspectionReaderTests.cs new file mode 100644 index 0000000..e0817ed --- /dev/null +++ b/tests/ClipDiff.Windows.Tests/ClipboardInspectionReaderTests.cs @@ -0,0 +1,143 @@ +using ClipDiff.Windows.Clipboard; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ClipDiff.Windows.Tests; + +[TestClass] +public sealed class ClipboardInspectionReaderTests +{ + private static readonly DateTimeOffset Now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + [TestMethod] + public async Task BlockedRendererDoesNotBlockCallerAndClosesOnTheOpeningThread() + { + using var release = new ManualResetEventSlim(); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var callingThread = 0; + var openingThread = 0; + var closingThread = 0; + var reader = new ClipboardInspectionReader( + () => { openingThread = Environment.CurrentManagedThreadId; return true; }, + () => closingThread = Environment.CurrentManagedThreadId, + (sequence, time) => + { + Assert.AreEqual(openingThread, Environment.CurrentManagedThreadId); + started.SetResult(); + Assert.IsTrue(release.Wait(TimeSpan.FromSeconds(5))); + return new ClipboardInspection.Completed(ClipboardObservation.TextValue(sequence, time, "synthetic")); + }); + var returned = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + var caller = new Thread(() => + { + callingThread = Environment.CurrentManagedThreadId; + returned.SetResult(reader.ReadAsync(7, Now, CancellationToken.None)); + }); + caller.Start(); + + try + { + var pending = await returned.Task.WaitAsync(TimeSpan.FromSeconds(3)); + await started.Task.WaitAsync(TimeSpan.FromSeconds(3)); + Assert.AreNotEqual(callingThread, openingThread); + Assert.IsFalse(pending.IsCompleted); + Assert.AreEqual(0, closingThread); + } + finally + { + release.Set(); + Assert.IsTrue(caller.Join(TimeSpan.FromSeconds(3))); + await (await returned.Task).WaitAsync(TimeSpan.FromSeconds(3)); + } + + var completed = (ClipboardInspection.Completed)(await await returned.Task)!; + Assert.AreEqual(7u, completed.Observation.SequenceNumber); + Assert.AreEqual(Now, completed.Observation.ObservedAt); + Assert.AreEqual(openingThread, closingThread); + } + + [TestMethod] + public async Task FailedOpenDoesNotInspectOrCloseAnotherApplicationsClipboard() + { + var reader = new ClipboardInspectionReader( + () => false, + () => Assert.Fail("A failed open must not be closed."), + (_, _) => throw new AssertFailedException("A failed open must not inspect data.")); + + Assert.IsNull(await reader.ReadAsync(7, Now, CancellationToken.None)); + } + + [TestMethod] + public async Task InspectionFailureStillClosesClipboardExactlyOnce() + { + var closes = 0; + var reader = new ClipboardInspectionReader( + () => true, + () => Interlocked.Increment(ref closes), + (_, _) => throw new InvalidOperationException("Synthetic inspection failure.")); + + await Assert.ThrowsExactlyAsync(() => reader.ReadAsync(7, Now, CancellationToken.None)); + Assert.AreEqual(1, closes); + } + + [TestMethod] + public async Task CancellationAfterOpenClosesClipboardWithoutRetrievingPayload() + { + using var cancellation = new CancellationTokenSource(); + var closes = 0; + var reader = new ClipboardInspectionReader( + () => { cancellation.Cancel(); return true; }, + () => Interlocked.Increment(ref closes), + (_, _) => throw new AssertFailedException("Cancellation must prevent inspection.")); + + await Assert.ThrowsAsync(() => reader.ReadAsync(7, Now, cancellation.Token)); + Assert.AreEqual(1, closes); + } + + [TestMethod] + public async Task CancellingBlockedInspectionDoesNotAbandonAnOpenClipboard() + { + using var release = new ManualResetEventSlim(); + using var cancellation = new CancellationTokenSource(); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var closes = 0; + var reader = new ClipboardInspectionReader( + () => true, + () => Interlocked.Increment(ref closes), + (sequence, time) => + { + started.SetResult(); + Assert.IsTrue(release.Wait(TimeSpan.FromSeconds(5))); + return new ClipboardInspection.Completed(ClipboardObservation.TextValue(sequence, time, "synthetic stale text")); + }); + var pending = reader.ReadAsync(7, Now, cancellation.Token); + + try + { + await started.Task.WaitAsync(TimeSpan.FromSeconds(3)); + cancellation.Cancel(); + + Assert.IsFalse(pending.IsCompleted); + Assert.AreEqual(0, closes); + } + finally + { + release.Set(); + await Assert.ThrowsAsync(() => pending.WaitAsync(TimeSpan.FromSeconds(3))); + } + + Assert.AreEqual(1, closes); + } + + [TestMethod] + public async Task CancellationBeforeSchedulingDoesNotOpenClipboard() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var reader = new ClipboardInspectionReader( + () => throw new AssertFailedException("A cancelled read must not open the clipboard."), + () => Assert.Fail("An unopened clipboard must not be closed."), + (_, _) => throw new AssertFailedException("A cancelled read must not inspect data.")); + + await Assert.ThrowsAsync(() => reader.ReadAsync(7, Now, cancellation.Token)); + } +} diff --git a/tests/ClipDiff.Windows.Tests/ExplorerRegistrationValueTests.cs b/tests/ClipDiff.Windows.Tests/ExplorerRegistrationValueTests.cs new file mode 100644 index 0000000..d089345 --- /dev/null +++ b/tests/ClipDiff.Windows.Tests/ExplorerRegistrationValueTests.cs @@ -0,0 +1,57 @@ +using ClipDiff.Windows.Explorer; +using Microsoft.Win32; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ClipDiff.Windows.Tests; + +[TestClass] +public sealed class ExplorerRegistrationValueTests +{ + [TestMethod] + [DataRow(null)] + [DataRow("Icon")] + public void UnchangedRegistrationDoesNotRequestAnotherShellNotification(string? valueName) + { + WithKey(key => + { + Assert.IsTrue(ExplorerContextMenuRegistration.SetStringValue(key, valueName, "synthetic registration")); + + for (var update = 0; update < 1_000; update++) + { + Assert.IsFalse(ExplorerContextMenuRegistration.SetStringValue(key, valueName, "synthetic registration")); + } + + Assert.IsTrue(ExplorerContextMenuRegistration.SetStringValue(key, valueName, "changed registration")); + Assert.AreEqual("changed registration", key.GetValue(valueName)); + Assert.IsFalse(ExplorerContextMenuRegistration.SetStringValue(key, valueName, "changed registration")); + }); + } + + [TestMethod] + public void RegistrationStillRepairsDeletedValuesAndIncorrectRegistryTypes() + { + WithKey(key => + { + key.SetValue("Icon", "unchanged text", RegistryValueKind.ExpandString); + Assert.IsTrue(ExplorerContextMenuRegistration.SetStringValue(key, "Icon", "unchanged text")); + Assert.AreEqual(RegistryValueKind.String, key.GetValueKind("Icon")); + key.DeleteValue("Icon"); + Assert.IsTrue(ExplorerContextMenuRegistration.SetStringValue(key, "Icon", "unchanged text")); + }); + } + + private static void WithKey(Action run) + { + var path = @"Software\ClipDiff.Tests\" + Guid.NewGuid().ToString("N"); + + try + { + using var key = Registry.CurrentUser.CreateSubKey(path); + run(key); + } + finally + { + Registry.CurrentUser.DeleteSubKeyTree(path, throwOnMissingSubKey: false); + } + } +} diff --git a/tests/ClipDiff.Windows.Tests/ExternalDiffLauncherTests.cs b/tests/ClipDiff.Windows.Tests/ExternalDiffLauncherTests.cs new file mode 100644 index 0000000..1c49ee3 --- /dev/null +++ b/tests/ClipDiff.Windows.Tests/ExternalDiffLauncherTests.cs @@ -0,0 +1,100 @@ +using ClipDiff.Windows.ExternalDiff; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ClipDiff.Windows.Tests; + +[TestClass] +public sealed class ExternalDiffLauncherTests +{ + [TestMethod] + public async Task RapidlyExitingViewersReleaseAllWorkspacesAndOwnershipLeases() + { + var root = CreateTemporaryDirectory(); + + try + { + using var launcher = new ExternalDiffLauncher(new ExternalDiffWorkspace(root)); + var choice = ExitingViewer(); + + for (var index = 0; index < 32; index++) + { + Assert.IsTrue(launcher.TryLaunch(choice, Entry("synthetic older"), Entry("synthetic newer"))); + } + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + while (Directory.EnumerateFileSystemEntries(root).Any()) + { + await Task.Delay(25, timeout.Token); + } + + Assert.IsEmpty(Directory.GetFileSystemEntries(root)); + } + finally + { + ExternalDiffWorkspace.TryDelete(root); + } + } + + [TestMethod] + public void FailedProcessStartReleasesItsWorkspaceAndLease() + { + var root = CreateTemporaryDirectory(); + + try + { + var executable = Path.Combine(root, "not-an-executable.exe"); + File.WriteAllText(executable, "synthetic invalid executable"); + using var launcher = new ExternalDiffLauncher(new ExternalDiffWorkspace(Path.Combine(root, "workspaces"))); + var choice = ExitingViewer() with { ExecutablePath = executable }; + + Assert.IsFalse(launcher.TryLaunch(choice, Entry("synthetic older"), Entry("synthetic newer"))); + Assert.IsEmpty(Directory.GetFileSystemEntries(Path.Combine(root, "workspaces"))); + } + finally + { + ExternalDiffWorkspace.TryDelete(root); + } + } + + [TestMethod] + public void DisposedLauncherDoesNotStartViewerOrCreateFiles() + { + var root = CreateTemporaryDirectory(); + + try + { + using var launcher = new ExternalDiffLauncher(new ExternalDiffWorkspace(root)); + launcher.Dispose(); + launcher.Dispose(); + + Assert.IsFalse(launcher.TryLaunch(ExitingViewer(), Entry("synthetic older"), Entry("synthetic newer"))); + Assert.IsEmpty(Directory.GetFileSystemEntries(root)); + } + finally + { + ExternalDiffWorkspace.TryDelete(root); + } + } + + private static ExternalDiffToolChoice ExitingViewer() + { + // The test child ignores the synthetic comparison paths and exits immediately, + // exercising the race between Process.Exited and listener registration. + var executable = OperatingSystem.IsWindows() + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "cmd.exe") + : "/bin/true"; + var tool = new ExternalDiffTool("test", "Synthetic exiting viewer", [], + (_, _, _, _) => OperatingSystem.IsWindows() ? ["/d", "/c", "exit", "0"] : []); + return new(tool, executable); + } + + private static ClipboardEntry Entry(string text) => new(Guid.NewGuid(), text, DateTimeOffset.UtcNow); + + private static string CreateTemporaryDirectory() + { + var root = Path.Combine(Path.GetTempPath(), "ClipDiff.LauncherTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + return root; + } +} diff --git a/tests/ClipDiff.Windows.Tests/TrayIconControllerTests.cs b/tests/ClipDiff.Windows.Tests/TrayIconControllerTests.cs new file mode 100644 index 0000000..19febab --- /dev/null +++ b/tests/ClipDiff.Windows.Tests/TrayIconControllerTests.cs @@ -0,0 +1,39 @@ +using System.Reflection; +using ClipDiff.Windows.ExternalDiff; +using ClipDiff.Windows.Tray; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Forms = System.Windows.Forms; + +namespace ClipDiff.Windows.Tests; + +[TestClass] +public sealed class TrayIconControllerTests +{ + [STATestMethod] + public void ReplacingViewerChoicesDisposesEveryPreviousMenuItem() + { + var choice = new ExternalDiffToolChoice( + ExternalDiffToolCatalog.MatchExecutable("WinMergeU.exe"), @"C:\Synthetic\WinMergeU.exe"); + using var controller = new TrayIconController([choice], null); + // Keep the old item identities so the test verifies disposal, rather than + // merely observing that they disappeared from the visible menu. + var field = typeof(TrayIconController).GetField("diffViewerItem", BindingFlags.Instance | BindingFlags.NonPublic)!; + var viewer = (Forms.ToolStripMenuItem)field.GetValue(controller)!; + + for (var replacement = 0; replacement < 100; replacement++) + { + var previous = viewer.DropDownItems.Cast().ToArray(); + var disposed = 0; + + foreach (var item in previous) + { + item.Disposed += (_, _) => disposed++; + } + + controller.SetDiffTools([choice], replacement % 2 == 0 ? choice.ExecutablePath : null); + + Assert.AreEqual(previous.Length, disposed); + Assert.IsFalse(viewer.DropDownItems.Cast().Any(previous.Contains)); + } + } +} From 466dd5cd7ff1f260a2c66c9e45d3ec21e90c1c49 Mon Sep 17 00:00:00 2001 From: S D Date: Mon, 26 Oct 2026 19:42:22 +0000 Subject: [PATCH 2/2] Record successful Windows lifetime and release validation --- docs/long-running-hang-audit.md | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/long-running-hang-audit.md b/docs/long-running-hang-audit.md index 667986d..3a854f9 100644 --- a/docs/long-running-hang-audit.md +++ b/docs/long-running-hang-audit.md @@ -23,9 +23,10 @@ an in-progress Win32 call or prove that delayed rendering caused this incident. ## Native C++ ownership -The DLL is loaded into **Explorer**, not the ClipDiff process. I found no unmatched -allocation, COM reference, event handle, or menu ownership in its production paths. -That is a source-review result, not a Windows soak-test result. +The DLL is loaded into **Explorer**, not the ClipDiff process. Source review and +the new Windows lifetime regressions found no unmatched allocation, COM reference, +event handle, or menu ownership in the exercised production paths. This was not +a multi-day Windows soak test or inspection of the original hung process. | Native path | Ownership and release | | --- | --- | @@ -105,9 +106,15 @@ uptime or weekend-specific trigger in the resource ownership reviewed here. neither native binary was executed on Linux. - The hang-inspection PowerShell script parses successfully. Its Windows process-inspection behavior has not been executed on this machine. - -The new Windows registry, tray-menu, and native lifetime tests still need to run -on Windows. Use the existing workflow or, from a normal Windows PowerShell with +- The full [Windows workflow for code commit f3fb721](https://github.com/stuartd/ClipboardDiff/actions/runs/40388380708) + succeeded in 2m 30s. This ran the supported MSVC native build and expanded + native lifetime/Shell tests, the managed core and Windows tests (including + registry and tray disposal regressions), release-version checks, and the + complete release script. Both self-contained and framework-dependent Windows + packages passed the script's payload validation and were uploaded as artifacts. + +The Windows workflow result is separate from the local Linux checks above. +To repeat platform validation locally, use a normal Windows PowerShell with ClipDiff closed: ```powershell @@ -118,7 +125,8 @@ dotnet test tests/ClipDiff.Stress.Tests --configuration Release Then test the tray while a synthetic producer delays rendering, check ordinary clipboard capture/privacy behavior, repeat viewer selection, and exercise the -actual Explorer menu. No Windows functional-readiness claim is made here. +actual Explorer menu. Those manual desktop checks and reproduction of the +reported laptop hang remain outstanding. ## Inspect the existing hang before restarting