fix: force fresh reconnect when returning after idle period
The WebSocket can go stale during inactivity without triggering any error status (channel still shows 'joined' but socket is dead). Now when the user returns after 60+ seconds of inactivity, the connection is torn down and re-established to guarantee a fresh WebSocket. This ensures CRDT broadcasts actually reach other clients after returning from idle. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
fa8437d03a
commit
c28b9ac565
|
|
@ -44,6 +44,7 @@ const RECONNECT_BASE_DELAY_MS = 1000
|
||||||
const RECONNECT_MAX_DELAY_MS = 30_000
|
const RECONNECT_MAX_DELAY_MS = 30_000
|
||||||
const INACTIVITY_TIMEOUT_MS = 5 * 60_000 // 5 minutes of inactivity before pausing
|
const INACTIVITY_TIMEOUT_MS = 5 * 60_000 // 5 minutes of inactivity before pausing
|
||||||
const CONNECTION_TIMEOUT_MS = 15_000 // 15s timeout for initial connection
|
const CONNECTION_TIMEOUT_MS = 15_000 // 15s timeout for initial connection
|
||||||
|
const STALE_THRESHOLD_MS = 60_000 // 60s of inactivity = force fresh reconnect on return
|
||||||
|
|
||||||
export class RealtimeConnection {
|
export class RealtimeConnection {
|
||||||
private channel: RealtimeChannel | null = null
|
private channel: RealtimeChannel | null = null
|
||||||
|
|
@ -58,6 +59,7 @@ export class RealtimeConnection {
|
||||||
private callbacks: RealtimeCallbacks
|
private callbacks: RealtimeCallbacks
|
||||||
private isDestroyed = false
|
private isDestroyed = false
|
||||||
private isPaused = false
|
private isPaused = false
|
||||||
|
private lastActivityTime = Date.now()
|
||||||
private supabase = createClient()
|
private supabase = createClient()
|
||||||
|
|
||||||
constructor(projectId: string, userId: string, displayName: string, callbacks: RealtimeCallbacks) {
|
constructor(projectId: string, userId: string, displayName: string, callbacks: RealtimeCallbacks) {
|
||||||
|
|
@ -70,6 +72,7 @@ export class RealtimeConnection {
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
if (this.isDestroyed) return
|
if (this.isDestroyed) return
|
||||||
this.isPaused = false
|
this.isPaused = false
|
||||||
|
this.lastActivityTime = Date.now()
|
||||||
this.callbacks.onConnectionStateChange('connecting')
|
this.callbacks.onConnectionStateChange('connecting')
|
||||||
this.resetInactivityTimer()
|
this.resetInactivityTimer()
|
||||||
this.clearConnectionTimer()
|
this.clearConnectionTimer()
|
||||||
|
|
@ -244,15 +247,43 @@ export class RealtimeConnection {
|
||||||
/**
|
/**
|
||||||
* Notify that user activity has occurred, resetting the inactivity timer.
|
* Notify that user activity has occurred, resetting the inactivity timer.
|
||||||
* If the connection was paused due to inactivity, it will resume.
|
* If the connection was paused due to inactivity, it will resume.
|
||||||
|
* If the connection was idle for a significant period, force a fresh reconnect
|
||||||
|
* to ensure the WebSocket isn't stale.
|
||||||
*/
|
*/
|
||||||
notifyActivity(): void {
|
notifyActivity(): void {
|
||||||
if (this.isDestroyed) return
|
if (this.isDestroyed) return
|
||||||
|
const now = Date.now()
|
||||||
|
const idleDuration = now - this.lastActivityTime
|
||||||
|
this.lastActivityTime = now
|
||||||
this.resetInactivityTimer()
|
this.resetInactivityTimer()
|
||||||
|
|
||||||
if (this.isPaused) {
|
if (this.isPaused) {
|
||||||
this.resume()
|
this.resume()
|
||||||
|
} else if (idleDuration > STALE_THRESHOLD_MS && this.channel) {
|
||||||
|
// Connection may appear alive but WebSocket could be stale after idle.
|
||||||
|
// Force a fresh reconnect to ensure broadcasts actually work.
|
||||||
|
this.forceReconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force a fresh reconnect by tearing down the current channel and reconnecting.
|
||||||
|
*/
|
||||||
|
private async forceReconnect(): Promise<void> {
|
||||||
|
if (this.isDestroyed || this.isPaused) return
|
||||||
|
this.stopHeartbeat()
|
||||||
|
this.clearReconnectTimer()
|
||||||
|
this.clearConnectionTimer()
|
||||||
|
|
||||||
|
if (this.channel) {
|
||||||
|
this.supabase.removeChannel(this.channel)
|
||||||
|
this.channel = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconnectAttempts = 0
|
||||||
|
await this.connect()
|
||||||
|
}
|
||||||
|
|
||||||
getIsPaused(): boolean {
|
getIsPaused(): boolean {
|
||||||
return this.isPaused
|
return this.isPaused
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue