Pārlūkot izejas kodu

fix(hmr): match Node module paths and contain startup reload failures

Turtle 2 nedēļas atpakaļ
vecāks
revīzija
ca4db9c755

+ 2 - 2
apps/cli/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 apps/cli/README.md
-README.md: ee8bfb71fa4effed9a1345199757ff2121b85097
-README.zh.md: 7c899e8a0370f7c81b4312cab7c174b311640462
+README.md: 0f52e6afbe32a6be44f66afa11e5da1f4795f24c
+README.zh.md: 7fa8a27ed546adda1e54eab24dc9ac17a322b0cb

+ 1 - 1
apps/cli/README.md

@@ -34,7 +34,7 @@ dsh --help                          # the launcher's own help
 <a id="profiles"></a>
 ## Profiles
 
-A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list and `patchReload` lifecycle) and a `cordis.patch.yml` (the user's own patch layer). `patchReload: live` watches the profile manifest and both profile and home patch files, then recomposes all layers through one serialized reload; `startup` applies them once. [Plugin Manager](../../packages/boot/plugin-manager/README.md) shares package operations and the profile write lock with `dsh plugin`; package updates retain disabled bundle selections.
+A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list and `patchReload` lifecycle) and a `cordis.patch.yml` (the user's own patch layer). `patchReload: live` watches the profile manifest and both profile and home patch files, then recomposes all layers through one serialized reload; `startup` applies them once. Edits arriving during watcher registration use the same nonfatal reload reporting as later edits. [Plugin Manager](../../packages/boot/plugin-manager/README.md) shares package operations and the profile write lock with `dsh plugin`; package updates retain disabled bundle selections.
 
 The tree composes over an empty root:
 - each bundle's patch in `dsh.profile.bundles` order

+ 1 - 1
apps/cli/README.zh.md

@@ -34,7 +34,7 @@ dsh --help                          # the launcher's own help
 <a id="profiles"></a>
 ## Profile
 
-profile 目录包含一个 `package.json`,其中记录树外插件依赖,以及 profile manifest(元数据清单)`dsh.profile`、其中按顺序排列的 `bundles` 列表与 `patchReload` 生命周期;还包含一个 `cordis.patch.yml`,其中保存用户自己的 patch 层。`patchReload: live` 监视 profile manifest、profile 与 home 级 patch 文件,再通过统一串行重载重新组合所有层;`startup` 则只应用一次。[插件管理器](../../packages/boot/plugin-manager/README.zh.md) 与 `dsh plugin` 共享包操作和 profile 写锁;更新依赖会保留已停用的组合包选择。
+profile 目录包含一个 `package.json`,其中记录树外插件依赖,以及 profile manifest(元数据清单)`dsh.profile`、其中按顺序排列的 `bundles` 列表与 `patchReload` 生命周期;还包含一个 `cordis.patch.yml`,其中保存用户自己的 patch 层。`patchReload: live` 监视 profile manifest、profile 与 home 级 patch 文件,再通过统一串行重载重新组合所有层;`startup` 则只应用一次。监听器注册期间发生的编辑与后续编辑使用相同的非致命重载错误报告。[插件管理器](../../packages/boot/plugin-manager/README.zh.md) 与 `dsh plugin` 共享包操作和 profile 写锁;更新依赖会保留已停用的组合包选择。
 
 配置树以空根为起点,依次叠加以下配置层:
 - `dsh.profile.bundles` 中各组合包的 patch

+ 6 - 1
apps/cli/src/profile-boot.ts

@@ -359,7 +359,12 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
       })
       await watching.await()
       // Include writes that finished while the file watchers were registering.
-      await ctx.hmr.runExclusive(() => withFileLock(manifestPath, refresh))
+      try {
+        await ctx.hmr.runExclusive(() => withFileLock(manifestPath, refresh))
+      } catch (error) {
+        ctx.logger.warn('profile reload failed')
+        ctx.logger.warn(error)
+      }
     } catch (error) {
       suppressShutdownError(ctx, signalShutdown.signal, error)
     }

+ 31 - 0
apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts

@@ -336,6 +336,37 @@ describe.skipIf(!built)('Web process failure matrix', () => {
     }
   })
 
+  it('reports a patch failure during watcher registration and accepts a correction', async () => {
+    const f = fixture()
+    const observerPath = fileURLToPath(f.observer.name)
+    // Write after the last native watch is ready, before the launcher's catch-up
+    // reconciliation. No filesystem callback can run inside this synchronous write.
+    writeFileSync(observerPath, readFileSync(observerPath, 'utf8').replace(
+      'export function apply(ctx, config) {',
+      `export const inject = ['hmr']
+export function apply(ctx, config) {
+  const hmr = ctx.hmr
+  const watch = hmr.watchConfig
+  hmr.watchConfig = async function (filename, refresh) {
+    const dispose = await watch.call(this, filename, refresh)
+    if (filename.endsWith('package.json')) {
+      await this.runExclusive(async () => {})
+      writeFileSync(${JSON.stringify(f.patch)}, ${JSON.stringify(f.render('matrix-optional', 'import'))})
+    }
+    return dispose
+  }
+  ctx.effect(() => () => { hmr.watchConfig = watch })`,
+    ))
+    const app = start(f)
+    try {
+      await app.wait(() => app.logs().includes('profile reload failed') && app.logs().includes('missing.mjs'))
+      await app.serves()
+      writeFileSync(f.patch, f.render('matrix-optional', undefined, 2))
+      await app.wait(() => app.events().includes('target apply 2\n'))
+      await app.serves()
+    } finally { exit(await app.close(), 0) }
+  })
+
   it.each(['startup', 'HMR'])('optional HTTP bind failure at %s leaves Web serving', async (phase) => {
     const f = fixture()
     const blocker = createServer()

+ 2 - 2
docs/config-catalog.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 docs/config-catalog.md
-config-catalog.md: a196cd7cefd203388cc4511680ccf08a85b246ca
-config-catalog.zh.md: 25dd4f0d6781c51ffd1df6fcef8b6f4f34b161cb
+config-catalog.md: cfd41d7396c14d1662fe51bc74b295ccf551a895
+config-catalog.zh.md: eff5f686fef8f7add5ea527d18e997d695541ae5

+ 1 - 1
docs/config-catalog.md

@@ -929,7 +929,7 @@ export interface HmrConfig extends ChokidarOptions {
 
 Depends on: `ChokidarOptions` (`chokidar`)
 
-Source: [`packages/boot/hmr/src/index.ts:52`](../packages/boot/hmr/src/index.ts)
+Source: [`packages/boot/hmr/src/index.ts:54`](../packages/boot/hmr/src/index.ts)
 
 <a id="deepseek-aidsh-hooks-claude-code"></a>
 

+ 1 - 1
docs/config-catalog.zh.md

@@ -931,7 +931,7 @@ export interface HmrConfig extends ChokidarOptions {
 
 依赖: `ChokidarOptions` (`chokidar`)
 
-来源: [`packages/boot/hmr/src/index.ts:52`](../packages/boot/hmr/src/index.ts)
+来源: [`packages/boot/hmr/src/index.ts:54`](../packages/boot/hmr/src/index.ts)
 
 <a id="deepseek-aidsh-hooks-claude-code"></a>
 

+ 2 - 2
packages/boot/hmr/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/hmr/README.md
-README.md: dd08ab1edbd0e7e2495179b3ad88bd51d782a6b7
-README.zh.md: 197b4fea597ed16dd64c8d479e5aedfc833e911d
+README.md: a7e5d6272830b969b6990f841693854d5f7e1868
+README.zh.md: 87e37487af5f6c15af7ba91361fc76d88f1f3f81

+ 2 - 0
packages/boot/hmr/README.md

@@ -58,6 +58,8 @@ Chokidar options, including polling, retain their existing meaning. Exact config
 
 The launcher retains profile parsing and patch precedence. HMR owns watchers, module-cache replacement and reload scheduling. Unknown-file notifications do not acquire reload locks, so lock-file events cannot trigger another lock acquisition. No invariant companion is published because the queue and watcher registrations have no independent persisted projection.
 
+Watched module paths use Node ESM resolution's `realpathSync()` spelling, including Windows short directory names, so file events match the module cache.
+
 The module replacement implementation derives from `@cordisjs/plugin-hmr` 1.0.15, with Harness Node-loader and lazy-config changes. Its [MIT license](LICENSE) is retained.
 
 </details>

+ 2 - 0
packages/boot/hmr/README.zh.md

@@ -58,6 +58,8 @@ Chokidar 选项(包括轮询)保持原有含义。精确配置监听同时
 
 启动器保留 profile 解析和 patch 优先级规则。HMR 负责监听器、模块缓存替换和重载调度。未知文件通知不获取重载锁,因此锁文件事件不会触发下一次取锁。不发布 invariant 伴生入口,因为队列和监听注册没有独立的持久投影。
 
+被监听模块的路径沿用 Node ESM 解析所用的 `realpathSync()` 表示,包括 Windows 短目录名,使文件事件与模块缓存匹配。
+
 模块替换实现源自 `@cordisjs/plugin-hmr` 1.0.15,包含 Harness 的 Node loader 和惰性配置修改。保留其 [MIT 许可证](LICENSE)。
 
 </details>

+ 9 - 7
packages/boot/hmr/src/index.ts

@@ -6,7 +6,7 @@ import { ModuleLoader, type ModuleJob, type ResolveResult } from '@deepseek-ai/c
 import type { Include } from '@deepseek-ai/cordis-plugin-include'
 import { FSWatcher, watch, type ChokidarOptions } from 'chokidar'
 import { basename, dirname, relative, resolve } from 'node:path'
-import { realpath } from 'node:fs/promises'
+import { realpathSync } from 'node:fs'
 import { handleError } from './error.ts'
 import type {} from '@deepseek-ai/cordis-plugin-timer'
 import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -39,12 +39,14 @@ declare module '@deepseek-ai/cordis' {
   }
 }
 
-async function canonicalPath(filename: string): Promise<string> {
-  try { return await realpath(filename) } catch (error) {
+function canonicalPath(filename: string): string {
+  // Node's ESM resolver uses the JS realpath implementation; native realpath
+  // expands Windows short names differently and would miss its cache keys.
+  try { return realpathSync(filename) } catch (error) {
     if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
     const parent = dirname(filename)
     if (parent === filename) throw error
-    return resolve(await canonicalPath(parent), basename(filename))
+    return resolve(canonicalPath(parent), basename(filename))
   }
 }
 
@@ -156,7 +158,7 @@ class Hmr extends Service {
    * @returns Disposer closing this registration and waiting for its pending refresh.
    */
   async watchConfig(filename: string, refresh: () => Promise<void>): Promise<() => Promise<void>> {
-    const paths = [resolve(filename), await canonicalPath(filename)]
+    const paths = [resolve(filename), canonicalPath(filename)]
     if (paths.some(path => this.configPaths.has(path))) throw new Error(`config path already registered: ${filename}`)
     for (const path of paths) this.configPaths.add(path)
     try {
@@ -210,7 +212,7 @@ class Hmr extends Service {
     }
 
     const match = picomatch(ignored)
-    const watchBaseDir = await realpath(this.baseDir)
+    const watchBaseDir = realpathSync(this.baseDir)
 
     // Collect externals before opening the watcher so every post-ready change
     // is observed by listeners that already have their classification state.
@@ -237,7 +239,7 @@ class Hmr extends Service {
         const includes = new Set<Include>()
         let fullReload = false
         for (const path of batch) {
-          const filename = await canonicalPath(resolve(watchBaseDir, path))
+          const filename = canonicalPath(resolve(watchBaseDir, path))
           const configuredFilename = resolve(this.baseDir, path)
           if (this.configPaths.has(filename) || this.configPaths.has(configuredFilename)) continue
           const url = pathToFileURL(filename).href

+ 1 - 1
packages/boot/hmr/tests/coordination.spec.ts

@@ -118,7 +118,7 @@ it('refreshes an Include under the application lock and skips registered exact p
   writeFileSync(file, '[]\n')
   const imported = vi.spyOn(ctx.loader, 'import').mockResolvedValue(Include)
   onTestFinished(() => { imported.mockRestore() })
-  const id = await ctx.loader.create({ name: 'include', config: { path: file } })
+  const id = await ctx.loader.create({ name: 'include', config: { path: pathToFileURL(file).href } })
   await ctx.loader.await()
   const include = ctx.loader.resolve(id).subtree as Include
   const refresh = vi.spyOn(include, 'refresh')

+ 15 - 10
packages/boot/hmr/tests/watch-config.spec.ts

@@ -1,4 +1,5 @@
-import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
+import * as fs from 'node:fs'
 import { realpath } from 'node:fs/promises'
 import * as fsPromises from 'node:fs/promises'
 import { tmpdir } from 'node:os'
@@ -13,6 +14,10 @@ import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
 import { FSWatcher, type ChokidarOptions } from 'chokidar'
 
 const configWatch = vi.hoisted(() => ({ create: undefined as ((options?: ChokidarOptions) => FSWatcher) | undefined }))
+vi.mock('node:fs', async (importOriginal) => {
+  const native = await importOriginal<typeof import('node:fs')>()
+  return { ...native, realpathSync: vi.fn(native.realpathSync) }
+})
 vi.mock('node:fs/promises', async (importOriginal) => {
   const native = await importOriginal<typeof import('node:fs/promises')>()
   return { ...native, stat: vi.fn(native.stat), realpath: vi.fn(native.realpath) }
@@ -62,7 +67,7 @@ describe('HMR exact config paths', () => {
     // This acceptance owns alias-to-cache identity. Other cases below exercise
     // native events; polling keeps Windows fs.watch queue pressure out of it.
     const ctx = await bootHmr(alias, [rootKind === 'relative' ? '.' : alias], true)
-    const filename = join(await realpath(target), 'module.ts')
+    const filename = join(realpathSync(target), 'module.ts')
     const expected = pathToFileURL(filename).href
     const cacheHas = vi.spyOn(ctx.loader.internal!.loadCache, 'has').mockReturnValue(false)
     const observed: string[] = []
@@ -215,7 +220,7 @@ describe('HMR exact config paths', () => {
   it.each(['creation', 'ready'] as const)('releases registration after watcher %s fails', async (phase) => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-watch-failure-'))
     hmrRoots.push(dir)
-    const ctx = new Context()
+    const ctx = await bootHmr(dir)
     onTestFinished(() => ctx.fiber.dispose())
     const filename = join(dir, 'plugins.yml')
     const previousFactory = configWatch.create
@@ -228,11 +233,11 @@ describe('HMR exact config paths', () => {
       queueMicrotask(() => { failed.emit('error', failure) })
       return failed
     }
-    await expect(watchConfig(ctx, filename, {}, () => {})).rejects.toBe(failure)
+    await expect(ctx.hmr.watchConfig(filename, async () => {})).rejects.toBe(failure)
     if (phase === 'ready') expect(closed).toHaveBeenCalledOnce()
     const watcher = new FSWatcher()
     configWatch.create = () => { queueMicrotask(() => { watcher.emit('ready') }); return watcher }
-    await watchConfig(ctx, filename, {}, () => {})
+    await ctx.hmr.watchConfig(filename, async () => {})
     const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
     onTestFinished(() => { warn.mockRestore() })
     watcher.emit('error', failure)
@@ -257,7 +262,7 @@ describe('HMR exact config paths', () => {
     expect(close).toHaveBeenCalledOnce()
   })
 
-  it('logs a normalized refresh failure and continues processing later events', async () => {
+  it.each([42, new Error('42')])('logs refresh failure %s and continues processing later events', async (failure) => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-failure-'))
     hmrRoots.push(dir)
     const filename = join(dir, 'plugins.yml')
@@ -272,7 +277,7 @@ describe('HMR exact config paths', () => {
     let calls = 0
     const recovered = Promise.withResolvers<undefined>()
     await watchConfig(ctx, filename, {}, () => {
-      if (++calls === 1) throw 42
+      if (++calls === 1) throw failure
       recovered.resolve(undefined)
     })
     watcher.emit('change', filename)
@@ -290,13 +295,13 @@ it('reports inaccessible configuration paths and missing filesystem roots', asyn
   const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-path-error-'))
   const ctx = await bootHmr(dir)
   onTestFinished(async () => { await ctx.fiber.dispose(); rmSync(dir, { recursive: true, force: true }) })
-  const native = fsPromises.realpath
+  const native = await vi.importActual<typeof import('node:fs')>('node:fs')
   const target = join(dir, 'denied.yml')
   const root = parse(dir).root
-  const mocked = vi.spyOn(fsPromises, 'realpath').mockImplementation(async (path, options) => {
+  const mocked = vi.spyOn(fs, 'realpathSync').mockImplementation((path, options) => {
     if (path === target) throw Object.assign(new Error('denied'), { code: 'EACCES' })
     if (path === root) throw Object.assign(new Error('root missing'), { code: 'ENOENT' })
-    return native(path, options)
+    return native.realpathSync(path, options)
   })
   onTestFinished(() => { mocked.mockRestore() })
   await expect(ctx.hmr.watchConfig(target, async () => {})).rejects.toThrow('denied')

+ 3 - 3
packages/boot/plugin-manager/tests/operations.spec.ts

@@ -1,6 +1,6 @@
 /** The CLI and manager share package reconciliation, path anchoring and diagnostics. */
 import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
-import { join } from 'node:path'
+import { join, resolve } from 'node:path'
 import { tmpdir } from 'node:os'
 import { PassThrough } from 'node:stream'
 import { expect, it, onTestFinished, vi } from 'vitest'
@@ -46,8 +46,8 @@ function install(dir: string, name: string) {
 }
 
 it('anchors relative package specs without rewriting registry specs', () => {
-  expect(anchorPathSpec('.', '/workspace')).toBe('/workspace')
-  expect(anchorPathSpec('file:../plugin', '/workspace/project')).toBe('file:/workspace/plugin')
+  expect(anchorPathSpec('.', '/workspace')).toBe(resolve('/workspace'))
+  expect(anchorPathSpec('file:../plugin', '/workspace/project')).toBe(`file:${resolve('/workspace/plugin')}`)
   expect(anchorPathSpec('package@1', '/workspace')).toBe('package@1')
 })