Main Chain A · Step 04

runProfile:五层拼齐 + 热更新 + 信号

apps/cli/src/profile-boot.ts(324 行)是启动链的最后一站,也是 00 页 bin.ts 直接调用的那个函数。它把五层补丁拼齐、调 boot()、再给树挂上「改配置即热重组」的 watch。

bin.ts:34 runProfile({...}) → profile-boot.ts:242 runProfile → (内部) prepareProfile:166 · composeProfile:195 · readProfilePatches:45 · boot:294 → (终点) 活插件树 → 05 脊柱

示例本次示例:dsh web 的五层拼齐 + 热更新

示例轨迹 04-1 · 本次 dsh web 的五层补丁实际内容
# composeProfile('web', []) 的真实结果(对照 04 页 195-207 行):
# ① bundle 层:dsh-base 的 78 行 + dsh-web-app 的行(02 页示例轨迹 02-2 的 patches)
# ② profile 用户层:~/.dsh/profiles/web/cordis.patch.yml → 真实内容是空数组 []
# ③ home 用户层:~/.dsh/cordis.patch.yml → 不存在 → loadOptionalPatches 返回 []
# ④ --patch overlay:本次没传 → []
# ⑤ 遥测开关:DSH_TELEMETRY_DISABLED 未设 → 无补丁
#
# 所以本次的最终条目表 = 135 个条目(03 页 dump 的真实数字)
# 五层中只有第①层有内容——这就是「出厂即默认」的形态。
来源:本机真实文件(profile patch 是空 [])+ 04 页 composeProfile
示例轨迹 04-2 · 热更新验证(本机可复现)
# 1. 启动 dsh web(长驻进程)
# 2. 编辑 ~/.dsh/profiles/web/cordis.patch.yml,例如禁用 timer:
#    - id: timer
#      disabled: true
# 3. 进程不重启:HMR 服务(packages/boot/hmr/)发现变化 → readProfilePatches() 重算
#    → Loader 卸载旧 timer fiber(其注册全部逆转)→ 无新 timer 挂载
# 4. 再删掉这两行 → timer 恢复。改配置即改树。
来源:04 页 §3 + packages/boot/hmr/src/index.ts:198-237

卷零完成。下一卷:05 脊柱——这棵树里 dsh-agent-loop 的心脏。

§1composeProfile:五层拼齐

apps/cli/src/profile-boot.tscomposeProfile(0.1.7 已大幅瘦身)195-207
195async function composeProfile(
196  name: string,
197  patchFiles: readonly string[],
198  fromDefaultProfile?: string,
199  resolvedProfile?: ResolvedProfileRuntime,   // 0.1.6-alpha.2 新增应用自带的 profile
200): Promise<ComposedProfile> {
201  const profile = resolvedProfile?.profile ?? prepareProfile(name, true, fromDefaultProfile)
202  if (resolvedProfile !== undefined) writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
203  const resolutionOptions = { installAnchor: resolvedProfile?.installAnchor ?? INSTALL_ANCHOR, profile }
204  const resolution = await createRuntimeResolution(resolutionOptions)   // 0.1.7:唯一路径不再有三态模式
205  const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
206  return { profile, resolution, overlays }
207}
199-201

resolvedProfile(0.1.6-alpha.2 新增):调用方可以直接交来一个已加载好的 profile(连同它自己的 installAnchor),跳过「按名字在 $DSH_HOME/profiles 里找」这一步。这是给打包应用(Electron desktop)用的——它们的 profile 在自己包里,不属于用户的 Harness home。

203-204

0.1.7:分辨率只剩一条路径。createRuntimeResolution 无条件是唯一的实现——0.1.6-alpha.2 时这里还有个 resolutionMode 三态判断('link' / 'dual' / 'runtime'),现在整个概念连同它的三个符号一起消失了。§1c 讲这个演变。

205-206

这个函数现在只做三件事:拿到 profile、算分辨率、加载 --patch overlay。五层组装 + 遥测 patch 在 app-boot/src/profile-context.ts 的 readProfilePatches(§2)。

§1binitializeProfileFromDefault:0.1.5 的模板初始化

0.1.5 之前,「用出厂模板创建一个新 profile」只有一条隐式路径:profile 目录不存在且名字恰好有模板时,loadProfile 自动初始化(02 页 loadProfile:690-707)。这带来一个尴尬——想以 web 为底稿建一个自己的 rescue profile 做不到,因为 rescue 没有模板,loadProfile 只会报错让你用 dsh plugin 从零建。

0.1.5 的 --from-default-profile <name> 补上了这条路径:dsh --profile rescue --from-default-profile web。

apps/cli/src/profile-boot.tsinitializeProfileFromDefault(0.1.5 新增)100-150
100export function initializeProfileFromDefault(
101  name: string,
102  fromDefaultProfile: string,
103  home: string = resolveDshHome(),
104): void {
105  const dir = resolveProfileDir(name, home)
106  const template = Object.hasOwn(PROFILE_TEMPLATES, fromDefaultProfile)
107    ? PROFILE_TEMPLATES[fromDefaultProfile]
108    : undefined
109  if (template === undefined) {
110    const expected = Object.keys(PROFILE_TEMPLATES).sort().map(value => JSON.stringify(value)).join(', ')
111    throw new Error(
112      `${NAME}: unknown default profile ${JSON.stringify(fromDefaultProfile)}; expected one of ${expected}`,
113    )
114  }
115  if (Object.hasOwn(PROFILE_TEMPLATES, name)) {出货名保留
116    throw new Error(
117      `${NAME}: profile ${JSON.stringify(name)} is shipped and cannot be a custom profile target; `
118      + 'omit --from-default-profile to use it',
119    )
120  }
121  mkdirSync(dirname(dir), { recursive: true })
122  try {
123    mkdirSync(dir)   // 独占创建:不用 recursive,目录已存在即 EEXIST并发安全
124  } catch (error) {
125    if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
126    const manifestPath = join(dir, 'package.json')
127    if (existsSync(manifestPath)) {
128      throw new Error(
129        `${NAME}: profile ${JSON.stringify(name)} already exists at ${manifestPath}; `
130        + 'omit --from-default-profile to use it',
131      )
132    }
133    throw new Error(
134      `${NAME}: profile directory ${dir} already exists; choose an unused profile name`,
135    )
136  }
137  try {
133    initProfile(dir, template.bundles)   // 0.1.6-alpha.2:patchReload 参数已移除
139  } catch (error) {
140    try {
141      rmSync(dir, { recursive: true, force: true })   // 失败即回滚,不留半成品原子性
142    } catch (cleanupError) {
143      throw new AggregateError(
144        [error, cleanupError],
145        `${NAME}: profile initialization failed and ${dir} could not be removed`,
146      )
147    }
148    throw error
149  }
150}
100-114

只复制模板的 bundle 列表与 reload 策略(89-99 行的 JSDoc 原文):不读同名出厂 profile 的任何本地状态,也不持久化任何继承元数据。所以「从 web 派生」得到的是一份快照,不是一条持续跟随 web 变化的链接——后续 web 模板升级不会影响已建好的 profile(除了 normalizeShippedProfile 覆盖的历史元组规范化,见 02 页)。

115-120

出货名保留:--profile web --from-default-profile acp 被拒。理由很清楚——web 是出厂模板名,它的内容由安装方定义;让你用别的模板把它覆盖掉,等于让安装方的 profile 名不再可靠。想自定义就换个名字。

121-137

独占创建:mkdirSync(dir) 故意不加 recursive——目录已存在就抛 EEXIST。recursive: true 会静默成功,那样两个并发的 dsh --from-default-profile 就可能都以为自己是创建者。抛错后分两种情况报出不同的提示:已有 package.json(是个真 profile,别覆盖)vs 只有一个空目录(名字被别人占了)。

138-149

失败即回滚:initProfile 写文件到一半失败时,rmSync 把整个目录删掉——绝不留一个「有目录但没 package.json」的半成品(那会让下次 loadProfile 走进 02 页的自动初始化分支,行为难以预测)。连清理都失败时用 AggregateError 把两个错误一起抛出。

三条初始化路径的分工(0.1.5 起):

  • 隐式——loadProfile(02 页 loadProfile:690-707):名字有出厂模板 → 自动建。这是 dsh web 第一次跑就能用的原因。
  • 显式——initializeProfileFromDefault(本页):用 任意出厂模板建一个自定义名字的 profile。这是新增能力。
  • 手动——dsh plugin --profile <name> add <package>:从零建,bundle 列表自己攒。

显式路径只作用于「目录还不存在」的那一次:prepareProfile 每次都先调它,但已存在的 profile 会让它抛错——所以 prepareProfile 的调用点必须先判存在性。看 188 行的实现:if (fromDefaultProfile !== undefined) initializeProfileFromDefault(...),随后 loadProfile 的隐式分支自然不会触发。

§1c0.1.6–0.1.7:profile 分辨率子系统

插件 specifier 怎么解析成磁盘上的包?在 0.1.6 之前答案只有一种:物化 symlink——在 $DSH_HOME/profiles/node_modules 里为依赖闭包的每个包建链接,然后交给 Node 默认的解析器。0.1.6 加了第二种(内存路由表),0.1.7 则把第一种整个删掉了。

版本机制
≤ 0.1.6-alpha.1只有 healProfilesModuleFallback——把包投影写成 node_modules 里的 symlink 与 ESM proxy
0.1.6-alpha.2两套并存,用 ProfileResolutionMode = 'link' | 'dual' | 'runtime' 选择;默认值从 'link' 翻转为 'runtime'
0.1.7-rc.1只剩内存路由:createRuntimeResolution() 是唯一路径。healProfilesModuleFallback、healIsolatedProfileModuleFallback、createProfileResolutionGeneration、ProfileResolutionMode 全部删除(约 −420 行)
apps/cli/src/profile-boot.ts分辨率:唯一路径203-204
203  const resolutionOptions = { installAnchor: resolvedProfile?.installAnchor ?? INSTALL_ANCHOR, profile }
204  const resolution = await createRuntimeResolution(resolutionOptions)0.1.7:无分支
203-204

一个函数、一次调用、没有分支。0.1.6-alpha.2 时这里还有 resolutionMode === 'runtime' ? ... : ... 的三元;现在 createRuntimeResolution 无条件执行。经过一个版本的并存验证后,内存路由彻底取代了磁盘物化。

取代后的符号体系(都在 app-boot/src/profile.ts):

符号行号作用
createRuntimeResolution(406算出运行期解析所需的一切,不碰磁盘
RuntimeResolution129结果:{ profilesDir, profileDir, localPackageNames, entries, linkedRoots }
RuntimeResolutionEntry104一个包的解析条目(名字 + 目录)
LinkedRoot121需要投影的链接根
removeLinkProjections(250仅存的遗留清理:删掉旧版留下的 .dsh-module-fallback 投影目录

实际的解析拦截仍在 profile-resolution/resolver.ts——0.1.7 里那个文件长到 975 行,入口是 installRuntimeInterception((698 行)。

为什么值得做这件事?旧的 symlink 方案有三个痛点:① 需要可写磁盘;② 首次启动要建几百条链接(慢,且并发启动要加锁);③ 链接是一种全局副作用——它对整个 Node 进程可见,两个不同 profile 无法在同一进程里用不同的解析规则。内存路由把解析变成每次启动计算一次的纯函数,且天然支持多 profile 隔离。代价是它得接管 Node 的模块解析器——所以代码量不小。

04 页的 boot prepare 回调是 async 的(profile-boot.ts:294):在挂载任何配置树条目之前,它先 await hostCtx.plugin(PluginPackages, ...) 把路由表装进树。顺序不能反——任何插件 import 之前,解析规则必须已经就位。

§2runProfile:boot + 幂等 dispose

apps/cli/src/profile-boot.tsrunProfile 全文242-324
242export async function runProfile(options: RunProfileOptions): Promise<{ ctx; shutdown }> {
247  const disposeProxy = await installProxyFromEnvironment(  // HTTP 代理引导
248    options.environment,
249    (message) => { process.stderr.write(`${NAME}: ${message}\n`) },
250  )
252  const app: { current?: Context } = {}
253-261  // 单次幂等的 dispose(0.1.6-alpha.2 新增)聚合清理失败
262  try {
263    const composed = await composeProfile(
264      options.profile, options.patchFiles, options.fromDefaultProfile, options.resolvedProfile,
265    )
266    // 0.1.7:打包探测与 resolutionMode 三态判断已删除直接进 appReady
267    const shutdown = createProcessShutdown(dispose)
268    const signalShutdown = new AbortController()
278    process.on('SIGTERM', () => { interrupt(0) })
279    process.on('SIGINT', () => { interrupt(130) })
280    installFailLoud(NAME, process, async () => { await app.current?.fiber.dispose() })
284    const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
285-293    const profileContext: ProfileContext = { ... }   // 交给 HMR 的 profile 事实热重载需要它
294    const ctx = await boot(NAME, rootConfig, readProfilePatches(NAME, profileContext, composed.profile), async (hostCtx) => {五层在这里组装
295      app.current = hostCtx
296      hostCtx.provide('profileContext', profileContext)
299      hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment)
300-302      await hostCtx.plugin(PluginPackages, { resolution: composed.resolution })0.1.7:只传 resolution
305-309      provideCmdline(hostCtx, { args: options.args, exit: ..., ready: appReady.service })
310    })
311    app.current = ctx
312    if (!signalShutdown.signal.aborted
313      && ctx.fiber.state === FiberState.ACTIVE
314      && ctx.get('loader') !== undefined) {
315      appReady.commit()
316    }
317    return { ctx, shutdown }
318  } catch (error) {   // 启动失败也要清理
319    try { await dispose() } catch (cleanupError) {
320      throw new AggregateError([error, cleanupError], 'dsh: profile startup and cleanup failed')
321    }
322    throw error
323  }
324}

0.1.6-alpha.2 的两个新增:幂等 dispose 与失败清理

apps/cli/src/profile-boot.ts幂等 dispose253-261
253  let disposal: Promise<void> | undefined
254  const dispose = (): Promise<void> => disposal ??= (async () => {??= 记忆化:只跑一次
255    const failures: unknown[] = []
256    for (const release of [() => app.current?.fiber.dispose(), disposeProxy]) {
257      try { await release() } catch (error) { failures.push(error) }
258    }
259    if (failures.length === 1) throw failures[0]
260    if (failures.length > 1) throw new AggregateError(failures, 'dsh: profile cleanup failed')
261  })()
253-254

幂等 + 记忆化:disposal ??= 保证清理体只执行一次,后续调用返回同一个 promise。这很重要——清除路径有三条入口(信号、appExit、启动失败),它们可能并发或重复触发,而 fiber.dispose() 和 disposeProxy() 不该被跑两遍。

255-260

聚合清理失败:两个 release 各自 try/catch,失败收集进数组,最后按数量决定抛单个错误还是 AggregateError。注意顺序:先 dispose 树,再 dispose 代理——代理要活到最后一个请求结束。

262, 318-323

整个启动过程包在 try/catch 里:启动中途失败时主动调 dispose(),不再把清理留给进程退出。如果连清理都失败,用 AggregateError 把两个错误一起抛——原始失败不能被清理失败掩盖。

284-294

五层组装搬进了 readProfilePatches(app-boot/src/profile-context.ts:45-57)。profileContext 对象承载 profile 的事实(目录、patch 路径、overlay、遥测开关、启动时的 bundle 列表),在 296 行 provide 进树——§3 会看到谁在消费它。

300-302

0.1.7 简化:PluginPackages 的装载现在只传 { resolution }——0.1.6-alpha.2 时还有 behavior: 'verify' | 'enforce'(配合三态模式)。模式没了,行为参数也没了。

312-316

就绪信号在最后才 commit:只有「没被信号打断 + fiber 仍 ACTIVE + loader 还在」三条件齐备,才通知应用「可以开始干活了」。

§3热重载去哪了:新包 boot/hmr

0.1.6-alpha.1 时,热重载是 profile-boot.ts 里的一段胶水代码:composeLive() 闭包 + 两次 watchUserPatches + 按需挂载 cordis-plugin-timer/hmr。0.1.6-alpha.2 把它整个搬走了——这也是本页从 413 行缩到 337 行的主要原因。

被删除的符号:composeLive、watchUserPatches、suppressShutdownError、allPatches、patchReload 判定——在整个仓库里已经全部不存在(含 profile 的 patchReload 字段本身)。它们的功能进了一个全新包。

0.1.6-alpha.10.1.6-alpha.2
热重载的家apps/cli/src/profile-boot.ts 内联约 60 行新包 packages/boot/hmr/(723 行:index 590 + watch-config 92 + error 41)
形态launcher 里的闭包 + 两次 watcher 注册Cordis 服务 Hmr extends Service(index.ts:88),super(ctx, 'hmr')
profile 热重载patchReload === 'live' 才挂服务初始化时读 profileContext(198-237 行),有就自动观察。不再需要 profile 声明策略
重算入口composeLive() 闭包readProfilePatches + reconcileProfilePatches(app-boot/src/index.ts:251)
文件监听watchUserPatchesHmrService.watchConfig(filename, refresh)(160 行)+ 内部 watchExactConfig
packages/boot/hmr/src/index.ts服务初始化里的 profile 观察198-237
198  async* [Service.init](): AsyncGenerator<() => Promise<void>, void, unknown> {
199    yield async () => {                      // disposer:先关 watcher
200      this.closing = true
201      await this.watcher?.close()
206    const profile = this.ownerContext.get('profileContext')   // ← 04 页 308 行 provide 的闭环在这里合上
207    if (profile !== undefined) {
214      const manifestPath = join(profile.dir, 'package.json')
215      const patchFiles = [profile.patchPath, join(profile.home, PROFILE_PATCH_FILENAME)]
218      const refresh = async (manifestOnly: boolean): Promise<void> => {
229        const patches = readProfilePatches('dsh', profile)
230        const warnings = await reconcileProfilePatches(this.ownerContext.root, patches, 'dsh')
235      for (const filename of patchFiles) await this.watchConfig(filename, () => refresh(false))
236      await this.watchConfig(manifestPath, () => refresh(true))
237    }
206

闭环合上了:HMR 服务从 ownerContext 读 profileContext——正是 §2 里 runProfile 在 prepare 回调中 provide 的那个对象。launcher 与 HMR 之间不再有硬编码耦合:launcher 只负责把 profile 事实放进树,谁需要谁去读。

215, 235-236

三个被观察的文件:profile 的 cordis.patch.yml、home 的 cordis.patch.yml、profile 的 package.json。注意三次 watch 的 refresh 参数不同——patch 文件变化调 refresh(false),manifest 变化调 refresh(true)(218-220 行:manifestOnly 时先比对 bundle 列表,没变就提前返回)。

221-228

输入指纹去重:把「bundle 列表 + 三个文件的内容」序列化成字符串,与上次比对,相同就不重算。ENOENT 被正常化处理(文件不存在记为 null),所以「删掉 patch 文件」也是一个有效的变化。

229-230

readProfilePatches 重读五层 → reconcileProfilePatches 把新补丁应用进活树。这正是 02 页 §4 讲的 structuredClone gotcha 的落点——readProfilePatches 内部第 47 行就做了 clone,所以每次重组都是全新的补丁对象,不会把上一次的覆盖「烤」进 bundle 的 insert 行。

为什么值得把 60 行胶水做成 723 行的独立包?三个理由:① 它本来就是服务级的能力——模块重载、配置重载、watcher 生命周期,这些不该由 launcher 代管;② 可被别的应用复用——desktop、SDK 应用都能挂它,而不是各自复制一份;③ launcher 的职责变纯——runProfile 现在只做「组装 + boot + 信号」,热重载是树里某个插件的事,与启动器无关。这与 35 页讲的「一个事实一个家」是同一条原则。

§4启动链完成:你拥有了一棵树

到这里,主干 A 走完了。回望这条链:

bin.ts(分派)→ args.ts(解析,含 0.1.5 的 --from-default-profile)→ initializeProfileFromDefault(按需用模板建 profile)→ loadProfile(发现 profile 与 bundle)→ composeEntries(五层补丁压成条目表)→ boot()(条目表挂成插件树)→ runProfile(就绪信号 + 信号处理 + 热更新 watch)。

现在 runProfile 返回 { ctx, shutdown }——ctx 就是那棵活树。树上有 78+ 个插件,其中一个叫 dsh-agent-loop。接下来的主干 B(05→14 页)就钻进这个插件的心脏,跟踪一条消息从进入 inbox 到工具结果落盘的每一步代码。

动手验证:pnpm dsh --profile web --dump-config 打印的就是这台机器实际会启动的条目表(0.1.5 起 --from-default-profile 也对这个 dump 生效,见 00 页 54 行);在 $DSH_HOME/cordis.patch.yml 里改一行,长驻的 web 进程会即时重组。