The Wayland backend
Status: a working backend, opt-in. react-x11's own reconciler renders a window on a real Wayland compositor (mutter/GNOME, sway, labwc) with decorations — the compositor's where it draws them, client-side where it does not — text, shadows, paths, images, live partial repaints, pointer and keyboard input decoded against the compositor's keymap, a clipboard, popups, layer-shell docks and panels, and a screen capture with an eyedropper on top — drawn on the GPU and handed over as dma-buf, on Node or Bun, with no X server and no Xwayland anywhere in the path.
This is the companion to wayland.md, the research RFC it implements. Where the two disagree, this one is the one that ran.
REACT_X11_BACKEND=wayland node your-app.mjs # x11-dri >= 0.9 (native socket)
REACT_X11_BACKEND=wayland bun your-app.mjs # or Bun's bun:ffi transport
node examples/wayland-gl.mjs # GPU plasma, paced by the compositor
node examples/wayland-ui.mjs # every drawing primitive, no React
node --import tsx --test 'test/wayland/*.test.js' # the suite: no display needed
The two packages it needs beyond react-x11's own are optional dependencies,
installed with it: @windowkit/wayland,
the protocol layer — a fork of wayland-client that passes file descriptors
— and x11-dri 0.9 or later,
the GPU context and, on Node, the socket.
The backend is never chosen by 'auto': X11 stays the default on Linux
because the remote case (remote.md) is the flagship reason this
project exists and Wayland has no network transparency.
How to tell it is not Xwayland
A GNOME session answers $DISPLAY too, through Xwayland, so "the window
appeared" proves nothing. Three checks that do:
- Unset
DISPLAY. The Wayland backend never opens it; the X11 backend cannot start without it.env -u DISPLAY REACT_X11_BACKEND=wayland node app.mjs # works only natively - Ask the app.
root.app.backend === 'wayland', androot.app.Xis the stand-in fromapp.js— nodisplay.screen, noX.require. The examples print the transport they connected through (connected via x11-driorfdpass-bun) and never import an X module at all. - Ask the desktop.
xlsclientsandxwininfo -root -treelist Xwayland clients; a native window is absent from both. GNOME's Looking Glass (Alt+F2,lg, Windows tab) shows it as aMetaWindowWayland, and its frame is this backend's own titlebar (decorations.js) rather than mutter's Adwaita frame, which only Xwayland windows get.
What runs
| piece | file | what it does |
|---|---|---|
| connection | connection.js | fd-capable transport (native or bun:ffi), vendored protocol JSON |
| shell | window.js | xdg toplevels and popups, deferred ack_configure, fractional scale |
| GPU presentation | swapchain.js, dmabuf.js, glcontext.js, target.js | backing target → per-buffer damage → dma-buf |
| 2d context | context2d.js, glyphatlas.js | rects/rounded rects (SDF), paths (coverage masks, stencil), exact clips, gradients, images, text |
<glarea> | glarea.js | a rect of the window's backing target as the area's GL surface; composited panes for its children |
| input | seat.js, xkb.js, keysymnames.js, input.js | pointer frames, keymap parsing, key repeat, routing |
| touch and tablet | touch.js, tablet.js | wl_touch and zwp_tablet_v2, emulating the pointer; raw touches |
| decorations | decorations.js, ssd.js | xdg-decoration or KDE's, to hand the frame to the compositor; the client-side frame where neither |
| layer surfaces | layershell.js | docks, panels, wallpapers, overlays — wlr-layer-shell |
| screen capture | screencopy.js, shm.js | wlr-screencopy / ext-image-copy-capture into wl_shm; the eyedropper |
| offscreen surfaces | surface.js, device.js | the paint cache and scroll blits, over a render target; a held context works |
| clipboard | clipboard.js, fdutil.js | wl_data_device + primary selection, over pipes |
| screens | outputs.js | wl_output + xdg_output into screens.js: useScreens(), the cap |
| input methods | textinput.js | zwp_text_input_v3: the compositor's IME into the composition events |
| the app | app.js, backendwindow.js, framewatch.js | what createRoot({ backend: 'wayland' }) renders through |
| tests | test/wayland/ | an in-process compositor, and the pure parts |
Running the examples
Every example runs on this backend the way it runs on X11, with the display taken away so nothing can fall back:
env -u DISPLAY REACT_X11_BACKEND=wayland bun examples/widgets.jsx # or node --import tsx
Two are X11 by nature and say so: examples/wm.jsx is a window manager and
examples/xeyes.jsx reads the root window's pointer — neither has a
Wayland counterpart. examples/viewer3d.jsx runs but draws its model only
through indirect GLX (it is fixed-function GL by design) and shows its own
note here, as it does under glPolicy: 'direct' on X11.
Two environment variables help when a desktop offers no screenshot API:
REACT_X11_WAYLAND_TRACE=1 writes a line per presented frame, and
REACT_X11_WAYLAND_SNAPSHOT=/tmp/frame.png writes the backing target of
the first window at its 30th present (REACT_X11_WAYLAND_SNAPSHOT_AT
changes which) — the pixels the compositor was handed, read back from the
GPU. A third, REACT_X11_WAYLAND_FRAME_STALL_MS, is how long a frame
callback may go unanswered before the backend says the compositor has
stopped showing the window (2000; 0 turns that off) — see
A surface the compositor stops showing stops drawing.
The GPU context is made on glPolicy.devicePath, else on
REACT_X11_GL_DEVICE, else on the first render node, and a device named
either way is the only one tried. A machine with no render node at all — a
GitHub-hosted runner, a Hyper-V or VirtualBox guest — usually still has a
card node Mesa renders on in software through kms_swrast, so the backend
then tries each /dev/dri/card* in turn. On a hosted runner that is
card1, and headless weston with its GL renderer accepts the buffers it
makes, as scripts/wayland-ci-probe.mjs measured.
What was measured
Everything below was run on this machine (mutter, virtio-gpu/virgl over an Apple M1 Pro host, 2488×1668 @ 75Hz). Numbers from a VM's GPU are the VM's; the protocol numbers are not.
The protocol layer is not the cost
Asked first, because it decides whether a native protocol bridge is
justified. Measured with @windowkit/wayland, per request and per
event:
| measure | Bun | Node (native socket) |
|---|---|---|
| encode + write, async API | 0.32 M req/s | 0.20 M req/s |
encode + write, $ synchronous API | 0.49 M req/s | 0.67 M req/s (1.5 µs) |
| decode + dispatch | 2.3 M ev/s | 3.0 M ev/s (0.33 µs) |
round trip sync → done, plain socket | 0.141 ms p50 | 0.119 ms p50 |
| round trip, fd-capable socket | 0.178 ms p50 (reader thread) | 0.097 ms p50 |
A frame issues a handful of requests and input peaks near a thousand events
a second: the JavaScript protocol layer is one to two orders of magnitude
below anything visible. No native protocol bridge was built, on purpose.
The one native piece is the socket itself, and only because Node cannot
receive descriptors at all; its round trip turned out lower than Node's own
net.Socket's, and the thread-hop cost of the Bun transport (~37 µs) is real
but not worth acting on.
Where a frame goes
| phase | p50 |
|---|---|
makeCurrent | 0.04 ms |
| draw | 0.05 ms |
| Wayland (attach / damage / commit) | 0.09 ms |
Surface.swap() | 24.5 ms |
The cost is swap(), and it is environmental: with no compositor at all,
swap() is 0.07 ms and glFinish() is ~10 ms — the same at 64×64 as at
720×480. Size-independent means host round-trip latency, not fill rate; this
is virgl in a VM. On bare metal the number should mostly disappear and must
be re-measured there before any conclusion about the backend's performance is
drawn from it.
The 2d context on the UI demo: 252 quads, 239 glyphs and 2 paths in 23 draw calls.
The design, where it is not the obvious one
A backing target, not the swapchain, is what gets painted. react-x11's
renderer repaints dirty rectangles into a persistent surface, as it would an
X pixmap. A GBM swapchain rotates, so the buffer a frame lands in last showed
the frame before last, and a partial repaint into it leaves stale pixels
everywhere else. So the renderer paints into a persistent render target
(target.js) and the changed rectangles are copied into whichever buffer
comes round. GBM names the buffer only after the swap, so the copy uses the
union of what every known buffer still owes — and everything, until a full
rotation has passed without meeting a new buffer. The tests pin the rule
(swapchain.test.js); a React counter ticking at 400 ms presents frames
with one damage rectangle.
Analytic shapes first, stencil second. Rounded rectangles — most of a
UI — are signed distance fields in the fragment shader: antialiased without
multisampling, and every box in a window batches into one draw. Arbitrary
paths (<svg>, ctx.fill(path2d)) are flattened by ntk's own flattenPath
and filled stencil-then-cover on the target's stencil attachment. Strokes
are the union of segment quads and join polygons under the non-zero rule.
Box shadows are a feathered SDF (ctx.fillShadow, which boxpaint.js now
prefers when a context has it) rather than a CPU blur through the paint
cache.
An image in perspective is one quad. drawImageProjected(image, m)
draws an image through a 3×3 projection — a plane turned out of the page,
as CSS rotateY() in a perspective turns one — with each corner's texture
coordinates over its w and the fragment dividing them back, which is exact
across the quad. A context that can only draw through an affine matrix has
to cut such a plane into tiles, each drawn through the matrix nearest the
projection over it and clipped to it, and here every tile is a batch of its
own because the clip is a scissor: <Html> drew Zen Garden 219's sidebar
as 258 of them a frame, at 11 frames a second on a virgl VM, where seven
tiles ran at 25. X11 has no such call on purpose: RENDER takes a projective
picture transform, but glamor — Xwayland's, and most Xorg desktops' —
divides at the corners and folds the image along its diagonal, and pixman
runs out of 16.16 precision on a plane of any size. The method answers
false and draws nothing where a corner is behind the viewer, for the caller
to draw the part in front some other way.
The ack is deferred to the frame that adopts it. xdg_surface.configure
is acknowledged immediately before the commit of a frame painted at the
configured size, not on arrival — acking early tells the compositor the
next commit is at the new size, and a buffer painted at the old one is then
shown stretched.
Everything the renderer sees is content-relative device pixels. The
surface is larger than the content by the client-side frame, and the
compositor speaks logical pixels; the two conversions happen in
backendwindow.js and input.js and nowhere else.
The keymap is parsed, not dlopen'd. The compositor sends an XKB keymap
as a file descriptor; xkb.js reads keycodes, types (modifier → level
maps), symbols per group, and resolves virtual modifiers like LevelThree
to real ones through interpret + modifier_map — so AltGr works because
the keymap says which bit it is, not because Mod5 was assumed. The output is
both the X core keycode2keysyms table the accelerator code already speaks
and a full decode() for levels 3 and 4. Key repeat is synthesised at the
seat's advertised rate; compositors do not repeat.
The input method is told at the end of the frame. zwp_text_input_v3
wants to know which field has focus, what kind of text it holds, where its
caret is and what surrounds it, and every one of those changes repaints the
field — so the backend window calls textInput.sync() once per frame after
the renderer has painted, when the layout is current, and only what changed
since the last commit is sent. There is no hook into the event manager and
nothing per field. What comes back — preedit_string, commit_string,
delete_surrounding_text, applied together on done in the order the spec
fixes — lands on the same CompositionStart/Update/End a dead key raises,
so <textinput> and an application's onCompositionUpdate cannot tell IBus
from compose.js. Keys the IME consumes never reach wl_keyboard.key, so
key delivery is untouched; a key that arrives is one the IME declined, and
the client-side composer has its usual go at it. A sensitive field offers
no surrounding text and says sensitive_data; inputMode on a
<textinput> becomes the content purpose (email, url, tel, …).
A finger and a pen are the pointer. On X11 a tablet is an XI2 slave of
the master pointer and a touch emulates it, so every control in the tree is
written against mousedown/mousemove/mouseup and nothing else. The
Wayland seat keeps wl_touch and the tablet tools as devices of their own,
so touch.js and tablet.js make them the pointer here: the first finger
of a gesture and a tool in proximity emit the seat's own enter/motion/
buttonpress/buttonrelease/leave, carrying the touch down or tool
down serial (what xdg_toplevel.move wants when the press lands on the
titlebar), and input.js routes them through exactly the path the mouse
takes — frame hit-testing, popup grabs, the cursor, which a tool in
proximity follows through cursor-shape's tablet device. The emulated events
alone carry pointerType ('touch', 'pen', 'eraser', 'mouse'),
pressure, tiltX/tiltY, rotation, distance and
tangentialPressure in the DOM's names and ranges, so a mouse event is
unchanged and the X11 backend never sees a field it did not send. A second
finger emulates nothing; every finger also arrives raw on the window as
touchstart/touchmove/touchend/touchcancel — ntk's XI2 names, with
its touchId — each with the active touches, batched at wl_touch.frame.
Barrel buttons map as the desktop does: BTN_STYLUS is button 3, BTN_STYLUS2
button 2; proximity_out releases whatever is still held.
createWindow is synchronous. React's commit phase cannot await, and
that is where windows are realised. Nothing about Wayland needs the
asynchrony — a request is one-way and the client allocates ids — so the
library grew a synchronous namespace (Wl_interface.$) and the only
genuinely asynchronous step, binding globals, happens once in open().
The monitors are a fold over the registry. Each wl_output is its own
global, so outputs.js binds every one (through a second wl_registry,
because the library files globals by interface name and keeps one) and
publishes the layout into screens.js the way the cocoa backend does:
names from wl_output.name (DP-1, eDP-1), logical rects from
xdg_output where the compositor has it and from the mode over the scale
where it does not, converted to the renderer's device pixels at
app.scale. Hot-plug is global/global_remove on that registry. Two
things the protocol does not have are stood in for: a primary (the
output at the origin), and a work area — xdg_toplevel.configure_bounds
is the monitor less its panels, sent to a window ahead of its configure, so
a window's bounds become the usable rect of the output it has entered, and
every monitor carries its own rect so no head is clamped by another's. The
densest output also seeds app.scale before the first window exists, which
is the difference between a first layout at 2x and one corrected a frame
later; and where a compositor offers neither fractional-scale-v1 nor
preferred_buffer_scale, a surface's buffer scale is that of the output it
entered.
A <glarea> is a rectangle of the backing target. On X11 it is a child
window and on macOS a sublayer; a subsurface here would be a second
swapchain, a second frame clock and a second dma-buf import per frame for
what is already one GL context drawing into one target. So glarea.js
hands the area the shared GLES table with viewport, scissor and
bindFramebuffer(null) re-based on its rect (the Cocoa backend's own
trick, prototype-delegated), and the frame loop orders the three layers:
the tree's 2D is flushed under, the area draws over, and the panes its
children were painted on — offscreen targets, createOverlayPane — are
blended over that, translucent. One thing a shared target needs that a
child window never did: when the tree repaints under a static scene, the
scene is asked for its frame before the present, so the background never
shows through for a frame.
One GL device, several contexts, and whoever draws takes it. A window's
2d context, the pane a <glarea>'s children are painted on and every
offscreen Surface are the same GLES context, differing only in which
framebuffer they bind and what they left the blend, scissor and stencil
state saying — unlike an XRender context, which is an encoder aimed at a
drawable, or a Cocoa one, which carries its state in its bitmap. So
device.js gives the device an owner: a context claims it before it
touches GL, and claiming is where the target is bound and the state the
shader was written against is put back. That is what makes ntk's advice —
hold a surface's context and draw into it whenever you like — true here
too, where a held context used to draw wherever the device was last pointed
and say nothing about it (#566); it is also why a node that painted into a
surface in the middle of the frame does not have to restore the window's
context afterwards. Foreign GL is the exception it cannot see: a
<glarea> draws through entry points nobody tracks, so the frame loop
still calls restoreGLState() after one.
Paths are antialiased on the CPU, and clips are exact on the stencil.
A solid fill that fits the budget (512×512 after cutting to the clip) goes
through ntk's analytic coverage rasteriser — the same pixels the X11
backend's local raster and every glyph get — into a per-frame mask atlas,
drawn like a glyph; gradients and bigger shapes stay on stencil-then-cover.
clip() with anything but one axis-aligned rectangle sets bit 7 of the
stencil where the shape is (the low seven bits stay the winding count) and
every batch under it tests that bit; nested clips intersect in place,
restore() re-renders the restored list. The scissor still narrows to the
bounds, so the test is only paid inside them. test/wayland/context2d-gpu.test.js
reads the pixels back on any machine with a render node.
The glyph seam is public, and spelled the same way on every backend.
drawGlyphs(op, src, positioned) is how TextLayout.draw reaches a
context, and ntk documents it for callers that place their own glyphs too —
a terminal grid, a tabular column. The source in that call is spelled
ctx.createSolidPicture(r, g, b, a) and the op ctx.Render.PictOp.Over,
so both of those answer here as they do on X11 and on Cocoa. Implementing
drawGlyphs alone was not half a seam but a silent one: callers
feature-detected, the test came back false, and the frame drew with every
cell in its colour and no text in any of them rather than throwing (issue
#565). What a solid is stays backend-private — an XRender Picture on
X11, the premultiplied colour itself here — but a CSS colour string, a
premultiplied [r, g, b, a] and null (meaning the fill style in force)
are all accepted as sources as well, which is the set the Cocoa backend
takes.
A surface the compositor stops showing stops drawing
After the first present the frame loop has exactly one source of liveness:
wl_surface.frame. A compositor is entitled never to send one to a surface
it is not showing — covered, on another workspace, minimised, the screen
locked — and that is the right way to drive a client, because painting into a
surface nobody sees is work for nothing. There is no timer-driven fallback
here and there should not be one.
What it costs is that the silence is not an error. Nothing rejects, the
frame stays armed, and every later repaint request returns at its guard while
React goes on re-rendering; from outside the process, an app that has drawn
one frame and gone quiet is indistinguishable from a hang (#567 — 135 state
ticks, one present, exit code 0). So the loop is watched (framewatch.js),
and the parked state is reported twice over:
- On stderr, once per window, in development, when the window still had something to draw. One line, naming the compositor as the reason and saying the app is not hung.
- In the tree, as
useWindowState().presenting, which also folds intovisible— so an app can stop animating and simulating rather than feeding a loop nobody is turning, which is what every map, chart and game on this backend wants anyway.
Nothing is re-armed or polled to recover: the frame callback we asked for is
still queued with the compositor and fires the moment the surface is shown
again, which un-parks the loop and flips presenting back. The deadline is
two seconds, and REACT_X11_WAYLAND_FRAME_STALL_MS changes it — 0 turns the
watch off entirely, timer, warning and all.
Decorations: whose frame
decorations.js draws a titlebar because mutter will not, and that stays
the default where nothing better is offered. Where a compositor advertises
zxdg_decoration_manager_v1 — wlroots (sway, labwc), KDE — each toplevel
creates a decoration object before its first commit and asks for
server-side decorations (ssd.js). The compositor's answer is a
configure(mode) that is part of the surface's configure sequence, so it
is adopted on the same ack as the size it arrived with: 'server' switches
the client-side frame off (insets to zero, no titlebar painted, the content
grows into the whole surface, which the tree sees as a resize), 'client'
leaves it on. Before the first frame, where the compositor imposed no size,
the surface is refitted to the content the tree asked for, so a floating
window comes up the size it was declared and not a titlebar taller.
createRoot({ decorations }) says what to ask for: undefined/true/
'server' prefer the compositor's frame, 'client' never asks for it, and
false — as does <window decorations={false}> — wants no frame at all.
A frameless window still creates the decoration object where the protocol
exists, to decline server-side: sway frames every toplevel that has not
said client_side.
Layer surfaces: docks, panels, wallpapers
Where the compositor advertises zwlr_layer_shell_v1, a <window> whose
windowType is one of these is not an xdg_toplevel but a layer surface
(layershell.js), the same wl_surface, frame loop and input underneath:
windowType | layer | anchored to | exclusive zone |
|---|---|---|---|
'dock' | top | the edge its shape and position imply (see below) | its thickness |
'desktop' | background | all four edges | -1 |
'notification' | top | top and right, 12px in | 0 |
'splash' | overlay | nothing — the compositor centres it | 0 |
A dock's edge is read the way an X window manager reads the same window:
wide and at y={0} is a top panel, wide otherwise a bottom one; tall and at
x={0} a left dock, tall otherwise a right one. Its size is what the tree
measured (or width/height), so a 560×56 dock sits centred on its edge
with 56px reserved; anchoring it to both ends of the edge stretches it:
<window windowType="dock" height={56}>…</window>
<window windowType="dock" height={40} layerShell={{ anchor: ['bottom', 'left', 'right'] }}>…</window>
<window layerShell={{ layer: 'overlay', anchor: 'bottom', margin: { bottom: 40 } }}>an OSD</window>
layerShell names anything the type's default gets wrong — layer,
anchor (an edge, an array of edges, 'all', 'none'), exclusiveZone
(a number, 'auto' for the thickness, -1), margin,
keyboardInteractivity ('none', 'on-demand', 'exclusive'),
namespace, output — and layerShell={false} keeps a dock-typed window
an ordinary toplevel. Popups from a layer surface work (a dock's menus): the
xdg_popup is created parentless and adopted through
zwlr_layer_surface_v1.get_popup. A stretched axis is the compositor's:
resize() on it is not granted, and the configured size wins. Without
layer-shell — GNOME — the same JSX is a toplevel, as before.
Screen capture, and the eyedropper
app.screenCapture (screencopy.js) is there when the compositor offers
either ext_image_copy_capture_manager_v1 (wlroots 0.19+, KDE 6.2+) or
zwlr_screencopy_manager_v1 (sway 1.10 has this one), and null otherwise
(GNOME). capture() answers one frame of the output as straight RGBA, the
shape readback.js's encodePNG takes; pixelAt(x, y) one device pixel
of it. The pixels land in a wl_shm pool (shm.js) whose memory is a
memfd (or an unlinked /dev/shm file without x11-dri), read back with
fs.readSync — there is no mmap in Node or Bun, and one copy of one screen
on a path nobody runs per frame is the cheaper trade than a native binding.
pickColor() is the eyedropper, and it is what every wlroots colour picker
does: freeze the output into a capture, show that capture as a layer-shell
overlay anchored to every edge with an exclusive keyboard, take the click
on the overlay — whose coordinates are the output's — read the pixel out
of the capture, and take the overlay down. Escape cancels; Return, space and
KP_Enter pick at the pointer. It answers { r, g, b } in 0–1, the portal's
units, so src/screencolor.js converts it with the same function as the
portal's and the cocoa sampler's. There it is rung 3 of the ladder: below
the Screenshot portal (the desktop's own picker, user-consented — GNOME's
only route, and what sway with xdg-desktop-portal-wlr uses too) and above
the X11 grab. useEyedropper().supported is true on a bare sway; the rung
is guarded by app.backend === 'wayland' because the capability is a
Wayland object through and through.
Two protocol gaps are worth knowing. @windowkit/wayland keeps one
global per interface name, so the output bound here is the one advertised
last; capture({ output }) and layerShell.output take another proxy, but
enumerating them waits on outputs.js. And the portal's parent_window
handle names an X window; a Wayland toplevel has none without xdg-foreign,
so the portal dialog floats.
Under a second compositor
mutter was the only compositor this backend had met until sway 1.10.1 and
labwc 0.8.3 ran here, nested (WLR_BACKENDS=wayland) and headless
(WLR_BACKENDS=headless), which is also the first time a screenshot of the
whole path was possible — grim needs wlr-screencopy, which mutter
refuses:
WLR_BACKENDS=headless sway -c sway.conf & # output * resolution 1280x800
WAYLAND_DISPLAY=wayland-1 REACT_X11_BACKEND=wayland node --import tsx examples/simple.jsx &
WAYLAND_DISPLAY=wayland-1 grim shot.png
What it showed: the window comes up in sway's own frame (title bar and 2px
border, default_border normal 2) with the client-side bar gone and the
content filling the surface; a windowType="dock" window is a bottom layer
surface with a reserved strip the tiled toplevel keeps clear of; the
capture matches what grim sees; and the eyedropper's overlay maps and the
pick resolves from a synthesised click. The one protocol bug it surfaced was
in the client library rather than the compositor: it refused a null object
argument (set_parent(null), set_fullscreen(null), get_popup(null, …),
get_layer_surface(…, null, …)). @windowkit/wayland 3.1.1 takes null
wherever the protocol marks an argument allow-null, and the backend passes
it directly.
The transports, and Node
Descriptors are the protocol: the keymap, shm pools and clipboard pipes all arrive as fds, and Node's sockets abort on them (nodejs/node#53391, closed).
x11-driUnixSocket(>= 0.9): auv_pollover anAF_UNIXsocket,recvmsg/sendmsgwithSCM_RIGHTS, no thread. Works on Node. Two things it had to get right that a first version did not: N-API values made from a libuv callback need a handle scope, and callbacks from the loop must go throughnapi_make_callback—napi_call_functionfrom there runs the callback but never drains the promise-job queue, so a client awaiting its first reply hangs until an unrelated timer turns the loop.pipe(),socketpair()andmemfdCreate()came with it.- node-x11's
fdpass-bun.js:bun:ffito libc, with a reader thread. Bun only. Bun exports libuv's symbol names but several are stubs that abort the process (uv_poll_initamong them), so the native socket is never attempted under Bun; there is nothing to probe for safely. Its reader thread gave up on a healthy socket whenpoll(2)was interrupted sixty-four times in a row, and JavaScriptCore's thread-suspend signal (SIGPWR,tgkilled to every JS thread, the poller included) during WebAssembly compilation — yoga-layout's wasm tiering up ~0.3 s into any react-x11 app — produces exactly that burst, asstrace -f -e ppoll,tgkillshows. EINTR is a retry since x11 4.2.1 (sidorares/node-x11#297).
What is left
- Multisampling. Stencil-then-cover (gradients, shapes past the CPU
budget) and stencil clips are aliased at the edge. MSAA is the fix, and it
is not the few lines it looks like: GLES 3.0 cannot
blitFramebufferinto a multisampled framebuffer, so the scroll blits and the partial repaints the backing target exists for would have to be re-expressed as textured draws, and every read of the target (a present,getImageData, adrawImageof a surface) as a resolve first. virgl here reportsMAX_SAMPLESof 1, so none of it could be validated; it waits for a machine that can run it. <Frame>panes. The out-of-process pane path (createPaneHost) is macOS-only today — the pane presents an IOSurface id. Linux wants the same over a memfd or dma-buf fd on the pane channel; until then<Frame>here degrades the way it does without embedding.- Drag-and-drop. Wired (
src/wayland/dnd.js): the drop side answers another application's drag overwl_data_deviceenter/motion/leave/drop, and the source side offers a payload and hands the gesture tostart_drag, with an in-app drop keepinge.itemsby reference. What is left is the drag icon:start_dragis given a bare (invisible) surface, and rendering the tree's<popup dragPreview>into an icon surface the frame loop paints is a follow-up. - Input methods, the rest of it. The preedit's cursor is drawn (a
heavier underline under the segment being converted) but nothing is done
with a hidden cursor beyond putting the caret at the end, and the only
content hints sent are
multilineandsensitive_data— nospellcheck/auto_capitalization, which the tree has no notion of. - Tablet pads. The buttons, rings and strips on the tablet itself
(
zwp_tablet_pad_v2) are accepted and ignored: the tree has no vocabulary for them. useScreens().sourcereads'test', as it does on macOS: both backends publish throughsetScreensForTests, the one seamscreens.jshas, and it stamps the source. Asourceargument on it is the fix.fillTextonly sees the first font family of afontshorthand and strokes text as a fill.ext-image-copy-captureis implemented to the protocol text but has not run against a compositor: sway 1.10 advertises only wlr-screencopy, and GNOME neither. wlroots 0.19 (sway 1.11) is where to try it.
Things that are gone, not missing
Window managers (examples/wm.jsx), global grabs, reading other windows,
screen coordinates, GetImage, XEmbed/<foreign>, and ssh -X are
compositor-private by design. Panels and docks are layer-shell, and a
screen read is screencopy — both wlroots and KDE, neither GNOME, where the
same JSX is a toplevel and the same pick is the portal's.
Changes outside src/wayland/
src/Reconciler.js— a'wayland'rung inresolveBackend(). Additive;'auto'never selects it.src/nodes/boxpaint.js— prefersctx.fillShadowwhen a context has it.src/nodes/preedit.js— the composition may carry a cursor (cursorBegin/cursorEndon the composition event); without one the caret is at the end of the preedit, as before.src/events.js—_compositionpasses those extra fields through. Additive on both backends.src/screencolor.js— rung 3 of the eyedropper's ladder, the Wayland backend's own picker, guarded byapp.backend === 'wayland'; and the portal'sparent_windowhandle is left empty on that backend, where an X window id would be a lie.@windowkit/wayland3.1.1 — a fork ofwayland-client3.0.0: fd send/receive, the$synchronous request namespace, callback requests returning theirdonepayload, andnullforallow-nullarguments.x11-dri0.9.0 —UnixSocket,pipe,socketpair,memfdCreate; a self-test that passes a real descriptor through a socketpair.x114.2.1 —fdpass-bun.jsretries apoll(2)that EINTR interrupted.