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
|
|
@ -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. (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<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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue