perf(startup): build the local tool environment in the background and gate its consumers
Runtime creation, MCP launches, Obsidian, native clients and the tool-environment IPC await the initialization promise instead of startup awaiting it before the window path. A failure still fails startup with the same diagnostic.
This commit is contained in:
@@ -18,4 +18,7 @@ export type {
|
||||
LocalToolEnvironmentServiceDependencies,
|
||||
LocalToolEnvironmentServiceOptions
|
||||
} from './local-tool-environment-service'
|
||||
export type { LaunchEnvironmentProvider } from './launch-environment-provider'
|
||||
export type {
|
||||
LaunchEnvironmentProvider,
|
||||
LaunchEnvironmentReady
|
||||
} from './launch-environment-provider'
|
||||
|
||||
@@ -3,3 +3,9 @@
|
||||
* tool launches. Consumers must only copy the values they explicitly support.
|
||||
*/
|
||||
export type LaunchEnvironmentProvider = () => Readonly<NodeJS.ProcessEnv>
|
||||
|
||||
/**
|
||||
* Settles once the launch environment has been built at startup. Consumers that
|
||||
* can run before startup finishes await it before reading the provider.
|
||||
*/
|
||||
export type LaunchEnvironmentReady = () => Promise<void>
|
||||
@@ -217,6 +217,51 @@ describe('LocalToolEnvironmentService', () => {
|
||||
expect(Object.isFrozen(updated)).toBe(true)
|
||||
})
|
||||
|
||||
it('starts initialization once and lets consumers wait for the built environment', async () => {
|
||||
const { service } = await fixture()
|
||||
await expect(service.whenReady()).resolves.toBeUndefined()
|
||||
|
||||
const started = service.initialize()
|
||||
expect(service.initialize()).toBe(started)
|
||||
expect(service.launchEnvironmentProvider()).toEqual({})
|
||||
|
||||
await service.whenReady()
|
||||
expect(service.launchEnvironmentProvider().PATH).toContain('bin')
|
||||
await started
|
||||
})
|
||||
|
||||
it('applies a settings update after an in-flight initialization, never before it', async () => {
|
||||
const { service, node } = await fixture()
|
||||
const order: string[] = []
|
||||
const initialization = service.initialize().then(() => {
|
||||
order.push('initialized')
|
||||
})
|
||||
const update = service
|
||||
.updateSettings({
|
||||
...defaultLocalToolEnvironmentSettings,
|
||||
node: { source: 'custom', executablePath: node }
|
||||
})
|
||||
.then(() => {
|
||||
order.push('updated')
|
||||
})
|
||||
await Promise.all([initialization, update])
|
||||
|
||||
expect(order).toEqual(['initialized', 'updated'])
|
||||
expect(service.launchEnvironmentProvider().PATH).toContain('bin')
|
||||
})
|
||||
|
||||
it('reports a failed initialization through whenReady and still disposes', async () => {
|
||||
const { service, options } = await fixture()
|
||||
const failing = new LocalToolEnvironmentService(
|
||||
{ ...options, pythonArtifactCatalogPath: join(options.managedPythonRoot, 'missing.json') },
|
||||
{ toolEnvironment: { platform: 'win32', spawnProcess: fakeInspectionSpawn() } }
|
||||
)
|
||||
await expect(failing.initialize()).rejects.toThrow()
|
||||
await expect(failing.whenReady()).rejects.toThrow()
|
||||
await expect(failing.dispose()).resolves.toBeUndefined()
|
||||
await service.dispose()
|
||||
})
|
||||
|
||||
it('keeps executable paths inside the Main-owned picker callback', async () => {
|
||||
const { service, settingsStore, python } = await fixture()
|
||||
const snapshot = await service.selectExecutable('python')
|
||||
|
||||
@@ -453,6 +453,7 @@ export class LocalToolEnvironmentService {
|
||||
private installOperation?: Promise<LocalToolEnvironmentSnapshot>
|
||||
private launchEnvironment: Readonly<NodeJS.ProcessEnv> = Object.freeze({})
|
||||
private listeners = new Set<ProgressListener>()
|
||||
private initialization?: Promise<void>
|
||||
readonly launchEnvironmentProvider: LaunchEnvironmentProvider = () =>
|
||||
this.launchEnvironment
|
||||
|
||||
@@ -530,15 +531,35 @@ export class LocalToolEnvironmentService {
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await rm(this.options.binDirectory, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
await this.rebuildLaunchEnvironment()
|
||||
/**
|
||||
* Starts building the launch environment once. Startup no longer awaits this
|
||||
* before showing the window (P6); every consumer that reads
|
||||
* `launchEnvironmentProvider` or mutates the environment awaits
|
||||
* `whenReady()` first, so no launch observes the empty pre-start snapshot.
|
||||
*/
|
||||
initialize(): Promise<void> {
|
||||
this.initialization ??= (async () => {
|
||||
await rm(this.options.binDirectory, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
await this.rebuildLaunchEnvironment()
|
||||
})()
|
||||
return this.initialization
|
||||
}
|
||||
|
||||
/** Settles with the result of `initialize()`; resolves immediately if it never started. */
|
||||
whenReady(): Promise<void> {
|
||||
return this.initialization ?? Promise.resolve()
|
||||
}
|
||||
|
||||
/** Waits for initialization without failing; the startup path reports its error. */
|
||||
private async initialized(): Promise<void> {
|
||||
await this.whenReady().catch(() => undefined)
|
||||
}
|
||||
|
||||
async getSnapshot(): Promise<LocalToolEnvironmentSnapshot> {
|
||||
await this.initialized()
|
||||
const catalog = await loadPythonArtifactCatalog(
|
||||
this.options.pythonArtifactCatalogPath
|
||||
)
|
||||
@@ -562,6 +583,7 @@ export class LocalToolEnvironmentService {
|
||||
}
|
||||
|
||||
async updateSettings(input: unknown): Promise<LocalToolEnvironmentSnapshot> {
|
||||
await this.initialized()
|
||||
let settings = localToolEnvironmentSettingsSchema.parse(input)
|
||||
const current = await this.settings()
|
||||
for (const kind of ['node', 'python'] as const) {
|
||||
@@ -629,6 +651,7 @@ export class LocalToolEnvironmentService {
|
||||
}
|
||||
|
||||
async diagnose(target: LocalToolDiagnoseTarget): Promise<LocalToolEnvironmentSnapshot> {
|
||||
await this.initialized()
|
||||
const environment = await this.rebuildLaunchEnvironment(
|
||||
this.operationController?.signal
|
||||
)
|
||||
@@ -767,6 +790,7 @@ export class LocalToolEnvironmentService {
|
||||
|
||||
async removePython(): Promise<LocalToolEnvironmentSnapshot> {
|
||||
if (this.operationController) throw new Error('A Managed Python operation is running')
|
||||
await this.initialized()
|
||||
const catalog = await loadPythonArtifactCatalog(this.options.pythonArtifactCatalogPath)
|
||||
await removeManagedPython({
|
||||
rootDirectory: this.options.managedPythonRoot,
|
||||
@@ -781,6 +805,7 @@ export class LocalToolEnvironmentService {
|
||||
async dispose(): Promise<void> {
|
||||
this.cancelPython()
|
||||
await this.installOperation?.catch(() => undefined)
|
||||
await this.initialized()
|
||||
await rm(this.options.binDirectory, {
|
||||
recursive: true,
|
||||
force: true
|
||||
|
||||
Reference in New Issue
Block a user