From 08767749c17dc9f467cf5d2f2d371892e3f300e0 Mon Sep 17 00:00:00 2001 From: Levy Tate <78818969+iLevyTate@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:15:29 -0500 Subject: [PATCH 1/2] perf: keep models loaded across audio/video and group the watcher queue by file kind - Whisper runs on the CPU beside the resident text model when the text model holds a dedicated GPU and Whisper would not fit next to it; it then registers no GPU memory, so the next text load no longer evicts it. Each audio or video file used to evict one model for the other. Unified memory (Metal/Apple Silicon) is unchanged. - The smart-folder watcher keeps taking files of the kind it just ran (document, image, audio/video) while any are waiting, up to 25 picks in a row, then returns to queue order. With one file at a time it used to follow arrival order, swapping models whenever kinds alternated. --- CHANGELOG.md | 6 ++ src/main/services/SmartFolderWatcher.js | 45 +++++++++++-- src/main/services/WhisperService.js | 41 +++++++++++- test/SmartFolderWatcher.laneGrouping.test.js | 68 ++++++++++++++++++++ test/WhisperService.test.js | 55 ++++++++++++++++ 5 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 test/SmartFolderWatcher.laneGrouping.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 113c509..fb4d31a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -213,6 +213,12 @@ Changes you will notice: analysis of that image (within the 30-minute cache window) tries the vision model again. - **The smart-folder watcher waits for files you are analyzing in Discover** instead of analyzing them at the same time, then skips them once the analysis history has them. +- **Audio and video no longer swap Whisper and the text model for every file** on a GPU too small + for both. When the text model already holds a dedicated GPU, Whisper runs on the CPU beside it; + Macs with unified memory are unchanged. +- **The smart-folder watcher runs files of the same kind together.** It keeps taking documents, + images, or audio/video, whichever it just ran, while more are waiting (up to 25 in a row), so a + model loads once per group instead of once per file. - **On a GPU too small for both models, the text model stays loaded between files.** Loading the ~200 MB embedding model used to evict a 7B text model on a 6 GB card and reload it for the next file, about 4-5 s per file. The embedding model now runs on the CPU beside it. diff --git a/src/main/services/SmartFolderWatcher.js b/src/main/services/SmartFolderWatcher.js index 88c0119..1093226 100644 --- a/src/main/services/SmartFolderWatcher.js +++ b/src/main/services/SmartFolderWatcher.js @@ -65,6 +65,22 @@ const { isUserAnalysisInFlight } = require('../analysis/userAnalysisInFlight'); // How long a file Discover is analyzing right now waits before the watcher // checks it again. const USER_ANALYSIS_DEFER_MS = 15000; + +// The queue keeps taking files of the kind it just ran (documents, images, +// or audio/video) while any are waiting, so each model loads once per run of +// them instead of swapping with every file. After this many picks in a row it +// goes back to plain queue order so no kind waits indefinitely. +const MAX_LANE_STREAK = 25; + +/** + * @param {string} filePath + * @returns {'image' | 'media' | 'document'} + */ +function analysisLaneOf(filePath) { + if (isImagePath(filePath)) return 'image'; + if (isMediaPath(filePath)) return 'media'; + return 'document'; +} const { isUNCPath } = require('../../shared/crossPlatformUtils'); const { normalizeKeywords } = require('../../shared/normalization'); const { @@ -296,6 +312,9 @@ class SmartFolderWatcher { // they'd be absent from the durable record for that whole window. /** @type {Map} */ this._inFlightItems = new Map(); + /** @type {'image' | 'media' | 'document' | null} */ + this._lastLane = null; + this._laneStreak = 0; // Abort controllers of the analyses running now, so stop() can cancel // them instead of waiting them out. /** @type {Set} */ @@ -2254,15 +2273,32 @@ class SmartFolderWatcher { // case-variant duplicates are caught. const procKey = (/** @type {any} */ item) => item.dedupKey || item.filePath; const isRunnable = this._makeRunnableCheck(); - const firstAvailableIndex = this.analysisQueue.findIndex(isRunnable); + let firstAvailableIndex = this.analysisQueue.findIndex(isRunnable); // If all queued items are processing or waiting, wait for the queue timer if (firstAvailableIndex === -1) { return; } + // Stay with the kind of file just analyzed while one is waiting (see + // MAX_LANE_STREAK), so documents, images and audio/video each run + // together instead of swapping models file by file. + if (this._lastLane && this._laneStreak < MAX_LANE_STREAK) { + const sameLane = this.analysisQueue.findIndex( + (item) => isRunnable(item) && analysisLaneOf(item.filePath) === this._lastLane + ); + if (sameLane !== -1) firstAvailableIndex = sameLane; + } + const firstItem = this.analysisQueue[firstAvailableIndex]; - const firstIsImage = isImagePath(firstItem.filePath); + const lane = analysisLaneOf(firstItem.filePath); + if (lane === this._lastLane) { + this._laneStreak++; + } else { + this._lastLane = lane; + this._laneStreak = 1; + } + const firstIsImage = lane === 'image'; if (firstIsImage) { // Image batch: take consecutive images (up to 20) that are not currently processing @@ -2359,14 +2395,15 @@ class SmartFolderWatcher { } } } else { - // Document batch: take up to maxConcurrentAnalysis that are not currently processing + // Document or audio/video batch, one kind at a time: up to + // maxConcurrentAnalysis runnable items of this lane const batchSize = Math.max(1, this.maxConcurrentAnalysis || 1); const batch = []; let i = firstAvailableIndex; while (i < this.analysisQueue.length && batch.length < batchSize) { const item = this.analysisQueue[i]; - if (isRunnable(item) && !isImagePath(item.filePath)) { + if (isRunnable(item) && analysisLaneOf(item.filePath) === lane) { batch.push(item); this.analysisQueue.splice(i, 1); // Keep the item in the durable record while it is off both queues. diff --git a/src/main/services/WhisperService.js b/src/main/services/WhisperService.js index ae00d2a..c6af321 100644 --- a/src/main/services/WhisperService.js +++ b/src/main/services/WhisperService.js @@ -737,6 +737,8 @@ class WhisperService extends EventEmitter { this._activeTranscriptions = new Set(); this._cache = new Map(); // key -> { result, expiresAt } this._memoryRegistration = null; // { manager, type } once registered + /** Whisper runs on the CPU because the text model fills the GPU. */ + this._cpuBesideText = false; // Cache-miss dedup: when two callers race the same file before either // populates the cache, both would otherwise transcode + transcribe // independently. The whisper context is a single shared resource so @@ -953,7 +955,15 @@ class WhisperService extends EventEmitter { return { type: 'cpu', name: 'CPU' }; }) ); - const useGpu = shouldUseGpu(this._gpuInfo); + // On a dedicated GPU already holding the text model, a GPU Whisper did + // not fit beside it: loading one evicted the other, and every audio or + // video file swapped both models. Run Whisper on the CPU there instead + // (whisper.cpp takes useGpu: false with any build variant). + const besideText = shouldUseGpu(this._gpuInfo) + ? await this._shouldRunOnCpuBesideText(modelPath) + : false; + this._cpuBesideText = besideText; + const useGpu = shouldUseGpu(this._gpuInfo) && !besideText; const variantChain = whisperVariantFor(this._gpuInfo); if (variantChain.includes('cuda')) { @@ -985,7 +995,8 @@ class WhisperService extends EventEmitter { useGpu, backend: this._gpuInfo?.type || 'cpu', variantChain, - resolvedVariant + resolvedVariant, + cpuBesideText: besideText }); this._resolvedVariant = resolvedVariant; @@ -2129,6 +2140,29 @@ class WhisperService extends EventEmitter { return 200 * 1024 * 1024; } + /** + * Whether to run Whisper on the CPU because the text model holds a + * dedicated GPU that cannot also fit it. Never on unified memory, where CPU + * and GPU share the same budget and moving Whisper would gain nothing. + * @param {string} modelPath + * @returns {Promise} + * @private + */ + async _shouldRunOnCpuBesideText(modelPath) { + const manager = this._getMemoryManager(); + if (!manager?.getLoadedContext?.('text')) return false; + if (typeof manager.canFitExternalLoad !== 'function') return false; + const gpu = /** @type {any} */ (this._gpuInfo); + if (gpu?.type === 'metal' || gpu?.isAppleSilicon || gpu?.unifiedMemory) return false; + let sizeBytes; + try { + sizeBytes = await this._computeMemoryEstimate(modelPath); + } catch { + sizeBytes = 200 * 1024 * 1024; + } + return manager.canFitExternalLoad(sizeBytes) === false; + } + /** * Register this service's VRAM usage with the shared memory manager * so it participates in eviction decisions. Best-effort; failures are @@ -2154,6 +2188,9 @@ class WhisperService extends EventEmitter { }); sizeBytes = 200 * 1024 * 1024; } + // On the CPU beside the text model it uses system RAM, not the GPU budget + // the manager tracks; counting it there made the next text load evict it. + if (this._cpuBesideText) sizeBytes = 0; try { const dispose = async () => { diff --git a/test/SmartFolderWatcher.laneGrouping.test.js b/test/SmartFolderWatcher.laneGrouping.test.js new file mode 100644 index 0000000..40a0ee0 --- /dev/null +++ b/test/SmartFolderWatcher.laneGrouping.test.js @@ -0,0 +1,68 @@ +/** + * @jest-environment node + * + * The watcher's one-at-a-time queue ran documents and audio/video in arrival + * order, so an audio file between two documents swapped Whisper and the text + * model twice. It now keeps taking the kind of file it just ran while one is + * waiting, up to a streak limit, then returns to queue order. + */ +jest.mock('fs', () => jest.requireActual('fs')); +jest.mock('fs/promises', () => jest.requireActual('fs/promises')); +jest.mock('os', () => jest.requireActual('os')); +jest.mock('chokidar', () => ({ + watch: jest.fn(() => ({ + on: jest.fn().mockReturnThis(), + once: jest.fn(), + close: jest.fn().mockResolvedValue() + })) +})); +jest.mock('../src/shared/logger', () => { + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }; + return { logger, createLogger: () => logger }; +}); + +const path = require('path'); +const SmartFolderWatcher = require('../src/main/services/SmartFolderWatcher'); + +const root = path.join(require('os').tmpdir(), 'sfw-lanes'); + +function makeWatcher(names) { + const watcher = new SmartFolderWatcher({ + getSmartFolders: () => [{ id: 'f1', name: 'Inbox', path: root }], + analysisHistoryService: { recordAnalysis: jest.fn(), getAnalysisByPath: jest.fn() }, + settingsService: { load: jest.fn().mockResolvedValue({}) }, + analyzeDocumentFile: jest.fn(), + analyzeImageFile: jest.fn() + }); + watcher.isRunning = true; + watcher.maxConcurrentAnalysis = 1; + const order = []; + watcher._analyzeFile = jest.fn(async (item) => { + order.push(path.basename(item.filePath)); + }); + for (const name of names) { + watcher.analysisQueue.push({ filePath: path.join(root, name), mtime: 1, eventType: 'add' }); + } + return { watcher, order }; +} + +async function drain(watcher, times) { + for (let i = 0; i < times; i += 1) { + await watcher._processQueue(); + } +} + +test('documents and audio/video each run together', async () => { + const { watcher, order } = makeWatcher(['a.pdf', 'talk.mp3', 'b.docx', 'clip.mp4', 'c.txt']); + await drain(watcher, 5); + expect(order).toEqual(['a.pdf', 'b.docx', 'c.txt', 'talk.mp3', 'clip.mp4']); +}); + +test('after the streak limit the queue order wins again', async () => { + const docs = Array.from({ length: 30 }, (_, i) => `d${i}.pdf`); + const { watcher, order } = makeWatcher(['d-first.pdf', 'song.mp3', ...docs]); + await drain(watcher, 27); + // 25 documents in a row, then the waiting audio file gets its turn. + expect(order.slice(0, 25).every((name) => name.endsWith('.pdf'))).toBe(true); + expect(order[25]).toBe('song.mp3'); +}); diff --git a/test/WhisperService.test.js b/test/WhisperService.test.js index 4b71066..eca4d1b 100644 --- a/test/WhisperService.test.js +++ b/test/WhisperService.test.js @@ -1834,3 +1834,58 @@ describe('WhisperService — _collectCudaDllSearchDirs', () => { expect(unique.size).toBe(lower.length); }); }); + +describe('WhisperService — CPU beside a GPU-filling text model', () => { + // A 6 GB GPU with the 7B text model resident: a GPU Whisper did not fit + // beside it, so each audio or video file evicted one model for the other. + beforeEach(() => { + mockFsStat.mockReset(); + mockFsStat.mockResolvedValue({ isFile: () => true, size: 150 * 1024 * 1024 }); + }); + + function memoryWith({ textResident, fits }) { + const memory = fakeMemoryManager(); + memory.getLoadedContext = jest.fn((type) => (type === 'text' && textResident ? {} : null)); + memory.canFitExternalLoad = jest.fn(() => fits); + return memory; + } + + async function load(memory, gpu = { type: 'cuda', vramMB: 6141 }) { + const { binding } = makeFakeBinding(); + const svc = new WhisperService({ + bindingOverride: binding, + gpuMonitor: fakeGpuMonitor(gpu), + fsPromisesOverride: fakeFsPromises, + memoryManagerOverride: memory + }); + await svc._ensureLoaded(); + return { svc, init: binding.initWhisper }; + } + + test('runs on the CPU and stays out of the GPU budget when it would not fit', async () => { + const memory = memoryWith({ textResident: true, fits: false }); + const { init } = await load(memory); + expect(init.mock.calls[0][0].useGpu).toBe(false); + expect(memory.registerExternalConsumer.mock.calls[0][0].sizeBytes).toBe(0); + }); + + test('uses the GPU when it fits beside the text model', async () => { + const memory = memoryWith({ textResident: true, fits: true }); + const { init } = await load(memory); + expect(init.mock.calls[0][0].useGpu).toBe(true); + expect(memory.registerExternalConsumer.mock.calls[0][0].sizeBytes).toBeGreaterThan(0); + }); + + test('uses the GPU when no text model is resident', async () => { + const { init } = await load(memoryWith({ textResident: false, fits: false })); + expect(init.mock.calls[0][0].useGpu).toBe(true); + }); + + test('never moves to the CPU on unified memory (Metal)', async () => { + const { init } = await load(memoryWith({ textResident: true, fits: false }), { + type: 'metal', + vramMB: 16384 + }); + expect(init.mock.calls[0][0].useGpu).toBe(true); + }); +}); From 867ef08776183cdacdc9e9b604171fa6cb9e134e Mon Sep 17 00:00:00 2001 From: Levy Tate <78818969+iLevyTate@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:20:25 -0500 Subject: [PATCH 2/2] fix(discover): after waiting out an earlier batch, analyze only what it did not finish The rerun after a busy wait relied on the analysis cache, which keeps results for 30 minutes and at most 500 documents / 300 images; a long batch outlived it and the rerun redid the work. While waiting, the run now hydrates the earlier batch's results from the durable ready queue (so progress shows them) and, once free, sends only the files without a current ready result (same modified time). When the earlier batch finished everything, the run ends without another batch. --- CHANGELOG.md | 3 +- src/renderer/phases/discover/batchBusy.js | 52 ++++++++++++-- src/renderer/phases/discover/useAnalysis.js | 46 ++++++++++--- test/discoverBatchBusy.test.js | 76 +++++++++++++++++++++ 4 files changed, 163 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb4d31a..03bf0cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -208,7 +208,8 @@ Changes you will notice: then analyzed the same files again file by file, in parallel, and restarted itself when it ended. That doubled GPU work, showed each "analyzed" notification more than once, kept the progress count low, and could leave no room for the vision model, so an image fell back to OCR. A new run now - waits for the earlier batch and reuses its cached results. + waits for the earlier batch, shows what it finished, and then analyzes only the files it did not + finish. - **An image whose vision step failed is no longer cached with its OCR-only result.** The next analysis of that image (within the 30-minute cache window) tries the vision model again. - **The smart-folder watcher waits for files you are analyzing in Discover** instead of analyzing diff --git a/src/renderer/phases/discover/batchBusy.js b/src/renderer/phases/discover/batchBusy.js index b42f6ce..53a6de9 100644 --- a/src/renderer/phases/discover/batchBusy.js +++ b/src/renderer/phases/discover/batchBusy.js @@ -12,8 +12,9 @@ * worker pool, which analyzed the same files a second time in parallel with * the running batch: duplicate notifications, two runs fighting over the GPU, * and a restart loop when the second run requeued its files. Instead the run - * waits for the earlier batch to finish and then runs its own; files the - * earlier batch finished come back from the analysis cache. + * waits for the earlier batch to finish and then runs only the files it did + * not finish. Those are read from the durable ready queue, not the analysis + * cache, which keeps results for only 30 minutes and a few hundred files. */ /** How often a waiting run tries the batch again. */ @@ -55,28 +56,36 @@ function wait(ms, signal) { * progress. Returns without running when this run is superseded or aborted * while it waits. Any other error is rethrown for the caller's fallback. * + * selectRemaining runs before each retry: it returns the files still to + * analyze (an empty list ends the run, nothing left), or null to keep the + * previous list. runBatchPath receives that list (undefined on the first try). + * * @param {{ - * runBatchPath: () => Promise, + * runBatchPath: (files?: any[]) => Promise, * isActiveRun: () => boolean, * abortSignal?: AbortSignal | null, * onWaiting?: () => void, + * selectRemaining?: () => Promise, * retryMs?: number, * sleep?: (ms: number, signal?: AbortSignal | null) => Promise * }} options - * @returns {Promise<'ran' | 'superseded'>} + * @returns {Promise<'ran' | 'superseded' | 'finished-elsewhere'>} */ export async function runBatchPathWhenFree({ runBatchPath, isActiveRun, abortSignal = null, onWaiting, + selectRemaining, retryMs = BATCH_BUSY_RETRY_MS, sleep = wait }) { let announced = false; + /** @type {any[] | undefined} */ + let files; for (;;) { try { - await runBatchPath(); + await runBatchPath(files); return 'ran'; } catch (error) { if (!isBatchAlreadyRunning(error)) throw error; @@ -88,5 +97,38 @@ export async function runBatchPathWhenFree({ } await sleep(retryMs, abortSignal); if (!isActiveRun() || abortSignal?.aborted) return 'superseded'; + if (selectRemaining) { + const remaining = await selectRemaining(); + if (!isActiveRun() || abortSignal?.aborted) return 'superseded'; + if (Array.isArray(remaining)) { + if (remaining.length === 0) return 'finished-elsewhere'; + files = remaining; + } + } + } +} + +/** + * The files of a run that the ready queue does not already hold a current + * result for. A ready entry counts only when the file has not changed since + * it was analyzed (same modified time, when both are known). + * @param {any[]} files - the run's files ({ path, modified? }) + * @param {any[]} readyEntries - ready queue entries ({ path, modified?, analysis }) + * @param {(p: string) => string} canonical - path key + * @returns {any[]} + */ +export function filesNotYetReady(files, readyEntries, canonical) { + /** @type {Map} */ + const ready = new Map(); + for (const entry of Array.isArray(readyEntries) ? readyEntries : []) { + if (!entry?.path || !entry.analysis) continue; + ready.set(canonical(entry.path), entry); } + return (Array.isArray(files) ? files : []).filter((file) => { + const entry = ready.get(canonical(file?.path)); + if (!entry) return true; + const a = file?.modified ? Date.parse(file.modified) : NaN; + const b = entry.modified ? Date.parse(entry.modified) : NaN; + return Number.isFinite(a) && Number.isFinite(b) && a !== b; + }); } diff --git a/src/renderer/phases/discover/useAnalysis.js b/src/renderer/phases/discover/useAnalysis.js index 8ac2ec3..0084924 100644 --- a/src/renderer/phases/discover/useAnalysis.js +++ b/src/renderer/phases/discover/useAnalysis.js @@ -32,7 +32,7 @@ import { import { mapErrorToNotification } from '../../utils/errorMapping'; import { normalizeNamingConventionState } from '../../../shared/namingConventions'; import { toCanonicalPath } from '../../utils/canonicalPath'; -import { isBatchAlreadyRunning, runBatchPathWhenFree } from './batchBusy'; +import { filesNotYetReady, isBatchAlreadyRunning, runBatchPathWhenFree } from './batchBusy'; const logger = createLogger('DiscoverPhase:Analysis'); const BATCH_ANALYSIS_MIN_FILES = 40; @@ -1266,13 +1266,17 @@ export function useAnalysis(options = {}) { completedCountRef.current = 0; const fileByPath = new Map(uniqueFiles.map((/** @type {any} */ file) => [file.path, file])); - const runBatchPath = async () => { + // batchFiles: the files this attempt sends; a run that waited out an + // earlier batch sends only what that batch did not finish (see + // batchBusy.js). Progress still counts against the whole run. + const runBatchPath = async (/** @type {any[]} */ batchFiles = uniqueFiles) => { + const alreadyDone = uniqueFiles.length - batchFiles.length; const batchApi = window?.electronAPI?.analysis?.batch; if (typeof batchApi !== 'function') { throw new Error('Batch analysis API not available'); } - for (const file of uniqueFiles) { + for (const file of batchFiles) { const fileName = file.name || file.path.split(/[\\/]/).pop(); updateFileState(file.path, 'analyzing', { fileName }); } @@ -1299,7 +1303,10 @@ export function useAnalysis(options = {}) { } const previousCompleted = Number(completedCountRef.current) || 0; - const current = Math.min(Number(payload.completed) || 0, uniqueFiles.length); + const current = Math.min( + alreadyDone + (Number(payload.completed) || 0), + uniqueFiles.length + ); if (current > previousCompleted && typeof payload.currentFile === 'string') { batchCompletedPathsRef.current.add(payload.currentFile); } @@ -1374,7 +1381,7 @@ export function useAnalysis(options = {}) { const batchResult = await withTimeout( batchApi({ batchId: clientBatchId, - filePaths: uniqueFiles.map((/** @type {any} */ file) => file.path), + filePaths: batchFiles.map((/** @type {any} */ file) => file.path), options: { concurrency, autoConcurrency, @@ -1384,7 +1391,7 @@ export function useAnalysis(options = {}) { } }), TIMEOUTS.AI_ANALYSIS_BATCH, - `Batch analysis for ${uniqueFiles.length} files` + `Batch analysis for ${batchFiles.length} files` ); if (!batchResult || !Array.isArray(batchResult.results)) { @@ -1458,7 +1465,7 @@ export function useAnalysis(options = {}) { applyAnalysisOutcome(fileInfo, resultPayload, explicitError); } - const newCompletedCount = Math.min(i + 1, uniqueFiles.length); + const newCompletedCount = Math.min(alreadyDone + i + 1, uniqueFiles.length); completedCountRef.current = newCompletedCount; lastProgressAtRef.current = Date.now(); const progress = { @@ -1624,10 +1631,27 @@ export function useAnalysis(options = {}) { systemRecommended, lowVramBatch: systemRecommended === 1 }); - await runBatchPathWhenFree({ + const batchOutcome = await runBatchPathWhenFree({ runBatchPath, isActiveRun, abortSignal, + // While waiting, show what the earlier batch finished and send + // only the rest once it is done. The ready queue is durable; + // the analysis cache keeps results only 30 minutes. + selectRemaining: async () => { + await hydrateReadyQueueResults(); + const getReadyQueue = window?.electronAPI?.analysis?.getReadyQueue; + if (typeof getReadyQueue !== 'function') return null; + try { + return filesNotYetReady( + uniqueFiles, + normalizeReadyQueuePayload(await getReadyQueue()), + toCanonicalPath + ); + } catch { + return null; + } + }, onWaiting: () => addNotification( 'Analysis from before is still finishing. Continuing when it is done.', @@ -1636,6 +1660,12 @@ export function useAnalysis(options = {}) { 'analysis-batch-busy' ) }); + if (batchOutcome === 'finished-elsewhere') { + // The earlier batch finished every file of this run; its + // results were taken from the ready queue while waiting. + completedCountRef.current = uniqueFiles.length; + lastProgressAtRef.current = Date.now(); + } } catch (batchError) { const isLegacyBatchTimeout = String(batchError?.message || '').includes('30000ms') && diff --git a/test/discoverBatchBusy.test.js b/test/discoverBatchBusy.test.js index 8ad0787..f3f78ac 100644 --- a/test/discoverBatchBusy.test.js +++ b/test/discoverBatchBusy.test.js @@ -114,3 +114,79 @@ describe('runBatchPathWhenFree', () => { await expect(pending).resolves.toBe('superseded'); }); }); + +describe('runBatchPathWhenFree — only what the earlier batch left', () => { + const noSleep = jest.fn(async () => {}); + + test('after waiting, the retry sends only the remaining files', async () => { + const runBatchPath = jest.fn().mockRejectedValueOnce(busy()).mockResolvedValueOnce(undefined); + const remaining = [{ path: '/b.pdf' }]; + await expect( + runBatchPathWhenFree({ + runBatchPath, + isActiveRun: () => true, + selectRemaining: async () => remaining, + sleep: noSleep + }) + ).resolves.toBe('ran'); + expect(runBatchPath.mock.calls[0][0]).toBeUndefined(); + expect(runBatchPath.mock.calls[1][0]).toBe(remaining); + }); + + test('nothing left: the run ends without another batch', async () => { + const runBatchPath = jest.fn().mockRejectedValueOnce(busy()); + await expect( + runBatchPathWhenFree({ + runBatchPath, + isActiveRun: () => true, + selectRemaining: async () => [], + sleep: noSleep + }) + ).resolves.toBe('finished-elsewhere'); + expect(runBatchPath).toHaveBeenCalledTimes(1); + }); + + test('an unreadable ready queue keeps the full list', async () => { + const runBatchPath = jest.fn().mockRejectedValueOnce(busy()).mockResolvedValueOnce(undefined); + await runBatchPathWhenFree({ + runBatchPath, + isActiveRun: () => true, + selectRemaining: async () => null, + sleep: noSleep + }); + expect(runBatchPath.mock.calls[1][0]).toBeUndefined(); + }); +}); + +describe('filesNotYetReady', () => { + const { filesNotYetReady } = require('../src/renderer/phases/discover/batchBusy'); + const canonical = (p) => String(p).toLowerCase(); + const t1 = '2026-11-09T10:00:00.000Z'; + const t2 = '2026-11-09T11:00:00.000Z'; + + test('drops files with a current ready result, keeps the rest', () => { + const files = [ + { path: '/A.pdf', modified: t1 }, + { path: '/b.pdf', modified: t1 } + ]; + const ready = [{ path: '/a.pdf', modified: t1, analysis: { category: 'Work' } }]; + expect(filesNotYetReady(files, ready, canonical)).toEqual([{ path: '/b.pdf', modified: t1 }]); + }); + + test('a file changed since its ready result is analyzed again', () => { + const files = [{ path: '/a.pdf', modified: t2 }]; + const ready = [{ path: '/a.pdf', modified: t1, analysis: {} }]; + expect(filesNotYetReady(files, ready, canonical)).toEqual(files); + }); + + test('entries without an analysis do not count', () => { + const files = [{ path: '/a.pdf' }]; + expect(filesNotYetReady(files, [{ path: '/a.pdf' }], canonical)).toEqual(files); + }); + + test('without modified times on both sides a ready result counts as current', () => { + expect( + filesNotYetReady([{ path: '/a.pdf' }], [{ path: '/a.pdf', analysis: {} }], canonical) + ).toEqual([]); + }); +});