Files

18 KiB
Raw Permalink Blame History

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 original ARCHITECTURE.md; an ARCHITECTURE.md §N in 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; present and eglSwapBuffers are strictly 1:1 (the backend's frame-boundary drain happens only inside Present).
  • MOBILEGL_IPC_PRESENT_CREDIT defaults to 1 (range 1..8): the credit is the only steady-state backpressure for run-ahead, FrameSerial is minted by the client and starts at 1, and the server returns the credit after Present() 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 ignores sched_setaffinity for 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 ExternalClientHelper is 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 or off, 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 by MOBILEGL_IPC_SERVER_PATH or by libMobileGLServer.so in the same directory via dladdr), unix:<path>, tcp://host:port. The device-side supervisor is mobilegl_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 getting Refuse{Busy}.
  • Inside the server process Transport reads as Spawn, and this does not force the monolith (the two hundred-odd Transport != Monolith checks 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-inherited Dial == No (MOBILEGL_IPC_DIAL=no), and the envp built at spawn strips MOBILEGL_TRANSPORT and MOBILEGL_IPC_* as a second, independent safeguard; the server main program sets MOBILEGL_IPC_ROLE=server itself.
  • Hello (abi, backend type, fingerprints, configuration, token, LinkTerms request) → Welcome (the sizes the server states, segment / aux names, dataNonce, the real pid) or Refuse{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 by dataNonce, 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 most MOBILEGL_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 with Busy.
  • 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; after MOBILEGL_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 from MOBILEGL_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 by PreAuthGateTest; failures older than kAuthBackoffForgetMs no 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 with add_executable + a lib*.so name; fork+execve from an untrusted_app process stays in the same domain with zero avc denials; posix_spawn is unavailable at minSdk 26. One shared library, two roles (the stub does dlopen + dlsym("mobilegl_server_main")).
  • Offscreen server: the foreground MobileGLServerService (:mglsrv), exec'd from nativeLibraryDir, 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); ServerDisplay holds the window through three hooks—acquire / release / geometry—and the apply thread uses it under a lease. After the client sets MOBILEGL_IPC_SURFACE=server it is headless: eglCreateWindowSurface(NULL) sends one WindowKind::ServerOwned frame (control revision 3) and does not send SetWindowHandle, the size comes back in the reply, and a resize becomes a geometry request to the server window; when the client gives no EGL_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: surfaceDestroyed blocks 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 with Fatal{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, eglSwapBuffers returns EGL_CONTEXT_LOST, reset status GL_UNKNOWN_CONTEXT_RESET); the Session::Fail funnel sends the peer a SessionFault frame naming the family (FatalFamilies.def). A context created before the latch is permanently lost (eglMakeCurrent / eglSwapBuffers return EGL_CONTEXT_LOST, glGetError reports GL_CONTEXT_LOST once, and destruction sends no record to a new session). The process can recover (MOBILEGL_IPC_RECOVER, default 1): the application's next eglCreateContext / eglCreate*Surface / eglInitialize tears 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 the fd: 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 as SegmentMismatch, since that would blame the shared segment's size; the wire has no "resource unavailable" value either, and adding one would change wireFingerprint.
  • 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() and CanTouchGLNow() in Managers.cpp answer "no", the guards degrade silently—fences are always signaled, queries return a null handle, Present creates 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 eglMakeCurrent runs only when the tuple currently bound on the apply thread really changes, deduplicated in two layers: on the apply thread, ServerLoop::ApplyMakeCurrent compares against the last bound tuple with ClassifyEglMakeCurrent; 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 a MakeCurrent invalidation 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.
  • ReleaseEGLResources and ~BackendObject_DirectGLES must block: the former is itself a control request, and the latter runs on the apply thread before that thread exits, with ServerLoop::Stop() waiting for it to finish; otherwise MobileGL::Destroy() would keep tearing down while the server still holds the context.
  • The control ServerLoopEglTest.MakeCurrentBindsNativelyOncePerContextAndNeverForwardsAClientRelease counts at the EGL function table: surface creation + two identical make-currents + one client release + a bind after the release = one native eglMakeCurrent, zero native releases.