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,