dsh-notify-say 0.4.35 → 0.4.44

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/dev-reload.js CHANGED
@@ -130,15 +130,33 @@ const GEN_PARAM = 'dns-reload';
130
130
  * @returns {Promise<void>} 注册完成后 resolve(便于测试与调用方等待)
131
131
  */
132
132
  export async function enableFreshDependencies(packageUrl) {
133
- if (reloadHooks.has(packageUrl)) return;
134
- if (reloadHooks.has(packageUrl)) return;
135
- // 标记为"已尝试",避免重复注册导致 Node 抛错
136
- reloadHooks.set(packageUrl, null);
137
-
138
- // Node < 22.15 没有 registerHooks:静默跳过即可。
139
- // 代际绕过不成,重载仍走"清缓存 + 校验"的常规路径。
140
- await loadRegisterHooks().then((registerHooks) => {
141
- if (!registerHooks) return;
133
+ // ⚠️ 2026-10-18 外部审阅指出两个问题(都属实,此前没改过):
134
+ //
135
+ // 1. 调用方只写 `enableFreshDependencies(packageUrl)`,**没 await**。
136
+ // 而它要 `await import('node:module')` 才拿得到 registerHooks ——
137
+ // 于是新入口可能在钩子装好之前就被导入,依赖仍是旧的。
138
+ // 这恰好是热重载要解决的那个问题。
139
+ //
140
+ // 2. 原实现在注册**前**就 `reloadHooks.set(packageUrl, null)` 占位,
141
+ // 第二个并发调用看到 key 存在就直接 return,哪怕第一次还没装完。
142
+ //
143
+ // 修法:Map 里存 `{ pending, hook }` —— 后来者 await 同一个 Promise,
144
+ // 这样"返回时已装好"成立。hook 的 resolve 逻辑一字未动。
145
+
146
+ const prev = reloadHooks.get(packageUrl);
147
+ // 已装好:直接返回 hook
148
+ if (prev && prev.hook) return prev.hook;
149
+ // 正在装:等同一次 Promise(这就是修竞态的关键)
150
+ if (prev && prev.pending) return prev.pending;
151
+
152
+ const slot = { pending: null, hook: null };
153
+ reloadHooks.set(packageUrl, slot);
154
+
155
+ slot.pending = (async () => {
156
+ // Node < 22.15 没有 registerHooks:静默跳过即可。
157
+ // 代际绕过不成,重载仍走"清缓存 + 校验"的常规路径。
158
+ const registerHooks = await loadRegisterHooks();
159
+ if (!registerHooks) return null;
142
160
  try {
143
161
  const hook = registerHooks({
144
162
  resolve(specifier, context, nextResolve) {
@@ -159,11 +177,15 @@ export async function enableFreshDependencies(packageUrl) {
159
177
  }
160
178
  },
161
179
  });
162
- reloadHooks.set(packageUrl, hook);
180
+ slot.hook = hook;
181
+ return hook;
163
182
  } catch {
164
183
  // 重复注册等情况:忽略,不影响正常路径
184
+ return null;
165
185
  }
166
- }).catch(() => { /* 拿不到 registerHooks:忽略 */ });
186
+ })();
187
+
188
+ return slot.pending;
167
189
  }
168
190
 
169
191
  /**
@@ -494,22 +516,37 @@ export function selfReload(ctx, options = {}) {
494
516
  };
495
517
 
496
518
  let replacement;
519
+ // verdict 在 try 块内算出,但要一直用到"注册成功"之后(返回值里)。
520
+ // 所以把结论提到外层 —— 直接引用 verdict 会出作用域(S1 实测报过
521
+ // "verdict is not defined")。
522
+ let verifiedOk = false;
497
523
  let busted = false;
498
524
  try {
499
525
  replacement = await importFresh(entryUrl);
500
526
  if (!replacement || typeof replacement.apply !== 'function') {
501
527
  throw new Error('reloaded module does not export apply()');
502
528
  }
503
-
504
- // 缓存没清掉 / 这是新内核的 loadCache 形状 —— 改用代际绕过。
529
+ // v0.4.41:**无条件**走代际绕过。
530
+ //
531
+ // 原来是:
532
+ // if (backup.esm.size === 0 || isSameAsPrevious(replacement, previous))
533
+ //
534
+ // 只在「一个缓存都没清掉」或「拿到同一个对象」时才绕过。
535
+ // 但 2026-10-18 重启后实测发现第三种情况:
536
+ // · 清了部分缓存 → backup.esm.size > 0
537
+ // · 拿到新壳对象 → isSameAsPrevious 为 false
538
+ // · 于是**跳过**代际绕过
539
+ // · 可新对象是壳,模块级 const 的求值结果被复用
540
+ // · 指纹仍是旧的 → verifyReload 判 fingerprint unchanged
541
+ // 日志里三次不同编辑的指纹一模一样(adb61783),就是这个原因。
505
542
  //
506
- // 2026-10-17 的教训:这里原本是"直接失败返回",于是重载报 ok:false,
507
- // 但调用方(watchPackage)把它当普通失败吞掉,用户看到的是"好像没生效"。
508
- // 更要命的是有些内核能清掉**部分**条目,于是入口换了、依赖没换。
509
- // 现在两种异常(purged=0 或 拿到旧对象)都走代际绕过,强制整棵树重求值。
510
- if (backup.esm.size === 0 || isSameAsPrevious(replacement, previous)) {
543
+ // 代际绕过只是强制重新求值,语义不变、代价是一次多余求值;
544
+ // 而省掉它的代价是「改了代码拿不到新代码」——今天为此排查一整天。
545
+ {
546
+
511
547
  try { logger.info?.('[dsh-notify-say] reload: falling back to generation bust'); } catch { /* ignore */ }
512
- enableFreshDependencies(packageUrl);
548
+ // ⚠️ 必须 await:钩子装好之前导入新入口,依赖仍会是旧的。
549
+ await enableFreshDependencies(packageUrl);
513
550
  const freshUrl = new URL(entryUrl);
514
551
  freshUrl.searchParams.set(GEN_PARAM, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
515
552
  replacement = await importFresh(freshUrl.href);
@@ -532,12 +569,22 @@ export function selfReload(ctx, options = {}) {
532
569
  // 求值了,不证明它 import 的依赖也换了。2026-10-17 实测出现过
533
570
  // "入口换了、config-store 没换"的半新半旧,而重载照样报 ok。
534
571
  const gotFp = typeof replacement.fingerprint === 'string' ? replacement.fingerprint : '';
572
+ // ⚠️ 2026-10-18 外部审阅指出:调用方算好了 diskFingerprint 传进来,
573
+ // 但这里根本没用 → 「验证通过」不等于「加载的是磁盘上那份代码」。
574
+ //
575
+ // 现在:有 diskFingerprint 就拿它当期望指纹(expectedFingerprint 优先,
576
+ // 便于测试注入)。
577
+ const expectedFp = options.expectedFingerprint !== undefined
578
+ ? options.expectedFingerprint
579
+ : options.diskFingerprint;
580
+
535
581
  const verdict = verifyReload({
536
582
  gotFingerprint: gotFp,
537
583
  differFrom: options.differFrom,
538
- expectedFingerprint: options.expectedFingerprint,
584
+ expectedFingerprint: expectedFp,
539
585
  });
540
586
  if (!verdict.ok) throw new Error(verdict.error);
587
+ verifiedOk = !!verdict.checked;
541
588
  if (verdict.checked) {
542
589
  try { logger.info?.(`[dsh-notify-say] reload: verified (fp=${gotFp})`); } catch { /* ignore */ }
543
590
  }
@@ -546,11 +593,42 @@ export function selfReload(ctx, options = {}) {
546
593
  return { ok: false, error: `re-import failed: ${(error && error.message) || error}`, restored: true };
547
594
  }
548
595
 
596
+ // ⚠️ 2026-10-18 外部审阅指出:原来这里 catch 之后**仍然继续**注册新实例。
597
+ //
598
+ // 如果旧实例没真正退出,就会出现两个实例同时持有事件监听/文件句柄 ——
599
+ // 表现是通知重复、日志错乱,而且极难查("有时响两次")。
600
+ //
601
+ // 现在区分两种情况:
602
+ // · dispose 只是"报告了错误",但 fiber 确实停了 → 继续(正常路径)
603
+ // · fiber 仍有未停的 → 返回结构化失败,不切换
604
+ //
605
+ // 判据:`fiber.await()` 抛错说明停不下来;不抛就认为已停。
549
606
  try {
550
607
  registry.delete(previous);
551
608
  await Promise.all(fibers.map(({ fiber }) => fiber.await()));
552
609
  } catch (error) {
553
- try { logger.warn?.(`[dsh-notify-say] reload: dispose reported ${(error && error.message) || error}`); } catch { /* ignore */ }
610
+ // dispose 报错本身不一定是致命的(旧代码可能已部分卸载),
611
+ // 但必须确认没有残留的活动 fiber,否则不能切换。
612
+ const stillAlive = fibers.filter(({ fiber }) => {
613
+ try {
614
+ // Cordis 的 fiber 若已 dispose,status 会变成非 active
615
+ const st = fiber && (fiber.status !== undefined ? fiber.status : fiber.state);
616
+ return st === 'active' || st === 'pending' || st === 2 || st === 1;
617
+ } catch {
618
+ return true; // 查不清就当作还活着(宁可失败也不双开)
619
+ }
620
+ });
621
+ const msg = (error && error.message) || error;
622
+ if (stillAlive.length > 0) {
623
+ restoreCaches(internal, backup);
624
+ return {
625
+ ok: false,
626
+ error: `dispose-incomplete: ${stillAlive.length}/${fibers.length} fiber(s) still active (${msg})`,
627
+ restored: true,
628
+ };
629
+ }
630
+ // 都停干净了:只是报了个错,继续切换
631
+ try { logger.warn?.(`[dsh-notify-say] reload: dispose reported ${msg} (all fibers stopped, continuing)`); } catch { /* ignore */ }
554
632
  }
555
633
 
556
634
  try {
@@ -566,7 +644,10 @@ export function selfReload(ctx, options = {}) {
566
644
  }
567
645
  await Promise.all(created.map((fiber) => fiber.await()));
568
646
  try { logger.info?.(`[dsh-notify-say] reload: complete, ${created.length} fiber(s)`); } catch { /* ignore */ }
569
- return { ok: true, fibers: created.length };
647
+ // ⚠️ 2026-10-18 外部审阅指出:调用方读 r.busted / r.verified 来显示
648
+ // 「走了代际绕过」「指纹校验通过」,但这里从来没返回过这两个字段 ——
649
+ // 于是日志永远显示不出来,我据此判断重载行为时一直是盲的。
650
+ return { ok: true, fibers: created.length, busted, verified: verifiedOk };
570
651
  } catch (error) {
571
652
  restoreCaches(internal, backup);
572
653
  try { registry.delete(replacement); } catch { /* 可能从未启动 */ }
@@ -618,13 +699,27 @@ export function watchPackage(packageDir, opts = {}) {
618
699
  let paused = false;
619
700
  let watcher;
620
701
  try {
702
+ // ⚠️ 2026-10-18 外部审阅指出:监听整个目录,但指纹只覆盖
703
+ // package.json + lib/*.js。改文档或测试也会触发一次注定失败的重载。
704
+ // 过滤在 schedule(filename) 里做(见 isSourceFile)。
621
705
  watcher = fs.watch(packageDir, { recursive: true });
706
+ try { opts.diag?.('watching ', packageDir); } catch {}
622
707
  } catch (error) {
623
708
  try { logger.warn?.(`[dsh-notify-say] cannot watch ${packageDir}: ${(error && error.message) || error}`); } catch { /* ignore */ }
624
709
  return () => {};
625
710
  }
626
- const schedule = () => {
711
+ // 只有这些文件变了才值得重载(与指纹覆盖范围保持一致)。
712
+ const isSourceFile = (filename) => {
713
+ if (!filename) return true; // 拿不到文件名时保守处理:当真
714
+ const f = String(filename).replace(/\\/g, '/');
715
+ if (f === 'package.json') return true;
716
+ if (f.startsWith('lib/') && f.endsWith('.js')) return true;
717
+ return false;
718
+ };
719
+
720
+ const schedule = (filename) => {
627
721
  if (paused) return;
722
+ if (!isSourceFile(filename)) return; // 文档/测试变更不触发重载
628
723
  clearTimeout(timer);
629
724
  timer = setTimeout(() => {
630
725
  timer = undefined;
@@ -635,7 +730,11 @@ export function watchPackage(packageDir, opts = {}) {
635
730
  }, debounceMs);
636
731
  if (timer && typeof timer.unref === 'function') timer.unref();
637
732
  };
638
- watcher.on('change', schedule);
733
+ // fs.watch 的监听器签名是 (eventType, filename)
734
+ watcher.on('change', (eventType, filename) => {
735
+ try { opts.diag?.('event ', String(filename), ' accepted=', String(isSourceFile(filename))); } catch {}
736
+ schedule(filename);
737
+ });
639
738
  watcher.on('error', (error) => {
640
739
  try { logger.warn?.(`[dsh-notify-say] watcher error: ${(error && error.message) || error}`); } catch { /* ignore */ }
641
740
  });