[MG_Remote] (P6/cp): EGL control frames cross to the other process, and answer

An eglInitialize issued in one process is dispatched in another and its answer
comes back. That is the last thing standing between the session and a native
context, because the server creates its context on the apply thread when the
client's first eglMakeCurrent crosses - and until now nothing crossed.

ONE SEAM, NOT TWELVE. a6 row A4-13 counted twelve Server* forwarders, each
ending in ServerLoopInstance().RunSurfaceControlFrame(frame) with no role
branch, and concluded twelve edits. They all funnel through that one function,
so the branch goes there and the forwarders are untouched.

A HOOK, NOT A CALL. ServerLoop::SetRemoteControlSink takes a function pointer
the client installs at handshake. The obvious alternative - the server half
asking ClientSession::Active() - would have added a fifth symbol to the six
MG_Remote already owes itself (a6-link-experiment §5), in the one file with a
clean reason not to.

The branch is BEFORE the local seq mint, deliberately. RunSurfaceControlFrame
numbers unnumbered local posts; a wire request must keep the CLIENT's number all
the way through dispatch and reply or its answer cannot be correlated
(CONTRACT-P6 §6.3, which says the client mints, always).

The client sink encodes with EncodeSurfaceOpFrame, sends, and waits for the
SurfaceReply carrying its own seq - under one mutex, because the twelve
forwarders are already caller-serialised on the server's m_callerMutex under
inproc and two replies racing for one correlation is the same bug on either
side. A CapsSnapshot arriving while it waits is ADOPTED rather than dropped:
the server republishes caps from inside its MakeCurrent dispatch (a6 row A4-30)
so a snapshot legitimately precedes the reply to the op that produced it. The
wait is bounded and its expiry is NOT fatal - a peer that is gone is a different
fact from a peer that is slow, and only the doorbell's death latch may say which.

The server's pump decodes and applies through ServerApplyWireSurfaceOp, which
a6 found had no production caller anywhere. It runs on ServerMain's own thread -
NOT the apply thread - which is what §6.2 asks for: a control arrival must not
wake the applier, and frame decode must not happen on the thread holding the
native context. A frame that is not a verifiable SurfaceOp ends the session
rather than being ignored, because a silent drop is the failure mode this
project refuses everywhere else.

THE TEST'S ASSERTION IS THE OUT-PARAMETERS, not the EGL result. A headless
machine has no display to initialise, so the op is expected to be refused - and
ServerInitializeEGLDisplay writes major/minor "whenever the dispatch ran,
success or not" (the forwarder's own comment). They move off -1 if and only if
a SurfaceReply came back from the other process; a transport failure returns
before the write-back. I got this backwards first and the test said so.

Gates: ServerSpawnTest 5/5; unit suite 2345 with the same two pre-existing
environmental failures; G1 pull 0 added / 0 removed, .text 10822147 unchanged.

Not TCP. It was considered and dropped for now: TCP has no SCM_RIGHTS, so the
four segments cannot cross it, and a TCP control plane would have to refuse a
data plane by name until StreamLink exists (P6.5). Recorded here so the reason
is on file rather than re-derived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 10:50:28 -04:00
co-authored by Claude Opus 5
parent 207c31f6db
commit b0f4f8b744
6 changed files with 210 additions and 7 deletions
+32 -7
View File
@@ -38,8 +38,10 @@
#include "../Transport/FdPassing.h"
#include "../Transport/SocketTransport.h"
#include "../Transport/WireLog.h"
#include "../Protocol/SurfaceOpCodec.h"
#include "ServerLoop.h"
#include "ServerSession.h"
#include "SurfaceControlFrame.h"
#include <MG_Backend/ServerRole.h>
@@ -287,13 +289,36 @@ extern "C" __attribute__((visibility("default"))) int mobilegl_server_main(int a
static_cast<int>(result));
break;
}
// `cp` owns what a control frame MEANS. Until then the pump exists so the
// stream is drained and EOF is noticed; the echo carries this process's
// own pid so a test that talked to itself cannot pass.
std::string reply(reinterpret_cast<const char*>(buffer.data()),
static_cast<std::size_t>(size));
reply += "|server-pid=" + std::to_string(selfPid);
if (control->SendFrame(MobileGLByteSpan{reply.data(), reply.size()}) != MOBILEGL_OK) {
// `cp`: decode, apply, reply. THIS THREAD IS NOT THE APPLY THREAD, which
// is what CONTRACT-P6 §6.2 requires - a control arrival must not wake the
// applier, and frame decode must not happen on the thread holding the
// native context. ServerApplyWireSurfaceOp posts through ServerLoop's
// existing one-slot mailbox, unchanged.
const ::MobileGL::Wire::CtrlEnvelope* envelope = nullptr;
{
::flatbuffers::Verifier verifier(buffer.data(), static_cast<std::size_t>(size));
if (::MobileGL::Wire::VerifyCtrlEnvelopeBuffer(verifier)) {
envelope = ::MobileGL::Wire::GetCtrlEnvelope(buffer.data());
}
}
if (envelope == nullptr || envelope->msg_type() != ::MobileGL::Wire::CtrlMsg::SurfaceOp ||
envelope->msg_as_SurfaceOp() == nullptr) {
WireLogError("MG_Remote server: pid=%d a control frame that is not a verifiable "
"SurfaceOp arrived; ignoring it would be the silent drop this project "
"refuses, so the session ends here",
selfPid);
break;
}
Server::SurfaceControlFrame reply{};
const MobileGLResult applied =
ServerApplyWireSurfaceOp(*envelope->msg_as_SurfaceOp(), &reply);
(void)applied; // carried in the reply's ok/result, not thrown away here
::flatbuffers::FlatBufferBuilder builder(256);
EncodeSurfaceReplyFrame(reply, &builder);
if (control->SendFrame(
MobileGLByteSpan{builder.GetBufferPointer(), builder.GetSize()}) != MOBILEGL_OK) {
break;
}
}