fix(desktop): CloudSync Realtime 자동 재구독 + Refresh token 실패 재인증 알림 (U1+U3, 빅뱅 Phase 5 Part 6)
## U1 — Realtime TIMED_OUT 자동 재구독
CloudSyncService.startRealtime()의 subscribe() 콜백이 TIMED_OUT 한 번만
로깅하고 끝나던 것을 지수 백오프 재구독으로 교체.
- SUBSCRIBED: retry count reset + timer clear
- TIMED_OUT / CHANNEL_ERROR / CLOSED: _scheduleRealtimeRetry() 트리거
- 백오프: 1s → 3s → 10s (MAX 3회)
- 초과 시 warn 로깅 후 포기 (Auto push fallback으로만 동작)
- stopRealtime()에서 타이머/카운터 cleanup
Publication, RLS, setAuth 모두 설정 완료 상태에서도 매 기동 시 TIMED_OUT이
찍히는 증상. 근본 원인은 Supabase 서버 transient(네트워크/WebSocket
타임아웃) 의심. 이 패치로 완전 해결은 아니지만 일시적 네트워크 jitter
복구 윈도우를 확보하고, 3회 실패 시 명확한 포기 메시지를 남긴다.
## U3 — Refresh token 실패 시 재인증 이벤트
init()의 refreshSession 실패 브랜치에서 warn 로깅 + 토큰 삭제만 수행해
사용자가 "왜 로그아웃됐지?" 상태로 방치되던 문제.
- logger.warn → logger.error(에러 레벨 승격)
- sync-error 이벤트 emit (사용자에게 보여줄 메시지 포함)
- auth-changed { user: null } emit (renderer 게이트 재잠금)
- catch 브랜치도 동일 처리 (기존엔 로깅만)
## U4 — package-lock.json optional dep (유지 결정)
rollup 전 플랫폼 optional deps가 lock에 기록돼 있지만 Windows 빌드자
사전 fetch 효율 + 크로스플랫폼 CI 환경을 고려해 유지. apps/desktop/
package.json:56에 @rollup/rollup-win32-x64-msvc 명시 의존이 있는 이유도
동일. 이 이슈는 확인 후 close.
This commit is contained in:
parent
dd591d06e7
commit
dc7f93349e
2 changed files with 108 additions and 2 deletions
|
|
@ -64,6 +64,9 @@ class CloudSyncService extends EventEmitter {
|
|||
private _session: Session | null = null
|
||||
private _lastSyncAt: number | null = null
|
||||
private _syncing = false
|
||||
/** Realtime 재구독 지수 백오프 상태 (U1) */
|
||||
private _realtimeRetryCount = 0
|
||||
private _realtimeRetryTimer: NodeJS.Timeout | null = null
|
||||
private _initialized = false
|
||||
private _realtimeChannel: RealtimeChannel | null = null
|
||||
|
||||
|
|
@ -99,8 +102,17 @@ class CloudSyncService extends EventEmitter {
|
|||
try {
|
||||
const { data, error } = await this._client.auth.refreshSession({ refresh_token: stored })
|
||||
if (error) {
|
||||
logger.warn(`Stored session refresh failed: ${error.message}`)
|
||||
// U3: 장시간 idle 후 refresh token 소진 시 사용자에게 재인증 필요를
|
||||
// 알리기 위해 error 로깅 승격 + sync-error + auth-changed(user:null) emit.
|
||||
// 기존에는 조용히 토큰만 삭제해서 사용자가 "왜 로그아웃됐지?" 상태로 방치됐음.
|
||||
logger.error(
|
||||
`세션 복원 실패 — 재로그인 필요: ${error.message} (토큰 만료 또는 서버 세션 무효화)`
|
||||
)
|
||||
this._clearStoredRefreshToken()
|
||||
this.emit('sync-error', {
|
||||
error: `Sign-in session expired. Please sign in again. (${error.message})`,
|
||||
})
|
||||
this.emit('auth-changed', { user: null })
|
||||
} else if (data.session) {
|
||||
this._saveRefreshToken(data.session.refresh_token)
|
||||
logger.info(
|
||||
|
|
@ -110,7 +122,13 @@ class CloudSyncService extends EventEmitter {
|
|||
await this._onAuthenticated(data.session, { reason: 'restore' })
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Session restore error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.error(`세션 복원 예외 — 재로그인 필요: ${message}`)
|
||||
this._clearStoredRefreshToken()
|
||||
this.emit('sync-error', {
|
||||
error: `Sign-in session restore failed. Please sign in again. (${message})`,
|
||||
})
|
||||
this.emit('auth-changed', { user: null })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -414,10 +432,66 @@ class CloudSyncService extends EventEmitter {
|
|||
)
|
||||
.subscribe((status) => {
|
||||
logger.info(`Realtime 채널 상태: ${status}`)
|
||||
// U1: TIMED_OUT / CHANNEL_ERROR / CLOSED 시 지수 백오프 자동 재구독.
|
||||
// Supabase realtime은 네트워크 jitter로 간헐적 TIMED_OUT이 발생할 수 있고,
|
||||
// 이때 기본 fallback(Phase 3.3 auto push)은 동작하지만 다른 기기 변경은 놓친다.
|
||||
// 1s → 3s → 10s 3회 시도 후 포기. 성공(SUBSCRIBED) 시 카운터 리셋.
|
||||
if (status === 'SUBSCRIBED') {
|
||||
this._realtimeRetryCount = 0
|
||||
if (this._realtimeRetryTimer) {
|
||||
clearTimeout(this._realtimeRetryTimer)
|
||||
this._realtimeRetryTimer = null
|
||||
}
|
||||
return
|
||||
}
|
||||
if (status === 'TIMED_OUT' || status === 'CHANNEL_ERROR' || status === 'CLOSED') {
|
||||
this._scheduleRealtimeRetry()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Realtime 재구독 지수 백오프 스케줄러 (U1).
|
||||
* - 1차: 1s, 2차: 3s, 3차: 10s
|
||||
* - 3회 초과 시 포기 (Auto push fallback만으로 동작)
|
||||
* - 이미 스케줄돼 있으면 no-op (중복 방지)
|
||||
*/
|
||||
private _scheduleRealtimeRetry(): void {
|
||||
if (this._realtimeRetryTimer) return
|
||||
if (!this._client || !this._session) return
|
||||
|
||||
const MAX_RETRIES = 3
|
||||
const DELAYS_MS = [1000, 3000, 10000]
|
||||
|
||||
if (this._realtimeRetryCount >= MAX_RETRIES) {
|
||||
logger.warn(
|
||||
`Realtime 재구독 ${MAX_RETRIES}회 실패 — Auto push fallback으로만 동작. 수동 재시도: cloudSync.startRealtime()`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const delay = DELAYS_MS[this._realtimeRetryCount]
|
||||
this._realtimeRetryCount++
|
||||
const attempt = this._realtimeRetryCount
|
||||
|
||||
logger.info(`Realtime 재구독 예약 (${attempt}/${MAX_RETRIES}): ${delay}ms 후`)
|
||||
this._realtimeRetryTimer = setTimeout(() => {
|
||||
this._realtimeRetryTimer = null
|
||||
if (!this._session) return
|
||||
void this.startRealtime().catch((err) => {
|
||||
logger.warn(
|
||||
`Realtime 재구독 실패 (${attempt}/${MAX_RETRIES}): ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
})
|
||||
}, delay)
|
||||
}
|
||||
|
||||
async stopRealtime(): Promise<void> {
|
||||
if (this._realtimeRetryTimer) {
|
||||
clearTimeout(this._realtimeRetryTimer)
|
||||
this._realtimeRetryTimer = null
|
||||
}
|
||||
this._realtimeRetryCount = 0
|
||||
if (this._realtimeChannel) {
|
||||
await this._realtimeChannel.unsubscribe()
|
||||
this._realtimeChannel = null
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue