From 0a92e4e3c95e11007c4b81cc5b6d6c993d46f017 Mon Sep 17 00:00:00 2001 From: seizeh Date: Mon, 10 Aug 2026 11:33:03 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=BB=A4=EB=AE=A4=EB=8B=88=ED=8B=B0=20?= =?UTF-8?q?=EC=8A=A4=ED=94=BC=EB=84=88=20=EA=B3=A0=EC=B0=A9=20+=20releaseT?= =?UTF-8?q?oken=20=EA=B1=B0=EC=A7=93=20=EA=B2=BD=EB=B3=B4=20=EA=B0=95?= =?UTF-8?q?=EB=93=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ■ 커뮤니티 스피너가 영원히 도는 경로 최초 로드(스피너 켜짐)가 비행 중일 때 상세에서 돌아오며 silent 갱신이 _reqId 를 가져가면, 최초 로드의 응답은 최신성 가드에서 버려진다. 이때 silent 갱신마저 실패하면 "조용한 실패는 목록 유지" 규칙에 따라 그냥 반환해 버려 _loading 을 풀 사람이 아무도 남지 않는다. 스피너가 떠 있으면 그 silent 요청이 곧 최초 로드를 가로챈 것이므로 오류 화면으로 마무리한다. ■ push.releaseToken 세션이 이미 회수된 경우(다른 기기 비밀번호 변경·정지·탈퇴)의 42501 은 정상이다 — app.uid() 가 token_version 을 보므로 부를 자격이 없고, 기기 토큰은 DB 트리거(users_revoke_device_tokens)가 이미 껐다. 이걸 오류로 올리면 있지도 않은 누수를 알리는 거짓 경보가 된다. 그 밖의 실패(오프라인 로그아웃 등)는 여전히 진짜 누수라 report 유지 — #237 이 얼마나 자주 재현되는지는 계속 봐야 한다. Co-Authored-By: Claude Opus 5 (1M context) --- lib/screen/tabs/community_tab.dart | 8 +++++++- lib/services/push_service.dart | 26 +++++++++++++++++++++++--- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/lib/screen/tabs/community_tab.dart b/lib/screen/tabs/community_tab.dart index 75b22ea..e774039 100644 --- a/lib/screen/tabs/community_tab.dart +++ b/lib/screen/tabs/community_tab.dart @@ -261,7 +261,13 @@ class _CommunityTabState extends State }); } catch (_) { if (!mounted || myReq != _reqId) return; - if (silent) return; // 조용한 갱신 실패는 기존 목록 유지(에러 화면으로 안 덮음) + // 조용한 갱신 실패는 기존 목록을 유지한다(에러 화면으로 안 덮음). + // + // 단 **스피너가 떠 있으면 그냥 반환하면 안 된다.** 최초 로드가 비행 중일 때 + // 상세에서 돌아오며 silent 갱신이 _reqId 를 가져가면, 최초 로드의 응답은 + // 위 가드에서 버려진다 — 이 실패마저 조용히 반환하면 _loading 을 풀 사람이 + // 아무도 남지 않아 스피너가 영원히 돈다. + if (silent && !_loading) return; setState(() { _error = '게시글을 불러오지 못했어요'; _loading = false; diff --git a/lib/services/push_service.dart b/lib/services/push_service.dart index 44ea007..384d787 100644 --- a/lib/services/push_service.dart +++ b/lib/services/push_service.dart @@ -177,6 +177,13 @@ class PushService { why: '토큰을 못 읽으면 서버 해제를 건너뛴다 — 아래 기기측 삭제는 그대로 시도', ); } + // 세션이 이미 회수돼 RPC 를 부를 자격이 없는 상태인가. + // app.uid() 가 token_version 불일치·비활성 상태에서 null 을 주면 RLS 가 + // 42501(not_authenticated)로 막는다 — 강제 로그아웃의 정상 결과다. + bool isSessionRevoked(Object e) => + e is PostgrestException && + (e.code == '42501' || e.message.contains('not_authenticated')); + if (token != null && SessionManager.instance.isLoggedIn) { try { await Supabase.instance.client.rpc( @@ -184,9 +191,22 @@ class PushService { params: {'p_token': token}, ); } catch (e, st) { - // 오프라인 로그아웃이 정확히 이 경로다. 로그아웃 자체는 막지 않되, - // **얼마나 자주 새는지는 알아야 한다** — 이 실패가 곧 #237 의 재현이다. - ErrorReporter.report(e, where: 'push.releaseToken', stackTrace: st); + // 세션이 이미 회수된 경우(다른 기기 비밀번호 변경·정지·탈퇴)는 실패가 + // **정상이다.** app.uid() 가 token_version 을 보므로 부를 자격이 없고, + // 기기 토큰은 DB 트리거(users_revoke_device_tokens)가 이미 껐다. + // 이걸 오류로 올리면 있지도 않은 누수를 알리는 거짓 경보가 된다. + // + // 그 밖의 실패(오프라인 로그아웃 등)는 진짜 누수다 — #237 의 재현이므로 + // 얼마나 자주 새는지 계속 본다. + if (isSessionRevoked(e)) { + ErrorReporter.ignored( + e, + where: 'push.releaseToken', + why: '세션이 이미 회수됨 — 기기 토큰은 DB 트리거가 껐다', + ); + } else { + ErrorReporter.report(e, where: 'push.releaseToken', stackTrace: st); + } } } try {