fix(test): kill the a11y suite's pending-timer flake at its root

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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-18 18:02:03 +02:00
parent 383490027b
commit c18bb7f357
2 changed files with 32 additions and 0 deletions

View file

@ -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.

View file

@ -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<void>.delayed(const Duration(milliseconds: 100));
});
await tester.pump(const Duration(minutes: 6));
expect(
violations,
isEmpty,