From c51024957481d7b60cfecad5bbcaa597a6101e6b Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Fri, 21 Aug 2026 15:39:47 +0800 Subject: [PATCH] fix(reminder): stop chainSync() from crashing on a rejected syncRegions() syncRegions() has two unguarded awaits (getForegroundPermissionsAsync/ getBackgroundPermissionsAsync) that can reject for real on a native module hiccup, not just resolve to a denied status. chainSync() chained with .then(onFulfilled, onRejected), so a rejection sits unhandled until some later chainSync() call chains onto it. handleAppState's resync is fire-and-forget (void this.chainSync()) with no such follow-up guaranteed -- if the rejection lands there, Node/Hermes treats it as an unhandled rejection and crashes the process outright. Reproduced with a mocked permission call that rejects. Switch to .then(onFulfilled).catch(() => {}), the same idiom already used by AssistantContinuousConversationService.chainPlayback(): the .catch() is attached in the same statement, so the rejection is neutralized immediately instead of waiting on a future call that may never come. Fixes 1024XEngineer/timeflow#347. Co-Authored-By: Claude Sonnet 5 --- .../location/ExpoLocationMonitor.ts | 13 ++++++----- .../location/expoLocationMonitor.test.ts | 22 +++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/frontend/src/infrastructure/location/ExpoLocationMonitor.ts b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts index a1df5392..670e2fdb 100644 --- a/frontend/src/infrastructure/location/ExpoLocationMonitor.ts +++ b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts @@ -209,12 +209,15 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide /** 把一次区域同步接到 syncChain 末尾,保证上一次真正执行完(不管成功与否)才轮到 * 这一次——不是排队等着处理各自不同的输入,每次都是重新读 this.watches 当前 * 状态,纯粹为了不让 syncRegions() 并发跑,参照 AssistantContinuousConversationService - * 的 chainPlayback()/playbackChain 同一个模式。 */ + * 的 chainPlayback()/playbackChain 同一个模式。.catch(() => {}) 必须紧跟在同一条 + * 语句里同步接上——syncRegions() 里 getForegroundPermissionsAsync()/ + * getBackgroundPermissionsAsync() 没包 try/catch,哪次真抛了,如果这段失败要 + * 等下一次 chainSync() 调用(比如靠 then 的第二个参数)才被接住,中间这段没人 + * 接的窗口期会被 Node/Hermes 判定成 unhandled rejection 直接崩进程——不是理论 + * 风险,AssistantContinuousConversationService 的 commandResultChain 用一个会 + * 抛错的场景实测复现过。 */ private chainSync(): Promise { - this.syncChain = this.syncChain.then( - () => this.syncRegions(), - () => this.syncRegions(), - ); + this.syncChain = this.syncChain.then(() => this.syncRegions()).catch(() => {}); return this.syncChain; } diff --git a/frontend/tests/unit/infrastructure/location/expoLocationMonitor.test.ts b/frontend/tests/unit/infrastructure/location/expoLocationMonitor.test.ts index b5d54f77..34927dfa 100644 --- a/frontend/tests/unit/infrastructure/location/expoLocationMonitor.test.ts +++ b/frontend/tests/unit/infrastructure/location/expoLocationMonitor.test.ts @@ -476,5 +476,27 @@ describe('ExpoLocationMonitor', () => { const monitor = new ExpoLocationMonitor(); await expect(monitor.watch(request(), jest.fn())).resolves.toBeDefined(); }); + + it('recovers after syncRegions() itself throws instead of leaving the sync chain stuck', async () => { + // getForegroundPermissionsAsync/getBackgroundPermissionsAsync above are not + // wrapped in try/catch, so a real native hiccup makes syncRegions() itself + // reject (not just warn). chainSync() must neutralize that immediately -- + // handleAppState's resync is fire-and-forget (void this.chainSync()), so if + // the rejection is left unhandled even briefly, Node/Hermes treats it as an + // unhandled rejection and crashes the process outright (reproduced while + // writing this test, before switching chainSync() to .then().catch(() => {})). + const monitor = new ExpoLocationMonitor(); + await monitor.watch(request(), jest.fn()); + startGeofencing.mockClear(); + + getForeground.mockRejectedValueOnce(new Error('native location module hiccup')); + appStateHandler?.('active'); + await new Promise((resolve) => setImmediate(resolve)); + + await expect( + monitor.watch(request({ schedule_id: 'schedule-2' }), jest.fn()), + ).resolves.toBeDefined(); + expect(startGeofencing).toHaveBeenCalled(); + }); }); });