diff --git a/apps/appcontainer-node/packages/generic/src/appContainer.ts b/apps/appcontainer-node/packages/generic/src/appContainer.ts index 9d6de805..eefb6335 100644 --- a/apps/appcontainer-node/packages/generic/src/appContainer.ts +++ b/apps/appcontainer-node/packages/generic/src/appContainer.ts @@ -222,7 +222,7 @@ export class AppContainer { this.initWorkForceApiPromise = { resolve, reject } }) - this.logger.info(`Initialized"`) + this.logger.info(`Initialized`) } /** Return the API-methods that the AppContainer exposes to the WorkerAgent */ private getWorkerAgentAPI(clientId: WorkerAgentId): AppContainerWorkerAgent.AppContainer { diff --git a/apps/package-manager/packages/generic/package.json b/apps/package-manager/packages/generic/package.json index 0027e295..a1582e81 100644 --- a/apps/package-manager/packages/generic/package.json +++ b/apps/package-manager/packages/generic/package.json @@ -14,10 +14,10 @@ "@sofie-automation/shared-lib": "*" }, "dependencies": { + "@parcel/watcher": "^2.3.0", "@sofie-package-manager/api": "1.50.0-alpha.7", "@sofie-package-manager/expectation-manager": "1.50.0-alpha.7", "@sofie-package-manager/worker": "1.50.0-alpha.7", - "chokidar": "^3.5.1", "data-store": "^4.0.3", "deep-extend": "^0.6.0", "fast-clone": "^1.5.13", diff --git a/apps/package-manager/packages/generic/src/connector.ts b/apps/package-manager/packages/generic/src/connector.ts index 934d5bce..8c382d4a 100644 --- a/apps/package-manager/packages/generic/src/connector.ts +++ b/apps/package-manager/packages/generic/src/connector.ts @@ -17,7 +17,6 @@ import { import { ExpectationManager, ExpectationManagerServerOptions } from '@sofie-package-manager/expectation-manager' import { CoreHandler, CoreConfig } from './coreHandler' import { PackageContainers, PackageManagerHandler } from './packageManager' -import chokidar from 'chokidar' import fs from 'fs' import { promisify } from 'util' import path from 'path' @@ -211,24 +210,6 @@ export class Connector { 'utf-8' ) } - - const watcher = chokidar.watch(fileName, { persistent: true }) - - this.logger.info(`Watching file "${fileName}"`) - - watcher - .on('add', () => { - triggerReloadInput() - }) - .on('change', () => { - triggerReloadInput() - }) - .on('unlink', () => { - triggerReloadInput() - }) - .on('error', (error) => { - this.logger.error(`Error emitter in Filewatcher: ${stringifyError(error)}`) - }) const triggerReloadInput = () => { setTimeout(() => { reloadInput().catch((error) => { @@ -236,6 +217,15 @@ export class Connector { }) }, 100) } + + this.logger.info(`Watching file "${fileName}"`) + fs.watchFile(fileName, { persistent: true }, (currStats, prevStats) => { + if (currStats.mtimeMs !== prevStats.mtimeMs) { + triggerReloadInput() + } + }) + triggerReloadInput() + const reloadInput = async () => { this.logger.info(`Change detected in ${fileName}`) // Check that the file exists: diff --git a/apps/package-manager/packages/generic/src/generateExpectations/nrk/packageContainerExpectations.ts b/apps/package-manager/packages/generic/src/generateExpectations/nrk/packageContainerExpectations.ts index 1ceffd62..fa4cede2 100644 --- a/apps/package-manager/packages/generic/src/generateExpectations/nrk/packageContainerExpectations.ts +++ b/apps/package-manager/packages/generic/src/generateExpectations/nrk/packageContainerExpectations.ts @@ -31,7 +31,7 @@ export function getPackageContainerExpectations( packages: { label: 'Monitor Packages on source', targetLayers: ['target0'], - ignore: '.bat', + ignore: ['.bat'], // ignore: '', }, }, @@ -50,9 +50,6 @@ export function getPackageContainerExpectations( packages: { label: 'Monitor for Smartbull', targetLayers: ['source-smartbull'], // not used, since the layers of the original smartbull-package are used - usePolling: 2000, - awaitWriteFinishStabilityThreshold: 2000, - warningLimit: 3000, // We seem to get performance issues at around 9000 (when polling network drives), so 3000 should give us an early warning }, }, } diff --git a/apps/package-manager/packages/generic/src/packageManager.ts b/apps/package-manager/packages/generic/src/packageManager.ts index abe913a8..e226855d 100644 --- a/apps/package-manager/packages/generic/src/packageManager.ts +++ b/apps/package-manager/packages/generic/src/packageManager.ts @@ -228,18 +228,28 @@ export class PackageManagerHandler { this._triggerUpdatedExpectedPackagesTimeout = setTimeout(() => { this._triggerUpdatedExpectedPackagesTimeout = null - const expectedPackages: ExpectedPackageWrap[] = [] const packageContainers: PackageContainers = {} + const expectedPackageSources: { + sourceName: string + expectedPackages: ExpectedPackageWrap[] + }[] = [] let activePlaylist: PackageManagerActivePlaylist | null = null let activeRundowns: PackageManagerActiveRundown[] = [] // Add from external data: { + const expectedPackagesExternal: ExpectedPackageWrap[] = [] for (const expectedPackage of this.externalData.expectedPackages) { - expectedPackages.push(expectedPackage) + expectedPackagesExternal.push(expectedPackage) } Object.assign(packageContainers, this.externalData.packageContainers) + if (expectedPackagesExternal.length > 0) { + expectedPackageSources.push({ + sourceName: 'external', + expectedPackages: expectedPackagesExternal, + }) + } } if (!this.coreHandler.notUsingCore) { @@ -268,21 +278,24 @@ export class PackageManagerHandler { const expectedPackagesObjs = this.coreHandler .getCollection('packageManagerExpectedPackages') .find() + + const expectedPackagesCore: ExpectedPackageWrap[] = [] for (const expectedPackagesObj of expectedPackagesObjs) { - expectedPackages.push(expectedPackagesObj as any as ExpectedPackageWrap) + expectedPackagesCore.push(expectedPackagesObj as any as ExpectedPackageWrap) } } // Add from Monitors: { - for (const monitorExpectedPackages of this.monitoredPackages.values()) { - for (const expectedPackage of monitorExpectedPackages) { - expectedPackages.push(expectedPackage) - } + for (const [monitorId, monitorExpectedPackages] of this.monitoredPackages.entries()) { + expectedPackageSources.push({ + sourceName: `monitor_${monitorId}`, + expectedPackages: monitorExpectedPackages, + }) } } - this.handleExpectedPackages(packageContainers, activePlaylist, activeRundowns, expectedPackages) + this.handleExpectedPackages(packageContainers, activePlaylist, activeRundowns, expectedPackageSources) }, 300) } @@ -291,8 +304,18 @@ export class PackageManagerHandler { activePlaylist: PackageManagerActivePlaylist | null, activeRundowns: PackageManagerActiveRundown[], - expectedPackages: ExpectedPackageWrap[] + expectedPackageSources: { + sourceName: string + expectedPackages: ExpectedPackageWrap[] + }[] ) { + const expectedPackages: ExpectedPackageWrap[] = [] + for (const expectedPackageSource of expectedPackageSources) { + for (const exp of expectedPackageSource.expectedPackages) { + expectedPackages.push(exp) + } + } + // Step 0: Save local cache: this.expectedPackageCache = new Map() this.packageContainersCache = packageContainers @@ -310,7 +333,12 @@ export class PackageManagerHandler { } } - this.logger.debug(`Has ${expectedPackages.length} expectedPackages`) + this.logger.debug( + `Has ${expectedPackages.length} expectedPackages (${expectedPackageSources + .map((s) => `${s.sourceName}: ${s.expectedPackages.length}`) + .join(', ')})` + ) + this.logger.silly(JSON.stringify(expectedPackages, null, 2)) // this.logger.debug(JSON.stringify(expectedPackages, null, 2)) this.dataSnapshot.expectedPackages = expectedPackages @@ -327,15 +355,19 @@ export class PackageManagerHandler { this.settings ) this.logger.debug(`Has ${objectSize(expectations)} expectations`) + this.logger.silly(JSON.stringify(expectations, null, 2)) // this.logger.debug(JSON.stringify(expectations, null, 2)) this.dataSnapshot.expectations = expectations + this.logger.debug(`Has ${Object.keys(this.packageContainersCache).length} packageContainers`) + this.logger.silly(JSON.stringify(this.packageContainersCache, null, 2)) const packageContainerExpectations = this.expectationGeneratorApi.getPackageContainerExpectations( this.expectationManager.managerId, this.packageContainersCache, activePlaylist ) this.logger.debug(`Has ${objectSize(packageContainerExpectations)} packageContainerExpectations`) + this.logger.silly(JSON.stringify(packageContainerExpectations, null, 2)) this.dataSnapshot.packageContainerExpectations = packageContainerExpectations this.dataSnapshot.updated = Date.now() diff --git a/apps/single-app/app/src/singleApp.ts b/apps/single-app/app/src/singleApp.ts index 8867ed82..22518bfd 100644 --- a/apps/single-app/app/src/singleApp.ts +++ b/apps/single-app/app/src/singleApp.ts @@ -3,13 +3,26 @@ import * as QuantelHTTPTransformerProxy from '@quantel-http-transformer-proxy/ge import * as PackageManager from '@package-manager/generic' import * as Workforce from '@sofie-package-manager/workforce' import * as AppConatainerNode from '@appcontainer-node/generic' -import { getSingleAppConfig, ProcessHandler, setupLogger, initializeLogger } from '@sofie-package-manager/api' +import { + getSingleAppConfig, + ProcessHandler, + setupLogger, + initializeLogger, + setLogLevel, + isLogLevel, +} from '@sofie-package-manager/api' export async function startSingleApp(): Promise { const config = await getSingleAppConfig() initializeLogger(config) const logger = setupLogger(config, 'single-app') const baseLogger = setupLogger(config, '') + + const logLevel = config.process.logLevel + if (logLevel && isLogLevel(logLevel)) { + logger.info(`Setting log level to ${logLevel}`) + setLogLevel(logLevel) + } // Override some of the arguments, as they arent used in the single-app config.packageManager.port = 0 // 0 = Set the packageManager port to whatever is available config.packageManager.accessUrl = 'ws:127.0.0.1' diff --git a/shared/packages/api/src/HelpfulEventEmitter.ts b/shared/packages/api/src/HelpfulEventEmitter.ts index 50e2a078..56060659 100644 --- a/shared/packages/api/src/HelpfulEventEmitter.ts +++ b/shared/packages/api/src/HelpfulEventEmitter.ts @@ -3,24 +3,44 @@ import EventEmitter from 'events' /** An EventEmitter which does a check that you've remembered to listen to the 'error' event */ export class HelpfulEventEmitter extends EventEmitter { + private _listenersToCheck: string[] = ['error'] + constructor() { super() // Ensure that the error event is listened for: const orgError = new Error('No error event listener registered') setTimeout(() => { - if (!this.listenerCount('error')) { - // If no error event listener is registered, log a warning to let the developer - // know that they should do so: - console.error('WARNING: No error event listener registered') - console.error(`Stack: ${orgError.stack}`) + for (const event of this._listenersToCheck) { + if (!this.listenerCount(event)) { + // If no event listener is registered, log a warning to let the developer + // know that they should do so: + console.error(`WARNING: No "${event}" event listener registered`) + console.error(`Stack: ${orgError.stack}`) - // If we're running in Jest, it's better to make it a little more obvious that something is wrong: - if (process.env.JEST_WORKER_ID !== undefined) { - // Since no error listener is registered, this'll cause the process to exit and tests to fail: - this.emit('error', orgError) + // If we're running in Jest, it's better to make it a little more obvious that something is wrong: + if (process.env.JEST_WORKER_ID !== undefined) { + // Since no error listener is registered, this'll cause the process to exit and tests to fail: + this.emit('error', orgError) + } } } + + // If we're running in Jest, it's better to make it a little more obvious that something is wrong: + if (process.env.JEST_WORKER_ID !== undefined && !this.listenerCount('error')) { + // Since no error listener is registered, this'll cause the process to exit and tests to fail: + this.emit('error', orgError) + } }, 1) } + + /** + * To be called in constructor. + * Add an event that the HelpfulEventEmitter should check that it is being listened to + */ + protected addHelpfulEventCheck(event: string): void { + if (this._listenersToCheck.includes(event)) throw new Error(`Event "${event}" already added`) + + this._listenersToCheck.push(event) + } } diff --git a/shared/packages/api/src/config.ts b/shared/packages/api/src/config.ts index f1ff5ea7..46941fce 100644 --- a/shared/packages/api/src/config.ts +++ b/shared/packages/api/src/config.ts @@ -13,6 +13,7 @@ import { AppContainerId, WorkerAgentId } from './ids' /** Generic CLI-argument-definitions for any process */ const processOptions = defineArguments({ logPath: { type: 'string', describe: 'Set to write logs to this file' }, + logLevel: { type: 'string', describe: 'Set default log level. (Might be overwritten by Sofie Core)' }, unsafeSSL: { type: 'boolean', @@ -276,15 +277,22 @@ const quantelHTTPTransformerProxyConfigArguments = defineArguments({ export interface ProcessConfig { logPath: string | undefined - /** Will cause the Node applocation to blindly accept all certificates. Not recommenced unless in local, controlled networks. */ + logLevel: string | undefined + /** Will cause the Node app to blindly accept all certificates. Not recommenced unless in local, controlled networks. */ unsafeSSL: boolean /** Paths to certificates to load, for SSL-connections */ certificates: string[] } -function getProcessConfig(argv: { logPath: string | undefined; unsafeSSL: boolean; certificates: string | undefined }) { +function getProcessConfig(argv: { + logPath: string | undefined + logLevel: string | undefined + unsafeSSL: boolean + certificates: string | undefined +}) { const certs: string[] = (argv.certificates || process.env.CERTIFICATES || '').split(';') || [] return { logPath: argv.logPath, + logLevel: argv.logLevel, unsafeSSL: argv.unsafeSSL, certificates: _.compact(certs), } diff --git a/shared/packages/api/src/logger.ts b/shared/packages/api/src/logger.ts index 9daca243..e1ac9c28 100644 --- a/shared/packages/api/src/logger.ts +++ b/shared/packages/api/src/logger.ts @@ -20,6 +20,9 @@ export enum LogLevel { DEBUG = 'debug', SILLY = 'silly', } +export function isLogLevel(logLevel: string): logLevel is LogLevel { + return ['error', 'warn', 'info', 'verbose', 'debug', 'silly'].includes(logLevel) +} export const DEFAULT_LOG_LEVEL = LogLevel.VERBOSE diff --git a/shared/packages/api/src/packageContainerApi.ts b/shared/packages/api/src/packageContainerApi.ts index 2d7ed7c1..abaed6c2 100644 --- a/shared/packages/api/src/packageContainerApi.ts +++ b/shared/packages/api/src/packageContainerApi.ts @@ -26,12 +26,12 @@ export interface PackageContainerExpectation extends PackageContainer { /** Monitor the packages of a PackageContainer */ packages?: { label: string - /** If set, ignore any files matching this. (Regular expression). */ - ignore?: string + /** If set, ignore any files matching any of the patterns (Glob pattern). */ + ignore?: string[] /** If set, the monitoring will be using polling, at the given interval [ms] */ - usePolling?: number | null - /** If set, will set the awaitWriteFinish.StabilityThreshold of chokidar */ + // usePolling?: number | null + /** If set, will wait for the file being unchanged for the specified duration before considering it [ms] */ awaitWriteFinishStabilityThreshold?: number | null /** If set, the monitor will warn if the monitored number of packages is greater than this */ warningLimit?: number diff --git a/shared/packages/expectationManager/src/evaluationRunner/evaluationRunner.ts b/shared/packages/expectationManager/src/evaluationRunner/evaluationRunner.ts index 04c1ed77..73ae0b6b 100644 --- a/shared/packages/expectationManager/src/evaluationRunner/evaluationRunner.ts +++ b/shared/packages/expectationManager/src/evaluationRunner/evaluationRunner.ts @@ -638,6 +638,10 @@ export class EvaluationRunner { if (monitorSetup.success) { trackedPackageContainer.monitorIsSetup = true for (const [monitorId, monitor] of objectEntries(monitorSetup.monitors)) { + this.logger.debug( + `Set up monitor "${monitor.label}" (${monitorId}) for PackageContainer ${trackedPackageContainer.id}` + ) + if (trackedPackageContainer.status.monitors[monitorId]) { // In case there no monitor status has been emitted yet: this.tracker.trackedPackageContainerAPI.updateTrackedPackageContainerMonitorStatus( diff --git a/shared/packages/worker/package.json b/shared/packages/worker/package.json index 882184e2..cb6966a0 100644 --- a/shared/packages/worker/package.json +++ b/shared/packages/worker/package.json @@ -21,10 +21,10 @@ "@types/tmp": "~0.2.2" }, "dependencies": { + "@parcel/watcher": "^2.3.0", "@sofie-package-manager/api": "1.50.0-alpha.7", "abort-controller": "^3.0.0", "atem-connection": "^3.2.0", - "chokidar": "^3.5.1", "deep-diff": "^1.0.2", "form-data": "^4.0.0", "mkdirp": "^1.0.4", diff --git a/shared/packages/worker/src/worker/accessorHandlers/fileShare.ts b/shared/packages/worker/src/worker/accessorHandlers/fileShare.ts index 4e7dc43e..fc38c6bd 100644 --- a/shared/packages/worker/src/worker/accessorHandlers/fileShare.ts +++ b/shared/packages/worker/src/worker/accessorHandlers/fileShare.ts @@ -362,8 +362,9 @@ export class FileShareAccessorHandle extends GenericFileAccessorHandle for (const monitorIdStr of monitorIds) { if (monitorIdStr === 'packages') { // setup file monitor: - resultingMonitors[protectString(monitorIdStr)] = - this.setupPackagesMonitor(packageContainerExp) + resultingMonitors[protectString(monitorIdStr)] = await this.setupPackagesMonitor( + packageContainerExp + ) } else { // Assert that cronjob is of type "never", to ensure that all types of monitors are handled: assertNever(monitorIdStr) diff --git a/shared/packages/worker/src/worker/accessorHandlers/lib/FileHandler.ts b/shared/packages/worker/src/worker/accessorHandlers/lib/FileHandler.ts index cedea4da..377b4716 100644 --- a/shared/packages/worker/src/worker/accessorHandlers/lib/FileHandler.ts +++ b/shared/packages/worker/src/worker/accessorHandlers/lib/FileHandler.ts @@ -18,12 +18,13 @@ import { MonitorId, AccessorId, } from '@sofie-package-manager/api' -import chokidar from 'chokidar' + import { GenericWorker } from '../../worker' import { GenericAccessorHandle } from '../genericHandle' import { MonitorInProgress } from '../../lib/monitorInProgress' import { removeBasePath } from './pathJoin' +import { FileEvent, FileWatcher, IFileWatcher } from './FileWatcher' export const LocalFolderAccessorHandleType = 'localFolder' export const FileShareAccessorHandleType = 'fileShare' @@ -154,7 +155,7 @@ export abstract class GenericFileAccessorHandle extends GenericAccesso return this.getFullPath(filePath) + '_metadata.json' } - setupPackagesMonitor(packageContainerExp: PackageContainerExpectation): MonitorInProgress { + async setupPackagesMonitor(packageContainerExp: PackageContainerExpectation): Promise { const options = packageContainerExp.monitors.packages if (!options) throw new Error('Options not set (this should never happen)') @@ -164,32 +165,23 @@ export abstract class GenericFileAccessorHandle extends GenericAccesso }, async () => { // Called on stop - await watcher.close() + await watcher.stop() } ) + // Set up a temporary error listener, to catch any errors during setup: + monitorInProgress.on('error', (internalError: any) => { + this.worker.logger.error(`setupPackagesMonitor.monitorInProgress: ${JSON.stringify(internalError)}`) + monitorInProgress._setStatus(StatusCategory.SETUP, StatusCode.BAD, { + user: 'Internal error', + tech: `MonitorInProgress error: ${stringifyError(internalError)}`, + }) + }) - monitorInProgress._setStatus('setup', StatusCode.UNKNOWN, { + monitorInProgress._setStatus(StatusCategory.SETUP, StatusCode.UNKNOWN, { user: 'Setting up file watcher...', tech: `Setting up file watcher...`, }) - const chokidarOptions: chokidar.WatchOptions = { - ignored: options.ignore ? new RegExp(options.ignore) : undefined, - persistent: true, - } - if (options.usePolling) { - chokidarOptions.usePolling = true - chokidarOptions.interval = options.usePolling - chokidarOptions.binaryInterval = options.usePolling - } - if (options.awaitWriteFinishStabilityThreshold) { - chokidarOptions.awaitWriteFinish = { - stabilityThreshold: options.awaitWriteFinishStabilityThreshold, - pollInterval: options.awaitWriteFinishStabilityThreshold, - } - } - const watcher = chokidar.watch(this.folderPath, chokidarOptions) - const monitorId = protectString( `${this.worker.agentAPI.config.workerId}_${this.worker.uniqueId}_${Date.now()}` ) @@ -226,7 +218,7 @@ export abstract class GenericFileAccessorHandle extends GenericAccesso version = this.convertStatToVersion(stat) seenFiles.set(filePath, version) - monitorInProgress._unsetStatus(fullPath) + monitorInProgress._unsetStatus(StatusCategory.FILE + fullPath) } catch (err) { version = null this.worker.logger.error( @@ -235,7 +227,7 @@ export abstract class GenericFileAccessorHandle extends GenericAccesso )}` ) - monitorInProgress._setStatus(fullPath, StatusCode.BAD, { + monitorInProgress._setStatus(StatusCategory.FILE + fullPath, StatusCode.BAD, { user: 'Error when accessing watched file', tech: `Error: ${stringifyError(err)}`, }) @@ -293,12 +285,12 @@ export abstract class GenericFileAccessorHandle extends GenericAccesso }) if (options.warningLimit && seenFiles.size > options.warningLimit) { - monitorInProgress._setStatus('warningLimit', StatusCode.WARNING_MAJOR, { + monitorInProgress._setStatus(StatusCategory.WARNING_LIMIT, StatusCode.WARNING_MAJOR, { user: 'Warning: Too many files for monitor', tech: `There are ${seenFiles.size} files in the folder, which might cause performance issues. Reduce the number of files to below ${options.warningLimit} to get rid of this warning.`, }) } else { - monitorInProgress._unsetStatus('warningLimit') + monitorInProgress._unsetStatus(StatusCategory.WARNING_LIMIT) } // Finally @@ -312,63 +304,45 @@ export abstract class GenericFileAccessorHandle extends GenericAccesso }, 1000) // Wait just a little bit, to avoid doing multiple updates } - /** Get the local filepath from the fullPath */ - const getFilePath = (fullPath: string): string | undefined => { - return path.relative(this.folderPath, fullPath) - } - watcher - .on('add', (fullPath) => { - const localPath = getFilePath(fullPath) - if (localPath) { - seenFiles.set(localPath, null) - triggerSendUpdate() - } + const watcher: IFileWatcher = new FileWatcher(this.folderPath, { + ignore: options.ignore, + awaitWriteFinishStabilityThreshold: options.awaitWriteFinishStabilityThreshold, + }) + watcher.on('error', (errString: string) => { + this.worker.logger.error(`GenericFileAccessorHandle.setupPackagesMonitor: watcher.error: ${errString}}`) + monitorInProgress._setStatus(StatusCategory.WATCHER, StatusCode.BAD, { + user: 'There was an unexpected error in the file watcher', + tech: `FileWatcher error: ${stringifyError(errString)}`, }) - .on('change', (fullPath) => { - const localPath = getFilePath(fullPath) + }) + watcher.on('fileEvent', (fileEvent: FileEvent) => { + const localPath = watcher.getLocalFilePath(fileEvent.path) + + if (fileEvent.type === 'create' || fileEvent.type === 'update') { if (localPath) { seenFiles.set(localPath, null) // This will cause triggerSendUpdate() to update the version triggerSendUpdate() } - }) - .on('unlink', (fullPath) => { - // We don't trust chokidar, so we'll check it ourselves first.. - // (We've seen an issue where removing a single file from a folder causes chokidar to emit unlink for ALL the files) - fsAccess(fullPath, fs.constants.R_OK) - .then(() => { - // The file seems to exist, even though chokidar says it doesn't. - // Ignore the event, then - }) - .catch(() => { - // The file truly doesn't exist + } else if (fileEvent.type === 'delete') { + // Reset any BAD status related to this file: + monitorInProgress._unsetStatus(StatusCategory.FILE + fileEvent.path) - monitorInProgress._unsetStatus(fullPath) + if (localPath) { + seenFiles.delete(localPath) + triggerSendUpdate() + } + } else { + assertNever(fileEvent.type) + } + }) - const localPath = getFilePath(fullPath) - if (localPath) { - seenFiles.delete(localPath) - triggerSendUpdate() - } - }) - }) - .on('error', (err) => { - this.worker.logger.error( - `GenericFileAccessorHandle.setupPackagesMonitor: watcher.error: Unexpected error event: ${stringifyError( - err - )}` - ) - monitorInProgress._setStatus('watcher', StatusCode.BAD, { - user: 'Error in file watcher', - tech: `chokidar error: ${stringifyError(err)}`, - }) - }) - .on('ready', () => { - monitorInProgress._setStatus('setup', StatusCode.GOOD, { - user: 'File watcher is set up', - tech: `File watcher is set up`, - }) - triggerSendUpdate() - }) + // Watch for events: + await watcher.init() + triggerSendUpdate() + monitorInProgress._setStatus(StatusCategory.SETUP, StatusCode.GOOD, { + user: 'File watcher is set up', + tech: `File watcher is set up`, + }) return monitorInProgress } @@ -473,3 +447,10 @@ interface DelayPackageRemovalEntry { /** Unix timestamp for when it's clear to remove the file */ removeTime: number } + +enum StatusCategory { + SETUP = 'setup', + WARNING_LIMIT = 'warningLimit', + WATCHER = 'watcher', + FILE = 'file_', +} diff --git a/shared/packages/worker/src/worker/accessorHandlers/lib/FileWatcher.ts b/shared/packages/worker/src/worker/accessorHandlers/lib/FileWatcher.ts new file mode 100644 index 00000000..56451749 --- /dev/null +++ b/shared/packages/worker/src/worker/accessorHandlers/lib/FileWatcher.ts @@ -0,0 +1,200 @@ +import fs from 'fs' +import path from 'path' +import ParcelWatcher from '@parcel/watcher' +import { HelpfulEventEmitter, assertNever, stringifyError } from '@sofie-package-manager/api' + +export interface FileWatcherEvents { + /** Emitted whenever there is an error */ + error: (error: string) => void + + /** Emitted whenever a file is created, updated or deleted */ + fileEvent: (event: FileEvent) => void +} +export interface IFileWatcher { + on(event: U, listener: FileWatcherEvents[U]): this + emit(event: U, ...args: Parameters): boolean + + /** Start the file watcher */ + init: () => Promise + /** Stop the file watcher */ + stop: () => Promise + /** Get the local filepath from the fullPath */ + getLocalFilePath: (fullPath: string) => string | undefined +} + +/** + * The FileWatcher watches a folder for changes to files. + * It will emit events for files being created, updated or deleted. + */ +export class FileWatcher extends HelpfulEventEmitter implements IFileWatcher { + private initialized = false + private delayEmitNewFileTimeoutMap = new Map() + private watcher: ParcelWatcher.AsyncSubscription | undefined = undefined + + constructor(private folderPath: string, private options: Options) { + super() + this.addHelpfulEventCheck('fileEvent') + } + /** + * Initialize the FileWatcher. + * While this promise is resolving, the FileWatcher will emit the initial list of files + * + */ + async init(): Promise { + if (this.initialized) throw new Error('Already initialized') + this.initialized = true + + // Get the initial list of files: + const filePaths = await this.initListAllFiles(this.folderPath) + this.onFileEvents( + undefined, + filePaths.map((filePath) => ({ type: 'create', path: filePath })) + ) + + // Watch for events: + this.watcher = await ParcelWatcher.subscribe(this.folderPath, this.onFileEvents, this.options) + } + async stop(): Promise { + if (this.watcher) { + await this.watcher.unsubscribe() + delete this.watcher + } + } + /** Get the local filepath from the fullPath */ + public getLocalFilePath(fullPath: string): string | undefined { + return path.relative(this.folderPath, fullPath) + } + + private onFileEvents = (error: any | undefined, events: ParcelWatcher.Event[]) => { + if (error) { + this.emit('error', `Unexpected error event: ${stringifyError(error)}`) + } + + if (events) { + for (const event of events) { + if (event.type === 'create' || event.type === 'update') { + this.onFileCreatedUpdated(event) + } else if (event.type === 'delete') { + this.onFileCreatedDeleted(event) + } else { + assertNever(event.type) + } + } + } + } + /** Called whenever a file is created or updated */ + private onFileCreatedUpdated(event: ParcelWatcher.Event) { + const fullPath = event.path + + if (this.options.awaitWriteFinishStabilityThreshold) { + const waitTime = this.options.awaitWriteFinishStabilityThreshold + // Delay the emit, to avoid emitting multiple times for the same file while it's updating + + fs.stat(fullPath, (error, stats) => { + if (error) { + if (error.code === 'ENOENT') { + // File doesn't exist anymore, so don't emit + return + } else { + this.emit('error', `Error in fs.stat ${stringifyError(error)}`) + } + } else { + this.checkIfFileIsStable(event, stats.size, waitTime) + } + }) + } else { + this.emit('fileEvent', event) + } + } + /** Called whenever a file is deleted */ + private onFileCreatedDeleted(event: ParcelWatcher.Event) { + const fullPath = event.path + + // We don't trust the watcher completely, so we'll check it ourselves first.. + // (We've seen an issue where removing a single file from a folder causes chokidar to emit unlink for ALL the files) + fs.access(fullPath, fs.constants.R_OK, (err) => { + if (err) { + // The file truly doesn't exist + this.delayEmitNewFileTimeoutMap.delete(fullPath) + + const localPath = this.getLocalFilePath(fullPath) + if (localPath) { + this.emit('fileEvent', event) + } + } else { + // The file seems to exist, even though chokidar says it doesn't. + // Ignore the event, then + } + }) + } + + /** + * List all files recursively in a directory. + * This is done initially, to get an initial list of files. + */ + private async initListAllFiles(folderPath: string): Promise { + const fileList: string[] = [] + + const files = await fs.promises.readdir(folderPath, { withFileTypes: true }) + + for (const file of files) { + const fullPath = path.join(folderPath, file.name) + if (file.isDirectory()) { + const innerFileList = await this.initListAllFiles(fullPath) + for (const innerFile of innerFileList) { + fileList.push(innerFile) + } + } else { + fileList.push(fullPath) + } + } + + return fileList + } + + /** + * Checks if the file is stable, ie hasn't changed its size since last check. + * This is useful to avoid emitting multiple times for the same file while it's updating. + */ + private checkIfFileIsStable(event: ParcelWatcher.Event, previousSize: number, waitTime: number) { + const fullPath = event.path + + const previousTimeout = this.delayEmitNewFileTimeoutMap.get(fullPath) + if (previousTimeout) clearTimeout(previousTimeout) + + this.delayEmitNewFileTimeoutMap.set( + fullPath, + setTimeout(() => { + this.delayEmitNewFileTimeoutMap.delete(fullPath) + + fs.stat(fullPath, (error, stats) => { + if (error) { + if (error.code === 'ENOENT') { + // File doesn't exist anymore, so don't emit + return + } else { + this.emit('error', `Error in fs.stat ${stringifyError(error)}`) + } + } else { + if (stats.size !== previousSize) { + // Try again later: + this.checkIfFileIsStable(event, stats.size, waitTime) + } else { + this.emit('fileEvent', event) + } + } + }) + }, waitTime) + ) + } +} + +export interface Options { + ignore?: string[] + /** If set, will wait for the file being unchanged for the specified duration before considering it [ms] */ + awaitWriteFinishStabilityThreshold?: number | null +} +export interface FileEvent { + type: 'create' | 'update' | 'delete' + path: string +} diff --git a/shared/packages/worker/src/worker/accessorHandlers/localFolder.ts b/shared/packages/worker/src/worker/accessorHandlers/localFolder.ts index b5c5bcd2..7c7dcc7d 100644 --- a/shared/packages/worker/src/worker/accessorHandlers/localFolder.ts +++ b/shared/packages/worker/src/worker/accessorHandlers/localFolder.ts @@ -308,8 +308,9 @@ export class LocalFolderAccessorHandle extends GenericFileAccessorHand for (const monitorIdStr of monitorIds) { if (monitorIdStr === 'packages') { // setup file monitor: - resultingMonitors[protectString(monitorIdStr)] = - this.setupPackagesMonitor(packageContainerExp) + resultingMonitors[protectString(monitorIdStr)] = await this.setupPackagesMonitor( + packageContainerExp + ) } else { // Assert that cronjob is of type "never", to ensure that all types of monitors are handled: assertNever(monitorIdStr) diff --git a/shared/packages/worker/src/worker/lib/expectationHandler.ts b/shared/packages/worker/src/worker/lib/expectationHandler.ts index 1e3b4c87..9b73fe7c 100644 --- a/shared/packages/worker/src/worker/lib/expectationHandler.ts +++ b/shared/packages/worker/src/worker/lib/expectationHandler.ts @@ -51,13 +51,17 @@ export interface ExpectationHandler { specificWorker: any ) => Promise /** - * Start working on fullfilling an expectation. - * @returns a WorkInProgress, upon beginning of the work. WorkInProgress then handles signalling of the work progress. + * Start working on fulfilling an expectation. + * The function returns a WorkInProgress, which then handles the actual work asynchronously. + * The returned WorkInProgress is expected to emit 'progress'-events at some interval, to indicate that the work is progressing + * (otherwise the work will be considered timed out and will be cancelled). */ workOnExpectation: ( exp: Expectation.Any, genericWorker: GenericWorker, - specificWorker: any + specificWorker: any, + /** An FYI, the work will be considered timed out if there are no progression reports within this interval*/ + progressTimeout: number ) => Promise /** * "Make an expectation un-fulfilled" diff --git a/shared/packages/worker/src/worker/worker.ts b/shared/packages/worker/src/worker/worker.ts index 47f4655c..af1e7c8b 100644 --- a/shared/packages/worker/src/worker/worker.ts +++ b/shared/packages/worker/src/worker/worker.ts @@ -86,7 +86,11 @@ export abstract class GenericWorker { * Start working on fullfilling an expectation. * @returns a WorkInProgress, upon beginning of the work. WorkInProgress then handles signalling of the work progress. */ - abstract workOnExpectation(exp: Expectation.Any): Promise + abstract workOnExpectation( + exp: Expectation.Any, + /** An FYI, the work will be considered timed out if there are no progression reports within this interval*/ + progressTimeout: number + ): Promise /** * "Make an expectation un-fulfilled" * This is called when an expectation has been removed. diff --git a/shared/packages/worker/src/worker/workers/linuxWorker/linuxWorker.ts b/shared/packages/worker/src/worker/workers/linuxWorker/linuxWorker.ts index 24fb5457..86440514 100644 --- a/shared/packages/worker/src/worker/workers/linuxWorker/linuxWorker.ts +++ b/shared/packages/worker/src/worker/workers/linuxWorker/linuxWorker.ts @@ -52,7 +52,7 @@ export class LinuxWorker extends GenericWorker { ): Promise { throw new Error(`Not implemented yet`) } - async workOnExpectation(_exp: Expectation.Any): Promise { + async workOnExpectation(_exp: Expectation.Any, _progressTimeout: number): Promise { throw new Error(`Not implemented yet`) } async removeExpectation(_exp: Expectation.Any): Promise { diff --git a/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/expectationWindowsHandler.ts b/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/expectationWindowsHandler.ts index f5fb0353..c62f63dc 100644 --- a/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/expectationWindowsHandler.ts +++ b/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/expectationWindowsHandler.ts @@ -36,7 +36,9 @@ export interface ExpectationWindowsHandler extends ExpectationHandler { workOnExpectation: ( exp: Expectation.Any, genericWorker: GenericWorker, - windowsWorker: WindowsWorker + windowsWorker: WindowsWorker, + /** An FYI, the work will be considered timed out if there are no progression reports within this interval*/ + progressTimeout: number ) => Promise removeExpectation: ( exp: Expectation.Any, diff --git a/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/lib/scan.ts b/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/lib/scan.ts index 245daa10..6f91c64f 100644 --- a/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/lib/scan.ts +++ b/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/lib/scan.ts @@ -239,6 +239,7 @@ export function scanMoreInfo( freezes: ScanAnomaly[] blacks: ScanAnomaly[] }>(async (resolve, reject, onCancel) => { + let cancelled = false let filterString = '' if (targetVersion.blackDetection) { if (targetVersion.blackDuration && targetVersion.blackDuration?.endsWith('s')) { @@ -287,6 +288,7 @@ export function scanMoreInfo( } } onCancel(() => { + cancelled = true killFFMpeg() reject('Cancelled') }) @@ -309,6 +311,7 @@ export function scanMoreInfo( let previousStringData = '' let fileDuration: number | undefined = undefined ffMpegProcess.stderr.on('data', (data: any) => { + if (cancelled) return const stringData = data.toString() if (typeof stringData !== 'string') { @@ -413,6 +416,7 @@ export function scanMoreInfo( } const onClose = (code: number | null) => { + if (cancelled) return if (ffMpegProcess) { ffMpegProcess = undefined if (code === 0) { diff --git a/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/packageDeepScan.ts b/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/packageDeepScan.ts index e539d02e..86968bc6 100644 --- a/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/packageDeepScan.ts +++ b/shared/packages/worker/src/worker/workers/windowsWorker/expectationHandlers/packageDeepScan.ts @@ -124,7 +124,12 @@ export const PackageDeepScan: ExpectationWindowsHandler = { return { fulfilled: true } } }, - workOnExpectation: async (exp: Expectation.Any, worker: GenericWorker): Promise => { + workOnExpectation: async ( + exp: Expectation.Any, + worker: GenericWorker, + _: WindowsWorker, + progressTimeout: number + ): Promise => { if (!isPackageDeepScan(exp)) throw new Error(`Wrong exp.type: "${exp.type}"`) // Scan the source media file and upload the results to Core const timer = startTimer() @@ -185,16 +190,81 @@ export const PackageDeepScan: ExpectationWindowsHandler = { let resultFreezes: ScanAnomaly[] = [] let resultScenes: number[] = [] if (hasVideoStream) { - currentProcess = scanMoreInfo( - sourceHandle, - ffProbeScan, - exp.endRequirement.version, - (progress) => { - workInProgress._reportProgress(sourceVersionHash, 0.21 + 0.77 * progress) - }, - worker.logger.category('scanMoreInfo') - ) + let hasGottenProgress = false + + currentProcess = new CancelablePromise<{ + scenes: number[] + freezes: ScanAnomaly[] + blacks: ScanAnomaly[] + }>(async (resolve, reject, onCancel) => { + let isDone = false + let ignoreNextCancelError = false + + const scanMoreInfoProcess = scanMoreInfo( + sourceHandle, + ffProbeScan, + exp.endRequirement.version, + (progress) => { + hasGottenProgress = true + workInProgress._reportProgress(sourceVersionHash, 0.21 + 0.77 * progress) + }, + worker.logger.category('scanMoreInfo') + ) + onCancel(() => { + scanMoreInfoProcess.cancel() + }) + + scanMoreInfoProcess.then( + (result) => { + isDone = true + resolve(result) + }, + (error) => { + if (`${error}`.match(/cancelled/i) && ignoreNextCancelError) { + // ignore this + ignoreNextCancelError = false + } else { + reject(error) + } + } + ) + + // Guard against an edge case where we don't get any progress reports: + setTimeout(() => { + if (!isDone && currentProcess && !hasGottenProgress) { + // If we haven't gotten any progress yet, we probably won't get any. + + // 2023-09-20: There seems to be some bug in the FFMpeg scan where it won't output any progress + // if the scene detection is on. + // Let's abort and try again without scene detection: + + ignoreNextCancelError = true + currentProcess.cancel() + + const scanMoreInfoProcessSecondTry = scanMoreInfo( + sourceHandle, + ffProbeScan, + { + ...exp.endRequirement.version, + scenes: false, // no scene detection + }, + (progress) => { + hasGottenProgress = true + workInProgress._reportProgress(sourceVersionHash, 0.21 + 0.77 * progress) + }, + worker.logger.category('scanMoreInfo') + ) + + scanMoreInfoProcessSecondTry.then( + (result) => resolve(result), + (error) => reject(error) + ) + } + }, progressTimeout * 0.5) + }) + const result = await currentProcess + resultBlacks = result.blacks resultFreezes = result.freezes resultScenes = result.scenes diff --git a/shared/packages/worker/src/worker/workers/windowsWorker/windowsWorker.ts b/shared/packages/worker/src/worker/workers/windowsWorker/windowsWorker.ts index eb5f8ea0..5c8669ae 100644 --- a/shared/packages/worker/src/worker/workers/windowsWorker/windowsWorker.ts +++ b/shared/packages/worker/src/worker/workers/windowsWorker/windowsWorker.ts @@ -91,8 +91,8 @@ export class WindowsWorker extends GenericWorker { ): Promise { return this.getExpectationHandler(exp).isExpectationFulfilled(exp, wasFulfilled, this, this) } - async workOnExpectation(exp: Expectation.Any): Promise { - return this.getExpectationHandler(exp).workOnExpectation(exp, this, this) + async workOnExpectation(exp: Expectation.Any, progressTimeout: number): Promise { + return this.getExpectationHandler(exp).workOnExpectation(exp, this, this, progressTimeout) } async removeExpectation(exp: Expectation.Any): Promise { return this.getExpectationHandler(exp).removeExpectation(exp, this, this) diff --git a/shared/packages/worker/src/workerAgent.ts b/shared/packages/worker/src/workerAgent.ts index 75dfca01..b6e86ab2 100644 --- a/shared/packages/worker/src/workerAgent.ts +++ b/shared/packages/worker/src/workerAgent.ts @@ -523,7 +523,7 @@ export class WorkerAgent { ) try { - const workInProgress = await this._worker.workOnExpectation(exp) + const workInProgress = await this._worker.workOnExpectation(exp, timeout) currentJob.workInProgress = workInProgress @@ -637,6 +637,7 @@ export class WorkerAgent { activeMonitor.set(monitorId, monitorInProgress) returnMonitors[monitorId] = monitorInProgress.properties + monitorInProgress.removeAllListeners('error') // Replace any temporary listeners monitorInProgress.on('error', (internalError: unknown) => { this.logger.error( `WorkerAgent.methods.setupPackageContainerMonitors: ${JSON.stringify(internalError)}` diff --git a/tests/internal-tests/src/__tests__/lib/lib.ts b/tests/internal-tests/src/__tests__/lib/lib.ts index da347abc..f9493b23 100644 --- a/tests/internal-tests/src/__tests__/lib/lib.ts +++ b/tests/internal-tests/src/__tests__/lib/lib.ts @@ -10,15 +10,29 @@ export function waitTime(ms: number): Promise { */ export async function waitUntil(expectFcn: () => void, maxWaitTime: number): Promise { const timer = startTimer() + const previousErrors: string[] = [] + while (true) { await waitTime(100) try { expectFcn() return } catch (err) { - let waitedTime = timer.get() + const errorStr = `${err}` + if (previousErrors.length) { + const previousError = previousErrors[previousErrors.length - 1] + if (errorStr !== previousError) { + previousErrors.push(errorStr) + } + } else { + previousErrors.push(errorStr) + } + + const waitedTime = timer.get() if (waitedTime > maxWaitTime) { console.log(`waitUntil: waited for ${waitedTime} ms, giving up (maxWaitTime: ${maxWaitTime}).`) + console.log(`Previous errors: \n${previousErrors.join('\n')}`) + throw err } // else ignore error and try again later diff --git a/yarn.lock b/yarn.lock index 3d982e59..ed62e293 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1237,6 +1237,66 @@ dependencies: "@octokit/openapi-types" "^17.1.0" +"@parcel/watcher-android-arm64@2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.3.0.tgz#d82e74bb564ebd4d8a88791d273a3d2bd61e27ab" + integrity sha512-f4o9eA3dgk0XRT3XhB0UWpWpLnKgrh1IwNJKJ7UJek7eTYccQ8LR7XUWFKqw6aEq5KUNlCcGvSzKqSX/vtWVVA== + +"@parcel/watcher-darwin-arm64@2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.3.0.tgz#c9cd03f8f233d512fcfc873d5b4e23f1569a82ad" + integrity sha512-mKY+oijI4ahBMc/GygVGvEdOq0L4DxhYgwQqYAz/7yPzuGi79oXrZG52WdpGA1wLBPrYb0T8uBaGFo7I6rvSKw== + +"@parcel/watcher-darwin-x64@2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.3.0.tgz#83c902994a2a49b9e1ab5050dba24876fdc2c219" + integrity sha512-20oBj8LcEOnLE3mgpy6zuOq8AplPu9NcSSSfyVKgfOhNAc4eF4ob3ldj0xWjGGbOF7Dcy1Tvm6ytvgdjlfUeow== + +"@parcel/watcher-freebsd-x64@2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.3.0.tgz#7a0f4593a887e2752b706aff2dae509aef430cf6" + integrity sha512-7LftKlaHunueAEiojhCn+Ef2CTXWsLgTl4hq0pkhkTBFI3ssj2bJXmH2L67mKpiAD5dz66JYk4zS66qzdnIOgw== + +"@parcel/watcher-linux-arm-glibc@2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.3.0.tgz#3fc90c3ebe67de3648ed2f138068722f9b1d47da" + integrity sha512-1apPw5cD2xBv1XIHPUlq0cO6iAaEUQ3BcY0ysSyD9Kuyw4MoWm1DV+W9mneWI+1g6OeP6dhikiFE6BlU+AToTQ== + +"@parcel/watcher-linux-arm64-glibc@2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.3.0.tgz#f7bbbf2497d85fd11e4c9e9c26ace8f10ea9bcbc" + integrity sha512-mQ0gBSQEiq1k/MMkgcSB0Ic47UORZBmWoAWlMrTW6nbAGoLZP+h7AtUM7H3oDu34TBFFvjy4JCGP43JlylkTQA== + +"@parcel/watcher-linux-arm64-musl@2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.3.0.tgz#de131a9fcbe1fa0854e9cbf4c55bed3b35bcff43" + integrity sha512-LXZAExpepJew0Gp8ZkJ+xDZaTQjLHv48h0p0Vw2VMFQ8A+RKrAvpFuPVCVwKJCr5SE+zvaG+Etg56qXvTDIedw== + +"@parcel/watcher-linux-x64-glibc@2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.3.0.tgz#193dd1c798003cdb5a1e59470ff26300f418a943" + integrity sha512-P7Wo91lKSeSgMTtG7CnBS6WrA5otr1K7shhSjKHNePVmfBHDoAOHYRXgUmhiNfbcGk0uMCHVcdbfxtuiZCHVow== + +"@parcel/watcher-linux-x64-musl@2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.3.0.tgz#6dbdb86d96e955ab0fe4a4b60734ec0025a689dd" + integrity sha512-+kiRE1JIq8QdxzwoYY+wzBs9YbJ34guBweTK8nlzLKimn5EQ2b2FSC+tAOpq302BuIMjyuUGvBiUhEcLIGMQ5g== + +"@parcel/watcher-win32-arm64@2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.3.0.tgz#59da26a431da946e6c74fa6b0f30b120ea6650b6" + integrity sha512-35gXCnaz1AqIXpG42evcoP2+sNL62gZTMZne3IackM+6QlfMcJLy3DrjuL6Iks7Czpd3j4xRBzez3ADCj1l7Aw== + +"@parcel/watcher-win32-ia32@2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.3.0.tgz#3ee6a18b08929cd3b788e8cc9547fd9a540c013a" + integrity sha512-FJS/IBQHhRpZ6PiCjFt1UAcPr0YmCLHRbTc00IBTrelEjlmmgIVLeOx4MSXzx2HFEy5Jo5YdhGpxCuqCyDJ5ow== + +"@parcel/watcher-win32-x64@2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.3.0.tgz#14e7246289861acc589fd608de39fe5d8b4bb0a7" + integrity sha512-dLx+0XRdMnVI62kU3wbXvbIRhLck4aE28bIGKbRGS7BJNt54IIj9+c/Dkqb+7DJEbHUZAX1bwaoM8PqVlHJmCA== + "@parcel/watcher@2.0.4": version "2.0.4" resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.0.4.tgz#f300fef4cc38008ff4b8c29d92588eced3ce014b" @@ -1245,6 +1305,29 @@ node-addon-api "^3.2.1" node-gyp-build "^4.3.0" +"@parcel/watcher@^2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.3.0.tgz#803517abbc3981a1a1221791d9f59dc0590d50f9" + integrity sha512-pW7QaFiL11O0BphO+bq3MgqeX/INAk9jgBldVDYjlQPO4VddoZnF22TcF9onMhnLVHuNqBJeRf+Fj7eezi/+rQ== + dependencies: + detect-libc "^1.0.3" + is-glob "^4.0.3" + micromatch "^4.0.5" + node-addon-api "^7.0.0" + optionalDependencies: + "@parcel/watcher-android-arm64" "2.3.0" + "@parcel/watcher-darwin-arm64" "2.3.0" + "@parcel/watcher-darwin-x64" "2.3.0" + "@parcel/watcher-freebsd-x64" "2.3.0" + "@parcel/watcher-linux-arm-glibc" "2.3.0" + "@parcel/watcher-linux-arm64-glibc" "2.3.0" + "@parcel/watcher-linux-arm64-musl" "2.3.0" + "@parcel/watcher-linux-x64-glibc" "2.3.0" + "@parcel/watcher-linux-x64-musl" "2.3.0" + "@parcel/watcher-win32-arm64" "2.3.0" + "@parcel/watcher-win32-ia32" "2.3.0" + "@parcel/watcher-win32-x64" "2.3.0" + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -2092,7 +2175,7 @@ any-promise@^1.0.0: resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A== -anymatch@^3.0.3, anymatch@~3.1.2: +anymatch@^3.0.3: version "3.1.2" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== @@ -2387,11 +2470,6 @@ bin-links@^4.0.1: read-cmd-shim "^4.0.0" write-file-atomic "^5.0.0" -binary-extensions@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" - integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== - bitdepth@^7.0.2: version "7.0.2" resolved "https://registry.yarnpkg.com/bitdepth/-/bitdepth-7.0.2.tgz#d9290de0e4b44ce5fc0c29f813c8f49fbdfa2eeb" @@ -2445,7 +2523,7 @@ braces@^2.3.1: split-string "^3.0.2" to-regex "^3.0.1" -braces@^3.0.2, braces@~3.0.2: +braces@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== @@ -2788,21 +2866,6 @@ cherow@1.6.9: resolved "https://registry.yarnpkg.com/cherow/-/cherow-1.6.9.tgz#7c5e34fce297f152a6f7853dcc9fe2f9e1b36ab8" integrity sha512-pmmkpIQRcnDA7EawKcg9+ncSZNTYfXqDx+K3oqqYvpZlqVBChjTomTfw+hePnkqYR3Y013818c0R1Q5P/7PGrQ== -chokidar@^3.5.1: - version "3.5.3" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" - integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== - dependencies: - anymatch "~3.1.2" - braces "~3.0.2" - glob-parent "~5.1.2" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.6.0" - optionalDependencies: - fsevents "~2.3.2" - chownr@^1.1.1: version "1.1.4" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" @@ -3569,6 +3632,11 @@ detect-indent@^5.0.0: resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-5.0.0.tgz#3871cc0a6a002e8c3e5b3cf7f336264675f06b9d" integrity sha512-rlpvsxUtM0PQvy9iZe640/IWwWYyBsTApREbA1pHOpmOUIl9MkP/U4z7vTtg4Oaojvqhxt7sdufnT0EzGaR31g== +detect-libc@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg== + detect-libc@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.1.tgz#e1897aa88fa6ad197862937fbc0441ef352ee0cd" @@ -4537,7 +4605,7 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@^2.3.2, fsevents@~2.3.2: +fsevents@^2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== @@ -4729,7 +4797,7 @@ github-from-package@0.0.0: resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" integrity sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw== -glob-parent@5.1.2, glob-parent@^5.1.2, glob-parent@~5.1.2: +glob-parent@5.1.2, glob-parent@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== @@ -5400,13 +5468,6 @@ is-arrayish@^0.3.1: resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== -is-binary-path@~2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" - integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== - dependencies: - binary-extensions "^2.0.0" - is-buffer@^1.1.5: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" @@ -5516,7 +5577,7 @@ is-glob@^3.1.0: dependencies: is-extglob "^2.1.0" -is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== @@ -7375,6 +7436,11 @@ node-addon-api@^5.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-5.1.0.tgz#49da1ca055e109a23d537e9de43c09cca21eb762" integrity sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA== +node-addon-api@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.0.0.tgz#8136add2f510997b3b94814f4af1cce0b0e3962e" + integrity sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA== + node-fetch@2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" @@ -7482,7 +7548,7 @@ normalize-package-data@^5.0.0: semver "^7.3.5" validate-npm-package-license "^3.0.4" -normalize-path@^3.0.0, normalize-path@~3.0.0: +normalize-path@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== @@ -8235,7 +8301,7 @@ picocolors@^1.0.0: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== -picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: +picomatch@^2.0.4, picomatch@^2.2.3, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== @@ -8765,13 +8831,6 @@ readdir-scoped-modules@^1.0.0: graceful-fs "^4.1.2" once "^1.3.0" -readdirp@~3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" - integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== - dependencies: - picomatch "^2.2.1" - redent@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f"