fix: harden folder monitor lifecycle
This commit is contained in:
@@ -5,6 +5,7 @@ async function walkFolderAsync(rootDir, options = {}) {
|
|||||||
const fsPromises = options.fsPromises || fs.promises;
|
const fsPromises = options.fsPromises || fs.promises;
|
||||||
const pathImpl = options.pathImpl || path;
|
const pathImpl = options.pathImpl || path;
|
||||||
const yieldFn = options.yieldFn || (() => new Promise(setImmediate));
|
const yieldFn = options.yieldFn || (() => new Promise(setImmediate));
|
||||||
|
const recursive = options.recursive !== false;
|
||||||
const files = [];
|
const files = [];
|
||||||
const stack = [rootDir];
|
const stack = [rootDir];
|
||||||
let scanned = 0;
|
let scanned = 0;
|
||||||
@@ -20,7 +21,7 @@ async function walkFolderAsync(rootDir, options = {}) {
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const fullPath = pathImpl.join(dir, entry.name);
|
const fullPath = pathImpl.join(dir, entry.name);
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
stack.push(fullPath);
|
if (recursive) stack.push(fullPath);
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
let size = 0;
|
let size = 0;
|
||||||
try {
|
try {
|
||||||
|
|||||||
+249
-107
@@ -34,6 +34,7 @@ class FolderMonitor extends EventEmitter {
|
|||||||
this._initialScopes = new Set();
|
this._initialScopes = new Set();
|
||||||
this._reconcileTimer = null;
|
this._reconcileTimer = null;
|
||||||
this._scanPromise = null;
|
this._scanPromise = null;
|
||||||
|
this._scanGeneration = null;
|
||||||
this._followUpRequested = false;
|
this._followUpRequested = false;
|
||||||
this._followUpOptions = null;
|
this._followUpOptions = null;
|
||||||
this._paused = false;
|
this._paused = false;
|
||||||
@@ -42,6 +43,7 @@ class FolderMonitor extends EventEmitter {
|
|||||||
this._lastScanAt = null;
|
this._lastScanAt = null;
|
||||||
this._lastScanTrigger = '';
|
this._lastScanTrigger = '';
|
||||||
this._lastError = '';
|
this._lastError = '';
|
||||||
|
this._generation = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
get running() {
|
get running() {
|
||||||
@@ -49,7 +51,68 @@ class FolderMonitor extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
start(settings) {
|
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._settings = settings;
|
||||||
this._paused = false;
|
this._paused = false;
|
||||||
this._reachable = null;
|
this._reachable = null;
|
||||||
@@ -77,148 +140,180 @@ class FolderMonitor extends EventEmitter {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const generation = this._generation;
|
||||||
this._watcher = this._watch(folderPath, watchOptions);
|
this._watcher = this._watch(folderPath, watchOptions);
|
||||||
if (includeInitial) {
|
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('unlink', (filePath) => {
|
|
||||||
this._seenFiles.delete(this._normalizePath(filePath));
|
|
||||||
});
|
});
|
||||||
this._watcher.on('error', (err) => {
|
}
|
||||||
this._lastError = err instanceof Error ? err.message : String(err);
|
this._watcher.on('add', (filePath) => this._onNewFile(filePath, generation));
|
||||||
this._emitStatus();
|
this._watcher.on('unlink', (filePath) => {
|
||||||
this.emit('error', err);
|
if (this._acceptCallback(generation)) this._seenFiles.delete(this._normalizePath(filePath));
|
||||||
|
});
|
||||||
|
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;
|
const intervalMinutes = Number(settings.reconcileIntervalMinutes) || 5;
|
||||||
this._reconcileTimer = this._setInterval(() => this._reconcile(), intervalMinutes * 60 * 1000);
|
this._reconcileTimer = this._setInterval(
|
||||||
this._emitStatus();
|
() => this._reconcile(generation).catch((error) => this._publishBackgroundError(error, generation)),
|
||||||
|
intervalMinutes * 60 * 1000
|
||||||
|
);
|
||||||
|
this._emitStatus(generation);
|
||||||
return { includesExisting: includeInitial };
|
return { includesExisting: includeInitial };
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
_deactivate({ clearSeen, paused }) {
|
||||||
const changed = !!(this._watcher || this._reconcileTimer || this._batchTimer || this._batchBuffer.length || this._seenFiles.size);
|
const changed = !!(
|
||||||
if (this._watcher) {
|
this._watcher
|
||||||
this._watcher.close().catch(() => {});
|
|| this._reconcileTimer
|
||||||
this._watcher = null;
|
|| 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) {
|
if (clearSeen) this._seenFiles = new Set();
|
||||||
this._clearInterval(this._reconcileTimer);
|
if (changed) this._emitStatus(this._generation);
|
||||||
this._reconcileTimer = null;
|
|
||||||
}
|
|
||||||
if (this._batchTimer) {
|
|
||||||
this._clearTimeout(this._batchTimer);
|
|
||||||
this._batchTimer = null;
|
|
||||||
}
|
|
||||||
this._batchBuffer = [];
|
|
||||||
this._seenFiles = new Set();
|
|
||||||
if (changed) this._emitStatus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
status() {
|
_invalidateLifecycle(paused) {
|
||||||
return Object.freeze({
|
this._generation++;
|
||||||
running: this.running,
|
this._paused = paused;
|
||||||
paused: this._paused,
|
this._scanning = false;
|
||||||
reachable: this._reachable,
|
this._followUpRequested = false;
|
||||||
scanning: this._scanning,
|
this._followUpOptions = null;
|
||||||
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;
|
this._scanPromise = null;
|
||||||
});
|
this._scanGeneration = null;
|
||||||
return this._scanPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
async pause() {
|
|
||||||
this._paused = true;
|
|
||||||
if (this._reconcileTimer) {
|
|
||||||
this._clearInterval(this._reconcileTimer);
|
|
||||||
this._reconcileTimer = null;
|
|
||||||
}
|
|
||||||
const watcher = this._watcher;
|
const watcher = this._watcher;
|
||||||
this._watcher = null;
|
this._watcher = null;
|
||||||
if (watcher) await watcher.close().catch(() => {});
|
if (this._reconcileTimer) {
|
||||||
|
this._clearInterval(this._reconcileTimer);
|
||||||
|
this._reconcileTimer = null;
|
||||||
|
}
|
||||||
if (this._batchTimer) {
|
if (this._batchTimer) {
|
||||||
this._clearTimeout(this._batchTimer);
|
this._clearTimeout(this._batchTimer);
|
||||||
this._batchTimer = null;
|
this._batchTimer = null;
|
||||||
}
|
}
|
||||||
this._batchBuffer = [];
|
this._batchBuffer = [];
|
||||||
this._emitStatus();
|
return watcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
async resume(settings = this._settings) {
|
_onNewFile(filePath, generation) {
|
||||||
if (!settings) throw new Error('Keine Ordnerkonfiguration vorhanden');
|
if (!this._acceptCallback(generation)) return;
|
||||||
this.start(settings);
|
if (!this._classifyPath(filePath, this._settings).allowed || !this._acceptPath(filePath)) return;
|
||||||
return this.scan({ emitFiles: true, trigger: 'resume' });
|
|
||||||
}
|
|
||||||
|
|
||||||
_onNewFile(filePath) {
|
|
||||||
if (!this._settings || !this._classifyPath(filePath).allowed || !this._acceptPath(filePath)) return;
|
|
||||||
this._batchBuffer.push(filePath);
|
this._batchBuffer.push(filePath);
|
||||||
if (this._batchTimer) this._clearTimeout(this._batchTimer);
|
if (this._batchTimer) this._clearTimeout(this._batchTimer);
|
||||||
this._batchTimer = this._setTimeout(() => {
|
this._batchTimer = this._setTimeout(() => {
|
||||||
|
if (!this._acceptCallback(generation)) return;
|
||||||
const files = this._batchBuffer.splice(0);
|
const files = this._batchBuffer.splice(0);
|
||||||
this._batchTimer = null;
|
this._batchTimer = null;
|
||||||
if (files.length > 0) {
|
if (files.length === 0) return;
|
||||||
this.emit('new-files', files);
|
const listenerError = this._emitEvent('new-files', [files], generation);
|
||||||
|
if (listenerError && this._acceptCallback(generation)) {
|
||||||
|
this._lastError = 'Ordnerüberwachung fehlgeschlagen';
|
||||||
|
this._emitStatus(generation);
|
||||||
}
|
}
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
async _runScans(initialRequest) {
|
async _runProductiveScans(initialRequest, generation) {
|
||||||
let request = initialRequest;
|
let request = initialRequest;
|
||||||
let result;
|
let result = this._cancelledResult(request.trigger);
|
||||||
do {
|
do {
|
||||||
this._followUpRequested = false;
|
this._followUpRequested = false;
|
||||||
this._followUpOptions = null;
|
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;
|
request = this._followUpOptions || request;
|
||||||
} while (this._followUpRequested);
|
} while (this._followUpRequested);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async _performScan({ emitFiles, trigger }) {
|
async _performProductiveScan({ trigger }, generation) {
|
||||||
if (!this._settings) throw new Error('Keine Ordnerkonfiguration vorhanden');
|
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._scanning = true;
|
||||||
this._lastScanTrigger = trigger;
|
this._lastScanTrigger = trigger;
|
||||||
this._emitStatus();
|
const startListenerError = this._emitStatus(generation);
|
||||||
|
if (startListenerError) return this._finishProductiveError(startListenerError, generation, trigger, this._reachable);
|
||||||
const wasReachable = this._reachable;
|
const wasReachable = this._reachable;
|
||||||
try {
|
try {
|
||||||
await this._access(this._settings.folderPath);
|
await this._access(settings.folderPath);
|
||||||
} catch (error) {
|
} catch {
|
||||||
|
if (!this._isCurrent(generation)) return this._cancelledResult(trigger);
|
||||||
this._reachable = false;
|
this._reachable = false;
|
||||||
this._lastScanAt = this._now();
|
this._lastScanAt = this._now();
|
||||||
this._lastError = error instanceof Error ? error.message : String(error);
|
this._lastError = 'Ordner nicht erreichbar';
|
||||||
this._scanning = false;
|
this._scanning = false;
|
||||||
this._emitStatus();
|
this._emitStatus(generation);
|
||||||
return Object.freeze({ files: [], reachable: false, reconnected: false, trigger });
|
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 = [];
|
const files = [];
|
||||||
for (const descriptor of discovered) {
|
for (const descriptor of discovered) {
|
||||||
if (!this._settings.recursive && this._isNestedPath(descriptor.path)) continue;
|
if (!this._isCurrent(generation)) return [];
|
||||||
if (!this._classifyPath(descriptor.path).allowed) continue;
|
if (!settings.recursive && this._isNestedPath(descriptor.path, settings.folderPath)) continue;
|
||||||
|
if (!this._classifyPath(descriptor.path, settings).allowed) continue;
|
||||||
let mtimeMs = 0;
|
let mtimeMs = 0;
|
||||||
try {
|
try {
|
||||||
mtimeMs = Number((await this._stat(descriptor.path)).mtimeMs) || 0;
|
mtimeMs = Number((await this._stat(descriptor.path)).mtimeMs) || 0;
|
||||||
} catch {}
|
} catch {}
|
||||||
|
if (!this._isCurrent(generation)) return [];
|
||||||
files.push(Object.freeze({
|
files.push(Object.freeze({
|
||||||
path: descriptor.path,
|
path: descriptor.path,
|
||||||
name: descriptor.name || path.basename(descriptor.path),
|
name: descriptor.name || path.basename(descriptor.path),
|
||||||
@@ -226,34 +321,45 @@ class FolderMonitor extends EventEmitter {
|
|||||||
mtimeMs
|
mtimeMs
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
const emittedFiles = emitFiles ? files.filter((file) => this._acceptPath(file.path)) : [];
|
return files;
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async _reconcile() {
|
_finishProductiveError(error, generation, trigger, reachable) {
|
||||||
if (this._paused || !this._settings) return;
|
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';
|
const trigger = this._reachable === false ? 'reconnect' : 'interval';
|
||||||
await this.scan({ emitFiles: true, trigger });
|
await this.scan({ emitFiles: true, trigger });
|
||||||
}
|
}
|
||||||
|
|
||||||
_extensionSet() {
|
_publishBackgroundError(error, generation) {
|
||||||
return new Set(String(this._settings?.extensions || '')
|
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(',')
|
.split(',')
|
||||||
.map((extension) => extension.trim().toLowerCase().replace(/^\./, ''))
|
.map((extension) => extension.trim().toLowerCase().replace(/^\./, ''))
|
||||||
.filter(Boolean));
|
.filter(Boolean));
|
||||||
}
|
}
|
||||||
|
|
||||||
_classifyPath(filePath) {
|
_classifyPath(filePath, settings) {
|
||||||
const extension = path.extname(filePath).replace(/^\./, '').toLowerCase();
|
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 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' });
|
return Object.freeze({ allowed, reason: allowed ? 'matched' : 'extension' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,13 +375,49 @@ class FolderMonitor extends EventEmitter {
|
|||||||
return String(filePath).replace(/\\/g, '/').toLowerCase();
|
return String(filePath).replace(/\\/g, '/').toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
_isNestedPath(filePath) {
|
_isNestedPath(filePath, folderPath) {
|
||||||
const relativePath = path.relative(this._settings.folderPath, filePath);
|
const relativePath = path.relative(folderPath, filePath);
|
||||||
return relativePath.split(/[\\/]/).length > 1;
|
return relativePath.split(/[\\/]/).length > 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
_emitStatus() {
|
_acceptCallback(generation) {
|
||||||
this.emit('status', this.status());
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,3 +36,24 @@ test('folder discovery does not truncate long absolute paths', async () => {
|
|||||||
assert.equal(result[0].path, target);
|
assert.equal(result[0].path, target);
|
||||||
assert.ok(result[0].path.length > 260);
|
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']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ function createManualTimers() {
|
|||||||
},
|
},
|
||||||
async runInterval() {
|
async runInterval() {
|
||||||
for (const callback of [...intervals]) await callback();
|
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);
|
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 () => {
|
test('real temporary folder scan survives disconnect and reconnect', async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-automation-folder-'));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-automation-folder-'));
|
||||||
const detached = `${root}-detached`;
|
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.deepEqual(first.files.map((file) => file.name).sort(), ['a.mkv', 'c.mkv']);
|
||||||
assert.equal(first.files.every((file) => Number.isFinite(file.mtimeMs)), true);
|
assert.equal(first.files.every((file) => Number.isFinite(file.mtimeMs)), true);
|
||||||
fs.renameSync(root, detached);
|
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);
|
assert.equal(disconnected.reachable, false);
|
||||||
fs.renameSync(detached, root);
|
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.reachable, true);
|
||||||
assert.equal(reconnected.reconnected, true);
|
assert.equal(reconnected.reconnected, true);
|
||||||
await monitor.pause();
|
await monitor.pause();
|
||||||
|
|||||||
Reference in New Issue
Block a user