Просмотр исходного кода

fix(boot): re-probe records an older probe format wrote

Yichen Jiang 1 месяц назад
Родитель
Сommit
e234dc75fd

+ 2 - 2
packages/boot/app-boot/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
-README.md: 8fbeb4cfff013cefcc52f3dbe5d989fd081685c7
-README.zh.md: 01288030720419a930132dd180d92d874a682e23
+README.md: b09dad80d06849c17b4009bc4fde23d9fba0b9ac
+README.zh.md: 1b8c99c2162080c248b35967cec15081de9d4cb8

+ 1 - 1
packages/boot/app-boot/README.md

@@ -89,7 +89,7 @@ This section explains how the outcomes above are realized and points at the code
 - **External bundles are groups.** The vendored `EntryGroup.update` is all-or-nothing, so `composeExternalLayer` wraps each `runtime`-stage external layer's inserts in one `cordis:contained-group` under the ids the bundle declares; the group's `create()` records a failed row on the root's `pluginFailures` registry instead of rejecting, and `assertEntriesActivated` exempts recorded rows while still failing a built-in row left pending.
 - **Row ids are owned, not rewritten.** Entry ids are unique per tree and a `create()` that finds an existing id re-parents that entry instead of rejecting, so `composeProfileStack` decides ownership before anything mounts: built-in and boot-staged layers claim first and a duplicate among them fails the boot, a contained bundle that collides is left out whole, a user insert of a taken id is dropped, and every such row is a `conflict` record in `pluginFailures`. Boot, live recomposition, and `--dump-config` compose through the same function.
 - **Fail-loud is boot-scoped.** `installFailLoud` exits on any unhandled rejection because during startup one is a load failure; the launcher uninstalls it once the tree is up and installs `installRuntimeGuards`, which reports a rejection and keeps running and exits on an uncaught exception. Nested fibers (a `ctx.inject()` continuation) that fail under a built-in entry are reported by `warnNestedFiberFailures` as advisory lines.
-- **The probe never runs a package in the host.** `probePackage` reads an installed package's manifest here and imports it in a child process, so a package that throws, exits, hangs, or brings its own copy of cordis costs one child and yields a record with the reason. It calls a package a `plugin` only when the package declares itself to dsh — a `dsh` section or a dependency on `@deepseek-ai/cordis` — and its main export is plugin-shaped; a bare function export (`lodash`) is a `library`.
+- **The probe never runs a package in the host.** `probePackage` reads an installed package's manifest here and imports it in a child process, so a package that throws, exits, hangs, or brings its own copy of cordis costs one child and yields a record with the reason. It calls a package a `plugin` only when the package declares itself to dsh — a `dsh` section or a dependency on `@deepseek-ai/cordis` — and its main export is plugin-shaped; a bare function export (`lodash`) is a `library`. Records are cached under the profile's `.dsh-plugins/` with a format number, so a record an older probe wrote is probed again rather than trusted.
 - **Profile module fallback.** Bare plugin specifiers resolve through the Loader from the config directory. Plain Node maintains one symlink per package in the installation dependency closure. A packaged executable instead reads each installed export map with Node ESM conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Missing exports stay unavailable, malformed maps fail startup, and a cross-process writer lock replaces stale entries without exposing partial proxies. A selected external bundle absent from the installation closure receives a profile-local `.dsh-module-fallback` link; existing pnpm entries win, projected links are excluded from later closure discovery, and cleanup removes only dsh-owned links.
 - **One rejection checkpoint.** `assertEntriesActivated` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal.
 - **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack so the startup diagnostic preserves the original activation error instead of only the wrap chain.

+ 1 - 1
packages/boot/app-boot/README.zh.md

@@ -89,7 +89,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 - **外部组合包即组。** vendored 的 `EntryGroup.update` 是整组事务,因此 `composeExternalLayer` 把每个 `runtime` 阶段外部层的插入行按组合包声明的 id 包进一个 `cordis:contained-group`;该组的 `create()` 把失败的行记录到根上的 `pluginFailures` 注册表而不是 reject,`assertEntriesActivated` 豁免已记录的行,但内置行停在 pending 时仍然失败。
 - **行 id 归属而非改写。** entry id 在整棵树内唯一,而 `create()` 遇到已有 id 时会把那个 entry 挪到自己名下而不是 reject,所以 `composeProfileStack` 在任何行挂载之前先判定归属:内置层与 boot 阶段的层先占有 id,它们之间重复即启动失败;撞名的受控组合包整层排除;用户层插入已被占用的 id 时该行丢弃;每一条被排除的行都是 `pluginFailures` 里的一条 `conflict` 记录。启动、运行时重组与 `--dump-config` 走同一个函数。
 - **fail-loud 只在启动期。** `installFailLoud` 对任何未处理 rejection 退出,因为启动期间它就是加载失败;树起来后 launcher 卸载它并安装 `installRuntimeGuards`:rejection 被报告并继续运行,未捕获异常被报告并退出。内置条目下失败的嵌套 fiber(`ctx.inject()` 的延续)由 `warnNestedFiberFailures` 以提示行报告。
-- **探针从不在宿主内运行包。** `probePackage` 在本进程读取已安装包的 manifest,在子进程里 import 它,因此抛错、退出、挂起或自带 cordis 副本的包只消耗一个子进程,得到一条带原因的记录。只有包向 dsh 声明了自己——有 `dsh` 段或依赖 `@deepseek-ai/cordis`——且主导出是插件形状时才判为 `plugin`;光是导出一个函数(`lodash`)的包是 `library`。
+- **探针从不在宿主内运行包。** `probePackage` 在本进程读取已安装包的 manifest,在子进程里 import 它,因此抛错、退出、挂起或自带 cordis 副本的包只消耗一个子进程,得到一条带原因的记录。只有包向 dsh 声明了自己——有 `dsh` 段或依赖 `@deepseek-ai/cordis`——且主导出是插件形状时才判为 `plugin`;光是导出一个函数(`lodash`)的包是 `library`。记录缓存在 profile 的 `.dsh-plugins/` 下并带格式号,旧版探针写的记录会重新探测而不是被信任。
 - **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部 bundle 若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。
 - **单一 rejection 检查点。** `assertEntriesActivated` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。
 - **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链。

+ 1 - 1
packages/boot/app-boot/src/index.ts

@@ -71,7 +71,7 @@ export {
   ProfileRuntime, type ProfileRuntimeOptions, type RowOrigin,
 } from './profile-runtime.ts'
 export {
-  PLUGIN_PROBE_DIR, probePackage, readProbeCache, writeProbeCache,
+  PLUGIN_PROBE_DIR, PLUGIN_PROBE_FORMAT, probePackage, readProbeCache, writeProbeCache,
   type PluginProbe, type PluginProbeRow, type ProbeOptions,
 } from './probe.ts'
 

+ 13 - 4
packages/boot/app-boot/src/probe.ts

@@ -17,6 +17,13 @@ import { readProfileManifest, resolveBundleDir, type ProfileManifest } from './p
 /** Directory under a profile holding one probe record per package. */
 export const PLUGIN_PROBE_DIR = '.dsh-plugins'
 
+/**
+ * The probe record format. Bumped when the probe's verdicts change meaning,
+ * so a record an older probe wrote is re-probed instead of trusted: 2 made a
+ * bare function export a `library` rather than a `plugin`.
+ */
+export const PLUGIN_PROBE_FORMAT = 2
+
 /** One row a bundle's patch inserts, as its own patch declares it. */
 export interface PluginProbeRow {
   /** The row id the bundle declares, or undefined when the row leaves it to the Loader. */
@@ -306,17 +313,19 @@ function probeCachePath(profileDir: string, packageName: string): string {
  * @param profileDir - the profile directory.
  * @param packageName - the package.
  * @param version - when given, a record for a different version is treated as absent.
- * @returns the record, or undefined when none is cached.
+ * @returns the record, or undefined when none is cached or the cached one was written by another probe format.
  */
 export function readProbeCache(profileDir: string, packageName: string, version?: string): PluginProbe | undefined {
   const path = probeCachePath(profileDir, packageName)
   if (!existsSync(path)) return undefined
-  let record: PluginProbe
+  let stored: PluginProbe & { format?: number }
   try {
-    record = JSON.parse(readFileSync(path, 'utf8')) as PluginProbe
+    stored = JSON.parse(readFileSync(path, 'utf8')) as PluginProbe & { format?: number }
   } catch {
     return undefined
   }
+  const { format, ...record } = stored
+  if (format !== PLUGIN_PROBE_FORMAT) return undefined
   if (version !== undefined && record.version !== version) return undefined
   return record
 }
@@ -329,5 +338,5 @@ export function readProbeCache(profileDir: string, packageName: string, version?
 export function writeProbeCache(profileDir: string, probe: PluginProbe): void {
   const path = probeCachePath(profileDir, probe.packageName)
   mkdirSync(join(profileDir, PLUGIN_PROBE_DIR), { recursive: true })
-  writeFileSync(path, JSON.stringify(probe, undefined, 2) + '\n')
+  writeFileSync(path, JSON.stringify({ format: PLUGIN_PROBE_FORMAT, ...probe }, undefined, 2) + '\n')
 }

+ 6 - 1
packages/boot/app-boot/tests/probe.spec.ts

@@ -7,7 +7,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { describe, expect, it } from 'vitest'
-import { PLUGIN_PROBE_DIR, probePackage, readProbeCache, writeProbeCache, type PluginProbe } from '../src/index.ts'
+import { PLUGIN_PROBE_DIR, PLUGIN_PROBE_FORMAT, probePackage, readProbeCache, writeProbeCache, type PluginProbe } from '../src/index.ts'
 
 const NAME = 'dsh-test-bin'
 
@@ -194,5 +194,10 @@ describe('probe cache', () => {
     expect(readProbeCache(profileDir, '@scope/pkg', '2.0.0')).toBeUndefined()
     writeFileSync(join(profileDir, PLUGIN_PROBE_DIR, '@scope__pkg.json'), '{ not json')
     expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
+    // A record an older probe wrote — no format, or another one — is probed again, not trusted.
+    writeFileSync(join(profileDir, PLUGIN_PROBE_DIR, '@scope__pkg.json'), JSON.stringify(record))
+    expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
+    writeFileSync(join(profileDir, PLUGIN_PROBE_DIR, '@scope__pkg.json'), JSON.stringify({ format: PLUGIN_PROBE_FORMAT - 1, ...record }))
+    expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
   })
 })