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/client.js +1971 -1738
- package/lib/dev-reload.js +123 -24
- package/lib/dev-reload.js.bak +742 -0
- package/lib/http-guard.js +239 -222
- package/lib/index.js +126 -10
- package/lib/voice-pack-download.js +142 -23
- package/lib/voice-pack-route.js +260 -258
- package/lib/voice-regions.js +153 -0
- package/package.json +1 -1
- package/test/client-contract.test.mjs +171 -0
- package/test/config-coverage.test.mjs +75 -0
- package/test/diagnostics.test.mjs +5 -3
- package/test/docs-consistency.test.mjs +18 -0
- package/test/fingerprint-scope.test.mjs +89 -0
- package/test/http-guard.test.mjs +63 -6
- package/test/reload-review.test.mjs +158 -0
- package/test/review-fixes.test.mjs +164 -0
- package/test/review3-fixes.test.mjs +232 -0
- package/test/voice-pack-download.test.mjs +47 -0
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
|
-
|
|
134
|
-
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
180
|
+
slot.hook = hook;
|
|
181
|
+
return hook;
|
|
163
182
|
} catch {
|
|
164
183
|
// 重复注册等情况:忽略,不影响正常路径
|
|
184
|
+
return null;
|
|
165
185
|
}
|
|
166
|
-
})
|
|
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
|
-
//
|
|
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
|
-
//
|
|
507
|
-
//
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|