From 786932e89235b498b8d6e6585de3345d700a10e3 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 5 Sep 2026 23:24:56 +0800 Subject: [PATCH] fix: serialize desktop commands across CLI processes --- docs/validation/M6-Stability-2026-09-05.md | 23 ++++ scripts/windows/Invoke-M6Validation.ps1 | 4 + scripts/windows/Test-CommandGate.ps1 | 40 +++++++ src/WxAgent.Core/InterprocessCommandGate.cs | 58 +++++++++ .../WechatChatClient.Management.cs | 27 +++-- src/WxAgent.Windows/WechatChatClient.cs | 112 +++++++----------- src/WxAgent.Windows/WechatStabilityRunner.cs | 2 +- .../InterprocessCommandGateTests.cs | 63 ++++++++++ 8 files changed, 244 insertions(+), 85 deletions(-) create mode 100644 scripts/windows/Test-CommandGate.ps1 create mode 100644 src/WxAgent.Core/InterprocessCommandGate.cs create mode 100644 tests/WxAgent.Core.Tests/InterprocessCommandGateTests.cs diff --git a/docs/validation/M6-Stability-2026-09-05.md b/docs/validation/M6-Stability-2026-09-05.md index 4fb8100..9392346 100644 --- a/docs/validation/M6-Stability-2026-09-05.md +++ b/docs/validation/M6-Stability-2026-09-05.md @@ -31,6 +31,29 @@ 脱敏摘要见 `evidence/M6-20260905/`;完整本地证据位于 `artifacts/m6-final-validation/m6-validation-20260905T143832/`。历史失败证据不作为通过依据。 +## 已撤销的 72 小时候选运行(不得作为验收) + +报告可靠性修复 `e0d1ea1`:Core 56/56 通过,Windows 8 项验证全部 exit 0。阶段报告与最终报告共用完整性/心跳/异常检查;最终采样失败仍保留失败报告,报告使用临时文件原子替换。 + +- 固定产物:`releases/e0d1ea1/WxAgent.Host.exe`。 +- SHA-256:`AB02B373EDCF76F8A4D392B55F3CF0464D37474ED4214D1C1E3F5FA0E67CED65`。 +- Windows 任务:`WxAgentM6Endurance-e0d1ea1`,仅手动启动,75 小时上限,无定时触发器。 +- 实际开始:2026-09-05 14:59:02 UTC;预期结束:2026-09-08 14:59:02 UTC。 +- 输出:`C:\Users\Rogee\wx-agent\m6-e0d1ea1-endurance72`。 +- 已检查到 15:03:05 UTC 的第 5 个样本;最后有效快照为 15:03:03 UTC,窗口可用、Errors 为空。这只证明启动成功和当时心跳正常。 +- 2026-09-05 15:18:00 UTC 主动停止该候选,确认旧进程数为 0;`aborted.json` 明确记录 `Accepted=false`、未完成请求时长。旧的 8/24/72 小时唤醒已删除。 +- 原因:审计发现原队列仅在单进程内互斥,旧 `ListenAsync` 也未复用新监听入口。修复与新候选验证完成前,不能继续用旧产物做最终长测。 +- Windows 11 环境尚未提供,仍保留未验收状态。 + +## 跨进程队列补强 + +- 同一 Windows 用户会话的 CLI 实例通过同一独占文件句柄串行执行 UI 操作;句柄由操作系统在进程退出时释放,不留下永久锁死的命名信号量计数。 +- 会话读取/定位(可能隐式恢复窗口)和关闭子窗口也纳入队列。 +- 旧 `ListenAsync` 复用 `ListenEventsAsync` 的会话校验与去重;等待队列时仍遵守监听时长。 +- 托盘激活和等待真实主控件的恢复过程遵守调用方取消令牌。 +- Linux 59/59 通过;完整 Release 构建 0 警告、0 错误。新增独立队列实例互斥、取消后可再次使用、非争用 I/O 异常不盲重试等回归。 +- Windows 跨进程门禁验证脚本:`scripts/windows/Test-CommandGate.ps1`,由 M6 验证脚本统一调用。其结果必须单独检查,不能仅凭 Linux 通过认定 Windows 通过。 + ## 尚待完成的验收 - [ ] 8/24/72 小时真实监听及长时资源趋势。 diff --git a/scripts/windows/Invoke-M6Validation.ps1 b/scripts/windows/Invoke-M6Validation.ps1 index 516937a..350bca3 100644 --- a/scripts/windows/Invoke-M6Validation.ps1 +++ b/scripts/windows/Invoke-M6Validation.ps1 @@ -27,6 +27,10 @@ foreach ($case in $cases) { [IO.File]::WriteAllText("$directory\$($case.Name).json", ($lines -join [Environment]::NewLine), $utf8) $results += @{ Name=$case.Name; ExitCode=$code; StartedAt=$started; FinishedAt=[DateTimeOffset]::UtcNow } } +$started = [DateTimeOffset]::UtcNow +& (Join-Path $PSScriptRoot 'Test-CommandGate.ps1') -Executable $exe -OutputPath "$directory\command-gate.json" +$gateCode = $LASTEXITCODE +$results += @{ Name='command-gate'; ExitCode=$gateCode; StartedAt=$started; FinishedAt=[DateTimeOffset]::UtcNow } $summary = @{ BinarySha256=(Get-FileHash $exe -Algorithm SHA256).Hash SessionId=[Diagnostics.Process]::GetCurrentProcess().SessionId diff --git a/scripts/windows/Test-CommandGate.ps1 b/scripts/windows/Test-CommandGate.ps1 new file mode 100644 index 0000000..948e1dc --- /dev/null +++ b/scripts/windows/Test-CommandGate.ps1 @@ -0,0 +1,40 @@ +param( + [Parameter(Mandatory=$true)][string]$Executable, + [Parameter(Mandatory=$true)][string]$OutputPath +) +$ErrorActionPreference = 'Stop' +if (Test-Path $OutputPath) { throw 'Do not overwrite earlier gate evidence.' } +$exe = (Resolve-Path $Executable).Path +$session = [Diagnostics.Process]::GetCurrentProcess().SessionId +if ($session -eq 0) { throw 'Run in the interactive WeChat session, not Session 0.' } +$lockPath = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) "WxAgent\ui-session-$session.lock" +$before = & $exe recover-ui --timeout 30 +$beforeCode = $LASTEXITCODE +if ($beforeCode -ne 0) { throw 'Baseline recovery failed; a contention test would not be meaningful.' } +$lease = [IO.File]::Open($lockPath, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) +try { + $blocked = & $exe recover-ui --timeout 3 + $blockedCode = $LASTEXITCODE + $errorCode = (($blocked -join [Environment]::NewLine) | ConvertFrom-Json).error + # The legacy listener must honor its own duration while another process holds the gate. + $listenOutput = @(& $exe chat listen --seconds 1 --timeout 10) + $listenCode = $LASTEXITCODE + $listenResult = ($listenOutput -join [Environment]::NewLine) | ConvertFrom-Json + $messageCount = @($listenResult.messages).Count +} finally { $lease.Dispose() } +$after = & $exe recover-ui --timeout 30 +$afterCode = $LASTEXITCODE +$result = @{ + SessionId=$session + BaselineExit=$beforeCode + ContendedExit=$blockedCode + ContendedError=$errorCode + ListenerExit=$listenCode + UnexpectedListenerMessageCount=$messageCount + RecoveryAfterReleaseExit=$afterCode + Passed=($beforeCode -eq 0 -and $blockedCode -eq 124 -and $errorCode -eq 'Timeout' -and + $listenCode -eq 0 -and $messageCount -eq 0 -and $afterCode -eq 0) +} +[IO.File]::WriteAllText([IO.Path]::GetFullPath($OutputPath), ($result | ConvertTo-Json), (New-Object Text.UTF8Encoding($false))) +if (!$result.Passed) { exit 2 } +exit 0 diff --git a/src/WxAgent.Core/InterprocessCommandGate.cs b/src/WxAgent.Core/InterprocessCommandGate.cs new file mode 100644 index 0000000..a724e27 --- /dev/null +++ b/src/WxAgent.Core/InterprocessCommandGate.cs @@ -0,0 +1,58 @@ +namespace WxAgent.Core; + +/// Serializes commands locally and across processes; the OS releases the file handle after a crash. +public sealed class InterprocessCommandGate : IDisposable +{ + private readonly SemaphoreSlim _local = new(1, 1); + private readonly string _path; + private FileStream? _lease; + + public InterprocessCommandGate(string path) + { + _path = Path.GetFullPath(path); + Directory.CreateDirectory(Path.GetDirectoryName(_path)!); + } + + public async Task WaitAsync(CancellationToken cancellationToken) + { + await _local.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + _lease = new FileStream(_path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + return; + } + catch (IOException exception) when ((exception.HResult & 0xffff) is 11 or 32 or 33) + { + // EAGAIN on Unix; sharing/lock violation on Windows. Other I/O errors must surface. + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + } + } + catch + { + _local.Release(); + throw; + } + } + + public void Release() + { + try { _lease?.Dispose(); } + finally + { + _lease = null; + _local.Release(); + } + } + + public void Dispose() + { + _lease?.Dispose(); + _local.Dispose(); + } +} diff --git a/src/WxAgent.Windows/WechatChatClient.Management.cs b/src/WxAgent.Windows/WechatChatClient.Management.cs index bf9280a..2c6d8bb 100644 --- a/src/WxAgent.Windows/WechatChatClient.Management.cs +++ b/src/WxAgent.Windows/WechatChatClient.Management.cs @@ -38,18 +38,21 @@ public static partial class WechatChatClient (await GetSubWindowsAsync(cancellationToken).ConfigureAwait(false)) .FirstOrDefault(window => string.Equals(window.Title, title, StringComparison.Ordinal)); - public static Task CloseSubWindowAsync(string title, CancellationToken cancellationToken = default) + public static async Task CloseSubWindowAsync(string title, CancellationToken cancellationToken = default) { - cancellationToken.ThrowIfCancellationRequested(); - var processIds = Process.GetProcessesByName("Weixin").Select(process => process.Id).ToHashSet(); - using var automation = new UIA3Automation(); - var window = automation.GetDesktop().FindAllChildren(cf => cf.ByControlType(ControlType.Window)) - .FirstOrDefault(candidate => processIds.Contains(candidate.Properties.ProcessId.ValueOrDefault) - && !candidate.Properties.IsOffscreen.ValueOrDefault - && !candidate.BoundingRectangle.IsEmpty - && string.Equals(SafeName(candidate), title, StringComparison.Ordinal)); - window?.AsWindow().Close(); - return Task.CompletedTask; + await CommandQueue.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var processIds = Process.GetProcessesByName("Weixin").Select(process => process.Id).ToHashSet(); + using var automation = new UIA3Automation(); + var window = automation.GetDesktop().FindAllChildren(cf => cf.ByControlType(ControlType.Window)) + .FirstOrDefault(candidate => processIds.Contains(candidate.Properties.ProcessId.ValueOrDefault) + && !candidate.Properties.IsOffscreen.ValueOrDefault + && !candidate.BoundingRectangle.IsEmpty + && string.Equals(SafeName(candidate), title, StringComparison.Ordinal)); + window?.AsWindow().Close(); + } + finally { CommandQueue.Release(); } } public static Task SwitchToContactsAsync(CancellationToken cancellationToken = default) => @@ -355,7 +358,7 @@ public static partial class WechatChatClient try { using var automation = new UIA3Automation(); - var main = AttachWindow(automation); + var main = AttachWindow(automation, cancellationToken); return await action(main, automation).ConfigureAwait(false); } catch (OperationCanceledException) diff --git a/src/WxAgent.Windows/WechatChatClient.cs b/src/WxAgent.Windows/WechatChatClient.cs index 45b97de..b65623e 100644 --- a/src/WxAgent.Windows/WechatChatClient.cs +++ b/src/WxAgent.Windows/WechatChatClient.cs @@ -13,13 +13,19 @@ namespace WxAgent.Windows; public static partial class WechatChatClient { - private static readonly SemaphoreSlim CommandQueue = new(1, 1); + private static readonly InterprocessCommandGate CommandQueue = CreateCommandGate(); - public static Task> ListVisibleSessionsAsync(CancellationToken cancellationToken) + private static InterprocessCommandGate CreateCommandGate() + { + using var process = System.Diagnostics.Process.GetCurrentProcess(); + return new InterprocessCommandGate(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "WxAgent", $"ui-session-{process.SessionId}.lock")); + } + + public static Task> ListVisibleSessionsAsync(CancellationToken cancellationToken) => + WithMainWindowAsync((window, _) => { - cancellationToken.ThrowIfCancellationRequested(); - using var automation = new UIA3Automation(); - var window = AttachWindow(automation); var list = FindByAutomationId(window, WechatLocators.SessionList) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.SessionList} was not found."); var current = SafeName(FindByAutomationId(window, WechatLocators.CurrentChatName)); @@ -29,7 +35,7 @@ public static partial class WechatChatClient .Select(element => (SafeAutomationId(element), SafeName(element))), current); return Task.FromResult>(sessions); - } + }, cancellationToken); public static async Task> SearchSessionsAsync( string query, @@ -41,7 +47,7 @@ public static partial class WechatChatClient try { using var automation = new UIA3Automation(); - var window = AttachWindow(automation); + var window = AttachWindow(automation, cancellationToken); var search = window.FindAllDescendants().FirstOrDefault(element => SafeControlType(element) == ControlType.Edit && SafeName(element) == WechatLocators.Search) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat search box was not found."); @@ -74,17 +80,15 @@ public static partial class WechatChatClient } } - public static Task GetCurrentSessionAsync(CancellationToken cancellationToken) + public static Task GetCurrentSessionAsync(CancellationToken cancellationToken) => + WithMainWindowAsync((window, _) => { - cancellationToken.ThrowIfCancellationRequested(); - using var automation = new UIA3Automation(); - var window = AttachWindow(automation); var element = FindByAutomationId(window, WechatLocators.CurrentChatName); var name = SafeName(element); return Task.FromResult(string.IsNullOrWhiteSpace(name) ? null : new WechatSessionSnapshot(name, SafeAutomationId(element!), true)); - } + }, cancellationToken); public static async Task OpenSessionAsync(string name, CancellationToken cancellationToken) { @@ -93,7 +97,7 @@ public static partial class WechatChatClient try { using var automation = new UIA3Automation(); - var window = AttachWindow(automation); + var window = AttachWindow(automation, cancellationToken); if (SafeName(FindByAutomationId(window, WechatLocators.CurrentChatName)) == name) { return new WechatSessionSnapshot(name, SafeAutomationId(FindByAutomationId(window, WechatLocators.CurrentChatName)!), true); @@ -147,7 +151,7 @@ public static partial class WechatChatClient try { using var automation = new UIA3Automation(); - var window = AttachWindow(automation); + var window = AttachWindow(automation, cancellationToken); await OpenFileTransferAssistantCoreAsync(window, cancellationToken); await Task.Delay(500, cancellationToken); @@ -188,7 +192,7 @@ public static partial class WechatChatClient try { using var automation = new UIA3Automation(); - var window = AttachWindow(automation); + var window = AttachWindow(automation, cancellationToken); await OpenFileTransferAssistantCoreAsync(window, cancellationToken); var before = ReadVisible(window).Select(message => message.Fingerprint).ToHashSet(StringComparer.Ordinal); var list = FindByAutomationId(window, WechatLocators.MessageList) @@ -254,7 +258,7 @@ public static partial class WechatChatClient try { using var automation = new UIA3Automation(); - var window = AttachWindow(automation); + var window = AttachWindow(automation, cancellationToken); await OpenFileTransferAssistantCoreAsync(window, cancellationToken); var before = ReadVisible(window).Select(message => message.Fingerprint).ToHashSet(StringComparer.Ordinal); var input = FindByAutomationId(window, WechatLocators.ChatInput) @@ -303,7 +307,7 @@ public static partial class WechatChatClient try { using var automation = new UIA3Automation(); - var window = AttachWindow(automation); + var window = AttachWindow(automation, cancellationToken); await OpenFileTransferAssistantCoreAsync(window, cancellationToken); var messages = new List(); var deduper = new BoundedMessageDeduper(Math.Max(2048, maxMessages * 4)); @@ -346,7 +350,7 @@ public static partial class WechatChatClient try { using var automation = new UIA3Automation(); - var window = AttachWindow(automation); + var window = AttachWindow(automation, cancellationToken); await OpenFileTransferAssistantCoreAsync(window, cancellationToken); return ReadVisible(window); } @@ -360,55 +364,10 @@ public static partial class WechatChatClient TimeSpan duration, [EnumeratorCancellation] CancellationToken cancellationToken) { - if (duration <= TimeSpan.Zero) + await foreach (var item in ListenEventsAsync(duration, checkpointPath: null, cancellationToken)) { - throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Listen duration must be positive."); - } - - using var automation = new UIA3Automation(); - AutomationElement window; - await CommandQueue.WaitAsync(cancellationToken); - try - { - window = AttachWindow(automation); - await OpenFileTransferAssistantCoreAsync(window, cancellationToken); - } - finally - { - CommandQueue.Release(); - } - - var list = FindByAutomationId(window, WechatLocators.MessageList) - ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.MessageList} was not found."); - var signals = Channel.CreateBounded(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite }); - using var subscription = list.RegisterStructureChangedEvent( - TreeScope.Subtree, - (_, _, _) => signals.Writer.TryWrite(true)); - var deduper = new BoundedMessageDeduper(); - foreach (var message in ReadVisible(window)) - { - deduper.TryAdd(message.Fingerprint); - } - - var deadline = DateTimeOffset.UtcNow + duration; - while (DateTimeOffset.UtcNow < deadline) - { - cancellationToken.ThrowIfCancellationRequested(); - var remaining = deadline - DateTimeOffset.UtcNow; - var delay = Task.Delay(remaining < TimeSpan.FromSeconds(2) ? remaining : TimeSpan.FromSeconds(2), cancellationToken); - var eventReady = signals.Reader.WaitToReadAsync(cancellationToken).AsTask(); - await Task.WhenAny(delay, eventReady); - while (signals.Reader.TryRead(out _)) - { - } - - foreach (var message in ReadVisible(window)) - { - if (deduper.TryAdd(message.Fingerprint)) - { - yield return message; - } - } + if (item.Kind == MessageEventKind.MessageReceived && item.Message is { } message) + yield return message; } } @@ -440,6 +399,8 @@ public static partial class WechatChatClient var connected = false; var announceReconnect = recoveredScan; var elapsed = System.Diagnostics.Stopwatch.StartNew(); + using var readDeadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + readDeadline.CancelAfter(duration); try { @@ -449,14 +410,14 @@ public static partial class WechatChatClient IReadOnlyList? visible = null; try { - await CommandQueue.WaitAsync(cancellationToken); + await CommandQueue.WaitAsync(readDeadline.Token); try { - window = AttachWindow(automation); + window = AttachWindow(automation, readDeadline.Token); if (!IsFileTransferAssistantOpen(window)) connected = false; if (!connected) { - await OpenFileTransferAssistantCoreAsync(window, cancellationToken); + await OpenFileTransferAssistantCoreAsync(window, readDeadline.Token); subscription?.Dispose(); var list = FindByAutomationId(window, WechatLocators.MessageList) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Listener message list was not found."); @@ -476,6 +437,10 @@ public static partial class WechatChatClient CommandQueue.Release(); } } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && readDeadline.IsCancellationRequested) + { + break; + } catch (OperationCanceledException) { throw; @@ -577,21 +542,24 @@ public static partial class WechatChatClient } } - private static AutomationElement AttachWindow(UIA3Automation automation) + private static AutomationElement AttachWindow(UIA3Automation automation, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); var window = WechatDoctor.FindWechatWindow(automation); if (window is null || FindByAutomationId(window, WechatLocators.MainView) is null) { - if (!WechatTray.TryActivateUia(automation)) WechatTray.Inspect(true, CancellationToken.None); + cancellationToken.ThrowIfCancellationRequested(); + if (!WechatTray.TryActivateUia(automation)) WechatTray.Inspect(true, cancellationToken); for (var attempt = 0; attempt < 50; attempt++) { - Thread.Sleep(100); + if (cancellationToken.WaitHandle.WaitOne(100)) cancellationToken.ThrowIfCancellationRequested(); window = WechatDoctor.FindWechatWindow(automation); if (window is not null && FindByAutomationId(window, WechatLocators.MainView) is not null) break; } } if (window is null || FindByAutomationId(window, WechatLocators.MainView) is null) throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat tray activation did not restore an accessible main view; no new process was started."); + cancellationToken.ThrowIfCancellationRequested(); window.Focus(); return window; } diff --git a/src/WxAgent.Windows/WechatStabilityRunner.cs b/src/WxAgent.Windows/WechatStabilityRunner.cs index ff16ce3..b807fe0 100644 --- a/src/WxAgent.Windows/WechatStabilityRunner.cs +++ b/src/WxAgent.Windows/WechatStabilityRunner.cs @@ -151,7 +151,7 @@ public static partial class WechatChatClient { using var automation = new UIA3Automation(); cancellationToken.ThrowIfCancellationRequested(); - var main = AttachWindow(automation); + var main = AttachWindow(automation, cancellationToken); if (FindByAutomationId(main, WechatLocators.SessionList) is not null) return WechatOperationResult.Ok("WeChat UI is already on chats"); diff --git a/tests/WxAgent.Core.Tests/InterprocessCommandGateTests.cs b/tests/WxAgent.Core.Tests/InterprocessCommandGateTests.cs new file mode 100644 index 0000000..ba67ffa --- /dev/null +++ b/tests/WxAgent.Core.Tests/InterprocessCommandGateTests.cs @@ -0,0 +1,63 @@ +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class InterprocessCommandGateTests +{ + [Fact] + public async Task SeparateInstancesShareAnExclusiveLease() + { + var path = Path.Combine(Path.GetTempPath(), "wx-gate-" + Guid.NewGuid(), "commands.lock"); + try + { + using var first = new InterprocessCommandGate(path); + using var second = new InterprocessCommandGate(path); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await first.WaitAsync(timeout.Token); + var waiting = second.WaitAsync(timeout.Token); + Assert.False(waiting.IsCompleted); + first.Release(); + await waiting; + second.Release(); + } + finally { Directory.Delete(Path.GetDirectoryName(path)!, recursive: true); } + } + + [Fact] + public async Task CancelledContentionDoesNotPoisonTheLocalQueue() + { + var path = Path.Combine(Path.GetTempPath(), "wx-gate-" + Guid.NewGuid(), "commands.lock"); + try + { + using var first = new InterprocessCommandGate(path); + using var second = new InterprocessCommandGate(path); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await first.WaitAsync(timeout.Token); + using var cancelled = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + await Assert.ThrowsAnyAsync(() => second.WaitAsync(cancelled.Token)); + first.Release(); + await second.WaitAsync(timeout.Token); + second.Release(); + } + finally { Directory.Delete(Path.GetDirectoryName(path)!, recursive: true); } + } + + [Fact] + public async Task NonContentionIoErrorsAreNotRetried() + { + var path = Path.Combine(Path.GetTempPath(), "wx-gate-" + Guid.NewGuid(), "commands.lock"); + Directory.CreateDirectory(path); // A directory cannot be opened as the lock file. + try + { + using var gate = new InterprocessCommandGate(path); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var error = await Record.ExceptionAsync(() => gate.WaitAsync(timeout.Token)); + Assert.True(error is IOException or UnauthorizedAccessException); + Directory.Delete(path); + await gate.WaitAsync(timeout.Token); + gate.Release(); + } + finally { Directory.Delete(Path.GetDirectoryName(path)!, recursive: true); } + } +}