From 1220e2b21faf2e89c92027312f8d2256cbbe0d97 Mon Sep 17 00:00:00 2001 From: Torrey Betts Date: Sat, 24 Oct 2026 17:42:26 -0400 Subject: [PATCH 1/4] Queue the gated tracing start's answer before releasing the gate StartAsync_ConcurrentOnSameBrowser_SerializesViaGate queued the answer for the second Tracing.start only after awaiting the first StopAsync. The stop releases the browser-wide gate in its finally block, and the second StartAsync resumes from that release on a thread-pool thread and sends Tracing.start straight away. When it won that race the fake socket had nothing queued, the command went unanswered, and the test waited out the transport's 60 second command timeout before failing with a TaskCanceledException, which is what the ubuntu leg of main hit. Both answers are now queued before the stop. The gate itself keeps them in order, since nothing can send Tracing.start until Tracing.end has been answered, and the test now also checks that order on the wire. Signed-off-by: Torrey Betts --- tests/Motus.Tests/Context/TracingTests.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/Motus.Tests/Context/TracingTests.cs b/tests/Motus.Tests/Context/TracingTests.cs index ad6dca4..29d7c68 100644 --- a/tests/Motus.Tests/Context/TracingTests.cs +++ b/tests/Motus.Tests/Context/TracingTests.cs @@ -296,16 +296,27 @@ public async Task StartAsync_ConcurrentOnSameBrowser_SerializesViaGate() await Task.Delay(50); Assert.IsFalse(secondStartTask.IsCompleted, "Second StartAsync should be gated until the first stops."); - // Now stop the first and let the second proceed. + // Now stop the first and let the second proceed. The second start's answer is queued + // here, before the stop, because the stop releases the gate on its way out and the + // second start sends Tracing.start from that moment on another thread. Queued after + // awaiting the stop, the answer could arrive after the command it was meant for, which + // then waits out the transport's command timeout. The gate is what keeps the two + // answers in order: nothing can send Tracing.start until Tracing.end has been answered. _socket.QueueResponse("""{"id":2,"result":{}}"""); // first end + _socket.QueueResponse("""{"id":3,"result":{}}"""); // second start, once the gate opens var firstStopTask = first.StopAsync(); await Task.Delay(50); _socket.Enqueue("""{"method":"Tracing.tracingComplete","params":{"dataLossOccurred":false}}"""); await firstStopTask; - _socket.QueueResponse("""{"id":3,"result":{}}"""); // second start (now unblocked) await secondStartTask; + var sent = _socket.SentMessages.Select(bytes => System.Text.Encoding.UTF8.GetString(bytes)).ToList(); + var firstEnd = sent.FindIndex(json => json.Contains("\"Tracing.end\"")); + var secondStart = sent.FindLastIndex(json => json.Contains("\"Tracing.start\"")); + Assert.IsTrue(firstEnd >= 0 && secondStart > firstEnd, + "The second Tracing.start went out before the first trace had ended."); + // Cleanly stop the second so the gate is released for any teardown. _socket.QueueResponse("""{"id":4,"result":{}}"""); var secondStopTask = second.StopAsync(); From 6fc26f0bc9374492aecc5cc2a0bf76685803fc7a Mon Sep 17 00:00:00 2001 From: Torrey Betts Date: Sat, 24 Oct 2026 17:42:26 -0400 Subject: [PATCH 2/4] Measure the Firefox no-scroll case from a whole-pixel position Firefox_ScrollIntoViewScript_ScrollsOnlyWhenNeeded read window.scrollY straight after the script had centered the button, then asserted it was unchanged after a second call. Centering puts the button's middle at half the viewport height, so whenever that height is odd the scroll lands on a half pixel (4677.5 locally with a 683px viewport; 4678.5 on the runner, which read 4679 and then 4678). Firefox may round that half pixel some time after the scroll, so the position moved by a pixel with nothing scrolling it. The baseline is now a whole-pixel position the test scrolls to itself, with the button in view but off center. That removes the half pixel, and it also lets the assertion catch a script that scrolls when it should not: re-centering an element that is already centered never moved the page, so the old check could not have seen it. Signed-off-by: Torrey Betts --- .../Browser/FirefoxIntegrationTests.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/Motus.Tests/Browser/FirefoxIntegrationTests.cs b/tests/Motus.Tests/Browser/FirefoxIntegrationTests.cs index 73aabee..d6622bd 100644 --- a/tests/Motus.Tests/Browser/FirefoxIntegrationTests.cs +++ b/tests/Motus.Tests/Browser/FirefoxIntegrationTests.cs @@ -209,7 +209,20 @@ await page.SetContentAsync( Assert.IsTrue(await page.EvaluateAsync(Run("b")), "The script did not scroll to an element below the fold."); Assert.IsTrue(await page.EvaluateAsync(InView), "The element is still outside the viewport."); - var scrollY = await page.EvaluateAsync("window.scrollY"); + // Centering lands on a half pixel whenever the viewport's height is odd, and Firefox can + // round a half-pixel scroll position to a whole one after the scroll has finished, so a + // position read straight after centering can move by a pixel with nothing scrolling it. + // The element is put at a whole-pixel position off center instead: still in view, so the + // script must leave it alone, and far enough from center that a script that scrolled + // anyway would visibly move it, which re-centering an already centered element would not. + var scrollY = await page.EvaluateAsync(""" + (() => { + const top = document.getElementById('b').getBoundingClientRect().top + window.scrollY; + window.scrollTo({ top: Math.round(top) - 100, behavior: 'instant' }); + return window.scrollY; + })() + """); + Assert.IsTrue(await page.EvaluateAsync(InView), "The element left the viewport when it was moved off center."); Assert.IsFalse(await page.EvaluateAsync(Run("b")), "The script scrolled an element that was already in view."); Assert.AreEqual(scrollY, await page.EvaluateAsync("window.scrollY")); From 29c07574e525347554fffeb4436690d4c3996b6a Mon Sep 17 00:00:00 2001 From: Torrey Betts Date: Sat, 24 Oct 2026 17:42:26 -0400 Subject: [PATCH 3/4] Temporary: time the first local request on Windows Signed-off-by: Torrey Betts --- .../Browser/ConnectDiagnosticsTests.cs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/Motus.Tests/Browser/ConnectDiagnosticsTests.cs b/tests/Motus.Tests/Browser/ConnectDiagnosticsTests.cs index 7cc7116..f930ee3 100644 --- a/tests/Motus.Tests/Browser/ConnectDiagnosticsTests.cs +++ b/tests/Motus.Tests/Browser/ConnectDiagnosticsTests.cs @@ -22,6 +22,63 @@ public class ConnectDiagnosticsTests private static readonly TimeSpan PollBudget = TimeSpan.FromMilliseconds(400); + // TEMPORARY DIAGNOSTIC: measures where a cold Windows test host spends its time on the first + // request to a local endpoint. Removed before the PR is ready. + [TestMethod] + public async Task A0_TemporaryWindowsTimingProbe() + { + var report = new System.Text.StringBuilder(); + var sw = System.Diagnostics.Stopwatch.StartNew(); + void Mark(string what) { report.Append($"{what}={sw.ElapsedMilliseconds}ms; "); sw.Restart(); } + + var endpoint = new StallingCdpEndpoint(completeHandshake: false, offersWebSocketUrl: false); + Mark("ctor"); + var url = new Uri(endpoint.HttpEndpoint + "/json/version"); + + var proxy = System.Net.Http.HttpClient.DefaultProxy; + Mark($"defaultProxy({proxy.GetType().Name})"); + var p1 = proxy.GetProxy(url); + Mark($"getProxy1({p1?.ToString() ?? "null"})"); + var b1 = proxy.IsBypassed(url); + Mark($"isBypassed({b1})"); + proxy.GetProxy(url); + Mark("getProxy2"); + + using (var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }) + { + for (var i = 0; i < 4; i++) + { + try { await client.GetStringAsync(url); Mark($"get{i}"); } + catch (Exception ex) { Mark($"get{i}-{ex.GetType().Name}"); } + } + } + + using (var client = new HttpClient(new SocketsHttpHandler { UseProxy = false }) { Timeout = TimeSpan.FromSeconds(10) }) + { + for (var i = 0; i < 2; i++) + { + try { await client.GetStringAsync(url); Mark($"noProxyGet{i}"); } + catch (Exception ex) { Mark($"noProxyGet{i}-{ex.GetType().Name}"); } + } + } + + using (var other = new StallingCdpEndpoint(completeHandshake: false, offersWebSocketUrl: false)) + { + Mark("ctor2"); + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + try { await client.GetStringAsync(new Uri(other.HttpEndpoint + "/json/version")); Mark("ctor2get0"); } + catch (Exception ex) { Mark($"ctor2get0-{ex.GetType().Name}"); } + } + Mark("dispose2"); + + endpoint.Dispose(); + Mark("dispose"); + + if (OperatingSystem.IsWindows()) + Assert.Fail("TIMING " + report); + Console.WriteLine("TIMING " + report); + } + [TestMethod] public async Task WaitForEndpoint_SaysWhyTheLastAttemptFailed() { From 2c589832e785bcc30a9617a71f14fa01b7c61fcb Mon Sep 17 00:00:00 2001 From: Torrey Betts Date: Sat, 24 Oct 2026 17:49:04 -0400 Subject: [PATCH 4/4] Remove the temporary Windows timing probe The probe showed that on the Windows runner the default proxy answers loopback lookups at once and a cold test host's first request to a local HttpListener takes tens of milliseconds, so neither explains the intermittent connect-timeout failure. It has done its job. Signed-off-by: Torrey Betts --- .../Browser/ConnectDiagnosticsTests.cs | 57 ------------------- 1 file changed, 57 deletions(-) diff --git a/tests/Motus.Tests/Browser/ConnectDiagnosticsTests.cs b/tests/Motus.Tests/Browser/ConnectDiagnosticsTests.cs index f930ee3..7cc7116 100644 --- a/tests/Motus.Tests/Browser/ConnectDiagnosticsTests.cs +++ b/tests/Motus.Tests/Browser/ConnectDiagnosticsTests.cs @@ -22,63 +22,6 @@ public class ConnectDiagnosticsTests private static readonly TimeSpan PollBudget = TimeSpan.FromMilliseconds(400); - // TEMPORARY DIAGNOSTIC: measures where a cold Windows test host spends its time on the first - // request to a local endpoint. Removed before the PR is ready. - [TestMethod] - public async Task A0_TemporaryWindowsTimingProbe() - { - var report = new System.Text.StringBuilder(); - var sw = System.Diagnostics.Stopwatch.StartNew(); - void Mark(string what) { report.Append($"{what}={sw.ElapsedMilliseconds}ms; "); sw.Restart(); } - - var endpoint = new StallingCdpEndpoint(completeHandshake: false, offersWebSocketUrl: false); - Mark("ctor"); - var url = new Uri(endpoint.HttpEndpoint + "/json/version"); - - var proxy = System.Net.Http.HttpClient.DefaultProxy; - Mark($"defaultProxy({proxy.GetType().Name})"); - var p1 = proxy.GetProxy(url); - Mark($"getProxy1({p1?.ToString() ?? "null"})"); - var b1 = proxy.IsBypassed(url); - Mark($"isBypassed({b1})"); - proxy.GetProxy(url); - Mark("getProxy2"); - - using (var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }) - { - for (var i = 0; i < 4; i++) - { - try { await client.GetStringAsync(url); Mark($"get{i}"); } - catch (Exception ex) { Mark($"get{i}-{ex.GetType().Name}"); } - } - } - - using (var client = new HttpClient(new SocketsHttpHandler { UseProxy = false }) { Timeout = TimeSpan.FromSeconds(10) }) - { - for (var i = 0; i < 2; i++) - { - try { await client.GetStringAsync(url); Mark($"noProxyGet{i}"); } - catch (Exception ex) { Mark($"noProxyGet{i}-{ex.GetType().Name}"); } - } - } - - using (var other = new StallingCdpEndpoint(completeHandshake: false, offersWebSocketUrl: false)) - { - Mark("ctor2"); - using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; - try { await client.GetStringAsync(new Uri(other.HttpEndpoint + "/json/version")); Mark("ctor2get0"); } - catch (Exception ex) { Mark($"ctor2get0-{ex.GetType().Name}"); } - } - Mark("dispose2"); - - endpoint.Dispose(); - Mark("dispose"); - - if (OperatingSystem.IsWindows()) - Assert.Fail("TIMING " + report); - Console.WriteLine("TIMING " + report); - } - [TestMethod] public async Task WaitForEndpoint_SaysWhyTheLastAttemptFailed() {