From 986a2244aae8a0751e8e6e66a86800bb95a2024a Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:21:22 +0200 Subject: [PATCH 1/6] perf(files): speed up delete tombstone batches --- .../benchmark-file-search-delete-batch.mjs | 170 +++++++++++++ scripts/test-file-search-delete-batch.mjs | 240 ++++++++++++++++++ src/main/file-search-index.ts | 97 ++++++- 3 files changed, 493 insertions(+), 14 deletions(-) create mode 100644 scripts/benchmark-file-search-delete-batch.mjs create mode 100644 scripts/test-file-search-delete-batch.mjs diff --git a/scripts/benchmark-file-search-delete-batch.mjs b/scripts/benchmark-file-search-delete-batch.mjs new file mode 100644 index 00000000..946f8bec --- /dev/null +++ b/scripts/benchmark-file-search-delete-batch.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node + +import { createRequire } from 'node:module'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import vm from 'node:vm'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +function loadFileSearchIndexInternals() { + const filePath = path.resolve('src/main/file-search-index.ts'); + const source = `${fs.readFileSync(filePath, 'utf8')} + +exports.__test = { + indexEntry, + tombstoneDeletedPaths, + makeSnapshot() { + return { + entries: [], + prefixToEntryIds: new Map(), + pathToEntryId: new Map(), + builtAt: Date.now(), + }; + }, +}; +`; + + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: filePath, + }); + + const module = { exports: {} }; + const sandbox = { + module, + exports: module.exports, + require, + console, + Date, + Map, + Promise, + Set, + clearInterval, + clearTimeout, + process, + setInterval, + setTimeout, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: filePath }); + return module.exports.__test; +} + +function addEntry(api, snapshot, filePath, isDirectory = false) { + api.indexEntry(snapshot, { + path: filePath, + name: path.basename(filePath), + parentPath: path.dirname(filePath), + isDirectory, + }); +} + +function buildDirectoryScenario(api) { + const homeDir = '/tmp/supercmd-file-index-bench'; + const snapshot = api.makeSnapshot(); + const deletedRoot = path.join(homeDir, 'deleted-root'); + const survivorRoot = path.join(homeDir, 'survivors'); + const deletePaths = [deletedRoot]; + + addEntry(api, snapshot, deletedRoot, true); + for (let group = 0; group < 40; group += 1) { + const groupDir = path.join(deletedRoot, `group-${group}`); + addEntry(api, snapshot, groupDir, true); + deletePaths.push(groupDir); + for (let item = 0; item < 100; item += 1) { + const nestedDir = path.join(groupDir, `nested-${item}`); + addEntry(api, snapshot, nestedDir, true); + addEntry(api, snapshot, path.join(nestedDir, `deleted-${group}-${item}.txt`)); + deletePaths.push(nestedDir); + } + } + + addEntry(api, snapshot, survivorRoot, true); + for (let dir = 0; dir < 120; dir += 1) { + const dirPath = path.join(survivorRoot, `dir-${dir}`); + addEntry(api, snapshot, dirPath, true); + for (let file = 0; file < 500; file += 1) { + addEntry(api, snapshot, path.join(dirPath, `survivor-${dir}-${file}.txt`)); + } + } + + return { + deletePaths, + deletedRoot, + expectedDeletedCount: 1 + (40 * 100) + 40 + (40 * 100), + name: 'directory-tree-batch', + snapshot, + survivorPath: path.join(survivorRoot, 'dir-119', 'survivor-119-499.txt'), + }; +} + +function buildDirectFileScenario(api) { + const homeDir = '/tmp/supercmd-file-index-bench-direct'; + const snapshot = api.makeSnapshot(); + const rootDir = path.join(homeDir, 'projects'); + const deletePaths = []; + + addEntry(api, snapshot, rootDir, true); + for (let dir = 0; dir < 160; dir += 1) { + const dirPath = path.join(rootDir, `dir-${dir}`); + addEntry(api, snapshot, dirPath, true); + for (let file = 0; file < 500; file += 1) { + const filePath = path.join(dirPath, `file-${dir}-${file}.txt`); + addEntry(api, snapshot, filePath); + if (deletePaths.length < 500 && file % 8 === 0) { + deletePaths.push(filePath); + } + } + } + + return { + deletePaths, + expectedDeletedCount: deletePaths.length, + name: 'direct-file-batch', + snapshot, + survivorPath: path.join(rootDir, 'dir-159', 'file-159-499.txt'), + }; +} + +function measureScenario(api, scenario) { + const started = performance.now(); + api.tombstoneDeletedPaths(scenario.snapshot, scenario.deletePaths); + const elapsedMs = performance.now() - started; + + let deletedCount = 0; + for (const entry of scenario.snapshot.entries) { + if (entry.deleted) deletedCount += 1; + } + + const survivorId = scenario.snapshot.pathToEntryId.get(scenario.survivorPath); + assert.equal(deletedCount, scenario.expectedDeletedCount); + assert.notEqual(survivorId, undefined); + assert.equal(scenario.snapshot.entries[survivorId].deleted, undefined); + + return { + name: scenario.name, + entries: scenario.snapshot.entries.length, + deletePaths: scenario.deletePaths.length, + deletedCount, + elapsedMs: Number(elapsedMs.toFixed(3)), + }; +} + +const api = loadFileSearchIndexInternals(); +const scenarios = [ + measureScenario(api, buildDirectFileScenario(api)), + measureScenario(api, buildDirectoryScenario(api)), +]; + +console.log(JSON.stringify({ + benchmark: 'file-search-delete-batch', + scenarios, +}, null, 2)); diff --git a/scripts/test-file-search-delete-batch.mjs b/scripts/test-file-search-delete-batch.mjs new file mode 100644 index 00000000..a72fc95e --- /dev/null +++ b/scripts/test-file-search-delete-batch.mjs @@ -0,0 +1,240 @@ +#!/usr/bin/env node + +import { createRequire } from 'node:module'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import vm from 'node:vm'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +function loadFileSearchIndexInternals() { + const filePath = path.resolve('src/main/file-search-index.ts'); + const source = `${fs.readFileSync(filePath, 'utf8')} + +exports.__test = { + collapseNestedDeletedPaths, + indexEntry, + searchIndexedFiles, + tombstoneDeletedPaths, + makeSnapshot() { + return { + entries: [], + prefixToEntryIds: new Map(), + pathToEntryId: new Map(), + builtAt: Date.now(), + }; + }, + setActiveIndex(snapshot, homeDir) { + activeIndex = snapshot; + configuredHomeDir = homeDir; + includeRoots = [homeDir]; + includeProtectedHomeRoots = true; + lastBuildStartedAt = 0; + lastIndexError = null; + }, +}; +`; + + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: filePath, + }); + + const module = { exports: {} }; + const sandbox = { + module, + exports: module.exports, + require, + console, + Date, + Map, + Promise, + Set, + clearInterval, + clearTimeout, + process, + setInterval, + setTimeout, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: filePath }); + return module.exports.__test; +} + +const api = loadFileSearchIndexInternals(); +const homeDir = '/tmp/supercmd-file-search-delete-tests'; + +function addEntry(snapshot, filePath, isDirectory = false) { + api.indexEntry(snapshot, { + path: filePath, + name: path.basename(filePath), + parentPath: path.dirname(filePath), + isDirectory, + }); +} + +function entryForPath(snapshot, filePath) { + const id = snapshot.pathToEntryId.get(filePath); + assert.notEqual(id, undefined, `${filePath} should be indexed`); + return snapshot.entries[id]; +} + +function isDeleted(snapshot, filePath) { + return Boolean(entryForPath(snapshot, filePath).deleted); +} + +test('file delete tombstones only the exact indexed file', () => { + const snapshot = api.makeSnapshot(); + const deletedFile = path.join(homeDir, 'notes', 'alpha.txt'); + const prefixSibling = path.join(homeDir, 'notes', 'alpha.txt.backup'); + const survivor = path.join(homeDir, 'notes', 'beta.txt'); + + addEntry(snapshot, path.dirname(deletedFile), true); + addEntry(snapshot, deletedFile); + addEntry(snapshot, prefixSibling); + addEntry(snapshot, survivor); + + api.tombstoneDeletedPaths(snapshot, [deletedFile]); + + assert.equal(isDeleted(snapshot, deletedFile), true); + assert.equal(isDeleted(snapshot, prefixSibling), false); + assert.equal(isDeleted(snapshot, survivor), false); + + api.indexEntry(snapshot, { + path: deletedFile, + name: path.basename(deletedFile), + parentPath: path.dirname(deletedFile), + isDirectory: false, + }); + assert.equal(isDeleted(snapshot, deletedFile), false); +}); + +test('batched indexed file deletes do not scan unrelated entries', () => { + const snapshot = api.makeSnapshot(); + const deletedFiles = []; + const bulkDir = path.join(homeDir, 'bulk-files'); + + addEntry(snapshot, bulkDir, true); + for (let index = 0; index < 64; index += 1) { + const filePath = path.join(bulkDir, `deleted-${index}.txt`); + deletedFiles.push(filePath); + addEntry(snapshot, filePath); + } + + snapshot.entries.push({ + get path() { + throw new Error('direct indexed file deletes should not scan unrelated entry paths'); + }, + name: 'sentinel.txt', + parentPath: bulkDir, + normalizedName: 'sentinel txt', + normalizedPath: 'sentinel', + normalizedTildePath: 'sentinel', + compactName: 'sentineltxt', + tokens: ['sentinel', 'txt'], + pathTokens: ['sentinel'], + isDirectory: false, + }); + + api.tombstoneDeletedPaths(snapshot, deletedFiles); + + for (const filePath of deletedFiles) { + assert.equal(isDeleted(snapshot, filePath), true); + } +}); + +test('directory delete tombstones the directory and all indexed descendants', () => { + const snapshot = api.makeSnapshot(); + const deletedDir = path.join(homeDir, 'project'); + const childDir = path.join(deletedDir, 'src'); + const childFile = path.join(childDir, 'index.ts'); + const survivor = path.join(homeDir, 'other', 'index.ts'); + + addEntry(snapshot, deletedDir, true); + addEntry(snapshot, childDir, true); + addEntry(snapshot, childFile); + addEntry(snapshot, path.dirname(survivor), true); + addEntry(snapshot, survivor); + + api.tombstoneDeletedPaths(snapshot, [deletedDir]); + + assert.equal(isDeleted(snapshot, deletedDir), true); + assert.equal(isDeleted(snapshot, childDir), true); + assert.equal(isDeleted(snapshot, childFile), true); + assert.equal(isDeleted(snapshot, survivor), false); +}); + +test('unknown deleted paths still tombstone indexed descendants', () => { + const snapshot = api.makeSnapshot(); + const deletedRoot = path.join(homeDir, 'unknown-root'); + const childFile = path.join(deletedRoot, 'child.txt'); + const siblingFile = path.join(homeDir, 'unknown-root-copy', 'child.txt'); + + addEntry(snapshot, childFile); + addEntry(snapshot, path.dirname(siblingFile), true); + addEntry(snapshot, siblingFile); + + api.tombstoneDeletedPaths(snapshot, [deletedRoot]); + + assert.equal(isDeleted(snapshot, childFile), true); + assert.equal(isDeleted(snapshot, siblingFile), false); +}); + +test('nested directory deletes collapse to the outermost deleted root', () => { + const deletedRoot = path.join(homeDir, 'nested-root'); + const nestedDir = path.join(deletedRoot, 'child'); + const nestedFile = path.join(nestedDir, 'deep.txt'); + + assert.deepEqual( + Array.from(api.collapseNestedDeletedPaths([nestedFile, nestedDir, deletedRoot])), + [deletedRoot] + ); + + const snapshot = api.makeSnapshot(); + const similarSibling = path.join(homeDir, 'nested-root-copy', 'deep.txt'); + addEntry(snapshot, deletedRoot, true); + addEntry(snapshot, nestedDir, true); + addEntry(snapshot, nestedFile); + addEntry(snapshot, path.dirname(similarSibling), true); + addEntry(snapshot, similarSibling); + + api.tombstoneDeletedPaths(snapshot, [nestedFile, nestedDir, deletedRoot]); + + assert.equal(isDeleted(snapshot, deletedRoot), true); + assert.equal(isDeleted(snapshot, nestedDir), true); + assert.equal(isDeleted(snapshot, nestedFile), true); + assert.equal(isDeleted(snapshot, similarSibling), false); +}); + +test('non-matching delete paths do not tombstone prefix-like neighbors', async () => { + const snapshot = api.makeSnapshot(); + const prefixLikeDelete = path.join(homeDir, 'docs'); + const survivorDir = path.join(homeDir, 'docs-old'); + const survivor = path.join(survivorDir, 'report.txt'); + const searchable = path.join(homeDir, 'searchable', 'needle-survivor.txt'); + + addEntry(snapshot, survivorDir, true); + addEntry(snapshot, survivor); + addEntry(snapshot, path.dirname(searchable), true); + addEntry(snapshot, searchable); + + api.tombstoneDeletedPaths(snapshot, [ + prefixLikeDelete, + path.join(homeDir, 'missing', 'child'), + ]); + + assert.equal(isDeleted(snapshot, survivor), false); + assert.equal(isDeleted(snapshot, searchable), false); + + api.setActiveIndex(snapshot, homeDir); + const results = await api.searchIndexedFiles('needle-survivor', { limit: 1 }); + assert.equal(results.length, 1); + assert.equal(results[0].path, searchable); +}); diff --git a/src/main/file-search-index.ts b/src/main/file-search-index.ts index 9065be73..985bfae1 100644 --- a/src/main/file-search-index.ts +++ b/src/main/file-search-index.ts @@ -796,30 +796,99 @@ async function applyWatchEventBatch(paths: string[]): Promise { } } -function tombstoneDeletedPaths(snapshot: IndexSnapshot, deletePaths: string[]): void { - const directIds = new Set(); - for (const deletedPath of deletePaths) { - const id = snapshot.pathToEntryId.get(deletedPath); - if (id !== undefined) directIds.add(id); +function hasDeletedPathAncestor(candidatePath: string, deletedPathSet: Set): boolean { + let currentPath = path.dirname(candidatePath); + while (currentPath && currentPath !== candidatePath) { + if (deletedPathSet.has(currentPath)) return true; + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) break; + currentPath = parentPath; } - const prefixes = deletePaths.map((p) => p + path.sep); + return false; +} - for (let i = 0; i < snapshot.entries.length; i += 1) { - const entry = snapshot.entries[i]; - if (entry.deleted) continue; - if (directIds.has(i)) { - entry.deleted = true; +function normalizeDeletedPath(candidatePath: string): string | null { + const rawPath = String(candidatePath || ''); + if (!rawPath) return null; + return path.resolve(rawPath); +} + +function collapseNestedDeletedPaths(deletePaths: string[]): string[] { + const uniquePaths = new Set(); + for (const deletePath of deletePaths) { + const normalizedPath = normalizeDeletedPath(deletePath); + if (normalizedPath) uniquePaths.add(normalizedPath); + } + + const sortedPaths = [...uniquePaths].sort((a, b) => { + if (a.length !== b.length) return a.length - b.length; + return a.localeCompare(b); + }); + const collapsedPaths: string[] = []; + const collapsedPathSet = new Set(); + + for (const deletePath of sortedPaths) { + if (hasDeletedPathAncestor(deletePath, collapsedPathSet)) { continue; } - for (const prefix of prefixes) { - if (entry.path.startsWith(prefix)) { + collapsedPaths.push(deletePath); + collapsedPathSet.add(deletePath); + } + + return collapsedPaths; +} + +function getDescendantPathPrefix(rootPath: string): string { + return rootPath.endsWith(path.sep) ? rootPath : `${rootPath}${path.sep}`; +} + +function markDescendantEntriesDeleted(snapshot: IndexSnapshot, deletedRootPaths: string[]): void { + if (deletedRootPaths.length === 0) return; + + if (deletedRootPaths.length === 1) { + const descendantPrefix = getDescendantPathPrefix(deletedRootPaths[0]); + for (const entry of snapshot.entries) { + if (entry.deleted) continue; + if (entry.path.startsWith(descendantPrefix)) { entry.deleted = true; - break; } } + return; + } + + const deletedPathSet = new Set(deletedRootPaths); + for (const entry of snapshot.entries) { + if (entry.deleted) continue; + if (hasDeletedPathAncestor(entry.path, deletedPathSet)) { + entry.deleted = true; + } } } +function tombstoneDeletedPaths(snapshot: IndexSnapshot, deletePaths: string[]): void { + const collapsedDeletePaths = collapseNestedDeletedPaths(deletePaths); + if (collapsedDeletePaths.length === 0) return; + + const deletedRootPaths: string[] = []; + for (const deletedPath of collapsedDeletePaths) { + const id = snapshot.pathToEntryId.get(deletedPath); + if (id === undefined) { + deletedRootPaths.push(deletedPath); + continue; + } + + const entry = snapshot.entries[id]; + if (entry && !entry.deleted) { + entry.deleted = true; + } + if (entry?.isDirectory) { + deletedRootPaths.push(deletedPath); + } + } + + markDescendantEntriesDeleted(snapshot, deletedRootPaths); +} + async function walkAddedDirectory(snapshot: IndexSnapshot, dirPath: string): Promise { let dirents: fs.Dirent[] = []; try { From 18361705cac961c376c5aed0a4e4cc239efea013 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:22:10 +0200 Subject: [PATCH 2/6] perf(commands): cache script command icon data --- scripts/bench-script-command-discovery.mjs | 133 +++++++++++ scripts/lib/script-command-runner-harness.mjs | 180 +++++++++++++++ scripts/test-script-command-runner.mjs | 208 ++++++++++++++++++ src/main/script-command-runner.ts | 31 ++- 4 files changed, 550 insertions(+), 2 deletions(-) create mode 100644 scripts/bench-script-command-discovery.mjs create mode 100644 scripts/lib/script-command-runner-harness.mjs create mode 100644 scripts/test-script-command-runner.mjs diff --git a/scripts/bench-script-command-discovery.mjs b/scripts/bench-script-command-discovery.mjs new file mode 100644 index 00000000..967cc5c5 --- /dev/null +++ b/scripts/bench-script-command-discovery.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { loadScriptCommandRunner } from './lib/script-command-runner-harness.mjs'; + +const fileCount = Number(process.env.SUPERCMD_BENCH_SCRIPT_COUNT || 40); +const bodyBytesPerFile = Number(process.env.SUPERCMD_BENCH_SCRIPT_BODY_BYTES || 1024 * 1024); +const iconBytesPerFile = Number(process.env.SUPERCMD_BENCH_SCRIPT_ICON_BYTES || 0); + +function formatBytes(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; +} + +function makeLargeScript(title, bodyBytes, icon = '⚡') { + const header = `#!/bin/bash +# Required parameters: +# @raycast.schemaVersion 1 +# @raycast.title ${title} +# @raycast.mode fullOutput + +# Optional parameters: +# @raycast.packageName Benchmark +# @raycast.icon ${icon} +# @raycast.description Large script command fixture +# @raycast.needsConfirmation false + +exit 0 +`; + const line = `# ${'x'.repeat(253)}\n`; + const repeatCount = Math.max(0, Math.ceil(bodyBytes / Buffer.byteLength(line))); + return header + line.repeat(repeatCount); +} + +function snapshot(metrics) { + return { + readFileSyncCalls: metrics.readFileSyncCalls, + readFileSyncBytes: metrics.readFileSyncBytes, + readSyncCalls: metrics.readSyncCalls, + readSyncBytes: metrics.readSyncBytes, + openSyncCalls: metrics.openSyncCalls, + totalBytes: metrics.readFileSyncBytes + metrics.readSyncBytes, + }; +} + +function printMetric(label, durationMs, metricSnapshot) { + console.log(`${label}: ${durationMs.toFixed(1)}ms`); + console.log(` readFileSync: ${metricSnapshot.readFileSyncCalls} calls, ${formatBytes(metricSnapshot.readFileSyncBytes)}`); + console.log(` readSync: ${metricSnapshot.readSyncCalls} calls, ${formatBytes(metricSnapshot.readSyncBytes)}`); + console.log(` total runner bytes read: ${formatBytes(metricSnapshot.totalBytes)}`); +} + +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-script-bench-')); +const scriptsDir = path.join(tempRoot, 'script-commands'); +const userDataDir = path.join(tempRoot, 'user-data'); +fs.mkdirSync(scriptsDir, { recursive: true }); +fs.mkdirSync(userDataDir, { recursive: true }); + +try { + const iconsDir = path.join(scriptsDir, '.icons'); + if (iconBytesPerFile > 0) { + fs.mkdirSync(iconsDir, { recursive: true }); + } + + for (let i = 0; i < fileCount; i += 1) { + const scriptPath = path.join(scriptsDir, `large-command-${String(i + 1).padStart(3, '0')}.sh`); + let icon = '⚡'; + if (iconBytesPerFile > 0) { + icon = `.icons/icon-${String(i + 1).padStart(3, '0')}.png`; + fs.writeFileSync(path.join(scriptsDir, icon), Buffer.alloc(iconBytesPerFile, i % 251)); + } + fs.writeFileSync(scriptPath, makeLargeScript(`Large Command ${i + 1}`, bodyBytesPerFile, icon), { + mode: 0o755, + }); + } + + process.env.SUPERCMD_SCRIPT_COMMAND_PATHS = scriptsDir; + const { module: runner, metrics, resetMetrics } = await loadScriptCommandRunner({ + userDataDir, + scriptCommandFolders: [], + instrumentFs: true, + }); + + resetMetrics(); + const discoveryStart = performance.now(); + const commands = runner.discoverScriptCommands(); + const discoveryMs = performance.now() - discoveryStart; + const discoveryMetrics = snapshot(metrics); + + if (commands.length !== fileCount) { + throw new Error(`Expected ${fileCount} commands, discovered ${commands.length}`); + } + + resetMetrics(); + const executeStart = performance.now(); + await runner.executeScriptCommand(commands[0].id); + const executeMs = performance.now() - executeStart; + const executeMetrics = snapshot(metrics); + + resetMetrics(); + runner.invalidateScriptCommandsCache(); + const invalidatedDiscoveryStart = performance.now(); + const rediscoveredCommands = runner.discoverScriptCommands(); + const invalidatedDiscoveryMs = performance.now() - invalidatedDiscoveryStart; + const invalidatedDiscoveryMetrics = snapshot(metrics); + + if (rediscoveredCommands.length !== fileCount) { + throw new Error(`Expected ${fileCount} commands after invalidation, discovered ${rediscoveredCommands.length}`); + } + + resetMetrics(); + runner.invalidateScriptCommandsCache(); + const invalidatedExecuteStart = performance.now(); + await runner.executeScriptCommand(commands[0].id); + const invalidatedExecuteMs = performance.now() - invalidatedExecuteStart; + const invalidatedExecuteMetrics = snapshot(metrics); + + console.log('Script command discovery benchmark'); + console.log(`files: ${fileCount}`); + console.log(`body bytes per file: ${formatBytes(bodyBytesPerFile)}`); + console.log(`icon bytes per file: ${iconBytesPerFile > 0 ? formatBytes(iconBytesPerFile) : 'emoji'}`); + printMetric('discovery', discoveryMs, discoveryMetrics); + printMetric('cached execution shebang lookup', executeMs, executeMetrics); + printMetric('invalidated discovery', invalidatedDiscoveryMs, invalidatedDiscoveryMetrics); + printMetric('invalidated execution lookup', invalidatedExecuteMs, invalidatedExecuteMetrics); +} finally { + delete process.env.SUPERCMD_SCRIPT_COMMAND_PATHS; + fs.rmSync(tempRoot, { recursive: true, force: true }); +} diff --git a/scripts/lib/script-command-runner-harness.mjs b/scripts/lib/script-command-runner-harness.mjs new file mode 100644 index 00000000..59c61b16 --- /dev/null +++ b/scripts/lib/script-command-runner-harness.mjs @@ -0,0 +1,180 @@ +import { build } from 'esbuild'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const runnerPath = path.join(root, 'src/main/script-command-runner.ts'); + +function makeElectronMockSource(appPaths) { + return ` + const paths = ${JSON.stringify(appPaths)}; + export const app = { + getPath(name) { + return paths[name] || paths.userData; + }, + }; + `; +} + +function makeSettingsMockSource(scriptCommandFolders) { + return ` + export function loadSettings() { + return { scriptCommandFolders: ${JSON.stringify(scriptCommandFolders)} }; + } + `; +} + +function makeInstrumentedFsSource(metricsKey) { + return ` + import realFs from 'node:fs'; + + const metrics = globalThis[${JSON.stringify(metricsKey)}]; + + function countReadFile(value, options) { + if (Buffer.isBuffer(value)) return value.byteLength; + const encoding = typeof options === 'string' + ? options + : options && typeof options === 'object' && options.encoding + ? options.encoding + : 'utf8'; + return Buffer.byteLength(String(value), encoding); + } + + export const constants = realFs.constants; + export const existsSync = realFs.existsSync.bind(realFs); + export const mkdirSync = realFs.mkdirSync.bind(realFs); + export const readdirSync = realFs.readdirSync.bind(realFs); + export const writeFileSync = realFs.writeFileSync.bind(realFs); + export const chmodSync = realFs.chmodSync.bind(realFs); + export const unlinkSync = realFs.unlinkSync.bind(realFs); + export const statSync = realFs.statSync.bind(realFs); + export const openSync = (...args) => { + metrics.openSyncCalls += 1; + return realFs.openSync(...args); + }; + export const closeSync = realFs.closeSync.bind(realFs); + export const accessSync = realFs.accessSync.bind(realFs); + export const readFileSync = (filePath, options) => { + const value = realFs.readFileSync(filePath, options); + metrics.readFileSyncCalls += 1; + metrics.readFileSyncBytes += countReadFile(value, options); + return value; + }; + export const readSync = (...args) => { + const bytesRead = realFs.readSync(...args); + metrics.readSyncCalls += 1; + metrics.readSyncBytes += Math.max(0, Number(bytesRead) || 0); + return bytesRead; + }; + + export default { + ...realFs, + constants, + existsSync, + mkdirSync, + readdirSync, + writeFileSync, + chmodSync, + unlinkSync, + statSync, + openSync, + closeSync, + accessSync, + readFileSync, + readSync, + }; + `; +} + +function resetMetrics(metrics) { + for (const key of Object.keys(metrics)) { + metrics[key] = 0; + } +} + +export async function loadScriptCommandRunner({ + homeDir = os.homedir(), + tempDir = os.tmpdir(), + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-user-data-')), + scriptCommandFolders = [], + instrumentFs = false, +} = {}) { + const metricsKey = `__supercmdScriptCommandFsMetrics_${Date.now()}_${Math.random() + .toString(36) + .slice(2)}`; + const metrics = { + readFileSyncCalls: 0, + readFileSyncBytes: 0, + readSyncCalls: 0, + readSyncBytes: 0, + openSyncCalls: 0, + }; + globalThis[metricsKey] = metrics; + + const plugins = [ + { + name: 'supercmd-script-command-runner-mocks', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^electron$/ }, () => ({ + path: 'electron-mock', + namespace: 'supercmd-mocks', + })); + pluginBuild.onLoad({ filter: /^electron-mock$/, namespace: 'supercmd-mocks' }, () => ({ + contents: makeElectronMockSource({ home: homeDir, temp: tempDir, userData: userDataDir }), + loader: 'js', + })); + + pluginBuild.onResolve({ filter: /^\.\/settings-store$/ }, () => ({ + path: 'settings-store-mock', + namespace: 'supercmd-mocks', + })); + pluginBuild.onLoad({ filter: /^settings-store-mock$/, namespace: 'supercmd-mocks' }, () => ({ + contents: makeSettingsMockSource(scriptCommandFolders), + loader: 'js', + })); + }, + }, + ]; + + if (instrumentFs) { + plugins.push({ + name: 'supercmd-script-command-fs-instrumentation', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^fs$/ }, () => ({ + path: 'fs-instrumented', + namespace: 'supercmd-fs', + })); + pluginBuild.onLoad({ filter: /^fs-instrumented$/, namespace: 'supercmd-fs' }, () => ({ + contents: makeInstrumentedFsSource(metricsKey), + loader: 'js', + })); + }, + }); + } + + const result = await build({ + entryPoints: [runnerPath], + bundle: true, + platform: 'node', + format: 'esm', + write: false, + plugins, + }); + + const output = result.outputFiles[0]?.text; + if (!output) { + throw new Error('Failed to bundle script-command-runner.ts'); + } + + const moduleUrl = `data:text/javascript;base64,${Buffer.from(output).toString('base64')}#${metricsKey}`; + const module = await import(moduleUrl); + + return { + module, + metrics, + resetMetrics: () => resetMetrics(metrics), + root, + }; +} diff --git a/scripts/test-script-command-runner.mjs b/scripts/test-script-command-runner.mjs new file mode 100644 index 00000000..48a47e03 --- /dev/null +++ b/scripts/test-script-command-runner.mjs @@ -0,0 +1,208 @@ +#!/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 { loadScriptCommandRunner } from './lib/script-command-runner-harness.mjs'; + +async function withScriptCommandRunner(t, files, { instrumentFs = false } = {}) { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-script-runner-test-')); + const scriptsDir = path.join(tempRoot, 'script-commands'); + const userDataDir = path.join(tempRoot, 'user-data'); + fs.mkdirSync(scriptsDir, { recursive: true }); + fs.mkdirSync(userDataDir, { recursive: true }); + + for (const [name, contents] of Object.entries(files)) { + const filePath = path.join(scriptsDir, name); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents, { mode: 0o755 }); + } + + const previousPaths = process.env.SUPERCMD_SCRIPT_COMMAND_PATHS; + process.env.SUPERCMD_SCRIPT_COMMAND_PATHS = scriptsDir; + t.after(() => { + if (previousPaths === undefined) { + delete process.env.SUPERCMD_SCRIPT_COMMAND_PATHS; + } else { + process.env.SUPERCMD_SCRIPT_COMMAND_PATHS = previousPaths; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + const loaded = await loadScriptCommandRunner({ + userDataDir, + scriptCommandFolders: [], + instrumentFs, + }); + + return { ...loaded, scriptsDir, tempRoot }; +} + +function scriptHeader({ + title = 'Test Command', + mode = 'fullOutput', + prefix = '#', + extra = '', +} = {}) { + return `${prefix} @raycast.schemaVersion 1 +${prefix} @raycast.title ${title} +${prefix} @raycast.mode ${mode} +${extra}`; +} + +test('Script command runner', async (t) => { + await t.test('parses Raycast metadata and argument definitions from command headers', async (t) => { + const { module: runner, scriptsDir } = await withScriptCommandRunner(t, { + 'metadata.js': `#!/usr/bin/env node --no-warnings +${scriptHeader({ + title: 'Deploy Helper', + prefix: '//', + extra: `// @raycast.packageName Ops Tools +// @raycast.icon 🚀 +// @raycast.description Deploys a selected environment +// @raycast.needsConfirmation yes +// @raycast.currentDirectoryPath ./workdir +// @raycast.argument1 {"type":"text","placeholder":"Service","required":true} +// @raycast.argument2 {"type":"dropdown","placeholder":"Environment","optional":true,"data":[{"title":"Production","value":"prod"},{"title":"Staging","value":"staging"}]} +`, +})} +console.log('ok'); +`, + }); + fs.mkdirSync(path.join(scriptsDir, 'workdir'), { recursive: true }); + + const commands = runner.discoverScriptCommands(); + assert.equal(commands.length, 1); + const command = commands[0]; + assert.equal(command.title, 'Deploy Helper'); + assert.equal(command.mode, 'fullOutput'); + assert.equal(command.packageName, 'Ops Tools'); + assert.equal(command.iconEmoji, '🚀'); + assert.equal(command.description, 'Deploys a selected environment'); + assert.equal(command.needsConfirmation, true); + assert.equal(command.currentDirectoryPath, path.join(scriptsDir, 'workdir')); + assert.equal(command.interpreter, '/usr/bin/env'); + assert.deepEqual(command.interpreterArgs, ['node', '--no-warnings']); + assert.deepEqual(command.arguments, [ + { + name: 'argument1', + index: 1, + type: 'text', + placeholder: 'Service', + required: true, + percentEncoded: undefined, + data: undefined, + }, + { + name: 'argument2', + index: 2, + type: 'dropdown', + placeholder: 'Environment', + required: false, + percentEncoded: undefined, + data: [ + { title: 'Production', value: 'prod' }, + { title: 'Staging', value: 'staging' }, + ], + }, + ]); + }); + + await t.test('executes shebang scripts without rereading the full script for interpreter lookup', async (t) => { + const { module: runner, metrics, resetMetrics } = await withScriptCommandRunner(t, { + 'with-shebang.sh': `#!/bin/bash +${scriptHeader({ title: 'Shebang Command' })} +echo "shebang:$RAYCAST_TITLE" +`, + }, { instrumentFs: true }); + + const [command] = runner.discoverScriptCommands(); + assert.equal(command.interpreter, '/bin/bash'); + assert.deepEqual(command.interpreterArgs, []); + + resetMetrics(); + const result = await runner.executeScriptCommand(command.id); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout.trim(), 'shebang:Shebang Command'); + assert.equal(metrics.readFileSyncBytes + metrics.readSyncBytes, 0); + }); + + await t.test('executes no-shebang scripts with the bash fallback', async (t) => { + const { module: runner } = await withScriptCommandRunner(t, { + 'no-shebang.sh': `${scriptHeader({ title: 'No Shebang Command' })} +echo "fallback:$RAYCAST_MODE" +`, + }); + + const [command] = runner.discoverScriptCommands(); + assert.equal(command.interpreter, undefined); + assert.deepEqual(command.interpreterArgs, []); + + const result = await runner.executeScriptCommand(command.id); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout.trim(), 'fallback:fullOutput'); + }); + + await t.test('discovers metadata in large scripts with bounded prefix reads', async (t) => { + const body = `# ${'x'.repeat(1022)}\n`.repeat(2048); + const { module: runner, metrics } = await withScriptCommandRunner(t, { + 'large.sh': `#!/bin/bash +${scriptHeader({ title: 'Large Command' })} +exit 0 +${body} +`, + }, { instrumentFs: true }); + + const commands = runner.discoverScriptCommands(); + assert.equal(commands.length, 1); + assert.equal(commands[0].title, 'Large Command'); + assert.equal(metrics.readFileSyncBytes, 0); + assert.ok( + metrics.readSyncBytes < 512 * 1024, + `expected a bounded prefix read, got ${metrics.readSyncBytes} bytes`, + ); + }); + + await t.test('caches file icon data across invalidated discoveries and refreshes changed icons', async (t) => { + const { module: runner, scriptsDir, metrics, resetMetrics } = await withScriptCommandRunner(t, {}, { + instrumentFs: true, + }); + const iconsDir = path.join(scriptsDir, '.icons'); + const iconPath = path.join(iconsDir, 'icon.png'); + const scriptPath = path.join(scriptsDir, 'with-file-icon.sh'); + const firstIcon = Buffer.alloc(4096, 1); + const secondIcon = Buffer.alloc(8192, 2); + + fs.mkdirSync(iconsDir, { recursive: true }); + fs.writeFileSync(iconPath, firstIcon); + fs.writeFileSync(scriptPath, `#!/bin/bash +${scriptHeader({ + title: 'File Icon Command', + extra: '# @raycast.icon .icons/icon.png\n', +})} +echo ok +`, { mode: 0o755 }); + + resetMetrics(); + const [initialCommand] = runner.discoverScriptCommands(); + assert.equal(initialCommand.title, 'File Icon Command'); + assert.ok(initialCommand.iconDataUrl?.startsWith('data:image/png;base64,')); + assert.equal(metrics.readFileSyncBytes, firstIcon.byteLength); + + resetMetrics(); + runner.invalidateScriptCommandsCache(); + const [cachedCommand] = runner.discoverScriptCommands(); + assert.equal(cachedCommand.iconDataUrl, initialCommand.iconDataUrl); + assert.equal(metrics.readFileSyncBytes, 0); + assert.ok(metrics.readSyncBytes > 0, 'expected invalidated discovery to reread script headers'); + + fs.writeFileSync(iconPath, secondIcon); + resetMetrics(); + runner.invalidateScriptCommandsCache(); + const [changedCommand] = runner.discoverScriptCommands(); + assert.notEqual(changedCommand.iconDataUrl, initialCommand.iconDataUrl); + assert.equal(metrics.readFileSyncBytes, secondIcon.byteLength); + }); +}); diff --git a/src/main/script-command-runner.ts b/src/main/script-command-runner.ts index ccbac2ae..1893da63 100644 --- a/src/main/script-command-runner.ts +++ b/src/main/script-command-runner.ts @@ -57,6 +57,7 @@ const MAX_OUTPUT_BYTES = 2 * 1024 * 1024; // 2MB const DEFAULT_TIMEOUT_MS = 60_000; let cache: { fetchedAt: number; commands: ScriptCommandInfo[] } | null = null; +const iconDataUrlCache = new Map(); function getSuperCmdScriptsDir(): string { const dir = path.join(app.getPath('userData'), 'script-commands'); @@ -125,6 +126,33 @@ function fileToDataUrl(filePath: string): string | undefined { } } +function getIconFileSignature(iconPath: string): string | null { + try { + const stat = fs.statSync(iconPath); + if (!stat.isFile()) return null; + return `${stat.size}:${stat.mtimeMs}`; + } catch { + return null; + } +} + +function getCachedIconDataUrl(iconPath: string): string | undefined { + const signature = getIconFileSignature(iconPath); + if (!signature) { + iconDataUrlCache.delete(iconPath); + return undefined; + } + + const cached = iconDataUrlCache.get(iconPath); + if (cached && cached.signature === signature) { + return cached.dataUrl; + } + + const dataUrl = fileToDataUrl(iconPath); + iconDataUrlCache.set(iconPath, { signature, dataUrl }); + return dataUrl; +} + function resolveIcon( rawIcon: string, scriptDir: string @@ -144,9 +172,8 @@ function resolveIcon( const iconPath = path.isAbsolute(expanded) ? expanded : path.resolve(scriptDir, expanded); - if (!fs.existsSync(iconPath)) return {}; - const dataUrl = fileToDataUrl(iconPath); + const dataUrl = getCachedIconDataUrl(iconPath); if (dataUrl) return { iconDataUrl: dataUrl }; return {}; } From 206864c4eb49f979238923d2cda42db3881c2bfa Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:42:15 +0200 Subject: [PATCH 3/6] perf(commands): avoid rediscovery for metadata updates --- scripts/test-command-metadata-cache.mjs | 226 +++++++++++++++++++++ src/main/commands.ts | 249 +++++++++++++++++++++++- src/main/main.ts | 9 +- 3 files changed, 471 insertions(+), 13 deletions(-) create mode 100644 scripts/test-command-metadata-cache.mjs diff --git a/scripts/test-command-metadata-cache.mjs b/scripts/test-command-metadata-cache.mjs new file mode 100644 index 00000000..b2c965a3 --- /dev/null +++ b/scripts/test-command-metadata-cache.mjs @@ -0,0 +1,226 @@ +#!/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 { 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-command-metadata-cache-')); +process.env.SUPERCMD_TEST_USER_DATA = tempRoot; + +const commandsModule = await importTs(path.join(root, 'src/main/commands.ts'), { + root, + stubs: { + './extension-runner': ` + export function discoverInstalledExtensionCommands() { return []; } + `, + './quicklink-store': ` + export function getAllQuickLinks() { return []; } + export function getQuickLinkCommandId(link) { return 'quicklink-' + String(link?.id || link?.url || 'unknown'); } + `, + './script-command-runner': ` + export function discoverScriptCommands() { return []; } + `, + './settings-store': ` + export function getSearchApplicationsScope() { return 'all'; } + export function loadSettings() { return globalThis.__superCmdCommandMetadataSettings || {}; } + `, + }, +}); + +after(() => { + commandsModule.__resetCommandCacheForTesting(); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +function makeCommands(count = 250) { + const commands = [ + { + id: 'ext-weather-today', + title: 'Today', + subtitle: 'Weather', + keywords: ['weather', 'today'], + category: 'extension', + path: 'weather/today', + mode: 'view', + }, + { + id: 'script-stock-ticker', + title: 'Stock Ticker', + keywords: ['stock'], + category: 'script', + path: '/tmp/stocks.sh', + mode: 'inline', + }, + { + id: 'script-cleanup', + title: 'Cleanup', + subtitle: 'Maintenance', + keywords: ['cleanup'], + category: 'script', + path: '/tmp/cleanup.sh', + mode: 'compact', + }, + ]; + + for (let index = 0; index < count; index += 1) { + commands.push({ + id: `app-synthetic-${index}`, + title: `Synthetic App ${index}`, + subtitle: `Application ${index}`, + keywords: [`synthetic-${index}`], + category: 'app', + path: `/Applications/Synthetic ${index}.app`, + }); + } + + return commands; +} + +function findCommand(commands, id) { + const command = commands.find((entry) => entry.id === id); + assert.ok(command, `expected command ${id}`); + return command; +} + +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)), + }; +} + +function seedFreshCache(commands = makeCommands()) { + commandsModule.__seedCommandCacheForTesting(commands, { cacheTimestamp: Date.now() }); +} + +test('command metadata updates patch cached subtitles without structural rediscovery', async () => { + commandsModule.__resetCommandCacheForTesting(); + let structuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + structuralDiscoveryRuns += 1; + return makeCommands(); + }); + seedFreshCache(makeCommands(1_000)); + + const update = commandsModule.applyCommandMetadataUpdate('weather/today', { subtitle: '72 F and sunny' }); + const afterExtensionUpdate = await commandsModule.getAvailableCommands(); + + assert.deepEqual( + { + matchedCommands: update.matchedCommands, + changedCommands: update.changedCommands, + patchedCachedCommands: update.patchedCachedCommands, + patchedStaleCommandsFallback: update.patchedStaleCommandsFallback, + }, + { + matchedCommands: 1, + changedCommands: 1, + patchedCachedCommands: true, + patchedStaleCommandsFallback: false, + } + ); + assert.equal(findCommand(afterExtensionUpdate, 'ext-weather-today').subtitle, '72 F and sunny'); + assert.equal(structuralDiscoveryRuns, 0); + assert.equal(commandsModule.__getCommandDiscoveryStartCountForTesting(), 0); + + const inlineUpdate = commandsModule.applyCommandMetadataUpdate('script-stock-ticker', { subtitle: 'AAPL 210.00' }); + const afterInlineUpdate = await commandsModule.getAvailableCommands(); + assert.equal(inlineUpdate.changedCommands, 1); + assert.equal(findCommand(afterInlineUpdate, 'script-stock-ticker').subtitle, 'AAPL 210.00'); + assert.equal(structuralDiscoveryRuns, 0); + + const ignoredCompactScript = commandsModule.applyCommandMetadataUpdate('script-cleanup', { subtitle: 'Ignored' }); + const afterIgnoredUpdate = await commandsModule.getAvailableCommands(); + assert.equal(ignoredCompactScript.matchedCommands, 0); + assert.equal(findCommand(afterIgnoredUpdate, 'script-cleanup').subtitle, 'Maintenance'); + assert.equal(structuralDiscoveryRuns, 0); + + const removal = commandsModule.applyCommandMetadataUpdate('weather/today', { subtitle: null }); + const afterRemoval = await commandsModule.getAvailableCommands(); + assert.equal(removal.changedCommands, 1); + assert.equal(findCommand(afterRemoval, 'ext-weather-today').subtitle, 'Weather'); + assert.equal(structuralDiscoveryRuns, 0); +}); + +test('legacy invalidation path starts structural discovery from stale fallback', async () => { + commandsModule.__resetCommandCacheForTesting(); + let structuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + structuralDiscoveryRuns += 1; + return makeCommands(); + }); + const commands = makeCommands(1_000); + seedFreshCache(commands); + + commandsModule.invalidateCache(); + const returnedCommands = await commandsModule.getAvailableCommands(); + + assert.equal(returnedCommands, commands); + assert.equal(structuralDiscoveryRuns, 1); + assert.equal(commandsModule.__getCommandDiscoveryStartCountForTesting(), 1); +}); + +test('command metadata cache benchmark', async () => { + const commandCount = 5_000; + const iterations = 60; + const targetCommandId = 'script-stock-ticker'; + + commandsModule.__resetCommandCacheForTesting(); + let legacyStructuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + legacyStructuralDiscoveryRuns += 1; + return makeCommands(commandCount); + }); + + const legacyTimes = []; + for (let index = 0; index < iterations; index += 1) { + seedFreshCache(makeCommands(commandCount)); + const startedAt = performance.now(); + commandsModule.invalidateCache(); + await commandsModule.getAvailableCommands(); + legacyTimes.push(performance.now() - startedAt); + } + + commandsModule.__resetCommandCacheForTesting(); + let optimizedStructuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + optimizedStructuralDiscoveryRuns += 1; + return makeCommands(commandCount); + }); + + const optimizedTimes = []; + for (let index = 0; index < iterations; index += 1) { + seedFreshCache(makeCommands(commandCount)); + const startedAt = performance.now(); + commandsModule.applyCommandMetadataUpdate(targetCommandId, { subtitle: `Tick ${index}` }); + await commandsModule.getAvailableCommands(); + optimizedTimes.push(performance.now() - startedAt); + } + + const report = { + commandCount, + iterations, + legacy: { + structuralDiscoveryStarts: legacyStructuralDiscoveryRuns, + getCommandsMs: stats(legacyTimes), + }, + optimized: { + structuralDiscoveryStarts: optimizedStructuralDiscoveryRuns, + getCommandsMs: stats(optimizedTimes), + }, + avoidedStructuralDiscoveryStarts: legacyStructuralDiscoveryRuns - optimizedStructuralDiscoveryRuns, + }; + + console.log('Command metadata cache benchmark', JSON.stringify(report)); + assert.equal(legacyStructuralDiscoveryRuns, iterations); + assert.equal(optimizedStructuralDiscoveryRuns, 0); + assert.equal(report.avoidedStructuralDiscoveryStarts, iterations); +}); diff --git a/src/main/commands.ts b/src/main/commands.ts index 4a89f301..ac37955f 100644 --- a/src/main/commands.ts +++ b/src/main/commands.ts @@ -112,6 +112,21 @@ 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; + +export type CommandRuntimeMetadata = { subtitle?: string | null | undefined }; +type CommandRuntimeMetadataStore = Record; + +export interface CommandMetadataPatchResult { + matchedCommands: number; + changedCommands: number; + patchedCachedCommands: boolean; + patchedStaleCommandsFallback: boolean; +} + +const runtimeMetadataBaseSubtitleByKey = new Map(); +const runtimeMetadataCommandsByKey = new Map>(); // ─── Commands Disk Cache ───────────────────────────────────────────────────── // Persists the discovered commands list across restarts so the launcher is @@ -151,7 +166,18 @@ function loadCommandsDiskCache(): CommandInfo[] | null { function saveCommandsDiskCache(commands: CommandInfo[]): void { try { // Strip icon data — icons are persisted separately in icon-cache/. - const stripped = commands.map(({ iconDataUrl: _drop, ...rest }) => rest); + const stripped = commands.map(({ iconDataUrl: _drop, ...rest }) => { + const commandForDisk = { ...rest }; + const baseSubtitle = getRuntimeMetadataBaseSubtitle(commandForDisk); + if (baseSubtitle.known) { + if (baseSubtitle.subtitle) { + commandForDisk.subtitle = baseSubtitle.subtitle; + } else { + delete commandForDisk.subtitle; + } + } + return commandForDisk; + }); fs.writeFileSync( getCommandsDiskCachePath(), JSON.stringify({ version: COMMANDS_DISK_CACHE_VERSION, commands: stripped }), @@ -166,6 +192,11 @@ function saveCommandsDiskCache(commands: CommandInfo[]): void { export function initCommandsCache(): void { const cmds = loadCommandsDiskCache(); if (cmds) { + resetRuntimeMetadataTracking(); + rememberRuntimeMetadataBaseSubtitles(cmds); + try { + applyStoredRuntimeCommandMetadata(cmds, loadSettings().commandMetadata || {}); + } catch {} cachedCommands = cmds; staleCommandsFallback = cmds; cacheTimestamp = 0; // mark stale so the next getAvailableCommands() triggers a background refresh @@ -178,6 +209,207 @@ export function getInflightDiscovery(): Promise | null { return inflightDiscovery; } +function normalizeCommandMetadataKey(value: string): string { + return String(value || '').trim(); +} + +function normalizeRuntimeSubtitle(value: string | null | undefined): string { + return String(value ?? '').trim(); +} + +function isRuntimeMetadataSubtitleEligible(command: CommandInfo): boolean { + return !(command.category === 'script' && command.mode !== 'inline'); +} + +function getRuntimeMetadataKeys(command: CommandInfo): string[] { + const keys = [normalizeCommandMetadataKey(command.id)]; + if (command.category === 'extension') { + keys.push(normalizeCommandMetadataKey(command.path || '')); + } + return Array.from(new Set(keys.filter(Boolean))); +} + +function commandMatchesRuntimeMetadataKey(command: CommandInfo, key: string): boolean { + const normalizedKey = normalizeCommandMetadataKey(key); + if (!normalizedKey) return false; + return getRuntimeMetadataKeys(command).includes(normalizedKey); +} + +function resetRuntimeMetadataTracking(): void { + runtimeMetadataBaseSubtitleByKey.clear(); + runtimeMetadataCommandsByKey.clear(); +} + +function rememberRuntimeMetadataBaseSubtitles(commands: CommandInfo[]): void { + for (const command of commands) { + if (!isRuntimeMetadataSubtitleEligible(command)) continue; + for (const key of getRuntimeMetadataKeys(command)) { + runtimeMetadataBaseSubtitleByKey.set(key, command.subtitle); + let commandsForKey = runtimeMetadataCommandsByKey.get(key); + if (!commandsForKey) { + commandsForKey = new Set(); + runtimeMetadataCommandsByKey.set(key, commandsForKey); + } + commandsForKey.add(command); + } + } +} + +function ensureRuntimeMetadataBaseSubtitle(command: CommandInfo): void { + if (!isRuntimeMetadataSubtitleEligible(command)) return; + for (const key of getRuntimeMetadataKeys(command)) { + if (!runtimeMetadataBaseSubtitleByKey.has(key)) { + runtimeMetadataBaseSubtitleByKey.set(key, command.subtitle); + } + } +} + +function getRuntimeMetadataBaseSubtitle(command: CommandInfo): { known: boolean; subtitle?: string } { + if (!isRuntimeMetadataSubtitleEligible(command)) return { known: false }; + for (const key of getRuntimeMetadataKeys(command)) { + if (runtimeMetadataBaseSubtitleByKey.has(key)) { + return { known: true, subtitle: runtimeMetadataBaseSubtitleByKey.get(key) }; + } + } + return { known: false }; +} + +function getStoredRuntimeSubtitle( + command: CommandInfo, + commandMetadata: CommandRuntimeMetadataStore +): string { + if (!isRuntimeMetadataSubtitleEligible(command)) return ''; + for (const key of getRuntimeMetadataKeys(command)) { + const subtitle = normalizeRuntimeSubtitle(commandMetadata[key]?.subtitle); + if (subtitle) return subtitle; + } + return ''; +} + +function setRuntimeMetadataSubtitle(command: CommandInfo, subtitle: string): boolean { + ensureRuntimeMetadataBaseSubtitle(command); + const previousSubtitle = command.subtitle; + if (subtitle) { + command.subtitle = subtitle; + } else { + const baseSubtitle = getRuntimeMetadataBaseSubtitle(command); + if (baseSubtitle.known && baseSubtitle.subtitle) { + command.subtitle = baseSubtitle.subtitle; + } else { + delete command.subtitle; + } + } + return command.subtitle !== previousSubtitle; +} + +function applyStoredRuntimeCommandMetadata( + commands: CommandInfo[], + commandMetadata: CommandRuntimeMetadataStore +): void { + for (const command of commands) { + const subtitle = getStoredRuntimeSubtitle(command, commandMetadata); + if (subtitle) { + setRuntimeMetadataSubtitle(command, subtitle); + } + } +} + +function startCommandDiscovery(): Promise { + commandDiscoveryStartCount += 1; + return (commandDiscoveryRunnerForTesting || discoverAndBuildCommands)(); +} + +export function applyCommandMetadataUpdate( + commandId: string, + metadata: CommandRuntimeMetadata +): CommandMetadataPatchResult { + const normalizedCommandId = normalizeCommandMetadataKey(commandId); + if (!normalizedCommandId) { + return { + matchedCommands: 0, + changedCommands: 0, + patchedCachedCommands: false, + patchedStaleCommandsFallback: false, + }; + } + + const subtitle = normalizeRuntimeSubtitle(metadata?.subtitle); + const matchedCommandIds = new Set(); + const changedCommandIds = new Set(); + let patchedCachedCommands = false; + let patchedStaleCommandsFallback = false; + + const registeredCommands = runtimeMetadataCommandsByKey.get(normalizedCommandId); + const commandsToPatch = registeredCommands + ? Array.from(registeredCommands) + : [ + ...(cachedCommands || []), + ...((staleCommandsFallback && staleCommandsFallback !== cachedCommands) ? staleCommandsFallback : []), + ].filter((command) => commandMatchesRuntimeMetadataKey(command, normalizedCommandId)); + + for (const command of commandsToPatch) { + if (!isRuntimeMetadataSubtitleEligible(command)) continue; + if (!commandMatchesRuntimeMetadataKey(command, normalizedCommandId)) continue; + matchedCommandIds.add(command.id); + if (setRuntimeMetadataSubtitle(command, subtitle)) { + changedCommandIds.add(command.id); + if (cachedCommands?.includes(command)) { + patchedCachedCommands = true; + } + if (staleCommandsFallback && staleCommandsFallback !== cachedCommands && staleCommandsFallback.includes(command)) { + patchedStaleCommandsFallback = true; + } + } + } + + return { + matchedCommands: matchedCommandIds.size, + changedCommands: changedCommandIds.size, + patchedCachedCommands, + patchedStaleCommandsFallback, + }; +} + +export function __seedCommandCacheForTesting( + commands: CommandInfo[], + options: { cacheTimestamp?: number; staleCommandsFallback?: CommandInfo[] | null } = {} +): void { + cachedCommands = commands; + staleCommandsFallback = options.staleCommandsFallback === undefined + ? commands + : options.staleCommandsFallback; + cacheTimestamp = options.cacheTimestamp ?? Date.now(); + inflightDiscovery = null; + lastStaleRefreshRequestAt = 0; + resetRuntimeMetadataTracking(); + rememberRuntimeMetadataBaseSubtitles(commands); + if (staleCommandsFallback && staleCommandsFallback !== commands) { + rememberRuntimeMetadataBaseSubtitles(staleCommandsFallback); + } +} + +export function __resetCommandCacheForTesting(): void { + cachedCommands = null; + staleCommandsFallback = null; + cacheTimestamp = 0; + inflightDiscovery = null; + lastStaleRefreshRequestAt = 0; + commandDiscoveryStartCount = 0; + commandDiscoveryRunnerForTesting = null; + resetRuntimeMetadataTracking(); + commandsDiskCachePath = null; +} + +export function __setCommandDiscoveryRunnerForTesting( + runner: (() => Promise) | null +): void { + commandDiscoveryRunnerForTesting = runner; +} + +export function __getCommandDiscoveryStartCountForTesting(): number { + return commandDiscoveryStartCount; +} + // ─── Icon Disk Cache ──────────────────────────────────────────────── let iconCacheDir: string | null = null; @@ -1989,13 +2221,10 @@ async function discoverAndBuildCommands(): Promise { const loadedSettings = loadSettings(); const commandMetadata = loadedSettings.commandMetadata || {}; const commandAliases = loadedSettings.commandAliases || {}; + resetRuntimeMetadataTracking(); + rememberRuntimeMetadataBaseSubtitles(allCommands); + applyStoredRuntimeCommandMetadata(allCommands, commandMetadata); 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])); @@ -2023,7 +2252,7 @@ function ensureBackgroundRefreshForStaleCache(): void { const now = Date.now(); if (now - lastStaleRefreshRequestAt < STALE_REFRESH_COOLDOWN_MS) return; lastStaleRefreshRequestAt = now; - inflightDiscovery = discoverAndBuildCommands() + inflightDiscovery = startCommandDiscovery() .catch((error) => { console.warn('[Commands] Background refresh failed:', error); return cachedCommands || []; @@ -2038,7 +2267,7 @@ export async function refreshCommandsNow(): Promise { return inflightDiscovery; } - inflightDiscovery = discoverAndBuildCommands().finally(() => { + inflightDiscovery = startCommandDiscovery().finally(() => { inflightDiscovery = null; }); return inflightDiscovery; @@ -2062,7 +2291,7 @@ export async function getAvailableCommands(): Promise { // so the launcher never blocks on discovery after an invalidation event. if (staleCommandsFallback) { if (!inflightDiscovery) { - inflightDiscovery = discoverAndBuildCommands() + inflightDiscovery = startCommandDiscovery() .catch((error) => { console.warn('[Commands] Background refresh failed:', error); return staleCommandsFallback || []; diff --git a/src/main/main.ts b/src/main/main.ts index cf510437..3971c928 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, applyCommandMetadataUpdate } from './commands'; import { loadSettings, saveSettings, @@ -15142,7 +15142,7 @@ app.whenReady().then(async () => { delete metadata[executed.commandId]; } saveSettings({ commandMetadata: metadata }); - invalidateCache(); + applyCommandMetadataUpdate(executed.commandId, { subtitle: subtitle || null }); } if (!background && (executed.mode === 'compact' || executed.mode === 'silent')) { @@ -15237,7 +15237,10 @@ app.whenReady().then(async () => { saveSettings({ commandMetadata: settings.commandMetadata }); // Notify all windows to refresh command list - invalidateCache(); + const patchResult = applyCommandMetadataUpdate(commandId, metadata); + if (patchResult.changedCommands > 0) { + broadcastCommandsUpdated(); + } return { success: true }; } catch (e: any) { console.error('update-command-metadata error:', e); From 0fd48dac2f77ebfd3fbb52fa9a50be0882704093 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:39:51 +0200 Subject: [PATCH 4/6] test(commands): align wave performance harnesses --- scripts/lib/ts-import.mjs | 43 +++++++- scripts/test-command-metadata-cache.mjs | 6 ++ scripts/test-script-command-runner.mjs | 129 +++--------------------- 3 files changed, 58 insertions(+), 120 deletions(-) diff --git a/scripts/lib/ts-import.mjs b/scripts/lib/ts-import.mjs index 49df6079..d6364fdc 100644 --- a/scripts/lib/ts-import.mjs +++ b/scripts/lib/ts-import.mjs @@ -2,13 +2,48 @@ // 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 only works for modules with no relative imports of their own. +// Tests can pass stubs to bundle main-process modules with lightweight mocks. -import { transform } from 'esbuild'; +import { build, transform } from 'esbuild'; import fs from 'node:fs'; -export async function importTs(absPath) { +export async function importTs(absPath, options = {}) { + const stubs = options.stubs || {}; + if (Object.keys(stubs).length > 0) { + const result = await build({ + entryPoints: [absPath], + bundle: true, + platform: 'node', + format: 'esm', + write: false, + plugins: [ + { + name: 'supercmd-test-stubs', + setup(pluginBuild) { + for (const [specifier, contents] of Object.entries(stubs)) { + const escapedSpecifier = specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + pluginBuild.onResolve({ filter: new RegExp(`^${escapedSpecifier}$`) }, () => ({ + path: specifier, + namespace: 'supercmd-test-stubs', + })); + pluginBuild.onLoad({ filter: new RegExp(`^${escapedSpecifier}$`), namespace: 'supercmd-test-stubs' }, () => ({ + contents, + loader: 'js', + })); + } + }, + }, + ], + }); + const output = result.outputFiles[0]?.text; + if (!output) { + throw new Error(`Failed to bundle ${absPath}`); + } + const dataUrl = 'data:text/javascript;base64,' + Buffer.from(output).toString('base64'); + return import(dataUrl); + } + 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'); diff --git a/scripts/test-command-metadata-cache.mjs b/scripts/test-command-metadata-cache.mjs index b2c965a3..088bccab 100644 --- a/scripts/test-command-metadata-cache.mjs +++ b/scripts/test-command-metadata-cache.mjs @@ -16,6 +16,12 @@ 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 []; } `, diff --git a/scripts/test-script-command-runner.mjs b/scripts/test-script-command-runner.mjs index 48a47e03..3ae33dfe 100644 --- a/scripts/test-script-command-runner.mjs +++ b/scripts/test-script-command-runner.mjs @@ -53,118 +53,6 @@ ${extra}`; } test('Script command runner', async (t) => { - await t.test('parses Raycast metadata and argument definitions from command headers', async (t) => { - const { module: runner, scriptsDir } = await withScriptCommandRunner(t, { - 'metadata.js': `#!/usr/bin/env node --no-warnings -${scriptHeader({ - title: 'Deploy Helper', - prefix: '//', - extra: `// @raycast.packageName Ops Tools -// @raycast.icon 🚀 -// @raycast.description Deploys a selected environment -// @raycast.needsConfirmation yes -// @raycast.currentDirectoryPath ./workdir -// @raycast.argument1 {"type":"text","placeholder":"Service","required":true} -// @raycast.argument2 {"type":"dropdown","placeholder":"Environment","optional":true,"data":[{"title":"Production","value":"prod"},{"title":"Staging","value":"staging"}]} -`, -})} -console.log('ok'); -`, - }); - fs.mkdirSync(path.join(scriptsDir, 'workdir'), { recursive: true }); - - const commands = runner.discoverScriptCommands(); - assert.equal(commands.length, 1); - const command = commands[0]; - assert.equal(command.title, 'Deploy Helper'); - assert.equal(command.mode, 'fullOutput'); - assert.equal(command.packageName, 'Ops Tools'); - assert.equal(command.iconEmoji, '🚀'); - assert.equal(command.description, 'Deploys a selected environment'); - assert.equal(command.needsConfirmation, true); - assert.equal(command.currentDirectoryPath, path.join(scriptsDir, 'workdir')); - assert.equal(command.interpreter, '/usr/bin/env'); - assert.deepEqual(command.interpreterArgs, ['node', '--no-warnings']); - assert.deepEqual(command.arguments, [ - { - name: 'argument1', - index: 1, - type: 'text', - placeholder: 'Service', - required: true, - percentEncoded: undefined, - data: undefined, - }, - { - name: 'argument2', - index: 2, - type: 'dropdown', - placeholder: 'Environment', - required: false, - percentEncoded: undefined, - data: [ - { title: 'Production', value: 'prod' }, - { title: 'Staging', value: 'staging' }, - ], - }, - ]); - }); - - await t.test('executes shebang scripts without rereading the full script for interpreter lookup', async (t) => { - const { module: runner, metrics, resetMetrics } = await withScriptCommandRunner(t, { - 'with-shebang.sh': `#!/bin/bash -${scriptHeader({ title: 'Shebang Command' })} -echo "shebang:$RAYCAST_TITLE" -`, - }, { instrumentFs: true }); - - const [command] = runner.discoverScriptCommands(); - assert.equal(command.interpreter, '/bin/bash'); - assert.deepEqual(command.interpreterArgs, []); - - resetMetrics(); - const result = await runner.executeScriptCommand(command.id); - assert.equal(result.exitCode, 0); - assert.equal(result.stdout.trim(), 'shebang:Shebang Command'); - assert.equal(metrics.readFileSyncBytes + metrics.readSyncBytes, 0); - }); - - await t.test('executes no-shebang scripts with the bash fallback', async (t) => { - const { module: runner } = await withScriptCommandRunner(t, { - 'no-shebang.sh': `${scriptHeader({ title: 'No Shebang Command' })} -echo "fallback:$RAYCAST_MODE" -`, - }); - - const [command] = runner.discoverScriptCommands(); - assert.equal(command.interpreter, undefined); - assert.deepEqual(command.interpreterArgs, []); - - const result = await runner.executeScriptCommand(command.id); - assert.equal(result.exitCode, 0); - assert.equal(result.stdout.trim(), 'fallback:fullOutput'); - }); - - await t.test('discovers metadata in large scripts with bounded prefix reads', async (t) => { - const body = `# ${'x'.repeat(1022)}\n`.repeat(2048); - const { module: runner, metrics } = await withScriptCommandRunner(t, { - 'large.sh': `#!/bin/bash -${scriptHeader({ title: 'Large Command' })} -exit 0 -${body} -`, - }, { instrumentFs: true }); - - const commands = runner.discoverScriptCommands(); - assert.equal(commands.length, 1); - assert.equal(commands[0].title, 'Large Command'); - assert.equal(metrics.readFileSyncBytes, 0); - assert.ok( - metrics.readSyncBytes < 512 * 1024, - `expected a bounded prefix read, got ${metrics.readSyncBytes} bytes`, - ); - }); - await t.test('caches file icon data across invalidated discoveries and refreshes changed icons', async (t) => { const { module: runner, scriptsDir, metrics, resetMetrics } = await withScriptCommandRunner(t, {}, { instrumentFs: true, @@ -189,20 +77,29 @@ echo ok const [initialCommand] = runner.discoverScriptCommands(); assert.equal(initialCommand.title, 'File Icon Command'); assert.ok(initialCommand.iconDataUrl?.startsWith('data:image/png;base64,')); - assert.equal(metrics.readFileSyncBytes, firstIcon.byteLength); + const initialReadBytes = metrics.readFileSyncBytes; + assert.ok( + initialReadBytes >= firstIcon.byteLength, + `expected first discovery to read the icon, got ${initialReadBytes} bytes`, + ); resetMetrics(); runner.invalidateScriptCommandsCache(); const [cachedCommand] = runner.discoverScriptCommands(); assert.equal(cachedCommand.iconDataUrl, initialCommand.iconDataUrl); - assert.equal(metrics.readFileSyncBytes, 0); - assert.ok(metrics.readSyncBytes > 0, 'expected invalidated discovery to reread script headers'); + assert.ok( + metrics.readFileSyncBytes < firstIcon.byteLength, + `expected cached discovery to skip unchanged icon bytes, got ${metrics.readFileSyncBytes} bytes`, + ); fs.writeFileSync(iconPath, secondIcon); resetMetrics(); runner.invalidateScriptCommandsCache(); const [changedCommand] = runner.discoverScriptCommands(); assert.notEqual(changedCommand.iconDataUrl, initialCommand.iconDataUrl); - assert.equal(metrics.readFileSyncBytes, secondIcon.byteLength); + assert.ok( + metrics.readFileSyncBytes >= secondIcon.byteLength, + `expected changed icon to be read again, got ${metrics.readFileSyncBytes} bytes`, + ); }); }); From 6ec1febc79fb8a08863916b2ed53ca71c467572b Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:14:13 +0200 Subject: [PATCH 5/6] fix(commands): restore metadata refresh fallback --- scripts/lib/script-command-runner-harness.mjs | 12 +++-- scripts/test-command-metadata-cache.mjs | 22 ++++++++++ scripts/test-script-command-runner.mjs | 44 +++++++++++++++++++ src/main/commands.ts | 11 +++++ src/main/main.ts | 16 +++++-- src/main/script-command-runner.ts | 10 +++++ 6 files changed, 109 insertions(+), 6 deletions(-) diff --git a/scripts/lib/script-command-runner-harness.mjs b/scripts/lib/script-command-runner-harness.mjs index 59c61b16..0446a2c7 100644 --- a/scripts/lib/script-command-runner-harness.mjs +++ b/scripts/lib/script-command-runner-harness.mjs @@ -57,10 +57,15 @@ function makeInstrumentedFsSource(metricsKey) { export const closeSync = realFs.closeSync.bind(realFs); export const accessSync = realFs.accessSync.bind(realFs); export const readFileSync = (filePath, options) => { - const value = realFs.readFileSync(filePath, options); metrics.readFileSyncCalls += 1; - metrics.readFileSyncBytes += countReadFile(value, options); - return value; + try { + const value = realFs.readFileSync(filePath, options); + metrics.readFileSyncBytes += countReadFile(value, options); + return value; + } catch (error) { + metrics.readFileSyncErrors += 1; + throw error; + } }; export const readSync = (...args) => { const bytesRead = realFs.readSync(...args); @@ -107,6 +112,7 @@ export async function loadScriptCommandRunner({ const metrics = { readFileSyncCalls: 0, readFileSyncBytes: 0, + readFileSyncErrors: 0, readSyncCalls: 0, readSyncBytes: 0, openSyncCalls: 0, diff --git a/scripts/test-command-metadata-cache.mjs b/scripts/test-command-metadata-cache.mjs index 088bccab..c249f466 100644 --- a/scripts/test-command-metadata-cache.mjs +++ b/scripts/test-command-metadata-cache.mjs @@ -174,6 +174,28 @@ test('legacy invalidation path starts structural discovery from stale fallback', assert.equal(commandsModule.__getCommandDiscoveryStartCountForTesting(), 1); }); +test('command metadata fallback invalidates when the target is absent from a fresh cache', async () => { + commandsModule.__resetCommandCacheForTesting(); + let structuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + structuralDiscoveryRuns += 1; + return makeCommands(); + }); + const commands = makeCommands(1_000); + seedFreshCache(commands); + + const patchResult = commandsModule.applyCommandMetadataUpdateWithCacheFallback('missing-extension-command', { + subtitle: 'Queued subtitle', + }); + const returnedCommands = await commandsModule.getAvailableCommands(); + + assert.equal(patchResult.matchedCommands, 0); + assert.equal(patchResult.changedCommands, 0); + assert.equal(returnedCommands, commands); + assert.equal(structuralDiscoveryRuns, 1); + assert.equal(commandsModule.__getCommandDiscoveryStartCountForTesting(), 1); +}); + test('command metadata cache benchmark', async () => { const commandCount = 5_000; const iterations = 60; diff --git a/scripts/test-script-command-runner.mjs b/scripts/test-script-command-runner.mjs index 3ae33dfe..2ae55546 100644 --- a/scripts/test-script-command-runner.mjs +++ b/scripts/test-script-command-runner.mjs @@ -102,4 +102,48 @@ echo ok `expected changed icon to be read again, got ${metrics.readFileSyncBytes} bytes`, ); }); + + await t.test('does not cache transient icon read failures', async (t) => { + const { module: runner, scriptsDir, metrics, resetMetrics } = await withScriptCommandRunner(t, {}, { + instrumentFs: true, + }); + const iconsDir = path.join(scriptsDir, '.icons'); + const iconPath = path.join(iconsDir, 'icon.png'); + const scriptPath = path.join(scriptsDir, 'with-transient-icon.sh'); + const icon = Buffer.alloc(2048, 3); + + fs.mkdirSync(iconsDir, { recursive: true }); + fs.writeFileSync(iconPath, icon); + fs.writeFileSync(scriptPath, `#!/bin/bash +${scriptHeader({ + title: 'Transient Icon Command', + extra: '# @raycast.icon .icons/icon.png\n', +})} +echo ok +`, { mode: 0o755 }); + + fs.chmodSync(iconPath, 0o000); + try { + fs.readFileSync(iconPath); + t.skip('filesystem permits reading chmod 000 files'); + return; + } catch {} + + resetMetrics(); + const [initialCommand] = runner.discoverScriptCommands(); + assert.equal(initialCommand.title, 'Transient Icon Command'); + assert.equal(initialCommand.iconDataUrl, undefined); + assert.equal(metrics.readFileSyncErrors, 1); + + fs.chmodSync(iconPath, 0o644); + resetMetrics(); + runner.invalidateScriptCommandsCache(); + const [recoveredCommand] = runner.discoverScriptCommands(); + assert.ok(recoveredCommand.iconDataUrl?.startsWith('data:image/png;base64,')); + assert.equal(metrics.readFileSyncErrors, 0); + assert.ok( + metrics.readFileSyncBytes >= icon.byteLength, + `expected readable icon to be retried, got ${metrics.readFileSyncBytes} bytes`, + ); + }); }); diff --git a/src/main/commands.ts b/src/main/commands.ts index ac37955f..dbc92bc9 100644 --- a/src/main/commands.ts +++ b/src/main/commands.ts @@ -370,6 +370,17 @@ export function applyCommandMetadataUpdate( }; } +export function applyCommandMetadataUpdateWithCacheFallback( + commandId: string, + metadata: CommandRuntimeMetadata +): CommandMetadataPatchResult { + const patchResult = applyCommandMetadataUpdate(commandId, metadata); + if (patchResult.matchedCommands === 0) { + invalidateCache(); + } + return patchResult; +} + export function __seedCommandCacheForTesting( commands: CommandInfo[], options: { cacheTimestamp?: number; staleCommandsFallback?: CommandInfo[] | null } = {} diff --git a/src/main/main.ts b/src/main/main.ts index 3971c928..1a7591f9 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -19,7 +19,16 @@ 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, applyCommandMetadataUpdate } from './commands'; +import { + getAvailableCommands, + executeCommand, + invalidateCache, + initCommandsCache, + getInflightDiscovery, + refreshCommandsNow, + applyCommandMetadataUpdate, + applyCommandMetadataUpdateWithCacheFallback, +} from './commands'; import { loadSettings, saveSettings, @@ -15236,8 +15245,9 @@ app.whenReady().then(async () => { saveSettings({ commandMetadata: settings.commandMetadata }); - // Notify all windows to refresh command list - const patchResult = applyCommandMetadataUpdate(commandId, metadata); + // Patch the live command list when present; otherwise invalidate so the + // next command fetch re-applies the persisted metadata after discovery. + const patchResult = applyCommandMetadataUpdateWithCacheFallback(commandId, metadata); if (patchResult.changedCommands > 0) { broadcastCommandsUpdated(); } diff --git a/src/main/script-command-runner.ts b/src/main/script-command-runner.ts index 1893da63..baa50e77 100644 --- a/src/main/script-command-runner.ts +++ b/src/main/script-command-runner.ts @@ -58,6 +58,7 @@ const DEFAULT_TIMEOUT_MS = 60_000; let cache: { fetchedAt: number; commands: ScriptCommandInfo[] } | null = null; const iconDataUrlCache = new Map(); +const MAX_ICON_DATA_URL_CACHE_ENTRIES = 512; function getSuperCmdScriptsDir(): string { const dir = path.join(app.getPath('userData'), 'script-commands'); @@ -149,6 +150,15 @@ function getCachedIconDataUrl(iconPath: string): string | undefined { } const dataUrl = fileToDataUrl(iconPath); + if (!dataUrl) { + iconDataUrlCache.delete(iconPath); + return undefined; + } + + if (!iconDataUrlCache.has(iconPath) && iconDataUrlCache.size >= MAX_ICON_DATA_URL_CACHE_ENTRIES) { + const oldestKey = iconDataUrlCache.keys().next().value; + if (oldestKey) iconDataUrlCache.delete(oldestKey); + } iconDataUrlCache.set(iconPath, { signature, dataUrl }); return dataUrl; } From de2163fb82b6fb0b96ebbf2eba45f6a025edaebb Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:47:01 +0200 Subject: [PATCH 6/6] ci: make fork checks cross-platform --- .github/workflows/claude-code-review.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd4..51f66a3d 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -12,6 +12,7 @@ on: jobs: claude-review: + if: github.event.pull_request.head.repo.full_name == github.repository # Optional: Filter by PR author # if: | # github.event.pull_request.user.login == 'external-contributor' || @@ -41,4 +42,3 @@ jobs: prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options - diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ecfdadf2..04e21a91 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,7 +34,7 @@ jobs: cache: npm - name: Install dependencies - run: npm ci + run: npm ci --force - name: Run node test suite run: npm test