From d1d8c19fc7a04ba559e21642178465261ff2ef29 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Tue, 22 Sep 2026 19:56:19 +0200 Subject: [PATCH] Retry a transient cursor-source failure and stop reporting stale framebuffer geometry --- client/vnc/server/capture_fb_unix.go | 20 ++++++++++++++++---- client/vnc/server/session.go | 7 +++++++ client/vnc/server/session_cursor.go | 18 ++++++++++++++++-- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/client/vnc/server/capture_fb_unix.go b/client/vnc/server/capture_fb_unix.go index faddefc9e..6f774cbb7 100644 --- a/client/vnc/server/capture_fb_unix.go +++ b/client/vnc/server/capture_fb_unix.go @@ -53,22 +53,33 @@ func (p *FBPoller) ClientDisconnect() { if p.clients <= 0 && p.capturer != nil { p.capturer.Close() p.capturer = nil + // Drop the geometry with the device. The next client re-reads it, and + // the framebuffer may be gone or resized by then. + p.w, p.h = 0, 0 } } -// Width returns the framebuffer width, doing lazy init if needed. +// Width returns the framebuffer width, doing lazy init if needed. Zero when +// the device cannot be opened: the geometry cached from an earlier session is +// no evidence the framebuffer is still there, and reporting it admits a VNC +// session whose every frame then fails. func (p *FBPoller) Width() int { p.mu.Lock() defer p.mu.Unlock() - _ = p.ensureCapturerLocked() + if err := p.ensureCapturerLocked(); err != nil { + return 0 + } return p.w } -// Height returns the framebuffer height, doing lazy init if needed. +// Height returns the framebuffer height, doing lazy init if needed. Zero when +// the device cannot be opened, for the reason given on Width. func (p *FBPoller) Height() int { p.mu.Lock() defer p.mu.Unlock() - _ = p.ensureCapturerLocked() + if err := p.ensureCapturerLocked(); err != nil { + return 0 + } return p.h } @@ -103,6 +114,7 @@ func (p *FBPoller) Close() { p.capturer.Close() p.capturer = nil } + p.w, p.h = 0, 0 } func (p *FBPoller) ensureCapturerLocked() error { diff --git a/client/vnc/server/session.go b/client/vnc/server/session.go index 5f450aaac..262d3f79b 100644 --- a/client/vnc/server/session.go +++ b/client/vnc/server/session.go @@ -114,6 +114,12 @@ type session struct { // source so the encoder stops polling for the rest of the session. // Reset on SetEncodings so a reconnect can retry. cursorSourceFailed bool + // cursorSourceFailures counts consecutive errors from the cursor source. + // A single error does not latch: the Windows sampler fails across a + // desktop switch and recovers on the next capture, and latching there + // costs the client its cursor for the rest of the session. Reset by the + // first success. + cursorSourceFailures int // showRemoteCursor switches the encoder to compositing the server // cursor sprite into the captured framebuffer at the remote position // instead of emitting the Cursor pseudo-encoding. Toggled by the @@ -489,6 +495,7 @@ func (s *session) resetEncodingCaps() { s.clientSupportsCursor = false s.clientSupportsExtMouseButtons = false s.cursorSourceFailed = false + s.cursorSourceFailures = 0 s.clientJPEGQuality = -1 s.clientZlibLevel = -1 } diff --git a/client/vnc/server/session_cursor.go b/client/vnc/server/session_cursor.go index 61e10232a..0742eb36c 100644 --- a/client/vnc/server/session_cursor.go +++ b/client/vnc/server/session_cursor.go @@ -8,6 +8,13 @@ import ( "image" ) +// cursorSourceFailureLimit is how many consecutive Cursor() errors are taken as +// the source being genuinely unavailable rather than momentarily unreadable. +// Transient failures are normal: the Windows sampler cannot read the cursor +// across a desktop switch or while the secure desktop is up, and recovers by +// the next capture. +const cursorSourceFailureLimit = 10 + // pendingCursorRect returns the Cursor pseudo-rect for the current sprite // when the client negotiated the encoding and the platform exposes a // cursor source whose serial has changed since the last emission. A nil @@ -46,11 +53,18 @@ func (s *session) pendingCursorRect(pf clientPixelFormat) []byte { img, hotX, hotY, serial, err := src.Cursor() if err != nil { s.encMu.Lock() - s.cursorSourceFailed = true + s.cursorSourceFailures++ + failures := s.cursorSourceFailures + if failures >= cursorSourceFailureLimit { + s.cursorSourceFailed = true + } s.encMu.Unlock() - s.log.Debugf("cursor source unavailable: %v", err) + s.log.Debugf("cursor source unavailable (%d consecutive): %v", failures, err) return nil } + s.encMu.Lock() + s.cursorSourceFailures = 0 + s.encMu.Unlock() if img == nil { s.logCursorSkip("no-sprite", "no cursor rect: capturer returned no sprite") return nil