From 764f70732f6d888dc76701b543d992393079c745 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Tue, 25 Aug 2026 00:14:09 +0800 Subject: [PATCH 1/2] fix(reminder): recover the background guard when it fails to start or wakes up mid-permission-grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two separate ways the reminder guard could stay off until a full app restart: - ReminderGuardCoordinator.ensureLocationUpdates() called Location.getForegroundPermissionsAsync() with no try/catch, unlike every other native call in the same function. A cold-start hiccup there rejected the whole reconcile() chain and propagated out of AppRuntime.start() — which AppProviders.tsx calls with a bare `void` and no .catch(), so the failure was a silent unhandled rejection that took the reminder engine down with it (AppRuntime only stops modules it recorded as started, and the guard's own start() never returned to be recorded). - Granting a permission mid-session only called reminder.rebuild(); nothing ever nudged the guard coordinator. If its one-shot reconcile() had already hit the "permission not granted" skip branch, there was no code path back to it short of a restart. AppProviders.tsx now catches runtime.start()/stop() failures instead of swallowing them silently, and AppRoot.tsx's handlePermissionsUpdated also calls schedules.refresh(), which the guard already subscribes to. --- frontend/src/app/AppProviders.tsx | 11 ++++++-- frontend/src/app/AppRoot.tsx | 3 +++ .../application/ReminderGuardCoordinator.ts | 12 ++++++++- frontend/tests/unit/app/AppProviders.test.tsx | 26 +++++++++++++++++-- frontend/tests/unit/app/AppRoot.test.tsx | 7 +++++ .../ReminderGuardCoordinator.test.ts | 14 ++++++++++ 6 files changed, 68 insertions(+), 5 deletions(-) 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..bf2132d7 100644 --- a/frontend/src/app/AppRoot.tsx +++ b/frontend/src/app/AppRoot.tsx @@ -37,6 +37,9 @@ export function AppRoot({ services: providedServices }: { services?: AppServices const controller = services.auth.controller; const handlePermissionsUpdated = useCallback(() => { void services.reminder.rebuild(); + // ReminderGuardCoordinator 冷启动时如果权限还没给,会跳过启动且不重试; + // schedules.refresh() 会顺带唤醒它订阅的 reconcile(),不用单独给协调器开端口。 + void services.schedules.refresh(); }, [services]); return ( Promise>().mockResolvedValue(undefined), + stop: jest.fn<() => Promise>().mockResolvedValue(undefined), + }, protectedClient: {}, scheduleView: {}, webSocketClient: {}, @@ -74,4 +77,23 @@ 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(); + }); }); diff --git a/frontend/tests/unit/app/AppRoot.test.tsx b/frontend/tests/unit/app/AppRoot.test.tsx index 71221a31..1b5d990b 100644 --- a/frontend/tests/unit/app/AppRoot.test.tsx +++ b/frontend/tests/unit/app/AppRoot.test.tsx @@ -451,16 +451,23 @@ 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('日程日历'); 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()]); From b6995672098d9589821342e2c2a8d0c60615c140 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Tue, 25 Aug 2026 00:29:17 +0800 Subject: [PATCH 2/2] fix(reminder): catch schedules.refresh() rejections instead of letting them go unhandled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fennoai flagged the new refresh() call in handlePermissionsUpdated as another fire-and-forget promise that could silently fail. While adding a regression test for it, the sibling refresh() call in the SQLite-ready effect turned out to have the exact same gap — both now log instead of rejecting silently. Also covers the runtime.stop() failure branch in AppProviders.tsx that Codecov flagged as untested. --- frontend/src/app/AppRoot.tsx | 10 +++- frontend/tests/unit/app/AppProviders.test.tsx | 20 +++++++ frontend/tests/unit/app/AppRoot.test.tsx | 59 +++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/AppRoot.tsx b/frontend/src/app/AppRoot.tsx index bf2132d7..6834d61f 100644 --- a/frontend/src/app/AppRoot.tsx +++ b/frontend/src/app/AppRoot.tsx @@ -39,7 +39,11 @@ export function AppRoot({ services: providedServices }: { services?: AppServices void services.reminder.rebuild(); // ReminderGuardCoordinator 冷启动时如果权限还没给,会跳过启动且不重试; // schedules.refresh() 会顺带唤醒它订阅的 reconcile(),不用单独给协调器开端口。 - void services.schedules.refresh(); + // 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/tests/unit/app/AppProviders.test.tsx b/frontend/tests/unit/app/AppProviders.test.tsx index 128673df..cec00af7 100644 --- a/frontend/tests/unit/app/AppProviders.test.tsx +++ b/frontend/tests/unit/app/AppProviders.test.tsx @@ -96,4 +96,24 @@ describe('AppProviders', () => { ); 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 1b5d990b..0aa381db 100644 --- a/frontend/tests/unit/app/AppRoot.test.tsx +++ b/frontend/tests/unit/app/AppRoot.test.tsx @@ -472,6 +472,65 @@ describe('AppRoot', () => { 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(