Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit c55f900

Browse files
petebacondarwinmatsko
authored andcommitted
fix(ngcc): a new LockFile implementation that uses a child-process (#35861)
This version of `LockFile` creates an "unlocker" child-process that monitors the main ngcc process and deletes the lock file if it exits unexpectedly. This resolves the issue where the main process could not be killed by pressing Ctrl-C at the terminal. Fixes #35761 PR Close #35861
1 parent 4acd658 commit c55f900

File tree

7 files changed

+367
-2
lines changed

7 files changed

+367
-2
lines changed
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import {ChildProcess, fork} from 'child_process';
9+
10+
import {AbsoluteFsPath, CachedFileSystem, FileSystem} from '../../../../src/ngtsc/file_system';
11+
import {LogLevel, Logger} from '../../logging/logger';
12+
import {LockFile, getLockFilePath} from '../lock_file';
13+
14+
import {removeLockFile} from './util';
15+
16+
/// <reference types="node" />
17+
18+
/**
19+
* This `LockFile` implementation uses a child-process to remove the lock file when the main process
20+
* exits (for whatever reason).
21+
*
22+
* There are a few milliseconds between the child-process being forked and it registering its
23+
* `disconnect` event, which is responsible for tidying up the lock-file in the event that the main
24+
* process exits unexpectedly.
25+
*
26+
* We eagerly create the unlocker child-process so that it maximizes the time before the lock-file
27+
* is actually written, which makes it very unlikely that the unlocker would not be ready in the
28+
* case that the developer hits Ctrl-C or closes the terminal within a fraction of a second of the
29+
* lock-file being created.
30+
*
31+
* The worst case scenario is that ngcc is killed too quickly and leaves behind an orphaned
32+
* lock-file. In which case the next ngcc run will display a helpful error message about deleting
33+
* the lock-file.
34+
*/
35+
export class LockFileWithChildProcess implements LockFile {
36+
path: AbsoluteFsPath;
37+
private unlocker: ChildProcess|null;
38+
39+
constructor(protected fs: FileSystem, protected logger: Logger) {
40+
this.path = getLockFilePath(fs);
41+
this.unlocker = this.createUnlocker(this.path);
42+
}
43+
44+
45+
write(): void {
46+
if (this.unlocker === null) {
47+
// In case we already disconnected the previous unlocker child-process, perhaps by calling
48+
// `remove()`. Normally the LockFile should only be used once per instance.
49+
this.unlocker = this.createUnlocker(this.path);
50+
}
51+
this.logger.debug(`Attemping to write lock-file at ${this.path} with PID ${process.pid}`);
52+
// To avoid race conditions, check for existence of the lock-file by trying to create it.
53+
// This will throw an error if the file already exists.
54+
this.fs.writeFile(this.path, process.pid.toString(), /* exclusive */ true);
55+
this.logger.debug(`Written lock-file at ${this.path} with PID ${process.pid}`);
56+
}
57+
58+
read(): string {
59+
try {
60+
if (this.fs instanceof CachedFileSystem) {
61+
// The lock-file file is "volatile", it might be changed by an external process,
62+
// so we must not rely upon the cached value when reading it.
63+
this.fs.invalidateCaches(this.path);
64+
}
65+
return this.fs.readFile(this.path);
66+
} catch {
67+
return '{unknown}';
68+
}
69+
}
70+
71+
remove() {
72+
removeLockFile(this.fs, this.logger, this.path, process.pid.toString());
73+
if (this.unlocker !== null) {
74+
// If there is an unlocker child-process then disconnect from it so that it can exit itself.
75+
this.unlocker.disconnect();
76+
this.unlocker = null;
77+
}
78+
}
79+
80+
protected createUnlocker(path: AbsoluteFsPath): ChildProcess {
81+
this.logger.debug('Forking unlocker child-process');
82+
const logLevel =
83+
this.logger.level !== undefined ? this.logger.level.toString() : LogLevel.info.toString();
84+
return fork(this.fs.resolve(__dirname, './unlocker.js'), [path, logLevel], {detached: true});
85+
}
86+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import {NodeJSFileSystem} from '../../../../src/ngtsc/file_system';
9+
import {ConsoleLogger} from '../../logging/console_logger';
10+
import {removeLockFile} from './util';
11+
12+
/// <reference types="node" />
13+
14+
// This file is an entry-point for the child-process that is started by `LockFileWithChildProcess`
15+
// to ensure that the lock-file is removed when the primary process exits unexpectedly.
16+
17+
// We have no choice but to use the node.js file-system here since we are in a separate process
18+
// from the main ngcc run, which may be running a mock file-system from within a test.
19+
const fs = new NodeJSFileSystem();
20+
21+
// We create a logger that has the same logging level as the parent process, since it should have
22+
// been passed through as one of the args
23+
const logLevel = parseInt(process.argv.pop() !, 10);
24+
const logger = new ConsoleLogger(logLevel);
25+
26+
// We must store the parent PID now as it changes if the parent process is killed early
27+
const ppid = process.ppid.toString();
28+
29+
// The path to the lock-file to remove should have been passed as one of the args
30+
const lockFilePath = fs.resolve(process.argv.pop() !);
31+
32+
logger.debug(`Starting unlocker at process ${process.pid} on behalf of process ${ppid}`);
33+
logger.debug(`The lock-file path is ${lockFilePath}`);
34+
35+
/**
36+
* When the parent process exits (for whatever reason) remove the loc-file if it exists and as long
37+
* as it was one that was created by the parent process.
38+
*/
39+
process.on('disconnect', () => { removeLockFile(fs, logger, lockFilePath, ppid); });
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import {AbsoluteFsPath, FileSystem} from '../../../../src/ngtsc/file_system';
9+
import {Logger} from '../../logging/logger';
10+
11+
/**
12+
* Remove the lock-file at the provided `lockFilePath` from the given file-system.
13+
*
14+
* It only removes the file if the pid stored in the file matches the provided `pid`.
15+
* The provided `pid` is of the process that is exiting and so no longer needs to hold the lock.
16+
*/
17+
export function removeLockFile(
18+
fs: FileSystem, logger: Logger, lockFilePath: AbsoluteFsPath, pid: string) {
19+
try {
20+
logger.debug(`Attempting to remove lock-file at ${lockFilePath}.`);
21+
const lockFilePid = fs.readFile(lockFilePath);
22+
if (lockFilePid === pid) {
23+
logger.debug(`PIDs match (${pid}), so removing ${lockFilePath}.`);
24+
fs.removeFile(lockFilePath);
25+
} else {
26+
logger.debug(
27+
`PIDs do not match (${pid} and ${lockFilePid}), so not removing ${lockFilePath}.`);
28+
}
29+
} catch (e) {
30+
if (e.code === 'ENOENT') {
31+
logger.debug(`The lock-file at ${lockFilePath} was already removed.`);
32+
// File already removed so quietly exit
33+
} else {
34+
throw e;
35+
}
36+
}
37+
}

‎packages/compiler-cli/ngcc/src/main.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {SingleProcessExecutorAsync, SingleProcessExecutorSync} from './execution
3131
import {ParallelTaskQueue} from './execution/task_selection/parallel_task_queue';
3232
import {SerialTaskQueue} from './execution/task_selection/serial_task_queue';
3333
import {AsyncLocker} from './locking/async_locker';
34-
import {LockFileWithSignalHandlers} from './locking/lock_file_with_signal_handlers';
34+
import {LockFileWithChildProcess} from './locking/lock_file_with_child_process';
3535
import {SyncLocker} from './locking/sync_locker';
3636
import {ConsoleLogger} from './logging/console_logger';
3737
import {LogLevel, Logger} from './logging/logger';
@@ -338,7 +338,7 @@ function getTaskQueue(
338338
function getExecutor(
339339
async: boolean, inParallel: boolean, logger: Logger, pkgJsonUpdater: PackageJsonUpdater,
340340
fileSystem: FileSystem): Executor {
341-
const lockFile = new LockFileWithSignalHandlers(fileSystem);
341+
const lockFile = new LockFileWithChildProcess(fileSystem, logger);
342342
if (async) {
343343
// Execute asynchronously (either serially or in parallel)
344344
const locker = new AsyncLocker(lockFile, logger, 500, 50);
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import {ChildProcess} from 'child_process';
9+
import * as process from 'process';
10+
11+
import {CachedFileSystem, FileSystem, getFileSystem} from '../../../../src/ngtsc/file_system';
12+
import {runInEachFileSystem} from '../../../../src/ngtsc/file_system/testing';
13+
import {getLockFilePath} from '../../../src/locking/lock_file';
14+
import {LockFileWithChildProcess} from '../../../src/locking/lock_file_with_child_process';
15+
import {MockLogger} from '../../helpers/mock_logger';
16+
17+
runInEachFileSystem(() => {
18+
describe('LockFileWithChildProcess', () => {
19+
/**
20+
* This class allows us to test ordering of the calls, and to avoid actually attaching signal
21+
* handlers and most importantly not actually exiting the process.
22+
*/
23+
class LockFileUnderTest extends LockFileWithChildProcess {
24+
// Note that log is initialized in the `createUnlocker()` function that is called from
25+
// super(), so we can't initialize it here.
26+
log !: string[];
27+
constructor(fs: FileSystem) {
28+
super(fs, new MockLogger());
29+
fs.ensureDir(fs.dirname(this.path));
30+
}
31+
remove() {
32+
this.log.push('remove()');
33+
super.remove();
34+
}
35+
write() {
36+
this.log.push('write()');
37+
super.write();
38+
}
39+
read() {
40+
const contents = super.read();
41+
this.log.push('read() => ' + contents);
42+
return contents;
43+
}
44+
createUnlocker(): ChildProcess {
45+
this.log = this.log || [];
46+
this.log.push('createUnlocker()');
47+
const log = this.log;
48+
// Normally this would fork a child process and return it.
49+
// But we don't want to do that in these tests.
50+
return <any>{disconnect() { log.push('unlocker.disconnect()'); }};
51+
}
52+
}
53+
54+
describe('constructor', () => {
55+
it('should create the unlocker process', () => {
56+
const fs = getFileSystem();
57+
const lockFile = new LockFileUnderTest(fs);
58+
expect(lockFile.log).toEqual(['createUnlocker()']);
59+
});
60+
});
61+
62+
describe('write()', () => {
63+
it('should write the lock-file to disk', () => {
64+
const fs = getFileSystem();
65+
const lockFile = new LockFileUnderTest(fs);
66+
expect(fs.exists(getLockFilePath(fs))).toBe(false);
67+
lockFile.write();
68+
expect(fs.exists(getLockFilePath(fs))).toBe(true);
69+
expect(fs.readFile(getLockFilePath(fs))).toEqual('' + process.pid);
70+
});
71+
72+
it('should create the unlocker process if it is not already created', () => {
73+
const fs = getFileSystem();
74+
const lockFile = new LockFileUnderTest(fs);
75+
lockFile.log = [];
76+
(lockFile as any).unlocker = null;
77+
lockFile.write();
78+
expect(lockFile.log).toEqual(['write()', 'createUnlocker()']);
79+
expect((lockFile as any).unlocker).not.toBe(null);
80+
});
81+
});
82+
83+
describe('read()', () => {
84+
it('should return the contents of the lock-file', () => {
85+
const fs = getFileSystem();
86+
const lockFile = new LockFileUnderTest(fs);
87+
fs.writeFile(lockFile.path, '' + process.pid);
88+
expect(lockFile.read()).toEqual('' + process.pid);
89+
});
90+
91+
it('should return `{unknown}` if the lock-file does not exist', () => {
92+
const fs = getFileSystem();
93+
const lockFile = new LockFileUnderTest(fs);
94+
expect(lockFile.read()).toEqual('{unknown}');
95+
});
96+
97+
it('should not read file from the cache, since the file may have been modified externally',
98+
() => {
99+
const rawFs = getFileSystem();
100+
const fs = new CachedFileSystem(rawFs);
101+
const lockFile = new LockFileUnderTest(fs);
102+
rawFs.writeFile(lockFile.path, '' + process.pid);
103+
expect(lockFile.read()).toEqual('' + process.pid);
104+
// We need to write to the rawFs to ensure that we don't update the cache at this point
105+
rawFs.writeFile(lockFile.path, '444');
106+
expect(lockFile.read()).toEqual('444');
107+
});
108+
});
109+
110+
describe('remove()', () => {
111+
it('should remove the lock file from the file-system', () => {
112+
const fs = getFileSystem();
113+
const lockFile = new LockFileUnderTest(fs);
114+
fs.writeFile(lockFile.path, '' + process.pid);
115+
lockFile.remove();
116+
expect(fs.exists(lockFile.path)).toBe(false);
117+
});
118+
119+
it('should not error if the lock file does not exist', () => {
120+
const fs = getFileSystem();
121+
const lockFile = new LockFileUnderTest(fs);
122+
expect(() => lockFile.remove()).not.toThrow();
123+
});
124+
125+
it('should disconnect the unlocker child process', () => {
126+
const fs = getFileSystem();
127+
const lockFile = new LockFileUnderTest(fs);
128+
fs.writeFile(lockFile.path, '' + process.pid);
129+
lockFile.remove();
130+
expect(lockFile.log).toEqual(['createUnlocker()', 'remove()', 'unlocker.disconnect()']);
131+
expect((lockFile as any).unlocker).toBe(null);
132+
});
133+
});
134+
});
135+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
/// <reference types="node" />
10+
11+
describe('unlocker', () => {
12+
it('should attach a handler to the `disconnect` event', () => {
13+
spyOn(process, 'on');
14+
require('../../../src/locking/lock_file_with_child_process/unlocker');
15+
expect(process.on).toHaveBeenCalledWith('disconnect', jasmine.any(Function));
16+
});
17+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import {AbsoluteFsPath, FileSystem, absoluteFrom, getFileSystem} from '../../../../src/ngtsc/file_system';
9+
import {runInEachFileSystem} from '../../../../src/ngtsc/file_system/testing';
10+
import {removeLockFile} from '../../../src/locking/lock_file_with_child_process/util';
11+
import {MockLogger} from '../../helpers/mock_logger';
12+
13+
runInEachFileSystem(() => {
14+
describe('LockFileWithChildProcess utils', () => {
15+
let lockFilePath: AbsoluteFsPath;
16+
let fs: FileSystem;
17+
let logger: MockLogger;
18+
19+
beforeEach(() => {
20+
fs = getFileSystem();
21+
logger = new MockLogger();
22+
lockFilePath = absoluteFrom('/lockfile/path');
23+
fs.ensureDir(absoluteFrom('/lockfile'));
24+
});
25+
26+
describe('removeLockFile()', () => {
27+
it('should do nothing if there is no file to remove',
28+
() => { removeLockFile(fs, logger, absoluteFrom('/lockfile/path'), '1234'); });
29+
30+
it('should do nothing if the pid does not match', () => {
31+
fs.writeFile(lockFilePath, '888');
32+
removeLockFile(fs, logger, lockFilePath, '1234');
33+
expect(fs.exists(lockFilePath)).toBe(true);
34+
expect(fs.readFile(lockFilePath)).toEqual('888');
35+
});
36+
37+
it('should remove the file if the pid matches', () => {
38+
fs.writeFile(lockFilePath, '1234');
39+
removeLockFile(fs, logger, lockFilePath, '1234');
40+
expect(fs.exists(lockFilePath)).toBe(false);
41+
});
42+
43+
it('should re-throw any other error', () => {
44+
spyOn(fs, 'removeFile').and.throwError('removeFile() error');
45+
fs.writeFile(lockFilePath, '1234');
46+
expect(() => removeLockFile(fs, logger, lockFilePath, '1234'))
47+
.toThrowError('removeFile() error');
48+
});
49+
});
50+
});
51+
});

0 commit comments

Comments
 (0)