diff --git a/packages/util/atomic-write/README.i18n.yaml b/packages/util/atomic-write/README.i18n.yaml index c54e974be8..98e35ef83a 100644 --- a/packages/util/atomic-write/README.i18n.yaml +++ b/packages/util/atomic-write/README.i18n.yaml @@ -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/util/atomic-write/README.md -README.md: 4d0b55291955c9d37f4788c7d37ad8e6ce728f70 -README.zh.md: c2d7f0b49fa123befbb663ac43862a40b4ef19b4 +README.md: 0e6501c0ae35bf26d7df67f28ce0ae24c85ecca6 +README.zh.md: 377f85f5c4aab74e8c04068b616de32a8ba063d6 diff --git a/packages/util/atomic-write/README.md b/packages/util/atomic-write/README.md index 4d0b552919..0e6501c0ae 100644 --- a/packages/util/atomic-write/README.md +++ b/packages/util/atomic-write/README.md @@ -30,6 +30,8 @@ await withFileLock('/home/u/.dsh/settings.yaml', async () => { `withFileLock` serializes the writers of one file across processes, for the read-render-commit cycles a bare atomic commit cannot make safe on its own. The lock is a `wx`-created `.lock` sibling, so readers never contend; waiters back off exponentially and fail with a timeout rather than block forever. `EEXIST` identifies contention directly; `EPERM` does so only when a fresh `lstat` confirms that the lock path exists, covering Windows exclusive-create behavior without hiding an unrelated permission failure. A contender never removes the existing lock: age cannot distinguish a crashed owner from a paused live writer. +How long a contender waits is a property of the operation the holder runs, so it is stated per call through `waitMs`. The default is sized for file work alone; a holder whose cycle includes a network round trip — a credential mutation that refreshes an expired token — states a longer one, because leaving the default would fail every other writer of that file for the duration. The retry cadence stays fixed: it governs how often a contender asks, which no caller has a reason to vary. + ## Model Experience None, as this is a pure filesystem primitive; nothing here reaches a model request. diff --git a/packages/util/atomic-write/README.zh.md b/packages/util/atomic-write/README.zh.md index c2d7f0b49f..377f85f5c4 100644 --- a/packages/util/atomic-write/README.zh.md +++ b/packages/util/atomic-write/README.zh.md @@ -30,6 +30,8 @@ await withFileLock('/home/u/.dsh/settings.yaml', async () => { `withFileLock` 跨进程串行化同一文件的写入方,服务于单靠原子提交无法保证安全的读-渲染-提交循环。锁是以 `wx` 创建的同目录 `.lock`,因此读取方从不参与竞争;等待方按指数退避,超时即失败而非无限阻塞。`EEXIST` 直接表示竞争;只有一次新的 `lstat` 确认锁路径存在时,`EPERM` 才表示竞争,从而兼容 Windows 的独占创建行为,又不掩盖无关的权限故障。竞争者绝不移除现有锁:锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方。 +等待多久是持锁方所跑操作的属性,因此由每次调用经 `waitMs` 声明。默认值只按纯文件工作量级选定;若持锁方的循环包含一次网络往返——例如刷新过期 token 的凭据变更——就应声明更长的值,否则该文件的其他写入方会在这段时间内全部失败。退避节奏保持固定:它决定竞争者多久问一次,调用方没有理由改变它。 + ## 模型体验 无:本包是纯文件系统原语,此处没有任何内容会到达模型请求。 diff --git a/packages/util/atomic-write/src/index.ts b/packages/util/atomic-write/src/index.ts index 21c9de5f35..3e5764a329 100644 --- a/packages/util/atomic-write/src/index.ts +++ b/packages/util/atomic-write/src/index.ts @@ -78,14 +78,36 @@ async function isLockContention(error: unknown, lockPath: string): Promise( filename: string, operation: () => Promise, + options?: FileLockOptions, ): Promise { const lockPath = `${filename}.lock` - const deadline = Date.now() + LOCK_TIMEOUT_MS + const deadline = Date.now() + (options?.waitMs ?? DEFAULT_LOCK_WAIT_MS) let delay = LOCK_RETRY_INITIAL_MS for (;;) { try { diff --git a/packages/util/atomic-write/tests/atomic-write.spec.ts b/packages/util/atomic-write/tests/atomic-write.spec.ts index 42cbd287c0..683abe51bc 100644 --- a/packages/util/atomic-write/tests/atomic-write.spec.ts +++ b/packages/util/atomic-write/tests/atomic-write.spec.ts @@ -28,6 +28,18 @@ async function scratch(): Promise { return mkdtemp(join(tmpdir(), 'dsh-atomic-write-')) } +/** Resolve once the lockfile exists, so contention is measured against a held lock. */ +async function waitForLock(lockPath: string): Promise { + for (;;) { + try { + await stat(lockPath) + return + } catch { + await new Promise(resolve => setTimeout(resolve, 5)) + } + } +} + describe('writeFileAtomic', () => { it('creates the file and its parents with exactly the stated mode', async () => { const dir = await scratch() @@ -105,4 +117,33 @@ describe('withFileLock', () => { })).rejects.toThrow(/ENOENT|ENOTDIR|not a directory/i) expect(called).toBe(false) }) + + it('waits for the caller-stated limit rather than the protocol default', async () => { + // An operation whose work includes a network round trip legitimately holds + // the lock far longer than the render-and-rename the default was sized + // for. The limit is per call so one such operation cannot fail every other + // writer of the same file, and a caller that states a short one still + // fails fast. + const dir = await scratch() + const target = join(dir, 'document') + let release = (): void => {} + const held = new Promise((resolve) => { release = resolve }) + const holder = withFileLock(target, () => held) + // The holder owns the lock once its lockfile exists; contending before + // that would measure nothing. + await waitForLock(`${target}.lock`) + + // Elapsed time is the assertion that distinguishes a honoured limit from + // the ignored argument: without it the contender simply waits out the + // protocol default and fails with the same message. + const startedAt = Date.now() + await expect(withFileLock(target, async () => 'impatient', { waitMs: 50 })) + .rejects.toThrow(/timed out waiting for the writer lock/) + expect(Date.now() - startedAt).toBeLessThan(1_000) + + const patient = withFileLock(target, async () => 'patient', { waitMs: 10_000 }) + release() + await holder + expect(await patient).toBe('patient') + }) })