@a9i5k4/dsh-auto-memory 3.1.0 → 3.1.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/config-io.js CHANGED
@@ -27,12 +27,34 @@
27
27
  * - CRLF、无 BOM。
28
28
  */
29
29
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
30
- import { writeFile } from 'node:fs/promises'
30
+ import { rename, writeFile } from 'node:fs/promises'
31
+ import { setTimeout as sleepDelayPre } from 'node:timers/promises'
31
32
  import path from 'node:path'
32
33
 
33
34
  /** 临时文件后缀。与既有 hub 落盘的 `.tmp` 同族,便于排查时一眼认出。 */
34
35
  export const ATOMIC_TMP_SUFFIX_V1 = '.tmp'
35
36
 
37
+ /**
38
+ * ★2026-09-22(并发写修复):tmp 路径的**唯一段** = pid + 进程内自增序号。
39
+ *
40
+ * 病症:tmp 名原先**只由目标路径决定**(`file + '.tmp'`)⇒ 对同一目标并发保存时,
41
+ * 先完成者把 tmp 改名走,后到者的 rename 找不到源文件而抛 **ENOENT**(非瞬时错误、
42
+ * 按纪律不得重试)⇒ 后一次保存静默失败(只回 `{ok:false}`,界面看起来"改了没生效")。
43
+ * 实测复现:对同一目标并发 5 次 `writeTextAtomicPre`,部分调用返回
44
+ * `{ok:false, error:'ENOENT … rename … .tmp -> …'}`(见 `tests/smoke/smoke-test-p3-batchb-guards-pre.mjs` P3B-2d)。
45
+ *
46
+ * 处方:每次调用生成一个独立的 tmp 名 —— 同进程并发各写各的,跨进程并发靠 pid 区分;
47
+ * 最后一次 rename 胜出,**原子性不变量不变**(目标任意时刻仍是旧完整版或新完整版)。
48
+ *
49
+ * 边界:仍落在**同目录**且保持 `.tmp` 前缀(`rename` 必须同文件系统);失败路径照旧
50
+ * `rmSync` 清理。唯一代价是**进程崩溃**时会在目标目录留下一个 `.tmp-<pid>-<n>`
51
+ * (可识别、可手工清理;不自动扫描删除,避免误删他人正在写的 tmp)。
52
+ */
53
+ let _tmpSeqPre = 0
54
+ function tmpUniqueSuffixPre() {
55
+ return '-' + process.pid + '-' + (++_tmpSeqPre)
56
+ }
57
+
36
58
  /** 损坏留存的默认后缀前缀。 */
37
59
  export const CORRUPT_QUARANTINE_PREFIX_V1 = '.corrupt-'
38
60
 
@@ -70,7 +92,7 @@ export function quarantineFilePreSync(file, opts = {}) {
70
92
  * @returns {{ok: boolean, path?: string, error?: string}} 结构化结果(fail-soft,不抛)。
71
93
  */
72
94
  export function writeTextAtomicPreSync(file, text) {
73
- const tmp = file + ATOMIC_TMP_SUFFIX_V1
95
+ const tmp = file + ATOMIC_TMP_SUFFIX_V1 + tmpUniqueSuffixPre()
74
96
  try {
75
97
  mkdirSync(path.dirname(file), { recursive: true })
76
98
  writeFileSync(tmp, String(text), 'utf8')
@@ -83,18 +105,49 @@ export function writeTextAtomicPreSync(file, text) {
83
105
  }
84
106
  }
85
107
 
108
+ /**
109
+ * **有界退避 rename**(★P3-12,2026-09-22)。
110
+ *
111
+ * 病症:异步原子写原先走裸 `renameSync` —— Windows 上目标文件被别的进程持有句柄时会抛
112
+ * EPERM/EACCES/EBUSY,于是**整次设置保存失败**(用户侧表现为"改了设置没生效")。
113
+ * 处方:仅对这三个**瞬时**错误码退避重试;首项延迟为 0 ⇒ **正常路径零延迟变化**。
114
+ *
115
+ * ⚠️ **为什么不 import `fs-retry.js`**:本模块的**零运行时依赖纪律**(见文件头)
116
+ * 被 `tests/smoke/smoke-test-t8-configio-pre.mjs` 的 T8c-6 钉死 —— 配置 I/O 层不得因为
117
+ * 任何兄弟模块的加载期故障而失效。故此处**内联**同一套退避刻度(只用 node: 内置),
118
+ * 语义与 `fs-retry.js` 的 `retryRename` 保持一致。
119
+ */
120
+ const RENAME_RETRY_DELAYS_V1 = Object.freeze([0, 50, 150, 400, 1000])
121
+ const TRANSIENT_RENAME_CODES_V1 = new Set(['EPERM', 'EACCES', 'EBUSY'])
122
+ async function renameBoundedRetryPre(from, to) {
123
+ const last = RENAME_RETRY_DELAYS_V1.length - 1
124
+ for (let attempt = 0; attempt <= last; attempt++) {
125
+ const wait = RENAME_RETRY_DELAYS_V1[attempt]
126
+ if (wait) await sleepDelayPre(wait)
127
+ try {
128
+ await rename(from, to)
129
+ return
130
+ } catch (e) {
131
+ const transient = TRANSIENT_RENAME_CODES_V1.has(e && e.code)
132
+ if (!transient || attempt === last) throw e
133
+ }
134
+ }
135
+ }
136
+
86
137
  /**
87
138
  * **异步原子写**:tmp → rename。语义与同步版一致。
88
139
  *
89
- * 只把**可能较大**的正文写入交给异步 IO;rename 本身极快,保持同步调用。
90
- * 这样与既有 `persistConfigPre` 的异步口径对齐,不引入额外的 await 链。
140
+ * 只把**可能较大**的正文写入交给异步 IO。
141
+ *
142
+ * ★P3-12(2026-09-22):rename 走 `renameBoundedRetryPre`(有界退避;首项 0 ⇒ 正常路径零延迟变化)。
143
+ * 同步版 `writeTextAtomicPreSync` 的契约是同步,保持 `renameSync` 不动。
91
144
  */
92
145
  export async function writeTextAtomicPre(file, text) {
93
- const tmp = file + ATOMIC_TMP_SUFFIX_V1
146
+ const tmp = file + ATOMIC_TMP_SUFFIX_V1 + tmpUniqueSuffixPre()
94
147
  try {
95
148
  mkdirSync(path.dirname(file), { recursive: true })
96
149
  await writeFile(tmp, String(text), 'utf8')
97
- renameSync(tmp, file)
150
+ await renameBoundedRetryPre(tmp, file)
98
151
  return { ok: true, path: file }
99
152
  } catch (e) {
100
153
  try { rmSync(tmp, { force: true }) } catch (_) {}
@@ -164,7 +164,11 @@ export function createContextHost(opts = {}) {
164
164
  return bridge
165
165
  }
166
166
  function storeFor() {
167
- if (!store) store = new EvidenceEventStore({ root: path.join(dshHome(), 'memory', 'evidence') })
167
+ if (!store) store = new EvidenceEventStore({
168
+ root: path.join(dshHome(), 'memory', 'evidence'),
169
+ // ★P3-9(组2,2026-09-22):注入统一节流诊断通道;不注入时 store 内回落 console.error。
170
+ onDiag: (key, msg) => { try { diagCtx(msg) } catch (_) {} },
171
+ })
168
172
  return store
169
173
  }
170
174
  function evidenceRootExists() {
@@ -819,6 +823,11 @@ export function createContextHost(opts = {}) {
819
823
  const st = states.get(runtime)
820
824
  if (st) st.disposed = true
821
825
  states.delete(runtime)
826
+ // ★P3-3(a)(2026-09-22):pathsByKey 原先是「模块生命周期内永不释放」的集合——
827
+ // 写入点只有 capturePaths(每会话一条),全文件零 delete/clear;且 disposeAll 走本函数,
828
+ // 连全量退出路径也清不掉 ⇒ findProvenance 会在已释放的 runtime 上命中陈旧快照。
829
+ // 键口径与 :316/:482/:683 三处读点一致(String(runtime.key || ''))。
830
+ pathsByKey.delete(String(runtime.key || ''))
822
831
  }
823
832
  function disposeAll(reason) {
824
833
  for (const pair of collectStates()) disposeRuntime(pair.runtime)
@@ -95,6 +95,23 @@ export function parseDurableEventLine(line) {
95
95
 
96
96
  // ========== Store(appen-only JSONL,engine 级串行链) ==========
97
97
 
98
+ /**
99
+ * ★P3-9(组2,2026-09-22):诊断**节流**表。
100
+ * 原先 `_writeLine` 每次写失败都直接 console.error ⇒ 持续失败(磁盘满/权限错)时刷屏。
101
+ * 表放**模块级**而非实例级:同进程多实例共享 ⇒ 不会各自持一份而双写。
102
+ * 语义同 index.js 的 diagThrottled:首条立即透出,窗口内重复压掉。
103
+ */
104
+ const DIAG_THROTTLE_MS_V1 = 300000
105
+ const _diagAt = new Map()
106
+ function shouldReportDiag(key) {
107
+ try {
108
+ const now = Date.now()
109
+ if (now - (Number(_diagAt.get(key)) || 0) < DIAG_THROTTLE_MS_V1) return false
110
+ _diagAt.set(key, now)
111
+ } catch (_) { /* 计时失败不阻断上报 */ }
112
+ return true
113
+ }
114
+
98
115
  export class EvidenceEventStore {
99
116
  constructor(opts = {}) {
100
117
  this.root = opts.root || null
@@ -103,6 +120,15 @@ export class EvidenceEventStore {
103
120
  this._appended = new BoundedIdSet(EVIDENCE_STORE_POLICY_V1.appendedIdCache)
104
121
  this._swept = false
105
122
  this.stats = { appended: 0, duplicates: 0, oversize: 0, invalid: 0, writeFailed: 0, sweptFiles: 0, lastWriteError: null }
123
+ // ★P3-9(组2):可注入诊断通道(宿主不注入时 reportDiag 回落 console.error,行为向后兼容)。
124
+ this._onDiag = typeof opts.onDiag === 'function' ? opts.onDiag : null
125
+ }
126
+
127
+ /** ★P3-9(组2):节流上报。首条立即透出,窗口内重复压掉。 */
128
+ reportDiag(key, msg) {
129
+ if (!shouldReportDiag(key)) return
130
+ if (this._onDiag) { try { this._onDiag(key, msg); return } catch (_) { /* 回落 */ } }
131
+ try { console.error(msg) } catch (_) {}
106
132
  }
107
133
 
108
134
  /** §9 幂等:同 evidenceId 进程内只落盘一次(seed replay 与 live 双喂安全网;磁盘侧靠 rebuild 去重兜底)。 */
@@ -140,7 +166,7 @@ export class EvidenceEventStore {
140
166
  } catch (e) {
141
167
  this.stats.writeFailed++
142
168
  this.stats.lastWriteError = String(e && e.message ? e.message : e)
143
- try { console.error('[evidence-store] write-failed: ' + this.stats.lastWriteError) } catch (_) {}
169
+ try { this.reportDiag('evidence:write-failed', '[evidence-store] write-failed: ' + this.stats.lastWriteError) } catch (_) {}
144
170
  return false
145
171
  }
146
172
  }