From 68cc0792e12f698931bcf6039e107ef94922f04c Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sat, 3 Oct 2026 16:56:13 +0800 Subject: [PATCH] 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. --- src/main/agent/knowledge-mcp-gateway.ts | 12 ++++- .../agent/native-client-coordinator.test.ts | 14 +++++- src/main/agent/native-client-coordinator.ts | 5 ++- src/main/capabilities/capability-ipc.ts | 4 +- src/main/index.ts | 33 +++++++++++--- src/main/ipc.test.ts | 2 +- src/main/local-tool-environment/index.ts | 5 ++- .../launch-environment-provider.ts | 6 +++ .../local-tool-environment-service.test.ts | 45 +++++++++++++++++++ .../local-tool-environment-service.ts | 37 ++++++++++++--- src/main/obsidian/obsidian-service.ts | 5 ++- 11 files changed, 149 insertions(+), 19 deletions(-) diff --git a/src/main/agent/knowledge-mcp-gateway.ts b/src/main/agent/knowledge-mcp-gateway.ts index 8a3bd6a5..3459d1ea 100644 --- a/src/main/agent/knowledge-mcp-gateway.ts +++ b/src/main/agent/knowledge-mcp-gateway.ts @@ -81,7 +81,10 @@ import { listAllMcpTools, normalizeMcpToolSchema } from './mcp-tool-utils' -import type { LaunchEnvironmentProvider } from '../local-tool-environment' +import type { + LaunchEnvironmentProvider, + LaunchEnvironmentReady +} from '../local-tool-environment' import { BrowserModelTools, browserNavigateInputSchema, @@ -270,6 +273,8 @@ export type KnowledgeMcpGatewayOptions = { ): BrowserTabUsageLease } launchEnvironmentProvider?: LaunchEnvironmentProvider + /** Awaited before reading the provider; startup builds the environment in the background. */ + launchEnvironmentReady?: LaunchEnvironmentReady } function toMagicNoteToolSummary( @@ -400,6 +405,8 @@ export class KnowledgeMcpGateway { ): BrowserTabUsageLease } private readonly launchEnvironmentProvider?: LaunchEnvironmentProvider + private readonly launchEnvironmentReady?: LaunchEnvironmentReady + private readonly observeFailure?: DesktopDiagnosticFailureObserver private server?: Server private endpoint?: string @@ -416,6 +423,8 @@ export class KnowledgeMcpGateway { this.obsidianService = options.obsidianService this.browserService = options.browserService this.launchEnvironmentProvider = options.launchEnvironmentProvider + this.launchEnvironmentReady = options.launchEnvironmentReady + this.observeFailure = options.observeFailure } @@ -1048,6 +1057,7 @@ export class KnowledgeMcpGateway { capability.brokerController.signal ]) try { + await this.launchEnvironmentReady?.() await client.connect( createMcpTransport(server, this.launchEnvironmentProvider), { diff --git a/src/main/agent/native-client-coordinator.test.ts b/src/main/agent/native-client-coordinator.test.ts index e1e84e81..fa47a6fa 100644 --- a/src/main/agent/native-client-coordinator.test.ts +++ b/src/main/agent/native-client-coordinator.test.ts @@ -10,7 +10,7 @@ afterEach(async () => { vi.restoreAllMocks() }) -function fixture() { +function fixture(whenReady: () => Promise = async () => undefined) { const profile = { id: 'selected', name: 'Selected', protocol: 'openai-chat-completions', authentication: 'api-key', apiKey: 'secret', baseUrl: 'https://example.com/v1', modelName: 'selected-model' } const settings = { provider: 'deepseek-harness', modelProfiles: [profile], deepseekHarnessModelProfile: profile } @@ -37,7 +37,7 @@ function fixture() { getResolvedMcpServers: async () => [{ id: 'custom' }], getEnabledBuiltinMcpServerIds: async () => ['knowledge-base', 'obsidian', 'goodbuddy-config'], getObsidianSettings: async () => ({}) }, executionSpaceResolver: { resolveProject: () => ({ kind: 'local', rootPath: '/workspace', cacheIdentity: '/workspace' }) }, - terminalManager: { closeOwner: vi.fn() }, localEnvironment: { launchEnvironmentProvider: () => process.env }, + terminalManager: { closeOwner: vi.fn() }, localEnvironment: { launchEnvironmentProvider: () => process.env, whenReady }, rootDirectory: '/clients', createGateway: () => gateway, openExternal } as unknown as ConstructorParameters[0]) coordinators.push(coordinator) @@ -45,6 +45,16 @@ function fixture() { } describe('native client coordinator', () => { + it('waits for the startup tool environment before launching a native client', async () => { + let ready!: () => void + const { coordinator, start } = fixture(() => new Promise(resolve => { ready = resolve })) + const opened = coordinator.open(1, 'conversation') + await new Promise(resolve => setTimeout(resolve, 10)) + expect(start).not.toHaveBeenCalled() + ready() + await expect(opened).resolves.toEqual({ kind: 'browser', serviceId: 'service-0' }) + expect(start).toHaveBeenCalledOnce() + }) it('retains a ready service and its gateway after browser failure, then reopens and stops it', async () => { const { coordinator, gateway, start, stop, openExternal } = fixture() openExternal.mockRejectedValueOnce(new Error('Browser unavailable')) diff --git a/src/main/agent/native-client-coordinator.ts b/src/main/agent/native-client-coordinator.ts index b9bd30ca..18c43ffb 100644 --- a/src/main/agent/native-client-coordinator.ts +++ b/src/main/agent/native-client-coordinator.ts @@ -25,7 +25,7 @@ type Options = { capabilities: CapabilityService executionSpaceResolver: ExecutionSpaceResolver terminalManager: TerminalSessionManager - localEnvironment: LocalToolEnvironmentService + localEnvironment: Pick bundledRuntimePaths: BundledRuntimePaths rootDirectory: string managedNodeDirectory: string @@ -122,6 +122,9 @@ export class NativeClientCoordinator { } async open(ownerId: number, conversationId: string): Promise { + this.assertOwner(ownerId) + // Native clients launch with the prepared tool PATH; startup builds it in the background. + await this.options.localEnvironment.whenReady() this.assertOwner(ownerId) const context = await this.resolve(conversationId) this.assertOwner(ownerId) diff --git a/src/main/capabilities/capability-ipc.ts b/src/main/capabilities/capability-ipc.ts index 6d73a056..8f0b2db6 100644 --- a/src/main/capabilities/capability-ipc.ts +++ b/src/main/capabilities/capability-ipc.ts @@ -64,7 +64,7 @@ export function registerCapabilityIpcHandlers( runtimeExtensionStore?: RuntimeExtensionStore knowledgeGateway?: Pick obsidianService?: Pick - localToolEnvironmentService?: Pick + localToolEnvironmentService?: Pick } ): void { registerHandler( @@ -266,6 +266,8 @@ export function registerCapabilityIpcHandlers( ipcChannels.capabilitiesTestMcp, async (event, input: unknown): Promise => { assertTrustedSender(event, window) + // A stdio MCP test must launch with the prepared tool PATH. + await localToolEnvironmentService?.whenReady() return testMcpServer( await capabilityService.getResolvedMcpServer( mcpServerIdSchema.parse(input) diff --git a/src/main/index.ts b/src/main/index.ts index 0b83fc23..d4840b0f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -677,7 +677,18 @@ if (hasSingleInstanceLock) { }, baseEnvironment: buildCredentialFilteredUserEnvironment() }) - await startupSpan('main:local-tool-environment', () => startupLocalToolEnvironmentService.initialize()) + // Built in the background (P6): only runtime launches read the launch + // environment, and each of them awaits this first. A failure still fails + // startup with the same diagnostic once the prerequisites have settled. + let localToolEnvironmentFailed = false + let localToolEnvironmentError: unknown + const localToolEnvironmentReady = startupSpan( + 'main:local-tool-environment', + () => startupLocalToolEnvironmentService.initialize() + ).catch((error: unknown) => { + localToolEnvironmentFailed = true + localToolEnvironmentError = error + }) localToolEnvironmentService = startupLocalToolEnvironmentService const sshHostStore = new SshHostStore( @@ -1038,7 +1049,8 @@ if (hasSingleInstanceLock) { const obsidianService = new ObsidianService({ appPath: app.getAppPath(), launchEnvironmentProvider: - startupLocalToolEnvironmentService.launchEnvironmentProvider + startupLocalToolEnvironmentService.launchEnvironmentProvider, + launchEnvironmentReady: () => localToolEnvironmentReady }) const startupKnowledgeGateway = new KnowledgeMcpGateway( startupKnowledgeService, @@ -1054,7 +1066,8 @@ if (hasSingleInstanceLock) { obsidianService, browserService, launchEnvironmentProvider: - startupLocalToolEnvironmentService.launchEnvironmentProvider + startupLocalToolEnvironmentService.launchEnvironmentProvider, + launchEnvironmentReady: () => localToolEnvironmentReady } ) knowledgeGateway = startupKnowledgeGateway @@ -1063,6 +1076,8 @@ if (hasSingleInstanceLock) { target: SelectedRuntimeTarget, executionSpace?: ExecutionSpaceDescriptor ): Promise => { + await localToolEnvironmentReady + if (localToolEnvironmentFailed) throw localToolEnvironmentError const [ skillContext, mcpServers, @@ -1271,7 +1286,14 @@ if (hasSingleInstanceLock) { ) startupMark('main:assistant-db-init:end') } - })) + })).catch(async (error: unknown) => { + await localToolEnvironmentReady + throw localToolEnvironmentFailed ? localToolEnvironmentError : error + }) + // Runtime hydration awaited the environment, so it has settled here. Keep + // the previous failure behaviour: an environment error fails startup. + await localToolEnvironmentReady + if (localToolEnvironmentFailed) throw localToolEnvironmentError const [initialSubagentRuntime, initialSubagentProfileRuntimes] = await startupSpan('main:subagent-runtimes', () => Promise.all([ createSubagentRuntime(initialResolvedSettings), @@ -1523,7 +1545,8 @@ if (hasSingleInstanceLock) { read: (name, input, projectId, signal) => startupAssistantDatabase.readStoryGraphAsync(name, input, projectId, signal) }, magicNotesDatabase: startupAssistantDatabase, configService: goodbuddyConfigService, - obsidianService, launchEnvironmentProvider: startupLocalToolEnvironmentService.launchEnvironmentProvider + obsidianService, launchEnvironmentProvider: startupLocalToolEnvironmentService.launchEnvironmentProvider, + launchEnvironmentReady: () => localToolEnvironmentReady }) }) startupMark('main:ipc-register:start') diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index c517ec1e..af54a1de 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -9270,7 +9270,7 @@ describe('registerIpcHandlers agent terminal state', () => { coordinator = new NativeClientCoordinator({ database, settingsStore, applicationSettingsStore, capabilities, executionSpaceResolver: new ExecutionSpaceResolver(), terminalManager, - localEnvironment: { launchEnvironmentProvider: () => process.env } as never, + localEnvironment: { launchEnvironmentProvider: () => process.env, whenReady: async () => undefined } as never, rootDirectory: join(root, 'clients'), managedNodeDirectory: join(root, 'node'), bundledRuntimePaths: resolveBundledRuntimePaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }), npmCliPath: resolveNpmCliPaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }).npmCliPath, diff --git a/src/main/local-tool-environment/index.ts b/src/main/local-tool-environment/index.ts index b4846d87..d7dea232 100644 --- a/src/main/local-tool-environment/index.ts +++ b/src/main/local-tool-environment/index.ts @@ -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' diff --git a/src/main/local-tool-environment/launch-environment-provider.ts b/src/main/local-tool-environment/launch-environment-provider.ts index fe2d1968..1ba8ff9c 100644 --- a/src/main/local-tool-environment/launch-environment-provider.ts +++ b/src/main/local-tool-environment/launch-environment-provider.ts @@ -3,3 +3,9 @@ * tool launches. Consumers must only copy the values they explicitly support. */ export type LaunchEnvironmentProvider = () => Readonly + +/** + * 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 \ No newline at end of file diff --git a/src/main/local-tool-environment/local-tool-environment-service.test.ts b/src/main/local-tool-environment/local-tool-environment-service.test.ts index 3b851264..4fd6a714 100644 --- a/src/main/local-tool-environment/local-tool-environment-service.test.ts +++ b/src/main/local-tool-environment/local-tool-environment-service.test.ts @@ -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') diff --git a/src/main/local-tool-environment/local-tool-environment-service.ts b/src/main/local-tool-environment/local-tool-environment-service.ts index 2247987a..3da143e2 100644 --- a/src/main/local-tool-environment/local-tool-environment-service.ts +++ b/src/main/local-tool-environment/local-tool-environment-service.ts @@ -453,6 +453,7 @@ export class LocalToolEnvironmentService { private installOperation?: Promise private launchEnvironment: Readonly = Object.freeze({}) private listeners = new Set() + private initialization?: Promise readonly launchEnvironmentProvider: LaunchEnvironmentProvider = () => this.launchEnvironment @@ -530,15 +531,35 @@ export class LocalToolEnvironmentService { } } - async initialize(): Promise { - 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 { + 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 { + return this.initialization ?? Promise.resolve() + } + + /** Waits for initialization without failing; the startup path reports its error. */ + private async initialized(): Promise { + await this.whenReady().catch(() => undefined) } async getSnapshot(): Promise { + await this.initialized() const catalog = await loadPythonArtifactCatalog( this.options.pythonArtifactCatalogPath ) @@ -562,6 +583,7 @@ export class LocalToolEnvironmentService { } async updateSettings(input: unknown): Promise { + 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 { + await this.initialized() const environment = await this.rebuildLaunchEnvironment( this.operationController?.signal ) @@ -767,6 +790,7 @@ export class LocalToolEnvironmentService { async removePython(): Promise { 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 { this.cancelPython() await this.installOperation?.catch(() => undefined) + await this.initialized() await rm(this.options.binDirectory, { recursive: true, force: true diff --git a/src/main/obsidian/obsidian-service.ts b/src/main/obsidian/obsidian-service.ts index 37c9b9cf..6c304590 100644 --- a/src/main/obsidian/obsidian-service.ts +++ b/src/main/obsidian/obsidian-service.ts @@ -6,7 +6,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { CallToolResultSchema, type CallToolResult, type Tool } from '@modelcontextprotocol/sdk/types.js' import { applyLaunchEnvironmentPath, buildCredentialFilteredUserEnvironment } from '../agent/process-environment' -import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider' +import type { LaunchEnvironmentProvider, LaunchEnvironmentReady } from '../local-tool-environment/launch-environment-provider' export type ObsidianSettings = { vaultPath?: string } export type ObsidianVault = { id: string; name: string; path: string } @@ -14,6 +14,8 @@ export type ObsidianServiceOptions = { /** Electron app.getAppPath(), or the project root in development. */ appPath: string launchEnvironmentProvider: LaunchEnvironmentProvider + /** Awaited before reading the provider; startup builds the environment in the background. */ + launchEnvironmentReady?: LaunchEnvironmentReady homePath?: string platform?: NodeJS.Platform environment?: NodeJS.ProcessEnv @@ -122,6 +124,7 @@ export class ObsidianService { await stat(serverPath).catch((cause) => { throw new Error(`Bundled Obsidian MCPVault server is missing: ${serverPath}`, { cause }) }) + await this.options.launchEnvironmentReady?.() const env = applyLaunchEnvironmentPath( buildCredentialFilteredUserEnvironment(this.options.environment ?? process.env), this.options.launchEnvironmentProvider