18 KiB
Frame pacing, processes, EGL and platform (formerly ARCHITECTURE §14–§15)
Design details. The design points and the index of all chapters are in
../ARCHITECTURE.md. Section numbers follow the originalARCHITECTURE.md; anARCHITECTURE.md §Nin a code comment is found by its number here.
14. Present, threads and frame pacing
eglSwapBuffers→present{frameSerial}→ publish + ring the doorbell → return, unless the credit is exceeded;presentandeglSwapBuffersare strictly 1:1 (the backend's frame-boundary drain happens only insidePresent).MOBILEGL_IPC_PRESENT_CREDITdefaults to 1 (range 1..8): the credit is the only steady-state backpressure for run-ahead,FrameSerialis minted by the client and starts at 1, and the server returns the credit afterPresent()returns; the wait happens before encoding. credit > 1 has never been measured (P10).- Budget (~850 draws/frame):
SEG_CMD~70 KB/frame,SEG_STAGE~1 MB/frame; across machines that ~1 MB/frame faces Wi-Fi. - Threads: client v1 adds no thread, and encoding writes the ring directly on the GL thread; the server has an io thread and
mgl-srv-apply, which holds the native context for life. The whole library has no effective affinity control (the Redmi kernel ignoressched_setaffinityfor app threads). - Teardown order: publish + server drain → stop the apply thread → close the transport → the client drains the compile pool →
MobileGL::Destroy()(the landed shape is in §17).
14.1 Affinity and spin of the apply thread (auto)
MOBILEGL_IPC_SERVER_AFFINITY=auto (the default) chooses the apply thread's affinity and pre-park spin from the session peer (Hello's DialMode) and the data plane (MG_Remote/Server/ApplyThreadPolicy.h, pure functions): a same-process peer, a forked peer, or a dialed-in peer on stream → all big cores and MOBILEGL_IPC_SPIN_US; a dialed-in peer on a shared segment → one core (ReservedApplyCore: the lowest when there are several prime cores; another big core when there is only one prime core; otherwise no pinning, and never the only prime core) and kConnectedPeerSpinUs (unless MOBILEGL_IPC_SPIN_US is set explicitly). An explicit mask or off takes precedence for any peer.
- A dialed-in shared-segment client writes directly to the ring the apply thread spins on, and the two land on the same two prime cores: on the Redmi the shared segment gives 23–26 fps and TCP 45–54, and 118 after separating them by hand. On the stream data plane the io thread feeds the apply thread, and apply does not spin on the client's writes (apply CPU 7.9 ms/frame, versus 26.8 on the shared segment), so all big cores are kept.
- Taking the lowest rather than the highest prime core: the scheduler puts the foreground app's hot GL thread on the highest prime core (43 of 44 samples on cpu7); pinning the apply thread there gives 6.5 fps, while pinning it to the lowest gives 120 fps.
- The short spin is kept: parking immediately (0) is slower in every measured cell (the helper path 120 → 74 fps, the shell 27 → 17).
- A client started through
ExternalClientHelperis kept off the reserved core too: the supervisor announces the core at startup (ReservedApplyCoreForConfig) and the broker passes it to the helper; when the operator gives a mask oroff, no core is reserved.
15. Processes, EGL and platform (P6 / P6.5 / P12)
15.1 Startup and handshake
- Endpoints:
fork(socketpair+fork/execve, fd 3 = socket; the server is located byMOBILEGL_IPC_SERVER_PATHor bylibMobileGLServer.soin the same directory viadladdr),unix:<path>,tcp://host:port. The device-side supervisor ismobilegl_server_main <endpoint> --serve: listen → authenticate → fork one child process per session (or an in-process thread, P12), serving one session at a time, with a second connection gettingRefuse{Busy}. - Inside the server process
Transportreads asSpawn, and this does not force the monolith (the two hundred-oddTransport != Monolithchecks rely on it to pick the server arm;CONTRACT-P6.md§3.1 revises the original "a child process forces the monolith" rule). An unbounded fork chain is prevented by the non-inheritedDial == No(MOBILEGL_IPC_DIAL=no), and the envp built at spawn stripsMOBILEGL_TRANSPORTandMOBILEGL_IPC_*as a second, independent safeguard; the server main program setsMOBILEGL_IPC_ROLE=serveritself. Hello(abi, backend type, fingerprints, configuration, token,LinkTermsrequest) →Welcome(the sizes the server states, segment / aux names,dataNonce, the real pid) orRefuse{code, detail, peer value}; the handshake path has zero aborts. A non-loopback listen requires a token (≥16 bytes, constant-time comparison, the data plane bound to the authenticated control connection bydataNonce, authentication before fork; Ph, with OQ-21 ruling out TLS).- A TCP keepalive / heartbeat failure is reported by the transport as a hang-up and enters the device-lost latch; "the latch is never taken from an apply timeout" is not loosened by this. During cold start the server sends
SurfaceProgress, and the client budgets that silence (control revision 2).
15.1.1 Pre-authentication in the TCP supervisor
The supervisor of TCP --serve (TcpSupervisor in ServerMain.cpp, with the decision part in PreAuthGate.h) authenticates first and only then hands the session over (a forked child, or an in-process thread); an unauthenticated peer holds at most one bounded pending slot:
- A new connection is pending until its first frame is complete. The supervisor reads from all pending connections non-blocking in a single poll loop, so no unauthenticated peer can hold up the others; it reads only up to the end of the first frame, and the bytes after that belong to the session.
- Each pending connection has
MOBILEGL_IPC_PREAUTH_MS(default 2000) to finish the first frame, and there are at mostMOBILEGL_IPC_PREAUTH_MAX(default 8) at a time. The queue is shared by address: when it is full, a new connection evicts the oldest one of the address holding the most slots; if its own address already holds as many, it is the one refused withBusy. - Hello is authenticated in the supervisor, token before fingerprints: an unauthenticated peer only receives
Refuse{Authentication}and never gets this build's wire / build fingerprints. The session is handed over only for an authenticated Hello, and that frame is passed to the session. - A failure (wrong token, malformed first frame, timeout, a silent slot-holder closed after
kPreAuthProbeGraceMs, or being evicted) is recorded against the peer address; afterMOBILEGL_IPC_AUTH_BACKOFF_AFTER(default 5, 0 = off) failures that address is refused at accept time, before any byte is read, with the window doubling fromMOBILEGL_IPC_AUTH_BACKOFF_MS(default 1000) up to one minute; one successful authentication of that address clears it. A port probe that closes without sending a byte within the grace period, a DataBind aimed at no live session, and a refusal issued by the backoff itself do not count as failures. - DataBind is handed off with the nonce to the live session (which compares the nonce); with no live session the supervisor refuses by name. A second authenticated Hello receives
Busy. - The decision part contains no I/O (
MG_Remote/Server/PreAuthGate.h) and is covered byPreAuthGateTest; failures older thankAuthBackoffForgetMsno longer count toward the backoff.
15.2 Android
- Delivery chain (proven in spike A): the only executable location in an APK is
lib/<abi>/, and the server is built withadd_executable+ alib*.soname;fork+execvefrom anuntrusted_appprocess stays in the same domain with zero avc denials;posix_spawnis unavailable at minSdk 26. One shared library, two roles (the stub doesdlopen+dlsym("mobilegl_server_main")). - Offscreen server: the foreground
MobileGLServerService(:mglsrv), exec'd fromnativeLibraryDir, fork-per-session, with pbuffer / surfaceless EGL. - On-screen server (P12 subset): the window belongs to the server itself (the client's window does not cross processes).
MobileGLDisplayActivity(:mglwin) holds a full-screen SurfaceView and runs a TCP server on one thread in its own process (sessions serial, the latch reset between sessions, the backend pinned per process);ServerDisplayholds the window through three hooks—acquire / release / geometry—and the apply thread uses it under a lease. After the client setsMOBILEGL_IPC_SURFACE=serverit is headless:eglCreateWindowSurface(NULL)sends oneWindowKind::ServerOwnedframe (control revision 3) and does not sendSetWindowHandle, the size comes back in the reply, and a resize becomes a geometry request to the server window; when the client gives noEGL_WIDTH/HEIGHT(SDL), the native window size is taken as the request, and the server fixes the window buffer to it and letterboxes it with black bars (otherwise a landscape game would be cropped in a portrait window). FCL can only attach to an external server (its own spawn would send the window pointer over, which Rule H refuses), and it needs the version setting "do not pause the game when it goes to the background", otherwise the game pauses as soon as FCL goes to the background (see git history). One server per device at a time; one surface mode per session (SurfaceModeMismatch); a server with no display refuses ServerOwned by name (NoServerDisplay). - Window loss:
surfaceDestroyedblocks with a bound (3 s) until the apply thread (which also checks before every record in a batch) drops the backend surface and latches the session withFatal{ServerWindowLost}; the server keeps listening. When an in-process session ends, the cleanup that a process exit would give for free is done by hand (Espryt twins and unit shadows, swap interval, the Magma renderer). HeadlessGL's fork pre-check produces orphan servers: the server's EOF detection exits immediately and unconditionally.
15.3 Linux / Windows / crashes
- Linux / WSL / CI never open a window (
EGL_PLATFORM=surfaceless); Wayland is not supported. A Windows / desktop client is a real shape under the TCP end goal (Windows is the second batch, open question 22); macOS is not split; a Windows machine is not a correctness gate. - Server death: the client reads a hang-up → device-lost latch (GL calls no-op,
eglSwapBuffersreturnsEGL_CONTEXT_LOST, reset statusGL_UNKNOWN_CONTEXT_RESET); theSession::Failfunnel sends the peer aSessionFaultframe naming the family (FatalFamilies.def). A context created before the latch is permanently lost (eglMakeCurrent/eglSwapBuffersreturnEGL_CONTEXT_LOST,glGetErrorreportsGL_CONTEXT_LOSTonce, and destruction sends no record to a new session). The process can recover (MOBILEGL_IPC_RECOVER, default 1): the application's nexteglCreateContext/eglCreate*Surface/eglInitializetears down the lost session and dials again for a new one, every client-side cache describing the old server is cleared (the publish latch, the CSO/sampler caches, the emitter latch, create window, the applier-reset sequence number, the persistent map), and the wire epoch advances so that every old GLContext is treated as lost; surfaces are kept and rebuilt in the new session at the next make-current (EGL's semantics for power-management events), and a Wayland window's shared image is reallocated in the new session. An in-process server and thefd:pair cannot be re-dialed and remain permanently lost.MOBILEGL_IPC_RESPAWN(a full re-push after a server restart) is still refused by name. Client death: the server reads EOF and immediately destroys the context and exits (the supervisor keeps serving the next connection).
15.3.1 Fatal families and the wire FatalCode
The internal Fatal families (MG_Pipe/FatalFamilies.def) are far more numerous than the 7 values of the wire FatalCode, so a family is an internal enum: the peer only receives the coarse code, while the fine-grained word stays in the local log and in the family string of the SessionFault frame. The code answers only "which kind of end this is":
ProtocolCorruption: things that cannot be executed correctly—malformed records, an unimplemented verb / surface, an invariant the two roles must uphold, a reply that breaks the contract. What the peer reads is "the two sides disagree on the protocol".RingOverrun: the shared ring or the event channel overflowed, a transport capacity problem.SegmentMismatch: the shared segment's size cannot hold what a role must put in it (a wrong size, not a transient fill).AbiMismatch: the two ends' layout or protocol version disagree.ServerCrashed: a liveness end the waiter blames on the peer: an incomplete drain, the apply thread gone, the server unable to get a required native resource (ResourceUnavailable), the server window destroyed (ServerWindowLost), GPU device loss (BackendDeviceLost, which latches only that session). An unavailable resource is not reported asSegmentMismatch, since that would blame the shared segment's size; the wire has no "resource unavailable" value either, and adding one would changewireFingerprint.DeviceLost: set only by the device-lost latch; no family projects onto it.
15.4 Shared images (shared_image)
A color buffer that the server allocates and exports as a dma-buf descriptor, letting one client's frame enter another client's texture without leaving GPU memory. One record (MGPSharedImageOp), and every operation is answered through a reply slot with MGPSharedImageReply; the descriptor the server hands the client goes over the aux socket and arrives before the reply; ImageId is minted by the server and is not reused for the server's lifetime. Damage is a rectangle in GL window coordinates with a bottom-left origin, and 0 of them = the whole surface.
| Operation | Semantics |
|---|---|
Allocate / Release |
create a new Width×Height image of Format, the reply gives the id, and the descriptor is delivered over the aux socket / the session drops its reference to ImageId |
Import |
claim the descriptor the client queued on the aux socket (MGPSharedImageFdOffer carries this record's seq); it must be an image this server exported, with matching size and format |
Present |
the current context's default framebuffer is copied into ImageId top row first (the window frame becomes the wl_buffer's contents), copying only Damage: the client records, per image, the regions missing since the last write; the reply comes only after the GPU copy completes |
Attach |
the storage of texture Texture becomes ImageId (glEGLImageTargetTexture2DOES), until its next respecify that defines storage |
QueryBufferAge |
answers in the stream with the current draw surface's EGL_BUFFER_AGE_EXT (0 = unknown); when the application first declares the drawing region (EGL_KHR_partial_update), Format = kMGPBufferAgeDamageRegionFollows |
SetDamageRegion |
eglSetDamageRegionKHR: the region that will be drawn this frame |
Flush |
glFlush / glFinish of a session rendering into the image (the X server's glamor): images used since the last boundary are published as written and read at the flush's fence (implicit sync), and the answer follows the publication |
NativeFence |
EGL_ANDROID_native_fence_sync: a sync_file signaled when the session's submitted work completes (a session that writes the image does the Flush publication at the same time), delivered over the aux socket before the reply, Format = kMGPNativeFenceFdFollows; Format = 0 means the server cannot give a descriptor and the work was already done when the reply was sent |
15.5 EGL ownership: the apply thread holds the native context for life
Under the split the application thread never calls the driver's eglMakeCurrent: the EGL virtual functions of BackendObject_Remote call the Server* forwarders in ServerLoop.h, each of which becomes a blocking control request through RunSurfaceControlFrame and is executed by mgl-srv-apply on the server's private backend object.
- Why this thread: when DirectGLES's
IsBackendContextCurrentOnThisThread()andCanTouchGLNow()inManagers.cppanswer "no", the guards degrade silently—fences are always signaled, queries return a null handle,Presentcreates no frame fence (the buffer pool's reclamation watermark stops advancing), and the persistent map refuses adoption. The apply thread holds the context for life, so these guards all answer "yes" on the server. - The native
eglMakeCurrentruns only when the tuple currently bound on the apply thread really changes, deduplicated in two layers: on the apply thread,ServerLoop::ApplyMakeCurrentcompares against the last bound tuple withClassifyEglMakeCurrent; a match is a no-op with no caps resent, a difference is forwarded with a resent caps snapshot, and the client's release-current is only counted (ClientReleaseCount), never forwarded;BackendObject_DirectGLES::MakeEGLCurrent, when there is a transport, skips the native call if the target draw surface is already natively current on this thread and the virtual tuple is exactly what that binding produced (or that binding was the surface's own creation). Switching virtual contexts on the same surface still binds natively again, because aMakeCurrentinvalidation targets the frontend context that changed. Only one tuple is kept: a thread has a single current context at a time, so A → B → A is three native bindings. - The tuple is forgotten (
ServerLoop::ForgetCurrentTuple) after every event in which the native binding may have changed or the context the tuple points at may no longer exist:ReleaseEGLResources, releasing the surface it points at, a surface (re)creation (which natively binds the new surface), and backend destruction. EGL handle values are reused; remembering a dead tuple costs a silent absence of a current context, while forgetting costs only one redundant forward. ReleaseEGLResourcesand~BackendObject_DirectGLESmust block: the former is itself a control request, and the latter runs on the apply thread before that thread exits, withServerLoop::Stop()waiting for it to finish; otherwiseMobileGL::Destroy()would keep tearing down while the server still holds the context.- The control
ServerLoopEglTest.MakeCurrentBindsNativelyOncePerContextAndNeverForwardsAClientReleasecounts at the EGL function table: surface creation + two identical make-currents + one client release + a bind after the release = one nativeeglMakeCurrent, zero native releases.