From 70c45ab9ce6833651db0016782806156b4764f67 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Mon, 21 Sep 2026 10:04:31 -0400 Subject: [PATCH] [MG_Remote] (P6/sm): two MobileGL processes, talking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first time this project has had a second process. fork + execve of a real server image, the control socket surviving the exec, bytes crossing a process boundary, and the child exiting on EOF with nothing left behind. mobilegl_server_main EXISTS NOW. a6 found ARCHITECTURE.md:488-496 had described it for three phases while the symbol was nowhere in the tree, and the only MobileGLServer target was the P0 spike stub, which links no MobileGL library at all. The spike keeps its evidence and its name becomes MobileGLServerSpike; the real image links the library, so an undefined symbol is a build error rather than a dlsym failure in a child whose stderr is /dev/null. WHAT sm OWNS AND WHAT IT LEAVES: the process, the inherited descriptors, the transport and the exit discipline. Not the session, not the applier, not the four segments - wiring those from ServerMain before lk has a seam to wire them THROUGH is how call sites get written twice. The loop is a read/answer/exit-on- EOF stand-in whose SHAPE is the part that matters: one reader, EOF ends the process, and no timeout is ever allowed to stand in for the death fact (§5.4). ANTI-RECURSION IS TWO CATCHES, and neither is ARCHITECTURE.md:488's. That rule says the child hard-sets Transport to Monolith; a6 measured that 226 live lines test `Transport != Monolith` to select the SERVER arm, 148 in MG_Backend, so obeying it flips the whole backend to frontend glue in the one process with no frontend. Instead: (a) MOBILEGL_IPC_DIAL=no, structural and separate from role, and (b) MOBILEGL_TRANSPORT and every MOBILEGL_IPC_* scrubbed from the child's envp. The child refuses with 64 for (a) and 65 for (b) so the two safeties are distinguishable - two that cannot be told apart are one. TWO BUGS WORTH RECORDING, both found by the test rather than by review: dup2(n, n) IS A NO-OP THAT DOES NOT CLEAR CLOSE-ON-EXEC. FdPassing sets SOCK_CLOEXEC on the aux pair, so the child depends on dup2 to clear it - but in a gtest process a fresh socketpair lands on exactly fds 3..8, so a source fd often IS its own target. The no-op left CLOEXEC set and the following close() then shut the descriptor we had just "installed". The child came up with no socket and the parent saw TRANSPORT_CLOSED on its first send. Now spelled out rather than written cleverly. THE CHILD'S DIAGNOSTICS WERE INVISIBLE. WireLogError reaches the file sink only - Defines.h builds the logger with the console sink off - so a child that refused to start said so into a file the parent does not know the path of. ARCHITECTURE.md §15.2 warns about exactly this on Android. Both refusal paths now also write stderr directly, and both carry a distinct exit code the parent can name without reading anything. TESTS: six cases, 6/6. Each exists because something would otherwise pass silently - the reply carries the child's own getpid() so a test that talked to itself cannot pass (ID-124's lesson at the smallest scale); the process tree is COUNTED before, during and after, not promised; the child is proven not to exit while merely idle before it is proven to exit on EOF; an unresolvable image is a named refusal that spawns nothing (S1); a surviving client knob is caught by name (S3); and a descriptor crosses execve by SCM_RIGHTS, which is the mechanism the four segments will ride on and is now exercised before it is needed. Also records the asio decision in the contract: declined for P6 because spawn needs five platform mechanisms and asio covers one - its own socketpair returns operation_not_supported on Windows, it has no descriptor passing, segment delivery is POSIX-only by design, and there is no fork. Revisit at P6.5, where asio::generic::stream_protocol carries a raw family/type/protocol triple and therefore gives AF_VSOCK from one implementation. Gates: unit suite 2346, up 6, same two pre-existing environmental failures. G1 pull 0 added / 0 removed, .text size 10822147 unchanged, and zero spawn, server or SocketTransport symbols in the pull build. --- CMakeLists.txt | 60 +++- MobileGL/MG_Remote/CONTRACT-P6.md | 11 + MobileGL/MG_Remote/Server/ServerEntry.cpp | 19 ++ MobileGL/MG_Remote/Server/ServerMain.cpp | 165 +++++++++++ MobileGL/MG_Remote/Server/ServerSpawn.cpp | 332 ++++++++++++++++++++++ MobileGL/MG_Remote/Server/ServerSpawn.h | 76 +++++ MobileGL/MG_Test/Wire/CMakeLists.txt | 23 +- MobileGL/MG_Test/Wire/ServerSpawnTest.cpp | 206 ++++++++++++++ 8 files changed, 885 insertions(+), 7 deletions(-) create mode 100644 MobileGL/MG_Remote/Server/ServerEntry.cpp create mode 100644 MobileGL/MG_Remote/Server/ServerMain.cpp create mode 100644 MobileGL/MG_Remote/Server/ServerSpawn.cpp create mode 100644 MobileGL/MG_Remote/Server/ServerSpawn.h create mode 100644 MobileGL/MG_Test/Wire/ServerSpawnTest.cpp 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