Main Chain A · Step 04
runProfile:五层拼齐 + 热更新 + 信号
apps/cli/src/profile-boot.ts(324 行)是启动链的最后一站,也是 00 页 bin.ts 直接调用的那个函数。它把五层补丁拼齐、调 boot()、再给树挂上「改配置即热重组」的 watch。
示例本次示例: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 的真实数字)
# 五层中只有第①层有内容——这就是「出厂即默认」的形态。
# 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 恢复。改配置即改树。
packages/boot/hmr/src/index.ts:198-237卷零完成。下一卷:05 脊柱——这棵树里 dsh-agent-loop 的心脏。
§1composeProfile:五层拼齐
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}
resolvedProfile(0.1.6-alpha.2 新增):调用方可以直接交来一个已加载好的 profile(连同它自己的 installAnchor),跳过「按名字在 $DSH_HOME/profiles 里找」这一步。这是给打包应用(Electron desktop)用的——它们的 profile 在自己包里,不属于用户的 Harness home。
0.1.7:分辨率只剩一条路径。createRuntimeResolution 无条件是唯一的实现——0.1.6-alpha.2 时这里还有个 resolutionMode 三态判断('link' / 'dual' / 'runtime'),现在整个概念连同它的三个符号一起消失了。§1c 讲这个演变。
这个函数现在只做三件事:拿到 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。
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}
只复制模板的 bundle 列表与 reload 策略(89-99 行的 JSDoc 原文):不读同名出厂 profile 的任何本地状态,也不持久化任何继承元数据。所以「从 web 派生」得到的是一份快照,不是一条持续跟随 web 变化的链接——后续 web 模板升级不会影响已建好的 profile(除了 normalizeShippedProfile 覆盖的历史元组规范化,见 02 页)。
出货名保留:--profile web --from-default-profile acp 被拒。理由很清楚——web 是出厂模板名,它的内容由安装方定义;让你用别的模板把它覆盖掉,等于让安装方的 profile 名不再可靠。想自定义就换个名字。
独占创建:mkdirSync(dir) 故意不加 recursive——目录已存在就抛 EEXIST。recursive: true 会静默成功,那样两个并发的 dsh --from-default-profile 就可能都以为自己是创建者。抛错后分两种情况报出不同的提示:已有 package.json(是个真 profile,别覆盖)vs 只有一个空目录(名字被别人占了)。
失败即回滚: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 行) |
203 const resolutionOptions = { installAnchor: resolvedProfile?.installAnchor ?? INSTALL_ANCHOR, profile }
204 const resolution = await createRuntimeResolution(resolutionOptions)0.1.7:无分支
一个函数、一次调用、没有分支。0.1.6-alpha.2 时这里还有 resolutionMode === 'runtime' ? ... : ... 的三元;现在 createRuntimeResolution 无条件执行。经过一个版本的并存验证后,内存路由彻底取代了磁盘物化。
取代后的符号体系(都在 app-boot/src/profile.ts):
| 符号 | 行号 | 作用 |
|---|---|---|
createRuntimeResolution( | 406 | 算出运行期解析所需的一切,不碰磁盘 |
RuntimeResolution | 129 | 结果:{ profilesDir, profileDir, localPackageNames, entries, linkedRoots } |
RuntimeResolutionEntry | 104 | 一个包的解析条目(名字 + 目录) |
LinkedRoot | 121 | 需要投影的链接根 |
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
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 与失败清理
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 })()
幂等 + 记忆化:disposal ??= 保证清理体只执行一次,后续调用返回同一个 promise。这很重要——清除路径有三条入口(信号、appExit、启动失败),它们可能并发或重复触发,而 fiber.dispose() 和 disposeProxy() 不该被跑两遍。
聚合清理失败:两个 release 各自 try/catch,失败收集进数组,最后按数量决定抛单个错误还是 AggregateError。注意顺序:先 dispose 树,再 dispose 代理——代理要活到最后一个请求结束。
整个启动过程包在 try/catch 里:启动中途失败时主动调 dispose(),不再把清理留给进程退出。如果连清理都失败,用 AggregateError 把两个错误一起抛——原始失败不能被清理失败掩盖。
五层组装搬进了 readProfilePatches(app-boot/src/profile-context.ts:45-57)。profileContext 对象承载 profile 的事实(目录、patch 路径、overlay、遥测开关、启动时的 bundle 列表),在 296 行 provide 进树——§3 会看到谁在消费它。
0.1.7 简化:PluginPackages 的装载现在只传 { resolution }——0.1.6-alpha.2 时还有 behavior: 'verify' | 'enforce'(配合三态模式)。模式没了,行为参数也没了。
就绪信号在最后才 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.1 | 0.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) |
| 文件监听 | watchUserPatches | HmrService.watchConfig(filename, refresh)(160 行)+ 内部 watchExactConfig |
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 }
闭环合上了:HMR 服务从 ownerContext 读 profileContext——正是 §2 里 runProfile 在 prepare 回调中 provide 的那个对象。launcher 与 HMR 之间不再有硬编码耦合:launcher 只负责把 profile 事实放进树,谁需要谁去读。
三个被观察的文件:profile 的 cordis.patch.yml、home 的 cordis.patch.yml、profile 的 package.json。注意三次 watch 的 refresh 参数不同——patch 文件变化调 refresh(false),manifest 变化调 refresh(true)(218-220 行:manifestOnly 时先比对 bundle 列表,没变就提前返回)。
输入指纹去重:把「bundle 列表 + 三个文件的内容」序列化成字符串,与上次比对,相同就不重算。ENOENT 被正常化处理(文件不存在记为 null),所以「删掉 patch 文件」也是一个有效的变化。
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 进程会即时重组。