Skip to content

Verify the Wayland paths against real peers, and settle the two decisions they left - #481

Merged
JE-Chen merged 21 commits into
mainfrom
fix/clipboard-handles-and-window-input
Aug 19, 2026
Merged

Verify the Wayland paths against real peers, and settle the two decisions they left#481
JE-Chen merged 21 commits into
mainfrom
fix/clipboard-handles-and-window-input

Conversation

@JE-Chen

@JE-Chen JE-Chen commented Aug 19, 2026

Copy link
Copy Markdown
Member

What this is

The Wayland backend had three things recorded as "needs a VM running a real desktop". None of them did. Replacing that assumption with five container jobs that talk to real peers turned up a set of defects that all shared one shape: a call that reported success while doing nothing, or landing somewhere else.

The jobs

Job Peer Checks
wayland-verification real headless sway 27 x 2 layouts
eis-verification real libeis.so.1 over a Unix socket 20
portal-verification real dbus-daemon + real liboeffis 20
ydotool-verification real uinput device, read back from /dev/input/eventN 12
seat-verification wlroots session consuming the real ydotool device 14 x 2 layouts

A seat that consumes libinput devices is WLR_BACKENDS=headless,libinput + LIBSEAT_BACKEND=builtin + SEATD_VTBOUND=0, with udev up before ydotoold. A portal is whoever owns the bus name. The second layout is negative-origin — a monitor left of the primary one.

What they caught

  • ydotool 0.1.x exits 0 and emits nothing for every argument this backend builds. That is what Debian bookworm and every current Ubuntu ship under the name. Run with check=True, nothing ever raised.
  • The xdg-desktop-portal capture tier could never have worked. A portal Response is directed at the connection that made the call; the tier listened on a gdbus monitor subprocess and called from a separate gdbus invocation. Every capture ran its full 30-second timeout.
  • A monitor left of the primary broke every capture path three separate ways: size() returned the layout's right edge rather than its width, the fallback crop cropped in the wrong coordinate space, and grab_logical() reported origin (0, 0) so matches were reported a monitor's width to the right.
  • libei silently discards absolute motion outside the advertised regions — no return code, no event, no error — so the move was lost and never reached the ydotool fallback.
  • --absolute counts from the layout's top-left corner, not layout (0, 0).
  • Eleven call sites imported PIL.ImageGrab or mss directly, so on Wayland they read the XWayland root: OCR, smart waits, visual regression, the recorder, the WebRTC host, the monitor enumerator, the MCP monitor tools and the template cropper all captured a blank screen and reported success.

The two decisions they left

Both were DECIDE in Progress.md, and both are the same problem: a compositor setting the library can measure but cannot read.

  • Pointer acceleration is now declared by the operator via JE_AUTOCONTROL_WAYLAND_POINTER_ACCELflat moves silently, strict refuses rather than land somewhere else, unset keeps today's warn-once-and-move so nothing that works now stops working. An unrecognised value falls back to warn and says so.
  • The software cursor in a Wayland capture is documented rather than worked around. wlroots composites a software cursor into the buffer wlr-screencopy hands back whenever the backend has no cursor plane, so a locator can find a pointer-shaped hole in its target — Windows and X11 never do this. Both mitigations need a cursor position Wayland will not give a client, and masking the wrong place is worse than a visible pointer. The screen_capture diagnostics check now carries cursor_may_be_captured.

Also in here

  • Six over-limit files split back under 750 lines along seams that already existed, no behaviour change.
  • POST /execute answers 400, not 500, for a command name the executor does not know.
  • Windows clipboard calls wait out a clipboard another process is holding open (~200 ms) instead of failing immediately.
  • CLAUDE.md now states which limits CI actually rejects; test_doc_line_counts.py --fix re-measures every line count the architecture map quotes in one pass.

Test plan

  • python -m pytest test/unit_test/headless4633 passed, 16 skipped locally.
  • ruff / bandit / radon clean on the touched modules.
  • The one thing CI has to answer that a local run cannot: whether the ydotool-verification and seat-verification jobs can modprobe uinput evdev on a GitHub runner. Both fail loudly rather than skipping if they cannot, so the answer will be unambiguous.

JE-Chen added 13 commits August 17, 2026 21:11
set_clipboard_files, set_clipboard_html, set_clipboard_rtf and
set_clipboard_csv raised OverflowError on every call, and the matching
readers raised whenever that format was actually present. Each module
declared restype but not argtypes, so ctypes passed the pointer-width
memory handle as c_int. Declaring only restype fixes the value coming
back and leaves the argument going in broken.

The prototypes and the open/alloc/lock dance now live once in
utils/clipboard/win32_clipboard_api.py, on private WinDLL handles so the
declarations cannot leak into other user32 callers in the process.

The pure-function tests could not catch this: the byte packing was always
correct and nothing exercised the Win32 half. The new test round-trips
text, HTML, RTF, CSV and file lists through the real clipboard, and adds
a static check that no module calls a handle API without prototypes.
save_window_layout documented "every titled window" but its default
lister returned all of them, while restore_window_layout addresses a
window by title and skips blank ones. On a real desktop that was 28
entries saved against 15 restorable, so a caller reporting the saved
count was over-promising by a factor of two.

The lister now passes titled_only=True, and a round-trip test pins save
and restore to the same set.
Three related gaps in window handling, all of which failed silently.

A window title cannot identify a multi-process application: its windows
are named after whatever they display and several of its processes have
no window at all. foreground_window_process_id, window_process_id,
windows_for_process_id and minimize_windows_for_process answer by
ownership instead. Unavailable reads as None rather than a bare 0, which
a caller could otherwise match against a process list and hit the System
Idle Process.

Posted input went to the top-level frame, but keyboard messages are
delivered to the control that has focus, and a click belongs to the child
under the point in that child's client coordinates. Measured on Character
Map: posting to the frame typed nothing, posting to the focused edit
typed the character. post_key_to_window and post_click_to_window resolve
the real target (GetGUIThreadInfo, ChildWindowFromPointEx) and report
whether the messages were queued; a printable key also posts WM_CHAR,
without which most edit controls type nothing.

send_key_event_to_window and send_mouse_event_to_window are deprecated
and delegate to those. They kept reporting success while doing nothing,
so leaving them as-is meant leaving a trap. Behaviour changes: the key
sender now matches the title as a substring (it required an exact title),
and the mouse sender accepts a title as well as the hwnd it always took.

All four surfaces are wired: facade, AC_* commands, MCP tools and Script
Builder specs.
The module declared none, so the HDC from GetDC — pointer-width — came
back through ctypes' default c_int, and the same truncated value was
passed on to GetPixel and ReleaseDC: a wrong colour and a leaked DC, with
no symptom either way. Same trap as the HWND and HGLOBAL handles
elsewhere.

The module now owns private user32 / gdi32 handles so the declarations
cannot leak into other callers, and its import-time SetProcessDPIAware()
call is documented as what it is: process-wide and irreversible.
Command / MCP-tool counts move to 773 and 676 (the doc-count guard
measures them), the touched module line counts are re-measured, and the
clipboard subpackage row now names the shared Win32 prototype module.

Progress.md loses the settled item: the two deprecated window-input
functions are no longer a decision, they delegate.
Five container jobs replace assumptions with measurements, and none of
them needs the VM three of these items had been recorded as needing:

* wayland-verification — a real headless sway session, 27 checks over
  two output layouts, the second one negative-origin.
* eis-verification — the real libeis server over a Unix socket, so the
  libei sender is read back off the wire, 20 checks.
* portal-verification — a real dbus-daemon and real liboeffis against a
  portal implemented here; ConnectToEIS hands over a live fd to that
  same libeis, 20 checks including six refusal paths.
* ydotool-verification — a real uinput device read back from
  /dev/input/eventN, 12 checks.
* seat-verification — a wlroots session consuming the real ydotool
  device, cursor position read out of grim -c pixels, 14 checks over
  the same two layouts.

A seat that consumes libinput devices is WLR_BACKENDS=headless,libinput
plus LIBSEAT_BACKEND=builtin plus SEATD_VTBOUND=0, with udev up before
ydotoold. A portal is whoever owns the bus name. Both were mistaken for
things a container cannot do.

The jobs fail loudly when uinput or evdev will not load rather than
skipping, so a runner that cannot provide them says so.
Two capture defects that only a real compositor could show.

A monitor left of or above the primary one puts the layout's top-left
pixel at a negative coordinate, and three places assumed (0, 0):
size() returned the layout's right edge rather than its width, so every
caller composing size with a capture asked for half the desktop; the
crop applied when a tier cannot take a region itself cropped in layout
coordinates on a layout-origin image, returning black padding; and
grab_logical() reported origin (0, 0), so a match on that monitor was
reported a monitor's width to the right and the click missed. The
backend now publishes layout_origin(), size() returns the bounding
box, and the crop subtracts the origin.

The xdg-desktop-portal tier could never have worked. A portal Response
is directed at the connection that made the call, and the tier listened
on a `gdbus monitor` subprocess while calling from a separate `gdbus`
invocation — two connections, so the listener was never the addressee.
Against a real bus every capture ran its full 30-second timeout. The
tier now speaks D-Bus itself on one connection, subscribing to the
request path it predicts before it calls, which also drops the gdbus
binary requirement: _dbus_client is stdlib-only.
Eleven call sites imported PIL.ImageGrab or mss directly. On Wayland
both read the XWayland root, which holds none of the native windows —
so OCR, smart waits, visual regression, the recorder, the WebRTC host,
the monitor enumerator, the MCP monitor tools and the template cropper
each captured a blank or stale screen while reporting success.

screen_grabber is now the one place that decides: it hands back an
ImageGrab-shaped and an mss-shaped object backed by the compositor's
own capture tool under Wayland, and by Pillow / mss everywhere else.
Nothing above it changes shape.

The diagnostics bundle gains a screen_capture check naming the tier in
use, because a Wayland session with no capture helper is the one case
where the framework can still move the mouse and see nothing. It also
carries cursor_may_be_captured: wlroots composites a software cursor
into the buffer wlr-screencopy hands back whenever the backend has no
cursor plane, so a locator can find a pointer-shaped hole in its
target, and the report that explains that failure should say so.
Four ways this backend claimed to have moved something it had not.

ydotool 0.1.x — what Debian bookworm and every current Ubuntu ship
under that name — exits 0 for every argument this backend builds and
emits nothing at all. Run with check=True, nothing raised. The CLI
generation is now classified once per process and an old one is
refused by name; a version the probe does not recognise is let
through so this cannot block a future release.

libei discards absolute motion outside the regions the compositor
advertises, without a return code, an event or an error. Region
offsets are uint32, so no region can cover the negative layout space a
monitor left of the primary one creates: the pointer went nowhere and
never reached the ydotool fallback. The sender now maps the point into
region space, retries normalised by the layout origin, and refuses
what neither covers so _select_input hands over.

An emission a live backend refused raised instead of falling back, as
libei's own docstring claimed it did not; a chord refused part-way now
releases what it already pressed. LibeiUnavailable derives from
AutoControlException so it stops escaping every containment boundary,
and a completed session is released rather than leaked.

Absolute moves through ydotool now subtract layout_origin(): --absolute
counts from the layout's top-left corner, not layout (0, 0). What the
compositor then does to that displacement is acceleration nobody can
read back, so JE_AUTOCONTROL_WAYLAND_POINTER_ACCEL is how an operator
declares it — flat moves silently, strict refuses rather than land
somewhere else, unset keeps the warn-once default. Scroll also goes
through libei now, with the vertical axis negated: ydotool counts
detents in the kernel's REL_WHEEL frame and libei in wl_pointer's.
Each was one module holding several, and each split along a seam that
already existed rather than by line count:

* main_widget.py keeps registration and layout; the screenshot, image
  detection, recording and script tabs move to their own modules.
* webrtc_dialogs.py sheds known-host storage and verification.
* remote_desktop/host.py sheds viewer access control, host capture and
  the per-client handler.
* webrtc_host.py sheds authentication and media negotiation.
* mcp_server/server.py sheds the wire protocol and client-initiated
  requests; http_transport takes _notification_message from _protocol.
* flow_control.py sheds the data-producing commands (ocr / pdf / shell
  / sql / otp to var, assert_duration).

No behaviour change: the moves are re-exported where callers expect
them, and the tests follow the symbols to their new homes.
POST /execute and /execute_file funnelled every executor failure into
500 {"error": "execute_action failed"}, so a client could not tell a
typo in its own request from a broken server.

Every name in the list is now checked before anything runs, nested
flow-control bodies included, and an unrecognised one comes back as
400 naming all of them with nothing executed. /execute_file answers
the same way for a path that is unreadable or holds something that is
not an action list.
Only one process may have the Windows clipboard open at a time, so
RuntimeError: OpenClipboard failed escaped whenever anything else was
mid-copy — roughly one call in a thousand on a live desktop.

win32_clipboard_api.open_clipboard() is now the single place that
opens it, retrying for about 200 ms before raising as before.
CLAUDE.md now says which limits a CI job actually rejects — line
length, and nothing else — so the section stops being read as a gate.
The file-length limit gets its scope written down: new files always,
rewrites and growth yes, flat data tables no, everything else
grandfathered in Progress.md and nowhere else.

test_doc_line_counts.py measures every line count the architecture map
quotes and rewrites all of them with --fix. Hand-editing is how the
map ended up quoting one subsystem at two sizes at once: most tables
had been counting a phantom trailing line per file while the totals
counted correctly.

Progress.md loses both DECIDE items. Pointer acceleration became an
operator declaration; the software cursor in a Wayland capture becomes
documentation, because both ways around it need a cursor position
Wayland will not give a client, and masking the wrong place is worse
than a visible pointer. What is left there is external: two libeis
observations with no peer to drive them, the consent dialog as a
dialog, and whether a GitHub runner can modprobe uinput and evdev.
@codacy-production

codacy-production Bot commented Aug 19, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 1419 complexity · 14 duplication

Metric Results
Complexity 1419
Duplication 14

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

The negative-origin check swapped `libei._layout_origin`, which has not
existed since the lookup moved to `_layout.py`; `_region_point` calls
`layout_origin`, the name libei binds on import.

Caught by the eis-verification job on its first run against a real EIS
server, which is what that job is for. The swap raises AttributeError on
a name that is not there, so the check failed loudly rather than
passing while measuring nothing.
All five verification jobs ran on a GitHub runner. modprobe uinput evdev
works there and systemd-udevd receives kernel uevents inside the
container, which had only been measured locally on a WSL2 kernel. The
jobs still fail loudly rather than skipping, so a runner kernel that
stops providing either will say so.
The one that mattered was the gate: portal_verify built a filesystem
path out of a string that had crossed the bus, which reads as
user-controlled however the mock produced it. It now builds the path
from its own two inputs — the --shot-dir it passed and the file name it
expects — and compares the recorded string to that, which is both
untainted and a stricter check: a portal writing somewhere else now
fails instead of being followed.

The rest are quality:

* parse_wlr_randr, libei_verify.main and the uinput drain loop were
  each over the cognitive complexity limit. The first two hold a block
  of state per iteration, so the per-line fold and the six check
  closures move out; the third merges two except branches that were
  always the same branch, since BlockingIOError is an OSError.
* _forget_device dropped the copy-then-mutate over the device map for
  a list of the keys to remove.
* eis_server's fixture thread catches Exception rather than
  BaseException, so an interpreter shutdown is still an exit.
* Two dict constructions in _dbus_client, an unused uniform-signature
  parameter in two places, and a handful of test-only ones: fixtures
  built before they are installed rather than mutated in place, and
  constructors hoisted out of pytest.raises blocks so each block names
  the one call expected to throw.
* apt package lists sorted inside the groups they were already in.
Seven places either defaulted XDG_RUNTIME_DIR to "/tmp" or named a file
directly under it — a socket path, a capture, a mock portal's shot
directory. Inside the images the fallback never ran, because every
Dockerfile sets XDG_RUNTIME_DIR; run one of these by hand and it wrote a
predictable name into a directory any user on the host can create
entries in, which is what the sockets in particular must not do.

They now take XDG_RUNTIME_DIR when it is set, creating it 0700, and a
fresh mkdtemp when it is not. The mock portal's --shot-dir defaults to
the same rather than to /tmp; the driver has always passed one.
The sid source both images add was plain http. apt verifies package
signatures either way, so this was not a way in — but the base image
already carries the CA certificates pip uses, so there is nothing to
trade: `apt-get update` and the `-t sid ydotool` install both complete
over https, measured in the same python:3.12-slim these build from.
Every other script under docker/ annotates the same argv-list calls with
the reason they are safe; this one was the only file where bandit still
had something to say, so the harness now reads consistently and a real
finding there will stand out.
`getattr(self, f"_do_{method}", None)` can return any attribute that
happens to carry the name, so checking it against None accepted one that
is not callable and crashed a line later. callable() is the check the
dispatch actually needs.

pylint still reads the call as not-callable, because it infers the type
of a dynamic getattr from the default and cannot narrow past a guard;
that is what the inline disable says.
Codacy honours a nosemgrep marker only when it sits on the exact line it
reports, and its taint rule reports the tainted argument rather than the
call, so a marker on the subprocess.run() line silenced the audit rule and
left the taint one standing. Hoist the argument onto the call line so one
marker covers both, and mark the two constructors in the ydotool CLI test,
which build a result and an exception rather than launching anything.
@sonarqubecloud

Copy link
Copy Markdown

@JE-Chen
JE-Chen merged commit d79c93d into main Aug 19, 2026
30 checks passed
@JE-Chen
JE-Chen deleted the fix/clipboard-handles-and-window-input branch August 19, 2026 17:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant