Something went wrong. Try again.
The agentic engineering control plane for the posthuman future
Something went wrong. Try again.
30 kB · 792 lines
C++
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793// DaemonLink.cpp — the UI's control-plane link to xenomorphicd.//// Implements exactly the declarations in DaemonLink.h; the wire contract is// normative in notes/daemon-contract.md. QtCore + QtNetwork + QtWebSockets// only (no Quick includes allowed in support/).#include "DaemonLink.h"
#include "XenoPaths.h"
#include <QAbstractSocket>#include <QByteArray>#include <QDir>#include <QElapsedTimer>#include <QEventLoop>#include <QFile>#include <QFileInfo>#include <QHash>#include <QIODevice>#include <QJsonArray>#include <QJsonDocument>#include <QJsonObject>#include <QJsonParseError>#include <QJsonValue>#include <QNetworkRequest>#include <QProcess>#include <QProcessEnvironment>#include <QString>#include <QtGlobal>#include <QTimer>#include <QUrl>#include <QWebSocket>#include <QWebSocketProtocol>
#include <cerrno>#include <csignal>// kill(pid, 0) liveness probe (POSIX, not in csignal).#include <unistd.h>#include <utility>
namespace {
// Bounded waits, so a dead or slow daemon degrades instead of hanging the// GUI thread that called the blocking entry points.constexpr int kSpawnDiscoveryTimeoutMs = 10000;constexpr int kSpawnPollIntervalMs = 100;constexpr int kHandshakeTimeoutMs = 8000;
[[nodiscard]] bool pidAlive(qint64 pid) { if (pid <= 0) { return false; } // kill(pid, 0) does not signal; it only probes existence. EPERM means the // process exists but is owned by someone else — still alive. const auto target = static_cast<pid_t>(pid); if (::kill(target, 0) == 0) { return true; } return errno == EPERM;}
// Fold a successful `spawn` reply into the pane cache so a freshly spawned// pane is immediately visible to pane()/paneAlive(). Runs synchronously in// the reply path: a hash write only, no sockets, no waits.//// Field conventions match applyListResult(): the spawn result reports `seq`// (the seq the pane's NEXT record gets), so a fresh log — headSeq 0 — yields// tailSeq = seq. `foregroundPid` and `title` are not in the spawn reply and// stay at their defaults until a `list`, `cwd`, or `title` reports them.void applySpawnResult(QHash<QString, DaemonLink::PaneSnapshot>& panes, const QJsonObject& spec, const QJsonObject& result) { const QString paneId = result.value(QStringLiteral("paneId")).toString(); if (paneId.isEmpty()) { return; } DaemonLink::PaneSnapshot snapshot; snapshot.pid = static_cast<qint64>(result.value(QStringLiteral("pid")).toDouble(0)); snapshot.shellPath = result.value(QStringLiteral("shellPath")).toString(); snapshot.cwd = spec.value(QStringLiteral("cwd")).toString(); snapshot.alive = true; snapshot.headSeq = 0; snapshot.tailSeq = static_cast<quint64>( result.value(QStringLiteral("seq")).toDouble(0)); panes.insert(paneId, std::move(snapshot));}
} // namespace
DaemonLink::DaemonLink(QObject* parent) : QObject(parent), m_spawnPollTimer(new QTimer(this)), m_handshakeTimer(new QTimer(this)) { m_socket = new QWebSocket(QStringLiteral("xenomorphic"), QWebSocketProtocol::VersionLatest, this); connect(m_socket, &QWebSocket::connected, this, &DaemonLink::onConnected); connect(m_socket, &QWebSocket::disconnected, this, &DaemonLink::onDisconnected); connect(m_socket, &QWebSocket::textMessageReceived, this, &DaemonLink::onTextMessage); connect(m_socket, &QWebSocket::errorOccurred, this, &DaemonLink::onErrorOccurred);
// Connect watchdogs (see connectAsync). Both are single-shot and are // stopped by setStatus() the moment the link leaves Connecting, so a // completed or failed connect never leaves a stale timer armed. m_spawnPollTimer->setSingleShot(true); connect(m_spawnPollTimer, &QTimer::timeout, this, &DaemonLink::pollSpawn);
m_handshakeTimer->setSingleShot(true); connect(m_handshakeTimer, &QTimer::timeout, this, [this]() { if (m_status == Status::Connecting) { failConnect(QStringLiteral("daemon handshake did not complete within %1 ms") .arg(kHandshakeTimeoutMs)); } });}
DaemonLink::~DaemonLink() { // QWebSocket is a child object; Qt tears it down. Do not invoke user // callbacks during destruction: disconnect-time cleanup (onDisconnected) // already fired them, and anything left here has no safe receiver. m_pending.clear();}
void DaemonLink::setStatus(Status status) { if (m_status == status) { return; } m_status = status; if (status != Status::Connecting) { // The watchdogs only guard the Connecting window. Ready and Unavailable // both end it, so neither timer may stay armed into the next connect. m_spawnPollTimer->stop(); m_handshakeTimer->stop(); } emit statusChanged(status);}
bool DaemonLink::discover(QString* errorOut) { // Read daemon.json and validate it against a LIVE daemon. A stale file // (dead pid after a crash) is the normal post-crash case, never fatal. QFile file(xeno::paths::daemonJsonPath()); if (!file.open(QIODevice::ReadOnly)) { if (errorOut != nullptr) { *errorOut = QStringLiteral("no daemon discovery file at %1") .arg(xeno::paths::daemonJsonPath()); } return false; } QJsonParseError parseError{}; const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseError); file.close(); if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { if (errorOut != nullptr) { *errorOut = QStringLiteral("daemon.json is not valid JSON: %1") .arg(parseError.errorString()); } return false; } const QJsonObject obj = doc.object();
const qint64 version = static_cast<qint64>(obj.value("version").toDouble(-1)); if (version != 1) { if (errorOut != nullptr) { *errorOut = QStringLiteral("daemon.json has unsupported version %1").arg(version); } return false; }
const double portValue = obj.value("port").toDouble(0); if (portValue < 1 || portValue > 65535) { if (errorOut != nullptr) { *errorOut = QStringLiteral("daemon.json has invalid port"); } return false; }
const QString token = obj.value("token").toString(); if (token.isEmpty()) { if (errorOut != nullptr) { *errorOut = QStringLiteral("daemon.json has no token"); } return false; }
const qint64 pid = static_cast<qint64>(obj.value("pid").toDouble(0)); if (!pidAlive(pid)) { if (errorOut != nullptr) { *errorOut = QStringLiteral("daemon.json points at dead pid %1").arg(pid); } return false; }
m_port = static_cast<quint16>(portValue); m_token = token; m_boot = obj.value("boot").toString(); return true;}
bool DaemonLink::startDaemon(QString* errorOut) { const QString exe = xeno::paths::daemonExecutable(); if (exe.isEmpty()) { // Clear error naming the resolution order XenoPaths documents. if (errorOut != nullptr) { *errorOut = QStringLiteral( "cannot resolve the xenomorphicd executable (tried " "$XENO_DAEMON_PATH, a sibling of the running executable, " "then PATH); the daemon cannot be spawned"); } return false; }
QProcess process; process.setProgram(exe);
// Forward PERF_DEBUG into the child: the pty (and its // Pty::stampPerfChunk() probe) now lives in the daemon, so a PERF_DEBUG set // only on the app would silently zero the latency probe after the split. QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); const QByteArray perfDebug = qgetenv("PERF_DEBUG"); if (!perfDebug.isEmpty()) { env.insert(QStringLiteral("PERF_DEBUG"), QString::fromLocal8Bit(perfDebug)); } process.setProcessEnvironment(env);
// Daemon output goes to the log file, never inherited from the app. // // The config dir must exist BEFORE this: setStandardOutputFile with a path // whose directory is missing makes startDetached() fail, and this runs on // the very first launch (connectDaemon() is called from TerminalPage.qml // ahead of AppModel.startup(), which is what would otherwise mkpath via // SessionStore). The daemon creates the dir for its own lock/discovery files // (DaemonCore::claimLock), but that is too late — the redirect is set up by // the parent, before the child runs. Without this a genuine first run spawns // no daemon and every pane comes up empty. const QString logPath = xeno::paths::daemonLogPath(); if (!QDir().mkpath(QFileInfo(logPath).absolutePath())) { if (errorOut != nullptr) { *errorOut = QStringLiteral("cannot create %1 for the daemon log") .arg(QFileInfo(logPath).absolutePath()); } return false; } process.setStandardOutputFile(logPath, QIODevice::Append); process.setStandardErrorFile(logPath, QIODevice::Append);
// startDetached() puts the child in its own session (the setsid() of the // old recipe) and returns immediately. Do NOT setsid again here. The wait // for daemon.json lives in connectAsync()'s poll, never here: this runs on // paths that may sit under a QML signal handler. qint64 pid = 0; if (!process.startDetached(&pid)) { if (errorOut != nullptr) { *errorOut = QStringLiteral("failed to spawn %1").arg(exe); } return false; } return true;}
void DaemonLink::connectAsync() { // Idempotent: Ready means there is nothing to do, and Connecting means a // connect (with its watchdogs) is already in flight. if (m_status == Status::Ready || m_status == Status::Connecting) { return; } setStatus(Status::Connecting);
// Discovery is synchronous and cheap: a file read plus a kill(pid, 0) // probe. Only the waits need to be asynchronous. QString error; if (discover(&error)) { openControlSocket(); return; } if (!startDaemon(&error)) { failConnect(error); return; } // Wait for the daemon to publish daemon.json: it only appears after the // daemon's listen() succeeds (and after it won the single-instance lock), // so a successful read means a usable port/token pair. m_spawnElapsed.start(); m_spawnPollTimer->start(kSpawnPollIntervalMs);}
void DaemonLink::pollSpawn() { QString error; if (discover(&error)) { openControlSocket(); return; } if (m_spawnElapsed.hasExpired(kSpawnDiscoveryTimeoutMs)) { failConnect( QStringLiteral("spawned daemon did not publish %1 within %2 ms " "(last discovery error: %3)") .arg(xeno::paths::daemonJsonPath()) .arg(kSpawnDiscoveryTimeoutMs) .arg(error)); return; } m_spawnPollTimer->start(kSpawnPollIntervalMs);}
void DaemonLink::openControlSocket() { // /ctl with the token in the handshake header, never in-band. QNetworkRequest controlRequest(QUrl(QStringLiteral("ws://127.0.0.1:%1/ctl") .arg(m_port))); controlRequest.setRawHeader("X-Xeno-Token", m_token.toUtf8()); m_socket->abort(); m_socket->open(controlRequest);
// Bounded wait for connected() -> onConnected() -> hello -> list -> Ready. // onConnected() drives the handshake non-blocking, so the watchdog only // has to fail the connect if the daemon never answers. m_handshakeTimer->start(kHandshakeTimeoutMs);}
void DaemonLink::failConnect(const QString& message) { qWarning() << "DaemonLink:" << message; setStatus(Status::Unavailable); if (m_socket != nullptr) { m_socket->abort(); }}
void DaemonLink::onConnected() { // hello MUST be the first message on /ctl (contract). Sent non-blocking // here: requestSync() would spin a nested event loop from inside this // socket's own connected() delivery, which is exactly the reentry that // wedges a link. The callback chain sends `list`, then populates the cache // and flips to Ready. QJsonObject hello; hello.insert(QStringLiteral("op"), QStringLiteral("hello")); hello.insert(QStringLiteral("client"), QStringLiteral("desktop")); request(hello, [this](const QJsonObject& result, const QString& error) { if (!error.isEmpty()) { qWarning() << "DaemonLink: hello failed:" << error; setStatus(Status::Unavailable); if (m_socket != nullptr) { m_socket->close(); } return; } // `boot` is DIAGNOSTIC ONLY: a mismatch warns but never errors — the // reattach decision is driven by `list` membership, not by boot. const QString daemonBoot = result.value(QStringLiteral("boot")).toString(); if (!daemonBoot.isEmpty() && !m_boot.isEmpty() && daemonBoot != m_boot) { qWarning() << "DaemonLink: daemon boot stamp changed" << m_boot << "->" << daemonBoot; } if (!daemonBoot.isEmpty()) { m_boot = daemonBoot; } // The daemon's own pid, cached for the restart orchestration's // kill(pid, 0) liveness probe (daemonProcessAlive, polled on a timer by // PanePool::restartDaemon). m_daemonPid = static_cast<qint64>(result.value(QStringLiteral("pid")).toDouble(0));
QJsonObject list; list.insert(QStringLiteral("op"), QStringLiteral("list")); request(list, [this](const QJsonObject& listResult, const QString& listError) { if (!listError.isEmpty()) { qWarning() << "DaemonLink: initial list failed:" << listError; setStatus(Status::Unavailable); if (m_socket != nullptr) { m_socket->close(); } return; } applyListResult(listResult); setStatus(Status::Ready); }); });}
void DaemonLink::onDisconnected() { const quint16 code = (m_socket != nullptr) ? static_cast<quint16>(m_socket->closeCode()) : static_cast<quint16>(QWebSocketProtocol::CloseCodeGoingAway); const QString reason = (m_socket != nullptr) ? m_socket->closeReason() : QString();
// No callback can leak across a disconnect. failAllPending(QStringLiteral("daemon connection closed (%1)").arg(code));
// Drop the per-connection diagnostics, including the pid: the daemon is // gone, so any later probe must not test a stale pid. PanePool's restart // orchestration tolerates this (daemonProcessAlive treats "unknown pid" as // already gone). m_ctlClients = -1; m_daemonPid = 0;
switch (code) { case static_cast<quint16>(QWebSocketProtocol::CloseCodePolicyViolated): // 1008: bad/missing token. Never retry in a tight loop — surface // unavailable; a later connectAsync() may attempt the spawn path. qWarning() << "DaemonLink: daemon rejected authentication (close 1008," " reason:" << reason << ")"; setStatus(Status::Unavailable); break; case static_cast<quint16>(QWebSocketProtocol::CloseCodeBadOperation): // 1011: protocol violation on the daemon side. This is a bug. qWarning() << "DaemonLink: daemon protocol violation (close 1011," " reason:" << reason << ") — this is a bug"; setStatus(Status::Unavailable); break; case static_cast<quint16>(QWebSocketProtocol::CloseCodeGoingAway): // 1001: daemon shutting down. Do NOT auto-respawn from here. qWarning() << "DaemonLink: daemon is shutting down (close 1001)"; setStatus(Status::Unavailable); break; default: // Abnormal close or transport error: daemon dead. The spawn path in // connectAsync() is allowed to bring one up. qWarning() << "DaemonLink: daemon connection lost (close" << code << ")" << reason; setStatus(Status::Unavailable); break; }}
void DaemonLink::onTextMessage(const QString& message) { QJsonParseError parseError{}; const QJsonDocument doc = QJsonDocument::fromJson(message.toUtf8(), &parseError); if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { qWarning() << "DaemonLink: dropping non-JSON control frame:" << parseError.errorString(); return; } const QJsonObject obj = doc.object(); if (obj.contains(QStringLiteral("id"))) { handleResponse(obj); } else { handleNotification(obj); }}
void DaemonLink::onErrorOccurred() { // Transport errors surface through onDisconnected()'s close-code handling; // this only keeps the warning trail. RemoteHostClosed is a normal daemon // death, not noise, but the disconnected() handler classifies it. if (m_socket != nullptr && m_socket->error() != QAbstractSocket::RemoteHostClosedError && m_socket->error() != QAbstractSocket::SslHandshakeFailedError) { qWarning() << "DaemonLink: socket error:" << m_socket->errorString(); }}
void DaemonLink::handleResponse(const QJsonObject& message) { const quint64 id = static_cast<quint64>( message.value(QStringLiteral("id")).toDouble(0)); auto it = m_pending.find(id); if (it == m_pending.end()) { return; // Late reply to an already-timed-out sync request: ignore. } auto pending = m_pending.take(id); // Exactly-once: removed before it runs.
const QString op = message.value(QStringLiteral("op")).toString(); if (!pending.done) { return; // Fire-and-forget (resize/kill): reply matched, nothing to invoke. } if (op == QLatin1String("err")) { const QString error = message.value(QStringLiteral("error")).toString(); pending.done(QJsonObject{}, error.isEmpty() ? QStringLiteral("unknown daemon error") : error); return; } pending.done(message.value(QStringLiteral("result")).toObject(), QString());}
void DaemonLink::handleNotification(const QJsonObject& message) { const QString op = message.value(QStringLiteral("op")).toString(); if (op == QLatin1String("exit")) { const QString paneId = message.value(QStringLiteral("paneId")).toString(); const int code = message.value(QStringLiteral("code")).toInt(); const quint64 seq = static_cast<quint64>( message.value(QStringLiteral("seq")).toDouble(0)); auto it = m_panes.find(paneId); if (it != m_panes.end()) { it->alive = false; } emit paneExited(paneId, code, seq); return; } if (op == QLatin1String("cwd")) { const QString paneId = message.value(QStringLiteral("paneId")).toString(); const QString cwd = message.value(QStringLiteral("cwd")).toString(); auto it = m_panes.find(paneId); if (it != m_panes.end()) { it->cwd = cwd; } emit paneCwdChanged(paneId, cwd); return; } if (op == QLatin1String("title")) { const QString paneId = message.value(QStringLiteral("paneId")).toString(); const QString title = message.value(QStringLiteral("title")).toString(); auto it = m_panes.find(paneId); if (it != m_panes.end()) { it->title = title; } emit paneTitleChanged(paneId, title); return; } if (op == QLatin1String("daemonDown")) { const QString reason = message.value(QStringLiteral("reason")).toString(); // Announced shutdown. Clients must NOT auto-respawn: surface it and let // the user (or an explicit connectAsync) decide. emit daemonDown(reason); return; } qWarning() << "DaemonLink: ignoring unknown notification op" << op;}
void DaemonLink::failAllPending(const QString& reason) { // Fire every outstanding callback exactly once, then drop them. Called on // disconnect and on destruction, so no caller of request() ever waits // forever. const auto pendingList = m_pending.values(); m_pending.clear(); for (const Pending& pending : pendingList) { pending.done(QJsonObject{}, reason); }}
void DaemonLink::applyListResult(const QJsonObject& result) { // Control-client count for the daemon settings page (-1 stays "unknown" // when an older daemon omits the field). m_ctlClients = result.value(QStringLiteral("ctlClients")).toInt(-1); const QJsonValue panesValue = result.value(QStringLiteral("panes")); if (!panesValue.isArray()) { qWarning() << "DaemonLink: list reply has no panes array"; return; } QHash<QString, PaneSnapshot> panes; const QJsonArray array = panesValue.toArray(); panes.reserve(array.size()); for (const auto& entryValue : array) { if (!entryValue.isObject()) { continue; } const QJsonObject entry = entryValue.toObject(); PaneSnapshot snapshot; snapshot.pid = static_cast<qint64>(entry.value(QStringLiteral("pid")).toDouble(0)); snapshot.foregroundPid = static_cast<qint64>( entry.value(QStringLiteral("foregroundPid")).toDouble(0)); snapshot.cwd = entry.value(QStringLiteral("cwd")).toString(); snapshot.shellPath = entry.value(QStringLiteral("shellPath")).toString(); snapshot.title = entry.value(QStringLiteral("title")).toString(); snapshot.alive = entry.value(QStringLiteral("alive")).toBool(false); snapshot.headSeq = static_cast<quint64>( entry.value(QStringLiteral("headSeq")).toDouble(0)); snapshot.tailSeq = static_cast<quint64>( entry.value(QStringLiteral("tailSeq")).toDouble(0)); panes.insert(entry.value(QStringLiteral("paneId")).toString(), snapshot); } m_panes = std::move(panes);}
quint64 DaemonLink::request( const QJsonObject& body, std::function<void(const QJsonObject&, const QString&)> done) { // Usable means: the /ctl socket is actually open. This covers the // handshake's own hello/list (socket open, status still Connecting) and // answers the not-ready case (default-constructed link, disconnected // link) with 0 and no callback. if (m_socket == nullptr || m_socket->state() != QAbstractSocket::ConnectedState) { return 0; }
const quint64 id = m_nextRequestId++; QJsonObject message = body; message.insert(QStringLiteral("id"), static_cast<double>(id)); const QString op = body.value(QStringLiteral("op")).toString(); if (!done) { // Fire-and-forget (e.g. resize(), kill()): send the message but track the // id with a null callback so the reply is matched and erased instead of // accumulating in m_pending forever. Nothing is invoked on completion; // 0 still means not-ready (the header contract is unchanged). m_pending.insert(id, Pending{nullptr}); m_socket->sendTextMessage(QString::fromUtf8( QJsonDocument(message).toJson(QJsonDocument::Compact))); return id; } // Track the originating op without touching the header's Pending: wrap the // caller's callback so a successful `spawn` reply folds into the pane cache // before the caller sees it. const QJsonObject spec = body.value(QStringLiteral("spec")).toObject(); m_pending.insert(id, Pending{[this, op, spec, done = std::move(done)]( const QJsonObject& result, const QString& error) { if (error.isEmpty() && op == QLatin1String("spawn")) { applySpawnResult(m_panes, spec, result); } done(result, error); }}); m_socket->sendTextMessage( QString::fromUtf8(QJsonDocument(message).toJson(QJsonDocument::Compact))); return id;}
// REENTRANCY NOTE (read before touching): requestSync() spins a nested event// loop, so OTHER socket events are delivered while it waits — including// frames from this very link. It is therefore legal ONLY from paths this// class's own handlers cannot re-enter: the quit-time batched `list`// (refreshPanes), which runs outside any QML handler. Never call it from// onTextMessage / onConnected / onDisconnected, and never from the pane-spawn// path — PtyClient drives spawns through the non-blocking request().bool DaemonLink::requestSync(const QJsonObject& body, QJsonObject* result, QString* errorOut, int timeoutMs) { if (errorOut != nullptr) { errorOut->clear(); } if (m_socket == nullptr || m_socket->state() != QAbstractSocket::ConnectedState) { if (errorOut != nullptr) { *errorOut = QStringLiteral("daemon link is not connected"); } return false; } if (m_syncWaiting) { // Single wait slot: a nested requestSync would corrupt the waiter state. if (errorOut != nullptr) { *errorOut = QStringLiteral("another requestSync is already in flight"); } return false; }
const quint64 id = m_nextRequestId++; QJsonObject message = body; message.insert(QStringLiteral("id"), static_cast<double>(id));
// Only the waiter matching m_syncRequestId fills the sync slot, so a reply // wakes exactly this request rather than any other pending one. m_syncRequestId = id; m_syncResult = QJsonObject{}; m_syncError.clear(); m_syncOk = false; m_syncWaiting = true;
const QString op = body.value(QStringLiteral("op")).toString(); const QJsonObject spec = body.value(QStringLiteral("spec")).toObject(); m_pending.insert(id, Pending{[this, id, op, spec](const QJsonObject& reply, const QString& error) { if (id != m_syncRequestId) { return; // Stale reply for an abandoned sync slot. } if (error.isEmpty() && op == QLatin1String("spawn")) { applySpawnResult(m_panes, spec, reply); } m_syncResult = reply; m_syncError = error; m_syncOk = error.isEmpty(); m_syncWaiting = false; }}); m_socket->sendTextMessage(QString::fromUtf8( QJsonDocument(message).toJson(QJsonDocument::Compact)));
// The reply lands in the done-callback above via the normal socket // delivery; a small poll timer notices it and exits the loop. Polling // keeps the loop's lifetime purely local (no QEventLoop pointer reaches // the header) and cannot miss: the slot flip happens before delivery // returns to the loop. QEventLoop loop; QTimer pollTimer; pollTimer.setInterval(5); QTimer timeoutTimer; timeoutTimer.setSingleShot(true); bool timedOut = false; QObject::connect(&pollTimer, &QTimer::timeout, &loop, [&loop, this]() { if (!m_syncWaiting) { loop.exit(); } }); QObject::connect(&timeoutTimer, &QTimer::timeout, &loop, [&]() { timedOut = true; loop.exit(); }); pollTimer.start(); timeoutTimer.start(timeoutMs); loop.exec(); pollTimer.stop(); timeoutTimer.stop(); m_syncWaiting = false;
// The reply may have raced the timeout; either way the pending entry must // not leak. failAllPending() covers the disconnect case. m_pending.remove(id); m_syncRequestId = 0;
if (timedOut || !m_syncOk) { if (errorOut != nullptr) { *errorOut = timedOut ? QStringLiteral("daemon request timed out") : m_syncError; } // result is left untouched on failure, per the header contract. return false; } if (result != nullptr) { *result = m_syncResult; } return true;}
const DaemonLink::PaneSnapshot* DaemonLink::pane(const QString& paneId) const { // Returned pointer points into m_panes; the caller must not retain it // across a refreshPanes() (or any `list` apply), which rebuilds the hash. const auto it = m_panes.constFind(paneId); return it == m_panes.constEnd() ? nullptr : &it.value();}
bool DaemonLink::paneAlive(const QString& paneId) const { const PaneSnapshot* snapshot = pane(paneId); return snapshot != nullptr && snapshot->alive;}
bool DaemonLink::refreshPanes(int timeoutMs) { // The quit-time batched query: blocking and bounded, so a slow or dead // daemon degrades to "no restart data" instead of hanging the app's exit. QJsonObject list; list.insert(QStringLiteral("op"), QStringLiteral("list")); QJsonObject result; return requestSync(list, &result, nullptr, timeoutMs) && (applyListResult(result), true);}
void DaemonLink::refreshPanesAsync(const std::function<void(bool ok)>& done) { // Non-blocking `list` for the GUI thread: fire the request, fold the reply // into the cache, and report the outcome. request() returns 0 (and never // invokes `done`) when not usable, so mirror that with an immediate // done(false) — the caller (PanePool::refreshDaemonStats) emits // statsChanged from its callback either way. QJsonObject list; list.insert(QStringLiteral("op"), QStringLiteral("list")); const quint64 id = request(list, [this, done](const QJsonObject& result, const QString& error) { if (!error.isEmpty()) { if (done) { done(false); } return; } applyListResult(result); if (done) { done(true); } }); if (id == 0 && done) { done(false); }}
quint64 DaemonLink::requestShutdown() { // Fire-and-forget (null-done shape, same as resize/kill): the daemon // replies ok, then announces daemonDown("shutdown-requested") and closes // with 1001 — onDisconnected() lands in Unavailable, which is the correct // post-shutdown posture. No auto-respawn here; PanePool::restartDaemon // owns bringing a fresh daemon up. QJsonObject shutdown; shutdown.insert(QStringLiteral("op"), QStringLiteral("shutdown")); return request(shutdown, {});}
bool DaemonLink::daemonProcessAlive() const { // kill(pid, 0) asks only "does the process exist"; it delivers no signal. // pidAlive() also counts EPERM as alive (the process exists, owned by // someone else). An unknown pid reads as gone: there is nothing to wait for. return pidAlive(m_daemonPid);}
int DaemonLink::alivePaneCount() const { int alive = 0; for (const PaneSnapshot& snapshot : m_panes) { if (snapshot.alive) { ++alive; } } return alive;}