A Wayland client's window is its own wl_surface, and what the compositor shows is the wl_buffer
attached to it - but the pixels of a MobileGL frame are drawn where the backend is, on a split
build in another process on another OS. So far a wl_egl_window was taken for an X11 window and
its client-side address sent to the Android server (EGL_BAD_NATIVE_WINDOW at best), and under
glvnd a Wayland display never reached this vendor at all.
A window on a Wayland display - asked for as EGL_PLATFORM_WAYLAND, or a wl_display recognised by
its first word being wl_display_interface - is now a window surface in the EGL state at the
wl_egl_window's size with a pbuffer of that size behind it in the backend, whatever
MOBILEGL_IPC_SURFACE says. At every swap the frame is read back out of the default framebuffer
through this library's own glReadPixels (the application's read framebuffer, pack buffer and pack
state put back as they were), its rows flipped and swizzled into a wl_shm buffer, and attached,
damaged and committed on the application's wl_surface. Three buffers rotate on their release
events, dispatched on an event queue of the surface's own. libwayland-client is loaded at run
time, so neither the build nor the library depends on it. EGL_KHR_platform_wayland and
EGL_EXT_platform_wayland are announced on Linux.
Measured: glmark2-wayland runs its build scene at 13 FPS (400x300) and the window shows the frame
under kwin. A window resize is not followed yet; the surface keeps its creation size.
MGPipeForgetAllPublications cleared the publication latch, but two death helpers do not ask the
latch: vertex elements ask the vertex-input emitter's RecordIsPublished and buffers ask the
resource tracker's WasPublished. A VAO and its buffers destroyed with the GL context after the
split roles were down still found themselves published there, and glmark2's eglTerminate stopped
on the same assertion eglinfo's did (ResolveVertexElements, then ResolveResourceIn for a buffer).
Both records are now dropped beside the latch: the emitter's latches whole, the tracker's
Published flags.
The window of creates whose answers are read later is process-global, and every entry names a
sequence number of the session that wrote it. A process can run several sessions one after
another - eglinfo terminates one platform display and initializes the next - and an entry left by
the previous session made the new session's first create wait, in DrainCreateWindow, for a
sequence number the new server would not reach: the second session hung on its first texture
create.
InstallClientWireTables, which runs at the start of every session, now empties the window and the
create suspects; their answers and their objects died with the session that wrote them.
MobileGL::Destroy shuts the split roles down first and resets the GL context after, by design. The
frontend objects that die in that reset still found their handles published, so their deletes were
routed to whichever applier was installed by then - the monolith one, which never had those
resources and asserted ("ResourceDestroy named {slot, gen}") from eglTerminate. The abort also
threw away the program's buffered output, so eglinfo printed nothing but the assertion.
Every twin the server held died with the session, so ShutdownSplitRoles now clears the publication
latch (MGPipeForgetAllPublications): the deletes that follow are local bookkeeping only.
A desktop is a compositor AND the GL programs run inside it, each a client with a session of its
own. The TCP supervisor answered every authenticated Hello after the first with Refuse{Busy}, so
with kwin connected any other program - eglinfo, glxinfo, glmark2 from konsole - waited out its
Welcome and died on Fatal{CapsBeforeFirstSnapshot}.
The fork shape now keeps a list of live children and forks for a Hello while fewer than eight are
live. What made it a single slot was the data connection: a DataBind names its session only by
the data nonce, which the child minted, so the supervisor could hand it to nothing but "the" child.
The nonce is now minted by the supervisor just before fork(); the child adopts it
(PresetDataNonceForThisProcess) instead of minting its own, and a DataBind goes to the child whose
nonce it presents. Nothing on the wire changes. The thread shape (the in-process display server)
still serves one session, Busy for a second as before.
The arms that bind the display host's frame in place of an imported texture were gated on the
attach target being 0x8D65, but an external texture is attached through the GL_TEXTURE_2D upload
target, so they never ran. The texture's twin on the server has no storage - the dma-buf behind it
never crosses - and kwin's framebuffers over it were incomplete (status 0x8cd7, every draw
GL_INVALID_FRAMEBUFFER_OPERATION), so nothing it drew landed anywhere.
The record arm (SyncAttachmentSurface) now recognises a texture point whose target is
TextureExternalOES and attaches the host frame canvas before any twin adoption or storage sync,
which have nothing to offer a texture with no storage; it takes the host frame first if none is held
yet. The legacy object arm keys on the texture object's own target the same way. Measured: all
four of kwin's daemon-buffer framebuffers complete, and the desktop it draws reaches the glass.
Every present hands the frame back to the display host and takes a different one, and the frame's
renderbuffer is deleted with it. A compositor attaches its framebuffers once (kwin: one per daemon
buffer, for the life of the process), so attachments bound to a frame's own renderbuffer named a
deleted object from the second frame on.
The backend now keeps a canvas - a renderbuffer and framebuffer of its own, made at the first
frame's size and kept for the session. The host-frame globals (the default-framebuffer redirect,
the storage external attachments are bound to) name the canvas instead of the frame, and keep
naming it between a frame being handed back and the next one arriving. PresentHostFrame copies the
canvas into the frame before its fence; the copy puts back the compositor's framebuffer bindings,
scissor test and rasterizer discard exactly as they were. Without a canvas the frame itself stays
the draw target, as before.
A split client's swap reaches the server as a present record, and ServerVerbSink::OnPresent only
ran the backend's Present - an eglSwapBuffers of the pbuffer the context was made with.
BackendObject::SwapEGLBuffers is the only route to PresentHostFrame and a split client never takes
it, so over TCP the display host was never told a frame was drawn: the app offered thousands of
frames and presented none.
BackendObject::PresentHostFrameIfCurrent presents the host frame when the current surface is
host-framed and is a no-op otherwise; OnPresent calls it after Present and before the credit goes
back, so a client released by the credit is released by a frame that is on its way to the glass.
kwin draws the desktop into the display host's frame and then calls glFlush - and glFlush alone;
it never calls eglSwapBuffers. The stream reached the server and the server drew, but the display
host was never told a frame was done, so nothing reached the glass. When the surfaceless-bind hook
has taken the host frame, glFlush now swaps that surface after flushing the stream: that swap is the
host's "frame is done".
A swap of a surface the EGL state does not know used to return EGL_BAD_SURFACE without a word - the
whole reason the host frame's presents were invisible - so that refusal is now reported once.
A compositor on the anland route never creates a window surface: it imports the display host's
dma-bufs, draws into them through framebuffers and binds its contexts with EGL_NO_SURFACE. The
host frame bridge is only opened by a host-framed surface being created and activated, so with no
surface the bridge stayed shut and the display host offered its frames into a name nobody served.
So the first surfaceless bind of a real context, in a session that asked for host-framed surfaces,
makes the surface eglCreateWindowSurface would have made - registered in the EGL state AND created
through the backend's window-surface entry, which the remote backend routes to the host frame - and
every later surfaceless bind binds it, because the compositor rebinds with EGL_NO_SURFACE all the
time and a bind without it leaves nothing current to present. Registering it in the EGL state is
not optional: a backend-only token was refused by the state (EGL_BAD_SURFACE) on every bind and
every swap.
Only the compositor takes the host frame. Desktops are full of processes that bind with no surface
(konsole, plasmashell, kactivitymanagerd), and a second process taking it made the activation fail;
the compositor's launcher marks it with MOBILEGL_HOST_FRAME_CLIENT=1.
GL_TEXTURE_EXTERNAL_OES had a target of its own but nothing behind it: the texture factory had no
row for it (the compositor died on "Unimplemented texture type" the moment it bound one), the
attach path had no upload target for 0x8D65 (validation failed, the attachment was never recorded
and the compositor read GL_FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT), and nothing downstream
could tell an imported texture from an ordinary one.
An external texture is now a TextureObject2D created with the TextureExternalOES target, attached
through the 2D upload target - which the three framebuffer-texture validations accept for an
external object - and the target crosses the pipe (TexExternalOES maps back on the server) so the
render server can recognise it. To the driver it stays a 2D texture: MapToBackendTextureTarget
folds it to Texture2D like the other targets ES does not have.
Only four buffer targets have per-index binding points (Uniform, TransformFeedback, AtomicCounter,
ShaderStorage). A caller that asks GetBindingPoint for any other target is reading the wrong
accessor, not passing a bad enum - and kwin reached exactly that right after its vertex-attrib
setup, where the assertion killed the whole compositor with Trace/breakpoint trap.
The answer is now a one-time warning and index 0 of the indexed array: the caller gets a valid
slot, the global binding is untouched, and the mismatch stays visible in the log.
A compositor's bring-up is a fixed sequence of questions that all have to be answered by the same
library: which devices exist, which display a device serves, what its DRM node is, which platform
the display comes from, which configs exist, a context, a surfaceless make-current, the GL entry
points for that context, and finally the dma-buf import it draws with. Every missing answer in that
list cost a run to find, and each is measured rather than assumed - an unanswered device query was a
call through a null pointer, an unanswered GL name left glvnd's dispatch table empty so glGetString
answered NULL through libGL while the same context answered through eglGetProcAddress, an unanswered
node string produced "couldn't find dev node for drm device", and a rejected
GL_TEXTURE_EXTERNAL_OES binding is why the framebuffer over an imported dma-buf was reported
incomplete.
The EGL side is whole now: the device-query family is published as real entry points with the DRM
node named, the dispatch hook answers GL names as well as EGL ones, and the swap-and-sync family the
compositor checks between its context and its first surface - EGL_KHR_fence_sync, EGL_KHR_wait_sync,
the damage variants, EGL_EXT_buffer_age - is implemented and announced. A surfaceless context is
recorded as what it is rather than as a surface that does not exist. These entry points log what
they are asked at DEBUG level, because the next bring-up should not need a disassembler.
GL_TEXTURE_EXTERNAL_OES is a target of its own (its TextureTarget, its pipe row, the GLenum mapping,
and the OES_EGL_image extension named), which is what lets the compositor's bind succeed at all; the
texture an imported image backs is marked backed, so a framebuffer built over it is complete. The
client half of the import is new beside it: each plane's descriptor is described and shared with the
render server over the session transport, which is the change the server side has been waiting for.
Neither showed up in the Linux build that verified this library, and both are the kind that would
have been found by the first person to build the phone half - so they are fixed here rather than
left for that person.
bionic only declares memfd_create from API 30 and this library builds against 26. The kernel call is
far older than the declaration, so it is asked for by number: the same call with the name the
headers of this API level do not carry yet.
The library carries the tree's logger, which reaches Android's own log through liblog. The main
target gets that library in its own Android block; a second target built from the same sources needs
it for the same reason, and without it the link is what fails rather than anything in the code.
The channel existed and had no callers. Now the frame the server takes from the host is offered to
the container with the geometry the host decided, the apply loop consumes the releases the
compositor sends back, and the teardown that drops the host frame closes the channel.
Every call is on the optional path: with no container the render path is what it was.
The container's GBM hands out buffers that are the display host's frames, and this is the phone end
of that: the server takes a frame from the host, offers its descriptor to the container over a unix
socket at a path both sides can reach, and takes the release back when the compositor is done with
it.
The channel is optional and silent by design. A compositor must render whether or not a container is
listening, and the render path must never wait on a socket - so it is opened lazily, every send is
bounded, and any error simply marks it down.
The self-allocated backend makes the library stand on its own; this one makes it the thing the
project needs. The display host on the phone owns a pool of frames, the render server draws into
them with Espryt and acks, and the host presents. A gbm_bo created here is one of those frames: the
phone sends its dma-buf fd over a unix socket, the offer carries the geometry the host decided (not
the geometry the caller asked for), and destroying the bo sends the frame back.
Verified against a fake display host on this machine, with no phone involved: the buffer the fake
host fills is the buffer the bo maps - every pixel of the pattern is there - the fd the caller is
handed is a view of that same memory rather than a copy, a device with no frame waiting falls back
to allocating locally, and teardown closes exactly the descriptor it opened. The self-allocated
path still passes its own test unchanged.
The channel is lazy and never blocking: a compositor must work whether or not the phone is there.
kwin never creates an EGL window surface. Its undefined libgbm symbols are seventeen entry points
whose whole purpose is to hand out gbm_bo fds, and those fds are what Anland's display producer
sends to the daemon on the phone - so the question "whose pixels are on the screen" is answered by
"who allocated the bo", and today that is Mesa. The client can be loaded, initialized and rendering
through glvnd with no window surface ever created, which is exactly what was measured.
This is a complete GBM: every public entry point of the vendored Mesa header, with the semantics a
compositor relies on - fds the caller owns, real mmap with refcounting, a swapchain that refuses to
hand out a busy buffer, import by fd, and teardown that leaves no descriptor or mapping behind. The
buffers come from the display host's frames; where there is no host, the device allocates from the
dma-buf heap, falling back to an uncached heap and then to memfd, so the library stands on its own.
The vendored gbm.h is Mesa's, verbatim: the entry points are the ABI, not our idea of it.
Two defects, both found by asking the client directly rather than by reading its log.
The backend refused to initialize any EGLDisplay but the first - "already bound to a different
EGLDisplay" - and the field it compared against has no reader anywhere else in the tree: the domain
rule was the whole of the rule. What it cost was a compositor, which asks for the display it will
draw into AFTER probing another one. The probe succeeded, the real display was refused, and the
caller reported the refusal one layer up as "no usable DRM render device" - a message that names
where it stopped, not why.
The second: glvnd asks a vendor for entry points from two places. getProcAddress is how it builds
its own static EGL table, and getDispatchAddress is how it answers eglGetProcAddress for every name
it does not know itself - which is every extension entry point, the device queries among them. This
side answered the second from a GL-only table, so an application asking for eglQueryDeviceStringEXT
got a null for a function the vendor implements.
On a system with libglvnd the libEGL.so.1 an application links is a dispatcher, not an
implementation, and the supported way to be the implementation it talks to is to be a vendor
library it loads and routes to. On this machine it is also the ONLY way that reaches a caller which
does not link EGL directly: kwin goes through libepoxy, which dlopen()s libEGL.so.1 and dlsyms its
entry points at startup, so a library injected with LD_PRELOAD is invisible to it however loudly it
is named - measured, with the client in kwin's maps and not one call into it.
So this is the vendor half of that ABI: __egl_Main answers glvnd with the entry points by name, the
platforms this implementation serves, and the device it is associated with - the render node of the
machine, because that is what lets a compositor match its own node to the display it is about to
draw into, and because glvnd drops a vendor that does not answer at all.
Two details are the difference between a vendor that is loaded and a vendor that is used. glvnd
requires setDispatchIndex to exist even when a vendor has nothing to record, which this one has -
its entry points are the implementation, they never re-enter glvnd. And a vendor must hand over ITS
OWN entry points: with default visibility a reference inside this library resolves against the whole
process, where another libEGL that loaded first wins. That is what -Bsymbolic-functions is for;
declaring the entry points protected says the same thing but does not compile against the platform
headers, which declare them default.
A vendor string and an eglQueryString answer have to agree, and they are read by two different
parties: the system's libglvnd reads the first to decide which vendor a platform request should be
routed to, and an application reads the second before it decides to make that request at all. Kept
apart they drift, and the drift has a signature - a library that is loaded, initialized, and never
called, while the caller reports that it found no provider for a function it never reached.
The list gains the device family for the same reason: a compositor asks which devices exist and
reads the DRM node off one before it asks for a display on it, so an implementation that answers
"no devices" is one it cannot use no matter how well the implementation draws.
Two MIT headers from libglvnd, verbatim, because MobileGL is about to implement the vendor half of
glvnd's EGL ABI and the distribution ships the runtime without them.
Two permissions decide whether a server on Android lives or dies, and both are invisible until
something goes wrong: without POST_NOTIFICATIONS (13+) a foreground service cannot show the
notification that is its reason to exist, and without the battery exemption a dozing phone freezes
the process - the listener stays in /proc/net and stops accepting, which from the other side of the
socket looks exactly like a network fault.
The control screen now shows the state of both and asks for whichever is missing, in the order that
makes sense: the notification prompt is a dialog of ours, the battery one leaves the app entirely,
and coming back from it re-runs the other branch through onResume. A denial is said out loud rather
than left to be discovered.
The manifest carries the two permissions; the service's own notification was already there.
The last thing between the host-framed surface and a run: the sub-byte on the wire that names it.
ServerOwned had one (7) and the host frame had none, so the encoder refused the op with
UnknownWindowKind - a correct refusal, and the reason the first run of MOBILEGL_IPC_SURFACE=host
failed in the codec rather than anywhere near the pixels.
- protocol.fbs: WindowKind::HostFrame = 8, appended, legal on CreateWindowSurface only and always
with nativeToken 0 - the same rules ServerOwned carries, for the same reason: no pointer of any
process's window system crosses (Rule G/H).
- SurfaceRefusal: HostFrameOnSetWindowHandle = 5, its own code rather than reusing the
server-owned one: a refusal that names the wrong owner sends the next reader to the wrong place.
- SurfaceOpCodec: the encode and decode arms, the two wire errors that go with them
(HostFrameTokenNotZero latches as corruption, HostFrameOnSetWindowHandle is a named refusal), and
the range bound that had ServerOwned as its upper limit now ends at HostFrame.
- protocol_generated.h regenerated with the pinned submodule's flatc (scripts/gen_protocol.py),
diff being exactly the two appended enumerators.
Both ends must be rebuilt together, as any protocol revision here: the container's client and the
device's server went out with this on either side.
Three things this path needs that the server-owned one never did, each because the pixels are not
in an EGL surface this time:
- The client's DEFAULT framebuffer has to resolve to the frame. Four places bind the driver's 0
for the default framebuffer; they now ask g_hostFrameFramebufferId, which is 0 everywhere except
while a host-framed surface is current, where it is the frame's framebuffer. Without this every
draw would land in the pbuffer the context was made with - a surface nobody ever sees.
- eglMakeCurrent binds the surface's own default framebuffer behind the engine's binding shadow, so
the frame has to be put back after every make-current, with the shadow invalidated first: a bind
the shadow thinks already happened never reaches the driver.
- The bind itself goes through FramebufferImpl::BindFramebufferId, not a raw glBindFramebuffer, for
the same reason - a raw bind leaves the shadow claiming the previous framebuffer.
Frame lifecycle is otherwise unchanged: the frame becomes the draw target when it arrives, and is
dropped after the host has been answered and has presented it.
Compiles and links (libMobileGL.so, libMobileGLServer.so, exit 0), deployed to the device, and the
container's client is rebuilding against it. The run that shows Plasma through it is next.
The third on-screen shape, and the one this whole line was built toward: Anland keeps Wayland,
input and the display, and the render server draws into the frames the display host allocates.
client (container) asks for a window surface with no window of its own
server (Android) takes a frame from the host over the host frame bridge and binds it as
the draw target of that surface
host (the app that
owns the screen) offers each frame, presents it when the server says it is drawn
The pieces, in the order a frame travels:
- Config: MOBILEGL_IPC_SURFACE=host beside =server, and HostFrameWindowSurfaces() as the one
predicate every client-side consumer asks (EGLImpl accepts a NULL window for either on-screen
shape, and BackendObject_Remote routes the creation to the server instead of sending a handle).
- Wire: the frame-local tag kHostFrameWindowBackend, the mirror of kServerOwnedWindowBackend - it
names no WindowBackend of the pull build's enum, and the server consumes it before the unpack
that would refuse it.
- Server: ApplyHostFrameWindowSurface replaces nothing, unlike the server-owned arm: there is no
window of either side's to substitute, because the backend takes the frames from the bridge.
- Backend: SurfaceKind::HostFrame, InitHostFrameSurface (the EGL context comes from the pbuffer
path; what changes is what it draws INTO) and PresentHostFrame (glFinish, answer the host, take
the next frame) - and SwapEGLBuffers routes to it, because a host-framed surface has no
swapchain to swap: the host is what puts the frame on the glass.
HostFrameTarget and HostFrameBridge, committed just before this, are what the backend uses.
Compiles and links in this tree (libMobileGL.so and libMobileGLServer.so, exit 0). What is NOT
here yet, and is the next thing: the run that puts Plasma on the phone's screen through it.
Two modules, and nothing else changes yet: they are the half of the host-framed surface that is
about the frame itself, kept apart from the surface lifecycle so they can be reviewed and built
on their own.
HostFrameTarget (DirectGLES): an AHardwareBuffer becomes a renderbuffer through an EGLImage and a
framebuffer that is the drawing target while its frame is current. The extension strings decide
first and the four entry points are taken from eglGetProcAddress only after they have, which is
the shape the T0 AHB import in Managers.cpp already uses; a driver that lacks one of them is
answered with its name, never an abort. Every failure path drops what it made, so no GL name and
no EGLImage outlives a refusal.
HostFrameBridge (MG_Backend): the display host's own protocol, restated field for field - a
handle ahead of a fixed-size description on one AF_UNIX connection, then the answer, then the
host's readback of the pixels it can see. The host dials; this side listens on the well-known
name. A handle that arrives without its description is released before the failure is reported,
because a reference held past the connection starves the pool the host draws its frames from.
Why the host allocates the frames at all is measured, not chosen: AHardwareBuffer_createFromHandle
accepts an AHardwareBuffer this process allocated and refuses one the display queue handed over
(gralloc BAD_VALUE) - same process, same descriptor, same handle shape. That result is what these
two modules are built around, and it is written down where the next reader will look for it.
Both compile against this tree's own flags. Wiring them into the surface lifecycle, the swap path
and the client's opt-in is the next step, not this one.
Measured on the Redmi today: the Droidspaces container shares the phone's network namespace, so a
client in the container reaches the server's loopback listener with no adb forward and no LAN
address; the server owns the window (MOBILEGL_IPC_SURFACE=server), the Adreno does the drawing,
and a screenshot of the phone shows the colour that headless client asked for.
The two traps are written down because both cost a run: MOBILEGL_IPC_REQUIRE_SAME_BUILD=1 on the
server turns a Connect handshake's build-stamp warning into Refuse{BuildFingerprint} against a
release APK whose stamp is 0, and the client then dies on a caps gate that names something else.
The leg now reports OK on the device: offered=3 imported=3 drawn=3 hostReadback=3 agreed=3 --
three self-allocated AHardwareBuffers from the display host imported as EGLImages by this side,
drawn into, and read back by the host at both the first pixel and the far corner.
Getting there took one measurement and one correction. The handle message is 256 bytes on this
path and recvHandleFromUnixSocket drains it exactly (pending 256 -> 100, then 0 -> 100, then
0 -> 0), so the description that follows it was never the problem; the comparison was. This leg
was checking the display host's messages against kT4Magic -- the dma-buf protocol's magic, which
belongs to the container peer -- while the host stamps AHB_BRIDGE_MAGIC on all three of its
messages. A perfect round trip was reported as 'the offer's sideband did not follow its handle',
which named the wrong cause and cost the time. kT4HostMagic is that value, and the leg now also
prints the handle's footprint and the bytes it actually read, so the next failure of this shape
cannot be mistaken for the other one.