dsh-mobile-plugin 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -224,6 +224,15 @@ window.__ModuleLoader__.load({
224
224
  /** 自检上报的出口与间隔:只为可观测性存在,不做任何状态变更。 */
225
225
  const CLIENT_HALF_ENDPOINT = '/api/relay/client-half';
226
226
  const CLIENT_HALF_REPORT_MS = 10000;
227
+ /**
228
+ * 宿主半边的自检口(由外壳注册,实现挂了它也还在)。
229
+ *
230
+ * 实现装载失败时整张 `/api/relay/*` 都 404,这一项只显示一句「插件宿主侧没有加载」,
231
+ * 真正的原因(等哪个服务超时、哪一代装不上)就没人看得见。这两条是那个原因唯一的出口;
232
+ * 第二条点一下就在原地重新装载一次,不必重启整个 DSH。
233
+ */
234
+ const HOST_HALF_ENDPOINT = '/api/relay/host-half';
235
+ const HOST_HALF_RELOAD_ENDPOINT = '/api/relay/host-half/reload';
227
236
 
228
237
  /**
229
238
  * 把 sessionStatus 的快照摊成 [id, status] 列表。
@@ -469,6 +478,10 @@ window.__ModuleLoader__.load({
469
478
  panelMissing: '宿主还没给出页面地址,稍后再打开这一页试试。',
470
479
  loadFailed: '读取宿主状态失败',
471
480
  loadMissing: '插件宿主侧没有加载:把 DSH 完整退出再打开一次;还不行就在插件市场重装一遍插件。',
481
+ hostHalfReason: '宿主侧报的原因:',
482
+ hostHalfRetrying: '它还在后台自己重试,过几分钟刷新这一页就能看到结果;也可以点下面的按钮立刻再试一次。',
483
+ hostHalfReload: '重新装载',
484
+ hostHalfReloadFailed: '重新装载没成:',
472
485
  webAccessOn: '公网网页管理已开放,到期时间',
473
486
  webAccessOff: '公网网页管理已关闭',
474
487
  webAccessOpen: '开放网页管理 2 小时',
@@ -487,6 +500,10 @@ window.__ModuleLoader__.load({
487
500
  panelMissing: 'The host has not reported a page address yet — reopen this item in a moment.',
488
501
  loadFailed: 'Failed to read host status',
489
502
  loadMissing: 'The plugin host half is not loaded: quit and reopen DSH; if that does not help, reinstall the plugin from the market.',
503
+ hostHalfReason: 'Reported by the host: ',
504
+ hostHalfRetrying: 'It is still retrying on its own in the background — reopen this item in a few minutes, or press the button below to retry right now.',
505
+ hostHalfReload: 'Reload now',
506
+ hostHalfReloadFailed: 'Reload failed: ',
490
507
  webAccessOn: 'Public web management is open until',
491
508
  webAccessOff: 'Public web management is closed',
492
509
  webAccessOpen: 'Open web management for 2 hours',
@@ -514,6 +531,10 @@ window.__ModuleLoader__.load({
514
531
  const [webAccessBusy, setWebAccessBusy] = useState(false);
515
532
  /** 最近一次失败的 HTTP 状态码(0 = 网络异常 / 还没失败过)。404 单独说。 */
516
533
  const [errorStatus, setErrorStatus] = useState(0);
534
+ /** 宿主半边自检的结果(外壳给的那份),404 时才有内容。 */
535
+ const [half, setHalf] = useState(null);
536
+ const [halfBusy, setHalfBusy] = useState(false);
537
+ const [halfError, setHalfError] = useState(null);
517
538
 
518
539
  useEffect(() => {
519
540
  let alive = true;
@@ -528,6 +549,8 @@ window.__ModuleLoader__.load({
528
549
  if (!alive) return;
529
550
  setError(null);
530
551
  setErrorStatus(0);
552
+ setHalf(null);
553
+ setHalfError(null);
531
554
  setPanelUrl(typeof next.panelUrl === 'string' ? next.panelUrl : '');
532
555
  setWebAccess(next.webAccess && typeof next.webAccess.enabled === 'boolean' ? next.webAccess : null);
533
556
  if (retryTimer !== null) {
@@ -549,6 +572,20 @@ window.__ModuleLoader__.load({
549
572
  clearTimer(refreshTimer);
550
573
  refreshTimer = null;
551
574
  }
575
+ // 404 = 实现那一半没装载。它为什么没装载,只有外壳答得出来(这两条路由挂在
576
+ // 外壳上,实现挂了它们还在);老版本宿主没有这条口,问不到就当没有,别报错。
577
+ if (problem?.status === 404) {
578
+ try {
579
+ const picture = await call(HOST_HALF_ENDPOINT);
580
+ if (!alive) return;
581
+ setHalf(picture && typeof picture.half === 'object' ? picture.half : null);
582
+ } catch {
583
+ if (!alive) return;
584
+ setHalf(null);
585
+ }
586
+ } else {
587
+ setHalf(null);
588
+ }
552
589
  // 无权的远程设置页不轮询;宿主还没启动等临时故障每 3s 重试。
553
590
  if (problem?.status !== 403 && problem?.status !== 423 && retryTimer === null) {
554
591
  retryTimer = setInterval(() => {
@@ -580,6 +617,27 @@ window.__ModuleLoader__.load({
580
617
  }
581
618
  };
582
619
 
620
+ /**
621
+ * 让外壳当场把实现再装一次。
622
+ *
623
+ * 装载失败那阵子,这是唯一不必重启整个 DSH 的出路;成不成以宿主回来的那份自检为准,
624
+ * 失败原因原样贴出来(它往往就是「等哪个服务超时」,过一会儿自己就好了)。
625
+ */
626
+ const reloadHalf = async () => {
627
+ if (halfBusy) return;
628
+ setHalfBusy(true);
629
+ setHalfError(null);
630
+ try {
631
+ const next = await postJson(HOST_HALF_RELOAD_ENDPOINT, {});
632
+ if (next && typeof next.half === 'object') setHalf(next.half);
633
+ if (next && next.ok === false) setHalfError(errorText(next, 200));
634
+ } catch (problem) {
635
+ setHalfError(String((problem && problem.message) || problem));
636
+ } finally {
637
+ setHalfBusy(false);
638
+ }
639
+ };
640
+
583
641
  const url = typeof panelUrl === 'string' ? panelUrl : '';
584
642
  const webAccessEnabled = Boolean(webAccess && webAccess.enabled);
585
643
  const expiresAt = webAccess && Number.isFinite(webAccess.expiresAt)
@@ -588,11 +646,32 @@ window.__ModuleLoader__.load({
588
646
  let webAccessStatus = t('webAccessUnavailable');
589
647
  if (webAccess) webAccessStatus = webAccessEnabled ? `${t('webAccessOn')} ${expiresAt}` : t('webAccessOff');
590
648
 
649
+ const missing = errorStatus === 404;
650
+
591
651
  return h('div', { className: 'dshm_root' }, [
592
652
  h('h2', { className: 'dshm_heading' }, t('heading')),
593
653
  error
594
- ? h('p', { className: 'dshm_error' }, errorStatus === 404 ? t('loadMissing') : `${t('loadFailed')}: ${error}`)
654
+ ? h('p', { className: 'dshm_error' }, missing ? t('loadMissing') : `${t('loadFailed')}: ${error}`)
655
+ : null,
656
+ // 404 时把宿主那半边自己的说法贴出来:重启 / 重装这两句提示在「装载超时」那种
657
+ // 现场是帮倒忙的,真正该做的是等它后台补齐、或者点一下重新装载。
658
+ missing && half && typeof half.lastError === 'string' && half.lastError
659
+ ? h('p', { className: 'dshm_note' }, `${t('hostHalfReason')}${half.lastError}`)
660
+ : null,
661
+ missing && half && half.retrying === true
662
+ ? h('p', { className: 'dshm_note' }, t('hostHalfRetrying'))
663
+ : null,
664
+ missing
665
+ ? h('div', { className: 'dshm_actions' }, [
666
+ h('button', {
667
+ type: 'button',
668
+ className: 'dshm_btn',
669
+ disabled: halfBusy,
670
+ onClick: reloadHalf,
671
+ }, t('hostHalfReload')),
672
+ ])
595
673
  : null,
674
+ halfError ? h('p', { className: 'dshm_error' }, `${t('hostHalfReloadFailed')}${halfError}`) : null,
596
675
 
597
676
  // 用 `<a target="_blank">` 而不是 iframe:DSH 桌面端会把这个链接交给系统浏览器
598
677
  // 打开(网页版有自己的地址与刷新节奏,嵌在设置弹窗里反而不好用)。
package/lib/index.js CHANGED
@@ -28,6 +28,7 @@ import { createRuntime } from './shell/runtime.js'
28
28
  import * as updater from './shell/updater.js'
29
29
  import { installedPackageDir, installedVersion, profileNodeModules, snapshotRunningGeneration } from './shell/manager.js'
30
30
  import { createDeviceOta } from './shell/device-ota.js'
31
+ import { installHostHalf } from './shell/half-route.js'
31
32
 
32
33
  export const name = 'dsh-mobile-plugin'
33
34
  export const inject = ['webServer', 'sessions']
@@ -118,16 +119,21 @@ export function apply(ctx) {
118
119
  }
119
120
  }, 'dsh-mobile-plugin: generation runtime')
120
121
 
122
+ const installedShellHash = () => updater.shellFingerprint(installedPackageDir())
123
+
124
+ // 宿主半边的自检口(实现在 `shell/half-route.js`):实现装载失败时整张控制面路由
125
+ // 都不在,只有外壳还活着 —— 那是「它为什么没起来」唯一说得通的落点。
126
+ ctx.effect(() => installHostHalf(ctx, runtime, { installedVersion, installedShellHash, runningShellHash }), 'dsh-mobile-plugin: host half self check')
127
+
121
128
  ctx.reflect.provide('dshMobilePluginRuntime', {
122
129
  status: () => ({
123
130
  ...runtime.status(),
124
131
  /** 盘上装着的版本(市场装完立刻变,和运行中的版本对照就能看出「要不要换」)。 */
125
132
  installedVersion: installedVersion(),
126
- /** 盘上那份的外壳指纹与跑着的对不上 = 新版本连外壳一起改了,那部分要等下次启动才生效。 */
127
133
  runningShellHash,
128
- installedShellHash: updater.shellFingerprint(installedPackageDir()),
134
+ installedShellHash: installedShellHash(),
129
135
  shellChanged: (() => {
130
- const installed = updater.shellFingerprint(installedPackageDir())
136
+ const installed = installedShellHash()
131
137
  return installed !== null && runningShellHash !== null && installed !== runningShellHash
132
138
  })(),
133
139
  profileNodeModules: profileNodeModules(),
@@ -0,0 +1,73 @@
1
+ /**
2
+ * 宿主半边的自检口:`/api/relay/host-half` 与它的 `/reload`。
3
+ *
4
+ * 这两条**必须由外壳注册**,不能放进实现:实现装载失败时整张 `/api/relay/*` 都不在,
5
+ * 而「它为什么没起来」只有外壳答得出来(外壳自己活得好好的是刻意的故障边界)。
6
+ * 现场的样子是设置页一句「插件宿主侧没有加载」,原因只打在宿主 stderr 里、桌面端不落盘,
7
+ * 用户看不见、我们也查不出来 —— 这两条就是那个原因唯一的出口。
8
+ *
9
+ * 放在 `lib/shell/` 而不是 `lib/index.js` 里,是因为外壳的守卫钉着「只该有装载逻辑」。
10
+ * 这里只干两件小事:把 state 拍成响应体(`half-view.js`),以及把「重新装载」转给
11
+ * runtime 那套换代秩序(串行、先停后起、失败回滚)。
12
+ *
13
+ * 两条都走 `localOnly`:拒绝中转/局域网转发来的请求、拒绝跨站、钉住 method。它们能读到的
14
+ * 是这台机器的插件装载状态,不值得让别处看见。
15
+ */
16
+
17
+ import { createRequire } from 'node:module'
18
+ import { hostHalfView } from './half-view.js'
19
+
20
+ const require = createRequire(import.meta.url)
21
+ const { localOnly } = require('../http/routing.js')
22
+ const { sendJson } = require('../shared/http-util.js')
23
+
24
+ export const HOST_HALF_PATH = '/api/relay/host-half'
25
+ export const HOST_HALF_RELOAD_PATH = '/api/relay/host-half/reload'
26
+
27
+ /**
28
+ * 挂上两条路由,返回清理函数(给 `ctx.effect` 当返回值用)。
29
+ *
30
+ * @param {object} ctx 外壳那份 context(只用它的 `webServer`)。
31
+ * @param {{status: () => object, reload: () => Promise<unknown>}} runtime 代际运行时。
32
+ * @param {{installedVersion?: () => string|null, installedShellHash?: () => string|null,
33
+ * runningShellHash?: string|null}} [deps] 盘上那一份的坐标,只为「要不要重启」这一格。
34
+ */
35
+ export function installHostHalf(ctx, runtime, deps = {}) {
36
+ const server = ctx?.webServer
37
+ if (typeof server?.register !== 'function') return () => {}
38
+ const installedVersion = typeof deps.installedVersion === 'function' ? deps.installedVersion : () => null
39
+ const installedShellHash = typeof deps.installedShellHash === 'function' ? deps.installedShellHash : () => null
40
+ const runningShellHash = typeof deps.runningShellHash === 'string' ? deps.runningShellHash : null
41
+ const view = () => hostHalfView(runtime.status(), {
42
+ installedVersion: installedVersion(),
43
+ shellChanged: installedShellHash() !== null && installedShellHash() !== runningShellHash,
44
+ })
45
+ const offs = [
46
+ server.register({
47
+ kind: 'exact',
48
+ path: HOST_HALF_PATH,
49
+ handler: localOnly('GET', async (_req, res) => sendJson(res, 200, view())),
50
+ }),
51
+ server.register({
52
+ kind: 'exact',
53
+ path: HOST_HALF_RELOAD_PATH,
54
+ handler: localOnly('POST', async (_req, res) => {
55
+ try {
56
+ await runtime.reload()
57
+ return sendJson(res, 200, view())
58
+ } catch (error) {
59
+ // 装载失败时外壳已经按老规矩回滚过了,这里只把原因与回滚后的状态一起交出去:
60
+ // 面板要能原样显示「还是不行,因为……」。
61
+ return sendJson(res, 200, {
62
+ ...view(),
63
+ ok: false,
64
+ error: { code: 'reload-failed', message: String((error && error.message) || error) },
65
+ })
66
+ }
67
+ }),
68
+ }),
69
+ ].filter(off => typeof off === 'function')
70
+ return () => {
71
+ for (const off of offs) off()
72
+ }
73
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 宿主半边自检的响应体:把 `runtime.status()` 收成设置页能读的那几格。
3
+ *
4
+ * 为什么单独一个纯函数:拼响应体的地方在外壳里,而外壳不好在测试里跑起来(它一 apply
5
+ * 就真的去装载实现)。这里只做取值与白名单,测试直接喂一份 status 就能钉住形状。
6
+ *
7
+ * **`lastError` 是这一条接口存在的理由**:实现装载失败时外壳仍然活着,于是路由 404、
8
+ * 设置页只显示一句「插件宿主侧没有加载」,而真正的原因(等哪个服务超时、哪一代装不上)
9
+ * 只打在宿主 stderr 里,桌面端不落盘、用户看不见、我们也就查不出来。这条接口把它原样
10
+ * 交给设置页。
11
+ */
12
+
13
+ /**
14
+ * @param {object} status `runtime.status()` 的快照。
15
+ * @param {{installedVersion?: string|null, shellChanged?: boolean}} [extra]
16
+ * 盘上那一份的坐标(市场装完立刻变,和跑着的对照就看得出要不要重启)。
17
+ */
18
+ export function hostHalfView(status, extra = {}) {
19
+ const s = status && typeof status === 'object' ? status : {};
20
+ const info = extra && typeof extra === 'object' ? extra : {};
21
+ return {
22
+ ok: true,
23
+ half: {
24
+ version: typeof s.version === 'string' ? s.version : null,
25
+ source: typeof s.source === 'string' ? s.source : null,
26
+ previousVersion: typeof s.previousVersion === 'string' ? s.previousVersion : null,
27
+ activatedAt: typeof s.activatedAt === 'string' ? s.activatedAt : null,
28
+ lastError: typeof s.lastError === 'string' ? s.lastError : null,
29
+ /** 启动时那几次没成、现在仍在后台按长间隔重试 —— 面板据此劝人「再等等」而不是重装。 */
30
+ retrying: s.retrying === true,
31
+ retryAttempts: Number.isFinite(s.retryAttempts) ? s.retryAttempts : 0,
32
+ nextRetryAt: typeof s.nextRetryAt === 'string' ? s.nextRetryAt : null,
33
+ installedVersion: typeof info.installedVersion === 'string' ? info.installedVersion : null,
34
+ shellChanged: info.shellChanged === true,
35
+ },
36
+ };
37
+ }
@@ -16,10 +16,26 @@ import { checkGenerationAlive } from './self-check-after-swap.js'
16
16
 
17
17
  /** 换代前等在飞请求归零的上限。 */
18
18
  const DRAIN_TIMEOUT_MS = 2000
19
- /** 等一代装载完成的上限。装载卡住(依赖永远不来)时不能把整个外壳拖死。 */
20
- const ACTIVATE_TIMEOUT_MS = 20000
19
+ /**
20
+ * 等一代装载完成的上限。装载卡住(依赖永远不来)时不能把整个外壳拖死。
21
+ *
22
+ * 20s 在慢机器上不够:失败的首要原因就是注入的服务(typertGateway / apiProxy)比本插件
23
+ * 晚就位,而 Windows 冷启动、装了一堆插件的 DSH 要几十秒才轮到那条服务。真实现场是
24
+ * 「重启还是不行」—— 设置页一句 404,日志里什么都没有。
25
+ */
26
+ const ACTIVATE_TIMEOUT_MS = 60000
21
27
  /** 首次装载全失败后的重试间隔与次数(重试理由见 `start()`)。 */
22
28
  const START_RETRY_LIMIT = 6
29
+ /**
30
+ * 启动那一轮没成之后,后台继续按这个节奏重试多久。
31
+ *
32
+ * 失败代价是**整张控制面路由表都不在**,而根因多半是「服务晚到」这种会自己好的事。
33
+ * 只重试启动那六次(约五分钟)然后就躺平,等于把一个瞬态问题固定成故障,用户唯一的
34
+ * 出路是重启 —— 而重启正好又落回同一个时间窗里。这里按长间隔再试一小时,并让设置页
35
+ * 看得到「还在试」。
36
+ */
37
+ const RETRY_BACKLOG_MS = 120000
38
+ const RETRY_BACKLOG_LIMIT = 30
23
39
  /** 间隔可用环境变量压低,只给测试(与 `DSH_MOBILE_PROFILE_NODE_MODULES` 同款逃生口)。 */
24
40
  const START_RETRY_MS = Number(process.env.DSH_MOBILE_START_RETRY_MS) > 0
25
41
  ? Number(process.env.DSH_MOBILE_START_RETRY_MS)
@@ -68,6 +84,15 @@ export function createRuntime() {
68
84
  * 读到这条已经发生过的事实(`lastApply.rolledBack` 就是从它来的)。
69
85
  */
70
86
  let lastRollback = null
87
+ /**
88
+ * 后台补齐重试的账:启动那一轮失败后接着试,试到成功或额度用完。
89
+ *
90
+ * 这三格同时也是说给用户听的:`retrying` 决定面板劝人「再等等」还是「重装」,
91
+ * `nextRetryAt` 让人知道下一次是几分钟后。
92
+ */
93
+ let retryTimer = null
94
+ let retryAttempts = 0
95
+ let nextRetryAt = null
71
96
 
72
97
  /** 对外状态快照。控制面接口直接把它交给客户端。 */
73
98
  function status() {
@@ -80,6 +105,9 @@ export function createRuntime() {
80
105
  inFlight,
81
106
  swapping: swapping !== null,
82
107
  lastRollback,
108
+ retrying: retryTimer !== null,
109
+ retryAttempts,
110
+ nextRetryAt,
83
111
  }
84
112
  }
85
113
 
@@ -261,6 +289,7 @@ export function createRuntime() {
261
289
  throw error
262
290
  }
263
291
  }
292
+ clearBacklogRetry()
264
293
  return status()
265
294
  } catch (error) {
266
295
  // 自检那条已经在上面退回过一次了,别退第二遍(第二遍会把刚装回去的旧代再卸一次)。
@@ -286,37 +315,103 @@ export function createRuntime() {
286
315
  * (typertGateway / apiProxy)比本插件晚就位、撞上 20s 装载上限,或者第一次装载撞上
287
316
  * IO/时序抖动。一次失败就永久躺平到下次重启,等于把一个瞬态问题固定成故障。
288
317
  */
318
+ /**
319
+ * 把当前可用的每一代都试一遍:成功就装成当前代并返回 `{ok:true}`,全失败返回
320
+ * `{ok:false, failures}`。`start()` 与后台补齐重试共用这一份,别各写一遍。
321
+ *
322
+ * 候选**现算**而不是启动时抓一份快照:市场换过代、OTA 落过一版之后,这里要能看见新目录。
323
+ */
324
+ async function attemptAll() {
325
+ const failures = []
326
+ for (const target of resolveGenerations()) {
327
+ if (detached) throw new Error('插件已经卸载,不再重试装载')
328
+ try {
329
+ const { loaded, fiber: startedFiber } = await activate(target)
330
+ fiber = startedFiber
331
+ loadedCurrent = loaded
332
+ current = describe(loaded, target.dir)
333
+ activatedAt = new Date().toISOString()
334
+ lastError = failures.length
335
+ ? `${failures.join(';')};已回退到 ${target.version}(${target.source})`
336
+ : null
337
+ return { ok: true, version: target.version }
338
+ } catch (error) {
339
+ failures.push(`装载 ${target.version}(${target.source})失败:${error?.message || error}`)
340
+ }
341
+ }
342
+ return { ok: false, failures }
343
+ }
344
+
345
+ /** 停掉后台补齐重试(装成功后调用:再试一次只会撞同一批路由)。 */
346
+ function clearBacklogRetry() {
347
+ if (retryTimer !== null) {
348
+ clearTimeout(retryTimer)
349
+ retryTimer = null
350
+ }
351
+ retryAttempts = 0
352
+ nextRetryAt = null
353
+ }
354
+
355
+ /**
356
+ * 启动那一轮没成之后接着试:长间隔、有额度,成功即停。
357
+ *
358
+ * 它是 `lastError` 之外的第二次机会,也是「用户什么都不做也能自己好」的那条路。
359
+ */
360
+ function scheduleBacklogRetry(log) {
361
+ if (detached || retryTimer !== null || retryAttempts >= RETRY_BACKLOG_LIMIT) return
362
+ retryAttempts += 1
363
+ nextRetryAt = new Date(Date.now() + RETRY_BACKLOG_MS).toISOString()
364
+ retryTimer = setTimeout(() => {
365
+ retryTimer = null
366
+ nextRetryAt = null
367
+ if (detached) return
368
+ void attemptAll().then(result => {
369
+ if (result.ok) {
370
+ retryAttempts = 0
371
+ log('info', 'dsh-mobile-plugin: 后台补齐成功,已装载 %s', result.version)
372
+ return
373
+ }
374
+ lastError = `所有可用插件实现均装载失败:${result.failures.join(';')}`
375
+ log('warn', 'dsh-mobile-plugin: 后台补齐仍未成功:%s', lastError)
376
+ scheduleBacklogRetry(log)
377
+ }).catch(error => {
378
+ lastError = `后台补齐装载出错:${error?.message || error}`
379
+ scheduleBacklogRetry(log)
380
+ })
381
+ }, RETRY_BACKLOG_MS)
382
+ if (typeof retryTimer.unref === 'function') retryTimer.unref()
383
+ }
384
+
385
+ /**
386
+ * 第一次激活;最高代装载失败时按优先级尝试保留的回退代;全失败则按固定间隔重试有限次,
387
+ * 之后交给后台补齐接着试。
388
+ *
389
+ * 为什么要重试:一次失败 = **整张控制面路由表都不在**,而设置页
390
+ * 的「移动端」照样画得出来(客户端半边按包扫描,不看宿主 fiber 活没活),于是现场只表现成
391
+ * 「读取宿主状态失败: HTTP 404」。失败最常见的原因恰好是**可能自己好**的:注入的服务
392
+ * (typertGateway / apiProxy)比本插件晚就位、撞上装载上限,或者第一次装载撞上 IO/时序抖动。
393
+ * 一次失败就永久躺平到下次重启,等于把一个瞬态问题固定成故障。
394
+ */
289
395
  async function start(context, options = {}) {
290
396
  ctx = context
291
397
  const log = typeof options.log === 'function' ? options.log : () => {}
292
- const candidates = resolveGenerations()
293
- if (!candidates.length) throw new Error('找不到任何一代实现:包内的 lib/impl.js 缺失')
398
+ if (!resolveGenerations().length) throw new Error('找不到任何一代实现:包内的 lib/impl.js 缺失')
294
399
  let lastFailure = ''
295
400
  for (let attempt = 1; attempt <= START_RETRY_LIMIT; attempt += 1) {
296
- const failures = []
297
- for (const target of candidates) {
298
- if (detached) throw new Error('插件已经卸载,不再重试装载')
299
- try {
300
- const { loaded, fiber: startedFiber } = await activate(target)
301
- fiber = startedFiber
302
- loadedCurrent = loaded
303
- current = describe(loaded, target.dir)
304
- activatedAt = new Date().toISOString()
305
- lastError = failures.length
306
- ? `${failures.join(';')};已回退到 ${target.version}(${target.source})`
307
- : null
308
- return status()
309
- } catch (error) {
310
- failures.push(`装载 ${target.version}(${target.source})失败:${error?.message || error}`)
311
- }
401
+ const result = await attemptAll()
402
+ if (result.ok) {
403
+ clearBacklogRetry()
404
+ return status()
312
405
  }
313
- lastFailure = `所有可用插件实现均装载失败:${failures.join(';')}`
406
+ lastFailure = `所有可用插件实现均装载失败:${result.failures.join(';')}`
314
407
  if (attempt >= START_RETRY_LIMIT) break
315
408
  lastError = `${lastFailure}(第 ${attempt} 次,${START_RETRY_MS / 1000}s 后重试)`
316
409
  log('warn', 'dsh-mobile-plugin: 装载失败,%d 秒后重试:%s', START_RETRY_MS / 1000, lastFailure)
317
410
  await sleep(START_RETRY_MS)
318
411
  }
319
412
  lastError = lastFailure
413
+ // 抛出去是为了让外壳那行日志照旧留下;后台补齐另起一条线,两条不冲突。
414
+ scheduleBacklogRetry(log)
320
415
  throw new Error(lastFailure)
321
416
  }
322
417
 
@@ -331,6 +426,7 @@ export function createRuntime() {
331
426
  /** 外壳被卸载时调用:停当前代,之后不再接受换代。 */
332
427
  async function detach() {
333
428
  detached = true
429
+ clearBacklogRetry()
334
430
  const oldFiber = fiber
335
431
  fiber = null
336
432
  current = null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (dsh 0.2.x, peers resolved by the host)",
5
5
  "license": "MIT",
6
6
  "icon": "./icon.png",