Skip to content

Commit 3441178

Browse files
author
Aleksander Boruch-Gruszecki
committed
VsCode plugin: review adjustments
An item in status bar now shows tracing status and can be clicked to adjust consent. Tracing and workspace dump now take into account that consent can be given/revoked - workspace dump is only initialized after consent is given, tracing socket is only created lazily as necessary.
1 parent 9af4bf3 commit 3441178

File tree

3 files changed

+166
-77
lines changed

3 files changed

+166
-77
lines changed

vscode-dotty/src/extension.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export let client: LanguageClient
1919

2020
import { Tracer } from './tracer';
2121

22-
const extensionName = 'dotty';
22+
export const extensionName = 'dotty';
2323
const extensionConfig = vscode.workspace.getConfiguration(extensionName);
2424

2525
let extensionContext: ExtensionContext
@@ -189,8 +189,7 @@ function configureIDE(sbtClasspath: string, languageServerScalaVersion: string,
189189

190190
function run(serverOptions: ServerOptions, isOldServer: boolean) {
191191

192-
tracer.initializeAsyncWorkspaceDump();
193-
const tracingOutputChannel = tracer.createLspOutputChannel();
192+
const { lspOutputChannel } = tracer.run();
194193

195194
const clientOptions: LanguageClientOptions = {
196195
documentSelector: [
@@ -202,7 +201,7 @@ function run(serverOptions: ServerOptions, isOldServer: boolean) {
202201
synchronize: {
203202
configurationSection: 'dotty'
204203
},
205-
outputChannel: tracingOutputChannel,
204+
outputChannel: lspOutputChannel,
206205
revealOutputChannelOn: RevealOutputChannelOn.Never
207206
}
208207

@@ -215,5 +214,4 @@ function run(serverOptions: ServerOptions, isOldServer: boolean) {
215214
// Push the disposable to the context's subscriptions so that the
216215
// client can be deactivated on extension deactivation
217216
extensionContext.subscriptions.push(client.start())
218-
if (tracingOutputChannel) extensionContext.subscriptions.push(tracingOutputChannel);
219217
}

vscode-dotty/src/tracer.ts

Lines changed: 156 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ import * as archiver from 'archiver';
77
import * as WebSocket from 'ws';
88
import * as request from 'request';
99

10+
import { extensionName } from './extension';
1011
import { TracingConsentCache } from './tracing-consent';
1112

13+
const consentCommandName = `${extensionName}.adjust-consent`;
14+
1215
export interface Ctx {
1316
readonly extensionContext: vscode.ExtensionContext,
1417
readonly extensionConfig: vscode.WorkspaceConfiguration,
@@ -24,13 +27,20 @@ export class Tracer {
2427

2528
private tracingConsent: TracingConsentCache
2629

27-
private remoteTracingUrl?: string
30+
private readonly remoteTracingUrl: string | undefined
31+
private readonly remoteWorkspaceDumpUrl: string | undefined
32+
private get isTracingEnabled(): boolean {
33+
return Boolean(this.remoteWorkspaceDumpUrl || this.remoteTracingUrl);
34+
}
2835

2936
constructor(ctx: Ctx) {
3037
this.ctx = ctx;
3138

3239
this.tracingConsent = new TracingConsentCache(ctx.extensionContext.workspaceState);
3340

41+
this.remoteWorkspaceDumpUrl = this.ctx.extensionConfig.get<string>('remoteWorkspaceDumpUrl');
42+
this.remoteTracingUrl = this.ctx.extensionConfig.get<string>('remoteTracingUrl');
43+
3444
this.machineId = (() => {
3545
const machineIdKey = 'tracing.machineId';
3646
function persisted(value: string): string {
@@ -60,35 +70,82 @@ export class Tracer {
6070
this.sessionId = new Date().toISOString();
6171
}
6272

63-
initializeAsyncWorkspaceDump() {
64-
const remoteWorkspaceDumpUrl = this.ctx.extensionConfig.get<string>('remoteWorkspaceDumpUrl');
65-
if (remoteWorkspaceDumpUrl === undefined) return;
73+
run(): { lspOutputChannel?: vscode.OutputChannel } {
74+
const consentCommandDisposable = vscode.commands.registerCommand(consentCommandName, () => this.askForTracingConsent());
75+
if (this.isTracingEnabled && this.tracingConsent.get() === 'no-answer') this.askForTracingConsent();
76+
this.initializeAsyncWorkspaceDump();
77+
const lspOutputChannel = this.createLspOutputChannel();
78+
const statusBarItem = this.createStatusBarItem();
79+
for (const disposable of [consentCommandDisposable, lspOutputChannel, statusBarItem]) {
80+
if (disposable) this.ctx.extensionContext.subscriptions.push(disposable);
81+
}
82+
return { lspOutputChannel };
83+
}
84+
85+
private askForTracingConsent(): void {
86+
vscode.window.showInformationMessage(
87+
'Do you want to help EPFL develop Dotty LSP plugin by uploading your LSP communication? ' +
88+
'PLEASE BE AWARE that the data sent contains your entire codebase and ALL the IDE actions, ' +
89+
'including every single keystroke.',
90+
'yes', 'no'
91+
).then((value: string | undefined) => {
92+
if (value === 'yes' || value === 'no') this.tracingConsent.set(value);
93+
});
94+
}
6695

67-
try {
68-
this.asyncUploadWorkspaceDump(remoteWorkspaceDumpUrl);
69-
} catch (err) {
70-
this.logError('error during workspace dump', safeError(err));
96+
private initializeAsyncWorkspaceDump() {
97+
if (this.remoteWorkspaceDumpUrl === undefined) return;
98+
// convince TS that this is a string
99+
const definedUrl: string = this.remoteWorkspaceDumpUrl;
100+
101+
const doInitialize = () => {
102+
try {
103+
this.asyncUploadWorkspaceDump(definedUrl);
104+
} catch (err) {
105+
this.logError('error during workspace dump', safeError(err));
106+
}
107+
};
108+
109+
if (this.tracingConsent.get() === 'yes') {
110+
doInitialize()
111+
} else {
112+
let didInitialize = false;
113+
this.tracingConsent.subscribe(() => {
114+
if (didInitialize) return;
115+
didInitialize = true;
116+
doInitialize();
117+
})
71118
}
72119
}
73120

74-
createLspOutputChannel(): vscode.OutputChannel | undefined {
75-
const remoteTracingUrl = this.ctx.extensionConfig.get<string>('remoteTracingUrl');
76-
if (!remoteTracingUrl) return undefined;
77-
78-
if (this.tracingConsent.get() === 'no-answer') {
79-
vscode.window.showInformationMessage(
80-
'Do you want to help EPFL develop this plugin by uploading your usage data? ' +
81-
'PLEASE BE AWARE that this will upload all of your keystrokes and all of your code, ' +
82-
'among other things.',
83-
'yes', 'no'
84-
).then((value: string | undefined) => {
85-
if (value === 'yes' || value === 'no') this.tracingConsent.set(value);
86-
});
121+
private createStatusBarItem(): vscode.StatusBarItem | undefined {
122+
if (!this.isTracingEnabled) return undefined;
123+
const item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 0)
124+
item.command = consentCommandName;
125+
const renderStatusBarItem = () => {
126+
item.text = (() => {
127+
const desc = this.tracingConsent.get() === 'yes' ? 'ON' : 'OFF';
128+
return `$(radio-tower) Dotty trace: ${desc}`;
129+
})();
130+
131+
item.tooltip = (() => {
132+
const desc = this.tracingConsent.get() === 'yes' ? 'consented' : 'not consented';
133+
return `This workspace is configured for remote tracing of Dotty LSP and you have ${desc} to it. ` +
134+
'Click to adjust your consent.';
135+
})();
87136
}
137+
renderStatusBarItem();
138+
this.tracingConsent.subscribe(renderStatusBarItem);
139+
item.show();
140+
return item;
141+
}
142+
143+
private createLspOutputChannel(): vscode.OutputChannel | undefined {
144+
if (!this.remoteTracingUrl) return undefined;
88145

89146
const localLspOutputChannel = vscode.window.createOutputChannel('Dotty LSP Communication')
90147
try {
91-
return this.createRemoteLspOutputChannel(remoteTracingUrl, localLspOutputChannel);
148+
return this.createRemoteLspOutputChannel(this.remoteTracingUrl, localLspOutputChannel);
92149
} catch (err) {
93150
this.logError('error during remote output channel creation', safeError(err));
94151
return localLspOutputChannel;
@@ -97,6 +154,7 @@ export class Tracer {
97154

98155
private asyncUploadWorkspaceDump(url: string) {
99156
const storagePath = this.ctx.extensionContext.storagePath;
157+
// TODO: handle multi-root workspaces
100158
const rootPath = vscode.workspace.rootPath;
101159
if (storagePath === undefined || rootPath === undefined) {
102160
this.logError('Cannot start workspace dump b/c of workspace state:', { storagePath, rootPath });
@@ -106,7 +164,7 @@ export class Tracer {
106164
if (!fs.existsSync(storagePath)) fs.mkdirSync(storagePath);
107165
const outputPath = path.join(storagePath, 'workspace-dump.zip');
108166
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
109-
let output = fs.createWriteStream(outputPath);
167+
const output = fs.createWriteStream(outputPath);
110168
output.on('end', () => {
111169
this.ctx.extensionOut.appendLine('zip - data has been drained');
112170
});
@@ -138,57 +196,81 @@ export class Tracer {
138196
);
139197
});
140198
zip.pipe(output);
141-
zip.glob('./**/*.{scala,sbt}', { cwd: rootPath });
199+
zip.glob('./**/*.{scala,sc,sbt,java}', { cwd: rootPath });
200+
zip.glob('./**/.dotty-ide{.json,-artifact}', { cwd: rootPath });
142201
zip.finalize();
143202
}
144203

145204
private createRemoteLspOutputChannel(
146205
remoteTracingUrl: string,
147206
localOutputChannel: vscode.OutputChannel
148207
): vscode.OutputChannel {
149-
const socketHeaders = {
150-
'X-DLS-Project-ID': this.projectId,
151-
'X-DLS-Client-ID': this.machineId,
152-
'X-DLS-Session-ID': this.sessionId,
153-
};
208+
const createSocket = () => {
209+
const socket = new WebSocket(remoteTracingUrl, {
210+
headers: {
211+
'X-DLS-Project-ID': this.projectId,
212+
'X-DLS-Client-ID': this.machineId,
213+
'X-DLS-Session-ID': this.sessionId,
214+
},
215+
});
216+
217+
const timer = setInterval(
218+
() => {
219+
if (socket.readyState === WebSocket.OPEN) {
220+
socket.send('');
221+
} else if (socket.readyState === WebSocket.CLOSED) {
222+
clearInterval(timer);
223+
}
224+
},
225+
10 * 1000 /*ms*/,
226+
)
227+
228+
socket.onerror = (event) => {
229+
this.logErrorWithoutNotifying(
230+
'socket error',
231+
remoteTracingUrl,
232+
new SafeJsonifier(event, (event) => ({
233+
error: safeError(event.error),
234+
message: event.message,
235+
type: event.type
236+
}))
237+
);
238+
vscode.window.showWarningMessage('An error occured in Dotty LSP remote tracing connection.');
239+
}
154240

155-
const socket = new WebSocket(remoteTracingUrl, { headers: socketHeaders });
241+
socket.onclose = (event) => {
242+
this.logErrorWithoutNotifying(
243+
'socket closed',
244+
remoteTracingUrl,
245+
new SafeJsonifier(event, (event) => ({
246+
wasClean: event.wasClean,
247+
code: event.code,
248+
reason: event.reason
249+
}))
250+
);
251+
vscode.window.showWarningMessage('Dotty LSP remote tracing connection was dropped.');
252+
}
156253

157-
const timer = setInterval(
158-
() => {
159-
if (socket.readyState === WebSocket.OPEN) {
160-
socket.send('');
161-
} else if (socket.readyState === WebSocket.CLOSED) {
162-
clearInterval(timer);
254+
return socket;
255+
};
256+
257+
let alreadyCreated = false;
258+
let socket: WebSocket;
259+
// note: creating socket lazily is important for correctness
260+
// if the user did not initially give his consent on IDE start, but gives it afterwards
261+
// we only want to start a connection and upload data *after* being given consent
262+
const withSocket: (thunk: (socket: WebSocket) => any) => void = (thunk) => {
263+
// only try to create the socket _once_ to avoid endlessly looping
264+
if (!alreadyCreated) {
265+
alreadyCreated = true;
266+
try {
267+
socket = createSocket();
268+
} catch (err) {
269+
this.logError('socket create error', safeError(err));
163270
}
164-
},
165-
10 * 1000 /*ms*/,
166-
)
167-
168-
socket.onerror = (event) => {
169-
this.logErrorWithoutNotifying(
170-
'socket error',
171-
remoteTracingUrl,
172-
new SafeJsonifier(event, (event) => ({
173-
error: safeError(event.error),
174-
message: event.message,
175-
type: event.type
176-
}))
177-
);
178-
vscode.window.showWarningMessage('An error occured in Dotty LSP remote tracing connection.');
179-
}
271+
}
180272

181-
socket.onclose = (event) => {
182-
this.logErrorWithoutNotifying(
183-
'socket closed',
184-
remoteTracingUrl,
185-
new SafeJsonifier(event, (event) => ({
186-
wasClean: event.wasClean,
187-
code: event.code,
188-
reason: event.reason
189-
}))
190-
);
191-
vscode.window.showWarningMessage('Dotty LSP remote tracing connection was dropped.');
273+
if (socket) thunk(socket);
192274
}
193275

194276
let log: string = '';
@@ -210,21 +292,23 @@ export class Tracer {
210292

211293
log += value;
212294
log += '\n';
213-
if (this.tracingConsent.get() === 'yes' && socket.readyState === WebSocket.OPEN) {
214-
socket.send(log, (err) => {
215-
if (err) {
216-
this.logError('socket send error', err)
217-
}
218-
});
219-
log = '';
220-
}
295+
if (this.tracingConsent.get() === 'yes') withSocket((socket) => {
296+
if (socket.readyState === WebSocket.OPEN) {
297+
socket.send(log, (err) => {
298+
if (err) {
299+
this.logError('socket send error', err)
300+
}
301+
});
302+
log = '';
303+
}
304+
});
221305
},
222306

223307
clear() { },
224308
show() { },
225309
hide() { },
226310
dispose() {
227-
socket.close();
311+
if (socket) socket.close();
228312
localOutputChannel.dispose();
229313
}
230314
};

vscode-dotty/src/tracing-consent.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ export type TracingConsent = 'yes' | 'no' | 'no-answer';
55
export class TracingConsentCache {
66
private readonly workspaceState: Memento
77

8+
// since updating Memento is async, caching prevents nonsense edge-cases
89
private cache?: TracingConsent
10+
private subscribers: Array<() => void> = [];
911

1012
constructor(workspaceState: Memento) {
1113
this.workspaceState = workspaceState;
@@ -23,5 +25,10 @@ export class TracingConsentCache {
2325
set(value: 'yes' | 'no'): void {
2426
this.workspaceState.update('remote-tracing-consent', value === 'yes');
2527
this.cache = value;
28+
this.subscribers.forEach(f => f());
29+
}
30+
31+
subscribe(callback: () => void): void {
32+
this.subscribers.push(callback);
2633
}
2734
}

0 commit comments

Comments
 (0)