diff --git a/packages/compiler-cli/ngcc/src/execution/cluster/executor.ts b/packages/compiler-cli/ngcc/src/execution/cluster/executor.ts index 1edd161488..9b7eeb3332 100644 --- a/packages/compiler-cli/ngcc/src/execution/cluster/executor.ts +++ b/packages/compiler-cli/ngcc/src/execution/cluster/executor.ts @@ -10,10 +10,10 @@ import * as cluster from 'cluster'; +import {AsyncLocker} from '../../locking/async_locker'; import {Logger} from '../../logging/logger'; import {PackageJsonUpdater} from '../../writing/package_json_updater'; import {AnalyzeEntryPointsFn, CreateCompileFn, Executor} from '../api'; -import {AsyncLocker} from '../lock_file'; import {ClusterMaster} from './master'; import {ClusterWorker} from './worker'; diff --git a/packages/compiler-cli/ngcc/src/execution/lock_file.ts b/packages/compiler-cli/ngcc/src/execution/lock_file.ts deleted file mode 100644 index ecb0fd0daa..0000000000 --- a/packages/compiler-cli/ngcc/src/execution/lock_file.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import * as process from 'process'; - -import {AbsoluteFsPath, CachedFileSystem, FileSystem} from '../../../src/ngtsc/file_system'; -import {Logger} from '../logging/logger'; - -let _lockFilePath: AbsoluteFsPath; -export function getLockFilePath(fs: FileSystem) { - if (!_lockFilePath) { - _lockFilePath = - fs.resolve(require.resolve('@angular/compiler-cli/ngcc'), '../__ngcc_lock_file__'); - } - return _lockFilePath; -} - -export interface LockFile { - path: AbsoluteFsPath; - /** - * Write a lock file to disk containing the PID of the current process. - */ - write(): void; - - /** - * Read the PID, of the process holding the lock, from the lockFile. - * - * It is feasible that the lockFile was removed between the call to `write()` that effectively - * checks for existence and this attempt to read the file. If so then this method should just - * gracefully return `"{unknown}"`. - */ - read(): string; - - /** - * Remove the lock file from disk, whether or not it exists. - */ - remove(): void; -} - -export class LockFileWithSignalHandlers implements LockFile { - constructor(protected fs: FileSystem) {} - - path = getLockFilePath(this.fs); - - write(): void { - try { - this.addSignalHandlers(); - // To avoid race conditions, we check for existence of the lockFile by actually trying to - // create it exclusively. - return this.fs.writeFile(this.path, process.pid.toString(), /* exclusive */ true); - } catch (e) { - this.removeSignalHandlers(); - throw e; - } - } - - read(): string { - try { - if (this.fs instanceof CachedFileSystem) { - // This file is "volatile", it might be changed by an external process, - // so we cannot rely upon the cached value when reading it. - this.fs.invalidateCaches(this.path); - } - return this.fs.readFile(this.path); - } catch { - return '{unknown}'; - } - } - - remove() { - this.removeSignalHandlers(); - if (this.fs.exists(this.path)) { - this.fs.removeFile(this.path); - } - } - - /** - * Capture CTRL-C and terminal closing events. - * When these occur we remove the lockFile and exit. - */ - protected addSignalHandlers() { - process.addListener('SIGINT', this.signalHandler); - process.addListener('SIGHUP', this.signalHandler); - } - - /** - * Clear the event handlers to prevent leakage. - */ - protected removeSignalHandlers() { - process.removeListener('SIGINT', this.signalHandler); - process.removeListener('SIGHUP', this.signalHandler); - } - - /** - * This handler needs to be defined as a property rather than a method - * so that it can be passed around as a bound function. - */ - protected signalHandler = - () => { - this.remove(); - this.exit(1); - } - - /** - * This function wraps `process.exit()` which makes it easier to manage in unit tests, - * since it is not possible to mock out `process.exit()` when it is called from signal handlers. - */ - protected exit(code: number): void { - process.exit(code); - } -} - -/** - * SyncLocker is used to prevent more than one instance of ngcc executing at the same time, - * when being called in a synchronous context. - * - * * When ngcc starts executing, it creates a file in the `compiler-cli/ngcc` folder. - * * If it finds one is already there then it fails with a suitable error message. - * * When ngcc completes executing, it removes the file so that future ngcc executions can start. - */ -export class SyncLocker { - constructor(private lockFile: LockFile) {} - - /** - * Run the given function guarded by the lock file. - * - * @param fn the function to run. - * @returns the value returned from the `fn` call. - */ - lock(fn: () => T): T { - this.create(); - try { - return fn(); - } finally { - this.lockFile.remove(); - } - } - - /** - * Write a lock file to disk, or error if there is already one there. - */ - protected create(): void { - try { - this.lockFile.write(); - } catch (e) { - if (e.code !== 'EEXIST') { - throw e; - } - this.handleExistingLockFile(); - } - } - - /** - * The lockFile already exists so raise a helpful error. - */ - protected handleExistingLockFile(): void { - const pid = this.lockFile.read(); - throw new Error( - `ngcc is already running at process with id ${pid}.\n` + - `If you are running multiple builds in parallel then you should pre-process your node_modules via the command line ngcc tool before starting the builds;\n` + - `See https://v9.angular.io/guide/ivy#speeding-up-ngcc-compilation.\n` + - `(If you are sure no ngcc process is running then you should delete the lockFile at ${this.lockFile.path}.)`); - } -} - -/** - * AsyncLocker is used to prevent more than one instance of ngcc executing at the same time, - * when being called in an asynchronous context. - * - * * When ngcc starts executing, it creates a file in the `compiler-cli/ngcc` folder. - * * If it finds one is already there then it pauses and waits for the file to be removed by the - * other process. If the file is not removed within a set timeout period given by - * `retryDelay*retryAttempts` an error is thrown with a suitable error message. - * * If the process locking the file changes, then we restart the timeout. - * * When ngcc completes executing, it removes the file so that future ngcc executions can start. - */ -export class AsyncLocker { - constructor( - private lockFile: LockFile, protected logger: Logger, private retryDelay: number, - private retryAttempts: number) {} - - /** - * Run a function guarded by the lock file. - * - * @param fn The function to run. - */ - async lock(fn: () => Promise): Promise { - await this.create(); - return fn().finally(() => this.lockFile.remove()); - } - - protected async create() { - let pid: string = ''; - for (let attempts = 0; attempts < this.retryAttempts; attempts++) { - try { - return this.lockFile.write(); - } catch (e) { - if (e.code !== 'EEXIST') { - throw e; - } - const newPid = this.lockFile.read(); - if (newPid !== pid) { - // The process locking the file has changed, so restart the timeout - attempts = 0; - pid = newPid; - } - if (attempts === 0) { - this.logger.info( - `Another process, with id ${pid}, is currently running ngcc.\n` + - `Waiting up to ${this.retryDelay*this.retryAttempts/1000}s for it to finish.`); - } - // The file is still locked by another process so wait for a bit and retry - await new Promise(resolve => setTimeout(resolve, this.retryDelay)); - } - } - // If we fall out of the loop then we ran out of rety attempts - throw new Error( - `Timed out waiting ${this.retryAttempts * this.retryDelay/1000}s for another ngcc process, with id ${pid}, to complete.\n` + - `(If you are sure no ngcc process is running then you should delete the lockFile at ${this.lockFile.path}.)`); - } -} diff --git a/packages/compiler-cli/ngcc/src/execution/single_process_executor.ts b/packages/compiler-cli/ngcc/src/execution/single_process_executor.ts index 4f6992a859..da0efe7602 100644 --- a/packages/compiler-cli/ngcc/src/execution/single_process_executor.ts +++ b/packages/compiler-cli/ngcc/src/execution/single_process_executor.ts @@ -6,11 +6,12 @@ * found in the LICENSE file at https://angular.io/license */ +import {AsyncLocker} from '../locking/async_locker'; +import {SyncLocker} from '../locking/sync_locker'; import {Logger} from '../logging/logger'; import {PackageJsonUpdater} from '../writing/package_json_updater'; import {AnalyzeEntryPointsFn, CreateCompileFn, Executor} from './api'; -import {AsyncLocker, SyncLocker} from './lock_file'; import {onTaskCompleted} from './utils'; export abstract class SingleProcessorExecutorBase { diff --git a/packages/compiler-cli/ngcc/src/locking/async_locker.ts b/packages/compiler-cli/ngcc/src/locking/async_locker.ts new file mode 100644 index 0000000000..56bc531357 --- /dev/null +++ b/packages/compiler-cli/ngcc/src/locking/async_locker.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {Logger} from '../logging/logger'; +import {LockFile} from './lock_file'; + +/** + * AsyncLocker is used to prevent more than one instance of ngcc executing at the same time, + * when being called in an asynchronous context. + * + * * When ngcc starts executing, it creates a file in the `compiler-cli/ngcc` folder. + * * If it finds one is already there then it pauses and waits for the file to be removed by the + * other process. If the file is not removed within a set timeout period given by + * `retryDelay*retryAttempts` an error is thrown with a suitable error message. + * * If the process locking the file changes, then we restart the timeout. + * * When ngcc completes executing, it removes the file so that future ngcc executions can start. + */ +export class AsyncLocker { + constructor( + private lockFile: LockFile, protected logger: Logger, private retryDelay: number, + private retryAttempts: number) {} + + /** + * Run a function guarded by the lock file. + * + * @param fn The function to run. + */ + async lock(fn: () => Promise): Promise { + await this.create(); + return fn().finally(() => this.lockFile.remove()); + } + + protected async create() { + let pid: string = ''; + for (let attempts = 0; attempts < this.retryAttempts; attempts++) { + try { + return this.lockFile.write(); + } catch (e) { + if (e.code !== 'EEXIST') { + throw e; + } + const newPid = this.lockFile.read(); + if (newPid !== pid) { + // The process locking the file has changed, so restart the timeout + attempts = 0; + pid = newPid; + } + if (attempts === 0) { + this.logger.info( + `Another process, with id ${pid}, is currently running ngcc.\n` + + `Waiting up to ${this.retryDelay*this.retryAttempts/1000}s for it to finish.`); + } + // The file is still locked by another process so wait for a bit and retry + await new Promise(resolve => setTimeout(resolve, this.retryDelay)); + } + } + // If we fall out of the loop then we ran out of rety attempts + throw new Error( + `Timed out waiting ${this.retryAttempts * this.retryDelay/1000}s for another ngcc process, with id ${pid}, to complete.\n` + + `(If you are sure no ngcc process is running then you should delete the lock-file at ${this.lockFile.path}.)`); + } +} diff --git a/packages/compiler-cli/ngcc/src/locking/lock_file.ts b/packages/compiler-cli/ngcc/src/locking/lock_file.ts new file mode 100644 index 0000000000..4bcb425466 --- /dev/null +++ b/packages/compiler-cli/ngcc/src/locking/lock_file.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {AbsoluteFsPath, FileSystem} from '../../../src/ngtsc/file_system'; + +export function getLockFilePath(fs: FileSystem) { + return fs.resolve(require.resolve('@angular/compiler-cli/ngcc'), '../__ngcc_lock_file__'); +} + +export interface LockFile { + path: AbsoluteFsPath; + /** + * Write a lock file to disk containing the PID of the current process. + */ + write(): void; + + /** + * Read the PID, of the process holding the lock, from the lock-file. + * + * It is feasible that the lock-file was removed between the call to `write()` that effectively + * checks for existence and this attempt to read the file. If so then this method should just + * gracefully return `"{unknown}"`. + */ + read(): string; + + /** + * Remove the lock file from disk, whether or not it exists. + */ + remove(): void; +} diff --git a/packages/compiler-cli/ngcc/src/locking/lock_file_with_signal_handlers.ts b/packages/compiler-cli/ngcc/src/locking/lock_file_with_signal_handlers.ts new file mode 100644 index 0000000000..e50aaf7763 --- /dev/null +++ b/packages/compiler-cli/ngcc/src/locking/lock_file_with_signal_handlers.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import * as process from 'process'; +import {CachedFileSystem, FileSystem} from '../../../src/ngtsc/file_system'; +import {LockFile, getLockFilePath} from './lock_file'; + +export class LockFileWithSignalHandlers implements LockFile { + constructor(protected fs: FileSystem) {} + + path = getLockFilePath(this.fs); + + write(): void { + try { + this.addSignalHandlers(); + // To avoid race conditions, we check for existence of the lock-file by actually trying to + // create it exclusively. + return this.fs.writeFile(this.path, process.pid.toString(), /* exclusive */ true); + } catch (e) { + this.removeSignalHandlers(); + throw e; + } + } + + read(): string { + try { + if (this.fs instanceof CachedFileSystem) { + // This file is "volatile", it might be changed by an external process, + // so we cannot rely upon the cached value when reading it. + this.fs.invalidateCaches(this.path); + } + return this.fs.readFile(this.path); + } catch { + return '{unknown}'; + } + } + + remove() { + this.removeSignalHandlers(); + if (this.fs.exists(this.path)) { + this.fs.removeFile(this.path); + } + } + + /** + * Capture CTRL-C and terminal closing events. + * When these occur we remove the lock-file and exit. + */ + protected addSignalHandlers() { + process.addListener('SIGINT', this.signalHandler); + process.addListener('SIGHUP', this.signalHandler); + } + + /** + * Clear the event handlers to prevent leakage. + */ + protected removeSignalHandlers() { + process.removeListener('SIGINT', this.signalHandler); + process.removeListener('SIGHUP', this.signalHandler); + } + + /** + * This handler needs to be defined as a property rather than a method + * so that it can be passed around as a bound function. + */ + protected signalHandler = + () => { + this.remove(); + this.exit(1); + } + + /** + * This function wraps `process.exit()` which makes it easier to manage in unit tests, + * since it is not possible to mock out `process.exit()` when it is called from signal handlers. + */ + protected exit(code: number): void { + process.exit(code); + } +} diff --git a/packages/compiler-cli/ngcc/src/locking/sync_locker.ts b/packages/compiler-cli/ngcc/src/locking/sync_locker.ts new file mode 100644 index 0000000000..391ca822e3 --- /dev/null +++ b/packages/compiler-cli/ngcc/src/locking/sync_locker.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {Logger} from '../logging/logger'; +import {LockFile} from './lock_file'; + +/** + * SyncLocker is used to prevent more than one instance of ngcc executing at the same time, + * when being called in a synchronous context. + * + * * When ngcc starts executing, it creates a file in the `compiler-cli/ngcc` folder. + * * If it finds one is already there then it fails with a suitable error message. + * * When ngcc completes executing, it removes the file so that future ngcc executions can start. + */ +export class SyncLocker { + constructor(private lockFile: LockFile) {} + + /** + * Run the given function guarded by the lock file. + * + * @param fn the function to run. + * @returns the value returned from the `fn` call. + */ + lock(fn: () => T): T { + this.create(); + try { + return fn(); + } finally { + this.lockFile.remove(); + } + } + + /** + * Write a lock file to disk, or error if there is already one there. + */ + protected create(): void { + try { + this.lockFile.write(); + } catch (e) { + if (e.code !== 'EEXIST') { + throw e; + } + this.handleExistingLockFile(); + } + } + + /** + * The lock-file already exists so raise a helpful error. + */ + protected handleExistingLockFile(): void { + const pid = this.lockFile.read(); + throw new Error( + `ngcc is already running at process with id ${pid}.\n` + + `If you are running multiple builds in parallel then you should pre-process your node_modules via the command line ngcc tool before starting the builds;\n` + + `See https://v9.angular.io/guide/ivy#speeding-up-ngcc-compilation.\n` + + `(If you are sure no ngcc process is running then you should delete the lock-file at ${this.lockFile.path}.)`); + } +} diff --git a/packages/compiler-cli/ngcc/src/main.ts b/packages/compiler-cli/ngcc/src/main.ts index baacf964e3..36c37ca83d 100644 --- a/packages/compiler-cli/ngcc/src/main.ts +++ b/packages/compiler-cli/ngcc/src/main.ts @@ -27,10 +27,12 @@ import {TargetedEntryPointFinder} from './entry_point_finder/targeted_entry_poin import {AnalyzeEntryPointsFn, CreateCompileFn, Executor, PartiallyOrderedTasks, Task, TaskProcessingOutcome, TaskQueue} from './execution/api'; import {ClusterExecutor} from './execution/cluster/executor'; import {ClusterPackageJsonUpdater} from './execution/cluster/package_json_updater'; -import {AsyncLocker, LockFileWithSignalHandlers, SyncLocker} from './execution/lock_file'; import {SingleProcessExecutorAsync, SingleProcessExecutorSync} from './execution/single_process_executor'; import {ParallelTaskQueue} from './execution/task_selection/parallel_task_queue'; import {SerialTaskQueue} from './execution/task_selection/serial_task_queue'; +import {AsyncLocker} from './locking/async_locker'; +import {LockFileWithSignalHandlers} from './locking/lock_file_with_signal_handlers'; +import {SyncLocker} from './locking/sync_locker'; import {ConsoleLogger} from './logging/console_logger'; import {LogLevel, Logger} from './logging/logger'; import {hasBeenProcessed} from './packages/build_marker'; diff --git a/packages/compiler-cli/ngcc/test/execution/cluster/executor_spec.ts b/packages/compiler-cli/ngcc/test/execution/cluster/executor_spec.ts index 20a8840b4b..8b3b9c9dfb 100644 --- a/packages/compiler-cli/ngcc/test/execution/cluster/executor_spec.ts +++ b/packages/compiler-cli/ngcc/test/execution/cluster/executor_spec.ts @@ -14,7 +14,7 @@ import {MockFileSystemNative} from '../../../../src/ngtsc/file_system/testing'; import {ClusterExecutor} from '../../../src/execution/cluster/executor'; import {ClusterMaster} from '../../../src/execution/cluster/master'; import {ClusterWorker} from '../../../src/execution/cluster/worker'; -import {AsyncLocker} from '../../../src/execution/lock_file'; +import {AsyncLocker} from '../../../src/locking/async_locker'; import {PackageJsonUpdater} from '../../../src/writing/package_json_updater'; import {MockLockFile} from '../../helpers/mock_lock_file'; import {MockLogger} from '../../helpers/mock_logger'; diff --git a/packages/compiler-cli/ngcc/test/execution/lock_file_spec.ts b/packages/compiler-cli/ngcc/test/execution/lock_file_spec.ts deleted file mode 100644 index 8a3c1258b6..0000000000 --- a/packages/compiler-cli/ngcc/test/execution/lock_file_spec.ts +++ /dev/null @@ -1,366 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import * as process from 'process'; - -import {CachedFileSystem, FileSystem, getFileSystem} from '../../../src/ngtsc/file_system'; -import {runInEachFileSystem} from '../../../src/ngtsc/file_system/testing'; -import {AsyncLocker, LockFileWithSignalHandlers, SyncLocker} from '../../src/execution/lock_file'; -import {MockLockFile} from '../helpers/mock_lock_file'; -import {MockLogger} from '../helpers/mock_logger'; - -runInEachFileSystem(() => { - describe('LockFileWithSignalHandlers', () => { - /** - * This class allows us to test ordering of the calls, and to avoid actually attaching signal - * handlers and most importantly not actually exiting the process. - */ - class LockFileUnderTest extends LockFileWithSignalHandlers { - log: string[] = []; - constructor(fs: FileSystem, private handleSignals = false) { - super(fs); - fs.ensureDir(fs.dirname(this.path)); - } - remove() { - this.log.push('remove()'); - super.remove(); - } - addSignalHandlers() { - this.log.push('addSignalHandlers()'); - if (this.handleSignals) { - super.addSignalHandlers(); - } - } - write() { - this.log.push('write()'); - super.write(); - } - read() { - const contents = super.read(); - this.log.push('read() => ' + contents); - return contents; - } - removeSignalHandlers() { - this.log.push('removeSignalHandlers()'); - super.removeSignalHandlers(); - } - exit(code: number) { this.log.push(`exit(${code})`); } - } - - describe('write()', () => { - it('should call `addSignalHandlers()`', () => { - const fs = getFileSystem(); - const lockFile = new LockFileUnderTest(fs); - lockFile.write(); - expect(lockFile.log).toEqual(['write()', 'addSignalHandlers()']); - }); - - it('should call `removeSignalHandlers()` if there is an error', () => { - const fs = getFileSystem(); - spyOn(fs, 'writeFile').and.throwError('WRITING ERROR'); - const lockFile = new LockFileUnderTest(fs); - expect(() => lockFile.write()).toThrowError('WRITING ERROR'); - expect(lockFile.log).toEqual(['write()', 'addSignalHandlers()', 'removeSignalHandlers()']); - }); - - it('should remove the lockFile if CTRL-C is triggered', () => { - const fs = getFileSystem(); - const lockFile = new LockFileUnderTest(fs, /* handleSignals */ true); - - lockFile.write(); - expect(lockFile.log).toEqual(['write()', 'addSignalHandlers()']); - - // Simulate the CTRL-C signal - lockFile.log.push('SIGINT'); - process.emit('SIGINT', 'SIGINT'); - - expect(lockFile.log).toEqual([ - 'write()', 'addSignalHandlers()', 'SIGINT', 'remove()', 'removeSignalHandlers()', - 'exit(1)' - ]); - }); - - it('should remove the lockFile if terminal is closed', () => { - const fs = getFileSystem(); - const lockFile = new LockFileUnderTest(fs, /* handleSignals */ true); - - lockFile.write(); - expect(lockFile.log).toEqual(['write()', 'addSignalHandlers()']); - - // Simulate the terminal being closed - lockFile.log.push('SIGHUP'); - process.emit('SIGHUP', 'SIGHUP'); - - expect(lockFile.log).toEqual([ - 'write()', 'addSignalHandlers()', 'SIGHUP', 'remove()', 'removeSignalHandlers()', - 'exit(1)' - ]); - }); - }); - - describe('read()', () => { - it('should return the contents of the lockFile', () => { - const fs = getFileSystem(); - const lockFile = new LockFileUnderTest(fs); - fs.writeFile(lockFile.path, '188'); - expect(lockFile.read()).toEqual('188'); - }); - - it('should return `{unknown}` if the lockFile does not exist', () => { - const fs = getFileSystem(); - const lockFile = new LockFileUnderTest(fs); - expect(lockFile.read()).toEqual('{unknown}'); - }); - - it('should not read file from the cache, since the file may have been modified externally', - () => { - const rawFs = getFileSystem(); - const fs = new CachedFileSystem(rawFs); - const lockFile = new LockFileUnderTest(fs); - rawFs.writeFile(lockFile.path, '188'); - expect(lockFile.read()).toEqual('188'); - // We need to write to the rawFs to ensure that we don't update the cache at this point - rawFs.writeFile(lockFile.path, '444'); - expect(lockFile.read()).toEqual('444'); - }); - }); - - describe('remove()', () => { - it('should remove the lock file from the file-system', () => { - const fs = getFileSystem(); - const lockFile = new LockFileUnderTest(fs); - fs.writeFile(lockFile.path, '188'); - lockFile.remove(); - expect(fs.exists(lockFile.path)).toBe(false); - }); - - it('should not error if the lock file does not exist', () => { - const fs = getFileSystem(); - const lockFile = new LockFileUnderTest(fs); - expect(() => lockFile.remove()).not.toThrow(); - }); - - it('should call removeSignalHandlers()', () => { - const fs = getFileSystem(); - const lockFile = new LockFileUnderTest(fs); - fs.writeFile(lockFile.path, '188'); - lockFile.remove(); - expect(lockFile.log).toEqual(['remove()', 'removeSignalHandlers()']); - }); - }); - }); - - describe('SyncLocker', () => { - describe('lock()', () => { - it('should guard the `fn()` with calls to `write()` and `remove()`', () => { - const fs = getFileSystem(); - const log: string[] = []; - const lockFile = new MockLockFile(fs, log); - const locker = new SyncLocker(lockFile); - - locker.lock(() => log.push('fn()')); - - expect(log).toEqual(['write()', 'fn()', 'remove()']); - }); - - it('should guard the `fn()` with calls to `write()` and `remove()`, even if it throws', - () => { - let error: string = ''; - const fs = getFileSystem(); - const log: string[] = []; - const lockFile = new MockLockFile(fs, log); - const locker = new SyncLocker(lockFile); - - try { - locker.lock(() => { - log.push('fn()'); - throw new Error('ERROR'); - }); - } catch (e) { - error = e.message; - } - expect(error).toEqual('ERROR'); - expect(log).toEqual(['write()', 'fn()', 'remove()']); - }); - - it('should error if a lock file already exists', () => { - const fs = getFileSystem(); - const log: string[] = []; - const lockFile = new MockLockFile(fs, log); - const locker = new SyncLocker(lockFile); - - spyOn(lockFile, 'write').and.callFake(() => { throw {code: 'EEXIST'}; }); - spyOn(lockFile, 'read').and.returnValue('188'); - - expect(() => locker.lock(() => {})) - .toThrowError( - `ngcc is already running at process with id 188.\n` + - `If you are running multiple builds in parallel then you should pre-process your node_modules via the command line ngcc tool before starting the builds;\n` + - `See https://v9.angular.io/guide/ivy#speeding-up-ngcc-compilation.\n` + - `(If you are sure no ngcc process is running then you should delete the lockFile at ${lockFile.path}.)`); - }); - }); - }); - - describe('AsyncLocker', () => { - describe('lock()', () => { - it('should guard the `fn()` with calls to `write()` and `remove()`', async() => { - const fs = getFileSystem(); - const log: string[] = []; - const lockFile = new MockLockFile(fs, log); - const locker = new AsyncLocker(lockFile, new MockLogger(), 100, 10); - - await locker.lock(async() => { - log.push('fn() - before'); - // This promise forces node to do a tick in this function, ensuring that we are truly - // testing an async scenario. - await Promise.resolve(); - log.push('fn() - after'); - return Promise.resolve(); - }); - expect(log).toEqual(['write()', 'fn() - before', 'fn() - after', 'remove()']); - }); - - it('should guard the `fn()` with calls to `write()` and `remove()`, even if it throws', - async() => { - let error: string = ''; - const fs = getFileSystem(); - const log: string[] = []; - const lockFile = new MockLockFile(fs, log); - const locker = new AsyncLocker(lockFile, new MockLogger(), 100, 10); - - try { - await locker.lock(async() => { - log.push('fn()'); - throw new Error('ERROR'); - }); - } catch (e) { - error = e.message; - } - expect(error).toEqual('ERROR'); - expect(log).toEqual(['write()', 'fn()', 'remove()']); - }); - - it('should retry if another process is locking', async() => { - const fs = getFileSystem(); - const log: string[] = []; - const lockFile = new MockLockFile(fs, log); - const logger = new MockLogger(); - const locker = new AsyncLocker(lockFile, logger, 100, 10); - - let lockFileContents: string|null = '188'; - spyOn(lockFile, 'write').and.callFake(() => { - log.push('write()'); - if (lockFileContents) { - throw {code: 'EEXIST'}; - } - }); - spyOn(lockFile, 'read').and.callFake(() => { - log.push('read() => ' + lockFileContents); - return lockFileContents; - }); - - const promise = locker.lock(async() => log.push('fn()')); - // The lock is now waiting on the lockFile becoming free, so no `fn()` in the log. - expect(log).toEqual(['write()', 'read() => 188']); - expect(logger.logs.info).toEqual([[ - 'Another process, with id 188, is currently running ngcc.\nWaiting up to 1s for it to finish.' - ]]); - - lockFileContents = null; - // The lockFile has been removed, so we can create our own lockFile, call `fn()` and then - // remove the lockFile. - await promise; - expect(log).toEqual(['write()', 'read() => 188', 'write()', 'fn()', 'remove()']); - }); - - it('should extend the retry timeout if the other process locking the file changes', async() => { - const fs = getFileSystem(); - const log: string[] = []; - const lockFile = new MockLockFile(fs, log); - const logger = new MockLogger(); - const locker = new AsyncLocker(lockFile, logger, 100, 10); - - let lockFileContents: string|null = '188'; - spyOn(lockFile, 'write').and.callFake(() => { - log.push('write()'); - if (lockFileContents) { - throw {code: 'EEXIST'}; - } - }); - spyOn(lockFile, 'read').and.callFake(() => { - log.push('read() => ' + lockFileContents); - return lockFileContents; - }); - - async() => { - const promise = locker.lock(async() => log.push('fn()')); - // The lock is now waiting on the lockFile becoming free, so no `fn()` in the log. - expect(log).toEqual(['write()', 'read() => 188']); - expect(logger.logs.info).toEqual([[ - 'Another process, with id 188, is currently running ngcc.\nWaiting up to 1s for it to finish.' - ]]); - - lockFileContents = '444'; - // The lockFile has been taken over by another process - await new Promise(resolve => setTimeout(resolve, 250)); - expect(log).toEqual(['write()', 'read() => 188', 'write()', 'read() => 444']); - expect(logger.logs.info).toEqual([ - [ - 'Another process, with id 188, is currently running ngcc.\nWaiting up to 1s for it to finish.' - ], - [ - 'Another process, with id 444, is currently running ngcc.\nWaiting up to 1s for it to finish.' - ] - ]); - - lockFileContents = null; - // The lockFile has been removed, so we can create our own lockFile, call `fn()` and then - // remove the lockFile. - await promise; - expect(log).toEqual([ - 'write()', 'read() => 188', 'write()', 'read() => 444', 'write()', 'fn()', 'remove()' - ]); - }; - }); - - it('should error if another process does not release the lockFile before this times out', - async() => { - const fs = getFileSystem(); - const log: string[] = []; - const lockFile = new MockLockFile(fs, log); - const logger = new MockLogger(); - const locker = new AsyncLocker(lockFile, logger, 100, 2); - - let lockFileContents: string|null = '188'; - spyOn(lockFile, 'write').and.callFake(() => { - log.push('write()'); - if (lockFileContents) { - throw {code: 'EEXIST'}; - } - }); - spyOn(lockFile, 'read').and.callFake(() => { - log.push('read() => ' + lockFileContents); - return lockFileContents; - }); - - const promise = locker.lock(async() => log.push('fn()')); - - // The lock is now waiting on the lockFile becoming free, so no `fn()` in the log. - expect(log).toEqual(['write()', 'read() => 188']); - // Do not remove the lockFile and let the call to `lock()` timeout. - let error: Error; - await promise.catch(e => error = e); - expect(log).toEqual(['write()', 'read() => 188', 'write()', 'read() => 188']); - expect(error !.message) - .toEqual( - `Timed out waiting 0.2s for another ngcc process, with id 188, to complete.\n` + - `(If you are sure no ngcc process is running then you should delete the lockFile at ${lockFile.path}.)`); - }); - }); - }); -}); diff --git a/packages/compiler-cli/ngcc/test/execution/single_processor_executor_spec.ts b/packages/compiler-cli/ngcc/test/execution/single_processor_executor_spec.ts index 4fa3c4d4e8..6d18cf8240 100644 --- a/packages/compiler-cli/ngcc/test/execution/single_processor_executor_spec.ts +++ b/packages/compiler-cli/ngcc/test/execution/single_processor_executor_spec.ts @@ -9,9 +9,9 @@ /// import {MockFileSystemNative} from '../../../src/ngtsc/file_system/testing'; -import {SyncLocker} from '../../src/execution/lock_file'; import {SingleProcessExecutorSync} from '../../src/execution/single_process_executor'; import {SerialTaskQueue} from '../../src/execution/task_selection/serial_task_queue'; +import {SyncLocker} from '../../src/locking/sync_locker'; import {PackageJsonUpdater} from '../../src/writing/package_json_updater'; import {MockLockFile} from '../helpers/mock_lock_file'; import {MockLogger} from '../helpers/mock_logger'; diff --git a/packages/compiler-cli/ngcc/test/helpers/mock_lock_file.ts b/packages/compiler-cli/ngcc/test/helpers/mock_lock_file.ts index a630265d8a..c4271216b8 100644 --- a/packages/compiler-cli/ngcc/test/helpers/mock_lock_file.ts +++ b/packages/compiler-cli/ngcc/test/helpers/mock_lock_file.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ import {FileSystem} from '../../../src/ngtsc/file_system'; -import {LockFile} from '../../src/execution/lock_file'; +import {LockFile} from '../../src/locking/lock_file'; /** * A mock implementation of `LockFile` that just logs its calls. diff --git a/packages/compiler-cli/ngcc/test/integration/ngcc_spec.ts b/packages/compiler-cli/ngcc/test/integration/ngcc_spec.ts index 7861ea16a1..698c660a9b 100644 --- a/packages/compiler-cli/ngcc/test/integration/ngcc_spec.ts +++ b/packages/compiler-cli/ngcc/test/integration/ngcc_spec.ts @@ -13,7 +13,7 @@ import * as os from 'os'; import {AbsoluteFsPath, FileSystem, absoluteFrom, getFileSystem, join} from '../../../src/ngtsc/file_system'; import {Folder, MockFileSystem, TestFile, runInEachFileSystem} from '../../../src/ngtsc/file_system/testing'; import {loadStandardTestFiles, loadTestFiles} from '../../../test/helpers'; -import {getLockFilePath} from '../../src/execution/lock_file'; +import {getLockFilePath} from '../../src/locking/lock_file'; import {mainNgcc} from '../../src/main'; import {markAsProcessed} from '../../src/packages/build_marker'; import {EntryPointJsonProperty, EntryPointPackageJson, SUPPORTED_FORMAT_PROPERTIES} from '../../src/packages/entry_point'; diff --git a/packages/compiler-cli/ngcc/test/locking/async_locker_spec.ts b/packages/compiler-cli/ngcc/test/locking/async_locker_spec.ts new file mode 100644 index 0000000000..0922c92f35 --- /dev/null +++ b/packages/compiler-cli/ngcc/test/locking/async_locker_spec.ts @@ -0,0 +1,169 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {getFileSystem} from '../../../src/ngtsc/file_system'; +import {runInEachFileSystem} from '../../../src/ngtsc/file_system/testing'; +import {AsyncLocker} from '../../src/locking/async_locker'; +import {MockLockFile} from '../helpers/mock_lock_file'; +import {MockLogger} from '../helpers/mock_logger'; + +runInEachFileSystem(() => { + describe('AsyncLocker', () => { + describe('lock()', () => { + it('should guard the `fn()` with calls to `write()` and `remove()`', async() => { + const fs = getFileSystem(); + const log: string[] = []; + const lockFile = new MockLockFile(fs, log); + const locker = new AsyncLocker(lockFile, new MockLogger(), 100, 10); + + await locker.lock(async() => { + log.push('fn() - before'); + // This promise forces node to do a tick in this function, ensuring that we are truly + // testing an async scenario. + await Promise.resolve(); + log.push('fn() - after'); + }); + expect(log).toEqual(['write()', 'fn() - before', 'fn() - after', 'remove()']); + }); + + it('should guard the `fn()` with calls to `write()` and `remove()`, even if it throws', + async() => { + let error: string = ''; + const fs = getFileSystem(); + const log: string[] = []; + const lockFile = new MockLockFile(fs, log); + const locker = new AsyncLocker(lockFile, new MockLogger(), 100, 10); + + try { + await locker.lock(async() => { + log.push('fn()'); + throw new Error('ERROR'); + }); + } catch (e) { + error = e.message; + } + expect(error).toEqual('ERROR'); + expect(log).toEqual(['write()', 'fn()', 'remove()']); + }); + + it('should retry if another process is locking', async() => { + const fs = getFileSystem(); + const log: string[] = []; + const lockFile = new MockLockFile(fs, log); + const logger = new MockLogger(); + const locker = new AsyncLocker(lockFile, logger, 100, 10); + + let lockFileContents: string|null = '188'; + spyOn(lockFile, 'write').and.callFake(() => { + log.push('write()'); + if (lockFileContents) { + throw {code: 'EEXIST'}; + } + }); + spyOn(lockFile, 'read').and.callFake(() => { + log.push('read() => ' + lockFileContents); + return lockFileContents; + }); + + const promise = locker.lock(async() => log.push('fn()')); + // The lock is now waiting on the lock-file becoming free, so no `fn()` in the log. + expect(log).toEqual(['write()', 'read() => 188']); + expect(logger.logs.info).toEqual([[ + 'Another process, with id 188, is currently running ngcc.\nWaiting up to 1s for it to finish.' + ]]); + + lockFileContents = null; + // The lock-file has been removed, so we can create our own lock-file, call `fn()` and then + // remove the lock-file. + await promise; + expect(log).toEqual(['write()', 'read() => 188', 'write()', 'fn()', 'remove()']); + }); + + it('should extend the retry timeout if the other process locking the file changes', async() => { + const fs = getFileSystem(); + const log: string[] = []; + const lockFile = new MockLockFile(fs, log); + const logger = new MockLogger(); + const locker = new AsyncLocker(lockFile, logger, 200, 5); + + let lockFileContents: string|null = '188'; + spyOn(lockFile, 'write').and.callFake(() => { + log.push('write()'); + if (lockFileContents) { + throw {code: 'EEXIST'}; + } + }); + spyOn(lockFile, 'read').and.callFake(() => { + log.push('read() => ' + lockFileContents); + return lockFileContents; + }); + + const promise = locker.lock(async() => log.push('fn()')); + // The lock is now waiting on the lock-file becoming free, so no `fn()` in the log. + expect(log).toEqual(['write()', 'read() => 188']); + expect(logger.logs.info).toEqual([[ + 'Another process, with id 188, is currently running ngcc.\nWaiting up to 1s for it to finish.' + ]]); + + lockFileContents = '444'; + // The lock-file has been taken over by another process - wait for the next attempt + await new Promise(resolve => setTimeout(resolve, 250)); + expect(log).toEqual(['write()', 'read() => 188', 'write()', 'read() => 444']); + expect(logger.logs.info).toEqual([ + [ + 'Another process, with id 188, is currently running ngcc.\nWaiting up to 1s for it to finish.' + ], + [ + 'Another process, with id 444, is currently running ngcc.\nWaiting up to 1s for it to finish.' + ] + ]); + + lockFileContents = null; + // The lock-file has been removed, so we can create our own lock-file, call `fn()` and + // then remove the lock-file. + await promise; + expect(log).toEqual([ + 'write()', 'read() => 188', 'write()', 'read() => 444', 'write()', 'fn()', 'remove()' + ]); + }); + + it('should error if another process does not release the lock-file before this times out', + async() => { + const fs = getFileSystem(); + const log: string[] = []; + const lockFile = new MockLockFile(fs, log); + const logger = new MockLogger(); + const locker = new AsyncLocker(lockFile, logger, 100, 2); + + let lockFileContents: string|null = '188'; + spyOn(lockFile, 'write').and.callFake(() => { + log.push('write()'); + if (lockFileContents) { + throw {code: 'EEXIST'}; + } + }); + spyOn(lockFile, 'read').and.callFake(() => { + log.push('read() => ' + lockFileContents); + return lockFileContents; + }); + + const promise = locker.lock(async() => log.push('fn()')); + + // The lock is now waiting on the lock-file becoming free, so no `fn()` in the log. + expect(log).toEqual(['write()', 'read() => 188']); + // Do not remove the lock-file and let the call to `lock()` timeout. + let error: Error; + await promise.catch(e => error = e); + expect(log).toEqual(['write()', 'read() => 188', 'write()', 'read() => 188']); + expect(error !.message) + .toEqual( + `Timed out waiting 0.2s for another ngcc process, with id 188, to complete.\n` + + `(If you are sure no ngcc process is running then you should delete the lock-file at ${lockFile.path}.)`); + }); + }); + }); +}); \ No newline at end of file diff --git a/packages/compiler-cli/ngcc/test/locking/lock_file_with_signal_handlers_spec.ts b/packages/compiler-cli/ngcc/test/locking/lock_file_with_signal_handlers_spec.ts new file mode 100644 index 0000000000..78269bb956 --- /dev/null +++ b/packages/compiler-cli/ngcc/test/locking/lock_file_with_signal_handlers_spec.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import * as process from 'process'; + +import {CachedFileSystem, FileSystem, getFileSystem} from '../../../src/ngtsc/file_system'; +import {runInEachFileSystem} from '../../../src/ngtsc/file_system/testing'; +import {LockFileWithSignalHandlers} from '../../src/locking/lock_file_with_signal_handlers'; + +runInEachFileSystem(() => { + describe('LockFileWithSignalHandlers', () => { + /** + * This class allows us to test ordering of the calls, and to avoid actually attaching signal + * handlers and most importantly not actually exiting the process. + */ + class LockFileUnderTest extends LockFileWithSignalHandlers { + log: string[] = []; + constructor(fs: FileSystem, private handleSignals = false) { + super(fs); + fs.ensureDir(fs.dirname(this.path)); + } + remove() { + this.log.push('remove()'); + super.remove(); + } + addSignalHandlers() { + this.log.push('addSignalHandlers()'); + if (this.handleSignals) { + super.addSignalHandlers(); + } + } + write() { + this.log.push('write()'); + super.write(); + } + read() { + const contents = super.read(); + this.log.push('read() => ' + contents); + return contents; + } + removeSignalHandlers() { + this.log.push('removeSignalHandlers()'); + super.removeSignalHandlers(); + } + exit(code: number) { this.log.push(`exit(${code})`); } + } + + describe('write()', () => { + it('should call `addSignalHandlers()`', () => { + const fs = getFileSystem(); + const lockFile = new LockFileUnderTest(fs); + lockFile.write(); + expect(lockFile.log).toEqual(['write()', 'addSignalHandlers()']); + }); + + it('should call `removeSignalHandlers()` if there is an error', () => { + const fs = getFileSystem(); + spyOn(fs, 'writeFile').and.throwError('WRITING ERROR'); + const lockFile = new LockFileUnderTest(fs); + expect(() => lockFile.write()).toThrowError('WRITING ERROR'); + expect(lockFile.log).toEqual(['write()', 'addSignalHandlers()', 'removeSignalHandlers()']); + }); + + it('should remove the lock-file if CTRL-C is triggered', () => { + const fs = getFileSystem(); + const lockFile = new LockFileUnderTest(fs, /* handleSignals */ true); + + lockFile.write(); + expect(lockFile.log).toEqual(['write()', 'addSignalHandlers()']); + + // Simulate the CTRL-C signal + lockFile.log.push('SIGINT'); + process.emit('SIGINT', 'SIGINT'); + + expect(lockFile.log).toEqual([ + 'write()', 'addSignalHandlers()', 'SIGINT', 'remove()', 'removeSignalHandlers()', + 'exit(1)' + ]); + }); + + it('should remove the lock-file if terminal is closed', () => { + const fs = getFileSystem(); + const lockFile = new LockFileUnderTest(fs, /* handleSignals */ true); + + lockFile.write(); + expect(lockFile.log).toEqual(['write()', 'addSignalHandlers()']); + + // Simulate the terminal being closed + lockFile.log.push('SIGHUP'); + process.emit('SIGHUP', 'SIGHUP'); + + expect(lockFile.log).toEqual([ + 'write()', 'addSignalHandlers()', 'SIGHUP', 'remove()', 'removeSignalHandlers()', + 'exit(1)' + ]); + }); + }); + + describe('read()', () => { + it('should return the contents of the lock-file', () => { + const fs = getFileSystem(); + const lockFile = new LockFileUnderTest(fs); + fs.writeFile(lockFile.path, '188'); + expect(lockFile.read()).toEqual('188'); + }); + + it('should return `{unknown}` if the lock-file does not exist', () => { + const fs = getFileSystem(); + const lockFile = new LockFileUnderTest(fs); + expect(lockFile.read()).toEqual('{unknown}'); + }); + + it('should not read file from the cache, since the file may have been modified externally', + () => { + const rawFs = getFileSystem(); + const fs = new CachedFileSystem(rawFs); + const lockFile = new LockFileUnderTest(fs); + rawFs.writeFile(lockFile.path, '188'); + expect(lockFile.read()).toEqual('188'); + // We need to write to the rawFs to ensure that we don't update the cache at this point + rawFs.writeFile(lockFile.path, '444'); + expect(lockFile.read()).toEqual('444'); + }); + }); + + describe('remove()', () => { + it('should remove the lock file from the file-system', () => { + const fs = getFileSystem(); + const lockFile = new LockFileUnderTest(fs); + fs.writeFile(lockFile.path, '188'); + lockFile.remove(); + expect(fs.exists(lockFile.path)).toBe(false); + }); + + it('should not error if the lock file does not exist', () => { + const fs = getFileSystem(); + const lockFile = new LockFileUnderTest(fs); + expect(() => lockFile.remove()).not.toThrow(); + }); + + it('should call removeSignalHandlers()', () => { + const fs = getFileSystem(); + const lockFile = new LockFileUnderTest(fs); + fs.writeFile(lockFile.path, '188'); + lockFile.remove(); + expect(lockFile.log).toEqual(['remove()', 'removeSignalHandlers()']); + }); + }); + }); +}); diff --git a/packages/compiler-cli/ngcc/test/locking/sync_locker_spec.ts b/packages/compiler-cli/ngcc/test/locking/sync_locker_spec.ts new file mode 100644 index 0000000000..8cd9687d07 --- /dev/null +++ b/packages/compiler-cli/ngcc/test/locking/sync_locker_spec.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {getFileSystem} from '../../../src/ngtsc/file_system'; +import {runInEachFileSystem} from '../../../src/ngtsc/file_system/testing'; +import {SyncLocker} from '../../src/locking/sync_locker'; +import {MockLockFile} from '../helpers/mock_lock_file'; + +runInEachFileSystem(() => { + describe('SyncLocker', () => { + describe('lock()', () => { + it('should guard the `fn()` with calls to `write()` and `remove()`', () => { + const fs = getFileSystem(); + const log: string[] = []; + const lockFile = new MockLockFile(fs, log); + const locker = new SyncLocker(lockFile); + + locker.lock(() => log.push('fn()')); + + expect(log).toEqual(['write()', 'fn()', 'remove()']); + }); + + it('should guard the `fn()` with calls to `write()` and `remove()`, even if it throws', + () => { + let error: string = ''; + const fs = getFileSystem(); + const log: string[] = []; + const lockFile = new MockLockFile(fs, log); + const locker = new SyncLocker(lockFile); + + try { + locker.lock(() => { + log.push('fn()'); + throw new Error('ERROR'); + }); + } catch (e) { + error = e.message; + } + expect(error).toEqual('ERROR'); + expect(log).toEqual(['write()', 'fn()', 'remove()']); + }); + + it('should error if a lock file already exists', () => { + const fs = getFileSystem(); + const log: string[] = []; + const lockFile = new MockLockFile(fs, log); + const locker = new SyncLocker(lockFile); + + spyOn(lockFile, 'write').and.callFake(() => { throw {code: 'EEXIST'}; }); + spyOn(lockFile, 'read').and.returnValue('188'); + + expect(() => locker.lock(() => {})) + .toThrowError( + `ngcc is already running at process with id 188.\n` + + `If you are running multiple builds in parallel then you should pre-process your node_modules via the command line ngcc tool before starting the builds;\n` + + `See https://v9.angular.io/guide/ivy#speeding-up-ngcc-compilation.\n` + + `(If you are sure no ngcc process is running then you should delete the lock-file at ${lockFile.path}.)`); + }); + }); + }); +});