From 2d372443c11d84dc3c9947761bb3ac5d78d11e32 Mon Sep 17 00:00:00 2001 From: vibesoftwarecoder Date: Thu, 10 Sep 2026 09:23:49 -0500 Subject: [PATCH] fix: find seats by probing the seat port block, not by asking the service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1. Discovery polled http://127.0.0.1:9550/api/seats and emitted every seat at 127.0.0.1. On any machine that is not the host, that address is the CLIENT, which runs no MultiSeat service -- so the request failed and was swallowed on purpose ("silent failure, will retry"). Seats never appeared, no error was shown and nothing was logged. It could only ever have worked with Moonlight running on the host itself. Seats are now found by probing the seat port block on hosts the user already has. A seat's Apollo answers /serverinfo on its own port -- exactly what adding it by hand does, and that path is known to work: verified end to end on 2026-09-10 by adding 192.168.1.46:48100, pairing and streaming. Ports come from MultiSeat's own constants (PortBase 48100, PortsPerSeat 30 -> 48100/48130/48160/48190) and a seat identifies itself by the hostname ApolloConfigBuilder gives it, MultiSeat-{Account}-{N}. Anything answering on those ports without that prefix is left alone. Why probing rather than fixing the address: - no API key on the client, and no secret to distribute - MultiSeat's ApiBindLoopbackOnly stays true; its dashboard API never has to be reachable from the LAN - no dependency on mDNS, which does NOT work for seats. A seat's Apollo logs "Registered Apollo mDNS service" but the registration never reaches the network: Apollo registers through Windows' responder instead of binding 5353 itself, and a registration made inside an RDP session does not escape it. Measured -- browsing _nvstream._tcp with a seat running returns the console Apollo and nothing else, with that same browse finding the console Apollo every time as the control. Addresses are pulled on an aboutToPoll signal rather than pushed from the host add/remove paths, which run on a thread pool under ComputerManager's write lock -- reaching back into it from there invites a deadlock. Local and manual addresses only, deduplicated: a seat and the console Apollo beside it share one address, and seat ports are never port-forwarded. Parsing reuses NvHTTP::getXmlString rather than opening a second QXmlStreamReader on the same shape of document. ⛔ NOT compiled or run: this host has no Qt toolchain (no qmake, no cmake), so CI is the only verification. The probe's server side is proven -- the seat answers /serverinfo with the expected hostname -- but the client code itself has not executed anywhere. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SQvL62WkT8xDWXqyjFCGDw --- app/backend/computermanager.cpp | 40 ++++++++++- app/backend/computermanager.h | 4 ++ app/backend/multiseatdiscovery.cpp | 104 +++++++++++++++++------------ app/backend/multiseatdiscovery.h | 60 +++++++++++++++-- 4 files changed, 158 insertions(+), 50 deletions(-) diff --git a/app/backend/computermanager.cpp b/app/backend/computermanager.cpp index 5e07591c6..7e82e0de9 100644 --- a/app/backend/computermanager.cpp +++ b/app/backend/computermanager.cpp @@ -385,12 +385,18 @@ void ComputerManager::startPolling() qWarning() << "mDNS is disabled by user preference"; } - // Start MultiSeat seat auto-discovery + // Start MultiSeat seat auto-discovery. + // + // Seats are found by probing the seat port block on hosts the user already has, not by asking + // the MultiSeat service — that only worked when Moonlight ran on the host itself. So discovery + // needs the known-host addresses, refreshed each time it polls. m_MultiSeatDiscovery = new MultiSeatDiscovery(this); connect(m_MultiSeatDiscovery, &MultiSeatDiscovery::seatFound, this, [this](QString host, uint16_t port, QString name) { addNewHost(NvAddress(host, port), false, name); }); + connect(m_MultiSeatDiscovery, &MultiSeatDiscovery::aboutToPoll, + this, &ComputerManager::updateMultiSeatProbeTargets); m_MultiSeatDiscovery->start(); // Start polling threads for each known host @@ -426,6 +432,38 @@ void ComputerManager::startPollingComputer(NvComputer* computer) } } +// Hand seat discovery the addresses of hosts the user already has. +// +// Deduplicated by address rather than by host, because a seat and the console Apollo it lives +// beside share one address — probing it twice would just double the requests for nothing. Local +// addresses only: seat ports are not port-forwarded, so probing a remote address would be four +// guaranteed failures per tick against someone else's network. +void ComputerManager::updateMultiSeatProbeTargets() +{ + if (m_MultiSeatDiscovery == nullptr) { + return; + } + + QStringList addresses; + + QReadLocker lock(&m_Lock); + QMapIterator i(m_KnownHosts); + while (i.hasNext()) { + i.next(); + NvComputer* computer = i.value(); + + QReadLocker computerLock(&computer->lock); + for (const NvAddress& candidate : { computer->localAddress, computer->manualAddress }) { + if (!candidate.isNull() && !addresses.contains(candidate.address())) { + addresses.append(candidate.address()); + } + } + } + lock.unlock(); + + m_MultiSeatDiscovery->setHostsToProbe(addresses); +} + void ComputerManager::handleMdnsServiceResolved(MdnsPendingComputer* computer, QVector& addresses) { diff --git a/app/backend/computermanager.h b/app/backend/computermanager.h index 6e43c801b..f3df8a6ce 100644 --- a/app/backend/computermanager.h +++ b/app/backend/computermanager.h @@ -261,6 +261,10 @@ private slots: void handleMdnsServiceResolved(MdnsPendingComputer* computer, QVector& addresses); + // Refresh the addresses MultiSeatDiscovery probes for seats. Driven by its aboutToPoll signal + // rather than by the host add/remove paths, which run under the write lock on a thread pool. + void updateMultiSeatProbeTargets(); + private: void saveHosts(); diff --git a/app/backend/multiseatdiscovery.cpp b/app/backend/multiseatdiscovery.cpp index b79862538..af1eb6c6e 100644 --- a/app/backend/multiseatdiscovery.cpp +++ b/app/backend/multiseatdiscovery.cpp @@ -2,16 +2,13 @@ #include #include -#include -#include -#include #include +#include "nvhttp.h" MultiSeatDiscovery::MultiSeatDiscovery(QObject* parent) : QObject(parent), m_Nam(new QNetworkAccessManager(this)), - m_Timer(new QTimer(this)), - m_RequestPending(false) + m_Timer(new QTimer(this)) { connect(m_Nam, &QNetworkAccessManager::finished, this, &MultiSeatDiscovery::handleReply); @@ -38,59 +35,80 @@ void MultiSeatDiscovery::stop() m_Timer->stop(); } +void MultiSeatDiscovery::setHostsToProbe(const QStringList& addresses) +{ + m_Hosts = addresses; +} + void MultiSeatDiscovery::poll() { - if (m_RequestPending) { - return; + // Refresh the address list first — hosts may have been added or removed since the last tick. + emit aboutToPoll(); + + // Nothing to probe until the user has a host. That is the right dependency: a seat lives on + // the same machine as the Apollo they already added, so its address is already known. + for (const QString& host : std::as_const(m_Hosts)) { + for (int seat = 0; seat < MAX_SEATS_PROBED; seat++) { + uint16_t port = static_cast(SEAT_PORT_BASE + seat * SEAT_PORT_STRIDE); + + QString key = QStringLiteral("%1:%2").arg(host).arg(port); + if (m_InFlight.contains(key)) { + // Still waiting on the previous probe of this endpoint. Skipping avoids stacking + // one request per tick against something slow or firewalled. + continue; + } + + QUrl url; + url.setScheme(QStringLiteral("http")); + url.setHost(host); + url.setPort(port); + url.setPath(QStringLiteral("/serverinfo")); + + QNetworkRequest request(url); + request.setAttribute(QNetworkRequest::ConnectionEncryptedAttribute, false); + // Do not let a probe hold a connection open; most ports probed will be closed. + request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, + QNetworkRequest::AlwaysNetwork); + + m_InFlight.insert(key); + m_Nam->get(request); + } } - - QUrl url; - url.setScheme("http"); - url.setHost("127.0.0.1"); - url.setPort(MULTISEAT_API_PORT); - url.setPath("/api/seats"); - - QNetworkRequest request(url); - request.setAttribute(QNetworkRequest::ConnectionEncryptedAttribute, false); - - m_RequestPending = true; - m_Nam->get(request); } void MultiSeatDiscovery::handleReply(QNetworkReply* reply) { - m_RequestPending = false; reply->deleteLater(); + const QUrl url = reply->url(); + const QString host = url.host(); + const uint16_t port = static_cast(url.port()); + m_InFlight.remove(QStringLiteral("%1:%2").arg(host).arg(port)); + if (reply->error() != QNetworkReply::NoError) { - // MultiSeat service not running — silent failure, will retry + // Expected for every port with no seat on it, which is most of them. Staying quiet here + // is correct — unlike the old code, silence now means "no seat on this port", not "the + // whole mechanism could never work". return; } - QJsonParseError parseError; - QJsonDocument doc = QJsonDocument::fromJson(reply->readAll(), &parseError); - if (doc.isNull() || !doc.isArray()) { - qWarning() << "MultiSeat: invalid seats response:" << parseError.errorString(); + // Apollo answers /serverinfo with XML. Read the hostname; it is what tells a seat apart from + // an unrelated Apollo that happens to sit on one of these ports. + // + // Reuses NvHTTP's parser rather than opening a second QXmlStreamReader on the same shape of + // document — one place to be wrong about Apollo's XML is enough. + const QString hostname = NvHTTP::getXmlString(QString::fromUtf8(reply->readAll()), + QStringLiteral("hostname")); + + if (hostname.isEmpty()) { return; } - QJsonArray seats = doc.array(); - for (const QJsonValue& val : std::as_const(seats)) { - if (!val.isObject()) continue; - QJsonObject seat = val.toObject(); - - QString status = seat["status"].toString(); - // Only expose seats that have Apollo running and ready for streaming - if (status != "Ready" && status != "Streaming") continue; - - int portBase = seat["portBase"].toInt(0); - if (portBase <= 0) continue; - - QString accountName = seat["accountName"].toString(); - QString displayName = accountName.isEmpty() - ? QStringLiteral("MultiSeat Seat") - : QStringLiteral("MultiSeat - %1").arg(accountName); - - emit seatFound("127.0.0.1", static_cast(portBase), displayName); + if (!hostname.startsWith(QLatin1String(SEAT_NAME_PREFIX))) { + // Something is serving here, but it is not a MultiSeat seat. Leave it alone rather than + // adding a host the user did not ask for. + return; } + + emit seatFound(host, port, hostname); } diff --git a/app/backend/multiseatdiscovery.h b/app/backend/multiseatdiscovery.h index 31a9979e1..662ac6ee3 100644 --- a/app/backend/multiseatdiscovery.h +++ b/app/backend/multiseatdiscovery.h @@ -2,17 +2,49 @@ #include #include +#include +#include #include -// Polls the local MultiSeat service API (http://localhost:9550/api/seats) -// and emits seatFound() for each active seat so ComputerManager can add it. +// Finds MultiSeat seats by probing the seat port block on hosts the user already has, and emits +// seatFound() for each one so ComputerManager can add it. +// +// ⭐ It does NOT ask the MultiSeat service. The previous implementation polled +// http://127.0.0.1:9550/api/seats, which only ever worked when Moonlight ran on the host itself — +// on any other machine that address is the CLIENT, which runs no MultiSeat service. The request +// failed and was swallowed deliberately ("silent failure, will retry"), so seats simply never +// appeared and nothing said why. See MoonlightVibe#1. +// +// Asking the service would also mean exposing MultiSeat's dashboard API to the LAN and putting its +// API key on every client. Probing needs neither: a seat's Apollo already answers /serverinfo on +// its own port, which is exactly what adding it by hand does. +// +// ⛔ mDNS is NOT an alternative here. A seat's Apollo logs "Registered Apollo mDNS service", but +// the registration never reaches the network: Apollo registers through Windows' responder rather +// than binding 5353 itself, and a registration made inside an RDP session does not escape it. +// Measured 2026-09-10 — browsing _nvstream._tcp with a seat running returns the console Apollo and +// nothing else, every time. class MultiSeatDiscovery : public QObject { Q_OBJECT public: static constexpr int POLL_INTERVAL_MS = 15000; - static constexpr int MULTISEAT_API_PORT = 9550; + + // Mirrors MultiSeat's Constants.PortBase / Constants.PortsPerSeat. Seat N answers on + // PortBase + N * PortsPerSeat, so the defaults give 48100, 48130, 48160, 48190. + // + // ⚠️ Both are configurable on the host (MultiSeat:PortBase, MultiSeat:MaxSeats) and nothing + // advertises them, so a host that has changed them needs its seats added by hand. Probing the + // default block covers the normal case without any host-side cooperation at all. + static constexpr uint16_t SEAT_PORT_BASE = 48100; + static constexpr uint16_t SEAT_PORT_STRIDE = 30; + static constexpr int MAX_SEATS_PROBED = 4; + + // A seat's Apollo is named MultiSeat-{Account}-{N} by ApolloConfigBuilder, so its /serverinfo + // hostname identifies it. This is what keeps the probe from mistaking an unrelated Apollo on a + // nearby port for a seat. + static constexpr const char* SEAT_NAME_PREFIX = "MultiSeat-"; explicit MultiSeatDiscovery(QObject* parent = nullptr); ~MultiSeatDiscovery(); @@ -20,11 +52,22 @@ class MultiSeatDiscovery : public QObject void start(); void stop(); + // Addresses to probe — the hosts the user already knows about. ComputerManager keeps this + // current; discovery has no opinion about where hosts come from. + void setHostsToProbe(const QStringList& addresses); + signals: - // Emitted for each Ready/Streaming seat found in the MultiSeat API. - // port is the Apollo HTTP discovery port (seat.portBase). + // Emitted for each seat that answers on a seat port with a MultiSeat- hostname. + // port is the seat's Apollo HTTP port (its portBase). void seatFound(QString host, uint16_t port, QString displayName); + // Raised immediately before each poll so the owner can refresh setHostsToProbe(). + // + // Pulling the addresses here rather than pushing them from every place a host is added or + // removed keeps this off ComputerManager's locking paths — the add path runs on a thread pool + // while holding the write lock, and reaching back into it from there invites a deadlock. + void aboutToPoll(); + private slots: void poll(); void handleReply(QNetworkReply* reply); @@ -32,5 +75,10 @@ private slots: private: QNetworkAccessManager* m_Nam; QTimer* m_Timer; - bool m_RequestPending; + QStringList m_Hosts; + + // Keyed by "host:port". Stops a slow or unreachable endpoint from accumulating one request + // per tick, which the old single m_RequestPending flag could not express once probes run in + // parallel. + QSet m_InFlight; };