diff --git a/lib/data/sealed_areas.dart b/lib/data/sealed_areas.dart index cfc0d38..9a07f31 100644 --- a/lib/data/sealed_areas.dart +++ b/lib/data/sealed_areas.dart @@ -19,6 +19,8 @@ import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; + /// One sealed project instance as Studio needs it for the switcher /// and the connection switch. class SealedArea { @@ -63,7 +65,21 @@ class SealedArea { class SealedAreaService { SealedAreaService._(); - static final SealedAreaService instance = SealedAreaService._(); + static SealedAreaService instance = SealedAreaService._(); + + /// Test hook (HubService.debugSetInstance pattern): swap in a fake + /// so suites never scan the operator's real `~/.chain`. Pass null + /// to restore the real service. + @visibleForTesting + static void debugSetInstance(SealedAreaService? replacement) { + instance = replacement ?? SealedAreaService._(); + } + + /// Extension seam for the test fake — the real constructor is + /// library-private, and the fake must not inherit a live + /// filesystem scan by accident, so it overrides the members. + @visibleForTesting + SealedAreaService.forTest(); /// `~/.chain` root, or null when the home dir can't be determined. String? _chainHome() { diff --git a/lib/data/workspace.dart b/lib/data/workspace.dart index a726808..4ba608a 100644 --- a/lib/data/workspace.dart +++ b/lib/data/workspace.dart @@ -46,6 +46,28 @@ class Workspace extends ChangeNotifier { SealedArea? get activeSealed => _activeSealed; bool get inSealedArea => _activeSealed != null; + /// True while a sealed-area connection switch (either direction) + /// is in flight. Set BEFORE the hub client re-points and cleared + /// only after the new context is fully announced, so pages pause + /// their pollers and the identity bar can mark the transition — + /// otherwise a 2 s poll could render the other hub's data under + /// the old context's marking (privacy race). + bool _switching = false; + bool get switching => _switching; + + /// The area being entered while [switching]; null when the switch + /// returns to the shared hub. Lets the identity bar announce the + /// target optimistically ("switching to X…"). + SealedArea? _switchTarget; + SealedArea? get switchTarget => _switchTarget; + + /// Monotonic counter bumped each time a connection switch + /// completes. Pages capture it before an async fetch and drop the + /// reply when it changed — a response from the previous hub must + /// never render under the new context's marking. + int _contextEpoch = 0; + int get contextEpoch => _contextEpoch; + /// Active project slug for the shared-hub filter; empty = all /// projects. Meaningful only when [inSealedArea] is false. String _activeSlug = ''; @@ -119,6 +141,10 @@ class Workspace extends ChangeNotifier { _activeSlug = active; _sealed = sealed; _activeSealed = activeSealed; + _switching = false; + _switchTarget = null; + _sharedSlug = null; + _sharedEndpoint = null; _loaded = true; notifyListeners(); } @@ -163,53 +189,79 @@ class Workspace extends ChangeNotifier { // there — capture it before the first sealed switch. _sharedEndpoint ??= HubService.instance.currentEndpoint; - var started = false; - // Re-read the current running state (the list may be stale). - final live = await _reread(area.slug) ?? area; - if (!live.running) { - final r = await SystemActions.chainProjectStart(area.slug); - if (!r.ok) { + // Open the switch window BEFORE anything touches the connection. + _beginSwitch(area); + try { + var started = false; + // Re-read the current running state (the list may be stale). + final live = await _reread(area.slug) ?? area; + if (!live.running) { + final r = await SystemActions.chainProjectStart(area.slug); + if (!r.ok) { + return _switchResult( + ok: false, + error: r.stderr.isEmpty ? r.stdout : r.stderr, + ); + } + started = true; + // Give the daemon a moment to bind + write its endpoint file. + await Future.delayed(const Duration(milliseconds: 800)); + } + + // Prefer the endpoint the daemon actually wrote; fall back to + // the manifest port. + final ep = await SealedAreaService.instance.boundEndpoint(area.slug); + final endpoint = _parseEndpoint(ep) ?? + HubEndpoint(host: '127.0.0.1', port: area.port); + + await HubService.instance.reconnect(endpoint, persist: false); + final healthy = await HubService.instance.healthy(); + if (!healthy) { + // Roll back to the shared hub so Studio isn't stranded. The + // sealed context was never announced, so only the endpoint + // needs restoring — filter and marking are untouched. + await _reconnectShared(); return _switchResult( ok: false, - error: r.stderr.isEmpty ? r.stdout : r.stderr, + started: started, + error: 'Sealed area "${area.name}" did not respond on ' + '${endpoint.host}:${endpoint.port} after start.', ); } - started = true; - // Give the daemon a moment to bind + write its endpoint file. - await Future.delayed(const Duration(milliseconds: 800)); + + _activeSealed = area; + // Entering from the shared hub: park the filter so the round + // trip restores it. (Area→area keeps the first park.) + _sharedSlug ??= _activeSlug; + _activeSlug = ''; // the shared-hub filter does not apply here + await refresh(); + return _switchResult(started: started); + } finally { + _endSwitch(); } - - // Prefer the endpoint the daemon actually wrote; fall back to - // the manifest port. - final ep = await SealedAreaService.instance.boundEndpoint(area.slug); - final endpoint = _parseEndpoint(ep) ?? - HubEndpoint(host: '127.0.0.1', port: area.port); - - await HubService.instance.reconnect(endpoint, persist: false); - final healthy = await HubService.instance.healthy(); - if (!healthy) { - // Roll back to the shared hub so Studio isn't stranded. - await switchToShared(); - return _switchResult( - ok: false, - started: started, - error: 'Sealed area "${area.name}" did not respond on ' - '${endpoint.host}:${endpoint.port} after start.', - ); - } - - _activeSealed = area; - _activeSlug = ''; // the shared-hub filter does not apply here - await refresh(); - notifyListeners(); - return _switchResult(started: started); } /// Return to the shared hub from a sealed area. No-op when already - /// on the shared hub. + /// on the shared hub. Restores the project filter that was active + /// before the sealed round trip — it was parked, not dropped. Future switchToShared() async { if (_activeSealed == null) return; - _activeSealed = null; + _beginSwitch(null); + try { + await _reconnectShared(); + _activeSealed = null; + _activeSlug = _sharedSlug ?? ''; + _sharedSlug = null; + await refresh(); + } finally { + _endSwitch(); + } + } + + /// Point the hub client back at the shared hub (endpoint only — + /// no context bookkeeping). Shared by [switchToShared] and the + /// failed-switch rollback. + Future _reconnectShared() async { final shared = _sharedEndpoint; if (shared != null) { await HubService.instance.reconnect(shared, persist: false); @@ -217,12 +269,30 @@ class Workspace extends ChangeNotifier { // Fall back to re-discovering the installed channel. await HubService.instance.loadPersistedEndpoint(); } - await refresh(); + } + + void _beginSwitch(SealedArea? target) { + _switching = true; + _switchTarget = target; + notifyListeners(); + } + + void _endSwitch() { + _switching = false; + _switchTarget = null; + _contextEpoch++; notifyListeners(); } HubEndpoint? _sharedEndpoint; + /// The shared-hub project filter parked during a sealed session, + /// or null when none is parked. Prefs keep the parked value (they + /// always describe the SHARED hub's filter), so live state and + /// prefs agree again after the round trip — and a launch after a + /// kill inside a sealed area still restores the filter. + String? _sharedSlug; + Future _reread(String slug) async { try { final all = await SealedAreaService.instance.list(); diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e40fab3..c0b5d6f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1877,6 +1877,7 @@ "workspaceSealedSwitchFailed": "Wechsel in den abgeschotteten Bereich fehlgeschlagen: {error}", "@workspaceSealedSwitchFailed": {"placeholders": {"error": {"type": "String"}}}, "sealedIdentityBar": "Abgeschotteter Bereich — isolierter Hub, eigene Daten und Audit-Kette", + "sealedSwitchingBar": "Verbindung wird umgeschaltet — Daten erscheinen erst nach Abschluss", "sealedLeave": "Verlassen", "setupStepOf": "Schritt {n} von {total}", "@setupStepOf": {"placeholders": {"n": {"type": "int"}, "total": {"type": "int"}}}, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 51012a2..7376af6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1916,6 +1916,7 @@ "workspaceSealedSwitchFailed": "Could not switch to the sealed area: {error}", "@workspaceSealedSwitchFailed": {"placeholders": {"error": {"type": "String"}}}, "sealedIdentityBar": "Sealed area — isolated hub, own data and audit chain", + "sealedSwitchingBar": "Switching connection — data appears once the switch completes", "sealedLeave": "Leave", "setupStepOf": "Step {n} of {total}", "@setupStepOf": {"placeholders": {"n": {"type": "int"}, "total": {"type": "int"}}}, diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 241658b..453356a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5977,6 +5977,12 @@ abstract class AppLocalizations { /// **'Sealed area — isolated hub, own data and audit chain'** String get sealedIdentityBar; + /// No description provided for @sealedSwitchingBar. + /// + /// In en, this message translates to: + /// **'Switching connection — data appears once the switch completes'** + String get sealedSwitchingBar; + /// No description provided for @sealedLeave. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index a074c42..e4f14ec 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3556,6 +3556,10 @@ class AppLocalizationsDe extends AppLocalizations { String get sealedIdentityBar => 'Abgeschotteter Bereich — isolierter Hub, eigene Daten und Audit-Kette'; + @override + String get sealedSwitchingBar => + 'Verbindung wird umgeschaltet — Daten erscheinen erst nach Abschluss'; + @override String get sealedLeave => 'Verlassen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index dd54266..5e22e89 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3548,6 +3548,10 @@ class AppLocalizationsEn extends AppLocalizations { String get sealedIdentityBar => 'Sealed area — isolated hub, own data and audit chain'; + @override + String get sealedSwitchingBar => + 'Switching connection — data appears once the switch completes'; + @override String get sealedLeave => 'Leave'; diff --git a/lib/main.dart b/lib/main.dart index 5e1d1b7..9342523 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -553,6 +553,10 @@ class StudioShellState extends State { } Future _checkHealth({bool retriedAfterTokenReload = false}) async { + // Skip the tick while a sealed-area switch is in flight — the + // client points between contexts and a probe result (or the + // approvals badge) would be attributed to the wrong hub. + if (Workspace.instance.switching) return; final probe = debugProbeOverride != null ? await debugProbeOverride!() : await HubService.instance.probeHealth(); diff --git a/lib/pages/approvals.dart b/lib/pages/approvals.dart index 7b5e28f..ca7da0d 100644 --- a/lib/pages/approvals.dart +++ b/lib/pages/approvals.dart @@ -104,6 +104,9 @@ class _ApprovalsPageState extends State { void _refresh() { if (!mounted) return; + // Mid-switch the client points between contexts — hold off; the + // end-of-switch notify lands here again with a settled client. + if (Workspace.instance.switching) return; final project = Workspace.instance.activeSlug; setState(() { _pendingFuture = HubService.instance.listApprovalsRecords( diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index 1dd0012..b84a35a 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -109,6 +109,9 @@ class _AuditPageState extends State { /// query and the live stream have to be re-established. void _onWorkspaceChanged() { if (!mounted) return; + // Mid-switch the client points between contexts — hold off; the + // end-of-switch notify lands here again with a settled client. + if (Workspace.instance.switching) return; _refresh(); _subscribeLive(); } @@ -132,7 +135,12 @@ class _AuditPageState extends State { // suite caught this as a pending-timer flake). _reconnect?.cancel(); _reconnect = Timer(const Duration(seconds: 3), () { - if (mounted && _eventSub == null) _subscribeLive(); + // Not mid-switch: the end-of-switch notify resubscribes. + if (mounted && + _eventSub == null && + !Workspace.instance.switching) { + _subscribeLive(); + } }); } }, @@ -262,19 +270,25 @@ class _AuditPageState extends State { // copy-affordance). Future _refresh() async { + final ws = Workspace.instance; + // Paused during a sealed-area switch: the hub client may already + // point at the other hub while this page still renders the old + // context — fetching now would show data under the wrong marking. + if (ws.switching) return; + final epoch = ws.contextEpoch; try { final events = await HubService.instance.recentEvents( limit: 100, project: Workspace.instance.activeSlug, ); - if (!mounted) return; + if (!mounted || epoch != ws.contextEpoch) return; setState(() { _events = events; _error = null; _initialLoaded = true; }); } catch (e) { - if (!mounted) return; + if (!mounted || epoch != ws.contextEpoch) return; setState(() { _error = e; _initialLoaded = true; diff --git a/lib/pages/runs.dart b/lib/pages/runs.dart index d841ad2..1961972 100644 --- a/lib/pages/runs.dart +++ b/lib/pages/runs.dart @@ -226,11 +226,18 @@ class _RunsPageState extends State { } Future _refresh() async { + final ws = Workspace.instance; + // Paused during a sealed-area switch: the hub client may already + // point at the other hub while this page still renders the old + // context — fetching now would show data under the wrong marking. + // The end-of-switch notify re-runs this listener with fresh data. + if (ws.switching) return; + final epoch = ws.contextEpoch; try { final snapshot = await HubService.instance.listDetachedRuns( project: Workspace.instance.activeSlug, ); - if (!mounted) return; + if (!mounted || epoch != ws.contextEpoch) return; setState(() { _runs = snapshot.runs; _detachedEnabled = snapshot.enabled; @@ -238,7 +245,7 @@ class _RunsPageState extends State { _loaded = true; }); } catch (e) { - if (!mounted) return; + if (!mounted || epoch != ws.contextEpoch) return; setState(() { _error = e; _issue = classifyRunsLoadError(e); diff --git a/lib/widgets/chain_sealed_identity_bar.dart b/lib/widgets/chain_sealed_identity_bar.dart index 75f9d59..61acab5 100644 --- a/lib/widgets/chain_sealed_identity_bar.dart +++ b/lib/widgets/chain_sealed_identity_bar.dart @@ -22,7 +22,15 @@ class ChainSealedIdentityBar extends StatelessWidget { return ListenableBuilder( listenable: Workspace.instance, builder: (context, _) { - final area = Workspace.instance.activeSealed; + final ws = Workspace.instance; + // While a switch is in flight the bar announces it BEFORE the + // client re-points (privacy race guard): entering shows the + // target area, leaving keeps the current area's marking — + // whatever is still on screen is that area's data. + final switching = ws.switching; + final area = switching + ? (ws.switchTarget ?? ws.activeSealed) + : ws.activeSealed; if (area == null) return const SizedBox.shrink(); final theme = Theme.of(context); final l = AppLocalizations.of(context)!; @@ -51,7 +59,7 @@ class ChainSealedIdentityBar extends StatelessWidget { const SizedBox(width: ChainSpace.md), Expanded( child: Text( - l.sealedIdentityBar, + switching ? l.sealedSwitchingBar : l.sealedIdentityBar, style: theme.textTheme.bodySmall?.copyWith( color: onAccent.withValues(alpha: 0.85), ), @@ -59,12 +67,22 @@ class ChainSealedIdentityBar extends StatelessWidget { ), ), const SizedBox(width: ChainSpace.sm), - TextButton.icon( - onPressed: () => Workspace.instance.switchToShared(), - style: TextButton.styleFrom(foregroundColor: onAccent), - icon: const Icon(Icons.logout, size: 15), - label: Text(l.sealedLeave), - ), + if (switching) + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: onAccent, + ), + ) + else + TextButton.icon( + onPressed: () => Workspace.instance.switchToShared(), + style: TextButton.styleFrom(foregroundColor: onAccent), + icon: const Icon(Icons.logout, size: 15), + label: Text(l.sealedLeave), + ), ], ), ), diff --git a/test/workspace_switch_race_test.dart b/test/workspace_switch_race_test.dart new file mode 100644 index 0000000..f583607 --- /dev/null +++ b/test/workspace_switch_race_test.dart @@ -0,0 +1,280 @@ +// Wächter for the sealed-switch privacy race (persona review +// 2026-08-27, HOCH): the hub client must never point at a different +// hub than the context the UI announces. The bug class: Studio's +// client re-pointed at a sealed hub BEFORE the workspace announced +// the sealed context, so a 2 s page poller could fetch and render +// confidential data without the sealed marking. +// +// Invariants pinned here: +// 1. reconnect() happens only inside an open switch window +// (Workspace.switching == true), and the sealed context is +// announced only AFTER the connection settled — never before, +// never skipped. +// 2. While the window is open, the polling pages issue no reads — +// sealed data can never render unmarked (RunsPage as the +// poll-pattern representative; Audit uses the same guard). +// 3. The shared-hub project filter survives a sealed round trip +// (park + restore — the second HOCH bug), and prefs agree with +// the live state afterwards. +// 4. A failed switch rolls back to the shared hub without ever +// announcing the sealed context and closes the window. +// +// Hermetic: scripted fakes for HubService AND SealedAreaService — +// the suite never scans the operator's real ~/.chain. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:chain_client_sdk/chain_client_sdk.dart' show HubEndpoint; +import 'package:chain_studio/data/hub.dart'; +import 'package:chain_studio/data/sealed_areas.dart'; +import 'package:chain_studio/data/workspace.dart'; +import 'package:chain_studio/l10n/app_localizations.dart'; +import 'package:chain_studio/pages/runs.dart'; + +import 'support/fake_hub.dart'; + +const _areaA = SealedArea( + slug: 'area-a', + name: 'Area A', + color: '#e0a458', + port: 51001, + running: true, +); + +const _areaB = SealedArea( + slug: 'area-b', + name: 'Area B', + color: '#7cb47c', + port: 51002, + running: true, +); + +class FakeSealedAreas extends SealedAreaService { + FakeSealedAreas(this.areas) : super.forTest(); + + List areas; + + @override + Future> list() async => areas; + + @override + Future boundEndpoint(String slug) async => null; +} + +class RecordingHub extends FakeHubService { + /// Every reconnect with the workspace state AT CALL TIME — the + /// race guard reads exactly these two flags. + final List<({int port, bool switchingAtCall, bool sealedAnnounced})> + reconnects = []; + + /// When set, healthy() blocks until completed — holds the switch + /// window open so the poller-pause test can tick timers inside it. + Completer? healthyGate; + bool healthyAnswer = true; + + int detachedReads = 0; + + @override + Future reconnect( + HubEndpoint endpoint, { + Object? authToken = const Object(), + bool persist = true, + }) async { + reconnects.add(( + port: endpoint.port, + switchingAtCall: Workspace.instance.switching, + sealedAnnounced: Workspace.instance.inSealedArea, + )); + } + + @override + Future healthy() async { + final gate = healthyGate; + if (gate != null) await gate.future; + return healthyAnswer; + } + + @override + Future<({List runs, bool enabled})> listDetachedRuns({ + String project = '', + }) { + detachedReads++; + return super.listDetachedRuns(project: project); + } +} + +RecordingHub _install({List areas = const [_areaA, _areaB]}) { + SharedPreferences.setMockInitialValues({}); + final hub = RecordingHub(); + HubService.debugSetInstance(hub); + SealedAreaService.debugSetInstance(FakeSealedAreas(areas)); + addTearDown(() { + HubService.debugSetInstance(null); + SealedAreaService.debugSetInstance(null); + Workspace.instance.debugSeed(projects: const [], active: ''); + }); + Workspace.instance.debugSeed( + projects: const [], + active: '', + sealed: areas, + ); + return hub; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('the switch window opens before the client re-points and the ' + 'sealed context is announced only after it settled', () async { + final hub = _install(); + final ws = Workspace.instance; + + final r = await ws.switchToSealed(_areaA); + + expect(r.ok, isTrue); + expect(hub.reconnects, hasLength(1)); + final call = hub.reconnects.single; + expect(call.port, _areaA.port); + expect( + call.switchingAtCall, + isTrue, + reason: 'the client re-pointed OUTSIDE a switch window — pollers ' + 'were not paused and could render sealed data unmarked', + ); + expect( + call.sealedAnnounced, + isFalse, + reason: 'the sealed context must be announced only after the ' + 'connection settled, never before reconnect', + ); + expect(ws.switching, isFalse); + expect(ws.inSealedArea, isTrue); + expect(ws.activeSealed, _areaA); + }); + + test('leaving a sealed area also runs inside a switch window and ' + 'keeps the sealed marking until the connection settled', () async { + final hub = _install(); + final ws = Workspace.instance; + await ws.switchToSealed(_areaA); + hub.reconnects.clear(); + + await ws.switchToShared(); + + expect(hub.reconnects, hasLength(1)); + final call = hub.reconnects.single; + expect(call.switchingAtCall, isTrue); + expect( + call.sealedAnnounced, + isTrue, + reason: 'while returning, the data still on screen is the sealed ' + "area's — its marking must not drop before the switch settled", + ); + expect(ws.switching, isFalse); + expect(ws.inSealedArea, isFalse); + }); + + test('the project filter survives a sealed round trip, live and in ' + 'prefs', () async { + _install(); + final ws = Workspace.instance; + await ws.setActive('proj-a'); + + await ws.switchToSealed(_areaA); + expect(ws.activeSlug, isEmpty, + reason: 'the shared-hub filter does not apply inside an area'); + + await ws.switchToShared(); + expect(ws.activeSlug, 'proj-a'); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('workspace.active'), 'proj-a', + reason: 'live state and prefs must agree after the round trip'); + }); + + test('area-to-area switching keeps the originally parked filter', + () async { + _install(); + final ws = Workspace.instance; + await ws.setActive('proj-a'); + + await ws.switchToSealed(_areaA); + await ws.switchToSealed(_areaB); + await ws.switchToShared(); + + expect(ws.activeSlug, 'proj-a'); + }); + + test('a failed switch rolls back without announcing the sealed ' + 'context and closes the window', () async { + final hub = _install(); + hub.healthyAnswer = false; + final ws = Workspace.instance; + await ws.setActive('proj-a'); + hub.reconnects.clear(); + + final r = await ws.switchToSealed(_areaA); + + expect(r.ok, isFalse); + expect(r.error, contains(_areaA.name)); + expect(ws.inSealedArea, isFalse); + expect(ws.switching, isFalse); + expect(ws.activeSlug, 'proj-a', reason: 'filter untouched'); + // Attempt at the area, then the rollback to the shared hub — + // and the sealed context was never announced in between. + expect(hub.reconnects, hasLength(2)); + expect(hub.reconnects.last.port, isNot(_areaA.port)); + expect(hub.reconnects.map((c) => c.sealedAnnounced), everyElement(isFalse)); + }); + + testWidgets('the runs poller is paused while the switch window is ' + 'open and resumes on the settled context', (tester) async { + final hub = _install(); + final ws = Workspace.instance; + + await tester.pumpWidget( + const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: Locale('de'), + home: RunsPage(), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + final beforeWindow = hub.detachedReads; + expect(beforeWindow, greaterThan(0), + reason: 'sanity: the page polls at all'); + + // Hold the switch in flight and tick the 2 s poller inside it. + hub.healthyGate = Completer(); + final switching = ws.switchToSealed(_areaA); + await tester.pump(const Duration(milliseconds: 10)); + final inWindow = hub.detachedReads; + await tester.pump(const Duration(seconds: 2)); + await tester.pump(const Duration(seconds: 2)); + expect( + hub.detachedReads, + inWindow, + reason: 'a poll fired inside the switch window — it could have ' + 'rendered the sealed hub\'s data without the sealed marking', + ); + + hub.healthyGate!.complete(); + await switching; + await tester.pump(const Duration(milliseconds: 100)); + expect(ws.inSealedArea, isTrue); + expect( + hub.detachedReads, + greaterThan(inWindow), + reason: 'the end-of-switch notify must refresh the page against ' + 'the settled context', + ); + + // Tear down the page so its periodic timer dies with it. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(minutes: 1)); + }); +}