[MG_Remote] (P6/sm): two MobileGL processes, talking

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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 10:04:31 -04:00
co-authored by Claude Opus 5
parent a351836154
commit e156ea5f7a
8 changed files with 885 additions and 7 deletions
+165
View File
@@ -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 <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#if defined(__unix__) || defined(__APPLE__) || defined(__ANDROID__)
# include <unistd.h>
#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<int>(::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<std::uint8_t> 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<std::size_t>(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<int>(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<const char*>(buffer.data()),
static_cast<std::size_t>(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);
}