// ProcessScanner.cpp — see ProcessScanner.h. #include "ProcessScanner.h" #include #include #include #include #include #include #include // Scroll-hitch instrumentation: enable with XENO_SIDEBAR_DEBUG=1. Kept // permanent so the timing evidence is reproducible; silent by default. static const bool kScrollDebug = qEnvironmentVariableIsSet("XENO_SIDEBAR_DEBUG"); // A fresh-enough snapshot answers a request from cache without spawning // another `ps`. The label refresh is approximate by design (2s cadence), // so a 1s cache serves every card's staggered 2s timer from one subprocess // per ~snapshot window. static constexpr qint64 kCacheTtlMs = 1000; namespace { // Match one pane command line against one whitelist entry. struct Matcher { enum class Kind : std::uint8_t { Name, Path }; Kind kind = Kind::Name; QString path; QRegularExpression regex; }; // Build the entry matchers. Entries are { "kind": "name"|"path", "value" }. // An entry with a missing/unknown kind or a trimmed-empty value is skipped // (an invalid pattern in a name entry makes that entry inert, not the whole // whitelist). An empty whitelist restarts nothing. QVector buildMatchers(const QVariantList& entries) { QVector matchers; matchers.reserve(entries.size()); for (const QVariant& entryVariant : entries) { const QVariantMap entry = entryVariant.toMap(); const QString kind = entry.value(QStringLiteral("kind")).toString().trimmed(); const QString value = entry.value(QStringLiteral("value")).toString().trimmed(); if (value.isEmpty()) { continue; } Matcher matcher; if (kind == QLatin1String("path")) { matcher.kind = Matcher::Kind::Path; matcher.path = value; } else { // Default to name. Only a full-line match counts; a path value in a // name entry is impossible (regexes never match "/"). if (value.contains(QLatin1Char('/'))) { continue; } matcher.kind = Matcher::Kind::Name; matcher.regex = QRegularExpression(value); if (!matcher.regex.isValid()) { // Invalid regex: entry is inert, never matches. continue; } } matchers.append(matcher); } return matchers; } // True when `command` (the ps command line) satisfies any entry. bool matchesAny(const QString& command, const QVector& matchers) { return std::ranges::any_of(matchers, [&command](const Matcher& matcher) { if (matcher.kind == Matcher::Kind::Path) { return command == matcher.path; } return matcher.regex.match(command).hasMatch(); }); } // Parse a full `ps -Ao pid=,command=` stdout dump into a pid -> command map. // One snapshot is generated per subprocess; both the sync and async paths // share this parser. QHash parseSnapshot(const QByteArray& out) { QHash commandsByPid; const QList lines = out.split('\n'); for (const QByteArray& rawLine : lines) { const QByteArray line = rawLine.trimmed(); if (line.isEmpty()) { continue; } const qsizetype sep = line.indexOf(' '); if (sep <= 0) { continue; } bool ok = false; const int pid = line.left(sep).toInt(&ok); if (!ok || pid <= 0) { continue; } commandsByPid.insert(pid, QString::fromUtf8(line.mid(sep + 1).trimmed())); } return commandsByPid; } } // namespace ProcessScanner::ProcessScanner(QObject* parent) : QObject(parent) { connect(&m_process, &QProcess::finished, this, &ProcessScanner::onFinished); } namespace { // One synchronous snapshot of every process, keyed by pid. Returns an empty // map when ps fails or times out (the caller degrades to "no data" rather // than blocking). QHash snapshotCommands() { QHash commandsByPid; QElapsedTimer scanTimer; scanTimer.start(); QProcess psScan; psScan.start(QStringLiteral("ps"), {QStringLiteral("-Ao"), QStringLiteral("pid=,command=")}); if (!psScan.waitForFinished(2000)) { if (kScrollDebug) { qDebug() << "[xeno-process] ps scan TIMED OUT at" << scanTimer.elapsed() << "ms"; } return commandsByPid; } const QByteArray out = psScan.readAllStandardOutput(); commandsByPid = parseSnapshot(out); if (kScrollDebug) { qDebug() << "[xeno-process] ps scan:" << scanTimer.elapsed() << "ms," << commandsByPid.size() << "pids, from" << out.size() << "bytes"; } return commandsByPid; } // Build the per-pid result map { "": { "argv": QVariantList } } for // the given pids from the snapshot. Pids with no resolvable command line // are skipped. QVariantMap commandsForPids(const QVariantList& pids, const QHash& commandsByPid) { QVariantMap result; for (const QVariant& pidVariant : pids) { const int pid = pidVariant.toInt(); if (pid <= 0) { continue; } const auto it = commandsByPid.constFind(pid); if (it == commandsByPid.cend()) { continue; } const QString& command = it.value(); QVariantMap entry; QVariantList argv; const QStringList tokens = command.split(QLatin1Char(' '), Qt::SkipEmptyParts); for (const QString& token : tokens) { argv.append(token); } entry.insert(QStringLiteral("argv"), argv); result.insert(QString::number(pid), entry); } return result; } } // namespace QVariantMap ProcessScanner::scanForeground(const QVariantList& pids, const QVariantList& entries) { QVariantMap result; const QVector matchers = buildMatchers(entries); if (matchers.isEmpty()) { return result; } const QHash commandsByPid = snapshotCommands(); const QVariantMap resolved = commandsForPids(pids, commandsByPid); const QList keys = resolved.keys(); for (const QString& key : keys) { const QVariantMap entry = resolved.value(key).toMap(); const QString command = entry.value(QStringLiteral("argv")) .toStringList() .join(QLatin1Char(' ')); if (!matchesAny(command, matchers)) { continue; } QVariantMap match = entry; match.insert(QStringLiteral("matched"), true); result.insert(key, match); } if (kScrollDebug) { qDebug() << "[xeno-process] scanForeground:" << pids.size() << "requested," << result.size() << "matched"; } return result; } int ProcessScanner::requestPrograms(const QVariantList& pids) { const int requestId = m_nextRequestId++; // Fresh cache answers without another subprocess, but NEVER synchronously: // the caller must receive the requestId (its return value) before the // answer arrives, so the signal is deferred to the event loop. A direct // emit here would fire before QML can store pendingRequestId. m_requests.insert(requestId, pids); if (m_cacheValid && m_cacheAge.elapsed() < kCacheTtlMs) { QTimer::singleShot(0, this, [this, requestId]() { const auto it = m_requests.constFind(requestId); if (it == m_requests.cend()) { return; } const QVariantMap resolved = commandsForPids(it.value(), m_cache); Q_EMIT programsUpdated(requestId, it.value(), resolved); m_requests.remove(requestId); }); return requestId; } m_pendingRequests.insert(requestId, pids); if (!m_scanInFlight) { m_scanInFlight = true; m_process.start(QStringLiteral("ps"), {QStringLiteral("-Ao"), QStringLiteral("pid=,command=")}); } return requestId; } void ProcessScanner::onFinished() { m_scanInFlight = false; const QByteArray out = m_process.readAllStandardOutput(); m_cache = parseSnapshot(out); m_cacheValid = true; m_cacheAge.restart(); if (kScrollDebug) { qDebug() << "[xeno-process] async ps snapshot:" << m_cache.size() << "pids, from" << out.size() << "bytes"; } // Answer every request that rode this snapshot. const auto requests = m_pendingRequests; m_pendingRequests.clear(); for (auto it = requests.constBegin(); it != requests.constEnd(); ++it) { const QVariantMap resolved = commandsForPids(it.value(), m_cache); Q_EMIT programsUpdated(it.key(), it.value(), resolved); m_requests.remove(it.key()); } }