dsh-hot-reload 0.2.3 → 0.2.4

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/CHANGELOG.md CHANGED
@@ -3,6 +3,37 @@
3
3
  All notable changes to `dsh-hot-reload` are documented here. This project
4
4
  follows [semantic versioning](https://semver.org/).
5
5
 
6
+ ## 0.2.4
7
+
8
+ `dsh-hot-reload` now hot-reloads itself when its own package is upgraded, and its
9
+ tracked reload state now survives both a self-reload and a restart. Config and
10
+ API unchanged: `debounce` and `profileDir` are still the only keys.
11
+
12
+ **Added**
13
+
14
+ - **The plugin now hot-reloads itself.** Upgrading `dsh-hot-reload` used to need
15
+ a manual dsh restart, because the running instance was the one doing the
16
+ swapping. Now the running instance imports the new module, commits its own
17
+ version and persists it, closes its watcher, and then swaps its own fiber in
18
+ place. The new `apply()` re-reads state and opens its own watcher — the old
19
+ watcher is closed before the new one opens, so there is never more than one
20
+ live watcher.
21
+ - **Reload state is persisted to a file.** `versions` / `failedVersions` /
22
+ `noticedVersions` are now written to `profileDir/.dsh-hot-reload-state.json`
23
+ and read back at startup, so a fresh instance inherits the versions the
24
+ previous one committed instead of treating already-loaded plugins as new and
25
+ spuriously re-reloading them. The file is written **atomically** — a `.tmp`
26
+ file is written and then renamed over the target, so a crash mid-write never
27
+ leaves a truncated file.
28
+
29
+ **Changed**
30
+
31
+ - **An unwritable state file now stops the plugin from starting.** Previously
32
+ that state lived only in memory and was lost on a self-reload or restart. If
33
+ the state file cannot be written at startup, `apply()` now throws instead of
34
+ running with in-memory-only state that a later self-reload or restart would
35
+ lose.
36
+
6
37
  ## 0.2.3
7
38
 
8
39
  A plugin that loads after `dsh-hot-reload` in the bundle order is now reloaded
package/README-zh.md CHANGED
@@ -35,6 +35,24 @@ dsh 自带的热重载(`cordis-plugin-hmr`)刻意忽略 `node_modules`,所
35
35
  重载。提示是**每个版本只给一次**,不是每次检查都给——所以,如果该插件已有
36
36
  足够时间启动,你却仍看到同一个版本被报告,请重启 dsh。
37
37
 
38
+ ### 升级 dsh-hot-reload 自身
39
+
40
+ `dsh-hot-reload` 也能热重载**它自己**。当它自己的包被升级时,运行中的实例会
41
+ 导入新模块、提交自己的版本并持久化、关闭自己的 watcher,然后就地把自己的 fiber
42
+ 换掉。新实例会重新读取状态文件并打开自己的 watcher——旧 watcher 先关闭、新 watcher
43
+ 后打开,因此任何时刻都不会有超过一个存活的 watcher。
44
+
45
+ 为了让这次交接——以及一次普通重启——安全,本插件把跟踪状态保存在一个文件里:
46
+ `profileDir/.dsh-hot-reload-state.json`。里面存放已提交的 `versions`、永不重试的
47
+ `failedVersions`,以及每个版本只提示一次的 `noticedVersions`。文件是**原子写入**的
48
+ ——先写一个 `.tmp` 文件,再重命名覆盖目标文件,所以写入中途崩溃绝不会留下残缺文件
49
+ ——并在启动时读回,因此新实例会继承上一实例提交的内容,而不会把已加载的插件当作
50
+ 新插件再重载一遍。
51
+
52
+ 这个状态文件是**必需的**。如果启动时无法写入它,本插件会**拒绝启动**(直接抛错),
53
+ 而不会带着只存在内存里的状态继续运行——那样的话,一次后续的自我重载或重启就会丢失
54
+ 这些状态。
55
+
38
56
  它**绝不会替你重启 dsh**——重启交给你(以及你的守护进程,如果有的话)。
39
57
 
40
58
  ## 你如何知道发生了什么
@@ -199,6 +217,10 @@ web 应用里的提示(且仅这一部分)还用到:
199
217
  版本,所以它选择重载,而不是采纳一个可能从未运行过的版本。对于 HMR 安全的插件,
200
218
  这只是无害的单次多余重载。
201
219
 
220
+ - 状态文件是**必需的**。本插件会把跟踪状态写入
221
+ `profileDir/.dsh-hot-reload-state.json`。如果这个文件无法写入——例如 profile
222
+ 目录只读——本插件会拒绝启动(直接抛错),而不会带着只存在内存里的状态继续运行。
223
+
202
224
  范围说明:本插件处理的是**已加载插件的升级**。安装一个**全新**插件是另一回事
203
225
  (把它的行加入 `cordis.patch.yml`,这个 dsh 本身已经会热应用)。
204
226
 
package/README.md CHANGED
@@ -41,6 +41,27 @@ Two cases produce no reload, by design:
41
41
  You are told **once per version**, not once per check — so if you still see
42
42
  the same version reported after the plugin has had time to start, restart dsh.
43
43
 
44
+ ### Upgrading dsh-hot-reload itself
45
+
46
+ `dsh-hot-reload` can hot-reload **itself** too. When its own package is
47
+ upgraded, the running instance imports the new module, commits its own version
48
+ and persists it, closes its watcher, and then swaps its own fiber in place. The
49
+ new instance re-reads the state file and opens its own watcher — the old watcher
50
+ is closed before the new one opens, so there is never more than one live watcher.
51
+
52
+ To make that handoff — and a plain restart — safe, the plugin keeps its tracked
53
+ state in a file: `profileDir/.dsh-hot-reload-state.json`. It holds the committed
54
+ `versions`, the never-retried `failedVersions`, and the once-announced
55
+ `noticedVersions`. The file is written **atomically** — a `.tmp` file is written
56
+ and then renamed over the target, so a crash mid-write never leaves a truncated
57
+ file — and read back at startup, so a fresh instance inherits what the previous
58
+ one committed instead of treating already-loaded plugins as new and re-reloading
59
+ them.
60
+
61
+ This state file is **required**. If it cannot be written at startup, the plugin
62
+ refuses to start (it throws) rather than running with in-memory-only state that
63
+ a later self-reload or restart would lose.
64
+
44
65
  It **never restarts dsh for you** — restarting is left to you (and your
45
66
  supervisor, if any).
46
67
 
@@ -230,6 +251,11 @@ live fiber to swap). It does **not** detect *silent* leaks:
230
251
  never have run. For an HMR-safe plugin this is a harmless single redundant
231
252
  reload.
232
253
 
254
+ - The state file is **required**. The plugin writes its tracked state to
255
+ `profileDir/.dsh-hot-reload-state.json`. If that file cannot be written — for
256
+ example because the profile directory is read-only — the plugin refuses to
257
+ start (it throws) rather than running with in-memory-only state.
258
+
233
259
  Scope note: this handles **upgrades of already-loaded plugins**. Installing a
234
260
  *brand-new* plugin is a separate concern (adding its row to `cordis.patch.yml`,
235
261
  which dsh already hot-applies).
package/lib/index.js CHANGED
@@ -28,7 +28,7 @@
28
28
  // crash dsh.
29
29
 
30
30
  import { watch } from "chokidar";
31
- import { readFileSync, existsSync } from "node:fs";
31
+ import { readFileSync, existsSync, writeFileSync, renameSync } from "node:fs";
32
32
  import { createRequire } from "node:module";
33
33
  import { fileURLToPath, pathToFileURL } from "node:url";
34
34
  import { dirname, join } from "node:path";
@@ -44,6 +44,12 @@ const cjsRequire = createRequire(import.meta.url);
44
44
  * package has no build step to generate a shared one from. */
45
45
  const EVENTS_ENDPOINT = "/dsh-hot-reload/events";
46
46
 
47
+ /** File under profileDir that persists versions / failedVersions / noticedVersions
48
+ * across a self-reload (and a restart), so a fresh apply() inherits the
49
+ * committed versions instead of treating them as first-seen and re-reloading
50
+ * unrelated plugins. Written atomically (a .tmp then rename). */
51
+ const STATE_FILE = ".dsh-hot-reload-state.json";
52
+
47
53
  /** handlePackage outcome: a reload was attempted and failed — never retry it. */
48
54
  const TERMINAL = Symbol("dsh-hot-reload:terminal");
49
55
 
@@ -72,6 +78,7 @@ export function apply(ctx, config = {}) {
72
78
  }
73
79
  const lockfile = join(profileDir, "pnpm-lock.yaml");
74
80
  const nodeModules = join(profileDir, "node_modules");
81
+ const stateFile = join(profileDir, STATE_FILE);
75
82
  // Validate the auto-detected dir loudly: watching a wrong/nonexistent lockfile
76
83
  // would silently track 0 packages and never fire.
77
84
  if (!existsSync(lockfile)) {
@@ -360,6 +367,24 @@ export function apply(ctx, config = {}) {
360
367
  // context that began tearing down while we were awaiting.
361
368
  if (disposed) throw new Error("dsh-hot-reload disposed mid-reload");
362
369
 
370
+ // ---- self-reload handoff ----
371
+ //
372
+ // Reloading THIS package destroys this closure: registry.delete(oldPlugin)
373
+ // below disposes this fiber and runs this instance's disposer. Before that
374
+ // destructive swap, commit the self version and persist + close the watcher,
375
+ // so the new apply() (run by the reattach) reads committed versions for
376
+ // everything — including the just-upgraded self — and opens its own watcher
377
+ // while this one is already closed: never two live watchers at once.
378
+ const isSelf = pkg === name;
379
+ if (isSelf) {
380
+ versions[pkg] = importedVersion;
381
+ delete failedVersions[pkg];
382
+ delete noticedVersions[pkg];
383
+ persistState(); // throws -> the swap is aborted and reported as failed
384
+ await closeWatcher();
385
+ selfReloading = true; // from here on the disposer must NOT abort the swap
386
+ }
387
+
363
388
  // Snapshot fibers before disposal, then swap: dispose old (runs ctx disposers),
364
389
  // re-instantiate the new plugin against each old fiber's entry + config.
365
390
  const fibers = [...runtime.fibers];
@@ -486,14 +511,115 @@ export function apply(ctx, config = {}) {
486
511
  // tracked state. Accepted tradeoff — if the loader is degraded at boot AND
487
512
  // the very first lockfile event is a real upgrade, that upgrade is adopted
488
513
  // silently (old code keeps running, no notice). Deliberate, not an oversight.
489
- let versions = boot ? currentVersions(boot) : null;
490
- const failedVersions = Object.create(null); // pkg -> version whose reload failed (never retried)
491
- const noticedVersions = Object.create(null); // pkg -> version already announced on a retryable path
514
+
515
+ // ---- persisted state ----
516
+ //
517
+ // versions / failedVersions / noticedVersions used to be closure-only, so a
518
+ // self-reload (registry.delete of THIS plugin) destroyed them and the fresh
519
+ // apply() treated every already-loaded plugin as first-seen, spuriously
520
+ // reloading them. They now persist to profileDir/.dsh-hot-reload-state.json.
521
+ const persisted = loadState();
522
+
523
+ // Seed from what a previous instance committed, then let the boot snapshot be
524
+ // authoritative for what is actually on disk NOW. The boot override keeps a
525
+ // normal dsh restart from re-reloading plugins that were upgraded while it was
526
+ // down (persisted is stale there); only packages the boot snapshot never saw —
527
+ // loaded "late" relative to this instance — keep their persisted version.
528
+ let versions = persisted?.versions ?? null;
529
+ if (!versions) {
530
+ versions = boot ? currentVersions(boot) : null;
531
+ } else if (boot) {
532
+ const bootVersions = currentVersions(boot);
533
+ for (const pkg in bootVersions) {
534
+ // A null boot read (package.json momentarily unreadable) is not
535
+ // authoritative — keep the persisted version rather than "changing" it.
536
+ if (bootVersions[pkg] != null) versions[pkg] = bootVersions[pkg];
537
+ }
538
+ }
539
+ const failedVersions = persisted?.failedVersions ?? Object.create(null); // pkg -> version, never retried
540
+ const noticedVersions = persisted?.noticedVersions ?? Object.create(null); // pkg -> version, announced once
541
+
542
+ let watcher = null;
543
+ let watcherClosed = false;
544
+ let selfReloading = false; // true while THIS instance is swapping itself out
492
545
  let timer = null;
493
546
  let pending = false; // at most ONE cycle queued behind the running one; bursts coalesce into it
494
547
  let disposed = false;
495
548
  let running = Promise.resolve(); // serializes reload cycles across debounce batches
496
549
 
550
+ /** Copy a JSON-parsed object into a null-prototype map, matching how these
551
+ * maps are created everywhere else in this file — membership is tested with
552
+ * `in`, so a plain object would report inherited names like "constructor". */
553
+ function toNullProto(obj) {
554
+ const out = Object.create(null);
555
+ if (obj && typeof obj === "object") {
556
+ for (const key in obj) out[key] = obj[key];
557
+ }
558
+ return out;
559
+ }
560
+
561
+ /** Read the persisted state, or null when there is none / it is unreadable. */
562
+ function loadState() {
563
+ let raw;
564
+ try {
565
+ raw = JSON.parse(readFileSync(stateFile, "utf8"));
566
+ } catch {
567
+ return null;
568
+ }
569
+ // A null/absent `versions` field means "no baseline yet" (adopt on the first
570
+ // successful snapshot); a non-object field (hand-edited/corrupt) is treated
571
+ // the same rather than being diffed against as a fake baseline.
572
+ const versions =
573
+ raw && typeof raw.versions === "object" && raw.versions ? toNullProto(raw.versions) : null;
574
+ return {
575
+ versions,
576
+ failedVersions: toNullProto(raw?.failedVersions),
577
+ noticedVersions: toNullProto(raw?.noticedVersions),
578
+ };
579
+ }
580
+
581
+ /** Atomically persist the tracked maps (write a .tmp then rename over it, so a
582
+ * crash mid-write never leaves a truncated file). Throws — fail-loud — on any
583
+ * write or rename failure: running on with a state file that cannot be written
584
+ * would lose every committed version at the next self-reload/restart and
585
+ * spuriously reload unrelated plugins. `versions` is written verbatim so a
586
+ * null baseline round-trips as null (not {}). */
587
+ function persistState() {
588
+ const payload = JSON.stringify(
589
+ {
590
+ versions,
591
+ failedVersions,
592
+ noticedVersions,
593
+ },
594
+ null,
595
+ 2
596
+ );
597
+ const tmp = `${stateFile}.tmp`;
598
+ try {
599
+ writeFileSync(tmp, payload);
600
+ renameSync(tmp, stateFile);
601
+ } catch (err) {
602
+ throw new Error(
603
+ `dsh-hot-reload: cannot persist reload state to ${stateFile}: ${err?.message ?? err}`,
604
+ { cause: err }
605
+ );
606
+ }
607
+ }
608
+
609
+ /** Close the watcher exactly once, whatever path (self-reload or teardown)
610
+ * reaches here first. */
611
+ async function closeWatcher() {
612
+ if (watcherClosed || !watcher) return;
613
+ watcherClosed = true;
614
+ try {
615
+ await watcher.close();
616
+ } catch {}
617
+ }
618
+
619
+ // Fail loud at startup: if the state file cannot be written now, stop rather
620
+ // than run with in-memory-only state that a later self-reload would lose.
621
+ persistState();
622
+
497
623
  async function runCycle() {
498
624
  if (disposed) return;
499
625
  // ONE loader enumeration + ONE package.json read per package for the whole
@@ -542,10 +668,12 @@ export function apply(ctx, config = {}) {
542
668
  const commit = await handlePackage(pkg, snap[pkg]);
543
669
  if (commit === TERMINAL) {
544
670
  failedVersions[pkg] = version; // attempted, failed: don't try this version again
671
+ persistState();
545
672
  } else if (commit) {
546
673
  versions[pkg] = commit; // the version actually imported, not the cycle-start one
547
674
  delete failedVersions[pkg];
548
675
  delete noticedVersions[pkg];
676
+ persistState();
549
677
  }
550
678
  // commit === false: not attempted (teardown, or nothing attached yet) —
551
679
  // leave it uncommitted and retryable on a later event.
@@ -554,13 +682,16 @@ export function apply(ctx, config = {}) {
554
682
  // when its directory is missing. A point-in-time fs probe is wrong twice
555
683
  // over: a dangling pnpm symlink mid-swap would evict a live plugin, and a
556
684
  // removed plugin row whose package stays installed would be tracked forever.
685
+ let dropped = false;
557
686
  for (const pkg of Object.keys(versions)) {
558
687
  if (!(pkg in snap)) {
559
688
  delete versions[pkg];
560
689
  delete failedVersions[pkg];
561
690
  delete noticedVersions[pkg];
691
+ dropped = true;
562
692
  }
563
693
  }
694
+ if (dropped) persistState();
564
695
  }
565
696
 
566
697
  const trigger = () => {
@@ -579,12 +710,20 @@ export function apply(ctx, config = {}) {
579
710
  }, debounceMs);
580
711
  };
581
712
 
582
- const watcher = watch(lockfile, { ignoreInitial: true });
713
+ watcher = watch(lockfile, { ignoreInitial: true });
583
714
  watcher.on("change", trigger);
584
715
  watcher.on("add", trigger);
585
716
  watcher.on("error", (e) => log.warn?.("dsh-hot-reload: watcher error", e));
586
717
 
587
718
  ctx.effect(() => async () => {
719
+ if (selfReloading) {
720
+ // The self-reload swap is in flight: reloadEntry has already persisted
721
+ // state and closed the watcher. Do NOT set `disposed` (it would abort the
722
+ // swap mid-flight) and do NOT close the watcher again. Only keep an
723
+ // in-flight cycle rejection from going unhandled.
724
+ running.catch(() => {});
725
+ return;
726
+ }
588
727
  // Never wait on an in-flight reload: dsh's shutdown must not hang on an
589
728
  // arbitrary plugin's apply()/fiber.await(). Setting `disposed` first makes
590
729
  // any straggling cycle harmless — it stops between modules, and a reload
@@ -593,9 +732,7 @@ export function apply(ctx, config = {}) {
593
732
  disposed = true;
594
733
  if (timer) clearTimeout(timer);
595
734
  running.catch(() => {}); // keep an in-flight rejection from going unhandled
596
- try {
597
- await watcher.close();
598
- } catch {}
735
+ await closeWatcher();
599
736
  });
600
737
 
601
738
  // ---- notice channel ----
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-hot-reload",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "description": "Live-reload upgraded DeepSeek Harness (dsh) plugins without restarting dsh \u2014 the running plugin is swapped in place, and a reload that fails rolls back to the working old version and asks for a manual restart.",
6
6
  "keywords": [