Skip to content

Commit f09ce86

Browse files
authored
Merge pull request #299 from WorldSEnder/connection-issues
Use local files on connection failure, closes #298
2 parents 35085c4 + 2a6e93d commit f09ce86

File tree

5 files changed

+326
-40
lines changed

5 files changed

+326
-40
lines changed

package.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,22 @@
151151
"type": "string",
152152
"default": "",
153153
"description": "Manually set a language server executable. Can be something on the $PATH or a path to an executable itself. Works with ~, ${HOME} and ${workspaceFolder}."
154+
},
155+
"haskell.updateBehavior": {
156+
"scope": "machine",
157+
"type": "string",
158+
"enum": [
159+
"keep-up-to-date",
160+
"prompt",
161+
"never-check"
162+
],
163+
"enumDescriptions": [
164+
"Always download the latest available version when it is published",
165+
"Prompt before upgrading to a newer version",
166+
"Don't check for newer versions"
167+
],
168+
"default": "keep-up-to-date",
169+
"markdownDescription": "Only applicable with `#haskell.languageServerVariant#` set to `haskell-language-server`. Determine what to do when a new version of the language server is available."
154170
}
155171
}
156172
},

src/hlsBinaries.ts

Lines changed: 103 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import * as fs from 'fs';
33
import * as https from 'https';
44
import * as os from 'os';
55
import * as path from 'path';
6-
import { env, ExtensionContext, ProgressLocation, Uri, window, WorkspaceFolder } from 'vscode';
7-
import { downloadFile, executableExists, userAgentHeader } from './utils';
6+
import { promisify } from 'util';
7+
import { env, ExtensionContext, ProgressLocation, Uri, window, workspace, WorkspaceFolder } from 'vscode';
8+
import { downloadFile, executableExists, httpsGetSilently } from './utils';
9+
import * as validate from './validation';
810

911
/** GitHub API release */
1012
interface IRelease {
11-
assets: [IAsset];
13+
assets: IAsset[];
1214
tag_name: string;
1315
prerelease: boolean;
1416
}
@@ -18,6 +20,23 @@ interface IAsset {
1820
name: string;
1921
}
2022

23+
type UpdateBehaviour = 'keep-up-to-date' | 'prompt' | 'never-check';
24+
25+
const assetValidator: validate.Validator<IAsset> = validate.object({
26+
browser_download_url: validate.string(),
27+
name: validate.string(),
28+
});
29+
30+
const releaseValidator: validate.Validator<IRelease> = validate.object({
31+
assets: validate.array(assetValidator),
32+
tag_name: validate.string(),
33+
prerelease: validate.boolean(),
34+
});
35+
36+
const githubReleaseApiValidator: validate.Validator<IRelease[]> = validate.array(releaseValidator);
37+
38+
const cachedReleaseValidator: validate.Validator<IRelease | null> = validate.optional(releaseValidator);
39+
2140
// On Windows the executable needs to be stored somewhere with an .exe extension
2241
const exeExt = process.platform === 'win32' ? '.exe' : '';
2342

@@ -140,6 +159,75 @@ async function getProjectGhcVersion(context: ExtensionContext, dir: string, rele
140159
return callWrapper(downloadedWrapper);
141160
}
142161

162+
async function getLatestReleaseMetadata(context: ExtensionContext): Promise<IRelease | null> {
163+
const opts: https.RequestOptions = {
164+
host: 'api.github.com',
165+
path: '/repos/haskell/haskell-language-server/releases',
166+
};
167+
168+
const offlineCache = path.join(context.globalStoragePath, 'latestApprovedRelease.cache.json');
169+
170+
async function readCachedReleaseData(): Promise<IRelease | null> {
171+
try {
172+
const cachedInfo = await promisify(fs.readFile)(offlineCache, { encoding: 'utf-8' });
173+
return validate.parseAndValidate(cachedInfo, cachedReleaseValidator);
174+
} catch (err) {
175+
// If file doesn't exist, return null, otherwise consider it a failure
176+
if (err.code === 'ENOENT') {
177+
return null;
178+
}
179+
throw err;
180+
}
181+
}
182+
// Not all users want to upgrade right away, in that case prompt
183+
const updateBehaviour = workspace.getConfiguration('haskell').get('updateBehavior') as UpdateBehaviour;
184+
185+
if (updateBehaviour === 'never-check') {
186+
return readCachedReleaseData();
187+
}
188+
189+
try {
190+
const releaseInfo = await httpsGetSilently(opts);
191+
const latestInfoParsed =
192+
validate.parseAndValidate(releaseInfo, githubReleaseApiValidator).find((x) => !x.prerelease) || null;
193+
194+
if (updateBehaviour === 'prompt') {
195+
const cachedInfoParsed = await readCachedReleaseData();
196+
197+
if (
198+
latestInfoParsed !== null &&
199+
(cachedInfoParsed === null || latestInfoParsed.tag_name !== cachedInfoParsed.tag_name)
200+
) {
201+
const promptMessage =
202+
cachedInfoParsed === null
203+
? 'No version of the haskell-language-server is installed, would you like to install it now?'
204+
: 'A new version of the haskell-language-server is available, would you like to upgrade now?';
205+
206+
const decision = await window.showInformationMessage(promptMessage, 'Download', 'Nevermind');
207+
if (decision !== 'Download') {
208+
// If not upgrade, bail and don't overwrite cached version information
209+
return cachedInfoParsed;
210+
}
211+
}
212+
}
213+
214+
// Cache the latest successfully fetched release information
215+
await promisify(fs.writeFile)(offlineCache, JSON.stringify(latestInfoParsed), { encoding: 'utf-8' });
216+
return latestInfoParsed;
217+
} catch (githubError) {
218+
// Attempt to read from the latest cached file
219+
try {
220+
const cachedInfoParsed = await readCachedReleaseData();
221+
222+
window.showWarningMessage(
223+
`Couldn't get the latest haskell-language-server releases from GitHub, used local cache instead:\n${githubError.message}`
224+
);
225+
return cachedInfoParsed;
226+
} catch (fileError) {
227+
throw new Error(`Couldn't get the latest haskell-language-server releases from GitHub:\n${githubError.message}`);
228+
}
229+
}
230+
}
143231
/**
144232
* Downloads the latest haskell-language-server binaries from GitHub releases.
145233
* Returns null if it can't find any that match.
@@ -149,27 +237,6 @@ export async function downloadHaskellLanguageServer(
149237
resource: Uri,
150238
folder?: WorkspaceFolder
151239
): Promise<string | null> {
152-
// Fetch the latest release from GitHub
153-
const releases: IRelease[] = await new Promise((resolve, reject) => {
154-
let data: string = '';
155-
const opts: https.RequestOptions = {
156-
host: 'api.github.com',
157-
path: '/repos/haskell/haskell-language-server/releases',
158-
headers: userAgentHeader,
159-
};
160-
https
161-
.get(opts, (res) => {
162-
res.on('data', (d) => (data += d));
163-
res.on('error', reject);
164-
res.on('close', () => {
165-
resolve(JSON.parse(data));
166-
});
167-
})
168-
.on('error', (e) => {
169-
reject(new Error(`Couldn't get the latest haskell-language-server releases from GitHub:\n${e.message}`));
170-
});
171-
});
172-
173240
// Make sure to create this before getProjectGhcVersion
174241
if (!fs.existsSync(context.globalStoragePath)) {
175242
fs.mkdirSync(context.globalStoragePath);
@@ -182,13 +249,20 @@ export async function downloadHaskellLanguageServer(
182249
return null;
183250
}
184251

185-
const release = releases.find((x) => !x.prerelease);
252+
// Fetch the latest release from GitHub or from cache
253+
const release = await getLatestReleaseMetadata(context);
186254
if (!release) {
187-
window.showErrorMessage("Couldn't find any pre-built haskell-language-server binaries");
255+
let message = "Couldn't find any pre-built haskell-language-server binaries";
256+
const updateBehaviour = workspace.getConfiguration('haskell').get('updateBehavior') as UpdateBehaviour;
257+
if (updateBehaviour === 'never-check') {
258+
message += ' (and checking for newer versions is disabled)';
259+
}
260+
window.showErrorMessage(message);
188261
return null;
189262
}
190-
const dir: string = folder?.uri?.fsPath ?? path.dirname(resource.fsPath);
191263

264+
// Figure out the ghc version to use or advertise an installation link for missing components
265+
const dir: string = folder?.uri?.fsPath ?? path.dirname(resource.fsPath);
192266
let ghcVersion: string;
193267
try {
194268
ghcVersion = await getProjectGhcVersion(context, dir, release);
@@ -224,15 +298,8 @@ export async function downloadHaskellLanguageServer(
224298
const binaryDest = path.join(context.globalStoragePath, serverName);
225299

226300
const title = `Downloading haskell-language-server ${release.tag_name} for GHC ${ghcVersion}`;
227-
try {
228-
await downloadFile(title, asset.browser_download_url, binaryDest);
229-
return binaryDest;
230-
} catch (e) {
231-
if (e instanceof Error) {
232-
window.showErrorMessage(e.message);
233-
}
234-
return null;
235-
}
301+
await downloadFile(title, asset.browser_download_url, binaryDest);
302+
return binaryDest;
236303
}
237304

238305
/** Get the OS label used by GitHub for the current platform */

src/utils.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ import * as http from 'http';
66
import * as https from 'https';
77
import { extname } from 'path';
88
import * as url from 'url';
9+
import { promisify } from 'util';
910
import { ProgressLocation, window } from 'vscode';
1011
import * as yazul from 'yauzl';
1112
import { createGunzip } from 'zlib';
1213

1314
/** When making http requests to github.com, use this header otherwise
1415
* the server will close the request
1516
*/
16-
export const userAgentHeader = { 'User-Agent': 'vscode-haskell' };
17+
const userAgentHeader = { 'User-Agent': 'vscode-haskell' };
1718

1819
/** downloadFile may get called twice on the same src and destination:
1920
* When this happens, we should only download the file once but return two
@@ -27,6 +28,36 @@ export const userAgentHeader = { 'User-Agent': 'vscode-haskell' };
2728
*/
2829
const inFlightDownloads = new Map<string, Map<string, Thenable<void>>>();
2930

31+
export async function httpsGetSilently(options: https.RequestOptions): Promise<string> {
32+
const opts: https.RequestOptions = {
33+
...options,
34+
headers: {
35+
...(options.headers ?? {}),
36+
...userAgentHeader,
37+
},
38+
};
39+
40+
return new Promise((resolve, reject) => {
41+
let data: string = '';
42+
https
43+
.get(opts, (res) => {
44+
res.on('data', (d) => (data += d));
45+
res.on('error', reject);
46+
res.on('close', () => {
47+
resolve(data);
48+
});
49+
})
50+
.on('error', reject);
51+
});
52+
}
53+
54+
async function ignoreFileNotExists(err: NodeJS.ErrnoException): Promise<void> {
55+
if (err.code === 'ENOENT') {
56+
return;
57+
}
58+
throw err;
59+
}
60+
3061
export async function downloadFile(titleMsg: string, src: string, dest: string): Promise<void> {
3162
// Check to see if we're already in the process of downloading the same thing
3263
const inFlightDownload = inFlightDownloads.get(src)?.get(dest);
@@ -135,10 +166,10 @@ export async function downloadFile(titleMsg: string, src: string, dest: string):
135166
} else {
136167
inFlightDownloads.set(src, new Map([[dest, downloadTask]]));
137168
}
138-
return downloadTask;
169+
return await downloadTask;
139170
} catch (e) {
140-
fs.unlinkSync(downloadDest);
141-
throw new Error(`Failed to download ${url}`);
171+
await promisify(fs.unlink)(downloadDest).catch(ignoreFileNotExists);
172+
throw new Error(`Failed to download ${src}:\n${e.message}`);
142173
}
143174
}
144175

0 commit comments

Comments
 (0)