diff --git a/apps/desktop/src/main/services/CloudSyncService.ts b/apps/desktop/src/main/services/CloudSyncService.ts index 983cec1..64720e4 100644 --- a/apps/desktop/src/main/services/CloudSyncService.ts +++ b/apps/desktop/src/main/services/CloudSyncService.ts @@ -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 { + if (this._realtimeRetryTimer) { + clearTimeout(this._realtimeRetryTimer) + this._realtimeRetryTimer = null + } + this._realtimeRetryCount = 0 if (this._realtimeChannel) { await this._realtimeChannel.unsubscribe() this._realtimeChannel = null diff --git a/memory/project_status.md b/memory/project_status.md index 1948039..e51252a 100644 --- a/memory/project_status.md +++ b/memory/project_status.md @@ -3,6 +3,38 @@ > 마지막 갱신: 2026-04-11 (빅뱅 Phase 5 Part 6 — Voice Conversation 몰입 패널 구현) > 규칙 13: 작업 완료 즉시 이 파일 갱신 의무 +## 빅뱅 Phase 5 Part 6++ (2026-04-11) — U1/U3/U4 남은 이슈 정리 ✅ + +### U1 — Realtime TIMED_OUT 자동 재구독 (CloudSyncService.ts) + +Publication/RLS/setAuth 모두 설정 완료 상태에서도 매 기동 시 `TIMED_OUT`이 찍히는 증상. 근본 원인은 Supabase 서버 측 transient(네트워크/WebSocket 타임아웃) 의심으로 판정. Auto push(Phase 3.3) fallback이 완벽히 동작해 블로커는 아니지만, 다른 기기에서의 원격 변경 pull을 놓치는 window가 생김. + +**변경**: `subscribe()` 콜백에 지수 백오프 자동 재구독 추가. +- `SUBSCRIBED` → retry count reset + 타이머 클리어 +- `TIMED_OUT` / `CHANNEL_ERROR` / `CLOSED` → `_scheduleRealtimeRetry()` +- 백오프: 1s → 3s → 10s, 3회 초과 시 포기하고 경고 로그(Auto push로만 동작) +- `stopRealtime()`에서 타이머 cleanup + count reset + +### U3 — Refresh token 실패 시 재인증 이벤트 emit (CloudSyncService.ts) + +`init()`의 `refreshSession` 실패 브랜치에서 기존에는 `warn` 로깅 + 토큰 삭제만 수행 → 사용자는 "왜 로그아웃됐지?" 상태로 방치. 재현이 쉽지 않지만 장시간 idle 후 발생 가능성 있어 예방적 개선. + +**변경**: +- `logger.warn` → `logger.error`(에러 레벨 승격) +- `sync-error` 이벤트 emit (`"Sign-in session expired. Please sign in again."`) +- `auth-changed { user: null }` emit (renderer 게이트 재잠금) +- `try/catch`의 catch 브랜치도 동일 처리 (기존에는 로깅만) + +### U4 — package-lock optional dep (확인 후 유지 결정) + +`package-lock.json`에 rollup의 전 플랫폼 `optionalDependencies`(darwin/win32/linux/freebsd/android)가 기록돼 있음. Mac `npm install --force` 시 win32 관련 경고가 나왔지만 런타임 영향 없음. `apps/desktop/package.json:56`에 `@rollup/rollup-win32-x64-msvc`가 명시적 의존으로 선언되어 있는 이유는 Windows 빌드자가 사전에 binding을 확보하기 위함. + +**결정**: **유지**. 크로스플랫폼 개발/CI 효율 + Win 빌드자 경험을 우선. 향후 lock 재생성 시에도 선택적 제거 불필요. 이 이슈는 **close**. + +### 변경 파일 (2) +- `apps/desktop/src/main/services/CloudSyncService.ts` — U1 재구독 + U3 재인증 이벤트 +- `memory/project_status.md` — 본 섹션 + ## 빅뱅 Phase 5 Part 6+ (2026-04-11) — U7 AudioCaptureService Mac 분기 ✅ `AudioCaptureService.getDevices()`가 `powershell -NoProfile`만 호출하던 것을