test(hmr): wait for a live native watch before the first config write

Chokidar reports ready once fs.watch() returns, but libuv on darwin builds
the per-process FSEvents stream later on its CoreFoundation thread, so a
write that lands first is never reported. The watch-config cases now close
a second fs.watch directory handle after registration, which blocks until
that stream is rebuilt with the remaining handles, and eventually() takes
the case's effective budget instead of a local 10 s constant.

Fixes #5414
This commit is contained in:
Tianyi Cui committed 2026-09-29 01:29:18 +08:00
1 parent 3e973e689a
commit 7d3c9750e6
4 files changed
+31 -12

No files matched your search

@@ -9,8 +9,8 @@
en: 3f8862080b8c3c89
zh: 0e83c485f02c10f1
/agent-note-user-patch-tests-control-filesystem-event-delivery/decision:
en: a49b4a6e76725b7e
zh: 9813f60c40a19f83
en: 601a3437a4332b69
zh: 9847646ec11d321c
/agent-note-user-patch-tests-control-filesystem-event-delivery/alternatives-considered:
en: 5d559ee291e2e15e
zh: 828e7def587d71e8
@@ -12,7 +12,7 @@ The macOS Sandbox run (run 34238200206, job 102101292119) times out while waitin
The [user-patch test](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) writes real patch files and delivers their add, change, and unlink events through a Chokidar watcher without native watch handles. App-boot watcher registration, refresh serialization, Include recomposition, plugin activation, failure reporting, and recovery remain real; plugin rollback is absent under the [Loader policy](../simplification/2026-09-09-nontransactional-loader.md). The fixture restores its watcher factory and disposes the Context even when setup fails before the local cleanup block.
The separate [watcher tests](../../../../packages/boot/hmr/tests/watch-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. The refresh test does not establish operating-system delivery guarantees.
The separate [watcher tests](../../../../packages/boot/hmr/tests/watch-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. Before their first write they close a second `fs.watch` directory handle, which on darwin blocks until libuv has rebuilt the FSEvents stream that Chokidar readiness does not wait for, and their polling deadline is the case's effective budget. The refresh test does not establish operating-system delivery guarantees.
## Alternatives considered
@@ -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、初始不存在的父目录和文件系统别名。刷新测试不验证操作系统的投递保证。
独立的 [监视器测试](../../../../packages/boot/hmr/tests/watch-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。它们在首次写入前关闭第二个 `fs.watch` 目录句柄,该操作在 darwin 上会阻塞到 libuv 重建 Chokidar 就绪信号并不等待的 FSEvents 流为止;它们的轮询截止时间取用例的有效预算。刷新测试不验证操作系统的投递保证。
## 考虑过的替代方案
+27 -8
View File
@@ -45,8 +45,24 @@ async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean):
return ctx
}
async function eventually(test: () => boolean, message: string): Promise<void> {
const deadline = Date.now() + 10_000
/**
* 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.
*/
function ensureNativeWatchLive(dir: string): void {
fs.watch(dir).close()
}
/** Poll `test` every 10 ms under the calling case's effective budget. */
async function eventually(test: () => boolean, message: string, budget: number): Promise<void> {
const deadline = Date.now() + budget
while (!test()) {
if (Date.now() >= deadline) throw new Error(message)
await new Promise(resolve => setTimeout(resolve, 10))
@@ -109,7 +125,7 @@ describe('HMR exact config paths', () => {
}
})
it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => {
it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async ({ task }) => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
hmrRoots.push(dir)
const filename = join(dir, 'plugins.yml')
@@ -124,19 +140,20 @@ describe('HMR exact config paths', () => {
observed.push('missing')
}
})
ensureNativeWatchLive(dir)
writeFileSync(filename, 'one', { flag: 'wx' })
await eventually(() => observed.includes('one'), 'HMR did not observe config creation')
await eventually(() => observed.includes('one'), 'HMR did not observe config creation', task.timeout)
writeFileSync(filename, 'two')
await eventually(() => observed.includes('two'), 'HMR did not observe config change')
await eventually(() => observed.includes('two'), 'HMR did not observe config change', task.timeout)
unlinkSync(filename)
await eventually(() => observed.includes('missing'), 'HMR did not observe config removal')
await eventually(() => observed.includes('missing'), 'HMR did not observe config removal', task.timeout)
} finally {
await ctx.fiber.dispose()
}
})
it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => {
it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async ({ task }) => {
const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
hmrRoots.push(root)
const dir = join(root, 'later')
@@ -147,9 +164,10 @@ describe('HMR exact config paths', () => {
await watchConfig(ctx, filename, {}, () => {
observed.push(readFileSync(filename, 'utf8'))
})
ensureNativeWatchLive(root)
mkdirSync(dir)
writeFileSync(filename, 'created')
await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent')
await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent', task.timeout)
} finally {
await ctx.fiber.dispose()
}
@@ -168,6 +186,7 @@ describe('HMR exact config paths', () => {
await hmr.runExclusive(() => hmr.watchConfig(filename, async () => {
observed.resolve(readFileSync(filename, 'utf8'))
}))
ensureNativeWatchLive(dir)
writeFileSync(filename, 'created-after-transaction')
expect(await observed.promise).toBe('created-after-transaction')
})