#include "PanePool.h" #include #include #include #include #include #include #include #include #include "AppModel.h" #include "DaemonLink.h" #include "EditorController.h" #include "ProcessScanner.h" #include "TerminalQuickItem.h" PanePool* PanePool::s_instance = nullptr; namespace { // Restart orchestration (PanePool::restartDaemon): the old daemon process // gets this long to disappear after the shutdown request before the // successor spawn is attempted anyway, and the exit probe runs this often. constexpr int kRestartExitTimeoutMs = 3000; constexpr int kRestartPollIntervalMs = 100; } // namespace PanePool::PanePool(QObject* parent) : QObject(parent), m_restartPollTimer(new QTimer(this)), m_daemon(new DaemonLink(this)) { s_instance = this; // A newly created pane must attach its data connection promptly. // refreshStreaming() is idempotent (setStreaming no-ops when the state // already matches), so firing it per pane creation is cheap. This is a // pool self-connection: it must NOT depend on setParkHost having run // (connectDaemon() runs BEFORE AppModel.startup creates any pane, so // without this the attach trigger is whatever model signal happens to // fire afterwards). connect(this, &PanePool::paneCreated, this, &PanePool::refreshStreaming); // The stats page tracks the link's status too: every transition // (connecting/ready/unavailable) invalidates daemonStats(). Ready is also // when queued work proceeds, and Unavailable clears a stuck restart guard. connect(m_daemon, &DaemonLink::statusChanged, this, [this](DaemonLink::Status status) { if (status == DaemonLink::Status::Ready) { onDaemonReady(); } else if (status == DaemonLink::Status::Unavailable && m_restartConnecting) { // The reconnect this restart asked for failed (the link's own // watchdogs report Unavailable on spawn/handshake timeout). // Release the guard so the user can try again. qWarning() << "xenomorphic: daemon restart failed to reconnect"; m_restarting = false; m_restartConnecting = false; } emit statsChanged(); }); // Restart-exit poll (restartDaemon): a timer, never a nested event loop, // because restartDaemon runs from a QML button handler. m_restartPollTimer->setSingleShot(true); connect(m_restartPollTimer, &QTimer::timeout, this, &PanePool::pollRestartExit); } PanePool::~PanePool() { if (s_instance == this) { s_instance = nullptr; } } PanePool* PanePool::create(QQmlEngine* engine, QJSEngine* /*scriptEngine*/) { auto* pool = new PanePool(engine); pool->m_engine = engine; return pool; } PanePool* PanePool::instance() { return s_instance; } QString PanePool::createPaneId() const { // NOLINT(readability-convert-member-functions-to-static) - QML cannot invoke a static Q_INVOKABLE on the singleton; the method must stay a member. return QUuid::createUuid().toString(QUuid::WithoutBraces); } QString PanePool::createPane(const QVariantMap& spawn) { return createPaneWithId(createPaneId(), spawn); } QString PanePool::createPaneWithId(const QString& paneId, const QVariantMap& spawn) { // Create the TerminalQuickItem directly from C++. The QML component // approach (TerminalItem.qml) fails during early singleton init when // the module QRC isn't registered yet. Config bindings are set by the // TerminalPane wrapper after adoption. auto* term = new TerminalQuickItem; if (m_engine) { // Ensure JS ownership semantics are correct. QQmlEngine::setObjectOwnership(term, QQmlEngine::CppOwnership); } // Apply spawn properties before the terminal's componentComplete fires. term->setWorkingDirectory(spawn.value(QStringLiteral("cwd")).toString()); term->setCommand(spawn.value(QStringLiteral("command")).toString()); term->setInitialCommands( spawn.value(QStringLiteral("initialCommands")).toStringList()); const QString initialTitle = spawn.value(QStringLiteral("title")).toString(); if (!initialTitle.isEmpty()) { term->setTitle(initialTitle); } // Inject the daemon link + pane id and the restart spec BEFORE start(): // PtyClient::start folds them into the spawn spec it sends on /ctl. The // restart fields are PaneRecord shape-parity only (the quit-time restart // scan is dead code in phase 1). term->setDaemon(m_daemon, paneId); term->setRestartSpec(spawn.value(QStringLiteral("restart")).toBool(), spawn.value(QStringLiteral("restartArgv")).toStringList()); // A C++-new'd item skips the QQmlParserStatus lifecycle (componentComplete // fires only for QML-created objects), so drive the terminal startup // manually via start() — it runs the same ensureTerminal() path. term->start(); // Park on the hidden host until a wrapper adopts the item. if (m_parkHost) { term->setParentItem(m_parkHost); term->setVisible(false); } connectItem(term, paneId); PaneEntry entry; entry.item = term; entry.spawn = spawn; m_panes.insert(paneId, entry); emit paneCreated(paneId); return paneId; } QObject* PanePool::item(const QString& paneId) const { auto it = m_panes.constFind(paneId); if (it == m_panes.constEnd()) { return nullptr; } return it->item.data(); } void PanePool::setParkHost(QQuickItem* host) { m_parkHost = host; // Connect pool signals to AppModel (the only listener per contract). // The AppModel QML singleton always exists by the time this is called // (TerminalPage.qml wires parkHost in Component.onCompleted, after the // engine created the singletons). NEVER AppModel::create() here: a // C++-created, unparented second instance gets GC'd and s_instance // churns, leaving stale QPointer caches elsewhere. if (auto* model = AppModel::instance()) { connect(this, &PanePool::paneTitleChanged, model, &AppModel::paneTitleFromPool); connect(this, &PanePool::paneCwdChanged, model, &AppModel::paneCwdFromPool); connect(this, &PanePool::paneCloseRequested, model, &AppModel::paneCloseFromPool); // Model -> pool: any change to which panes are in the streaming set must // push the attach/detach state. The set is the active tab of the active // workspace, widened to the active tab of every workspace while a // preview surface is visible. refreshStreaming is idempotent, so coarse // signals are fine — setStreaming is a no-op for panes whose state // already matches. connect(model, &AppModel::activeTabChanged, this, &PanePool::refreshStreaming); connect(model, &AppModel::activeWorkspaceChanged, this, &PanePool::refreshStreaming); connect(model, &AppModel::structureChanged, this, &PanePool::refreshStreaming); connect(model, &AppModel::workspacesChanged, this, &PanePool::refreshStreaming); } } void PanePool::destroyPane(const QString& paneId) { if (auto editorIt = m_editors.find(paneId); editorIt != m_editors.end()) { EditorController* controller = editorIt.value().data(); m_editors.erase(editorIt); if (controller != nullptr) { // Drop the view's document first: the wrapper may still hold the // controller when this runs, and a document call after the document // died must not touch it. controller->detach(); controller->deleteLater(); } } auto it = m_panes.find(paneId); if (it == m_panes.end()) { return; } // EXPLICIT kill BEFORE deleteLater: the item's destructor only detaches // (PtyClient::close() never kills), so this daemon request is the ONLY // path that terminates a shell. Fire-and-forget — destroyPane can run // during teardown, so no requestSync here. if (m_daemon->isReady()) { m_daemon->request(QJsonObject{{QLatin1String("op"), QLatin1String("kill")}, {QLatin1String("paneId"), paneId}}, {}); } else { qWarning() << "xenomorphic: daemon not ready; cannot kill pane" << paneId; } if (auto* term = it->item.data()) { term->deleteLater(); } m_panes.erase(it); emit paneDestroyed(paneId); } void PanePool::connectDaemon() { // Non-blocking by contract: discovery, the daemon spawn, and the handshake // all complete on the GUI thread's event loop. Panes created in the // meantime queue their spawn inside PtyClient and start when the link // reports Ready (onDaemonReady pushes the streaming set at the same moment). // A nested loop here would run under QML handlers (this is called from // TerminalPage.qml's Component.onCompleted) and would deliver QML deferred // deletes while a handler is still on the stack, which Qt aborts on. m_daemon->connectAsync(); } QVariantMap PanePool::daemonStats() const { // Pure projection of the DaemonLink caches — no round trip. The QML page // re-reads this whenever statsChanged() fires (or on demand). const DaemonLink::Status st = m_daemon->status(); QString status; switch (st) { case DaemonLink::Status::Disconnected: status = QStringLiteral("disconnected"); break; case DaemonLink::Status::Connecting: status = QStringLiteral("connecting"); break; case DaemonLink::Status::Ready: status = QStringLiteral("ready"); break; case DaemonLink::Status::Unavailable: status = QStringLiteral("unavailable"); break; } QVariantMap stats; stats.insert(QStringLiteral("status"), status); stats.insert(QStringLiteral("pid"), m_daemon->daemonPid()); stats.insert(QStringLiteral("port"), m_daemon->port()); stats.insert(QStringLiteral("boot"), m_daemon->boot()); stats.insert(QStringLiteral("panes"), m_daemon->paneCount()); stats.insert(QStringLiteral("alivePanes"), m_daemon->alivePaneCount()); stats.insert(QStringLiteral("clients"), m_daemon->ctlClients()); return stats; } void PanePool::refreshDaemonStats() { // Non-blocking `list`: the callback fires on the GUI thread when the reply // lands (or fails), which is exactly when statsChanged() is meaningful. m_daemon->refreshPanesAsync([this](bool) { emit statsChanged(); }); } void PanePool::restartDaemon() { // Fully asynchronous: no nested event loop runs here, because this is // called from a QML button handler and a loop there would deliver QML // deferred deletes (destroy()) while a handler is still on the stack, which // Qt aborts on. The respawn pass runs from onDaemonReady(). if (m_restarting) { qWarning() << "xenomorphic: daemon restart already in progress"; return; } m_restarting = true; // 1) Ask the running daemon to shut down. A 0 return (nothing connected) // is fine: the exit poll finds no live process and the successor spawn // follows. m_daemon->requestShutdown(); // 2) Poll for the daemon PROCESS to die so the single-instance lock and the // /ctl listen socket are free before spawning a successor. On timeout // proceed anyway: connectAsync() surfaces any real failure. m_restartElapsed.start(); m_restartPollTimer->start(kRestartPollIntervalMs); } void PanePool::pollRestartExit() { const bool stillAlive = m_daemon->daemonProcessAlive(); if (stillAlive && !m_restartElapsed.hasExpired(kRestartExitTimeoutMs)) { m_restartPollTimer->start(kRestartPollIntervalMs); return; } if (stillAlive) { qWarning() << "xenomorphic: daemon did not exit within" << kRestartExitTimeoutMs << "ms; attempting respawn anyway"; } // Discover-or-spawn + handshake, in the background. m_restarting stays set // until Ready (onDaemonReady runs the respawn pass) or a failed connect // (the status handler clears the guard). m_restartConnecting = true; m_daemon->connectAsync(); } void PanePool::onDaemonReady() { if (m_restarting) { // 4) Respawn every pooled pane against the fresh daemon. respawn() // re-queues the PTY spawn (same pane id, same spec); the items' vt/render // state is untouched, so the terminals keep showing their old content // until the new shell draws. m_restarting = false; m_restartConnecting = false; for (auto entryIt = m_panes.constBegin(); entryIt != m_panes.constEnd(); ++entryIt) { if (auto* term = entryIt->item.data()) { term->respawn(); } } } // Panes that asked to stream while the link was down attach now; this is // also the ordinary path, and setStreaming is idempotent. refreshStreaming(); } void PanePool::setPreviewStreaming(bool on) { if (m_previewStreaming == on) { return; } m_previewStreaming = on; refreshStreaming(); } void PanePool::refreshStreaming() { AppModel* model = AppModel::instance(); QStringList streamIds; if (model != nullptr) { streamIds = m_previewStreaming ? model->streamingPaneIds() : model->activePaneIds(); } for (auto entryIt = m_panes.constBegin(); entryIt != m_panes.constEnd(); ++entryIt) { if (auto* term = entryIt->item.data()) { term->setStreaming(streamIds.contains(entryIt.key())); } } } QVariantMap PanePool::spawnSpec(const QString& paneId) const { auto it = m_panes.constFind(paneId); if (it == m_panes.constEnd()) { return {}; } QVariantMap spec = it->spawn; // Refresh cwd from the live terminal so session save captures the // current working directory, not just the spawn-time cwd. if (auto* term = it->item.data()) { spec.insert(QStringLiteral("cwd"), term->workingDirectory()); spec.insert(QStringLiteral("title"), term->title()); } return spec; } // Quit-time restart scan. Reads the foreground process of every pane from // the DaemonLink pane-snapshot cache (keyed by paneId, populated from the // daemon that outlives the app's QML teardown), matches them against the // user's whitelist, and records the result on the MODEL (where // buildSessionRoot reads it) via AppModel::setPaneRestart. Q_INVOKABLE so // AppModel::buildSessionRoot can reach it through QMetaObject::invokeMethod // on the QPointer m_pool, keeping models/ compile-time Quick-free. void PanePool::runRestartScan() { if (AppModel::instance() == nullptr) { return; } // Bounded refresh of the cached pane table; on timeout degrade to // whatever the cache still holds rather than hanging the quit. The // explicit 800ms bound (instead of the 1500ms default) keeps the // worst-case quit stall bounded: 800ms daemon `list` here + up to // 2000ms inside scanForeground's `ps -Ao` waitForFinished = ~2.8s // worst case, milliseconds in the common case. That is the deliberate // arithmetic — the blocking subprocess below is NOT unbounded. m_daemon->refreshPanes(800); const QVariantList entries = AppModel::instance()->settings()->processRestartMatches(); if (entries.isEmpty()) { return; // scanForeground would return empty anyway; skip the ps run. } // Enumerate the daemon's cached snapshots through the pane ids THIS pool // owns. Copy the values out immediately: pane() returns a pointer INTO // the link's internal hash and must not be retained. QVariantList pids; QHash paneIdByPid; for (auto it = m_panes.constBegin(); it != m_panes.constEnd(); ++it) { const DaemonLink::PaneSnapshot* snap = m_daemon->pane(it.key()); if (snap == nullptr) { continue; } const qint64 pid = snap->foregroundPid > 0 ? snap->foregroundPid : snap->pid; if (pid <= 0) { continue; } pids.append(QVariant(pid)); paneIdByPid.insert(QString::number(pid), it.key()); } if (pids.isEmpty()) { return; } const QVariantMap matched = ProcessScanner::scanForeground(pids, entries); for (auto mIt = matched.constBegin(); mIt != matched.constEnd(); ++mIt) { const QVariantMap entry = mIt.value().toMap(); if (!entry.value(QLatin1String("matched")).toBool()) { continue; } const QString paneId = paneIdByPid.value(mIt.key()); if (paneId.isEmpty()) { continue; } AppModel::instance()->setPaneRestart( paneId, true, entry.value(QLatin1String("argv")).toStringList()); } } QString PanePool::paneIdOf(QObject* term) const { for (auto it = m_panes.constBegin(); it != m_panes.constEnd(); ++it) { if (it->item.data() == term) { return it.key(); } } return {}; } void PanePool::reparent(const QString& paneId, QQuickItem* newParent) { auto it = m_panes.find(paneId); if (it == m_panes.end()) { return; } if (auto* term = it->item.data()) { term->setParentItem(newParent); term->setVisible(newParent != nullptr); if (newParent == nullptr) { // The item just entered park limbo. A wrapper elsewhere in the // scene may still believe it owns this paneId (its `term` ref is // set); announce the orphan so it re-adopts. emit paneOrphaned(paneId); } } } QString PanePool::spatialNeighborPaneId(const QString& fromPaneId, // NOLINT(readability-convert-member-functions-to-static) const QString& direction) const { if (fromPaneId.isEmpty()) { return {}; } AppModel* model = AppModel::instance(); if (model == nullptr) { return {}; } const int wsIndex = model->workspaceIndexOf(model->activeWorkspaceId()); const int tabIndex = model->tabIndexOf(model->activeWorkspaceId(), model->activeTabId()); if (wsIndex < 0 || tabIndex < 0) { return {}; } SplitContainerNode* tree = model->tabTree(wsIndex, tabIndex); if (tree == nullptr) { return {}; } // Model-native: the tree's normalized rects, so an editor or picker pane // is a neighbour like any terminal. return ::spatialNeighborPaneId(*tree, fromPaneId, direction); } void PanePool::swapFocusedDirection(const QString& direction) const { AppModel* model = AppModel::instance(); if (model == nullptr) { return; } const QString fromId = model->focusedPaneId(); if (fromId.isEmpty()) { return; } const QString neighbor = spatialNeighborPaneId(fromId, direction); if (neighbor.isEmpty()) { return; } model->swapPanes(fromId, neighbor); } EditorController* PanePool::editorControllerFor(const QString& paneId) { if (paneId.isEmpty()) { return nullptr; } if (EditorController* existing = m_editors.value(paneId).data()) { return existing; } auto* controller = new EditorController(this); m_editors.insert(paneId, controller); connect(controller, &EditorController::dirtyChanged, this, [this, paneId]() { AppModel* model = AppModel::instance(); if (model != nullptr) { EditorController* live = m_editors.value(paneId).data(); model->notePaneDirty(paneId, live != nullptr && live->dirty()); } refreshEditorTitle(paneId); }); // A refused save is a pane-level event, but the dialog that answers it lives // at the window, so the request travels through the pool. connect(controller, &EditorController::conflictSaveRequested, this, [this, paneId]() { emit conflictSaveRequested(paneId); }); AppModel* model = AppModel::instance(); if (model != nullptr) { // The editor font is the terminal font at the editor's own size, so the // controller needs the settings object that holds both. controller->setSettings(model->settings()); const PaneRecord record = model->paneRecord(paneId); if (!record.editorPath.isEmpty() && controller->openFile(record.editorPath) && record.editorLine > 0) { controller->requestCursor(record.editorLine - 1, qMax(0, record.editorColumn - 1)); } } refreshEditorTitle(paneId); return controller; } void PanePool::refreshEditorTitle(const QString& paneId) { EditorController* controller = m_editors.value(paneId).data(); if (controller == nullptr) { return; } AppModel* model = AppModel::instance(); if (model == nullptr) { return; } const QString name = QFileInfo(controller->filePath()).fileName(); model->notePaneTitle(paneId, controller->dirty() ? name + QStringLiteral(" •") : name); } bool PanePool::saveDirtyEditors(const QStringList& paneIds) { bool everyPaneWrote = true; for (const QString& paneId : paneIds) { EditorController* controller = m_editors.value(paneId).data(); if (controller != nullptr && controller->dirty()) { // The write is not short-circuited: every pane gets its attempt, and the // answer is whether all of them succeeded. everyPaneWrote = controller->save() && everyPaneWrote; } } return everyPaneWrote; } QStringList PanePool::dirtyEditorsThatCannotSave() const { QStringList blocked; for (auto entry = m_editors.cbegin(); entry != m_editors.cend(); ++entry) { EditorController* controller = entry.value().data(); if (controller != nullptr && controller->model() != nullptr && controller->model()->saveWouldBeRefused()) { blocked.append(entry.key()); } } // A hash iterates in an unspecified order, and the quit guard focuses the // first entry, so the answer is sorted to stay the same from run to run. std::ranges::sort(blocked); return blocked; } void PanePool::connectItem(TerminalQuickItem* term, const QString& paneId) { connect(term, &TerminalQuickItem::titleChanged, this, [this, paneId]() { if (auto* t = m_panes.value(paneId).item.data()) { emit paneTitleChanged(paneId, t->title()); } }); connect(term, &TerminalQuickItem::workingDirectoryChanged, this, [this, paneId]() { if (auto* t = m_panes.value(paneId).item.data()) { emit paneCwdChanged(paneId, t->workingDirectory()); } }); connect(term, &TerminalQuickItem::closeRequested, this, [this, paneId]() { emit paneCloseRequested(paneId); }); }