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:
mesalogo
2026-10-03 16:56:13 +08:00
parent b28ddd4ba8
commit 68cc0792e1
11 changed files with 149 additions and 19 deletions
+4 -1
View File
@@ -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