fix(studio): daemon-start probes health first + shows a readable error
Some checks failed
Security / Security check (push) Failing after 2s

'Tap to start' on an already-running hub reported 'daemon could not be
started' (the start failed because it was already up) then flashed the
error away as a SnackBar — confusing next to the sidebar's 'connected'.
Now: on a failed start, probe the hub; if it answers, just connect (no
false error). If it is genuinely down, show the daemon's stderr in a
persistent, copyable dialog (showFaiProcessErrorDialog) instead of a
SnackBar that vanishes before the operator can read or copy it.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-06-18 16:17:26 +02:00
parent 57dac3d999
commit 62ed3951e5
2 changed files with 59 additions and 23 deletions

View file

@ -109,6 +109,27 @@ void showFaiProcessError(
); );
} }
/// Like [showFaiProcessError] but as a persistent modal dialog. Use for
/// failures the operator needs time to read and copy (a daemon that
/// won't start) — a SnackBar flashes away before they can act on it.
Future<void> showFaiProcessErrorDialog(
BuildContext context,
String source,
String stdout,
String stderr, {
String? title,
}) {
final detail = [stderr.trim(), stdout.trim()]
.where((s) => s.isNotEmpty)
.join('\n\n');
return showFaiErrorDialog(
context,
source,
detail.isEmpty ? 'process exited non-zero (no output)' : detail,
title: title,
);
}
/// Show [error] as a centered modal dialog. Use this when the /// Show [error] as a centered modal dialog. Use this when the
/// failure blocks meaningful interaction (auth missing, hub /// failure blocks meaningful interaction (auth missing, hub
/// unreachable) a SnackBar would be too easy to dismiss. /// unreachable) a SnackBar would be too easy to dismiss.

View file

@ -275,20 +275,29 @@ class StudioShellState extends State<StudioShell> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.daemonStartRequested)), SnackBar(content: Text(l.daemonStartRequested)),
); );
} else { Future.delayed(const Duration(seconds: 1), _checkHealth);
// Surface the daemon's stderr/stdout as a copyable error — this return;
// is the message the operator most often needs to paste back.
showFaiProcessError(
context,
'daemon.start',
r.stdout,
r.stderr,
title: l.daemonStartFailed(''),
);
} }
// Probe again shortly so the UI flips to "connected" without // The start command reported failure but very often the daemon is
// waiting for the next 5 s tick. // simply already running (port in use). Probe before crying error,
Future.delayed(const Duration(seconds: 1), _checkHealth); // so "tap to start" on an already-up hub just connects.
final alive =
await HubService.instance.healthy().catchError((_) => false);
if (!mounted) return;
if (alive) {
await _checkHealth();
return;
}
// Genuinely down: a persistent, copyable dialog a SnackBar flashes
// away before the operator can read or copy the daemon's stderr.
await showFaiProcessErrorDialog(
context,
'daemon.start',
r.stdout,
r.stderr,
title: l.daemonStartFailed(''),
);
if (mounted) Future.delayed(const Duration(seconds: 1), _checkHealth);
} }
/// Switch the sidebar's selected page by its registered id /// Switch the sidebar's selected page by its registered id
@ -702,17 +711,23 @@ class _SidebarState extends State<_Sidebar>
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.daemonStartRequested)), SnackBar(content: Text(l.daemonStartRequested)),
); );
} else { return;
// Surface the daemon's stderr/stdout as a copyable error — this
// is the message the operator most often needs to paste back.
showFaiProcessError(
context,
'daemon.start',
r.stdout,
r.stderr,
title: l.daemonStartFailed(''),
);
} }
// The daemon may already be running (port in use) probe before
// showing an error, so this just connects on an already-up hub.
final alive =
await HubService.instance.healthy().catchError((_) => false);
if (!context.mounted) return;
if (alive) return;
// Genuinely down: persistent, copyable dialog (a SnackBar flashes
// away before the operator can read or copy the daemon's stderr).
await showFaiProcessErrorDialog(
context,
'daemon.start',
r.stdout,
r.stderr,
title: l.daemonStartFailed(''),
);
} }
@override @override