Skip to content

Commit dd8a930

Browse files
kyliauKeen Yee Liau
authored andcommitted
fix: move ngcc operation to server
This commit fixes a bug in which ngcc could not be resolved from the client when the extension is running in the production vscode environment. This is because internally, the client depends on `require.resolve` to look for `ngcc` given the directory where the `tsconfig.json` is. However, vscode provides its own [`require` mechanism][1], which is different from Node.js. In particular, the bug is due to `require.resolve` not honoring the `paths` parameter provided to the method. See Node.js [documentation][2] on `require.resolve`. We did not catch this bug because the custom `require` is not used in the extension development host. In order to mitigate this, we have a few options: 1. Use an external [`resolve` package][3] in the client to perform node module resolution 2. Bundle ngcc with the client 3. Move ngcc operation to the server (1) is not ideal because it adds an external dependency to the extension (2) could potentially lead to compatibility issues due to different version of ngcc in the extension vs user's project. (3) would not only solve this issue, but also provides the additional benefit of reducing the work needed to integrate the language server for clients other than vscode (vim, emacs, for example). This is because the client needs to do some extra work to find ngcc then launch it, and report back to the server that the operation has completed. With this change, the server handles ngcc completely, and no work is needed on the client side. As an aside, the initial motivation for implementing ngcc on the client side is to provide the guarantee that the server is idempotent, i.e. it should not produce the side effect of modifying files on disk, which ngcc does. However, at this point, the benefits outweigh the cons, so ngcc operation is moved to the server. [1]: https://github.com/microsoft/vscode/blob/92adef0bac1260a6273443a2132428fdfc28a31f/src/vs/loader.js#L743-L745 [2]: https://nodejs.org/docs/latest-v12.x/api/modules.html#modules_require_resolve_request_options [3]: https://www.npmjs.com/package/resolve
1 parent 13a4086 commit dd8a930

File tree

8 files changed

+130
-107
lines changed

8 files changed

+130
-107
lines changed

client/src/extension.ts

Lines changed: 35 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ import * as path from 'path';
1111
import * as vscode from 'vscode';
1212
import * as lsp from 'vscode-languageclient';
1313

14-
import * as notification from '../common/notifications';
14+
import {ProjectLoadingFinish, ProjectLoadingStart} from '../common/notifications';
15+
import {NgccProgress, NgccProgressToken, NgccProgressType} from '../common/progress';
1516

16-
import {resolveAndRunNgcc} from './command-ngcc';
1717
import {registerCommands} from './commands';
18-
import {withProgress} from './progress-reporter';
18+
import {ProgressReporter} from './progress-reporter';
1919

2020
export function activate(context: vscode.ExtensionContext) {
2121
// If the extension is launched in debug mode then the debug server options are used
@@ -59,50 +59,54 @@ export function activate(context: vscode.ExtensionContext) {
5959
client.onDidChangeState((e: lsp.StateChangeEvent) => {
6060
if (e.newState === lsp.State.Running) {
6161
registerNotificationHandlers(client);
62+
registerProgressHandlers(client, context);
6263
}
6364
});
6465
}
6566

6667
function registerNotificationHandlers(client: lsp.LanguageClient) {
67-
client.onNotification(notification.ProjectLoadingStart, () => {
68+
client.onNotification(ProjectLoadingStart, () => {
6869
vscode.window.withProgress(
6970
{
7071
location: vscode.ProgressLocation.Window,
7172
title: 'Initializing Angular language features',
7273
},
7374
() => new Promise<void>((resolve) => {
74-
client.onNotification(notification.ProjectLoadingFinish, resolve);
75+
client.onNotification(ProjectLoadingFinish, resolve);
7576
}),
7677
);
7778
});
79+
}
7880

79-
client.onNotification(notification.RunNgcc, async (params: notification.RunNgccParams) => {
80-
const {configFilePath} = params;
81-
try {
82-
await withProgress(
83-
{
84-
location: vscode.ProgressLocation.Window,
85-
title: `Running ngcc for project ${configFilePath}`,
86-
cancellable: false,
87-
},
88-
(progress: vscode.Progress<string>) => {
89-
return resolveAndRunNgcc(configFilePath, progress);
90-
},
91-
);
92-
client.sendNotification(notification.NgccComplete, {
93-
configFilePath,
94-
success: true,
95-
});
96-
} catch (e) {
97-
vscode.window.showWarningMessage(
98-
`Failed to run ngcc. Ivy language service might not function correctly. Please see the log file for more information.`);
99-
client.sendNotification(notification.NgccComplete, {
100-
configFilePath,
101-
success: false,
102-
error: e.message,
81+
function registerProgressHandlers(client: lsp.LanguageClient, context: vscode.ExtensionContext) {
82+
const progressReporters = new Map<string, ProgressReporter>();
83+
const disposable =
84+
client.onProgress(NgccProgressType, NgccProgressToken, async (params: NgccProgress) => {
85+
const {configFilePath} = params;
86+
if (!progressReporters.has(configFilePath)) {
87+
progressReporters.set(configFilePath, new ProgressReporter());
88+
}
89+
const reporter = progressReporters.get(configFilePath)!;
90+
if (params.done) {
91+
reporter.finish();
92+
progressReporters.delete(configFilePath);
93+
if (!params.success) {
94+
const selection = await vscode.window.showErrorMessage(
95+
`Failed to run ngcc. Ivy language service is disabled. ` +
96+
`Please see the extension output for more information.`,
97+
{modal: true},
98+
'See error message',
99+
);
100+
if (selection) {
101+
client.outputChannel.show();
102+
}
103+
}
104+
} else {
105+
reporter.report(params.message);
106+
}
103107
});
104-
}
105-
});
108+
// Dispose the progress handler on exit
109+
context.subscriptions.push(disposable);
106110
}
107111

108112
/**

client/src/progress-reporter.ts

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,43 +10,18 @@ import * as vscode from 'vscode';
1010

1111
const EMPTY_DISPOSABLE = vscode.Disposable.from();
1212

13-
class ProgressReporter implements vscode.Progress<unknown> {
13+
export class ProgressReporter implements vscode.Progress<unknown> {
1414
private lastMessage: vscode.Disposable = EMPTY_DISPOSABLE;
1515

1616
report(value: unknown) {
1717
this.lastMessage.dispose(); // clear the last message
1818
// See https://code.visualstudio.com/api/references/icons-in-labels for
1919
// icons available in vscode. "~spin" animates the icon.
20-
this.lastMessage = vscode.window.setStatusBarMessage(`$(loading~spin) Angular: ${value}`);
20+
this.lastMessage = vscode.window.setStatusBarMessage(`$(sync~spin) Angular: ${value}`);
2121
}
2222

2323
finish() {
2424
this.lastMessage.dispose();
2525
this.lastMessage = EMPTY_DISPOSABLE;
2626
}
2727
}
28-
29-
interface Task<R> {
30-
(progress: vscode.Progress<string>): Promise<R>;
31-
}
32-
33-
/**
34-
* Show progress in the editor. Progress is shown while running the given `task`
35-
* callback and while the promise it returns is in the pending state.
36-
* If the given `task` returns a rejected promise, this function will reject with
37-
* the same promise.
38-
*/
39-
export async function withProgress<R>(options: vscode.ProgressOptions, task: Task<R>): Promise<R> {
40-
// Although not strictly compatible, the signature of this function follows
41-
// the signature of vscode.window.withProgress() to make it easier to switch
42-
// to the official API if we choose to do so later.
43-
const reporter = new ProgressReporter();
44-
if (options.title) {
45-
reporter.report(options.title);
46-
}
47-
try {
48-
return await task(reporter);
49-
} finally {
50-
reporter.finish();
51-
}
52-
}

common/notifications.ts

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,3 @@ export interface ProjectLanguageServiceParams {
1818

1919
export const ProjectLanguageService =
2020
new NotificationType<ProjectLanguageServiceParams>('angular/projectLanguageService');
21-
22-
export interface RunNgccParams {
23-
configFilePath: string;
24-
}
25-
26-
export const RunNgcc = new NotificationType<RunNgccParams>('angular/runNgcc');
27-
28-
export type NgccCompleteParams = {
29-
configFilePath: string; success: true;
30-
}|{
31-
configFilePath: string;
32-
success: false;
33-
error: string;
34-
};
35-
36-
export const NgccComplete = new NotificationType<NgccCompleteParams>('angular/ngccComplete');

common/progress.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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+
import {ProgressType} from 'vscode-jsonrpc';
10+
11+
export type NgccProgress = {
12+
done: false,
13+
configFilePath: string,
14+
message: string,
15+
}|{
16+
done: true,
17+
configFilePath: string,
18+
success: boolean,
19+
};
20+
export const NgccProgressToken = 'ngcc';
21+
export const NgccProgressType = new ProgressType<NgccProgress>();

integration/lsp/ivy_spec.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
import {MessageConnection} from 'vscode-jsonrpc';
1010
import * as lsp from 'vscode-languageserver-protocol';
1111

12-
import {NgccComplete, ProjectLanguageService, ProjectLanguageServiceParams, RunNgcc, RunNgccParams} from '../../common/notifications';
12+
import {ProjectLanguageService, ProjectLanguageServiceParams} from '../../common/notifications';
13+
import {NgccProgress, NgccProgressToken, NgccProgressType} from '../../common/progress';
1314

1415
import {APP_COMPONENT, createConnection, FOO_TEMPLATE, initializeServer, openTextDocument} from './test_utils';
1516

@@ -30,9 +31,9 @@ describe('Angular Ivy language server', () => {
3031
client.dispose();
3132
});
3233

33-
it('should send ngcc notification after a project has finished loading', async () => {
34+
it('should send ngcc progress after a project has finished loading', async () => {
3435
openTextDocument(client, APP_COMPONENT);
35-
const configFilePath = await onRunNgccNotification(client);
36+
const configFilePath = await onNgccProgress(client);
3637
expect(configFilePath.endsWith('integration/project/tsconfig.json')).toBeTrue();
3738
});
3839

@@ -82,10 +83,12 @@ describe('Angular Ivy language server', () => {
8283
});
8384
});
8485

85-
function onRunNgccNotification(client: MessageConnection): Promise<string> {
86+
function onNgccProgress(client: MessageConnection): Promise<string> {
8687
return new Promise(resolve => {
87-
client.onNotification(RunNgcc, (params: RunNgccParams) => {
88-
resolve(params.configFilePath);
88+
client.onProgress(NgccProgressType, NgccProgressToken, (params: NgccProgress) => {
89+
if (params.done) {
90+
resolve(params.configFilePath);
91+
}
8992
});
9093
});
9194
}
@@ -111,11 +114,6 @@ function getDiagnosticsForFile(
111114
}
112115

113116
async function waitForNgcc(client: MessageConnection): Promise<boolean> {
114-
const configFilePath = await onRunNgccNotification(client);
115-
// We run ngcc before the test, so no need to do anything here.
116-
client.sendNotification(NgccComplete, {
117-
success: true,
118-
configFilePath,
119-
});
117+
await onNgccProgress(client);
120118
return onLanguageServiceStateNotification(client);
121119
}

scripts/test.sh

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ set -ex -o pipefail
66
(
77
cd integration/project
88
yarn
9-
yarn ngcc
109
)
1110

1211
# Server unit tests

client/src/command-ngcc.ts renamed to server/src/ngcc.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,8 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {spawn} from 'child_process';
9+
import {fork} from 'child_process';
1010
import * as path from 'path';
11-
import * as vscode from 'vscode';
1211

1312
function resolveNgccFrom(directory: string): string|null {
1413
try {
@@ -20,18 +19,21 @@ function resolveNgccFrom(directory: string): string|null {
2019
}
2120
}
2221

22+
interface Progress {
23+
report(msg: string): void;
24+
}
25+
2326
/**
2427
* Resolve ngcc from the directory that contains the specified `tsconfig` and
2528
* run ngcc.
2629
*/
27-
export async function resolveAndRunNgcc(
28-
tsconfig: string, progress: vscode.Progress<string>): Promise<void> {
30+
export async function resolveAndRunNgcc(tsconfig: string, progress: Progress): Promise<void> {
2931
const directory = path.dirname(tsconfig);
3032
const ngcc = resolveNgccFrom(directory);
3133
if (!ngcc) {
3234
throw new Error(`Failed to resolve ngcc from ${directory}`);
3335
}
34-
const childProcess = spawn(
36+
const childProcess = fork(
3537
ngcc,
3638
[
3739
'--tsconfig',
@@ -41,7 +43,12 @@ export async function resolveAndRunNgcc(
4143
cwd: directory,
4244
});
4345

44-
childProcess.stdout.on('data', (data: Buffer) => {
46+
let stderr = '';
47+
childProcess.stderr?.on('data', (data: Buffer) => {
48+
stderr += data.toString();
49+
});
50+
51+
childProcess.stdout?.on('data', (data: Buffer) => {
4552
for (let entry of data.toString().split('\n')) {
4653
entry = entry.trim();
4754
if (entry) {
@@ -58,7 +65,8 @@ export async function resolveAndRunNgcc(
5865
if (code === 0) {
5966
resolve();
6067
} else {
61-
throw new Error(`ngcc for ${tsconfig} returned exit code ${code}`);
68+
reject(
69+
new Error(`ngcc for ${tsconfig} returned exit code ${code}, stderr: ${stderr.trim()}`));
6270
}
6371
});
6472
});

0 commit comments

Comments
 (0)