test(hmr): print pending refresh state on failure and cite the libuv barrier

The case budget fires before eventually() can name the pending step, so the
native-delivery cases print the observed refresh values on failure. The
barrier JSDoc cites the libuv close-time wait and states that any directory
handle rebuilds the per-process stream; the transaction case takes the same
budget as its siblings; the hmr README records the darwin registration
window as a known limitation.
This commit is contained in:
Tianyi Cui committed 2026-09-29 02:03:10 +08:00
1 parent 7d3c9750e6
commit c194143fef
6 files changed
+26 -14

No files matched your search

@@ -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
@@ -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 的就绪信号并不等待该流建立);它们的轮询截止时间取用例的有效预算。刷新测试不验证操作系统的投递保证。
## 考虑过的替代方案
+2 -2
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -87,6 +87,7 @@ App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提
- 模块替换需要 Node loader 内部接口。框架依赖变化调用宿主提供的 `loader.exit()` 钩子;HMR 本身不重启进程。
- 通过插件管理器替换已安装包版本仍需要重启。浏览器 Client 模块图保留独立的浏览器侧加载机制。
- `watchConfig()` 在 Chokidar 报告就绪时 resolve。darwin 上 libuv 随后才在自己的线程启动 FSEvents 流,因此注册后数毫秒内落地的写入要等到该目录的下一个事件才会被报告;启动之后的编辑不受影响。
### 开发备注
+20 -10
View File
@@ -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 }) })