Fix: prevent mobile web remote desktop freeze on Safari app-switch
by forcing clean WS reconnect on foreground return; add 30-second grace period on server to avoid cold-start on quick reconnects
This commit is contained in:
@@ -1814,6 +1814,16 @@ bool CWebService::StartRemoteDesktop(uint64_t device_id) {
|
|||||||
context* ctx = m_pParentDlg->FindHost(device_id);
|
context* ctx = m_pParentDlg->FindHost(device_id);
|
||||||
if (!ctx) return false;
|
if (!ctx) return false;
|
||||||
|
|
||||||
|
// Cancel any pending grace-period close so the existing session stays alive.
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(m_PendingCloseMutex);
|
||||||
|
auto it = m_PendingCloseTimers.find(device_id);
|
||||||
|
if (it != m_PendingCloseTimers.end()) {
|
||||||
|
it->second->store(true);
|
||||||
|
m_PendingCloseTimers.erase(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if there's already a Web session for this device
|
// Check if there's already a Web session for this device
|
||||||
// Only reuse if Web has already triggered AND a Web dialog exists
|
// Only reuse if Web has already triggered AND a Web dialog exists
|
||||||
// This ensures MFC and Web have independent dialogs
|
// This ensures MFC and Web have independent dialogs
|
||||||
@@ -1854,11 +1864,36 @@ void CWebService::StopRemoteDesktop(uint64_t device_id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no more web clients watching, close only the Web session dialog
|
// If no more web clients watching, defer close by 30 s so a quick
|
||||||
// MFC dialogs remain open
|
// mobile reconnect (e.g. switching to a TOTP app) reuses the existing
|
||||||
|
// CScreenSpyDlg without a full cold-start.
|
||||||
if (watchingCount == 0) {
|
if (watchingCount == 0) {
|
||||||
ClearWebTriggered(device_id);
|
auto cancelled = std::make_shared<std::atomic<bool>>(false);
|
||||||
m_pParentDlg->CloseWebRemoteDesktopByClientID(device_id);
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(m_PendingCloseMutex);
|
||||||
|
auto it = m_PendingCloseTimers.find(device_id);
|
||||||
|
if (it != m_PendingCloseTimers.end())
|
||||||
|
it->second->store(true);
|
||||||
|
m_PendingCloseTimers[device_id] = cancelled;
|
||||||
|
}
|
||||||
|
std::thread([this, device_id, cancelled]() {
|
||||||
|
std::this_thread::sleep_for(std::chrono::seconds(30));
|
||||||
|
if (cancelled->load()) return;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(m_PendingCloseMutex);
|
||||||
|
m_PendingCloseTimers.erase(device_id);
|
||||||
|
}
|
||||||
|
int count = 0;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||||||
|
for (const auto& [ws, client] : m_Clients)
|
||||||
|
if (client.watch_device_id == device_id) count++;
|
||||||
|
}
|
||||||
|
if (count == 0 && m_pParentDlg) {
|
||||||
|
ClearWebTriggered(device_id);
|
||||||
|
m_pParentDlg->CloseWebRemoteDesktopByClientID(device_id);
|
||||||
|
}
|
||||||
|
}).detach();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <set>
|
#include <set>
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
@@ -231,6 +233,13 @@ private:
|
|||||||
std::set<uint64_t> m_OfflineDevices; // Devices that went offline since last flush
|
std::set<uint64_t> m_OfflineDevices; // Devices that went offline since last flush
|
||||||
std::mutex m_DirtyDevicesMutex;
|
std::mutex m_DirtyDevicesMutex;
|
||||||
|
|
||||||
|
// Grace-period timers: delay CloseWebRemoteDesktopByClientID by 30 s after
|
||||||
|
// the last web viewer disconnects to allow quick mobile reconnects (e.g.
|
||||||
|
// switching to a TOTP app briefly). Cancelled by StartRemoteDesktop when a
|
||||||
|
// viewer reconnects within the window.
|
||||||
|
std::map<uint64_t, std::shared_ptr<std::atomic<bool>>> m_PendingCloseTimers;
|
||||||
|
std::mutex m_PendingCloseMutex;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
// Check if a device session was triggered by web (should be hidden)
|
// Check if a device session was triggered by web (should be hidden)
|
||||||
bool IsWebTriggered(uint64_t device_id);
|
bool IsWebTriggered(uint64_t device_id);
|
||||||
|
|||||||
@@ -95,6 +95,12 @@ type wsHub struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
clients map[*wsClient]struct{}
|
clients map[*wsClient]struct{}
|
||||||
|
|
||||||
|
// screenCloseTimers delays CloseScreen by screenCloseGrace after the last
|
||||||
|
// viewer disconnects. A reconnect within that window cancels the timer and
|
||||||
|
// reuses the live screen sub-conn (hot path), avoiding a full cold-start.
|
||||||
|
// Guarded by mu.
|
||||||
|
screenCloseTimers map[string]*time.Timer
|
||||||
|
|
||||||
unsub func()
|
unsub func()
|
||||||
|
|
||||||
// Hardening knobs wired from server.Config. Nil/empty values mean
|
// Hardening knobs wired from server.Config. Nil/empty values mean
|
||||||
@@ -111,7 +117,8 @@ func newWSHub(auth *wsauth.Authenticator, devices *hub.Hub, log *logger.Logger)
|
|||||||
auth: auth,
|
auth: auth,
|
||||||
devices: devices,
|
devices: devices,
|
||||||
log: log,
|
log: log,
|
||||||
clients: make(map[*wsClient]struct{}),
|
clients: make(map[*wsClient]struct{}),
|
||||||
|
screenCloseTimers: make(map[string]*time.Timer),
|
||||||
}
|
}
|
||||||
h.unsub = devices.Subscribe(h)
|
h.unsub = devices.Subscribe(h)
|
||||||
return h
|
return h
|
||||||
@@ -405,7 +412,7 @@ func (h *wsHub) unregister(c *wsClient) {
|
|||||||
// session so the device stops encoding. Done OUTSIDE the lock so the
|
// session so the device stops encoding. Done OUTSIDE the lock so the
|
||||||
// hub's mutators can take their own locks without risk of recursion.
|
// hub's mutators can take their own locks without risk of recursion.
|
||||||
if c.watching != "" && h.countWatchers(c.watching) == 0 {
|
if c.watching != "" && h.countWatchers(c.watching) == 0 {
|
||||||
h.devices.CloseScreen(c.watching)
|
h.deferredCloseScreen(c.watching)
|
||||||
}
|
}
|
||||||
// Terminal sessions are single-viewer by design, so any open session
|
// Terminal sessions are single-viewer by design, so any open session
|
||||||
// belongs to this client. Tear it down so the next viewer doesn't
|
// belongs to this client. Tear it down so the next viewer doesn't
|
||||||
@@ -421,6 +428,30 @@ func (h *wsHub) unregister(c *wsClient) {
|
|||||||
c.close()
|
c.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// screenCloseGrace is how long we keep a device's screen sub-conn alive after
|
||||||
|
// the last browser viewer disconnects. A reconnect within this window (e.g.
|
||||||
|
// the user briefly switched to a TOTP app on mobile) cancels the timer and
|
||||||
|
// resumes on the hot path — no cold-start, no waiting for a new IDR.
|
||||||
|
const screenCloseGrace = 30 * time.Second
|
||||||
|
|
||||||
|
// deferredCloseScreen schedules CloseScreen after screenCloseGrace unless a
|
||||||
|
// viewer reconnects first. Must be called without h.mu held.
|
||||||
|
func (h *wsHub) deferredCloseScreen(deviceID string) {
|
||||||
|
h.mu.Lock()
|
||||||
|
if t, ok := h.screenCloseTimers[deviceID]; ok {
|
||||||
|
t.Stop()
|
||||||
|
}
|
||||||
|
h.screenCloseTimers[deviceID] = time.AfterFunc(screenCloseGrace, func() {
|
||||||
|
h.mu.Lock()
|
||||||
|
delete(h.screenCloseTimers, deviceID)
|
||||||
|
h.mu.Unlock()
|
||||||
|
if h.countWatchers(deviceID) == 0 {
|
||||||
|
h.devices.CloseScreen(deviceID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// ----- HTTP handler -------------------------------------------------------
|
// ----- HTTP handler -------------------------------------------------------
|
||||||
|
|
||||||
func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) {
|
func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ func (h *wsHub) handleDisconnect(c *wsClient, _ []byte) {
|
|||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
c.queue([]byte(`{"cmd":"disconnect_result","ok":true}`))
|
c.queue([]byte(`{"cmd":"disconnect_result","ok":true}`))
|
||||||
if prev != "" && h.countWatchers(prev) == 0 {
|
if prev != "" && h.countWatchers(prev) == 0 {
|
||||||
h.devices.CloseScreen(prev)
|
h.deferredCloseScreen(prev)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +288,11 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
|
|||||||
c.queueBinary(cache.Keyframe)
|
c.queueBinary(cache.Keyframe)
|
||||||
}
|
}
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
|
// Cancel any pending deferred close so the relay stays alive.
|
||||||
|
if t, ok := h.screenCloseTimers[in.ID]; ok {
|
||||||
|
t.Stop()
|
||||||
|
delete(h.screenCloseTimers, in.ID)
|
||||||
|
}
|
||||||
c.watching = in.ID
|
c.watching = in.ID
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -4185,11 +4185,33 @@
|
|||||||
clearTimeout(backgroundDisconnectTimer);
|
clearTimeout(backgroundDisconnectTimer);
|
||||||
backgroundDisconnectTimer = null;
|
backgroundDisconnectTimer = null;
|
||||||
}
|
}
|
||||||
// Reconnect or send immediate ping
|
const screenPage = document.getElementById('screen-page');
|
||||||
|
const onScreenPage = screenPage && screenPage.classList.contains('active') && currentDevice;
|
||||||
|
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||||
|
// WS was dropped — reset decoder before reconnect.
|
||||||
|
if (onScreenPage && decoder) {
|
||||||
|
try { decoder.close(); } catch(e) {}
|
||||||
|
decoder = null;
|
||||||
|
needKeyframe = true;
|
||||||
|
}
|
||||||
|
connectWebSocket();
|
||||||
|
} else if (onScreenPage && isTouchDevice) {
|
||||||
|
// iOS suspends the tab and silently invalidates the VideoDecoder
|
||||||
|
// GPU context (or quietly closes the WS while readyState stays OPEN).
|
||||||
|
// Always force a clean WS reconnect on mobile so the server delivers
|
||||||
|
// a fresh stream start; the server no longer sends a stale cached
|
||||||
|
// keyframe (cleared on disconnect), so the client waits for the next
|
||||||
|
// natural IDR — no mosaic, no freeze.
|
||||||
|
if (decoder) {
|
||||||
|
try { decoder.close(); } catch(e) {}
|
||||||
|
decoder = null;
|
||||||
|
needKeyframe = true;
|
||||||
|
}
|
||||||
|
ws.onclose = null;
|
||||||
|
ws.close();
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
} else if (token) {
|
} else if (token) {
|
||||||
// Connection still open - send immediate ping to refresh server heartbeat
|
|
||||||
ws.send(JSON.stringify({ cmd: 'ping', token }));
|
ws.send(JSON.stringify({ cmd: 'ping', token }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user