fix(fs): watch parent before acknowledging missing files
This commit is contained in:
1 parent
5b13cba35c
commit
a77250603c
5 files changed
+72
-18
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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` 会把整个文件及编辑后的副本保存在内存中**:只有读取路径支持流式处理。
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user