From abe29eae2d13c1b44f068a59e2f616628b94229d Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Tue, 29 Sep 2026 10:01:45 -0400 Subject: [PATCH] [Fix] (MG_Remote, P11 PAIR): a socket client's control and aux connections pair by the nonce each presents first, not by arrival order - a readiness probe or a second client can no longer take half a session, and a client that sends its Hello first is refused by name --- CMakeLists.txt | 3 + MobileGL/MG_Remote/Server/PairAcceptor.cpp | 291 ++++++++++++++++++ MobileGL/MG_Remote/Server/PairAcceptor.h | 131 ++++++++ MobileGL/MG_Remote/Server/ServerMain.cpp | 37 ++- MobileGL/MG_Remote/Transport/PairBind.h | 55 ++++ .../MG_Remote/Transport/SocketTransport.cpp | 114 +++---- .../MG_Remote/Transport/SocketTransport.h | 38 ++- 7 files changed, 572 insertions(+), 97 deletions(-) create mode 100644 MobileGL/MG_Remote/Server/PairAcceptor.cpp create mode 100644 MobileGL/MG_Remote/Server/PairAcceptor.h create mode 100644 MobileGL/MG_Remote/Transport/PairBind.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 9ac67cc3..9dadbbd3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -656,6 +656,9 @@ if (MOBILEGL_BUILD_DISAGGREGATED) MobileGL/MG_Remote/Server/PipeApplier.cpp MobileGL/MG_Remote/Server/ServerLoop.cpp MobileGL/MG_Remote/Server/ServerMain.cpp + # P11 PAIR: a socket client's control and aux connections paired by the nonce each + # presents first (PairBind), not by arrival order - both unix supervisors take pairs here. + MobileGL/MG_Remote/Server/PairAcceptor.cpp MobileGL/MG_Remote/Server/ServerSpawn.cpp # P5f fc: the value frame that replaced the control mailbox's function-pointer payload, # and its codec against protocol.fbs's SurfaceOp/SurfaceReply (the spawn arm's seam). diff --git a/MobileGL/MG_Remote/Server/PairAcceptor.cpp b/MobileGL/MG_Remote/Server/PairAcceptor.cpp new file mode 100644 index 00000000..b4d9a38e --- /dev/null +++ b/MobileGL/MG_Remote/Server/PairAcceptor.cpp @@ -0,0 +1,291 @@ +// MobileGL - MobileGL/MG_Remote/Server/PairAcceptor.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// P11 PAIR: see PairAcceptor.h for what is paired, what is refused and why. + +#include "PairAcceptor.h" + +#if !defined(_WIN32) + +#include "../Handshake.h" // AuthenticatePeerToken / ValidatePeerHandshake, for a Hello sent first +#include "../Transport/WireLog.h" + +#include +#include +#include + +#include +#include +#include + +namespace MobileGL::MG_Remote::Server { + + namespace { + namespace Protocol = ::MobileGL::Wire; + using Transport::SocketTransport; + using Transport::TransportRole; + using Step = FirstFrameAssembler::Step; + + // A pause after accept(2) ran out of descriptors or memory: the connection waits in the + // backlog and the listener is fine (TcpSupervisor pauses the same way). + constexpr std::uint32_t kAcceptPauseMs = 100; + + std::int64_t MillisecondsUntil(PairAcceptor::Clock::time_point when, PairAcceptor::Clock::time_point now) { + return std::chrono::duration_cast(when - now).count(); + } + + // Whatever the peer already sent, read and dropped (non-blocking, bounded), so the close + // after a refusal does not become an RST that discards the refusal (ServerMain.cpp's + // DrainUnread; RefuseBusy's comment has the reason). + void DrainUnread(int fd) { + char sink[4096]; + for (int rounds = 0; rounds < 256; ++rounds) { + if (::recv(fd, sink, sizeof(sink), MSG_DONTWAIT) <= 0) return; + } + } + + void Refuse(SocketTransport& connection, Protocol::RefuseCode code, const char* detail) { + ::flatbuffers::FlatBufferBuilder builder(256); + auto refusal = Protocol::CreateRefuseDirect(builder, code, detail); + auto envelope = Protocol::CreateCtrlEnvelope(builder, Protocol::CtrlMsg::Refuse, refusal.Union()); + Protocol::FinishCtrlEnvelopeBuffer(builder, envelope); + (void)connection.SendFrame({builder.GetBufferPointer(), builder.GetSize()}); + Transport::WireLogError("MG_Remote server: Refuse{%s} %s", Protocol::EnumNameRefuseCode(code), detail); + } + + // Refuses a connection held as a bare descriptor, and closes it (with shutdown(2): here the + // descriptor is only ever the supervisor's). + void RefuseAndClose(int fd, Protocol::RefuseCode code, const char* detail) { + SocketTransport connection(fd, -1, TransportRole::Server); + DrainUnread(fd); + Refuse(connection, code, detail); + } + } // namespace + + PairAcceptor::PairAcceptor(int listenFd, std::uint32_t budgetMs) : m_listener(listenFd), m_budgetMs(budgetMs) {} + + PairAcceptor::~PairAcceptor() { + for (auto& waiting : m_waiting) Drop(waiting, true, kPairStopped); + for (const auto& pair : m_ready) { + RefuseAndClose(pair.first, Protocol::RefuseCode::Busy, kPairStopped); + ::close(pair.second); + } + } + + void PairAcceptor::CloseInForkedChild() { + for (auto& waiting : m_waiting) + if (waiting.fd >= 0) ::close(waiting.fd); + for (const auto& pair : m_ready) { + ::close(pair.first); + ::close(pair.second); + } + m_waiting.clear(); + m_ready.clear(); + } + + MobileGLResult PairAcceptor::Accept(std::uint32_t timeoutMs, std::unique_ptr& outServer) { + outServer.reset(); + if (m_listener < 0) return MOBILEGL_ERR_INVALID_ARGUMENT; + const auto until = Clock::now() + std::chrono::milliseconds(timeoutMs); + std::vector fds; + std::vector polled; // index into m_waiting of fds[1..] + for (;;) { + if (!m_ready.empty()) { + const auto pair = m_ready.front(); + m_ready.pop_front(); + outServer = std::make_unique(pair.first, pair.second, TransportRole::Server); + return MOBILEGL_OK; + } + auto now = Clock::now(); + Expire(now); + // While accepting is paused the listener sits out the poll (it stays readable and would + // spin the loop), and the pause's end is a wakeup. + const bool acceptPaused = now < m_acceptResumeAt; + fds.clear(); + polled.clear(); + fds.push_back({acceptPaused ? -1 : m_listener, POLLIN, 0}); + std::int64_t wait = std::clamp(MillisecondsUntil(until, now), 0, 1 << 30); + for (std::size_t index = 0; index < m_waiting.size(); ++index) { + const Waiting& waiting = m_waiting[index]; + wait = std::clamp(MillisecondsUntil(waiting.deadline, now) + 1, 0, wait); + // A bound connection is not read again: on control its Hello follows, and that is + // the session's to read. It only waits for its partner or its deadline. + if (waiting.bound) continue; + fds.push_back({waiting.fd, POLLIN, 0}); + polled.push_back(index); + } + if (acceptPaused) wait = std::clamp(MillisecondsUntil(m_acceptResumeAt, now) + 1, 0, wait); + const int ready = ::poll(fds.data(), static_cast(fds.size()), static_cast(wait)); + if (ready < 0 && errno != EINTR) { + Transport::WireLogError("MG_Remote server: the pairing poll failed: %s", std::strerror(errno)); + return MOBILEGL_ERR_TRANSPORT_CLOSED; + } + if (ready > 0) { + for (std::size_t slot = 0; slot < polled.size(); ++slot) + if (fds[slot + 1].revents != 0) Read(m_waiting[polled[slot]]); + Compact(); + if ((fds[0].revents & (POLLIN | POLLERR | POLLHUP)) != 0) { + const MobileGLResult accepted = AcceptNew(Clock::now()); + if (accepted != MOBILEGL_OK) return accepted; + } + } + if (m_ready.empty() && Clock::now() >= until) return MOBILEGL_ERR_TIMEOUT; + } + } + + // Accepts everything the listener has; each connection waits in the set until it identifies. + MobileGLResult PairAcceptor::AcceptNew(Clock::time_point now) { + for (int burst = 0; burst < 64; ++burst) { + int fd = -1; + const MobileGLResult accepted = SocketTransport::AcceptOne(m_listener, 0, &fd); + if (accepted == MOBILEGL_ERR_TIMEOUT) return MOBILEGL_OK; + if (accepted == MOBILEGL_ERR_OUT_OF_MEMORY) { + m_acceptResumeAt = now + std::chrono::milliseconds(kAcceptPauseMs); + MGLOG_W("MG_Remote server: accept paused for %u ms: out of descriptors or memory with %zu " + "connections waiting to be paired", + kAcceptPauseMs, m_waiting.size()); + return MOBILEGL_OK; + } + if (accepted != MOBILEGL_OK) { + Transport::WireLogError("MG_Remote server: the listener failed while pairing (rc=%d)", + static_cast(accepted)); + return MOBILEGL_ERR_TRANSPORT_CLOSED; + } + if (m_waiting.size() >= kPairMaxUnpaired) { + // The oldest connection that has not said who it is goes first; with none, the oldest. + auto victim = std::find_if(m_waiting.begin(), m_waiting.end(), + [](const Waiting& waiting) { return !waiting.bound; }); + if (victim == m_waiting.end()) victim = m_waiting.begin(); + MGLOG_W("MG_Remote server: %zu connections are waiting to be paired; the oldest %s one is dropped", + m_waiting.size(), victim->bound ? "identified" : "unidentified"); + Drop(*victim, true, kPairFull); + m_waiting.erase(victim); + } + Waiting waiting; + waiting.fd = fd; + waiting.deadline = now + std::chrono::milliseconds(m_budgetMs); + m_waiting.push_back(std::move(waiting)); + } + return MOBILEGL_OK; + } + + void PairAcceptor::Read(Waiting& waiting) { + switch (waiting.frame.Pump(waiting.fd)) { + case Step::NeedMore: + return; + case Step::Complete: + Judge(waiting); + return; + case Step::EndedEmpty: + // A readiness probe - a connect() and a close() - costs an accept and nothing else. + MGLOG_D("MG_Remote server: a connection closed before presenting a PairBind (a probe); dropped"); + ::close(waiting.fd); + waiting.fd = -1; + return; + case Step::NotAFrame: + RefuseAndClose(waiting.fd, Protocol::RefuseCode::MalformedHello, kFirstFrameNotAFrame); + waiting.fd = -1; + return; + case Step::EndedPartial: + RefuseAndClose(waiting.fd, Protocol::RefuseCode::MalformedHello, kFirstFrameTruncated); + waiting.fd = -1; + return; + } + } + + // A complete first frame. A PairBind either completes a pair or waits for its partner. + void PairAcceptor::Judge(Waiting& waiting) { + std::uint8_t nonce[Transport::kPairNonceBytes] = {}; + bool aux = false; + if (!Transport::DecodePairBind(waiting.frame.Payload(), nonce, &aux)) { + RefuseUnpairable(waiting); + return; + } + for (auto& other : m_waiting) { + if (&other == &waiting || other.fd < 0 || !other.bound || + !Transport::ConstantTimeNonceMatch(other.nonce, nonce)) + continue; + if (other.aux == aux) { + MGLOG_W("MG_Remote server: a second %s connection presented a pair nonce that is already waiting", + aux ? "aux" : "control"); + Drop(waiting, false, kPairDuplicate); + return; + } + m_ready.emplace_back(aux ? other.fd : waiting.fd, aux ? waiting.fd : other.fd); + other.fd = -1; + waiting.fd = -1; + return; + } + waiting.bound = true; + waiting.aux = aux; + std::memcpy(waiting.nonce, nonce, sizeof(nonce)); + } + + // A first frame that is not a PairBind. Nothing can be paired with it, and it is told so by name. + void PairAcceptor::RefuseUnpairable(Waiting& waiting) { + const auto& payload = waiting.frame.Payload(); + const int fd = waiting.fd; + waiting.fd = -1; + SocketTransport connection(fd, -1, TransportRole::Server); + DrainUnread(fd); + const FirstFrameShape shape = ClassifyFirstFrame(payload.data(), payload.size()); + if (shape == FirstFrameShape::Hello) { + // A client from before PairBind (control revision < 4) writes its Hello first. It is + // asked what RunSession would ask, in RunSession's order - the token, then the wire - so + // an older client hears Refuse{WireFingerprint} exactly as a session would answer it. + const auto* hello = Protocol::GetCtrlEnvelope(payload.data())->msg_as_Hello(); + if (AuthenticatePeerToken(connection, hello->token()) != MOBILEGL_OK) return; + if (ValidatePeerHandshake(connection, hello->abiMajor(), hello->abiMinor(), hello->wireFingerprint(), + hello->buildFingerprint() ? hello->buildFingerprint()->c_str() : nullptr, + hello->dialMode()) != MOBILEGL_OK) + return; + Refuse(connection, Protocol::RefuseCode::MalformedHello, kPairHelloFirst); + return; + } + if (shape == FirstFrameShape::DataBind) { + // RunSession's own words for a DataBind that reaches a listener with no session for it. + Refuse(connection, Protocol::RefuseCode::Authentication, "data connection names no live session"); + return; + } + Refuse(connection, Protocol::RefuseCode::MalformedHello, FirstFrameRefusalDetail(shape)); + } + + // Past its deadline a connection is dropped; nothing that is still inside its budget is touched. + void PairAcceptor::Expire(Clock::time_point now) { + for (auto& waiting : m_waiting) { + if (waiting.fd < 0 || now < waiting.deadline) continue; + if (waiting.bound) + MGLOG_W("MG_Remote server: a%s connection's partner never presented its pair nonce within %u ms; " + "dropped", + waiting.aux ? "n aux" : " control", m_budgetMs); + Drop(waiting, false, waiting.bound ? kPairNoPartner : kPairNoBind); + } + Compact(); + } + + void PairAcceptor::Drop(Waiting& waiting, bool busy, const char* detail) { + if (waiting.fd < 0) return; + if (waiting.bound && waiting.aux) { + ::shutdown(waiting.fd, SHUT_RDWR); + ::close(waiting.fd); + } else { + RefuseAndClose(waiting.fd, busy ? Protocol::RefuseCode::Busy : Protocol::RefuseCode::Authentication, + detail); + } + waiting.fd = -1; + } + + void PairAcceptor::Compact() { + m_waiting.erase(std::remove_if(m_waiting.begin(), m_waiting.end(), + [](const Waiting& waiting) { return waiting.fd < 0; }), + m_waiting.end()); + } + +} // namespace MobileGL::MG_Remote::Server + +#endif diff --git a/MobileGL/MG_Remote/Server/PairAcceptor.h b/MobileGL/MG_Remote/Server/PairAcceptor.h new file mode 100644 index 00000000..bc3cfbf0 --- /dev/null +++ b/MobileGL/MG_Remote/Server/PairAcceptor.h @@ -0,0 +1,131 @@ +// MobileGL - MobileGL/MG_Remote/Server/PairAcceptor.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// P11 PAIR. A SOCKET CLIENT'S TWO CONNECTIONS ARE PAIRED BY WHO THEY ARE, NOT BY WHEN THEY ARRIVED. +// +// SocketTransport::ConnectTo opens two connections to one listener - control, then aux (the +// SCM_RIGHTS socket; on tcp:// the data connection). The server used to take "the next two accepted +// connections, first is control" (SocketTransport::AcceptPair). That was a protocol only while +// nothing else could reach the listener, and on the device it was not (B0-CROSS-APP.md F2): the +// server app's readiness probe - one connect() and close() - became the next client's control +// connection, that session read the probe's socket and ended ("control peer closed before sending +// a first frame"), and the client got no Welcome. Two clients connecting at the same moment could +// just as well swap halves. +// +// Now each connection's first frame is a PairBind (protocol.fbs, control revision 4): the client's +// nonce, the same on both, and which half it is. This acceptor keeps every accepted connection in a +// small set until it has presented one, and hands out a control and an aux connection only when +// their nonces match. What it does with everything else: +// - a connection that closes having sent nothing (a readiness probe) is closed, quietly; +// - a first frame that is not a PairBind is refused by name. A Hello is a client from before +// PairBind: it is asked what the session would ask, in the session's order - the token, then the +// wire (Handshake.h) - so it hears Refuse{WireFingerprint} as it would from any session, and one +// that passes both is still refused, because it cannot be paired; +// - a connection that has not presented a PairBind, or whose partner has not presented the same +// nonce, within the budget is dropped - a control connection with a refusal, an aux connection +// (whose client reads descriptors there, not frames) by closing it - and nobody else's +// connection is touched; +// - past kPairMaxUnpaired connections the oldest one that has not identified itself (else the +// oldest) is dropped the same way. +// +// It never reads a byte past a PairBind (FirstFrameAssembler): on control the Hello follows it, and +// the session reads that itself; on aux the descriptors follow it. +// +// Stateful because pairs complete out of order: with A-control, B-control, B-aux, A-aux, B is +// handed out first and A-control has to still be here for the call that hands out A. + +#pragma once + +#if !defined(_WIN32) + +#include "../Transport/PairBind.h" +#include "../Transport/SocketTransport.h" +#include "PreAuthGate.h" // FirstFrameAssembler + +#include +#include +#include +#include +#include +#include +#include + +namespace MobileGL::MG_Remote::Server { + + // How long an accepted connection may stay unpaired: to present its PairBind, and then to be + // joined by its partner. The old AcceptPair's wait for a second connection, and ConnectTo's + // budget for opening it. + inline constexpr std::uint32_t kPairBudgetMs = 2000; + // Connections held unpaired at once. A client needs two, and the rendezvous is local. + inline constexpr std::size_t kPairMaxUnpaired = 16; + + // The refusal details a peer (and the log) sees. + inline constexpr const char* kPairNoBind = "no PairBind within the pairing budget"; + inline constexpr const char* kPairNoPartner = + "no aux connection presented this connection's pair nonce within the pairing budget"; + inline constexpr const char* kPairHelloFirst = + "the control connection's first frame is a Hello, not a PairBind (control revision 4 pairs by nonce)"; + inline constexpr const char* kPairDuplicate = "a connection with this pair nonce and role is already waiting"; + inline constexpr const char* kPairFull = "too many connections are waiting to be paired"; + inline constexpr const char* kPairStopped = "the server stopped accepting before this connection was paired"; + + class PairAcceptor { + public: + using Clock = std::chrono::steady_clock; + + // `listenFd` stays the caller's. `budgetMs` is kPairBudgetMs outside the unit test. + explicit PairAcceptor(int listenFd, std::uint32_t budgetMs = kPairBudgetMs); + // Every connection still held is let go of (kPairStopped, Busy), with shutdown(2). + ~PairAcceptor(); + PairAcceptor(const PairAcceptor&) = delete; + PairAcceptor& operator=(const PairAcceptor&) = delete; + + // One client's pair, waiting at most `timeoutMs` for it: OK with `outServer` holding the + // control connection as its stream and the aux connection; MOBILEGL_ERR_TIMEOUT when no pair + // completed in time (connections still unpaired are KEPT for the next call); and + // MOBILEGL_ERR_TRANSPORT_CLOSED when the listener itself failed. + MobileGLResult Accept(std::uint32_t timeoutMs, std::unique_ptr& outServer); + + // A forked session child's copy of every held descriptor, closed WITHOUT shutdown(2) - that + // would disconnect the supervisor's copy too - and without a word: they are the supervisor's. + void CloseInForkedChild(); + + // Connections accepted and not yet handed out as a pair. + std::size_t Unpaired() const { return m_waiting.size(); } + + private: + struct Waiting { + int fd = -1; + Clock::time_point deadline{}; + FirstFrameAssembler frame; + bool bound = false; // its PairBind has arrived + bool aux = false; + std::uint8_t nonce[Transport::kPairNonceBytes] = {}; + }; + + MobileGLResult AcceptNew(Clock::time_point now); + void Read(Waiting& waiting); + void Judge(Waiting& waiting); + void RefuseUnpairable(Waiting& waiting); + void Expire(Clock::time_point now); + // A control connection, or one that never said which it is, is refused (Busy when it was + // crowded out or the server stopped, else Authentication) and closed; an aux connection is + // closed - its client reads descriptors there, never frames. + void Drop(Waiting& waiting, bool busy, const char* detail); + void Compact(); + + int m_listener; + std::uint32_t m_budgetMs; + std::vector m_waiting; // accept order, oldest first + std::deque> m_ready; // {control, aux}, not yet handed out + Clock::time_point m_acceptResumeAt{}; + }; + +} // namespace MobileGL::MG_Remote::Server + +#endif diff --git a/MobileGL/MG_Remote/Server/ServerMain.cpp b/MobileGL/MG_Remote/Server/ServerMain.cpp index 71b5a0d1..e7921ba7 100644 --- a/MobileGL/MG_Remote/Server/ServerMain.cpp +++ b/MobileGL/MG_Remote/Server/ServerMain.cpp @@ -9,6 +9,7 @@ #include "../Handshake.h" #include "../FatalFunnel.h" #include "InProcessServer.h" +#include "PairAcceptor.h" #include "PreAuthGate.h" #include "ServerDisplay.h" #include "ServerLoop.h" @@ -340,7 +341,7 @@ void DelaySessionHandshakeForTest() { // Called only after fork (or, in the single-session shape, instead of it): the supervisor never // creates a backend or EGL context. `dataSource` is set on TCP (PH-7 (4)) and empty on a unix -// endpoint, whose second connection is the SCM_RIGHTS socket AcceptPair already paired. +// endpoint, whose second connection is the SCM_RIGHTS socket PairAcceptor already paired. // `sourceFd` is the descriptor that source reads from - the child's end of the hand-off // socketpair under --serve, the listener itself in the single-session shape - and -1 when there // is none; it is let go of the moment Accept returns, and on every exit before that @@ -1308,16 +1309,17 @@ private: }; // THE UNIX SHAPE OF THE IN-PROCESS DISPLAY SERVER (P12 D5 extended to unix endpoints). The -// endpoint is an @abstract name or a filesystem path. A client's control AND data connections -// pair by arrival order (AcceptPair) and the shm segments' descriptors cross by SCM_RIGHTS, so -// there is no DataBind to route and no pre-auth gate - both are TCP exposure machinery, and a -// unix peer is local by construction (the forked unix supervisor runs without either). Sessions -// take the TCP in-process shape's thread hand-off: one at a time, Busy for a second client, the -// latch reset between sessions, stop by g_inProcessStop. +// endpoint is an @abstract name or a filesystem path. A client's control AND aux connections +// pair by the nonce each presents first (PairAcceptor, P11 PAIR - never by arrival order) and the +// shm segments' descriptors cross by SCM_RIGHTS, so there is no DataBind to route and no pre-auth +// gate - both are TCP exposure machinery, and a unix peer is local by construction (the forked +// unix supervisor runs without either). Sessions take the TCP in-process shape's thread hand-off: +// one at a time, Busy for a second client, the latch reset between sessions, stop by +// g_inProcessStop. class UnixInProcessSupervisor { public: UnixInProcessSupervisor(int listener, unsigned long& sessionsFaulted) - : m_listener(listener), m_sessionsFaulted(sessionsFaulted) {} + : m_listener(listener), m_pairs(listener), m_sessionsFaulted(sessionsFaulted) {} ~UnixInProcessSupervisor() { // Run() only returns with the session thread joined; a listener failure (73) is the @@ -1330,7 +1332,7 @@ public: if (g_inProcessStop.load(std::memory_order_acquire)) return StopServing(); Reap(); std::unique_ptr control; - const auto accepted = SocketTransport::AcceptPair(m_listener, 250, control); + const auto accepted = m_pairs.Accept(250, control); if (accepted == MOBILEGL_ERR_TIMEOUT) continue; if (accepted != MOBILEGL_OK) { WireLogError("MG_Remote server: the in-process unix listener failed (rc=%d)", @@ -1401,6 +1403,8 @@ private: } int m_listener; + // The connections that have not been paired yet live here between Accept calls. + Server::PairAcceptor m_pairs; unsigned long& m_sessionsFaulted; std::thread m_sessionThread; std::atomic m_sessionDone{false}; @@ -1472,6 +1476,10 @@ extern "C" __attribute__((visibility("default"))) int mobilegl_server_main(int a // From here: the unix endpoint (--serve or not), and the single-session TCP shape. pid_t active = -1; const auto reap = [&]() { return ReapActiveSession(active, sessionsFaulted); }; + // P11 PAIR: a unix client's two connections, paired by the nonce each presents first. The + // connections not yet paired stay in it between calls. + std::unique_ptr pairs; + if (!tcpEndpoint) pairs = std::make_unique(listener); for (;;) { reap(); std::unique_ptr control; @@ -1481,11 +1489,14 @@ extern "C" __attribute__((visibility("default"))) int mobilegl_server_main(int a accepted = SocketTransport::AcceptOne(listener, 30000, &fd); if (accepted == MOBILEGL_OK) control = std::make_unique(fd, -1, TransportRole::Server); } else { - accepted = SocketTransport::AcceptPair(listener, serve ? 250 : 30000, control); + accepted = pairs->Accept(serve ? 250 : 30000, control); } if (accepted == MOBILEGL_ERR_TIMEOUT && serve) continue; - if (accepted != MOBILEGL_OK) { LogSupervisorSummary(sessionsFaulted); ::close(listener); return 73; } + if (accepted != MOBILEGL_OK) { pairs.reset(); LogSupervisorSummary(sessionsFaulted); ::close(listener); return 73; } if (!serve) { + // The process IS this one session: whatever else is waiting to be paired is let go of by + // name now, not held for the session's lifetime. + pairs.reset(); if (tcpEndpoint) { // The listener stays open until this session's data connection has arrived on // it; RunSession closes it the moment Accept returns. The process IS the session, @@ -1518,6 +1529,10 @@ extern "C" __attribute__((visibility("default"))) int mobilegl_server_main(int a // P12 (D8): the unix `--serve` child dies with its supervisor too. DieWithSupervisor(supervisor); ::close(listener); + // Every other waiting connection is the supervisor's: closed here without a word, or the + // supervisor's refusal of one would not end it while this session lives (TcpSupervisor's + // fork does the same with its pending peers). + if (pairs) pairs->CloseInForkedChild(); ExitSessionProcess(RunSession(std::move(control), {}, {}, -1, DataSource::None, 10000)); } if (child < 0) { diff --git a/MobileGL/MG_Remote/Transport/PairBind.h b/MobileGL/MG_Remote/Transport/PairBind.h new file mode 100644 index 00000000..915c6495 --- /dev/null +++ b/MobileGL/MG_Remote/Transport/PairBind.h @@ -0,0 +1,55 @@ +// MobileGL - MobileGL/MG_Remote/Transport/PairBind.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// P11 PAIR. The PairBind frame (protocol.fbs), both directions of it: SocketTransport::ConnectTo +// writes one as the first frame of each of a client's two connections, and Server/PairAcceptor +// reads them and pairs the two halves by the nonce. Here, not in Handshake.h beside the DataBind +// codec, because the transport itself writes it and Handshake.h brings the logger's umbrella +// header with it (WireLog.h says why Transport keeps that out). + +#pragma once + +#include "../Protocol/generated/protocol_generated.h" +#include "AuthToken.h" // kDataNonceBytes + +#include +#include +#include +#include + +namespace MobileGL::MG_Remote::Transport { + + // 128 bits from the client's CSPRNG, the width of Welcome.dataNonce, so the nonce comparison + // (ConstantTimeNonceMatch) is the same one. + inline constexpr std::size_t kPairNonceBytes = kDataNonceBytes; + + inline std::vector EncodePairBind(const std::uint8_t (&nonce)[kPairNonceBytes], bool aux) { + ::flatbuffers::FlatBufferBuilder builder(64); + auto bind = ::MobileGL::Wire::CreatePairBind(builder, builder.CreateVector(nonce, kPairNonceBytes), aux); + auto envelope = ::MobileGL::Wire::CreateCtrlEnvelope(builder, ::MobileGL::Wire::CtrlMsg::PairBind, + bind.Union()); + ::MobileGL::Wire::FinishCtrlEnvelopeBuffer(builder, envelope); + return std::vector(builder.GetBufferPointer(), builder.GetBufferPointer() + builder.GetSize()); + } + + // True when `frame` is a verifiable CtrlEnvelope carrying a PairBind whose nonce is exactly + // kPairNonceBytes; the nonce and the role are copied out. Anything else is false. + inline bool DecodePairBind(const std::vector& frame, std::uint8_t (&nonce)[kPairNonceBytes], + bool* outAux) { + if (outAux == nullptr || frame.size() < 8 || !::MobileGL::Wire::CtrlEnvelopeBufferHasIdentifier(frame.data())) + return false; + ::flatbuffers::Verifier verifier(frame.data(), frame.size()); + if (!::MobileGL::Wire::VerifyCtrlEnvelopeBuffer(verifier)) return false; + const auto* bind = ::MobileGL::Wire::GetCtrlEnvelope(frame.data())->msg_as_PairBind(); + if (bind == nullptr || bind->nonce() == nullptr || bind->nonce()->size() != kPairNonceBytes) return false; + std::memcpy(nonce, bind->nonce()->data(), kPairNonceBytes); + *outAux = bind->aux(); + return true; + } + +} // namespace MobileGL::MG_Remote::Transport diff --git a/MobileGL/MG_Remote/Transport/SocketTransport.cpp b/MobileGL/MG_Remote/Transport/SocketTransport.cpp index 937722a9..498b9375 100644 --- a/MobileGL/MG_Remote/Transport/SocketTransport.cpp +++ b/MobileGL/MG_Remote/Transport/SocketTransport.cpp @@ -11,6 +11,7 @@ #include "AuthToken.h" #include "Doorbell.h" // kWaitForever #include "FdPassing.h" +#include "PairBind.h" // P11 PAIR: ConnectTo's first frame on each connection #include "WireLog.h" #include @@ -315,6 +316,31 @@ namespace MobileGL::MG_Remote::Transport { ::usleep(5000); } } + + // One more connection to `path` whose first bytes are `payload`, framed: a DataBind + // (ConnectDataConnection) or a PairBind (ConnectTo). `what` names the frame in the log. + MobileGLResult ConnectPresenting(const std::string& path, std::uint32_t timeoutMs, const void* payload, + std::uint64_t size, const char* what, int* outFd) { + *outFd = -1; + std::vector framed; + const MobileGLResult appended = AppendFrame(framed, payload, size); + if (appended != MOBILEGL_OK) return appended; + int fd = -1; + const MobileGLResult connected = ConnectOne(path, timeoutMs, &fd); + if (connected != MOBILEGL_OK) return connected; + std::size_t written = 0; + while (written < framed.size()) { + const ssize_t n = ::send(fd, framed.data() + written, framed.size() - written, MSG_NOSIGNAL); + if (n > 0) { written += static_cast(n); continue; } + if (n < 0 && errno == EINTR) continue; + WireLogError("MG_Remote SocketTransport: the %s could not be sent (%zu of %zu bytes): %s", what, + written, framed.size(), std::strerror(errno)); + ::close(fd); + return MOBILEGL_ERR_TRANSPORT_CLOSED; + } + *outFd = fd; + return MOBILEGL_OK; + } } // namespace MobileGLResult SocketTransport::Listen(const std::string& path, int* outListenFd) { @@ -370,68 +396,30 @@ namespace MobileGL::MG_Remote::Transport { return MOBILEGL_OK; } - MobileGLResult SocketTransport::AcceptPair(int listenFd, std::uint32_t timeoutMs, - std::unique_ptr& outServer) { - outServer.reset(); - if (listenFd < 0) { - return MOBILEGL_ERR_INVALID_ARGUMENT; - } - int accepted[2] = {-1, -1}; - for (int index = 0; index < 2; ++index) { - const std::uint32_t budget = index == 0 ? timeoutMs : 2000; - for (;;) { - const int ready = WaitReadable(listenFd, budget); - if (ready <= 0) { - for (int fd : accepted) { - if (fd >= 0) ::close(fd); - } - // Half a client is not a client. Both connections or neither, so - // a peer that died between the two cannot leave a session that - // looks up but has no way to receive a descriptor. - if (index != 0 || ready < 0) WireLogError("MG_Remote SocketTransport: only %d of 2 connections arrived within " - "%u ms", index, timeoutMs); - return ready == 0 ? MOBILEGL_ERR_TIMEOUT : MOBILEGL_ERR_TRANSPORT_CLOSED; - } - accepted[index] = ::accept(listenFd, nullptr, nullptr); - if (accepted[index] >= 0) break; - // EINTR (this process reaps SIGCHLD by construction) and ECONNABORTED - // (the peer reset between poll() and accept()) are not transport - // failures: the listener is still good. Re-poll within the budget - // rather than returning TRANSPORT_CLOSED, which the --serve - // supervisor turns into a full server exit (return 73). - if (errno == EINTR || errno == ECONNABORTED) continue; - for (int fd : accepted) { - if (fd >= 0) ::close(fd); - } - WireLogError("MG_Remote SocketTransport: accept failed: %s", std::strerror(errno)); - return MOBILEGL_ERR_TRANSPORT_CLOSED; - } - ::fcntl(accepted[index], F_SETFD, FD_CLOEXEC); - if (TcpSocket(accepted[index]) && !ConfigureTcp(accepted[index])) { - for (int fd : accepted) if (fd >= 0) ::close(fd); - return MOBILEGL_ERR_UNSUPPORTED; - } - } - // First connection is control, second is aux. The order IS the protocol; - // it is stated here and in ConnectTo and nowhere else. - outServer = std::make_unique(accepted[0], accepted[1], - TransportRole::Server); - return MOBILEGL_OK; - } - MobileGLResult SocketTransport::ConnectTo(const std::string& path, std::uint32_t timeoutMs, std::unique_ptr& outClient) { outClient.reset(); + // P11 PAIR. Both connections present the same nonce as their first frame, each saying + // which half it is; the server pairs them by it (Server/PairAcceptor.h), so neither the + // order they arrive in nor anybody else's connection in between decides the pairing. + std::uint8_t nonce[kPairNonceBytes]; + const MobileGLResult minted = MintNonce(nonce, sizeof(nonce)); + if (minted != MOBILEGL_OK) return minted; + const auto controlBind = EncodePairBind(nonce, false); int control = -1; - const MobileGLResult first = ConnectOne(path, timeoutMs, &control); + const MobileGLResult first = + ConnectPresenting(path, timeoutMs, controlBind.data(), controlBind.size(), "control connection's PairBind", + &control); if (first != MOBILEGL_OK) { return first; } + const auto auxBind = EncodePairBind(nonce, true); int aux = -1; // The second connect has a SHORT budget: the server is provably up - we // just connected to it - so a slow second connection means something is // wrong rather than something is starting. - const MobileGLResult second = ConnectOne(path, 2000, &aux); + const MobileGLResult second = + ConnectPresenting(path, 2000, auxBind.data(), auxBind.size(), "aux connection's PairBind", &aux); if (second != MOBILEGL_OK) { ::close(control); return second; @@ -455,24 +443,8 @@ namespace MobileGL::MG_Remote::Transport { MobileGLByteSpan firstFrame, int* outFd) { if (outFd == nullptr || !TcpName(path)) return MOBILEGL_ERR_INVALID_ARGUMENT; *outFd = -1; - std::vector framed; - const MobileGLResult appended = AppendFrame(framed, firstFrame.data, firstFrame.size); - if (appended != MOBILEGL_OK) return appended; - int fd = -1; - const MobileGLResult connected = ConnectOne(path, timeoutMs, &fd); - if (connected != MOBILEGL_OK) return connected; - std::size_t written = 0; - while (written < framed.size()) { - const ssize_t n = ::send(fd, framed.data() + written, framed.size() - written, MSG_NOSIGNAL); - if (n > 0) { written += static_cast(n); continue; } - if (n < 0 && errno == EINTR) continue; - WireLogError("MG_Remote SocketTransport: the data connection's DataBind could not be " - "sent (%zu of %zu bytes): %s", written, framed.size(), std::strerror(errno)); - ::close(fd); - return MOBILEGL_ERR_TRANSPORT_CLOSED; - } - *outFd = fd; - return MOBILEGL_OK; + return ConnectPresenting(path, timeoutMs, firstFrame.data, firstFrame.size, "data connection's DataBind", + outFd); } MobileGLResult SocketTransport::AcceptOne(int listenFd, std::uint32_t timeoutMs, int* outFd) { @@ -484,7 +456,9 @@ namespace MobileGL::MG_Remote::Transport { if (ready < 0) return MOBILEGL_ERR_TRANSPORT_CLOSED; const int fd = ::accept(listenFd, nullptr, nullptr); if (fd < 0) { - // The same two non-failures AcceptPair re-polls on. + // EINTR (this process reaps SIGCHLD by construction) and ECONNABORTED (the peer + // reset between poll() and accept()) are not listener failures: re-poll rather + // than return TRANSPORT_CLOSED, which a supervisor turns into a full server exit (73). if (errno == EINTR || errno == ECONNABORTED) continue; const int error = errno; WireLogError("MG_Remote SocketTransport: accept failed: %s", std::strerror(error)); diff --git a/MobileGL/MG_Remote/Transport/SocketTransport.h b/MobileGL/MG_Remote/Transport/SocketTransport.h index f7d42d1b..3f9c619a 100644 --- a/MobileGL/MG_Remote/Transport/SocketTransport.h +++ b/MobileGL/MG_Remote/Transport/SocketTransport.h @@ -78,10 +78,16 @@ namespace MobileGL::MG_Remote::Transport { // offer is a sendmsg whose ancillary data rides with specific BYTES, and // interleaving those bytes with the framed control stream would make the // frame reassembler and the descriptor receiver race for the same bytes. - // So the client connects twice to the same listening path - first - // connection is control, second is aux - and the server accepts them in - // that order. One name, no extra configuration, and FdPassing keeps the - // dedicated socket it was written for. + // So the client connects twice to the same listening path - control, then + // aux - and each connection's first frame is a PairBind carrying one nonce + // and which half it is. One name, no extra configuration, and FdPassing + // keeps the dedicated socket it was written for. + // + // P11 PAIR: THE SERVER PAIRS THEM BY THAT NONCE, NOT BY ARRIVAL ORDER + // (Server/PairAcceptor.h). "First is control, second is aux" was a protocol + // only while nothing else could reach the listener: a readiness probe's + // lone connect() + close() became the next client's control connection, and + // two clients connecting at once could swap halves (B0-CROSS-APP.md F2). // // `path` is a filesystem AF_UNIX path. ARCHITECTURE.md ยง15.1 ruled // against one for the FORK shape, where the fds were inherited and a @@ -92,27 +98,26 @@ namespace MobileGL::MG_Remote::Transport { // the machine. static MobileGLResult Listen(const std::string& path, int* outListenFd); - // Accepts one client's TWO connections, in order. - static MobileGLResult AcceptPair(int listenFd, std::uint32_t timeoutMs, - std::unique_ptr& outServer); - - // The client's half: connects twice to `path`. + // The client's half: connects twice to `path`, each connection presenting a PairBind + // (the same fresh nonce; control, then aux) as its first frame. The server's half is + // Server::PairAcceptor. static MobileGLResult ConnectTo(const std::string& path, std::uint32_t timeoutMs, std::unique_ptr& outClient); // ---- PH-7 (4), ID-P7-3: a TCP data connection is BOUND, not paired -------- // - // AcceptPair/ConnectTo pair a session's two connections by ARRIVAL ORDER inside a - // window: first is control, second is data. That is a protocol only while nothing sits - // between the two processes. Over a Windows `adb forward` the two arrive reordered, the - // data connection is read as control, and every session child exits 67 waiting for a + // The server used to pair a session's two connections by ARRIVAL ORDER inside a window + // (AcceptPair): first is control, second is data. That is a protocol only while nothing + // sits between the two processes. Over a Windows `adb forward` the two arrive reordered, + // the data connection is read as control, and every session child exits 67 waiting for a // Hello that went down the other socket - and anybody who can reach the port can race a // connection into the gap and become somebody else's data plane. So on TCP the session // is ONE connection until it is authenticated; the server mints `Welcome.dataNonce`, // and the client then opens its data connection and presents the nonce as that // connection's first frame (DataBind). The server matches the value, not the order. - // Unix endpoints keep AcceptPair: their second connection is the SCM_RIGHTS socket of a - // same-user rendezvous, not a data plane anybody else can reach. + // Unix endpoints keep their two connections from the start (the second is the SCM_RIGHTS + // socket the Welcome's descriptors cross), and since P11 PAIR they too are matched by a + // value - the client's PairBind nonce - not by order. // Connects the control connection only on `tcp://`; identical to ConnectTo otherwise. static MobileGLResult ConnectControl(const std::string& path, std::uint32_t timeoutMs, @@ -122,7 +127,8 @@ namespace MobileGL::MG_Remote::Transport { // StreamLink to. static MobileGLResult ConnectDataConnection(const std::string& path, std::uint32_t timeoutMs, MobileGLByteSpan firstFrame, int* outFd); - // Accepts ONE connection (TCP options applied, CLOEXEC). MOBILEGL_ERR_TIMEOUT when none. + // Accepts ONE connection (TCP options applied, CLOEXEC; EINTR and ECONNABORTED re-polled - + // the listener is still good). MOBILEGL_ERR_TIMEOUT when none. // MOBILEGL_ERR_OUT_OF_MEMORY when accept(2) ran out of descriptors or memory (EMFILE, // ENFILE, ENOBUFS, ENOMEM): the connection stays in the backlog and the listener is fine, // so a caller that serves many peers can wait and try again (the TCP supervisor does);