@mzzsfy/dsh-shell-select 0.4.4 → 0.4.5

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-shell-select",
3
3
  "description": "Windows shell 接管:替换默认 pwsh 工具为可配置多客户端的 shell 工具(git bash/WSL/cmd/pwsh,自定义路径,默认客户端,模型按名选择);支持命令 deny 黑名单、会话终端卡增强和直接 pwsh 接管徽章,接管失败自动回退官方 pwsh 链",
4
- "version": "0.4.4",
4
+ "version": "0.4.5",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -159,7 +159,7 @@ window.__ModuleLoader__.load({
159
159
  })
160
160
  .catch((error) => {
161
161
  pathMapRequested = false
162
- console.warn('shell-select: pathmap fetch failed', error)
162
+ console.warn('shell-select: pathmap fetch failed: ' + (error instanceof Error ? error.message : String(error)))
163
163
  })
164
164
  }
165
165
  // LOGIC-END loadPathMap
package/src/guard.js CHANGED
@@ -208,6 +208,9 @@ export async function installGuard(ctx, {
208
208
  const dead = detectDeadState(ctx.loader, { applyState: shellSelectApplyState })
209
209
  if (dead === true && mounted.size === 0 && mounting === null) await mountOfficial()
210
210
  else if (dead === false && (mounted.size > 0 || mounting !== null)) await unmountOfficial()
211
+ } catch {
212
+ // 停机竞态:fiber 已停而末次 tick 先于 effect 清理触发,loader 访问必抛;
213
+ // rejection 逃逸会被宿主 fail-loud 判 fatal load failure 杀进程,静默放行
211
214
  } finally {
212
215
  sweeping = false
213
216
  }
@@ -246,10 +249,15 @@ export async function installGuard(ctx, {
246
249
  ctx.logger.info?.(`shell-select/guard: 初始判定 dead=${initial}`)
247
250
  if (initial === true) await mountOfficial()
248
251
 
249
- const timer = setInterval(() => {
250
- void sweep()
251
- }, sweepIntervalMs)
252
- timer.unref?.()
252
+ // 定时器随 fiber 清理:裸 setInterval 会在行停用后残留 tick,停机序列中
253
+ // 访问 loader 抛错并经宿主 fail-loud 升级为进程级 fatal
254
+ ctx.effect(() => {
255
+ const timer = setInterval(() => {
256
+ void sweep()
257
+ }, sweepIntervalMs)
258
+ timer.unref?.()
259
+ return () => clearInterval(timer)
260
+ }, 'shell-select/guard sweep timer')
253
261
  return undefined
254
262
  }
255
263
 
@@ -45,6 +45,9 @@ function stubCtx({ rows, importOfficial }) {
45
45
  },
46
46
  })
47
47
  },
48
+ effect(execute) {
49
+ return execute()
50
+ },
48
51
  }
49
52
  return { ctx, log, mounts, listeners }
50
53
  }
@@ -0,0 +1,110 @@
1
+ // guard 生命周期:定时器随 fiber 清理(effect),停机竞态异常不逃逸为
2
+ // unhandledRejection——宿主 fail-loud 会把这类逃逸判为 fatal load failure
3
+ // 杀掉整个进程(dsh-maintain 重启停机窗口实测事故)。
4
+
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import { setTimeout as sleep } from 'node:timers/promises'
8
+ import { installGuard } from '../src/guard.js'
9
+ import { MAIN_ROW_ID } from '../src/guard-state.mjs'
10
+
11
+ const OFFICIAL_MODULE = (name) => ({ __name: name, name, apply() {} })
12
+ const makeImport = async (packageName) => OFFICIAL_MODULE(packageName)
13
+
14
+ function row({ id, disabled = false, running = false }) {
15
+ return {
16
+ options: { id },
17
+ get disabled() {
18
+ return disabled
19
+ },
20
+ fiber: running ? { uid: 'f' } : undefined,
21
+ }
22
+ }
23
+
24
+ function deadWorld() {
25
+ return [
26
+ row({ id: MAIN_ROW_ID, disabled: true }),
27
+ row({ id: 'tool-pwsh', disabled: true }),
28
+ row({ id: 'pwsh-sandbox', disabled: true }),
29
+ ]
30
+ }
31
+
32
+ function stubCtx({ rows, resolveCount = { value: 0 } }) {
33
+ const log = []
34
+ const mounts = []
35
+ const effects = []
36
+ const listeners = new Map()
37
+ const ctx = {
38
+ logger: {
39
+ warn: (msg) => log.push(['warn', msg]),
40
+ error: (msg) => log.push(['error', msg]),
41
+ info: (msg) => log.push(['info', msg]),
42
+ },
43
+ get loader() {
44
+ return {
45
+ resolve(id) {
46
+ resolveCount.value += 1
47
+ const found = rows.find((candidate) => candidate.options.id === id)
48
+ if (found) return found
49
+ throw new Error('not found')
50
+ },
51
+ }
52
+ },
53
+ on(event, handler) {
54
+ listeners.set(event, handler)
55
+ },
56
+ effect(execute, label) {
57
+ const dispose = execute()
58
+ effects.push({ label, dispose })
59
+ return dispose
60
+ },
61
+ plugin(module, config) {
62
+ mounts.push({ moduleName: module.__name, config })
63
+ return Promise.resolve({
64
+ dispose: () => {
65
+ mounts.length = 0
66
+ return Promise.resolve()
67
+ },
68
+ })
69
+ },
70
+ }
71
+ return { ctx, log, mounts, effects, resolveCount }
72
+ }
73
+
74
+ test('定时器随 fiber 清理:effect dispose 后不再扫描', async () => {
75
+ const sweepIntervalMs = 20
76
+ const { ctx, mounts, effects, resolveCount } = stubCtx({ rows: deadWorld() })
77
+ await installGuard(ctx, { importOfficial: makeImport, sweepIntervalMs })
78
+ assert.equal(mounts.length, 2)
79
+ const timerEffect = effects.find((entry) => entry.label.includes('sweep'))
80
+ assert.ok(timerEffect, 'sweep 定时器必须注册为 effect')
81
+ assert.equal(typeof timerEffect.dispose, 'function')
82
+ timerEffect.dispose()
83
+ await sleep(sweepIntervalMs * 3)
84
+ const settled = resolveCount.value
85
+ await sleep(sweepIntervalMs * 3)
86
+ assert.equal(resolveCount.value, settled, 'dispose 后不得再有 sweep 扫描')
87
+ })
88
+
89
+ test('sweep 异常不逃逸:停机竞态下 loader 抛错不产生 unhandledRejection', async () => {
90
+ const sweepIntervalMs = 20
91
+ const { ctx, mounts } = stubCtx({ rows: deadWorld() })
92
+ await installGuard(ctx, { importOfficial: makeImport, sweepIntervalMs })
93
+ assert.equal(mounts.length, 2)
94
+ // 模拟 fiber 停用后 ctx.loader 访问抛 inactive context 错误
95
+ Object.defineProperty(ctx, 'loader', {
96
+ get() {
97
+ throw new Error('cannot get required service "loader" in inactive context')
98
+ },
99
+ })
100
+ const rejections = []
101
+ const onRejection = (error) => rejections.push(error)
102
+ process.on('unhandledRejection', onRejection)
103
+ try {
104
+ await sleep(sweepIntervalMs * 4)
105
+ assert.equal(mounts.length, 2, '异常不得破坏已挂载状态')
106
+ assert.deepEqual(rejections, [])
107
+ } finally {
108
+ process.off('unhandledRejection', onRejection)
109
+ }
110
+ })