Files
MobileGL/MobileGL/MG_Remote/Server/ServerLoop.h
T

748 lines
52 KiB
C++

// MobileGL - MobileGL/MG_Remote/Server/ServerLoop.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
// The apply thread and the server's private backend object. Owner: package v1 - the highest
// risk item in P5. Signatures by c0.
//
// WHY THE THREAD IS THE POINT. DirectGLES has 16 IsBackendContextCurrentOnThisThread() guards
// (DirectGLES.cpp:12034..12428) and Managers.cpp has 16 CanTouchGLNow() guards (:1494..:3428);
// every one of them DEGRADES when the answer is false - fences become always-signaled, queries
// return null handles, Present creates no frame fence so the buffer pool's recycle watermark
// never advances, and the two persistent-map acquisitions (Managers.cpp:1494, :2170) DECLINE,
// which would make PersistentCoherentMapScenario unreachable. Making the apply thread the
// context owner for life turns all 32 of those answers true on the server and removes the
// whole degradation class at once. It is also exactly the shape P6's spawned server inherits.
//
// P5 BUILDS ONE THREAD, NOT TWO. No mgl-srv-io: inproc's control plane is in the same process.
// P6 splits it.
//
// PARKING AND SHUTDOWN. The thread parks on Doorbell::Wait(consumerParked, ready, spinUs,
// kWaitForever) and shuts down when Wait returns false with Dead() set. TWO things can un-park a
// kWaitForever waiter, not one, and the difference is the whole of review m-4: for the STOP case a
// plain Doorbell::Notify() is sufficient, because m_stopRequested is in the park predicate and
// Stop() publishes it BEFORE it rings (Doorbell.h re-tests ready() after every Park return);
// Doorbell::Kill() (Doorbell.h, CondVarDoorbell::Kill) is load-bearing only for a predicate
// that has NOTHING to see, which is why the redcheck's park-predicate-loses-control entry - the one that drops the
// control flag FROM the predicate - is the case that actually times out. Kill BEFORE join; join
// before the client frees any emitter-owned Vector; and the join must be bounded (that test uses
// 5 s) so a regression is a red test and not a hung CI job.
//
// THE EGL OWNERSHIP MOVE, AS MEASURED (ID-54; review v2 item 10 and N-3). The native
// eglMakeCurrent for a surface runs ONCE PER CONTEXT LIFETIME on this thread and the context is
// then held for life. That "once" lives in TWO layers, because one is not enough:
// DirectGLES::MakeCurrent always calls native eglMakeCurrent and rewrites the owner (codex C7),
// and it is reached twice per bring-up - once from InitPbufferSurface/InitWindowSurface when the
// surface is CREATED, and once more from the client's first eglMakeCurrent. So (1)
// ServerMakeEGLCurrent classifies the request against the tuple it last bound
// (ClassifyEglMakeCurrent): an identical repeat is a no-op, and the R-12 republish decision for
// it is "nothing to republish" (ID-67: the client's mirror generation must not move); a different
// tuple is a real forwarded bind AND a caps republish; a client release-current is RECORDED
// (ClientReleaseCount) and NOT forwarded. And (2) BackendObject_DirectGLES::MakeEGLCurrent, under
// an active transport only, skips the native call when the requested draw surface is the one
// already natively current on this thread (IsBackendContextCurrentOnThisThread, which is EGL
// ground truth) AND the virtual tuple is the one that bind was for - or the bind is the surface's
// own creation, which the first tuple adopts; a different virtual context onto the same surface
// binds natively again, because MakeCurrent's invalidations describe the frontend context that
// changed (ID-67). Measured at the EGL function
// table by ServerLoopTest's C7 control on a real llvmpipe context: surface creation + two
// identical make-currents + a client release + a bind after the release = ONE native
// eglMakeCurrent, ZERO native releases, and the apply thread still the owner afterwards. So
// DirectGLES.cpp's six cache invalidations run once per context lifetime rather than once per
// client make-current, and the 16 IsBackendContextCurrentOnThisThread() / 16 CanTouchGLNow()
// sites answer TRUE on the server. The tuple is FORGOTTEN (N-3) on every event after which the
// native context it names may be gone - ReleaseEGLResources, ReleaseEGLSurface of the surface it
// names, a surface (re)creation, backend destruction - so a recycled handle value after a destroy
// is a real bind again and never a silent no-context-current. The client's nine EGL virtuals
// become BLOCKING control requests executed here. ReleaseEGLResources and
// ~BackendObject_DirectGLES MUST be blocking: MobileGL::Destroy() (MobileGL/Init.cpp:68)
// otherwise walks on while the server still holds the context.
//
// THE FALLBACK IS PRE-DECLARED, NOT INVENTED UNDER PRESSURE (R-1). If the context migration is
// still not running ClearThenReadPixels at the end of v1's fourth working day, the integrator -
// not the package - declares `inproc-inline`: the client thread drains the ring itself, no
// thread is created, no context migrates, and a second package picks up the thread arm.
#pragma once
#include <Includes.h>
#include "ServerDisplay.h"
#include "ServerSession.h"
#include "SurfaceControlFrame.h"
#include <atomic>
#include <condition_variable>
#include <cstdint>
#include <cstring>
#include <mutex>
#include <thread>
#include <type_traits>
#include <vector>
namespace MobileGL::MG_Remote::Server {
// ---------------------------------------------------------------------------------
// THE APPLY-THREAD IDENTITY, AS ONE INTEGER (P5d round 3, package D)
// ---------------------------------------------------------------------------------
//
// OnApplyThread() is the predicate under EVERY role guard on both hot paths -
// RefuseLegacyBufferArmFromApplyThread in BufferObject's accessors, PersistentMapTracker::
// OnServerRole, ClientSession::RefusePipeInputsTouchWhileApplierOwnsIt, RunsAsTheServerRole,
// MGPipeRefuseAllocatorFromApplyThread, RefuseLegacyTextureArmFromApplyThread. simpleperf at
// head 56a77348 (FCL + Minecraft, VD12, Adreno 830) measured it at 2.66% self / 3.64%
// inclusive ON THE CLIENT THREAD, for an answer that is "no" every single time.
//
// The old body paid five times over for that "no": ServerLoopInstance()'s function-static
// guard byte, an acquire load of m_running, an acquire load of an atomic<std::thread::id>,
// std::this_thread::get_id(), and a thread::id comparison that is an out-of-line
// pthread_equal through the PLT (@plt was 6.1% of the apply thread). None of it carries
// information the guard needs. A live thread's identity is already sitting in a
// register-cheap place - the thread pointer, TPIDR_EL0 on aarch64 - so the loop publishes
// ITS identity as a plain integer here and every caller compares one relaxed load against
// one register read, inline, with no call and no singleton.
//
// ZERO MEANS "NO APPLY THREAD IS RUNNING", which is exactly the answer m_running == false
// used to give: the key is published as the apply thread's FIRST act (ApplyThreadMain) and
// cleared in the same m_controlMutex critical section that clears m_running on the way out
// (ApplyThreadMain's C2 exit block), so "key != 0" and "m_running" move together and the
// window in which a live-but-exiting apply thread answers true is byte-for-byte the old
// one. The ServerLoop object still OWNS the key - nothing but ApplyThreadMain writes it -
// it simply no longer has to be reached through the singleton to be read.
// P14 S2: AND THE ANSWER IS PER THREAD NOW, NOT PER PROCESS. One word cannot hold N apply
// threads: two sessions' threads would each publish their own key and the second store would
// make the FIRST thread answer "no" to every role guard it owns - which is not a slower guard
// but a dead one (a draw would take the client arm on the apply thread, and
// RunSurfaceControlFrame would lose its re-entrancy shortcut and deadlock the EGL teardown).
// The question is "am I an apply thread", a fact about the CALLER, so it is asked in the
// caller's own TLS - one constant-initialized word, no guard, no atomic:
//
// t_onApplyThread the calling thread IS an apply thread.
// g_applyThreadKey HOW MANY are running in this process. Kept because
// MG_Backend/DirectGLES/Managers.cpp (outside this package's file set)
// reads this name `!= 0` - "an apply thread is running somewhere" - which
// is the count's zero-ness exactly. It is no longer a key and nothing on
// the hot paths reads it.
//
// ZERO MEANS "NO APPLY THREAD IS RUNNING" still: t_onApplyThread is raised as the apply
// thread's FIRST act and lowered in the same m_controlMutex critical section that clears
// m_running on the way out, so the window in which a live-but-exiting apply thread answers
// true is byte-for-byte the old one.
//
// WHAT WENT WITH IT, so nobody re-derives it: the aarch64 TPIDR_EL0 fast path, the
// std::thread::id fallback with its no-padding static_assert, the MOBILEGL_DETAIL_
// APPLY_THREAD_POINTER build knob and the relaxed-load argument about a recycled TLS block.
// They existed to make "is the process's one apply thread mine" cheap; the question is now
// "is the caller an apply thread", whose cheap answer is the caller's own word - and a word
// cannot be recycled, because the thread that inherits a dead apply thread's TLS block is
// not an apply thread until it runs ApplyThreadMain.
namespace Detail {
// A fresh thread's TLS word is zero-initialized, so a thread that inherits the exited
// apply thread's TLS block - the recycling case the old relaxed-load argument was about -
// starts out answering false and can only become the apply thread by running
// ApplyThreadMain. There is no stale value to read and nothing to order.
inline thread_local Bool t_onApplyThread = false;
// The count of live apply threads, as a relaxed atomic: an identity-like gauge, not a
// handshake (the real ordering lives in the ring, the doorbell and m_controlMutex), and
// its one reader wants "is it zero".
inline std::atomic<Uint64> g_applyThreadKey{0};
} // namespace Detail
// ---------------------------------------------------------------------------------
// P12 (on-screen server window), D4: ONE SURFACE MODE PER SESSION
// ---------------------------------------------------------------------------------
//
// "Only one of the two rendering paths is active at a time, decided at context creation": a
// session's mode latches at its FIRST successful surface creation - a ServerOwned window makes
// it on-screen, a pbuffer makes it offscreen - and a later surface of the OTHER kind in the same
// session is refused by name (SurfaceRefusal::SurfaceModeMismatch: reply ok=false, logged, the
// session NOT latched). Windows the client names itself (the X11/Win32/None tokens CONTRACT-P6
// D8 has yet to close) are neither kind and neither latch nor are refused: nothing about them
// changes here. Reset by ServerLoop::Start, i.e. per session.
//
// P14 S2: AND THE LATCH IS PER SESSION, WHICH IS ALL THIS SLICE NEEDS. The mode is a member of
// the loop and a served session owns its loop, so two sessions in one process latch
// independently and neither's first surface decides the other's - the semantics above are
// otherwise unchanged (design/11-state-ownership.md §4.5: the latch stays at the session layer
// and is never hoisted to the process). A mode DECLARED before a session's first surface would
// have to travel on the wire - the Hello, or a SurfaceOp the client sends - because one
// process's MOBILEGL_IPC_SURFACE cannot speak for the several sessions a unified server runs;
// that is P14d's. Reading that env here instead refused a dialled-in ServerOwned client with
// SurfaceModeMismatch before the display was ever consulted (ServerOwnedSurfaceTest).
enum class SessionSurfaceMode : Uint8 { None, OnScreen, Offscreen };
const char* SessionSurfaceModeName(SessionSurfaceMode mode);
// The decision, pure, so a unit case can drive all six pairs without a backend.
Bool SessionSurfaceModeAdmits(SessionSurfaceMode current, Bool serverOwnedWindow);
// P14 S2 (SessionRuntime.h): WHICH SESSION THIS LOOP IS, when it is one of several served by
// the process. Set by ServerMain::RunSession before Start(); null for every other shape (the
// inproc one-process client+server, a unit case), which is what keeps those byte-for-byte.
class SessionRuntime;
class ServerLoop {
public:
// Creates the apply thread, names it mgl-srv-apply, applies
// MOBILEGL_IPC_SERVER_AFFINITY (borrowing ShaderCompilePool's big-core detection) and
// LOGS THE RESOLVED MASK - an affinity that silently did nothing is indistinguishable
// from one that worked, and the split's whole performance claim rests on both halves
// landing on fast cores.
MobileGLResult Start(ServerSession& session);
// P14 S2. The session this loop belongs to, so its apply thread can open the same thread
// scope the control thread has (session/loop/inputs/latch domain/segment resolver).
void SetRuntime(SessionRuntime& runtime) { m_runtime = &runtime; }
SessionRuntime* Runtime() const { return m_runtime; }
// Kill the doorbell, join the thread (bounded), then destroy the private backend object
// ON THAT THREAD before it exits. Blocking by contract - see the header note.
void Stop();
// P12 review fix: THE NEXT Stop() LEAVES THE QUEUE UNAPPLIED. A Stop() normally lets the
// apply thread finish its batch and drain what is still queued - the client's own drain
// bounds that. A SERVER that is stopping (the in-process display server's
// mobilegl_server_stop_inprocess) has a client that may still be streaming, and a batch plus
// a final drain of a ring that client keeps refilling can outlast the bounded join
// (Fatal{ApplyThreadJoinTimeout} aborts the display Activity's process). Called before Stop():
// the drain stops after the record in hand and the exit path declines the rest, as a
// ReverseChannelForfeit does. Reset by Start().
void AbandonQueuedRecords();
Bool Running() const;
// The server role's own backend object. NOT pActiveBackendObject: that global holds the
// client's BackendObject_Remote. Table 3's ruling is that the server holds its
// BackendObject_DirectGLES privately here, and that the seven backend-internal reads of
// pActiveBackendObject - ClampSamplesToBackendSupport (BackendObject_DirectGLES.cpp:815,
// :819) and five in Utils.cpp (:74, :82, :126, :220, :260), all of them format-capability
// lookups - take the format cache as a parameter instead. That is six functions across
// two files, and it is why no thread-keyed shim is needed for MOBILEGL_BUILD_DISAGGREGATED_INPROC.
MG_Backend::BackendObject* Backend();
// Run one blocking control request on the apply thread and wait for it. This is how all
// nine EGL lifecycle virtuals cross; it is deliberately NOT a queue of async messages,
// because every one of them has a return value the caller acts on immediately.
//
// THE REQUEST IS A VALUE FRAME (P5f, package fc). It used to be a raw function pointer
// plus a void* to a stack-local Args struct - neither has a meaning across a process
// boundary, which is the whole of P5c audit row G4. Now the slot carries one
// SurfaceControlFrame BY VALUE: the op's identity is the frame's `kind`, its arguments
// are scalars, and its reply fields come back in the same value when the wait returns.
// What did NOT move: the blocking handshake, the one slot, and the apply-thread
// ownership. Under spawn the same frame is what SurfaceOpCodec encodes into a
// Wire::SurfaceOp - this signature is the seam both transports share.
// P6 `cp`: where a control frame goes when the server is ANOTHER PROCESS.
//
// All twelve Server* forwarders funnel through RunSurfaceControlFrame, so
// this is ONE seam rather than twelve (a6 row A4-13 counted them). The
// client installs a sink at handshake; with none installed the behaviour
// is exactly what it was - post into the one-slot mailbox, or run inline
// when we are already on the apply thread.
//
// A HOOK RATHER THAN A CALL INTO ClientSession, deliberately: the server
// half calling the client half is four of the six symbols a6 found
// MG_Remote owes itself (a6-link-experiment §5), and this is the one
// place that would have added a fifth.
using RemoteControlSink = MobileGLResult (*)(void* user, SurfaceControlFrame& frame);
void SetRemoteControlSink(RemoteControlSink sink, void* user);
MobileGLResult RunSurfaceControlFrame(SurfaceControlFrame& frame);
// The TEST seam through the same channel: posts a ProbeForTesting frame whose dispatch
// runs the given hook on the apply thread. A raw function pointer held in a MEMBER (not
// in the mailbox slot, which carries the frame and nothing else), because the suites that
// drive guards and Fatal arms need arbitrary work on the apply thread and the teardown
// path's no-allocation rule (ID-8) forbids a std::function here. Same discipline as
// SetBeforeRetireHookForTesting.
using ControlProbeHook = MobileGLResult (*)(void* user);
MobileGLResult RunProbeOnApplyThreadForTesting(ControlProbeHook hook, void* user);
// P7 (p7/spawnhang). WHAT A POSTER SAYS WHILE THE APPLY THREAD RUNS ITS FRAME.
//
// A posted frame is waited for in slices of kControlProgressIntervalMs, and after every
// slice in which the apply thread is RUNNING it - taken, not yet answered - the poster calls
// this sink with the frame's kind, its seq and how long ago it was posted, WITHOUT
// m_controlMutex held. ServerMain installs one that sends Wire::SurfaceProgress on the
// control connection: that is how a spawn / TCP client tells a server BUSY with its op (a
// cold native bring-up - eglInitialize loading a software rasteriser off a cold disk ran
// ~20 s on a CI runner) from a SILENT one, and restarts its reply budget. A frame posted but
// NOT TAKEN reports nothing, because that silence is exactly what the client's budget
// exists to name. Null (inproc, and every case that does not set one): the slices are
// waited out and nothing is said. Read on the posting thread, set from any (both under
// m_controlMutex).
using ControlProgressSink = void (*)(void* user, SurfaceControlOp kind, Uint64 seq, Uint32 elapsedMs);
void SetControlProgressSink(ControlProgressSink sink, void* user);
// Well inside the smallest default reply budget (MOBILEGL_IPC_CONTROL_TIMEOUT_MS, 5000),
// and a frame's worth of bytes per interval only while an op runs that long.
static constexpr Uint32 kControlProgressIntervalMs = 250;
// A dispatch that runs at least this long is logged, with its op, seq and duration, on the
// apply thread when it returns - the line the retrace-split investigation did not have.
static constexpr Uint32 kSlowControlDispatchMs = 1000;
// How many frames this loop has dispatched (every kind, probe included). Reset by
// Start(). The frame channel's own red-once handle: a forwarder that stopped posting
// frames leaves this unmoved.
Uint64 ControlFramesDispatched() const;
// ---- v1's additions beyond c0's signature block ---------------------------------
// Constructs the SERVER ROLE's private BackendObject and runs its non-GL Initialize().
// Called from MG_Backend::Init()'s single hook, BEFORE ClientSession::Start(), because
// ServerSession::Accept() publishes the first CapsSnapshot from it and because the two
// CallMask halves - which have no default and Fatal when unset (ServerSession.h) - are
// answered from what this backend is.
//
// NO GL AND NO EGL HAPPENS HERE. BackendObject_DirectGLES::Initialize() loads the
// driver's entry points; the native context does not exist until the client's first
// eglMakeCurrent crosses as a blocking control request and runs on the apply thread.
// That split is what lets the backend object be BUILT on the app thread while its
// context is never OWNED by it.
MobileGLResult CreateBackend(BackendType type);
// True on the apply thread itself. RunSurfaceControlFrame uses it to run inline rather
// than deadlock when the apply thread posts to itself - which the EGL teardown path does,
// because ~BackendObject_DirectGLES runs THERE and reaches ReleaseEGLResources.
//
// INLINE, AND THAT IS THE POINT (P5d round 3, package D): see the Detail block above.
// Every role guard in MG_State, MG_Impl, MG_Pipe and MG_Remote/Client asks this once
// or more per draw, so the call itself - a cross-library PLT hop - was a measurable
// share of the cost of the answer.
static Bool OnApplyThread() {
// P14 S2: the caller's own TLS word. One constant-initialized load, no atomic, no
// comparison and - unlike the process-wide key - the right answer with N sessions.
return Detail::t_onApplyThread;
}
// The CPU mask MOBILEGL_IPC_SERVER_AFFINITY resolved to and sched_setaffinity accepted.
// 0 means "no affinity was applied" - the honest answer for `off`, for a platform with
// no affinity call, and for a failed syscall - and is exactly why the RESOLVED mask is
// logged rather than the string an operator typed.
Uint64 ResolvedAffinityMask() const;
// Diagnostics the tests read. DrainedRecords is how many records this thread has handed
// to the applier; ParkCount how many times it actually parked. A shutdown test that
// asserts only "Stop() returned" cannot tell a thread that parked and was woken by Kill
// from one that never parked at all - which is the R-16 shape of a check that cannot
// fail for its own reason.
Uint64 DrainedRecords() const;
Uint64 ParkCount() const;
// How many of those waits ran out of spin budget and really blocked. ParkCount is an
// INTENTION (counted on the way toward a park, C10's note); this is the outcome, and
// the pair is what says whether MOBILEGL_IPC_SPIN_US is sized for the workload. It is
// this loop's OWN tally rather than the consumer bell's ParkEntries() because a bell
// belongs to an endpoint and counts every waiter on it; only a tally passed into the
// one Wait this loop makes is guaranteed to stay a subset of ParkCount().
Uint64 ParkBlockCount() const;
// Scheduling perturbation only: the hook runs after application, before retirement.
// Integration tests use it to observe real producer back-pressure from GL uploads.
void SetBeforeRetireHookForTesting(void (*hook)()) {
m_beforeRetireHook.store(hook, std::memory_order_release);
}
// Scheduling point only, same discipline: DrainRing runs the hook on the apply thread
// BETWEEN two records - after a popped record's own checks, before the next pop's latch
// check - and never on an empty-ring poll. ServerLoopLatchTest latches from a second
// thread there, which is the interleaving codex closeout finding 6 named.
void SetBetweenRecordsHookForTesting(void (*hook)()) {
m_betweenRecordsHook.store(hook, std::memory_order_release);
}
// C7 / ID-54 diagnostics, read by ServerLoopTest's C7 and N-3 controls. NativeBindCount is
// how many times ApplyMakeCurrent FORWARDED a bind to the backend (a tuple it did not
// hold); ClientReleaseCount how many client release-current requests were recorded and
// not forwarded. Two identical binds must move the first by one and the second not at
// all. The number of native eglMakeCurrent calls the DRIVER saw is a different number -
// the backend object skips the native call for a surface already current (header block)
// - and the control reads that one at the EGL function table, not here.
Uint64 NativeBindCount() const;
Uint64 ClientReleaseCount() const;
// ID-67: how many caps snapshots ServerMakeEGLCurrent has re-published (R-12 arm (a)) - one
// per forwarded bind of a tuple it did not hold, never for an identical repeat, so the
// client's mirror generation moves exactly when the server's answers could have.
Uint64 MakeCurrentRepublishCount() const;
void NoteMakeCurrentRepublished();
// The deduped make-current, on the apply thread. Classifies the request (see
// ClassifyEglMakeCurrent), forwards a native bind only for a genuinely new tuple, records
// a release without forwarding it, and returns whether a native bind happened so the
// caller (ServerMakeEGLCurrent) knows whether to re-publish the caps snapshot (R-12).
struct MakeCurrentOutcome {
Bool ok = false; // the request was honoured
Bool boundNatively = false; // a native eglMakeCurrent ran (=> republish caps)
};
MakeCurrentOutcome ApplyMakeCurrent(MG_Backend::BackendObject* backend, EGLDisplay dpy,
EGLSurface draw, EGLSurface read, EGLContext ctx);
// N-3: forget the tuple ApplyMakeCurrent last bound. Apply thread only, like the tuple
// itself (the forwarders that call these run their Args::Run there). Called on every
// event after which the native context that tuple named may no longer exist -
// ReleaseEGLResources, ReleaseEGLSurface of a surface the tuple names, a surface
// (re)creation (BackendObject_DirectGLES destroys the context to create a different
// surface), backend destruction - so the next make-current with the SAME handle values
// (EGL handles are recycled; on this host every one of them is literally 0x1) is
// classified as a real bind, not as a RepeatNoOp that binds nothing, runs no base-class
// bookkeeping and republishes no caps. Forgetting is always safe: the cost of a
// forgotten-but-still-current tuple is one forwarded bind the backend object dedups
// natively; the cost of a remembered-but-dead one is a silent no-context-current.
void ForgetCurrentTuple();
void ForgetCurrentTupleIfItNames(EGLSurface surface);
// Part of the apply thread's park predicate: a posted control request must be able to
// un-park a thread waiting on kWaitForever, which a Notify alone cannot do.
//
// IT READS ONE ATOMIC AND TAKES NO LOCK (P5d round 3, package T item 1). It used to
// take m_controlMutex on every call, and the idle poll calls it on every iteration -
// once inside the `ready` lambda and once more through PumpControlRequest - which
// simpleperf at head 56a77348 measured as ~49% of the whole apply thread's cycles
// (mutex::lock 12.4 + mutex::unlock 13.6 + the two pthread_* halves, 58-60% of them
// with ApplyThreadMain as the DIRECT caller). An idle poll on an empty ring must cost
// loads, not a futex-backed critical section. m_controlPosted is that load; the
// mailbox's own fields stay under the mutex and the handshake below is unchanged.
//
// PUBLIC because ServerLoopTest reads it: the clear side of the shadow has a failure
// mode - a shadow left set makes `ready` permanently true and the loop never parks
// again - that no other public surface can see.
//
// READ THE PREDICATE LITERALLY: "a request is POSTED AND NOT YET TAKEN", which is
// narrower than the name's "a control request is in flight". The shadow is cleared
// when the pump takes the work, not when the work returns, so for the whole duration
// of `work(user)` this answers false although m_controlPending is still true and the
// poster is still blocked in RunSurfaceControlFrame. That is deliberate - it is a PARK
// PREDICATE, and the thread that would act on it is the one running the work - but it
// means this is not a liveness query and must not be used as one. The only in-tree
// reader outside the test is the apply thread's own `ready` lambda, which by
// construction cannot be inside that window.
Bool ControlIsPending() const;
// ---- P12 (on-screen server window), D3/D6 -------------------------------------------
//
// How long a ServerOwned creation waits for the display server's window (D3: ~10 s). The
// control pump's SurfaceProgress heartbeat keeps the client's reply budget alive meanwhile.
static constexpr Uint32 kServerWindowWaitMs = ServerDisplay::kDefaultAcquireTimeoutMs;
// APPLY THREAD ONLY. Leases the process display's window for this session (ServerDisplay::
// AcquireFor with this loop as the holder), refusing by name when there is no display
// (NoServerDisplay, NOT a latch - a configuration answer) or no window within `timeoutMs`
// (NoServerWindow). The ServerOwned arm of the dispatch is its production caller; a test
// reaches it through RunProbeOnApplyThreadForTesting. The lease is held until the backend
// has let go of the window: ServerDisplay::Detach's lost hook (D6) or the session's end.
MobileGLResult AcquireServerWindow(Uint32 width, Uint32 height, Uint32 timeoutMs,
ServerWindowLease* out, SurfaceRefusalCode* refusal);
// APPLY THREAD ONLY: this session's surface mode (D4).
SessionSurfaceMode SurfaceMode() const { return m_surfaceMode; }
// How many times this loop released a lost server window and latched ServerWindowLost (D6) -
// the path of a backend that cannot suspend.
Uint64 ServerWindowsLost() const { return m_serverWindowsLost.load(std::memory_order_acquire); }
// SUSPEND, NOT END. How many times a lost server window SUSPENDED this session (its backend let
// go of the window and kept everything else) and how many times a new window resumed it. A
// suspended session holds no lease and keeps applying records; its window surfaces draw into
// a placeholder until the display attaches a window again.
Uint64 ServerWindowsSuspended() const { return m_serverWindowsSuspended.load(std::memory_order_acquire); }
Uint64 ServerWindowsResumed() const { return m_serverWindowsResumed.load(std::memory_order_acquire); }
// How many times a resize of the leased window was published to the client (the display's
// surfaceChanged, answered on the apply thread without waiting for a present).
Uint64 ServerWindowResizesPublished() const { return m_serverWindowResizes.load(std::memory_order_acquire); }
Bool ServerWindowSuspended() const { return m_serverWindowSuspended.load(std::memory_order_acquire); }
// TEST SEAM: the backend this loop owns, instead of CreateBackend's (a fake that can or cannot
// suspend, for the window-loss cases). Before Start(); fails when one is already installed.
MobileGLResult InstallBackendForTesting(UniquePtr<MG_Backend::BackendObject> backend);
private:
RemoteControlSink m_remoteSink = nullptr;
void* m_remoteSinkUser = nullptr;
// P12. The ServerOwned arm of the CreateWindowSurface dispatch (D3, D4).
MobileGLResult ApplyServerOwnedWindowSurface(MG_Backend::BackendObject* backend, SurfaceControlFrame& frame);
// P12 (D6), apply thread: the lost window is let go of before the lease ends. A backend that
// can suspend releases only what is bound to the window and the session carries on, waiting
// for the next window (SuspendLease); otherwise the backend's EGL resources go and the session
// latches ServerWindowLost. Runs from PumpControlRequest / DrainRing when Detach asked.
void ReleaseLostServerWindow();
// The old ending: the backend's EGL resources go, the session latches ServerWindowLost by
// name with `why`, the lease ends.
void LatchServerWindowLost(const char* why);
// Apply thread: the window requests ServerWindowLostThunk / ServerWindowAttachedThunk posted.
void HandleServerWindowRequests();
// Apply thread: a suspended session leases the display's new window and its backend
// rebuilds the suspended surfaces on it.
void ResumeSuspendedServerWindow();
// Apply thread, holding a fresh lease on `lease.window`: the backend rebuilds what it
// suspended. False: it could not, and the session latched (LatchServerWindowLost).
Bool ResumeServerWindowOnLease(const ServerWindowLease& lease);
// Ends this loop's display lease if it holds one (after the backend let go of the window).
void EndServerWindowLease();
// The session's end: the lease, and any wait for a window to resume on.
void ForgetServerWindow();
// Review fix: the ResizeWindowSurface arm for a server-owned surface - a geometry request to
// the display, the backend surface at the window's real extent, that extent in the reply.
MobileGLResult ApplyServerOwnedWindowResize(MG_Backend::BackendObject* backend, SurfaceControlFrame& frame);
Bool IsServerOwnedSurface(EGLSurface surface) const;
void ForgetServerOwnedSurface(EGLSurface surface);
// Apply thread only: this session's surfaces created on the server's window; reset by Start().
std::vector<EGLSurface> m_serverOwnedSurfaces;
// ServerDisplay's lost hook: called under the display's lock from Detach's thread. Sets the
// request and rings the apply thread's bell; never blocks.
static void ServerWindowLostThunk(void* self);
// ServerDisplay's attached hook for a suspended session: the same discipline as the lost one.
static void ServerWindowAttachedThunk(void* self);
// ServerDisplay's resized hook for the lease holder: the same discipline again.
static void ServerWindowResizedThunk(void* self);
// Apply thread: the leased window's new extent (the display's) is handed to the backend, which
// publishes it to the client now.
void PublishServerWindowResize();
// Apply thread: the extent a backend publishes for the server window from here on (MGPipe's), and
// what this session last handed it.
void NoteServerWindowExtent(void* window, Uint32 width, Uint32 height);
// AcquireFor's cancel predicate: the loop is stopping, or the session latched.
static Bool ServerWindowWaitCancelled(void* self);
// D4: latched at the session's first successful surface creation; reset by Start(). Per
// session, because the loop is (P14 S2).
SessionSurfaceMode m_surfaceMode = SessionSurfaceMode::None;
// P14 S2: the session this loop serves, when the process serves several; see SetRuntime.
SessionRuntime* m_runtime = nullptr;
// Apply thread only: this loop holds the display's lease, on this window.
Bool m_holdsWindowLease = false;
void* m_leasedWindow = nullptr;
// The window requests: kWindowLost set by ServerWindowLostThunk, kWindowAttached by
// ServerWindowAttachedThunk (any thread), taken by the apply thread. ONE word, so the idle
// poll and DrainRing's per-pop check stay one load. Part of the park predicate, so a parked
// apply thread wakes for it.
static constexpr Uint32 kWindowLost = 1u;
static constexpr Uint32 kWindowAttached = 2u;
static constexpr Uint32 kWindowResized = 4u;
std::atomic<Uint32> m_windowRequests{0};
std::atomic<Uint64> m_serverWindowsLost{0};
// Written by the apply thread, read by tests.
std::atomic<Bool> m_serverWindowSuspended{false};
std::atomic<Uint64> m_serverWindowsSuspended{0};
std::atomic<Uint64> m_serverWindowsResumed{0};
// Apply thread only: the geometry this session last asked the display for (0/0 = the
// layout's), which a resume asks for again.
Uint32 m_serverWindowWantWidth = 0;
Uint32 m_serverWindowWantHeight = 0;
// Apply thread only: the leased window's extent this session last published.
Uint32 m_publishedWindowWidth = 0;
Uint32 m_publishedWindowHeight = 0;
std::atomic<Uint64> m_serverWindowResizes{0};
void ApplyThreadMain();
// Runs a posted control frame, if there is one. Returns true if it ran one.
Bool PumpControlRequest();
// The dispatch, on the apply thread (or inline for a re-entrant post): executes the
// frame's op against the private backend and fills the frame's reply half. The eleven
// wire+inproc op bodies from the old forwarder Args structs live here now.
MobileGLResult ApplySurfaceControlFrame(SurfaceControlFrame& frame,
ControlProbeHook probeHook = nullptr, void* probeUser = nullptr);
// The caller already holds m_callerMutex; shared by normal and test-probe posting so
// the probe's metadata is protected for the same lifetime as its frame.
MobileGLResult PostSurfaceControlFrameWithCallerLock(SurfaceControlFrame& frame);
// Pops and applies every record currently in the ring; returns how many it applied.
Uint64 DrainRing();
void SignalExited();
ServerSession* m_session = nullptr;
std::atomic<Bool> m_running{false};
std::atomic<Bool> m_stopRequested{false};
// P12 review fix: AbandonQueuedRecords() - read after every popped record and at the exit.
std::atomic<Bool> m_abandonQueue{false};
std::thread m_thread;
// The apply thread's identity used to live here as an atomic<std::thread::id>. It is
// Detail::g_applyThreadKey now - see the block at the top of this header for why the
// identity had to stop being a member reachable only through ServerLoopInstance().
// The private backend object. Destroyed ON the apply thread while it still owns the
// context - see Stop().
UniquePtr<MG_Backend::BackendObject> m_backend;
// The blocking control channel. ONE slot, because the verb barrier already leaves one
// client thread runnable at a time; m_callerMutex serialises anything that is not.
// The slot carries ONE SurfaceControlFrame BY VALUE (P5f, package fc) - no function
// pointer, no address of caller storage. The poster's frame is copied in at publish and
// the dispatch's reply half is copied back over it before m_controlDone is signalled.
std::mutex m_callerMutex;
std::mutex m_controlMutex;
// THE MAILBOX'S ONE-BIT SHADOW, and the only field of it the idle poll may read.
// Written to `true` by the poster under m_controlMutex AFTER m_controlPending, and
// back to `false` under the same lock by whoever TAKES the request (PumpControlRequest,
// or ApplyThreadMain's exit block when it answers NOT_INITIALIZED). A reader that sees
// `true` through the release/acquire pair therefore also sees m_controlPending; a
// reader that sees `false` cannot be missing a request, because the store precedes the
// poster's Notify and Doorbell::Wait re-tests its predicate after every Park return.
// The mutex still owns every other field - this is a fast NEGATIVE answer, not a second
// copy of the mailbox.
//
// It replaces a std::condition_variable of the same name that had no waiter and no
// notifier anywhere in the tree (m_controlDone is the one that carries the handshake).
std::atomic<Bool> m_controlPosted{false};
std::condition_variable m_controlDone;
// The one slot. A frame by value; the reply half is written through it on the way back.
SurfaceControlFrame m_controlFrame;
MobileGLResult m_controlResult = MOBILEGL_OK;
Bool m_controlPending = false;
Bool m_controlFinished = false;
// Minted per posted frame (0 = never posted) and the dispatch tally beside it. Both
// atomic: the re-entrant inline arm mints without taking m_callerMutex.
std::atomic<Uint64> m_controlSeq{0};
std::atomic<Uint64> m_controlFramesDispatched{0};
// The test probe (RunProbeOnApplyThreadForTesting). Members, NOT slot content - the slot
// carries only the frame. Written under m_callerMutex before the probe frame is
// published; held until its reply returns. Re-entrant probes use call-local arguments.
std::atomic<ControlProbeHook> m_controlProbeHook{nullptr};
std::atomic<void*> m_controlProbeUser{nullptr};
// SetControlProgressSink's pair (p7/spawnhang). Guarded by m_controlMutex: the poster reads
// both while it holds that lock between slices, so the pair is always read whole.
ControlProgressSink m_progressSink = nullptr;
void* m_progressUser = nullptr;
// The BOUNDED join's other half. std::thread::join has no deadline, so a lost wakeup
// would wedge CI rather than fail it; the thread signals here last and Stop() waits
// with a deadline (InProcessTransportTest.cpp:344's five seconds).
std::mutex m_exitMutex;
std::condition_variable m_exitCv;
Bool m_exited = false;
// True only while mgl-srv-apply is parked on its consumer bell - the one place a lost
// wakeup can strand it. Stop()'s join reads it to tell a lost wakeup (parked: Fatal at
// the bound) from a thread that is BUSY in the backend (a GPU stall: waited for longer).
std::atomic<Bool> m_applyParked{false};
Uint64 m_affinityMask = 0;
std::atomic<Uint64> m_drained{0};
std::atomic<Uint64> m_parks{0};
// Incremented by Doorbell::Wait through its `parkTally` out-parameter, once per wait
// that reached the blocking Park. Relaxed everywhere: it is a gauge.
std::atomic<Uint64> m_parkBlocks{0};
std::atomic<void (*)()> m_beforeRetireHook{nullptr};
std::atomic<void (*)()> m_betweenRecordsHook{nullptr};
// P9 W3, fault injection F2: MOBILEGL_TEST_APPLY_LOG_FLOOD, read by Start(). Zero - the only
// value a production run has - makes the per-record test a load of this and nothing else.
Uint32 m_testLogFloodLines = 0;
// C7 / ID-54: the (dpy, draw, read, ctx) currently bound on the apply thread. Written and
// read ONLY on the apply thread inside ApplyMakeCurrent, so it needs no lock; the two
// counters beside it are atomic because a test reads them from another thread.
//
// P14 S4 LOOKED AT MAKING THIS A PER-CONTEXT TABLE AND DID NOT, with the reason recorded
// here rather than in a review thread: ID-67's pinned control
// (ServerLoopEglTest.ADifferentTupleBindsNativelyAndRepublishesAnIdenticalRepeatDoesNeither)
// defines "a different tuple" as a change of the CURRENTLY BOUND tuple, and asserts that
// A -> B -> A costs three native binds. A table that remembered each context's tuple would
// make the third one a RepeatNoOp - and it would be wrong for a real reason, not just for
// the assertion: only ONE EGLContext can be current on the apply thread, so coming back to A
// really does have to run the driver call that DirectGLES' seven frontend-context
// invalidations hang off. "Each context holds its own native triple" is satisfied by the
// backend's per-(session, token) registry; the dedup here is about the thread's CURRENT
// binding and stays one tuple.
Bool m_haveCurrentTuple = false;
EGLDisplay m_curDpy = EGL_NO_DISPLAY;
EGLSurface m_curDraw = EGL_NO_SURFACE;
EGLSurface m_curRead = EGL_NO_SURFACE;
EGLContext m_curCtx = EGL_NO_CONTEXT;
std::atomic<Uint64> m_nativeBinds{0};
std::atomic<Uint64> m_clientReleases{0};
std::atomic<Uint64> m_makeCurrentRepublishes{0};
};
ServerLoop& ServerLoopInstance();
// C7 / ID-54, factored out so a unit case can drive the DECISION without a live EGL context
// (the native bind itself needs the joint lane). Given the tuple currently held on the apply
// thread and the request, is this a native bind, an identical no-op repeat, or a client
// release-current the server must record without forwarding?
enum class EglBindAction { NativeBind, RepeatNoOp, ClientRelease };
EglBindAction ClassifyEglMakeCurrent(Bool haveCurrent, EGLDisplay curDpy, EGLSurface curDraw,
EGLSurface curRead, EGLContext curCtx, EGLDisplay dpy,
EGLSurface draw, EGLSurface read, EGLContext ctx);
// P11 B1 (CONTRACT-P11 B1): the cpu core this process's `auto` policy reserves for a dialled-in
// shared-segment client's apply thread (ApplyThreadPolicy.h ReservedApplyCoreForConfig over this
// machine's topology and MOBILEGL_IPC_SERVER_AFFINITY), 0 when none; `outRule` receives the reason.
// The supervisor announces it at start-up; the server app's broker relays it to the helper.
Uint64 ReservedApplyCoreForThisProcess(const char** outRule);
// ---------------------------------------------------------------------------------
// THE EGL OWNERSHIP MOVE - the part that can sink the phase, expressed as twelve calls
// ---------------------------------------------------------------------------------
//
// Under split the app thread must never reach the driver's eglMakeCurrent. Today it does:
// EGLImpl.cpp:284 -> BackendObject_DirectGLES.cpp:963 -> DirectGLES.cpp:11925, and the
// owner slot g_backendContextOwnerThread (DirectGLES.cpp:11865) is stamped with whatever
// thread got there. So BackendObject_Remote's nine EGL virtuals - package c1's - call these
// twelve, each of which is a BLOCKING control request that runs the SERVER's backend object
// on mgl-srv-apply. The native eglMakeCurrent then runs once per context lifetime on that
// thread (the surface's own creation binds; the client's make-currents onto that surface
// are deduped at both layers, header block above) and a client release is never forwarded,
// so g_backendContextOwnerThread is written once per context lifetime, DirectGLES.cpp's six
// cache invalidations run once per context lifetime rather than once per client
// make-current, the per-frame EGL re-verification stamp holds, and the 16
// IsBackendContextCurrentOnThisThread() sites plus the 16 CanTouchGLNow() sites answer TRUE
// on the server instead of silently degrading. Measured, not assumed: ServerLoopTest's C7
// control counts the driver's eglMakeCurrent calls at the EGL function table.
//
// TWO OF THEM MUST BLOCK OR THE PROCESS TEARS ITS OWN CONTEXT DOWN UNDER ITSELF:
// ReleaseEGLResources (reached from EGLImpl.cpp:326, which for DirectGLES runs
// DestroyEGLContext) and ~BackendObject_DirectGLES (reached from
// pActiveBackendObject.reset() at MobileGL/Init.cpp:68). Both are blocking here - the first
// by being one of these calls, the second because ServerLoop::Stop() destroys the private
// backend ON the apply thread before that thread exits and Stop() itself waits.
//
// WHY THEY ARE FREE FUNCTIONS AND NOT MEMBERS: c1 needs exactly this surface and nothing
// else of the server, so the seam between the two packages is a list of twelve signatures
// rather than a class with a lifecycle.
//
// P5f (package fc): each of the twelve packs its arguments into a SurfaceControlFrame and
// posts it through RunSurfaceControlFrame - the wire-shaped value channel that replaced the
// function-pointer mailbox (P5c audit row G4). Nine of them map onto the ten wire ops of
// protocol.fbs's SurfaceOp; ServerSwapEGLBuffers, ServerInitCapabilities and
// ServerInitWindowSurface ride the same channel as inproc-only kinds (present travels as a
// record, capabilities are answered by the CapsSnapshot frame, and the third has no caller -
// f0-egl's census, findings F8/§4.2).
Bool ServerInitializeEGLDisplay(EGLDisplay dpy, EGLint* major, EGLint* minor);
Bool ServerCreateEGLWindowSurface(EGLSurface surface, const MG_Backend::WindowHandle& handle);
Bool ServerResizeEGLWindowSurface(EGLSurface surface, Uint32 width, Uint32 height);
// P12: `refusal`, when given, receives the server's named refusal (SurfaceModeMismatch for a
// pbuffer in an on-screen session), None otherwise. `flags`: kPbufferFlagSurfacelessStandIn
// for the hidden pbuffer of a surfaceless make-current, which takes no surface mode.
Bool ServerCreateEGLPbufferSurface(EGLSurface surface, EGLint width, EGLint height,
SurfaceRefusalCode* refusal = nullptr, Uint32 flags = 0);
// P12 (on-screen server window), D1/D2. CreateWindowSurface on the SERVER's window: one frame,
// WindowKind::ServerOwned with nativeToken 0 on the wire, no SetWindowHandle. `width`/`height`
// is the size the client asked for (0/0 = the server window's own size); the reply carries the
// window's REAL extent, and the server's named refusal when it declined.
struct ServerOwnedWindowReply {
Bool ok = false;
MobileGLResult transport = MOBILEGL_OK; // the frame channel's own answer
SurfaceRefusalCode refusal = SurfaceRefusalCode::None;
Uint32 width = 0;
Uint32 height = 0;
};
ServerOwnedWindowReply ServerCreateServerOwnedWindowSurface(EGLSurface surface, Uint32 width, Uint32 height);
// P12 review fix: ResizeWindowSurface for a surface created on the server's window. The server
// resizes its WINDOW (a geometry request) and replies with the window's real extent.
ServerOwnedWindowReply ServerResizeServerOwnedWindowSurface(EGLSurface surface, Uint32 width, Uint32 height);
// Also RE-PUBLISHES THE CAPS SNAPSHOT on success (R-12). BackendObject::MakeEGLCurrent runs
// InitCapabilities() on the first make-current per surface (BackendObject.cpp:341-347), so
// this is the moment the server's answers stop being the empty ones Accept() published -
// and a SECOND arrival IS the invalidation signal, which is how DirectGLES, which has no
// OnCapsInvalidated producer at all, tells the client without a dev-shaped backend edit.
Bool ServerMakeEGLCurrent(EGLDisplay dpy, EGLSurface draw, EGLSurface read, EGLContext ctx);
// P14 S1 (docs/Disaggregated/design/11-state-ownership.md). The EGL CONTEXT LIFECYCLE's two
// frames, on the same channel and with the same blocking handshake as the twelve above.
// `clientContextToken` is the token EGLState minted for the new context (never 0), and
// `shareGroupToken` the group it joins - the sharing context's group, or a fresh one.
// S1 creates a PLACEHOLDER server-side runtime: the token enters the session's table and
// nothing native is built (S4 is what gives the entry a native context). DestroyContext
// drops the entry and unbinds it if it was the current one.
Bool ServerCreateEGLContext(Uint64 clientContextToken, Uint64 shareGroupToken, Uint32 flags);
Bool ServerDestroyEGLContext(Uint64 clientContextToken);
Bool ServerSwapEGLBuffers(EGLDisplay dpy, EGLSurface draw);
void ServerSetEGLSwapInterval(Int interval);
void ServerReleaseEGLSurface(EGLSurface surface);
void ServerReleaseEGLResources();
Bool ServerInitCapabilities();
Bool ServerInitWindowSurface();
void ServerSetWindowHandle(const MG_Backend::WindowHandle& handle);
} // namespace MobileGL::MG_Remote::Server