dsh-hot-reload 0.2.2 → 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 +52 -0
- package/README-zh.md +32 -3
- package/README.md +39 -4
- package/lib/index.js +177 -20
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,58 @@
|
|
|
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
|
+
|
|
37
|
+
## 0.2.3
|
|
38
|
+
|
|
39
|
+
A plugin that loads after `dsh-hot-reload` in the bundle order is now reloaded
|
|
40
|
+
when it is upgraded, and every reload outcome — not just successes — reaches the
|
|
41
|
+
terminal. Config and API unchanged.
|
|
42
|
+
|
|
43
|
+
**Fixes**
|
|
44
|
+
|
|
45
|
+
- **A plugin loaded after this one now hot-reloads when upgraded.** A first-seen
|
|
46
|
+
package used to be adopted at face value whatever its version, so a plugin that
|
|
47
|
+
appears later in the bundle order (for example `dsh-crew`) kept running old
|
|
48
|
+
code when it had already been upgraded before this plugin's first cycle — the
|
|
49
|
+
change was silently missed. A first-seen package that has a live fiber is now
|
|
50
|
+
reloaded instead of adopted; a first-seen package with no live fiber is still
|
|
51
|
+
adopted, because there is nothing running to replace.
|
|
52
|
+
- **Every outcome now reaches the terminal.** `report()` used to write its stderr
|
|
53
|
+
line only for a successful reload. A failed or stale reload was then visible
|
|
54
|
+
only as a transient pop-up and a log line dsh never prints, so the "restart
|
|
55
|
+
dsh" instruction never reached anyone watching the terminal. stderr is now
|
|
56
|
+
written for every outcome — reloaded, failed, and stale.
|
|
57
|
+
|
|
6
58
|
## 0.2.2
|
|
7
59
|
|
|
8
60
|
Reloads now re-import a plugin's whole package, not just its entry module.
|
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
|
## 你如何知道发生了什么
|
|
@@ -42,7 +60,7 @@ dsh 自带的热重载(`cordis-plugin-hmr`)刻意忽略 `node_modules`,所
|
|
|
42
60
|
插件会把每个结果写进 dsh 的日志。但 dsh 不会把日志打印到你的终端,所以这些内容
|
|
43
61
|
很容易被忽略。另有两个地方会告诉你发生了什么。
|
|
44
62
|
|
|
45
|
-
**1.
|
|
63
|
+
**1. 每种结果,在你的终端里输出一行。** 任意 profile 都有:
|
|
46
64
|
|
|
47
65
|
```
|
|
48
66
|
dsh-hot-reload: hot-reloaded some-plugin@1.2.0 (1 module(s))
|
|
@@ -75,8 +93,9 @@ web 那一部分只在运行 web 服务器的 profile 中加载,并通过
|
|
|
75
93
|
|
|
76
94
|
### 如果你想在终端里看到全部内容
|
|
77
95
|
|
|
78
|
-
|
|
79
|
-
dsh
|
|
96
|
+
上面那一行覆盖每一种重载结果——成功、失败和 stale(未尝试、旧代码仍在运行)。若想
|
|
97
|
+
看到本插件写进日志的其它内容(它的警告和诊断信息),请把 dsh 的控制台日志插件加进
|
|
98
|
+
你的 profile。它是一个独立的包:
|
|
80
99
|
|
|
81
100
|
```sh
|
|
82
101
|
dsh plugin --profile web add @deepseek-ai/cordis-plugin-logger-console
|
|
@@ -192,6 +211,16 @@ web 应用里的提示(且仅这一部分)还用到:
|
|
|
192
211
|
这些内容,所以并没有多暴露什么秘密。但如果你把 dsh 绑定到 `0.0.0.0`,请把它
|
|
193
212
|
算作局域网里任何人都能打开的又一个地址。
|
|
194
213
|
|
|
214
|
+
- 在 bundle 顺序里排在 `dsh-hot-reload` **之后**加载的插件,会在启动后的第一次
|
|
215
|
+
lockfile 写入时被重载一次——重载到它当前的版本——即使那次写入针对的是另一个
|
|
216
|
+
包。在本插件跨过一轮循环、跟踪到该包之前,它无法分辨运行中的代码是旧版还是当前
|
|
217
|
+
版本,所以它选择重载,而不是采纳一个可能从未运行过的版本。对于 HMR 安全的插件,
|
|
218
|
+
这只是无害的单次多余重载。
|
|
219
|
+
|
|
220
|
+
- 状态文件是**必需的**。本插件会把跟踪状态写入
|
|
221
|
+
`profileDir/.dsh-hot-reload-state.json`。如果这个文件无法写入——例如 profile
|
|
222
|
+
目录只读——本插件会拒绝启动(直接抛错),而不会带着只存在内存里的状态继续运行。
|
|
223
|
+
|
|
195
224
|
范围说明:本插件处理的是**已加载插件的升级**。安装一个**全新**插件是另一回事
|
|
196
225
|
(把它的行加入 `cordis.patch.yml`,这个 dsh 本身已经会热应用)。
|
|
197
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
|
|
|
@@ -50,7 +71,7 @@ The plugin writes every result to dsh's log. But dsh does not print its log to
|
|
|
50
71
|
your terminal, so those lines are easy to miss. Two extra places show you what
|
|
51
72
|
happened.
|
|
52
73
|
|
|
53
|
-
**1. One line in your terminal, for
|
|
74
|
+
**1. One line in your terminal, for every outcome.** You get this in
|
|
54
75
|
every profile:
|
|
55
76
|
|
|
56
77
|
```
|
|
@@ -88,9 +109,10 @@ reload the page to start again.
|
|
|
88
109
|
|
|
89
110
|
### If you want every line in your terminal
|
|
90
111
|
|
|
91
|
-
The terminal line above
|
|
92
|
-
|
|
93
|
-
profile. It is a separate
|
|
112
|
+
The terminal line above covers every reload outcome — reloaded, failed, and
|
|
113
|
+
stale (not attempted — the old code is still running). To see everything else this plugin writes to the log (its warnings and
|
|
114
|
+
diagnostics), add dsh's console logger to your profile. It is a separate
|
|
115
|
+
package:
|
|
94
116
|
|
|
95
117
|
```sh
|
|
96
118
|
dsh plugin --profile web add @deepseek-ai/cordis-plugin-logger-console
|
|
@@ -221,6 +243,19 @@ live fiber to swap). It does **not** detect *silent* leaks:
|
|
|
221
243
|
secret. But if you bind dsh to `0.0.0.0`, count it as one more address that
|
|
222
244
|
anyone on your network can open.
|
|
223
245
|
|
|
246
|
+
- A plugin that loads **after** `dsh-hot-reload` in the bundle order is reloaded
|
|
247
|
+
once — to its current version — on the first lockfile write after boot, even
|
|
248
|
+
when that write was for an unrelated package. Until this plugin has tracked the
|
|
249
|
+
package across one cycle, it cannot tell whether the running code is the old or
|
|
250
|
+
the current version, so it reloads rather than adopting a version that may
|
|
251
|
+
never have run. For an HMR-safe plugin this is a harmless single redundant
|
|
252
|
+
reload.
|
|
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
|
+
|
|
224
259
|
Scope note: this handles **upgrades of already-loaded plugins**. Installing a
|
|
225
260
|
*brand-new* plugin is a separate concern (adding its row to `cordis.patch.yml`,
|
|
226
261
|
which dsh already hot-applies).
|
package/lib/index.js
CHANGED
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
// row with no fiber attached yet is reported and left for a later change.
|
|
19
19
|
//
|
|
20
20
|
// Outcomes are announced on two surfaces besides ctx.logger — one stderr line
|
|
21
|
-
// per
|
|
22
|
-
// turns into a transient toast. Both are additive and
|
|
23
|
-
// "notification surfaces" section in apply().
|
|
21
|
+
// per outcome (reloaded, failed, or stale), and an SSE channel the browser half
|
|
22
|
+
// (lib/client.js) turns into a transient toast. Both are additive and
|
|
23
|
+
// best-effort; see the "notification surfaces" section in apply().
|
|
24
24
|
//
|
|
25
25
|
// NOTE: the reload path uses cordis/loader internals (loader.internal.loadCache,
|
|
26
26
|
// registry.plugin/delete, fiber.entry) — the same ones HMR uses. If a future
|
|
@@ -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)) {
|
|
@@ -87,10 +94,11 @@ export function apply(ctx, config = {}) {
|
|
|
87
94
|
// and neither may throw into a reload cycle: a broken notification must never
|
|
88
95
|
// turn a working reload into a failed one.
|
|
89
96
|
//
|
|
90
|
-
// - stderr,
|
|
97
|
+
// - stderr, every cycle outcome. cordis's logger fans messages out to
|
|
91
98
|
// registered exporters, and the dsh host process registers none (only the
|
|
92
99
|
// browser shell does), so nothing this plugin logs reaches the terminal dsh
|
|
93
|
-
// runs in. One line per
|
|
100
|
+
// runs in. One line per outcome — reloaded, failed, or stale — is the
|
|
101
|
+
// profile-independent baseline.
|
|
94
102
|
// - an SSE channel the web half subscribes to (lib/client.js) and renders as
|
|
95
103
|
// a transient toast. Registered only when a webServer service exists, so a
|
|
96
104
|
// profile without one — tui — behaves exactly as it does today.
|
|
@@ -112,17 +120,23 @@ export function apply(ctx, config = {}) {
|
|
|
112
120
|
*
|
|
113
121
|
* `kind` is "reloaded" (it worked), "failed" (attempted and rolled back), or
|
|
114
122
|
* "stale" (not attempted; the old code is still running). It selects the log
|
|
115
|
-
* level and the browser's icon
|
|
116
|
-
*
|
|
123
|
+
* level and the browser's icon. Every kind also reaches stderr — successes
|
|
124
|
+
* and failures alike — because the dsh host registers no cordis logger
|
|
125
|
+
* exporter, so the log.* line above never reaches the terminal on its own.
|
|
126
|
+
* Callers pass the bare message — every surface adds its own prefix. */
|
|
117
127
|
function report(kind, message) {
|
|
118
128
|
if (kind === "reloaded") {
|
|
119
129
|
log.info?.(`dsh-hot-reload: ${message}`);
|
|
120
|
-
try {
|
|
121
|
-
process.stderr.write(`dsh-hot-reload: ${message}\n`);
|
|
122
|
-
} catch {}
|
|
123
130
|
} else {
|
|
124
131
|
log.warn?.(`dsh-hot-reload: ${message}`);
|
|
125
132
|
}
|
|
133
|
+
// One durable, profile-independent line per outcome, written for every kind.
|
|
134
|
+
// A failed/stale reload used to be visible only as a transient toast plus an
|
|
135
|
+
// invisible log line (dsh exports no logger), so "restart needed" never
|
|
136
|
+
// reached anyone looking at the terminal dsh runs in.
|
|
137
|
+
try {
|
|
138
|
+
process.stderr.write(`dsh-hot-reload: ${message}\n`);
|
|
139
|
+
} catch {}
|
|
126
140
|
if (!connections.size) return;
|
|
127
141
|
const line = `data: ${JSON.stringify({ type: "notice", kind, text: message })}\n\n`;
|
|
128
142
|
for (const res of connections) {
|
|
@@ -353,6 +367,24 @@ export function apply(ctx, config = {}) {
|
|
|
353
367
|
// context that began tearing down while we were awaiting.
|
|
354
368
|
if (disposed) throw new Error("dsh-hot-reload disposed mid-reload");
|
|
355
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
|
+
|
|
356
388
|
// Snapshot fibers before disposal, then swap: dispose old (runs ctx disposers),
|
|
357
389
|
// re-instantiate the new plugin against each old fiber's entry + config.
|
|
358
390
|
const fibers = [...runtime.fibers];
|
|
@@ -479,14 +511,115 @@ export function apply(ctx, config = {}) {
|
|
|
479
511
|
// tracked state. Accepted tradeoff — if the loader is degraded at boot AND
|
|
480
512
|
// the very first lockfile event is a real upgrade, that upgrade is adopted
|
|
481
513
|
// silently (old code keeps running, no notice). Deliberate, not an oversight.
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
|
485
545
|
let timer = null;
|
|
486
546
|
let pending = false; // at most ONE cycle queued behind the running one; bursts coalesce into it
|
|
487
547
|
let disposed = false;
|
|
488
548
|
let running = Promise.resolve(); // serializes reload cycles across debounce batches
|
|
489
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
|
+
|
|
490
623
|
async function runCycle() {
|
|
491
624
|
if (disposed) return;
|
|
492
625
|
// ONE loader enumeration + ONE package.json read per package for the whole
|
|
@@ -511,8 +644,21 @@ export function apply(ctx, config = {}) {
|
|
|
511
644
|
continue;
|
|
512
645
|
}
|
|
513
646
|
if (!(pkg in versions)) {
|
|
514
|
-
|
|
515
|
-
|
|
647
|
+
// A package our boot snapshot never saw. Two cases:
|
|
648
|
+
// - no live fiber: nothing is running yet (mid-import, or only disabled
|
|
649
|
+
// rows), so there is no stale code to replace — dsh loads fresh code
|
|
650
|
+
// itself. Adopt the version.
|
|
651
|
+
// - has a live fiber: the package was already running before we first
|
|
652
|
+
// saw it — it loaded after our boot snapshot, as a bundle entry
|
|
653
|
+
// ordered after this plugin. Its running code may therefore predate
|
|
654
|
+
// this version. Reload it rather than silently adopting a version
|
|
655
|
+
// that never ran (otherwise a late-loaded plugin upgraded before the
|
|
656
|
+
// first cycle keeps its old code forever, with no notice).
|
|
657
|
+
if (!snap[pkg].live.length) {
|
|
658
|
+
versions[pkg] = version;
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
// fall through: treat as changed and reload
|
|
516
662
|
}
|
|
517
663
|
if (versions[pkg] === version) continue;
|
|
518
664
|
// A version whose reload failed is never re-attempted: each attempt tears
|
|
@@ -522,10 +668,12 @@ export function apply(ctx, config = {}) {
|
|
|
522
668
|
const commit = await handlePackage(pkg, snap[pkg]);
|
|
523
669
|
if (commit === TERMINAL) {
|
|
524
670
|
failedVersions[pkg] = version; // attempted, failed: don't try this version again
|
|
671
|
+
persistState();
|
|
525
672
|
} else if (commit) {
|
|
526
673
|
versions[pkg] = commit; // the version actually imported, not the cycle-start one
|
|
527
674
|
delete failedVersions[pkg];
|
|
528
675
|
delete noticedVersions[pkg];
|
|
676
|
+
persistState();
|
|
529
677
|
}
|
|
530
678
|
// commit === false: not attempted (teardown, or nothing attached yet) —
|
|
531
679
|
// leave it uncommitted and retryable on a later event.
|
|
@@ -534,13 +682,16 @@ export function apply(ctx, config = {}) {
|
|
|
534
682
|
// when its directory is missing. A point-in-time fs probe is wrong twice
|
|
535
683
|
// over: a dangling pnpm symlink mid-swap would evict a live plugin, and a
|
|
536
684
|
// removed plugin row whose package stays installed would be tracked forever.
|
|
685
|
+
let dropped = false;
|
|
537
686
|
for (const pkg of Object.keys(versions)) {
|
|
538
687
|
if (!(pkg in snap)) {
|
|
539
688
|
delete versions[pkg];
|
|
540
689
|
delete failedVersions[pkg];
|
|
541
690
|
delete noticedVersions[pkg];
|
|
691
|
+
dropped = true;
|
|
542
692
|
}
|
|
543
693
|
}
|
|
694
|
+
if (dropped) persistState();
|
|
544
695
|
}
|
|
545
696
|
|
|
546
697
|
const trigger = () => {
|
|
@@ -559,12 +710,20 @@ export function apply(ctx, config = {}) {
|
|
|
559
710
|
}, debounceMs);
|
|
560
711
|
};
|
|
561
712
|
|
|
562
|
-
|
|
713
|
+
watcher = watch(lockfile, { ignoreInitial: true });
|
|
563
714
|
watcher.on("change", trigger);
|
|
564
715
|
watcher.on("add", trigger);
|
|
565
716
|
watcher.on("error", (e) => log.warn?.("dsh-hot-reload: watcher error", e));
|
|
566
717
|
|
|
567
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
|
+
}
|
|
568
727
|
// Never wait on an in-flight reload: dsh's shutdown must not hang on an
|
|
569
728
|
// arbitrary plugin's apply()/fiber.await(). Setting `disposed` first makes
|
|
570
729
|
// any straggling cycle harmless — it stops between modules, and a reload
|
|
@@ -573,9 +732,7 @@ export function apply(ctx, config = {}) {
|
|
|
573
732
|
disposed = true;
|
|
574
733
|
if (timer) clearTimeout(timer);
|
|
575
734
|
running.catch(() => {}); // keep an in-flight rejection from going unhandled
|
|
576
|
-
|
|
577
|
-
await watcher.close();
|
|
578
|
-
} catch {}
|
|
735
|
+
await closeWatcher();
|
|
579
736
|
});
|
|
580
737
|
|
|
581
738
|
// ---- notice channel ----
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-hot-reload",
|
|
3
|
-
"version": "0.2.
|
|
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": [
|