From c18bb7f357eced699460091a4670287f1ae84694 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sat, 18 Jul 2026 18:02:03 +0200 Subject: [PATCH] fix(test): kill the a11y suite's pending-timer flake at its root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finally caught with a creation stack trace: when the last gRPC stream closes, Http2ClientConnection arms the channel's 5-minute idleTimeout timer — even on a shut-down connection — so the test's 1-minute drain never covered it and the framework's pending-timer invariant tripped whenever the arm landed inside the test window (frequent while a real hub listens on 50051). The suite now closes the channel in real-async space at the end of the body (new @visibleForTesting HubService.debugResetChannel; shutdown is deliberately not awaited — it wedges on a mid-connect socket, but cancels its timers synchronously) and pumps past the idle timeout so the timer fires inside the test. 6 consecutive full-suite runs + 3 isolated runs green; before, roughly 1 in 3 full runs failed. Signed-off-by: flemming-it --- lib/data/hub.dart | 19 +++++++++++++++++++ test/a11y_test.dart | 13 +++++++++++++ 2 files changed, 32 insertions(+) diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 23112c3..d0dc1cd 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -5,6 +5,7 @@ // Methods return UI-friendly types so pages stay free of // protobuf imports. +import 'dart:async' show unawaited; import 'dart:io'; import 'dart:typed_data'; @@ -196,6 +197,24 @@ class HubService { } } + /// Test hook: close the gRPC channel and replace the client with + /// a fresh, not-yet-dialled one (same endpoint + token). Suites + /// that pump the real app against no hub end with this so the + /// channel's internal reconnect/idle timers cannot outlive the + /// test body — a real socket failure landing late otherwise arms + /// a new backoff timer and trips the framework's pending-timer + /// invariant (the a11y suite's long-standing flake). + @visibleForTesting + void debugResetChannel() { + final old = _client; + _client = HubClient(endpoint: old.endpoint, authToken: _lastAuthToken); + // Deliberately not awaited: shutdown() wedges on a socket that + // is mid-connect, but the channel marks itself shut down and + // cancels its timers synchronously at the start — which is all + // this hook needs. + unawaited(old.close().catchError((_) {})); + } + /// Reload the token from disk and reconnect using the current /// endpoint. Called by Settings after the operator pastes or /// clears a token. diff --git a/test/a11y_test.dart b/test/a11y_test.dart index 3ef853a..981e52b 100644 --- a/test/a11y_test.dart +++ b/test/a11y_test.dart @@ -88,6 +88,19 @@ void main() { // !timersPending after the test body. await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(const Duration(minutes: 1)); + // The suite's long-standing pending-timer flake, finally + // caught with a creation stack: when the last gRPC stream + // closes, Http2ClientConnection._handleActiveStateChanged + // arms the channel's 5-minute idleTimeout timer — even on a + // shut-down connection — so a 1-minute drain never covered + // it. Close the channel in real-async space (lets in-flight + // socket callbacks land), then pump PAST the idle timeout so + // the timer fires inside the test body. + await tester.runAsync(() async { + HubService.instance.debugResetChannel(); + await Future.delayed(const Duration(milliseconds: 100)); + }); + await tester.pump(const Duration(minutes: 6)); expect( violations, isEmpty,