perf: optimize app startup and background lifecycles
This commit is contained in:
1 parent
31332f6d76
commit
927e13349b
121 files changed
+4359
-1400
No files matched your search
+168
-84
@@ -2,7 +2,7 @@
|
||||
* Electron Main Process Entry
|
||||
* Manages window creation, system tray, and IPC handlers
|
||||
*/
|
||||
import { app, BrowserWindow, nativeImage, shell } from 'electron';
|
||||
import { app, BrowserWindow, nativeImage, safeStorage, shell } from 'electron';
|
||||
import type { Server } from 'node:http';
|
||||
import { createServer } from 'node:http';
|
||||
import { join } from 'node:path';
|
||||
@@ -22,8 +22,6 @@ import {
|
||||
createProjectStore,
|
||||
type OpencodeProjectStore,
|
||||
} from '../opencode/project-store';
|
||||
import { readProjectConfig } from '../opencode/project-config';
|
||||
import { warmupOpencodeRuntime } from '../opencode/startup-warmup';
|
||||
import { registerIpcHandlers } from './ipc-handlers';
|
||||
import { createTray } from './tray';
|
||||
import { createMenu } from './menu';
|
||||
@@ -32,7 +30,6 @@ import { getNativeWindowMaterialOptions } from './window-material';
|
||||
|
||||
import { appUpdater, registerUpdateHandlers } from './updater';
|
||||
import { logger } from '../utils/logger';
|
||||
import { warmupNetworkOptimization } from '../utils/uv-env';
|
||||
import { resolvePythonRuntime, resolveUvRuntime } from '../utils/python-runtime';
|
||||
import { initTelemetry } from '../utils/telemetry';
|
||||
|
||||
@@ -62,13 +59,22 @@ import {
|
||||
} from './relaunch-on-replaced-app';
|
||||
import { createSignalQuitHandler } from './signal-quit';
|
||||
import { acquireProcessInstanceFileLock } from './process-instance-lock';
|
||||
import { BackgroundLifecycleController } from './background-lifecycle';
|
||||
import {
|
||||
readGpuFallbackState,
|
||||
recordGpuCrash,
|
||||
shouldUseSoftwareRendering,
|
||||
} from './gpu-fallback';
|
||||
|
||||
import { getHostApiToken, startHostApiServer } from '../api/server';
|
||||
import type { HostApiContext } from '../api/context';
|
||||
import { HostEventBus } from '../api/event-bus';
|
||||
import { AgentBrowserModule, ElectronAgentBrowserAdapter } from '../agent-browser';
|
||||
import { browserOAuthManager } from '../utils/browser-oauth';
|
||||
import { createProjectProgressSync } from '../services/project-progress-sync';
|
||||
import { createWorksSubmissionBindingStore } from '../services/works-submission-binding';
|
||||
import { ReleaseJobManager } from '../services/release-job';
|
||||
import { createReleaseUtilityPreparer } from '../services/release-utility-process';
|
||||
import { createStaticArtifactSnapshot } from '../services/static-release-server';
|
||||
import {
|
||||
consumeWorksSquareStartupRuntimeCleanupRequired,
|
||||
@@ -78,6 +84,7 @@ import {
|
||||
subscribeWorksSquareSession,
|
||||
type WorksSquareSessionChangeReason,
|
||||
} from '../services/works-square-session';
|
||||
import { shouldUseSecureWorksSquareSessionPersistence } from '../services/works-square-session-persistence-policy';
|
||||
import { clearManagedWorksSquareRuntimeBestEffort } from '../services/works-square-runtime';
|
||||
import { getPort } from '../utils/config';
|
||||
import { initializeMeowaGameAssetsCredential } from '../api/routes/meowa-game-assets';
|
||||
@@ -91,6 +98,9 @@ import type { DesignWorkspaceModule } from '../image-workspace/module';
|
||||
const WINDOWS_APP_USER_MODEL_ID = 'app.niancode.desktop';
|
||||
const isE2EMode = process.env.NIANCODE_E2E === '1';
|
||||
const requestedUserDataDir = process.env.NIANCODE_USER_DATA_DIR?.trim();
|
||||
const useSecureWorksSquareSessionPersistence = shouldUseSecureWorksSquareSessionPersistence(
|
||||
app.isPackaged,
|
||||
);
|
||||
|
||||
async function buildMakeloreOpencodeRuntimeConfig() {
|
||||
return await buildOpencodeRuntimeConfigFromNianCodeProviders({
|
||||
@@ -100,50 +110,34 @@ async function buildMakeloreOpencodeRuntimeConfig() {
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleOpencodeRuntimeWarmup(): void {
|
||||
if (isE2EMode) return;
|
||||
|
||||
void warmupOpencodeRuntime({
|
||||
hasAuthenticatedSession: () => Boolean(getWorksSquareSessionSnapshot()),
|
||||
getStatus: () => opencodeManager.getStatus(),
|
||||
getActiveProject: () => opencodeProjectStore.getActiveProject(),
|
||||
readProjectConfig,
|
||||
getConfiguredProviderCount: async () => {
|
||||
const runtime = await buildMakeloreOpencodeRuntimeConfig();
|
||||
return Object.keys(runtime.config.provider).length;
|
||||
},
|
||||
start: () => opencodeManager.start(),
|
||||
onError: (error, phase) => {
|
||||
logger.warn(`[opencode-runtime] Startup warmup ${phase} failed`, error);
|
||||
},
|
||||
}).then((result) => {
|
||||
if (result.started) {
|
||||
logger.info('[opencode-runtime] Startup warmup completed');
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.warn('[opencode-runtime] Startup warmup could not be scheduled', error);
|
||||
});
|
||||
}
|
||||
|
||||
if (isE2EMode && requestedUserDataDir) {
|
||||
app.setPath('userData', requestedUserDataDir);
|
||||
}
|
||||
|
||||
// Disable GPU hardware acceleration globally for maximum stability across
|
||||
// all GPU configurations (no GPU, integrated, discrete).
|
||||
//
|
||||
// Rationale (following VS Code's philosophy):
|
||||
// - Page/file loading is async data fetching — zero GPU dependency.
|
||||
// - The original per-platform GPU branching was added to avoid CPU rendering
|
||||
// competing with sync I/O on Windows, but all file I/O is now async
|
||||
// (fs/promises), so that concern no longer applies.
|
||||
// - Software rendering is deterministic across all hardware; GPU compositing
|
||||
// behaviour varies between vendors (Intel, AMD, NVIDIA, Apple Silicon) and
|
||||
// driver versions, making it the #1 source of rendering bugs in Electron.
|
||||
//
|
||||
// Users who want GPU acceleration can pass `--enable-gpu` on the CLI or
|
||||
// set `"disable-hardware-acceleration": false` in the app config (future).
|
||||
app.disableHardwareAcceleration();
|
||||
// Hardware compositing is the fast path for Makelore's opaque light surface.
|
||||
// Keep an explicit escape hatch for driver-specific failures rather than
|
||||
// forcing every installation through Chromium's software compositor.
|
||||
const gpuFallbackPath = join(app.getPath('userData'), 'gpu-fallback.json');
|
||||
const disableHardwareAcceleration = shouldUseSoftwareRendering({
|
||||
envDisabled: process.env.MAKELORE_DISABLE_GPU === '1',
|
||||
cliDisabled: process.argv.includes('--disable-gpu'),
|
||||
state: readGpuFallbackState(gpuFallbackPath),
|
||||
});
|
||||
if (disableHardwareAcceleration) {
|
||||
app.disableHardwareAcceleration();
|
||||
}
|
||||
|
||||
app.on('child-process-gone', (_event, details) => {
|
||||
if (details.type !== 'GPU' || !['crashed', 'abnormal-exit', 'oom'].includes(details.reason)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const state = recordGpuCrash(gpuFallbackPath, `${details.reason}:${details.exitCode}`);
|
||||
logger.warn('[gpu] GPU process failure recorded; software rendering will be used after repeated failures', state);
|
||||
} catch (error) {
|
||||
logger.warn('[gpu] Failed to persist GPU process failure state', error);
|
||||
}
|
||||
});
|
||||
|
||||
// On Linux, set CHROME_DESKTOP so Chromium can find the correct .desktop file.
|
||||
// On Wayland this maps the running window to niancode.desktop (→ icon + app grouping);
|
||||
@@ -201,6 +195,8 @@ let worksSubmissionBinding: ReturnType<typeof createWorksSubmissionBindingStore>
|
||||
let agentBrowser: AgentBrowserModule | null = null;
|
||||
let applicationInitialization: Promise<void> | null = null;
|
||||
let imageWorkspaceModule: DesignWorkspaceModule | null = null;
|
||||
let backgroundLifecycle!: BackgroundLifecycleController;
|
||||
let releaseJobs: ReleaseJobManager | null = null;
|
||||
const mainWindowFocusState = createMainWindowFocusState();
|
||||
const quitLifecycleState = createQuitLifecycleState();
|
||||
const launchDeepLinkUrl = findNianCodeDeepLinkUrl(process.argv);
|
||||
@@ -430,6 +426,25 @@ function createMainWindow(): BrowserWindow {
|
||||
}
|
||||
});
|
||||
|
||||
win.on('hide', () => {
|
||||
backgroundLifecycle?.setActivity({
|
||||
...backgroundLifecycle.getActivity(),
|
||||
visible: false,
|
||||
});
|
||||
// The browser is a disposable renderer and must not survive a hidden
|
||||
// application window. Active cloud tasks are reconciled when reopened.
|
||||
void agentBrowser?.close().catch((error) => {
|
||||
logger.warn('Failed to close Agent Browser after the main window was hidden:', error);
|
||||
});
|
||||
});
|
||||
|
||||
win.on('show', () => {
|
||||
backgroundLifecycle?.setActivity({
|
||||
...backgroundLifecycle.getActivity(),
|
||||
visible: true,
|
||||
});
|
||||
});
|
||||
|
||||
win.on('closed', () => {
|
||||
unsubscribeAuthSession();
|
||||
const browser = agentBrowser;
|
||||
@@ -465,32 +480,14 @@ async function initialize(): Promise<void> {
|
||||
handleAppDeepLinkActivation(launchDeepLinkUrl);
|
||||
}
|
||||
|
||||
if (!isE2EMode) {
|
||||
// Warm up network optimization (non-blocking)
|
||||
void warmupNetworkOptimization();
|
||||
|
||||
// Initialize Telemetry early
|
||||
await initTelemetry();
|
||||
|
||||
// Apply persisted proxy settings before creating windows or network requests.
|
||||
await applyProxySettings();
|
||||
const meowaCredential = await initializeMeowaGameAssetsCredential();
|
||||
if (meowaCredential.source !== 'none') {
|
||||
logger.info(`Meowa game-assets credential initialized via ${meowaCredential.source}`);
|
||||
}
|
||||
await syncLaunchAtStartupSettingFromStore();
|
||||
} else {
|
||||
if (isE2EMode) {
|
||||
logger.info('Running in E2E mode: startup side effects minimized');
|
||||
}
|
||||
|
||||
// Restore authentication before any background service or renderer can request a token.
|
||||
await initializeWorksSquareSession();
|
||||
|
||||
// Only local storage and the lightweight workspace object are needed to
|
||||
// construct the first window. Authentication and remote setup are restored
|
||||
// after the renderer has had a chance to paint.
|
||||
opencodeProjectStore = createProjectStore(await createElectronProjectStorage());
|
||||
|
||||
if (!isE2EMode) {
|
||||
projectProgressSync = createProjectProgressSync(opencodeProjectStore);
|
||||
}
|
||||
worksSubmissionBinding = createWorksSubmissionBindingStore(opencodeProjectStore);
|
||||
|
||||
const localImageWorkspaceEnabled = isLocalImageWorkspaceDevelopmentEnabled({
|
||||
@@ -502,12 +499,6 @@ async function initialize(): Promise<void> {
|
||||
? new LocalImageWorkspace({ userDataDir: app.getPath('userData') })
|
||||
: new WorksSquareDesignWorkspace();
|
||||
imageWorkspaceModule = imageWorkspace;
|
||||
if (consumeWorksSquareStartupRuntimeCleanupRequired()) {
|
||||
await clearManagedWorksSquareRuntimeBestEffort({
|
||||
opencodeManager,
|
||||
imageWorkspace,
|
||||
}, 'expired persisted session during startup');
|
||||
}
|
||||
if (localImageWorkspaceEnabled) {
|
||||
logger.info('AI painting workspace is using local development storage');
|
||||
} else {
|
||||
@@ -520,10 +511,6 @@ async function initialize(): Promise<void> {
|
||||
// Create the main window
|
||||
const window = createMainWindow();
|
||||
agentBrowser = new AgentBrowserModule(new ElectronAgentBrowserAdapter(window));
|
||||
if (!isE2EMode) {
|
||||
void projectProgressSync?.start();
|
||||
void worksSubmissionBinding.start();
|
||||
}
|
||||
|
||||
// Create system tray
|
||||
if (!isE2EMode) {
|
||||
@@ -531,9 +518,33 @@ async function initialize(): Promise<void> {
|
||||
}
|
||||
|
||||
// Register IPC handlers
|
||||
registerIpcHandlers(undefined, opencodeManager, undefined, window);
|
||||
|
||||
hostApiServer = startHostApiServer({
|
||||
backgroundLifecycle = new BackgroundLifecycleController({
|
||||
onSleep: () => {
|
||||
if (!window.isDestroyed() && !window.webContents.isDestroyed()) {
|
||||
window.webContents.send('lifecycle:sleep');
|
||||
}
|
||||
},
|
||||
onStopRuntime: () => opencodeManager.stop(),
|
||||
});
|
||||
const releaseUtilityPreparer = createReleaseUtilityPreparer({
|
||||
runtimeContext: {
|
||||
isPackaged: app.isPackaged,
|
||||
resourcesPath: process.resourcesPath,
|
||||
npmCacheDir: join(app.getPath('userData'), 'npm-cache'),
|
||||
},
|
||||
});
|
||||
releaseJobs = new ReleaseJobManager({
|
||||
prepare: releaseUtilityPreparer.prepare,
|
||||
dispose: releaseUtilityPreparer.dispose,
|
||||
acquireLease: (lease) => backgroundLifecycle.acquireLease(lease),
|
||||
});
|
||||
releaseJobs.on('status', (status) => {
|
||||
hostEventBus.emit('release-job:status', status);
|
||||
if (!window.isDestroyed() && !window.webContents.isDestroyed()) {
|
||||
window.webContents.send('release-job:status', status);
|
||||
}
|
||||
});
|
||||
const hostApiContext: HostApiContext = {
|
||||
opencodeManager,
|
||||
opencodeProjectStore,
|
||||
eventBus: hostEventBus,
|
||||
@@ -541,7 +552,12 @@ async function initialize(): Promise<void> {
|
||||
agentBrowser,
|
||||
worksSubmissionBinding: worksSubmissionBinding ?? undefined,
|
||||
imageWorkspace,
|
||||
});
|
||||
lifecycle: backgroundLifecycle,
|
||||
releaseJobs,
|
||||
};
|
||||
registerIpcHandlers(undefined, opencodeManager, undefined, window, backgroundLifecycle, hostApiContext);
|
||||
|
||||
hostApiServer = startHostApiServer(hostApiContext);
|
||||
|
||||
// Register update handlers
|
||||
registerUpdateHandlers(appUpdater, window);
|
||||
@@ -549,6 +565,70 @@ async function initialize(): Promise<void> {
|
||||
// Note: Auto-check for updates is driven by the renderer (update store init)
|
||||
// so it respects the user's "Auto-check for updates" setting.
|
||||
|
||||
const initializeBackgroundServices = async (): Promise<void> => {
|
||||
const runDeferred = async (label: string, task: () => Promise<void>): Promise<void> => {
|
||||
try {
|
||||
await task();
|
||||
} catch (error) {
|
||||
logger.warn(`Deferred ${label} initialization failed:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isE2EMode) {
|
||||
// Apply the proxy before any external request made by background
|
||||
// services, but never before the first local paint.
|
||||
await runDeferred('proxy', applyProxySettings);
|
||||
await runDeferred('telemetry', initTelemetry);
|
||||
await runDeferred('game-assets credential', async () => {
|
||||
const meowaCredential = await initializeMeowaGameAssetsCredential();
|
||||
if (meowaCredential.source !== 'none') {
|
||||
logger.info(`Meowa game-assets credential initialized via ${meowaCredential.source}`);
|
||||
}
|
||||
});
|
||||
await runDeferred('launch-at-startup setting', syncLaunchAtStartupSettingFromStore);
|
||||
}
|
||||
|
||||
if (!useSecureWorksSquareSessionPersistence) {
|
||||
logger.info('[works-square-session] Unpackaged development keeps credentials in memory; OS secure storage is disabled');
|
||||
}
|
||||
await runDeferred('Works Square session', async () => {
|
||||
await initializeWorksSquareSession({
|
||||
secureStorage: useSecureWorksSquareSessionPersistence ? safeStorage : null,
|
||||
});
|
||||
});
|
||||
if (consumeWorksSquareStartupRuntimeCleanupRequired()) {
|
||||
await runDeferred('expired runtime cleanup', async () => {
|
||||
await clearManagedWorksSquareRuntimeBestEffort({
|
||||
opencodeManager,
|
||||
imageWorkspace,
|
||||
}, 'expired persisted session during startup');
|
||||
});
|
||||
}
|
||||
if (!isE2EMode) {
|
||||
projectProgressSync = createProjectProgressSync(opencodeProjectStore);
|
||||
const activateProgrammingServices = (): void => {
|
||||
void projectProgressSync?.start()
|
||||
.then(() => projectProgressSync?.activate())
|
||||
.catch((error) => logger.warn('Failed to activate project progress sync:', error));
|
||||
void worksSubmissionBinding?.start()
|
||||
.catch((error) => logger.warn('Failed to migrate active submission binding:', error));
|
||||
};
|
||||
backgroundLifecycle.on('activity', (activity) => {
|
||||
if (activity.visible && activity.module === 'programming') {
|
||||
activateProgrammingServices();
|
||||
} else {
|
||||
projectProgressSync?.stop();
|
||||
}
|
||||
});
|
||||
const activity = backgroundLifecycle.getActivity();
|
||||
if (activity.visible && activity.module === 'programming') {
|
||||
activateProgrammingServices();
|
||||
}
|
||||
// Project watchers and binding migration now start from module
|
||||
// activation; the idle shell does not scan every project.
|
||||
}
|
||||
};
|
||||
|
||||
opencodeManager.on('status', (status) => {
|
||||
logger.info(
|
||||
`opencode runtime status: state=${status.state}, port=${status.port}, pid=${status.pid ?? 'n/a'}, url=${status.url ?? 'n/a'}${status.error ? `, error=${status.error}` : ''}`,
|
||||
@@ -580,10 +660,12 @@ async function initialize(): Promise<void> {
|
||||
hostEventBus.emit('oauth:error', error);
|
||||
});
|
||||
|
||||
// Start the local Code runtime in the background once the Main process has
|
||||
// restored session state and registered the Host API. Chat still retains
|
||||
// its lazy-start fallback for first-run and failed-warmup cases.
|
||||
scheduleOpencodeRuntimeWarmup();
|
||||
// OpenCode is started by the Code module on demand. Keeping the runtime
|
||||
// cold for Canvas/Learning/Robot sessions avoids a resident child process
|
||||
// and its project event stream on every launch.
|
||||
void initializeBackgroundServices().catch((error) => {
|
||||
logger.warn('Deferred background initialization failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
if (gotTheLock) {
|
||||
@@ -736,6 +818,8 @@ if (gotTheLock) {
|
||||
hostEventBus.closeAll();
|
||||
hostApiServer?.close();
|
||||
projectProgressSync?.stop();
|
||||
backgroundLifecycle?.dispose();
|
||||
releaseJobs?.dispose();
|
||||
|
||||
const stopOpencodePromise = opencodeManager.stop().catch((err) => {
|
||||
logger.warn('opencodeManager.stop() error during quit:', err);
|
||||
|
||||
Reference in new issue
Block a user