fix(runtime): close review lifecycle and bound gaps
This commit is contained in:
@@ -106,6 +106,16 @@ export const Config: z<Config> = z.object({
|
||||
servers: z.dict(LspLocalServerConfig).required(),
|
||||
})
|
||||
|
||||
/** Propagate teardown failures only after every sibling has settled. */
|
||||
function throwTeardownFailures(results: readonly PromiseSettledResult<void>[], message: string): void {
|
||||
const failures: unknown[] = []
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') failures.push(result.reason)
|
||||
}
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the configured stdio LSP providers. Resolves every executable at load (after credential
|
||||
* scrubbing) before publishing any provider; each process launches lazily on its first matching
|
||||
@@ -169,7 +179,8 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
return async () => {
|
||||
// Remove every route before process teardown so no new query can enter a draining provider.
|
||||
for (const dispose of disposers.reverse()) dispose()
|
||||
await Promise.all(providers.map(provider => provider.disposeAll()))
|
||||
const results = await Promise.allSettled(providers.map(provider => provider.disposeAll()))
|
||||
throwTeardownFailures(results, 'lsp-local provider teardown failed')
|
||||
}
|
||||
}, 'lsp-local.registerProviders')
|
||||
}
|
||||
@@ -346,12 +357,13 @@ class LocalLspProvider implements LspProvider {
|
||||
const draining = [...this.queues.values()]
|
||||
const resolving = [...this.workspaceLookups]
|
||||
this.instances.clear()
|
||||
await Promise.all([
|
||||
const results = await Promise.allSettled([
|
||||
...live.map(instance => instance.dispose()),
|
||||
...draining,
|
||||
...resolving,
|
||||
])
|
||||
this.queues.clear()
|
||||
this.workspaceLookups.clear()
|
||||
throwTeardownFailures(results, 'lsp-local instance teardown failed')
|
||||
}
|
||||
}
|
||||
@@ -350,6 +350,78 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
expect(signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('waits for every owned teardown before aggregating instance failures', async () => {
|
||||
let provider: LspProvider | undefined
|
||||
const ctx = await mount({ LSP_FAKE_DEF: 'null' }, {}, (registered) => { provider = registered })
|
||||
if (provider === undefined) throw new Error('expected lsp-local to register a provider')
|
||||
const internals = provider as unknown as {
|
||||
readonly instances: Map<string, { dispose(): Promise<void> }>
|
||||
readonly queues: Map<string, Promise<void>>
|
||||
readonly workspaceLookups: Set<Promise<void>>
|
||||
disposeAll(): Promise<void>
|
||||
}
|
||||
const firstFailure = new Error('first instance cleanup failed')
|
||||
const secondFailure = new Error('second instance cleanup failed')
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
internals.instances.set('first', { dispose: async () => { throw firstFailure } })
|
||||
internals.instances.set('second', { dispose: async () => { throw secondFailure } })
|
||||
internals.queues.set('pending', release.promise)
|
||||
internals.workspaceLookups.add(Promise.resolve())
|
||||
|
||||
let settled = false
|
||||
const disposing = internals.disposeAll().finally(() => { settled = true })
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
expect(settled).toBe(false)
|
||||
release.resolve(undefined)
|
||||
await expect(disposing).rejects.toMatchObject({
|
||||
errors: [firstFailure, secondFailure],
|
||||
message: 'lsp-local instance teardown failed',
|
||||
})
|
||||
expect(internals.instances.size).toBe(0)
|
||||
expect(internals.queues.size).toBe(0)
|
||||
expect(internals.workspaceLookups.size).toBe(0)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('waits for every provider before reporting plugin teardown failure', async () => {
|
||||
const ctx = new Context()
|
||||
const disposalErrors: unknown[] = []
|
||||
ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
const providers: LspProvider[] = []
|
||||
const register = ctx.lsp.registerProvider.bind(ctx.lsp)
|
||||
const registrationSpy = vi.spyOn(ctx.lsp, 'registerProvider').mockImplementation((provider) => {
|
||||
providers.push(provider)
|
||||
return register(provider)
|
||||
})
|
||||
const fiber = await ctx.plugin(LspLocal, {
|
||||
servers: {
|
||||
first: fakeServer(),
|
||||
second: fakeServer({}, { extensionToLanguage: { '.js': 'javascript' } }),
|
||||
},
|
||||
})
|
||||
registrationSpy.mockRestore()
|
||||
expect(providers).toHaveLength(2)
|
||||
const failure = new Error('provider cleanup failed')
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const first = providers[0] as LspProvider & { disposeAll(): Promise<void> }
|
||||
const second = providers[1] as LspProvider & { disposeAll(): Promise<void> }
|
||||
first.disposeAll = async () => { throw failure }
|
||||
second.disposeAll = async () => { await release.promise }
|
||||
|
||||
let disposed = false
|
||||
const disposing = fiber.dispose().then(() => { disposed = true })
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
expect(disposed).toBe(false)
|
||||
expect(disposalErrors).toEqual([])
|
||||
release.resolve(undefined)
|
||||
await disposing
|
||||
expect(disposalErrors).toEqual([failure])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('runs distinct workspaces in parallel instances', async () => {
|
||||
const ws2 = join(root, 'ws2')
|
||||
await mkdir(ws2)
|
||||
|
||||
Reference in New Issue
Block a user