Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@
"node_modules/@esbuild/**",
"node_modules/node-edge-tts/**",
"node_modules/node-window-manager/**",
"node_modules/extract-file-icon/**",
"node_modules/node-gyp-build/**",
"node_modules/electron-liquid-glass/**"
],
"files": [
Expand Down
3 changes: 1 addition & 2 deletions scripts/measure-extension-bundle-cache.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,7 @@ async function main() {

const menuBar = await measure('repeated-menu-bar-background-prep', fixture.extDir, async () => {
for (let i = 0; i < iterations; i++) {
const commands = runner
.discoverInstalledExtensionCommands()
const commands = (await runner.discoverInstalledExtensionCommands())
.filter((command) => command.mode === 'menu-bar');
assert.equal(commands.length, 1);
const bundle = await runner.getExtensionBundle(commands[0].extName, commands[0].cmdName);
Expand Down
35 changes: 23 additions & 12 deletions src/main/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,19 +129,21 @@ function getCommandsDiskCachePath(): string {
return commandsDiskCachePath;
}

function loadCommandsDiskCache(): CommandInfo[] | null {
async function loadCommandsDiskCache(): Promise<CommandInfo[] | null> {
try {
const raw = fs.readFileSync(getCommandsDiskCachePath(), 'utf-8');
const raw = await fs.promises.readFile(getCommandsDiskCachePath(), 'utf-8');
const parsed = JSON.parse(raw) as { version: number; commands: CommandInfo[] };
if (parsed?.version !== COMMANDS_DISK_CACHE_VERSION) return null;
const cmds = parsed.commands;
// Re-attach icons from the per-app icon disk cache (fast file reads).
for (const cmd of cmds) {
if (cmd.path) {
const icon = getCachedIcon(cmd.path);
if (icon) cmd.iconDataUrl = icon;
}
}
// Re-attach icons from the per-app icon disk cache. Reading them
// concurrently (fs.promises, backed by libuv's threadpool) instead of one
// fs.readFileSync per command avoids serializing every icon read on the
// main thread right before the first window is created.
await Promise.all(cmds.map(async (cmd) => {
if (!cmd.path) return;
const icon = await getCachedIconAsync(cmd.path);
if (icon) cmd.iconDataUrl = icon;
}));
return cmds;
} catch {
return null;
Expand All @@ -163,8 +165,8 @@ function saveCommandsDiskCache(commands: CommandInfo[]): void {
}

/** Call once after app.whenReady() to pre-populate the in-memory cache from disk. */
export function initCommandsCache(): void {
const cmds = loadCommandsDiskCache();
export async function initCommandsCache(): Promise<void> {
const cmds = await loadCommandsDiskCache();
if (cmds) {
cachedCommands = cmds;
staleCommandsFallback = cmds;
Expand Down Expand Up @@ -207,6 +209,15 @@ function getCachedIcon(bundlePath: string): string | undefined {
return undefined;
}

async function getCachedIconAsync(bundlePath: string): Promise<string | undefined> {
try {
const cacheFile = path.join(getIconCacheDir(), `${iconCacheKey(bundlePath)}.b64`);
return await fs.promises.readFile(cacheFile, 'utf-8');
} catch {
return undefined;
}
}

function setCachedIcon(bundlePath: string, dataUrl: string): void {
try {
const cacheFile = path.join(getIconCacheDir(), `${iconCacheKey(bundlePath)}.b64`);
Expand Down Expand Up @@ -1849,7 +1860,7 @@ async function discoverAndBuildCommands(): Promise<CommandInfo[]> {
// Installed community extensions
let extensionCommands: CommandInfo[] = [];
try {
extensionCommands = discoverInstalledExtensionCommands().map((ext) => ({
extensionCommands = (await discoverInstalledExtensionCommands()).map((ext) => ({
id: ext.id,
title: ext.title,
subtitle: ext.extensionTitle,
Expand Down
63 changes: 59 additions & 4 deletions src/main/extension-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,53 @@ interface CachedTextFile {
value: string;
}

/**
* Map-backed LRU cache with a hard size cap. Reads move the key to the
* most-recently-used position; inserts past the cap evict the oldest entry.
* Keeps long sessions that open many distinct extensions from accumulating
* unbounded bundle/manifest text in memory.
*/
class LruCache<K, V> {
private readonly map = new Map<K, V>();

constructor(private readonly maxEntries: number) {}

get(key: K): V | undefined {
const value = this.map.get(key);
if (value === undefined) return undefined;
this.map.delete(key);
this.map.set(key, value);
return value;
}

set(key: K, value: V): void {
this.map.delete(key);
this.map.set(key, value);
if (this.map.size > this.maxEntries) {
const oldestKey = this.map.keys().next().value as K;
this.map.delete(oldestKey);
}
}

delete(key: K): void {
this.map.delete(key);
}

clear(): void {
this.map.clear();
}

keys(): IterableIterator<K> {
return this.map.keys();
}
}

const MAX_EXTENSION_MANIFEST_CACHE_ENTRIES = 200;
const MAX_EXTENSION_BUNDLE_CACHE_ENTRIES = 40;

let _installedExtensionsSnapshot: InstalledExtensionsSnapshot | null = null;
const _extensionManifestCache = new Map<string, CachedManifest>();
const _extensionBundleCodeCache = new Map<string, CachedTextFile>();
const _extensionManifestCache = new LruCache<string, CachedManifest>(MAX_EXTENSION_MANIFEST_CACHE_ENTRIES);
const _extensionBundleCodeCache = new LruCache<string, CachedTextFile>(MAX_EXTENSION_BUNDLE_CACHE_ENTRIES);

function getManagedExtensionsDir(): string {
const dir = path.join(app.getPath('userData'), 'extensions');
Expand Down Expand Up @@ -497,13 +541,24 @@ function normalizePreferenceSchema(pref: any, scope: 'extension' | 'command'): E

// ─── Discovery ──────────────────────────────────────────────────────

const DISCOVERY_YIELD_EVERY_N_EXTENSIONS = 5;

/**
* Scan installed extensions directory and return a flat list of
* commands that should appear in the launcher.
*
* Yields back to the event loop every few extensions so a large install
* (many manifests + icon reads, all sync fs calls) doesn't monopolize the
* single main-process thread for the whole scan in one uninterrupted burst.
*/
export function discoverInstalledExtensionCommands(): ExtensionCommandInfo[] {
export async function discoverInstalledExtensionCommands(): Promise<ExtensionCommandInfo[]> {
const results: ExtensionCommandInfo[] = [];
for (const source of collectInstalledExtensions()) {
const sources = collectInstalledExtensions();
for (let i = 0; i < sources.length; i++) {
if (i > 0 && i % DISCOVERY_YIELD_EVERY_N_EXTENSIONS === 0) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
const source = sources[i];
const extPath = source.extPath;
const extName = source.extName;

Expand Down
90 changes: 80 additions & 10 deletions src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,13 +257,36 @@ type ParakeetModelStatus = {
let parakeetModelStatus: ParakeetModelStatus | null = null;
let parakeetModelEnsurePromise: Promise<string> | null = null;

// Persistent transcription servers (parakeet/qwen3/whisper.cpp) keep a model
// loaded in memory for fast repeat use, but otherwise sit idle for the rest
// of the session once used once. Auto-kill them after a period of no use so
// they don't hold their memory footprint until the app quits.
const AI_TRANSCRIPTION_SERVER_IDLE_SHUTDOWN_MS = 10 * 60_000; // 10 minutes

function createIdleShutdownScheduler(killFn: () => void, idleMs: number): { bump: () => void } {
let timer: ReturnType<typeof setTimeout> | null = null;
return {
bump(): void {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
killFn();
}, idleMs);
},
};
}

// Persistent serve-mode process for fast transcription (models stay loaded in memory)
let parakeetServerProcess: any = null; // ChildProcess
let parakeetServerReady = false;
let parakeetServerStarting: Promise<void> | null = null;
let parakeetServerBuffer = '';
type PendingParakeetRequest = { resolve: (json: any) => void; reject: (err: Error) => void };
let parakeetPendingRequest: PendingParakeetRequest | null = null;
const parakeetIdleShutdown = createIdleShutdownScheduler(
() => killParakeetServer(),
AI_TRANSCRIPTION_SERVER_IDLE_SHUTDOWN_MS
);

function killParakeetServer(): void {
if (parakeetServerProcess) {
Expand All @@ -283,6 +306,7 @@ function killParakeetServer(): void {
}

function ensureParakeetServer(): Promise<void> {
parakeetIdleShutdown.bump();
Comment thread
temperatio marked this conversation as resolved.
if (parakeetServerReady && parakeetServerProcess && !parakeetServerProcess.killed) {
return Promise.resolve();
}
Expand Down Expand Up @@ -619,6 +643,10 @@ let qwen3ServerStarting: Promise<void> | null = null;
let qwen3ServerBuffer = '';
type PendingQwen3Request = { resolve: (json: any) => void; reject: (err: Error) => void };
let qwen3PendingRequest: PendingQwen3Request | null = null;
const qwen3IdleShutdown = createIdleShutdownScheduler(
() => killQwen3Server(),
AI_TRANSCRIPTION_SERVER_IDLE_SHUTDOWN_MS
);

function killQwen3Server(): void {
if (qwen3ServerProcess) {
Expand All @@ -638,6 +666,7 @@ function killQwen3Server(): void {
}

function ensureQwen3Server(): Promise<void> {
qwen3IdleShutdown.bump();
if (qwen3ServerReady && qwen3ServerProcess && !qwen3ServerProcess.killed) {
return Promise.resolve();
}
Expand Down Expand Up @@ -1153,6 +1182,10 @@ let whisperCppServerStarting: Promise<void> | null = null;
let whisperCppServerBuffer = '';
type PendingWhisperCppRequest = { resolve: (json: any) => void; reject: (err: Error) => void };
let whisperCppPendingRequest: PendingWhisperCppRequest | null = null;
const whisperCppIdleShutdown = createIdleShutdownScheduler(
() => killWhisperCppServer(),
AI_TRANSCRIPTION_SERVER_IDLE_SHUTDOWN_MS
);

function killWhisperCppServer(): void {
if (whisperCppServerProcess) {
Expand All @@ -1172,6 +1205,7 @@ function killWhisperCppServer(): void {
}

function ensureWhisperCppServer(): Promise<void> {
whisperCppIdleShutdown.bump();
if (whisperCppServerReady && whisperCppServerProcess && !whisperCppServerProcess.killed) {
return Promise.resolve();
}
Expand Down Expand Up @@ -1685,6 +1719,23 @@ const windowManagerWorkerPending = new Map<number, {
timer: ReturnType<typeof setTimeout>;
}>();

// ─── Graceful shutdown: drain in-flight extension/script work ───────
// Extension bundling (esbuild + fs) and script command execution
// (child_process) run real Node async work on the main process. If one of
// those callbacks completes after Electron starts tearing down the V8/Node
// environment on quit, Node has been observed to abort natively
// (EXC_BREAKPOINT/SIGTRAP) instead of throwing a catchable JS error. Track
// this work so before-quit can wait for it to settle first.
const EXTENSION_WORK_QUIT_DRAIN_TIMEOUT_MS = 2000;
const inFlightExtensionWork = new Set<Promise<unknown>>();

function trackInFlightExtensionWork<T>(promise: Promise<T>): Promise<T> {
inFlightExtensionWork.add(promise);
const untrack = () => { inFlightExtensionWork.delete(promise); };
promise.then(untrack, untrack);
return promise;
}

function parseMajorVersion(value: string | undefined): number | null {
if (!value) return null;
const major = Number.parseInt(String(value).split('.')[0], 10);
Expand Down Expand Up @@ -7868,7 +7919,7 @@ async function buildLaunchBundle(options: {
sourceExtensionName,
sourcePreferences,
} = options;
const result = await getExtensionBundle(extensionName, commandName);
const result = await trackInFlightExtensionWork(getExtensionBundle(extensionName, commandName));
if (!result) {
throw new Error(`Command "${commandName}" not found in extension "${extensionName}"`);
}
Expand Down Expand Up @@ -13562,7 +13613,7 @@ async function rebuildExtensions() {
// This ensures we always have fresh builds on startup.
console.log(`Rebuilding extension: ${name}`);
try {
await buildAllCommands(name);
await trackInFlightExtensionWork(buildAllCommands(name));
} catch (e) {
console.error(`Failed to rebuild ${name}:`, e);
}
Expand Down Expand Up @@ -13605,8 +13656,9 @@ app.whenReady().then(async () => {
trackEvent("app_started");
app.setAsDefaultProtocolClient('supercmd');
scrubInternalClipboardProbe('app startup');
// Warm the worker so the first window-management action does not race spawn.
setTimeout(() => { ensureWindowManagerWorker(); }, 0);
// Worker is forked lazily by callWindowManagerWorker() on first actual use
// (ensureWindowManagerWorker() inside sendAttempt) — most sessions never
// touch window management, so we no longer pre-warm it at startup.

// Some external image hosts (e.g. libgen.bz, libgen.li, libgen.is) only
// serve covers when a Referer header is present — without one they return
Expand Down Expand Up @@ -15050,7 +15102,7 @@ app.whenReady().then(async () => {
async (_event: any, extName: string, cmdName: string) => {
try {
// Read the pre-built bundle (built at install time), or build on-demand
const result = await getExtensionBundle(extName, cmdName);
const result = await trackInFlightExtensionWork(getExtensionBundle(extName, cmdName));
if (!result) {
return { error: `No pre-built bundle for ${extName}/${cmdName}. Try reinstalling the extension.` };
}
Expand Down Expand Up @@ -15110,7 +15162,7 @@ app.whenReady().then(async () => {
: {};
const background = Boolean(payload?.background);

const executed = await executeScriptCommand(commandId, argumentValues);
const executed = await trackInFlightExtensionWork(executeScriptCommand(commandId, argumentValues));
if ('missingArguments' in executed) {
return {
success: false,
Expand Down Expand Up @@ -18974,12 +19026,12 @@ if let tiff = image?.tiffRepresentation {

// Get all menu-bar extension bundles so the renderer can run them
ipcMain.handle('get-menubar-extensions', async () => {
const allCmds = discoverInstalledExtensionCommands();
const allCmds = await discoverInstalledExtensionCommands();
const menuBarCmds = allCmds.filter((c) => c.mode === 'menu-bar');

const bundles: any[] = [];
for (const cmd of menuBarCmds) {
const bundle = await getExtensionBundle(cmd.extName, cmd.cmdName);
const bundle = await trackInFlightExtensionWork(getExtensionBundle(cmd.extName, cmd.cmdName));
if (bundle) {
bundles.push({
code: bundle.code,
Expand Down Expand Up @@ -19251,7 +19303,7 @@ if let tiff = image?.tiffRepresentation {
// This populates cachedCommands with cacheTimestamp=0 (stale), so the first
// getAvailableCommands() call serves the disk cache immediately and kicks off
// a silent background refresh.
initCommandsCache();
await initCommandsCache();

createWindow();

Expand Down Expand Up @@ -19343,8 +19395,26 @@ app.on('window-all-closed', () => {
}
});

app.on('before-quit', () => {
let hasAttemptedExtensionWorkDrainOnQuit = false;

app.on('before-quit', (event: any) => {
prepareWindowsForAppQuit();
if (hasAttemptedExtensionWorkDrainOnQuit || inFlightExtensionWork.size === 0) return;
hasAttemptedExtensionWorkDrainOnQuit = true;
event.preventDefault();
const pending = Array.from(inFlightExtensionWork);
console.log(`[Shutdown] Draining ${pending.length} in-flight extension/script operation(s) before quitting...`);
const drained = Promise.allSettled(pending);
const timedOut = new Promise<void>((resolve) => setTimeout(resolve, EXTENSION_WORK_QUIT_DRAIN_TIMEOUT_MS));
Promise.race([drained, timedOut]).then(() => {
if (inFlightExtensionWork.size > 0) {
console.warn(
`[Shutdown] ${inFlightExtensionWork.size} extension/script operation(s) still pending after ` +
`${EXTENSION_WORK_QUIT_DRAIN_TIMEOUT_MS}ms; quitting anyway.`
);
}
app.quit();
});
});

app.on('will-quit', () => {
Expand Down