Skip to content

Commit 8af7024

Browse files
authored
fix(service): 安装期 FGS 崩溃三重根因——停启交叉、锚定缺口、prefs 风暴 (#27)
* fix(service): 安装期 FGS 崩溃三重根因——停启交叉、锚定缺口、prefs 风暴 crash-2026-10-15_01-00-01(ForegroundServiceDidNotStartInTimeException)的 完整因果链,三层独立修复: 1. 停启交叉(主根因):每条沙箱命令都走 setActive → startForegroundService() → 命令 → setInactive → stopService()。 探针对已装组件 ~7ms 完成,stopService() 与下一条命令的 startForegroundService() 在 ActivityManager 处交叉;平台把 start 排在 teardown 之后,对尚未创建的服务实例武装 5-10s 前台期限——期限在 onCreate 之前到期,进程被杀。 修复:ServiceStopLinger 把 stop 延迟 10s 静默窗;setActive/setPresent 取消挂起停机,setInactive/setAbsent/clearPresence 调度。快速命令序列 (dev-suite 装配、聊天工具链、探针级联)保持单一服务实例,不再每条 命令 churn 一次 start/stop;真空闲后照常停机,无电池回归。 2. 锚定缺口(不变量):onStartCommand 的刷新管线对相同状态跳过发布、 applyForeground 的 notify() 分支不再锚定——两者对通知更新是对的,但 都让平台的前台期限对"自认为已锚定"的服务保持武装。 修复:每次 onStartCommand 分发无条件 applyForeground(reanchor=true), 先于任何 stopSelf() 返回路径(stopSelf 不撤回待定期限,见 applyForeground 的 catch 注释)。 3. prefs 风暴(放大器):DevSuiteStore.update 对每条 apt/pip 输出行做 全量 XML SharedPreferences 重写;apply() 排队的写盘可被 QueuedWork.waitToFinish() 强制主线程排干——安装期主线程多秒级停顿, 推迟 createService 分发越过期限。 修复:仅状态跃迁写盘(shouldPersistComponentState 纯函数);进度行 只走内存 StateFlow。崩溃恢复只需状态标记,不受影响。 测试:ServiceStopLingerTest(虚拟时间:窗口精确触发/取消/重启/单次)、 SessionActivityTrackerLingerTest(接线:空调度、激活取消、presence 取消、 多会话不调度)、DevSuiteStoreThrottleTest(同状态不写盘、跃迁写盘、 编解码 round-trip、损坏行降级不抛)。 * fix(test): Status 是 DevSuiteStore 嵌套枚举——补 import * fix(test): advanceTimeBy 不执行恰好落在边界的任务——补 runCurrent --------- Co-authored-by: tall-1997 <278631348+tall-1997@users.noreply.github.com>
1 parent 656fee8 commit 8af7024

7 files changed

Lines changed: 448 additions & 22 deletions

File tree

‎src/android/app/src/main/java/com/openminis/app/plugins/DevSuiteStore.kt‎

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -39,31 +39,37 @@ object DevSuiteStore {
3939
val p = prefs(context)
4040
val out = mutableMapOf<String, ComponentState>()
4141
for (id in p.all.keys) {
42-
val raw = p.getString(id, null) ?: continue
43-
val parts = raw.split('\u0001', limit = 3)
44-
val status = when (parts.getOrNull(0)) {
45-
"INSTALLING" -> Status.INSTALLING
46-
"INSTALLED" -> Status.INSTALLED
47-
"FAILED" -> Status.FAILED
48-
else -> Status.NOT_INSTALLED
49-
}
50-
out[id] = ComponentState(
51-
componentId = id,
52-
status = status,
53-
progressLine = parts.getOrNull(1).orEmpty(),
54-
updatedAtMs = parts.getOrNull(2)?.toLongOrNull() ?: 0L,
55-
)
42+
out[id] = decodeComponentState(id, p.getString(id, null)) ?: continue
5643
}
5744
_states.value = out
5845
return out
5946
}
6047

6148
fun update(context: Context, componentId: String, status: Status, progressLine: String = "") {
62-
val p = prefs(context)
63-
p.edit()
64-
.putString(componentId, "$status\u0001$progressLine\u0001${System.currentTimeMillis()}")
65-
.apply()
66-
snapshot(context)
49+
val previous = _states.value[componentId]
50+
val next = ComponentState(
51+
componentId = componentId,
52+
status = status,
53+
progressLine = progressLine,
54+
updatedAtMs = System.currentTimeMillis(),
55+
)
56+
// [T-dev-suite-prefs-storm] Persist ONLY status transitions. The
57+
// installer feeds every apt/pip output line through here — hundreds
58+
// of full-XML SharedPreferences rewrites per install, and each
59+
// apply() queues a disk write that QueuedWork.waitToFinish() can
60+
// later force the MAIN thread to drain (observed as a multi-second
61+
// main-thread stall during installs that deferred the foreground
62+
// service's createService dispatch past its deadline — the
63+
// install-crash amplifier). Progress lines are UI-only state: they
64+
// flow through the in-memory StateFlow and never touch disk. Crash
65+
// recovery (recoverInterrupted) only needs the status marker, and
66+
// it overwrites the line anyway.
67+
if (shouldPersistComponentState(previous?.status, status)) {
68+
prefs(context).edit()
69+
.putString(componentId, encodeComponentState(status, progressLine, next.updatedAtMs))
70+
.apply()
71+
}
72+
_states.value = _states.value + (componentId to next)
6773
}
6874

6975
/**
@@ -80,4 +86,35 @@ object DevSuiteStore {
8086
interrupted.forEach { update(context, it, Status.FAILED, "安装被中断") }
8187
return interrupted
8288
}
89+
90+
/**
91+
* [T-dev-suite-prefs-storm] True when this update must hit disk: ONLY
92+
* status transitions. Progress-line refreshes (the installer feeds every
93+
* apt/pip output line through [update]) stay in memory — see the block
94+
* comment in [update]. Pure so the throttle contract is unit-testable.
95+
*/
96+
internal fun shouldPersistComponentState(previous: Status?, next: Status): Boolean =
97+
previous != next
98+
99+
/** Encodes one component marker for SharedPreferences. Pure (testable). */
100+
internal fun encodeComponentState(status: Status, progressLine: String, updatedAtMs: Long): String =
101+
"$status\u0001$progressLine\u0001$updatedAtMs"
102+
103+
/** Decodes one component marker; null for absent/corrupt rows. Pure (testable). */
104+
internal fun decodeComponentState(componentId: String, raw: String?): ComponentState? {
105+
if (raw == null) return null
106+
val parts = raw.split('\u0001', limit = 3)
107+
val status = when (parts.getOrNull(0)) {
108+
"INSTALLING" -> Status.INSTALLING
109+
"INSTALLED" -> Status.INSTALLED
110+
"FAILED" -> Status.FAILED
111+
else -> Status.NOT_INSTALLED
112+
}
113+
return ComponentState(
114+
componentId = componentId,
115+
status = status,
116+
progressLine = parts.getOrNull(1).orEmpty(),
117+
updatedAtMs = parts.getOrNull(2)?.toLongOrNull() ?: 0L,
118+
)
119+
}
83120
}

‎src/android/app/src/main/java/com/openminis/app/service/AgentForegroundService.kt‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,27 @@ class AgentForegroundService : Service() {
239239
"processAliveMs=${android.os.SystemClock.elapsedRealtime() - processStartElapsedMs} " +
240240
"slots=${SessionConcurrencyManager.diagSnapshot()}",
241241
)
242+
// [T-fgs-deadline-invariant] Every Context.startForegroundService()
243+
// delivery must be answered by Service.startForeground() in THIS
244+
// dispatch — unconditionally. The refresh pipeline below deliberately
245+
// skips no-op publishes (identical state) and applyForeground's
246+
// notify()-only branch (non-forked SystemUI) never re-anchors; both
247+
// are fine for notification churn but leave the platform's 5-10s
248+
// foreground deadline armed against a service that already considers
249+
// itself anchored (crash-2026-10-15_01-00-01 class). Re-anchoring
250+
// with the CURRENT state notification is a cheap idempotent no-op for
251+
// the platform, and it must run BEFORE any stopSelf() return path —
252+
// stopSelf() tears the component down but does NOT retract the
253+
// pending foreground requirement (see applyForeground's catch).
254+
if (bootstrapPosted) {
255+
val anchor = try {
256+
buildNotification(notificationState())
257+
} catch (t: Throwable) {
258+
Log.w(TAG, "anchor build failed, using stub: ${t.message}")
259+
stubForegroundNotification()
260+
}
261+
applyForeground(anchor, reanchor = true)
262+
}
242263
// onCreate already satisfied the foreground deadline with a plain stub.
243264
// If even the stub failed, no background service should remain alive.
244265
if (!bootstrapPosted || com.openminis.app.crash.CrashFrequencyDetector.isSafeMode()) {
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package com.openminis.app.service
2+
3+
import kotlinx.coroutines.CoroutineScope
4+
import kotlinx.coroutines.Job
5+
import kotlinx.coroutines.delay
6+
import kotlinx.coroutines.launch
7+
8+
/**
9+
* [T-fgs-stop-linger] Defers a foreground-service stop by [lingerMs].
10+
*
11+
* Root cause this closes (install-time ForegroundServiceDidNotStartInTimeException,
12+
* crash-2026-10-15_01-00-01): every sandbox command (probe / apt / post-install
13+
* step) drove the tracker through setActive → startForegroundService() → command →
14+
* setInactive → stopService(). A probe against an already-installed component
15+
* finishes in ~7ms, so the stopService() binder call and the NEXT command's
16+
* startForegroundService() cross in flight at ActivityManager. The platform
17+
* defers the start behind the teardown and arms the 5-10s foreground deadline
18+
* against a service instance that has not been created yet — the deadline fires
19+
* before onCreate ever runs, and the process is killed.
20+
*
21+
* Lingering the stop closes the crossing for every rapid command sequence
22+
* (dev-suite assemblies, chat tool-call chains, probe cascades): the next
23+
* setActive/setPresent lands while the service is still alive, cancels the
24+
* pending stop, and the sequence keeps ONE service instance instead of
25+
* churning start/stop per command. The stop still fires after a genuinely
26+
* idle window, so no battery regression beyond [DEFAULT_LINGER_MS] of
27+
* completed-state notification.
28+
*
29+
* Pure coroutine class (no Android imports) so the schedule/cancel/restart
30+
* contract is unit-testable with virtual time.
31+
*/
32+
class ServiceStopLinger(
33+
private val scope: CoroutineScope,
34+
private val lingerMs: Long = DEFAULT_LINGER_MS,
35+
private val onStop: () -> Unit,
36+
) {
37+
private var job: Job? = null
38+
39+
/** True while a stop is scheduled but has not fired yet. */
40+
val isPending: Boolean get() = job?.isActive == true
41+
42+
/**
43+
* (Re)schedule the deferred stop. Calling this again restarts the window —
44+
* the linger measures quiet time since the LAST emptiness event, not since
45+
* the first.
46+
*/
47+
fun schedule() {
48+
job?.cancel()
49+
job = scope.launch {
50+
delay(lingerMs)
51+
onStop()
52+
}
53+
}
54+
55+
/** Cancel a pending stop — the service is needed again. */
56+
fun cancel() {
57+
job?.cancel()
58+
job = null
59+
}
60+
61+
companion object {
62+
/**
63+
* Long enough to bridge command-to-command gaps (ExecutionCoordinator
64+
* bind-mount + shell-reuse overhead is sub-second; component-to-component
65+
* transitions in an assembly are adjacent), short enough that the
66+
* completed-state notification clears promptly after a real idle.
67+
*/
68+
const val DEFAULT_LINGER_MS: Long = 10_000L
69+
}
70+
}

‎src/android/app/src/main/java/com/openminis/app/service/SessionActivityTracker.kt‎

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ package com.openminis.app.service
33
import android.content.Context
44
import android.os.SystemClock
55
import android.util.Log
6+
import kotlinx.coroutines.CoroutineScope
7+
import kotlinx.coroutines.Dispatchers
8+
import kotlinx.coroutines.SupervisorJob
69
import kotlinx.coroutines.flow.MutableStateFlow
710
import kotlinx.coroutines.flow.StateFlow
811
import kotlinx.coroutines.flow.asStateFlow
@@ -26,6 +29,21 @@ object SessionActivityTracker {
2629

2730
private const val TAG = "SessionTracker"
2831

32+
// [T-fgs-stop-linger] Owns the deferred service stop. See
33+
// [ServiceStopLinger] for the crossing this closes.
34+
private val trackerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
35+
private val stopLinger = ServiceStopLinger(
36+
scope = trackerScope,
37+
onStop = { stopService() },
38+
)
39+
40+
/**
41+
* Test seam: true while a deferred service stop is scheduled. The stop
42+
* itself is a no-op without an initialized [appContext], so unit tests
43+
* observe the linger wiring through this instead.
44+
*/
45+
val isStopPendingForTest: Boolean get() = stopLinger.isPending
46+
2947
private val _activeSessions = MutableStateFlow<Set<String>>(emptySet())
3048
val activeSessions: StateFlow<Set<String>> = _activeSessions.asStateFlow()
3149

@@ -315,6 +333,10 @@ object SessionActivityTracker {
315333
* action can fan out to every running session.
316334
*/
317335
fun setActive(sessionId: String, onStop: (() -> Unit)? = null) {
336+
// [T-fgs-stop-linger] A new run cancels any deferred stop — the
337+
// service is needed again, and the next startForegroundService()
338+
// must not race the teardown it would have triggered.
339+
stopLinger.cancel()
318340
val wasIdle = !shouldRunService()
319341
// [T-android-live-update-completed] Capture BEFORE mutating: a run
320342
// "begins" only on the empty → non-empty edge. A second concurrent
@@ -393,8 +415,13 @@ object SessionActivityTracker {
393415
}
394416
publishNotificationTimeline()
395417
if (!shouldRunService()) {
396-
stopService()
418+
// [T-fgs-stop-linger] Deferred stop: a rapid follow-up command
419+
// (next probe / apt step in the same assembly) cancels this and
420+
// keeps one service instance, instead of crossing stopService()
421+
// with its own startForegroundService() at ActivityManager.
422+
stopLinger.schedule()
397423
} else {
424+
stopLinger.cancel()
398425
updateService()
399426
}
400427
// T180-bg-notif: fire the completion listener AFTER service state
@@ -425,6 +452,9 @@ object SessionActivityTracker {
425452
*/
426453
fun setPresent(sessionId: String) {
427454
if (sessionId in _presentSessions.value) return
455+
// [T-fgs-stop-linger] Presence re-holds the service the same way a
456+
// new active session does — cancel any deferred stop.
457+
stopLinger.cancel()
428458
val wasIdle = !shouldRunService()
429459
_presentSessions.value = _presentSessions.value + sessionId
430460
Log.d(TAG, "Presence set: $sessionId (present total: ${_presentSessions.value.size})")
@@ -446,8 +476,10 @@ object SessionActivityTracker {
446476
_presentSessions.value = _presentSessions.value - sessionId
447477
Log.d(TAG, "Presence cleared: $sessionId (present total: ${_presentSessions.value.size})")
448478
if (!shouldRunService()) {
449-
stopService()
479+
// [T-fgs-stop-linger] see setInactive — deferred, cancellable.
480+
stopLinger.schedule()
450481
} else {
482+
stopLinger.cancel()
451483
updateService()
452484
}
453485
}
@@ -464,8 +496,10 @@ object SessionActivityTracker {
464496
_presentSessions.value = emptySet()
465497
Log.d(TAG, "Presence cleared (all)")
466498
if (!shouldRunService()) {
467-
stopService()
499+
// [T-fgs-stop-linger] see setInactive — deferred, cancellable.
500+
stopLinger.schedule()
468501
} else {
502+
stopLinger.cancel()
469503
updateService()
470504
}
471505
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package com.openminis.app.plugins
2+
3+
import com.openminis.app.plugins.DevSuiteStore.Status
4+
import org.junit.Assert.assertEquals
5+
import org.junit.Assert.assertFalse
6+
import org.junit.Assert.assertNull
7+
import org.junit.Assert.assertTrue
8+
import org.junit.Test
9+
10+
/**
11+
* [T-dev-suite-prefs-storm] Contract for the SharedPreferences write throttle.
12+
*
13+
* The installer feeds every apt/pip output line through
14+
* [DevSuiteStore.update] — hundreds of full-XML prefs rewrites per install
15+
* used to queue disk writes that QueuedWork.waitToFinish() could later force
16+
* the MAIN thread to drain (the install-crash amplifier). The contract:
17+
* only STATUS transitions persist; progress lines are in-memory only.
18+
*
19+
* Pure functions only (repo convention: framework entry points are
20+
* documented trade-offs, not unit-test targets).
21+
*/
22+
class DevSuiteStoreThrottleTest {
23+
24+
// -- shouldPersistComponentState --
25+
26+
@Test
27+
fun `progress-line refreshes with the same status never persist`() {
28+
// The apt/pip line stream: same status, new line, hundreds of times.
29+
assertFalse(DevSuiteStore.shouldPersistComponentState(Status.INSTALLING, Status.INSTALLING))
30+
assertFalse(DevSuiteStore.shouldPersistComponentState(Status.INSTALLED, Status.INSTALLED))
31+
assertFalse(DevSuiteStore.shouldPersistComponentState(Status.FAILED, Status.FAILED))
32+
assertFalse(DevSuiteStore.shouldPersistComponentState(Status.NOT_INSTALLED, Status.NOT_INSTALLED))
33+
}
34+
35+
@Test
36+
fun `status transitions always persist`() {
37+
// First write (nothing known yet), every real transition, and the
38+
// purge path back to NOT_INSTALLED.
39+
assertTrue(DevSuiteStore.shouldPersistComponentState(null, Status.INSTALLING))
40+
assertTrue(DevSuiteStore.shouldPersistComponentState(Status.INSTALLING, Status.INSTALLED))
41+
assertTrue(DevSuiteStore.shouldPersistComponentState(Status.INSTALLING, Status.FAILED))
42+
assertTrue(DevSuiteStore.shouldPersistComponentState(Status.FAILED, Status.INSTALLING))
43+
assertTrue(DevSuiteStore.shouldPersistComponentState(Status.INSTALLED, Status.NOT_INSTALLED))
44+
}
45+
46+
// -- encode / decode round trip --
47+
48+
@Test
49+
fun `encode decode round trip preserves every field`() {
50+
val raw = DevSuiteStore.encodeComponentState(Status.INSTALLING, "apt 42% (Reading database…)", 1_728_950_000_000L)
51+
val state = DevSuiteStore.decodeComponentState("nodejs-suite", raw)!!
52+
53+
assertEquals("nodejs-suite", state.componentId)
54+
assertEquals(Status.INSTALLING, state.status)
55+
assertEquals("apt 42% (Reading database…)", state.progressLine)
56+
assertEquals(1_728_950_000_000L, state.updatedAtMs)
57+
}
58+
59+
@Test
60+
fun `decode tolerates absent and corrupt rows without throwing`() {
61+
assertNull(DevSuiteStore.decodeComponentState("x", null))
62+
63+
// Corrupt/legacy row: unknown status token falls back to
64+
// NOT_INSTALLED, missing timestamp decodes as 0 — snapshot() must
65+
// never throw on prefs written by an older build.
66+
val state = DevSuiteStore.decodeComponentState("x", "GARBAGE")!!
67+
assertEquals(Status.NOT_INSTALLED, state.status)
68+
assertEquals("", state.progressLine)
69+
assertEquals(0L, state.updatedAtMs)
70+
}
71+
72+
@Test
73+
fun `decode degrades gracefully when a line contains the separator`() {
74+
// split(limit = 3) keeps the remainder in the LAST field, so a
75+
// progress line that itself contains \u0001 swallows the timestamp
76+
// (toLongOrNull → null → 0). The status stays intact and nothing
77+
// throws — that is the pinned contract: prefs corruption degrades,
78+
// never crashes snapshot().
79+
val raw = "INSTALLING\u0001line\u0001with\u0001sep\u0001123"
80+
val state = DevSuiteStore.decodeComponentState("x", raw)!!
81+
assertEquals(Status.INSTALLING, state.status)
82+
assertEquals("line", state.progressLine)
83+
assertEquals(0L, state.updatedAtMs)
84+
}
85+
}

0 commit comments

Comments
 (0)