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
+11 -1
View File
@@ -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),
{
@@ -10,7 +10,7 @@ afterEach(async () => {
vi.restoreAllMocks()
})
function fixture() {
function fixture(whenReady: () => Promise<void> = 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<typeof NativeClientCoordinator>[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<void>(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'))
+4 -1
View File
@@ -25,7 +25,7 @@ type Options = {
capabilities: CapabilityService
executionSpaceResolver: ExecutionSpaceResolver
terminalManager: TerminalSessionManager
localEnvironment: LocalToolEnvironmentService
localEnvironment: Pick<LocalToolEnvironmentService, 'launchEnvironmentProvider' | 'whenReady'>
bundledRuntimePaths: BundledRuntimePaths
rootDirectory: string
managedNodeDirectory: string
@@ -122,6 +122,9 @@ export class NativeClientCoordinator {
}
async open(ownerId: number, conversationId: string): Promise<RuntimeNativeClientResult> {
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)
+3 -1
View File
@@ -64,7 +64,7 @@ export function registerCapabilityIpcHandlers(
runtimeExtensionStore?: RuntimeExtensionStore
knowledgeGateway?: Pick<KnowledgeMcpGateway, 'revokeObsidianCapabilities'>
obsidianService?: Pick<ObsidianService, 'testConnection'>
localToolEnvironmentService?: Pick<LocalToolEnvironmentService, 'launchEnvironmentProvider'>
localToolEnvironmentService?: Pick<LocalToolEnvironmentService, 'launchEnvironmentProvider' | 'whenReady'>
}
): void {
registerHandler(
@@ -266,6 +266,8 @@ export function registerCapabilityIpcHandlers(
ipcChannels.capabilitiesTestMcp,
async (event, input: unknown): Promise<McpServerTestResult> => {
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)
+28 -5
View File
@@ -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<AgentRuntime> => {
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')
+1 -1
View File
@@ -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,
+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
+4 -1
View File
@@ -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