diff --git a/frontend/src/app/AppProviders.tsx b/frontend/src/app/AppProviders.tsx index 562483a6..31ce7714 100644 --- a/frontend/src/app/AppProviders.tsx +++ b/frontend/src/app/AppProviders.tsx @@ -35,9 +35,16 @@ function AuthenticatedRuntime({ services }: { readonly services: AppServices }) return; } - void services.runtime.start(); + // 不 catch 的话,runtime.start() 里任何一个模块启动失败(哪怕只是某次原生 + // 调用偶发抛错)都会变成静默的 unhandled rejection——用户看到的就是"提醒/ + // 守护线程这次没起来",没有任何日志能定位到具体是哪次启动失败的。 + services.runtime.start().catch((error) => { + console.error('[app] runtime.start() failed', error); + }); return () => { - void services.runtime.stop(); + services.runtime.stop().catch((error) => { + console.error('[app] runtime.stop() failed', error); + }); }; }, [isAuthenticated, services]); diff --git a/frontend/src/app/AppRoot.tsx b/frontend/src/app/AppRoot.tsx index 0ccca12f..6834d61f 100644 --- a/frontend/src/app/AppRoot.tsx +++ b/frontend/src/app/AppRoot.tsx @@ -37,6 +37,13 @@ export function AppRoot({ services: providedServices }: { services?: AppServices const controller = services.auth.controller; const handlePermissionsUpdated = useCallback(() => { void services.reminder.rebuild(); + // ReminderGuardCoordinator 冷启动时如果权限还没给,会跳过启动且不重试; + // schedules.refresh() 会顺带唤醒它订阅的 reconcile(),不用单独给协调器开端口。 + // refresh() 内部要读 SQLite,可能因为瞬时的原生/数据库错误 reject——不 catch + // 的话这里又是一次静默 unhandled rejection,跟这个 PR 本身要修的问题一样。 + services.schedules.refresh().catch((error) => { + console.error('[app] schedules.refresh() failed after a permission update', error); + }); }, [services]); return ( { + console.error('[app] schedules.refresh() failed after SQLite became ready', error); + }); return () => { scheduleReader.detach(); reminderState.detach(); diff --git a/frontend/src/features/reminder/application/ReminderGuardCoordinator.ts b/frontend/src/features/reminder/application/ReminderGuardCoordinator.ts index 86c621d9..a8986c35 100644 --- a/frontend/src/features/reminder/application/ReminderGuardCoordinator.ts +++ b/frontend/src/features/reminder/application/ReminderGuardCoordinator.ts @@ -128,7 +128,17 @@ export class ReminderGuardCoordinator { // 才需要)——上一版这里连后台权限一起卡,导致用户只给了"仅使用时允许"(没给 // "始终允许",这是安卓上很常见的选择)时,连时间型提醒的兜底轮询都启动不了, // 跟这条日程要不要用到位置完全没关系。 - const { status: foreground } = await Location.getForegroundPermissionsAsync(); + // 冷启动时原生模块可能还没链接完,这次调用偶尔会抛错——不兜住的话会一路 + // 传出 reconcile()/start(),把整个 AppRuntime 启动流程炸掉(详见 + // AppProviders.tsx 里 runtime.start() 未 catch 的说明),而不是仅仅这一次 + // reconcile 失败。 + let foreground: Location.PermissionStatus; + try { + ({ status: foreground } = await Location.getForegroundPermissionsAsync()); + } catch (error) { + console.warn('[guard] getForegroundPermissionsAsync failed', error); + return; + } if (foreground !== 'granted') { console.warn('[guard] ensureLocationUpdates skipped: foreground permission not granted'); return; diff --git a/frontend/tests/unit/app/AppProviders.test.tsx b/frontend/tests/unit/app/AppProviders.test.tsx index ee78370a..cec00af7 100644 --- a/frontend/tests/unit/app/AppProviders.test.tsx +++ b/frontend/tests/unit/app/AppProviders.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, jest } from '@jest/globals'; -import { render } from '@testing-library/react-native'; +import { render, waitFor } from '@testing-library/react-native'; import type { PropsWithChildren } from 'react'; import type { AppServices } from '../../../src/app/composition/createAppServices'; @@ -26,7 +26,10 @@ function createServices(): AppServices { reminder: { rebuild: jest.fn() }, reminderPorts: { device: {} }, alertDialog: {}, - runtime: { start: jest.fn(), stop: jest.fn() }, + runtime: { + start: jest.fn<() => Promise>().mockResolvedValue(undefined), + stop: jest.fn<() => Promise>().mockResolvedValue(undefined), + }, protectedClient: {}, scheduleView: {}, webSocketClient: {}, @@ -74,4 +77,43 @@ describe('AppProviders', () => { expect(services.runtime.stop).toHaveBeenCalledTimes(1); }); + + it('logs instead of throwing an unhandled rejection when runtime.start() fails', async () => { + mockAuthStatus = 'authenticated'; + const services = createServices(); + const startError = new Error('module start failed'); + (services.runtime.start as jest.Mock<() => Promise>).mockRejectedValue(startError); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + render( + + {null} + , + ); + + await waitFor(() => + expect(errorSpy).toHaveBeenCalledWith('[app] runtime.start() failed', startError), + ); + errorSpy.mockRestore(); + }); + + it('logs instead of throwing an unhandled rejection when runtime.stop() fails', async () => { + mockAuthStatus = 'authenticated'; + const services = createServices(); + const stopError = new Error('module stop failed'); + (services.runtime.stop as jest.Mock<() => Promise>).mockRejectedValue(stopError); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const { unmount } = render( + + {null} + , + ); + unmount(); + + await waitFor(() => + expect(errorSpy).toHaveBeenCalledWith('[app] runtime.stop() failed', stopError), + ); + errorSpy.mockRestore(); + }); }); diff --git a/frontend/tests/unit/app/AppRoot.test.tsx b/frontend/tests/unit/app/AppRoot.test.tsx index 71221a31..0aa381db 100644 --- a/frontend/tests/unit/app/AppRoot.test.tsx +++ b/frontend/tests/unit/app/AppRoot.test.tsx @@ -451,20 +451,86 @@ describe('AppRoot', () => { return true; }); const rebuild = jest.spyOn(services.reminder, 'rebuild').mockResolvedValue([]); + const refresh = jest.spyOn(services.schedules, 'refresh').mockResolvedValue(undefined); render(); await screen.findByText('需要这些权限'); + // DB ready 那条 effect 自己也会在挂载时 refresh() 一次;只关心权限授予 + // 这次触发的那一下,把挂载阶段的调用先清掉。 + refresh.mockClear(); fireEvent.press(screen.getByTestId('permission-action-notifications')); await waitFor(() => expect(screen.getByLabelText('进入 App').props.accessibilityState.disabled).toBe(false), ); expect(rebuild).toHaveBeenCalledTimes(1); + // 冷启动权限没给时,守护线程的 reconcile() 会跳过启动且不重试;权限中途 + // 被授予后必须有人把它叫醒,不然只能等下次重启才会再跑一次 reconcile()。 + expect(refresh).toHaveBeenCalledTimes(1); fireEvent.press(screen.getByLabelText('进入 App')); await screen.findByText('日程日历'); }); + + it('logs instead of throwing when schedules.refresh() fails after a permission grant', async () => { + const services = createController({ + accountId: 'acc_001', + accessToken: 'opaque-token', + expiresAt: 200_000, + username: 'timeflow_user', + }); + let status = { + platform: 'android' as const, + supported: true, + permissions: { + notifications: false, + exact_alarm: true, + overlay: true, + full_screen: true, + battery_optimization: true, + location_foreground: true, + location_background: true, + microphone: true, + }, + background_execution: true, + oemGuidance: { + manufacturer: null, + autostartGuided: false, + backgroundPopupGuided: false, + lastOverlayFailed: false, + }, + }; + jest.spyOn(services.reminderPorts.device, 'getStatus').mockImplementation(async () => status); + jest + .spyOn(services.reminderPorts.device, 'requestPermission') + .mockImplementation(async (permission) => { + status = { ...status, permissions: { ...status.permissions, [permission]: true } }; + return true; + }); + jest.spyOn(services.reminder, 'rebuild').mockResolvedValue([]); + const refreshError = new Error('sqlite read failed'); + const refresh = jest.spyOn(services.schedules, 'refresh').mockRejectedValue(refreshError); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + render(); + + await screen.findByText('需要这些权限'); + refresh.mockClear(); + fireEvent.press(screen.getByTestId('permission-action-notifications')); + + await waitFor(() => + expect(errorSpy).toHaveBeenCalledWith( + '[app] schedules.refresh() failed after a permission update', + refreshError, + ), + ); + // 失败不能挡住用户继续走完权限流程。 + await waitFor(() => + expect(screen.getByLabelText('进入 App').props.accessibilityState.disabled).toBe(false), + ); + errorSpy.mockRestore(); + }); }); function createController( diff --git a/frontend/tests/unit/features/reminder/application/ReminderGuardCoordinator.test.ts b/frontend/tests/unit/features/reminder/application/ReminderGuardCoordinator.test.ts index 37e52609..fdae0eae 100644 --- a/frontend/tests/unit/features/reminder/application/ReminderGuardCoordinator.test.ts +++ b/frontend/tests/unit/features/reminder/application/ReminderGuardCoordinator.test.ts @@ -312,6 +312,20 @@ describe('ReminderGuardCoordinator', () => { expect(startUpdates).toHaveBeenCalledTimes(1); }); + it('does not let a getForegroundPermissionsAsync failure reject start()', async () => { + getForeground.mockRejectedValue(new Error('boom')); + const reader = createReader([timeSchedule()]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + + // 改之前:这次原生调用不兜错的话,start() 会直接 reject,把整个 + // AppRuntime 的启动流程一起炸掉,而不是仅仅这次 reconcile 没启动成功。 + await expect(coordinator.start()).resolves.toBeUndefined(); + expect(startUpdates).not.toHaveBeenCalled(); + }); + it('swallows a startLocationUpdatesAsync failure', async () => { startUpdates.mockRejectedValue(new Error('boom')); const reader = createReader([timeSchedule()]);