fix(llm,settings): refuse post-disposal route replacement and teardown notifications

Two lifecycle holes the registry and the consumer helper left open.

`AdapterRegistrationHandle.replace` had no liveness guard: after the handle's
disposer ran, a replace put routes back into the registry with nothing left to
release them, so the adapter leaked permanently. `owned` being empty cannot
carry that fact, because `replace([])` is the legal empty-section state, so the
disposer records it explicitly.

`installSettingsSection`'s watcher lacked the guard its own disposer carries:
a stored change landing while the consumer unloads reached `onChange`, which
re-registers routes against a fiber whose resources are being released.

Also documents `withFileLock` in the atomic-write README (it claimed one
export), records the age-based lock takeover as a known limitation, and lists
ctx.settings and ctx.credentials in the architecture capability table.
This commit is contained in:
Yichen Jiang
2026-07-30 23:23:18 +08:00
parent 80c36ff0d3
commit 54c60f4079
14 changed files with 119 additions and 11 deletions

View File

@@ -612,6 +612,11 @@ export function installSettingsSection<T>(
})
hooks.onChange()
scope.watch(() => {
// A stored change landing while the consumer unloads reaches the watcher
// before the registration is released, and `onChange` is exactly as
// harmful here as in the disposer above: it re-registers routes against
// a fiber whose resources are being let go.
if (isUnloading(ctx)) return
hooks.onChange()
})
})

View File

@@ -724,4 +724,36 @@ describe('installSettingsSection', () => {
await new Promise(resolve => setTimeout(resolve, 20))
expect(changes).toEqual(['user'])
})
it('stays silent for a stored change that lands while the consumer unloads', async () => {
// The watcher outlives the start of teardown by the width of the unload,
// so a document change arriving in that window reaches it. Notifying then
// is exactly as harmful as notifying from the disposer.
const { ctx, provider } = await boot({ doc: { 'helper-ns': { theme: 'user' } } })
const entry = { theme: 'entry' }
let current: () => { theme: string } = () => entry
const changes: string[] = []
const consumer = ctx.plugin({
inject: ['settings'],
apply: (child: Context) => {
installSettingsSection(child, settingsNamespace('helper-ns'), HelperSchema, entry, {
setSource: (source) => {
current = source
},
onChange: () => {
changes.push(current().theme)
},
})
},
})
await consumer
await vi.waitFor(() => {
expect(changes).toEqual(['user'])
})
const unloading = consumer.dispose()
provider.pushExternal({ 'helper-ns': { theme: 'racing' } })
await unloading
expect(changes).toEqual(['user'])
})
})