reattach the desktop when officer restarts under it
x11vnc mirrors :0 with `-forever`, so the desktop survives officer going away — only the bridge socket dies. The panel treated that as the end of the session: "Disconnected from desktop" until someone closed and reopened it. Retry with backoff instead, and cover the password fetch too, since that is what fails first while officer is still coming up. Retries are generation-guarded so tearing an instance down cannot be mistaken for a lost connection, and a security failure or a missing noVNC is terminal — those do not come back on their own. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -41,7 +41,9 @@ export const DesktopView = ({ className, style }: DesktopViewProps) => {
|
|||||||
const rfbRef = useRef<RFBInstance | null>(null);
|
const rfbRef = useRef<RFBInstance | null>(null);
|
||||||
const isMounted = useMounted();
|
const isMounted = useMounted();
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting');
|
const [status, setStatus] = useState<'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error'>(
|
||||||
|
'connecting',
|
||||||
|
);
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,20 +52,64 @@ export const DesktopView = ({ className, style }: DesktopViewProps) => {
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
let attempts = 0;
|
||||||
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
// Tearing an RFB down makes it fire its own `disconnect`, and a bad password fires `securityfailure`
|
||||||
|
// and then `disconnect` too. Both would otherwise be read as "officer went away, reattach".
|
||||||
|
let generation = 0;
|
||||||
|
let fatal = false;
|
||||||
|
const MAX_ATTEMPTS = 5;
|
||||||
|
const RETRY_DELAYS = [1000, 2000, 3000, 5000, 5000];
|
||||||
|
|
||||||
|
const detach = () => {
|
||||||
|
const rfb = rfbRef.current;
|
||||||
|
rfbRef.current = null;
|
||||||
|
generation++;
|
||||||
|
if (!rfb) return;
|
||||||
|
try {
|
||||||
|
rfb.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// x11vnc mirrors :0 with `-forever`, so the desktop itself outlives this socket — losing it means
|
||||||
|
// officer restarted under us, not that the session ended. Reattach instead of parking on
|
||||||
|
// "Disconnected" until someone reopens the panel.
|
||||||
|
const retry = (reason: string) => {
|
||||||
|
if (disposed || fatal || retryTimer) return;
|
||||||
|
detach();
|
||||||
|
if (attempts >= MAX_ATTEMPTS) {
|
||||||
|
setStatus('disconnected');
|
||||||
|
setErrorMsg(reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delay = RETRY_DELAYS[attempts] ?? 5000;
|
||||||
|
attempts++;
|
||||||
|
setStatus('reconnecting');
|
||||||
|
setErrorMsg(`${reason} — reconnecting (${attempts}/${MAX_ATTEMPTS})...`);
|
||||||
|
retryTimer = setTimeout(() => {
|
||||||
|
retryTimer = null;
|
||||||
|
void connect();
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
const connect = async () => {
|
const connect = async () => {
|
||||||
|
const gen = ++generation;
|
||||||
|
const isCurrent = () => !disposed && gen === generation;
|
||||||
let password = '';
|
let password = '';
|
||||||
try {
|
try {
|
||||||
const res = await client.get<{ password: string }>('/desktop/vnc-password');
|
const res = await client.get<{ password: string }>('/desktop/vnc-password');
|
||||||
password = res.password;
|
password = res.password;
|
||||||
} catch {
|
} catch {
|
||||||
if (disposed) return;
|
if (!isCurrent()) return;
|
||||||
setStatus('error');
|
// Officer being down is the common case here, and it comes back — so this is a retry, not a
|
||||||
setErrorMsg('Failed to fetch VNC password');
|
// dead end. A sidecar that is genuinely missing still ends up at "Disconnected" after five.
|
||||||
|
retry('Failed to fetch VNC password');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (disposed) return;
|
if (!isCurrent()) return;
|
||||||
|
|
||||||
let RFB: Awaited<ReturnType<typeof loadRFB>>['default'];
|
let RFB: Awaited<ReturnType<typeof loadRFB>>['default'];
|
||||||
try {
|
try {
|
||||||
@@ -71,13 +117,14 @@ export const DesktopView = ({ className, style }: DesktopViewProps) => {
|
|||||||
RFB = mod.default;
|
RFB = mod.default;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[desktop] Failed to load noVNC:', err);
|
console.error('[desktop] Failed to load noVNC:', err);
|
||||||
if (disposed) return;
|
if (!isCurrent()) return;
|
||||||
|
fatal = true;
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMsg('Failed to load noVNC library');
|
setErrorMsg('Failed to load noVNC library');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (disposed) return;
|
if (!isCurrent()) return;
|
||||||
|
|
||||||
const wsUrl = buildWsUrl();
|
const wsUrl = buildWsUrl();
|
||||||
const rfb = new RFB(container, wsUrl, {
|
const rfb = new RFB(container, wsUrl, {
|
||||||
@@ -90,15 +137,18 @@ export const DesktopView = ({ className, style }: DesktopViewProps) => {
|
|||||||
rfbRef.current = rfb;
|
rfbRef.current = rfb;
|
||||||
|
|
||||||
rfb.addEventListener('connect', () => {
|
rfb.addEventListener('connect', () => {
|
||||||
if (!disposed) setStatus('connected');
|
if (!isCurrent()) return;
|
||||||
|
attempts = 0;
|
||||||
|
setErrorMsg('');
|
||||||
|
setStatus('connected');
|
||||||
});
|
});
|
||||||
|
|
||||||
rfb.addEventListener('disconnect', (ev: CustomEvent) => {
|
// Every disconnect we did not ask for is worth retrying, clean or not: officer closing its side
|
||||||
if (disposed) return;
|
// tidily during a restart still reports `clean`, and the desktop behind it is still there.
|
||||||
setStatus('disconnected');
|
rfb.addEventListener('disconnect', () => {
|
||||||
if (!ev.detail.clean) {
|
if (!isCurrent()) return;
|
||||||
setErrorMsg('Connection lost');
|
rfbRef.current = null;
|
||||||
}
|
retry('Connection lost');
|
||||||
});
|
});
|
||||||
|
|
||||||
rfb.addEventListener('credentialsrequired', () => {
|
rfb.addEventListener('credentialsrequired', () => {
|
||||||
@@ -106,25 +156,30 @@ export const DesktopView = ({ className, style }: DesktopViewProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
rfb.addEventListener('securityfailure', (ev: CustomEvent) => {
|
rfb.addEventListener('securityfailure', (ev: CustomEvent) => {
|
||||||
if (!disposed) {
|
if (!isCurrent()) return;
|
||||||
|
fatal = true;
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setErrorMsg(ev.detail.reason || 'Security failure');
|
setErrorMsg(ev.detail.reason || 'Security failure');
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Coming back to the tab after the retries ran out should try once more rather than stay dead.
|
||||||
|
const handleVisibility = () => {
|
||||||
|
if (disposed || fatal || document.visibilityState !== 'visible') return;
|
||||||
|
if (rfbRef.current || retryTimer) return;
|
||||||
|
attempts = 0;
|
||||||
|
setStatus('connecting');
|
||||||
|
void connect();
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility);
|
||||||
|
|
||||||
void connect();
|
void connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
if (rfbRef.current) {
|
document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
try {
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
rfbRef.current.disconnect();
|
detach();
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
rfbRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [isMounted, client]);
|
}, [isMounted, client]);
|
||||||
|
|
||||||
@@ -138,7 +193,7 @@ export const DesktopView = ({ className, style }: DesktopViewProps) => {
|
|||||||
Connecting to desktop...
|
Connecting to desktop...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(status === 'disconnected' || status === 'error') && (
|
{(status === 'reconnecting' || status === 'disconnected' || status === 'error') && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||||
{errorMsg || 'Disconnected from desktop'}
|
{errorMsg || 'Disconnected from desktop'}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user