Skip to content

Commit 644af3a

Browse files
committed
Handle multiple downloads + workspaces more robustly
1 parent 3b0b82e commit 644af3a

File tree

3 files changed

+79
-45
lines changed

3 files changed

+79
-45
lines changed

src/extension.ts

Lines changed: 34 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,22 @@ import { DocsBrowser } from './docsBrowser';
2525
import { downloadHaskellLanguageServer } from './hlsBinaries';
2626
import { executableExists } from './utils';
2727

28-
const clients: Map<string, LanguageClient> = new Map();
28+
// The current map of documents & folders to language servers.
29+
// It may be null to indicate that we are in the process of launching a server,
30+
// in which case don't try to launch another one for that uri
31+
const clients: Map<string, LanguageClient | null> = new Map();
2932

3033
// This is the entrypoint to our extension
3134
export async function activate(context: ExtensionContext) {
32-
// Register HIE to check every time a text document gets opened, to
33-
// support multi-root workspaces.
34-
35-
workspace.onDidOpenTextDocument(async (document: TextDocument) => await activateHie(context, document));
36-
workspace.textDocuments.forEach(async (document: TextDocument) => await activateHie(context, document));
37-
38-
// Stop HIE from any workspace folders that are removed.
35+
// (Possibly) launch the language server every time a document is opened, so
36+
// it works across multiple workspace folders. Eventually, haskell-lsp should
37+
// just support
38+
// https://microsoft.github.io/language-server-protocol/specifications/specification-3-15/#workspace_workspaceFolders
39+
// and then we can just launch one server
40+
workspace.onDidOpenTextDocument(async (document: TextDocument) => await activeServer(context, document));
41+
workspace.textDocuments.forEach(async (document: TextDocument) => await activeServer(context, document));
42+
43+
// Stop the server from any workspace folders that are removed.
3944
workspace.onDidChangeWorkspaceFolders((event) => {
4045
for (const folder of event.removed) {
4146
const client = clients.get(folder.uri.toString());
@@ -49,8 +54,8 @@ export async function activate(context: ExtensionContext) {
4954
// Register editor commands for HIE, but only register the commands once at activation.
5055
const restartCmd = commands.registerCommand(CommandNames.RestartHieCommandName, async () => {
5156
for (const langClient of clients.values()) {
52-
await langClient.stop();
53-
langClient.start();
57+
await langClient?.stop();
58+
langClient?.start();
5459
}
5560
});
5661
context.subscriptions.push(restartCmd);
@@ -107,7 +112,7 @@ function findLocalServer(context: ExtensionContext, uri: Uri, folder?: Workspace
107112
return null;
108113
}
109114

110-
async function activateHie(context: ExtensionContext, document: TextDocument) {
115+
async function activeServer(context: ExtensionContext, document: TextDocument) {
111116
// We are only interested in Haskell files.
112117
if (
113118
(document.languageId !== 'haskell' &&
@@ -121,15 +126,20 @@ async function activateHie(context: ExtensionContext, document: TextDocument) {
121126
const uri = document.uri;
122127
const folder = workspace.getWorkspaceFolder(uri);
123128

124-
// If the client already has an LSP server for this folder, then don't start a new one.
125-
if (folder && clients.has(folder.uri.toString())) {
129+
activateServerForFolder(context, uri, folder);
130+
}
131+
132+
async function activateServerForFolder(context: ExtensionContext, uri: Uri, folder?: WorkspaceFolder) {
133+
const clientsKey = folder ? folder.uri.toString() : uri.toString();
134+
135+
// If the client already has an LSP server for this uri/folder, then don't start a new one.
136+
if (clients.has(clientsKey)) {
126137
return;
127138
}
128-
activateHieNoCheck(context, uri, folder);
129-
}
139+
// Set the key to null to prevent multiple servers being launched at once
140+
clients.set(clientsKey, null);
130141

131-
async function activateHieNoCheck(context: ExtensionContext, uri: Uri, folder?: WorkspaceFolder) {
132-
// Stop right here, if HIE is disabled in the resource/workspace folder.
142+
// Stop right here, if Haskell is disabled in the resource/workspace folder.
133143
const enable = workspace.getConfiguration('haskell', uri).enable;
134144
if (!enable) {
135145
return;
@@ -223,35 +233,27 @@ async function activateHieNoCheck(context: ExtensionContext, uri: Uri, folder?:
223233
};
224234

225235
// Create the LSP client.
226-
const langClient = new LanguageClient(langName, langName, serverOptions, clientOptions, true);
236+
const langClient = new LanguageClient(langName, langName, serverOptions, clientOptions);
227237

228238
// Register ClientCapabilities for stuff like window/progress
229239
langClient.registerProposedFeatures();
230240

231-
// If the client already has an LSP server, then don't start a new one.
232-
// We check this again, as there may be multiple parallel requests.
233-
if (folder && clients.has(folder.uri.toString())) {
234-
return;
235-
}
236-
237241
// Finally start the client and add it to the list of clients.
238242
langClient.start();
239-
if (folder) {
240-
clients.set(folder.uri.toString(), langClient);
241-
} else {
242-
clients.set(uri.toString(), langClient);
243-
}
243+
clients.set(clientsKey, langClient);
244244
}
245245

246246
/*
247247
* Deactivate each of the LSP servers.
248248
*/
249-
export function deactivate(): Thenable<void> {
249+
export async function deactivate() {
250250
const promises: Array<Thenable<void>> = [];
251251
for (const client of clients.values()) {
252-
promises.push(client.stop());
252+
if (client) {
253+
promises.push(client.stop());
254+
}
253255
}
254-
return Promise.all(promises).then(() => undefined);
256+
await Promise.all(promises);
255257
}
256258

257259
function showNotInstalledErrorMessage(uri: Uri) {

src/hlsBinaries.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import * as child_process from 'child_process';
22
import * as fs from 'fs';
33
import * as https from 'https';
44
import * as path from 'path';
5-
import * as url from 'url';
6-
import { env, ExtensionContext, ProgressLocation, Uri, window, workspace, WorkspaceFolder } from 'vscode';
5+
import { env, ExtensionContext, ProgressLocation, Uri, window, WorkspaceFolder } from 'vscode';
76
import { downloadFile, executableExists, userAgentHeader } from './utils';
87

98
/** GitHub API release */
@@ -120,8 +119,11 @@ async function getProjectGhcVersion(context: ExtensionContext, dir: string, rele
120119
throw Error(`Couldn't find any ${assetName} binaries for release ${release.tag_name}`);
121120
}
122121

123-
const wrapperUrl = url.parse(wrapperAsset.browser_download_url);
124-
await downloadFile('Downloading haskell-language-server-wrapper', wrapperUrl, downloadedWrapper);
122+
await downloadFile(
123+
'Downloading haskell-language-server-wrapper',
124+
wrapperAsset.browser_download_url,
125+
downloadedWrapper
126+
);
125127

126128
return callWrapper(downloadedWrapper);
127129
}
@@ -199,14 +201,13 @@ export async function downloadHaskellLanguageServer(
199201
);
200202
return null;
201203
}
202-
const binaryURL = url.parse(asset.browser_download_url);
203204

204205
const serverName = `haskell-language-server-${release.tag_name}-${process.platform}-${ghcVersion}${exeExtension}`;
205206
const binaryDest = path.join(context.globalStoragePath, serverName);
206207

207208
const title = `Downloading haskell-language-server ${release.tag_name} for GHC ${ghcVersion}`;
208209
try {
209-
await downloadFile(title, binaryURL, binaryDest);
210+
await downloadFile(title, asset.browser_download_url, binaryDest);
210211
return binaryDest;
211212
} catch (e) {
212213
if (e instanceof Error) {

src/utils.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,25 @@ import { createGunzip } from 'zlib';
1414
*/
1515
export const userAgentHeader = { 'User-Agent': 'vscode-hie-server' };
1616

17-
export async function downloadFile(titleMsg: string, srcUrl: url.UrlWithStringQuery, dest: string): Promise<void> {
17+
/** downloadFile may get called twice on the same src and destination:
18+
* When this happens, we should only download the file once but return two
19+
* promises that wait on the same download. This map keeps track of which
20+
* files are currently being downloaded and we short circuit any calls to
21+
* downloadFile which have a hit in this map by returning the promise stored
22+
* here.
23+
* Note that we have to use a double nested map since array/pointer/object
24+
* equality is by reference, not value in Map. And we are using a tuple of
25+
* [src, dest] as the key.
26+
*/
27+
const inFlightDownloads = new Map<string, Map<string, Thenable<void>>>();
28+
29+
export async function downloadFile(titleMsg: string, src: string, dest: string): Promise<void> {
30+
// Check to see if we're already in the process of downloading the same thing
31+
const inFlightDownload = inFlightDownloads.get(src)?.get(dest);
32+
if (inFlightDownload) {
33+
return inFlightDownload;
34+
}
35+
1836
// If it already is downloaded just use that
1937
if (fs.existsSync(dest)) {
2038
return;
@@ -28,17 +46,20 @@ export async function downloadFile(titleMsg: string, srcUrl: url.UrlWithStringQu
2846
fs.unlinkSync(downloadDest);
2947
}
3048

31-
const downloadHieTask = window.withProgress(
49+
const downloadTask = window.withProgress(
3250
{
3351
location: ProgressLocation.Notification,
3452
title: titleMsg,
3553
cancellable: false,
3654
},
3755
async (progress) => {
3856
const p = new Promise<void>((resolve, reject) => {
57+
const srcUrl = url.parse(src);
3958
const opts: https.RequestOptions = {
4059
host: srcUrl.host,
4160
path: srcUrl.path,
61+
protocol: srcUrl.protocol,
62+
port: srcUrl.port,
4263
headers: userAgentHeader,
4364
};
4465
getWithRedirects(opts, (res) => {
@@ -67,17 +88,27 @@ export async function downloadFile(titleMsg: string, srcUrl: url.UrlWithStringQu
6788
fileStream.on('close', resolve);
6889
}).on('error', reject);
6990
});
70-
await p;
71-
// Finally rename it to the actual dest
72-
fs.renameSync(downloadDest, dest);
91+
try {
92+
await p;
93+
// Finally rename it to the actual dest
94+
fs.renameSync(downloadDest, dest);
95+
} finally {
96+
// And remember to remove it from the list of current downloads
97+
inFlightDownloads.get(src)?.delete(dest);
98+
}
7399
}
74100
);
75101

76102
try {
77-
return downloadHieTask;
103+
if (inFlightDownloads.has(src)) {
104+
inFlightDownloads.get(src)?.set(dest, downloadTask);
105+
} else {
106+
inFlightDownloads.set(src, new Map([[dest, downloadTask]]));
107+
}
108+
return downloadTask;
78109
} catch (e) {
79110
fs.unlinkSync(downloadDest);
80-
throw new Error(`Failed to download ${url.format(srcUrl)}`);
111+
throw new Error(`Failed to download ${url}`);
81112
}
82113
}
83114

0 commit comments

Comments
 (0)