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.
This commit is contained in:
Levi Neely 2026-08-05 09:02:51 +02:00
parent d01d7c8ad8
commit 2c7b5e6fc5
2 changed files with 58 additions and 18 deletions

View File

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

View File

@ -5,6 +5,7 @@
#include <QVariantList>
#include <QHash>
#include <QSet>
#include <QTimer>
#if QT_VERSION_MAJOR >= 6
#include <QQmlEngine>
#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";