From e5d95514eac67a9ca608ce4f852310f1e14d8a98 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:39:34 +0200 Subject: [PATCH 1/8] perf: reuse stale extension command builds (cherry picked from commit 925b2d8669d713e6f89b00972c94f38715d1f487) --- scripts/measure-extension-bundle-cache.mjs | 258 ++++++++ ...t-extension-runner-multi-command-build.mjs | 296 +++++++++ src/main/extension-runner.ts | 564 +++++++++++++++--- 3 files changed, 1020 insertions(+), 98 deletions(-) create mode 100644 scripts/measure-extension-bundle-cache.mjs create mode 100644 scripts/test-extension-runner-multi-command-build.mjs diff --git a/scripts/measure-extension-bundle-cache.mjs b/scripts/measure-extension-bundle-cache.mjs new file mode 100644 index 00000000..e30db23c --- /dev/null +++ b/scripts/measure-extension-bundle-cache.mjs @@ -0,0 +1,258 @@ +#!/usr/bin/env node + +import { fileURLToPath, pathToFileURL } from 'node:url'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { build } from 'esbuild'; +import { performance } from 'node:perf_hooks'; + +const require = createRequire(import.meta.url); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const iterations = Number.parseInt(process.env.SUPERCMD_EXTENSION_BUNDLE_MEASURE_ITERATIONS || '20', 10); + +export function createFixture() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sc-extension-bundle-cache-')); + const userDataDir = path.join(tmpDir, 'userData'); + const extensionsDir = path.join(userDataDir, 'extensions'); + const extDir = path.join(extensionsDir, 'bundle-cache-fixture'); + const buildDir = path.join(extDir, '.sc-build'); + + fs.mkdirSync(buildDir, { recursive: true }); + fs.writeFileSync( + path.join(extDir, 'package.json'), + JSON.stringify( + { + name: 'bundle-cache-fixture', + title: 'Bundle Cache Fixture', + description: 'Fixture for extension bundle cache measurement', + owner: 'codex', + commands: [ + { + name: 'background', + title: 'Background', + description: 'No-view command', + mode: 'no-view', + preferences: [ + { + name: 'freshCommandPref', + title: 'Fresh Command Pref', + type: 'textfield', + default: 'default-command', + }, + ], + }, + { + name: 'menu', + title: 'Menu', + description: 'Menu-bar command', + mode: 'menu-bar', + }, + ], + preferences: [ + { + name: 'freshExtensionPref', + title: 'Fresh Extension Pref', + type: 'textfield', + default: 'default-extension', + }, + ], + }, + null, + 2 + ) + ); + fs.writeFileSync(path.join(buildDir, 'background.js'), 'module.exports = { name: "background" };\n'); + fs.writeFileSync(path.join(buildDir, 'menu.js'), 'module.exports = { name: "menu" };\n'); + + return { tmpDir, userDataDir, extDir }; +} + +export async function bundleExtensionRunner(userDataDir) { + const outFile = path.join( + os.tmpdir(), + `sc-extension-runner-measure-${process.pid}-${Date.now()}.cjs` + ); + + const stubModule = (filter, source) => ({ + name: `stub-${filter}`, + setup(pluginBuild) { + pluginBuild.onResolve({ filter }, () => ({ + path: filter.source, + namespace: `stub-${filter.source}`, + })); + pluginBuild.onLoad({ filter: /.*/, namespace: `stub-${filter.source}` }, () => ({ + contents: source, + loader: 'js', + })); + }, + }); + + await build({ + entryPoints: [path.join(root, 'src/main/extension-runner.ts')], + outfile: outFile, + bundle: true, + external: ['esbuild'], + platform: 'node', + format: 'cjs', + target: 'node20', + logLevel: 'silent', + plugins: [ + stubModule( + /^electron$/, + ` + module.exports = { + app: { + getPath(name) { + if (name === 'userData') return ${JSON.stringify(userDataDir)}; + return ${JSON.stringify(userDataDir)}; + } + } + }; + ` + ), + stubModule( + /extension-preferences-store$/, + ` + module.exports = { + getExtensionPreferences(extName, cmdName) { + const values = globalThis.__extensionPreferenceValues || {}; + if (cmdName) return values[extName + '/' + cmdName] || {}; + return values[extName] || {}; + } + }; + ` + ), + stubModule( + /settings-store$/, + ` + module.exports = { + loadSettings() { + return { customExtensionFolders: [] }; + } + }; + ` + ), + ], + }); + + return outFile; +} + +export function createCounters(extDir) { + const counters = { + readFileSync: 0, + packageJsonReads: 0, + bundleReads: 0, + jsonParseCalls: 0, + existsSync: 0, + statSync: 0, + readdirSync: 0, + }; + const restore = []; + const packageJsonPath = path.join(extDir, 'package.json'); + const buildDir = path.join(extDir, '.sc-build'); + + const patch = (target, key, wrapper) => { + const original = target[key]; + target[key] = wrapper(original); + restore.push(() => { + target[key] = original; + }); + }; + + patch(fs, 'readFileSync', (original) => function patchedReadFileSync(filePath, ...args) { + const normalizedPath = typeof filePath === 'string' ? filePath : String(filePath); + counters.readFileSync++; + if (path.resolve(normalizedPath) === packageJsonPath) counters.packageJsonReads++; + if (path.resolve(normalizedPath).startsWith(buildDir + path.sep)) counters.bundleReads++; + return original.call(this, filePath, ...args); + }); + patch(fs, 'existsSync', (original) => function patchedExistsSync(filePath) { + counters.existsSync++; + return original.call(this, filePath); + }); + patch(fs, 'statSync', (original) => function patchedStatSync(filePath, ...args) { + counters.statSync++; + return original.call(this, filePath, ...args); + }); + patch(fs, 'readdirSync', (original) => function patchedReaddirSync(filePath, ...args) { + counters.readdirSync++; + return original.call(this, filePath, ...args); + }); + patch(JSON, 'parse', (original) => function patchedJsonParse(source, ...args) { + counters.jsonParseCalls++; + return original.call(this, source, ...args); + }); + + return { + counters, + restore() { + while (restore.length > 0) restore.pop()(); + }, + }; +} + +export async function measure(label, extDir, fn) { + const { counters, restore } = createCounters(extDir); + const started = performance.now(); + try { + await fn(); + } finally { + counters.elapsedMs = Number((performance.now() - started).toFixed(3)); + restore(); + } + return { label, iterations, ...counters }; +} + +async function main() { + const fixture = createFixture(); + let bundledRunner; + try { + bundledRunner = await bundleExtensionRunner(fixture.userDataDir); + const runner = require(bundledRunner); + + globalThis.__extensionPreferenceValues = { + 'bundle-cache-fixture': { freshExtensionPref: 'stored-extension-1' }, + 'bundle-cache-fixture/background': { freshCommandPref: 'stored-command-1' }, + }; + + const noView = await measure('repeated-getExtensionBundle-no-view', fixture.extDir, async () => { + for (let i = 0; i < iterations; i++) { + const bundle = await runner.getExtensionBundle('bundle-cache-fixture', 'background'); + assert.equal(bundle.mode, 'no-view'); + assert.equal(bundle.preferences.freshExtensionPref, 'stored-extension-1'); + assert.equal(bundle.preferences.freshCommandPref, 'stored-command-1'); + } + }); + + const menuBar = await measure('repeated-menu-bar-background-prep', fixture.extDir, async () => { + for (let i = 0; i < iterations; i++) { + const commands = runner + .discoverInstalledExtensionCommands() + .filter((command) => command.mode === 'menu-bar'); + assert.equal(commands.length, 1); + const bundle = await runner.getExtensionBundle(commands[0].extName, commands[0].cmdName); + assert.equal(bundle.mode, 'menu-bar'); + } + }); + + console.log(JSON.stringify({ iterations, measurements: [noView, menuBar] }, null, 2)); + } finally { + try { + if (bundledRunner) fs.unlinkSync(bundledRunner); + } catch {} + try { + fs.rmSync(fixture.tmpDir, { recursive: true, force: true }); + } catch {} + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/scripts/test-extension-runner-multi-command-build.mjs b/scripts/test-extension-runner-multi-command-build.mjs new file mode 100644 index 00000000..e502e9a6 --- /dev/null +++ b/scripts/test-extension-runner-multi-command-build.mjs @@ -0,0 +1,296 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { performance } from 'node:perf_hooks'; +import { + bundleExtensionRunner, +} from './measure-extension-bundle-cache.mjs'; + +const require = createRequire(import.meta.url); +const Module = require('node:module'); + +function createBuildFixture(commandCount) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `sc-extension-multi-build-${commandCount}-`)); + const userDataDir = path.join(tmpDir, 'userData'); + const extDir = path.join(tmpDir, 'extension'); + const srcDir = path.join(extDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + const commands = Array.from({ length: commandCount }, (_unused, index) => { + const name = `command-${index}`; + fs.writeFileSync( + path.join(srcDir, `${name}.ts`), + [ + `export default async function ${name.replace(/-/g, '_')}() {`, + ` return ${JSON.stringify(name)};`, + `}`, + '', + ].join('\n') + ); + return { + name, + title: `Command ${index}`, + description: `Command ${index}`, + mode: 'view', + }; + }); + + fs.writeFileSync( + path.join(extDir, 'package.json'), + JSON.stringify( + { + name: `multi-build-fixture-${commandCount}`, + title: `Multi Build Fixture ${commandCount}`, + description: 'Fixture for batched extension builds', + owner: 'codex', + commands, + }, + null, + 2 + ) + ); + + return { tmpDir, userDataDir, extDir }; +} + +async function withBundledRunner(fixture, callback) { + let bundledRunner; + const esbuild = require('esbuild'); + const originalBuild = esbuild.build; + const originalLoad = Module._load; + const buildCalls = []; + const patchedBuild = async function patchedBuild(options) { + if (options?.absWorkingDir === fixture.extDir) { + const entryCount = Array.isArray(options.entryPoints) + ? options.entryPoints.length + : Object.keys(options.entryPoints || {}).length; + buildCalls.push({ + entryCount, + outdir: options.outdir, + outfile: options.outfile, + }); + } + return originalBuild.call(this, options); + }; + Module._load = function patchedModuleLoad(request, parent, isMain) { + if (request === 'esbuild') { + return { ...esbuild, build: patchedBuild }; + } + return originalLoad.call(this, request, parent, isMain); + }; + + try { + bundledRunner = await bundleExtensionRunner(fixture.userDataDir); + const runner = require(bundledRunner); + return await callback({ runner, buildCalls }); + } finally { + Module._load = originalLoad; + try { + if (bundledRunner) fs.unlinkSync(bundledRunner); + } catch {} + } +} + +function readManifest(extDir) { + return JSON.parse(fs.readFileSync(path.join(extDir, 'package.json'), 'utf-8')); +} + +function writeManifest(extDir, pkg) { + fs.writeFileSync(path.join(extDir, 'package.json'), JSON.stringify(pkg, null, 2)); +} + +function mutateManifest(extDir, mutator) { + const pkg = readManifest(extDir); + mutator(pkg); + writeManifest(extDir, pkg); +} + +test('buildAllCommands batches 1/5/20 command fixtures into one esbuild call each', async (t) => { + for (const commandCount of [1, 5, 20]) { + await t.test(`${commandCount} command fixture`, async () => { + const fixture = createBuildFixture(commandCount); + try { + await withBundledRunner(fixture, async ({ runner, buildCalls }) => { + const built = await runner.buildAllCommands(`multi-build-fixture-${commandCount}`, fixture.extDir); + + assert.equal(built, commandCount); + assert.equal(buildCalls.length, 1, 'expected one esbuild invocation for all commands'); + assert.equal(buildCalls[0].entryCount, commandCount); + assert.equal(buildCalls[0].outdir, path.join(fixture.extDir, '.sc-build')); + assert.equal(buildCalls[0].outfile, undefined); + + for (let index = 0; index < commandCount; index += 1) { + assert.ok( + fs.existsSync(path.join(fixture.extDir, '.sc-build', `command-${index}.js`)), + `expected command-${index}.js output` + ); + } + }); + } finally { + try { + fs.rmSync(fixture.tmpDir, { recursive: true, force: true }); + } catch {} + } + }); + } +}); + +test('buildAllCommands reuses unchanged command bundles', async () => { + const fixture = createBuildFixture(5); + try { + await withBundledRunner(fixture, async ({ runner, buildCalls }) => { + assert.equal(await runner.buildAllCommands('multi-build-fixture-5', fixture.extDir), 5); + assert.equal(await runner.buildAllCommands('multi-build-fixture-5', fixture.extDir), 5); + + assert.equal(buildCalls.length, 1, 'expected unchanged rebuild to skip esbuild'); + assert.ok( + fs.existsSync(path.join(fixture.extDir, '.sc-build', '.sc-build-stamp.json')), + 'expected build stamp' + ); + }); + } finally { + fs.rmSync(fixture.tmpDir, { recursive: true, force: true }); + } +}); + +test('buildAllCommands rebuilds only stale commands in a multi-command extension', async () => { + const fixture = createBuildFixture(5); + try { + await withBundledRunner(fixture, async ({ runner, buildCalls }) => { + assert.equal(await runner.buildAllCommands('multi-build-fixture-5', fixture.extDir), 5); + const untouchedOutFile = path.join(fixture.extDir, '.sc-build', 'command-1.js'); + const untouchedBefore = fs.statSync(untouchedOutFile).mtimeMs; + + fs.appendFileSync(path.join(fixture.extDir, 'src', 'command-0.ts'), '\nexport const changed = true;\n'); + assert.equal(await runner.buildAllCommands('multi-build-fixture-5', fixture.extDir), 5); + + assert.equal(buildCalls.length, 2, 'expected one initial build and one partial rebuild'); + assert.equal(buildCalls[1].entryCount, 1, 'expected only the stale command to be rebuilt'); + assert.equal( + fs.statSync(untouchedOutFile).mtimeMs, + untouchedBefore, + 'expected unchanged command output to be reused' + ); + }); + } finally { + fs.rmSync(fixture.tmpDir, { recursive: true, force: true }); + } +}); + +test('buildAllCommands rebuilds when source, manifest, tsconfig, externals, or deps change', async (t) => { + const cases = [ + { + name: 'source', + mutate(fixture) { + fs.appendFileSync(path.join(fixture.extDir, 'src', 'command-0.ts'), '\nexport const changed = true;\n'); + }, + }, + { + name: 'manifest command metadata', + mutate(fixture) { + mutateManifest(fixture.extDir, (pkg) => { + pkg.commands[0].title = 'Updated Command Title'; + }); + }, + }, + { + name: 'tsconfig', + mutate(fixture) { + fs.writeFileSync( + path.join(fixture.extDir, 'tsconfig.json'), + JSON.stringify({ compilerOptions: { jsx: 'react-jsx' } }, null, 2) + ); + }, + }, + { + name: 'manifest externals', + mutate(fixture) { + mutateManifest(fixture.extDir, (pkg) => { + pkg.external = ['@example/native-helper']; + }); + }, + }, + { + name: 'dependency manifest', + mutate(fixture) { + fs.mkdirSync(path.join(fixture.extDir, 'node_modules'), { recursive: true }); + mutateManifest(fixture.extDir, (pkg) => { + pkg.dependencies = { 'left-pad': '1.3.0' }; + }); + }, + }, + ]; + + for (const testCase of cases) { + await t.test(testCase.name, async () => { + const fixture = createBuildFixture(1); + try { + await withBundledRunner(fixture, async ({ runner, buildCalls }) => { + assert.equal(await runner.buildAllCommands('multi-build-fixture-1', fixture.extDir), 1); + testCase.mutate(fixture); + assert.equal(await runner.buildAllCommands('multi-build-fixture-1', fixture.extDir), 1); + + assert.equal(buildCalls.length, 2, `expected ${testCase.name} change to rebuild`); + }); + } finally { + fs.rmSync(fixture.tmpDir, { recursive: true, force: true }); + } + }); + } +}); + +test('buildAllCommands keeps pre-built output when source files are missing', async () => { + const fixture = createBuildFixture(1); + try { + await withBundledRunner(fixture, async ({ runner, buildCalls }) => { + assert.equal(await runner.buildAllCommands('multi-build-fixture-1', fixture.extDir), 1); + const outFile = path.join(fixture.extDir, '.sc-build', 'command-0.js'); + assert.ok(fs.existsSync(outFile), 'expected initial output'); + + fs.rmSync(path.join(fixture.extDir, 'src'), { recursive: true, force: true }); + assert.equal(await runner.buildAllCommands('multi-build-fixture-1', fixture.extDir), 1); + + assert.ok(fs.existsSync(outFile), 'expected pre-built output to be preserved'); + assert.equal(buildCalls.length, 1, 'expected no rebuild when only pre-built output is available'); + }); + } finally { + fs.rmSync(fixture.tmpDir, { recursive: true, force: true }); + } +}); + +test('buildAllCommands reports repeated build measurements for 1/5/20 command fixtures', async (t) => { + const enabled = process.env.SUPERCMD_EXTENSION_BUILD_MEASURE === '1'; + if (!enabled) { + t.skip('set SUPERCMD_EXTENSION_BUILD_MEASURE=1 to print local timing evidence'); + return; + } + + for (const commandCount of [1, 5, 20]) { + const fixture = createBuildFixture(commandCount); + try { + await withBundledRunner(fixture, async ({ runner, buildCalls }) => { + const timingsMs = []; + for (let index = 0; index < 3; index += 1) { + const started = performance.now(); + assert.equal( + await runner.buildAllCommands(`multi-build-fixture-${commandCount}`, fixture.extDir), + commandCount + ); + timingsMs.push(Number((performance.now() - started).toFixed(2))); + } + console.log(JSON.stringify({ + commandCount, + timingsMs, + esbuildCalls: buildCalls.length, + })); + }); + } finally { + fs.rmSync(fixture.tmpDir, { recursive: true, force: true }); + } + } +}); diff --git a/src/main/extension-runner.ts b/src/main/extension-runner.ts index 034462e5..dca80d70 100644 --- a/src/main/extension-runner.ts +++ b/src/main/extension-runner.ts @@ -14,6 +14,7 @@ */ import { app } from 'electron'; +import { createHash } from 'crypto'; import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; @@ -111,6 +112,44 @@ interface InstalledExtensionSource { sourceRoot: string; } +interface FsPathSignature { + exists: boolean; + isFile: boolean; + isDirectory: boolean; + size: number; + mtimeMs: number; +} + +interface BuildFileSignature extends FsPathSignature { + path: string; + hash: string; +} + +interface ExtensionCommandBuildStamp { + name: string; + commandSignature: string; + entryFile: string; + outFile: string; + outputSignature: BuildFileSignature; + inputSignatures: BuildFileSignature[]; +} + +interface ExtensionBuildStamp { + version: number; + extName: string; + platform: string; + arch: string; + esbuildVersion: string; + external: string[]; + tsconfigRaw: string; + runtimeDeps: string[]; + configSignatures: BuildFileSignature[]; + commands: ExtensionCommandBuildStamp[]; +} + +const extensionBuildStampVersion = 1; +const extensionBuildStampFile = '.sc-build-stamp.json'; + function getManagedExtensionsDir(): string { const dir = path.join(app.getPath('userData'), 'extensions'); if (!fs.existsSync(dir)) { @@ -144,6 +183,102 @@ function normalizeExtensionName(name: string): string { return raw.replace(/^@/, '').replace(/[\\/]/g, '-'); } +function getPathSignature(filePath: string): FsPathSignature { + try { + const stat = fs.statSync(filePath); + return { + exists: true, + isFile: stat.isFile(), + isDirectory: stat.isDirectory(), + size: stat.size, + mtimeMs: stat.mtimeMs, + }; + } catch { + return { + exists: false, + isFile: false, + isDirectory: false, + size: -1, + mtimeMs: -1, + }; + } +} + +function getStoredBuildPath(extPath: string, filePath: string): string { + const normalized = path.resolve(filePath); + const relative = path.relative(extPath, normalized); + if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) { + return `rel:${relative.split(path.sep).join('/')}`; + } + return `abs:${normalized}`; +} + +function resolveStoredBuildPath(extPath: string, storedPath: string): string { + if (storedPath.startsWith('rel:')) { + return path.join(extPath, ...storedPath.slice(4).split('/')); + } + if (storedPath.startsWith('abs:')) { + return storedPath.slice(4); + } + return storedPath; +} + +function hashFile(filePath: string): string { + try { + return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); + } catch { + return ''; + } +} + +function getBuildFileSignature(extPath: string, filePath: string): BuildFileSignature { + const signature = getPathSignature(filePath); + return { + path: getStoredBuildPath(extPath, filePath), + ...signature, + hash: signature.exists && signature.isFile ? hashFile(filePath) : '', + }; +} + +function sameBuildFileSignature(a: BuildFileSignature, b: BuildFileSignature): boolean { + return ( + a.path === b.path && + a.exists === b.exists && + a.isFile === b.isFile && + a.isDirectory === b.isDirectory && + a.size === b.size && + a.mtimeMs === b.mtimeMs && + a.hash === b.hash + ); +} + +function sameBuildFileSignatures(a: BuildFileSignature[], b: BuildFileSignature[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!sameBuildFileSignature(a[i], b[i])) return false; + } + return true; +} + +function readExtensionBuildStamp(buildDir: string): ExtensionBuildStamp | null { + try { + const parsed = JSON.parse(fs.readFileSync(path.join(buildDir, extensionBuildStampFile), 'utf-8')); + if (parsed?.version !== extensionBuildStampVersion || !Array.isArray(parsed?.commands)) return null; + return parsed; + } catch { + return null; + } +} + +function writeExtensionBuildStamp(buildDir: string, stamp: ExtensionBuildStamp): void { + try { + fs.mkdirSync(buildDir, { recursive: true }); + fs.writeFileSync(path.join(buildDir, extensionBuildStampFile), JSON.stringify(stamp, null, 2)); + } catch (error: any) { + console.warn('Failed to write extension build stamp:', error?.message || error); + } +} + function getConfiguredExtensionRoots(): string[] { const settingsPaths = Array.isArray(loadSettings().customExtensionFolders) ? loadSettings().customExtensionFolders @@ -465,6 +600,43 @@ function extensionRequiresNodeModules(pkg: any): boolean { return getInstallableRuntimeDeps(pkg).length > 0; } +function createNativeSchemeExternalPlugin(): any { + return { + name: 'native-scheme-external', + setup(build: any) { + build.onResolve({ filter: /^(swift|rust):/ }, (args: any) => ({ + path: args.path, + external: true, + })); + }, + }; +} + +function getExtensionBuildExternals(manifestExternal: string[]): string[] { + return [ + 'react', + 'react-dom', + 'react-dom/*', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + '@raycast/api', + '@raycast/utils', + 're2', + 'better-sqlite3', + 'fsevents', + 'raycast-cross-extension', + 'node-fetch', + 'undici', + 'undici/*', + 'axios', + 'tar', + 'extract-zip', + 'sha256-file', + ...manifestExternal, + ...nodeBuiltins, + ]; +} + /** * Parse a tsconfig.json that may contain JSONC features (comments, trailing commas). * TypeScript itself accepts these, and many Raycast extensions ship them @@ -638,6 +810,151 @@ function resolveEntryFile(extPath: string, cmd: any): string | null { return null; } +function getEsbuildPackageVersion(): string { + try { + const pkgPath = require.resolve('esbuild/package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + return typeof pkg?.version === 'string' ? pkg.version : 'unknown'; + } catch { + return 'unknown'; + } +} + +function getExtensionConfigSignatures(extPath: string): BuildFileSignature[] { + return [ + 'package.json', + 'tsconfig.json', + 'package-lock.json', + 'npm-shrinkwrap.json', + 'yarn.lock', + 'pnpm-lock.yaml', + 'bun.lock', + 'bun.lockb', + ] + .map((fileName) => path.join(extPath, fileName)) + .filter((filePath) => fs.existsSync(filePath)) + .map((filePath) => getBuildFileSignature(extPath, filePath)); +} + +function createExtensionBuildContext( + extName: string, + extPath: string, + pkg: any, + manifestExternal: string[], + tsconfigRaw: string +): Omit { + return { + version: extensionBuildStampVersion, + extName, + platform: process.platform, + arch: process.arch, + esbuildVersion: getEsbuildPackageVersion(), + external: getExtensionBuildExternals(manifestExternal), + tsconfigRaw, + runtimeDeps: getInstallableRuntimeDeps(pkg), + configSignatures: getExtensionConfigSignatures(extPath), + }; +} + +function sameExtensionBuildContext( + stamp: ExtensionBuildStamp | null, + context: Omit +): stamp is ExtensionBuildStamp { + if (!stamp) return false; + return ( + stamp.version === context.version && + stamp.extName === context.extName && + stamp.platform === context.platform && + stamp.arch === context.arch && + stamp.esbuildVersion === context.esbuildVersion && + JSON.stringify(stamp.external) === JSON.stringify(context.external) && + stamp.tsconfigRaw === context.tsconfigRaw && + JSON.stringify(stamp.runtimeDeps) === JSON.stringify(context.runtimeDeps) && + sameBuildFileSignatures(stamp.configSignatures, context.configSignatures) + ); +} + +function commandBuildSignature(cmd: any): string { + return JSON.stringify(cmd || {}); +} + +function findCommandStamp( + stamp: ExtensionBuildStamp, + cmdName: string +): ExtensionCommandBuildStamp | null { + return stamp.commands.find((command) => command.name === cmdName) || null; +} + +function isCommandBuildUpToDate( + extPath: string, + commandStamp: ExtensionCommandBuildStamp | null, + cmd: any, + entryFile: string, + outFile: string +): commandStamp is ExtensionCommandBuildStamp { + if (!commandStamp) return false; + if (commandStamp.commandSignature !== commandBuildSignature(cmd)) return false; + if (commandStamp.entryFile !== getStoredBuildPath(extPath, entryFile)) return false; + if (commandStamp.outFile !== getStoredBuildPath(extPath, outFile)) return false; + if (!fs.existsSync(outFile)) return false; + + const outputSignature = getBuildFileSignature(extPath, outFile); + if (!sameBuildFileSignature(commandStamp.outputSignature, outputSignature)) return false; + + const currentInputSignatures = commandStamp.inputSignatures.map((input) => + getBuildFileSignature(extPath, resolveStoredBuildPath(extPath, input.path)) + ); + return sameBuildFileSignatures(commandStamp.inputSignatures, currentInputSignatures); +} + +function getMetafileInputsForOutput( + extPath: string, + metafile: any, + outFile: string +): string[] { + const outputs = metafile?.outputs && typeof metafile.outputs === 'object' + ? metafile.outputs + : {}; + const normalizedOutFile = path.resolve(outFile); + + for (const [outputPath, outputMeta] of Object.entries(outputs)) { + const resolvedOutput = path.isAbsolute(outputPath) + ? path.resolve(outputPath) + : path.resolve(extPath, outputPath); + if (resolvedOutput !== normalizedOutFile) continue; + const inputs = (outputMeta as any)?.inputs; + if (!inputs || typeof inputs !== 'object') return []; + return Object.keys(inputs).map((inputPath) => + path.isAbsolute(inputPath) ? inputPath : path.resolve(extPath, inputPath) + ); + } + + return []; +} + +function createCommandBuildStamp( + extPath: string, + cmd: any, + entryFile: string, + outFile: string, + metafile: any +): ExtensionCommandBuildStamp { + const inputFiles = new Set([ + entryFile, + ...getMetafileInputsForOutput(extPath, metafile, outFile), + ]); + return { + name: String(cmd.name), + commandSignature: commandBuildSignature(cmd), + entryFile: getStoredBuildPath(extPath, entryFile), + outFile: getStoredBuildPath(extPath, outFile), + outputSignature: getBuildFileSignature(extPath, outFile), + inputSignatures: [...inputFiles] + .map((filePath) => getBuildFileSignature(extPath, filePath)) + .sort((a, b) => a.path.localeCompare(b.path)), + }; +} + /** * Build ALL commands for an installed extension using esbuild. * Called at install time so the extension is ready to run instantly. @@ -661,10 +978,11 @@ export async function buildAllCommands(extName: string, extPathOverride?: string } let commands: any[]; + let pkg: any; let requiresNodeModules = false; let manifestExternal: string[] = []; try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); if (!isManifestPlatformCompatible(pkg)) { console.warn(`Skipping build for incompatible extension ${extName}`); return 0; @@ -680,120 +998,170 @@ export async function buildAllCommands(extName: string, extPathOverride?: string if (commands.length === 0) return 0; - const esbuild = requireEsbuild(); - const extNodeModules = path.join(extPath, 'node_modules'); - if (requiresNodeModules && !fs.existsSync(extNodeModules)) { - try { - const { installExtensionDeps } = require('./extension-registry'); - await installExtensionDeps(extPath); - } catch (e: any) { - console.error(`Failed to install dependencies for ${extName}:`, e?.message || e); - return 0; - } - if (!fs.existsSync(extNodeModules)) { - console.error(`Dependencies missing for ${extName}: ${extNodeModules} not found`); - return 0; - } - } const buildDir = getBuildDir(extPath); - // Avoid stale command bundles when extension source layout changes. - try { - fs.rmSync(buildDir, { recursive: true, force: true }); - } catch {} - fs.mkdirSync(buildDir, { recursive: true }); - let built = 0; + const tsconfigRaw = getEsbuildTsconfigRaw(extPath); + const buildContext = createExtensionBuildContext(extName, extPath, pkg, manifestExternal, tsconfigRaw); + const previousStamp = readExtensionBuildStamp(buildDir); + const matchingStamp = sameExtensionBuildContext(previousStamp, buildContext) ? previousStamp : null; + const buildableCommands: Array<{ cmd: any; entryFile: string; outFile: string }> = []; + const staleCommands: Array<{ cmd: any; entryFile: string; outFile: string }> = []; + const nextCommandStamps = new Map(); + let reusedPrebuilt = 0; + let skipped = 0; for (const cmd of commands) { if (!cmd.name) continue; if (!isCommandPlatformCompatible(cmd)) continue; + const outFile = path.join(buildDir, `${cmd.name}.js`); const entryFile = resolveEntryFile(extPath, cmd); if (!entryFile) { + if (fs.existsSync(outFile)) { + reusedPrebuilt++; + continue; + } console.warn(`No entry file for ${extName}/${cmd.name}, skipping`); continue; } - const outFile = path.join(buildDir, `${cmd.name}.js`); fs.mkdirSync(path.dirname(outFile), { recursive: true }); + const buildableCommand = { cmd, entryFile, outFile }; + buildableCommands.push(buildableCommand); + + const commandStamp = matchingStamp + ? findCommandStamp(matchingStamp, String(cmd.name)) + : null; + if (isCommandBuildUpToDate(extPath, commandStamp, cmd, entryFile, outFile)) { + nextCommandStamps.set(String(cmd.name), commandStamp); + skipped++; + } else { + staleCommands.push(buildableCommand); + } + } + + if (buildableCommands.length === 0) { + if (reusedPrebuilt > 0) { + console.log(`Reused ${reusedPrebuilt}/${commands.length} pre-built commands for ${extName}`); + return reusedPrebuilt; + } + console.log(`Built 0/${commands.length} commands for ${extName}`); + return 0; + } + + if (staleCommands.length === 0) { + const ready = skipped + reusedPrebuilt; + console.log(`Reused ${ready}/${commands.length} commands for ${extName}`); + return ready; + } + + const extNodeModules = path.join(extPath, 'node_modules'); + if (requiresNodeModules && !fs.existsSync(extNodeModules)) { + try { + const { installExtensionDeps } = require('./extension-registry'); + await installExtensionDeps(extPath); + } catch (e: any) { + console.error(`Failed to install dependencies for ${extName}:`, e?.message || e); + return skipped + reusedPrebuilt; + } + if (!fs.existsSync(extNodeModules)) { + console.error(`Dependencies missing for ${extName}: ${extNodeModules} not found`); + return skipped + reusedPrebuilt; + } + } + const esbuild = requireEsbuild(); + const commonOptions = { + absWorkingDir: extPath, + bundle: true, + format: 'cjs', + platform: 'node', + plugins: [createNativeSchemeExternalPlugin()], + external: buildContext.external, + nodePaths: fs.existsSync(extNodeModules) ? [extNodeModules] : [], + target: 'es2020', + jsx: 'automatic', + jsxImportSource: 'react', + tsconfigRaw, + define: { + 'process.env.NODE_ENV': '"production"', + 'global': 'globalThis', + }, + logLevel: 'warning', + metafile: true, + }; + + for (const { outFile } of staleCommands) { try { - console.log(` Building ${extName}/${cmd.name}…`); - - await runEsbuildBuild( - esbuild, - { - entryPoints: [entryFile], - absWorkingDir: extPath, - bundle: true, - format: 'cjs', - platform: 'node', - outfile: outFile, - plugins: [ - // Mark swift:/rust: imports as external so fakeRequire can handle them at runtime - { - name: 'native-scheme-external', - setup(build: any) { - build.onResolve({ filter: /^(swift|rust):/ }, (args: any) => ({ - path: args.path, - external: true, - })); - }, - }, - ], - external: [ - // React — provided by the renderer at runtime - 'react', - 'react-dom', - 'react-dom/*', - 'react/jsx-runtime', - 'react/jsx-dev-runtime', - // Raycast — provided by our shim - '@raycast/api', - '@raycast/utils', - // Native C++ addons — cannot be bundled, we stub them at runtime - 're2', - 'better-sqlite3', - 'fsevents', - // Cross-extension calls — not supported, stubbed - 'raycast-cross-extension', - // Fetch libs — use runtime shims in renderer instead of bundling Node internals - 'node-fetch', - 'undici', - 'undici/*', - // HTTP / file-download / archive packages — must be kept external so our renderer - // shim can intercept them and route file I/O through the main process (which has - // real filesystem access). Bundling them inline breaks binary downloads because the - // browser renderer cannot do streaming file writes or archive extraction natively. - 'axios', - 'tar', - 'extract-zip', - 'sha256-file', - // Respect extension-defined externals from manifest - ...manifestExternal, - // Node.js built-ins — stubbed at runtime in the renderer - ...nodeBuiltins, - ], - nodePaths: fs.existsSync(extNodeModules) ? [extNodeModules] : [], - target: 'es2020', - jsx: 'automatic', - jsxImportSource: 'react', - tsconfigRaw: getEsbuildTsconfigRaw(extPath), - define: { - 'process.env.NODE_ENV': '"production"', - 'global': 'globalThis', - }, - logLevel: 'warning', - }, - extPath, - `${extName}/${cmd.name}` - ); + fs.rmSync(outFile, { force: true }); + } catch {} + } + let built = skipped + reusedPrebuilt; + try { + console.log(` Building ${staleCommands.length}/${buildableCommands.length} stale commands for ${extName}…`); + const entryPoints = Object.fromEntries( + staleCommands.map(({ cmd, entryFile }) => [String(cmd.name), entryFile]) + ); + const result = await runEsbuildBuild( + esbuild, + { + ...commonOptions, + entryPoints, + outdir: buildDir, + }, + extPath, + `${extName}/*` + ); + + for (const { cmd, entryFile, outFile } of staleCommands) { if (fs.existsSync(outFile)) { + nextCommandStamps.set( + String(cmd.name), + createCommandBuildStamp(extPath, cmd, entryFile, outFile, result?.metafile) + ); built++; } - } catch (e) { - console.error(` esbuild failed for ${extName}/${cmd.name}:`, e); } + } catch (batchError) { + console.warn( + ` Batched esbuild failed for ${extName}; falling back to per-command builds:`, + (batchError as any)?.message || batchError + ); + + built = skipped + reusedPrebuilt; + for (const { cmd, entryFile, outFile } of staleCommands) { + try { + console.log(` Building ${extName}/${cmd.name}…`); + + const result = await runEsbuildBuild( + esbuild, + { + ...commonOptions, + entryPoints: [entryFile], + outfile: outFile, + }, + extPath, + `${extName}/${cmd.name}` + ); + + if (fs.existsSync(outFile)) { + nextCommandStamps.set( + String(cmd.name), + createCommandBuildStamp(extPath, cmd, entryFile, outFile, result?.metafile) + ); + built++; + } + } catch (e) { + console.error(` esbuild failed for ${extName}/${cmd.name}:`, e); + } + } + } + + if (nextCommandStamps.size > 0) { + writeExtensionBuildStamp(buildDir, { + ...buildContext, + commands: [...nextCommandStamps.values()].sort((a, b) => a.name.localeCompare(b.name)), + }); } console.log(`Built ${built}/${commands.length} commands for ${extName}`); @@ -1111,9 +1479,9 @@ async function runEsbuildBuild( options: any, extPath: string, label: string -): Promise { +): Promise { try { - await esbuild.build(options); + return await esbuild.build(options); } catch (error: any) { const missing = extractMissingBareImports(error); if (missing.length === 0) throw error; @@ -1129,7 +1497,7 @@ async function runEsbuildBuild( ); throw error; } - await esbuild.build(options); + return await esbuild.build(options); } } From 7905622294cf63fc1b68dca4f256c755824c7221 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:29:01 +0200 Subject: [PATCH 2/8] perf: target extension command refresh (cherry picked from commit 39b567aadac9a5ba994fdcdc6ecd59774980453c) --- .../test-extension-command-refresh-cache.mjs | 282 +++++++++++++ src/main/commands.ts | 383 ++++++++++++------ src/main/main.ts | 15 +- 3 files changed, 553 insertions(+), 127 deletions(-) create mode 100644 scripts/test-extension-command-refresh-cache.mjs diff --git a/scripts/test-extension-command-refresh-cache.mjs b/scripts/test-extension-command-refresh-cache.mjs new file mode 100644 index 00000000..96feb009 --- /dev/null +++ b/scripts/test-extension-command-refresh-cache.mjs @@ -0,0 +1,282 @@ +#!/usr/bin/env node + +import test, { after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { setTimeout as delay } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-extension-command-refresh-')); +process.env.SUPERCMD_TEST_USER_DATA = tempRoot; + +const commandsModule = await importTs(path.join(root, 'src/main/commands.ts'), { + root, + stubs: { + electron: ` + export const app = { + getPath() { return ${JSON.stringify(tempRoot)}; }, + quit() {}, + }; + `, + './extension-runner': ` + export function discoverInstalledExtensionCommands() { return []; } + `, + './quicklink-store': ` + export function getAllQuickLinks() { return []; } + export function getQuickLinkCommandId(link) { return 'quicklink-' + String(link?.id || link?.url || 'unknown'); } + export function isQuickLinkCommandId(commandId) { return String(commandId || '').startsWith('quicklink-'); } + `, + './script-command-runner': ` + export function discoverScriptCommands() { return []; } + `, + './settings-store': ` + export function getSearchApplicationsScope() { return []; } + export function loadSettings() { return globalThis.__superCmdExtensionRefreshSettings || {}; } + `, + }, +}); + +after(() => { + commandsModule.__resetCommandCacheForTesting(); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +function makeBaseCommands(extraAppCount = 0) { + const commands = [ + { + id: 'app-safari', + title: 'Safari', + keywords: ['browser'], + category: 'app', + path: '/Applications/Safari.app', + }, + { + id: 'settings-network', + title: 'Network', + keywords: ['wifi'], + category: 'settings', + path: 'com.apple.Network-Settings.extension', + }, + { + id: 'ext-old-search', + title: 'Old Search', + subtitle: 'Old Extension', + keywords: ['old'], + category: 'extension', + path: 'old/search', + mode: 'view', + deeplink: 'supercmd://extensions/old/search', + }, + { + id: 'script-cleanup', + title: 'Cleanup', + subtitle: 'Scripts', + keywords: ['cleanup'], + category: 'script', + path: '/tmp/cleanup.sh', + mode: 'inline', + }, + { + id: 'quicklink-docs', + title: 'Docs', + subtitle: 'Quick Link', + keywords: ['docs'], + category: 'system', + }, + { + id: 'system-open-settings', + title: 'SuperCmd Settings', + keywords: ['settings'], + category: 'system', + }, + ]; + + for (let index = 0; index < extraAppCount; index += 1) { + commands.unshift({ + id: `app-synthetic-${index}`, + title: `Synthetic App ${index}`, + keywords: [`synthetic-${index}`], + category: 'app', + path: `/Applications/Synthetic ${index}.app`, + }); + } + + return commands; +} + +function makeExtensionCommands(count = 1) { + return Array.from({ length: count }, (_, index) => ({ + id: `ext-new-command-${index}`, + title: `New Command ${index}`, + subtitle: 'New Extension', + keywords: ['new', `command-${index}`], + category: 'extension', + path: `new/command-${index}`, + mode: 'view', + deeplink: `supercmd://extensions/new/command-${index}`, + })); +} + +function commandIds(commands) { + return commands.map((command) => command.id); +} + +function stats(values) { + const sorted = [...values].sort((a, b) => a - b); + return { + min: Number(sorted[0].toFixed(3)), + median: Number(sorted[Math.floor(sorted.length / 2)].toFixed(3)), + max: Number(sorted[sorted.length - 1].toFixed(3)), + }; +} + +test('extension command refresh swaps installed extensions without structural rediscovery', async () => { + commandsModule.__resetCommandCacheForTesting(); + globalThis.__superCmdExtensionRefreshSettings = { + commandMetadata: { + 'new/command-0': { subtitle: 'Runtime status' }, + }, + }; + + let structuralDiscoveryRuns = 0; + let extensionDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + structuralDiscoveryRuns += 1; + return makeBaseCommands(); + }); + commandsModule.__setExtensionCommandInfoRunnerForTesting(() => { + extensionDiscoveryRuns += 1; + return makeExtensionCommands(1); + }); + + commandsModule.__seedCommandCacheForTesting(makeBaseCommands(), { cacheTimestamp: Date.now() }); + const refreshed = await commandsModule.refreshCommandsForExtensionChange(); + + assert.equal(structuralDiscoveryRuns, 0); + assert.equal(commandsModule.__getCommandDiscoveryStartCountForTesting(), 0); + assert.equal(extensionDiscoveryRuns, 1); + assert.deepEqual(commandIds(refreshed), [ + 'app-safari', + 'settings-network', + 'ext-new-command-0', + 'script-cleanup', + 'quicklink-docs', + 'system-open-settings', + ]); + assert.equal(refreshed.find((command) => command.id === 'ext-new-command-0')?.subtitle, 'Runtime status'); + assert.equal(refreshed.some((command) => command.id === 'ext-old-search'), false); + assert.equal(await commandsModule.getAvailableCommands(), refreshed); +}); + +test('extension command refresh removes uninstalled extensions from cached command list', async () => { + commandsModule.__resetCommandCacheForTesting(); + globalThis.__superCmdExtensionRefreshSettings = {}; + + let extensionDiscoveryRuns = 0; + commandsModule.__setExtensionCommandInfoRunnerForTesting(() => { + extensionDiscoveryRuns += 1; + return []; + }); + + commandsModule.__seedCommandCacheForTesting(makeBaseCommands(), { cacheTimestamp: Date.now() }); + const refreshed = await commandsModule.refreshCommandsForExtensionChange(); + + assert.equal(extensionDiscoveryRuns, 1); + assert.equal(refreshed.some((command) => command.category === 'extension'), false); + assert.deepEqual(commandIds(refreshed), [ + 'app-safari', + 'settings-network', + 'script-cleanup', + 'quicklink-docs', + 'system-open-settings', + ]); +}); + +test('extension command refresh falls back to full discovery without a command cache', async () => { + commandsModule.__resetCommandCacheForTesting(); + globalThis.__superCmdExtensionRefreshSettings = {}; + + let structuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + structuralDiscoveryRuns += 1; + return makeBaseCommands(); + }); + + const refreshed = await commandsModule.refreshCommandsForExtensionChange(); + + assert.equal(structuralDiscoveryRuns, 1); + assert.equal(commandsModule.__getCommandDiscoveryStartCountForTesting(), 1); + assert.equal(refreshed.some((command) => command.id === 'app-safari'), true); +}); + +test('extension command refresh benchmark avoids full discovery starts with a warm cache', async () => { + const iterations = 40; + const baseCommandCount = 2_000; + const simulatedStructuralDiscoveryMs = 8; + + commandsModule.__resetCommandCacheForTesting(); + globalThis.__superCmdExtensionRefreshSettings = {}; + let legacyStructuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + legacyStructuralDiscoveryRuns += 1; + await delay(simulatedStructuralDiscoveryMs); + return makeBaseCommands(baseCommandCount); + }); + + const legacyTimes = []; + for (let index = 0; index < iterations; index += 1) { + commandsModule.__seedCommandCacheForTesting(makeBaseCommands(baseCommandCount), { cacheTimestamp: Date.now() }); + const startedAt = performance.now(); + commandsModule.invalidateCache(); + await commandsModule.refreshCommandsNow(); + legacyTimes.push(performance.now() - startedAt); + } + + commandsModule.__resetCommandCacheForTesting(); + let targetedStructuralDiscoveryRuns = 0; + let targetedExtensionDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + targetedStructuralDiscoveryRuns += 1; + await delay(simulatedStructuralDiscoveryMs); + return makeBaseCommands(baseCommandCount); + }); + commandsModule.__setExtensionCommandInfoRunnerForTesting(() => { + targetedExtensionDiscoveryRuns += 1; + return makeExtensionCommands(3); + }); + + const targetedTimes = []; + for (let index = 0; index < iterations; index += 1) { + commandsModule.__seedCommandCacheForTesting(makeBaseCommands(baseCommandCount), { cacheTimestamp: Date.now() }); + const startedAt = performance.now(); + await commandsModule.refreshCommandsForExtensionChange(); + targetedTimes.push(performance.now() - startedAt); + } + + const report = { + iterations, + baseCommandCount: makeBaseCommands(baseCommandCount).length, + simulatedStructuralDiscoveryMs, + legacy: { + structuralDiscoveryStarts: legacyStructuralDiscoveryRuns, + refreshMs: stats(legacyTimes), + }, + targeted: { + structuralDiscoveryStarts: targetedStructuralDiscoveryRuns, + extensionDiscoveryStarts: targetedExtensionDiscoveryRuns, + refreshMs: stats(targetedTimes), + }, + avoidedStructuralDiscoveryStarts: legacyStructuralDiscoveryRuns - targetedStructuralDiscoveryRuns, + }; + + console.log('Extension command refresh benchmark', JSON.stringify(report)); + assert.equal(legacyStructuralDiscoveryRuns, iterations); + assert.equal(targetedStructuralDiscoveryRuns, 0); + assert.equal(targetedExtensionDiscoveryRuns, iterations); + assert.equal(report.avoidedStructuralDiscoveryStarts, iterations); +}); diff --git a/src/main/commands.ts b/src/main/commands.ts index 4a89f301..8f34ed81 100644 --- a/src/main/commands.ts +++ b/src/main/commands.ts @@ -18,7 +18,7 @@ import * as path from 'path'; import * as crypto from 'crypto'; import { discoverInstalledExtensionCommands } from './extension-runner'; import { discoverScriptCommands } from './script-command-runner'; -import { getAllQuickLinks, getQuickLinkCommandId, type QuickLink, type QuickLinkIcon } from './quicklink-store'; +import { getAllQuickLinks, getQuickLinkCommandId, isQuickLinkCommandId, type QuickLink, type QuickLinkIcon } from './quicklink-store'; import { loadSettings,getSearchApplicationsScope} from './settings-store'; const execAsync = promisify(exec); @@ -112,6 +112,9 @@ let inflightDiscovery: Promise | null = null; let lastStaleRefreshRequestAt = 0; const CACHE_TTL = 30 * 60_000; // 30 min const STALE_REFRESH_COOLDOWN_MS = 15_000; +let commandDiscoveryStartCount = 0; +let commandDiscoveryRunnerForTesting: (() => Promise) | null = null; +let extensionCommandInfoRunnerForTesting: (() => CommandInfo[]) | null = null; // ─── Commands Disk Cache ───────────────────────────────────────────────────── // Persists the discovered commands list across restarts so the launcher is @@ -178,6 +181,43 @@ export function getInflightDiscovery(): Promise | null { return inflightDiscovery; } +export function __resetCommandCacheForTesting(): void { + cachedCommands = null; + staleCommandsFallback = null; + cacheTimestamp = 0; + inflightDiscovery = null; + lastStaleRefreshRequestAt = 0; + commandDiscoveryStartCount = 0; + commandDiscoveryRunnerForTesting = null; + extensionCommandInfoRunnerForTesting = null; + commandsDiskCachePath = null; +} + +export function __seedCommandCacheForTesting( + commands: CommandInfo[], + options?: { cacheTimestamp?: number } +): void { + cachedCommands = commands; + staleCommandsFallback = commands; + cacheTimestamp = options?.cacheTimestamp ?? Date.now(); +} + +export function __setCommandDiscoveryRunnerForTesting( + runner: (() => Promise) | null +): void { + commandDiscoveryRunnerForTesting = runner; +} + +export function __setExtensionCommandInfoRunnerForTesting( + runner: (() => CommandInfo[]) | null +): void { + extensionCommandInfoRunnerForTesting = runner; +} + +export function __getCommandDiscoveryStartCountForTesting(): number { + return commandDiscoveryStartCount; +} + // ─── Icon Disk Cache ──────────────────────────────────────────────── let iconCacheDir: string | null = null; @@ -657,6 +697,98 @@ function buildQuickLinkKeywords(quickLink: QuickLink): string[] { return Array.from(set); } +function discoverExtensionCommandInfos(): CommandInfo[] { + if (extensionCommandInfoRunnerForTesting) { + return extensionCommandInfoRunnerForTesting(); + } + + try { + return discoverInstalledExtensionCommands().map((ext) => ({ + id: ext.id, + title: ext.title, + subtitle: ext.extensionTitle, + keywords: ext.keywords, + iconDataUrl: ext.iconDataUrl, + category: 'extension' as const, + path: `${ext.extName}/${ext.cmdName}`, + mode: ext.mode, + interval: ext.interval, + disabledByDefault: ext.disabledByDefault, + commandArgumentDefinitions: ext.commandArgumentDefinitions || [], + deeplink: ext.owner + ? `supercmd://extensions/${encodeURIComponent(ext.owner)}/${encodeURIComponent(ext.extName)}/${encodeURIComponent(ext.cmdName)}` + : `supercmd://extensions/${encodeURIComponent(ext.extName)}/${encodeURIComponent(ext.cmdName)}`, + })); + } catch (e) { + console.error('Failed to discover installed extensions:', e); + return []; + } +} + +function discoverScriptCommandInfos(): CommandInfo[] { + try { + return discoverScriptCommands().map((script) => ({ + id: script.id, + title: script.title, + subtitle: script.packageName, + keywords: script.keywords, + iconDataUrl: script.iconDataUrl, + iconEmoji: script.iconEmoji, + category: 'script' as const, + path: script.scriptPath, + mode: script.mode, + interval: script.interval, + needsConfirmation: script.needsConfirmation, + commandArgumentDefinitions: script.arguments.map((arg) => ({ + name: arg.name, + required: arg.required, + type: arg.type, + placeholder: arg.placeholder, + title: arg.placeholder, + data: arg.data, + })), + deeplink: script.slug + ? `supercmd://script-commands/${encodeURIComponent(script.slug)}` + : undefined, + })); + } catch (e) { + console.error('Failed to discover script commands:', e); + return []; + } +} + +async function discoverQuickLinkCommandInfos(): Promise { + try { + const quickLinks = getAllQuickLinks(); + return await Promise.all( + quickLinks.map(async (quickLink) => { + const resolvedIconName = resolveQuickLinkIconName(quickLink.icon); + let iconDataUrl = resolveQuickLinkIconDataUrl(quickLink, resolvedIconName); + + if (!resolvedIconName && quickLink.applicationPath) { + const resolvedAppIconDataUrl = await getIconDataUrl(quickLink.applicationPath); + if (resolvedAppIconDataUrl) { + iconDataUrl = resolvedAppIconDataUrl; + } + } + + return { + id: getQuickLinkCommandId(quickLink.id), + title: quickLink.name, + subtitle: quickLink.applicationName || 'Quick Link', + keywords: buildQuickLinkKeywords(quickLink), + iconDataUrl, + iconName: iconDataUrl ? undefined : resolvedIconName, + category: 'system' as const, + }; + }) + ); + } catch (e) { + console.error('Failed to discover quick links:', e); + return []; + } +} + function getLocaleCandidates(): string[] { const set = new Set(); const preferredAppLanguage = loadSettings().appLanguage; @@ -1270,7 +1402,106 @@ async function openSettingsPane(identifier: string): Promise { // ─── Public API ───────────────────────────────────────────────────── +function assignUniversalDeeplinks(commands: CommandInfo[]): void { + for (const cmd of commands) { + if (!cmd.deeplink && cmd.id) { + cmd.deeplink = `supercmd://commands/${encodeURIComponent(cmd.id)}`; + } + } +} + +function applyRuntimeMetadataAndAliases(commands: CommandInfo[]): void { + try { + const loadedSettings = loadSettings(); + const commandMetadata = loadedSettings.commandMetadata || {}; + const commandAliases = loadedSettings.commandAliases || {}; + for (const cmd of commands) { + if (!(cmd.category === 'script' && cmd.mode !== 'inline') && commandMetadata[cmd.id]?.subtitle !== undefined) { + const subtitle = String(commandMetadata[cmd.id]?.subtitle || '').trim(); + if (subtitle) { + cmd.subtitle = subtitle; + } + } + const alias = String(commandAliases[cmd.id] || '').trim(); + if (alias) { + cmd.keywords = Array.from(new Set([...(cmd.keywords || []), alias])); + } + } + } catch {} +} + +function publishCommandCache(commands: CommandInfo[]): CommandInfo[] { + assignUniversalDeeplinks(commands); + applyRuntimeMetadataAndAliases(commands); + + cachedCommands = commands; + cacheTimestamp = Date.now(); + staleCommandsFallback = commands; + saveCommandsDiskCache(commands); + + return cachedCommands; +} + +function cloneCommandForTargetedRefresh(command: CommandInfo): CommandInfo { + return { + ...command, + keywords: command.keywords ? [...command.keywords] : undefined, + commandArgumentDefinitions: command.commandArgumentDefinitions + ? command.commandArgumentDefinitions.map((arg) => ({ + ...arg, + data: arg.data ? arg.data.map((item) => ({ ...item })) : arg.data, + })) + : undefined, + }; +} + +function getExtensionInsertionIndex(commands: CommandInfo[]): number { + const existingExtensionIndex = commands.findIndex((command) => command.category === 'extension'); + if (existingExtensionIndex >= 0) return existingExtensionIndex; + + const scriptIndex = commands.findIndex((command) => command.category === 'script'); + if (scriptIndex >= 0) return scriptIndex; + + const quickLinkIndex = commands.findIndex((command) => isQuickLinkCommandId(command.id)); + if (quickLinkIndex >= 0) return quickLinkIndex; + + const systemIndex = commands.findIndex((command) => command.category === 'system'); + if (systemIndex >= 0) return systemIndex; + + return commands.length; +} + +function rebuildCommandsWithFreshExtensions( + baseCommands: CommandInfo[], + extensionCommands: CommandInfo[] +): CommandInfo[] { + const insertionIndex = getExtensionInsertionIndex(baseCommands); + const beforeExtensions: CommandInfo[] = []; + const afterExtensions: CommandInfo[] = []; + + baseCommands.forEach((command, index) => { + if (command.category === 'extension') return; + const cloned = cloneCommandForTargetedRefresh(command); + if (index < insertionIndex) { + beforeExtensions.push(cloned); + } else { + afterExtensions.push(cloned); + } + }); + + return [ + ...beforeExtensions, + ...extensionCommands.map(cloneCommandForTargetedRefresh), + ...afterExtensions, + ]; +} + async function discoverAndBuildCommands(): Promise { + commandDiscoveryStartCount++; + if (commandDiscoveryRunnerForTesting) { + return publishCommandCache(await commandDiscoveryRunnerForTesting()); + } + const t0 = Date.now(); console.log('Discovering applications and settings…'); @@ -1847,90 +2078,12 @@ async function discoverAndBuildCommands(): Promise { ]; // Installed community extensions - let extensionCommands: CommandInfo[] = []; - try { - extensionCommands = discoverInstalledExtensionCommands().map((ext) => ({ - id: ext.id, - title: ext.title, - subtitle: ext.extensionTitle, - keywords: ext.keywords, - iconDataUrl: ext.iconDataUrl, - category: 'extension' as const, - path: `${ext.extName}/${ext.cmdName}`, - mode: ext.mode, - interval: ext.interval, - disabledByDefault: ext.disabledByDefault, - commandArgumentDefinitions: ext.commandArgumentDefinitions || [], - deeplink: ext.owner - ? `supercmd://extensions/${encodeURIComponent(ext.owner)}/${encodeURIComponent(ext.extName)}/${encodeURIComponent(ext.cmdName)}` - : `supercmd://extensions/${encodeURIComponent(ext.extName)}/${encodeURIComponent(ext.cmdName)}`, - })); - } catch (e) { - console.error('Failed to discover installed extensions:', e); - } + const extensionCommands = discoverExtensionCommandInfos(); // Raycast-compatible script commands - let scriptCommands: CommandInfo[] = []; - try { - scriptCommands = discoverScriptCommands().map((script) => ({ - id: script.id, - title: script.title, - subtitle: script.packageName, - keywords: script.keywords, - iconDataUrl: script.iconDataUrl, - iconEmoji: script.iconEmoji, - category: 'script' as const, - path: script.scriptPath, - mode: script.mode, - interval: script.interval, - needsConfirmation: script.needsConfirmation, - commandArgumentDefinitions: script.arguments.map((arg) => ({ - name: arg.name, - required: arg.required, - type: arg.type, - placeholder: arg.placeholder, - title: arg.placeholder, - data: arg.data, - })), - deeplink: script.slug - ? `supercmd://script-commands/${encodeURIComponent(script.slug)}` - : undefined, - })); - } catch (e) { - console.error('Failed to discover script commands:', e); - } - - let quickLinkCommands: CommandInfo[] = []; - try { - const quickLinks = getAllQuickLinks(); - quickLinkCommands = await Promise.all( - quickLinks.map(async (quickLink) => { - const resolvedIconName = resolveQuickLinkIconName(quickLink.icon); - let iconDataUrl = resolveQuickLinkIconDataUrl(quickLink, resolvedIconName); - - // Prefer real app icon for default quick-link icons so launcher search - // reflects the target application even when stored icon data is stale. - if (!resolvedIconName && quickLink.applicationPath) { - const resolvedAppIconDataUrl = await getIconDataUrl(quickLink.applicationPath); - if (resolvedAppIconDataUrl) { - iconDataUrl = resolvedAppIconDataUrl; - } - } + const scriptCommands = discoverScriptCommandInfos(); - return { - id: getQuickLinkCommandId(quickLink.id), - title: quickLink.name, - subtitle: quickLink.applicationName || 'Quick Link', - keywords: buildQuickLinkKeywords(quickLink), - iconDataUrl, - iconName: iconDataUrl ? undefined : resolvedIconName, - category: 'system' as const, - }; - }) - ); - } catch (e) { - console.error('Failed to discover quick links:', e); - } + const quickLinkCommands = await discoverQuickLinkCommandInfos(); const allCommands = [...apps, ...settings, ...extensionCommands, ...scriptCommands, ...quickLinkCommands, ...systemCommands]; @@ -1974,47 +2127,13 @@ async function discoverAndBuildCommands(): Promise { delete cmd._bundlePath; } - // Assign a universal deeplink to any launcher command that doesn't already - // have one (extensions + scripts keep their owner/slug-based schemes above). - // This lets apps, settings, system, and quick-link commands be copied and - // re-invoked via `supercmd://commands/`. - for (const cmd of allCommands) { - if (!cmd.deeplink && cmd.id) { - cmd.deeplink = `supercmd://commands/${encodeURIComponent(cmd.id)}`; - } - } - - // Runtime metadata overlays (used by updateCommandMetadata and inline scripts). - try { - const loadedSettings = loadSettings(); - const commandMetadata = loadedSettings.commandMetadata || {}; - const commandAliases = loadedSettings.commandAliases || {}; - for (const cmd of allCommands) { - if (!(cmd.category === 'script' && cmd.mode !== 'inline')) { - const subtitle = String(commandMetadata[cmd.id]?.subtitle || '').trim(); - if (subtitle) { - cmd.subtitle = subtitle; - } - } - const alias = String(commandAliases[cmd.id] || '').trim(); - if (alias) { - cmd.keywords = Array.from(new Set([...(cmd.keywords || []), alias])); - } - } - } catch {} - - cachedCommands = allCommands; - cacheTimestamp = Date.now(); - staleCommandsFallback = allCommands; + publishCommandCache(allCommands); console.log( `Discovered ${apps.length} apps, ${settings.length} settings panes, ${extensionCommands.length} extension commands, ${scriptCommands.length} script commands, ${quickLinkCommands.length} quick links in ${Date.now() - t0}ms` ); - // Persist to disk so the next startup can serve commands instantly. - saveCommandsDiskCache(allCommands); - - return cachedCommands; + return allCommands; } function ensureBackgroundRefreshForStaleCache(): void { @@ -2044,6 +2163,34 @@ export async function refreshCommandsNow(): Promise { return inflightDiscovery; } +export async function refreshCommandsForExtensionChange(): Promise { + if (!cachedCommands && !staleCommandsFallback) { + return refreshCommandsNow(); + } + + if (inflightDiscovery) { + try { + await inflightDiscovery; + } catch (error) { + console.warn('[Commands] Inflight refresh failed before extension refresh:', error); + } + } + + const baseCommands = cachedCommands || staleCommandsFallback; + if (!baseCommands) { + return refreshCommandsNow(); + } + + const t0 = Date.now(); + const extensionCommands = discoverExtensionCommandInfos(); + const nextCommands = rebuildCommandsWithFreshExtensions(baseCommands, extensionCommands); + const refreshed = publishCommandCache(nextCommands); + console.log( + `[Commands] Refreshed ${extensionCommands.length} extension commands from cached app/settings base in ${Date.now() - t0}ms` + ); + return refreshed; +} + export async function getAvailableCommands(): Promise { const now = Date.now(); if (cachedCommands && now - cacheTimestamp < CACHE_TTL) { diff --git a/src/main/main.ts b/src/main/main.ts index cf510437..0d419e57 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -19,7 +19,7 @@ import * as fs from 'fs'; import * as os from 'os'; import { fork, execFileSync, type ChildProcess } from 'child_process'; import { getNativeBinaryPath, resolvePackagedUnpackedPath } from './native-binary'; -import { getAvailableCommands, executeCommand, invalidateCache, initCommandsCache, getInflightDiscovery, refreshCommandsNow } from './commands'; +import { getAvailableCommands, executeCommand, invalidateCache, initCommandsCache, getInflightDiscovery, refreshCommandsNow, refreshCommandsForExtensionChange } from './commands'; import { loadSettings, saveSettings, @@ -16429,12 +16429,10 @@ return appURL's |path|() as text`, if (!success) { throw new Error(`Failed to install extension "${name}". Check SuperCmd main-process logs for details.`); } - // Invalidate the command cache and rebuild it BEFORE we broadcast, so + // Refresh extension commands BEFORE we broadcast, so // the renderer's follow-up get-commands fetch lands on fresh data - // rather than the stale fallback that getAvailableCommands() returns - // immediately after an invalidation. - invalidateCache(); - try { await refreshCommandsNow(); } catch (e) { console.warn('refreshCommandsNow after install failed:', e); } + // without rediscovering unrelated apps/settings when a cache exists. + try { await refreshCommandsForExtensionChange(); } catch (e) { console.warn('refreshCommandsForExtensionChange after install failed:', e); } broadcastExtensionsUpdated(); // The launcher's root list listens for 'commands-updated', not // 'extensions-updated' — without this, the new extension wouldn't @@ -16452,10 +16450,9 @@ return appURL's |path|() as text`, async (_event: any, name: string) => { const success = await uninstallExtension(name); if (success) { - // Invalidate the command cache and rebuild synchronously before + // Refresh extension commands synchronously before // broadcasting — see install-extension handler for context. - invalidateCache(); - try { await refreshCommandsNow(); } catch (e) { console.warn('refreshCommandsNow after uninstall failed:', e); } + try { await refreshCommandsForExtensionChange(); } catch (e) { console.warn('refreshCommandsForExtensionChange after uninstall failed:', e); } // Tell the launcher renderer to tear down any live runners (menu-bar // tray, background no-view loop, interval re-runner) for this // extension before its bundle keeps trying to re-mount itself. From 977dd98ae3ba5394faedd29d13463c210c60161e Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:28:07 +0200 Subject: [PATCH 3/8] fix: clean up extension spawn lifecycle (cherry picked from commit 23ddb55106f837faf6bcbc8d0705ea696ebe5f75) --- src/main/main.ts | 80 +++++++++++++++++++------- src/renderer/src/ExtensionView.tsx | 90 +++++++++++++++++++++++++++--- 2 files changed, 143 insertions(+), 27 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 0d419e57..8a118a0d 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -15476,7 +15476,58 @@ app.whenReady().then(async () => { // This is the generic fix for any extension that uses child_process.spawn with progressive output // (e.g. speedtest CLI outputting JSON lines, ffmpeg progress, etc.) { - const spawnedProcesses = new Map(); + const spawnedProcesses = new Map(); + const spawnedProcessPidsBySender = new WeakMap>(); + const senderCleanupRegistered = new WeakSet(); + + const forgetSpawnedProcess = (pid: number) => { + const entry = spawnedProcesses.get(pid); + if (!entry) return; + spawnedProcesses.delete(pid); + const senderPids = spawnedProcessPidsBySender.get(entry.sender); + senderPids?.delete(pid); + }; + + const terminateSpawnedProcess = (pid: number, signal?: string | number) => { + const entry = spawnedProcesses.get(pid); + if (!entry) return; + const proc = entry.proc; + const killSignal = signal ?? 'SIGTERM'; + forgetSpawnedProcess(pid); + try { + if (process.platform !== 'win32' && typeof proc.pid === 'number' && proc.pid > 0) { + process.kill(-proc.pid, killSignal as NodeJS.Signals | number); + } else { + proc.kill(killSignal); + } + } catch { + try { proc.kill(killSignal); } catch {} + } + }; + + const cleanupSenderSpawnedProcesses = (sender: any) => { + const senderPids = spawnedProcessPidsBySender.get(sender); + if (!senderPids) return; + for (const pid of Array.from(senderPids)) { + terminateSpawnedProcess(pid, 'SIGTERM'); + } + spawnedProcessPidsBySender.delete(sender); + }; + + const trackSenderSpawnedProcess = (sender: any, pid: number) => { + if (pid === -1 || !sender) return; + let senderPids = spawnedProcessPidsBySender.get(sender); + if (!senderPids) { + senderPids = new Set(); + spawnedProcessPidsBySender.set(sender, senderPids); + } + senderPids.add(pid); + if (!senderCleanupRegistered.has(sender)) { + senderCleanupRegistered.add(sender); + try { sender.once?.('destroyed', () => cleanupSenderSpawnedProcesses(sender)); } catch {} + try { sender.on?.('render-process-gone', () => cleanupSenderSpawnedProcesses(sender)); } catch {} + } + }; ipcMain.handle( 'spawn-process', @@ -15515,9 +15566,11 @@ app.whenReady().then(async () => { : spawn(resolvedFile, args || [], spawnOpts); const pid: number = proc.pid ?? -1; - if (pid !== -1) spawnedProcesses.set(pid, proc); - const sender = event.sender; + if (pid !== -1) { + spawnedProcesses.set(pid, { proc, sender }); + trackSenderSpawnedProcess(sender, pid); + } const safeSend = (channel: string, ...sendArgs: any[]) => { try { if (!sender.isDestroyed()) sender.send(channel, ...sendArgs); } catch {} }; @@ -15549,7 +15602,7 @@ app.whenReady().then(async () => { }); proc.on('close', (code: number | null) => { if (!finalize()) return; - spawnedProcesses.delete(pid); + forgetSpawnedProcess(pid); const exitCode = code ?? 0; const seq = nextSeq(); safeSendSpawnEvent({ pid, seq, type: 'exit', code: exitCode }); @@ -15558,7 +15611,7 @@ app.whenReady().then(async () => { }); proc.on('error', (err: Error) => { if (!finalize()) return; - spawnedProcesses.delete(pid); + forgetSpawnedProcess(pid); const message = err.message; const seq = nextSeq(); safeSendSpawnEvent({ pid, seq, type: 'error', message }); @@ -15571,7 +15624,7 @@ app.whenReady().then(async () => { ); ipcMain.on('spawn-stdin', (_event: any, pid: number, data: Uint8Array | string, end?: boolean) => { - const proc = spawnedProcesses.get(pid); + const proc = spawnedProcesses.get(pid)?.proc; if (!proc?.stdin) return; try { if (data != null && (typeof data === 'string' ? data.length > 0 : data.byteLength > 0)) { @@ -15582,20 +15635,7 @@ app.whenReady().then(async () => { }); ipcMain.handle('spawn-kill', (_event: any, pid: number, signal?: string | number) => { - const proc = spawnedProcesses.get(pid); - if (proc) { - const killSignal = signal ?? 'SIGTERM'; - try { - if (process.platform !== 'win32' && typeof proc.pid === 'number' && proc.pid > 0) { - process.kill(-proc.pid, killSignal as NodeJS.Signals | number); - } else { - proc.kill(killSignal); - } - } catch { - try { proc.kill(killSignal); } catch {} - } - spawnedProcesses.delete(pid); - } + terminateSpawnedProcess(pid, signal); }); } diff --git a/src/renderer/src/ExtensionView.tsx b/src/renderer/src/ExtensionView.tsx index 85643d80..13100d12 100644 --- a/src/renderer/src/ExtensionView.tsx +++ b/src/renderer/src/ExtensionView.tsx @@ -1798,7 +1798,8 @@ const childProcessStub = { return childProcessStub._isGitInvocation(commandOrFile, execArgs) && childProcessStub._isBenignMissingPathError(message); }, - exec: (...args: any[]) => { + exec: (...args: any[]) => childProcessStub._execWithRegistry(undefined, ...args), + _execWithRegistry: (registry: TimerRegistry | undefined, ...args: any[]) => { // Parse arguments: exec(command[, options][, callback]) const command = args[0]; let options: any = {}; @@ -1813,7 +1814,7 @@ const childProcessStub = { const { file, args: execArgs } = resolveExecShellLaunch(normalizedCommand, options?.shell); let stdout = ''; let stderr = ''; - const spawned = childProcessStub.spawn(file, execArgs, { + const spawned = childProcessStub._spawnWithRegistry(registry, file, execArgs, { shell: false, env: options?.env, cwd: options?.cwd, @@ -1892,7 +1893,8 @@ const childProcessStub = { } return BufferPolyfill.from(result?.stdout || ''); }, - execFile: (...args: any[]) => { + execFile: (...args: any[]) => childProcessStub._execFileWithRegistry(undefined, ...args), + _execFileWithRegistry: (registry: TimerRegistry | undefined, ...args: any[]) => { // Parse arguments: execFile(file[, args][, options][, callback]) const file = resolveExecutablePath(args[0]); let execArgs: string[] = []; @@ -1915,7 +1917,7 @@ const childProcessStub = { if ((window as any).electron?.spawnProcess) { let stdout = ''; let stderr = ''; - const spawned = childProcessStub.spawn(file, execArgs, { shell: false, env: options?.env, cwd: options?.cwd }); + const spawned = childProcessStub._spawnWithRegistry(registry, file, execArgs, { shell: false, env: options?.env, cwd: options?.cwd }); cp.stdin = spawned.stdin; cp.stdout = spawned.stdout; cp.stderr = spawned.stderr; @@ -2003,7 +2005,8 @@ const childProcessStub = { if (options?.encoding) return result.stdout || ''; return BufferPolyfill.from(result.stdout || ''); }, - spawn: (...args: any[]) => { + spawn: (...args: any[]) => childProcessStub._spawnWithRegistry(undefined, ...args), + _spawnWithRegistry: (registry: TimerRegistry | undefined, ...args: any[]) => { const file = resolveExecutablePath(args[0]); const spawnArgs = Array.isArray(args[1]) ? args[1] : []; const options = (typeof args[2] === 'object' && args[2]) ? args[2] : {}; @@ -2014,7 +2017,18 @@ const childProcessStub = { // This is the generic solution for all extensions using child_process.spawn with progressive output. let pid: number | null = null; const cleanups: Array<() => void> = []; - const cleanup = () => { cleanups.forEach(fn => fn()); cleanups.length = 0; }; + let untrackLifecycleChildProcess: (() => void) | null = null; + let killWhenPidArrives = false; + let disposedByLifecycle = false; + const cleanup = () => { + if (untrackLifecycleChildProcess) { + const untrack = untrackLifecycleChildProcess; + untrackLifecycleChildProcess = null; + untrack(); + } + cleanups.forEach(fn => fn()); + cleanups.length = 0; + }; let didHandleTerminalEvent = false; type PendingSpawnEvent = | { kind: 'stdout'; p: number; data: any; seq?: number } @@ -2061,6 +2075,7 @@ const childProcessStub = { cp.emit('exit', 1, null); }; const queueOrProcess = (event: PendingSpawnEvent) => { + if (disposedByLifecycle) return; if (pid === null) { pendingEvents.push(event); return; @@ -2114,8 +2129,19 @@ const childProcessStub = { cp.kill = (signal?: string | number) => { cp.killed = true; if (pid !== null) electron.killSpawnProcess?.(pid, signal); + else killWhenPidArrives = true; return pid !== null; }; + if (registry) { + untrackLifecycleChildProcess = trackChildProcess(registry, { + cleanup, + kill: () => { + disposedByLifecycle = true; + cp.kill('SIGTERM'); + }, + getPid: () => pid, + }); + } // Wire up stdin forwarding to the main process const stdinQueue: Array<{ data?: any; end?: boolean }> = []; @@ -2156,10 +2182,15 @@ const childProcessStub = { }).then((result: { pid: number }) => { pid = result.pid; cp.pid = pid; + if (killWhenPidArrives) { + cp.kill('SIGTERM'); + return; + } flushStdinQueue(); flushPendingEvents(); }).catch((err: any) => { cleanup(); + if (disposedByLifecycle) return; const message = String(err?.message || err || 'spawn failed'); cp.stderr.emit('data', BufferPolyfill.from(message)); cp.emit('error', new Error(message)); @@ -2236,6 +2267,16 @@ const childProcessStub = { fork: () => createStubChildProcess(), }; +function createExtensionChildProcessStub(registry: TimerRegistry | undefined): any { + if (!registry) return childProcessStub; + return { + ...childProcessStub, + exec: (...args: any[]) => childProcessStub._execWithRegistry(registry, ...args), + execFile: (...args: any[]) => childProcessStub._execFileWithRegistry(registry, ...args), + spawn: (...args: any[]) => childProcessStub._spawnWithRegistry(registry, ...args), + }; +} + // ── timers stubs ──────────────────────────────────────────────── const timersStub = { setTimeout: globalThis.setTimeout.bind(globalThis), @@ -3362,6 +3403,12 @@ function ensureGlobals() { } } +interface TrackedChildProcess { + cleanup: () => void; + kill: () => void; + getPid?: () => number | null; +} + /** * Per-ExtensionView registry of timer handles created by the extension's * sandboxed setInterval/setTimeout/requestAnimationFrame. Cleared on unmount @@ -3372,19 +3419,40 @@ export interface TimerRegistry { intervals: Set; timeouts: Set; rafs: Set; + childProcesses: Set; } export function createTimerRegistry(): TimerRegistry { - return { intervals: new Set(), timeouts: new Set(), rafs: new Set() }; + return { intervals: new Set(), timeouts: new Set(), rafs: new Set(), childProcesses: new Set() }; } export function clearTimerRegistry(registry: TimerRegistry): void { + Array.from(registry.childProcesses).forEach((entry) => { + try { + entry.cleanup(); + } catch {} + try { + entry.kill(); + } catch {} + }); registry.intervals.forEach((id) => window.clearInterval(id)); registry.timeouts.forEach((id) => window.clearTimeout(id)); registry.rafs.forEach((id) => window.cancelAnimationFrame(id)); registry.intervals.clear(); registry.timeouts.clear(); registry.rafs.clear(); + registry.childProcesses.clear(); +} + +export function trackChildProcess( + registry: TimerRegistry | undefined, + childProcess: TrackedChildProcess +): () => void { + if (!registry) return () => {}; + registry.childProcesses.add(childProcess); + return () => { + registry.childProcesses.delete(childProcess); + }; } /** @@ -3747,6 +3815,14 @@ function loadExtensionExport( // Prefer real Node (via the preload bridge) when the hosting window // has Node enabled. Falls back to the stub if the module isn't a // recognised built-in, or if real require throws. + if (shouldUseSuperCmdBuiltinFacade(name)) { + const normalizedBuiltinName = name.startsWith('node:') ? name.slice(5) : name; + if (normalizedBuiltinName === 'child_process') { + return createExtensionChildProcessStub(timerRegistry); + } + const facade = nodeBuiltinStubs[name] || nodeBuiltinStubs[`node:${name}`]; + if (facade) return facade; + } const realModule = tryRealNodeRequire(name); if (realModule !== undefined) { return realModule; From ce93d8d608b6256916ab8f4630f60f4a500e32cd Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:29:19 +0200 Subject: [PATCH 4/8] Fix extension fetch proxy cancellation (cherry picked from commit 59166b4e305008eae71b77ef618bd48e61154d17) --- scripts/test-extension-fetch-proxy-abort.mjs | 154 +++++++++++++++ scripts/test-http-response-decode-async.mjs | 46 +++++ src/main/http-response-decode.ts | 38 ++++ src/main/main.ts | 121 +++++++++--- src/renderer/src/ExtensionView.tsx | 116 +---------- src/renderer/src/extension-fetch-bridge.ts | 194 +++++++++++++++++++ 6 files changed, 524 insertions(+), 145 deletions(-) create mode 100644 scripts/test-extension-fetch-proxy-abort.mjs create mode 100644 scripts/test-http-response-decode-async.mjs create mode 100644 src/main/http-response-decode.ts create mode 100644 src/renderer/src/extension-fetch-bridge.ts diff --git a/scripts/test-extension-fetch-proxy-abort.mjs b/scripts/test-extension-fetch-proxy-abort.mjs new file mode 100644 index 00000000..52ec9a10 --- /dev/null +++ b/scripts/test-extension-fetch-proxy-abort.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { importTs } from './lib/ts-import.mjs'; + +const { installExtensionFetchBridge } = await importTs(path.resolve('src/renderer/src/extension-fetch-bridge.ts')); + +function deferred() { + let resolve; + let reject; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return { promise, resolve, reject }; +} + +function createTrackedAbortSignal() { + const listeners = new Set(); + return { + signal: { + aborted: false, + addCount: 0, + removeCount: 0, + addEventListener(type, listener) { + if (type !== 'abort') return; + this.addCount += 1; + listeners.add(listener); + }, + removeEventListener(type, listener) { + if (type !== 'abort') return; + this.removeCount += 1; + listeners.delete(listener); + }, + }, + listenerCount: () => listeners.size, + abort() { + this.signal.aborted = true; + for (const listener of [...listeners]) listener(); + }, + }; +} + +function createBridgeHarness() { + const pending = []; + const requests = []; + const canceled = []; + const g = { + crypto: { randomUUID: () => `uuid-${requests.length + 1}` }, + fetch: async () => new Response('native'), + }; + const electron = { + httpRequest: (request) => { + requests.push(request); + const run = deferred(); + pending.push(run); + return run.promise; + }, + cancelHttpRequest: (requestId) => { + canceled.push(requestId); + }, + }; + installExtensionFetchBridge(g, () => electron); + return { canceled, g, pending, requests }; +} + +async function flushAsync() { + await Promise.resolve(); + await Promise.resolve(); +} + +test('extension fetch abort sends IPC cancel and removes its abort listener on completion', async () => { + const { canceled, g, pending, requests } = createBridgeHarness(); + const abort = createTrackedAbortSignal(); + + const fetchPromise = g.fetch('https://api.test/slow', { signal: abort.signal }); + await flushAsync(); + + assert.equal(requests.length, 1); + assert.match(requests[0].requestId, /^extensionFetch:/); + assert.equal(abort.listenerCount(), 1); + assert.equal(abort.signal.addCount, 1); + + abort.abort(); + abort.abort(); + assert.deepEqual(canceled, [requests[0].requestId]); + + pending[0].resolve({ + status: 0, + statusText: 'Request canceled', + headers: {}, + bodyText: '', + url: 'https://api.test/slow', + }); + + await assert.rejects(fetchPromise, { name: 'AbortError' }); + assert.equal(abort.listenerCount(), 0); + assert.equal(abort.signal.removeCount, 1); +}); + +test('extension fetch success path clears abort listener and does not retain request IDs', async () => { + const { canceled, g, pending, requests } = createBridgeHarness(); + const abort = createTrackedAbortSignal(); + + const fetchPromise = g.fetch('https://api.test/items', { signal: abort.signal }); + await flushAsync(); + assert.equal(requests.length, 1); + + pending[0].resolve({ + status: 200, + statusText: 'OK', + headers: { 'content-type': 'text/plain' }, + bodyText: 'done', + url: 'https://api.test/items', + }); + + const response = await fetchPromise; + assert.equal(await response.text(), 'done'); + assert.equal(response.url, 'https://api.test/items'); + assert.deepEqual(canceled, []); + assert.equal(abort.listenerCount(), 0); + assert.equal(abort.signal.removeCount, 1); +}); + +test('extension fetch preserves fallback behavior for non-http and unsupported bodies', async () => { + const nativeCalls = []; + const requests = []; + const g = { + fetch: async (input) => { + nativeCalls.push(String(input)); + return new Response('native'); + }, + }; + const electron = { + httpRequest: (request) => { + requests.push(request); + return Promise.resolve({ + status: 200, + statusText: 'OK', + headers: {}, + bodyText: 'proxied', + url: request.url, + }); + }, + }; + installExtensionFetchBridge(g, () => electron); + + assert.equal(await (await g.fetch('data:text/plain,hello')).text(), 'native'); + assert.equal(await (await g.fetch('https://api.test/upload', { method: 'POST', body: new FormData() })).text(), 'native'); + assert.deepEqual(nativeCalls, ['data:text/plain,hello', 'https://api.test/upload']); + assert.deepEqual(requests, []); +}); diff --git a/scripts/test-http-response-decode-async.mjs b/scripts/test-http-response-decode-async.mjs new file mode 100644 index 00000000..d75bb2e8 --- /dev/null +++ b/scripts/test-http-response-decode-async.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import zlib from 'node:zlib'; +import { importTs } from './lib/ts-import.mjs'; + +const decodeSourcePath = path.resolve('src/main/http-response-decode.ts'); +const { decodeHttpResponseBodyBuffer } = await importTs(decodeSourcePath, { + browserGlobals: false, +}); + +function nextTimer() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +test('HTTP response decompression uses async zlib APIs and preserves decoded text', async () => { + const source = fs.readFileSync(decodeSourcePath, 'utf8'); + assert.equal(/(?:brotliDecompressSync|gunzipSync|inflateSync)/.test(source), false); + + const fixtureText = 'supercmd-compressed-fixture\n'.repeat(350_000); + const fixture = Buffer.from(fixtureText, 'utf8'); + const gzipFixture = zlib.gzipSync(fixture); + + const syncTimerStart = performance.now(); + const syncTimer = nextTimer().then(() => performance.now() - syncTimerStart); + zlib.gunzipSync(gzipFixture); + const syncTimerDelayMs = await syncTimer; + + const asyncTimerStart = performance.now(); + const asyncTimer = nextTimer().then(() => performance.now() - asyncTimerStart); + const decodedPromise = decodeHttpResponseBodyBuffer(gzipFixture, 'gzip'); + const asyncTimerDelayMs = await asyncTimer; + const decoded = await decodedPromise; + + assert.equal(decoded.toString('utf8'), fixtureText); + console.log(JSON.stringify({ + fixtureBytes: fixture.length, + gzipBytes: gzipFixture.length, + syncTimerDelayMs: Number(syncTimerDelayMs.toFixed(3)), + asyncTimerDelayMs: Number(asyncTimerDelayMs.toFixed(3)), + })); +}); diff --git a/src/main/http-response-decode.ts b/src/main/http-response-decode.ts new file mode 100644 index 00000000..63d0732d --- /dev/null +++ b/src/main/http-response-decode.ts @@ -0,0 +1,38 @@ +import * as zlib from 'zlib'; + +function zlibDecode( + decoder: (input: Buffer, callback: (error: Error | null, result: Buffer) => void) => void, + bodyBuffer: Buffer +): Promise { + return new Promise((resolve, reject) => { + decoder(bodyBuffer, (error, result) => { + if (error) { + reject(error); + return; + } + resolve(result); + }); + }); +} + +export async function decodeHttpResponseBodyBuffer( + bodyBuffer: Buffer, + contentEncoding: string +): Promise { + const normalizedEncoding = String(contentEncoding || '').toLowerCase(); + try { + if (normalizedEncoding.includes('br')) { + return await zlibDecode(zlib.brotliDecompress, bodyBuffer); + } + if (normalizedEncoding.includes('gzip')) { + return await zlibDecode(zlib.gunzip, bodyBuffer); + } + if (normalizedEncoding.includes('deflate')) { + return await zlibDecode(zlib.inflate, bodyBuffer); + } + } catch { + // If decompression fails, keep raw buffer to avoid hard-failing requests. + return bodyBuffer; + } + return bodyBuffer; +} diff --git a/src/main/main.ts b/src/main/main.ts index 8a118a0d..5b0a3508 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -68,6 +68,7 @@ import { mergeAiChatSnapshot, upsertAiChatConversation, } from './ai-chat-store'; +import { decodeHttpResponseBodyBuffer } from './http-response-decode'; import { getExtensionPreferences, getExtensionPreferencesSnapshot, @@ -15249,6 +15250,15 @@ app.whenReady().then(async () => { // ─── IPC: Extension APIs (for @raycast/api compatibility) ──────── // HTTP request proxy (so extensions can make Node.js HTTP requests without CORS) + const activeHttpRequests = new Map void>(); + + ipcMain.on('http-request-cancel', (_event: any, requestId: string) => { + if (!requestId) return; + const cancel = activeHttpRequests.get(requestId); + if (!cancel) return; + cancel(); + }); + ipcMain.handle( 'http-request', async ( @@ -15258,6 +15268,7 @@ app.whenReady().then(async () => { method?: string; headers?: Record; body?: string; + requestId?: string; } ) => { const http = require('http'); @@ -15275,17 +15286,39 @@ app.whenReady().then(async () => { } } catch {} + const requestId = typeof options.requestId === 'string' ? options.requestId : ''; + let canceled = false; + const resolveCanceled = (url: string) => ({ + status: 0, + statusText: 'Request canceled', + headers: {}, + bodyText: '', + url, + }); + const doRequest = (url: string, method: string, headers: Record, body: string | undefined, redirectsLeft: number): Promise => { return new Promise((resolve) => { + let settled = false; + const settle = (value: any) => { + if (settled) return; + settled = true; + resolve(value); + }; + const settleCanceled = () => settle(resolveCanceled(url)); + + if (canceled) { + settleCanceled(); + return; + } + try { const parsedUrl = new URL(url); const transport = parsedUrl.protocol === 'https:' ? https : http; - const reqOptions: any = { hostname: parsedUrl.hostname, port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80), path: parsedUrl.pathname + parsedUrl.search, - method: method, + method, headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', ...headers, @@ -15293,87 +15326,113 @@ app.whenReady().then(async () => { }; const req = transport.request(reqOptions, (res: any) => { - // Follow redirects (301, 302, 303, 307, 308) + if (canceled) { + res.resume(); + settleCanceled(); + return; + } + if (redirectsLeft > 0 && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - res.resume(); // drain the response + res.resume(); const redirectUrl = new URL(res.headers.location, url).toString(); - const redirectMethod = (res.statusCode === 303) ? 'GET' : method; - const redirectBody = (res.statusCode === 303) ? undefined : body; - resolve(doRequest(redirectUrl, redirectMethod, headers, redirectBody, redirectsLeft - 1)); + const redirectMethod = res.statusCode === 303 ? 'GET' : method; + const redirectBody = res.statusCode === 303 ? undefined : body; + settle(doRequest(redirectUrl, redirectMethod, headers, redirectBody, redirectsLeft - 1)); return; } const chunks: Buffer[] = []; - res.on('data', (chunk: Buffer) => chunks.push(chunk)); - res.on('end', () => { + res.on('data', (chunk: Buffer) => { + if (!canceled) chunks.push(chunk); + }); + res.on('end', async () => { + if (canceled) { + settleCanceled(); + return; + } + const bodyBuffer = Buffer.concat(chunks); const contentEncoding = String(res.headers['content-encoding'] || '').toLowerCase(); - let decodedBuffer = bodyBuffer; - try { - const zlib = require('zlib'); - if (contentEncoding.includes('br')) { - decodedBuffer = zlib.brotliDecompressSync(bodyBuffer); - } else if (contentEncoding.includes('gzip')) { - decodedBuffer = zlib.gunzipSync(bodyBuffer); - } else if (contentEncoding.includes('deflate')) { - decodedBuffer = zlib.inflateSync(bodyBuffer); - } - } catch { - // If decompression fails, keep raw buffer to avoid hard-failing requests. - decodedBuffer = bodyBuffer; + const decodedBuffer = await decodeHttpResponseBodyBuffer(bodyBuffer, contentEncoding); + if (canceled) { + settleCanceled(); + return; } const responseHeaders: Record = {}; for (const [key, val] of Object.entries(res.headers)) { responseHeaders[key] = Array.isArray(val) ? val.join(', ') : String(val); } - resolve({ + settle({ status: res.statusCode, statusText: res.statusMessage || '', headers: responseHeaders, bodyText: decodedBuffer.toString('utf-8'), - url: url, + url, }); }); }); req.on('error', (err: Error) => { - resolve({ + if (canceled) { + settleCanceled(); + return; + } + + settle({ status: 0, statusText: err.message, headers: {}, bodyText: '', - url: url, + url, }); }); req.setTimeout(30000, () => { + if (settled) return; req.destroy(); - resolve({ + settle({ status: 0, statusText: 'Request timed out', headers: {}, bodyText: '', - url: url, + url, }); }); + if (requestId) { + activeHttpRequests.set(requestId, () => { + canceled = true; + if (settled) return; + try { + req.destroy(new Error('Request canceled')); + } catch { + req.destroy(); + } + settleCanceled(); + }); + } + if (body) { req.write(body); } req.end(); } catch (e: any) { - resolve({ + settle({ status: 0, statusText: e?.message || 'Request failed', headers: {}, bodyText: '', - url: url, + url, }); } }); }; - return doRequest(requestUrl, (options.method || 'GET').toUpperCase(), options.headers || {}, options.body, 5); + try { + return await doRequest(requestUrl, (options.method || 'GET').toUpperCase(), options.headers || {}, options.body, 5); + } finally { + if (requestId) activeHttpRequests.delete(requestId); + } } ); diff --git a/src/renderer/src/ExtensionView.tsx b/src/renderer/src/ExtensionView.tsx index 13100d12..aa445203 100644 --- a/src/renderer/src/ExtensionView.tsx +++ b/src/renderer/src/ExtensionView.tsx @@ -16,6 +16,7 @@ import { ArrowLeft, AlertTriangle } from 'lucide-react'; import * as RaycastAPI from './raycast-api'; import { NavigationContext, setExtensionContext, setGlobalNavigation, ExtensionContextType, ExtensionInfoReactContext } from './raycast-api'; import { withExtensionContext } from './raycast-api/context-scope-runtime'; +import { installExtensionFetchBridge } from './extension-fetch-bridge'; // Also import @raycast/utils stubs from our shim import * as RaycastUtils from './raycast-api'; @@ -3287,120 +3288,7 @@ function ensureGlobals() { // fetch bridge — route extension HTTP(S) through main process to avoid CORS. // Keep native fetch for non-HTTP URLs and unsupported body types. - if (!g.__SUPERCMD_NATIVE_FETCH && typeof g.fetch === 'function') { - g.__SUPERCMD_NATIVE_FETCH = g.fetch.bind(g); - } - if (!g.__SUPERCMD_FETCH_PATCHED) { - const nativeFetch = g.__SUPERCMD_NATIVE_FETCH; - const isHttpUrl = (value: string) => /^https?:\/\//i.test(value); - const toHeadersObject = (headersLike: any): Record => { - const out: Record = {}; - if (!headersLike) return out; - try { - const normalized = new Headers(headersLike as HeadersInit); - normalized.forEach((v, k) => { - out[k] = v; - }); - } catch { - if (typeof headersLike === 'object') { - for (const [k, v] of Object.entries(headersLike)) { - out[k] = String(v); - } - } - } - return out; - }; - const normalizeBody = async (body: any): Promise => { - if (body == null) return undefined; - if (typeof body === 'string') return body; - if (body instanceof URLSearchParams) return body.toString(); - if (body instanceof Blob) return await body.text(); - if (typeof body === 'object') return JSON.stringify(body); - return String(body); - }; - - g.fetch = async (input: any, init?: any) => { - const url = - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : input?.url || String(input ?? ''); - - // Only proxy HTTP(S) requests. - if (!isHttpUrl(url) || !(window as any).electron?.httpRequest) { - return typeof nativeFetch === 'function' ? nativeFetch(input, init) : fetch(input, init); - } - - // FormData/streams are not representable via current IPC payload. Fall back. - const requestBody = init?.body; - if ( - requestBody instanceof FormData || - requestBody instanceof ReadableStream || - (typeof requestBody === 'object' && requestBody?.getReader) - ) { - return typeof nativeFetch === 'function' ? nativeFetch(input, init) : fetch(input, init); - } - - const method = (init?.method || input?.method || 'GET').toUpperCase(); - const headers = { - ...toHeadersObject(input?.headers), - ...toHeadersObject(init?.headers), - }; - const body = await normalizeBody(requestBody); - - const binaryDownloader = (window as any).electron?.httpDownloadBinary; - const canDownloadBinary = method === 'GET' && typeof binaryDownloader === 'function'; - const ipcRes = await (window as any).electron.httpRequest({ url, method, headers, body }); - - if (!ipcRes || ipcRes.status === 0) { - if (typeof nativeFetch === 'function') { - try { - return await nativeFetch(input, init); - } catch (nativeErr: any) { - const proxyMsg = ipcRes?.statusText || `Failed to fetch ${url}`; - const nativeMsg = nativeErr?.message || String(nativeErr); - throw new TypeError(`${proxyMsg}; native fetch fallback failed: ${nativeMsg}`); - } - } - throw new TypeError(ipcRes?.statusText || `Failed to fetch ${url}`); - } - - const contentType = String( - ipcRes.headers?.['content-type'] || - ipcRes.headers?.['Content-Type'] || - '' - ).toLowerCase(); - const requestAccept = String(headers?.Accept || headers?.accept || '').toLowerCase(); - const looksLikeBinaryUrl = /\.(gif|png|apng|jpe?g|webp|bmp|ico|icns|tiff?|mp3|wav|ogg|aac|m4a|mp4|mov|webm|woff2?|ttf|otf|eot|pdf|zip|gz|tgz|bz2|7z|rar)(?:[?#]|$)/i.test(url); - const isBinaryContentType = - /^image\/(?!svg\+xml)/i.test(contentType) || - /^(audio|video|font)\//i.test(contentType) || - /^application\/(?:octet-stream|pdf|zip|gzip|x-gzip|x-bzip|x-7z-compressed|x-rar-compressed)/i.test(contentType); - const prefersBinaryResponse = requestAccept.includes('image/') || requestAccept.includes('application/octet-stream'); - - let rawBytes: Uint8Array | null = null; - if (canDownloadBinary && (isBinaryContentType || prefersBinaryResponse || looksLikeBinaryUrl)) { - rawBytes = await binaryDownloader(url).catch(() => null as Uint8Array | null); - } - - // Build Response with binary body when available, text otherwise. - const responseBody = rawBytes && rawBytes.length > 0 ? rawBytes : (ipcRes.bodyText ?? ''); - const response = new Response(responseBody, { - status: ipcRes.status, - statusText: ipcRes.statusText || '', - headers: ipcRes.headers || {}, - }); - - try { - Object.defineProperty(response, 'url', { value: ipcRes.url || url }); - } catch {} - - return response; - }; - - g.__SUPERCMD_FETCH_PATCHED = true; - } + installExtensionFetchBridge(g); } interface TrackedChildProcess { diff --git a/src/renderer/src/extension-fetch-bridge.ts b/src/renderer/src/extension-fetch-bridge.ts new file mode 100644 index 00000000..12af3a69 --- /dev/null +++ b/src/renderer/src/extension-fetch-bridge.ts @@ -0,0 +1,194 @@ +interface ExtensionFetchElectronBridge { + httpRequest?: (options: { + url: string; + method?: string; + headers?: Record; + body?: string; + requestId?: string; + }) => Promise<{ + status: number; + statusText: string; + headers: Record; + bodyText: string; + url: string; + }>; + cancelHttpRequest?: (requestId: string) => void; + httpDownloadBinary?: (url: string) => Promise; +} + +let extensionFetchRequestSeq = 0; + +function createAbortError(): Error { + try { + return new DOMException('The operation was aborted.', 'AbortError'); + } catch { + const err = new Error('The operation was aborted.'); + err.name = 'AbortError'; + return err; + } +} + +function createRequestId(g: any): string { + const randomId = + typeof g.crypto?.randomUUID === 'function' + ? g.crypto.randomUUID() + : Math.random().toString(36).slice(2); + return `extensionFetch:${Date.now()}:${++extensionFetchRequestSeq}:${randomId}`; +} + +function getRequestSignal(input: any, init?: any): AbortSignal | undefined { + if (init?.signal) return init.signal; + if (typeof Request !== 'undefined' && input instanceof Request) return input.signal; + return input?.signal; +} + +export function installExtensionFetchBridge( + g: any, + getElectronBridge: () => ExtensionFetchElectronBridge | undefined = () => (globalThis as any).window?.electron +): void { + if (!g.__SUPERCMD_NATIVE_FETCH && typeof g.fetch === 'function') { + g.__SUPERCMD_NATIVE_FETCH = g.fetch.bind(g); + } + if (g.__SUPERCMD_FETCH_PATCHED) return; + + const nativeFetch = g.__SUPERCMD_NATIVE_FETCH; + const isHttpUrl = (value: string) => /^https?:\/\//i.test(value); + const toHeadersObject = (headersLike: any): Record => { + const out: Record = {}; + if (!headersLike) return out; + try { + const normalized = new Headers(headersLike as HeadersInit); + normalized.forEach((v, k) => { + out[k] = v; + }); + } catch { + if (typeof headersLike === 'object') { + for (const [k, v] of Object.entries(headersLike)) { + out[k] = String(v); + } + } + } + return out; + }; + const normalizeBody = async (body: any): Promise => { + if (body == null) return undefined; + if (typeof body === 'string') return body; + if (body instanceof URLSearchParams) return body.toString(); + if (body instanceof Blob) return await body.text(); + if (typeof body === 'object') return JSON.stringify(body); + return String(body); + }; + + g.fetch = async (input: any, init?: any) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input?.url || String(input ?? ''); + + const electronBridge = getElectronBridge(); + + // Only proxy HTTP(S) requests. + if (!isHttpUrl(url) || !electronBridge?.httpRequest) { + return typeof nativeFetch === 'function' ? nativeFetch(input, init) : fetch(input, init); + } + + // FormData/streams are not representable via current IPC payload. Fall back. + const requestBody = init?.body; + if ( + requestBody instanceof FormData || + requestBody instanceof ReadableStream || + (typeof requestBody === 'object' && requestBody?.getReader) + ) { + return typeof nativeFetch === 'function' ? nativeFetch(input, init) : fetch(input, init); + } + + const method = (init?.method || input?.method || 'GET').toUpperCase(); + const headers = { + ...toHeadersObject(input?.headers), + ...toHeadersObject(init?.headers), + }; + const body = await normalizeBody(requestBody); + const signal = getRequestSignal(input, init); + const requestId = createRequestId(g); + let cancelSent = false; + let abortListener: (() => void) | undefined; + const sendCancel = () => { + if (cancelSent) return; + cancelSent = true; + electronBridge.cancelHttpRequest?.(requestId); + }; + const removeAbortListener = () => { + if (!signal || !abortListener) return; + signal.removeEventListener('abort', abortListener); + abortListener = undefined; + }; + + if (signal?.aborted) throw createAbortError(); + if (signal) { + abortListener = sendCancel; + signal.addEventListener('abort', abortListener, { once: true }); + } + + const binaryDownloader = electronBridge.httpDownloadBinary; + const canDownloadBinary = method === 'GET' && typeof binaryDownloader === 'function'; + let ipcRes: Awaited>>; + try { + ipcRes = await electronBridge.httpRequest({ url, method, headers, body, requestId }); + } finally { + removeAbortListener(); + } + + if (signal?.aborted) throw createAbortError(); + + if (!ipcRes || ipcRes.status === 0) { + if (typeof nativeFetch === 'function') { + try { + return await nativeFetch(input, init); + } catch (nativeErr: any) { + const proxyMsg = ipcRes?.statusText || `Failed to fetch ${url}`; + const nativeMsg = nativeErr?.message || String(nativeErr); + throw new TypeError(`${proxyMsg}; native fetch fallback failed: ${nativeMsg}`); + } + } + throw new TypeError(ipcRes?.statusText || `Failed to fetch ${url}`); + } + + const contentType = String( + ipcRes.headers?.['content-type'] || + ipcRes.headers?.['Content-Type'] || + '' + ).toLowerCase(); + const requestAccept = String(headers?.Accept || headers?.accept || '').toLowerCase(); + const looksLikeBinaryUrl = /\.(gif|png|apng|jpe?g|webp|bmp|ico|icns|tiff?|mp3|wav|ogg|aac|m4a|mp4|mov|webm|woff2?|ttf|otf|eot|pdf|zip|gz|tgz|bz2|7z|rar)(?:[?#]|$)/i.test(url); + const isBinaryContentType = + /^image\/(?!svg\+xml)/i.test(contentType) || + /^(audio|video|font)\//i.test(contentType) || + /^application\/(?:octet-stream|pdf|zip|gzip|x-gzip|x-bzip|x-7z-compressed|x-rar-compressed)/i.test(contentType); + const prefersBinaryResponse = requestAccept.includes('image/') || requestAccept.includes('application/octet-stream'); + + let rawBytes: Uint8Array | null = null; + if (canDownloadBinary && (isBinaryContentType || prefersBinaryResponse || looksLikeBinaryUrl)) { + if (signal?.aborted) throw createAbortError(); + rawBytes = await binaryDownloader(url).catch(() => null as Uint8Array | null); + if (signal?.aborted) throw createAbortError(); + } + + // Build Response with binary body when available, text otherwise. + const responseBody = rawBytes && rawBytes.length > 0 ? rawBytes as BodyInit : (ipcRes.bodyText ?? ''); + const response = new Response(responseBody, { + status: ipcRes.status, + statusText: ipcRes.statusText || '', + headers: ipcRes.headers || {}, + }); + + try { + Object.defineProperty(response, 'url', { value: ipcRes.url || url }); + } catch {} + + return response; + }; + + g.__SUPERCMD_FETCH_PATCHED = true; +} From c4480445672c18243fc7fea9e8c024e5d9078f15 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:30:11 +0200 Subject: [PATCH 5/8] fix: clean up main abort listeners (cherry picked from commit 83c2a894dd97759d7b16a1d537c7a6f3ce3ef631) --- src/main/ai-provider.ts | 77 +++++++++++++++++++++++++++++++++-------- src/main/main.ts | 30 ++++++++++++---- 2 files changed, 87 insertions(+), 20 deletions(-) diff --git a/src/main/ai-provider.ts b/src/main/ai-provider.ts index 65942908..53f4775e 100644 --- a/src/main/ai-provider.ts +++ b/src/main/ai-provider.ts @@ -687,6 +687,29 @@ interface HttpRequestOptions { function httpRequest(opts: HttpRequestOptions): Promise { return new Promise((resolve, reject) => { const mod = opts.useHttps ? https : http; + let settled = false; + let onAbort: (() => void) | null = null; + + const cleanup = () => { + if (opts.signal && onAbort) { + opts.signal.removeEventListener('abort', onAbort); + onAbort = null; + } + }; + + const finish = (settle: (value: T) => void, value: T, shouldCleanup = true) => { + if (settled) return; + settled = true; + if (shouldCleanup) cleanup(); + settle(value); + }; + + const cleanupAfterResponse = (res: http.IncomingMessage) => { + const cleanupOnce = () => cleanup(); + res.once('end', cleanupOnce); + res.once('close', cleanupOnce); + res.once('error', cleanupOnce); + }; const reqOpts: https.RequestOptions = { hostname: opts.hostname, @@ -701,25 +724,31 @@ function httpRequest(opts: HttpRequestOptions): Promise { let body = ''; res.on('data', (chunk) => { body += chunk; }); res.on('end', () => { - reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 500)}`)); + finish(reject, new Error(`HTTP ${res.statusCode}: ${body.slice(0, 500)}`)); }); return; } - resolve(res); + cleanupAfterResponse(res); + finish(resolve, res, false); }); - req.on('error', reject); + req.on('error', (err) => { + finish(reject, err); + }); if (opts.signal) { if (opts.signal.aborted) { + cleanup(); + finish(reject, new Error('Request aborted')); req.destroy(); - reject(new Error('Request aborted')); return; } - opts.signal.addEventListener('abort', () => { + onAbort = () => { + cleanup(); + finish(reject, new Error('Request aborted')); req.destroy(); - reject(new Error('Request aborted')); - }, { once: true }); + }; + opts.signal.addEventListener('abort', onAbort, { once: true }); } req.write(opts.body); @@ -857,6 +886,23 @@ export function transcribeAudio(opts: TranscribeOptions): Promise { const body = Buffer.concat(parts); return new Promise((resolve, reject) => { + let settled = false; + let onAbort: (() => void) | null = null; + + const cleanup = () => { + if (opts.signal && onAbort) { + opts.signal.removeEventListener('abort', onAbort); + onAbort = null; + } + }; + + const finish = (settle: (value: T) => void, value: T) => { + if (settled) return; + settled = true; + cleanup(); + settle(value); + }; + const req = https.request( { hostname: 'api.openai.com', @@ -873,26 +919,29 @@ export function transcribeAudio(opts: TranscribeOptions): Promise { res.on('data', (chunk) => { responseBody += chunk; }); res.on('end', () => { if (res.statusCode && res.statusCode >= 400) { - reject(new Error(`Whisper API HTTP ${res.statusCode}: ${responseBody.slice(0, 500)}`)); + finish(reject, new Error(`Whisper API HTTP ${res.statusCode}: ${responseBody.slice(0, 500)}`)); return; } - resolve(responseBody.trim()); + finish(resolve, responseBody.trim()); }); } ); - req.on('error', reject); + req.on('error', (err) => { + finish(reject, err); + }); if (opts.signal) { if (opts.signal.aborted) { + finish(reject, new Error('Transcription aborted')); req.destroy(); - reject(new Error('Transcription aborted')); return; } - opts.signal.addEventListener('abort', () => { + onAbort = () => { + finish(reject, new Error('Transcription aborted')); req.destroy(); - reject(new Error('Transcription aborted')); - }, { once: true }); + }; + opts.signal.addEventListener('abort', onAbort, { once: true }); } req.write(body); diff --git a/src/main/main.ts b/src/main/main.ts index 5b0a3508..541d739a 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -18387,7 +18387,7 @@ if let tiff = image?.tiffRepresentation { requestId, error: `HTTP ${res.statusCode}: ${errBody.slice(0, 200)}`, }); - activeAIRequests.delete(requestId); + finishPullRequest(); }); return; } @@ -18431,7 +18431,7 @@ if let tiff = image?.tiffRepresentation { if (!controller.signal.aborted) { event.sender.send('ollama-pull-done', { requestId }); } - activeAIRequests.delete(requestId); + finishPullRequest(); }); } ); @@ -18443,16 +18443,34 @@ if let tiff = image?.tiffRepresentation { error: err.message || 'Failed to pull model', }); } - activeAIRequests.delete(requestId); + finishPullRequest(); }); + let abortListenerAttached = false; + let pullRequestFinished = false; + const onAbort = () => { + finishPullRequest(); + req.destroy(); + }; + const cleanupAbortListener = () => { + if (!abortListenerAttached) return; + controller.signal.removeEventListener('abort', onAbort); + abortListenerAttached = false; + }; + const finishPullRequest = () => { + if (pullRequestFinished) return; + pullRequestFinished = true; + cleanupAbortListener(); + activeOllamaPullRequests.delete(requestId); + }; + if (controller.signal.aborted) { + finishPullRequest(); req.destroy(); return; } - controller.signal.addEventListener('abort', () => { - req.destroy(); - }, { once: true }); + controller.signal.addEventListener('abort', onAbort, { once: true }); + abortListenerAttached = true; req.write(body); req.end(); From a5e7148ba9c8e0fb0bfb6fc038e55fc8f1e1e362 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:35:29 +0200 Subject: [PATCH 6/8] perf: stream cloud media buffers (cherry picked from commit 0a7560bff66f5c8049cbdafc66035ac5449b1dc8) --- scripts/test-cloud-media-buffering.mjs | 475 +++++++++++++++++++++++++ src/main/main.ts | 96 +++-- 2 files changed, 542 insertions(+), 29 deletions(-) create mode 100644 scripts/test-cloud-media-buffering.mjs diff --git a/scripts/test-cloud-media-buffering.mjs b/scripts/test-cloud-media-buffering.mjs new file mode 100644 index 00000000..93420cbc --- /dev/null +++ b/scripts/test-cloud-media-buffering.mjs @@ -0,0 +1,475 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import vm from 'node:vm'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { createRequire } from 'node:module'; + +const requireFromHere = createRequire(import.meta.url); +const mainSource = fs.readFileSync(path.resolve('src/main/main.ts'), 'utf8'); +const providerSource = extractSourceBetween( + mainSource, + 'type BufferedRequestPart = Buffer;', + 'function fetchElevenLabsVoices', +); + +test('ElevenLabs STT streams multipart upload parts without a full body concat', async (t) => { + const audioBuffer = Buffer.alloc(6 * 1024 * 1024, 0x65); + const https = createFakeHttps({ responseChunks: [Buffer.from('{"text":" eleven transcript "}')] }); + const { transcribeAudioWithElevenLabs } = await loadMainMediaProviders({ https }); + + const { result, concatCalls } = await instrumentBufferConcat(() => transcribeAudioWithElevenLabs({ + audioBuffer, + apiKey: 'eleven-key', + model: 'scribe_v1', + language: 'en', + mimeType: 'audio/wav', + })); + + assert.equal(result, 'eleven transcript'); + assert.equal(https.requests.length, 1); + + const req = https.requests[0]; + const boundary = extractBoundary(req.options.headers['Content-Type']); + const expectedParts = buildExpectedElevenLabsSttParts({ + audioBuffer, + boundary, + language: 'en', + mimeType: 'audio/wav', + model: 'scribe_v1', + }); + const expectedBody = Buffer.concat(expectedParts); + const actualBody = Buffer.concat(req.writes); + + assert.deepEqual( + { + hostname: req.options.hostname, + path: req.options.path, + method: req.options.method, + apiKey: req.options.headers['xi-api-key'], + contentLength: req.options.headers['Content-Length'], + }, + { + hostname: 'api.elevenlabs.io', + path: '/v1/speech-to-text', + method: 'POST', + apiKey: 'eleven-key', + contentLength: expectedBody.length, + }, + ); + assert.equal(actualBody.equals(expectedBody), true); + assert.equal(req.writes.length, expectedParts.length); + assert.equal(req.writes[1], audioBuffer, 'the original audio Buffer should be written directly'); + assertNoProviderConcat(concatCalls, 'production ElevenLabs STT path should not call Buffer.concat'); + assertMultipartOrder(actualBody, ['name="file"', 'name="model_id"', 'name="language_code"']); + + const framingBytes = expectedBody.length - audioBuffer.length; + t.diagnostic(`before: legacy Buffer.concat would allocate a ${expectedBody.length} byte ElevenLabs multipart body copy`); + t.diagnostic(`after: streamed upload wrote ${req.writes.length} buffers, reusing the ${audioBuffer.length} byte audio Buffer and allocating ${framingBytes} framing bytes`); +}); + +test('Mistral Voxtral STT streams multipart upload parts without a full body concat', async (t) => { + const audioBuffer = Buffer.alloc(7 * 1024 * 1024, 0x6d); + const https = createFakeHttps({ responseChunks: [Buffer.from('{"choices":[{"message":{"content":[{"text":"mistral "},"transcript"]}}]}')] }); + const { transcribeAudioWithMistralVoxtral } = await loadMainMediaProviders({ https }); + + const { result, concatCalls } = await instrumentBufferConcat(() => transcribeAudioWithMistralVoxtral({ + audioBuffer, + apiKey: 'mistral-key', + model: 'voxtral-mini-latest', + language: 'fr', + mimeType: 'audio/mpeg', + })); + + assert.equal(result, 'mistral transcript'); + assert.equal(https.requests.length, 1); + + const req = https.requests[0]; + const boundary = extractBoundary(req.options.headers['Content-Type']); + const expectedParts = buildExpectedMistralSttParts({ + audioBuffer, + boundary, + language: 'fr', + model: 'voxtral-mini-latest', + mimeType: 'audio/mpeg', + }); + const expectedBody = Buffer.concat(expectedParts); + const actualBody = Buffer.concat(req.writes); + + assert.deepEqual( + { + hostname: req.options.hostname, + path: req.options.path, + method: req.options.method, + authorization: req.options.headers.Authorization, + contentLength: req.options.headers['Content-Length'], + timeoutMs: req.timeoutMs, + }, + { + hostname: 'api.mistral.ai', + path: '/v1/audio/transcriptions', + method: 'POST', + authorization: 'Bearer mistral-key', + contentLength: expectedBody.length, + timeoutMs: 60000, + }, + ); + assert.equal(actualBody.equals(expectedBody), true); + assert.equal(req.writes.length, expectedParts.length); + assert.equal(req.writes[1], audioBuffer, 'the original audio Buffer should be written directly'); + assertNoProviderConcat(concatCalls, 'production Mistral STT path should not call Buffer.concat'); + assertMultipartOrder(actualBody, ['name="file"', 'name="model"', 'name="language"']); + + const framingBytes = expectedBody.length - audioBuffer.length; + t.diagnostic(`before: legacy Buffer.concat would allocate a ${expectedBody.length} byte Mistral multipart body copy`); + t.diagnostic(`after: streamed upload wrote ${req.writes.length} buffers, reusing the ${audioBuffer.length} byte audio Buffer and allocating ${framingBytes} framing bytes`); +}); + +test('ElevenLabs TTS streams successful MP3 responses to the temp file without full audio concat', async (t) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-cloud-media-buffering-')); + const audioPath = path.join(tempDir, 'speech.mp3'); + const audioChunks = [ + Buffer.alloc(3 * 1024 * 1024, 0x11), + Buffer.alloc(2 * 1024 * 1024, 0x22), + Buffer.alloc(512 * 1024, 0x33), + ]; + const audioBytes = audioChunks.reduce((total, chunk) => total + chunk.length, 0); + const https = createFakeHttps({ responseChunks: audioChunks }); + const fakeFs = createInstrumentedFs(); + const { synthesizeElevenLabsToFile } = await loadMainMediaProviders({ https, fsModule: fakeFs }); + + try { + const { concatCalls } = await instrumentBufferConcat(() => synthesizeElevenLabsToFile({ + text: 'Read this aloud.', + apiKey: 'eleven-key', + modelId: 'eleven_multilingual_v2', + voiceId: 'voice-id', + audioPath, + timeoutMs: 30000, + })); + + assert.equal(https.requests.length, 1); + const req = https.requests[0]; + assert.deepEqual( + { + hostname: req.options.hostname, + path: req.options.path, + method: req.options.method, + apiKey: req.options.headers['xi-api-key'], + contentType: req.options.headers['Content-Type'], + accept: req.options.headers.Accept, + timeoutMs: req.timeoutMs, + }, + { + hostname: 'api.elevenlabs.io', + path: '/v1/text-to-speech/voice-id?output_format=mp3_44100_128', + method: 'POST', + apiKey: 'eleven-key', + contentType: 'application/json', + accept: 'audio/mpeg', + timeoutMs: 30000, + }, + ); + assert.deepEqual(JSON.parse(req.writes.join('')), { + text: 'Read this aloud.', + model_id: 'eleven_multilingual_v2', + }); + assert.equal(fs.statSync(audioPath).size, audioBytes); + assert.equal(fakeFs.writeFileCalls.length, 0, 'success path should stream with createWriteStream instead of fs.writeFile'); + assert.equal(fakeFs.createWriteStreamCalls.length, 1); + assertNoProviderConcat(concatCalls, 'production ElevenLabs TTS success path should not call Buffer.concat'); + + t.diagnostic(`before: legacy Buffer.concat would retain a ${audioBytes} byte MP3 response before fs.writeFile`); + t.diagnostic(`after: streamed ${audioBytes} MP3 bytes directly to ${fs.statSync(audioPath).size} temp-file bytes without a full audio Buffer concat`); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('ElevenLabs TTS keeps HTTP error text bounded and preserves unusual-activity message', async () => { + { + const https = createFakeHttps({ + statusCode: 500, + responseChunks: [Buffer.from('x'.repeat(2 * 1024 * 1024))], + }); + const { synthesizeElevenLabsToFile } = await loadMainMediaProviders({ https, fsModule: createInstrumentedFs() }); + + const { error, concatCalls } = await instrumentBufferConcatRejection(() => synthesizeElevenLabsToFile({ + text: 'Read this aloud.', + apiKey: 'eleven-key', + modelId: 'eleven_multilingual_v2', + voiceId: 'voice-id', + audioPath: path.join(os.tmpdir(), 'unused-supercmd-tts-error.mp3'), + })); + + assert.match(error.message, /^ElevenLabs TTS HTTP 500: x{500}$/); + assert.equal(error.message.length, 'ElevenLabs TTS HTTP 500: '.length + 500); + assertNoProviderConcat(concatCalls, 'production ElevenLabs TTS error path should not call Buffer.concat'); + } + + { + const https = createFakeHttps({ + statusCode: 401, + responseChunks: [Buffer.from('{"detail":"detected_unusual_activity"}')], + }); + const { synthesizeElevenLabsToFile } = await loadMainMediaProviders({ https, fsModule: createInstrumentedFs() }); + + await assert.rejects( + synthesizeElevenLabsToFile({ + text: 'Read this aloud.', + apiKey: 'eleven-key', + modelId: 'eleven_multilingual_v2', + voiceId: 'voice-id', + audioPath: path.join(os.tmpdir(), 'unused-supercmd-tts-unusual.mp3'), + }), + /ElevenLabs rejected this key due to account restrictions \(detected_unusual_activity\)/, + ); + } +}); + +async function loadMainMediaProviders({ https, fsModule = fs } = {}) { + const cjs = stripMediaProviderTypes(`${providerSource} +module.exports = { + transcribeAudioWithElevenLabs, + transcribeAudioWithMistralVoxtral, + synthesizeElevenLabsToFile, +};`); + const module = { exports: {} }; + const context = vm.createContext({ + Buffer, + Error, + JSON, + Math, + Promise, + String, + console, + module, + exports: module.exports, + require(specifier) { + if (specifier === 'https') return https; + if (specifier === 'fs') return fsModule; + if (specifier === 'stream') return requireFromHere('node:stream'); + if (specifier === 'string_decoder') return requireFromHere('node:string_decoder'); + return requireFromHere(specifier); + }, + }); + + vm.runInContext(cjs, context, { filename: 'main-media-providers.cjs' }); + return module.exports; +} + +function stripMediaProviderTypes(source) { + return source + .replace(/^type BufferedRequestPart = Buffer;\n/m, '') + .replace(/function getBufferedRequestPartsContentLength\(parts: readonly BufferedRequestPart\[\]\): number \{/g, 'function getBufferedRequestPartsContentLength(parts) {') + .replace(/function writeBufferedRequestParts\(req: \{ write: \(chunk: Buffer\) => unknown; end: \(\) => unknown \}, parts: readonly BufferedRequestPart\[\]\): void \{/g, 'function writeBufferedRequestParts(req, parts) {') + .replace(/function collectBoundedResponseText\(res: any, maxBytes = ([^)]+)\): Promise \{/g, 'function collectBoundedResponseText(res, maxBytes = $1) {') + .replace(/function (transcribeAudioWithElevenLabs|transcribeAudioWithMistralVoxtral|synthesizeElevenLabsToFile)\(opts: \{[\s\S]*?\}\): Promise<[^>]+> \{/g, 'function $1(opts) {') + .replace(/ as typeof import\('string_decoder'\)/g, '') + .replace(/ as typeof import\('stream'\)/g, '') + .replace(/: Buffer\[\]/g, '') + .replace(/\((chunk|err|res|part): (?:Buffer \| string|Buffer|Error \| null|any)\) =>/g, '($1) =>') + .replace(/new Promise<[^>]+>/g, 'new Promise'); +} + +async function instrumentBufferConcat(fn) { + const originalConcat = Buffer.concat; + const concatCalls = []; + + Buffer.concat = function instrumentedConcat(list, totalLength) { + concatCalls.push({ + chunks: list.length, + totalLength: totalLength ?? list.reduce((total, chunk) => total + chunk.length, 0), + stack: new Error().stack || '', + }); + return originalConcat.call(Buffer, list, totalLength); + }; + + try { + const result = await fn(); + return { result, concatCalls }; + } finally { + Buffer.concat = originalConcat; + } +} + +async function instrumentBufferConcatRejection(fn) { + const originalConcat = Buffer.concat; + const concatCalls = []; + + Buffer.concat = function instrumentedConcat(list, totalLength) { + concatCalls.push({ + chunks: list.length, + totalLength: totalLength ?? list.reduce((total, chunk) => total + chunk.length, 0), + stack: new Error().stack || '', + }); + return originalConcat.call(Buffer, list, totalLength); + }; + + try { + await fn(); + assert.fail('expected rejection'); + } catch (error) { + return { error, concatCalls }; + } finally { + Buffer.concat = originalConcat; + } +} + +function assertNoProviderConcat(concatCalls, message) { + const providerConcatCalls = concatCalls.filter((call) => call.stack.includes('main-media-providers.cjs')); + assert.equal(providerConcatCalls.length, 0, message); +} + +function createFakeHttps(options = {}) { + const requests = []; + return { + requests, + request(requestOptions, onResponse) { + const req = new FakeClientRequest(requestOptions, onResponse, options); + requests.push(req); + return req; + }, + }; +} + +class FakeClientRequest extends EventEmitter { + constructor(options, onResponse, responseOptions) { + super(); + this.options = options; + this.onResponse = onResponse; + this.responseOptions = responseOptions; + this.destroyed = false; + this.ended = false; + this.timeoutMs = null; + this.writes = []; + } + + write(chunk) { + this.writes.push(chunk); + return true; + } + + end() { + this.ended = true; + queueMicrotask(() => { + if (this.responseOptions.requestError) { + this.emit('error', this.responseOptions.requestError); + return; + } + if (this.destroyed) return; + + const res = new PassThrough(); + res.statusCode = this.responseOptions.statusCode ?? 200; + this.onResponse(res); + for (const chunk of this.responseOptions.responseChunks ?? [Buffer.from('transcript')]) { + res.write(chunk); + } + res.end(); + }); + } + + setTimeout(timeoutMs) { + this.timeoutMs = timeoutMs; + } + + destroy(error) { + this.destroyed = true; + if (error) this.emit('error', error); + } +} + +function createInstrumentedFs() { + return { + ...fs, + createWriteStreamCalls: [], + writeFileCalls: [], + createWriteStream(filePath, options) { + this.createWriteStreamCalls.push({ filePath, options }); + return fs.createWriteStream(filePath, options); + }, + writeFile(...args) { + this.writeFileCalls.push(args); + return fs.writeFile(...args); + }, + }; +} + +function buildExpectedElevenLabsSttParts({ audioBuffer, boundary, language, mimeType, model }) { + const normalized = String(mimeType || '').toLowerCase(); + const filename = normalized.includes('wav') + ? 'audio.wav' + : normalized.includes('mpeg') || normalized.includes('mp3') + ? 'audio.mp3' + : normalized.includes('mp4') || normalized.includes('m4a') + ? 'audio.m4a' + : normalized.includes('ogg') || normalized.includes('oga') + ? 'audio.ogg' + : normalized.includes('flac') + ? 'audio.flac' + : 'audio.webm'; + const contentType = normalized || 'audio/webm'; + const parts = [ + Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`), + audioBuffer, + Buffer.from('\r\n'), + Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="model_id"\r\n\r\n${model}\r\n`), + ]; + + if (language) { + parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="language_code"\r\n\r\n${language}\r\n`)); + } + + parts.push(Buffer.from(`--${boundary}--\r\n`)); + return parts; +} + +function buildExpectedMistralSttParts({ audioBuffer, boundary, language, mimeType, model }) { + const normalized = String(mimeType || '').toLowerCase(); + const filename = normalized.includes('mp3') || normalized.includes('mpeg') ? 'audio.mp3' : 'audio.wav'; + const contentType = filename.endsWith('.mp3') ? 'audio/mpeg' : 'audio/wav'; + const parts = [ + Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`), + audioBuffer, + Buffer.from('\r\n'), + Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\n${model || 'voxtral-mini-latest'}\r\n`), + ]; + + if (language) { + parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="language"\r\n\r\n${language}\r\n`)); + } + + parts.push(Buffer.from(`--${boundary}--\r\n`)); + return parts; +} + +function extractBoundary(contentType) { + const match = /^multipart\/form-data; boundary=(.+)$/.exec(contentType); + assert.ok(match, `expected multipart content type, got ${contentType}`); + return match[1]; +} + +function assertMultipartOrder(body, markers) { + const text = body.toString('utf8'); + let previous = -1; + + for (const marker of markers) { + const index = text.indexOf(marker); + assert.notEqual(index, -1, `expected multipart marker ${marker}`); + assert.ok(index > previous, `expected ${marker} after previous multipart field`); + previous = index; + } +} + +function extractSourceBetween(source, startMarker, endMarker) { + const start = source.indexOf(startMarker); + const end = source.indexOf(endMarker, start); + assert.notEqual(start, -1, `expected source marker ${startMarker}`); + assert.notEqual(end, -1, `expected source marker ${endMarker}`); + return source.slice(start, end); +} diff --git a/src/main/main.ts b/src/main/main.ts index 541d739a..b3c32cce 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -5716,6 +5716,43 @@ function resolveElevenLabsTtsConfig(selectedModel: string): { modelId: string; v return { modelId, voiceId }; } +type BufferedRequestPart = Buffer; + +function getBufferedRequestPartsContentLength(parts: readonly BufferedRequestPart[]): number { + return parts.reduce((total, part) => total + part.length, 0); +} + +function writeBufferedRequestParts(req: { write: (chunk: Buffer) => unknown; end: () => unknown }, parts: readonly BufferedRequestPart[]): void { + for (const part of parts) { + req.write(part); + } + req.end(); +} + +function collectBoundedResponseText(res: any, maxBytes = 1024 * 1024): Promise { + return new Promise((resolve, reject) => { + const { StringDecoder } = require('string_decoder') as typeof import('string_decoder'); + const decoder = new StringDecoder('utf8'); + let remaining = maxBytes; + let text = ''; + + res.on('data', (chunk: Buffer | string) => { + if (remaining <= 0) return; + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8'); + const next = buffer.length > remaining ? buffer.subarray(0, remaining) : buffer; + remaining -= next.length; + text += decoder.write(next); + }); + res.on('error', reject); + res.on('end', () => { + if (remaining > 0) { + text += decoder.end(); + } + resolve(text); + }); + }); +} + function transcribeAudioWithElevenLabs(opts: { audioBuffer: Buffer; apiKey: string; @@ -5756,7 +5793,6 @@ function transcribeAudioWithElevenLabs(opts: { } parts.push(Buffer.from(`--${boundary}--\r\n`)); - const body = Buffer.concat(parts); return new Promise((resolve, reject) => { try { @@ -5769,14 +5805,11 @@ function transcribeAudioWithElevenLabs(opts: { headers: { 'xi-api-key': opts.apiKey, 'Content-Type': `multipart/form-data; boundary=${boundary}`, - 'Content-Length': body.length, + 'Content-Length': getBufferedRequestPartsContentLength(parts), }, }, (res: any) => { - const chunks: Buffer[] = []; - res.on('data', (chunk: Buffer) => chunks.push(chunk)); - res.on('end', () => { - const responseBody = Buffer.concat(chunks).toString('utf-8'); + collectBoundedResponseText(res).then((responseBody) => { if (res.statusCode && res.statusCode >= 400) { if (res.statusCode === 401 && responseBody.includes('detected_unusual_activity')) { reject(new Error('ElevenLabs rejected this key due to account restrictions (detected_unusual_activity). Verify plan/account status in ElevenLabs dashboard.')); @@ -5801,12 +5834,11 @@ function transcribeAudioWithElevenLabs(opts: { } resolve(text); } - }); + }).catch(reject); } ); req.on('error', reject); - req.write(body); - req.end(); + writeBufferedRequestParts(req, parts); } catch (error) { reject(error); } @@ -5843,7 +5875,6 @@ function transcribeAudioWithMistralVoxtral(opts: { } parts.push(Buffer.from(`--${boundary}--\r\n`)); - const body = Buffer.concat(parts); return new Promise((resolve, reject) => { try { @@ -5856,14 +5887,11 @@ function transcribeAudioWithMistralVoxtral(opts: { headers: { 'Authorization': `Bearer ${opts.apiKey}`, 'Content-Type': `multipart/form-data; boundary=${boundary}`, - 'Content-Length': body.length, + 'Content-Length': getBufferedRequestPartsContentLength(parts), }, }, (res: any) => { - const chunks: Buffer[] = []; - res.on('data', (chunk: Buffer) => chunks.push(chunk)); - res.on('end', () => { - const responseBody = Buffer.concat(chunks).toString('utf-8'); + collectBoundedResponseText(res).then((responseBody) => { if (res.statusCode && res.statusCode >= 400) { reject(new Error(`Mistral Voxtral STT HTTP ${res.statusCode}: ${responseBody.slice(0, 500)}`)); return; @@ -5890,15 +5918,14 @@ function transcribeAudioWithMistralVoxtral(opts: { } resolve(text); } - }); + }).catch(reject); } ); req.on('error', reject); req.setTimeout(60000, () => { req.destroy(new Error('Mistral Voxtral STT timed out.')); }); - req.write(body); - req.end(); + writeBufferedRequestParts(req, parts); } catch (error) { reject(error); } @@ -5929,27 +5956,38 @@ function synthesizeElevenLabsToFile(opts: { }, }, (res: any) => { - const chunks: Buffer[] = []; - res.on('data', (chunk: Buffer) => chunks.push(chunk)); - res.on('end', () => { - if (res.statusCode && res.statusCode >= 400) { - const responseText = Buffer.concat(chunks).toString('utf-8'); + if (res.statusCode && res.statusCode >= 400) { + collectBoundedResponseText(res).then((responseText) => { if (res.statusCode === 401 && responseText.includes('detected_unusual_activity')) { reject(new Error('ElevenLabs rejected this key due to account restrictions (detected_unusual_activity). Verify plan/account status in ElevenLabs dashboard.')); return; } reject(new Error(`ElevenLabs TTS HTTP ${res.statusCode}: ${responseText.slice(0, 500)}`)); return; + }).catch(reject); + return; + } + + const fileStream = fs.createWriteStream(opts.audioPath); + const { pipeline } = require('stream') as typeof import('stream'); + let audioBytes = 0; + + res.on('data', (chunk: Buffer) => { + audioBytes += chunk.length; + }); + + pipeline(res, fileStream, (err: Error | null) => { + if (err) { + try { fs.unlink(opts.audioPath, () => {}); } catch {} + reject(err); + return; } - const audio = Buffer.concat(chunks); - if (!audio.length) { + if (!audioBytes) { + try { fs.unlink(opts.audioPath, () => {}); } catch {} reject(new Error('ElevenLabs TTS returned empty audio.')); return; } - fs.writeFile(opts.audioPath, audio, (err: Error | null) => { - if (err) reject(err); - else resolve(); - }); + resolve(); }); } ); From aac9a64e50b782bcb3e7652a042dedf612931f4f Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:55:00 +0200 Subject: [PATCH 7/8] fix(raycast): guard registry microtask teardown (cherry picked from commit 17fb535e5d55b544f3115ea70ff2f52226c63593) --- scripts/test-registry-microtask-lifecycle.mjs | 361 ++++++++++++++++++ .../raycast-api/action-runtime-registry.tsx | 20 +- .../src/raycast-api/grid-runtime-hooks.ts | 22 +- .../src/raycast-api/list-runtime-hooks.ts | 22 +- .../raycast-api/menubar-runtime-parent.tsx | 47 ++- 5 files changed, 452 insertions(+), 20 deletions(-) create mode 100644 scripts/test-registry-microtask-lifecycle.mjs diff --git a/scripts/test-registry-microtask-lifecycle.mjs b/scripts/test-registry-microtask-lifecycle.mjs new file mode 100644 index 00000000..6ff0197e --- /dev/null +++ b/scripts/test-registry-microtask-lifecycle.mjs @@ -0,0 +1,361 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function reactStubPlugin() { + return { + name: 'react-stub', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^react$/ }, () => ({ path: 'react-stub', namespace: 'react-stub' })); + pluginBuild.onResolve({ filter: /^react\/jsx-(dev-)?runtime$/ }, () => ({ path: 'react-jsx-runtime-stub', namespace: 'react-stub' })); + pluginBuild.onLoad({ filter: /^react-stub$/, namespace: 'react-stub' }, () => ({ + loader: 'js', + contents: ` + function runtime() { + const current = globalThis.__SUPERCMD_REACT_RUNTIME__; + if (!current) throw new Error('React hook runtime is not installed'); + return current; + } + + export const Fragment = Symbol.for('react.fragment'); + export function createContext(defaultValue) { + const context = { _currentValue: defaultValue }; + context.Provider = function Provider(props) { + context._currentValue = props.value; + return props.children; + }; + return context; + } + export function createElement(type, props, ...children) { + return { $$typeof: Symbol.for('react.element'), type, props: { ...(props || {}), children } }; + } + export function isValidElement(value) { + return Boolean(value && value.$$typeof === Symbol.for('react.element')); + } + export function useCallback(callback, deps) { + return runtime().useCallback(callback, deps); + } + export function useContext(context) { + return runtime().useContext(context); + } + export function useEffect(effect, deps) { + return runtime().useEffect(effect, deps); + } + export function useMemo(factory, deps) { + return runtime().useMemo(factory, deps); + } + export function useRef(initialValue) { + return runtime().useRef(initialValue); + } + export function useState(initialValue) { + return runtime().useState(initialValue); + } + + const React = { + Fragment, + createContext, + createElement, + isValidElement, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + }; + export default React; + `, + })); + pluginBuild.onLoad({ filter: /^react-jsx-runtime-stub$/, namespace: 'react-stub' }, () => ({ + loader: 'js', + contents: ` + import { Fragment, createElement } from 'react'; + export { Fragment }; + export function jsx(type, props, key) { + return createElement(type, { ...(props || {}), ...(key === undefined ? {} : { key }) }); + } + export function jsxs(type, props, key) { + return jsx(type, props, key); + } + export function jsxDEV(type, props, key) { + return jsx(type, props, key); + } + `, + })); + }, + }; +} + +function menuBarStubsPlugin() { + return { + name: 'menubar-stubs', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^\.\/menubar-runtime-config$/ }, () => ({ path: 'menubar-runtime-config', namespace: 'menubar-stubs' })); + pluginBuild.onResolve({ filter: /^\.\/menubar-runtime-payload-cache$/ }, () => ({ path: 'menubar-runtime-payload-cache', namespace: 'menubar-stubs' })); + pluginBuild.onResolve({ filter: /^\.\/menubar-runtime-shared$/ }, () => ({ path: 'menubar-runtime-shared', namespace: 'menubar-stubs' })); + + pluginBuild.onLoad({ filter: /^menubar-runtime-config$/, namespace: 'menubar-stubs' }, () => ({ + loader: 'js', + contents: ` + export function getMenuBarRuntimeDeps() { + return globalThis.__SUPERCMD_MENUBAR_DEPS__; + } + `, + })); + pluginBuild.onLoad({ filter: /^menubar-runtime-payload-cache$/, namespace: 'menubar-stubs' }, () => ({ + loader: 'js', + contents: ` + export function createMenuBarVisiblePayloadHashCache() { + return {}; + } + export function shouldSendMenuBarVisiblePayload() { + return true; + } + `, + })); + pluginBuild.onLoad({ filter: /^menubar-runtime-shared$/, namespace: 'menubar-stubs' }, () => ({ + loader: 'js', + contents: ` + import { createContext } from 'react'; + export const MBRegistryContext = createContext(null); + export function initMenuBarClickListener() {} + export function removeMenuBarActions() {} + export function resetMenuBarOrderCounters() {} + export function setMenuBarActions() {} + export async function toMenuBarIconPayloadAsync() { + return undefined; + } + `, + })); + }, + }; +} + +async function importBundledModule(relativePath, plugins = []) { + const result = await build({ + entryPoints: [path.join(root, relativePath)], + bundle: true, + write: false, + platform: 'node', + format: 'esm', + jsx: 'transform', + tsconfigRaw: { + compilerOptions: { + jsx: 'react', + }, + }, + plugins: [reactStubPlugin(), ...plugins], + }); + return import(`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}`); +} + +function depsChanged(previous, next) { + if (!previous || !next || previous.length !== next.length) return true; + return next.some((value, index) => !Object.is(value, previous[index])); +} + +function createHookRuntime(label) { + const hooks = []; + const counters = { + label, + setStateCalls: 0, + postUnmountSetStateCalls: 0, + }; + let hookIndex = 0; + let mounted = true; + + const runtime = { + counters, + render(callback) { + mounted = true; + hookIndex = 0; + return callback(); + }, + unmount() { + mounted = false; + for (let index = hooks.length - 1; index >= 0; index -= 1) { + const cleanup = hooks[index]?.cleanup; + if (typeof cleanup === 'function') { + cleanup(); + hooks[index].cleanup = undefined; + } + } + }, + useCallback(callback, deps) { + return runtime.useMemo(() => callback, deps); + }, + useContext(context) { + return context?._currentValue; + }, + useEffect(effect, deps) { + const index = hookIndex; + hookIndex += 1; + const existing = hooks[index]; + if (existing && !depsChanged(existing.deps, deps)) return; + if (typeof existing?.cleanup === 'function') existing.cleanup(); + const cleanup = effect(); + hooks[index] = { deps, cleanup }; + }, + useMemo(factory, deps) { + const index = hookIndex; + hookIndex += 1; + const existing = hooks[index]; + if (existing && !depsChanged(existing.deps, deps)) return existing.value; + const value = factory(); + hooks[index] = { deps, value }; + return value; + }, + useRef(initialValue) { + const index = hookIndex; + hookIndex += 1; + if (!hooks[index]) hooks[index] = { current: initialValue }; + return hooks[index]; + }, + useState(initialValue) { + const index = hookIndex; + hookIndex += 1; + if (!hooks[index]) { + const state = { value: typeof initialValue === 'function' ? initialValue() : initialValue }; + const setState = (nextValue) => { + counters.setStateCalls += 1; + if (!mounted) counters.postUnmountSetStateCalls += 1; + state.value = typeof nextValue === 'function' ? nextValue(state.value) : nextValue; + }; + hooks[index] = { state, setState }; + } + return [hooks[index].state.value, hooks[index].setState]; + }, + }; + + return runtime; +} + +function createLegacyRegistryScheduler(label) { + const counters = { + label, + setStateCalls: 0, + postUnmountSetStateCalls: 0, + pendingCallbacks: 0, + }; + let mounted = true; + let pending = false; + + return { + counters, + schedule() { + if (pending) return; + pending = true; + counters.pendingCallbacks += 1; + queueMicrotask(() => { + pending = false; + counters.setStateCalls += 1; + if (!mounted) counters.postUnmountSetStateCalls += 1; + }); + }, + unmount() { + mounted = false; + }, + }; +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +function installRuntime(runtime) { + globalThis.__SUPERCMD_REACT_RUNTIME__ = runtime; +} + +test('legacy registry microtask scheduler calls setState after unmount', async () => { + const legacySurfaces = ['actions', 'list', 'grid', 'menubar'].map(createLegacyRegistryScheduler); + + for (const surface of legacySurfaces) { + surface.schedule(); + surface.unmount(); + } + await flushMicrotasks(); + + const metrics = Object.fromEntries(legacySurfaces.map((surface) => [surface.counters.label, surface.counters])); + for (const surface of legacySurfaces) { + assert.equal(surface.counters.pendingCallbacks, 1, `${surface.counters.label} should have queued one callback`); + assert.equal(surface.counters.postUnmountSetStateCalls, 1, `${surface.counters.label} legacy callback should set state after unmount`); + } + + console.log(JSON.stringify({ mode: 'registry-microtask-legacy-reproduction', metrics }, null, 2)); +}); + +test('registry microtasks scheduled before unmount are suppressed by runtime hooks', async () => { + const [actionModule, listModule, gridModule, menuBarModule] = await Promise.all([ + importBundledModule('src/renderer/src/raycast-api/action-runtime-registry.tsx'), + importBundledModule('src/renderer/src/raycast-api/list-runtime-hooks.ts'), + importBundledModule('src/renderer/src/raycast-api/grid-runtime-hooks.ts'), + importBundledModule('src/renderer/src/raycast-api/menubar-runtime-parent.tsx', [menuBarStubsPlugin()]), + ]); + + const actionRuntime = createHookRuntime('actions'); + installRuntime(actionRuntime); + const actionRegistryRuntime = actionModule.createActionRegistryRuntime({ + snapshotExtensionContext: () => ({}), + withExtensionContext: (_ctx, callback) => callback(), + ExtensionInfoReactContext: { _currentValue: { extId: 'ext/cmd', assetsPath: '', commandMode: 'view' } }, + getFormValues: () => ({}), + Clipboard: { copy: () => {} }, + trash: () => {}, + getGlobalNavigation: () => ({ push: () => {} }), + }); + const actionHook = actionRuntime.render(() => actionRegistryRuntime.useCollectedActions()); + actionHook.registryAPI.register('action-1', { title: 'Action', execute: () => {}, order: 1 }); + actionRuntime.unmount(); + actionHook.registryAPI.register('action-after-unmount', { title: 'Late', execute: () => {}, order: 2 }); + + const listRuntime = createHookRuntime('list'); + installRuntime(listRuntime); + const listHook = listRuntime.render(() => listModule.useListRegistry()); + listHook.registryAPI.set('list-1', { props: { id: 'list-1', title: 'List' }, order: 1 }); + listRuntime.unmount(); + listHook.registryAPI.set('list-after-unmount', { props: { id: 'late', title: 'Late' }, order: 2 }); + + const gridRuntime = createHookRuntime('grid'); + installRuntime(gridRuntime); + const gridHook = gridRuntime.render(() => gridModule.useGridRegistry()); + gridHook.registryAPI.set('grid-1', { props: { id: 'grid-1', title: 'Grid' }, order: 1 }); + gridRuntime.unmount(); + gridHook.registryAPI.set('grid-after-unmount', { props: { id: 'late', title: 'Late' }, order: 2 }); + + const menuBarRuntime = createHookRuntime('menubar'); + globalThis.window = { electron: { removeMenuBar: () => {}, updateMenuBar: () => {} } }; + globalThis.__SUPERCMD_MENUBAR_DEPS__ = { + ExtensionInfoReactContext: { _currentValue: { extId: 'ext/cmd', assetsPath: '', commandMode: 'menu-bar' } }, + getExtensionContext: () => ({ extensionName: 'ext', commandName: 'cmd', assetsPath: '', commandMode: 'menu-bar' }), + setExtensionContext: () => {}, + isEmojiOrSymbol: () => false, + }; + installRuntime(menuBarRuntime); + const menuTree = menuBarRuntime.render(() => menuBarModule.MenuBarExtraComponent({ children: null, title: 'Menu' })); + const menuRegistryAPI = menuTree.props.value; + menuRegistryAPI.register({ id: 'menu-1', type: 'item', title: 'Menu', order: 1 }); + menuBarRuntime.unmount(); + menuRegistryAPI.register({ id: 'menu-after-unmount', type: 'item', title: 'Late', order: 2 }); + + await flushMicrotasks(); + + const metrics = { + actions: actionRuntime.counters, + list: listRuntime.counters, + grid: gridRuntime.counters, + menubar: menuBarRuntime.counters, + }; + for (const [label, counters] of Object.entries(metrics)) { + assert.equal(counters.setStateCalls, 0, `${label} should suppress queued post-unmount updates`); + assert.equal(counters.postUnmountSetStateCalls, 0, `${label} should not call state after unmount`); + } + + console.log(JSON.stringify({ mode: 'registry-microtask-lifecycle-fixed', metrics }, null, 2)); +}); diff --git a/src/renderer/src/raycast-api/action-runtime-registry.tsx b/src/renderer/src/raycast-api/action-runtime-registry.tsx index 5c8c8db8..e362ffb7 100644 --- a/src/renderer/src/raycast-api/action-runtime-registry.tsx +++ b/src/renderer/src/raycast-api/action-runtime-registry.tsx @@ -348,14 +348,30 @@ export function createActionRegistryRuntime(deps: RegistryDeps) { function useCollectedActions() { const registryRef = useRef(new Map()); const [version, setVersion] = useState(0); + const mountedRef = useRef(true); const pendingRef = useRef(false); const lastSnapshotRef = useRef(''); + useEffect(() => { + mountedRef.current = true; + + return () => { + mountedRef.current = false; + pendingRef.current = false; + registryRef.current.clear(); + lastSnapshotRef.current = ''; + }; + }, []); + const scheduleUpdate = useCallback(() => { - if (pendingRef.current) return; + if (!mountedRef.current || pendingRef.current) return; pendingRef.current = true; queueMicrotask(() => { + if (!mountedRef.current) { + pendingRef.current = false; + return; + } pendingRef.current = false; const entries = Array.from(registryRef.current.values()); const snapshot = entries.map((entry) => `${entry.id}:${entry.title}:${entry.sectionTitle || ''}`).join('|'); @@ -369,6 +385,7 @@ export function createActionRegistryRuntime(deps: RegistryDeps) { const registryAPI = useMemo( () => ({ register(id, data) { + if (!mountedRef.current) return; const existing = registryRef.current.get(id); if (existing) { existing.title = data.title; @@ -384,6 +401,7 @@ export function createActionRegistryRuntime(deps: RegistryDeps) { scheduleUpdate(); }, unregister(id) { + if (!mountedRef.current) return; if (!registryRef.current.has(id)) return; registryRef.current.delete(id); scheduleUpdate(); diff --git a/src/renderer/src/raycast-api/grid-runtime-hooks.ts b/src/renderer/src/raycast-api/grid-runtime-hooks.ts index aada480b..c68ba996 100644 --- a/src/renderer/src/raycast-api/grid-runtime-hooks.ts +++ b/src/renderer/src/raycast-api/grid-runtime-hooks.ts @@ -4,19 +4,35 @@ * Extracted registry/grouping logic for the grid runtime container. */ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { GridItemRegistration, GridRegistryAPI } from './grid-runtime-items'; export function useGridRegistry() { const registryRef = useRef(new Map()); const [registryVersion, setRegistryVersion] = useState(0); + const mountedRef = useRef(true); const pendingRef = useRef(false); const lastSnapshotRef = useRef(''); + useEffect(() => { + mountedRef.current = true; + + return () => { + mountedRef.current = false; + pendingRef.current = false; + registryRef.current.clear(); + visibleSignatureRef.current.clear(); + }; + }, []); + const scheduleRegistryUpdate = useCallback(() => { - if (pendingRef.current) return; + if (!mountedRef.current || pendingRef.current) return; pendingRef.current = true; queueMicrotask(() => { + if (!mountedRef.current) { + pendingRef.current = false; + return; + } pendingRef.current = false; const snapshot = Array.from(registryRef.current.values()) .map((entry) => { @@ -35,6 +51,7 @@ export function useGridRegistry() { const registryAPI = useMemo( () => ({ set(id, data) { + if (!mountedRef.current) return; const existing = registryRef.current.get(id); if (existing) { existing.props = data.props; @@ -46,6 +63,7 @@ export function useGridRegistry() { scheduleRegistryUpdate(); }, delete(id) { + if (!mountedRef.current) return; if (!registryRef.current.has(id)) return; registryRef.current.delete(id); scheduleRegistryUpdate(); diff --git a/src/renderer/src/raycast-api/list-runtime-hooks.ts b/src/renderer/src/raycast-api/list-runtime-hooks.ts index bae2b9cd..e6a9eb93 100644 --- a/src/renderer/src/raycast-api/list-runtime-hooks.ts +++ b/src/renderer/src/raycast-api/list-runtime-hooks.ts @@ -4,7 +4,7 @@ * Extracted list registry/grouping helpers to keep List container module small. */ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ItemRegistration, ListRegistryAPI } from './list-runtime-types'; function getReactTypeName(type: any): string { @@ -45,13 +45,29 @@ function buildSnapshotSignature(value: unknown, seen = new WeakSet()): s export function useListRegistry() { const registryRef = useRef(new Map()); const [registryVersion, setRegistryVersion] = useState(0); + const mountedRef = useRef(true); const pendingRef = useRef(false); const lastSnapshotRef = useRef(''); + useEffect(() => { + mountedRef.current = true; + + return () => { + mountedRef.current = false; + pendingRef.current = false; + registryRef.current.clear(); + visibleSignatureRef.current.clear(); + }; + }, []); + const scheduleRegistryUpdate = useCallback(() => { - if (pendingRef.current) return; + if (!mountedRef.current || pendingRef.current) return; pendingRef.current = true; queueMicrotask(() => { + if (!mountedRef.current) { + pendingRef.current = false; + return; + } pendingRef.current = false; const snapshot = Array.from(registryRef.current.values()).map((item) => { const actionType = item.props.actions?.type as any; @@ -78,6 +94,7 @@ export function useListRegistry() { const registryAPI = useMemo(() => ({ set(id, data) { + if (!mountedRef.current) return; const existing = registryRef.current.get(id); if (existing) { // Hot path: an unrelated re-render (e.g. hover changing selection) @@ -98,6 +115,7 @@ export function useListRegistry() { scheduleRegistryUpdate(); }, delete(id) { + if (!mountedRef.current) return; if (!registryRef.current.has(id)) return; registryRef.current.delete(id); scheduleRegistryUpdate(); diff --git a/src/renderer/src/raycast-api/menubar-runtime-parent.tsx b/src/renderer/src/raycast-api/menubar-runtime-parent.tsx index bb523c32..4e5872c4 100644 --- a/src/renderer/src/raycast-api/menubar-runtime-parent.tsx +++ b/src/renderer/src/raycast-api/menubar-runtime-parent.tsx @@ -30,6 +30,7 @@ export function MenuBarExtraComponent({ children, icon, title, tooltip, isLoadin const registryRef = useRef(new Map()); const [registryVersion, setRegistryVersion] = useState(0); + const mountedRef = useRef(true); const pendingRef = useRef(false); resetMenuBarOrderCounters(); @@ -38,28 +39,44 @@ export function MenuBarExtraComponent({ children, icon, title, tooltip, isLoadin if (isMenuBar) initMenuBarClickListener(); }, [isMenuBar]); + useEffect(() => { + mountedRef.current = true; + + return () => { + mountedRef.current = false; + pendingRef.current = false; + registryRef.current.clear(); + visiblePayloadHashCacheRef.current = createMenuBarVisiblePayloadHashCache(); + serializedStaticPayloadRef.current = null; + }; + }, []); + + const scheduleRegistryUpdate = useCallback(() => { + if (!mountedRef.current || pendingRef.current) return; + + pendingRef.current = true; + queueMicrotask(() => { + if (!mountedRef.current) { + pendingRef.current = false; + return; + } + pendingRef.current = false; + setRegistryVersion((v) => v + 1); + }); + }, []); + const registryAPI = useMemo(() => ({ register: (item: MBItemRegistration) => { + if (!mountedRef.current) return; registryRef.current.set(item.id, item); - if (!pendingRef.current) { - pendingRef.current = true; - queueMicrotask(() => { - pendingRef.current = false; - setRegistryVersion((v) => v + 1); - }); - } + scheduleRegistryUpdate(); }, unregister: (id: string) => { + if (!mountedRef.current) return; registryRef.current.delete(id); - if (!pendingRef.current) { - pendingRef.current = true; - queueMicrotask(() => { - pendingRef.current = false; - setRegistryVersion((v) => v + 1); - }); - } + scheduleRegistryUpdate(); }, - }), []); + }), [scheduleRegistryUpdate]); useEffect(() => { if (!isMenuBar) return; From b4e5dab66ab567ab969906478dc5546e67fb9649 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:43:38 +0200 Subject: [PATCH 8/8] fix: align runtime consolidation integration --- scripts/lib/ts-import.mjs | 41 ++++++++++++++++--- src/main/commands.ts | 5 ++- src/main/main.ts | 2 +- src/renderer/src/ExtensionView.tsx | 5 +++ .../src/raycast-api/grid-runtime-hooks.ts | 2 +- .../src/raycast-api/list-runtime-hooks.ts | 2 +- .../raycast-api/menubar-runtime-parent.tsx | 4 +- 7 files changed, 47 insertions(+), 14 deletions(-) diff --git a/scripts/lib/ts-import.mjs b/scripts/lib/ts-import.mjs index 49df6079..9f5536c5 100644 --- a/scripts/lib/ts-import.mjs +++ b/scripts/lib/ts-import.mjs @@ -2,15 +2,44 @@ // esbuild on the fly. This lets the recovery tests run the *real* production // source (renderer-recovery.ts, reload-budget.ts) instead of grepping it. // -// Only works for modules with no relative imports of their own — the recovery -// helpers are deliberately dependency-free for exactly this reason. +// By default this stays intentionally lightweight for dependency-free helpers. +// Tests that need production modules with a few heavy dependencies can pass +// stubs keyed by import specifier. import { transform } from 'esbuild'; import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; -export async function importTs(absPath) { +function toDataUrl(code) { + return 'data:text/javascript;base64,' + Buffer.from(code).toString('base64'); +} + +async function transformTs(source) { + const { code } = await transform(source, { loader: 'ts', format: 'esm' }); + return code; +} + +function rewriteImportSpecifiers(code, absPath, options) { + const stubs = options.stubs || {}; + const stubUrls = new Map(Object.keys(stubs).map((specifier) => [specifier, toDataUrl(stubs[specifier])])); + + const rewriteSpecifier = (specifier) => { + if (stubUrls.has(specifier)) return stubUrls.get(specifier); + if ((specifier.startsWith('./') || specifier.startsWith('../')) && options.root) { + const resolved = path.resolve(path.dirname(absPath), specifier); + return pathToFileURL(resolved).href; + } + return specifier; + }; + + return code + .replace(/(\bfrom\s*["'])([^"']+)(["'])/g, (_match, prefix, specifier, suffix) => `${prefix}${rewriteSpecifier(specifier)}${suffix}`) + .replace(/(\bimport\s*["'])([^"']+)(["'])/g, (_match, prefix, specifier, suffix) => `${prefix}${rewriteSpecifier(specifier)}${suffix}`); +} + +export async function importTs(absPath, options = {}) { const src = fs.readFileSync(absPath, 'utf8'); - const { code } = await transform(src, { loader: 'ts', format: 'esm' }); - const dataUrl = 'data:text/javascript;base64,' + Buffer.from(code).toString('base64'); - return import(dataUrl); + const code = rewriteImportSpecifiers(await transformTs(src), absPath, options); + return import(toDataUrl(code)); } diff --git a/src/main/commands.ts b/src/main/commands.ts index 8f34ed81..03350a32 100644 --- a/src/main/commands.ts +++ b/src/main/commands.ts @@ -1416,8 +1416,9 @@ function applyRuntimeMetadataAndAliases(commands: CommandInfo[]): void { const commandMetadata = loadedSettings.commandMetadata || {}; const commandAliases = loadedSettings.commandAliases || {}; for (const cmd of commands) { - if (!(cmd.category === 'script' && cmd.mode !== 'inline') && commandMetadata[cmd.id]?.subtitle !== undefined) { - const subtitle = String(commandMetadata[cmd.id]?.subtitle || '').trim(); + const metadata = commandMetadata[cmd.id] || (cmd.path ? commandMetadata[cmd.path] : undefined); + if (!(cmd.category === 'script' && cmd.mode !== 'inline') && metadata?.subtitle !== undefined) { + const subtitle = String(metadata.subtitle || '').trim(); if (subtitle) { cmd.subtitle = subtitle; } diff --git a/src/main/main.ts b/src/main/main.ts index b3c32cce..4ba007e1 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -18499,7 +18499,7 @@ if let tiff = image?.tiffRepresentation { if (pullRequestFinished) return; pullRequestFinished = true; cleanupAbortListener(); - activeOllamaPullRequests.delete(requestId); + activeAIRequests.delete(requestId); }; if (controller.signal.aborted) { diff --git a/src/renderer/src/ExtensionView.tsx b/src/renderer/src/ExtensionView.tsx index aa445203..881312b6 100644 --- a/src/renderer/src/ExtensionView.tsx +++ b/src/renderer/src/ExtensionView.tsx @@ -3031,6 +3031,11 @@ for (const [key, val] of Object.entries({ ...nodeBuiltinStubs })) { } } +function shouldUseSuperCmdBuiltinFacade(name: string): boolean { + const normalizedName = name.startsWith('node:') ? name.slice(5) : name; + return normalizedName in nodeBuiltinStubs || `node:${normalizedName}` in nodeBuiltinStubs; +} + // ─── Real Node built-in bridge ────────────────────────────────────── // The launcher window runs with `sandbox: false` + `nodeIntegration: true` // + `contextIsolation: false`. In that mode Node's `require`, `process`, diff --git a/src/renderer/src/raycast-api/grid-runtime-hooks.ts b/src/renderer/src/raycast-api/grid-runtime-hooks.ts index c68ba996..dcbaccd5 100644 --- a/src/renderer/src/raycast-api/grid-runtime-hooks.ts +++ b/src/renderer/src/raycast-api/grid-runtime-hooks.ts @@ -21,7 +21,7 @@ export function useGridRegistry() { mountedRef.current = false; pendingRef.current = false; registryRef.current.clear(); - visibleSignatureRef.current.clear(); + lastSnapshotRef.current = ''; }; }, []); diff --git a/src/renderer/src/raycast-api/list-runtime-hooks.ts b/src/renderer/src/raycast-api/list-runtime-hooks.ts index e6a9eb93..3965d254 100644 --- a/src/renderer/src/raycast-api/list-runtime-hooks.ts +++ b/src/renderer/src/raycast-api/list-runtime-hooks.ts @@ -56,7 +56,7 @@ export function useListRegistry() { mountedRef.current = false; pendingRef.current = false; registryRef.current.clear(); - visibleSignatureRef.current.clear(); + lastSnapshotRef.current = ''; }; }, []); diff --git a/src/renderer/src/raycast-api/menubar-runtime-parent.tsx b/src/renderer/src/raycast-api/menubar-runtime-parent.tsx index 4e5872c4..e7f8c93f 100644 --- a/src/renderer/src/raycast-api/menubar-runtime-parent.tsx +++ b/src/renderer/src/raycast-api/menubar-runtime-parent.tsx @@ -3,7 +3,7 @@ * Purpose: MenuBarExtra parent component and native menu serialization/effects. */ -import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { getMenuBarRuntimeDeps } from './menubar-runtime-config'; import { type MBItemRegistration, @@ -46,8 +46,6 @@ export function MenuBarExtraComponent({ children, icon, title, tooltip, isLoadin mountedRef.current = false; pendingRef.current = false; registryRef.current.clear(); - visiblePayloadHashCacheRef.current = createMenuBarVisiblePayloadHashCache(); - serializedStaticPayloadRef.current = null; }; }, []);