diff --git a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml index 138d6f46ca..4eb8c76f25 100644 --- a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml @@ -10,7 +10,7 @@ zh: 0e83c485f02c10f1 /agent-note-user-patch-tests-control-filesystem-event-delivery/decision: en: 601a3437a4332b69 - zh: 9847646ec11d321c + zh: 2b6bcc4636314c31 /agent-note-user-patch-tests-control-filesystem-event-delivery/alternatives-considered: en: 5d559ee291e2e15e zh: 828e7def587d71e8 diff --git a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md index 82fff333aa..3a57b6cc88 100644 --- a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md +++ b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md @@ -12,7 +12,7 @@ macOS Sandbox 运行 (run 34238200206, job 102101292119) 在等待首次用户 p [用户 patch 测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) 写入真实 patch 文件,并通过不持有原生监听句柄的 Chokidar watcher 投递 add、change 和 unlink 事件。应用启动监视器注册、刷新串行化、Include 重组、插件激活、失败报告与恢复仍使用真实实现;[Loader 策略](../simplification/2026-09-09-nontransactional-loader.zh.md) 不提供插件回滚。即使初始化在进入局部清理块前失败,夹具也会恢复 watcher 工厂并销毁 Context。 -独立的 [监视器测试](../../../../packages/boot/hmr/tests/watch-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。它们在首次写入前关闭第二个 `fs.watch` 目录句柄,该操作在 darwin 上会阻塞到 libuv 重建 Chokidar 就绪信号并不等待的 FSEvents 流为止;它们的轮询截止时间取用例的有效预算。刷新测试不验证操作系统的投递保证。 +独立的 [监视器测试](../../../../packages/boot/hmr/tests/watch-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。它们在首次写入前关闭第二个 `fs.watch` 目录句柄,该操作在 darwin 上会阻塞到 libuv 重建 FSEvents 流为止(Chokidar 的就绪信号并不等待该流建立);它们的轮询截止时间取用例的有效预算。刷新测试不验证操作系统的投递保证。 ## 考虑过的替代方案 diff --git a/packages/boot/hmr/README.i18n.yaml b/packages/boot/hmr/README.i18n.yaml index c5996ca9af..7e4bce6b3b 100644 --- a/packages/boot/hmr/README.i18n.yaml +++ b/packages/boot/hmr/README.i18n.yaml @@ -39,8 +39,8 @@ en: 89e0624fafd701fb zh: 19d36a33c1d9af9d /deepseek-ai-dsh-hmr/known-limitations-and-deferred-work: - en: 75e861bd7c91e37b - zh: d209c33dd5b16182 + en: 379bc2c72fd9e53e + zh: 1fff1e0e676bb161 /deepseek-ai-dsh-hmr/known-limitations-and-deferred-work/dev-note: en: 7972e4b2005ec9a4 zh: 754c49ec538c164c diff --git a/packages/boot/hmr/README.md b/packages/boot/hmr/README.md index 9e2c62c739..c4405697c6 100644 --- a/packages/boot/hmr/README.md +++ b/packages/boot/hmr/README.md @@ -87,6 +87,7 @@ Reloading a contributing plugin can change later request prefixes; HMR does not - Module replacement requires Node loader internals. Framework dependency changes call the host-provided `loader.exit()` hook; HMR itself does not restart the process. - Replacing installed package versions still requires a restart through Plugin Manager. The browser Client module graph retains its separate browser-side loading mechanism. +- `watchConfig()` resolves when Chokidar reports readiness. On darwin, libuv starts the FSEvents stream afterwards on its own thread, so a write that lands within milliseconds of registration is not reported until the next event in that directory; edits made after startup are unaffected. ### Dev Note diff --git a/packages/boot/hmr/README.zh.md b/packages/boot/hmr/README.zh.md index fa926d5998..6c82c3db8e 100644 --- a/packages/boot/hmr/README.zh.md +++ b/packages/boot/hmr/README.zh.md @@ -87,6 +87,7 @@ App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提 - 模块替换需要 Node loader 内部接口。框架依赖变化调用宿主提供的 `loader.exit()` 钩子;HMR 本身不重启进程。 - 通过插件管理器替换已安装包版本仍需要重启。浏览器 Client 模块图保留独立的浏览器侧加载机制。 +- `watchConfig()` 在 Chokidar 报告就绪时 resolve。darwin 上 libuv 随后才在自己的线程启动 FSEvents 流,因此注册后数毫秒内落地的写入要等到该目录的下一个事件才会被报告;启动之后的编辑不受影响。 ### 开发备注 diff --git a/packages/boot/hmr/tests/watch-config.spec.ts b/packages/boot/hmr/tests/watch-config.spec.ts index e432bd3869..db31c0b654 100644 --- a/packages/boot/hmr/tests/watch-config.spec.ts +++ b/packages/boot/hmr/tests/watch-config.spec.ts @@ -48,13 +48,19 @@ async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): /** * Block until this process's native directory watches deliver events. Chokidar * reports `ready` once `fs.watch()` returns, but libuv on darwin builds the - * per-process FSEvents stream later on its CoreFoundation thread, and a write - * that lands before then is never reported. Closing a second directory handle - * waits for that thread to rebuild the stream with the remaining handles, so a - * watcher registered before this call observes the next write. Per-file handles - * use kqueue and leave the stream alone, so one call after registration covers - * the later change and unlink steps. Linux and Windows deliver from `fs.watch()` - * on; there this is an immediate open and close. + * per-process FSEvents stream later on its CoreFoundation thread + * (`uv__fsevents_init` only signals that thread), and a write that lands before + * then is never reported. Closing any directory handle runs `uv__fsevents_close`, + * whose `uv_sem_wait` returns only after `uv__fsevents_reschedule` has rebuilt + * and started the stream with the remaining handles (libuv `src/unix/fsevents.c`, + * unchanged across the libuv 1.x releases bundled by Node 22–24), so a watcher + * registered before this call observes the next write. Any existing directory + * works because the stream is per process. Per-file handles use kqueue and leave + * the stream alone, so one call after registration covers the later change and + * unlink steps. Linux inotify and Windows `ReadDirectoryChangesW` are armed + * inside `fs.watch()`, so there this is an immediate open and close. Should a + * libuv release drop the close-time wait, these cases regain the darwin loss + * rate this call removes rather than failing deterministically. */ function ensureNativeWatchLive(dir: string): void { fs.watch(dir).close() @@ -125,12 +131,14 @@ describe('HMR exact config paths', () => { } }) - it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async ({ task }) => { + it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async ({ task, onTestFailed }) => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) hmrRoots.push(dir) const filename = join(dir, 'plugins.yml') const ctx = await bootHmr(dir) const observed: string[] = [] + // The case budget fires before eventually() can name the pending step. + onTestFailed(() => { console.error(`refresh observed ${JSON.stringify(observed)}`) }) try { await watchConfig(ctx, filename, {}, () => { try { @@ -153,13 +161,15 @@ describe('HMR exact config paths', () => { } }) - it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async ({ task }) => { + it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async ({ task, onTestFailed }) => { const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) hmrRoots.push(root) const dir = join(root, 'later') const filename = join(dir, 'plugins.yml') const ctx = await bootHmr(root) const observed: string[] = [] + // The case budget fires before eventually() can name the pending step. + onTestFailed(() => { console.error(`refresh observed ${JSON.stringify(observed)}`) }) try { await watchConfig(ctx, filename, {}, () => { observed.push(readFileSync(filename, 'utf8')) @@ -173,7 +183,7 @@ describe('HMR exact config paths', () => { } }) - it('processes native events for a watcher registered during a transaction', async () => { + it('processes native events for a watcher registered during a transaction', { timeout: 20_000 }, async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-transaction-watch-')) const filename = join(dir, 'plugins.yml') onTestFinished(() => { rmSync(dir, { recursive: true, force: true }) })