fix(workspace): close the sealed-switch privacy race, restore the parked filter
Two findings from the workspace persona review, both rated high:
* Switch race: the hub client re-pointed at a sealed area's hub
before the workspace announced the sealed context, so the 2 s
page pollers (runs, audit) could fetch and render that hub's
data without the sealed marking. Switches now run inside an
explicit switching window: opened before anything touches the
connection, announced optimistically in the identity bar
("switching…" + spinner, leave button hidden), pollers and the
shell health tick pause inside it, and pages drop replies whose
context epoch changed mid-flight. The sealed context is
announced only after the new hub answered healthy.
* Filter loss: entering a sealed area cleared the shared-hub
project filter and returning restored only the endpoint. The
filter is now parked on entry and restored on return; prefs
keep the parked value throughout, so live state and prefs agree
after the round trip (and after a mid-session relaunch).
Guard: workspace_switch_race_test pins both invariants
state-matrix-style against scripted hub + sealed-area fakes —
reconnects may only happen inside an open switch window, pollers
must stay silent inside it, and the filter must survive the round
trip. SealedAreaService gained a debugSetInstance seam so the
suite never scans a real ~/.chain.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
906290f445
commit
afe782e826
13 changed files with 480 additions and 52 deletions
|
|
@ -19,6 +19,8 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
/// One sealed project instance as Studio needs it for the switcher
|
/// One sealed project instance as Studio needs it for the switcher
|
||||||
/// and the connection switch.
|
/// and the connection switch.
|
||||||
class SealedArea {
|
class SealedArea {
|
||||||
|
|
@ -63,7 +65,21 @@ class SealedArea {
|
||||||
|
|
||||||
class SealedAreaService {
|
class SealedAreaService {
|
||||||
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.
|
/// `~/.chain` root, or null when the home dir can't be determined.
|
||||||
String? _chainHome() {
|
String? _chainHome() {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,28 @@ class Workspace extends ChangeNotifier {
|
||||||
SealedArea? get activeSealed => _activeSealed;
|
SealedArea? get activeSealed => _activeSealed;
|
||||||
bool get inSealedArea => _activeSealed != null;
|
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
|
/// Active project slug for the shared-hub filter; empty = all
|
||||||
/// projects. Meaningful only when [inSealedArea] is false.
|
/// projects. Meaningful only when [inSealedArea] is false.
|
||||||
String _activeSlug = '';
|
String _activeSlug = '';
|
||||||
|
|
@ -119,6 +141,10 @@ class Workspace extends ChangeNotifier {
|
||||||
_activeSlug = active;
|
_activeSlug = active;
|
||||||
_sealed = sealed;
|
_sealed = sealed;
|
||||||
_activeSealed = activeSealed;
|
_activeSealed = activeSealed;
|
||||||
|
_switching = false;
|
||||||
|
_switchTarget = null;
|
||||||
|
_sharedSlug = null;
|
||||||
|
_sharedEndpoint = null;
|
||||||
_loaded = true;
|
_loaded = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
@ -163,53 +189,79 @@ class Workspace extends ChangeNotifier {
|
||||||
// there — capture it before the first sealed switch.
|
// there — capture it before the first sealed switch.
|
||||||
_sharedEndpoint ??= HubService.instance.currentEndpoint;
|
_sharedEndpoint ??= HubService.instance.currentEndpoint;
|
||||||
|
|
||||||
var started = false;
|
// Open the switch window BEFORE anything touches the connection.
|
||||||
// Re-read the current running state (the list may be stale).
|
_beginSwitch(area);
|
||||||
final live = await _reread(area.slug) ?? area;
|
try {
|
||||||
if (!live.running) {
|
var started = false;
|
||||||
final r = await SystemActions.chainProjectStart(area.slug);
|
// Re-read the current running state (the list may be stale).
|
||||||
if (!r.ok) {
|
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<void>.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(
|
return _switchResult(
|
||||||
ok: false,
|
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.
|
_activeSealed = area;
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 800));
|
// 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
|
/// 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<void> switchToShared() async {
|
Future<void> switchToShared() async {
|
||||||
if (_activeSealed == null) return;
|
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<void> _reconnectShared() async {
|
||||||
final shared = _sharedEndpoint;
|
final shared = _sharedEndpoint;
|
||||||
if (shared != null) {
|
if (shared != null) {
|
||||||
await HubService.instance.reconnect(shared, persist: false);
|
await HubService.instance.reconnect(shared, persist: false);
|
||||||
|
|
@ -217,12 +269,30 @@ class Workspace extends ChangeNotifier {
|
||||||
// Fall back to re-discovering the installed channel.
|
// Fall back to re-discovering the installed channel.
|
||||||
await HubService.instance.loadPersistedEndpoint();
|
await HubService.instance.loadPersistedEndpoint();
|
||||||
}
|
}
|
||||||
await refresh();
|
}
|
||||||
|
|
||||||
|
void _beginSwitch(SealedArea? target) {
|
||||||
|
_switching = true;
|
||||||
|
_switchTarget = target;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _endSwitch() {
|
||||||
|
_switching = false;
|
||||||
|
_switchTarget = null;
|
||||||
|
_contextEpoch++;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
HubEndpoint? _sharedEndpoint;
|
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<SealedArea?> _reread(String slug) async {
|
Future<SealedArea?> _reread(String slug) async {
|
||||||
try {
|
try {
|
||||||
final all = await SealedAreaService.instance.list();
|
final all = await SealedAreaService.instance.list();
|
||||||
|
|
|
||||||
|
|
@ -1877,6 +1877,7 @@
|
||||||
"workspaceSealedSwitchFailed": "Wechsel in den abgeschotteten Bereich fehlgeschlagen: {error}",
|
"workspaceSealedSwitchFailed": "Wechsel in den abgeschotteten Bereich fehlgeschlagen: {error}",
|
||||||
"@workspaceSealedSwitchFailed": {"placeholders": {"error": {"type": "String"}}},
|
"@workspaceSealedSwitchFailed": {"placeholders": {"error": {"type": "String"}}},
|
||||||
"sealedIdentityBar": "Abgeschotteter Bereich — isolierter Hub, eigene Daten und Audit-Kette",
|
"sealedIdentityBar": "Abgeschotteter Bereich — isolierter Hub, eigene Daten und Audit-Kette",
|
||||||
|
"sealedSwitchingBar": "Verbindung wird umgeschaltet — Daten erscheinen erst nach Abschluss",
|
||||||
"sealedLeave": "Verlassen",
|
"sealedLeave": "Verlassen",
|
||||||
"setupStepOf": "Schritt {n} von {total}",
|
"setupStepOf": "Schritt {n} von {total}",
|
||||||
"@setupStepOf": {"placeholders": {"n": {"type": "int"}, "total": {"type": "int"}}},
|
"@setupStepOf": {"placeholders": {"n": {"type": "int"}, "total": {"type": "int"}}},
|
||||||
|
|
|
||||||
|
|
@ -1916,6 +1916,7 @@
|
||||||
"workspaceSealedSwitchFailed": "Could not switch to the sealed area: {error}",
|
"workspaceSealedSwitchFailed": "Could not switch to the sealed area: {error}",
|
||||||
"@workspaceSealedSwitchFailed": {"placeholders": {"error": {"type": "String"}}},
|
"@workspaceSealedSwitchFailed": {"placeholders": {"error": {"type": "String"}}},
|
||||||
"sealedIdentityBar": "Sealed area — isolated hub, own data and audit chain",
|
"sealedIdentityBar": "Sealed area — isolated hub, own data and audit chain",
|
||||||
|
"sealedSwitchingBar": "Switching connection — data appears once the switch completes",
|
||||||
"sealedLeave": "Leave",
|
"sealedLeave": "Leave",
|
||||||
"setupStepOf": "Step {n} of {total}",
|
"setupStepOf": "Step {n} of {total}",
|
||||||
"@setupStepOf": {"placeholders": {"n": {"type": "int"}, "total": {"type": "int"}}},
|
"@setupStepOf": {"placeholders": {"n": {"type": "int"}, "total": {"type": "int"}}},
|
||||||
|
|
|
||||||
|
|
@ -5977,6 +5977,12 @@ abstract class AppLocalizations {
|
||||||
/// **'Sealed area — isolated hub, own data and audit chain'**
|
/// **'Sealed area — isolated hub, own data and audit chain'**
|
||||||
String get sealedIdentityBar;
|
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.
|
/// No description provided for @sealedLeave.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -3556,6 +3556,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get sealedIdentityBar =>
|
String get sealedIdentityBar =>
|
||||||
'Abgeschotteter Bereich — isolierter Hub, eigene Daten und Audit-Kette';
|
'Abgeschotteter Bereich — isolierter Hub, eigene Daten und Audit-Kette';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sealedSwitchingBar =>
|
||||||
|
'Verbindung wird umgeschaltet — Daten erscheinen erst nach Abschluss';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get sealedLeave => 'Verlassen';
|
String get sealedLeave => 'Verlassen';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3548,6 +3548,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get sealedIdentityBar =>
|
String get sealedIdentityBar =>
|
||||||
'Sealed area — isolated hub, own data and audit chain';
|
'Sealed area — isolated hub, own data and audit chain';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sealedSwitchingBar =>
|
||||||
|
'Switching connection — data appears once the switch completes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get sealedLeave => 'Leave';
|
String get sealedLeave => 'Leave';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -553,6 +553,10 @@ class StudioShellState extends State<StudioShell> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkHealth({bool retriedAfterTokenReload = false}) async {
|
Future<void> _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
|
final probe = debugProbeOverride != null
|
||||||
? await debugProbeOverride!()
|
? await debugProbeOverride!()
|
||||||
: await HubService.instance.probeHealth();
|
: await HubService.instance.probeHealth();
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,9 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
|
|
||||||
void _refresh() {
|
void _refresh() {
|
||||||
if (!mounted) return;
|
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;
|
final project = Workspace.instance.activeSlug;
|
||||||
setState(() {
|
setState(() {
|
||||||
_pendingFuture = HubService.instance.listApprovalsRecords(
|
_pendingFuture = HubService.instance.listApprovalsRecords(
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,9 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
/// query and the live stream have to be re-established.
|
/// query and the live stream have to be re-established.
|
||||||
void _onWorkspaceChanged() {
|
void _onWorkspaceChanged() {
|
||||||
if (!mounted) return;
|
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();
|
_refresh();
|
||||||
_subscribeLive();
|
_subscribeLive();
|
||||||
}
|
}
|
||||||
|
|
@ -132,7 +135,12 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
// suite caught this as a pending-timer flake).
|
// suite caught this as a pending-timer flake).
|
||||||
_reconnect?.cancel();
|
_reconnect?.cancel();
|
||||||
_reconnect = Timer(const Duration(seconds: 3), () {
|
_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<AuditPage> {
|
||||||
// copy-affordance).
|
// copy-affordance).
|
||||||
|
|
||||||
Future<void> _refresh() async {
|
Future<void> _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 {
|
try {
|
||||||
final events = await HubService.instance.recentEvents(
|
final events = await HubService.instance.recentEvents(
|
||||||
limit: 100,
|
limit: 100,
|
||||||
project: Workspace.instance.activeSlug,
|
project: Workspace.instance.activeSlug,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted || epoch != ws.contextEpoch) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_events = events;
|
_events = events;
|
||||||
_error = null;
|
_error = null;
|
||||||
_initialLoaded = true;
|
_initialLoaded = true;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted || epoch != ws.contextEpoch) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = e;
|
_error = e;
|
||||||
_initialLoaded = true;
|
_initialLoaded = true;
|
||||||
|
|
|
||||||
|
|
@ -226,11 +226,18 @@ class _RunsPageState extends State<RunsPage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refresh() async {
|
Future<void> _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 {
|
try {
|
||||||
final snapshot = await HubService.instance.listDetachedRuns(
|
final snapshot = await HubService.instance.listDetachedRuns(
|
||||||
project: Workspace.instance.activeSlug,
|
project: Workspace.instance.activeSlug,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted || epoch != ws.contextEpoch) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_runs = snapshot.runs;
|
_runs = snapshot.runs;
|
||||||
_detachedEnabled = snapshot.enabled;
|
_detachedEnabled = snapshot.enabled;
|
||||||
|
|
@ -238,7 +245,7 @@ class _RunsPageState extends State<RunsPage> {
|
||||||
_loaded = true;
|
_loaded = true;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted || epoch != ws.contextEpoch) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = e;
|
_error = e;
|
||||||
_issue = classifyRunsLoadError(e);
|
_issue = classifyRunsLoadError(e);
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,15 @@ class ChainSealedIdentityBar extends StatelessWidget {
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: Workspace.instance,
|
listenable: Workspace.instance,
|
||||||
builder: (context, _) {
|
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();
|
if (area == null) return const SizedBox.shrink();
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
|
|
@ -51,7 +59,7 @@ class ChainSealedIdentityBar extends StatelessWidget {
|
||||||
const SizedBox(width: ChainSpace.md),
|
const SizedBox(width: ChainSpace.md),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
l.sealedIdentityBar,
|
switching ? l.sealedSwitchingBar : l.sealedIdentityBar,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: onAccent.withValues(alpha: 0.85),
|
color: onAccent.withValues(alpha: 0.85),
|
||||||
),
|
),
|
||||||
|
|
@ -59,12 +67,22 @@ class ChainSealedIdentityBar extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: ChainSpace.sm),
|
const SizedBox(width: ChainSpace.sm),
|
||||||
TextButton.icon(
|
if (switching)
|
||||||
onPressed: () => Workspace.instance.switchToShared(),
|
SizedBox(
|
||||||
style: TextButton.styleFrom(foregroundColor: onAccent),
|
width: 14,
|
||||||
icon: const Icon(Icons.logout, size: 15),
|
height: 14,
|
||||||
label: Text(l.sealedLeave),
|
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),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
280
test/workspace_switch_race_test.dart
Normal file
280
test/workspace_switch_race_test.dart
Normal file
|
|
@ -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<SealedArea> areas;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<SealedArea>> list() async => areas;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> 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<void>? healthyGate;
|
||||||
|
bool healthyAnswer = true;
|
||||||
|
|
||||||
|
int detachedReads = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> 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<bool> healthy() async {
|
||||||
|
final gate = healthyGate;
|
||||||
|
if (gate != null) await gate.future;
|
||||||
|
return healthyAnswer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<({List<DetachedRun> runs, bool enabled})> listDetachedRuns({
|
||||||
|
String project = '',
|
||||||
|
}) {
|
||||||
|
detachedReads++;
|
||||||
|
return super.listDetachedRuns(project: project);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RecordingHub _install({List<SealedArea> 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<void>();
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue