fix: lazy init local inference runtime in router instead of app start (#8656)

This commit is contained in:
Lifei Zhou
2026-04-27 13:16:53 +10:00
committed by GitHub
parent 10f23a9c4d
commit 8014adf982
14 changed files with 391 additions and 22 deletions
+12 -4
View File
@@ -92,7 +92,8 @@ const i18n = defineMessages({
},
tooManyTools: {
id: 'chatInput.tooManyTools',
defaultMessage: 'Too many tools can degrade performance.\nTool count: {toolCount} (recommend: {recommended})',
defaultMessage:
'Too many tools can degrade performance.\nTool count: {toolCount} (recommend: {recommended})',
},
viewExtensions: {
id: 'chatInput.viewExtensions',
@@ -572,7 +573,10 @@ export default function ChatInput({
if (toolCount !== null && toolCount > TOOLS_MAX_SUGGESTED) {
addAlert({
type: AlertType.Warning,
message: intl.formatMessage(i18n.tooManyTools, { toolCount, recommended: TOOLS_MAX_SUGGESTED }),
message: intl.formatMessage(i18n.tooManyTools, {
toolCount,
recommended: TOOLS_MAX_SUGGESTED,
}),
action: {
text: intl.formatMessage(i18n.viewExtensions),
onClick: () => setView('extensions'),
@@ -1567,7 +1571,9 @@ export default function ChatInput({
<p className="text-sm text-text-primary truncate" title={file.name}>
{file.name}
</p>
<p className="text-xs text-text-secondary">{file.type || intl.formatMessage(i18n.unknownType)}</p>
<p className="text-xs text-text-secondary">
{file.type || intl.formatMessage(i18n.unknownType)}
</p>
</div>
</div>
)}
@@ -1683,7 +1689,9 @@ export default function ChatInput({
</Button>
</TooltipTrigger>
<TooltipContent>
{recipe ? intl.formatMessage(i18n.viewEditRecipe) : intl.formatMessage(i18n.createRecipeFromSession)}
{recipe
? intl.formatMessage(i18n.viewEditRecipe)
: intl.formatMessage(i18n.createRecipeFromSession)}
</TooltipContent>
</Tooltip>
</div>
@@ -68,7 +68,9 @@ const CustomProviderCard = memo(function CustomProviderCard({ onClick }: { onCli
<Plus className="w-8 h-8 text-gray-400 mb-2" />
<div className="text-sm text-gray-600 dark:text-gray-400 text-center">
<div className="font-medium">{intl.formatMessage(i18n.addProvider)}</div>
<div className="text-xs text-gray-500 mt-1">{intl.formatMessage(i18n.fromTemplateOrManual)}</div>
<div className="text-xs text-gray-500 mt-1">
{intl.formatMessage(i18n.fromTemplateOrManual)}
</div>
</div>
</div>
}
@@ -277,7 +279,9 @@ function ProviderCards({
const editable = editingProvider ? editingProvider.isEditable : true;
const title = editingProvider
? (editable ? intl.formatMessage(i18n.editProvider) : intl.formatMessage(i18n.configureProvider))
? editable
? intl.formatMessage(i18n.editProvider)
: intl.formatMessage(i18n.configureProvider)
: intl.formatMessage(i18n.addProviderTitle);
return (
<>
+61 -2
View File
@@ -6,6 +6,11 @@ import { createServer } from 'net';
import { Buffer } from 'node:buffer';
import { status } from './api';
import { Client, createClient, createConfig } from './api/client';
import {
appendTail,
createStartupDiagnostics,
type StartupDiagnostics,
} from './startupDiagnostics';
export interface Logger {
info: (...args: unknown[]) => void;
@@ -77,24 +82,36 @@ export const findGoosedBinaryPath = (options: FindBinaryOptions = {}): string =>
);
};
export const checkServerStatus = async (client: Client, errorLog: string[]): Promise<boolean> => {
export interface CheckServerStatusOptions {
onEvent?: (name: string, details?: Record<string, unknown>) => void;
}
export const checkServerStatus = async (
client: Client,
errorLog: string[],
options: CheckServerStatusOptions = {}
): Promise<boolean> => {
const timeout = 30000;
const interval = 100;
const maxAttempts = Math.ceil(timeout / interval);
options.onEvent?.('healthcheck_start', { timeoutMs: timeout, intervalMs: interval });
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (errorLog.some(isFatalError)) {
options.onEvent?.('healthcheck_fatal_error', { attempt });
return false;
}
try {
await status({ client, throwOnError: true });
options.onEvent?.('healthcheck_success', { attempt });
return true;
} catch {
await new Promise((resolve) => setTimeout(resolve, interval));
}
}
options.onEvent?.('healthcheck_timeout', { timeoutMs: timeout });
return false;
};
@@ -153,6 +170,7 @@ export interface StartGoosedOptions {
isPackaged?: boolean;
resourcesPath?: string;
logger?: Logger;
diagnosticsDir?: string;
}
export interface GoosedResult {
@@ -164,6 +182,9 @@ export interface GoosedResult {
cleanup: () => Promise<void>;
client: Client;
certFingerprint: string | null;
startupDiagnosticsPath: string | null;
getStartupDiagnostics: () => StartupDiagnostics | null;
recordStartupEvent: (name: string, details?: Record<string, unknown>) => void;
}
const goosedClientForUrlAndSecret = (url: string, secret: string): Client => {
@@ -187,14 +208,19 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
env: additionalEnv = {},
externalGoosed,
logger = defaultLogger,
diagnosticsDir,
} = options;
const errorLog: string[] = [];
const workingDir = dir || os.homedir();
const startupTrace = createStartupDiagnostics(diagnosticsDir, workingDir);
if (externalGoosed?.enabled && externalGoosed.url) {
const url = externalGoosed.url.replace(/\/$/, '');
logger.info(`Using external goosed backend at ${url}`);
if (startupTrace) {
startupTrace.diagnostics.baseUrl = url;
}
return {
baseUrl: url,
@@ -207,6 +233,9 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
},
client: goosedClientForUrlAndSecret(url, serverSecret),
certFingerprint: null,
startupDiagnosticsPath: startupTrace?.diagnosticsPath ?? null,
getStartupDiagnostics: () => startupTrace?.diagnostics ?? null,
recordStartupEvent: (name, details) => startupTrace?.record(name, details),
};
}
@@ -214,6 +243,9 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
const port = process.env.GOOSE_PORT || '3000';
const url = `https://127.0.0.1:${port}`;
logger.info(`Using external goosed backend from env at ${url}`);
if (startupTrace) {
startupTrace.diagnostics.baseUrl = url;
}
return {
baseUrl: url,
@@ -226,6 +258,9 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
},
client: goosedClientForUrlAndSecret(url, serverSecret),
certFingerprint: null,
startupDiagnosticsPath: startupTrace?.diagnosticsPath ?? null,
getStartupDiagnostics: () => startupTrace?.diagnostics ?? null,
recordStartupEvent: (name, details) => startupTrace?.record(name, details),
};
}
@@ -235,6 +270,11 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
logger.info(`Starting goosed from: ${goosedPath} on port ${port} in dir ${workingDir}`);
const baseUrl = `https://127.0.0.1:${port}`;
if (startupTrace) {
startupTrace.diagnostics.goosedPath = goosedPath;
startupTrace.diagnostics.baseUrl = baseUrl;
startupTrace.record('spawn_start', { goosedPath, port, workingDir });
}
const spawnEnv: Record<string, string | undefined> = {
...process.env,
@@ -273,6 +313,10 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
logger.info('Spawn options:', JSON.stringify(safeSpawnOptions, null, 2));
const goosedProcess = spawn(spawnCommand, spawnArgs, spawnOptions);
if (startupTrace) {
startupTrace.diagnostics.pid = goosedProcess.pid ?? null;
startupTrace.record('spawn_success', { pid: goosedProcess.pid ?? null });
}
let certFingerprint: string | null = null;
const fingerprintReady = new Promise<string | null>((resolve) => {
@@ -288,6 +332,10 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
if (line.startsWith(FINGERPRINT_PREFIX)) {
certFingerprint = line.slice(FINGERPRINT_PREFIX.length).trim();
logger.info(`Pinned cert fingerprint: ${certFingerprint}`);
if (startupTrace) {
startupTrace.diagnostics.certFingerprintSeen = true;
startupTrace.record('fingerprint_received', { certFingerprint });
}
resolved = true;
resolve(certFingerprint);
break;
@@ -319,6 +367,8 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
const onStderrData = (data: Buffer) => {
const lines = data.toString().split('\n');
const nonEmptyLines = lines.filter((line) => line.trim());
appendTail(startupTrace?.diagnostics.stderrTail ?? [], nonEmptyLines);
for (const line of lines) {
if (line.trim()) {
errorLog.push(line);
@@ -334,13 +384,19 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
goosedProcess.stderr?.off('data', onStderrData);
};
goosedProcess.on('exit', (code) => {
goosedProcess.on('exit', (code, signal) => {
logger.info(`goosed process exited with code ${code} for port ${port} and dir ${workingDir}`);
if (startupTrace) {
startupTrace.diagnostics.childExitCode = code;
startupTrace.diagnostics.childExitSignal = signal;
startupTrace.record('child_exit', { code, signal });
}
});
goosedProcess.on('error', (err) => {
logger.error(`Failed to start goosed on port ${port} and dir ${workingDir}`, err);
errorLog.push(err.message);
startupTrace?.record('spawn_error', { message: err.message, name: err.name });
});
const cleanup = async (): Promise<void> => {
@@ -387,5 +443,8 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
cleanup,
client: goosedClientForUrlAndSecret(baseUrl, serverSecret),
certFingerprint,
startupDiagnosticsPath: startupTrace?.diagnosticsPath ?? null,
getStartupDiagnostics: () => startupTrace?.diagnostics ?? null,
recordStartupEvent: (name, details) => startupTrace?.record(name, details),
};
};
+25 -2
View File
@@ -61,6 +61,7 @@ function shouldSetupUpdater(): boolean {
// Settings management
const SETTINGS_FILE = path.join(app.getPath('userData'), 'settings.json');
const STARTUP_LOGS_DIR = path.join(app.getPath('userData'), 'logs', 'startup');
function getSettings(): Settings {
if (fsSync.existsSync(SETTINGS_FILE)) {
@@ -617,6 +618,7 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
isPackaged: app.isPackaged,
resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
logger: log,
diagnosticsDir: STARTUP_LOGS_DIR,
});
// For locally-spawned goosed, pin using the fingerprint from stdout.
@@ -637,6 +639,9 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
process: goosedProcess,
errorLog,
stopErrorLogCollection,
startupDiagnosticsPath,
getStartupDiagnostics,
recordStartupEvent,
} = goosedResult;
const mainWindowState = windowStateKeeper({
@@ -705,9 +710,27 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
);
goosedClients.set(mainWindow.id, goosedClient);
const serverReady = await checkServerStatus(goosedClient, errorLog);
const serverReady = await checkServerStatus(goosedClient, errorLog, {
onEvent: recordStartupEvent,
});
if (!serverReady) {
const isUsingExternalBackend = settings.externalGoosed?.enabled;
const diagnostics = getStartupDiagnostics();
const stderrTail = diagnostics?.stderrTail ?? [];
const failureDetailParts = [
diagnostics?.childExitCode !== null || diagnostics?.childExitSignal
? `Child exit: code=${diagnostics?.childExitCode ?? 'null'} signal=${diagnostics?.childExitSignal ?? 'null'}`
: 'Child exit: unavailable',
diagnostics?.certFingerprintSeen
? 'TLS fingerprint observed: yes'
: 'TLS fingerprint observed: no',
diagnostics?.healthCheckSucceeded
? 'Health check observed: yes'
: 'Health check observed: no',
startupDiagnosticsPath ? `Startup diagnostics: ${startupDiagnosticsPath}` : '',
errorLog.length > 0 ? `Startup errors:\n${errorLog.join('\n')}` : '',
stderrTail.length > 0 ? `Captured startup stderr:\n${stderrTail.join('\n')}` : '',
].filter(Boolean);
if (isUsingExternalBackend) {
const response = dialog.showMessageBoxSync({
@@ -734,7 +757,7 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
type: 'error',
title: 'Goose Failed to Start',
message: 'The backend server failed to start.',
detail: errorLog.join('\n'),
detail: failureDetailParts.join('\n\n'),
buttons: ['OK'],
});
}
+119
View File
@@ -0,0 +1,119 @@
import fs from 'node:fs';
import path from 'node:path';
export interface StartupTraceEvent {
name: string;
at: string;
elapsedMs: number;
details?: Record<string, unknown>;
}
export interface StartupDiagnostics {
attemptId: string;
startedAt: string;
goosedPath: string | null;
workingDir: string;
baseUrl: string | null;
pid: number | null;
certFingerprintSeen: boolean;
healthCheckSucceeded: boolean;
childExitCode: number | null;
childExitSignal: string | null;
stderrTail: string[];
events: StartupTraceEvent[];
}
export interface StartupTrace {
diagnosticsPath: string;
diagnostics: StartupDiagnostics;
record: (name: string, details?: Record<string, unknown>) => void;
flush: () => void;
}
const STARTUP_TAIL_LIMIT = 80;
const STARTUP_LOGS_TO_KEEP = 20;
export const appendTail = (target: string[], lines: string[]) => {
target.push(...lines.filter((line) => line.trim()));
if (target.length > STARTUP_TAIL_LIMIT) {
target.splice(0, target.length - STARTUP_TAIL_LIMIT);
}
};
const cleanupStartupDiagnostics = (diagnosticsDir: string) => {
const startupLogs = fs
.readdirSync(diagnosticsDir, { withFileTypes: true })
.filter(
(entry) =>
entry.isFile() && entry.name.startsWith('goosed-startup-') && entry.name.endsWith('.json')
)
.map((entry) => {
const filePath = path.join(diagnosticsDir, entry.name);
return {
filePath,
modifiedMs: fs.statSync(filePath).mtimeMs,
};
})
.sort((a, b) => b.modifiedMs - a.modifiedMs);
for (const startupLog of startupLogs.slice(STARTUP_LOGS_TO_KEEP)) {
fs.unlinkSync(startupLog.filePath);
}
};
export const createStartupDiagnostics = (
diagnosticsDir: string | undefined,
workingDir: string
): StartupTrace | null => {
if (!diagnosticsDir) {
return null;
}
fs.mkdirSync(diagnosticsDir, { recursive: true });
cleanupStartupDiagnostics(diagnosticsDir);
const startedAt = new Date();
const attemptId = `goosed-startup-${startedAt.toISOString().replace(/:/g, '-')}-${process.pid}.json`;
const diagnosticsPath = path.join(diagnosticsDir, attemptId);
const monotonicStart = Date.now();
const diagnostics: StartupDiagnostics = {
attemptId,
startedAt: startedAt.toISOString(),
goosedPath: null,
workingDir,
baseUrl: null,
pid: null,
certFingerprintSeen: false,
healthCheckSucceeded: false,
childExitCode: null,
childExitSignal: null,
stderrTail: [],
events: [],
};
const flush = () => {
fs.writeFileSync(diagnosticsPath, `${JSON.stringify(diagnostics, null, 2)}\n`);
};
const record = (name: string, details?: Record<string, unknown>) => {
if (name === 'healthcheck_success') {
diagnostics.healthCheckSucceeded = true;
}
diagnostics.events.push({
name,
at: new Date().toISOString(),
elapsedMs: Date.now() - monotonicStart,
...(details ? { details } : {}),
});
flush();
};
flush();
return {
diagnosticsPath,
diagnostics,
record,
flush,
};
};