gamecenter: refactor ProcessScanner for cancellable single-pass scans

Add ProcessScanner::scan(ScanOptions) for combined env/cmdline/exe matching, extend Match with match-source flags, and make pollUntilFound() cancellation-aware. Update monitored launch matchers to use the new API.
This commit is contained in:
Marco Allegretti 2026-02-14 17:38:28 +01:00
parent 7200ad179c
commit f03eb95b52
3 changed files with 225 additions and 122 deletions

View file

@ -1402,7 +1402,7 @@ QString GameCenterDaemon::launchMonitored(const QVariantMap &launchSpec, const Q
// Determine bootstrap command and scanner matcher // Determine bootstrap command and scanner matcher
QString bootstrapProgram; QString bootstrapProgram;
QStringList bootstrapArgs; QStringList bootstrapArgs;
std::function<QList<ProcessScanner::Match>()> matcher; ProcessScanner::Matcher matcher;
if (provider == QLatin1String("steam")) { if (provider == QLatin1String("steam")) {
const QString appId = extractSteamAppId(command); const QString appId = extractSteamAppId(command);
@ -1414,9 +1414,13 @@ QString GameCenterDaemon::launchMonitored(const QVariantMap &launchSpec, const Q
bootstrapProgram = QStringLiteral("steam"); bootstrapProgram = QStringLiteral("steam");
bootstrapArgs = {QStringLiteral("-silent"), QStringLiteral("-applaunch"), appId}; bootstrapArgs = {QStringLiteral("-silent"), QStringLiteral("-applaunch"), appId};
matcher = [appId]() -> QList<ProcessScanner::Match> { matcher = [appId](const std::atomic_bool &cancelled) -> QList<ProcessScanner::Match> {
// Primary: look for SteamAppId=<appId> in environ ProcessScanner::ScanOptions opts;
QList<ProcessScanner::Match> results = ProcessScanner::findByAnyEnvironment({QStringLiteral("SteamAppId"), QStringLiteral("SteamGameId")}, appId); opts.envKeys = {QStringLiteral("SteamAppId"), QStringLiteral("SteamGameId")};
opts.envValue = appId;
opts.preferEnvironmentMatches = true;
QList<ProcessScanner::Match> results = ProcessScanner::scan(opts, cancelled);
// Filter out the steam client itself // Filter out the steam client itself
QList<ProcessScanner::Match> filtered; QList<ProcessScanner::Match> filtered;
for (const auto &m : std::as_const(results)) { for (const auto &m : std::as_const(results)) {
@ -1438,11 +1442,25 @@ QString GameCenterDaemon::launchMonitored(const QVariantMap &launchSpec, const Q
bootstrapArgs = {QStringLiteral("lutris:rungameid/%1").arg(lutrisId)}; bootstrapArgs = {QStringLiteral("lutris:rungameid/%1").arg(lutrisId)};
// For Lutris, look for child processes with the game slug or lutris game env // For Lutris, look for child processes with the game slug or lutris game env
matcher = [lutrisId]() -> QList<ProcessScanner::Match> { matcher = [lutrisId](const std::atomic_bool &cancelled) -> QList<ProcessScanner::Match> {
QList<ProcessScanner::Match> results = ProcessScanner::findByEnvironment(QStringLiteral("LUTRIS_GAME_SLUG"), lutrisId); ProcessScanner::ScanOptions opts;
if (results.isEmpty()) { opts.envKeys = {QStringLiteral("LUTRIS_GAME_SLUG")};
results = ProcessScanner::findByCmdline(lutrisId); opts.envValue = lutrisId;
// Filter out lutris itself opts.cmdlineSubstring = lutrisId;
opts.preferEnvironmentMatches = true;
QList<ProcessScanner::Match> results = ProcessScanner::scan(opts, cancelled);
bool anyEnvMatch = false;
for (const auto &m : std::as_const(results)) {
if (m.envMatched) {
anyEnvMatch = true;
break;
}
}
if (!anyEnvMatch) {
// Filter out lutris itself (only for cmdline fallback)
QList<ProcessScanner::Match> filtered; QList<ProcessScanner::Match> filtered;
for (const auto &m : std::as_const(results)) { for (const auto &m : std::as_const(results)) {
if (!m.exe.endsWith(QLatin1String("/lutris")) && !m.exe.endsWith(QLatin1String("/python3"))) { if (!m.exe.endsWith(QLatin1String("/lutris")) && !m.exe.endsWith(QLatin1String("/python3"))) {
@ -1453,6 +1471,7 @@ QString GameCenterDaemon::launchMonitored(const QVariantMap &launchSpec, const Q
results = filtered; results = filtered;
} }
} }
return results; return results;
}; };
} }

View file

@ -6,8 +6,19 @@
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QLoggingCategory>
#include <QtConcurrentRun> #include <QtConcurrentRun>
#include <utility>
Q_LOGGING_CATEGORY(lcProcessScanner, "org.kde.alakarte.processscanner")
static QString readExeLink(const QString &pidDir);
static QByteArray readCmdline(const QString &pidDir);
static QByteArray readEnviron(const QString &pidDir);
static bool containsNullSeparatedEntry(const QByteArray &blob, const QByteArray &needle);
static QList<uint> listPids();
ProcessScanner::ProcessScanner(QObject *parent) ProcessScanner::ProcessScanner(QObject *parent)
: QObject(parent) : QObject(parent)
{ {
@ -16,7 +27,11 @@ ProcessScanner::ProcessScanner(QObject *parent)
connect(&m_deadline, &QTimer::timeout, this, [this]() { connect(&m_deadline, &QTimer::timeout, this, [this]() {
m_timer.stop(); m_timer.stop();
m_matcher = nullptr; if (m_cancelToken) {
m_cancelToken->store(true);
}
m_cancelToken.reset();
m_matcher = {};
++m_generation; ++m_generation;
m_scanInFlight = false; m_scanInFlight = false;
Q_EMIT timedOut(); Q_EMIT timedOut();
@ -42,7 +57,8 @@ void ProcessScanner::startScan()
m_scanInFlight = true; m_scanInFlight = true;
m_scanGeneration = gen; m_scanGeneration = gen;
const std::function<QList<Match>()> matcher = m_matcher; const Matcher matcher = m_matcher;
const std::shared_ptr<std::atomic_bool> cancelToken = m_cancelToken;
auto *watcher = new QFutureWatcher<QList<Match>>(this); auto *watcher = new QFutureWatcher<QList<Match>>(this);
connect(watcher, &QFutureWatcher<QList<Match>>::finished, this, [this, watcher, gen]() { connect(watcher, &QFutureWatcher<QList<Match>>::finished, this, [this, watcher, gen]() {
@ -60,19 +76,130 @@ void ProcessScanner::startScan()
if (!results.isEmpty()) { if (!results.isEmpty()) {
m_timer.stop(); m_timer.stop();
m_deadline.stop(); m_deadline.stop();
m_matcher = nullptr; if (m_cancelToken) {
m_cancelToken->store(true);
}
m_cancelToken.reset();
m_matcher = {};
Q_EMIT found(results); Q_EMIT found(results);
} }
}); });
watcher->setFuture(QtConcurrent::run([matcher]() { watcher->setFuture(QtConcurrent::run([matcher, cancelToken]() {
static const std::atomic_bool neverCancelled{false};
const std::atomic_bool &cancelled = cancelToken ? *cancelToken : neverCancelled;
if (!matcher) { if (!matcher) {
return QList<Match>{}; return QList<Match>{};
} }
return matcher(); return matcher(cancelled);
})); }));
} }
QList<ProcessScanner::Match> ProcessScanner::scan(const ScanOptions &options)
{
static const std::atomic_bool neverCancelled{false};
return scan(options, neverCancelled);
}
QList<ProcessScanner::Match> ProcessScanner::scan(const ScanOptions &options, const std::atomic_bool &cancelled)
{
QList<Match> matches;
QList<QByteArray> envNeedles;
if (!options.envKeys.isEmpty() && !options.envValue.isEmpty()) {
envNeedles.reserve(options.envKeys.size());
for (const QString &key : options.envKeys) {
if (!key.isEmpty()) {
envNeedles.push_back((key + QLatin1Char('=') + options.envValue).toUtf8());
}
}
}
const QByteArray cmdNeedle = options.cmdlineSubstring.isEmpty() ? QByteArray{} : options.cmdlineSubstring.toUtf8();
const bool checkExePrefix = !options.exePathPrefix.isEmpty();
const QList<uint> pids = listPids();
for (uint pid : pids) {
if (cancelled.load()) {
break;
}
const QString pidDir = QStringLiteral("/proc/%1").arg(pid);
bool envMatched = false;
if (!envNeedles.isEmpty()) {
const QByteArray env = readEnviron(pidDir);
if (!env.isEmpty()) {
for (const QByteArray &needle : envNeedles) {
if (containsNullSeparatedEntry(env, needle)) {
envMatched = true;
break;
}
}
}
}
bool cmdlineMatched = false;
QByteArray cmd;
if (!cmdNeedle.isEmpty()) {
cmd = readCmdline(pidDir);
cmdlineMatched = !cmd.isEmpty() && cmd.contains(cmdNeedle);
}
bool exeMatched = false;
QString exe;
if (checkExePrefix) {
exe = readExeLink(pidDir);
exeMatched = !exe.isEmpty() && exe.startsWith(options.exePathPrefix);
}
if (!envMatched && !cmdlineMatched && !exeMatched) {
continue;
}
Match m;
m.pid = pid;
m.envMatched = envMatched;
m.cmdlineMatched = cmdlineMatched;
m.exeMatched = exeMatched;
if (exe.isEmpty()) {
exe = readExeLink(pidDir);
}
m.exe = exe;
if (cmd.isEmpty()) {
cmd = readCmdline(pidDir);
}
m.cmdline = QString::fromLocal8Bit(cmd).replace(QLatin1Char('\0'), QLatin1Char(' ')).trimmed();
matches.push_back(m);
}
if (options.preferEnvironmentMatches) {
bool anyEnvMatch = false;
for (const Match &m : std::as_const(matches)) {
if (m.envMatched) {
anyEnvMatch = true;
break;
}
}
if (anyEnvMatch) {
QList<Match> filtered;
filtered.reserve(matches.size());
for (const Match &m : std::as_const(matches)) {
if (m.envMatched) {
filtered.push_back(m);
}
}
return filtered;
}
}
return matches;
}
static QByteArray readProcFile(const QString &path, qint64 maxSize = 65536) static QByteArray readProcFile(const QString &path, qint64 maxSize = 65536)
{ {
QFile f(path); QFile f(path);
@ -136,141 +263,75 @@ static QList<uint> listPids()
QList<ProcessScanner::Match> ProcessScanner::findByEnvironment(const QString &key, const QString &value) QList<ProcessScanner::Match> ProcessScanner::findByEnvironment(const QString &key, const QString &value)
{ {
QList<Match> matches; ScanOptions opts;
const QByteArray needle = (key + QLatin1Char('=') + value).toUtf8(); opts.envKeys = {key};
opts.envValue = value;
const QList<uint> pids = listPids(); return scan(opts);
for (uint pid : pids) {
const QString pidDir = QStringLiteral("/proc/%1").arg(pid);
const QByteArray env = readEnviron(pidDir);
if (env.isEmpty()) {
continue;
}
if (containsNullSeparatedEntry(env, needle)) {
Match m;
m.pid = pid;
m.exe = readExeLink(pidDir);
const QByteArray cmd = readCmdline(pidDir);
m.cmdline = QString::fromLocal8Bit(cmd).replace(QLatin1Char('\0'), QLatin1Char(' ')).trimmed();
matches.push_back(m);
}
}
return matches;
} }
QList<ProcessScanner::Match> ProcessScanner::findByAnyEnvironment(const QStringList &keys, const QString &value) QList<ProcessScanner::Match> ProcessScanner::findByAnyEnvironment(const QStringList &keys, const QString &value)
{ {
QList<Match> matches; ScanOptions opts;
if (keys.isEmpty()) { opts.envKeys = keys;
return matches; opts.envValue = value;
} return scan(opts);
QList<QByteArray> needles;
needles.reserve(keys.size());
for (const QString &key : keys) {
if (!key.isEmpty()) {
needles.push_back((key + QLatin1Char('=') + value).toUtf8());
}
}
if (needles.isEmpty()) {
return matches;
}
const QList<uint> pids = listPids();
for (uint pid : pids) {
const QString pidDir = QStringLiteral("/proc/%1").arg(pid);
const QByteArray env = readEnviron(pidDir);
if (env.isEmpty()) {
continue;
}
bool found = false;
for (const QByteArray &needle : needles) {
if (containsNullSeparatedEntry(env, needle)) {
found = true;
break;
}
}
if (!found) {
continue;
}
Match m;
m.pid = pid;
m.exe = readExeLink(pidDir);
const QByteArray cmd = readCmdline(pidDir);
m.cmdline = QString::fromLocal8Bit(cmd).replace(QLatin1Char('\0'), QLatin1Char(' ')).trimmed();
matches.push_back(m);
}
return matches;
} }
QList<ProcessScanner::Match> ProcessScanner::findByCmdline(const QString &substring) QList<ProcessScanner::Match> ProcessScanner::findByCmdline(const QString &substring)
{ {
QList<Match> matches; ScanOptions opts;
const QByteArray needle = substring.toUtf8(); opts.cmdlineSubstring = substring;
return scan(opts);
const QList<uint> pids = listPids();
for (uint pid : pids) {
const QString pidDir = QStringLiteral("/proc/%1").arg(pid);
const QByteArray cmd = readCmdline(pidDir);
if (cmd.isEmpty()) {
continue;
}
// cmdline has null-separated args; search the whole blob
if (cmd.contains(needle)) {
Match m;
m.pid = pid;
m.exe = readExeLink(pidDir);
m.cmdline = QString::fromLocal8Bit(cmd).replace(QLatin1Char('\0'), QLatin1Char(' ')).trimmed();
matches.push_back(m);
}
}
return matches;
} }
QList<ProcessScanner::Match> ProcessScanner::findByExePath(const QString &dirPrefix) QList<ProcessScanner::Match> ProcessScanner::findByExePath(const QString &dirPrefix)
{ {
QList<Match> matches; ScanOptions opts;
opts.exePathPrefix = dirPrefix;
const QList<uint> pids = listPids(); return scan(opts);
for (uint pid : pids) {
const QString pidDir = QStringLiteral("/proc/%1").arg(pid);
const QString exe = readExeLink(pidDir);
if (exe.isEmpty()) {
continue;
}
if (exe.startsWith(dirPrefix)) {
Match m;
m.pid = pid;
m.exe = exe;
const QByteArray cmd = readCmdline(pidDir);
m.cmdline = QString::fromLocal8Bit(cmd).replace(QLatin1Char('\0'), QLatin1Char(' ')).trimmed();
matches.push_back(m);
}
}
return matches;
} }
void ProcessScanner::pollUntilFound(std::function<QList<Match>()> matcher, int intervalMs, int timeoutMs) void ProcessScanner::pollUntilFound(Matcher matcher, int intervalMs, int timeoutMs)
{ {
cancel(); cancel();
m_cancelToken = std::make_shared<std::atomic_bool>(false);
m_matcher = std::move(matcher); m_matcher = std::move(matcher);
if (!m_matcher) {
return;
}
m_deadline.start(timeoutMs); m_deadline.start(timeoutMs);
m_timer.start(intervalMs); m_timer.start(intervalMs);
startScan(); startScan();
} }
void ProcessScanner::pollUntilFound(std::function<QList<Match>()> matcher, int intervalMs, int timeoutMs)
{
pollUntilFound(
[matcher = std::move(matcher)](const std::atomic_bool &cancelled) {
if (cancelled.load()) {
return QList<Match>{};
}
if (!matcher) {
return QList<Match>{};
}
return matcher();
},
intervalMs,
timeoutMs);
}
void ProcessScanner::cancel() void ProcessScanner::cancel()
{ {
m_timer.stop(); m_timer.stop();
m_deadline.stop(); m_deadline.stop();
m_matcher = nullptr; if (m_cancelToken) {
m_cancelToken->store(true);
}
m_cancelToken.reset();
m_matcher = {};
++m_generation; ++m_generation;
m_scanInFlight = false; m_scanInFlight = false;
} }

View file

@ -5,11 +5,16 @@
#include <QFutureWatcher> #include <QFutureWatcher>
#include <QList> #include <QList>
#include <QLoggingCategory>
#include <QObject> #include <QObject>
#include <QString> #include <QString>
#include <QTimer> #include <QTimer>
#include <QtGlobal> #include <QtGlobal>
#include <atomic>
#include <functional> #include <functional>
#include <memory>
Q_DECLARE_LOGGING_CATEGORY(lcProcessScanner)
class ProcessScanner : public QObject class ProcessScanner : public QObject
{ {
@ -18,12 +23,28 @@ class ProcessScanner : public QObject
public: public:
explicit ProcessScanner(QObject *parent = nullptr); explicit ProcessScanner(QObject *parent = nullptr);
struct ScanOptions {
QStringList envKeys;
QString envValue;
QString cmdlineSubstring;
QString exePathPrefix;
bool preferEnvironmentMatches = false;
};
struct Match { struct Match {
uint pid = 0; uint pid = 0;
QString exe; QString exe;
QString cmdline; QString cmdline;
bool envMatched = false;
bool cmdlineMatched = false;
bool exeMatched = false;
}; };
using Matcher = std::function<QList<Match>(const std::atomic_bool &cancelled)>;
static QList<Match> scan(const ScanOptions &options);
static QList<Match> scan(const ScanOptions &options, const std::atomic_bool &cancelled);
// Find PIDs whose /proc/<pid>/environ contains key=value // Find PIDs whose /proc/<pid>/environ contains key=value
static QList<Match> findByEnvironment(const QString &key, const QString &value); static QList<Match> findByEnvironment(const QString &key, const QString &value);
@ -38,6 +59,7 @@ public:
// Async poll: calls matcher repeatedly until it returns non-empty or timeout. // Async poll: calls matcher repeatedly until it returns non-empty or timeout.
// Emits found() with matching PIDs, or timedOut() on failure. // Emits found() with matching PIDs, or timedOut() on failure.
void pollUntilFound(Matcher matcher, int intervalMs = 500, int timeoutMs = 15000);
void pollUntilFound(std::function<QList<Match>()> matcher, int intervalMs = 500, int timeoutMs = 15000); void pollUntilFound(std::function<QList<Match>()> matcher, int intervalMs = 500, int timeoutMs = 15000);
void cancel(); void cancel();
@ -51,7 +73,8 @@ private:
QTimer m_timer; QTimer m_timer;
QTimer m_deadline; QTimer m_deadline;
std::function<QList<Match>()> m_matcher; Matcher m_matcher;
std::shared_ptr<std::atomic_bool> m_cancelToken;
quint64 m_generation = 0; quint64 m_generation = 0;
bool m_scanInFlight = false; bool m_scanInFlight = false;
quint64 m_scanGeneration = 0; quint64 m_scanGeneration = 0;