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
+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)