fix(fs): watch parent before acknowledging missing files

This commit is contained in:
imccyu committed 2026-09-18 23:45:02 +08:00
1 parent 5b13cba35c
commit a77250603c
5 files changed
+72 -18

No files matched your search

+2 -2
View File
@@ -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/fs/fs-local/README.md
README.md: 6584fade3517637726f9bae4e7ca692ffffcca01
README.zh.md: cc1ab560a071a92b624a1580fa82902a2da08d99
README.md: 9c005443eb064a03b3050c9ae82295c794315239
README.zh.md: 3adc249afe68b4cd8b56c68101baafec47ffb981
+1 -2
View File
@@ -54,7 +54,7 @@ Read any regular UTF-8 text file whole or as a stream, read raw bytes up to a ca
Read, listing, and mutation failures are typed `FsError`s with stable codes — `FS_NOT_FOUND`, `FS_NOT_TEXT` (binary content), `FS_STALE_VERSION` (changed since observation), `FS_EDIT_NOT_FOUND` or `FS_AMBIGUOUS_EDIT` (no unique literal match), and others — so callers branch on the code, never on message text. A missing target on an edit reports `FS_STALE_VERSION` whether or not the version guard is supplied.
Chokidar observes one file or a directory's direct entries through OS events, without polling or recursive watching. File watches cover in-place writes, atomic replacement, deletion, and same-path recreation while the parent directory remains.
Chokidar observes one file or a directory's direct entries through OS events, without polling or recursive watching. Files use a filtered parent-directory watch, so readiness also covers creation of an initially missing file. File watches cover in-place writes, atomic replacement, deletion, and same-path recreation while the parent directory remains.
-----
@@ -129,7 +129,6 @@ No direct invalidation; the named consumer owns any request-prefix changes.
These limits define when the local backend is a poor fit or needs special operational care. They are current package constraints, not a general filesystem comparison or a task backlog.
- **`config.cwd` is not a sandbox** — it is a resolution default, not containment: absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall.
- **Initially missing targets** — Chokidar can report readiness before the parent-directory watch is attached; creation immediately after readiness can be missed.
- **Linux parent-directory recreation** — restoring observation after a parent directory is deleted and recreated is deferred; same-path file recreation while its parent remains is supported.
- **Version tokens depend on filesystem metadata** — they combine device, inode, size, nanosecond mtime, and nanosecond ctime; a storage layer that cannot update any of those facts for a rewrite can still defeat the stale guard.
- **`editText` holds the whole file (plus the edited copy) in memory** — streaming exists only on the read path.
+1 -2
View File
@@ -54,7 +54,7 @@ kind: "package-reference"
读取、列出与变更操作的失败是携带稳定错误码的类型化 `FsError`——`FS_NOT_FOUND`、`FS_NOT_TEXT`(二进制内容)、`FS_STALE_VERSION`(自观察以来已变化)、`FS_EDIT_NOT_FOUND` 或 `FS_AMBIGUOUS_EDIT`(无唯一字面量匹配)等——因此调用方依据错误码分支,绝不解析消息文本。编辑遇到缺失目标时,无论是否提供版本防护,都报告 `FS_STALE_VERSION`。
Chokidar 通过 OS 事件观察单个文件或目录的直接子项,不使用轮询或递归监听。父目录保持存在时,文件监听覆盖原地写入、原子替换、删除和同路径重建。
Chokidar 通过 OS 事件观察单个文件或目录的直接子项,不使用轮询或递归监听。文件使用筛选到目标的父目录监听,因此就绪时也能接收起初缺失文件的创建事件。父目录保持存在时,文件监听覆盖原地写入、原子替换、删除和同路径重建。
-----
@@ -129,7 +129,6 @@ Chokidar 通过 OS 事件观察单个文件或目录的直接子项,不使用
这些限制说明本地后端何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用文件系统对比或任务积压。
- **`config.cwd` 不是沙箱**:它是解析默认值,而非约束;绝对路径和 `..` 可以逃逸。请使用更严格的 `ctx.fs` 后端或 `tools/execute` waterfall(瀑布式事件)上的权限插件实施约束。
- **初始缺失目标**:Chokidar 可能在父目录监听建立前报告就绪;紧随就绪通知的创建可能漏报。
- **Linux 父目录重建**:父目录删除并重建后的监听恢复仍延期;父目录保持存在时,同路径文件重建仍受支持。
- **版本 token 依赖文件系统元数据**:它们组合设备、inode、大小、纳秒级 mtime 与纳秒级 ctime;如果存储层在重写时无法更新其中任何一项事实,仍可能绕过陈旧防护。
- **`editText` 会把整个文件及编辑后的副本保存在内存中**:只有读取路径支持流式处理。
+12 -3
View File
@@ -8,7 +8,7 @@ import { Context } from '@deepseek-ai/cordis'
import { constants as bufferConstants } from 'node:buffer'
import { once } from 'node:events'
import { watch } from 'chokidar'
import { isAbsolute, relative, resolve, sep } from 'node:path'
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import z from '@deepseek-ai/schemastery'
import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs'
@@ -68,8 +68,17 @@ const MAX_DIFF_BASIS_BYTES = Math.min(
export class LocalFileSystem extends FileSystem {
override async watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise<void>> {
signal.throwIfAborted()
const watcher = watch(this.processPath(target), { ignoreInitial: true, depth: 0 })
watcher.on('all', () => { changed() })
const path = resolve(this.processPath(target))
const directory = (await this.stat(target, signal))?.type === 'directory'
signal.throwIfAborted()
const root = directory ? path : dirname(path)
const watcher = watch(root, {
ignoreInitial: true, depth: 0,
ignored: entry => !directory && resolve(entry) !== root && resolve(entry) !== path,
})
watcher.on('all', (_event, entry) => {
if (directory || resolve(entry) === path) changed()
})
watcher.on('error', (error) => { changed(error instanceof Error ? error : new Error(String(error))) })
try {
await once(watcher, 'ready', { signal })
+56 -9
View File
@@ -1,6 +1,7 @@
/** Watch initialization and asynchronous release at the Chokidar adapter. */
import { Context } from '@deepseek-ai/cordis'
import { FsTargetKey } from '@deepseek-ai/dsh-fs'
import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import { dirname, join, resolve } from 'node:path'
import * as chokidar from 'chokidar'
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { LocalFileSystem } from '../src/index.ts'
@@ -12,32 +13,48 @@ vi.mock('chokidar', async (importOriginal) => {
afterEach(() => { vi.restoreAllMocks() })
async function setup() {
async function setup(kind: 'file' | 'directory' | 'missing' = 'file') {
const watcher = new chokidar.FSWatcher()
onTestFinished(() => watcher.close())
const watch = vi.mocked(chokidar.watch).mockReset().mockReturnValue(watcher)
const started = Promise.withResolvers<void>()
const watch = vi.mocked(chokidar.watch).mockReset().mockImplementation(() => {
started.resolve()
return watcher
})
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
await ctx.plugin(LocalFileSystem)
const target = { targetKey: FsTargetKey('/workspace/file.txt'), displayPath: 'file.txt' }
const path = resolve('/workspace/file.txt')
const target = { targetKey: FsTargetKey(path), displayPath: 'file.txt' }
const stat = vi.spyOn(ctx.fs, 'stat').mockResolvedValue(kind === 'missing'
? undefined : { type: kind, version: FsVersion('v1'), size: 1 })
const changed = vi.fn<(error?: Error) => void>()
const controller = new AbortController()
onTestFinished(() => { controller.abort() })
return { watcher, watch, fs: ctx.fs, target, changed, controller }
return { watcher, watch, fs: ctx.fs, target, changed, controller, path, stat, started: started.promise }
}
describe('local filesystem watch', () => {
it('waits for ready, forwards invalidation, and awaits watcher closure', async () => {
const h = await setup()
it.each(['file', 'missing'] as const)('waits for the parent watch of a %s target and awaits closure', async (kind) => {
const h = await setup(kind)
const pending = h.fs.watch(h.target, h.changed, h.controller.signal)
expect(h.watch).toHaveBeenCalledExactlyOnceWith('/workspace/file.txt', { ignoreInitial: true, depth: 0 })
await h.started
expect(h.watch).toHaveBeenCalledExactlyOnceWith(dirname(h.path), { ignoreInitial: true, depth: 0, ignored: expect.any(Function) })
const ignored = h.watch.mock.calls[0]![1]!.ignored
if (typeof ignored !== 'function') throw new Error('Expected a target filter')
expect(ignored(dirname(h.path))).toBe(false)
expect(ignored(h.path)).toBe(false)
expect(ignored(join(dirname(h.path), 'unrelated.txt'))).toBe(true)
let ready = false
void pending.then(() => { ready = true })
await Promise.resolve(undefined)
expect(ready).toBe(false)
h.watcher.emit('ready')
const close = await pending
h.watcher.emit('all', 'change', '/workspace/file.txt')
h.watcher.emit('all', 'addDir', dirname(h.path))
h.watcher.emit('all', 'change', join(dirname(h.path), 'unrelated.txt'))
expect(h.changed).not.toHaveBeenCalled()
h.watcher.emit('all', 'change', h.path)
expect(h.changed).toHaveBeenCalledExactlyOnceWith()
const closed = Promise.withResolvers<undefined>()
onTestFinished(() => { closed.resolve(undefined) })
@@ -53,6 +70,33 @@ describe('local filesystem watch', () => {
expect(h.watcher.closed).toBe(true)
})
it('watches a directory itself without filtering its direct entries', async () => {
const h = await setup('directory')
const pending = h.fs.watch(h.target, h.changed, h.controller.signal)
await h.started
expect(h.watch.mock.calls[0]![0]).toBe(h.path)
const ignored = h.watch.mock.calls[0]![1]!.ignored
if (typeof ignored !== 'function') throw new Error('Expected a target filter')
expect(ignored(join(h.path, 'child.txt'))).toBe(false)
h.watcher.emit('ready')
const close = await pending
h.watcher.emit('all', 'change', join(h.path, 'child.txt'))
expect(h.changed).toHaveBeenCalledExactlyOnceWith()
await close()
})
it('does not acquire a watcher when cancelled during metadata lookup', async () => {
const h = await setup()
const metadata = Promise.withResolvers<undefined>()
h.stat.mockReturnValueOnce(metadata.promise)
const pending = h.fs.watch(h.target, h.changed, h.controller.signal)
const rejected = expect(pending).rejects.toThrow()
h.controller.abort()
metadata.resolve(undefined)
await rejected
expect(h.watch).not.toHaveBeenCalled()
})
it('rejects an already-cancelled initialization without acquiring a watcher', async () => {
const h = await setup()
h.controller.abort()
@@ -64,6 +108,7 @@ describe('local filesystem watch', () => {
const h = await setup()
const pending = h.fs.watch(h.target, h.changed, h.controller.signal)
const rejected = expect(pending).rejects.toThrow()
await h.started
h.controller.abort()
await rejected
expect(h.watcher.closed).toBe(true)
@@ -75,6 +120,7 @@ describe('local filesystem watch', () => {
const error = new Error('watch failed')
const pending = h.fs.watch(h.target, h.changed, h.controller.signal)
const rejected = expect(pending).rejects.toBe(error)
await h.started
h.watcher.emit('error', error)
await rejected
expect(h.changed).toHaveBeenCalledExactlyOnceWith(error)
@@ -84,6 +130,7 @@ describe('local filesystem watch', () => {
it('normalizes watcher errors after initialization', async () => {
const h = await setup()
const pending = h.fs.watch(h.target, h.changed, h.controller.signal)
await h.started
h.watcher.emit('ready')
const close = await pending
const error = new Error('watch failed')