aboutsummaryrefslogtreecommitdiff
path: root/editors/code/src/installation
diff options
context:
space:
mode:
authorAleksey Kladov <[email protected]>2020-03-17 11:44:31 +0000
committerAleksey Kladov <[email protected]>2020-03-19 08:04:59 +0000
commitfb6e655de8a44c65275ad45a27bf5bd684670ba0 (patch)
tree9c307ac69c8fc59465ee2fb6f9a8a619fc064167 /editors/code/src/installation
parentf0a1b64d7ee3baa7ccf980b35b85f0a4a3b85b1a (diff)
Rewrite auto-update
Everything now happens in main.ts, in the bootstrap family of functions. The current flow is: * check everything only on extension installation. * if the user is on nightly channel, try to download the nightly extension and reload. * when we install nightly extension, we persist its release id, so that we can check if the current release is different. * if server binary was not downloaded by the current version of the extension, redownload it (we persist the version of ext that downloaded the server).
Diffstat (limited to 'editors/code/src/installation')
-rw-r--r--editors/code/src/installation/downloads.ts97
-rw-r--r--editors/code/src/installation/extension.ts146
-rw-r--r--editors/code/src/installation/fetch_artifact_release_info.ts77
-rw-r--r--editors/code/src/installation/interfaces.ts63
-rw-r--r--editors/code/src/installation/server.ts131
5 files changed, 0 insertions, 514 deletions
diff --git a/editors/code/src/installation/downloads.ts b/editors/code/src/installation/downloads.ts
deleted file mode 100644
index 7ce2e2960..000000000
--- a/editors/code/src/installation/downloads.ts
+++ /dev/null
@@ -1,97 +0,0 @@
1import fetch from "node-fetch";
2import * as vscode from "vscode";
3import * as path from "path";
4import * as fs from "fs";
5import * as stream from "stream";
6import * as util from "util";
7import { log, assert } from "../util";
8import { ArtifactReleaseInfo } from "./interfaces";
9
10const pipeline = util.promisify(stream.pipeline);
11
12/**
13 * Downloads file from `url` and stores it at `destFilePath` with `destFilePermissions`.
14 * `onProgress` callback is called on recieveing each chunk of bytes
15 * to track the progress of downloading, it gets the already read and total
16 * amount of bytes to read as its parameters.
17 */
18export async function downloadFile(
19 url: string,
20 destFilePath: fs.PathLike,
21 destFilePermissions: number,
22 onProgress: (readBytes: number, totalBytes: number) => void
23): Promise<void> {
24 const res = await fetch(url);
25
26 if (!res.ok) {
27 log.error("Error", res.status, "while downloading file from", url);
28 log.error({ body: await res.text(), headers: res.headers });
29
30 throw new Error(`Got response ${res.status} when trying to download a file.`);
31 }
32
33 const totalBytes = Number(res.headers.get('content-length'));
34 assert(!Number.isNaN(totalBytes), "Sanity check of content-length protocol");
35
36 log.debug("Downloading file of", totalBytes, "bytes size from", url, "to", destFilePath);
37
38 let readBytes = 0;
39 res.body.on("data", (chunk: Buffer) => {
40 readBytes += chunk.length;
41 onProgress(readBytes, totalBytes);
42 });
43
44 const destFileStream = fs.createWriteStream(destFilePath, { mode: destFilePermissions });
45
46 await pipeline(res.body, destFileStream);
47 return new Promise<void>(resolve => {
48 destFileStream.on("close", resolve);
49 destFileStream.destroy();
50
51 // Details on workaround: https://github.com/rust-analyzer/rust-analyzer/pull/3092#discussion_r378191131
52 // Issue at nodejs repo: https://github.com/nodejs/node/issues/31776
53 });
54}
55
56/**
57 * Downloads artifact from given `downloadUrl`.
58 * Creates `installationDir` if it is not yet created and puts the artifact under
59 * `artifactFileName`.
60 * Displays info about the download progress in an info message printing the name
61 * of the artifact as `displayName`.
62 */
63export async function downloadArtifactWithProgressUi(
64 { downloadUrl, releaseName }: ArtifactReleaseInfo,
65 artifactFileName: string,
66 installationDir: string,
67 displayName: string,
68) {
69 await fs.promises.mkdir(installationDir).catch(err => assert(
70 err?.code === "EEXIST",
71 `Couldn't create directory "${installationDir}" to download ` +
72 `${artifactFileName} artifact: ${err?.message}`
73 ));
74
75 const installationPath = path.join(installationDir, artifactFileName);
76
77 await vscode.window.withProgress(
78 {
79 location: vscode.ProgressLocation.Notification,
80 cancellable: false, // FIXME: add support for canceling download?
81 title: `Downloading rust-analyzer ${displayName} (${releaseName})`
82 },
83 async (progress, _cancellationToken) => {
84 let lastPrecentage = 0;
85 const filePermissions = 0o755; // (rwx, r_x, r_x)
86 await downloadFile(downloadUrl, installationPath, filePermissions, (readBytes, totalBytes) => {
87 const newPercentage = (readBytes / totalBytes) * 100;
88 progress.report({
89 message: newPercentage.toFixed(0) + "%",
90 increment: newPercentage - lastPrecentage
91 });
92
93 lastPrecentage = newPercentage;
94 });
95 }
96 );
97}
diff --git a/editors/code/src/installation/extension.ts b/editors/code/src/installation/extension.ts
deleted file mode 100644
index a1db96f05..000000000
--- a/editors/code/src/installation/extension.ts
+++ /dev/null
@@ -1,146 +0,0 @@
1import * as vscode from "vscode";
2import * as path from "path";
3import { promises as fs } from 'fs';
4
5import { vscodeReinstallExtension, vscodeReloadWindow, log, vscodeInstallExtensionFromVsix, assert, notReentrant } from "../util";
6import { Config, UpdatesChannel } from "../config";
7import { ArtifactReleaseInfo, ArtifactSource } from "./interfaces";
8import { downloadArtifactWithProgressUi } from "./downloads";
9import { fetchArtifactReleaseInfo } from "./fetch_artifact_release_info";
10import { PersistentState } from "../persistent_state";
11
12const HEURISTIC_NIGHTLY_RELEASE_PERIOD_IN_HOURS = 25;
13
14/**
15 * Installs `stable` or latest `nightly` version or does nothing if the current
16 * extension version is what's needed according to `desiredUpdateChannel`.
17 */
18export async function ensureProperExtensionVersion(config: Config, state: PersistentState): Promise<never | void> {
19 // User has built lsp server from sources, she should manage updates manually
20 if (config.serverSource?.type === ArtifactSource.Type.ExplicitPath) return;
21
22 const currentUpdChannel = config.installedExtensionUpdateChannel;
23 const desiredUpdChannel = config.updatesChannel;
24
25 if (currentUpdChannel === UpdatesChannel.Stable) {
26 // Release date is present only when we are on nightly
27 await state.installedNightlyExtensionReleaseDate.set(null);
28 }
29
30 if (desiredUpdChannel === UpdatesChannel.Stable) {
31 // VSCode should handle updates for stable channel
32 if (currentUpdChannel === UpdatesChannel.Stable) return;
33
34 if (!await askToDownloadProperExtensionVersion(config)) return;
35
36 await vscodeReinstallExtension(config.extensionId);
37 await vscodeReloadWindow(); // never returns
38 }
39
40 if (currentUpdChannel === UpdatesChannel.Stable) {
41 if (!await askToDownloadProperExtensionVersion(config)) return;
42
43 return await tryDownloadNightlyExtension(config, state);
44 }
45
46 const currentExtReleaseDate = state.installedNightlyExtensionReleaseDate.get();
47
48 if (currentExtReleaseDate === null) {
49 void vscode.window.showErrorMessage(
50 "Nightly release date must've been set during the installation. " +
51 "Did you download and install the nightly .vsix package manually?"
52 );
53 throw new Error("Nightly release date was not set in globalStorage");
54 }
55
56 const dateNow = new Date;
57 const hoursSinceLastUpdate = diffInHours(currentExtReleaseDate, dateNow);
58 log.debug(
59 "Current rust-analyzer nightly was downloaded", hoursSinceLastUpdate,
60 "hours ago, namely:", currentExtReleaseDate, "and now is", dateNow
61 );
62
63 if (hoursSinceLastUpdate < HEURISTIC_NIGHTLY_RELEASE_PERIOD_IN_HOURS) {
64 return;
65 }
66 if (!await askToDownloadProperExtensionVersion(config, "The installed nightly version is most likely outdated. ")) {
67 return;
68 }
69
70 await tryDownloadNightlyExtension(config, state, releaseInfo => {
71 assert(
72 currentExtReleaseDate.getTime() === state.installedNightlyExtensionReleaseDate.get()?.getTime(),
73 "Other active VSCode instance has reinstalled the extension"
74 );
75
76 if (releaseInfo.releaseDate.getTime() === currentExtReleaseDate.getTime()) {
77 vscode.window.showInformationMessage(
78 "Whoops, it appears that your nightly version is up-to-date. " +
79 "There might be some problems with the upcomming nightly release " +
80 "or you traveled too far into the future. Sorry for that 😅! "
81 );
82 return false;
83 }
84 return true;
85 });
86}
87
88async function askToDownloadProperExtensionVersion(config: Config, reason = "") {
89 if (!config.askBeforeDownload) return true;
90
91 const stableOrNightly = config.updatesChannel === UpdatesChannel.Stable ? "stable" : "latest nightly";
92
93 // In case of reentering this function and showing the same info message
94 // (e.g. after we had shown this message, the user changed the config)
95 // vscode will dismiss the already shown one (i.e. return undefined).
96 // This behaviour is what we want, but likely it is not documented
97
98 const userResponse = await vscode.window.showInformationMessage(
99 reason + `Do you want to download the ${stableOrNightly} rust-analyzer extension ` +
100 `version and reload the window now?`,
101 "Download now", "Cancel"
102 );
103 return userResponse === "Download now";
104}
105
106/**
107 * Shutdowns the process in case of success (i.e. reloads the window) or throws an error.
108 *
109 * ACHTUNG!: this function has a crazy amount of state transitions, handling errors during
110 * each of them would result in a ton of code (especially accounting for cross-process
111 * shared mutable `globalState` access). Enforcing no reentrancy for this is best-effort.
112 */
113const tryDownloadNightlyExtension = notReentrant(async (
114 config: Config,
115 state: PersistentState,
116 shouldDownload: (releaseInfo: ArtifactReleaseInfo) => boolean = () => true
117): Promise<never | void> => {
118 const vsixSource = config.nightlyVsixSource;
119 try {
120 const releaseInfo = await fetchArtifactReleaseInfo(vsixSource.repo, vsixSource.file, vsixSource.tag);
121
122 if (!shouldDownload(releaseInfo)) return;
123
124 await downloadArtifactWithProgressUi(releaseInfo, vsixSource.file, vsixSource.dir, "nightly extension");
125
126 const vsixPath = path.join(vsixSource.dir, vsixSource.file);
127
128 await vscodeInstallExtensionFromVsix(vsixPath);
129 await state.installedNightlyExtensionReleaseDate.set(releaseInfo.releaseDate);
130 await fs.unlink(vsixPath);
131
132 await vscodeReloadWindow(); // never returns
133 } catch (err) {
134 log.downloadError(err, "nightly extension", vsixSource.repo.name);
135 }
136});
137
138function diffInHours(a: Date, b: Date): number {
139 // Discard the time and time-zone information (to abstract from daylight saving time bugs)
140 // https://stackoverflow.com/a/15289883/9259330
141
142 const utcA = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
143 const utcB = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
144
145 return (utcA - utcB) / (1000 * 60 * 60);
146}
diff --git a/editors/code/src/installation/fetch_artifact_release_info.ts b/editors/code/src/installation/fetch_artifact_release_info.ts
deleted file mode 100644
index 1ad3b8338..000000000
--- a/editors/code/src/installation/fetch_artifact_release_info.ts
+++ /dev/null
@@ -1,77 +0,0 @@
1import fetch from "node-fetch";
2import { GithubRepo, ArtifactReleaseInfo } from "./interfaces";
3import { log } from "../util";
4
5const GITHUB_API_ENDPOINT_URL = "https://api.github.com";
6
7/**
8 * Fetches the release with `releaseTag` from GitHub `repo` and
9 * returns metadata about `artifactFileName` shipped with
10 * this release.
11 *
12 * @throws Error upon network failure or if no such repository, release, or artifact exists.
13 */
14export async function fetchArtifactReleaseInfo(
15 repo: GithubRepo,
16 artifactFileName: string,
17 releaseTag: string
18): Promise<ArtifactReleaseInfo> {
19
20 const repoOwner = encodeURIComponent(repo.owner);
21 const repoName = encodeURIComponent(repo.name);
22
23 const apiEndpointPath = `/repos/${repoOwner}/${repoName}/releases/tags/${releaseTag}`;
24
25 const requestUrl = GITHUB_API_ENDPOINT_URL + apiEndpointPath;
26
27 log.debug("Issuing request for released artifacts metadata to", requestUrl);
28
29 const response = await fetch(requestUrl, { headers: { Accept: "application/vnd.github.v3+json" } });
30
31 if (!response.ok) {
32 log.error("Error fetching artifact release info", {
33 requestUrl,
34 releaseTag,
35 artifactFileName,
36 response: {
37 headers: response.headers,
38 status: response.status,
39 body: await response.text(),
40 }
41 });
42
43 throw new Error(
44 `Got response ${response.status} when trying to fetch ` +
45 `"${artifactFileName}" artifact release info for ${releaseTag} release`
46 );
47 }
48
49 // We skip runtime type checks for simplicity (here we cast from `any` to `GithubRelease`)
50 const release: GithubRelease = await response.json();
51
52 const artifact = release.assets.find(artifact => artifact.name === artifactFileName);
53
54 if (!artifact) {
55 throw new Error(
56 `Artifact ${artifactFileName} was not found in ${release.name} release!`
57 );
58 }
59
60 return {
61 releaseName: release.name,
62 releaseDate: new Date(release.published_at),
63 downloadUrl: artifact.browser_download_url
64 };
65
66 // We omit declaration of tremendous amount of fields that we are not using here
67 interface GithubRelease {
68 name: string;
69 // eslint-disable-next-line camelcase
70 published_at: string;
71 assets: Array<{
72 name: string;
73 // eslint-disable-next-line camelcase
74 browser_download_url: string;
75 }>;
76 }
77}
diff --git a/editors/code/src/installation/interfaces.ts b/editors/code/src/installation/interfaces.ts
deleted file mode 100644
index 1a8ea0884..000000000
--- a/editors/code/src/installation/interfaces.ts
+++ /dev/null
@@ -1,63 +0,0 @@
1export interface GithubRepo {
2 name: string;
3 owner: string;
4}
5
6/**
7 * Metadata about particular artifact retrieved from GitHub releases.
8 */
9export interface ArtifactReleaseInfo {
10 releaseDate: Date;
11 releaseName: string;
12 downloadUrl: string;
13}
14
15/**
16 * Represents the source of a an artifact which is either specified by the user
17 * explicitly, or bundled by this extension from GitHub releases.
18 */
19export type ArtifactSource = ArtifactSource.ExplicitPath | ArtifactSource.GithubRelease;
20
21export namespace ArtifactSource {
22 /**
23 * Type tag for `ArtifactSource` discriminated union.
24 */
25 export const enum Type { ExplicitPath, GithubRelease }
26
27 export interface ExplicitPath {
28 type: Type.ExplicitPath;
29
30 /**
31 * Filesystem path to the binary specified by the user explicitly.
32 */
33 path: string;
34 }
35
36 export interface GithubRelease {
37 type: Type.GithubRelease;
38
39 /**
40 * Repository where the binary is stored.
41 */
42 repo: GithubRepo;
43
44
45 // FIXME: add installationPath: string;
46
47 /**
48 * Directory on the filesystem where the bundled binary is stored.
49 */
50 dir: string;
51
52 /**
53 * Name of the binary file. It is stored under the same name on GitHub releases
54 * and in local `.dir`.
55 */
56 file: string;
57
58 /**
59 * Tag of github release that denotes a version required by this extension.
60 */
61 tag: string;
62 }
63}
diff --git a/editors/code/src/installation/server.ts b/editors/code/src/installation/server.ts
deleted file mode 100644
index 05d326131..000000000
--- a/editors/code/src/installation/server.ts
+++ /dev/null
@@ -1,131 +0,0 @@
1import * as vscode from "vscode";
2import * as path from "path";
3import { spawnSync } from "child_process";
4
5import { ArtifactSource } from "./interfaces";
6import { fetchArtifactReleaseInfo } from "./fetch_artifact_release_info";
7import { downloadArtifactWithProgressUi } from "./downloads";
8import { log, assert, notReentrant } from "../util";
9import { Config, NIGHTLY_TAG } from "../config";
10import { PersistentState } from "../persistent_state";
11
12export async function ensureServerBinary(config: Config, state: PersistentState): Promise<null | string> {
13 const source = config.serverSource;
14
15 if (!source) {
16 vscode.window.showErrorMessage(
17 "Unfortunately we don't ship binaries for your platform yet. " +
18 "You need to manually clone rust-analyzer repository and " +
19 "run `cargo xtask install --server` to build the language server from sources. " +
20 "If you feel that your platform should be supported, please create an issue " +
21 "about that [here](https://github.com/rust-analyzer/rust-analyzer/issues) and we " +
22 "will consider it."
23 );
24 return null;
25 }
26
27 switch (source.type) {
28 case ArtifactSource.Type.ExplicitPath: {
29 if (isBinaryAvailable(source.path)) {
30 return source.path;
31 }
32
33 vscode.window.showErrorMessage(
34 `Unable to run ${source.path} binary. ` +
35 `To use the pre-built language server, set "rust-analyzer.serverPath" ` +
36 "value to `null` or remove it from the settings to use it by default."
37 );
38 return null;
39 }
40 case ArtifactSource.Type.GithubRelease: {
41 if (!shouldDownloadServer(state, source)) {
42 return path.join(source.dir, source.file);
43 }
44
45 if (config.askBeforeDownload) {
46 const userResponse = await vscode.window.showInformationMessage(
47 `Language server version ${source.tag} for rust-analyzer is not installed. ` +
48 "Do you want to download it now?",
49 "Download now", "Cancel"
50 );
51 if (userResponse !== "Download now") return null;
52 }
53
54 return await downloadServer(state, source);
55 }
56 }
57}
58
59function shouldDownloadServer(
60 state: PersistentState,
61 source: ArtifactSource.GithubRelease,
62): boolean {
63 if (!isBinaryAvailable(path.join(source.dir, source.file))) return true;
64
65 const installed = {
66 tag: state.serverReleaseTag.get(),
67 date: state.serverReleaseDate.get()
68 };
69 const required = {
70 tag: source.tag,
71 date: state.installedNightlyExtensionReleaseDate.get()
72 };
73
74 log.debug("Installed server:", installed, "required:", required);
75
76 if (required.tag !== NIGHTLY_TAG || installed.tag !== NIGHTLY_TAG) {
77 return required.tag !== installed.tag;
78 }
79
80 assert(required.date !== null, "Extension release date should have been saved during its installation");
81 assert(installed.date !== null, "Server release date should have been saved during its installation");
82
83 return installed.date.getTime() !== required.date.getTime();
84}
85
86/**
87 * Enforcing no reentrancy for this is best-effort.
88 */
89const downloadServer = notReentrant(async (
90 state: PersistentState,
91 source: ArtifactSource.GithubRelease,
92): Promise<null | string> => {
93 try {
94 const releaseInfo = await fetchArtifactReleaseInfo(source.repo, source.file, source.tag);
95
96 await downloadArtifactWithProgressUi(releaseInfo, source.file, source.dir, "language server");
97 await Promise.all([
98 state.serverReleaseTag.set(releaseInfo.releaseName),
99 state.serverReleaseDate.set(releaseInfo.releaseDate)
100 ]);
101 } catch (err) {
102 log.downloadError(err, "language server", source.repo.name);
103 return null;
104 }
105
106 const binaryPath = path.join(source.dir, source.file);
107
108 assert(isBinaryAvailable(binaryPath),
109 `Downloaded language server binary is not functional.` +
110 `Downloaded from GitHub repo ${source.repo.owner}/${source.repo.name} ` +
111 `to ${binaryPath}`
112 );
113
114 vscode.window.showInformationMessage(
115 "Rust analyzer language server was successfully installed 🦀"
116 );
117
118 return binaryPath;
119});
120
121function isBinaryAvailable(binaryPath: string): boolean {
122 const res = spawnSync(binaryPath, ["--version"]);
123
124 // ACHTUNG! `res` type declaration is inherently wrong, see
125 // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/42221
126
127 log.debug("Checked binary availablity via --version", res);
128 log.debug(binaryPath, "--version output:", res.output?.map(String));
129
130 return res.status === 0;
131}