memory-eternal 0.10.39 → 0.10.41

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/watchdog.js CHANGED
@@ -351,6 +351,8 @@ export async function stopWatchdogs({ env = process.env, port, timeoutMs = 5000,
351
351
  for (const p of ports) {
352
352
  const found = await probeListener(p)
353
353
  if (found && found.pid) portOwners.push({ port: p, pid: Number(found.pid), command: String(found.command || '') })
354
+ // 「未知」要留痕(issue #45 后续):静默等于把人引回「端口空闲」的老误判
355
+ else if (found && found.unknown) warnings.push(`端口 ${p} 的占用者无法判定(进程枚举超时/不可用)—— 未知,不代表空闲`)
354
356
  }
355
357
  if (portOwners.length && forcePortOccupant) {
356
358
  for (const occ of portOwners) {
@@ -576,6 +578,46 @@ export async function probe(port, timeoutMs = 1000) {
576
578
  *
577
579
  * @returns {Promise<string>} 取不到返回空串
578
580
  */
581
+ /**
582
+ * 内部:向某端口发一条 GET 并解析 JSON。**每次都销毁 socket**(issue #45 后续第 4 条)。
583
+ * @returns {Promise<{status:number, data:any, error:string}>}
584
+ */
585
+ function requestJson(port, route, timeoutMs = 1200) {
586
+ return new Promise((resolve) => {
587
+ const req = http.request({ host: '127.0.0.1', port, path: route, method: 'GET', timeout: timeoutMs }, (res) => {
588
+ const chunks = []
589
+ res.on('data', (c) => chunks.push(c))
590
+ res.on('end', () => {
591
+ try { resolve({ status: res.statusCode || 0, data: JSON.parse(Buffer.concat(chunks).toString('utf8')), error: '' }) }
592
+ catch { resolve({ status: res.statusCode || 0, data: null, error: 'BAD_JSON' }) }
593
+ })
594
+ res.on('error', (e) => { try { res.destroy() } catch {} resolve({ status: 0, data: null, error: 'RES_ERROR:' + String((e && e.code) || e) }) })
595
+ })
596
+ req.on('error', (e) => resolve({ status: 0, data: null, error: String((e && e.code) || e) }))
597
+ req.on('timeout', () => { try { req.destroy() } catch {} resolve({ status: 0, data: null, error: 'TIMEOUT' }) })
598
+ req.end()
599
+ })
600
+ }
601
+
602
+ /**
603
+ * 「端口上的版本」+ **失败原因**(issue #45 后续第 3 条:`超时` 与 `没有服务` 必须能分开报)。
604
+ * 纯诊断用;返回的 version 为空时,timedOut 告诉你这是超时还是根本没人应答。
605
+ * @returns {Promise<{version:string, timedOut:boolean, lastError:string}>}
606
+ */
607
+ export async function probeServedVersionDetailed(port, timeoutMs = 1200, { tries = 3 } = {}) {
608
+ let lastError = ''
609
+ for (let i = 0; i < Math.max(1, tries); i++) {
610
+ const a = await requestJson(port, `${API_PREFIX}/version-check`, timeoutMs)
611
+ if (a.data && a.data.ok === true && typeof a.data.loaded === 'string' && a.data.loaded) return { version: String(a.data.loaded), timedOut: false, lastError: '' }
612
+ if (a.error) lastError = a.error
613
+ const b = await requestJson(port, `${API_PREFIX}/overview`, timeoutMs)
614
+ if (b.data && typeof b.data.version === 'string' && b.data.version) return { version: String(b.data.version), timedOut: false, lastError: '' }
615
+ if (b.error) lastError = b.error
616
+ if (i < tries - 1) await new Promise((r) => setTimeout(r, 150 * (i + 1)))
617
+ }
618
+ return { version: '', timedOut: /TIMEOUT/i.test(lastError), lastError }
619
+ }
620
+
579
621
  export async function probeServedVersion(port, timeoutMs = 1200) {
580
622
  // issue #31/#33 第②层:`/overview` 会碰数据库,库只读/损坏时它 500 —— 于是「端口上明明活着、
581
623
  // 版本却拿不到」:verify 报「(无响应)」、status 甚至误判成「v0.10.4 之前的旧版」,进而触发
@@ -603,7 +645,7 @@ export async function probeServedVersion(port, timeoutMs = 1200) {
603
645
  async function probeViaVersionCheck(port, timeoutMs = 1200) {
604
646
  return new Promise((resolve) => {
605
647
  const req = http.request({ host: '127.0.0.1', port, path: `${API_PREFIX}/version-check`, method: 'GET', timeout: timeoutMs }, (res) => {
606
- if (res.statusCode !== 200) return resolve('')
648
+ if (res.statusCode !== 200) { try { res.resume(); res.destroy() } catch { /* 已关闭 */ } return resolve('') }
607
649
  const chunks = []
608
650
  res.on('data', (c) => chunks.push(c))
609
651
  res.on('end', () => {
@@ -917,7 +959,8 @@ export async function clearResidentForTakeover({ port = 7999, env = process.env,
917
959
  const findListener = deps.findListener || findPortListener
918
960
  // 枚举本身要能被时间预算约束(否则 8s 预算被一次 13.6s 的 PowerShell 查询吃光,决策基于过期数据)
919
961
  const listenerOpts = { timeoutMs: Math.max(500, Math.min(2500, Number(timeoutMs) || 8000)) }
920
- const out = { stopped: [], webStopped: [], portStopped: [], warnings: [], failed: [], portFree: false }
962
+ // portFree 只是兼容布尔;**判定看 portState**(issue #45 后续:超时 ≠ 空闲)。
963
+ const out = { stopped: [], webStopped: [], portStopped: [], warnings: [], failed: [], portFree: false, portState: 'unknown' }
921
964
  const rawKill = deps.kill || ((pid) => { try { process.kill(pid, 'SIGTERM'); return true } catch { return false } })
922
965
  // 记下「已经判定收不掉」的 pid:立刻再来一次只会同样失败,白折腾还可能误导用户以为是权限抖动
923
966
  const failedKills = new Set()
@@ -949,7 +992,14 @@ export async function clearResidentForTakeover({ port = 7999, env = process.env,
949
992
  }
950
993
  if (left && left.pid) {
951
994
  out.portFree = false
995
+ out.portState = 'occupied'
952
996
  out.warnings.push(`端口 ${p} 仍被 pid ${left.pid} 占用(${left.command || '命令行未知'})`)
997
+ } else if (left && left.unknown) {
998
+ // 枚举器超时/不可用 —— 这是「未知」,不是「空闲」:中止接管(不杀、不漂移、不动锁)。
999
+ out.portFree = false
1000
+ out.portState = 'unknown'
1001
+ out.unknown = true
1002
+ out.warnings.push(`端口 ${p} 的占用者**无法判定**(进程枚举超时/不可用)→ 已中止接管:既不按「端口空闲」继续,也不把 web 挪到 port+1`)
953
1003
  } else {
954
1004
  // 枚举不到 ≠ 端口是空的(issue #30 现场):受限环境/被拒的进程查询会让 findListener 返回 null,
955
1005
  // 旧实现据此认为"端口已空出" → 新 web 绑不上 7999 → 退让或失败 → 自检「(无响应)」+ 锁被清空。
@@ -959,9 +1009,11 @@ export async function clearResidentForTakeover({ port = 7999, env = process.env,
959
1009
  try { listening = Boolean(await probe(p)) } catch { listening = false }
960
1010
  if (listening) {
961
1011
  out.portFree = false
1012
+ out.portState = 'occupied'
962
1013
  out.warnings.push(`端口 ${p} 仍在监听,但占用者无法识别(进程枚举被拒/不可用)—— 已中止接管,且未改动任何锁登记`)
963
1014
  } else {
964
1015
  out.portFree = true
1016
+ out.portState = 'free'
965
1017
  }
966
1018
  }
967
1019
  return out
@@ -1001,6 +1053,31 @@ export function parseLsofFields(text) {
1001
1053
  * @param {{exec?: (cmd:string,args:string[])=>Promise<string>}} [opts]
1002
1054
  * @returns {Promise<{pid:number, command:string}|null>}
1003
1055
  */
1056
+ /**
1057
+ * 纯函数:从 `netstat -ano` 输出里找出**监听指定端口**的 pid(issue #45 后续实测)。
1058
+ *
1059
+ * 为什么它是 Windows 上的首选:报告者实测 `Get-NetTCPConnection` 在本机要 **4.1–61.4 秒**
1060
+ * (TCP 表本身很大,它要先枚举整表再过滤),而 `netstat -ano` 全量只要 **1.8 秒**,且直接给 pid。
1061
+ * netstat 不连端口、不枚举两次 —— 对「端口上那个进程把 backlog 打满、连不上」的现场
1062
+ * (正是 0.10.36 自探递归留下的状态)也能给出正确结论。
1063
+ *
1064
+ * @returns {number} pid,没找到返回 0
1065
+ */
1066
+ export function parseNetstatListener(text, port) {
1067
+ const want = String(Number(port) || 0)
1068
+ if (!want || want === '0') return 0
1069
+ for (const line of String(text || '').split(/\r?\n/)) {
1070
+ if (!/LISTENING/i.test(line)) continue
1071
+ // 形如: TCP 127.0.0.1:7999 0.0.0.0:0 LISTENING 10924
1072
+ // TCP [::1]:7999 [::]:0 LISTENING 10924
1073
+ const m = /^\s*TCP\s+\S*:(\d+)\s+\S*:\d+\s+LISTENING\s+(\d+)\s*$/i.exec(line.trim())
1074
+ if (!m) continue
1075
+ if (String(Number(m[1])) !== want) continue
1076
+ return Number(m[2]) || 0
1077
+ }
1078
+ return 0
1079
+ }
1080
+
1004
1081
  export async function findPortListener(port, { exec, platform = process.platform, timeoutMs = 4000 } = {}) {
1005
1082
  const p = Number(port)
1006
1083
  if (!Number.isFinite(p) || p <= 0) return null
@@ -1008,6 +1085,24 @@ export async function findPortListener(port, { exec, platform = process.platform
1008
1085
  const execOpts = { timeoutMs: Math.max(200, Number(timeoutMs) || 4000) }
1009
1086
  try {
1010
1087
  if (platform === 'win32') {
1088
+ // ① netstat -ano 优先(issue #45 后续):不连端口,1.8s 量级给出监听 pid。
1089
+ // PowerShell 的 Get-NetTCPConnection 在 TCP 表大的机器上实测 4.1–61.4s ——
1090
+ // 一旦超时,旧实现返回 null,调用方把它当「端口空闲」,于是新看门狗去绑一个**还被旧 web 占着**的端口,
1091
+ // 只能漂到 port+1,旧 web 永远活着、修复永远轮不到执行(报告者的现场)。
1092
+ let netOut = null
1093
+ try { netOut = await runExec('netstat', ['-ano'], { timeoutMs: Math.max(3000, execOpts.timeoutMs) }) } catch { netOut = null }
1094
+ const netPid = netOut == null ? 0 : parseNetstatListener(netOut, p)
1095
+ if (netPid) {
1096
+ let cmd = ''
1097
+ try {
1098
+ cmd = String((await runExec('powershell', ['-NoProfile', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${netPid}" | Select-Object -ExpandProperty CommandLine)`], execOpts)) || '').trim()
1099
+ } catch { cmd = '' }
1100
+ return { pid: netPid, command: cmd }
1101
+ }
1102
+ // netOut 为 null = 命令没跑起来/被杀超时 → **未知**,绝不能当成「端口空闲」
1103
+ // 空输出同样不可信:netstat 正常时至少会有一堆行;空 = 命令没跑起来/被超时杀掉(defaultExec 超时返回 '')
1104
+ if (netOut == null || String(netOut).trim() === '') return { pid: 0, command: '', unknown: true }
1105
+ // ② 兜底:老路径(netstat 明确没有该端口的 LISTEN 行时,用 PowerShell 再确认一次,兼容旧系统)
1011
1106
  const out = await runExec('powershell', ['-NoProfile', '-Command', `Get-NetTCPConnection -LocalPort ${p} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess`], execOpts)
1012
1107
  const pid = Number(String(out || '').split(/\r?\n/).map((s) => s.trim()).find((s) => /^\d+$/.test(s))) || 0
1013
1108
  if (!pid) return null
@@ -1400,7 +1495,7 @@ if (process.argv[1] && /[\\/]watchdog\.js$/.test(process.argv[1])) {
1400
1495
  const claim = acquireWatchdogLock({ port, vault: vaultRoot || '', pkgVersion: currentPkgVersion(), force: true })
1401
1496
  if (claim.acquired) log('已登记本次替换尝试(端口仍被占用,看门狗未启动):pid ' + process.pid)
1402
1497
  else log('⚠ 未能登记本次替换(锁不可写?):' + (claim.file || ''))
1403
- const why = `${summary} —— 端口没空出来,已中止接管(不退让到 port+1,避免又留一个孤儿 web)`
1498
+ const why = `${summary} —— ${cleared.portState === 'unknown' ? '占用者无法判定(枚举超时/不可用),按未知处理' : '端口没空出来'},已中止接管(不退让到 port+1,避免又留一个孤儿 web)`
1404
1499
  await noteReplace('fail', why)
1405
1500
  process.exit(1)
1406
1501
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-eternal",
3
- "version": "0.10.39",
3
+ "version": "0.10.41",
4
4
  "description": "记忆核心(Memory Eternal):自研的 DeepSeek Harness 记忆插件,不移植任何既有记忆框架——对话结束后自动沉淀知识卡到本地 Markdown Vault(自研去重、自研 CJK 检索、可 git 管理),设置页提供图形化知识库(统计 / 搜索 / 知识图谱 + 侧边栏一键弹窗),Agent 通过 memory_recall 工具按需召回历史上下文。零人工干预。",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -64,7 +64,7 @@
64
64
  "prepublishOnly": "node build.mjs",
65
65
  "release": "node scripts/release.mjs",
66
66
  "release:dry": "node scripts/release.mjs --dry-run",
67
- "test": "node build.mjs && node tests/vault.test.mjs && node tests/capture.test.mjs && node tests/capture-route.test.mjs && node tests/stall.test.mjs && node tests/vault-path.test.mjs && node tests/vault-resolve.test.mjs && node tests/minhash.test.mjs && node tests/settings-save.test.mjs && node tests/config-coverage.test.mjs && node tests/config-sync.test.mjs && node tests/config-merge.test.mjs && node tests/sse.test.mjs && node tests/api.test.mjs && node tests/import-roundtrip.test.mjs && node tests/graph-perf.test.mjs && node tests/settings-compat.test.mjs && node tests/node-bin.test.mjs && node tests/watchdog-lock.test.mjs && node tests/markdown.test.mjs && node tests/feedback.test.mjs && node tests/graph-lod.test.mjs && node tests/ssr.test.mjs && node tests/web-assets.test.mjs && node tests/manifests.test.mjs && node tests/two-store.test.mjs && node tests/noise-gate.test.mjs && node tests/e2e-recall.test.mjs && node tests/issue-fixes.test.mjs && node tests/issue-fixes-2.test.mjs && node tests/issue-fixes-3.test.mjs && node tests/watchdog-orphans.test.mjs && node tests/watchdog-restart.test.mjs && node tests/readme-i18n.test.mjs && node tests/issue-fixes-4.test.mjs && node tests/host-drain.test.mjs && node tests/e2e-host-drain.mjs && node tests/issue-fixes-5.test.mjs && node tests/issue-fixes-6.test.mjs && node tests/capture-json-repair.test.mjs && node tests/csrf-origin.test.mjs && node tests/watchdog-orphan-web.test.mjs && node tests/config-validate.test.mjs && node tests/issue-fixes-8.test.mjs && node tests/issue-fixes-9.test.mjs && node tests/release-verify.test.mjs && node tests/refine.test.mjs && node tests/db-restore.test.mjs && node tests/align.test.mjs && node tests/tags-normalize.test.mjs && node tests/resident-state.test.mjs && node tests/llm-route.test.mjs && node tests/host-managed.test.mjs && node tests/replace-claim.test.mjs && node tests/version-check-self.test.mjs && node tests/setup-codex.test.mjs"
67
+ "test": "node build.mjs && node tests/vault.test.mjs && node tests/capture.test.mjs && node tests/capture-route.test.mjs && node tests/stall.test.mjs && node tests/vault-path.test.mjs && node tests/vault-resolve.test.mjs && node tests/minhash.test.mjs && node tests/settings-save.test.mjs && node tests/config-coverage.test.mjs && node tests/config-sync.test.mjs && node tests/config-merge.test.mjs && node tests/sse.test.mjs && node tests/api.test.mjs && node tests/import-roundtrip.test.mjs && node tests/graph-perf.test.mjs && node tests/settings-compat.test.mjs && node tests/node-bin.test.mjs && node tests/watchdog-lock.test.mjs && node tests/markdown.test.mjs && node tests/feedback.test.mjs && node tests/graph-lod.test.mjs && node tests/ssr.test.mjs && node tests/web-assets.test.mjs && node tests/manifests.test.mjs && node tests/two-store.test.mjs && node tests/noise-gate.test.mjs && node tests/e2e-recall.test.mjs && node tests/issue-fixes.test.mjs && node tests/issue-fixes-2.test.mjs && node tests/issue-fixes-3.test.mjs && node tests/watchdog-orphans.test.mjs && node tests/watchdog-restart.test.mjs && node tests/readme-i18n.test.mjs && node tests/issue-fixes-4.test.mjs && node tests/host-drain.test.mjs && node tests/e2e-host-drain.mjs && node tests/issue-fixes-5.test.mjs && node tests/issue-fixes-6.test.mjs && node tests/capture-json-repair.test.mjs && node tests/csrf-origin.test.mjs && node tests/watchdog-orphan-web.test.mjs && node tests/config-validate.test.mjs && node tests/issue-fixes-8.test.mjs && node tests/issue-fixes-9.test.mjs && node tests/release-verify.test.mjs && node tests/refine.test.mjs && node tests/db-restore.test.mjs && node tests/align.test.mjs && node tests/tags-normalize.test.mjs && node tests/resident-state.test.mjs && node tests/llm-route.test.mjs && node tests/host-managed.test.mjs && node tests/replace-claim.test.mjs && node tests/version-check-self.test.mjs && node tests/setup-codex.test.mjs && node tests/listener-netstat.test.mjs && node tests/issue-46-overlay.test.mjs && node tests/graph-initial-zoom.test.mjs"
68
68
  },
69
69
  "dependencies": {
70
70
  "@deepseek-ai/schemastery": "^3.18.1"