fix(settings-local): one operation chain, read-modify-write under a writer lock, and diff-shaped YAML edits

Review round three found the provider's write path could destroy state it
never observed:

- Watcher reloads and document writes ran on two independent promise
  chains, and a write rendered the whole next document from the cached
  text. An external edit still inside the debounce window (or missed
  outright) was overwritten, and the follow-up reload no-oped because the
  post-rename content matched the cache — the edit vanished without a
  trace. Reloads and writes now share one operation chain, and every write
  starts by reconciling the on-disk text into the seam before rendering,
  so unobserved sibling sections survive and publish first. An unparsable
  on-disk document fails the write loud instead of being overwritten.
- The initial load raced the watcher's own setup: a change written between
  that read and the watcher becoming active never fired an event. The
  watcher's ready signal now queues one reconcile, closing the gap.
- Two processes sharing a harness home rendered from independent caches,
  last writer winning. Writes now hold a wx-created <file>.lock sibling
  around the read-render-rename cycle with bounded backoff, a crashed-
  holder stale takeover, and a deadline failure; readers stay lock-free
  because the rename commit is atomic.
- renderYaml replaced the whole namespace node, dropping every comment
  inside the section. The next section now lands as a leaf-level diff
  (set changed values, delete removed keys), so comments, anchors, and
  formatting survive on every untouched node and on the key of every
  changed pair; arrays still replace wholesale when unequal.
This commit is contained in:
Yichen Jiang
2026-07-30 13:39:22 +08:00
parent bdc6d95d56
commit 85a3a158dd
5 changed files with 545 additions and 56 deletions
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import z from 'schemastery'
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
@@ -155,6 +155,7 @@ describe('watcher pipeline', () => {
await fiber.dispose()
disposed = true
instance!.watcher.emit('all', 'change', path)
instance!.watcher.emit('ready')
await new Promise(resolve => setTimeout(resolve, 100))
expect(postDisposeCommits).toBe(0)
})
@@ -169,4 +170,56 @@ describe('watcher pipeline', () => {
await new Promise(resolve => setTimeout(resolve, 50))
expect(scope.get()).toEqual({ theme: 'dark' })
})
it('folds an unobserved external edit into a write instead of overwriting it', 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 theme = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const editor = ctx.settings.register(settingsNamespace('editor'), z.object({
tabWidth: z.number().default(2),
}))
// The external edit has landed on disk but its watcher event has not
// fired yet (a debounce window, or a missed event): the write must fold
// it in, not resurrect the stale document.
await writeFile(path, 'ui-theme:\n theme: light\neditor:\n tabWidth: 8\n')
await theme.update({ theme: 'darker' })
const text = await readFile(path, 'utf8')
expect(text).toContain('tabWidth: 8')
expect(text).toContain('theme: darker')
// The fold published the unobserved section before the write committed.
expect(editor.get()).toEqual({ tabWidth: 8 })
})
it('reconciles at watcher ready so a change during setup is not missed', 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)
// Written after the initial load but before the watcher became active:
// no 'all' event will ever fire for it.
await writeFile(path, 'ui-theme:\n theme: written-before-ready\n')
const [instance] = await fakeInstances()
instance!.watcher.emit('ready')
await vi.waitFor(() => {
expect(scope.get().theme).toBe('written-before-ready')
})
})
it('fails a write loud when the on-disk document turned invalid unobserved', 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)
const broken = 'ui-theme: [unclosed\n flow: {\n'
await writeFile(path, broken)
await expect(scope.update({ theme: 'darker' })).rejects.toThrow(/invalid document/)
// The user's manual edit stays on disk untouched and the cache keeps the
// last good value.
expect(await readFile(path, 'utf8')).toBe(broken)
expect(scope.get()).toEqual({ theme: 'light' })
})
})