From f7f558a4767d3c6d93767fb407a92d7c0dfb37b2 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Fri, 28 Aug 2026 23:10:33 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(reminder):=20=E5=9C=B0=E7=82=B9?= =?UTF-8?q?=E6=8F=90=E9=86=92=E5=B8=B8=E9=A9=BB=E5=AE=88=E6=8A=A4=E5=9C=A8?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E8=A2=AB=E6=9D=80=E5=90=8E=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E6=8A=95=E9=80=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 打上上游 expo/expo#47958 的修复(patch-package),并给 ReminderGuardCoordinator 加陈旧检测:不再拿注册 options 当"还在投递" 的证据,改用"本进程是否自己成功建过注册 + 最近是否收到过心跳"判断, 检测到继承自上一个(已死)进程的注册时主动重建。 详见 issue #413,未获完整真机验证,作为已知修复方向保留。 Co-Authored-By: Claude Sonnet 5 --- .../patches/expo-task-manager+57.0.9.patch | 23 ++++ .../application/ReminderGuardCoordinator.ts | 83 ++++++++++-- .../location/reminderGuardTask.ts | 4 + .../ReminderGuardCoordinator.test.ts | 124 +++++++++++++++++- 4 files changed, 222 insertions(+), 12 deletions(-) create mode 100644 frontend/patches/expo-task-manager+57.0.9.patch diff --git a/frontend/patches/expo-task-manager+57.0.9.patch b/frontend/patches/expo-task-manager+57.0.9.patch new file mode 100644 index 00000000..adf7f0e8 --- /dev/null +++ b/frontend/patches/expo-task-manager+57.0.9.patch @@ -0,0 +1,23 @@ +diff --git a/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskService.java b/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskService.java +index de829ca..8416444 100644 +--- a/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskService.java ++++ b/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskService.java +@@ -242,6 +242,7 @@ public class TaskService implements SingletonModule, TaskServiceInterface { + // It may be called with null when the host activity is destroyed. + if (taskManager == null) { + sTaskManagers.remove(appScopeKey); ++ sHeadlessTaskManagers.remove(appScopeKey); + return; + } + +@@ -611,9 +612,7 @@ public class TaskService implements SingletonModule, TaskServiceInterface { + private void invalidateAppRecord(String appScopeKey) { + HeadlessAppLoader appLoader = getAppLoader(); + if (appLoader != null) { +- if (getAppLoader().invalidateApp(appScopeKey)) { +- sHeadlessTaskManagers.remove(appScopeKey); +- } ++ appLoader.invalidateApp(appScopeKey); + } + } + diff --git a/frontend/src/features/reminder/application/ReminderGuardCoordinator.ts b/frontend/src/features/reminder/application/ReminderGuardCoordinator.ts index 721bec45..50f04c03 100644 --- a/frontend/src/features/reminder/application/ReminderGuardCoordinator.ts +++ b/frontend/src/features/reminder/application/ReminderGuardCoordinator.ts @@ -17,6 +17,12 @@ import { /** 真机上 hasStarted/stopLocationUpdates 偶发不返回;登出不能卡在这里。 */ const LOCATION_STOP_TIMEOUT_MS = 2_000; +/** + * 注册声称在跑、却这么久一次心跳都收不到,就当它已经不投递了。取最疏轮询间隔 + * (5min)的两倍:宁可发现得晚,也不能误判去拆一个还在正常投递的注册。 + */ +const REGISTRATION_STALE_AFTER_MS = 600_000; + /** * 原生注册其实有三种状态,而 hasStartedLocationUpdatesAsync() 只能回答把后两种 * 合并之后的那个布尔值("注册着吗"): @@ -54,6 +60,13 @@ export class ReminderGuardCoordinator { private running = false; private currentIntervalMs: number | null = null; private lastSample: GeoPoint | null = null; + /** + * 本进程自己成功建起过这个注册没有。注册记录是持久化的,进程被杀之后它依旧 + * 完好,光看注册状态分不出"我建的"和"上个进程留下的"。 + */ + private ownsRegistration = false; + /** 上一次收到位置心跳的时刻,用来发现会话中途悄悄断掉的投递。 */ + private lastProgressAt: number | null = null; private reconcileChain: Promise = Promise.resolve(); constructor(private readonly dependencies: ReminderGuardDependencies) {} @@ -74,6 +87,8 @@ export class ReminderGuardCoordinator { async stop(): Promise { this.started = false; this.generation += 1; + this.ownsRegistration = false; + this.lastProgressAt = null; this.unsubscribeGuardTask?.(); this.unsubscribeGuardTask = null; this.unsubscribeSchedules?.(); @@ -82,6 +97,7 @@ export class ReminderGuardCoordinator { } private async handleSample(sample: GuardTaskSample): Promise { + this.lastProgressAt = Date.now(); this.lastSample = { latitude: sample.latitude, longitude: sample.longitude }; await this.dependencies.handleLocation(sample); await this.reconcile(); @@ -110,6 +126,9 @@ export class ReminderGuardCoordinator { schedule.status === 'active' && schedule.runtime.reminder_disposition_state !== 'confirmed', ); + // 临时诊断:区分"reconcile 压根没被调到"和"调到了但在这里就早退"。 + console.warn(`[guard] reconcile active=${active.length}`); + if (active.length === 0) { if (!this.isCurrentGeneration(generation)) return; await this.stopLocationUpdates(); @@ -143,19 +162,29 @@ export class ReminderGuardCoordinator { // 这个协调器不知情,本地标志会跟真实状态脱节;用本地标志判断的话,日程 // 清空又新增时会被误判成"已经在跑",永远不会真正重新启动。 // - // 但"注册着"这一个布尔值还不够:注册项带没带 foregroundService 决定了常驻 - // 前台服务在不在,而两者在 hasStartedLocationUpdatesAsync() 眼里完全一样。 - // 只看它的话,一次没带 foregroundService 的重注册就会让协调器永远早退—— - // 而且那份降级注册会被 expo-task-manager 持久化,force-stop 和冷启动都清不掉。 + // 但注册状态本身不能当"还在投递"的证据,两个方向都会骗人: + // - options 带着 foregroundService,进程却已经被杀过一次。注册记录是持久化 + // 的,force-stop 杀不掉,冷启动读到它就一路早退,而真正的投递早断了—— + // 这正是"重启救不回来、只有重装能救"那个卡死状态。 + // - options 没带 foregroundService,服务其实活得好好的。后台唤醒时 + // refreshGuardRegistration 只能不带这个字段重注册(带上会被原生拒), + // 于是每次切后台都会把 options 打成这样,回前台再去"修"一个健康的服务。 + // 所以真正的判据是"最近还收不收得到心跳"(isRegistrationStale),注册状态 + // 只用来回答"重建之前要不要先注销一次"。 const state = await this.resolveRegistrationState(); + const stale = this.isRegistrationStale(state); + // 临时诊断:这几个值就是下面全部分流的依据,卡住时只看这一行就够。 + console.warn( + `[guard] state=${state} foregrounded=${isAppForegrounded()} stale=${stale} wantInterval=${intervalMs}`, + ); if (!this.isCurrentGeneration(generation)) return; - if (state === 'foreground') { - this.running = true; - return; - } if (state === 'unknown') return; - if (state === 'degraded') { + if (state !== 'absent') { + if (!stale) { + this.running = true; + return; + } // 后台补不回来:带 foregroundService 的注册在后台会被原生直接拒掉,这时候 // 硬 stop 只会把仅剩的定位任务也弄没,比维持现状更糟。等回到前台的那次 // reconcile 再修(位置心跳每 15s~5min 就会触发一次 reconcile)。 @@ -171,9 +200,21 @@ export class ReminderGuardCoordinator { try { await Location.stopLocationUpdatesAsync(GUARD_TASK_NAME); } catch (error) { - console.warn('[guard] failed to clear the degraded registration', error); + console.warn('[guard] failed to clear the stale registration', error); return; } + // stopLocationUpdatesAsync() 只解绑位置更新,TaskManager 里那条任务注册还留着 + // ——跟 ExpoLocationMonitor 清理老围栏时遇到的是同一件事。真机实测:进程被杀 + // 之后光 stop 再 start,注册看着建上了(hasStarted=true、options 带着 + // foregroundService、间隔也对),却再也不投递一次样本,只有卸载重装才能恢复。 + // 这里补一次真正的注销,把持久化记录也抹掉,等价于重装那一下。 + try { + if (await TaskManager.isTaskRegisteredAsync(GUARD_TASK_NAME)) { + await TaskManager.unregisterTaskAsync(GUARD_TASK_NAME); + } + } catch (error) { + console.warn('[guard] failed to unregister the stale task', error); + } if (!this.isCurrentGeneration(generation)) return; this.running = false; } @@ -218,13 +259,35 @@ export class ReminderGuardCoordinator { return; } if (!this.isCurrentGeneration(generation)) return; + // 临时诊断:这行打出来才代表注册真的建上了;之后多久没有 dispatching sample + // 就能直接跟 interval 对照,区分"间隔太疏"和"根本不投递"。 + console.warn(`[guard] registered interval=${intervalMs}`); this.running = true; this.currentIntervalMs = intervalMs; + this.ownsRegistration = true; + // 自己刚建起来的注册,在第一次心跳到来之前也算"确认过还活着",否则紧接着 + // 的那次 reconcile(比如同时又新增了一条日程)会当它是陈旧的再拆一遍。 + this.lastProgressAt = Date.now(); } catch (error) { console.warn('[guard] startLocationUpdatesAsync failed', error); } } + /** + * 注册声称在跑,但它真的还在投递吗?两个判据缺一不可: + * + * 1. 本进程自己建过它没有。继承来的注册在冷启动瞬间还会投出一两次心跳(上一份 + * 注册的余波,真机上量到过两条紧挨着的样本),之后就彻底停摆——所以不能拿 + * "刚收到心跳"当它还活着的证据,只要不是自己建的就一律重建。 + * 2. 自己建的那份,最近还在不在投递。这条管的是会话中途悄悄断掉的情况。 + */ + private isRegistrationStale(state: GuardRegistrationState): boolean { + if (state === 'absent' || state === 'unknown') return false; + if (!this.ownsRegistration) return true; + if (this.lastProgressAt == null) return true; + return Date.now() - this.lastProgressAt > REGISTRATION_STALE_AFTER_MS; + } + /** * 判定原生注册处于 GuardRegistrationState 的哪一种。关键在第二步:光问 * hasStartedLocationUpdatesAsync() 只知道"注册着",得再把注册项自己的 options diff --git a/frontend/src/infrastructure/location/reminderGuardTask.ts b/frontend/src/infrastructure/location/reminderGuardTask.ts index eecba6a2..51822463 100644 --- a/frontend/src/infrastructure/location/reminderGuardTask.ts +++ b/frontend/src/infrastructure/location/reminderGuardTask.ts @@ -717,6 +717,10 @@ async function refreshGuardRegistration( distanceInterval: 0, ...(foregroundService == null ? {} : { foregroundService }), }); + // 临时诊断:任务自己的重注册才是最终生效的那次,间隔以这行为准。 + console.warn( + `[guard] refreshed interval=${intervalMs} withService=${foregroundService != null}`, + ); } catch (error) { console.warn('[guard] refresh startLocationUpdatesAsync failed', error); } diff --git a/frontend/tests/unit/features/reminder/application/ReminderGuardCoordinator.test.ts b/frontend/tests/unit/features/reminder/application/ReminderGuardCoordinator.test.ts index 44ee2359..6b9be5f1 100644 --- a/frontend/tests/unit/features/reminder/application/ReminderGuardCoordinator.test.ts +++ b/frontend/tests/unit/features/reminder/application/ReminderGuardCoordinator.test.ts @@ -26,6 +26,8 @@ jest.mock('expo-task-manager', () => ({ isTaskDefined: jest.fn(() => true), defineTask: jest.fn(), getRegisteredTasksAsync: jest.fn(), + isTaskRegisteredAsync: jest.fn(), + unregisterTaskAsync: jest.fn(), })); jest.mock('../../../../../src/infrastructure/location/reminderGuardTask', () => { @@ -60,6 +62,12 @@ const subscribeTaskEvents = subscribeGuardTaskEvents as jest.MockedFunction< const getRegisteredTasks = TaskManager.getRegisteredTasksAsync as jest.MockedFunction< typeof TaskManager.getRegisteredTasksAsync >; +const isTaskRegistered = TaskManager.isTaskRegisteredAsync as jest.MockedFunction< + typeof TaskManager.isTaskRegisteredAsync +>; +const unregisterTask = TaskManager.unregisterTaskAsync as jest.MockedFunction< + typeof TaskManager.unregisterTaskAsync +>; const foregrounded = isAppForegrounded as jest.MockedFunction; /** @@ -176,6 +184,8 @@ describe('ReminderGuardCoordinator', () => { getForeground.mockResolvedValue(granted()); startUpdates.mockResolvedValue(undefined); stopUpdates.mockResolvedValue(undefined); + isTaskRegistered.mockResolvedValue(true); + unregisterTask.mockResolvedValue(undefined); // 默认"还没启动"——ensureLocationUpdates 现在也会查这个真实状态来判断 // 要不要重新调 startLocationUpdatesAsync,跟 stopLocationUpdates 共用同一个 // mock;哪个测试要验证"已经在跑"分支(比如停止逻辑),在 start() 成功之后 @@ -407,7 +417,10 @@ describe('ReminderGuardCoordinator', () => { expect(options?.timeInterval).toBe(300_000); }); - it('does not re-register when the running registration still carries the foreground service', async () => { + it('rebuilds a registration inherited from a previous process even when it carries the foreground service', async () => { + // force-stop 杀不掉 expo-task-manager 持久化的注册记录:冷启动读到的 + // 'foreground' 可能是上一个进程留下的,注册还在、投递已经随进程一起死了。 + // 改之前协调器信了它直接早退,守护就此永久停摆,只有重装能恢复。 hasStarted.mockResolvedValue(true); getRegisteredTasks.mockResolvedValue([registeredTask(true)]); const reader = createReader([timeSchedule()]); @@ -418,8 +431,115 @@ describe('ReminderGuardCoordinator', () => { await coordinator.start(); - expect(startUpdates).not.toHaveBeenCalled(); + expect(stopUpdates).toHaveBeenCalledWith(GUARD_TASK_NAME); + expect(startUpdates).toHaveBeenCalledTimes(1); + }); + + it('still rebuilds an inherited registration when its dying heartbeats land first', async () => { + // 真机实测:force-stop 之后重开,继承来的注册会先投出一两次心跳(上一份注册 + // 的余波,两条样本紧挨着 13ms),之后彻底停摆。只按"最近有没有心跳"判断的话 + // 会被这两下骗过去,当它还活着而不去重建,守护就此又卡死。 + let sampleListener: + | ((sample: { + latitude: number; + longitude: number; + accuracy_meters: number; + observed_at: string; + }) => void) + | undefined; + subscribeTaskEvents.mockImplementation((listener) => { + sampleListener = listener as typeof sampleListener; + return () => {}; + }); + hasStarted.mockResolvedValue(true); + getRegisteredTasks.mockResolvedValue([registeredTask(true)]); + const reader = createReader([timeSchedule()]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + + await sampleListener?.({ + latitude: 31.2304, + longitude: 121.4737, + accuracy_meters: 10, + observed_at: '2026-08-18T10:00:00.000Z', + }); + await coordinator.start(); + + expect(stopUpdates).toHaveBeenCalledWith(GUARD_TASK_NAME); + expect(startUpdates).toHaveBeenCalledTimes(1); + }); + + it('unregisters the task itself before rebuilding, not just the location updates', async () => { + // 真机实测:进程被杀之后光 stopLocationUpdatesAsync 再 start,注册看着建上了 + // (hasStarted=true、options 带着 foregroundService、间隔也对)却一次样本都不 + // 投递,只有卸载重装能恢复——因为 TaskManager 里那条持久化的任务注册没被清掉。 + hasStarted.mockResolvedValue(true); + getRegisteredTasks.mockResolvedValue([registeredTask(true)]); + const reader = createReader([timeSchedule()]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + + await coordinator.start(); + + expect(unregisterTask).toHaveBeenCalledWith(GUARD_TASK_NAME); + expect(startUpdates).toHaveBeenCalledTimes(1); + }); + + it('rebuilds even when unregistering the stale task fails', async () => { + hasStarted.mockResolvedValue(true); + getRegisteredTasks.mockResolvedValue([registeredTask(true)]); + unregisterTask.mockRejectedValue(new Error('boom')); + const reader = createReader([timeSchedule()]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + + await coordinator.start(); + + expect(startUpdates).toHaveBeenCalledTimes(1); + }); + + it('leaves the registration alone once a heartbeat has confirmed it is delivering', async () => { + // 心跳还在流就说明注册真的在投递,这时候 options 里带不带 foregroundService + // 都不该去动它——后台唤醒重注册必然丢掉那个字段,据此"修复"就是对着一个 + // 健康的服务做一次多余的 stop+start。 + let sampleListener: + | ((sample: { + latitude: number; + longitude: number; + accuracy_meters: number; + observed_at: string; + }) => void) + | undefined; + subscribeTaskEvents.mockImplementation((listener) => { + sampleListener = listener as typeof sampleListener; + return () => {}; + }); + hasStarted.mockResolvedValue(true); + getRegisteredTasks.mockResolvedValue([registeredTask(false)]); + const reader = createReader([timeSchedule()]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + await coordinator.start(); + stopUpdates.mockClear(); + startUpdates.mockClear(); + + await sampleListener?.({ + latitude: 31.2304, + longitude: 121.4737, + accuracy_meters: 10, + observed_at: '2026-08-18T10:00:00.000Z', + }); + expect(stopUpdates).not.toHaveBeenCalled(); + expect(startUpdates).not.toHaveBeenCalled(); }); it('repairs a registration that lost its foreground service', async () => { From 92f73c936e5104a4a9df146bcd82d26a44e56741 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Sat, 29 Aug 2026 00:00:42 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(reminder):=20=E8=AE=A9=20expo-task-mana?= =?UTF-8?q?ger=20=E4=BB=8E=E6=9C=AC=E5=9C=B0=E6=BA=90=E7=A0=81=E7=BC=96?= =?UTF-8?q?=E8=AF=91=EF=BC=8Cbackport=20=E4=B8=8A=E6=B8=B8=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=89=8D=E8=83=BD=E7=9C=9F=E6=AD=A3=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit expo-task-manager(及其依赖 unimodules-app-loader)默认从 Maven 拉官方 预编译二进制,不读 node_modules 本地源码——导致之前 patch-package 打的 expo/expo#47958 backport 从未被真正编译进任何 APK,之前几轮"打了 patch 还是不行"的观测因此都不算数。 在 package.json 加 expo.autolinking.buildFromSource,逼这两个模块走本地 源码编译,backport 才第一次真正生效。真机验证:强杀重开后出圈再回圈, [reminder] TRIGGERED 正常触发,原生响铃正常展示。 顺带把地点提醒触发半径从 400m 调到 200m。 Co-Authored-By: Claude Sonnet 5 --- frontend/package.json | 5 ++ .../patches/expo-task-manager+57.0.9.patch | 70 +++++++++++++++++++ .../src/features/reminder/domain/geofence.ts | 2 +- 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/frontend/package.json b/frontend/package.json index 4acdde90..bc658f7f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,11 @@ "npm": ">=10.8.2 <11" }, "packageManager": "npm@10.8.2", + "expo": { + "autolinking": { + "buildFromSource": ["expo-task-manager", "unimodules-app-loader"] + } + }, "dependencies": { "@expo/metro-runtime": "~57.0.8", "@irvingouj/expo-audio-stream": "3.1.0", diff --git a/frontend/patches/expo-task-manager+57.0.9.patch b/frontend/patches/expo-task-manager+57.0.9.patch index adf7f0e8..4dff7e98 100644 --- a/frontend/patches/expo-task-manager+57.0.9.patch +++ b/frontend/patches/expo-task-manager+57.0.9.patch @@ -1,3 +1,73 @@ +diff --git a/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskManagerInternalModule.java b/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskManagerInternalModule.java +index 1157a8d..f209272 100644 +--- a/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskManagerInternalModule.java ++++ b/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskManagerInternalModule.java +@@ -30,6 +30,7 @@ public class TaskManagerInternalModule implements InternalModule, TaskManagerInt + + public TaskManagerInternalModule(Context context) { + mContextRef = new WeakReference<>(context); ++ Log.i("TimeflowDiag", "TaskManagerInternalModule created id=" + System.identityHashCode(this)); + } + + //region InternalModule +@@ -84,8 +85,11 @@ public class TaskManagerInternalModule implements InternalModule, TaskManagerInt + if (mEventsQueue != null) { + // `startObserving` on TaskManagerModule wasn't called yet - add event body to the queue. + mEventsQueue.add(body); ++ Log.i("TimeflowDiag", "executeTaskWithBody QUEUED id=" + System.identityHashCode(this) ++ + " queueSize=" + mEventsQueue.size()); + } else { + // Manager is already being observed by JS app, so we can execute the event immediately. ++ Log.i("TimeflowDiag", "executeTaskWithBody EMIT id=" + System.identityHashCode(this)); + emitEvent(body); + } + } +@@ -101,6 +105,9 @@ public class TaskManagerInternalModule implements InternalModule, TaskManagerInt + @Override + public synchronized void flushQueuedEvents() { + // Execute any events that came before this call. ++ Log.i("TimeflowDiag", "flushQueuedEvents called id=" + System.identityHashCode(this) ++ + " queueWasNull=" + (mEventsQueue == null) ++ + " queueSize=" + (mEventsQueue == null ? -1 : mEventsQueue.size())); + if (mEventsQueue != null) { + for (Bundle body : mEventsQueue) { + emitEvent(body); +@@ -188,8 +195,10 @@ public class TaskManagerInternalModule implements InternalModule, TaskManagerInt + + private void emitEvent(Bundle body) { + if (mEmitEventWrapper != null) { ++ Log.i("TimeflowDiag", "emitEvent -> mEmitEventWrapper.emit() id=" + System.identityHashCode(this)); + mEmitEventWrapper.emit(TaskManagerInterface.EVENT_NAME, body); + } else { ++ Log.e("TimeflowDiag", "emitEvent SKIPPED: EmitEventWrapper is null, id=" + System.identityHashCode(this)); + Log.e("ExpoTaskManager", "EmitEventWrapper is not set. Failed to emit the TaskManager Event."); + } + } +diff --git a/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskManagerModule.kt b/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskManagerModule.kt +index fc42f02..58b6d06 100644 +--- a/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskManagerModule.kt ++++ b/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskManagerModule.kt +@@ -81,14 +81,20 @@ class TaskManagerModule : Module() { + } + + OnStartObserving { ++ Log.i("TimeflowDiag", "OnStartObserving fired, internal id=" + System.identityHashCode(taskManagerInternal)) + val handler = Handler(Looper.getMainLooper()) + handler.postDelayed( + { ++ Log.i("TimeflowDiag", "OnStartObserving delayed flush running, internal id=" + System.identityHashCode(taskManagerInternal)) + taskManagerInternal?.flushQueuedEvents() + }, + 1000 + ) + } ++ ++ OnStopObserving { ++ Log.i("TimeflowDiag", "OnStopObserving fired, internal id=" + System.identityHashCode(taskManagerInternal)) ++ } + } + + private val appScopeKey: String diff --git a/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskService.java b/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskService.java index de829ca..8416444 100644 --- a/node_modules/expo-task-manager/android/src/main/java/expo/modules/taskManager/TaskService.java diff --git a/frontend/src/features/reminder/domain/geofence.ts b/frontend/src/features/reminder/domain/geofence.ts index 654a0867..083c42da 100644 --- a/frontend/src/features/reminder/domain/geofence.ts +++ b/frontend/src/features/reminder/domain/geofence.ts @@ -68,7 +68,7 @@ function toRadians(degrees: number): number { /** local_schedules 表没有单独的半径列,地点提醒目前全局统一用这个值——改这里 * 就是改全部地点提醒的实际触发半径,跟下面轮询密度用的门槛是同一个数字。 */ -export const DEFAULT_GEOFENCE_RADIUS_METERS = 400; +export const DEFAULT_GEOFENCE_RADIUS_METERS = 200; /** 离围栏边界(不是中心点)≤ 此距离时,按最密的轮询间隔查。门槛直接等于围栏 * 半径本身:沿着半径这段路程加密轮询,正好在真正跨过边界前进入最密档。之前 From ff2d5bcfda8b2cf651bb26a3438f375e923e8aa5 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Sat, 29 Aug 2026 00:16:33 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(reminder):=20=E4=BF=AE=E5=A4=8D=20CI=20?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E6=A3=80=E6=9F=A5=20+=20fennoai=20=E5=AE=A1?= =?UTF-8?q?=E9=98=85=E6=8C=87=E5=87=BA=E7=9A=84=E4=B8=A4=E5=A4=84=E5=A4=B1?= =?UTF-8?q?=E6=B4=BB=E6=81=A2=E5=A4=8D=E6=BC=8F=E6=B4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - package.json:补 prettier 格式,修 CI 的 format:check 失败 - 注销失败(unregisterTaskAsync 抛错)时不再继续 startLocationUpdatesAsync(): 持久化记录没被真正删掉的话,重注册走的还是原生"已存在就 setOptions"分支, 等于又绑上同一条失活记录,下次 reconcile 会把它当健康注册直接早退,恢复 彻底失败且无声无息。现在中止本次重建,把重试留给下一次 reconcile。 - 新增独立于心跳事件的 watchdog 定时器:isRegistrationStale() 要判的恰恰是 "心跳已经不再来了",此前只在 start()/日程订阅/handleSample() 触发的 reconcile 里查——一旦心跳静默失活,这三个触发源全指望不上,会一直卡到 用户手动改日程或重启 App。定时器本身在 Node 测试环境下 unref(),不影响 真机运行也不会拖 Jest 进程退出。 Co-Authored-By: Claude Sonnet 5 --- frontend/package.json | 5 +- .../application/ReminderGuardCoordinator.ts | 33 +++++++++- .../ReminderGuardCoordinator.test.ts | 60 ++++++++++++++++++- 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index bc658f7f..28f5e633 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,10 @@ "packageManager": "npm@10.8.2", "expo": { "autolinking": { - "buildFromSource": ["expo-task-manager", "unimodules-app-loader"] + "buildFromSource": [ + "expo-task-manager", + "unimodules-app-loader" + ] } }, "dependencies": { diff --git a/frontend/src/features/reminder/application/ReminderGuardCoordinator.ts b/frontend/src/features/reminder/application/ReminderGuardCoordinator.ts index 50f04c03..5eab7607 100644 --- a/frontend/src/features/reminder/application/ReminderGuardCoordinator.ts +++ b/frontend/src/features/reminder/application/ReminderGuardCoordinator.ts @@ -23,6 +23,16 @@ const LOCATION_STOP_TIMEOUT_MS = 2_000; */ const REGISTRATION_STALE_AFTER_MS = 600_000; +/** + * 独立于心跳事件的兜底重试节奏。isRegistrationStale() 要判的恰恰是"心跳已经 + * 不再来了"这件事——如果只在 handleSample()/日程变化触发的 reconcile 里查, + * 那么注册一旦在后台悄悄失活(收到过一次心跳、之后再没有),就再也没有任何 + * 代码会主动重新检查,会一直卡到用户手动改日程或重启 App。这里独立定时唤醒 + * 一次 reconcile,跟心跳来不来无关。取跟 REGISTRATION_STALE_AFTER_MS 同一个 + * 最疏轮询间隔,10 分钟的陈旧窗口内能查两次,发现得不算晚。 + */ +const WATCHDOG_INTERVAL_MS = 300_000; + /** * 原生注册其实有三种状态,而 hasStartedLocationUpdatesAsync() 只能回答把后两种 * 合并之后的那个布尔值("注册着吗"): @@ -68,6 +78,8 @@ export class ReminderGuardCoordinator { /** 上一次收到位置心跳的时刻,用来发现会话中途悄悄断掉的投递。 */ private lastProgressAt: number | null = null; private reconcileChain: Promise = Promise.resolve(); + /** 独立于心跳事件的兜底定时器,见 WATCHDOG_INTERVAL_MS 的说明。 */ + private watchdogTimer: ReturnType | null = null; constructor(private readonly dependencies: ReminderGuardDependencies) {} @@ -81,6 +93,15 @@ export class ReminderGuardCoordinator { this.unsubscribeSchedules = this.dependencies.schedules.subscribe(() => { void this.reconcile(); }); + this.watchdogTimer = setInterval(() => { + void this.reconcile(); + }, WATCHDOG_INTERVAL_MS); + // Node 测试环境下的定时器带 unref(),不调用它 Jest 进程退不出去;React + // Native 运行时的 setInterval 返回值没有这个方法,特性检测一下就是安全的 + // 空操作——两边都不影响真正的定时逻辑,只影响"这个定时器算不算 keep-alive + // 句柄"这一件事。 + const maybeUnref = this.watchdogTimer as unknown as { unref?: () => void }; + maybeUnref.unref?.(); await this.reconcile(); } @@ -93,6 +114,10 @@ export class ReminderGuardCoordinator { this.unsubscribeGuardTask = null; this.unsubscribeSchedules?.(); this.unsubscribeSchedules = null; + if (this.watchdogTimer != null) { + clearInterval(this.watchdogTimer); + this.watchdogTimer = null; + } await this.stopLocationUpdates(); } @@ -207,13 +232,19 @@ export class ReminderGuardCoordinator { // ——跟 ExpoLocationMonitor 清理老围栏时遇到的是同一件事。真机实测:进程被杀 // 之后光 stop 再 start,注册看着建上了(hasStarted=true、options 带着 // foregroundService、间隔也对),却再也不投递一次样本,只有卸载重装才能恢复。 - // 这里补一次真正的注销,把持久化记录也抹掉,等价于重装那一下。 + // 这里补一次真正的注销,把持久化记录也抹掉,等价于重装那一下。注销失败就 + // 中止本次重建、保留现状——不能继续往下 startLocationUpdatesAsync(): + // 持久化记录没删掉,走的还是那条"已存在就 setOptions"的原生分支,等于 + // 重新绑上同一条失活的记录,registerTasks 又会把它当健康注册提前返回, + // 恢复彻底失败且无声无息。留在原状态,等下一次 reconcile(心跳或 + // watchdog 定时器)重试。 try { if (await TaskManager.isTaskRegisteredAsync(GUARD_TASK_NAME)) { await TaskManager.unregisterTaskAsync(GUARD_TASK_NAME); } } catch (error) { console.warn('[guard] failed to unregister the stale task', error); + return; } if (!this.isCurrentGeneration(generation)) return; this.running = false; diff --git a/frontend/tests/unit/features/reminder/application/ReminderGuardCoordinator.test.ts b/frontend/tests/unit/features/reminder/application/ReminderGuardCoordinator.test.ts index 6b9be5f1..e797faf7 100644 --- a/frontend/tests/unit/features/reminder/application/ReminderGuardCoordinator.test.ts +++ b/frontend/tests/unit/features/reminder/application/ReminderGuardCoordinator.test.ts @@ -489,7 +489,12 @@ describe('ReminderGuardCoordinator', () => { expect(startUpdates).toHaveBeenCalledTimes(1); }); - it('rebuilds even when unregistering the stale task fails', async () => { + it('aborts the rebuild when unregistering the stale task fails, instead of re-registering anyway', async () => { + // 持久化记录没被真正删掉的话,重注册走的还是原生"已存在就 setOptions"那条 + // 分支——等于又绑上了同一条失活的记录,下次 reconcile 读到的 state 还是 + // 'foreground',会被当成健康注册直接早退,恢复彻底失败且没有任何痕迹。 + // 所以注销失败必须整段放弃,把重试留给下一次 reconcile,而不是硬着头皮 + // 继续 startLocationUpdatesAsync()。 hasStarted.mockResolvedValue(true); getRegisteredTasks.mockResolvedValue([registeredTask(true)]); unregisterTask.mockRejectedValue(new Error('boom')); @@ -501,7 +506,58 @@ describe('ReminderGuardCoordinator', () => { await coordinator.start(); - expect(startUpdates).toHaveBeenCalledTimes(1); + expect(startUpdates).not.toHaveBeenCalled(); + }); + + it('reconciles on an independent watchdog timer even when no sample or schedule change arrives', async () => { + // 心跳静默失活时,三个触发源(start() 只跑一次、日程订阅要等用户凑巧去改、 + // handleSample() 恰恰是已经停了的那个)全都指望不上。watchdog 定时器必须 + // 独立于这三者,自己把 reconcile 叫起来,才能发现"注册看着健在、其实早就 + // 不投递了"这种状态。 + hasStarted.mockResolvedValue(true); + getRegisteredTasks.mockResolvedValue([registeredTask(true)]); + const reader = createReader([timeSchedule()]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + + jest.useFakeTimers(); + try { + await coordinator.start(); + getRegisteredTasks.mockClear(); + + jest.advanceTimersByTime(300_000); + await flushMicrotasks(); + + expect(getRegisteredTasks).toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + + it('stops the watchdog timer on stop() so it does not keep reconciling after teardown', async () => { + hasStarted.mockResolvedValue(true); + getRegisteredTasks.mockResolvedValue([registeredTask(true)]); + const reader = createReader([timeSchedule()]); + const coordinator = new ReminderGuardCoordinator({ + schedules: reader, + handleLocation: jest.fn(async () => {}), + }); + + jest.useFakeTimers(); + try { + await coordinator.start(); + await coordinator.stop(); + getRegisteredTasks.mockClear(); + + jest.advanceTimersByTime(600_000); + await flushMicrotasks(); + + expect(getRegisteredTasks).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } }); it('leaves the registration alone once a heartbeat has confirmed it is delivering', async () => { From ff6f5be5f02806b76c4b639dba5ecf2e12e92f77 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Sat, 29 Aug 2026 00:26:26 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test:=20=E9=9B=86=E6=88=90=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=94=B9=E7=94=A8=20DEFAULT=5FGEOFENCE=5FRADIUS=5FMET?= =?UTF-8?q?ERS=20=E5=B8=B8=E9=87=8F=EF=BC=8C=E4=B8=8D=E5=86=8D=E7=A1=AC?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E6=97=A7=E7=9A=84=20400?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 半径改成 200 之后漏了这处 vitest 断言,CI 里 npm run test 分两段跑 (vitest + jest),本地只跑过 jest 没发现。改成引用常量,以后半径再变不会 再断。 Co-Authored-By: Claude Sonnet 5 --- frontend/tests/integration/sqliteLocalScheduleReader.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/tests/integration/sqliteLocalScheduleReader.test.ts b/frontend/tests/integration/sqliteLocalScheduleReader.test.ts index d6276fbd..c03b523f 100644 --- a/frontend/tests/integration/sqliteLocalScheduleReader.test.ts +++ b/frontend/tests/integration/sqliteLocalScheduleReader.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite import { ScheduleLocalRepository, type CloudScheduleRow } from '../../src/features/schedule/data'; import { SqliteLocalScheduleReader } from '../../src/features/reminder/data/local/SqliteLocalScheduleReader'; +import { DEFAULT_GEOFENCE_RADIUS_METERS } from '../../src/features/reminder/domain/geofence'; import { migrateScheduleDatabase } from '../../src/infrastructure/database/migrations'; import { SqlJsExpoDatabase } from '../helpers/sqliteTestDatabase'; @@ -70,7 +71,7 @@ describe('SqliteLocalScheduleReader', () => { id: 'schedule-a', account_id: 'account-a', title: 'Original title', - geofence_radius_meters: 400, + geofence_radius_meters: DEFAULT_GEOFENCE_RADIUS_METERS, reminder: { reminder_type: 'before_start', reminder_offset_minutes: 15,