Skip to content

x11: build single-monitor screen frame from root geometry, not the CRTC - #249

Open
e4779 wants to merge 3 commits into
gnustep:masterfrom
e4779:fix/live-resize-screen-frame
Open

e4779 wants to merge 3 commits into
gnustep:masterfrom
e4779:fix/live-resize-screen-frame

Conversation

@e4779

@e4779 e4779 commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

On X11, the monitor frames in -screenList are built from
XRRGetScreenResources CRTC data. An fb-only resize — RRSetScreenSize,
what xrandr --fb and streaming/remote-desktop servers issue on every
viewport change — resizes the framebuffer without touching the CRTC,
which keeps reporting the mode it still drives; the resources config
timestamp does not change either, but the data is not stale: the CRTC
is accurate about the mode. Applications then lay out against a screen
that no longer matches the framebuffer. With GNUstep Workspace as a
streamed desktop (Selkies + Xvfb with RANDR), after
1280x800 -> 1680x1050 -> 1280x800 at GSScaleFactor 2.0 the desktop
window clamps itself against the 1280-wide CRTC and lands at
origin.x = -400, painting a quarter of the display.

When exactly one output is connected and no window manager publishes
_NET_WORKAREA, nothing but the root geometry describes the screen, so
this takes the frame from DisplayWidth/DisplayHeight (refreshed
through XRRUpdateConfiguration when RRScreenChangeNotify arrives)
after the RANDR loop. Under a window manager the published work area
keeps governing the frame.

Tests/x11/screenframe.m runs its own Xvfb and checks all three: an
fb-only resize, the same resize with _NET_WORKAREA published, and a
mode change.

@e4779
e4779 requested a review from fredkiefer as a code owner October 12, 2026 09:29
@fredkiefer

Copy link
Copy Markdown
Member

Could you please clarify how this change relates to #217 and #226 ?

@e4779

e4779 commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

They fix different failures of the single-monitor screen frame in the
same function (XGServerWindow.m, -screenList), and they compose
rather than compete.

Current master builds that frame in two steps: _workAreas reads
_NET_WORKAREA, and when exactly one monitor is connected and the
property exists, the monitor's frame is replaced by the first work
area (1 == monitorsCount && workAreas != nil). Without a window
manager the property does not exist, and the frame stays at the CRTC
geometry.

  • Fix: flip the _NET_WORKAREA origin and count monitors, not outputs #226 fixes that work-area path for the with-WM case: the origin was
    not flipped, and the single-monitor test ran on the output count
    instead of the monitor count.
  • x11: report the monitor as the screen frame and the work area beside it #217 removes the work-area-as-frame semantics altogether: the frame
    is the monitor again, and the work area moves beside it into
    -workAreaForScreen:.
  • This change covers the case neither of them reaches: no window
    manager
    . Streaming servers resize through RRSetScreenSize
    (xrandr --fb), _NET_WORKAREA does not exist there (our backend
    logs Failed to get _NET_WORKAREA), so the frame stays at the CRTC
    geometry — which is stale after a live resize, because
    RRSetScreenSize does not bump the resources config timestamp that
    XRRGetScreenResources caches on. The normalization sets the
    single-monitor frame from the root geometry instead.

For a single monitor all three end at the same frame — the whole
screen — through different routes: #217 by semantics (frame = monitor,
work area reported separately), this change by staleness repair (root
geometry when the CRTC cannot be trusted). The change is also
orthogonal to the -workAreaForScreen: API #217 adds.

One practical overlap: this hunk sits ~40 lines below the
1 == monitorsCount replacement that #226 rewrites, in the same
-screenList flow — whichever merges first, the other needs a rebase
on top of it.

@rfm

rfm commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

I don't think this code is harmful or wrong as such, and I do think it raises a good/interesting issue, but I think it misses the point:

Given that the X system can change screen geometry outside our control, we need a mechanism to handle it, and
that mechanism exist, the NSApplicationDidChangeScreenParametersNotification

So when geometry changes, the fact should be detected ASAP, cached screen info cleared (the -resetScreens method), the notification should be sent, and then the app will regenerate screen information.

If that mechanism is working the -screenList method is going to be called after the geometry change, so there's really no point trying to handle a geometry change within the -screenList code.

If that mechanism is not working, then it seems to me that the correct solution would be to fix it:
A geometry change should be detected ASAP
On detection, relevant cached information should be cleared as soon as practicable (to avoid re-entrancy issues, a change during execution of a function/method is usually best signalled to higher level code at the end of that function/method).
Once cached info is cleared, the notification should be sent.

A slight complicating factor is that we might be caching information at multiple levels. In that case a single global notification is not sufficient because we need to ensure sequencing with each layer clearing cache at its own level before signalling the next higher level.

So the question is not why -screenList produced 'bad' output, but why the app didn't get a notification and immediately correct itself for the new geometry?

@e4779

e4779 commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — the sequencing principle is exactly right, and it is what we
applied. The reason the app does not "immediately correct itself" is
that on this stack the mechanism breaks in two places, both verified
with instrumentation (logging in the RandR branch of processEvent, in
_OSFrameToXFrame, plus an independent X client watching the raw RANDR
events):

  1. The ScreenChangeNotify for the grow transition never gets processed
    by the applications. An independent watcher client sees
    RRScreenChangeNotify for both transitions (1024x768 twice, 1280x800
    twice), but in the same runs neither Workspace's nor Terminal's
    backend processes the grow notify — the shrink notify is processed
    by both.

  2. The notifies that are processed deliver one-transition-stale
    geometry: RRSetScreenSize does not bump the resources config
    timestamp, so the -screenList refresh through XRRGetScreenResources
    serves the cached (previous) state. Instrumentation of the processed
    event shows previousHeight == xScreenSize.height (delta 0) while the
    server has already applied the new size — the backend's view lags
    one transition behind the X server.

So the app does correct itself — for the previous transition. By the
time it acts, the server is on the next one, and the cached AppKit
origin flips against the wrong height: +32 px per down→up cycle.

Our 12-line -screenList normalization (this PR) sidesteps the stale
CRTC by taking the single-monitor frame from the root geometry, which
does not depend on the cached resources. It does not address the
notify lag/loss — that part is not reachable from the backend, and it
reproduces on plain Xvfb with no Gershwin code running, so it may be
worth a look on the Xvfb/RandR side as well.

@e4779

e4779 commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

Following up on the two findings above — we now have the complete causal chain of the ±32 px jump, verified end-to-end with protocol-level tracing (an xtrace proxy on the app's connection), an independent three-connection watcher client, and pointer-level instrumentation of the gui and backend. Every link below is measured, not inferred.

1. Delivery model. The server defers RRScreenChangeNotify per client until that client's next request (protocol behavior, verified with the watcher). The app therefore receives transitions in bursts, often a transition late: in our trace the shrink notification was processed 3 s after the shrink, at the exact moment the grow transition was already applied server-side.

2. Stale RandR resources. On this stack (Xvfb + the dummy driver) the config timestamp is never bumped by mode/fb changes — every ScreenChangeNotify in the trace carried the same configTimestamp. XRRGetScreenResources therefore serves one-transition-stale state, so resetScreens/screenList derive the previous screen size (instrumented: the backend's screen size read 1280x800 while the server was already 1024x768, and 1024x768 while it was back at 1280x800). This is the layer #249's screenList normalization addresses; the RandR event branch needs the same treatment — a fresh read instead of the cached resources.

3. The gui remap race — the final step of the jump. applicationDidChangeScreenParameters: remaps the window frame as origin.y += newScreenFrame.height - oldScreenFrame.height, reading oldScreenFrame from the window's cached _screen. But NSWindow caches its _screen and only recomputes it when the frame no longer fits; and during the notification dispatch the first observer to run rebuilds the screen list with the new frames, so later observers' _screen is refreshed to the new frame before their own handlers read it (verified with instance pointers logged at every _screen transition; the refresh path is -screen's recompute against the rebuilt list, reachable e.g. via [NSScreen mainScreen] → [keyWindow screen]). The delta then collapses to zero exactly when a remap was needed, and the previous-screen frame is re-applied and flipped against the new screen height: +32 px per down→up cycle (and −32 in the double-compensation variant when the frame is pre-remapped and the delta is not zero).

4. Side finding. The style-probing loops in _styleoffsets drain and discard all foreign events while a window with a new style is created; combined with the deferred delivery, RandR notifies can be lost depending on window-creation timing. In the final traces this was not the jump's cause (the pump + drain accounting matched the wire event count exactly: 3 pumped + 4 collapsed by the branch drain = 7 delivered), but it is an independent loss source.

The fix, split by layer — both parts are up:

Together they take our container harness from a ±32 px jump on every down→up cycle to a stable 1 px rounding difference, across repeated fresh runs and multi-cycle sequences.

@e4779

e4779 commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

@rfm — ping on this one: the thread above now contains the complete verified mechanism, and #251 (which landed meanwhile) covers the backend half of the fix. This PR is the remaining half of the pair. Anything that needs adjusting?

@probonopd

Copy link
Copy Markdown
Contributor

Thanks for digging into this so thoroughly. A few thoughts for consideration, mostly about how this composes with #217:

  1. Work area together with the frame? With x11: report the monitor as the screen frame and the work area beside it #217 (which we carry in Gershwin as screen-work-area-api.patch), the no-window-manager case initialises monitors[mi].workArea from the CRTC frame inside the loop. This change applies cleanly on top, but since it only overwrites monitors[0].frame after the loop, would -workAreaForScreen: (and through libs-gui#953 -visibleFrame) then still report the old size? Would it make sense to set workArea in the same place when there is no _NET_WORKAREA?

  2. Is a single monitor always the whole X screen? I wonder about setups where the framebuffer is larger than the one active CRTC: panning, xrandr --fb larger than the mode, or a second monitor unplugged without the screen being shrunk. On real hardware, could this then report a frame larger than the visible monitor, so that windows end up partly off-screen? Would a narrower condition be worth considering, for example only when there is no _NET_WORKAREA, or only when the root size and the CRTC disagree in a way that suggests an fb-only resize?

  3. Stale cache or fb-only resize? RRSetScreenSize changes the framebuffer size but not the CRTC mode. Could it be that the CRTC data is actually accurate (the CRTC really is still 1280x800 inside a 1680x1050 framebuffer) and that the streaming server simply never sets a matching mode? If so, maybe the behavior could be described as "use the framebuffer when nothing else claims it" rather than as working around a cache, which might also make the condition in 2 easier to reason about. I could not try this here, as the Xvfb on this machine refuses to resize.

  4. Test? You mention the repro script; would it be possible to bring it in as a test, so that the multi-hop resize is covered going forward?

e4779 and others added 3 commits October 17, 2026 10:22
RRSetScreenSize (xrandr --fb -- and what streaming/remote-desktop
servers use) does not bump the resources config timestamp that
XRRGetScreenResources caches on, so crtc_info can serve stale
geometry after a live resolution change. Xvfb additionally exposes
several outputs with only one connected, so a check on the raw
output count inside the loop does not fire.

Normalize the frame after the RANDR loop, on the connected-output
count, from DisplayWidth/DisplayHeight (already refreshed through
XRRUpdateConfiguration when RRScreenChangeNotify arrives).

Validated with a pixel-assertion harness (Xvfb+RandR, multi-hop
1280x800 <-> 1680x1050 at GSScaleFactor 2.0, GNUstep Workspace
desktop): 13/13 assertions on baseline, after grow-resize and after
back-resize, three consecutive runs. Before the fix the desktop
window was clamped against the stale 1280-wide screen and ended up
at origin.x = -400.
Runs its own Xvfb and re-executes itself for every reading, so each one
is what a freshly launched application would see.  Covers an fb-only
resize (RRSetScreenSize without a mode change, what xrandr --fb and
streaming servers issue), the same resize with _NET_WORKAREA published,
and a mode change as the control case.
An fb-only resize leaves the CRTC reporting the mode it still drives
rather than the X screen, and with no window manager there is no
_NET_WORKAREA either: the root geometry is the only authority for the
screen.  Under a window manager the frame must stay the published work
area, which the unconditional form overrode; the new test in
Tests/x11 catches both directions.
@e4779
e4779 force-pushed the fix/live-resize-screen-frame branch from 4bf356d to c786491 Compare October 17, 2026 03:41
@e4779

e4779 commented Oct 17, 2026

Copy link
Copy Markdown
Contributor Author

All four points taken, and the third turned out to be the key. I
measured the two resize kinds on Xvfb:

operation root CRTC config ts events
xrandr --fb 1680x1050 1680x1050 1280x800 unchanged RRScreenChangeNotify
mode change to 1024x768 1024x768 1024x768 bumped RRScreenChange + CrtcChange

So the CRTC data is not stale at all — after an fb-only resize it
accurately reports the mode it still drives inside a grown framebuffer,
and the streaming server simply never sets a matching mode. I've
reworked the PR around that framing: the comment and the description
now say the root geometry is the only authority left when nothing else
claims the screen, and your point-2 condition fell out of it naturally.

  1. Agreed on the work area — but the field only exists with x11: report the monitor as the screen frame and the work area beside it #217, so
    this branch cannot set it yet. The condition is now
    monitorsCount == 1 && workAreas == nil, which is exactly the spot
    where x11: report the monitor as the screen frame and the work area beside it #217 would set workArea alongside: with no _NET_WORKAREA the
    work area is the whole screen, so the companion line on top of x11: report the monitor as the screen frame and the work area beside it #217
    is a one-liner. I could rebase this on x11: report the monitor as the screen frame and the work area beside it #217 or send that line as a
    follow-up commit there, whichever you prefer.

  2. Done — the normalization now only runs when there is no
    _NET_WORKAREA. That also fixed a regression the new test caught in
    the earlier unconditional form: under a window manager it overrode
    the published work area with the full root geometry. The residual
    case you describe (no WM at all, panning or an fb left larger than
    the mode) still reports the framebuffer — client-side it is
    indistinguishable from an fb-only resize, so that is documented
    rather than guessed at.

  3. Confirmed, see the table above: both XRRGetScreenResources and
    XRRGetScreenResourcesCurrent return the same honest CRTC, and
    RRScreenChangeNotify does arrive on the fb-only resize. The bug was
    reading the CRTC as if it described the screen; the PR now uses the
    framebuffer when nothing else claims it.

  4. Added Tests/x11/screenframe.m: it runs its own Xvfb, re-executes
    itself for every reading (so each one is what a freshly launched
    application would see, independent of event delivery), and covers
    the fb-only resize, the same resize with _NET_WORKAREA published,
    and a mode change as the control case. It skips itself when Xvfb,
    xrandr or xprop is missing.

One observation from writing the test: Xvfb exposes four outputs with
one connected, so the 1 == monitorsCount check on noutput never fires
there and the _NET_WORKAREA branch is unreachable on Xvfb — the same
outputs-versus-monitors counting that #226 addresses. The test
therefore asserts that a published work area is not overridden, rather
than the full work-area frame, since that part depends on #226.

@e4779 e4779 changed the title x11: build single-monitor screen frame from root geometry, not cached CRTC x11: build single-monitor screen frame from root geometry, not the CRTC Oct 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

4 participants