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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user