dsh-hot-reload 0.1.2 → 0.1.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 +115 -0
- package/README.md +33 -11
- package/README.zh.md +30 -12
- package/cordis.patch.yml +5 -6
- package/lib/index.js +243 -48
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,121 @@
|
|
|
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.1.4
|
|
7
|
+
|
|
8
|
+
Two rounds of code-review fixes (engine + CI). No config or API changes.
|
|
9
|
+
|
|
10
|
+
**Correct detection**
|
|
11
|
+
|
|
12
|
+
- **One consistent view per cycle**: each cycle now enumerates loader entries
|
|
13
|
+
once and reads each `package.json` once (the sole exception being a deliberate
|
|
14
|
+
re-read at import time, which is what makes the committed version truthful),
|
|
15
|
+
and every decision uses that view. A
|
|
16
|
+
transient loader failure mid-cycle could previously make a detected upgrade
|
|
17
|
+
look like "not a loaded plugin", committing it as loaded while the old code
|
|
18
|
+
kept running — silently, forever.
|
|
19
|
+
- **De-duplicate reloads by runtime, not specifier string** — aliased specifiers
|
|
20
|
+
(`pkg` vs `pkg/index.js`) resolving to one runtime no longer double-apply, and
|
|
21
|
+
one specifier mounted under two loader trees (two runtimes) now reloads both.
|
|
22
|
+
- **Commit the version actually imported** — captured at import time inside the
|
|
23
|
+
reload, never re-read afterwards. A version that lands while a slow `apply()`
|
|
24
|
+
is still activating is therefore *not* recorded as loaded; it stays visible as
|
|
25
|
+
a change and is picked up on the next cycle, so the running code converges on
|
|
26
|
+
the newest version instead of silently stalling on an older one.
|
|
27
|
+
- **Track by loader membership, not filesystem probes**: a package is dropped
|
|
28
|
+
when no loader entry is backed by it, not when a directory check fails. A
|
|
29
|
+
dangling pnpm symlink mid-swap can no longer evict a live plugin, and a
|
|
30
|
+
removed plugin row no longer stays tracked forever. A momentarily unreadable
|
|
31
|
+
`package.json` leaves the package tracked at its old version.
|
|
32
|
+
- **Newly loaded rows are adopted, not reloaded** — dsh already loaded them.
|
|
33
|
+
A package whose `package.json` was unreadable at boot is tracked as
|
|
34
|
+
version-unknown rather than untracked, so its first readable version is
|
|
35
|
+
loaded instead of being mistaken for a fresh row and adopted silently.
|
|
36
|
+
- **Disabled plugin rows are ignored**, using cordis's inherited `entry.disabled`
|
|
37
|
+
getter (an ancestor entry can disable a row, and the raw option may be a
|
|
38
|
+
`!!js` expression). Upgrading a disabled plugin no longer produces a spurious
|
|
39
|
+
"restart dsh" warning for something that isn't running. Group rows are skipped
|
|
40
|
+
too — they are containers, not plugin packages.
|
|
41
|
+
- Degraded snapshots (`loader.entries()` throwing) leave state untouched; the
|
|
42
|
+
next event retries. If the loader is degraded at boot, the first successful
|
|
43
|
+
snapshot is simply adopted as the tracked state.
|
|
44
|
+
|
|
45
|
+
**Honest reporting**
|
|
46
|
+
|
|
47
|
+
- **A failed reload is never retried automatically.** Each attempt tears down
|
|
48
|
+
the working rolled-back plugin, and unrelated lockfile writes used to
|
|
49
|
+
re-trigger it indefinitely. One clear message says what to do (install a
|
|
50
|
+
different version, or restart dsh); later cycles stay quiet.
|
|
51
|
+
- **No more false successes**: a changed package with no live fiber to reload
|
|
52
|
+
now warns and is *not* committed, instead of logging "hot-reloaded (0
|
|
53
|
+
module(s))". A package with a mix of live and fiberless entries reloads the
|
|
54
|
+
live ones and reports how many were skipped. An enabled row that simply has
|
|
55
|
+
no fiber *yet* (still importing) stays retryable — only a reload that was
|
|
56
|
+
attempted and failed is terminal.
|
|
57
|
+
- **Explicit `profileDir` config always wins** — it is no longer silently
|
|
58
|
+
overridden by the auto-detected dir when its lockfile is missing.
|
|
59
|
+
|
|
60
|
+
**Shutdown**
|
|
61
|
+
|
|
62
|
+
- The disposer **never waits** on an in-flight reload, so dsh's shutdown can't
|
|
63
|
+
hang on an arbitrary plugin's `apply()`. A reload caught mid-activation by
|
|
64
|
+
teardown skips its rollback rather than re-registering fibers into a context
|
|
65
|
+
that is already tearing down — and if that activation *succeeds* after
|
|
66
|
+
teardown, the new plugin is dropped rather than left running (with its timers
|
|
67
|
+
and sockets live) past shutdown.
|
|
68
|
+
- Lockfile churn during a long reload queues at most **one** follow-up cycle
|
|
69
|
+
instead of one per debounce window.
|
|
70
|
+
|
|
71
|
+
**Docs**
|
|
72
|
+
|
|
73
|
+
- `cordis.patch.yml` no longer advertises a **`reloadable` config key that was
|
|
74
|
+
never implemented** — a leftover from an abandoned opt-in design. The only
|
|
75
|
+
config keys are `debounce` and `profileDir`.
|
|
76
|
+
- Corrected the "safe plugins are reloaded, unsafe ones are flagged" framing in
|
|
77
|
+
the package description and bundle patch: nothing judges a plugin's safety.
|
|
78
|
+
Every upgrade is attempted optimistically, a throw rolls back, and
|
|
79
|
+
`dsh.hotReload: false` is the only opt-out.
|
|
80
|
+
- Both READMEs now document the disabled-row and not-yet-attached cases, and
|
|
81
|
+
list **every** cordis/loader internal the reload path depends on (previously
|
|
82
|
+
only three of six), which is what the Compatibility section is for.
|
|
83
|
+
|
|
84
|
+
**CI**
|
|
85
|
+
|
|
86
|
+
- Releases are now cut **only by pushing a `v*` tag**; pushes to `main` no
|
|
87
|
+
longer publish. One tag = one run = one version, which removes the E403 race
|
|
88
|
+
between the push- and tag-triggered runs of the same version.
|
|
89
|
+
- The run **fails loudly** if the tag disagrees with `package.json`'s version,
|
|
90
|
+
if the version is a **prerelease** (this project publishes stable versions
|
|
91
|
+
only — an unflagged prerelease would land on the `latest` dist-tag), or if
|
|
92
|
+
`npm view` fails for a non-404 reason (previously a green run that silently
|
|
93
|
+
skipped the release). E404 is detected structurally via `--json` rather than
|
|
94
|
+
by grepping npm's error prose.
|
|
95
|
+
- Concurrency is keyed per tag, so distinct releases never share a queue slot
|
|
96
|
+
(a shared group could silently cancel a pending release's run). Because that
|
|
97
|
+
allows two releases to publish concurrently, a post-publish step re-points
|
|
98
|
+
`latest` at the highest published version — `npm publish` sets `latest`
|
|
99
|
+
unconditionally, so otherwise the run finishing last would win regardless of
|
|
100
|
+
version order.
|
|
101
|
+
|
|
102
|
+
## 0.1.3
|
|
103
|
+
|
|
104
|
+
Code-review fixes (engine + CI):
|
|
105
|
+
|
|
106
|
+
- Reload each changed package **once per module**, not once per plugin row —
|
|
107
|
+
rows sharing a specifier share a runtime, so per-row reloading double-applied
|
|
108
|
+
and leaked a module instance.
|
|
109
|
+
- **Serialize reload cycles** so a change arriving mid-reload can't run a second
|
|
110
|
+
cycle concurrently against the registry.
|
|
111
|
+
- Commit the tracked version **only after a successful reload/skip**, so a failed
|
|
112
|
+
reload can be retried by re-installing the same version.
|
|
113
|
+
- **Validate the profile dir** (warn if no `pnpm-lock.yaml`), and prefer a
|
|
114
|
+
candidate dir that actually contains the lockfile.
|
|
115
|
+
- Disposer now awaits `watcher.close()` and guards against in-flight reloads via
|
|
116
|
+
a `disposed` flag.
|
|
117
|
+
- CI: add a `concurrency` group so a commit+tag push can't race to publish
|
|
118
|
+
(E403); publish only on a confirmed `E404` (not on transient `npm view`
|
|
119
|
+
failures).
|
|
120
|
+
|
|
6
121
|
## 0.1.2
|
|
7
122
|
|
|
8
123
|
- Docs: add a **Compatibility** section (built/tested against dsh `0.1.0-rc.6`;
|
package/README.md
CHANGED
|
@@ -25,6 +25,20 @@ On a plugin package upgrade, for each affected plugin:
|
|
|
25
25
|
- a failure while *initializing* it (the new `apply` throws, sync **or** async)
|
|
26
26
|
is rolled back — the old version is re-instantiated in place.
|
|
27
27
|
|
|
28
|
+
A version that failed is **not retried automatically** — retrying would tear
|
|
29
|
+
down the working plugin again on every later lockfile write. Install a
|
|
30
|
+
different version, or restart dsh, to pick the new code up.
|
|
31
|
+
|
|
32
|
+
Two cases produce no reload, by design:
|
|
33
|
+
|
|
34
|
+
- **Disabled plugin rows are skipped silently.** A disabled plugin isn't
|
|
35
|
+
running, so there is nothing to swap — and re-enabling it makes dsh load the
|
|
36
|
+
new code anyway.
|
|
37
|
+
- **A plugin that has no live fiber *yet*** (still importing, or it failed to
|
|
38
|
+
load earlier) is reported as `no live fiber to reload right now` and left
|
|
39
|
+
alone. Nothing was torn down, so this one *is* re-examined on the next
|
|
40
|
+
lockfile change — if it keeps repeating, restart dsh.
|
|
41
|
+
|
|
28
42
|
It **never restarts dsh for you** — restarting is left to you (and your
|
|
29
43
|
supervisor, if any).
|
|
30
44
|
|
|
@@ -47,11 +61,20 @@ the profile it's loaded into.
|
|
|
47
61
|
## Compatibility
|
|
48
62
|
|
|
49
63
|
Built and tested against **dsh `0.1.0-rc.6`** (Node 22 / 24). It reaches into
|
|
50
|
-
cordis/loader internals
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
64
|
+
cordis/loader internals — mostly the same ones `cordis-plugin-hmr` uses — so a
|
|
65
|
+
future dsh that changes any of them may require an update:
|
|
66
|
+
|
|
67
|
+
| Internal | Used for |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `loader.internal.loadCache` | invalidating the ESM module cache |
|
|
70
|
+
| `loader.internal.resolve` / `resolveSync` | resolving a specifier to a URL (dispatched on `internal.version`) |
|
|
71
|
+
| `registry.plugin` / `registry.delete` | swapping the plugin instance |
|
|
72
|
+
| `fiber.entry`, `fiber.runtime` | re-attaching the new plugin to the running rows |
|
|
73
|
+
| `entry.disabled` | skipping disabled rows (inherited getter) |
|
|
74
|
+
| `entry.options.group` | skipping group container rows |
|
|
75
|
+
|
|
76
|
+
It fails safe: if the internals it needs are missing, it degrades to reporting
|
|
77
|
+
"restart needed" rather than breaking dsh.
|
|
55
78
|
|
|
56
79
|
## Opting out
|
|
57
80
|
|
|
@@ -73,9 +96,9 @@ Set on the `hot-reload` row in your profile's `cordis.patch.yml`:
|
|
|
73
96
|
|
|
74
97
|
## Limitations — read this
|
|
75
98
|
|
|
76
|
-
This plugin is **optimistic**, not verified. It attempts the reload and
|
|
77
|
-
|
|
78
|
-
*silent* leaks:
|
|
99
|
+
This plugin is **optimistic**, not verified. It attempts the reload and falls
|
|
100
|
+
back to "restart needed" only when something **throws** (or when there is no
|
|
101
|
+
live fiber to swap). It does **not** detect *silent* leaks:
|
|
79
102
|
|
|
80
103
|
- A plugin that acquires a **raw resource outside cordis** — a bare
|
|
81
104
|
`setInterval`, a `net`/`http` server, a `WebSocketServer`, an `fs.watch`,
|
|
@@ -90,9 +113,8 @@ falls back to "restart needed" on a **thrown** error. It does **not** detect
|
|
|
90
113
|
- Reloading a plugin that holds **live connections** (e.g. a WebSocket bridge)
|
|
91
114
|
drops and re-establishes them; clients must reconnect. That's expected, not an
|
|
92
115
|
error.
|
|
93
|
-
- The reload path relies on cordis/loader internals
|
|
94
|
-
(
|
|
95
|
-
same ones `cordis-plugin-hmr` uses. If those internals are unavailable (no
|
|
116
|
+
- The reload path relies on the cordis/loader internals listed under
|
|
117
|
+
[Compatibility](#compatibility). If they are unavailable (no
|
|
96
118
|
`--expose-internals` and no `node-addon-require-builtin` addon), the plugin
|
|
97
119
|
degrades to reporting "restart needed" for every change instead of reloading.
|
|
98
120
|
|
package/README.zh.md
CHANGED
|
@@ -22,6 +22,17 @@ dsh 自带的热重载(`cordis-plugin-hmr`)刻意忽略 `node_modules`,所
|
|
|
22
22
|
- 新代码在**初始化**阶段失败(新的 `apply` 抛错,**同步或异步**)会被回滚——
|
|
23
23
|
旧版本就地重新实例化。
|
|
24
24
|
|
|
25
|
+
失败的版本**不会自动重试**——重试会在此后每次 lockfile 写入时再次拆除正在
|
|
26
|
+
正常工作的插件。请安装另一个版本,或重启 dsh,以加载新代码。
|
|
27
|
+
|
|
28
|
+
有两种情况按设计不做重载:
|
|
29
|
+
|
|
30
|
+
- **已禁用(disabled)的插件行会被静默跳过。** 已禁用的插件本就没在运行,没有
|
|
31
|
+
可替换的对象;重新启用时 dsh 自然会加载新代码。
|
|
32
|
+
- **尚未挂上 fiber 的插件**(仍在导入中,或此前加载失败)会被报告为
|
|
33
|
+
`no live fiber to reload right now` 并原样保留。由于什么都没有被拆除,这种
|
|
34
|
+
情况**会**在下次 lockfile 变化时重新检查;若反复出现,请重启 dsh。
|
|
35
|
+
|
|
25
36
|
它**绝不会替你重启 dsh**——重启交给你(以及你的守护进程,如果有的话)。
|
|
26
37
|
|
|
27
38
|
## 安装
|
|
@@ -41,11 +52,20 @@ dsh plugin --profile web add some-plugin@newer # 自动热重载
|
|
|
41
52
|
|
|
42
53
|
## 兼容性
|
|
43
54
|
|
|
44
|
-
基于并测试于 **dsh `0.1.0-rc.6`**(Node 22 / 24
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
基于并测试于 **dsh `0.1.0-rc.6`**(Node 22 / 24)。它会用到 cordis/loader 的内部
|
|
56
|
+
接口——大多与 `cordis-plugin-hmr` 相同——因此未来若某个 dsh 版本改动了其中任何
|
|
57
|
+
一项,可能需要更新本插件:
|
|
58
|
+
|
|
59
|
+
| 内部接口 | 用途 |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `loader.internal.loadCache` | 使 ESM 模块缓存失效 |
|
|
62
|
+
| `loader.internal.resolve` / `resolveSync` | 把 specifier 解析为 URL(按 `internal.version` 分派) |
|
|
63
|
+
| `registry.plugin` / `registry.delete` | 替换插件实例 |
|
|
64
|
+
| `fiber.entry`、`fiber.runtime` | 把新插件重新挂到运行中的行上 |
|
|
65
|
+
| `entry.disabled` | 跳过已禁用的行(继承式 getter) |
|
|
66
|
+
| `entry.options.group` | 跳过 group 容器行 |
|
|
67
|
+
|
|
68
|
+
它是失败安全的:一旦所需内部不可用,会退化为报告“需要重启”,而不会弄坏 dsh。
|
|
49
69
|
|
|
50
70
|
## 退出热重载(opt-out)
|
|
51
71
|
|
|
@@ -67,8 +87,8 @@ dsh plugin --profile web add some-plugin@newer # 自动热重载
|
|
|
67
87
|
|
|
68
88
|
## 局限——务必阅读
|
|
69
89
|
|
|
70
|
-
|
|
71
|
-
|
|
90
|
+
本插件是**乐观式**的,并非验证式。它尝试重载,且仅在**抛出**错误时(或没有可
|
|
91
|
+
替换的活动 fiber 时)回退到“需要重启”。它**无法**检测*静默*泄漏:
|
|
72
92
|
|
|
73
93
|
- 一个在 cordis 之外获取**裸资源**的插件——裸 `setInterval`、`net`/`http`
|
|
74
94
|
服务器、`WebSocketServer`、`fs.watch`、`child_process`——**且没有用
|
|
@@ -79,11 +99,9 @@ dsh plugin --profile web add some-plugin@newer # 自动热重载
|
|
|
79
99
|
仅限于绕过 `ctx` 的插件。拿不准时,让这类插件设 `dsh.hotReload: false`。
|
|
80
100
|
- 重载一个持有**活动连接**的插件(例如 WebSocket 桥接)会断开并重建这些连接;
|
|
81
101
|
客户端需要重连。这是预期行为,不是错误。
|
|
82
|
-
- 重载路径依赖 cordis/loader
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
`node-addon-require-builtin` 原生插件),本插件会退化为对每次变化只报告
|
|
86
|
-
“需要重启”,而不做重载。
|
|
102
|
+
- 重载路径依赖[兼容性](#兼容性)一节列出的 cordis/loader 内部接口。若这些内部
|
|
103
|
+
不可用(既无 `--expose-internals`,也无 `node-addon-require-builtin` 原生
|
|
104
|
+
插件),本插件会退化为对每次变化只报告“需要重启”,而不做重载。
|
|
87
105
|
|
|
88
106
|
范围说明:本插件处理的是**已加载插件的升级**。安装一个**全新**插件是另一回事
|
|
89
107
|
(把它的行加入 `cordis.patch.yml`,这个 dsh 本身已经会热应用)。
|
package/cordis.patch.yml
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
# dsh-hot-reload bundle patch — mounts the watcher plugin that live-reloads
|
|
2
2
|
# upgraded plugin packages without restarting dsh.
|
|
3
3
|
#
|
|
4
|
-
# Behavior: on a plugin package
|
|
5
|
-
#
|
|
6
|
-
#
|
|
4
|
+
# Behavior: on a plugin package upgrade it attempts a live reload, and on any
|
|
5
|
+
# thrown error rolls back to the working old version and logs that a manual
|
|
6
|
+
# `dsh` restart is needed. A plugin can opt out with `dsh.hotReload: false` in
|
|
7
|
+
# its own package.json. It never restarts dsh itself. See README.md for the
|
|
8
|
+
# limitations (silent leaks it cannot detect).
|
|
7
9
|
- insert:
|
|
8
10
|
- id: hot-reload
|
|
9
11
|
name: 'dsh-hot-reload'
|
|
10
12
|
# config:
|
|
11
|
-
# # Extra package names you personally vouch are safe to hot-reload,
|
|
12
|
-
# # even if they don't declare `dsh.hotReload: true` themselves.
|
|
13
|
-
# reloadable: []
|
|
14
13
|
# # Debounce (ms) after a lockfile change before acting.
|
|
15
14
|
# debounce: 300
|
|
16
15
|
# # Absolute path to the profile dir to watch; auto-detected if omitted.
|
package/lib/index.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
// ctx.effect disposer can reload without error yet leave that resource dangling.
|
|
14
14
|
// A plugin can opt out of reload entirely with `dsh.hotReload: false` in its
|
|
15
15
|
// package.json, which forces the restart-needed path without a reload attempt.
|
|
16
|
+
// Disabled rows are skipped silently (nothing is running to swap); an enabled
|
|
17
|
+
// row with no fiber attached yet is reported and left for a later change.
|
|
16
18
|
//
|
|
17
19
|
// NOTE: the reload path uses cordis/loader internals (loader.internal.loadCache,
|
|
18
20
|
// registry.plugin/delete, fiber.entry) — the same ones HMR uses. If a future
|
|
@@ -20,16 +22,19 @@
|
|
|
20
22
|
// crash dsh.
|
|
21
23
|
|
|
22
24
|
import { watch } from "chokidar";
|
|
23
|
-
import { readFileSync } from "node:fs";
|
|
25
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
24
26
|
import { createRequire } from "node:module";
|
|
25
27
|
import { fileURLToPath } from "node:url";
|
|
26
|
-
import {
|
|
28
|
+
import { join } from "node:path";
|
|
27
29
|
|
|
28
30
|
export const name = "dsh-hot-reload";
|
|
29
31
|
|
|
30
32
|
const getOuterStack = () => [];
|
|
31
33
|
const cjsRequire = createRequire(import.meta.url);
|
|
32
34
|
|
|
35
|
+
/** handlePackage outcome: a reload was attempted and failed — never retry it. */
|
|
36
|
+
const TERMINAL = Symbol("dsh-hot-reload:terminal");
|
|
37
|
+
|
|
33
38
|
export function apply(ctx, config = {}) {
|
|
34
39
|
const log = ctx.logger ?? console;
|
|
35
40
|
const loader = ctx.loader;
|
|
@@ -55,6 +60,14 @@ export function apply(ctx, config = {}) {
|
|
|
55
60
|
}
|
|
56
61
|
const lockfile = join(profileDir, "pnpm-lock.yaml");
|
|
57
62
|
const nodeModules = join(profileDir, "node_modules");
|
|
63
|
+
// Validate the auto-detected dir loudly: watching a wrong/nonexistent lockfile
|
|
64
|
+
// would silently track 0 packages and never fire.
|
|
65
|
+
if (!existsSync(lockfile)) {
|
|
66
|
+
log.warn?.(
|
|
67
|
+
`dsh-hot-reload: no pnpm-lock.yaml at ${lockfile} — is this the profile dir? ` +
|
|
68
|
+
"set config.profileDir to fix; the plugin will watch but detect nothing until it appears."
|
|
69
|
+
);
|
|
70
|
+
}
|
|
58
71
|
|
|
59
72
|
// ---- package <-> loader-entry helpers ----
|
|
60
73
|
|
|
@@ -70,6 +83,12 @@ export function apply(ctx, config = {}) {
|
|
|
70
83
|
return specifier.split("/")[0];
|
|
71
84
|
}
|
|
72
85
|
|
|
86
|
+
// Versions come from node_modules/<pkg>/package.json, never from the lockfile
|
|
87
|
+
// we watch: pnpm writes the lockfile BEFORE materializing node_modules, so a
|
|
88
|
+
// cycle triggered by that write can see the new version there while the old
|
|
89
|
+
// code is still on disk — re-importing would load the OLD module while
|
|
90
|
+
// committing the NEW version as loaded. The lockfile is the trigger; the
|
|
91
|
+
// installed package.json is the truth about what an import would actually get.
|
|
73
92
|
function readPkgJson(pkg) {
|
|
74
93
|
try {
|
|
75
94
|
return JSON.parse(readFileSync(join(nodeModules, pkg, "package.json"), "utf8"));
|
|
@@ -82,31 +101,71 @@ export function apply(ctx, config = {}) {
|
|
|
82
101
|
return readPkgJson(pkg)?.version ?? null;
|
|
83
102
|
}
|
|
84
103
|
|
|
85
|
-
function optedOut(pkg) {
|
|
86
|
-
return readPkgJson(pkg)?.dsh?.hotReload === false;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
104
|
function entries() {
|
|
90
105
|
try {
|
|
91
106
|
return [...loader.entries()];
|
|
92
107
|
} catch {
|
|
93
|
-
return
|
|
108
|
+
return null; // degraded — callers must not confuse this with "no plugins loaded"
|
|
94
109
|
}
|
|
95
110
|
}
|
|
96
111
|
|
|
97
|
-
|
|
98
|
-
|
|
112
|
+
/** One consistent per-cycle view: pkg -> { json, version, live, fiberless }
|
|
113
|
+
* for every package backing a loader entry, built from a SINGLE loader
|
|
114
|
+
* enumeration and a SINGLE package.json read per package (the whole cycle
|
|
115
|
+
* consumes this, so a loader/fs hiccup after the diff can't be misread as
|
|
116
|
+
* "not a loaded plugin"; `reloadEntry` deliberately re-reads the version at
|
|
117
|
+
* import time, and nothing else does).
|
|
118
|
+
*
|
|
119
|
+
* Every question about what a row IS gets answered here, once:
|
|
120
|
+
* - group rows are containers, not plugin packages — excluded entirely;
|
|
121
|
+
* - disabled rows aren't running, so there is nothing to reload and nothing
|
|
122
|
+
* to report. `disabled` is an inherited getter (an ancestor entry can
|
|
123
|
+
* disable a row, and the raw option may be a !!js expression node), so
|
|
124
|
+
* never read options.disabled — excluded entirely;
|
|
125
|
+
* - `live` holds one entry per RUNTIME: reloadEntry swaps all of a runtime's
|
|
126
|
+
* fibers at once, so aliased specifiers ("pkg" vs "pkg/index.js") sharing
|
|
127
|
+
* a runtime must reload once, while one specifier under two loader trees
|
|
128
|
+
* is two runtimes and must reload twice;
|
|
129
|
+
* - `fiberless` counts enabled rows with nothing attached (mid-import, or
|
|
130
|
+
* failed to load) — reportable, but not reloadable.
|
|
131
|
+
*
|
|
132
|
+
* Returns null when the loader can't enumerate right now — treating that as
|
|
133
|
+
* "everything uninstalled" would wipe the tracked versions and spuriously
|
|
134
|
+
* reload everything next cycle. A package whose package.json is momentarily
|
|
135
|
+
* unreadable (mid pnpm swap) still appears, with version null. */
|
|
136
|
+
function snapshot() {
|
|
137
|
+
const list = entries();
|
|
138
|
+
if (!list) return null;
|
|
139
|
+
const pkgs = Object.create(null);
|
|
140
|
+
const seenRuntimes = new Map();
|
|
141
|
+
for (const e of list) {
|
|
142
|
+
if (e?.options?.group || e?.disabled) continue;
|
|
143
|
+
const pkg = pkgOf(e?.options?.name);
|
|
144
|
+
if (!pkg) continue;
|
|
145
|
+
let rec = pkgs[pkg];
|
|
146
|
+
if (!rec) {
|
|
147
|
+
const json = readPkgJson(pkg);
|
|
148
|
+
rec = pkgs[pkg] = { json, version: json?.version ?? null, live: [], fiberless: 0 };
|
|
149
|
+
seenRuntimes.set(pkg, new Set());
|
|
150
|
+
}
|
|
151
|
+
const runtime = e?.fiber?.runtime;
|
|
152
|
+
if (!runtime) {
|
|
153
|
+
rec.fiberless += 1;
|
|
154
|
+
} else if (!seenRuntimes.get(pkg).has(runtime)) {
|
|
155
|
+
seenRuntimes.get(pkg).add(runtime);
|
|
156
|
+
rec.live.push(e);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return pkgs;
|
|
99
160
|
}
|
|
100
161
|
|
|
101
|
-
/**
|
|
102
|
-
|
|
162
|
+
/** Baseline map for a snapshot. A package whose version couldn't be read is
|
|
163
|
+
* tracked with a null version rather than omitted: omitting it would make its
|
|
164
|
+
* first readable version look like a brand-new row and get adopted without a
|
|
165
|
+
* reload, silently leaving the old code running. */
|
|
166
|
+
function currentVersions(snap) {
|
|
103
167
|
const map = Object.create(null);
|
|
104
|
-
for (const
|
|
105
|
-
const pkg = pkgOf(e?.options?.name);
|
|
106
|
-
if (!pkg || pkg in map) continue;
|
|
107
|
-
const v = versionOf(pkg);
|
|
108
|
-
if (v) map[pkg] = v;
|
|
109
|
-
}
|
|
168
|
+
for (const pkg in snap) map[pkg] = snap[pkg].version;
|
|
110
169
|
return map;
|
|
111
170
|
}
|
|
112
171
|
|
|
@@ -142,8 +201,14 @@ export function apply(ctx, config = {}) {
|
|
|
142
201
|
} catch {}
|
|
143
202
|
}
|
|
144
203
|
|
|
145
|
-
/** Reload one loaded entry's module in place. Throws
|
|
146
|
-
|
|
204
|
+
/** Reload one loaded entry's module in place. Throws on failure, after rolling
|
|
205
|
+
* the old plugin back — except when teardown began mid-reload, where it drops
|
|
206
|
+
* the new plugin and does NOT roll back (the dying context disposes what is
|
|
207
|
+
* still registered). Returns the package version read at IMPORT time — a
|
|
208
|
+
* version read afterwards could record one that was never imported (a bump
|
|
209
|
+
* landing during a slow apply()), which would make the next cycle see no
|
|
210
|
+
* change and skip that upgrade forever. */
|
|
211
|
+
async function reloadEntry(entry, pkg) {
|
|
147
212
|
const specifier = entry?.options?.name;
|
|
148
213
|
const parentURL = entry?.parent?.tree?.ctx?.baseUrl ?? ctx.baseUrl;
|
|
149
214
|
const oldFiber = entry.fiber;
|
|
@@ -156,9 +221,16 @@ export function apply(ctx, config = {}) {
|
|
|
156
221
|
|
|
157
222
|
invalidate(url); // matters for in-place edits; harmless no-op for a version bump (new realpath)
|
|
158
223
|
|
|
224
|
+
// Read the version as close to the import as possible: this is what the
|
|
225
|
+
// fresh module actually is, and the only value safe to commit.
|
|
226
|
+
const importedVersion = versionOf(pkg);
|
|
159
227
|
const newPlugin = loader.unwrapExports(await loader.import(url, getOuterStack));
|
|
160
228
|
if (!newPlugin) throw new Error(`fresh import produced no plugin for ${specifier}`);
|
|
161
229
|
|
|
230
|
+
// Re-check after the (slow) import: never start the destructive swap into a
|
|
231
|
+
// context that began tearing down while we were awaiting.
|
|
232
|
+
if (disposed) throw new Error("dsh-hot-reload disposed mid-reload");
|
|
233
|
+
|
|
162
234
|
// Snapshot fibers before disposal, then swap: dispose old (runs ctx disposers),
|
|
163
235
|
// re-instantiate the new plugin against each old fiber's entry + config.
|
|
164
236
|
const fibers = [...runtime.fibers];
|
|
@@ -169,11 +241,18 @@ export function apply(ctx, config = {}) {
|
|
|
169
241
|
// how an async apply() throw is surfaced into this try/catch (a plain
|
|
170
242
|
// reattach would let it escape asynchronously and leave the plugin dead).
|
|
171
243
|
await Promise.all(fresh.map((f) => f?.await?.()));
|
|
244
|
+
// Activation can SUCCEED after teardown began — and the disposer no longer
|
|
245
|
+
// waits for us, so nothing else would ever dispose these fibers. Throwing
|
|
246
|
+
// here routes into the same drop-and-bail path the failure case uses.
|
|
247
|
+
if (disposed) throw new Error("dsh-hot-reload disposed mid-reload");
|
|
172
248
|
} catch (err) {
|
|
173
|
-
// Rollback to the old plugin so a failed reload never leaves it dead.
|
|
174
249
|
try {
|
|
175
|
-
ctx.registry.delete(newPlugin);
|
|
250
|
+
ctx.registry.delete(newPlugin); // every path out of here drops the new plugin
|
|
176
251
|
} catch {}
|
|
252
|
+
// Teardown began while activation was awaiting: do NOT reattach into the
|
|
253
|
+
// dying context — its own teardown disposes whatever is still registered.
|
|
254
|
+
if (disposed) throw err;
|
|
255
|
+
// Otherwise roll back, so a failed reload never leaves the plugin dead.
|
|
177
256
|
const restored = [];
|
|
178
257
|
for (const of of fibers) {
|
|
179
258
|
try {
|
|
@@ -185,6 +264,7 @@ export function apply(ctx, config = {}) {
|
|
|
185
264
|
} catch {}
|
|
186
265
|
throw err;
|
|
187
266
|
}
|
|
267
|
+
return importedVersion;
|
|
188
268
|
}
|
|
189
269
|
|
|
190
270
|
function reattach(plugin, oldFiber) {
|
|
@@ -196,44 +276,149 @@ export function apply(ctx, config = {}) {
|
|
|
196
276
|
|
|
197
277
|
// ---- change handling ----
|
|
198
278
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
279
|
+
/** Reload the live modules of a changed package, as already classified by the
|
|
280
|
+
* cycle's snapshot record. Returns the version string to commit, `false` to
|
|
281
|
+
* leave it uncommitted but RETRYABLE, or TERMINAL when a reload was attempted
|
|
282
|
+
* and failed — that version is never retried (each attempt tears down the
|
|
283
|
+
* working rolled-back plugin; recovery is a different version or a dsh
|
|
284
|
+
* restart). */
|
|
285
|
+
async function handlePackage(pkg, rec) {
|
|
286
|
+
const { version, live, fiberless } = rec;
|
|
203
287
|
|
|
204
|
-
if (
|
|
288
|
+
if (rec.json?.dsh?.hotReload === false) {
|
|
205
289
|
log.info?.(`dsh-hot-reload: ${pkg}@${version} sets dsh.hotReload:false — restart dsh to load the new version`);
|
|
206
|
-
return;
|
|
290
|
+
return version;
|
|
207
291
|
}
|
|
208
292
|
if (!internal) {
|
|
209
293
|
log.info?.(`dsh-hot-reload: ${pkg}@${version} changed — restart dsh to load the new version`);
|
|
210
|
-
return;
|
|
294
|
+
return version;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!live.length) {
|
|
298
|
+
if (!fiberless) return version; // only disabled rows — nothing to do, nothing to say
|
|
299
|
+
// Enabled but nothing attached: mid-import or a load failure. Say so once,
|
|
300
|
+
// don't commit, and stay retryable — no reload was attempted, so a plugin
|
|
301
|
+
// that was merely still activating picks this up on a later event.
|
|
302
|
+
log.warn?.(
|
|
303
|
+
`dsh-hot-reload: ${pkg}@${version} has no live fiber to reload right now — restart dsh if it stays on the old version`
|
|
304
|
+
);
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
if (fiberless) {
|
|
308
|
+
log.warn?.(`dsh-hot-reload: ${pkg}@${version}: skipping ${fiberless} entry(ies) with no live fiber`);
|
|
211
309
|
}
|
|
212
310
|
|
|
213
311
|
try {
|
|
214
|
-
|
|
215
|
-
|
|
312
|
+
// Only ever commit a version some module actually imported. If a bump
|
|
313
|
+
// lands mid-cycle the modules can disagree about what they loaded; rather
|
|
314
|
+
// than pick one, leave the package uncommitted (retryable) so the next
|
|
315
|
+
// cycle re-snapshots and converges. Costs one redundant reload in a rare
|
|
316
|
+
// case; the alternative risks recording a version that never loaded.
|
|
317
|
+
let committed = null;
|
|
318
|
+
for (const entry of live) {
|
|
319
|
+
if (disposed) return false; // shutting down — don't touch the registry, don't commit
|
|
320
|
+
const imported = await reloadEntry(entry, pkg);
|
|
321
|
+
if (committed && imported !== committed) {
|
|
322
|
+
log.info?.(`dsh-hot-reload: ${pkg} changed again mid-reload — re-checking on the next change`);
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
committed ??= imported;
|
|
326
|
+
}
|
|
327
|
+
committed ??= version;
|
|
328
|
+
log.info?.(`dsh-hot-reload: hot-reloaded ${pkg}@${committed} (${live.length} module(s))`);
|
|
329
|
+
return committed;
|
|
216
330
|
} catch (err) {
|
|
217
|
-
|
|
331
|
+
if (disposed) return false; // aborted by teardown, not a real failure — stay quiet
|
|
332
|
+
log.warn?.(
|
|
333
|
+
`dsh-hot-reload: could not hot-reload ${pkg}@${version} — not retrying; restart dsh (or install a different version) to load it`
|
|
334
|
+
);
|
|
218
335
|
log.warn?.(err);
|
|
336
|
+
return TERMINAL; // attempted and failed: never retried for this version
|
|
219
337
|
}
|
|
220
338
|
}
|
|
221
339
|
|
|
222
340
|
// ---- watcher ----
|
|
223
341
|
|
|
224
|
-
|
|
342
|
+
const boot = snapshot();
|
|
343
|
+
// null when the loader couldn't enumerate at boot. By design there is no
|
|
344
|
+
// retry and no warning: the first successful snapshot simply becomes the
|
|
345
|
+
// tracked state. Accepted tradeoff — if the loader is degraded at boot AND
|
|
346
|
+
// the very first lockfile event is a real upgrade, that upgrade is adopted
|
|
347
|
+
// silently (old code keeps running, no notice). Deliberate, not an oversight.
|
|
348
|
+
let versions = boot ? currentVersions(boot) : null;
|
|
349
|
+
const failedVersions = Object.create(null); // pkg -> version whose reload failed (never retried)
|
|
225
350
|
let timer = null;
|
|
351
|
+
let pending = false; // at most ONE cycle queued behind the running one; bursts coalesce into it
|
|
352
|
+
let disposed = false;
|
|
353
|
+
let running = Promise.resolve(); // serializes reload cycles across debounce batches
|
|
354
|
+
|
|
355
|
+
async function runCycle() {
|
|
356
|
+
if (disposed) return;
|
|
357
|
+
// ONE loader enumeration + ONE package.json read per package for the whole
|
|
358
|
+
// cycle: the diff below and the reloads it drives act on the same facts, so
|
|
359
|
+
// a transient loader/fs failure can never make a detected upgrade look like
|
|
360
|
+
// "not a loaded plugin" and get silently committed. (The single exception is
|
|
361
|
+
// the at-import version re-read in reloadEntry, which must not be cached.)
|
|
362
|
+
const snap = snapshot();
|
|
363
|
+
if (!snap) return; // degraded — keep current state, the next event retries
|
|
364
|
+
if (!versions) {
|
|
365
|
+
versions = currentVersions(snap); // adopt whatever is in the system (see `boot` above)
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
for (const pkg in snap) {
|
|
369
|
+
if (disposed) return;
|
|
370
|
+
const version = snap[pkg].version;
|
|
371
|
+
if (!version) {
|
|
372
|
+
// package.json unreadable right now (mid pnpm swap). Track it with a
|
|
373
|
+
// null version if we've never had one, so the first readable version
|
|
374
|
+
// reads as a change and reloads, instead of being adopted as a fresh row.
|
|
375
|
+
if (!(pkg in versions)) versions[pkg] = null;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (!(pkg in versions)) {
|
|
379
|
+
versions[pkg] = version; // newly loaded row: dsh just loaded it fresh, nothing to reload
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (versions[pkg] === version) continue;
|
|
383
|
+
// A version whose reload failed is never re-attempted: each attempt tears
|
|
384
|
+
// down the working rolled-back plugin again. Recovery is a different
|
|
385
|
+
// version or a dsh restart — the failure log said so once, stay quiet now.
|
|
386
|
+
if (failedVersions[pkg] === version) continue;
|
|
387
|
+
const commit = await handlePackage(pkg, snap[pkg]);
|
|
388
|
+
if (commit === TERMINAL) {
|
|
389
|
+
failedVersions[pkg] = version; // attempted, failed: don't try this version again
|
|
390
|
+
} else if (commit) {
|
|
391
|
+
versions[pkg] = commit; // the version actually imported, not the cycle-start one
|
|
392
|
+
delete failedVersions[pkg];
|
|
393
|
+
}
|
|
394
|
+
// commit === false: not attempted (teardown, or nothing attached yet) —
|
|
395
|
+
// leave it uncommitted and retryable on a later event.
|
|
396
|
+
}
|
|
397
|
+
// Drop a package when the LOADER no longer has an entry backed by it — not
|
|
398
|
+
// when its directory is missing. A point-in-time fs probe is wrong twice
|
|
399
|
+
// over: a dangling pnpm symlink mid-swap would evict a live plugin, and a
|
|
400
|
+
// removed plugin row whose package stays installed would be tracked forever.
|
|
401
|
+
for (const pkg of Object.keys(versions)) {
|
|
402
|
+
if (!(pkg in snap)) {
|
|
403
|
+
delete versions[pkg];
|
|
404
|
+
delete failedVersions[pkg];
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
226
409
|
const trigger = () => {
|
|
410
|
+
if (disposed) return;
|
|
227
411
|
if (timer) clearTimeout(timer);
|
|
228
|
-
timer = setTimeout(
|
|
412
|
+
timer = setTimeout(() => {
|
|
229
413
|
timer = null;
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
414
|
+
if (pending) return; // a queued cycle will snapshot fresh state and see this change too
|
|
415
|
+
pending = true;
|
|
416
|
+
running = running
|
|
417
|
+
.then(() => {
|
|
418
|
+
pending = false;
|
|
419
|
+
return runCycle();
|
|
420
|
+
})
|
|
421
|
+
.catch((e) => log.warn?.("dsh-hot-reload: reload cycle error", e));
|
|
237
422
|
}, debounceMs);
|
|
238
423
|
};
|
|
239
424
|
|
|
@@ -242,24 +427,34 @@ export function apply(ctx, config = {}) {
|
|
|
242
427
|
watcher.on("add", trigger);
|
|
243
428
|
watcher.on("error", (e) => log.warn?.("dsh-hot-reload: watcher error", e));
|
|
244
429
|
|
|
245
|
-
ctx.effect(() => () => {
|
|
430
|
+
ctx.effect(() => async () => {
|
|
431
|
+
// Never wait on an in-flight reload: dsh's shutdown must not hang on an
|
|
432
|
+
// arbitrary plugin's apply()/fiber.await(). Setting `disposed` first makes
|
|
433
|
+
// any straggling cycle harmless — it stops between modules, and a reload
|
|
434
|
+
// caught mid-activation skips the rollback rather than reattaching fibers
|
|
435
|
+
// into a context that is already tearing down.
|
|
436
|
+
disposed = true;
|
|
246
437
|
if (timer) clearTimeout(timer);
|
|
247
|
-
|
|
438
|
+
running.catch(() => {}); // keep an in-flight rejection from going unhandled
|
|
439
|
+
try {
|
|
440
|
+
await watcher.close();
|
|
441
|
+
} catch {}
|
|
248
442
|
});
|
|
249
443
|
|
|
250
444
|
log.info?.(
|
|
251
|
-
`dsh-hot-reload: watching ${lockfile} (${Object.keys(versions).length} plugin package(s) tracked)`
|
|
445
|
+
`dsh-hot-reload: watching ${lockfile} (${Object.keys(versions ?? {}).length} plugin package(s) tracked)`
|
|
252
446
|
);
|
|
253
447
|
}
|
|
254
448
|
|
|
255
|
-
/**
|
|
449
|
+
/** Profile-dir resolution: an explicit config.profileDir ALWAYS wins — it must
|
|
450
|
+
* never be silently overridden by auto-detection (a fresh profile without a
|
|
451
|
+
* lockfile yet would otherwise get the baseUrl dir, watching and hot-swapping
|
|
452
|
+
* the wrong profile; apply() warns loudly when the lockfile is missing).
|
|
453
|
+
* Auto-detection from the loader base URL applies only when config is absent. */
|
|
256
454
|
function resolveProfileDir(ctx, config) {
|
|
257
455
|
if (config.profileDir) return config.profileDir;
|
|
258
456
|
try {
|
|
259
|
-
if (ctx.baseUrl)
|
|
260
|
-
const p = fileURLToPath(new URL(".", ctx.baseUrl));
|
|
261
|
-
return p.replace(/\/$/, "");
|
|
262
|
-
}
|
|
457
|
+
if (ctx.baseUrl) return fileURLToPath(new URL(".", ctx.baseUrl)).replace(/\/$/, "");
|
|
263
458
|
} catch {}
|
|
264
459
|
return null;
|
|
265
460
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-hot-reload",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Live-reload upgraded DeepSeek Harness (dsh) plugins without restarting dsh
|
|
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": [
|
|
7
7
|
"dsh",
|
|
8
8
|
"dsh-plugin",
|