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:
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user