aboutsummaryrefslogtreecommitdiff
path: root/editors
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
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')
-rw-r--r--editors/code/package.json2
-rw-r--r--editors/code/src/commands/server_version.ts16
-rw-r--r--editors/code/src/config.ts103
-rw-r--r--editors/code/src/ctx.ts9
-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
-rw-r--r--editors/code/src/main.ts158
-rw-r--r--editors/code/src/net.ts (renamed from editors/code/src/installation/downloads.ts)132
-rw-r--r--editors/code/src/persistent_state.ts64
-rw-r--r--editors/code/src/util.ts54
12 files changed, 264 insertions, 691 deletions
diff --git a/editors/code/package.json b/editors/code/package.json
index b9e0ffd2b..6528ff071 100644
--- a/editors/code/package.json
+++ b/editors/code/package.json
@@ -228,7 +228,7 @@
228 "default": "stable", 228 "default": "stable",
229 "markdownEnumDescriptions": [ 229 "markdownEnumDescriptions": [
230 "`\"stable\"` updates are shipped weekly, they don't contain cutting-edge features from VSCode proposed APIs but have less bugs in general", 230 "`\"stable\"` updates are shipped weekly, they don't contain cutting-edge features from VSCode proposed APIs but have less bugs in general",
231 "`\"nightly\"` updates are shipped daily, they contain cutting-edge features and latest bug fixes. These releases help us get your feedback very quickly and speed up rust-analyzer development **drastically**" 231 "`\"nightly\"` updates are shipped daily (extension updates automatically by downloading artifacts directly from GitHub), they contain cutting-edge features and latest bug fixes. These releases help us get your feedback very quickly and speed up rust-analyzer development **drastically**"
232 ], 232 ],
233 "markdownDescription": "Choose `\"nightly\"` updates to get the latest features and bug fixes every day. While `\"stable\"` releases occur weekly and don't contain cutting-edge features from VSCode proposed APIs" 233 "markdownDescription": "Choose `\"nightly\"` updates to get the latest features and bug fixes every day. While `\"stable\"` releases occur weekly and don't contain cutting-edge features from VSCode proposed APIs"
234 }, 234 },
diff --git a/editors/code/src/commands/server_version.ts b/editors/code/src/commands/server_version.ts
index 83b1acf67..03528b825 100644
--- a/editors/code/src/commands/server_version.ts
+++ b/editors/code/src/commands/server_version.ts
@@ -1,20 +1,10 @@
1import * as vscode from 'vscode'; 1import * as vscode from "vscode";
2import { ensureServerBinary } from '../installation/server'; 2import { spawnSync } from "child_process";
3import { Ctx, Cmd } from '../ctx'; 3import { Ctx, Cmd } from '../ctx';
4import { spawnSync } from 'child_process';
5 4
6export function serverVersion(ctx: Ctx): Cmd { 5export function serverVersion(ctx: Ctx): Cmd {
7 return async () => { 6 return async () => {
8 const binaryPath = await ensureServerBinary(ctx.config, ctx.state); 7 const version = spawnSync(ctx.serverPath, ["--version"], { encoding: "utf8" }).stdout;
9
10 if (binaryPath == null) {
11 throw new Error(
12 "Rust Analyzer Language Server is not available. " +
13 "Please, ensure its [proper installation](https://rust-analyzer.github.io/manual.html#installation)."
14 );
15 }
16
17 const version = spawnSync(binaryPath, ["--version"], { encoding: "utf8" }).stdout;
18 vscode.window.showInformationMessage('rust-analyzer version : ' + version); 8 vscode.window.showInformationMessage('rust-analyzer version : ' + version);
19 }; 9 };
20} 10}
diff --git a/editors/code/src/config.ts b/editors/code/src/config.ts
index b45b14bef..28698ab8e 100644
--- a/editors/code/src/config.ts
+++ b/editors/code/src/config.ts
@@ -1,9 +1,5 @@
1import * as os from "os";
2import * as vscode from 'vscode'; 1import * as vscode from 'vscode';
3import { ArtifactSource } from "./installation/interfaces"; 2import { log } from "./util";
4import { log, vscodeReloadWindow } from "./util";
5
6const RA_LSP_DEBUG = process.env.__RA_LSP_SERVER_DEBUG;
7 3
8export interface InlayHintOptions { 4export interface InlayHintOptions {
9 typeHints: boolean; 5 typeHints: boolean;
@@ -25,10 +21,7 @@ export interface CargoFeatures {
25 loadOutDirsFromCheck: boolean; 21 loadOutDirsFromCheck: boolean;
26} 22}
27 23
28export const enum UpdatesChannel { 24export type UpdatesChannel = "stable" | "nightly";
29 Stable = "stable",
30 Nightly = "nightly"
31}
32 25
33export const NIGHTLY_TAG = "nightly"; 26export const NIGHTLY_TAG = "nightly";
34export class Config { 27export class Config {
@@ -41,6 +34,7 @@ export class Config {
41 "cargo-watch", 34 "cargo-watch",
42 "highlighting.semanticTokens", 35 "highlighting.semanticTokens",
43 "inlayHints", 36 "inlayHints",
37 "updates.channel",
44 ] 38 ]
45 .map(opt => `${this.rootSection}.${opt}`); 39 .map(opt => `${this.rootSection}.${opt}`);
46 40
@@ -94,100 +88,17 @@ export class Config {
94 ); 88 );
95 89
96 if (userResponse === "Reload now") { 90 if (userResponse === "Reload now") {
97 await vscodeReloadWindow(); 91 await vscode.commands.executeCommand("workbench.action.reloadWindow");
98 } 92 }
99 } 93 }
100 94
101 private static replaceTildeWithHomeDir(path: string) { 95 get globalStoragePath(): string { return this.ctx.globalStoragePath; }
102 if (path.startsWith("~/")) {
103 return os.homedir() + path.slice("~".length);
104 }
105 return path;
106 }
107
108 /**
109 * Name of the binary artifact for `rust-analyzer` that is published for
110 * `platform` on GitHub releases. (It is also stored under the same name when
111 * downloaded by the extension).
112 */
113 get prebuiltServerFileName(): null | string {
114 // See possible `arch` values here:
115 // https://nodejs.org/api/process.html#process_process_arch
116
117 switch (process.platform) {
118
119 case "linux": {
120 switch (process.arch) {
121 case "arm":
122 case "arm64": return null;
123
124 default: return "rust-analyzer-linux";
125 }
126 }
127
128 case "darwin": return "rust-analyzer-mac";
129 case "win32": return "rust-analyzer-windows.exe";
130
131 // Users on these platforms yet need to manually build from sources
132 case "aix":
133 case "android":
134 case "freebsd":
135 case "openbsd":
136 case "sunos":
137 case "cygwin":
138 case "netbsd": return null;
139 // The list of platforms is exhaustive (see `NodeJS.Platform` type definition)
140 }
141 }
142
143 get installedExtensionUpdateChannel(): UpdatesChannel {
144 return this.extensionReleaseTag === NIGHTLY_TAG
145 ? UpdatesChannel.Nightly
146 : UpdatesChannel.Stable;
147 }
148
149 get serverSource(): null | ArtifactSource {
150 const serverPath = RA_LSP_DEBUG ?? this.serverPath;
151
152 if (serverPath) {
153 return {
154 type: ArtifactSource.Type.ExplicitPath,
155 path: Config.replaceTildeWithHomeDir(serverPath)
156 };
157 }
158
159 const prebuiltBinaryName = this.prebuiltServerFileName;
160
161 if (!prebuiltBinaryName) return null;
162
163 return this.createGithubReleaseSource(
164 prebuiltBinaryName,
165 this.extensionReleaseTag
166 );
167 }
168
169 private createGithubReleaseSource(file: string, tag: string): ArtifactSource.GithubRelease {
170 return {
171 type: ArtifactSource.Type.GithubRelease,
172 file,
173 tag,
174 dir: this.ctx.globalStoragePath,
175 repo: {
176 name: "rust-analyzer",
177 owner: "rust-analyzer",
178 }
179 };
180 }
181
182 get nightlyVsixSource(): ArtifactSource.GithubRelease {
183 return this.createGithubReleaseSource("rust-analyzer.vsix", NIGHTLY_TAG);
184 }
185 96
186 // We don't do runtime config validation here for simplicity. More on stackoverflow: 97 // We don't do runtime config validation here for simplicity. More on stackoverflow:
187 // https://stackoverflow.com/questions/60135780/what-is-the-best-way-to-type-check-the-configuration-for-vscode-extension 98 // https://stackoverflow.com/questions/60135780/what-is-the-best-way-to-type-check-the-configuration-for-vscode-extension
188 99
189 private get serverPath() { return this.cfg.get("serverPath") as null | string; } 100 get serverPath() { return this.cfg.get("serverPath") as null | string; }
190 get updatesChannel() { return this.cfg.get("updates.channel") as UpdatesChannel; } 101 get channel() { return this.cfg.get<"stable" | "nightly">("updates.channel")!; }
191 get askBeforeDownload() { return this.cfg.get("updates.askBeforeDownload") as boolean; } 102 get askBeforeDownload() { return this.cfg.get("updates.askBeforeDownload") as boolean; }
192 get highlightingSemanticTokens() { return this.cfg.get("highlighting.semanticTokens") as boolean; } 103 get highlightingSemanticTokens() { return this.cfg.get("highlighting.semanticTokens") as boolean; }
193 get highlightingOn() { return this.cfg.get("highlightingOn") as boolean; } 104 get highlightingOn() { return this.cfg.get("highlightingOn") as boolean; }
diff --git a/editors/code/src/ctx.ts b/editors/code/src/ctx.ts
index c929ab063..84c170ea8 100644
--- a/editors/code/src/ctx.ts
+++ b/editors/code/src/ctx.ts
@@ -4,21 +4,20 @@ import * as lc from 'vscode-languageclient';
4import { Config } from './config'; 4import { Config } from './config';
5import { createClient } from './client'; 5import { createClient } from './client';
6import { isRustEditor, RustEditor } from './util'; 6import { isRustEditor, RustEditor } from './util';
7import { PersistentState } from './persistent_state';
8 7
9export class Ctx { 8export class Ctx {
10 private constructor( 9 private constructor(
11 readonly config: Config, 10 readonly config: Config,
12 readonly state: PersistentState,
13 private readonly extCtx: vscode.ExtensionContext, 11 private readonly extCtx: vscode.ExtensionContext,
14 readonly client: lc.LanguageClient 12 readonly client: lc.LanguageClient,
13 readonly serverPath: string,
15 ) { 14 ) {
16 15
17 } 16 }
18 17
19 static async create(config: Config, state: PersistentState, extCtx: vscode.ExtensionContext, serverPath: string): Promise<Ctx> { 18 static async create(config: Config, extCtx: vscode.ExtensionContext, serverPath: string): Promise<Ctx> {
20 const client = await createClient(config, serverPath); 19 const client = await createClient(config, serverPath);
21 const res = new Ctx(config, state, extCtx, client); 20 const res = new Ctx(config, extCtx, client, serverPath);
22 res.pushCleanup(client.start()); 21 res.pushCleanup(client.start());
23 await client.onReady(); 22 await client.onReady();
24 return res; 23 return res;
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}
diff --git a/editors/code/src/main.ts b/editors/code/src/main.ts
index 94ecd4dab..d907f3e6f 100644
--- a/editors/code/src/main.ts
+++ b/editors/code/src/main.ts
@@ -1,15 +1,18 @@
1import * as vscode from 'vscode'; 1import * as vscode from 'vscode';
2import * as path from "path";
3import * as os from "os";
4import { promises as fs } from "fs";
2 5
3import * as commands from './commands'; 6import * as commands from './commands';
4import { activateInlayHints } from './inlay_hints'; 7import { activateInlayHints } from './inlay_hints';
5import { activateStatusDisplay } from './status_display'; 8import { activateStatusDisplay } from './status_display';
6import { Ctx } from './ctx'; 9import { Ctx } from './ctx';
7import { activateHighlighting } from './highlighting'; 10import { activateHighlighting } from './highlighting';
8import { ensureServerBinary } from './installation/server'; 11import { Config, NIGHTLY_TAG } from './config';
9import { Config } from './config'; 12import { log, assert } from './util';
10import { log } from './util';
11import { ensureProperExtensionVersion } from './installation/extension';
12import { PersistentState } from './persistent_state'; 13import { PersistentState } from './persistent_state';
14import { fetchRelease, download } from './net';
15import { spawnSync } from 'child_process';
13 16
14let ctx: Ctx | undefined; 17let ctx: Ctx | undefined;
15 18
@@ -35,27 +38,14 @@ export async function activate(context: vscode.ExtensionContext) {
35 context.subscriptions.push(defaultOnEnter); 38 context.subscriptions.push(defaultOnEnter);
36 39
37 const config = new Config(context); 40 const config = new Config(context);
38 const state = new PersistentState(context); 41 const state = new PersistentState(context.globalState);
39 42 const serverPath = await bootstrap(config, state);
40 vscode.workspace.onDidChangeConfiguration(() => ensureProperExtensionVersion(config, state).catch(log.error));
41
42 // Don't await the user response here, otherwise we will block the lsp server bootstrap
43 void ensureProperExtensionVersion(config, state).catch(log.error);
44
45 const serverPath = await ensureServerBinary(config, state);
46
47 if (serverPath == null) {
48 throw new Error(
49 "Rust Analyzer Language Server is not available. " +
50 "Please, ensure its [proper installation](https://rust-analyzer.github.io/manual.html#installation)."
51 );
52 }
53 43
54 // Note: we try to start the server before we activate type hints so that it 44 // Note: we try to start the server before we activate type hints so that it
55 // registers its `onDidChangeDocument` handler before us. 45 // registers its `onDidChangeDocument` handler before us.
56 // 46 //
57 // This a horribly, horribly wrong way to deal with this problem. 47 // This a horribly, horribly wrong way to deal with this problem.
58 ctx = await Ctx.create(config, state, context, serverPath); 48 ctx = await Ctx.create(config, context, serverPath);
59 49
60 // Commands which invokes manually via command palette, shortcut, etc. 50 // Commands which invokes manually via command palette, shortcut, etc.
61 ctx.registerCommand('reload', (ctx) => { 51 ctx.registerCommand('reload', (ctx) => {
@@ -109,3 +99,131 @@ export async function deactivate() {
109 await ctx?.client?.stop(); 99 await ctx?.client?.stop();
110 ctx = undefined; 100 ctx = undefined;
111} 101}
102
103async function bootstrap(config: Config, state: PersistentState): Promise<string> {
104 await fs.mkdir(config.globalStoragePath, { recursive: true });
105
106 await bootstrapExtension(config, state);
107 const path = await bootstrapServer(config, state);
108
109 return path;
110}
111
112async function bootstrapExtension(config: Config, state: PersistentState): Promise<void> {
113 if (config.channel === "stable") {
114 if (config.extensionReleaseTag === NIGHTLY_TAG) {
115 vscode.window.showWarningMessage(`You are running a nightly version of rust-analyzer extension.
116To switch to stable, uninstall the extension and re-install it from the marketplace`);
117 }
118 return;
119 };
120
121 const lastCheck = state.lastCheck;
122 const now = Date.now();
123
124 const anHour = 60 * 60 * 1000;
125 const shouldDownloadNightly = state.releaseId === undefined || (now - (lastCheck ?? 0)) > anHour;
126
127 if (!shouldDownloadNightly) return;
128
129 const release = await fetchRelease("nightly").catch((e) => {
130 log.error(e);
131 if (state.releaseId === undefined) { // Show error only for the initial download
132 vscode.window.showErrorMessage(`Failed to download rust-analyzer nightly ${e}`);
133 }
134 return undefined;
135 });
136 if (release === undefined || release.id === state.releaseId) return;
137
138 const userResponse = await vscode.window.showInformationMessage(
139 "New version of rust-analyzer (nightly) is available (requires reload).",
140 "Update"
141 );
142 if (userResponse !== "Update") return;
143
144 const artifact = release.assets.find(artifact => artifact.name === "rust-analyzer.vsix");
145 assert(!!artifact, `Bad release: ${JSON.stringify(release)}`);
146
147 const dest = path.join(config.globalStoragePath, "rust-analyzer.vsix");
148 await download(artifact.browser_download_url, dest, "Downloading rust-analyzer extension");
149
150 await vscode.commands.executeCommand("workbench.extensions.installExtension", vscode.Uri.file(dest));
151 await fs.unlink(dest);
152
153 await state.updateReleaseId(release.id);
154 await state.updateLastCheck(now);
155 await vscode.commands.executeCommand("workbench.action.reloadWindow");
156}
157
158async function bootstrapServer(config: Config, state: PersistentState): Promise<string> {
159 const path = await getServer(config, state);
160 if (!path) {
161 throw new Error(
162 "Rust Analyzer Language Server is not available. " +
163 "Please, ensure its [proper installation](https://rust-analyzer.github.io/manual.html#installation)."
164 );
165 }
166
167 const res = spawnSync(path, ["--version"], { encoding: 'utf8' });
168 log.debug("Checked binary availability via --version", res);
169 log.debug(res, "--version output:", res.output);
170 if (res.status !== 0) {
171 throw new Error(
172 `Failed to execute ${path} --version`
173 );
174 }
175
176 return path;
177}
178
179async function getServer(config: Config, state: PersistentState): Promise<string | undefined> {
180 const explicitPath = process.env.__RA_LSP_SERVER_DEBUG ?? config.serverPath;
181 if (explicitPath) {
182 if (explicitPath.startsWith("~/")) {
183 return os.homedir() + explicitPath.slice("~".length);
184 }
185 return explicitPath;
186 };
187
188 let binaryName: string | undefined = undefined;
189 if (process.arch === "x64" || process.arch === "x32") {
190 if (process.platform === "linux") binaryName = "rust-analyzer-linux";
191 if (process.platform === "darwin") binaryName = "rust-analyzer-mac";
192 if (process.platform === "win32") binaryName = "rust-analyzer-windows.exe";
193 }
194 if (binaryName === undefined) {
195 vscode.window.showErrorMessage(
196 "Unfortunately we don't ship binaries for your platform yet. " +
197 "You need to manually clone rust-analyzer repository and " +
198 "run `cargo xtask install --server` to build the language server from sources. " +
199 "If you feel that your platform should be supported, please create an issue " +
200 "about that [here](https://github.com/rust-analyzer/rust-analyzer/issues) and we " +
201 "will consider it."
202 );
203 return undefined;
204 }
205
206 const dest = path.join(config.globalStoragePath, binaryName);
207 const exists = await fs.stat(dest).then(() => true, () => false);
208 if (!exists) {
209 await state.updateServerVersion(undefined);
210 }
211
212 if (state.serverVersion === config.packageJsonVersion) return dest;
213
214 if (config.askBeforeDownload) {
215 const userResponse = await vscode.window.showInformationMessage(
216 `Language server version ${config.packageJsonVersion} for rust-analyzer is not installed.`,
217 "Download now"
218 );
219 if (userResponse !== "Download now") return dest;
220 }
221
222 const release = await fetchRelease(config.extensionReleaseTag);
223 const artifact = release.assets.find(artifact => artifact.name === binaryName);
224 assert(!!artifact, `Bad release: ${JSON.stringify(release)}`);
225
226 await download(artifact.browser_download_url, dest, "Downloading rust-analyzer server", { mode: 0o755 });
227 await state.updateServerVersion(config.packageJsonVersion);
228 return dest;
229}
diff --git a/editors/code/src/installation/downloads.ts b/editors/code/src/net.ts
index 7ce2e2960..492213937 100644
--- a/editors/code/src/installation/downloads.ts
+++ b/editors/code/src/net.ts
@@ -1,24 +1,101 @@
1import fetch from "node-fetch"; 1import fetch from "node-fetch";
2import * as vscode from "vscode"; 2import * as vscode from "vscode";
3import * as path from "path";
4import * as fs from "fs"; 3import * as fs from "fs";
5import * as stream from "stream"; 4import * as stream from "stream";
6import * as util from "util"; 5import * as util from "util";
7import { log, assert } from "../util"; 6import { log, assert } from "./util";
8import { ArtifactReleaseInfo } from "./interfaces";
9 7
10const pipeline = util.promisify(stream.pipeline); 8const pipeline = util.promisify(stream.pipeline);
11 9
10const GITHUB_API_ENDPOINT_URL = "https://api.github.com";
11const OWNER = "rust-analyzer";
12const REPO = "rust-analyzer";
13
14export async function fetchRelease(
15 releaseTag: string
16): Promise<GithubRelease> {
17
18 const apiEndpointPath = `/repos/${OWNER}/${REPO}/releases/tags/${releaseTag}`;
19
20 const requestUrl = GITHUB_API_ENDPOINT_URL + apiEndpointPath;
21
22 log.debug("Issuing request for released artifacts metadata to", requestUrl);
23
24 const response = await fetch(requestUrl, { headers: { Accept: "application/vnd.github.v3+json" } });
25
26 if (!response.ok) {
27 log.error("Error fetching artifact release info", {
28 requestUrl,
29 releaseTag,
30 response: {
31 headers: response.headers,
32 status: response.status,
33 body: await response.text(),
34 }
35 });
36
37 throw new Error(
38 `Got response ${response.status} when trying to fetch ` +
39 `release info for ${releaseTag} release`
40 );
41 }
42
43 // We skip runtime type checks for simplicity (here we cast from `any` to `GithubRelease`)
44 const release: GithubRelease = await response.json();
45 return release;
46}
47
48// We omit declaration of tremendous amount of fields that we are not using here
49export interface GithubRelease {
50 name: string;
51 id: number;
52 // eslint-disable-next-line camelcase
53 published_at: string;
54 assets: Array<{
55 name: string;
56 // eslint-disable-next-line camelcase
57 browser_download_url: string;
58 }>;
59}
60
61
62export async function download(
63 downloadUrl: string,
64 destinationPath: string,
65 progressTitle: string,
66 { mode }: { mode?: number } = {},
67) {
68 await vscode.window.withProgress(
69 {
70 location: vscode.ProgressLocation.Notification,
71 cancellable: false,
72 title: progressTitle
73 },
74 async (progress, _cancellationToken) => {
75 let lastPercentage = 0;
76 await downloadFile(downloadUrl, destinationPath, mode, (readBytes, totalBytes) => {
77 const newPercentage = (readBytes / totalBytes) * 100;
78 progress.report({
79 message: newPercentage.toFixed(0) + "%",
80 increment: newPercentage - lastPercentage
81 });
82
83 lastPercentage = newPercentage;
84 });
85 }
86 );
87}
88
12/** 89/**
13 * Downloads file from `url` and stores it at `destFilePath` with `destFilePermissions`. 90 * Downloads file from `url` and stores it at `destFilePath` with `destFilePermissions`.
14 * `onProgress` callback is called on recieveing each chunk of bytes 91 * `onProgress` callback is called on recieveing each chunk of bytes
15 * to track the progress of downloading, it gets the already read and total 92 * to track the progress of downloading, it gets the already read and total
16 * amount of bytes to read as its parameters. 93 * amount of bytes to read as its parameters.
17 */ 94 */
18export async function downloadFile( 95async function downloadFile(
19 url: string, 96 url: string,
20 destFilePath: fs.PathLike, 97 destFilePath: fs.PathLike,
21 destFilePermissions: number, 98 mode: number | undefined,
22 onProgress: (readBytes: number, totalBytes: number) => void 99 onProgress: (readBytes: number, totalBytes: number) => void
23): Promise<void> { 100): Promise<void> {
24 const res = await fetch(url); 101 const res = await fetch(url);
@@ -41,7 +118,7 @@ export async function downloadFile(
41 onProgress(readBytes, totalBytes); 118 onProgress(readBytes, totalBytes);
42 }); 119 });
43 120
44 const destFileStream = fs.createWriteStream(destFilePath, { mode: destFilePermissions }); 121 const destFileStream = fs.createWriteStream(destFilePath, { mode });
45 122
46 await pipeline(res.body, destFileStream); 123 await pipeline(res.body, destFileStream);
47 return new Promise<void>(resolve => { 124 return new Promise<void>(resolve => {
@@ -52,46 +129,3 @@ export async function downloadFile(
52 // Issue at nodejs repo: https://github.com/nodejs/node/issues/31776 129 // Issue at nodejs repo: https://github.com/nodejs/node/issues/31776
53 }); 130 });
54} 131}
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/persistent_state.ts b/editors/code/src/persistent_state.ts
index 13095b806..138d11b89 100644
--- a/editors/code/src/persistent_state.ts
+++ b/editors/code/src/persistent_state.ts
@@ -1,49 +1,41 @@
1import * as vscode from 'vscode'; 1import * as vscode from 'vscode';
2import { log } from "./util"; 2import { log } from './util';
3 3
4export class PersistentState { 4export class PersistentState {
5 constructor(private readonly ctx: vscode.ExtensionContext) { 5 constructor(private readonly globalState: vscode.Memento) {
6 const { lastCheck, releaseId, serverVersion } = this;
7 log.debug("PersistentState: ", { lastCheck, releaseId, serverVersion });
6 } 8 }
7 9
8 readonly installedNightlyExtensionReleaseDate = new DateStorage( 10 /**
9 "installed-nightly-extension-release-date", 11 * Used to check for *nightly* updates once an hour.
10 this.ctx.globalState 12 */
11 ); 13 get lastCheck(): number | undefined {
12 readonly serverReleaseDate = new DateStorage("server-release-date", this.ctx.globalState); 14 return this.globalState.get("lastCheck");
13 readonly serverReleaseTag = new Storage<null | string>("server-release-tag", this.ctx.globalState, null);
14}
15
16
17export class Storage<T> {
18 constructor(
19 private readonly key: string,
20 private readonly storage: vscode.Memento,
21 private readonly defaultVal: T
22 ) { }
23
24 get(): T {
25 const val = this.storage.get(this.key, this.defaultVal);
26 log.debug(this.key, "==", val);
27 return val;
28 } 15 }
29 async set(val: T) { 16 async updateLastCheck(value: number) {
30 log.debug(this.key, "=", val); 17 await this.globalState.update("lastCheck", value);
31 await this.storage.update(this.key, val);
32 } 18 }
33}
34export class DateStorage {
35 inner: Storage<null | string>;
36 19
37 constructor(key: string, storage: vscode.Memento) { 20 /**
38 this.inner = new Storage(key, storage, null); 21 * Release id of the *nightly* extension.
22 * Used to check if we should update.
23 */
24 get releaseId(): number | undefined {
25 return this.globalState.get("releaseId");
39 } 26 }
40 27 async updateReleaseId(value: number) {
41 get(): null | Date { 28 await this.globalState.update("releaseId", value);
42 const dateStr = this.inner.get();
43 return dateStr ? new Date(dateStr) : null;
44 } 29 }
45 30
46 async set(date: null | Date) { 31 /**
47 await this.inner.set(date ? date.toString() : null); 32 * Version of the extension that installed the server.
33 * Used to check if we need to update the server.
34 */
35 get serverVersion(): string | undefined {
36 return this.globalState.get("serverVersion");
37 }
38 async updateServerVersion(value: string | undefined) {
39 await this.globalState.update("serverVersion", value);
48 } 40 }
49} 41}
diff --git a/editors/code/src/util.ts b/editors/code/src/util.ts
index 2bfc145e6..978a31751 100644
--- a/editors/code/src/util.ts
+++ b/editors/code/src/util.ts
@@ -1,6 +1,5 @@
1import * as lc from "vscode-languageclient"; 1import * as lc from "vscode-languageclient";
2import * as vscode from "vscode"; 2import * as vscode from "vscode";
3import { promises as dns } from "dns";
4import { strict as nativeAssert } from "assert"; 3import { strict as nativeAssert } from "assert";
5 4
6export function assert(condition: boolean, explanation: string): asserts condition { 5export function assert(condition: boolean, explanation: string): asserts condition {
@@ -31,22 +30,6 @@ export const log = new class {
31 // eslint-disable-next-line no-console 30 // eslint-disable-next-line no-console
32 console.error(message, ...optionalParams); 31 console.error(message, ...optionalParams);
33 } 32 }
34
35 downloadError(err: Error, artifactName: string, repoName: string) {
36 vscode.window.showErrorMessage(
37 `Failed to download the rust-analyzer ${artifactName} from ${repoName} ` +
38 `GitHub repository: ${err.message}`
39 );
40 log.error(err);
41 dns.resolve('example.com').then(
42 addrs => log.debug("DNS resolution for example.com was successful", addrs),
43 err => log.error(
44 "DNS resolution for example.com failed, " +
45 "there might be an issue with Internet availability",
46 err
47 )
48 );
49 }
50}; 33};
51 34
52export async function sendRequestWithRetry<TParam, TRet>( 35export async function sendRequestWithRetry<TParam, TRet>(
@@ -86,17 +69,6 @@ function sleep(ms: number) {
86 return new Promise(resolve => setTimeout(resolve, ms)); 69 return new Promise(resolve => setTimeout(resolve, ms));
87} 70}
88 71
89export function notReentrant<TThis, TParams extends any[], TRet>(
90 fn: (this: TThis, ...params: TParams) => Promise<TRet>
91): typeof fn {
92 let entered = false;
93 return function(...params) {
94 assert(!entered, `Reentrancy invariant for ${fn.name} is violated`);
95 entered = true;
96 return fn.apply(this, params).finally(() => entered = false);
97 };
98}
99
100export type RustDocument = vscode.TextDocument & { languageId: "rust" }; 72export type RustDocument = vscode.TextDocument & { languageId: "rust" };
101export type RustEditor = vscode.TextEditor & { document: RustDocument; id: string }; 73export type RustEditor = vscode.TextEditor & { document: RustDocument; id: string };
102 74
@@ -110,29 +82,3 @@ export function isRustDocument(document: vscode.TextDocument): document is RustD
110export function isRustEditor(editor: vscode.TextEditor): editor is RustEditor { 82export function isRustEditor(editor: vscode.TextEditor): editor is RustEditor {
111 return isRustDocument(editor.document); 83 return isRustDocument(editor.document);
112} 84}
113
114/**
115 * @param extensionId The canonical extension identifier in the form of: `publisher.name`
116 */
117export async function vscodeReinstallExtension(extensionId: string) {
118 // Unfortunately there is no straightforward way as of now, these commands
119 // were found in vscode source code.
120
121 log.debug("Uninstalling extension", extensionId);
122 await vscode.commands.executeCommand("workbench.extensions.uninstallExtension", extensionId);
123 log.debug("Installing extension", extensionId);
124 await vscode.commands.executeCommand("workbench.extensions.installExtension", extensionId);
125}
126
127export async function vscodeReloadWindow(): Promise<never> {
128 await vscode.commands.executeCommand("workbench.action.reloadWindow");
129
130 assert(false, "unreachable");
131}
132
133export async function vscodeInstallExtensionFromVsix(vsixPath: string) {
134 await vscode.commands.executeCommand(
135 "workbench.extensions.installExtension",
136 vscode.Uri.file(vsixPath)
137 );
138}