From 99537d3f15a9934a9e2272b5582b187610a61f6c Mon Sep 17 00:00:00 2001 From: Clint Rutkas Date: Tue, 3 Nov 2026 23:48:08 +0000 Subject: [PATCH] [MWB][UITests] Fix StyleCop layout warnings (stack 3/4) (#51648) ## Summary of the Pull Request Mouse without border UI tests were added with allowing baseline warnings. i'm on a war against warnings. This is a stacked PR to keep the reviews small and tight. ## PR Checklist - [ ] Closes: #xxx - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed
AI-generated summary **Stack 3/4** (base: #51647). Fixes StyleCop layout warnings in `MouseWithoutBorders.UITests`: SA1107, SA1116, SA1117, SA1118, SA1130, SA1501, SA1502, SA1513, and SA1515. - **Formatting only.** A whitespace-insensitive token comparison against layer 2 shows exactly two kinds of token changes: - anonymous `delegate` -> lambda (SA1130). This is C# 3 syntax, so it still compiles under Windows PowerShell 5.1. - hoisting the multi-line `provision["Configuration"] ?? throw ...` argument into a local before its loop (SA1118) in `TwoEndpointFixture` and `EndpointRecoveryFixture`. - Payload types intentionally stay in `NativeSupport.cs` / `Receiver.cs` here. The one-type-per-file move is layer 4, so that diff reviews as a pure move. **Validation** (local, x64 Debug): msbuild exit 0, with only SA1402 x6 and SA1649 x2 remaining (fixed in layer 4); the Windows PowerShell 5.1 `Add-Type` payload compile passes; `ClipboardObservationDistinguishesContentionFromReadableData` and `StartMenuIdentityRequiresExactOsPathClassAndInteractiveSession` pass 2/2; `Scripts\Test-SuitePreparation.ps1` passes under PS 5.1 (89 checks). Sandbox end-to-end suites were not run locally.
--- .../tests/mwbEndpointDiagnostics.Tests.ps1 | 2 +- .../EndpointRecoveryFixture.cs | 5 +- .../Payload/NativeSupport.cs | 67 +++-- .../Payload/Receiver.cs | 151 +++++++++-- .../RecordingInfrastructureTests.cs | 4 +- .../RecoveryTests.cs | 8 +- .../TwoEndpointFixture.cs | 256 +++++++++++------- .../WinAppSandbox.cs | 229 +++++++++------- 8 files changed, 478 insertions(+), 244 deletions(-) diff --git a/.pipelines/tests/mwbEndpointDiagnostics.Tests.ps1 b/.pipelines/tests/mwbEndpointDiagnostics.Tests.ps1 index 37c6f7c21a..07055fe6d4 100644 --- a/.pipelines/tests/mwbEndpointDiagnostics.Tests.ps1 +++ b/.pipelines/tests/mwbEndpointDiagnostics.Tests.ps1 @@ -409,7 +409,7 @@ Describe 'MWB bounded nonsecret guest-command stages' { $method | Should Match 'CopyFromScreen' $method | Should Match 'NativeSupport.Desktop\(\).Ready' $method | Should Not Match 'FocusWindow|Clipboard|SetForegroundWindow|DrawToBitmap' - $receiver | Should Match 'public void CaptureFailureDesktop\(string path\) \{ Receiver.CaptureFailureDesktop\(path\); \}' + $receiver | Should Match 'public void CaptureFailureDesktop\(string path\)\s*\{\s*Receiver\.CaptureFailureDesktop\(path\);\s*\}' } It 'observes native mouse routing without injecting or reporting window text' { diff --git a/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/EndpointRecoveryFixture.cs b/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/EndpointRecoveryFixture.cs index 19927ee842..4740162a14 100644 --- a/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/EndpointRecoveryFixture.cs +++ b/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/EndpointRecoveryFixture.cs @@ -97,10 +97,11 @@ internal sealed class EndpointRecoveryFixture : IDisposable var product = Path.GetFullPath(Environment.GetEnvironmentVariable("POWERTOYS_INSTALL_DIR") ?? throw new InvalidOperationException("Missing protected product staging.")).TrimEnd('\\'); Assert.IsTrue(string.Equals(product, provision[nameof(ProductRoot)]!.GetValue().TrimEnd('\\'), StringComparison.OrdinalIgnoreCase)); + var configuration = provision["Configuration"]?.GetValue() + ?? throw new InvalidDataException("Protected provisioning must identify the payload configuration."); foreach (var path in new[] { "PowerToys.MouseWithoutBorders.dll", @"WinUI3Apps\PowerToys.Settings.dll" }) { - TwoEndpointFixture.AssertConfiguredAssembly(Path.Combine(product, path), provision["Configuration"]?.GetValue() - ?? throw new InvalidDataException("Protected provisioning must identify the payload configuration.")); + TwoEndpointFixture.AssertConfiguredAssembly(Path.Combine(product, path), configuration); } foreach (var (field, file) in new[] { ("ExecutableSha256", "PowerToys.MouseWithoutBorders.exe"), ("LibrarySha256", "PowerToys.MouseWithoutBorders.dll") }) diff --git a/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/Payload/NativeSupport.cs b/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/Payload/NativeSupport.cs index 853eec65b2..897534a5c3 100644 --- a/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/Payload/NativeSupport.cs +++ b/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/Payload/NativeSupport.cs @@ -79,17 +79,45 @@ namespace Microsoft.MouseWithoutBorders.UITests // toolhelp32 snapshot walk (ParentId), the process token/session query // (SessionId), or QueryFullProcessImageName (ImagePath) is the one that failed. int parentId; - try { parentId = NativeSupport.ParentId(id); } - catch (Exception error) { throw new InvalidOperationException("ParentId: " + error.Message, error); } + try + { + parentId = NativeSupport.ParentId(id); + } + catch (Exception error) + { + throw new InvalidOperationException("ParentId: " + error.Message, error); + } + int sessionId; - try { sessionId = process.SessionId; } - catch (Exception error) { throw new InvalidOperationException("SessionId: " + error.Message, error); } + try + { + sessionId = process.SessionId; + } + catch (Exception error) + { + throw new InvalidOperationException("SessionId: " + error.Message, error); + } + string path; - try { path = NativeSupport.ImagePath(id); } - catch (Exception error) { throw new InvalidOperationException("ImagePath: " + error.Message, error); } + try + { + path = NativeSupport.ImagePath(id); + } + catch (Exception error) + { + throw new InvalidOperationException("ImagePath: " + error.Message, error); + } + DateTime startTimeUtc; - try { startTimeUtc = process.StartTime.ToUniversalTime(); } - catch (Exception error) { throw new InvalidOperationException("StartTime: " + error.Message, error); } + try + { + startTimeUtc = process.StartTime.ToUniversalTime(); + } + catch (Exception error) + { + throw new InvalidOperationException("StartTime: " + error.Message, error); + } + return new ProcessIdentity { Id = id, @@ -472,12 +500,18 @@ namespace Microsoft.MouseWithoutBorders.UITests } var windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows); - return string.Equals(processPath, Path.Combine(windows, - @"SystemApps\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\StartMenuExperienceHost.exe"), StringComparison.OrdinalIgnoreCase) || - string.Equals(processPath, Path.Combine(windows, - @"SystemApps\MicrosoftWindows.Client.CBS_cw5n1h2txyewy\SearchHost.exe"), StringComparison.OrdinalIgnoreCase) || - string.Equals(processPath, Path.Combine(windows, - @"SystemApps\Microsoft.Windows.Search_cw5n1h2txyewy\SearchApp.exe"), StringComparison.OrdinalIgnoreCase); + return string.Equals( + processPath, + Path.Combine(windows, @"SystemApps\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\StartMenuExperienceHost.exe"), + StringComparison.OrdinalIgnoreCase) || + string.Equals( + processPath, + Path.Combine(windows, @"SystemApps\MicrosoftWindows.Client.CBS_cw5n1h2txyewy\SearchHost.exe"), + StringComparison.OrdinalIgnoreCase) || + string.Equals( + processPath, + Path.Combine(windows, @"SystemApps\Microsoft.Windows.Search_cw5n1h2txyewy\SearchApp.exe"), + StringComparison.OrdinalIgnoreCase); } private static bool IsUncloakedCoreWindow(IntPtr window) @@ -499,7 +533,8 @@ namespace Microsoft.MouseWithoutBorders.UITests int hresult = DwmGetWindowAttribute(window, 14 /* DWMWA_CLOAKED */, out cloaked, sizeof(int)); if (hresult < 0) { - throw new InvalidOperationException("Could not query Start-menu cloaking (HRESULT 0x" + hresult.ToString("X8", CultureInfo.InvariantCulture) + ").", + throw new InvalidOperationException( + "Could not query Start-menu cloaking (HRESULT 0x" + hresult.ToString("X8", CultureInfo.InvariantCulture) + ").", Marshal.GetExceptionForHR(hresult)); } @@ -606,7 +641,7 @@ namespace Microsoft.MouseWithoutBorders.UITests { List windows = new List(); EnumWindows( - delegate(IntPtr window, IntPtr lParam) + (window, lParam) => { if (WindowProcessId(window) != processId) { diff --git a/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/Payload/Receiver.cs b/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/Payload/Receiver.cs index 91a705b4b1..7802039549 100644 --- a/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/Payload/Receiver.cs +++ b/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/Payload/Receiver.cs @@ -30,7 +30,10 @@ namespace Microsoft.MouseWithoutBorders.UITests public int? ErrorHResult { get; private set; } - public bool Available { get { return !ErrorHResult.HasValue; } } + public bool Available + { + get { return !ErrorHResult.HasValue; } + } internal static ClipboardObservation Read(Func readDigest) { @@ -62,7 +65,7 @@ namespace Microsoft.MouseWithoutBorders.UITests public ReceiverController(string role, string runId) { var started = new TaskCompletionSource(); - thread = new Thread(delegate() + thread = new Thread(() => { try { @@ -100,45 +103,116 @@ namespace Microsoft.MouseWithoutBorders.UITests receiver = started.Task.Result; } - public IntPtr Handle { get { return handle; } } + public IntPtr Handle + { + get { return handle; } + } - public string ReceivedText { get { return Invoke(delegate { return receiver.ReceivedText; }); } } + public string ReceivedText + { + get { return Invoke(() => receiver.ReceivedText); } + } - public bool InputFocused { get { return Invoke(delegate { return receiver.InputFocused; }); } } + public bool InputFocused + { + get { return Invoke(() => receiver.InputFocused); } + } - public int Clicks { get { return Invoke(delegate { return receiver.Clicks; }); } } + public int Clicks + { + get { return Invoke(() => receiver.Clicks); } + } - public int StartMenuDismissals { get { return Invoke(delegate { return receiver.StartMenuDismissals; }); } } + public int StartMenuDismissals + { + get { return Invoke(() => receiver.StartMenuDismissals); } + } - public Rectangle ClickBounds { get { return Invoke(delegate { return receiver.ClickBounds; }); } } + public Rectangle ClickBounds + { + get { return Invoke(() => receiver.ClickBounds); } + } - public long ClickTargetHandle { get { return Invoke(delegate { return receiver.ClickTargetHandle; }); } } + public long ClickTargetHandle + { + get { return Invoke(() => receiver.ClickTargetHandle); } + } - public int MouseDownMessages { get { return Invoke(delegate { return receiver.MouseDownMessages; }); } } + public int MouseDownMessages + { + get { return Invoke(() => receiver.MouseDownMessages); } + } - public int MouseUpMessages { get { return Invoke(delegate { return receiver.MouseUpMessages; }); } } + public int MouseUpMessages + { + get { return Invoke(() => receiver.MouseUpMessages); } + } - public void FocusInput() { Invoke(delegate { receiver.FocusInput(); return true; }); } + public void FocusInput() + { + Invoke(() => + { + receiver.FocusInput(); + return true; + }); + } - public void ClearInput() { Invoke(delegate { receiver.ClearInput(); return true; }); } + public void ClearInput() + { + Invoke(() => + { + receiver.ClearInput(); + return true; + }); + } - public string PublishClipboard() { return Invoke(delegate { return receiver.PublishClipboard(); }); } + public string PublishClipboard() + { + return Invoke(() => receiver.PublishClipboard()); + } - public string ClipboardDigest() { return Invoke(delegate { return receiver.ClipboardDigest(); }); } + public string ClipboardDigest() + { + return Invoke(() => receiver.ClipboardDigest()); + } - public ClipboardObservation ObserveClipboard() { return Invoke(delegate { return receiver.ObserveClipboard(); }); } + public ClipboardObservation ObserveClipboard() + { + return Invoke(() => receiver.ObserveClipboard()); + } - public void RestoreClipboard() { Invoke(delegate { receiver.RestoreClipboard(); return true; }); } + public void RestoreClipboard() + { + Invoke(() => + { + receiver.RestoreClipboard(); + return true; + }); + } - public void CaptureDesktop(string path) { Invoke(delegate { receiver.CaptureDesktop(path); return true; }); } + public void CaptureDesktop(string path) + { + Invoke(() => + { + receiver.CaptureDesktop(path); + return true; + }); + } - public void CaptureFailureDesktop(string path) { Receiver.CaptureFailureDesktop(path); } + public void CaptureFailureDesktop(string path) + { + Receiver.CaptureFailureDesktop(path); + } public void Close() { if (thread.IsAlive && !receiver.IsDisposed) { - Invoke(delegate { receiver.Close(); return true; }); + Invoke(() => + { + receiver.Close(); + return true; + }); } } @@ -157,6 +231,7 @@ namespace Microsoft.MouseWithoutBorders.UITests { throw new InvalidOperationException("Receiver UI is no longer available."); } + return (T)receiver.Invoke(action); } } @@ -171,8 +246,16 @@ namespace Microsoft.MouseWithoutBorders.UITests protected override void WndProc(ref Message message) { - if (message.Msg == 0x0201) { MouseDownMessages++; } - if (message.Msg == 0x0202) { MouseUpMessages++; } + if (message.Msg == 0x0201) + { + MouseDownMessages++; + } + + if (message.Msg == 0x0202) + { + MouseUpMessages++; + } + base.WndProc(ref message); } } @@ -209,6 +292,7 @@ namespace Microsoft.MouseWithoutBorders.UITests Dock = DockStyle.Fill, BackColor = Color.LightSteelBlue, }; + // A MouseDown is physical input. Unlike Button.Click, Space/Invoke cannot increment it. clickTarget.MouseDown += (sender, args) => { @@ -219,7 +303,7 @@ namespace Microsoft.MouseWithoutBorders.UITests }; Controls.Add(clickTarget); Controls.Add(input); - Shown += delegate { input.Focus(); }; + Shown += (sender, args) => input.Focus(); restoresClipboard = role == "Host"; if (restoresClipboard) { @@ -246,11 +330,20 @@ namespace Microsoft.MouseWithoutBorders.UITests public int Clicks { get; private set; } - public long ClickTargetHandle { get { return clickTarget.Handle.ToInt64(); } } + public long ClickTargetHandle + { + get { return clickTarget.Handle.ToInt64(); } + } - public int MouseDownMessages { get { return clickTarget.MouseDownMessages; } } + public int MouseDownMessages + { + get { return clickTarget.MouseDownMessages; } + } - public int MouseUpMessages { get { return clickTarget.MouseUpMessages; } } + public int MouseUpMessages + { + get { return clickTarget.MouseUpMessages; } + } public string ReceivedText { @@ -271,7 +364,7 @@ namespace Microsoft.MouseWithoutBorders.UITests public void FocusInput() { - if (NativeSupport.DismissForegroundStartMenu(delegate { SendKeys.SendWait("{ESC}"); })) + if (NativeSupport.DismissForegroundStartMenu(() => SendKeys.SendWait("{ESC}"))) { StartMenuDismissals++; } @@ -294,8 +387,10 @@ namespace Microsoft.MouseWithoutBorders.UITests DataObject data = new DataObject(); data.SetText(token, TextDataFormat.UnicodeText); clipboardChanged = true; + // Keep the same token while bounded OLE retries cover slow clipboard owners. Clipboard.SetDataObject(data, true, 150, 100); + // An immediate OLE clipboard read can still be empty on a cold desktop. // Acknowledge the generated value; the controller verifies its publication. return Digest(token); @@ -462,6 +557,7 @@ namespace Microsoft.MouseWithoutBorders.UITests public void CaptureDesktop(string path) { Refresh(); + // Diagnostic snapshot of this test-owned control only. Never include // another window's generated pairing key or original clipboard. using (Bitmap bitmap = new Bitmap(Width, Height)) @@ -477,6 +573,7 @@ namespace Microsoft.MouseWithoutBorders.UITests { throw new InvalidOperationException("Failure capture requires the active test desktop."); } + Rectangle bounds = SystemInformation.VirtualScreen; using (Bitmap bitmap = new Bitmap(bounds.Width, bounds.Height)) using (Graphics graphics = Graphics.FromImage(bitmap)) diff --git a/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/RecordingInfrastructureTests.cs b/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/RecordingInfrastructureTests.cs index 42bd50a7f8..974b9d8532 100644 --- a/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/RecordingInfrastructureTests.cs +++ b/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/RecordingInfrastructureTests.cs @@ -21,7 +21,8 @@ public sealed class RecordingInfrastructureTests [TestCategory("MwbInfrastructure")] public void DesktopAndWindowVideosSurviveAnEarlyFailure() { - var directory = Path.Combine(RunFiles.PersistentResultsRoot(TestContext.TestRunDirectory), + var directory = Path.Combine( + RunFiles.PersistentResultsRoot(TestContext.TestRunDirectory), "mwb-recording-probe-" + Guid.NewGuid().ToString("N")); Assert.ThrowsExactly(() => _ = new ScreenRecording(directory, IntPtr.Zero, 0, 960, 540)); Assert.ThrowsExactly(() => _ = new ScreenRecording(directory, IntPtr.Zero, 5, 959, 540)); @@ -59,6 +60,7 @@ public sealed class RecordingInfrastructureTests Assert.AreEqual(SystemInformation.VirtualScreen.Width, image.Width); Assert.AreEqual(SystemInformation.VirtualScreen.Height, image.Height); } + TestContext.AddResultFile(screenshot); throw expected; } diff --git a/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/RecoveryTests.cs b/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/RecoveryTests.cs index 37b6771e27..a71944b041 100644 --- a/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/RecoveryTests.cs +++ b/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/RecoveryTests.cs @@ -36,7 +36,9 @@ public sealed class RecoveryTests try { var originalDigest = baseline.PublishClipboard(); - RunFiles.Wait(() => baseline.ClipboardDigest() == originalDigest, TimeSpan.FromSeconds(15), + RunFiles.Wait( + () => baseline.ClipboardDigest() == originalDigest, + TimeSpan.FromSeconds(15), "The known synthetic clipboard baseline was not published."); using (var fixture = new EndpointRecoveryFixture(TestContext, clipboardLost: true)) { @@ -45,7 +47,9 @@ public sealed class RecoveryTests fixture.AssertRecovery(); } - RunFiles.Wait(() => baseline.ClipboardDigest() == originalDigest, TimeSpan.FromSeconds(15), + RunFiles.Wait( + () => baseline.ClipboardDigest() == originalDigest, + TimeSpan.FromSeconds(15), "The independent recovery guard did not restore the known clipboard baseline."); } finally diff --git a/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/TwoEndpointFixture.cs b/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/TwoEndpointFixture.cs index e5b55fda11..2ddc4b0c83 100644 --- a/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/TwoEndpointFixture.cs +++ b/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/TwoEndpointFixture.cs @@ -242,12 +242,14 @@ internal sealed class TwoEndpointFixture : IDisposable using var identity = WindowsIdentity.GetCurrent(); userSid = identity.User!.Value; Assert.AreEqual(userSid, provision["TestUserSid"]?.GetValue(), "Provisioning belongs to another user."); - Assert.IsTrue(string.Equals(productRoot, provision["ProductRoot"]?.GetValue()?.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase), + Assert.IsTrue( + string.Equals(productRoot, provision["ProductRoot"]?.GetValue()?.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase), "Provisioning ProductRoot does not match POWERTOYS_INSTALL_DIR."); runId = Guid.Parse(provision["RunId"]!.GetValue()).ToString(); prerequisites.SetRunId(runId); Assert.IsTrue(!string.IsNullOrWhiteSpace(provision["RuleName"]?.GetValue()), "Missing scoped firewall rule identity."); - Assert.IsTrue(provision["Status"]?.GetValue() is "WaitingForSandbox" or "Ready", + Assert.IsTrue( + provision["Status"]?.GetValue() is "WaitingForSandbox" or "Ready", "BLOCKED_INFRASTRUCTURE: privileged provisioning must be WaitingForSandbox or Ready before the test."); var backend = SandboxBackendSelection.Resolve( provision["SandboxBackend"]?.GetValue(), @@ -256,11 +258,13 @@ internal sealed class TwoEndpointFixture : IDisposable { LegacySandbox.AssertNoneRunning(); } + foreach (var process in Process.GetProcesses()) { using (process) { - Assert.IsFalse(process.ProcessName == "PowerToys" || process.ProcessName.StartsWith("PowerToys.", StringComparison.OrdinalIgnoreCase), + Assert.IsFalse( + process.ProcessName == "PowerToys" || process.ProcessName.StartsWith("PowerToys.", StringComparison.OrdinalIgnoreCase), "Refusing an existing PowerToys process; the baseline must be clean."); } } @@ -286,9 +290,13 @@ internal sealed class TwoEndpointFixture : IDisposable ?? throw new InvalidOperationException("Provisioning must stage the guest product archive."); using (var archive = File.OpenRead(guestArchive)) { - Assert.AreEqual(provision["GuestArchiveSha256"]!.GetValue(), - Convert.ToHexString(SHA256.HashData(archive)), true, "The staged guest product archive changed."); + Assert.AreEqual( + provision["GuestArchiveSha256"]!.GetValue(), + Convert.ToHexString(SHA256.HashData(archive)), + true, + "The staged guest product archive changed."); } + var hardDeadlineUtc = DateTime.UtcNow + (backend == "WinApp" ? SandboxTimeouts.ModernRun : SandboxTimeouts.LegacyRun); foreach (var endpoint in new[] { host, guest }) { @@ -324,6 +332,7 @@ internal sealed class TwoEndpointFixture : IDisposable "Sandbox winapp.exe changed after privileged provisioning."); sandbox = new WinAppSandbox(runId, controlRoot, runRoot, sandboxWinApp, SaveJournal, context, prerequisites); } + // Keep liveness outside MTP/native recording, and isolate mapped guest writes // from host publication so one stalled path cannot starve both endpoints. using var owner = Process.GetCurrentProcess(); @@ -369,17 +378,24 @@ internal sealed class TwoEndpointFixture : IDisposable using var stream = File.OpenRead(Path.Combine(productRoot, path)); return Convert.ToHexString(SHA256.HashData(stream)); }); - Assert.AreEqual(hashes["PowerToys.Settings.UI.Lib.dll"], hashes[@"WinUI3Apps\PowerToys.Settings.UI.Lib.dll"], + Assert.AreEqual( + hashes["PowerToys.Settings.UI.Lib.dll"], + hashes[@"WinUI3Apps\PowerToys.Settings.UI.Lib.dll"], "Root and WinUI3Apps settings-library builds differ."); if (provision["TestSigningTrust"] is not null) { - Assert.AreEqual(provision["TestSigningTrust"]!["Sha256"]!.GetValue(), hashes["mwb-test-signer.cer"], true, + Assert.AreEqual( + provision["TestSigningTrust"]!["Sha256"]!.GetValue(), + hashes["mwb-test-signer.cer"], + true, "The public test certificate changed after protected provisioning."); } + + var configuration = provision["Configuration"]?.GetValue() + ?? throw new InvalidDataException("Protected provisioning must identify the payload configuration."); foreach (var path in new[] { "PowerToys.MouseWithoutBorders.dll", "PowerToys.MouseWithoutBordersHelper.dll", @"WinUI3Apps\PowerToys.Settings.dll" }) { - AssertConfiguredAssembly(Path.Combine(productRoot, path), provision["Configuration"]?.GetValue() - ?? throw new InvalidDataException("Protected provisioning must identify the payload configuration.")); + AssertConfiguredAssembly(Path.Combine(productRoot, path), configuration); } if (provision["Status"]?.GetValue() == "Ready" || @@ -387,9 +403,15 @@ internal sealed class TwoEndpointFixture : IDisposable { Assert.IsNotNull(provision["ExecutableSha256"], "Provisioning must fingerprint the staged MWB executable."); Assert.IsNotNull(provision["LibrarySha256"], "Provisioning must fingerprint the staged MWB assembly."); - Assert.AreEqual(provision["ExecutableSha256"]!.GetValue(), hashes["PowerToys.MouseWithoutBorders.exe"], true, + Assert.AreEqual( + provision["ExecutableSha256"]!.GetValue(), + hashes["PowerToys.MouseWithoutBorders.exe"], + true, "MWB executable changed after privileged provisioning."); - Assert.AreEqual(provision["LibrarySha256"]!.GetValue(), hashes["PowerToys.MouseWithoutBorders.dll"], true, + Assert.AreEqual( + provision["LibrarySha256"]!.GetValue(), + hashes["PowerToys.MouseWithoutBorders.dll"], + true, "MWB assembly changed after privileged provisioning."); } @@ -457,12 +479,16 @@ internal sealed class TwoEndpointFixture : IDisposable var current = RunFiles.Read(markerPath); Assert.AreEqual(runId, current["RunId"]?.GetValue(), "BLOCKED_INFRASTRUCTURE: provisioning RunId changed."); Assert.AreEqual(userSid, current["TestUserSid"]?.GetValue(), "BLOCKED_INFRASTRUCTURE: provisioning user changed."); - Assert.AreEqual(initialProvision["RuleName"]?.GetValue(), current["RuleName"]?.GetValue(), + Assert.AreEqual( + initialProvision["RuleName"]?.GetValue(), + current["RuleName"]?.GetValue(), "BLOCKED_INFRASTRUCTURE: provisioning rule identity changed."); - Assert.IsTrue(string.Equals(productRoot, current["ProductRoot"]?.GetValue()?.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase), + Assert.IsTrue( + string.Equals(productRoot, current["ProductRoot"]?.GetValue()?.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase), "BLOCKED_INFRASTRUCTURE: provisioning product identity changed."); var currentStatus = current["Status"]?.GetValue(); - Assert.IsTrue(currentStatus is "WaitingForSandbox" or "Ready", + Assert.IsTrue( + currentStatus is "WaitingForSandbox" or "Ready", $"BLOCKED_INFRASTRUCTURE: privileged setup reported {currentStatus}; inspect its protected marker."); if (currentStatus == "WaitingForSandbox") { @@ -473,7 +499,10 @@ internal sealed class TwoEndpointFixture : IDisposable { if (initialProvision[hashName] is not null) { - Assert.AreEqual(initialProvision[hashName]!.GetValue(), current[hashName]?.GetValue(), true, + Assert.AreEqual( + initialProvision[hashName]!.GetValue(), + current[hashName]?.GetValue(), + true, "BLOCKED_INFRASTRUCTURE: provisioned payload identity changed while waiting for Sandbox."); } } @@ -490,7 +519,9 @@ internal sealed class TwoEndpointFixture : IDisposable var candidates = guestReady["Addresses"]!.AsArray() .Where(node => InSubnet(node!["IPAddress"]!.GetValue(), subnet) && node["IPAddress"]!.GetValue() != hostAddress).ToArray(); - Assert.AreEqual(1, candidates.Length, + Assert.AreEqual( + 1, + candidates.Length, $"NETWORK_CHANGED: the guest must have exactly one address in provisioned Sandbox subnet {subnet}; inspect guest ready.json and reprovision the actual Default Switch."); guestAddress = candidates[0]!["IPAddress"]!.GetValue(); var guestInterface = candidates[0]!["InterfaceIndex"]!.GetValue(); @@ -498,7 +529,9 @@ internal sealed class TwoEndpointFixture : IDisposable .Where(node => node!["InterfaceIndex"]!.GetValue() == guestInterface) .Select(node => node!["NextHop"]!.GetValue()).Distinct().ToArray(); Assert.AreEqual(1, gateways.Length, "NETWORK_CHANGED: the selected guest interface must have one unambiguous default gateway."); - Assert.AreEqual(hostAddress, gateways[0], + Assert.AreEqual( + hostAddress, + gateways[0], $"NETWORK_CHANGED: guest gateway {gateways[0]} does not match provisioned HostAddress {hostAddress}; neither MWB endpoint was started."); RunFiles.Write(Path.Combine(runRoot, "topology.json"), new { @@ -512,19 +545,26 @@ internal sealed class TwoEndpointFixture : IDisposable { var global = CreateGlobalSettings(payloadRoot); hostPeersStarted = true; - var started = host!.Request("Start", new JsonObject - { - ["PeerName"] = guestName, ["PeerAddress"] = guestAddress, ["GlobalSettings"] = global.DeepClone(), - ["ExpectedLocalAddress"] = provision["HostAddress"]!.DeepClone(), - }, timeoutSeconds: SandboxTimeouts.EndpointStartRequestSeconds); + var started = host!.Request( + "Start", + new JsonObject + { + ["PeerName"] = guestName, ["PeerAddress"] = guestAddress, ["GlobalSettings"] = global.DeepClone(), + ["ExpectedLocalAddress"] = provision["HostAddress"]!.DeepClone(), + }, + timeoutSeconds: SandboxTimeouts.EndpointStartRequestSeconds); settings = Session.FromProcess(started["SettingsProcessId"]!.GetValue().ToString(CultureInfo.InvariantCulture), timeoutMS: 30_000); guestPeersStarted = true; + // Enclose the fifteen-minute readiness budget and the mapped-folder reply. - guest!.Request("Start", new JsonObject - { - ["PeerName"] = hostName, ["PeerAddress"] = provision["HostAddress"]!.DeepClone(), ["GlobalSettings"] = global.DeepClone(), - ["ExpectedLocalAddress"] = guestAddress, - }, timeoutSeconds: SandboxTimeouts.EndpointStartRequestSeconds); + guest!.Request( + "Start", + new JsonObject + { + ["PeerName"] = hostName, ["PeerAddress"] = provision["HostAddress"]!.DeepClone(), ["GlobalSettings"] = global.DeepClone(), + ["ExpectedLocalAddress"] = guestAddress, + }, + timeoutSeconds: SandboxTimeouts.EndpointStartRequestSeconds); } internal static JsonObject CreateGlobalSettings(string payloadRoot) @@ -560,37 +600,47 @@ internal sealed class TwoEndpointFixture : IDisposable var previous = RunFiles.Read(path)["properties"]!["SecurityKey"]!["value"]!.GetValue(); buttons[0].Invoke(msPostAction: 0); string key = string.Empty; - RunFiles.Wait(() => - { - key = RunFiles.Read(path)["properties"]!["SecurityKey"]!["value"]!.GetValue(); - return key.Length > 0 && key != previous; - }, TimeSpan.FromSeconds(20), "Settings New key did not change the persisted key."); + RunFiles.Wait( + () => + { + key = RunFiles.Read(path)["properties"]!["SecurityKey"]!["value"]!.GetValue(); + return key.Length > 0 && key != previous; + }, + TimeSpan.FromSeconds(20), + "Settings New key did not change the persisted key."); host!.Request("VerifyPeerMapping"); var connection = guest!.Request("Connect", new JsonObject { ["Key"] = key, ["PeerName"] = hostName }, timeoutSeconds: 600); - Assert.IsTrue(connection["Acknowledged"]?.GetValue() == true, + Assert.IsTrue( + connection["Acknowledged"]?.GetValue() == true, "Settings Connect must be acknowledged by the guest's persisted matching key and peer before testing transport."); } private void VerifyTransport() { var stable = 0; - RunFiles.Wait(() => - { - var hostTransport = host!.Request("Transport"); - var guestTransport = guest!.Request("Transport"); - RunFiles.Write(Path.Combine(runRoot, "host-transport.json"), hostTransport); - RunFiles.Write(Path.Combine(runRoot, "guest-transport.json"), guestTransport); - Assert.AreEqual((int)Key.F1, hostTransport["SwitchKey"]!.GetValue(), "The isolated baseline must use F1-F4 machine switching."); - bool established = HasPeer(hostTransport, guestAddress) && - HasPeer(guestTransport, provision["HostAddress"]!.GetValue()) && - HasRoutingLayout(hostTransport) && HasRoutingLayout(guestTransport); - stable = established ? stable + 1 : 0; - return stable >= 2; - }, TimeSpan.FromSeconds(90), "Both owned MWB PIDs must have peer transport and the expected host/guest routing slots."); + RunFiles.Wait( + () => + { + var hostTransport = host!.Request("Transport"); + var guestTransport = guest!.Request("Transport"); + RunFiles.Write(Path.Combine(runRoot, "host-transport.json"), hostTransport); + RunFiles.Write(Path.Combine(runRoot, "guest-transport.json"), guestTransport); + Assert.AreEqual((int)Key.F1, hostTransport["SwitchKey"]!.GetValue(), "The isolated baseline must use F1-F4 machine switching."); + bool established = HasPeer(hostTransport, guestAddress) && + HasPeer(guestTransport, provision["HostAddress"]!.GetValue()) && + HasRoutingLayout(hostTransport) && HasRoutingLayout(guestTransport); + stable = established ? stable + 1 : 0; + return stable >= 2; + }, + TimeSpan.FromSeconds(90), + "Both owned MWB PIDs must have peer transport and the expected host/guest routing slots."); + // ItemsControl itself has no UIA peer; its bound device labels do. - RunFiles.Wait(() => settings!.FindAll(By.Name(guestName), timeoutMS: 1000) - .Any(element => string.Equals(element.Name, guestName, StringComparison.OrdinalIgnoreCase)), - TimeSpan.FromSeconds(15), "Settings devices did not display the paired guest."); + RunFiles.Wait( + () => settings!.FindAll(By.Name(guestName), timeoutMS: 1000) + .Any(element => string.Equals(element.Name, guestName, StringComparison.OrdinalIgnoreCase)), + TimeSpan.FromSeconds(15), + "Settings devices did not display the paired guest."); } private void VerifyLocalInput() @@ -601,8 +651,10 @@ internal sealed class TwoEndpointFixture : IDisposable FocusHost(); SelectMachine(Key.F1); TypeToken("local1357924680"); - RunFiles.Wait(() => Observe(host)["Text"]!.GetValue() == "local1357924680", - TimeSpan.FromSeconds(10), "Local control token was not received by the host."); + RunFiles.Wait( + () => Observe(host)["Text"]!.GetValue() == "local1357924680", + TimeSpan.FromSeconds(10), + "Local control token was not received by the host."); var guestState = Observe(guest); Assert.AreEqual(string.Empty, guestState["Text"]!.GetValue(), "Local control leaked to the guest."); Assert.AreEqual(0, guestState["Clicks"]!.GetValue()); @@ -614,8 +666,10 @@ internal sealed class TwoEndpointFixture : IDisposable FocusHost(); SelectMachine(Key.F2); TypeToken("remote2468135790"); - RunFiles.Wait(() => Observe(guest)["Text"]!.GetValue() == "remote2468135790", - TimeSpan.FromSeconds(15), "Remote token never reached the independent guest receiver."); + RunFiles.Wait( + () => Observe(guest)["Text"]!.GetValue() == "remote2468135790", + TimeSpan.FromSeconds(15), + "Remote token never reached the independent guest receiver."); Assert.AreEqual("local1357924680", Observe(host!)["Text"]!.GetValue(), "Remote token leaked into the foreground host receiver."); var before = Observe(guest); var hostClicks = Observe(host!)["Clicks"]!.GetValue(); @@ -625,46 +679,58 @@ internal sealed class TwoEndpointFixture : IDisposable MouseHelper.MoveBy(30, 30, steps: 3); int stableClickTarget = 0; int blockedClickProbes = 0; - RunFiles.Wait(() => - { - var current = Observe(guest); - return current["CursorX"]!.GetValue() != beforeX || current["CursorY"]!.GetValue() != beforeY; - }, TimeSpan.FromSeconds(10), "Real host mouse movement did not reach the guest."); - RunFiles.Wait(() => - { - var current = Observe(guest); - Assert.AreEqual(guestReceiverHwnd, current["ForegroundHwnd"]!.GetValue(), - "No remote probe movement sent: the exact guest receiver must retain foreground."); - int x = current["CursorX"]!.GetValue(); - int y = current["CursorY"]!.GetValue(); - int left = current["ClickLeft"]!.GetValue(); - int right = current["ClickRight"]!.GetValue(); - int top = current["ClickTop"]!.GetValue(); - int bottom = current["ClickBottom"]!.GetValue(); - if (x > left + 30 && x < right - 30 && y > top + 30 && y < bottom - 30) + RunFiles.Wait( + () => { - // A delayed MWB cursor overlay can still intercept the button - // even when the receiver is foreground and the cursor is inside. - if (ReceiverObservation.IsPhysicalClickTarget(current, guestReceiverHwnd)) + var current = Observe(guest); + return current["CursorX"]!.GetValue() != beforeX || current["CursorY"]!.GetValue() != beforeY; + }, + TimeSpan.FromSeconds(10), + "Real host mouse movement did not reach the guest."); + RunFiles.Wait( + () => + { + var current = Observe(guest); + Assert.AreEqual( + guestReceiverHwnd, + current["ForegroundHwnd"]!.GetValue(), + "No remote probe movement sent: the exact guest receiver must retain foreground."); + int x = current["CursorX"]!.GetValue(); + int y = current["CursorY"]!.GetValue(); + int left = current["ClickLeft"]!.GetValue(); + int right = current["ClickRight"]!.GetValue(); + int top = current["ClickTop"]!.GetValue(); + int bottom = current["ClickBottom"]!.GetValue(); + if (x > left + 30 && x < right - 30 && y > top + 30 && y < bottom - 30) { - stableClickTarget++; - return stableClickTarget >= 2; + // A delayed MWB cursor overlay can still intercept the button + // even when the receiver is foreground and the cursor is inside. + if (ReceiverObservation.IsPhysicalClickTarget(current, guestReceiverHwnd)) + { + stableClickTarget++; + return stableClickTarget >= 2; + } + + stableClickTarget = 0; + var probe = ReceiverObservation.ClickProbePosition(left, top, right, bottom, blockedClickProbes++); + RequireHostForeground(); + MouseHelper.MoveBy( + Math.Clamp(probe.X - x, -100, 100), + Math.Clamp(probe.Y - y, -100, 100), + steps: 4); + return false; } stableClickTarget = 0; - var probe = ReceiverObservation.ClickProbePosition(left, top, right, bottom, blockedClickProbes++); RequireHostForeground(); - MouseHelper.MoveBy(Math.Clamp(probe.X - x, -100, 100), - Math.Clamp(probe.Y - y, -100, 100), steps: 4); + MouseHelper.MoveBy( + Math.Clamp(((left + right) / 2) - x, -100, 100), + Math.Clamp(((top + bottom) / 2) - y, -100, 100), + steps: 4); return false; - } - - stableClickTarget = 0; - RequireHostForeground(); - MouseHelper.MoveBy(Math.Clamp(((left + right) / 2) - x, -100, 100), - Math.Clamp(((top + bottom) / 2) - y, -100, 100), steps: 4); - return false; - }, TimeSpan.FromSeconds(20), "Remote cursor did not settle on the receiver's unobstructed physical click surface."); + }, + TimeSpan.FromSeconds(20), + "Remote cursor did not settle on the receiver's unobstructed physical click surface."); Assert.AreEqual(guestReceiverHwnd, Observe(guest)["ForegroundHwnd"]!.GetValue(), "Guest receiver lost foreground before click."); RequireHostForeground(); MouseHelper.LeftClick(); @@ -687,6 +753,7 @@ internal sealed class TwoEndpointFixture : IDisposable while (negative.Elapsed < TimeSpan.FromSeconds(5)); var card = settings!.Find(By.AccessibilityId("MouseWithoutBordersShareClipboard")); + // Element.Find currently searches the whole session. Inspect this exact subtree instead: // the page has several unnamed ToggleSwitch controls and a TransferFile CheckBox. var toggles = Descendants(InspectSubtree(card)).Where(element => @@ -696,6 +763,7 @@ internal sealed class TwoEndpointFixture : IDisposable Assert.IsFalse(toggle.IsOn, "Host clipboard sharing must begin disabled."); toggle.Invoke(msPostAction: 0); Assert.IsTrue(toggle.WaitForProperty("ToggleState", "On", 10_000), "Host clipboard sharing UI did not enable."); + // Enclose all three bounded UIA calls, persistence confirmation, and the mapped reply. guest!.Request("ClipboardSharing", new JsonObject { ["Enabled"] = true }, timeoutSeconds: 300); var hostSource = Publish(host!); @@ -711,16 +779,20 @@ internal sealed class TwoEndpointFixture : IDisposable var digest = endpoint.Request("PublishClipboard")["Digest"]!.GetValue(); Assert.AreEqual(64, digest.Length, "The source must acknowledge its generated synthetic clipboard digest."); var publication = Stopwatch.StartNew(); - RunFiles.Wait(() => ClipboardMatches(ReadClipboardDigest(Observe(endpoint)), digest, publication.Elapsed), - TimeSpan.FromSeconds(15), "The source did not publish its acknowledged synthetic clipboard."); + RunFiles.Wait( + () => ClipboardMatches(ReadClipboardDigest(Observe(endpoint)), digest, publication.Elapsed), + TimeSpan.FromSeconds(15), + "The source did not publish its acknowledged synthetic clipboard."); return digest; } private string RequireClipboardDigest(EndpointChannel endpoint) { string? digest = null; - RunFiles.Wait(() => (digest = ReadClipboardDigest(Observe(endpoint))) is not null, - TimeSpan.FromSeconds(15), "Clipboard remained unavailable for the sharing-off assertion."); + RunFiles.Wait( + () => (digest = ReadClipboardDigest(Observe(endpoint))) is not null, + TimeSpan.FromSeconds(15), + "Clipboard remained unavailable for the sharing-off assertion."); return digest!; } @@ -835,13 +907,15 @@ internal sealed class TwoEndpointFixture : IDisposable private void RequireHostForeground() { - Assert.IsTrue(WindowControl.WaitForForeground(new IntPtr(hostReceiverHwnd), timeoutMS: 5000, requiredConsecutiveMatches: 3), + Assert.IsTrue( + WindowControl.WaitForForeground(new IntPtr(hostReceiverHwnd), timeoutMS: 5000, requiredConsecutiveMatches: 3), $"No input sent: exact host receiver HWND must own foreground, not Sandbox. Actual: {WindowControl.GetForegroundWindowInfo()}"); } private void SelectMachine(Key machine) { RequireHostForeground(); + // MWB resets its simulated-input guard on the next mouse hook event. // Establish real pointer activity before a keyboard-only routing command. MouseHelper.MoveBy(1, 0, steps: 1); diff --git a/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/WinAppSandbox.cs b/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/WinAppSandbox.cs index 5a6e4f5b55..11c71a43ad 100644 --- a/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/WinAppSandbox.cs +++ b/src/modules/MouseWithoutBorders/MouseWithoutBorders.UITests/WinAppSandbox.cs @@ -125,7 +125,9 @@ internal sealed class WinAppSandbox : ISandboxSession // can create the instance even when it times out or returns an error. creationAttempted = true; saveJournal(); - var result = Run(wsbPath, ["start", "--id", instanceId.ToString("D"), "--config", configuration.ToString(SaveOptions.DisableFormatting), "--raw"], + var result = Run( + wsbPath, + ["start", "--id", instanceId.ToString("D"), "--config", configuration.ToString(SaveOptions.DisableFormatting), "--raw"], Budget(SandboxTimeouts.ModernStart, bootstrap: true)); WinAppSandboxProtocol.RequireStartResult(result, instanceId); WinAppSandboxProtocol.RequireExclusiveInstance(Inventory(Budget(InventoryTimeout, bootstrap: true)), instanceId); @@ -218,6 +220,7 @@ internal sealed class WinAppSandbox : ISandboxSession RequireLiveWorker(); ValidateOwnership(bootstrap: true); + // Correlated worker readiness has met the bootstrap deadline. Media // inspection remains bounded separately by the run's hard deadline. ReadSnapshot(bootstrap: false); @@ -234,6 +237,7 @@ internal sealed class WinAppSandbox : ISandboxSession } RequireLiveWorker(); + // Assertions are finished. Finalize the video before another guest-wide // capture/inspection competes with its active capture channel. var recordingErrors = new List(); @@ -270,18 +274,20 @@ internal sealed class WinAppSandbox : ISandboxSession Cleanup(() => FinishRecording(errors), errors); if (worker is not null) { - Cleanup(() => - { - if (!worker.WaitForExit(TimeSpan.FromSeconds(15))) + Cleanup( + () => { - errors.Add(new WinAppSandboxException("worker_forced_stop")); - worker.Terminate(); - } - else - { - worker.Complete(TimeSpan.FromSeconds(1)).RequireSuccess(); - } - }, errors); + if (!worker.WaitForExit(TimeSpan.FromSeconds(15))) + { + errors.Add(new WinAppSandboxException("worker_forced_stop")); + worker.Terminate(); + } + else + { + worker.Complete(TimeSpan.FromSeconds(1)).RequireSuccess(); + } + }, + errors); Cleanup(worker.Dispose, errors); if (worker.HasExited) { @@ -292,88 +298,97 @@ internal sealed class WinAppSandbox : ISandboxSession var absent = !creationAttempted; if (creationAttempted) { - Cleanup(() => - { - var inventory = WinAppSandboxProtocol.ReadInventory(Inventory(InventoryTimeout)); - if (inventory.Any(id => id != instanceId)) + Cleanup( + () => { - ownershipUnconfirmed = true; - } - if (inventory.Contains(instanceId) || !creationConfirmed) - { - // Even partial creation is fenced by the original GUID. Never - // use winapp target cleanup, which could act on an adopted VM. - Run(wsbPath, ["stop", "--id", instanceId.ToString("D"), "--raw"], TimeSpan.FromSeconds(45)); - } - - var timer = Stopwatch.StartNew(); - do - { - var remaining = WinAppSandboxProtocol.ReadInventory(Inventory(InventoryTimeout - timer.Elapsed)); - if (remaining.Any(id => id != instanceId)) + var inventory = WinAppSandboxProtocol.ReadInventory(Inventory(InventoryTimeout)); + if (inventory.Any(id => id != instanceId)) { ownershipUnconfirmed = true; } - if (!remaining.Contains(instanceId)) + if (inventory.Contains(instanceId) || !creationConfirmed) { - absent = true; - break; + // Even partial creation is fenced by the original GUID. Never + // use winapp target cleanup, which could act on an adopted VM. + Run(wsbPath, ["stop", "--id", instanceId.ToString("D"), "--raw"], TimeSpan.FromSeconds(45)); } - Thread.Sleep(250); - } - while (timer.Elapsed < TimeSpan.FromSeconds(30)); + var timer = Stopwatch.StartNew(); + do + { + var remaining = WinAppSandboxProtocol.ReadInventory(Inventory(InventoryTimeout - timer.Elapsed)); + if (remaining.Any(id => id != instanceId)) + { + ownershipUnconfirmed = true; + } - if (!absent) - { - throw new WinAppSandboxException("sandbox_stop_unconfirmed"); - } + if (!remaining.Contains(instanceId)) + { + absent = true; + break; + } - if (ownershipUnconfirmed) - { - throw new WinAppSandboxException("sandbox_ownership_unconfirmed"); - } - }, errors); + Thread.Sleep(250); + } + while (timer.Elapsed < TimeSpan.FromSeconds(30)); + + if (!absent) + { + throw new WinAppSandboxException("sandbox_stop_unconfirmed"); + } + + if (ownershipUnconfirmed) + { + throw new WinAppSandboxException("sandbox_ownership_unconfirmed"); + } + }, + errors); } if (stagedPayload is not null && Directory.Exists(stagedPayload)) { - Cleanup(() => - { - _ = WinAppSandboxPayload.PlainFiles(stagedPayload).ToArray(); - Directory.Delete(stagedPayload, recursive: true); - }, errors); + Cleanup( + () => + { + _ = WinAppSandboxPayload.PlainFiles(stagedPayload).ToArray(); + Directory.Delete(stagedPayload, recursive: true); + }, + errors); } if (absent && client is not null) { - Cleanup(() => - { - if (!client.HasExited) + Cleanup( + () => { - client.Kill(); - if (!client.WaitForExit(15000)) + if (!client.HasExited) { - throw new WinAppSandboxException("client_stop_timeout"); + client.Kill(); + if (!client.WaitForExit(15000)) + { + throw new WinAppSandboxException("client_stop_timeout"); + } } - } - client.Dispose(); - client = null; - }, errors); + client.Dispose(); + client = null; + }, + errors); } if (creationAttempted && absent && !ownershipUnconfirmed && ownsTargetStateRoot && worker is null && recorder is null && client is null && Directory.Exists(targetStateRoot)) { - Cleanup(() => - { - // Keep bootstrap secrets available for recovery until the exact - // provider instance is absent and both attached clients have exited. - // Pre-existing nonempty target state is never ours to delete. - _ = WinAppSandboxPayload.PlainFiles(targetStateRoot).ToArray(); - Directory.Delete(targetStateRoot, recursive: true); - }, errors); + Cleanup( + () => + { + // Keep bootstrap secrets available for recovery until the exact + // provider instance is absent and both attached clients have exited. + // Pre-existing nonempty target state is never ours to delete. + _ = WinAppSandboxPayload.PlainFiles(targetStateRoot).ToArray(); + Directory.Delete(targetStateRoot, recursive: true); + }, + errors); } stopped = absent && errors.Count == 0; @@ -554,8 +569,9 @@ internal sealed class WinAppSandbox : ISandboxSession { var remaining = deadline - DateTime.UtcNow; var timeout = remaining < TimeSpan.FromSeconds(15) ? remaining : TimeSpan.FromSeconds(15); - var result = Run(wsbPath, - ["exec", "--id", instanceId.ToString("D"), "--command", "cmd.exe /c exit 0", "--run-as", "ExistingLogin", "--raw"], + var result = Run( + wsbPath, + ["exec", "--id", instanceId.ToString("D"), "--command", "cmd.exe /c exit 0", "--run-as", "ExistingLogin", "--raw"], Budget(timeout, bootstrap: true)); WinAppSandboxProtocol.RequireGuestCommandSuccess(result); ValidateOwnership(bootstrap: true, requireState: false); @@ -727,6 +743,7 @@ internal sealed class WinAppSandbox : ISandboxSession recordingStartedUtc = DateTime.UtcNow; recorder = StartAttached(["target", "record", "sandbox", "--fps", "5", "--max-edge", "960", "--output", recordingPath, "--json"]); + // Ownership is checked before launch and after readiness. Provider inventory // polling here can block delivery of an already-published readiness event. // Allow the CLI's reconnect and capture initialization, not just capture. @@ -754,12 +771,14 @@ internal sealed class WinAppSandbox : ISandboxSession if (recorder is not null) { Cleanup(recorder.RequestRecordingStop, recordingErrors); - Cleanup(() => - { - var result = recorder.Complete(TimeSpan.FromSeconds(90)); - exitCode = result.ExitCode; - result.RequireSuccess(); - }, recordingErrors); + Cleanup( + () => + { + var result = recorder.Complete(TimeSpan.FromSeconds(90)); + exitCode = result.ExitCode; + result.RequireSuccess(); + }, + recordingErrors); Cleanup(recorder.Dispose, recordingErrors); if (recorder.HasExited) { @@ -772,36 +791,38 @@ internal sealed class WinAppSandbox : ISandboxSession recordingErrors.Add(new WinAppSandboxException("recording_output_unavailable")); } - Cleanup(() => - { - var available = HasOutput(recordingPath); - var manifest = Path.Combine(runRoot, "recordings", "sandbox-guest.json"); - RunFiles.Write(manifest, new + Cleanup( + () => { - Backend, - InstanceId = runId, - StartedUtc = recordingStartedUtc, - StoppedUtc = DateTime.UtcNow, - Started = recordingStarted, - Completed = recordingErrors.Count == 0 && exitCode == 0 && available, - Available = available, - ExitCode = exitCode, - FrameRate = 5, - MaxEdge = 960, - File = available ? Path.GetFileName(recordingPath) : null, - Errors = recordingErrors.Select(error => Sanitize(error).Code).ToArray(), - }); - context.AddResultFile(manifest); - if (available) - { - context.AddResultFile(recordingPath); - } + var available = HasOutput(recordingPath); + var manifest = Path.Combine(runRoot, "recordings", "sandbox-guest.json"); + RunFiles.Write(manifest, new + { + Backend, + InstanceId = runId, + StartedUtc = recordingStartedUtc, + StoppedUtc = DateTime.UtcNow, + Started = recordingStarted, + Completed = recordingErrors.Count == 0 && exitCode == 0 && available, + Available = available, + ExitCode = exitCode, + FrameRate = 5, + MaxEdge = 960, + File = available ? Path.GetFileName(recordingPath) : null, + Errors = recordingErrors.Select(error => Sanitize(error).Code).ToArray(), + }); + context.AddResultFile(manifest); + if (available) + { + context.AddResultFile(recordingPath); + } - if (recordingErrors.Count != 0) - { - context.WriteLine("Guest recording did not finalize cleanly; any nonempty MP4 was retained. See the safe Sandbox recording manifest."); - } - }, recordingErrors); + if (recordingErrors.Count != 0) + { + context.WriteLine("Guest recording did not finalize cleanly; any nonempty MP4 was retained. See the safe Sandbox recording manifest."); + } + }, + recordingErrors); recordingFinalized = recorder is null; errors.AddRange(recordingErrors); }