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:
@@ -662,7 +662,7 @@ Committed change to one registered namespace's resolved value. Emitted after the
|
|||||||
|
|
||||||
Types: [SettingsNamespace](../core-data-structures/settings.md) · [SettingsUpdateSource](../core-data-structures/settings.md)
|
Types: [SettingsNamespace](../core-data-structures/settings.md) · [SettingsUpdateSource](../core-data-structures/settings.md)
|
||||||
|
|
||||||
Source: [`packages/settings/settings/src/index.ts:96`](../../packages/settings/settings/src/index.ts)
|
Source: [`packages/settings/settings/src/index.ts:97`](../../packages/settings/settings/src/index.ts)
|
||||||
|
|
||||||
## `slash/*`
|
## `slash/*`
|
||||||
|
|
||||||
|
|||||||
@@ -1442,7 +1442,7 @@ async replace(ns: SettingsNamespace, section: object): Promise<void>
|
|||||||
|
|
||||||
Types: [SettingsDescriptor](../core-data-structures/settings.md) · [SettingsNamespace](../core-data-structures/settings.md) · [SettingsRegisterOptions](../core-data-structures/settings.md) · [SettingsScope](../core-data-structures/settings.md)
|
Types: [SettingsDescriptor](../core-data-structures/settings.md) · [SettingsNamespace](../core-data-structures/settings.md) · [SettingsRegisterOptions](../core-data-structures/settings.md) · [SettingsScope](../core-data-structures/settings.md)
|
||||||
|
|
||||||
Source: [`packages/settings/settings/src/index.ts:168`](../../packages/settings/settings/src/index.ts)
|
Source: [`packages/settings/settings/src/index.ts:176`](../../packages/settings/settings/src/index.ts)
|
||||||
|
|
||||||
## `ctx.skills` — `SkillService`
|
## `ctx.skills` — `SkillService`
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write docs/core-data-structures/core.md
|
# pnpm run verify-translation-pairing --write docs/core-data-structures/core.md
|
||||||
core.md: 647c7f273183e0890ef191d98ab009ad129db572
|
core.md: ca8426e6fbece18a277cc31f5e86d3058b3feb32
|
||||||
core.zh.md: 8b0f439f09a6e6609dbe69c3056aa74d553a0943
|
core.zh.md: 6b297ca233428d38dbe022e58344e98fab7a15ad
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
|
|||||||
| [commands.md](commands.md) | the human-command seam: definitions, adapter discovery, direct invocation, results, and parsing views |
|
| [commands.md](commands.md) | the human-command seam: definitions, adapter discovery, direct invocation, results, and parsing views |
|
||||||
| [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events |
|
| [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events |
|
||||||
| [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` |
|
| [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` |
|
||||||
|
| [settings.md](settings.md) | the user-settings seam: `SettingsNamespace` registration, layered resolution (defaults → composition `base` → user document), owner scopes, hot commits |
|
||||||
| [session-query.md](session-query.md) | logical records, bounded exact-event reads, relationship traces, semantic filters/documents, and full-text result pages |
|
| [session-query.md](session-query.md) | logical records, bounded exact-event reads, relationship traces, semantic filters/documents, and full-text result pages |
|
||||||
| [session-title.md](session-title.md) | durable title snapshots, source provenance, and the asynchronous provider contract |
|
| [session-title.md](session-title.md) | durable title snapshots, source provenance, and the asynchronous provider contract |
|
||||||
| [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly |
|
| [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly |
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数
|
|||||||
| [commands.md](commands.md) | 人类命令 seam:定义、适配器发现、直接调用、结果与解析视图 |
|
| [commands.md](commands.md) | 人类命令 seam:定义、适配器发现、直接调用、结果与解析视图 |
|
||||||
| [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 |
|
| [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 |
|
||||||
| [persistence.md](persistence.md) | 持久性 seam:`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` |
|
| [persistence.md](persistence.md) | 持久性 seam:`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` |
|
||||||
|
| [settings.md](settings.md) | 用户设置 seam:`SettingsNamespace` 注册、分层解析(默认值 → 组合 `base` → 用户文档)、owner scope、热提交 |
|
||||||
| [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 |
|
| [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 |
|
||||||
| [session-title.md](session-title.md) | 持久标题快照、来源 provenance 与异步提供方契约 |
|
| [session-title.md](session-title.md) | 持久标题快照、来源 provenance 与异步提供方契约 |
|
||||||
| [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 |
|
| [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 |
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write docs/core-data-structures/settings.md
|
# pnpm run verify-translation-pairing --write docs/core-data-structures/settings.md
|
||||||
settings.md: 851087065c627f2390041dd6e69273e31be3917d
|
settings.md: abbfecb35f67b27e16dffb9558a35cf368c90beb
|
||||||
settings.zh.md: 955c4fbf7c147b3d0a8be62c33c031d7bd2c4ba2
|
settings.zh.md: c746e3cc181f8347cbe634b9231cfee1b3beecd3
|
||||||
|
|||||||
@@ -46,8 +46,9 @@ interface SettingsScope<T> {
|
|||||||
/** Current resolved value: schema defaults, then `base`, then the user layer. */
|
/** Current resolved value: schema defaults, then `base`, then the user layer. */
|
||||||
get(): T
|
get(): T
|
||||||
/**
|
/**
|
||||||
* Observe committed changes to this namespace's resolved value. A callback
|
* Observe committed changes to this namespace's resolved value. Invocations
|
||||||
* may be async; a rejection is contained and logged like a sync throw.
|
* of one callback run asynchronously, one at a time, in commit order; a
|
||||||
|
* rejection is contained and logged like a sync throw.
|
||||||
* @param callback - invoked after each commit with the next and previous values.
|
* @param callback - invoked after each commit with the next and previous values.
|
||||||
* @returns the disposer removing this observer.
|
* @returns the disposer removing this observer.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -46,8 +46,9 @@ interface SettingsScope<T> {
|
|||||||
/** Current resolved value: schema defaults, then `base`, then the user layer. */
|
/** Current resolved value: schema defaults, then `base`, then the user layer. */
|
||||||
get(): T
|
get(): T
|
||||||
/**
|
/**
|
||||||
* Observe committed changes to this namespace's resolved value. A callback
|
* Observe committed changes to this namespace's resolved value. Invocations
|
||||||
* may be async; a rejection is contained and logged like a sync throw.
|
* of one callback run asynchronously, one at a time, in commit order; a
|
||||||
|
* rejection is contained and logged like a sync throw.
|
||||||
* @param callback - invoked after each commit with the next and previous values.
|
* @param callback - invoked after each commit with the next and previous values.
|
||||||
* @returns the disposer removing this observer.
|
* @returns the disposer removing this observer.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
|||||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
|
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
|
||||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
|
| `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
|
||||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
|
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
|
||||||
| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:96`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`emit`) | [`settings`](../packages/settings/settings) |
|
| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:97`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
|
||||||
| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
||||||
| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
||||||
| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:237`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:237`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/settings/settings-local/README.md
|
# pnpm run verify-translation-pairing --write packages/settings/settings-local/README.md
|
||||||
README.md: 9d0aa3982507ca16b382aaa918ca1536a98e29ea
|
README.md: af8df7c030757b330e034a1c46507fbe75c9bab8
|
||||||
README.zh.md: 075de7ee5b3e0ef0a4ee27eeb8cb098ca0d73456
|
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.
|
- **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.
|
- **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.
|
- **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.
|
- **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` 解析;删除文档发布同样的空状态。
|
- **启动报错响亮,重载保留最后可用值。** 存在但非法的文档使插件加载失败;运行中不可读或不可解析的编辑只告警并保留最后可用分节。文档缺失时所有 namespace 按默认值与 `base` 解析;删除文档发布同样的空状态。
|
||||||
- **写回原子、仅属主可读、抗符号链接。** `persist` 以 `0600` 权限独占创建随机后缀临时同级文件(`wx` 拒绝跟随预埋符号链接)后 rename 覆盖目标,失败时清理临时文件。YAML 写回在保留注释的文档里只修补目标 namespace;JSON 重新序列化。
|
- **写回原子、仅属主可读、抗符号链接。** `persist` 以 `0600` 权限独占创建随机后缀临时同级文件(`wx` 拒绝跟随预埋符号链接)后 rename 覆盖目标,失败时清理临时文件。YAML 写回在保留注释的文档里只修补目标 namespace;JSON 重新序列化。
|
||||||
|
- **跨 namespace 写入在同一文档上串行。** 所有 namespace 共享一个文件,来自不同 namespace 队列的 persist 在内部串联;每次渲染都基于上一次写入提交后的文本。
|
||||||
- **Dispose 保证静止。** 卸载先停止接收 watcher 事件、关闭 watcher,再等完排队与进行中的重载,之后不再有任何发布。
|
- **Dispose 保证静止。** 卸载先停止接收 watcher 事件、关闭 watcher,再等完排队与进行中的重载,之后不再有任何发布。
|
||||||
- **按内容抑制自写。** provider 缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。
|
- **按内容抑制自写。** provider 缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ export class SettingsLocal extends Settings {
|
|||||||
private text: string | undefined
|
private text: string | undefined
|
||||||
/** Serializes watcher-triggered reloads so reads never interleave. */
|
/** Serializes watcher-triggered reloads so reads never interleave. */
|
||||||
private refreshTask: Promise<void> = Promise.resolve()
|
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. */
|
/** Set at dispose: refuse new watcher events and let in-flight work no-op. */
|
||||||
private closed = false
|
private closed = false
|
||||||
|
|
||||||
@@ -121,7 +123,17 @@ export class SettingsLocal extends Settings {
|
|||||||
return doc
|
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'
|
const output = this.spec.format === 'yaml'
|
||||||
? this.renderYaml(ns, section)
|
? this.renderYaml(ns, section)
|
||||||
: this.renderJson(ns, section)
|
: this.renderJson(ns, section)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Real-composition guard: the provider and a consumer plugin boot from a
|
* Real-composition guard: the provider and a consumer plugin boot from a
|
||||||
* test-only cordis.yml through the actual Loader + Include path, and an
|
* test-only cordis.yml through the actual Loader + Include path, an external
|
||||||
* external edit of settings.yaml hot-publishes into the consumer's scope.
|
* 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'
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||||
@@ -39,33 +41,50 @@ afterEach(async () => {
|
|||||||
interface ConsumerState {
|
interface ConsumerState {
|
||||||
scope: SettingsScope<ThemeConfig> | undefined
|
scope: SettingsScope<ThemeConfig> | undefined
|
||||||
seen: ThemeConfig[]
|
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-'))
|
root = await mkdtemp(join(tmpdir(), 'dsh-settings-composition-'))
|
||||||
const settingsPath = join(root, 'settings.yaml')
|
const settingsPath = join(root, 'settings.yaml')
|
||||||
await writeFile(settingsPath, 'ui-theme:\n theme: light\n')
|
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 = {
|
const consumer = {
|
||||||
name: 'settings-consumer',
|
name: 'settings-consumer',
|
||||||
inject: ['settings'],
|
|
||||||
apply: (ctx: Context) => {
|
apply: (ctx: Context) => {
|
||||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
|
// The documented consumer shape: no hard dependency — entry config alone
|
||||||
base: { fontSize: 16 },
|
// 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')
|
const configPath = join(root, 'cordis.yml')
|
||||||
await writeFile(configPath, [
|
await writeFile(configPath, [
|
||||||
'- id: settings',
|
...withSettings
|
||||||
" name: '@deepseek-ai/dsh-settings-local'",
|
? [
|
||||||
' config:',
|
'- id: settings',
|
||||||
` path: ${JSON.stringify(settingsPath)}`,
|
" name: '@deepseek-ai/dsh-settings-local'",
|
||||||
' debounceMs: 10',
|
' config:',
|
||||||
|
` path: ${JSON.stringify(settingsPath)}`,
|
||||||
|
' debounceMs: 10',
|
||||||
|
]
|
||||||
|
: [],
|
||||||
'- id: consumer',
|
'- id: consumer',
|
||||||
' name: test-settings-consumer',
|
' name: test-settings-consumer',
|
||||||
'',
|
'',
|
||||||
@@ -100,7 +119,9 @@ describe('settings-local real composition', () => {
|
|||||||
const { ctx, state, settingsPath } = await loadComposition()
|
const { ctx, state, settingsPath } = await loadComposition()
|
||||||
|
|
||||||
// Composition resolution: user layer over the consumer's composition base.
|
// 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'])
|
expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual(['ui-theme'])
|
||||||
|
|
||||||
await writeFile(settingsPath, 'ui-theme:\n theme: dark\n fontSize: 20\n')
|
await writeFile(settingsPath, 'ui-theme:\n theme: dark\n fontSize: 20\n')
|
||||||
@@ -109,4 +130,16 @@ describe('settings-local real composition', () => {
|
|||||||
}, { timeout: 5000 })
|
}, { timeout: 5000 })
|
||||||
expect(state.seen.at(-1)).toEqual({ theme: 'dark', fontSize: 20 })
|
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'])
|
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 () => {
|
it('never follows a planted symlink at a temp path and never leaves the document a symlink', async () => {
|
||||||
const dir = await tempDir()
|
const dir = await tempDir()
|
||||||
const path = join(dir, 'settings.yaml')
|
const path = join(dir, 'settings.yaml')
|
||||||
@@ -209,6 +226,9 @@ describe('persist', () => {
|
|||||||
await chmod(dir, 0o700)
|
await chmod(dir, 0o700)
|
||||||
expect((await readdir(dir)).sort()).toEqual(['settings.yaml'])
|
expect((await readdir(dir)).sort()).toEqual(['settings.yaml'])
|
||||||
expect(scope.get().theme).toBe('light')
|
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 () => {
|
it('round-trips a json document', async () => {
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/settings/settings/README.md
|
# pnpm run verify-translation-pairing --write packages/settings/settings/README.md
|
||||||
README.md: f7858a247f6011cd0654a73b5325d81c118441e5
|
README.md: ff6cdeb57a265dbaa9d5f50de1d558f1e3cb581f
|
||||||
README.zh.md: 67ecba695066389bfe3a69f517d52f91b48b6c75
|
README.zh.md: d820a5c1fa804455c439f1a155e7628f5118a49b
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ Abstract user-settings seam (`ctx.settings`). One provider holds a raw document
|
|||||||
- `get(ns)` — resolved value, `undefined` while unregistered.
|
- `get(ns)` — resolved value, `undefined` while unregistered.
|
||||||
- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order.
|
- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order.
|
||||||
- `replace(ns, section)` — sets the user section wholesale: the removal/reset path a merge cannot express (`replace({})` re-inherits `base` and schema defaults).
|
- `replace(ns, section)` — sets the user section wholesale: the removal/reset path a merge cannot express (`replace({})` re-inherits `base` and schema defaults).
|
||||||
- Resolved values are deep-frozen snapshots; watchers receive `(next, prev)` after each commit, and watcher failures — sync throws and async rejections alike — are contained.
|
- Resolved values are deep-frozen snapshots. Watchers receive `(next, prev)` after each commit: invocations of one callback run asynchronously, one at a time, in commit order (a slow stale invocation can never apply after a newer one), and failures — sync throws and async rejections alike — are contained. The `settings/updated` event fans out one listener at a time, so one throwing listener cannot starve the rest.
|
||||||
|
- Service teardown refuses new writes and drains every queued write before disposal completes; a write whose registrant fiber was disposed mid-flight still reaches storage but commits and notifies nobody.
|
||||||
|
|
||||||
## Provider contract
|
## Provider contract
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
- `get(ns)` — 解析值;未注册时为 `undefined`。
|
- `get(ns)` — 解析值;未注册时为 `undefined`。
|
||||||
- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经 provider 持久化后提交。校验失败在持久化前拒绝;只读 provider(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。
|
- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经 provider 持久化后提交。校验失败在持久化前拒绝;只读 provider(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。
|
||||||
- `replace(ns, section)` — 整体替换用户分节:merge 表达不了的删除/重置路径(`replace({})` 重新继承 `base` 与 schema 默认值)。
|
- `replace(ns, section)` — 整体替换用户分节:merge 表达不了的删除/重置路径(`replace({})` 重新继承 `base` 与 schema 默认值)。
|
||||||
- 解析值是深冻结快照;每次提交后观察者收到 `(next, prev)`;观察者异常——同步抛出与异步拒绝——均被隔离。
|
- 解析值是深冻结快照。每次提交后观察者收到 `(next, prev)`:同一回调的调用异步、逐次、按提交顺序执行(慢的旧调用绝不会覆盖更新的结果),异常——同步抛出与异步拒绝——均被隔离。`settings/updated` 事件逐 listener 扇出,一个抛错的 listener 不会饿死其余 listener。
|
||||||
|
- 服务卸载先拒绝新写入并排干全部排队写入后才完成;registrant fiber 在写入途中被 dispose 时,该写入仍到达存储,但不向任何人提交或通知。
|
||||||
|
|
||||||
## Provider 契约
|
## Provider 契约
|
||||||
|
|
||||||
|
|||||||
@@ -58,8 +58,9 @@ export interface SettingsScope<T> {
|
|||||||
/** Current resolved value: schema defaults, then `base`, then the user layer. */
|
/** Current resolved value: schema defaults, then `base`, then the user layer. */
|
||||||
get(): T
|
get(): T
|
||||||
/**
|
/**
|
||||||
* Observe committed changes to this namespace's resolved value. A callback
|
* Observe committed changes to this namespace's resolved value. Invocations
|
||||||
* may be async; a rejection is contained and logged like a sync throw.
|
* of one callback run asynchronously, one at a time, in commit order; a
|
||||||
|
* rejection is contained and logged like a sync throw.
|
||||||
* @param callback - invoked after each commit with the next and previous values.
|
* @param callback - invoked after each commit with the next and previous values.
|
||||||
* @returns the disposer removing this observer.
|
* @returns the disposer removing this observer.
|
||||||
*/
|
*/
|
||||||
@@ -149,6 +150,13 @@ function deepFreeze<T>(value: T): T {
|
|||||||
return Object.freeze(value)
|
return Object.freeze(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One registered watcher and its serialized invocation chain. */
|
||||||
|
interface SettingsWatcher {
|
||||||
|
callback: (next: never, prev: never) => void | Promise<void>
|
||||||
|
/** Settled tail: invocations of this callback run one at a time, in commit order. */
|
||||||
|
tail: Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
/** One live namespace registration owned by a registrant fiber. */
|
/** One live namespace registration owned by a registrant fiber. */
|
||||||
interface SettingsRegistration {
|
interface SettingsRegistration {
|
||||||
ns: SettingsNamespace
|
ns: SettingsNamespace
|
||||||
@@ -156,7 +164,7 @@ interface SettingsRegistration {
|
|||||||
base: unknown
|
base: unknown
|
||||||
applies: SettingsApplies
|
applies: SettingsApplies
|
||||||
resolved: unknown
|
resolved: unknown
|
||||||
watchers: Set<(next: never, prev: never) => void | Promise<void>>
|
watchers: Set<SettingsWatcher>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -171,6 +179,13 @@ export abstract class Settings extends Service {
|
|||||||
private document: Record<string, unknown> = {}
|
private document: Record<string, unknown> = {}
|
||||||
/** Per-namespace write chains; settled tails, so a failure never poisons the queue. */
|
/** Per-namespace write chains; settled tails, so a failure never poisons the queue. */
|
||||||
private readonly writeQueues = new Map<SettingsNamespace, Promise<unknown>>()
|
private readonly writeQueues = new Map<SettingsNamespace, Promise<unknown>>()
|
||||||
|
/** Set at service dispose: refuse new writes while queued ones drain. */
|
||||||
|
private stopped = false
|
||||||
|
|
||||||
|
/** Opaque read of {@link stopped}: control flow cannot narrow it across awaits. */
|
||||||
|
private isStopped(): boolean {
|
||||||
|
return this.stopped
|
||||||
|
}
|
||||||
|
|
||||||
constructor(ctx: Context) {
|
constructor(ctx: Context) {
|
||||||
super(ctx, 'settings')
|
super(ctx, 'settings')
|
||||||
@@ -178,10 +193,17 @@ export abstract class Settings extends Service {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the provider's document once and publish it before the service
|
* Load the provider's document once and publish it before the service
|
||||||
* becomes injectable. Providers with their own init (watchers, connections)
|
* becomes injectable, and register the write-drain teardown. Providers with
|
||||||
* delegate here first via `yield* super[Service.init]()`.
|
* their own init (watchers, connections) delegate here first via
|
||||||
|
* `yield* super[Service.init]()`; their disposers then run before the drain.
|
||||||
*/
|
*/
|
||||||
async* [Service.init](): AsyncGenerator<() => void, void, void> {
|
async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
|
||||||
|
yield async () => {
|
||||||
|
// Teardown: refuse new writes, then wait until every queued write chain
|
||||||
|
// settles so disposal completes only once storage is quiescent.
|
||||||
|
this.stopped = true
|
||||||
|
await Promise.allSettled([...this.writeQueues.values()])
|
||||||
|
}
|
||||||
this.publish(await this.load())
|
this.publish(await this.load())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,8 +252,9 @@ export abstract class Settings extends Service {
|
|||||||
return {
|
return {
|
||||||
get: () => registration.resolved as T,
|
get: () => registration.resolved as T,
|
||||||
watch: (callback) => {
|
watch: (callback) => {
|
||||||
registration.watchers.add(callback)
|
const watcher: SettingsWatcher = { callback: callback, tail: Promise.resolve() }
|
||||||
return () => registration.watchers.delete(callback)
|
registration.watchers.add(watcher)
|
||||||
|
return () => registration.watchers.delete(watcher)
|
||||||
},
|
},
|
||||||
update: patch => this.update(ns, patch),
|
update: patch => this.update(ns, patch),
|
||||||
replace: section => this.replace(ns, section),
|
replace: section => this.replace(ns, section),
|
||||||
@@ -287,27 +310,50 @@ export abstract class Settings extends Service {
|
|||||||
|
|
||||||
/** Validate a write, then queue it on the namespace's serialized write chain. */
|
/** Validate a write, then queue it on the namespace's serialized write chain. */
|
||||||
private write(ns: SettingsNamespace, input: object, mode: 'merge' | 'replace'): Promise<void> {
|
private write(ns: SettingsNamespace, input: object, mode: 'merge' | 'replace'): Promise<void> {
|
||||||
|
const verb = mode === 'merge' ? 'update' : 'replace'
|
||||||
const registration = this.registrations.get(ns)
|
const registration = this.registrations.get(ns)
|
||||||
if (registration === undefined) {
|
if (registration === undefined) {
|
||||||
throw new Error(`settings namespace "${ns}" is not registered`)
|
throw new Error(`settings namespace "${ns}" is not registered`)
|
||||||
}
|
}
|
||||||
|
if (this.isStopped()) {
|
||||||
|
throw new Error(`settings service is disposed: "${ns}" cannot be written`)
|
||||||
|
}
|
||||||
if (!this.writable) {
|
if (!this.writable) {
|
||||||
throw new Error(`settings provider is read-only: "${ns}" cannot be updated in-process`)
|
throw new Error(`settings provider is read-only: "${ns}" cannot be updated in-process`)
|
||||||
}
|
}
|
||||||
if (!isPlainObject(input)) {
|
if (!isPlainObject(input)) {
|
||||||
throw new TypeError(`settings ${mode === 'merge' ? 'update' : 'replace'} for "${ns}" must be a plain object`)
|
throw new TypeError(`settings ${verb} for "${ns}" must be a plain object`)
|
||||||
|
}
|
||||||
|
// Snapshot at call time: the queue must never read a caller-owned object
|
||||||
|
// the caller may keep mutating while the write waits its turn.
|
||||||
|
let snapshot: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
snapshot = structuredClone(input)
|
||||||
|
} catch {
|
||||||
|
throw new TypeError(`settings ${verb} for "${ns}" must be JSON-shaped (structured-cloneable) data`)
|
||||||
}
|
}
|
||||||
const previous = this.writeQueues.get(ns) ?? Promise.resolve()
|
const previous = this.writeQueues.get(ns) ?? Promise.resolve()
|
||||||
// Chain past a failed predecessor: one rejected write must not poison the
|
// Chain past a failed predecessor: one rejected write must not poison the
|
||||||
// namespace queue for every later caller.
|
// namespace queue for every later caller.
|
||||||
const run = previous.catch(() => undefined).then(async () => {
|
const run = previous.catch(() => undefined).then(async () => {
|
||||||
|
if (this.isStopped()) {
|
||||||
|
throw new Error(`settings service was disposed before the queued "${ns}" ${verb} ran`)
|
||||||
|
}
|
||||||
|
if (this.registrations.get(ns) !== registration) {
|
||||||
|
throw new Error(`settings namespace "${ns}" registration was disposed before the queued ${verb} ran`)
|
||||||
|
}
|
||||||
const section = mode === 'merge'
|
const section = mode === 'merge'
|
||||||
? mergeLayers(this.section(ns) ?? {}, input) as Record<string, unknown>
|
? mergeLayers(this.section(ns) ?? {}, snapshot) as Record<string, unknown>
|
||||||
: structuredClone(input)
|
: snapshot
|
||||||
const next = deepFreeze(this.resolve(registration.schema, registration.base, section))
|
const next = deepFreeze(this.resolve(registration.schema, registration.base, section))
|
||||||
await this.persist(ns, section)
|
await this.persist(ns, section)
|
||||||
|
// The write reached storage either way; the cache must say so. Commit
|
||||||
|
// only when this registration is still the namespace owner — a fiber
|
||||||
|
// disposed (or replaced) mid-persist must not receive the notification.
|
||||||
this.document[ns] = section
|
this.document[ns] = section
|
||||||
this.commit(registration, next, 'update')
|
if (this.registrations.get(ns) === registration && !this.isStopped()) {
|
||||||
|
this.commit(registration, next, 'update')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
this.writeQueues.set(ns, run)
|
this.writeQueues.set(ns, run)
|
||||||
return run
|
return run
|
||||||
@@ -358,29 +404,35 @@ export abstract class Settings extends Service {
|
|||||||
if (deepEqualJson(next, prev)) return
|
if (deepEqualJson(next, prev)) return
|
||||||
registration.resolved = next
|
registration.resolved = next
|
||||||
for (const watcher of [...registration.watchers]) {
|
for (const watcher of [...registration.watchers]) {
|
||||||
|
// Serialize per watcher: invocations of one callback run one at a time
|
||||||
|
// in commit order, so a slow stale invocation can never apply after a
|
||||||
|
// newer one. Sync throws and async rejections land in the same handler.
|
||||||
|
watcher.tail = watcher.tail
|
||||||
|
.then(() => watcher.callback(next as never, prev as never))
|
||||||
|
.then(() => undefined, (error: unknown) => {
|
||||||
|
this.warnWatcherFailure(registration.ns, error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Fan the event out one listener at a time (the plain emit stops at the
|
||||||
|
// first throwing listener, starving the rest). Invariant violations are
|
||||||
|
// harness-fatal by design and rethrow after every listener ran; any other
|
||||||
|
// failure is contained so one broken observer cannot wedge the commit
|
||||||
|
// path (and, through it, a provider's reload loop).
|
||||||
|
let invariantFailure: unknown
|
||||||
|
const args = ['settings/updated', registration.ns, next, prev, source]
|
||||||
|
for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) {
|
||||||
try {
|
try {
|
||||||
// A watcher may be async: adopt its promise so a rejection is contained
|
listener(registration.ns, next, prev, source)
|
||||||
// here instead of surfacing as an unhandled rejection.
|
|
||||||
const outcome = watcher(next as never, prev as never) as unknown
|
|
||||||
if (outcome instanceof Promise) {
|
|
||||||
outcome.catch((error: unknown) => {
|
|
||||||
this.warnWatcherFailure(registration.ns, error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.warnWatcherFailure(registration.ns, error)
|
if ((error as { code?: unknown } | null)?.code === 'INVARIANT') {
|
||||||
|
invariantFailure ??= error
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
this.ctx.logger.warn('settings: a settings/updated listener for "%s" failed', registration.ns)
|
||||||
|
this.ctx.logger.warn(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
if (invariantFailure !== undefined) throw invariantFailure as Error
|
||||||
this.ctx.emit('settings/updated', registration.ns, next, prev, source)
|
|
||||||
} catch (error) {
|
|
||||||
// Invariant violations are harness-fatal by design; any other listener
|
|
||||||
// failure is contained so one broken observer cannot wedge the commit
|
|
||||||
// path (and, through it, a provider's reload loop).
|
|
||||||
if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error
|
|
||||||
this.ctx.logger.warn('settings: a settings/updated listener for "%s" failed', registration.ns)
|
|
||||||
this.ctx.logger.warn(error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Contained-watcher diagnostic shared by the sync and async failure paths. */
|
/** Contained-watcher diagnostic shared by the sync and async failure paths. */
|
||||||
|
|||||||
@@ -52,9 +52,10 @@ const NestedSchema: z<NestedConfig> = z.object({
|
|||||||
|
|
||||||
async function boot(options?: ConstructorParameters<typeof MemorySettings>[1]) {
|
async function boot(options?: ConstructorParameters<typeof MemorySettings>[1]) {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(MemorySettings, options)
|
const fiber = ctx.plugin(MemorySettings, options)
|
||||||
|
await fiber
|
||||||
const provider = ctx.get('settings') as MemorySettings
|
const provider = ctx.get('settings') as MemorySettings
|
||||||
return { ctx, provider }
|
return { ctx, provider, fiber }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Record every settings/updated emission. */
|
/** Record every settings/updated emission. */
|
||||||
@@ -341,6 +342,144 @@ describe('review regressions', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('second review regressions', () => {
|
||||||
|
it('runs every settings/updated listener even when an earlier one throws', async () => {
|
||||||
|
const { ctx, provider } = await boot()
|
||||||
|
ctx.on('settings/updated', () => {
|
||||||
|
throw new Error('first listener boom')
|
||||||
|
})
|
||||||
|
const second = vi.fn()
|
||||||
|
ctx.on('settings/updated', second)
|
||||||
|
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||||
|
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||||
|
expect(second).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an update queued after the registrant fiber disposed', async () => {
|
||||||
|
const { ctx } = await boot()
|
||||||
|
let scope: SettingsScope<ThemeConfig> | undefined
|
||||||
|
const fiber = ctx.plugin({
|
||||||
|
inject: ['settings'],
|
||||||
|
apply: (child: Context) => {
|
||||||
|
scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await fiber
|
||||||
|
await fiber.dispose()
|
||||||
|
await expect(scope!.update({ theme: 'light' })).rejects.toThrow(/disposed|not registered/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not notify a registrant disposed while its update was in flight', async () => {
|
||||||
|
const { ctx, provider } = await boot({ persistDelayMs: 30 })
|
||||||
|
const events = recordUpdates(ctx)
|
||||||
|
let scope: SettingsScope<ThemeConfig> | undefined
|
||||||
|
const watcher = vi.fn()
|
||||||
|
const fiber = ctx.plugin({
|
||||||
|
inject: ['settings'],
|
||||||
|
apply: (child: Context) => {
|
||||||
|
scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||||
|
scope.watch(watcher)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await fiber
|
||||||
|
const pending = scope!.update({ theme: 'light' })
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5))
|
||||||
|
await fiber.dispose()
|
||||||
|
await pending.catch(() => undefined)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10))
|
||||||
|
expect(watcher).not.toHaveBeenCalled()
|
||||||
|
expect(events).toEqual([])
|
||||||
|
// The persist was already in flight, so storage keeps the write — but no
|
||||||
|
// commit reached the disposed registration.
|
||||||
|
expect(provider.doc['ui-theme']).toEqual({ theme: 'light' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drains in-flight writes at service dispose and rejects later ones', async () => {
|
||||||
|
const { ctx, provider, fiber } = await boot({ persistDelayMs: 20 })
|
||||||
|
const service = ctx.settings
|
||||||
|
const scope = service.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||||
|
const pending = scope.update({ theme: 'light' })
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5))
|
||||||
|
await fiber.dispose()
|
||||||
|
// The teardown drained the in-flight write before completing…
|
||||||
|
await pending.catch(() => undefined)
|
||||||
|
const persistedAtDispose = provider.persisted.length
|
||||||
|
expect(persistedAtDispose).toBe(1)
|
||||||
|
// …and afterwards nothing writes and new writes reject.
|
||||||
|
await expect(service.update(settingsNamespace('ui-theme'), { theme: 'dark' }))
|
||||||
|
.rejects.toThrow(/disposed|not registered/)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 40))
|
||||||
|
expect(provider.persisted.length).toBe(persistedAtDispose)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes invocations of one async watcher in commit order', async () => {
|
||||||
|
const { ctx, provider } = await boot()
|
||||||
|
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||||
|
const applied: number[] = []
|
||||||
|
let firstCall = true
|
||||||
|
scope.watch(async (next) => {
|
||||||
|
// The first (stale) invocation is slow; unserialised it would finish
|
||||||
|
// last and clobber the newer applied state.
|
||||||
|
const delay = firstCall ? 30 : 0
|
||||||
|
firstCall = false
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay))
|
||||||
|
applied.push(next.fontSize)
|
||||||
|
})
|
||||||
|
provider.pushExternal({ 'ui-theme': { fontSize: 1 } })
|
||||||
|
provider.pushExternal({ 'ui-theme': { fontSize: 2 } })
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(applied).toHaveLength(2)
|
||||||
|
})
|
||||||
|
expect(applied).toEqual([1, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a plain object that is not structured-cloneable', async () => {
|
||||||
|
const { ctx } = await boot()
|
||||||
|
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||||
|
await expect(scope.update({ theme: () => 'dark' }))
|
||||||
|
.rejects.toThrow(/JSON-shaped/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a write still queued when the service disposes', async () => {
|
||||||
|
const { ctx, fiber } = await boot({ persistDelayMs: 20 })
|
||||||
|
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||||
|
const first = scope.update({ theme: 'light' })
|
||||||
|
const second = scope.update({ fontSize: 20 })
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5))
|
||||||
|
await fiber.dispose()
|
||||||
|
await first
|
||||||
|
await expect(second).rejects.toThrow(/disposed before the queued/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a write still queued when the registrant disposes', async () => {
|
||||||
|
const { ctx } = await boot({ persistDelayMs: 20 })
|
||||||
|
let scope: SettingsScope<ThemeConfig> | undefined
|
||||||
|
const fiber = ctx.plugin({
|
||||||
|
inject: ['settings'],
|
||||||
|
apply: (child: Context) => {
|
||||||
|
scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await fiber
|
||||||
|
const first = scope!.update({ theme: 'light' })
|
||||||
|
const second = scope!.update({ fontSize: 20 })
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5))
|
||||||
|
await fiber.dispose()
|
||||||
|
await first
|
||||||
|
await expect(second).rejects.toThrow(/registration was disposed before the queued/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snapshots the patch at call time so caller mutation cannot leak in', async () => {
|
||||||
|
const { ctx } = await boot()
|
||||||
|
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||||
|
const patch = { fontSize: 18 }
|
||||||
|
const pending = scope.update(patch)
|
||||||
|
patch.fontSize = 99
|
||||||
|
await pending
|
||||||
|
expect(scope.get().fontSize).toBe(18)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('publish', () => {
|
describe('publish', () => {
|
||||||
it('notifies watchers of an external change with source provider', async () => {
|
it('notifies watchers of an external change with source provider', async () => {
|
||||||
const { ctx, provider } = await boot()
|
const { ctx, provider } = await boot()
|
||||||
@@ -349,10 +488,12 @@ describe('publish', () => {
|
|||||||
const watcher = vi.fn()
|
const watcher = vi.fn()
|
||||||
scope.watch(watcher)
|
scope.watch(watcher)
|
||||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||||
expect(watcher).toHaveBeenCalledWith(
|
await vi.waitFor(() => {
|
||||||
{ theme: 'light', fontSize: 14 },
|
expect(watcher).toHaveBeenCalledWith(
|
||||||
{ theme: 'dark', fontSize: 14 },
|
{ theme: 'light', fontSize: 14 },
|
||||||
)
|
{ theme: 'dark', fontSize: 14 },
|
||||||
|
)
|
||||||
|
})
|
||||||
expect(events[0]!.source).toBe('provider')
|
expect(events[0]!.source).toBe('provider')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -410,7 +551,9 @@ describe('watch', () => {
|
|||||||
const second = vi.fn()
|
const second = vi.fn()
|
||||||
scope.watch(second)
|
scope.watch(second)
|
||||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||||
expect(second).toHaveBeenCalledTimes(1)
|
await vi.waitFor(() => {
|
||||||
|
expect(second).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
expect(events).toHaveLength(1)
|
expect(events).toHaveLength(1)
|
||||||
expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
|
expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user