nandi/jolt-nativepublic Fork 0
a9f3673
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Spend the frame's idle time watching for the resize, not asleep

Dragging a window's edge left the picture behind the edge. Part of that
is XWayland and stays; part of it was ours.

`end_frame` finished by sleeping out whatever was left of the frame
budget. The event loop is not pumped during that sleep, so a resize
arriving 1ms into it was not *seen* for another 15, and the frame after
that laid itself out against a window it had already outgrown. The same
idle time is now spent pumping in 2ms slices, abandoned the moment the
window stops matching the frame just presented, so the next frame starts
against the size the window actually is. Measured on a synthetic resize
storm the pacing is unchanged — 16.8ms median, 60fps — for about 2% of a
core more, and the retry path in `vidya_tree_frame` was already sound:
zero frames presented at a stale size.

Native Wayland would remove the rest of the lag by removing XWayland from
under it, and does not work. Presenting from inside winit's own
`RedrawRequested` dispatch — which the docs here described and the code
did not do — is not enough: traced, the surface receives exactly one
callback, at configure, and every later `request_redraw` goes unanswered
until `PRESENT_TIMEOUT`, 253ms a frame with nothing on the screen. The
callbacks do not survive a loop the caller owns, and owning it is what
this ABI is. So X11 stays, and the comment now says what was measured
rather than what was feared.

Two bugs found while looking, neither of them about resizing:

The fallback to Wayland could never have run. winit marks the process as
having built an event loop even when the build *fails*, so the second
attempt answered "EventLoop can't be recreated" — a session with no
XWayland got no window at all, after printing a message promising one.
The backend is chosen from the environment now and built once, with
`VIDYA_BACKEND` to override.

Closing a Wayland window aborted the process. `App`'s fields drop in
declaration order, so the event loop went first and took the display
connection with it, while egui-winit's clipboard — a smithay-clipboard
thread holding its own proxies — was still alive to destroy them against
nothing. The window's half comes down first now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-01T21:55:29-07:00 Browse files
a9f3673 parent: 757d8e7
modified crates/jolt-vidya/src/app.rs +75 -17
@@ -47,26 +47,47 @@ const PRESENT_TIMEOUT: Duration = Duration::from_millis(250);
4747 /// Pacing floor, so a caller that never sets a target FPS still yields.
4848 const DEFAULT_FRAME_BUDGET: Duration = Duration::from_micros(16_666);
4949
50-/// Build the event loop, preferring X11 where both backends exist.
50+/// Build the event loop: X11 (XWayland included) unless asked otherwise.
5151 ///
52-/// Native Wayland does not survive this ABI's shape: the caller owns the loop,
52+/// Native Wayland does not survive this ABI's shape. The caller owns the loop,
5353 /// so winit is driven with `pump_app_events`, and a surface driven that way
54-/// stops receiving frame callbacks after its first commit — the window never
55-/// gets a second frame and the session stalls behind it. Under X11 (XWayland
56-/// included) presentation does not depend on those callbacks, and the same loop
57-/// runs at full frame rate. Falls back to the default backend when X11 is
58-/// unavailable, so a compositor without XWayland still gets a window.
54+/// receives exactly one frame callback: the configure. Presenting inside
55+/// winit's own `RedrawRequested` dispatch is not enough to keep them coming —
56+/// measured, every later `request_redraw` goes unanswered and each frame waits
57+/// out `PRESENT_TIMEOUT` instead. Under X11 presentation depends on no such
58+/// callback, and the same loop runs at the display's rate.
59+///
60+/// There is exactly one attempt to spend, which is why this chooses rather than
61+/// tries: winit marks the process as having built an event loop even when the
62+/// build *fails*, so the old try-X11-then-fall-back arrangement could not fall
63+/// back — the second build answered "EventLoop can't be recreated", and a
64+/// session with no XWayland got no window at all rather than the Wayland one it
65+/// was promised.
66+///
67+/// `VIDYA_BACKEND=wayland` asks for the native surface anyway, for whoever is
68+/// fixing the above; `x11` forces X11 where `WAYLAND_DISPLAY` is set but
69+/// `DISPLAY` is what works.
5970 #[cfg(all(unix, not(target_os = "macos"), not(target_os = "android")))]
6071 fn build_event_loop() -> Result<EventLoop<()>, String> {
72+ use winit::platform::wayland::EventLoopBuilderExtWayland as _;
6173 use winit::platform::x11::EventLoopBuilderExtX11 as _;
6274
63- if let Ok(el) = EventLoop::builder().with_x11().build() {
64- return Ok(el);
65- }
66- eprintln!("vidya: X11 unavailable; falling back to Wayland (expect stalls)");
67- EventLoop::builder()
68- .build()
69- .map_err(|e| format!("event loop: {e}"))
75+ let wayland = match std::env::var("VIDYA_BACKEND").unwrap_or_default().as_str() {
76+ "wayland" => true,
77+ // X11 unless there is no X server to reach at all, in which case a
78+ // window that stalls beats no window.
79+ _ => std::env::var_os("DISPLAY").is_none(),
80+ };
81+
82+ let built = if wayland {
83+ EventLoop::builder().with_wayland().build()
84+ } else {
85+ EventLoop::builder().with_x11().build()
86+ };
87+ built.map_err(|e| {
88+ let backend = if wayland { "wayland" } else { "x11" };
89+ format!("event loop ({backend}): {e} — VIDYA_BACKEND selects the other")
90+ })
7091 }
7192
7293 /// Android has no display connection to choose: the activity already owns one,
@@ -730,6 +751,17 @@ impl App {
730751 self.handler.slot_waited = true;
731752 }
732753
754+ /// Whether the window is no longer the size the last pass was measured
755+ /// for. Unlike [`Self::resized_mid_frame`] it asks nothing of the open
756+ /// pass, so it also answers between frames.
757+ fn window_outgrew_frame(&self) -> bool {
758+ let Some(gl) = self.handler.gl.as_ref() else {
759+ return false;
760+ };
761+ let size = gl.window.inner_size();
762+ [size.width.max(1), size.height.max(1)] != self.handler.frame_dims
763+ }
764+
733765 /// Whether the window changed size after this frame started laying out.
734766 ///
735767 /// True means the pass now open measured a window that no longer exists,
@@ -818,19 +850,45 @@ impl App {
818850 }
819851 }
820852
821- // Floor for platforms that do not throttle presents at all.
822- if let Some(left) = self.frame_budget.checked_sub(self.frame_started.elapsed()) {
823- std::thread::sleep(left);
853+ // Floor for platforms that do not throttle presents at all — spent
854+ // pumping rather than asleep, and abandoned the moment the window
855+ // changes size.
856+ //
857+ // A drag's next size lands in exactly this window. Sleeping through it
858+ // means the resize is not even *seen* until the sleep ends, and the
859+ // frame after that paints a layout the window outgrew a whole budget
860+ // ago — the edge running ahead of the picture. Waiting on the event
861+ // loop instead makes the same idle time responsive: the resize wakes
862+ // it, and the next frame is laid out against the size the window
863+ // already is.
864+ while let Some(left) = self.frame_budget.checked_sub(self.frame_started.elapsed()) {
865+ if left.is_zero() {
866+ break;
867+ }
868+ self.pump(left.min(Duration::from_millis(2)));
869+ if self.window_outgrew_frame() {
870+ break;
871+ }
824872 }
825873 }
826874 }
827875
828876 impl Drop for App {
877+ /// Order matters, and the field order would get it exactly backwards.
878+ ///
879+ /// `event_loop` is declared first, so it is dropped first — taking the
880+ /// display connection with it while `Gl` still holds what was built on
881+ /// top. Under Wayland that is fatal rather than untidy: egui-winit's
882+ /// clipboard is a smithay-clipboard thread holding its own proxies, and
883+ /// destroying them against a connection that has already gone aborts the
884+ /// process inside libwayland. So the window's half comes down here, in
885+ /// full, before anything else is allowed to.
829886 fn drop(&mut self) {
830887 self.stack.unwind();
831888 if let Some(gl) = self.handler.gl.as_mut() {
832889 gl.painter.destroy();
833890 }
891+ drop(self.handler.gl.take());
834892 }
835893 }
836894
@@ -47,26 +47,47 @@ const PRESENT_TIMEOUT: Duration = Duration::from_millis(250);
47 /// Pacing floor, so a caller that never sets a target FPS still yields.47 /// Pacing floor, so a caller that never sets a target FPS still yields.
48 const DEFAULT_FRAME_BUDGET: Duration = Duration::from_micros(16_666);48 const DEFAULT_FRAME_BUDGET: Duration = Duration::from_micros(16_666);
49 49
50-/// Build the event loop, preferring X11 where both backends exist.50+/// Build the event loop: X11 (XWayland included) unless asked otherwise.
51 ///51 ///
52-/// Native Wayland does not survive this ABI's shape: the caller owns the loop,52+/// Native Wayland does not survive this ABI's shape. The caller owns the loop,
53 /// so winit is driven with `pump_app_events`, and a surface driven that way53 /// so winit is driven with `pump_app_events`, and a surface driven that way
54-/// stops receiving frame callbacks after its first commit — the window never54+/// receives exactly one frame callback: the configure. Presenting inside
55-/// gets a second frame and the session stalls behind it. Under X11 (XWayland55+/// winit's own `RedrawRequested` dispatch is not enough to keep them coming —
56-/// included) presentation does not depend on those callbacks, and the same loop56+/// measured, every later `request_redraw` goes unanswered and each frame waits
57-/// runs at full frame rate. Falls back to the default backend when X11 is57+/// out `PRESENT_TIMEOUT` instead. Under X11 presentation depends on no such
58-/// unavailable, so a compositor without XWayland still gets a window.58+/// callback, and the same loop runs at the display's rate.
59+///
60+/// There is exactly one attempt to spend, which is why this chooses rather than
61+/// tries: winit marks the process as having built an event loop even when the
62+/// build *fails*, so the old try-X11-then-fall-back arrangement could not fall
63+/// back — the second build answered "EventLoop can't be recreated", and a
64+/// session with no XWayland got no window at all rather than the Wayland one it
65+/// was promised.
66+///
67+/// `VIDYA_BACKEND=wayland` asks for the native surface anyway, for whoever is
68+/// fixing the above; `x11` forces X11 where `WAYLAND_DISPLAY` is set but
69+/// `DISPLAY` is what works.
59 #[cfg(all(unix, not(target_os = "macos"), not(target_os = "android")))]70 #[cfg(all(unix, not(target_os = "macos"), not(target_os = "android")))]
60 fn build_event_loop() -> Result<EventLoop<()>, String> {71 fn build_event_loop() -> Result<EventLoop<()>, String> {
72+ use winit::platform::wayland::EventLoopBuilderExtWayland as _;
61 use winit::platform::x11::EventLoopBuilderExtX11 as _;73 use winit::platform::x11::EventLoopBuilderExtX11 as _;
62 74
63- if let Ok(el) = EventLoop::builder().with_x11().build() {75+ let wayland = match std::env::var("VIDYA_BACKEND").unwrap_or_default().as_str() {
64- return Ok(el);76+ "wayland" => true,
65- }77+ // X11 unless there is no X server to reach at all, in which case a
66- eprintln!("vidya: X11 unavailable; falling back to Wayland (expect stalls)");78+ // window that stalls beats no window.
67- EventLoop::builder()79+ _ => std::env::var_os("DISPLAY").is_none(),
68- .build()80+ };
69- .map_err(|e| format!("event loop: {e}"))81+
82+ let built = if wayland {
83+ EventLoop::builder().with_wayland().build()
84+ } else {
85+ EventLoop::builder().with_x11().build()
86+ };
87+ built.map_err(|e| {
88+ let backend = if wayland { "wayland" } else { "x11" };
89+ format!("event loop ({backend}): {e} — VIDYA_BACKEND selects the other")
90+ })
70 }91 }
71 92
72 /// Android has no display connection to choose: the activity already owns one,93 /// Android has no display connection to choose: the activity already owns one,
@@ -730,6 +751,17 @@ impl App {
730 self.handler.slot_waited = true;751 self.handler.slot_waited = true;
731 }752 }
732 753
754+ /// Whether the window is no longer the size the last pass was measured
755+ /// for. Unlike [`Self::resized_mid_frame`] it asks nothing of the open
756+ /// pass, so it also answers between frames.
757+ fn window_outgrew_frame(&self) -> bool {
758+ let Some(gl) = self.handler.gl.as_ref() else {
759+ return false;
760+ };
761+ let size = gl.window.inner_size();
762+ [size.width.max(1), size.height.max(1)] != self.handler.frame_dims
763+ }
764+
733 /// Whether the window changed size after this frame started laying out.765 /// Whether the window changed size after this frame started laying out.
734 ///766 ///
735 /// True means the pass now open measured a window that no longer exists,767 /// True means the pass now open measured a window that no longer exists,
@@ -818,19 +850,45 @@ impl App {
818 }850 }
819 }851 }
820 852
821- // Floor for platforms that do not throttle presents at all.853+ // Floor for platforms that do not throttle presents at all — spent
822- if let Some(left) = self.frame_budget.checked_sub(self.frame_started.elapsed()) {854+ // pumping rather than asleep, and abandoned the moment the window
823- std::thread::sleep(left);855+ // changes size.
856+ //
857+ // A drag's next size lands in exactly this window. Sleeping through it
858+ // means the resize is not even *seen* until the sleep ends, and the
859+ // frame after that paints a layout the window outgrew a whole budget
860+ // ago — the edge running ahead of the picture. Waiting on the event
861+ // loop instead makes the same idle time responsive: the resize wakes
862+ // it, and the next frame is laid out against the size the window
863+ // already is.
864+ while let Some(left) = self.frame_budget.checked_sub(self.frame_started.elapsed()) {
865+ if left.is_zero() {
866+ break;
867+ }
868+ self.pump(left.min(Duration::from_millis(2)));
869+ if self.window_outgrew_frame() {
870+ break;
871+ }
824 }872 }
825 }873 }
826 }874 }
827 875
828 impl Drop for App {876 impl Drop for App {
877+ /// Order matters, and the field order would get it exactly backwards.
878+ ///
879+ /// `event_loop` is declared first, so it is dropped first — taking the
880+ /// display connection with it while `Gl` still holds what was built on
881+ /// top. Under Wayland that is fatal rather than untidy: egui-winit's
882+ /// clipboard is a smithay-clipboard thread holding its own proxies, and
883+ /// destroying them against a connection that has already gone aborts the
884+ /// process inside libwayland. So the window's half comes down here, in
885+ /// full, before anything else is allowed to.
829 fn drop(&mut self) {886 fn drop(&mut self) {
830 self.stack.unwind();887 self.stack.unwind();
831 if let Some(gl) = self.handler.gl.as_mut() {888 if let Some(gl) = self.handler.gl.as_mut() {
832 gl.painter.destroy();889 gl.painter.destroy();
833 }890 }
891+ drop(self.handler.gl.take());
834 }892 }
835 }893 }
836 894