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:
flemming-it 2026-08-27 23:48:03 +02:00
parent 906290f445
commit afe782e826
13 changed files with 480 additions and 52 deletions

View file

@ -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() {

View file

@ -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<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(
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<void>.delayed(const Duration(milliseconds: 800));
_activeSealed = area;
// Entering from the shared hub: park the filter so the round
// trip restores it. (Areaarea 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<void> 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<void> _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<SealedArea?> _reread(String slug) async {
try {
final all = await SealedAreaService.instance.list();

View file

@ -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"}}},

View file

@ -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"}}},

View file

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

View file

@ -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';

View file

@ -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';

View file

@ -553,6 +553,10 @@ class StudioShellState extends State<StudioShell> {
}
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
? await debugProbeOverride!()
: await HubService.instance.probeHealth();

View file

@ -104,6 +104,9 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
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(

View file

@ -109,6 +109,9 @@ class _AuditPageState extends State<AuditPage> {
/// 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<AuditPage> {
// 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<AuditPage> {
// copy-affordance).
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 {
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;

View file

@ -226,11 +226,18 @@ class _RunsPageState extends State<RunsPage> {
}
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 {
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<RunsPage> {
_loaded = true;
});
} catch (e) {
if (!mounted) return;
if (!mounted || epoch != ws.contextEpoch) return;
setState(() {
_error = e;
_issue = classifyRunsLoadError(e);

View file

@ -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),
),
],
),
),