Fix iOS auto-reconnect stall when prior session was on a non-primary display

Symptom
-------
With a self-hosted (or public) hbbs/hbbr stack, an iOS RustDesk client
controlling a Windows host with multiple monitors hangs at "Connected,
Waiting for image" after auto-reconnecting from a network drop, but only
when the previous session was viewing a non-primary display. Reconnects
that started from the primary display always succeed.

Root cause
----------
On reconnect the iOS client re-establishes the peer connection but does
NOT re-issue its display selection (no `CaptureDisplays` or
`SwitchDisplay` is sent). The host correctly defaults `display_idx` to
PRIMARY_DISPLAY_IDX in `Connection::start()` and brings up only
`monitor0`'s capturer. The iOS UI is still in "display N" mode from the
previous session and silently drops or waits-on frames it considers
mismatched, producing the indefinite "Waiting for image" state.

Verified against real service logs: in the failure case the host emits
`new video service: monitor0` and the matching snapshot, but no
`monitor1` is ever created during the entire lifetime of the
auto-reconnected session.

Fix
---
Persist the most recent active display set per remote peer id in a new
`LAST_VIEW_BY_PEER` map (in-memory, same pattern as the adjacent
`LOGIN_FAILURES` / `SESSIONS` statics). Hook the persist on both code
paths that change the active capture set (`switch_display_to` for older
clients, `capture_displays` for >= 1.2.4 clients).

On `handle_login_request_without_validation`, look up the stored set for
this peer's `my_id`. If it differs from primary-only:
  * Validate each stored index against the currently-present displays
    (drops stale entries if a monitor was unplugged between sessions).
  * Pre-set `self.display_idx` so the upcoming PeerInfo response carries
    the restored display index, keeping the iOS UI's expected display in
    sync with the frames the host is about to emit.
  * Stash the validated set in `pending_display_set`.

After `try_sub_monitor_services()` finishes (both call sites updated),
`apply_pending_display_set()` runs `self.capture_displays(&[], &[],
&set)` to actually swap to the restored capturer. Both paths fully
release locks before any `.await`.

Also clears any prior pending state at the top of
`handle_login_request_without_validation` to avoid leaking pending state
across re-logins on the same `Connection` instance.

Behaviour for the common case (primary-only, fresh peer, anonymous peer
with empty `my_id`) is unchanged.

Verification
------------
Reproduced and verified on Windows 11 host (build under
`flutter,hwcodec,vram`) with a 2-monitor setup against an iOS RustDesk
client through a self-hosted hbbs 1.1.15.

Failing run logs (before patch):
  monitor0 created on reconnect; no monitor1 ever created; iOS stalls.

Passing run logs (after patch):
  L36  Connection #1381 opened
  L84  Persisted view [1] for peer 1130614991
  L111 Connection closed: Timeout            <- airplane mode
  L144 Connection #1382 opened              <- auto-reconnect
  L145 Will restore previously viewed displays [1] for peer 1130614991
  L155 Restoring previously viewed display set [1] after services subscribed
  L162 new video service: monitor1          <- without any iOS message
  iOS resumes display 2 video, no stall.
This commit is contained in:
michaelotis 2026-05-05 20:38:31 -05:00
commit 89f6f6f0ad

View file

@ -76,6 +76,10 @@ lazy_static::lazy_static! {
static ref SWITCH_SIDES_UUID: Arc::<Mutex<HashMap<String, (Instant, uuid::Uuid)>>> = Default::default();
static ref WAKELOCK_SENDER: Arc::<Mutex<std::sync::mpsc::Sender<(usize, usize)>>> = Arc::new(Mutex::new(start_wakelock_thread()));
static ref WAKELOCK_KEEP_AWAKE_OPTION: Arc::<Mutex<Option<bool>>> = Default::default();
// Last-active display set per remote peer id. Restored on next login from
// the same peer so that auto-reconnect resumes on the previously selected
// display (iOS does not re-issue its display selection on auto-reconnect).
static ref LAST_VIEW_BY_PEER: Arc::<Mutex<HashMap<String, Vec<usize>>>> = Default::default();
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
@ -292,6 +296,14 @@ pub struct Connection {
file_remove_log_control: FileRemoveLogControl,
last_supported_encoding: Option<SupportedEncoding>,
services_subed: bool,
// Buffered SwitchDisplay (from early arrival) and pending display set to
// restore after services are subscribed. Together these fix the iOS
// auto-reconnect "waiting for image" hang when the prior session was on
// a non-primary display: iOS does not re-issue its display selection on
// auto-reconnect, so the host must restore the previously-active display
// set itself based on remembered per-peer state.
pending_display_switch: Option<usize>,
pending_display_set: Option<Vec<usize>>,
delayed_read_dir: Option<(String, bool)>,
#[cfg(target_os = "macos")]
retina: Retina,
@ -482,6 +494,8 @@ impl Connection {
file_remove_log_control: FileRemoveLogControl::new(id),
last_supported_encoding: None,
services_subed: false,
pending_display_switch: None,
pending_display_set: None,
delayed_read_dir: None,
#[cfg(target_os = "macos")]
retina: Retina::default(),
@ -1733,6 +1747,14 @@ impl Connection {
self.retina.set_displays(&displays);
}
pi.displays = displays;
// Clamp against the just-built `pi.displays` in case
// self.display_idx was pre-set in handle_login_request_without_validation
// from a snapshot that has since become stale (a monitor
// unplugged in the brief window between login validation
// and PeerInfo emit).
if self.display_idx >= pi.displays.len() {
self.display_idx = *display_service::PRIMARY_DISPLAY_IDX;
}
pi.current_display = self.display_idx as _;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
@ -1790,6 +1812,8 @@ impl Connection {
} else if sub_service {
if !wait_session_id_confirm {
self.try_sub_monitor_services();
self.apply_pending_display_switch().await;
self.apply_pending_display_set().await;
}
}
true
@ -2258,6 +2282,33 @@ impl Connection {
async fn handle_login_request_without_validation(&mut self, lr: &LoginRequest) {
self.lr = lr.clone();
// Clear any prior pending state in case this Connection handles re-login.
self.pending_display_set = None;
self.pending_display_switch = None;
// Restore previously-active display set for this peer so auto-reconnect
// resumes on the prior display. Only kicks in when the prior set is
// non-empty and isn't just the primary (no behaviour change for the
// common case). lr.my_id uniquely identifies the remote peer across
// reconnections.
if !lr.my_id.is_empty() {
if let Some(prev_set) = LAST_VIEW_BY_PEER.lock().unwrap().get(&lr.my_id).cloned() {
let primary = *display_service::PRIMARY_DISPLAY_IDX;
let valid = Self::validate_display_set(&prev_set, &lr.my_id);
if !valid.is_empty() && valid != vec![primary] {
log::info!(
"Will restore previously viewed displays {:?} for peer {} after services subscribed",
valid, lr.my_id
);
// Pre-set display_idx so the PeerInfo response carries the
// restored display index, keeping the iOS UI's expected
// display in sync with the frames we'll soon emit.
if valid.len() == 1 {
self.display_idx = valid[0];
}
self.pending_display_set = Some(valid);
}
}
}
self.peer_argb = crate::str2color(&format!("{}{}", &lr.my_id, &lr.my_platform), 0xff);
if let Some(o) = lr.option.as_ref() {
self.options_in_login = Some(o.clone());
@ -3354,6 +3405,8 @@ impl Connection {
self.try_sub_camera_displays();
} else if !self.terminal {
self.try_sub_monitor_services();
self.apply_pending_display_switch().await;
self.apply_pending_display_set().await;
}
}
}
@ -3686,6 +3739,24 @@ impl Connection {
async fn handle_switch_display(&mut self, s: SwitchDisplay) {
let display_idx = s.display as usize;
if !self.services_subed {
// Defer until try_sub_monitor_services() registers this connection
// as a subscriber. Otherwise switch_display_to() calls
// server.subscribe() for a connection the server doesn't yet know,
// the subscription is silently lost, and the client (notably iOS
// on auto-reconnect to a non-primary display) hangs waiting for
// frames that never arrive.
log::info!(
"Deferring SwitchDisplay({}) until video services subscribed",
display_idx
);
// An explicit client-side display selection should win over any
// login-time auto-restore — clear the restore queue so it cannot
// overwrite this newer choice.
self.pending_display_set = None;
self.pending_display_switch = Some(display_idx);
return;
}
if self.display_idx != display_idx {
if let Some(server) = self.server.upgrade() {
self.switch_display_to(display_idx, server.clone());
@ -3723,6 +3794,61 @@ impl Connection {
}
}
async fn apply_pending_display_switch(&mut self) {
if let Some(display_idx) = self.pending_display_switch.take() {
log::info!(
"Applying deferred SwitchDisplay({}) after services subscribed",
display_idx
);
let s = SwitchDisplay {
display: display_idx as i32,
..Default::default()
};
// Safe re-entry: at this call site `services_subed` is true, so
// handle_switch_display's defer branch (`!self.services_subed`)
// is guaranteed to be skipped — no infinite re-deferral loop.
self.handle_switch_display(s).await;
}
}
async fn apply_pending_display_set(&mut self) {
if let Some(set) = self.pending_display_set.take() {
// Re-validate against current monitor topology: this method can
// fire well after handle_login_request_without_validation (e.g.,
// after SelectedSid in multi-user-session-confirm flows), so the
// set we validated at login time may have gone stale if a monitor
// was unplugged or the display config changed in the interim.
let valid = Self::validate_display_set(&set, &self.lr.my_id);
if valid.is_empty() {
return;
}
log::info!(
"Restoring previously viewed display set {:?} after services subscribed",
valid
);
self.capture_displays(&[], &[], &valid).await;
}
}
/// Drop any indices in `set` that no longer correspond to a currently-present
/// display. Validates against the cached `SYNC_DISPLAYS` rather than calling
/// `Display::all()` so we don't reintroduce the Wayland blocking/crash issue
/// that `display_service::check_displays_changed()` explicitly avoids. If the
/// cache is empty (e.g., a login racing `update_get_sync_displays_on_login`)
/// we conservatively skip the restore — starting a capturer for an unknown
/// index would defeat the whole point of this validation.
fn validate_display_set(set: &[usize], peer_id: &str) -> Vec<usize> {
let displays = display_service::get_sync_displays();
if displays.is_empty() {
log::warn!(
"Skipping display restore for peer {} because display cache is empty",
peer_id
);
return Vec::new();
}
set.iter().copied().filter(|idx| *idx < displays.len()).collect()
}
fn switch_display_to(&mut self, display_idx: usize, server: Arc<RwLock<Server>>) {
let new_service_name = video_service::get_service_name(self.video_source(), display_idx);
let old_service_name =
@ -3743,6 +3869,15 @@ impl Connection {
}
lock.subscribe(&new_service_name, self.inner.clone(), true);
self.display_idx = display_idx;
// Persist for auto-reconnect restoration (older-client SwitchDisplay
// path). Camera-mode indices are not persisted: they're meaningless for
// a future monitor session and would confuse the restore path.
if !self.lr.my_id.is_empty() && matches!(self.video_source(), VideoSource::Monitor) {
LAST_VIEW_BY_PEER
.lock()
.unwrap()
.insert(self.lr.my_id.clone(), vec![display_idx]);
}
}
#[cfg(windows)]
@ -3800,6 +3935,24 @@ impl Connection {
}
drop(lock);
}
// Persist the current view for this peer so a future auto-reconnect
// can restore it. We persist only when:
// - `set` is non-empty (the canonical "view this exact set" call;
// add/sub updates are partial),
// - the video source is the monitor (camera-mode display indices are
// meaningless in a future monitor session and would only confuse the
// restore path), and
// - the peer has a non-empty id we can key by.
if !set.is_empty()
&& !self.lr.my_id.is_empty()
&& matches!(video_source, VideoSource::Monitor)
{
LAST_VIEW_BY_PEER
.lock()
.unwrap()
.insert(self.lr.my_id.clone(), set.to_vec());
log::info!("Persisted view {:?} for peer {}", set, self.lr.my_id);
}
}
#[cfg(windows)]