fix(llm,settings): refuse post-disposal route replacement and teardown notifications

Two lifecycle holes the registry and the consumer helper left open.

`AdapterRegistrationHandle.replace` had no liveness guard: after the handle's
disposer ran, a replace put routes back into the registry with nothing left to
release them, so the adapter leaked permanently. `owned` being empty cannot
carry that fact, because `replace([])` is the legal empty-section state, so the
disposer records it explicitly.

`installSettingsSection`'s watcher lacked the guard its own disposer carries:
a stored change landing while the consumer unloads reached `onChange`, which
re-registers routes against a fiber whose resources are being released.

Also documents `withFileLock` in the atomic-write README (it claimed one
export), records the age-based lock takeover as a known limitation, and lists
ctx.settings and ctx.credentials in the architecture capability table.
This commit is contained in:
Yichen Jiang
2026-07-30 23:23:18 +08:00
parent 80c36ff0d3
commit 54c60f4079
14 changed files with 119 additions and 11 deletions

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/util/atomic-write/README.md
README.md: 2cd57a0fa42601e393a41de68af3f9b1e2f033b5
README.zh.md: e8f18a8ec6ef6077f15cebed0062fabc0638ee0e
README.md: be9f896eb24e28aedc2c04858da8b8da9da548dc
README.zh.md: 19a067dc84f12d334e5c31dda58e7cf78dac51f9

View File

@@ -7,14 +7,20 @@ Zero-dependency atomic file replacement shared by file-backed stores that must n
## Surface
```ts
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
declare const text: string
declare const render: (previous: string) => string
await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 })
// Read-modify-write against the same file from several processes.
await withFileLock('/home/u/.dsh/settings.yaml', async () => {
await writeFileAtomic('/home/u/.dsh/settings.yaml', render(text), { mode: 0o600 })
})
```
One export. The contract, in the order failures would exploit it:
`writeFileAtomic` commits one already-rendered string. The contract, in the order failures would exploit it:
- **Exclusive-create temp** (`wx`, random suffix): the open refuses to follow a symlink planted at a guessable temp path.
- **The fresh inode carries `mode` through the rename**: replacing a wider-permission file narrows it without a chmod race. `mode` is required so the permission decision stays visible at every call site (subject to the process umask, like every fresh inode).
@@ -22,6 +28,8 @@ One export. The contract, in the order failures would exploit it:
- **Same-directory sibling** keeps the rename on one filesystem, so the swap stays atomic.
- Parent directories are created; on any failure the temp is removed and the failure rethrown; readers observe either the old or the new complete content.
`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 `<filename>.lock` sibling, so readers never contend; waiters back off exponentially and fail with a timeout rather than block forever. A lock older than the stale age is treated as a crashed holder and broken — see [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) for what that costs.
## Model Experience
None, as this is a pure filesystem primitive; nothing here reaches a model request.
@@ -34,3 +42,4 @@ None; nothing here enters a request prefix.
- **Atomic, not durable** — no `fsync` of the file or its directory, so after a crash the rename may be observed unwound. The file-backed stores here re-read and republish on boot, keeping durability the caller's policy.
- **String content only** — no `Buffer` or stream form until a consumer needs one.
- **The lock takes over by age, not by ownership** (`TODO(settings-lock-ownership)`) — a holder slower than the stale age has its lock broken by a waiter, and release unlinks the path unconditionally, so a slow writer can remove a successor's lock. Two writers can then overlap and one cycle's result be lost. The stale age is set well above any write this repo performs, so the exposure is a paused or swapped-out process; ownership-safe acquisition and release is the fix.

View File

@@ -7,14 +7,20 @@
## 接口面
```ts
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
declare const text: string
declare const render: (previous: string) => string
await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 })
// Read-modify-write against the same file from several processes.
await withFileLock('/home/u/.dsh/settings.yaml', async () => {
await writeFileAtomic('/home/u/.dsh/settings.yaml', render(text), { mode: 0o600 })
})
```
仅一个导出。契约按故障利用它的先后顺序列出:
`writeFileAtomic` 提交一份已经渲染好的字符串。契约按故障利用它的先后顺序列出:
- **独占创建临时文件**`wx` + 随机后缀open 拒绝跟随预先埋在可猜测临时路径上的符号链接。
- **全新 inode 携带 `mode` 走完 rename**:替换权限过宽的旧文件时直接收窄,不存在 chmod 竞态。`mode` 为必填,让权限决策始终可见于每个调用点(与所有新建 inode 一样受进程 umask 影响)。
@@ -22,6 +28,8 @@ await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 })
- **同目录兄弟文件**保证 rename 落在同一文件系统上,交换保持原子。
- 自动创建父目录;任何失败都会移除临时文件并重新抛出该失败;读取方只会观察到旧内容或完整的新内容。
`withFileLock` 跨进程串行化同一文件的写入方,服务于单靠原子提交无法保证安全的读-渲染-提交循环。锁是以 `wx` 创建的同目录 `<filename>.lock`,因此读取方从不参与竞争;等待方按指数退避,超时即失败而非无限阻塞。超过陈旧时限的锁被视为持有者已崩溃并被打破——其代价见[Known Limitations and Deferred Work](#known-limitations-and-deferred-work)。
## Model Experience
无:本包是纯文件系统原语,此处没有任何内容会到达模型请求。
@@ -34,3 +42,4 @@ await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 })
- **原子但不保证持久**——不对文件或其所在目录做 `fsync`,因此崩溃后可能观察到 rename 被回退。此处的文件型存储在启动时重新读取并重新发布,把持久性留作调用方的策略。
- **仅支持字符串内容**——在有消费方需要之前,不提供 `Buffer` 或流式形态。
- **锁按时长而非归属接管**`TODO(settings-lock-ownership)`)——持有者若慢于陈旧时限,其锁会被等待方打破,而释放又无条件删除该路径,因此慢写入方可能删掉后继者的锁。两个写入方随之重叠,一轮循环的结果可能丢失。陈旧时限远高于本仓库的任何一次写入,因此暴露面是被暂停或被换出的进程;修法是按归属安全地获取与释放。