Skip to content

Commit 998ef43

Browse files
committed
feat: enhance retention management and testing for mini programs
1 parent c97ed41 commit 998ef43

12 files changed

Lines changed: 445 additions & 28 deletions

File tree

.github/workflows/fe-tests.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,16 @@ on:
55
paths:
66
- 'fe/**'
77
- 'examples/miniprogram/**'
8+
- 'harmony/dimina/src/main/ets/**'
9+
- 'harmony/scripts/**'
10+
- '.github/workflows/fe-tests.yml'
811
push:
912
paths:
1013
- 'fe/**'
1114
- 'examples/miniprogram/**'
15+
- 'harmony/dimina/src/main/ets/**'
16+
- 'harmony/scripts/**'
17+
- '.github/workflows/fe-tests.yml'
1218
workflow_dispatch:
1319

1420
jobs:
@@ -60,3 +66,6 @@ jobs:
6066
- name: Run tests
6167
working-directory: ./fe
6268
run: pnpm test
69+
70+
- name: Run Harmony portable runtime regressions
71+
run: node --test harmony/scripts/*.test.mjs

android/dimina/src/test/java/com/didi/dimina/core/BackgroundRetentionTest.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,26 @@ class BackgroundRetentionTest {
3434

3535
@Test(expected = IllegalArgumentException::class)
3636
fun rejectsNegativeCapacity() { RetentionPolicy(-1) }
37+
@Test fun reentryStartsANewLeaseAndAnOldDeadlineCannotEvictIt() {
38+
val state = BackgroundRetention()
39+
state.policy = RetentionPolicy(3, 100)
40+
state.hide("a", 0)
41+
state.forget("a") // show before expiry
42+
assertNull(state.nextDelay(90) { true })
43+
state.hide("a", 200)
44+
assertEquals(emptyList<String>(), state.collect(299, false) { true })
45+
assertEquals(listOf("a"), state.collect(300, false) { true })
46+
}
47+
48+
@Test fun repeatedPressureAndCapacityChangesDoNotRetainDestroyedGenerations() {
49+
val state = BackgroundRetention()
50+
state.hide("a", 0); state.hide("b", 1)
51+
assertEquals(listOf("a", "b"), state.collect(2, true) { true })
52+
assertEquals(emptyList<String>(), state.collect(3, true) { true })
53+
state.hide("a", 200) // a new runtime with the same appId
54+
state.policy = RetentionPolicy(0, 0)
55+
assertEquals(listOf("a"), state.collect(200, false) { true })
56+
assertNull(state.nextDelay(200) { true })
57+
}
58+
3759
}

docs/MiniProgram-Retention.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Android、iOS 和 Harmony 的后台实例不能操作前台导航栈。销毁后
4040

4141
每个保留实例仍占用 JS 和 WebView 内存,宿主可通过主动销毁入口释放不再需要的实例。Harmony 隐藏期间的 TabBar 更新在恢复时应用;待处理更新最多 128 条,超过上限会失败,避免无限积压。
4242

43-
四端共用逻辑层的协作式挂起:`App.onHide` 执行后暂停 `setTimeout``setInterval` 和普通业务消息回调,包括网络、扩展事件与 Canvas 动画回调。`App.onShow` 时恢复,定时器继续剩余等待时间,周期定时器不补跑隐藏期间错过的次数。业务消息按接收顺序分批处理,每批最多 64 条,避免为每条积压消息创建原生定时器
43+
四端共用逻辑层的协作式挂起:`App.onHide` 执行后暂停 `setTimeout``setInterval` 和普通业务消息回调,包括网络、扩展事件与 Canvas 动画回调。`App.onShow` 时恢复,定时器继续剩余等待时间,周期定时器不补跑隐藏期间错过的次数。业务消息按接收顺序恢复,每条消息保留独立任务边界,让它产生的 Promise 微任务在下一条消息前完成。浏览器使用一个 `MessageChannel`,原生运行时使用计时器调度,始终只保留一个待执行的恢复任务。已经失效的定时器回调不会在快速隐藏、恢复后重复执行
4444

4545
资源初始化、页面生命周期、销毁屏障和跨小程序导航的成功/失败/完成回调继续分发,避免隐藏后的退出或重启操作互相等待。挂起不抢占正在执行的 JS 或微任务,不冻结整个 Worker、WebView、原生网络或媒体任务;已到达但尚未分发的业务消息仍占用内存。原生能力继续遵循各自的隐藏与销毁规则。
4646

@@ -59,6 +59,8 @@ Android、iOS 和 Harmony 的后台实例不能操作前台导航栈。销毁后
5959

6060
当前展示的实例,以及跨小程序导航中尚未解除的来源链,属于受保护的展示关系,不计入可回收缓存上限。宿主整体进入系统后台也不会立即解除该关系。因此总运行实例数可以大于 `maxBackgroundApps`,该配置不是进程总实例数的硬上限。实例脱离展示关系后才允许自动销毁,避免破坏返回页面与来源关系。
6161

62+
Harmony 某个实例关闭失败时保留其留存记录,并继续处理其他实例;下次配置、真实显示变化或内存压力事件可以重试,不针对失败实例反复启动立即到期的计时器。
63+
6264
内存压力会释放所有可回收后台实例,保留受保护的展示关系;之后再次打开被回收实例走冷启动。回收释放运行时、页面和实例资源,不清除 Storage 或用户文件,也不自动打开其他小程序。
6365

6466
### Android
@@ -132,3 +134,15 @@ Harmony 的 `customLaunchPageCallBack` 自定义挂载页面没有框架路由
132134
- Web:`retention-policy.spec.ts``retained-mini-program.spec.ts` 与容器 SDK 现有生命周期、并发打开测试。
133135
- 共享逻辑层:`background-scheduler.spec.js``background-lifecycle.spec.js`,验证挂起、恢复、定时器剩余时间和终止性回调的顺序。
134136
- Harmony:安装前端依赖后执行 `node --test harmony/scripts/retained-pages.test.mjs`;ArkUI 编译通过 `dimina:assembleHar` 验证,实际界面复用仍需设备验证。
137+
138+
### 业务执行回归
139+
140+
自动回归同时验证公开 API 的业务状态和管理器的资源状态:
141+
142+
- `App.globalData` 经 API 成功回调及 Promise 链修改后,完成回调能读取到最终状态。
143+
- 恢复过程中同步回调重入、Promise 再次隐藏、消息积压和旧定时器迟到,保持顺序与恰好一次执行。
144+
- Web 过期重开会冷启动,内存压力不影响其他容器或跨小程序返回链。
145+
- Android/iOS 再次显示取消旧期限,再次隐藏创建新期限;iOS 导航事务期间暂缓回收。
146+
- Harmony 关闭失败不会丢失留存记录、阻断其他实例回收或立即循环重试。
147+
148+
前端工作流会自动运行共享逻辑层、Web 容器及 Harmony 便携回归;Harmony 源码及脚本变更也会触发该工作流。Android 和 iOS 用例沿用各自的测试工作流。便携测试与模拟器测试不能替代真机压力和长时间运行验证。

fe/packages/container-sdk/__tests__/retained-mini-program.spec.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,60 @@ it('reclaims a hidden runtime after capacity reduction and rebuilds it on next e
8585
await container.application.destroyRootView(second)
8686
mount.remove()
8787
})
88+
89+
90+
it('checks expiry before reopening even when the host expiry timer has not run', async () => {
91+
const clock = vi.spyOn(performance, 'now').mockReturnValue(0)
92+
const mount = document.createElement('div')
93+
const container = createContainer({ mount, retention: { backgroundTimeoutMs: 100000 } })
94+
try {
95+
const first = await container.openApp({ appId: 'expired-entry' })
96+
await container.application.dismissView(first, { destroy: false })
97+
clock.mockReturnValue(100001)
98+
const reopened = await container.openApp({ appId: first.appId })
99+
expect(reopened).not.toBe(first)
100+
expect(FakeWorker.instances[0].terminate).toHaveBeenCalledTimes(1)
101+
expect(container.application.views.at(-1)).toBe(reopened)
102+
} finally {
103+
for (const app of [...container.application.appManager.apps.values()]) await container.application.destroyRootView(app)
104+
clock.mockRestore()
105+
}
106+
})
107+
108+
it('keeps a cross-app return chain usable under memory pressure and a zero cache limit', async () => {
109+
const container = createContainer({ mount: document.createElement('div'), retention: { maxBackgroundApps: 0 } })
110+
const manager = container.application.appManager
111+
try {
112+
const source = await container.openApp({ appId: 'pinned-source' })
113+
const target = await manager.navigateToMiniProgram({ appId: 'pinned-target' }, source)
114+
container.notifyMemoryPressure()
115+
await manager._enqueue(async () => {})
116+
expect(manager.getAppById(source.appId)).toBe(source)
117+
expect(manager.getAppById(target.appId)).toBe(target)
118+
await manager.navigateBackMiniProgram(target, {}, async () => {})
119+
expect(container.application.views.at(-1)).toBe(source)
120+
expect(FakeWorker.instances[0].terminate).not.toHaveBeenCalled()
121+
} finally {
122+
for (const app of [...manager.apps.values()]) await container.application.destroyRootView(app)
123+
}
124+
})
125+
126+
it('isolates memory pressure across containers even when appIds match', async () => {
127+
const one = createContainer({ mount: document.createElement('div') })
128+
const two = createContainer({ mount: document.createElement('div') })
129+
try {
130+
const a = await one.openApp({ appId: 'same-id' })
131+
const b = await two.openApp({ appId: 'same-id' })
132+
await one.application.dismissView(a, { destroy: false })
133+
await two.application.dismissView(b, { destroy: false })
134+
one.notifyMemoryPressure()
135+
await one.application.appManager._enqueue(async () => {})
136+
expect(one.application.appManager.getAppById('same-id')).toBeNull()
137+
expect(two.application.appManager.getAppById('same-id')).toBe(b)
138+
expect(await two.openApp({ appId: 'same-id' })).toBe(b)
139+
} finally {
140+
for (const container of [one, two]) {
141+
for (const app of [...container.application.appManager.apps.values()]) await container.application.destroyRootView(app)
142+
}
143+
}
144+
})

fe/packages/service/__tests__/background-lifecycle.spec.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,35 @@ it('hides before suspension, defers API callbacks, and keeps exit callbacks and
3030
vi.advanceTimersByTime(10)
3131
expect(events).toEqual(['hide', 'exit', 'complete', 'show', 'network', 'timer'])
3232
})
33+
34+
35+
it('preserves business state derived by Promise callbacks before the API complete callback', async () => {
36+
vi.resetModules()
37+
vi.useFakeTimers()
38+
globalThis.DiminaServiceBridge.invoke = vi.fn()
39+
const service = (await import('../src/index')).default
40+
globalThis.App({
41+
globalData: { phase: 'initial', completedPhase: undefined },
42+
onHide() { this.globalData.hidden = true },
43+
onShow() { this.globalData.hidden = false },
44+
})
45+
const app = globalThis.getApp()
46+
globalThis.wx.getStorageInfo({
47+
success() {
48+
Promise.resolve().then(() => { app.globalData.phase = 'decoded' })
49+
.then(() => { app.globalData.phase = 'ready' })
50+
},
51+
complete() { app.globalData.completedPhase = app.globalData.phase },
52+
})
53+
const params = globalThis.DiminaServiceBridge.invoke.mock.calls.at(-1)[0].body.params
54+
service.message.handleMsg({ type: 'appHide' })
55+
service.message.handleMsg({ type: 'triggerCallback', body: { id: params.success, args: { keys: [] } } })
56+
service.message.handleMsg({ type: 'triggerCallback', body: { id: params.complete } })
57+
await vi.runAllTimersAsync()
58+
expect(app.globalData.phase).toBe('initial')
59+
expect(app.globalData.hidden).toBe(true)
60+
service.message.handleMsg({ type: 'appShow', body: {} })
61+
await vi.runAllTimersAsync()
62+
expect(app.globalData.hidden).toBe(false)
63+
expect(app.globalData.completedPhase).toBe('ready')
64+
})

fe/packages/service/__tests__/background-scheduler.spec.js

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,15 @@ it('supports cancelling and creating timers while suspended and repeated visibil
4242
expect(callback).toHaveBeenCalledTimes(1)
4343
})
4444

45-
it('defers business callbacks in FIFO order until resume without replaying cancelled timers', () => {
45+
it('defers business callbacks in FIFO order until resume without replaying cancelled timers', async () => {
4646
const events = []
4747
scheduler.pause()
4848
scheduler.dispatch(() => events.push('network'))
4949
scheduler.dispatch(() => events.push('animation'))
5050
vi.runOnlyPendingTimers()
5151
expect(events).toEqual([])
5252
scheduler.resume()
53-
vi.runOnlyPendingTimers()
53+
await vi.runAllTimersAsync()
5454
expect(events).toEqual(['network', 'animation'])
5555
})
5656

@@ -61,3 +61,84 @@ it('an interval cancelled from its callback is not rearmed', () => {
6161
expect(callback).toHaveBeenCalledTimes(1)
6262
expect(vi.getTimerCount()).toBe(0)
6363
})
64+
65+
66+
it('retains FIFO order when a resumed callback dispatches another callback synchronously', async () => {
67+
const events = []
68+
scheduler.pause()
69+
scheduler.dispatch(() => {
70+
events.push('first')
71+
scheduler.dispatch(() => events.push('nested'))
72+
})
73+
scheduler.dispatch(() => events.push('second'))
74+
scheduler.resume()
75+
await vi.runAllTimersAsync()
76+
expect(events).toEqual(['first', 'second', 'nested'])
77+
})
78+
79+
it('finishes the full Promise chain of one message before delivering the next message', async () => {
80+
const events = []
81+
scheduler.pause()
82+
scheduler.dispatch(() => {
83+
events.push('success')
84+
Promise.resolve().then(() => events.push('then')).then(() => events.push('chained'))
85+
})
86+
scheduler.dispatch(() => events.push('complete'))
87+
scheduler.resume()
88+
await vi.runAllTimersAsync()
89+
expect(events).toEqual(['success', 'then', 'chained', 'complete'])
90+
})
91+
92+
it('does not run the rest of the backlog when a Promise callback hides the app again', async () => {
93+
const events = []
94+
scheduler.pause()
95+
scheduler.dispatch(() => Promise.resolve().then(() => scheduler.pause()))
96+
scheduler.dispatch(() => events.push('later'))
97+
scheduler.resume()
98+
await vi.runAllTimersAsync()
99+
expect(events).toEqual([])
100+
scheduler.resume()
101+
await vi.runAllTimersAsync()
102+
expect(events).toEqual(['later'])
103+
})
104+
105+
it('ignores an already queued native timer callback from before suspension', () => {
106+
const queued = []
107+
const clock = { now: () => 0, setTimeout: fn => { queued.push(fn); return queued.length }, clearTimeout() {} }
108+
const runtime = new BackgroundScheduler(clock)
109+
const callback = vi.fn()
110+
runtime.set(callback, 10, true)
111+
runtime.pause()
112+
runtime.resume()
113+
queued[0]() // Already submitted by native before clearTimeout; it cannot be withdrawn.
114+
expect(callback).not.toHaveBeenCalled()
115+
queued[1]()
116+
expect(callback).toHaveBeenCalledTimes(1)
117+
expect(queued).toHaveLength(3)
118+
})
119+
120+
121+
it('restores a large backlog in order with at most one pending host task', async () => {
122+
const events = []
123+
scheduler.pause()
124+
for (let index = 0; index < 257; index++) scheduler.dispatch(() => events.push(index))
125+
expect(vi.getTimerCount()).toBe(0)
126+
scheduler.resume()
127+
for (let index = 0; index < 257; index++) {
128+
expect(vi.getTimerCount()).toBe(1)
129+
await vi.advanceTimersToNextTimerAsync()
130+
}
131+
expect(events).toEqual(Array.from({ length: 257 }, (_, index) => index))
132+
expect(vi.getTimerCount()).toBe(0)
133+
})
134+
135+
it('an exception in one pending callback does not discard following messages', () => {
136+
const events = []
137+
scheduler.pause()
138+
scheduler.dispatch(() => { throw new Error('business callback') })
139+
scheduler.dispatch(() => events.push('next'))
140+
scheduler.resume()
141+
expect(() => vi.advanceTimersToNextTimer()).toThrow('business callback')
142+
vi.runAllTimers()
143+
expect(events).toEqual(['next'])
144+
})
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { afterEach, expect, it, vi } from 'vitest'
2+
3+
afterEach(() => {
4+
vi.useRealTimers()
5+
vi.doUnmock('@dimina/common')
6+
vi.resetModules()
7+
})
8+
9+
it('uses one Worker MessageChannel and ignores cancelled task packets across quick hide/show', async () => {
10+
vi.resetModules()
11+
vi.useFakeTimers()
12+
vi.doMock('@dimina/common', () => ({ isWebWorker: true }))
13+
const { installBackgroundScheduler } = await import('../src/core/background-scheduler')
14+
const nativeTimeout = setTimeout
15+
const userTimer = vi.fn(nativeTimeout)
16+
let channels = 0
17+
class TestMessageChannel {
18+
port1 = { onmessage: undefined }
19+
port2 = { postMessage: data => nativeTimeout(() => this.port1.onmessage?.({ data }), 0) }
20+
constructor() { channels++ }
21+
}
22+
const target = { setTimeout: userTimer, clearTimeout, MessageChannel: TestMessageChannel }
23+
const runtime = installBackgroundScheduler(target)
24+
const events = []
25+
runtime.pause()
26+
runtime.dispatch(() => {
27+
events.push('callback')
28+
Promise.resolve().then(() => events.push('promise'))
29+
})
30+
runtime.dispatch(() => events.push('complete'))
31+
runtime.resume()
32+
runtime.pause()
33+
runtime.resume()
34+
await vi.runAllTimersAsync()
35+
expect(events).toEqual(['callback', 'promise', 'complete'])
36+
expect(channels).toBe(1)
37+
expect(userTimer).not.toHaveBeenCalled()
38+
})

fe/packages/service/__tests__/env.spec.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const mockGlobalApi = { __mock: true }
88
const mockRegisterEnumerableApiNames = vi.fn()
99

1010
vi.mock('@dimina/common', () => ({
11+
isWebWorker: false,
1112
modDefine: vi.fn(),
1213
modRequire: vi.fn(),
1314
}))

0 commit comments

Comments
 (0)