From 724280b0cf78c8b9006c47700367bc581d476a50 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:24:18 +0200 Subject: [PATCH] fix: harden folder monitor lifecycle --- lib/file-discovery.js | 3 +- lib/folder-monitor.js | 354 ++++++++++++++++++++++++----------- tests/file-discovery.test.js | 21 +++ tests/folder-monitor.test.js | 226 +++++++++++++++++++++- 4 files changed, 495 insertions(+), 109 deletions(-) diff --git a/lib/file-discovery.js b/lib/file-discovery.js index e68f75a..0e6482d 100644 --- a/lib/file-discovery.js +++ b/lib/file-discovery.js @@ -5,6 +5,7 @@ async function walkFolderAsync(rootDir, options = {}) { const fsPromises = options.fsPromises || fs.promises; const pathImpl = options.pathImpl || path; const yieldFn = options.yieldFn || (() => new Promise(setImmediate)); + const recursive = options.recursive !== false; const files = []; const stack = [rootDir]; let scanned = 0; @@ -20,7 +21,7 @@ async function walkFolderAsync(rootDir, options = {}) { for (const entry of entries) { const fullPath = pathImpl.join(dir, entry.name); if (entry.isDirectory()) { - stack.push(fullPath); + if (recursive) stack.push(fullPath); } else if (entry.isFile()) { let size = 0; try { diff --git a/lib/folder-monitor.js b/lib/folder-monitor.js index 8716a5e..45843d7 100644 --- a/lib/folder-monitor.js +++ b/lib/folder-monitor.js @@ -34,6 +34,7 @@ class FolderMonitor extends EventEmitter { this._initialScopes = new Set(); this._reconcileTimer = null; this._scanPromise = null; + this._scanGeneration = null; this._followUpRequested = false; this._followUpOptions = null; this._paused = false; @@ -42,6 +43,7 @@ class FolderMonitor extends EventEmitter { this._lastScanAt = null; this._lastScanTrigger = ''; this._lastError = ''; + this._generation = 0; } get running() { @@ -49,7 +51,68 @@ class FolderMonitor extends EventEmitter { } start(settings) { - this.stop(); + return this._start(settings, false); + } + + stop() { + this._deactivate({ clearSeen: true, paused: false }); + } + + status() { + return Object.freeze({ + running: this.running, + paused: this._paused, + reachable: this._reachable, + scanning: this._scanning, + folderPath: this._settings ? this._settings.folderPath : '', + seenCount: this._seenFiles.size, + lastScanAt: this._lastScanAt, + lastScanTrigger: this._lastScanTrigger, + error: this._lastError + }); + } + + scan({ emitFiles = true, trigger = 'manual' } = {}) { + const request = { emitFiles: !!emitFiles, trigger: String(trigger || 'manual') }; + const generation = this._generation; + if (!request.emitFiles) return this._performDryScan(request, generation); + if (this._paused) return Promise.resolve(this._cancelledResult(request.trigger)); + if (this._scanPromise && this._scanGeneration === generation) { + this._followUpRequested = true; + this._followUpOptions = { emitFiles: true, trigger: request.trigger }; + return this._scanPromise; + } + let promise; + promise = this._runProductiveScans(request, generation).finally(() => { + if (this._scanPromise === promise) { + this._scanPromise = null; + this._scanGeneration = null; + } + }); + this._scanPromise = promise; + this._scanGeneration = generation; + return promise; + } + + async pause() { + const changed = !!(this._watcher || this._reconcileTimer || this._batchTimer || this._scanPromise || !this._paused); + const watcher = this._invalidateLifecycle(true); + if (watcher) { + try { + await watcher.close(); + } catch {} + } + if (changed) this._emitStatus(this._generation); + } + + async resume(settings = this._settings) { + if (!settings) throw new Error('Keine Ordnerkonfiguration vorhanden'); + this._start(settings, true); + return this.scan({ emitFiles: true, trigger: 'resume' }); + } + + _start(settings, preserveSeen) { + this._deactivate({ clearSeen: !preserveSeen, paused: false }); this._settings = settings; this._paused = false; this._reachable = null; @@ -77,148 +140,180 @@ class FolderMonitor extends EventEmitter { } }; + const generation = this._generation; this._watcher = this._watch(folderPath, watchOptions); if (includeInitial) { - this._watcher.once('ready', () => this.emit('initial-scan-complete')); + this._watcher.once('ready', () => { + if (this._acceptCallback(generation)) this._emitEvent('initial-scan-complete', [], generation); + }); } - this._watcher.on('add', (filePath) => this._onNewFile(filePath)); + this._watcher.on('add', (filePath) => this._onNewFile(filePath, generation)); this._watcher.on('unlink', (filePath) => { - this._seenFiles.delete(this._normalizePath(filePath)); + if (this._acceptCallback(generation)) this._seenFiles.delete(this._normalizePath(filePath)); }); - this._watcher.on('error', (err) => { - this._lastError = err instanceof Error ? err.message : String(err); - this._emitStatus(); - this.emit('error', err); + this._watcher.on('error', (error) => { + if (!this._acceptCallback(generation)) return; + this._lastError = 'Ordnerüberwachung fehlgeschlagen'; + this._emitStatus(generation); + this._emitEvent('error', [error], generation); }); const intervalMinutes = Number(settings.reconcileIntervalMinutes) || 5; - this._reconcileTimer = this._setInterval(() => this._reconcile(), intervalMinutes * 60 * 1000); - this._emitStatus(); + this._reconcileTimer = this._setInterval( + () => this._reconcile(generation).catch((error) => this._publishBackgroundError(error, generation)), + intervalMinutes * 60 * 1000 + ); + this._emitStatus(generation); return { includesExisting: includeInitial }; } - stop() { - const changed = !!(this._watcher || this._reconcileTimer || this._batchTimer || this._batchBuffer.length || this._seenFiles.size); - if (this._watcher) { - this._watcher.close().catch(() => {}); - this._watcher = null; + _deactivate({ clearSeen, paused }) { + const changed = !!( + this._watcher + || this._reconcileTimer + || this._batchTimer + || this._batchBuffer.length + || this._scanPromise + || (clearSeen && this._seenFiles.size) + || this._paused !== paused + ); + const watcher = this._invalidateLifecycle(paused); + if (watcher) { + try { + Promise.resolve(watcher.close()).catch(() => {}); + } catch {} } - if (this._reconcileTimer) { - this._clearInterval(this._reconcileTimer); - this._reconcileTimer = null; - } - if (this._batchTimer) { - this._clearTimeout(this._batchTimer); - this._batchTimer = null; - } - this._batchBuffer = []; - this._seenFiles = new Set(); - if (changed) this._emitStatus(); + if (clearSeen) this._seenFiles = new Set(); + if (changed) this._emitStatus(this._generation); } - status() { - return Object.freeze({ - running: this.running, - paused: this._paused, - reachable: this._reachable, - scanning: this._scanning, - folderPath: this._settings ? this._settings.folderPath : '', - seenCount: this._seenFiles.size, - lastScanAt: this._lastScanAt, - lastScanTrigger: this._lastScanTrigger, - error: this._lastError - }); - } - - scan({ emitFiles = true, trigger = 'manual' } = {}) { - const request = { emitFiles: !!emitFiles, trigger: String(trigger || 'manual') }; - if (this._scanPromise) { - this._followUpRequested = true; - this._followUpOptions = { - emitFiles: !!(this._followUpOptions?.emitFiles || request.emitFiles), - trigger: request.trigger - }; - return this._scanPromise; - } - this._scanPromise = this._runScans(request).finally(() => { - this._scanPromise = null; - }); - return this._scanPromise; - } - - async pause() { - this._paused = true; - if (this._reconcileTimer) { - this._clearInterval(this._reconcileTimer); - this._reconcileTimer = null; - } + _invalidateLifecycle(paused) { + this._generation++; + this._paused = paused; + this._scanning = false; + this._followUpRequested = false; + this._followUpOptions = null; + this._scanPromise = null; + this._scanGeneration = null; const watcher = this._watcher; this._watcher = null; - if (watcher) await watcher.close().catch(() => {}); + if (this._reconcileTimer) { + this._clearInterval(this._reconcileTimer); + this._reconcileTimer = null; + } if (this._batchTimer) { this._clearTimeout(this._batchTimer); this._batchTimer = null; } this._batchBuffer = []; - this._emitStatus(); + return watcher; } - async resume(settings = this._settings) { - if (!settings) throw new Error('Keine Ordnerkonfiguration vorhanden'); - this.start(settings); - return this.scan({ emitFiles: true, trigger: 'resume' }); - } - - _onNewFile(filePath) { - if (!this._settings || !this._classifyPath(filePath).allowed || !this._acceptPath(filePath)) return; + _onNewFile(filePath, generation) { + if (!this._acceptCallback(generation)) return; + if (!this._classifyPath(filePath, this._settings).allowed || !this._acceptPath(filePath)) return; this._batchBuffer.push(filePath); if (this._batchTimer) this._clearTimeout(this._batchTimer); this._batchTimer = this._setTimeout(() => { + if (!this._acceptCallback(generation)) return; const files = this._batchBuffer.splice(0); this._batchTimer = null; - if (files.length > 0) { - this.emit('new-files', files); + if (files.length === 0) return; + const listenerError = this._emitEvent('new-files', [files], generation); + if (listenerError && this._acceptCallback(generation)) { + this._lastError = 'Ordnerüberwachung fehlgeschlagen'; + this._emitStatus(generation); } }, 200); } - async _runScans(initialRequest) { + async _runProductiveScans(initialRequest, generation) { let request = initialRequest; - let result; + let result = this._cancelledResult(request.trigger); do { this._followUpRequested = false; this._followUpOptions = null; - result = await this._performScan(request); + result = await this._performProductiveScan(request, generation); + if (!this._isCurrent(generation)) return this._cancelledResult(request.trigger); request = this._followUpOptions || request; } while (this._followUpRequested); return result; } - async _performScan({ emitFiles, trigger }) { + async _performProductiveScan({ trigger }, generation) { if (!this._settings) throw new Error('Keine Ordnerkonfiguration vorhanden'); + if (!this._isCurrent(generation) || this._paused) return this._cancelledResult(trigger); + const settings = this._settings; this._scanning = true; this._lastScanTrigger = trigger; - this._emitStatus(); + const startListenerError = this._emitStatus(generation); + if (startListenerError) return this._finishProductiveError(startListenerError, generation, trigger, this._reachable); const wasReachable = this._reachable; try { - await this._access(this._settings.folderPath); - } catch (error) { + await this._access(settings.folderPath); + } catch { + if (!this._isCurrent(generation)) return this._cancelledResult(trigger); this._reachable = false; this._lastScanAt = this._now(); - this._lastError = error instanceof Error ? error.message : String(error); + this._lastError = 'Ordner nicht erreichbar'; this._scanning = false; - this._emitStatus(); - return Object.freeze({ files: [], reachable: false, reconnected: false, trigger }); + this._emitStatus(generation); + return this._result([], false, false, trigger, { error: this._lastError }); } + if (!this._isCurrent(generation)) return this._cancelledResult(trigger); + try { + const files = await this._discoverFiles(settings, generation); + if (!this._isCurrent(generation)) return this._cancelledResult(trigger); + const emittedFiles = files.filter((file) => this._acceptPath(file.path)); + if (emittedFiles.length > 0) { + const listenerError = this._emitEvent('new-files', [emittedFiles.map((file) => file.path)], generation); + if (listenerError) return this._finishProductiveError(listenerError, generation, trigger, true); + } + if (!this._isCurrent(generation)) return this._cancelledResult(trigger); + this._reachable = true; + this._lastScanAt = this._now(); + this._lastError = ''; + this._scanning = false; + const statusListenerError = this._emitStatus(generation); + if (statusListenerError) return this._finishProductiveError(statusListenerError, generation, trigger, true); + return this._result(files, true, wasReachable === false, trigger); + } catch (error) { + return this._finishProductiveError(error, generation, trigger, true); + } + } - const discovered = await this._walkFolder(this._settings.folderPath, { recursive: !!this._settings.recursive }); + async _performDryScan({ trigger }, generation) { + if (!this._settings) throw new Error('Keine Ordnerkonfiguration vorhanden'); + const settings = this._settings; + try { + await this._access(settings.folderPath); + } catch { + if (!this._isCurrent(generation)) return this._cancelledResult(trigger); + return this._result([], false, false, trigger); + } + if (!this._isCurrent(generation)) return this._cancelledResult(trigger); + try { + const files = await this._discoverFiles(settings, generation); + if (!this._isCurrent(generation)) return this._cancelledResult(trigger); + return this._result(files, true, this._reachable === false, trigger); + } catch { + if (!this._isCurrent(generation)) return this._cancelledResult(trigger); + return this._result([], true, false, trigger, { error: 'Ordnerscan fehlgeschlagen' }); + } + } + + async _discoverFiles(settings, generation) { + const discovered = await this._walkFolder(settings.folderPath, { recursive: !!settings.recursive }); + if (!this._isCurrent(generation)) return []; const files = []; for (const descriptor of discovered) { - if (!this._settings.recursive && this._isNestedPath(descriptor.path)) continue; - if (!this._classifyPath(descriptor.path).allowed) continue; + if (!this._isCurrent(generation)) return []; + if (!settings.recursive && this._isNestedPath(descriptor.path, settings.folderPath)) continue; + if (!this._classifyPath(descriptor.path, settings).allowed) continue; let mtimeMs = 0; try { mtimeMs = Number((await this._stat(descriptor.path)).mtimeMs) || 0; } catch {} + if (!this._isCurrent(generation)) return []; files.push(Object.freeze({ path: descriptor.path, name: descriptor.name || path.basename(descriptor.path), @@ -226,34 +321,45 @@ class FolderMonitor extends EventEmitter { mtimeMs })); } - const emittedFiles = emitFiles ? files.filter((file) => this._acceptPath(file.path)) : []; - if (emittedFiles.length > 0) this.emit('new-files', emittedFiles.map((file) => file.path)); - this._reachable = true; - this._lastScanAt = this._now(); - this._lastError = ''; - this._scanning = false; - this._emitStatus(); - return Object.freeze({ files: Object.freeze(files), reachable: true, reconnected: wasReachable === false, trigger }); + return files; } - async _reconcile() { - if (this._paused || !this._settings) return; + _finishProductiveError(error, generation, trigger, reachable) { + if (!this._isCurrent(generation)) return this._cancelledResult(trigger); + this._reachable = reachable; + this._lastScanAt = this._now(); + this._lastError = 'Ordnerscan fehlgeschlagen'; + this._scanning = false; + this._emitStatus(generation); + return this._result([], reachable === true, false, trigger, { error: this._lastError }); + } + + async _reconcile(generation) { + if (!this._acceptCallback(generation)) return; const trigger = this._reachable === false ? 'reconnect' : 'interval'; await this.scan({ emitFiles: true, trigger }); } - _extensionSet() { - return new Set(String(this._settings?.extensions || '') + _publishBackgroundError(error, generation) { + if (!this._acceptCallback(generation)) return; + this._lastScanAt = this._now(); + this._lastError = 'Ordnerscan fehlgeschlagen'; + this._scanning = false; + this._emitStatus(generation); + } + + _extensionSet(settings) { + return new Set(String(settings?.extensions || '') .split(',') .map((extension) => extension.trim().toLowerCase().replace(/^\./, '')) .filter(Boolean)); } - _classifyPath(filePath) { + _classifyPath(filePath, settings) { const extension = path.extname(filePath).replace(/^\./, '').toLowerCase(); - const extensions = this._extensionSet(); + const extensions = this._extensionSet(settings); const matches = extensions.size === 0 || extensions.has(extension); - const allowed = this._settings?.filterMode === 'exclude' ? !matches : matches; + const allowed = settings?.filterMode === 'exclude' ? !matches : matches; return Object.freeze({ allowed, reason: allowed ? 'matched' : 'extension' }); } @@ -269,13 +375,49 @@ class FolderMonitor extends EventEmitter { return String(filePath).replace(/\\/g, '/').toLowerCase(); } - _isNestedPath(filePath) { - const relativePath = path.relative(this._settings.folderPath, filePath); + _isNestedPath(filePath, folderPath) { + const relativePath = path.relative(folderPath, filePath); return relativePath.split(/[\\/]/).length > 1; } - _emitStatus() { - this.emit('status', this.status()); + _acceptCallback(generation) { + return this._isCurrent(generation) && !this._paused; + } + + _isCurrent(generation) { + return this._generation === generation; + } + + _emitStatus(generation) { + if (!this._isCurrent(generation)) return null; + return this._emitEvent('status', [this.status()], generation); + } + + _emitEvent(eventName, args, generation) { + let firstError = null; + for (const listener of this.rawListeners(eventName)) { + if (!this._isCurrent(generation)) break; + try { + listener.apply(this, args); + } catch (error) { + if (!firstError) firstError = error; + } + } + return firstError; + } + + _result(files, reachable, reconnected, trigger, extra = {}) { + return Object.freeze({ + files: Object.freeze(files), + reachable, + reconnected, + trigger, + ...extra + }); + } + + _cancelledResult(trigger) { + return this._result([], this._reachable === true, false, trigger, { cancelled: true }); } } diff --git a/tests/file-discovery.test.js b/tests/file-discovery.test.js index de46f33..ca3dd4a 100644 --- a/tests/file-discovery.test.js +++ b/tests/file-discovery.test.js @@ -36,3 +36,24 @@ test('folder discovery does not truncate long absolute paths', async () => { assert.equal(result[0].path, target); assert.ok(result[0].path.length > 260); }); + +test('nonrecursive folder discovery never reads child directories', async () => { + const root = 'C:\\watch'; + const child = path.win32.join(root, 'nested'); + const reads = []; + const result = await walkFolderAsync(root, { + recursive: false, + fsPromises: { + readdir: async (dir) => { + reads.push(dir); + if (dir === root) return [file('root.mkv'), directory('nested')]; + if (dir === child) return [file('nested.mkv')]; + return []; + }, + stat: async () => ({ size: 1 }) + }, + pathImpl: path.win32 + }); + assert.deepEqual(reads, [root]); + assert.deepEqual(result.map((entry) => entry.name), ['root.mkv']); +}); diff --git a/tests/folder-monitor.test.js b/tests/folder-monitor.test.js index b674ec6..47d3a51 100644 --- a/tests/folder-monitor.test.js +++ b/tests/folder-monitor.test.js @@ -38,6 +38,12 @@ function createManualTimers() { }, async runInterval() { for (const callback of [...intervals]) await callback(); + }, + async runTimeouts() { + for (const callback of [...timeouts]) { + timeouts.delete(callback); + await callback(); + } } }; } @@ -216,6 +222,222 @@ test('stop emits an immutable non-running status snapshot', () => { assert.equal(Object.isFrozen(statuses.at(-1)), true); }); +test('pause invalidates a running scan before it can publish late state or files', async () => { + let releaseWalk; + let markWalkStarted; + const walkPending = new Promise((resolve) => { releaseWalk = resolve; }); + const walkStarted = new Promise((resolve) => { markWalkStarted = resolve; }); + const timers = createManualTimers(); + const statuses = []; + const newFiles = []; + const monitor = new FolderMonitor({ + watch: createSilentWatch(), + access: async () => {}, + walkFolder: async () => { + markWalkStarted(); + await walkPending; + return [{ path: 'C:\\watch\\late.mkv', name: 'late.mkv', size: 1 }]; + }, + stat: async () => ({ mtimeMs: 1 }), + ...timers + }); + monitor.on('status', (status) => statuses.push(status)); + monitor.on('new-files', (files) => newFiles.push(files)); + monitor.start({ folderPath: 'C:\\watch', extensions: 'mkv', skipDuplicates: true, reconcileIntervalMinutes: 5 }); + const scan = monitor.scan({ emitFiles: true, trigger: 'manual' }); + await walkStarted; + await monitor.pause(); + const statusCountAfterPause = statuses.length; + releaseWalk(); + const result = await scan; + assert.equal(result.cancelled, true); + assert.equal(statuses.length, statusCountAfterPause); + assert.deepEqual(newFiles, []); + assert.equal(monitor.status().scanning, false); +}); + +test('stop cancels a running scan and its pending follow-up', async () => { + let releaseWalk; + let markWalkStarted; + let walkCalls = 0; + const walkPending = new Promise((resolve) => { releaseWalk = resolve; }); + const walkStarted = new Promise((resolve) => { markWalkStarted = resolve; }); + const timers = createManualTimers(); + const statuses = []; + const monitor = new FolderMonitor({ + watch: createSilentWatch(), + access: async () => {}, + walkFolder: async () => { + walkCalls++; + if (walkCalls === 1) { + markWalkStarted(); + await walkPending; + } + return []; + }, + stat: async () => ({ mtimeMs: 1 }), + ...timers + }); + monitor.on('status', (status) => statuses.push(status)); + monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 }); + const first = monitor.scan({ emitFiles: true, trigger: 'interval' }); + await walkStarted; + const followUp = monitor.scan({ emitFiles: true, trigger: 'manual' }); + monitor.stop(); + const statusCountAfterStop = statuses.length; + releaseWalk(); + const results = await Promise.all([first, followUp]); + assert.equal(walkCalls, 1); + assert.equal(results.every((result) => result.cancelled === true), true); + assert.equal(statuses.length, statusCountAfterStop); +}); + +test('late watcher add callbacks are ignored after pause', async () => { + const timers = createManualTimers(); + const watchers = []; + const newFiles = []; + const monitor = new FolderMonitor({ + watch: () => { + const watcher = new EventEmitter(); + watcher.close = async () => {}; + watchers.push(watcher); + return watcher; + }, + access: async () => {}, + walkFolder: async () => [], + stat: async () => ({ mtimeMs: 1 }), + ...timers + }); + monitor.on('new-files', (files) => newFiles.push(files)); + monitor.start({ folderPath: 'C:\\watch', extensions: 'mkv', reconcileIntervalMinutes: 5 }); + await monitor.pause(); + watchers[0].emit('add', 'C:\\watch\\late.mkv'); + await timers.runTimeouts(); + assert.deepEqual(newFiles, []); +}); + +test('resume preserves session duplicate history', async () => { + const timers = createManualTimers(); + const watchers = []; + const newFiles = []; + const monitor = new FolderMonitor({ + watch: () => { + const watcher = new EventEmitter(); + watcher.close = async () => {}; + watchers.push(watcher); + return watcher; + }, + access: async () => {}, + walkFolder: async () => [{ path: 'C:\\watch\\same.mkv', name: 'same.mkv', size: 1 }], + stat: async () => ({ mtimeMs: 1 }), + ...timers + }); + monitor.on('new-files', (files) => newFiles.push(files)); + const settings = { folderPath: 'C:\\watch', extensions: 'mkv', skipDuplicates: true, reconcileIntervalMinutes: 5 }; + monitor.start(settings); + watchers[0].emit('add', 'C:\\watch\\same.mkv'); + await timers.runTimeouts(); + await monitor.pause(); + await monitor.resume(settings); + watchers[1].emit('add', 'C:\\watch\\same.mkv'); + await timers.runTimeouts(); + assert.deepEqual(newFiles, [['C:\\watch\\same.mkv']]); + assert.equal(monitor.status().seenCount, 1); +}); + +test('dry scan leaves the public status byte-identical and does not consume reconnect state', async () => { + let reachable = false; + const timers = createManualTimers(); + const statuses = []; + const monitor = new FolderMonitor({ + watch: createSilentWatch(), + access: async () => { + if (!reachable) throw new Error('offline'); + }, + walkFolder: async () => [], + stat: async () => ({ mtimeMs: 1 }), + ...timers + }); + monitor.on('status', (status) => statuses.push(status)); + monitor.start({ folderPath: 'Z:\\watch', reconcileIntervalMinutes: 5 }); + await monitor.scan({ emitFiles: true, trigger: 'interval' }); + reachable = true; + const before = JSON.stringify(monitor.status()); + const statusCount = statuses.length; + const dry = await monitor.scan({ emitFiles: false, trigger: 'test' }); + assert.equal(dry.reachable, true); + assert.equal(JSON.stringify(monitor.status()), before); + assert.equal(statuses.length, statusCount); + const productive = await monitor.scan({ emitFiles: true, trigger: 'reconnect' }); + assert.equal(productive.reconnected, true); +}); + +test('walk failure clears scan state, sanitizes the error and preserves one follow-up', async () => { + let releaseFailure; + let walkCalls = 0; + const failurePending = new Promise((resolve) => { releaseFailure = resolve; }); + const timers = createManualTimers(); + const statuses = []; + const monitor = new FolderMonitor({ + watch: createSilentWatch(), + access: async () => {}, + walkFolder: async () => { + walkCalls++; + if (walkCalls === 1) { + await failurePending; + throw new Error('token=secret-value at C:\\private\\file.mkv'); + } + return []; + }, + stat: async () => ({ mtimeMs: 1 }), + ...timers + }); + monitor.on('status', (status) => statuses.push(status)); + monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 }); + const first = monitor.scan({ emitFiles: true, trigger: 'interval' }); + const second = monitor.scan({ emitFiles: true, trigger: 'reconnect' }); + const third = monitor.scan({ emitFiles: true, trigger: 'manual' }); + releaseFailure(); + await Promise.all([first, second, third]); + assert.equal(walkCalls, 2); + assert.equal(statuses.some((status) => status.error === 'Ordnerscan fehlgeschlagen'), true); + assert.equal(statuses.some((status) => status.error.includes('secret-value') || status.error.includes('C:\\private')), false); + assert.equal(monitor.status().scanning, false); +}); + +test('new-files listener failure terminates the productive scan with a sanitized error', async () => { + const { monitor } = createScanHarness({ + files: [{ path: 'C:\\watch\\a.mkv', name: 'a.mkv', size: 1, mtimeMs: 1 }] + }); + monitor.on('new-files', () => { + throw new Error('apiKey=listener-secret'); + }); + monitor.start({ folderPath: 'C:\\watch', extensions: 'mkv', reconcileIntervalMinutes: 5 }); + const result = await monitor.scan({ emitFiles: true, trigger: 'manual' }); + assert.equal(result.error, 'Ordnerscan fehlgeschlagen'); + assert.equal(monitor.status().error, 'Ordnerscan fehlgeschlagen'); + assert.equal(monitor.status().error.includes('listener-secret'), false); + assert.equal(monitor.status().scanning, false); +}); + +test('interval callback contains unexpected scan rejection', async () => { + const timers = createManualTimers(); + const statuses = []; + const monitor = new FolderMonitor({ + watch: createSilentWatch(), + access: async () => {}, + walkFolder: async () => [], + stat: async () => ({ mtimeMs: 1 }), + ...timers + }); + monitor.on('status', (status) => statuses.push(status)); + monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 }); + monitor.scan = async () => { throw new Error('token=interval-secret'); }; + await assert.doesNotReject(() => timers.runInterval()); + assert.equal(statuses.at(-1).error, 'Ordnerscan fehlgeschlagen'); + assert.equal(statuses.at(-1).error.includes('interval-secret'), false); +}); + test('real temporary folder scan survives disconnect and reconnect', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-automation-folder-')); const detached = `${root}-detached`; @@ -230,10 +452,10 @@ test('real temporary folder scan survives disconnect and reconnect', async () => assert.deepEqual(first.files.map((file) => file.name).sort(), ['a.mkv', 'c.mkv']); assert.equal(first.files.every((file) => Number.isFinite(file.mtimeMs)), true); fs.renameSync(root, detached); - const disconnected = await monitor.scan({ emitFiles: false, trigger: 'interval' }); + const disconnected = await monitor.scan({ emitFiles: true, trigger: 'interval' }); assert.equal(disconnected.reachable, false); fs.renameSync(detached, root); - const reconnected = await monitor.scan({ emitFiles: false, trigger: 'interval' }); + const reconnected = await monitor.scan({ emitFiles: true, trigger: 'interval' }); assert.equal(reconnected.reachable, true); assert.equal(reconnected.reconnected, true); await monitor.pause();