diff --git a/CMakeLists.txt b/CMakeLists.txt index 1a5e1750..baf87963 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -596,6 +596,8 @@ if (MOBILEGL_BUILD_DISAGGREGATED) MobileGL/MG_Remote/Server/ServerSession.cpp MobileGL/MG_Remote/Server/PipeApplier.cpp MobileGL/MG_Remote/Server/ServerLoop.cpp + MobileGL/MG_Remote/Server/ServerMain.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). MobileGL/MG_Remote/Server/SurfaceControlFrame.cpp @@ -924,20 +926,66 @@ endif() # Off by default and ANDROID-only, so no shipping configuration builds it. The # trace flavour of the plugin APK turns it on (android-plugin/build.gradle). # --------------------------------------------------------------------------- -if (ANDROID AND MOBILEGL_BUILD_SERVER_SPIKE) +# --------------------------------------------------------------------------- +# P6 `sm`: the REAL server image. Unlike MOBILEGL_BUILD_SERVER_SPIKE below - the +# P0 spike, which links no MobileGL library and exists only to prove the Android +# delivery chain - this one links the library and runs mobilegl_server_main. +# +# ARCHITECTURE.md:496 called for a ~30-line stub that dlopen's libMobileGL.so and +# dlsym's the entry point, so one shared library serves both roles. On a desktop +# configure we link it directly instead: there is no APK packaging rule to satisfy +# and a direct link is what makes an undefined symbol a BUILD error rather than a +# runtime dlsym failure in a child whose stderr is /dev/null. +# +# Android keeps the lib*.so naming and the nativeLibraryDir output the spike +# proved is the only exec-able location in an APK. +if (MOBILEGL_BUILD_DISAGGREGATED AND NOT WIN32) add_executable(MobileGLServer + ${CMAKE_CURRENT_SOURCE_DIR}/MobileGL/MG_Remote/Server/ServerEntry.cpp) + + target_include_directories(MobileGLServer PRIVATE + ${MOBILEGL_INCLUDE_DIR}) + + target_link_libraries(MobileGLServer PRIVATE ${CMAKE_PROJECT_NAME}) + + set_target_properties(MobileGLServer PROPERTIES + POSITION_INDEPENDENT_CODE ON) + target_compile_options(MobileGLServer PRIVATE -fPIE) + target_link_options(MobileGLServer PRIVATE -pie) + + if (ANDROID) + # An APK's only exec-able install location is lib//, and the packager + # only puts a file there if it is named lib*.so. P0 spike A proved this + # works from the app's own untrusted_app domain on two devices. + set_target_properties(MobileGLServer PROPERTIES + PREFIX "lib" SUFFIX ".so" OUTPUT_NAME "MobileGLServer") + if (CMAKE_LIBRARY_OUTPUT_DIRECTORY) + set_target_properties(MobileGLServer PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_LIBRARY_OUTPUT_DIRECTORY}") + endif() + else() + # Desktop: the name the dladdr fallback looks for beside libMobileGL.so. + set_target_properties(MobileGLServer PROPERTIES + PREFIX "lib" SUFFIX ".so" OUTPUT_NAME "MobileGLServer" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}") + endif() +endif() + +if (ANDROID AND MOBILEGL_BUILD_SERVER_SPIKE) + # Renamed by P6 sm: the real server took the name it had been holding. + add_executable(MobileGLServerSpike ${CMAKE_CURRENT_SOURCE_DIR}/tools/spikes/server_stub/main.cpp) # An executable that is named like a shared library still has to be a real # PIE executable: Android has refused non-PIE executables since API 21, and # the name alone does not change what the loader demands of the file. - set_target_properties(MobileGLServer PROPERTIES + set_target_properties(MobileGLServerSpike PROPERTIES PREFIX "lib" SUFFIX ".so" - OUTPUT_NAME "MobileGLServer" + OUTPUT_NAME "MobileGLServerSpike" POSITION_INDEPENDENT_CODE ON) - target_compile_options(MobileGLServer PRIVATE -fPIE) - target_link_options(MobileGLServer PRIVATE -pie) + target_compile_options(MobileGLServerSpike PRIVATE -fPIE) + target_link_options(MobileGLServerSpike PRIVATE -pie) # AGP packages what the external native build drops into the per-ABI output # directory, and it selects by the .so extension. CMake puts executables in @@ -945,7 +993,7 @@ if (ANDROID AND MOBILEGL_BUILD_SERVER_SPIKE) # CMAKE_LIBRARY_OUTPUT_DIRECTORY, so point this target's runtime output at # the library directory when the generator gave us one. if (CMAKE_LIBRARY_OUTPUT_DIRECTORY) - set_target_properties(MobileGLServer PROPERTIES + set_target_properties(MobileGLServerSpike PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_LIBRARY_OUTPUT_DIRECTORY}") endif() endif() diff --git a/MobileGL/MG_Remote/CONTRACT-P6.md b/MobileGL/MG_Remote/CONTRACT-P6.md index ace083c4..7d8608ae 100644 --- a/MobileGL/MG_Remote/CONTRACT-P6.md +++ b/MobileGL/MG_Remote/CONTRACT-P6.md @@ -73,6 +73,17 @@ Two consequences, and the second is why this file can exist without changing beh | `SurfaceOpKind::InitCapabilities = 11` | **Appended.** The draft claimed `ServerInitCapabilities` needs no wire form because "`CapsSnapshot` answers it". The *answer* is a `CapsSnapshot`; the **request** has a live client caller (`Client/BackendObject_Remote.cpp:126`) and the codec **refuses to encode it** (`Protocol/SurfaceOpCodec.cpp:119-122`, `InprocOnlyOpOnTheWire`). A spawn client cannot ask. | unknown tag → `Fatal{ProtocolCorruption}`, never ignored | the server's control pump | `cp` | | `SurfaceReply.result: int` | **Appended, id 5.** Carries the `MobileGLResult` that `ServerApplyWireSurfaceOp` already computes and drops. Without it a client cannot tell a server that died mid-op from a legitimate refusal, and therefore cannot choose between arming the device-lost latch and returning `EGL_FALSE`. | — | the client's control demultiplexer | `hs` | | `SurfaceReply.defaultFb` | **`(deprecated)`.** The encoder passes a literal `0` offset (`SurfaceOpCodec.cpp:184`) and the decoder never reads it; `kEventSurfaceChanged` is the landed carrier (P5f `fc`). The slot is **burned, never deleted** — a deleted field frees its vtable slot for the next append. | — | nobody, by construction | `hs` | +> **asio was considered for the byte stream and declined (2026-09-21).** It is already vendored +> and on the include path, so the question is a fair one. But spawn needs five platform +> mechanisms and asio covers one: its own `socketpair` returns `operation_not_supported` on +> Windows (`3rdparty/asio/include/asio/detail/impl/socket_ops.ipp:683`), it has no descriptor +> passing at all, segment delivery is POSIX-only by design, and there is no `fork`. So adopting +> it would not make Windows work; it would only put asio headers inside `Transport/`, which +> `ITransport.h` keeps dependency-light on purpose. **Revisit at P6.5**, where +> `asio::generic::stream_protocol` carries a raw (family, type, protocol) triple and therefore +> gives AF_VSOCK from the same implementation - and where `ITransport` lets an `AsioTransport` +> land beside `SocketTransport` without touching a caller. + | `LinkTerms{dataPlane, wireForm, maxReplyBytes, cmdWindowBytes, stageWindowBytes}` | Appended to `Hello`/`Welcome`. `dataPlane` has exactly **one** legal value in P6, `SharedSegments`; `wireForm` exactly one, `StructImage`. Anything else is refused **by name**. The four sizes are **stated by the SERVER**. | any other value → `Refuse{ProtocolMismatch}` | the handshake | `hs` | | `Refuse{code, detail, peer values}` | Appended `CtrlMsg` union tag. A handshake disagreement is an **answer**, not an abort (§5.1). | — | both sides | `hs` | | `wireFingerprint` / `buildFingerprint` | The single `abiFingerprint` splits. §4. | mismatch → `Refuse`, never `Fatal` | the handshake | `hs` | diff --git a/MobileGL/MG_Remote/Server/ServerEntry.cpp b/MobileGL/MG_Remote/Server/ServerEntry.cpp new file mode 100644 index 00000000..cc2f9e26 --- /dev/null +++ b/MobileGL/MG_Remote/Server/ServerEntry.cpp @@ -0,0 +1,19 @@ +// MobileGL - MobileGL/MG_Remote/Server/ServerEntry.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 + +// The server image's main(). Package `sm`. +// +// Deliberately the smallest thing that can be one: everything it could do +// belongs to mobilegl_server_main, which lives IN THE LIBRARY so that one +// shared library serves both roles (ARCHITECTURE.md:496). Keeping this file +// empty of logic is what stops the server growing a second, divergent copy of +// bring-up that the client half never executes. + +extern "C" int mobilegl_server_main(int argc, char** argv); + +int main(int argc, char** argv) { return mobilegl_server_main(argc, argv); } diff --git a/MobileGL/MG_Remote/Server/ServerMain.cpp b/MobileGL/MG_Remote/Server/ServerMain.cpp new file mode 100644 index 00000000..6bbd1481 --- /dev/null +++ b/MobileGL/MG_Remote/Server/ServerMain.cpp @@ -0,0 +1,165 @@ +// MobileGL - MobileGL/MG_Remote/Server/ServerMain.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 + +// The server process's entry point. Package `sm` (CONTRACT-P6.md §3). +// +// ARCHITECTURE.md:488-496 has described this symbol for three phases and a6 +// found it existed nowhere in the tree: the only MobileGLServer target was the +// P0 spike stub, which links no MobileGL library at all. This is the real one. +// +// WHAT IT IS IN THIS PACKAGE. The process, the inherited descriptors, the +// control transport and the exit discipline - and nothing above them yet. It +// accepts a control connection, answers, and exits on EOF. The session, the +// applier and the four shared segments arrive with the packages that own them; +// wiring them from here before `lk` has a seam to wire them THROUGH is how the +// call sites end up written twice. +// +// WHY IT STILL EARNS ITS PLACE NOW: it is the first time two MobileGL +// processes exist and talk, and every later package needs exactly this scaffold +// to have been proven - that the image links, that execve keeps the fds, that +// the child dies when the parent goes away, and that nothing is left behind. +// +// _exit, NOT exit (CONTRACT-P6 §12.4). It decides whether g_processTeardown is +// ever set, and therefore whether seven twin destructors take their early-out +// or call into a driver whose context is already gone. The log is flushed +// explicitly first, because _exit does not run atexit handlers and a server +// that died without saying why is the diagnostic this project refuses. + +#include "../Transport/Doorbell.h" // kWaitForever +#include "../Transport/SocketTransport.h" +#include "../Transport/WireLog.h" + +#include +#include +#include +#include +#include + +#if defined(__unix__) || defined(__APPLE__) || defined(__ANDROID__) +# include +#endif + +namespace { + + constexpr int kInheritedStreamFd = 3; + constexpr int kInheritedAuxFd = 4; + + // The child must not be able to become a client. CONTRACT-P6 §3.1: this is + // catch (a), STRUCTURAL and separate from role selection, because forcing + // MG_Config::Transport to Monolith - what ARCHITECTURE.md:488 says - would + // flip 148 MG_Backend lines to the frontend arm in the one process with no + // frontend. Catch (b) is the parent's envp scrub, and S3 falsifies it by + // leaving the scrub out; the diagnostic below is what names WHICH fired. + bool DialingIsForbidden() { + const char* dial = std::getenv("MOBILEGL_IPC_DIAL"); + return dial != nullptr && std::strcmp(dial, "no") == 0; + } + + bool LooksLikeAClientEnvironment() { + // Anything the parent should have scrubbed. If one of these survived, + // catch (b) failed and we say so by name rather than quietly relying on + // catch (a) - two safeties that cannot be told apart are one safety. + static constexpr const char* kNames[] = {"MOBILEGL_TRANSPORT", "MOBILEGL_IPC_SERVER_PATH", + "MOBILEGL_IPC_RING_MB", "MOBILEGL_IPC_STAGE_MB"}; + for (const char* name : kNames) { + if (std::getenv(name) != nullptr) { + return true; + } + } + return false; + } + +} // namespace + +extern "C" __attribute__((visibility("default"))) int mobilegl_server_main(int argc, char** argv) { + (void)argc; + (void)argv; + + using namespace MobileGL::MG_Remote::Transport; + + const int selfPid = static_cast(::getpid()); + + // The arm-proof line (CONTRACT-P6 §9.5). ID-124 proved a lane can run + // monolith and pass every other gate, so each entry has to leave its own + // evidence: the pid it ran as, and the transport it ran on. + WireLogError("MG_Remote server: pid=%d transport=spawn role=server", selfPid); + + if (!DialingIsForbidden()) { + // stderr DIRECTLY, not just WireLogError: Defines.h builds the logger + // with the console sink OFF (WireLog.h says so), so an error reaches + // exactly one place - a file whose path the parent may not know. A + // server that refused to start and could not say why is the diagnostic + // this project refuses, and ARCHITECTURE.md §15.2 already warns that the + // app process's stderr is /dev/null on Android, which is why the parent + // ALSO gets this as a non-zero exit code it can name. + std::fprintf(stderr, "MG_Remote server: pid=%d refusing to start - MOBILEGL_IPC_DIAL=no " + "was not set; anti-recursion catch (a) is missing\n", selfPid); + WireLogError("MG_Remote server: pid=%d refusing to start - MOBILEGL_IPC_DIAL=no was not " + "set. The anti-recursion catch (a) is missing, which means this process was " + "not started by SpawnServer and could try to spawn a server of its own.", + selfPid); + std::fflush(nullptr); + ::_exit(64); + } + if (LooksLikeAClientEnvironment()) { + // Catch (a) already stopped us; this names catch (b)'s failure so the + // two are distinguishable, which is the whole point of having two. + std::fprintf(stderr, "MG_Remote server: pid=%d the envp scrub FAILED - a client knob " + "survived into the child; catch (a) held, catch (b) did not\n", + selfPid); + WireLogError("MG_Remote server: pid=%d the envp scrub FAILED - a MOBILEGL_TRANSPORT or " + "MOBILEGL_IPC_* survived into the child. Catch (a) held; catch (b) did not.", + selfPid); + std::fflush(nullptr); + ::_exit(65); + } + + SocketTransport control(kInheritedStreamFd, kInheritedAuxFd, TransportRole::Server); + + // The loop this package owns: read a control frame, answer it, and exit on + // EOF. `cp` replaces the body with the real surface-op pump; the SHAPE - + // one reader, EOF ends the process, no timeout decides death - is what has + // to be right now, because CONTRACT-P6 §5.4 forbids a timeout from ever + // standing in for the death fact. + std::vector buffer(64 * 1024); + for (;;) { + std::uint64_t size = 0; + MobileGLMutableByteSpan span{buffer.data(), buffer.size()}; + const MobileGLResult result = control.ReceiveFrame(span, &size, kWaitForever); + if (result == MOBILEGL_ERR_TRANSPORT_CLOSED) { + WireLogError("MG_Remote server: pid=%d peer closed the control stream; exiting", + selfPid); + break; + } + if (result == MOBILEGL_ERR_BUFFER_TOO_SMALL) { + buffer.resize(static_cast(size)); + continue; // the message is still queued: that is ITransport's contract + } + if (result != MOBILEGL_OK) { + WireLogError("MG_Remote server: pid=%d control stream failed (result=%d); exiting", + selfPid, static_cast(result)); + break; + } + + // Package sm's stand-in for the control pump: echo the frame back with + // our pid appended, so the parent's test can prove the bytes crossed a + // PROCESS boundary and not just a socket it held both ends of. + std::string reply(reinterpret_cast(buffer.data()), + static_cast(size)); + reply += "|server-pid=" + std::to_string(selfPid); + const MobileGLResult sent = + control.SendFrame(MobileGLByteSpan{reply.data(), reply.size()}); + if (sent != MOBILEGL_OK) { + break; + } + } + + control.Shutdown(); + std::fflush(nullptr); + ::_exit(0); +} diff --git a/MobileGL/MG_Remote/Server/ServerSpawn.cpp b/MobileGL/MG_Remote/Server/ServerSpawn.cpp new file mode 100644 index 00000000..4c6321f1 --- /dev/null +++ b/MobileGL/MG_Remote/Server/ServerSpawn.cpp @@ -0,0 +1,332 @@ +// MobileGL - MobileGL/MG_Remote/Server/ServerSpawn.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 + +#include "ServerSpawn.h" + +#include "../Transport/FdPassing.h" +#include "../Transport/WireLog.h" + +#include + +#include +#include +#include + +#if defined(__unix__) || defined(__APPLE__) || defined(__ANDROID__) +# define MOBILEGL_SERVER_SPAWN_POSIX 1 +# include +# include +# include +# include +# include +# include +# include +# include +#else +# define MOBILEGL_SERVER_SPAWN_POSIX 0 +#endif + +namespace MobileGL::MG_Remote::Server { + +#if MOBILEGL_SERVER_SPAWN_POSIX + + namespace { + + constexpr int kChildStreamFd = 3; + constexpr int kChildAuxFd = 4; + + bool IsExecutableFile(const std::string& path) { + if (path.empty()) { + return false; + } + struct stat st {}; + if (::stat(path.c_str(), &st) != 0) { + return false; + } + return S_ISREG(st.st_mode) && ::access(path.c_str(), X_OK) == 0; + } + + // MOBILEGL_IPC_SERVER_PATH, then dladdr beside our own library. + // The fallback exists because the integration tests link MobileGL_s + // statically and trace replay's executable does not live in the library + // directory - ARCHITECTURE.md §15.1 names both. + std::string ResolveImage(const std::string& requested) { + if (!requested.empty()) { + return requested; // caller's explicit choice; validated below + } + const std::string configured(MG_Config::Ipc.ServerPath.c_str()); + if (!configured.empty()) { + return configured; + } + Dl_info info{}; + if (::dladdr(reinterpret_cast(&ResolveImage), &info) != 0 && + info.dli_fname != nullptr) { + const std::string self(info.dli_fname); + const std::size_t slash = self.find_last_of('/'); + if (slash != std::string::npos) { + return self.substr(0, slash + 1) + "libMobileGLServer.so"; + } + } + return std::string(); + } + + // Every MOBILEGL_TRANSPORT and MOBILEGL_IPC_* goes. Built BEFORE fork: + // between fork and execve only async-signal-safe calls are allowed, and + // allocating a vector of strings is not one of them. + bool ShouldScrub(const char* entry) { + static constexpr const char* kPrefixes[] = {"MOBILEGL_TRANSPORT=", "MOBILEGL_IPC_"}; + for (const char* prefix : kPrefixes) { + if (std::strncmp(entry, prefix, std::strlen(prefix)) == 0) { + return true; + } + } + return false; + } + + struct ScrubbedEnv { + std::vector storage; + std::vector pointers; // NULL-terminated, valid while `storage` lives + int removed = 0; + }; + + ScrubbedEnv BuildChildEnv() { + // `environ` is glibc/bionic's, declared by at global + // scope. Re-declaring it inside this anonymous namespace made it a + // NEW symbol that nothing defines - which the linker caught, and + // which is worth a comment because the error names a mangled name + // that looks nothing like the variable. + ScrubbedEnv env; + for (char** e = ::environ; e != nullptr && *e != nullptr; ++e) { + if (ShouldScrub(*e)) { + ++env.removed; + continue; + } + env.storage.emplace_back(*e); + } + // (a) of the two catches: the child is told, structurally, that it + // must not dial. Separate from which ROLE it plays - CONTRACT-P6 + // §3.1 is the whole argument for why those are two axes. + env.storage.emplace_back("MOBILEGL_IPC_ROLE=server"); + env.storage.emplace_back("MOBILEGL_IPC_DIAL=no"); + env.pointers.reserve(env.storage.size() + 1); + for (auto& entry : env.storage) { + env.pointers.push_back(entry.data()); + } + env.pointers.push_back(nullptr); + return env; + } + + } // namespace + + int CountOwnChildren() { + const pid_t self = ::getpid(); + DIR* proc = ::opendir("/proc"); + if (proc == nullptr) { + return -1; // not Linux-shaped; the gate must say so rather than read 0 + } + int children = 0; + while (const dirent* entry = ::readdir(proc)) { + if (entry->d_name[0] < '0' || entry->d_name[0] > '9') { + continue; + } + std::string statPath = std::string("/proc/") + entry->d_name + "/stat"; + FILE* file = std::fopen(statPath.c_str(), "r"); + if (file == nullptr) { + continue; + } + // `comm` can contain spaces and parentheses, so parse from the LAST + // ')' rather than by field index - the classic /proc/pid/stat trap. + char buffer[512] = {}; + const std::size_t read = std::fread(buffer, 1, sizeof(buffer) - 1, file); + std::fclose(file); + if (read == 0) { + continue; + } + const char* close = std::strrchr(buffer, ')'); + if (close == nullptr) { + continue; + } + int ppid = 0; + char state = 0; + if (std::sscanf(close + 1, " %c %d", &state, &ppid) == 2 && ppid == self) { + ++children; + } + } + ::closedir(proc); + return children; + } + + MobileGLResult SpawnServer(const std::string& imagePath, SpawnedServer* out) { + if (out == nullptr) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + *out = SpawnedServer{}; + + const std::string image = ResolveImage(imagePath); + if (!IsExecutableFile(image)) { + // NAMED, never a fallback. ConfigLoader.cpp's own comment names the + // accident this prevents: a lane that asked for spawn, silently got + // monolith, and went green on the wrong arm. + Transport::WireLogError("MG_Remote spawn: the server image is not executable: \"%s\" - refusing. " + "Set MOBILEGL_IPC_SERVER_PATH, or place libMobileGLServer.so beside " + "libMobileGL.so. There is NO monolith fallback from here.", + image.empty() ? "" : image.c_str()); + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + + int stream[2] = {-1, -1}; + if (::socketpair(AF_UNIX, SOCK_STREAM, 0, stream) != 0) { + Transport::WireLogError("MG_Remote spawn: socketpair failed: %s", std::strerror(errno)); + return MOBILEGL_ERR_UNSUPPORTED; + } + int aux[2] = {-1, -1}; + if (Transport::FdPassing::CreateSocketPair(aux) != MOBILEGL_OK) { + ::close(stream[0]); + ::close(stream[1]); + return MOBILEGL_ERR_UNSUPPORTED; + } + + ScrubbedEnv env = BuildChildEnv(); + std::string argv0 = image; + char* argv[] = {argv0.data(), nullptr}; + + // A close-on-exec pipe so an execve that fails reports its errno back + // instead of vanishing: the app process's stderr is /dev/null on + // Android (ARCHITECTURE.md §15.2), so a silent exec failure would look + // exactly like a server that started and then died. + int report[2] = {-1, -1}; + if (::pipe(report) != 0) { + ::close(stream[0]); ::close(stream[1]); + ::close(aux[0]); ::close(aux[1]); + return MOBILEGL_ERR_UNSUPPORTED; + } + ::fcntl(report[1], F_SETFD, FD_CLOEXEC); + + const pid_t pid = ::fork(); + if (pid < 0) { + Transport::WireLogError("MG_Remote spawn: fork failed: %s", std::strerror(errno)); + ::close(stream[0]); ::close(stream[1]); + ::close(aux[0]); ::close(aux[1]); + ::close(report[0]); ::close(report[1]); + return MOBILEGL_ERR_UNSUPPORTED; + } + + if (pid == 0) { + // ---- child: async-signal-safe calls ONLY until execve ----------- + // + // dup2 both fixes the number and clears close-on-exec, which the aux + // socket has set (FdPassing.cpp:89-90). But a source fd may ALREADY + // BE the target number - in a gtest process fds 3..8 are exactly + // where a fresh socketpair lands - and dup2(n, n) is specified to do + // nothing at all, INCLUDING not clearing close-on-exec. Closing the + // source afterwards then closes the descriptor we just "installed". + // That is this function's one genuinely subtle line, so it is spelled + // out rather than written cleverly. + const int sources[2] = {stream[1], aux[1]}; + const int targets[2] = {kChildStreamFd, kChildAuxFd}; + for (int i = 0; i < 2; ++i) { + if (sources[i] == targets[i]) { + ::fcntl(targets[i], F_SETFD, 0); // already in place: just un-CLOEXEC it + } else { + ::dup2(sources[i], targets[i]); + } + } + // Close the originals, but never a descriptor that IS one of the two + // we just installed. + const int spare[5] = {stream[0], stream[1], aux[0], aux[1], report[0]}; + for (int fd : spare) { + if (fd != kChildStreamFd && fd != kChildAuxFd) { + ::close(fd); + } + } + ::execve(image.c_str(), argv, env.pointers.data()); + // Only reachable when execve failed. + const int failure = errno; + const ssize_t ignored = ::write(report[1], &failure, sizeof(failure)); + (void)ignored; + ::_exit(127); + } + + // ---- parent ---------------------------------------------------------- + ::close(stream[1]); + ::close(aux[1]); + ::close(report[1]); + + int childErrno = 0; + const ssize_t got = ::read(report[0], &childErrno, sizeof(childErrno)); + ::close(report[0]); + if (got == static_cast(sizeof(childErrno))) { + // The pipe only carries bytes when execve failed. + Transport::WireLogError("MG_Remote spawn: execve(\"%s\") failed in the child: %s", + image.c_str(), std::strerror(childErrno)); + ::close(stream[0]); + ::close(aux[0]); + int status = 0; + ::waitpid(pid, &status, 0); + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + + out->pid = static_cast(pid); + out->transport = std::make_unique( + stream[0], aux[0], Transport::TransportRole::Client); + Transport::WireLogError("MG_Remote spawn: server pid=%d image=\"%s\" transport=spawn " + "(env scrubbed: %d entries removed)", + out->pid, image.c_str(), env.removed); + return MOBILEGL_OK; + } + + MobileGLResult ReapServer(SpawnedServer& server, std::uint32_t timeoutMs, int* outExitCode) { + if (server.pid < 0) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + // Closing our end is what the child sees as EOF, and EOF is what it + // exits on. Doing it here rather than making the caller remember is the + // difference between "reap" and "reap, eventually, if you also did the + // other thing". + if (server.transport) { + server.transport->Shutdown(); + } + + const auto deadline = timeoutMs; + std::uint32_t waited = 0; + for (;;) { + int status = 0; + const pid_t done = ::waitpid(server.pid, &status, WNOHANG); + if (done == server.pid) { + if (outExitCode != nullptr) { + *outExitCode = WIFEXITED(status) ? WEXITSTATUS(status) : -WTERMSIG(status); + } + server.pid = -1; + return MOBILEGL_OK; + } + if (done < 0) { + return MOBILEGL_ERR_INVALID_ARGUMENT; + } + if (waited >= deadline) { + return MOBILEGL_ERR_TIMEOUT; + } + ::usleep(1000); + waited += 1; + } + } + +#else // !MOBILEGL_SERVER_SPAWN_POSIX + + MobileGLResult SpawnServer(const std::string&, SpawnedServer*) { + Transport::WireLogError("MG_Remote spawn: unsupported on this platform - P6 lands POSIX only " + "(CONTRACT-P6 §2.6); there is no fork() to build a second process from"); + return MOBILEGL_ERR_UNSUPPORTED; + } + MobileGLResult ReapServer(SpawnedServer&, std::uint32_t, int*) { + return MOBILEGL_ERR_UNSUPPORTED; + } + int CountOwnChildren() { return -1; } + +#endif + +} // namespace MobileGL::MG_Remote::Server diff --git a/MobileGL/MG_Remote/Server/ServerSpawn.h b/MobileGL/MG_Remote/Server/ServerSpawn.h new file mode 100644 index 00000000..38dad6e6 --- /dev/null +++ b/MobileGL/MG_Remote/Server/ServerSpawn.h @@ -0,0 +1,76 @@ +// MobileGL - MobileGL/MG_Remote/Server/ServerSpawn.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 + +// fork + execve of the server image, and the process discipline around it. +// Package `sm` (CONTRACT-P6.md §3). +// +// THE TWO FDS THE CHILD INHERITS, at fixed numbers: +// fd 3 the control stream (AF_UNIX SOCK_STREAM) +// fd 4 the aux socket (AF_UNIX SOCK_DGRAM, SCM_RIGHTS) +// Fixed because the child has no other way to learn them: argv would work but +// puts a file-descriptor number in a string that anything can read out of +// /proc//cmdline, and an env var would have to survive the scrub below. +// +// dup2 IS WHAT MAKES THEM INHERITABLE. FdPassing::CreateSocketPair sets +// SOCK_CLOEXEC (FdPassing.cpp:89-90), so the aux socket would vanish at execve. +// dup2 does NOT copy the close-on-exec flag - the new descriptor always has it +// clear - so duplicating onto 3 and 4 both fixes the number and clears the +// flag, in one call each. +// +// ANTI-RECURSION IS TWO INDEPENDENT CATCHES, and CONTRACT-P6 §3.1 is why it is +// not the one ARCHITECTURE.md:488 describes. That rule says the child hard-sets +// Transport to Monolith; a6 measured what that does - 226 live lines test +// `Transport != Monolith` to select the SERVER arm, 148 of them in MG_Backend, +// so forcing Monolith flips the whole backend to frontend glue in the one +// process that has no frontend. So: +// (a) STRUCTURAL: the child is told it must not dial, which is a separate +// axis from which role it plays; +// (b) ENVIRONMENTAL: MOBILEGL_TRANSPORT and every MOBILEGL_IPC_* are removed +// from the child's envp, so even a child that ignored (a) has nothing to +// dial with. +// S3 falsifies (b) by leaving the scrub out, and the diagnostic must name WHICH +// of the two fired or the double safety is untestable as two things. + +#pragma once + +#include "../Transport/SocketTransport.h" + +#include +#include + +namespace MobileGL::MG_Remote::Server { + + struct SpawnedServer { + // -1 when nothing was spawned. Non-negative means this process owes the + // child a wait(): a SpawnedServer that goes out of scope without being + // reaped leaves a zombie, which is exactly what the process-tree gate + // (CONTRACT-P6 §9.4) counts. + int pid = -1; + std::unique_ptr transport; // the PARENT's end + }; + + // Locates the server image, forks, and execs it. + // + // `imagePath` empty means "resolve it": MOBILEGL_IPC_SERVER_PATH first, then + // dladdr on our own library to find libMobileGLServer.so beside it. An + // unresolvable path is a NAMED REFUSAL and never a monolith fallback - + // ConfigLoader.cpp names that accident and this is the code that must not + // repeat it. + MobileGLResult SpawnServer(const std::string& imagePath, SpawnedServer* out); + + // Waits for the child, up to `timeoutMs`. Returns MOBILEGL_ERR_TIMEOUT if it + // is still running, in which case the caller decides whether to escalate - + // this function never signals a child it did not have to. + MobileGLResult ReapServer(SpawnedServer& server, std::uint32_t timeoutMs, int* outExitCode); + + // How many children this process currently has, by scanning for our own + // pid as a parent. The process-tree gate needs a number, not a promise: + // "exactly one while running, zero after". + int CountOwnChildren(); + +} // namespace MobileGL::MG_Remote::Server diff --git a/MobileGL/MG_Test/Wire/CMakeLists.txt b/MobileGL/MG_Test/Wire/CMakeLists.txt index f508f08d..f28a9077 100644 --- a/MobileGL/MG_Test/Wire/CMakeLists.txt +++ b/MobileGL/MG_Test/Wire/CMakeLists.txt @@ -21,10 +21,18 @@ if (NOT WIN32) # socketpair with real threads, plus the three a stream has and a message queue does not. # POSIX for the same reason as FdPassingTest - and because CONTRACT-P6 §2.6 lands POSIX only. list(APPEND MOBILEGL_WIRE_TESTS SocketTransportTest) + # P6 sm: two MobileGL processes. Needs the real server image beside it, so + # the dependency is declared after the loop rather than assumed. + list(APPEND MOBILEGL_WIRE_TESTS ServerSpawnTest) endif() include(GoogleTest) +# ServerSpawnTest execs a real second process, so the image has to exist before +# the test runs and the test has to be told where it landed. Both stated here +# rather than left to a working-directory coincidence. +set(MOBILEGL_SERVER_IMAGE_PATH "${CMAKE_BINARY_DIR}/libMobileGLServer.so") + foreach (test IN LISTS MOBILEGL_WIRE_TESTS) add_executable(${test} ${test}.cpp) @@ -39,7 +47,20 @@ foreach (test IN LISTS MOBILEGL_WIRE_TESTS) ${LINK_LIBRARIES} ) - gtest_discover_tests(${test} DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) + if (test STREQUAL "ServerSpawnTest") + # It execs the real image, so it must be built first and must be told + # where it is. MOBILEGL_TEST_SERVER_PATH is read by the test's + # ServerImage() helper; without it the test would depend on the working + # directory, which ctest does not promise. + if (TARGET MobileGLServer) + add_dependencies(${test} MobileGLServer) + endif() + gtest_discover_tests(${test} DISCOVERY_TIMEOUT 30 + PROPERTIES LABELS unit + ENVIRONMENT "MOBILEGL_TEST_SERVER_PATH=${MOBILEGL_SERVER_IMAGE_PATH}") + else () + gtest_discover_tests(${test} DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) + endif () endforeach () # P5 w1's G3 codec suite. It is registered on its own rather than in the list above because it diff --git a/MobileGL/MG_Test/Wire/ServerSpawnTest.cpp b/MobileGL/MG_Test/Wire/ServerSpawnTest.cpp new file mode 100644 index 00000000..3aa47776 --- /dev/null +++ b/MobileGL/MG_Test/Wire/ServerSpawnTest.cpp @@ -0,0 +1,206 @@ +// MobileGL - MobileGL/MG_Test/Wire/ServerSpawnTest.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 + +// Two MobileGL processes, for the first time. Package `sm`. +// +// What these cases pin is the scaffold every later P6 package stands on, and +// each assertion exists because something specific would otherwise pass +// silently: +// +// the image links - a6 found mobilegl_server_main existed NOWHERE in +// the tree, only in ARCHITECTURE.md:488-496. If +// the target failed to link, everything below is +// unreachable and the failure would read as "test +// did not run". +// the fds survive execve - FdPassing::CreateSocketPair sets SOCK_CLOEXEC, +// so the aux socket WOULD vanish. dup2 onto fixed +// numbers is what clears it; if that were ever +// changed to a plain assignment the child would +// come up with fd 4 closed and the symptom would +// be a hang, not an error. +// the bytes crossed a PROCESS - the echo carries the child's own getpid(), +// so a test that accidentally talked to itself +// cannot pass. This is ID-124's lesson applied to +// the smallest possible case. +// the process tree is clean - CONTRACT-P6 §9.4: exactly one child while +// running, zero after. Counted, not promised. +// the child dies on EOF - and on EOF ONLY. §5.4 forbids a timeout from +// ever standing in for the death fact, so the +// parent closes and the child must go by itself. + +#include +#include + +#include + +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#endif + +using namespace MobileGL::MG_Remote; + +#if !defined(_WIN32) + +namespace { + + // The image lives beside the test binary's build root. The production + // resolver (MOBILEGL_IPC_SERVER_PATH, then dladdr) is exercised separately + // below; here we want a path we control so a resolution bug cannot look + // like a spawn bug. + std::string ServerImage() { + if (const char* explicitPath = std::getenv("MOBILEGL_TEST_SERVER_PATH")) { + return explicitPath; + } + return "libMobileGLServer.so"; + } + + std::string Exchange(Transport::ITransport& transport, const std::string& message, + std::uint32_t timeoutMs = 4000) { + const MobileGLResult sent = + transport.SendFrame(MobileGLByteSpan{message.data(), message.size()}); + if (sent != MOBILEGL_OK) { + return ""; + } + std::vector buffer(64 * 1024); + std::uint64_t size = 0; + MobileGLMutableByteSpan span{buffer.data(), buffer.size()}; + const MobileGLResult got = transport.ReceiveFrame(span, &size, timeoutMs); + if (got != MOBILEGL_OK) { + return std::string("(got)) + ">"; + } + return std::string(reinterpret_cast(buffer.data()), + static_cast(size)); + } + +} // namespace + +TEST(ServerSpawnTest, SpawnsAServerProcessAndTalksToIt) { + const int before = Server::CountOwnChildren(); + ASSERT_GE(before, 0) << "/proc unreadable; the process-tree gate cannot run here"; + + Server::SpawnedServer server; + ASSERT_EQ(Server::SpawnServer(ServerImage(), &server), MOBILEGL_OK); + ASSERT_GT(server.pid, 0); + ASSERT_TRUE(server.transport); + EXPECT_NE(server.pid, static_cast(::getpid())) << "that is this process, not a child"; + + // Exactly one more child while it runs - §9.4, counted. + EXPECT_EQ(Server::CountOwnChildren(), before + 1); + + // The reply carries the CHILD's pid, computed in the child. A test that + // accidentally held both ends of one socket cannot produce this string. + const std::string reply = Exchange(*server.transport, "Hello{abiMajor=1}"); + EXPECT_EQ(reply, "Hello{abiMajor=1}|server-pid=" + std::to_string(server.pid)); + + int exitCode = -1; + EXPECT_EQ(Server::ReapServer(server, 4000, &exitCode), MOBILEGL_OK); + EXPECT_EQ(exitCode, 0) << "the child must exit cleanly on EOF, not be killed"; + EXPECT_EQ(server.pid, -1); + + // Nothing left behind. A zombie would still be counted here. + EXPECT_EQ(Server::CountOwnChildren(), before); +} + +TEST(ServerSpawnTest, TheChildExitsOnEofAndOnlyOnEof) { + Server::SpawnedServer server; + ASSERT_EQ(Server::SpawnServer(ServerImage(), &server), MOBILEGL_OK); + + // It must NOT exit just because it is idle: §5.4 forbids a timeout from + // standing in for the death fact, and a server that wandered off on its own + // would make "the peer is gone" unfalsifiable. + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + EXPECT_EQ(Server::CountOwnChildren(), 1) << "the child exited while merely idle"; + EXPECT_EQ(Exchange(*server.transport, "still-there"), + "still-there|server-pid=" + std::to_string(server.pid)); + + // Now close. EOF is the whole signal. + int exitCode = -1; + ASSERT_EQ(Server::ReapServer(server, 4000, &exitCode), MOBILEGL_OK); + EXPECT_EQ(exitCode, 0); +} + +TEST(ServerSpawnTest, ManyMessagesCrossTheProcessBoundaryInOrder) { + Server::SpawnedServer server; + ASSERT_EQ(Server::SpawnServer(ServerImage(), &server), MOBILEGL_OK); + + // Order across a real process boundary, with the reassembler on both sides. + for (int i = 0; i < 128; ++i) { + const std::string message = "msg-" + std::to_string(i); + EXPECT_EQ(Exchange(*server.transport, message), + message + "|server-pid=" + std::to_string(server.pid)); + } + + int exitCode = -1; + ASSERT_EQ(Server::ReapServer(server, 4000, &exitCode), MOBILEGL_OK); + EXPECT_EQ(exitCode, 0); +} + +TEST(ServerSpawnTest, AnUnresolvableImageIsANamedRefusalAndSpawnsNothing) { + const int before = Server::CountOwnChildren(); + + // S1 (CONTRACT-P6 §9). The accident this prevents is the one + // ConfigLoader.cpp names in its own comment: a lane that asked for spawn, + // silently got monolith, and went green on the wrong arm. + Server::SpawnedServer server; + EXPECT_EQ(Server::SpawnServer("/nonexistent/libMobileGLServer.so", &server), + MOBILEGL_ERR_INVALID_ARGUMENT); + EXPECT_EQ(server.pid, -1); + EXPECT_FALSE(server.transport) << "a refused spawn must not hand back a usable transport"; + EXPECT_EQ(Server::CountOwnChildren(), before) << "a refused spawn must leave no process"; +} + +TEST(ServerSpawnTest, TheChildsEnvironmentIsScrubbedOfEveryClientKnob) { + // Catch (b). The child refuses to start with exit 65 if any MOBILEGL_IPC_* + // or MOBILEGL_TRANSPORT survived into it, so setting one here and getting a + // clean start back is the assertion. S3 falsifies it by removing the scrub. + ::setenv("MOBILEGL_TRANSPORT", "spawn", 1); + ::setenv("MOBILEGL_IPC_RING_MB", "64", 1); + + Server::SpawnedServer server; + ASSERT_EQ(Server::SpawnServer(ServerImage(), &server), MOBILEGL_OK); + EXPECT_EQ(Exchange(*server.transport, "scrubbed"), + "scrubbed|server-pid=" + std::to_string(server.pid)) + << "the child refused to start, which means a client knob survived the scrub"; + + int exitCode = -1; + ASSERT_EQ(Server::ReapServer(server, 4000, &exitCode), MOBILEGL_OK); + EXPECT_EQ(exitCode, 0) << "exit 64 = the dial catch fired; exit 65 = the scrub leaked"; + + ::unsetenv("MOBILEGL_TRANSPORT"); + ::unsetenv("MOBILEGL_IPC_RING_MB"); +} + +TEST(ServerSpawnTest, ADescriptorCrossesToTheChildProcess) { + // The mechanism the four shared segments will ride on. Proving it now, on + // a pipe, is what stops it being first exercised on the day the data plane + // needs it - the same argument SessionRings.h makes about the attach half. + Server::SpawnedServer server; + ASSERT_EQ(Server::SpawnServer(ServerImage(), &server), MOBILEGL_OK); + + int pipeFds[2] = {-1, -1}; + ASSERT_EQ(::pipe(pipeFds), 0); + const std::string sideband = "SegmentRef{id=1,kind=Cmd}"; + EXPECT_EQ(server.transport->ShareFd(pipeFds[0], + MobileGLByteSpan{sideband.data(), sideband.size()}), + MOBILEGL_OK) + << "SCM_RIGHTS across execve: if the aux fd lost its number or kept " + "close-on-exec, this is where it shows"; + + ::close(pipeFds[0]); + ::close(pipeFds[1]); + int exitCode = -1; + ASSERT_EQ(Server::ReapServer(server, 4000, &exitCode), MOBILEGL_OK); +} + +#endif // !_WIN32