From 2c7b5e6fc54b0eb78244650e6049a7b5e77a38b9 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 5 Aug 2026 09:02:51 +0200 Subject: [PATCH] gui: add heartbeat-based daemon disconnect detection - Add 2.5 second heartbeat timer using Lib9pClient to read 'help' - Heartbeat detects daemon death reliably (vs unreliable process exit codes) - On disconnect: clear sessions, stop streams, reset active selection - On reconnect: refresh sessions, restart agent streams Replaces flaky NinePConnection::disconnected signal which depended on eventwait process exit behavior. --- gui/ollie9pclient.cpp | 71 ++++++++++++++++++++++++++++++++----------- gui/ollie9pclient.h | 5 +++ 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/gui/ollie9pclient.cpp b/gui/ollie9pclient.cpp index 84b1194..7b5d454 100644 --- a/gui/ollie9pclient.cpp +++ b/gui/ollie9pclient.cpp @@ -47,10 +47,10 @@ Ollie9pClient::Ollie9pClient(QObject *parent) }); // State stream — guarded, reads agent statewait. Auto-restarts until - // session/agent is deselected (guard returns false). + // session/agent is deselected or daemon disconnects (guard returns false). m_state = new StreamFsm(StreamFsm::Guarded, this); m_state->setGuard([this]() { - return !m_activeSessionId.isEmpty() && !m_agentId.isEmpty(); + return m_daemonConnected && !m_activeSessionId.isEmpty() && !m_agentId.isEmpty(); }); connect(m_state, &StreamFsm::readyRead, this, [this](const QByteArray &data) { QString state = QString::fromUtf8(data).trimmed(); @@ -60,12 +60,10 @@ Ollie9pClient::Ollie9pClient(QObject *parent) } }); - // Event stream — connection-aware daemon sentinel. The operation reports - // connected only after the 9P open has completed, then retries slowly - // after the daemon closes or rejects the operation. + // Event stream — reads eventwait for server events. + // Note: NinePConnection handles retry/reconnect but we use heartbeat for disconnect detection. m_daemon = new NinePConnection(this); connect(m_daemon, &NinePConnection::connected, this, [this]() { - setDaemonConnected(true); // Initialize native 9P client for fast operations if (!m_9p) { m_9p = new Lib9pClient(this); @@ -73,23 +71,19 @@ Ollie9pClient::Ollie9pClient(QObject *parent) if (!m_9p->isConnected()) { m_9p->connectDefault(); } - refreshSessions(); - ensureRootDataLoaded(); - // Restart streams for the active agent if we have one - if (!m_activeSessionId.isEmpty() && !m_agentId.isEmpty()) { - startActiveAgentStreams(); - } - }); - connect(m_daemon, &NinePConnection::disconnected, this, [this]() { - setDaemonConnected(false); - if (m_9p) { - m_9p->disconnect(); - } + // Heartbeat will set daemonConnected on first successful ping + heartbeat(); }); connect(m_daemon, &NinePConnection::readyRead, this, [this](const QByteArray &data) { handleEvent(QString::fromUtf8(data).trimmed()); }); + // Heartbeat timer — detect daemon death by periodic reads + m_heartbeatTimer = new QTimer(this); + m_heartbeatTimer->setInterval(2500); // 2.5 seconds + connect(m_heartbeatTimer, &QTimer::timeout, this, &Ollie9pClient::heartbeat); + m_heartbeatTimer->start(); + refreshSessions(); ensureRootDataLoaded(); m_daemon->start(ollie9pBin(), {"-a", serverAddr(), "read", "--open-marker", "eventwait"}); @@ -97,6 +91,7 @@ Ollie9pClient::Ollie9pClient(QObject *parent) Ollie9pClient::~Ollie9pClient() { + if (m_heartbeatTimer) m_heartbeatTimer->stop(); stopAgentStreams(); if (m_daemon) m_daemon->stop(); } @@ -136,6 +131,45 @@ void Ollie9pClient::setDaemonConnected(bool connected) emit sessionsChanged(); } +void Ollie9pClient::heartbeat() +{ + // Try to read a cheap file to verify daemon is alive + if (!m_9p) { + m_9p = new Lib9pClient(this); + } + if (!m_9p->isConnected()) { + m_9p->connectDefault(); + } + + QByteArray result = m_9p->read(QStringLiteral("help")); + bool alive = !result.isEmpty(); + + if (alive && !m_daemonConnected) { + // Daemon came up — refresh state + setDaemonConnected(true); + refreshSessions(); + ensureRootDataLoaded(); + if (!m_activeSessionId.isEmpty() && !m_agentId.isEmpty()) { + startActiveAgentStreams(); + } + } else if (!alive && m_daemonConnected) { + // Daemon died — clear state + setDaemonConnected(false); + stopAgentStreams(); + m_sessions.clear(); + m_activeSessionId.clear(); + m_agentId.clear(); + m_activeState = "idle"; + emit sessionsChanged(); + emit activeSessionIdChanged(); + emit activeAgentIdChanged(); + emit activeStateChanged(); + if (m_9p) { + m_9p->disconnect(); + } + } +} + QString Ollie9pClient::sessionConnectionColor(const QString &sessionId) const { for (const QVariant &v : m_sessions) { @@ -755,6 +789,7 @@ void Ollie9pClient::ensureRootDataLoaded() void Ollie9pClient::startActiveAgentStreams() { + if (!m_daemonConnected) return; if (m_activeSessionId.isEmpty() || m_agentId.isEmpty()) return; const QString srv = serverAddr(); diff --git a/gui/ollie9pclient.h b/gui/ollie9pclient.h index 24e79c6..6cf4c51 100644 --- a/gui/ollie9pclient.h +++ b/gui/ollie9pclient.h @@ -5,6 +5,7 @@ #include #include #include +#include #if QT_VERSION_MAJOR >= 6 #include #endif @@ -110,10 +111,14 @@ private: void setDaemonConnected(bool connected); void startActiveAgentStreams(); void handleEvent(const QString &eventLine); + void heartbeat(); // Native 9P client (persistent connection, no subprocess overhead) Lib9pClient *m_9p = nullptr; + // Heartbeat timer — detects daemon death + QTimer *m_heartbeatTimer = nullptr; + QVariantList m_sessions; QString m_activeSessionId; QString m_activeState = "idle";