启动链路:从 npx dsh web 到运行时

📑 目录

前两篇讲了地图:包怎么组织、树怎么组装。这一篇沿着一条真实的命令走完全程:npx @deepseek-ai/dsh web。从按下回车到浏览器打开 http://127.0.0.1:3080,进程里发生了什么?读完本篇,你应该能指着源码说出每个阶段的位置、职责和失败时的行为。本文基于 2026 年 10 月的 master 分支(commit 5badb15,版本 0.2.1-alpha.1)。

bin 入口与调用链

入口在 apps/cli/package.json 的 bin 字段,指向编译产物 lib/bin.js;仓库内则用 node --import tsx/esm 直接跑源码 apps/cli/src/bin.ts,整个包是 ESM-only——这本身是个契约:不允许混出 CommonJS,否则源码直跑与产物运行会分叉。文件末尾 apps/cli/src/bin.ts:77 只有一行实质内容:在 import.meta.main 下 await runCli()。

runCli() 的链路并不长,但每一站都有明确的职责划分:

  • apps/cli/src/args.ts:146 的 parseDshArgs():基于 commander 解析 argv,产出 invocation。launcher flag(如 --profile、--patch)由它优先解析;从首个无法识别的 token 起,剩余参数全部透传给应用本身——所以 dsh web --port 4000 里的 --port 不是 launcher 读的,是树里的插件读的。职责切分由此完成:launcher 只懂"启动哪个 profile",应用参数属于应用。
  • apps/cli/src/profile-boot.ts:244 的 runProfile():启动器的核心。它在任何插件挂载之前,先用冻结的环境快照安装 fetch 代理——Node 自带的 fetch 不读代理环境变量,而 launcher 在采样 .env 分层快照之后才装代理,所以 .env 里声明的代理也生效;原生支持的 NODE_USE_ENV_PROXY 标志做不到这一点,因为 Node 在启动时就采样完了环境。这个时序讲究值得记住:环境必须在第一个插件挂载、第一行请求发出之前就位,晚了就拦不住了。随后它进入 composeProfile()(profile-boot.ts:197)组装上一篇讲过的五层补丁,创建进程关闭控制器(SIGTERM 视为监管者的普通停止、退出码 0;SIGINT 视为用户中断、退出码 130),并安装 fail-loud 处理。
  • packages/boot/app-boot/src/index.ts:973 的 boot():把组装结果变成一棵运行中的插件树。

一个命名上的补充:dsh web 等价于 dsh --profile web;plugin 是管理命令而非 profile,若真有同名 profile,必须用 dsh --profile plugin 消歧。launcher 的 argv 语法刻意保持"第一参数选配方,其余归应用"的形状,这让它对桌面宿主、Python SDK 等外部载体也成立——它们最终都汇聚到同一个 profile runner。

sequenceDiagram
    participant B as bin.ts
    participant A as parseDshArgs
    participant R as runProfile
    participant P as composeProfile
    participant T as boot
    participant L as Loader
    participant W as web-runtime
    B->>A: runCli 解析 argv
    A-->>R: invocation 含 profile 名与应用参数
    R->>P: composeProfile 组装五层补丁
    P-->>R: profile + 拍平的 PatchOptions 数组
    R->>T: boot 传入补丁与 prepare 回调
    T->>L: 挂载根 cordis:include
    L->>L: 并发挂载条目并等待依赖
    T->>T: loader.await 沉降后审计启动条目
    T-->>R: 根 Context
    R->>R: appReady.commit
    L-->>W: 树沉降完成
    W->>W: 再等审计通过并打印 dsh web URL

boot() 内部:一棵树的诞生

打开 boot()(app-boot/src/index.ts:973),流程是:new Context() 创建根上下文,把 Cordis 的 Loader 挂上去,执行 launcher 传入的 prepare 回调——packages/boot/cmdline/src/index.ts:84 的 provideCmdline 在这里把 cmdlineArgs、appExit、appReady 提供进树,Web 启动参数由此进入插件视野;profile 上下文、启动环境快照与运行时包表也在这一步注入,树内的插件从此可以问"我是谁(profile)、我从哪启动(launch env)、我能解析哪些包"。随后 mountRootInclude()(index.ts:539)把内建的 cordis:include 挂到根上,传入的正是 composeProfile 合成的全部补丁层。根 Include 是树与配置之间唯一的接缝:条目列表经它进入,Loader 的挂载决策都由它驱动。

从这一刻起,解释权交给 Cordis:Loader 并发挂载各条目,谁先激活不取决于行序,而取决于服务依赖——声明 inject: [webStartup] 的 webserver 行会等 web-startup 插件就绪才求值自己的配置,上篇的插件树图此刻变成真实的等待关系。所谓"沉降"(settlement),就是所有条目要么激活、要么明确失败,再没有任何 fiber 处于挂起状态;ctx.loader.await() 等的就是这一刻。然后 auditStartupEntries()(index.ts:926)做启动审计:按全局必需条目表 requiredStartupEntryIds(index.ts:747,含 agent-loop、webserver、modules、connection、headless-runner、acp、sdk-jsonrpc-server)判定,失败抛 StartupError(index.ts:802),消息附一张"等待服务"表格,列出每个未就绪条目卡在哪个依赖上——审计发生在沉降之后,所以表格反映的是终态,不是中途快照;某行的 disabled 表达式求值抛错,也按失败计入。审计通过,boot() 返回;launcher 在 profile-boot.ts:317 执行 appReady.commit(),启动器层的就绪就此落定。

两层就绪:AppReady 与 web-runtime

值得单独讲的是 dsh 里其实有两个就绪概念,分属两层,不要混淆。

launcher 的 AppReady(profile-boot.ts:44 的 createAppReady)在 boot() 返回后提交,它是进程级的"启动成功"信号,HMR 拿它当重载闸门:树没完全活起来之前,配置变更一律不许进场。应用层的就绪则以 Web 为代表:packages/bundle/web-app/src/index.ts:271-318 的 web-runtime 行在打印 dsh web: <url> 之前,要先等 connection 服务就绪,再等 loader.await() 树沉降,然后再跑一次 auditStartupEntries,确认兄弟行(如 /api 路由所有者)都挂载成功,才打印经认证的 URL,本地启动还会附一个 LAN 地址,随后开浏览器。

为什么要两道?因为两者的消费方不同,对"就绪"的定义也不同。AppReady 面向 launcher 与 HMR,关心的是"进程启动这一步是否完成";URL 行面向监督者与用户,代码注释写明:监督者一观察到这行就会发起 RPC,浏览器一打开就会请求页面——所以打印本身必须构成"这个 Web 应用现在真的可用"的承诺,任何一个兄弟行没起来,这个承诺都不能发出去。SSH 场景下则只打印 URL 不打开浏览器——转发地址归 SSH 客户端或编辑器所有,远程进程无权替你打开本地标签页。就绪信号的严谨程度,决定了上游系统能对 dsh 信任到什么程度。

这条链路上还有个漂亮的细节:webserver 行的端口不是启动器读出来的,而是懒配置——packages/bundle/web-app/cordis.patch.yml:178-186 里写着 port: !!js ctx.webStartup.port ?? 3080,该行 inject: [webStartup],等 web-startup 插件就绪后在自己的上下文里求值。而 web-startup 本身(packages/bundle/web-app/src/startup.ts:81)只是个普通插件:解析 --host/--port/--no-open,提供 webStartup 服务。"Web 如何启动"这件事,完全被收编成了树上的两行配置加一个插件——这正是"一切皆插件"在启动链路上的实证。你自己写的应用组合包,启动方式同样可以这样表达。

三个工程细节

启动链路里散落着一些不显眼但有分量的决定,挑三个。

根 cordis.yml 每次启动都被重写成 []。 prepareProfile()(profile-boot.ts:167)在装载 profile 后总会执行一次 writeFileSync,内容 PROFILE_ROOT_CONFIG(profile-boot.ts:81-88)是一段注释加一个空数组。原因是 Cordis Loader 有"树回写":插件自卸载时会把当前树持久化回配置文件,若不加干预,合成后的行会被烤进文件,下次启动时所有 bundle 的 insert 在旧行之上再叠一遍,行就翻倍了。每次启动重写成空表,等于明确宣告:这个文件只是合成过程的起点,不是配置本身。它的全部意义,是给 Loader 的 baseUrl 一个真实锚点——include 需要文件存在才能解析相对路径。一个旁证:回放模式($DSH_SNAPSHOT=replay)会把根文件换成同目录的 cordis.snapshot.yml(app-boot/src/index.ts:94-107),除回放外一切模式都用 cordis.yml——根文件的名字与内容都是机制的一部分,而不是用户该编辑的对象。

fail-loud 而不是静默退出。 installFailLoud()(app-boot/src/index.ts:680,在 profile-boot.ts:282 安装)把未处理的 Promise 拒绝变成一行可读诊断,并以退出码 1 结束进程,带两秒终端释放超时与去重。Agent 运行时长、异步重试多,静默的 unhandledRejection 是最难排查的一类事故:进程看起来还活着,其实已经失去了一部分任务。把这类失败变成显式的、带前缀的、去重的诊断行,是长驻进程的基本礼貌。去重这一条尤其实用:级联失败时你不会被几百行重复堆栈淹没,只会看到第一行根因。

入口收编。 scripts/verify-application-entrypoints.ts 把仓库里每个包的 bin、可执行源码、根 demo 以及根脚本逐一枚举归类,拒绝任何绕过 dsh profile 的 Node 应用路径——架构文档的应用启动一节就是这份分类的说明书,它还特意点明:vendored CLI、仅用于构建测试的可执行文件、进程内直接挂载插件不属于应用启动器。这条 CI 门禁保证了"所有应用都经 launcher 启动"这个架构约束不被未来的 PR 悄悄打破,也是"一切皆插件"能成立的边界条件:如果存在第二条绕过组装的启动路径,补丁层就管不到它。另一个相关的小决定:dsh web 明确拒绝 --host 0.0.0.0(startup.ts:85-86),报错原文说明理由——它会把远程代码执行暴露给网络,请用 127.0.0.1。

配置 HMR:监视、比对与事务

最后看启动之后的事。base 层默认挂载 dsh-hmr(packages/boot/hmr/src/index.ts:210),其 config 里 root: [] 意味着模块监视默认关闭,只剩配置监视;headless、sdk、acp 组合包在自己的补丁层里用 - id: hmr, disabled: true 禁用它——"默认配置重载"是 Web 场景的产品决定,不是全局默认。

配置重载的入口在 hmr/src/index.ts:320-351。HMR 服务拿到 profileContext 后,注册三个精确路径的 watcher:profile 的 cordis.patch.yml、home 级的 cordis.patch.yml、profile 的 package.json。watchConfig()(hmr/src/watch-config.ts:37)基于 chokidar,带 awaitWriteFinish 写入稳定化(编辑器保存往往分多次写),支持父目录尚不存在的情况,并把多次触发串行化——配置风暴不会被并发放大。

flowchart TD
    W1["watcher:profile/cordis.patch.yml"] --> Q["refresh 串行队列"]
    W2["watcher:home/cordis.patch.yml"] --> Q
    W3["watcher:profile package.json"] --> Q
    Q --> C{"bundles 列表与 patch 内容
和上次快照相同?"} C -->|"是"| SKIP["无事发生"] C -->|"否"| R["readProfilePatches 重读全部五层"] R --> X["runExclusive 事务
与插件安装互斥"] X --> U["entry.update 更新根 Include 条目"] U --> F["等旧 fiber 退出、新树沉降"] F --> N{"新引入的失败?"} N -->|"是"| LOUD["fail loud:拒绝这次重载"] N -->|"否"| E["emit app-boot/config-reload"]

三个设计点。其一,refresh 不是文件一变就重载:它把 bundles 列表与各 patch 文件内容 JSON 化后与上次快照比对,变了才调 reconcileProfilePatches()(app-boot/src/index.ts:274);package.json 走快路径,只比对 bundles 列表,没变就直接返回——改个无关字段不会触发整树重载。其二,重载语义:找到根 Include 条目,把新补丁数组 entry.update 进去,等旧 fiber 退出、新树沉降;新引入的失败一律 fail loud,已存在的旧失败只降级为警告——默认配方坏一处不该让你永远改不了配置;沉降完成后发出 app-boot/config-reload 事件(app-boot/src/index.ts:301),关注配置的插件据此刷新自己的视图。其三,互斥与闸门:重载跑在 runExclusive 加 AsyncLocalStorage 的事务里(hmr/src/index.ts:252、:262),与插件安装(可能改 bundles 列表)互斥,两边都先把对方挡在门外;且 appReady 是闸门——应用就绪前的重载一律跳过,因为那时树本身还没定型。对照之下,sdk-minimal 的组合包里干脆没有 hmr 行:最小载体不承诺任何热重载能力。至于模块级热替换 partialReload()(hmr/src/index.ts:525):从 Node 的模块缓存建依赖图做 accept/decline 分类,备份并清 ESM/CJS 缓存后重导入,按 registry 换 fiber,失败则回滚缓存、重新注册旧插件,最后发出 hmr/reload 事件(:728)。那是另一套机制,值得单独成文,本篇点到为止。

小结

一条 npx dsh web 的完整路径:launcher 解析 argv、组装五层补丁,把进程级设施(proxy、关闭、fail-loud)备好;boot() 建根 Context、挂 Loader、经根 cordis:include 一次性传入全部补丁,Cordis 按服务依赖并发挂载,树沉降后审计必需条目;launcher 的 AppReady 与应用的 web-runtime 各就各位,后者连等带审才把 URL 交给你。根 cordis.yml 恒为空、fail-loud、入口收编,是这条链路上三个典型的工程决定;配置 HMR 用三路径监视、快照比对和互斥事务,让"改配置即生效"不破坏树的一致性。Loader 与 Fiber 怎么实现这套并发与沉降,是下一篇 Cordis 内核的主题。