# DSH 配置的生命周期:什么时候需要重启、什么时候刷新就够 > 2026-09-25 源码查证 + 运行时实测(含一次**独立审核者做的对照实验**)。 > **回答一个反复出现的实际问题**:「我改了配置,要重启 DSH 吗?还是刷新页面就行?」 > > ⚠️ **适用范围**:下文的「热生效」结论只对**当前这个 `web` profile** 有实证。 > `acp` / `headless` / `sdk` / `sdk-minimal` 四个模板的 `patchReload` 是 `"startup"`, > 在那些 profile 上 **patch 文件也要重启**才生效(见 §2 末尾)。 --- ## 一、四层,四种生命周期 DSH 的插件与配置分四层,**各层何时生效完全不同** —— 混为一谈必然误判。 | 层 | 存在哪 | 改它之后 | 源码依据 | |---|---|---|---| | **① 宿主半代码** | 插件包 `lib/index.js` | ❌ **要重启** | 宿主进程启动时 `import` 一次;HMR 只监控客户端 bundle 的路径(`ctx.clientModules.clientPath(row.id)`),**不碰宿主半** | | **② 客户端半代码** | 插件包 `lib/client.js` | ✅ **自动热换**(约 500ms 内),连刷新都不用 | HMR 宿主半**轮询**该文件(`pollWatches`,`pollIntervalMs` 默认 **500**),`mtime`/`size` 变化即 `clientModules.rebuilt(id)` → 推 `rebuilt` 帧 → 浏览器侧 `reload(id)` 换 fiber | | **③a profile patch** | `~/.dsh/profiles//cordis.patch.yml` | ✅ **热生效**(`web` profile) | `composeLive()` 每次**从磁盘重读**;`watchUserPatches()` 用 `hmr.registerConfig(path, cb)` 监听 | | **③b home patch** | `~/.dsh/cordis.patch.yml` | ✅ **热生效**(同上,**同一条 `composeLive` 里也有它**) | 同上 —— `composeLive()` 里第二个 `loadOptionalPatches(NAME, homePatchPath())` | | **④ 插件清单** | `~/.dsh/profiles//package.json` 的 `dsh.profile.bundles` | ❌ **要重启** | `composeLive()` 里是 `...composed.bundlePatches` —— **启动时的快照**,从不重读 | | **⑤ `--patch` overlay** | 启动参数 `--patch ` 指定的文件 | ❌ **要重启** | 它在 `composed.overlays` 里,而该字段取自启动时的 `composeProfile()`,`composeLive` 只是把它原样带过来 | **一句话**: - 改 **patch 文件**(profile 的、home 的:停用插件、覆盖配置)⇒ **什么都不用做**。 - 改 **`bundles`** 或 **`--patch` overlay** ⇒ **重启宿主**。 - 改**插件自己的代码** ⇒ 宿主半要重启;**客户端半约 500ms 自动热换**。 > ⚠️ 上表把「③ 热 / ④⑤ 冷」分清了,但**③ 只在 `patchReload === "live"` 时成立**。 > 见 §2 末尾。 --- ## 二、源码证据(③ 行) ### ③ 为什么 patch 文件热生效 `@deepseek-ai/dsh/lib/profile-boot-Dk-7KqJc.js:305`: ```js const composeLive = () => structuredClone([ ...composed.bundlePatches, // ← 启动快照,不重读(④) ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], // ← 每次重读磁盘 ✓(③a) ...loadOptionalPatches(NAME, homePatchPath()) ?? [], // ← 每次重读磁盘 ✓(③b) ...composed.overlays // ← 启动快照,不重读(⑤) ]); ``` ⇒ **这个 4 元组里,只有中间两项是「每次重读」的**。首尾两项都取自启动时的 `composeProfile()`(`profile.layer` 的 patches → `bundlePatches`;`--patch` 文件 → `overlays`),`composeLive` 只是把它们原样带过来。 同文件 321–338 行(仅当 `patchReload === "live"`): ```js if (ctx.get("hmr") === void 0) { await ctx.loader.create({ name: "@deepseek-ai/cordis-plugin-timer" }); // 如需 await ctx.loader.create({ name: "@deepseek-ai/cordis-plugin-hmr", config: { root: [] } }); } await watchUserPatches(ctx, { binName: NAME, filename: composed.profile.patchPath, compose: composeLive }); await watchUserPatches(ctx, { binName: NAME, filename: homePatchPath(), compose: composeLive }); ``` `@deepseek-ai/dsh-app-boot/lib/index.js:1109` 的 `watchUserPatches()`: ```js const register = hmr.registerConfig(filename, async () => { const { patches: _previousPatches, ...includeConfig } = entry.options.config; const patches = compose(loadOptionalPatches(binName, filename) ?? []); await entry.update({ config: { ...includeConfig, patches } }); }); ``` ⇒ **文件一变 → 回调重读磁盘 → `entry.update` 重新组合**。不需要重启,也不需要刷新页面。 ### `patchReload` 的取值(⚠️ 这决定 ③ 成不成立) `PROFILE_TEMPLATES`(`dsh-app-boot/lib/index.js` 约 328–352 行): | profile | patchReload | ③ 是否热生效 | |---|---|---| | `web` | **`"live"`** | ✅ 是 | | `acp` / `headless` / `sdk` / `sdk-minimal` | `"startup"` | ❌ **否,要重启** | **`?? "live"` 兜底的实际路径**(`loadProfileDirectory`,约 848 行): ```js const rawPatchReload = manifest.dsh?.profile?.patchReload; if (rawPatchReload !== void 0 && rawPatchReload !== "live" && rawPatchReload !== "startup") throw new Error(`… must be "live" or "startup"`); const patchReload = rawPatchReload ?? "live"; // ← 本机走的就是这一行 ``` **本机实测**:`web` 的 `package.json` 里 `dsh.profile` **只有 `bundles` 一个键**, 没有 `patchReload` ⇒ 走 `?? "live"` ⇒ **实际就是 live**。 > 📌 **不要拿 `--dump-config` 去查 `patchReload`** —— 实测它**不输出**该字段 > (`dsh --profile web --dump-config | grep -i patchreload` 命中 0 行)。 > 想确认只能读源码路径或直接看 profile 的 `package.json`。 > 📌 **所以别把这条结论推广成「dsh 的 patch 都热生效」** —— 只有在 > `patchReload` 解析为 `"live"` 的 profile 上才成立。`web` 是本机唯一在用的 profile, > 但这个边界必须写清楚。(独立审核者 2026-09-25 指出我初版把 `web` 的结论 > 当成了 dsh 的通则,这条修正是对的。) ### ② 客户端半:改代码后约 500ms 自动热换 **宿主半 HMR**(`packages/client/hmr/lib/index.js`)对每个客户端 bundle 建一条监控: ```js const syncWatches = () => { const rows = new Map(); for (const row of ctx.clientModules.graph().entries) { const path = ctx.clientModules.clientPath(row.id); // ← 只盯 client.js if (path !== void 0) rows.set(row.id, path); } …增量增删监控项… }; ctx.effect(() => { syncWatches(); const unsubscribe = ctx.clientModules.onGraphChanged(syncWatches); const timer = setInterval(pollWatches, pollIntervalMs); // ← 轮询,默认 500ms timer.unref(); return () => { unsubscribe(); clearInterval(timer); watched.clear(); }; }, "client-hmr: bundle watches"); ``` `pollWatches` 比较 `mtimeMs` 与 `size`,变了就 `rehash()` → `ctx.clientModules.rebuilt(id)` → `onRebuilt` 回调用 `sseData({type:"rebuilt", id, rev})` **推给所有已连接的页面** (`/plugins/events`,`kind: "exact"` 路由)。 **客户端半**(`lib/client.js:38`)收到就换: ```js async function reload(id) { const entry = findEntry(loader, id); if (entry === void 0) { …warn: not in the loader tree…; return; } modLoader.invalidate(id); await modLoader.prefetch(id); …卸旧 fiber(等 inertia 归零)… removeOwnedStyles(id); // 清掉该插件注册的