fix(settings): close cross-namespace, dispatch, and lifecycle races from second review

Confirmed and fixed, each with a regression test that failed first:

- Concurrent writes to different namespaces lost whole sections on disk
  (each persist rendered the full document from a stale text): the local
  provider serializes render->write->rename->text-commit on one internal
  persist chain shared by every namespace queue.
- One throwing settings/updated listener starved the rest (cordis emit
  stops at the first throw): commit fans out per listener via
  events.dispatch, contains individual failures, and rethrows the first
  INVARIANT-coded error only after every listener ran.
- Write queues ignored fiber/service lifecycle: the base init now
  registers a teardown that refuses new writes and drains queued chains;
  queued tasks re-verify service liveness and namespace ownership before
  running and again before committing, so a registrant disposed
  mid-flight is never notified and a disposed service never commits.
- Async watcher invocations could interleave (a slow stale call applied
  last): each watcher carries a serialized invocation chain — one call
  at a time, in commit order; JSDoc/doc pages state the async timing.
- update/replace borrowed the caller's object until the queued task ran:
  inputs are structured-clone snapshotted at call time; non-cloneable
  plain objects reject with a typed error.
- Composition guard now proves the documented fallback: the consumer
  uses the optional scoped-inject shape and boots both with the settings
  entry (hot publish) and without it (entry-config resolution, no scope).
- core-data-structures index: settings.md row added to the sub-page
  table in core.md/core.zh.md.

Both packages hold per-file 100% coverage across repeated runs.
This commit is contained in:
Yichen Jiang
2026-07-29 10:07:28 +08:00
parent f44b4db1f2
commit 1010291fe6
20 changed files with 339 additions and 71 deletions
@@ -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/settings/settings-local/README.md
README.md: 9d0aa3982507ca16b382aaa918ca1536a98e29ea
README.zh.md: 075de7ee5b3e0ef0a4ee27eeb8cb098ca0d73456
README.md: af8df7c030757b330e034a1c46507fbe75c9bab8
README.zh.md: fc8943263b339baad1a92a1d0b0977b926e40f6e
@@ -19,6 +19,7 @@ Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension
- **Boot fails loud, reload keeps last-good.** An existing-but-invalid document fails plugin load; once live, an unreadable or unparsable edit warns and keeps the last good sections. A missing document resolves every namespace from defaults and `base`; deleting it publishes the same empty state.
- **Write-back is atomic, owner-only, and symlink-proof.** `persist` exclusive-creates a random-suffix temp sibling with mode `0600` (`wx` refuses to follow a planted symlink) and renames over the target, cleaning the temp up on failure. YAML writes patch one namespace in the comment-preserving document; JSON re-serializes.
- **Cross-namespace writes serialize on one document.** Every namespace shares the file, so persists from different namespace queues chain internally; each render sees the text the previous write committed.
- **Dispose quiesces.** Teardown stops accepting watcher events, closes the watcher, then waits out any queued or in-flight reload, so nothing publishes after disposal.
- **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op.
@@ -19,6 +19,7 @@
- **启动报错响亮,重载保留最后可用值。** 存在但非法的文档使插件加载失败;运行中不可读或不可解析的编辑只告警并保留最后可用分节。文档缺失时所有 namespace 按默认值与 `base` 解析;删除文档发布同样的空状态。
- **写回原子、仅属主可读、抗符号链接。** `persist` 以 `0600` 权限独占创建随机后缀临时同级文件(`wx` 拒绝跟随预埋符号链接)后 rename 覆盖目标,失败时清理临时文件。YAML 写回在保留注释的文档里只修补目标 namespace;JSON 重新序列化。
- **跨 namespace 写入在同一文档上串行。** 所有 namespace 共享一个文件,来自不同 namespace 队列的 persist 在内部串联;每次渲染都基于上一次写入提交后的文本。
- **Dispose 保证静止。** 卸载先停止接收 watcher 事件、关闭 watcher,再等完排队与进行中的重载,之后不再有任何发布。
- **按内容抑制自写。** provider 缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。
+13 -1
View File
@@ -87,6 +87,8 @@ export class SettingsLocal extends Settings {
private text: string | undefined
/** Serializes watcher-triggered reloads so reads never interleave. */
private refreshTask: Promise<void> = Promise.resolve()
/** Serializes whole-document writes across namespace queues; settled tail. */
private persistChain: Promise<void> = Promise.resolve()
/** Set at dispose: refuse new watcher events and let in-flight work no-op. */
private closed = false
@@ -121,7 +123,17 @@ export class SettingsLocal extends Settings {
return doc
}
protected async persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
// One document backs every namespace, so writes from different namespace
// queues must serialize here: each render must see the text the previous
// write committed, or the loser's section silently vanishes from disk.
// The stored tail is settled on both outcomes, so chaining needs no catch.
const task = this.persistChain.then(() => this.persistSection(ns, section))
this.persistChain = task.then(() => undefined, () => undefined)
return task
}
private async persistSection(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
const output = this.spec.format === 'yaml'
? this.renderYaml(ns, section)
: this.renderJson(ns, section)
@@ -1,7 +1,9 @@
/**
* Real-composition guard: the provider and a consumer plugin boot from a
* test-only cordis.yml through the actual Loader + Include path, and an
* external edit of settings.yaml hot-publishes into the consumer's scope.
* test-only cordis.yml through the actual Loader + Include path, an external
* edit of settings.yaml hot-publishes into the consumer's scope, and the same
* consumer booted WITHOUT a settings entry keeps its entry-config resolution —
* the documented optional-inject fallback.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
@@ -39,33 +41,50 @@ afterEach(async () => {
interface ConsumerState {
scope: SettingsScope<ThemeConfig> | undefined
seen: ThemeConfig[]
/** What the consumer is actually running with, settings or not. */
applied: ThemeConfig | undefined
}
async function loadComposition(): Promise<{ ctx: Context; state: ConsumerState; settingsPath: string }> {
async function loadComposition(
options?: { withSettings?: boolean },
): Promise<{ ctx: Context; state: ConsumerState; settingsPath: string }> {
const withSettings = options?.withSettings ?? true
root = await mkdtemp(join(tmpdir(), 'dsh-settings-composition-'))
const settingsPath = join(root, 'settings.yaml')
await writeFile(settingsPath, 'ui-theme:\n theme: light\n')
const state: ConsumerState = { scope: undefined, seen: [] }
const state: ConsumerState = { scope: undefined, seen: [], applied: undefined }
const consumer = {
name: 'settings-consumer',
inject: ['settings'],
apply: (ctx: Context) => {
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
base: { fontSize: 16 },
// The documented consumer shape: no hard dependency — entry config alone
// is the running state, and the scoped inject overlays the user layer
// only while a settings service exists.
const base: Partial<ThemeConfig> = { fontSize: 16 }
state.applied = ThemeSchema(base as ThemeConfig)
ctx.inject(['settings'], (child: Context) => {
const scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { base })
state.scope = scope
state.applied = scope.get()
scope.watch((next) => {
state.seen.push(next)
state.applied = next
})
})
state.scope = scope
scope.watch((next) => { state.seen.push(next) })
},
}
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
'- id: settings',
" name: '@deepseek-ai/dsh-settings-local'",
' config:',
` path: ${JSON.stringify(settingsPath)}`,
' debounceMs: 10',
...withSettings
? [
'- id: settings',
" name: '@deepseek-ai/dsh-settings-local'",
' config:',
` path: ${JSON.stringify(settingsPath)}`,
' debounceMs: 10',
]
: [],
'- id: consumer',
' name: test-settings-consumer',
'',
@@ -100,7 +119,9 @@ describe('settings-local real composition', () => {
const { ctx, state, settingsPath } = await loadComposition()
// Composition resolution: user layer over the consumer's composition base.
expect(state.scope!.get()).toEqual({ theme: 'light', fontSize: 16 })
await vi.waitFor(() => {
expect(state.scope!.get()).toEqual({ theme: 'light', fontSize: 16 })
})
expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual(['ui-theme'])
await writeFile(settingsPath, 'ui-theme:\n theme: dark\n fontSize: 20\n')
@@ -109,4 +130,16 @@ describe('settings-local real composition', () => {
}, { timeout: 5000 })
expect(state.seen.at(-1)).toEqual({ theme: 'dark', fontSize: 20 })
})
it('boots the same consumer without a settings entry and keeps entry-config resolution', async () => {
const { ctx, state } = await loadComposition({ withSettings: false })
// No settings service anywhere in the composition…
expect(ctx.get('settings')).toBeUndefined()
// …so the consumer runs on schema defaults plus its composition base, and
// never receives a scope.
expect(state.applied).toEqual({ theme: 'dark', fontSize: 16 })
expect(state.scope).toBeUndefined()
expect(state.seen).toEqual([])
})
})
@@ -146,6 +146,23 @@ describe('persist', () => {
expect((await readdir(dir)).sort()).toEqual(['settings.yaml'])
})
it('serializes cross-namespace writes into one on-disk document', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
const alpha = ctx.settings.register(settingsNamespace('alpha'), ThemeSchema)
const beta = ctx.settings.register(settingsNamespace('beta'), ThemeSchema)
await Promise.all([
alpha.update({ theme: 'light' }),
beta.update({ fontSize: 20 }),
])
const text = await readFile(path, 'utf8')
expect(text).toContain('alpha:')
expect(text).toContain('beta:')
expect(alpha.get().theme).toBe('light')
expect(beta.get().fontSize).toBe(20)
})
it('never follows a planted symlink at a temp path and never leaves the document a symlink', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
@@ -209,6 +226,9 @@ describe('persist', () => {
await chmod(dir, 0o700)
expect((await readdir(dir)).sort()).toEqual(['settings.yaml'])
expect(scope.get().theme).toBe('light')
// The failed persist must not poison the document write chain.
await scope.update({ theme: 'dark' })
expect(scope.get().theme).toBe('dark')
})
it('round-trips a json document', async () => {