fix(settings): harden seam and provider per review findings

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

- Concurrent update() lost patches (merge over one stale snapshot):
  per-namespace serialized write queues; a failed write cannot poison
  the queue for later writers.
- Fixed-name .tmp write followed planted symlinks and kept stale modes:
  random-suffix sibling, exclusive-create (wx), 0600, cleanup on
  failure, then rename.
- A throwing settings/updated listener escaped commit and permanently
  wedged the provider reload chain (rejected refreshTask): commit now
  contains listener failures (INVARIANT-coded errors still propagate),
  async watcher rejections are adopted and contained
  (watch callbacks are officially void | Promise<void>), and the
  provider chains refreshes on a settled tail with an error log.
- No way to remove a user override: scope/service replace(section)
  sets the user section wholesale; replace({}) re-inherits base and
  schema defaults.
- The three-primitive provider contract did not hold (base never
  called load()): the base Service.init loads and publishes once;
  settings-local delegates via yield* super[Service.init]().
- Dispose did not quiesce: teardown flags closed, closes the watcher,
  then awaits queued/in-flight reloads; closed is re-checked across
  await points.
- Invariant now checks the authoritative relation with the seam's own
  deepEqualJson: emitted next must equal settings.get(ns), and
  next/prev must differ structurally (cosmokit dependency dropped).
- New docs/core-data-structures/settings.{md,zh.md} with type-equiv
  blocks + manifest entries; catalog types moved from exemptions to
  LINK_MAP; website page registered.

Both packages stay at per-file 100% coverage.
This commit is contained in:
Yichen Jiang
2026-07-28 18:18:34 +08:00
parent ec0786e099
commit f44b4db1f2
27 changed files with 655 additions and 73 deletions

View File

@@ -55,7 +55,7 @@ async function loadComposition(): Promise<{ ctx: Context; state: ConsumerState;
base: { fontSize: 16 },
})
state.scope = scope
scope.watch(next => state.seen.push(next))
scope.watch((next) => { state.seen.push(next) })
},
}

View File

@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import z from 'schemastery'
import { chmod, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { chmod, lstat, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
@@ -146,6 +146,23 @@ describe('persist', () => {
expect((await readdir(dir)).sort()).toEqual(['settings.yaml'])
})
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')
const victim = join(dir, 'victim.txt')
await writeFile(victim, 'precious')
// A hostile sibling plants the historic fixed temp name as a symlink.
await symlink(victim, `${path}.tmp`)
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
expect(await readFile(victim, 'utf8')).toBe('precious')
expect((await lstat(path)).isSymbolicLink()).toBe(false)
expect((await stat(path)).mode & 0o777).toBe(0o600)
expect(await readFile(path, 'utf8')).toContain('theme: light')
})
it('preserves comments and unregistered sections across updates', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
@@ -180,6 +197,20 @@ describe('persist', () => {
expect(written).toEqual({ 'ui-theme': { theme: 'light' } })
})
it('rejects and leaves no temp residue when the directory turns unwritable', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await chmod(dir, 0o500)
cleanups.push(() => chmod(dir, 0o700))
await expect(scope.update({ theme: 'dark' })).rejects.toThrow()
await chmod(dir, 0o700)
expect((await readdir(dir)).sort()).toEqual(['settings.yaml'])
expect(scope.get().theme).toBe('light')
})
it('round-trips a json document', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.json')

View File

@@ -105,6 +105,60 @@ describe('watcher pipeline', () => {
expect(scope.get()).toEqual({ theme: 'light' })
})
it('keeps the reload queue alive after an invariant violation escapes a commit', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 5 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
let arm = true
ctx.on('settings/updated', () => {
if (!arm) return
throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
})
const [instance] = await fakeInstances()
await writeFile(path, 'ui-theme:\n theme: broken-commit\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(() => {
expect(scope.get().theme).toBe('broken-commit')
})
arm = false
await writeFile(path, 'ui-theme:\n theme: recovered\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(() => {
expect(scope.get().theme).toBe('recovered')
})
})
it('quiesces the refresh pipeline before dispose completes', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = new Context()
const fiber = ctx.plugin(SettingsLocal, { path, debounceMs: 5 })
await fiber
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
let disposed = false
let postDisposeCommits = 0
ctx.on('settings/updated', () => {
if (disposed) postDisposeCommits += 1
})
await writeFile(path, 'ui-theme:\n theme: darker\n')
const [instance] = await fakeInstances()
// Two queued refreshes: dispose interrupts one mid-flight and the other
// before it starts, so both closed guards must hold.
instance!.watcher.emit('all', 'change', path)
instance!.watcher.emit('all', 'change', path)
await fiber.dispose()
disposed = true
instance!.watcher.emit('all', 'change', path)
await new Promise(resolve => setTimeout(resolve, 100))
expect(postDisposeCommits).toBe(0)
})
it('treats an event for a still-absent file as a no-op', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')