// Copyright (C) 2024-2026 Petr Mironychev // SPDX-License-Identifier: GPL-3.0-or-later #include "ProviderLauncher.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "Logger.hpp" #include #include #ifdef Q_OS_WIN #include #else #include #endif namespace QodeAssist::Providers { namespace { Q_LOGGING_CATEGORY(launcherLog, "qodeassist.providerlauncher") constexpr std::chrono::milliseconds kProbeInterval{500}; constexpr std::chrono::milliseconds kProbeTransferTimeout{2000}; constexpr std::chrono::milliseconds kAdoptionTransferTimeout{1500}; constexpr std::chrono::milliseconds kStartTimeout{2000}; constexpr int kScrollbackBytesMax = 1 * 1024 * 1024; // 1 MiB cap per slot } // namespace struct ProviderLauncher::Slot { QString name; LaunchConfig cfg; State state = Idle; Utils::Process *process = nullptr; qint64 detachedPid = 0; bool adoptedExternal = false; bool started = false; QTimer *probeTimer = nullptr; QTimer *startTimer = nullptr; // fail-fast timer for QProcess::started QElapsedTimer probeStart; QPointer probeReply; QList> oneShotProbes; int generation = 0; QString lastError; QByteArray scrollback; }; ProviderLauncher::ProviderLauncher(QObject *parent) : QObject(parent) , m_nam(new QNetworkAccessManager(this)) { connect(m_nam, &QNetworkAccessManager::sslErrors, this, [this](QNetworkReply *reply, const QList &errors) { QStringList msgs; msgs.reserve(errors.size()); for (const QSslError &e : errors) msgs.append(e.errorString()); qCWarning(launcherLog).noquote() << "SSL errors on probe to" << reply->url().toString() << ":" << msgs.join(QStringLiteral("; ")); }); } ProviderLauncher::~ProviderLauncher() { m_nam->disconnect(this); for (Slot *slot : m_slots) { if (slot->cfg.detach) { if (slot->probeTimer) { slot->probeTimer->stop(); slot->probeTimer->deleteLater(); slot->probeTimer = nullptr; } if (slot->probeReply) { slot->probeReply->abort(); slot->probeReply->deleteLater(); slot->probeReply.clear(); } } else { teardownSlot(slot); } delete slot; } m_slots.clear(); } void ProviderLauncher::start(const QString &instanceName, const LaunchConfig &cfg) { if (instanceName.isEmpty() || cfg.isEmpty()) return; Slot *slot = m_slots.value(instanceName, nullptr); if (slot) { if (slot->state == Starting || slot->state == Probing || slot->state == Ready) { slot->cfg = cfg; return; } teardownSlot(slot); } else { slot = new Slot; slot->name = instanceName; m_slots.insert(instanceName, slot); } slot->cfg = cfg; slot->scrollback.clear(); slot->lastError.clear(); slot->detachedPid = 0; slot->adoptedExternal = false; slot->started = false; ++slot->generation; const int gen = slot->generation; if (!cfg.readyUrl.isEmpty()) { changeState(slot, Starting); const QString name = instanceName; const QString readyUrl = cfg.readyUrl; probeOnceAsync(slot, gen, readyUrl, [this, name, readyUrl](bool ok) { Slot *s = m_slots.value(name, nullptr); if (!s || s->state != Starting) return; if (ok) { s->adoptedExternal = true; s->detachedPid = 0; appendLog(s, QStringLiteral( "[adopt] %1 is already up — reusing the running process (no pid).") .arg(readyUrl)); changeState(s, Ready); return; } if (s->cfg.detach) launchDetached(s); else launchProcess(s); }); return; } if (cfg.detach) launchDetached(slot); else launchProcess(slot); } void ProviderLauncher::stop(const QString &instanceName) { Slot *slot = m_slots.value(instanceName, nullptr); if (!slot) return; if (slot->state == Idle || slot->state == Failed) { changeState(slot, Idle); return; } changeState(slot, Stopping); if (slot->cfg.detach) { const qint64 pid = slot->detachedPid; const QString readyUrl = slot->cfg.readyUrl; if (slot->probeTimer) { slot->probeTimer->stop(); slot->probeTimer->deleteLater(); slot->probeTimer = nullptr; } if (slot->probeReply) { slot->probeReply->abort(); slot->probeReply->deleteLater(); slot->probeReply.clear(); } slot->detachedPid = 0; slot->adoptedExternal = false; if (pid <= 0) { appendLog(slot, QStringLiteral( "[stop] no pid recorded (process was adopted via probe) — " "cannot terminate from the plugin; kill manually if needed.")); changeState(slot, Idle); return; } if (readyUrl.isEmpty()) { appendLog(slot, QStringLiteral("[stop] SIGTERM pid=%1").arg(pid)); killByPid(pid); changeState(slot, Idle); return; } const QString name = instanceName; ++slot->generation; const int gen = slot->generation; probeOnceAsync(slot, gen, readyUrl, [this, name, pid](bool stillUp) { Slot *s = m_slots.value(name, nullptr); if (!s) return; if (stillUp) { appendLog(s, QStringLiteral("[stop] SIGTERM pid=%1").arg(pid)); killByPid(pid); } else { appendLog(s, QStringLiteral( "[stop] pid=%1 no longer responsive on ready_url — " "skipping kill to avoid hitting a reused PID.").arg(pid)); } if (s->state == Stopping) changeState(s, Idle); }); return; } teardownSlot(slot); changeState(slot, Idle); } void ProviderLauncher::restart(const QString &instanceName, const LaunchConfig &cfg) { stop(instanceName); start(instanceName, cfg); } ProviderLauncher::State ProviderLauncher::state(const QString &instanceName) const { const Slot *slot = m_slots.value(instanceName, nullptr); return slot ? slot->state : Idle; } bool ProviderLauncher::isReady(const QString &instanceName) const { return state(instanceName) == Ready; } QString ProviderLauncher::lastError(const QString &instanceName) const { const Slot *slot = m_slots.value(instanceName, nullptr); return slot ? slot->lastError : QString{}; } QByteArray ProviderLauncher::scrollback(const QString &instanceName) const { const Slot *slot = m_slots.value(instanceName, nullptr); return slot ? slot->scrollback : QByteArray{}; } QStringList ProviderLauncher::activeInstances() const { QStringList out; for (auto it = m_slots.constBegin(); it != m_slots.constEnd(); ++it) { if (it.value()->state != Idle) out.append(it.key()); } std::sort(out.begin(), out.end(), [](const QString &a, const QString &b) { return a.compare(b, Qt::CaseInsensitive) < 0; }); return out; } void ProviderLauncher::launchProcess(Slot *slot) { const LaunchConfig &cfg = slot->cfg; const QString command = expandVars(cfg.command, slot); const QStringList args = expandVars(cfg.args, slot); const QString cwd = cfg.cwd.isEmpty() ? QDir::homePath() : expandVars(cfg.cwd, slot); const QString name = slot->name; auto *proc = new Utils::Process(this); slot->process = proc; Utils::Environment env = Utils::Environment::systemEnvironment(); env.set(QStringLiteral("PROVIDER_NAME"), slot->name); for (auto it = cfg.env.constBegin(); it != cfg.env.constEnd(); ++it) env.set(it.key(), it.value()); proc->setEnvironment(env); proc->setWorkingDirectory(Utils::FilePath::fromString(cwd)); proc->setCommand(Utils::CommandLine{Utils::FilePath::fromString(command), args}); proc->setPtyData(Utils::Pty::Data{}); connect(proc, &Utils::Process::readyReadStandardOutput, this, [this, name] { Slot *s = m_slots.value(name, nullptr); if (!s || !s->process) return; const QByteArray chunk = s->process->readAllRawStandardOutput(); if (!chunk.isEmpty()) { appendScrollback(s, chunk); emit bytesReceived(s->name, chunk); } }); connect(proc, &Utils::Process::readyReadStandardError, this, [this, name] { Slot *s = m_slots.value(name, nullptr); if (!s || !s->process) return; const QByteArray chunk = s->process->readAllRawStandardError(); if (!chunk.isEmpty()) { appendScrollback(s, chunk); emit bytesReceived(s->name, chunk); } }); connect(proc, &Utils::Process::started, this, [this, name] { Slot *s = m_slots.value(name, nullptr); if (!s) return; s->started = true; if (s->startTimer) { s->startTimer->stop(); s->startTimer->deleteLater(); s->startTimer = nullptr; } if (s->state != Starting) return; if (s->cfg.readyUrl.isEmpty()) { changeState(s, Ready); return; } s->probeStart.start(); changeState(s, Probing); scheduleReadyProbe(s); }); connect(proc, &Utils::Process::done, this, [this, name] { Slot *s = m_slots.value(name, nullptr); if (!s || !s->process) return; const QByteArray tailOut = s->process->readAllRawStandardOutput(); const QByteArray tailErr = s->process->readAllRawStandardError(); if (!tailOut.isEmpty()) { appendScrollback(s, tailOut); emit bytesReceived(s->name, tailOut); } if (!tailErr.isEmpty()) { appendScrollback(s, tailErr); emit bytesReceived(s->name, tailErr); } const int code = s->process->exitCode(); const QProcess::ExitStatus status = s->process->exitStatus(); appendLog(s, QStringLiteral("[exit] code=%1 status=%2") .arg(code) .arg(status == QProcess::NormalExit ? "normal" : "crashed")); const State prev = s->state; teardownSlot(s); if (prev != Stopping && code != 0) { s->lastError = QStringLiteral("Process exited (code %1)").arg(code); changeState(s, Failed); } else { changeState(s, Idle); } }); appendLog(slot, QStringLiteral("[spawn] %1 %2") .arg(command, args.join(QLatin1Char(' ')))); changeState(slot, Starting); proc->start(); if (slot->startTimer) { slot->startTimer->stop(); slot->startTimer->deleteLater(); } slot->startTimer = new QTimer(this); slot->startTimer->setSingleShot(true); const QString slotName = slot->name; connect(slot->startTimer, &QTimer::timeout, this, [this, slotName] { Slot *s = m_slots.value(slotName, nullptr); if (!s || s->started || s->state != Starting) return; s->lastError = s->process && !s->process->errorString().isEmpty() ? s->process->errorString() : QStringLiteral("Process failed to start"); appendLog(s, QStringLiteral("[error] %1").arg(s->lastError)); teardownSlot(s); changeState(s, Failed); }); slot->startTimer->start(kStartTimeout); } void ProviderLauncher::launchDetached(Slot *slot) { const LaunchConfig &cfg = slot->cfg; const QString command = expandVars(cfg.command, slot); const QStringList args = expandVars(cfg.args, slot); const QString cwd = cfg.cwd.isEmpty() ? QDir::homePath() : expandVars(cfg.cwd, slot); appendLog(slot, QStringLiteral("[spawn-detached] %1 %2") .arg(command, args.join(QLatin1Char(' ')))); QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); env.insert(QStringLiteral("PROVIDER_NAME"), slot->name); for (auto it = cfg.env.constBegin(); it != cfg.env.constEnd(); ++it) env.insert(it.key(), it.value()); QProcess tmp; tmp.setProgram(command); tmp.setArguments(args); tmp.setWorkingDirectory(cwd); tmp.setProcessEnvironment(env); tmp.setStandardOutputFile(QProcess::nullDevice()); tmp.setStandardErrorFile(QProcess::nullDevice()); qint64 pid = 0; const bool ok = tmp.startDetached(&pid); if (!ok || pid <= 0) { slot->lastError = tmp.errorString().isEmpty() ? QStringLiteral("Detached spawn failed") : tmp.errorString(); appendLog(slot, QStringLiteral("[error] %1").arg(slot->lastError)); changeState(slot, Failed); return; } slot->detachedPid = pid; appendLog(slot, QStringLiteral("[detached] pid=%1 (stdout/stderr discarded)").arg(pid)); if (cfg.readyUrl.isEmpty()) { changeState(slot, Ready); return; } slot->probeStart.start(); changeState(slot, Probing); scheduleReadyProbe(slot); } void ProviderLauncher::probeOnceAsync( Slot *slot, int expectedGeneration, const QString &url, std::function onResult) { QNetworkRequest req(QUrl{url}); req.setTransferTimeout(kAdoptionTransferTimeout); QNetworkReply *reply = m_nam->get(req); if (slot) slot->oneShotProbes.append(QPointer(reply)); const QString name = slot ? slot->name : QString{}; connect(reply, &QNetworkReply::finished, this, [this, reply, name, expectedGeneration, cb = std::move(onResult)] { reply->deleteLater(); Slot *s = m_slots.value(name, nullptr); if (s) { s->oneShotProbes.removeAll(QPointer(reply)); if (s->generation != expectedGeneration) return; } const int http = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); const bool ok = reply->error() == QNetworkReply::NoError && http >= 200 && http < 300; cb(ok); }); } void ProviderLauncher::killByPid(qint64 pid) { if (pid <= 0) return; #ifdef Q_OS_WIN HANDLE h = ::OpenProcess(PROCESS_TERMINATE, FALSE, static_cast(pid)); if (h) { ::TerminateProcess(h, 1); ::CloseHandle(h); } #else ::kill(static_cast(pid), SIGTERM); #endif } void ProviderLauncher::scheduleReadyProbe(Slot *slot) { if (!slot->probeTimer) { const QString name = slot->name; slot->probeTimer = new QTimer(this); slot->probeTimer->setSingleShot(true); connect(slot->probeTimer, &QTimer::timeout, this, [this, name] { if (Slot *s = m_slots.value(name, nullptr)) runReadyProbe(s); }); } slot->probeTimer->start(kProbeInterval); } void ProviderLauncher::runReadyProbe(Slot *slot) { if (!slot || slot->state != Probing) return; const auto elapsed = std::chrono::milliseconds{slot->probeStart.elapsed()}; if (elapsed > slot->cfg.readyTimeout) { slot->lastError = QStringLiteral("Ready probe timed out after %1 s") .arg(slot->cfg.readyTimeout.count()); appendLog(slot, QStringLiteral("[probe] timeout — %1").arg(slot->lastError)); changeState(slot, Failed); teardownSlot(slot); return; } QNetworkRequest req(QUrl{slot->cfg.readyUrl}); req.setTransferTimeout(kProbeTransferTimeout); slot->probeReply = m_nam->get(req); const QString name = slot->name; connect(slot->probeReply, &QNetworkReply::finished, this, [this, name] { Slot *s = m_slots.value(name, nullptr); if (!s || !s->probeReply) return; QNetworkReply *reply = s->probeReply; s->probeReply.clear(); reply->deleteLater(); if (s->state != Probing) return; const int http = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); if (reply->error() == QNetworkReply::NoError && http >= 200 && http < 300) { appendLog(s, QStringLiteral("[probe] %1 → %2 OK").arg(s->cfg.readyUrl).arg(http)); changeState(s, Ready); return; } if (reply->error() != QNetworkReply::NoError) { appendLog(s, QStringLiteral("[probe] %1 → %2") .arg(s->cfg.readyUrl, reply->errorString())); } scheduleReadyProbe(s); }); } void ProviderLauncher::teardownSlot(Slot *slot) { if (!slot) return; ++slot->generation; if (slot->probeTimer) { slot->probeTimer->stop(); slot->probeTimer->deleteLater(); slot->probeTimer = nullptr; } if (slot->startTimer) { slot->startTimer->stop(); slot->startTimer->deleteLater(); slot->startTimer = nullptr; } if (slot->probeReply) { slot->probeReply->abort(); slot->probeReply->deleteLater(); slot->probeReply.clear(); } for (const QPointer &probe : slot->oneShotProbes) { if (probe) { probe->abort(); probe->deleteLater(); } } slot->oneShotProbes.clear(); if (slot->process) { Utils::Process *p = slot->process; slot->process = nullptr; p->disconnect(this); if (p->state() == QProcess::NotRunning) { p->deleteLater(); } else { QObject::connect(p, &Utils::Process::done, p, &QObject::deleteLater); QTimer::singleShot(std::chrono::seconds{15}, p, &QObject::deleteLater); p->stop(); } } } void ProviderLauncher::appendLog(Slot *slot, const QString &line) { if (line.isEmpty()) return; const QByteArray bytes = (line + QStringLiteral("\r\n")).toUtf8(); appendScrollback(slot, bytes); emit bytesReceived(slot->name, bytes); } void ProviderLauncher::appendScrollback(Slot *slot, const QByteArray &chunk) { if (chunk.isEmpty()) return; slot->scrollback.append(chunk); if (slot->scrollback.size() > kScrollbackBytesMax) { const int over = slot->scrollback.size() - kScrollbackBytesMax; slot->scrollback.remove(0, over); } } void ProviderLauncher::changeState(Slot *slot, State newState) { if (slot->state == newState) return; slot->state = newState; const QString name = slot->name; qCDebug(launcherLog).noquote() << name << "→ state" << newState; emit stateChanged(name, newState); } QString ProviderLauncher::expandOne( const QString &input, const Slot *slot, const QProcessEnvironment &sys) { if (!input.contains(QLatin1String("${"))) return input; QString out = input; int searchFrom = 0; while (searchFrom < out.size()) { const int open = out.indexOf(QLatin1String("${"), searchFrom); if (open < 0) break; const int close = out.indexOf(QLatin1Char('}'), open + 2); if (close < 0) break; const QString key = out.mid(open + 2, close - open - 2); QString value; if (slot && slot->cfg.env.contains(key)) value = slot->cfg.env.value(key); else if (key == QLatin1String("PROVIDER_NAME") && slot) value = slot->name; else if (sys.contains(key)) value = sys.value(key); out.replace(open, close - open + 1, value); searchFrom = open + value.size(); } return out; } QString ProviderLauncher::expandVars(const QString &input, const Slot *slot) const { if (!input.contains(QLatin1String("${"))) return input; return expandOne(input, slot, QProcessEnvironment::systemEnvironment()); } QStringList ProviderLauncher::expandVars(const QStringList &args, const Slot *slot) const { if (args.isEmpty()) return {}; const QProcessEnvironment sys = QProcessEnvironment::systemEnvironment(); QStringList out; out.reserve(args.size()); for (const QString &a : args) out.append(expandOne(a, slot, sys)); return out; } } // namespace QodeAssist::Providers