From 62ed3951e544017a8e43f19347bc39c4c7a07726 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 18 Jun 2026 16:17:26 +0200 Subject: [PATCH] fix(studio): daemon-start probes health first + shows a readable error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '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 --- lib/data/error_presentation.dart | 21 +++++++++++ lib/main.dart | 61 ++++++++++++++++++++------------ 2 files changed, 59 insertions(+), 23 deletions(-) diff --git a/lib/data/error_presentation.dart b/lib/data/error_presentation.dart index 536fc1f..1e7421f 100644 --- a/lib/data/error_presentation.dart +++ b/lib/data/error_presentation.dart @@ -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 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 /// failure blocks meaningful interaction (auth missing, hub /// unreachable) — a SnackBar would be too easy to dismiss. diff --git a/lib/main.dart b/lib/main.dart index fd3791a..350b9f4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -275,20 +275,29 @@ class StudioShellState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l.daemonStartRequested)), ); - } else { - // 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(''), - ); + Future.delayed(const Duration(seconds: 1), _checkHealth); + return; } - // Probe again shortly so the UI flips to "connected" without - // waiting for the next 5 s tick. - Future.delayed(const Duration(seconds: 1), _checkHealth); + // The start command reported failure — but very often the daemon is + // simply already running (port in use). Probe before crying error, + // 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 @@ -702,17 +711,23 @@ class _SidebarState extends State<_Sidebar> ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l.daemonStartRequested)), ); - } else { - // 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(''), - ); + return; } + // 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