3162: Feature: vscode always downloads only the matching ra_lsp_server version r=matklad a=Veetaha

I tried to separate logically connected changes into separate commits, so enjoy!

Now TypeScript extension saves installed binary version in global state and always checks that the installed binary version equals the version of the TypeScript extension itself (to prevent version drifts).
Also, changed `fetchLatestArtifactReleaseInfo()` to `fetchArtifactReleaseInfo()` that takes an optional release tag (when not specified fetches the latest release). The version without a release tag will be useful in the future when adding auto-checking for updates.

I decided not to do `Download latest language server` command (I have stated the rationale for this in #3073) and let the extension itself decide which version of the binary it wants. This way the users will be able to get the latest `ra_lsp_server` binary after the approaching 2020-02-17 release, without having to manually delete the outdated one from `~/.config/Code/User/globalStorage/matklad.rust-analyzer`!

Closes #3073

Co-authored-by: Veetaha <gerzoh1@gmail.com>
This commit is contained in:
bors[bot] 2020-02-16 11:54:38 +00:00 committed by GitHub
commit a15c8739b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 187 additions and 116 deletions

View file

@ -27,8 +27,9 @@ https://github.com/rust-analyzer/rust-analyzer/tree/master/editors/code[in tree]
You can install the latest release of the plugin from
https://marketplace.visualstudio.com/items?itemName=matklad.rust-analyzer[the marketplace].
By default, the plugin will download the latest version of the server as well.
By default, the plugin will download the matching version of the server as well.
// FIXME: update the image (its text has changed)
image::https://user-images.githubusercontent.com/36276403/74103174-a40df100-4b52-11ea-81f4-372c70797924.png[]
The server binary is stored in `~/.config/Code/User/globalStorage/matklad.rust-analyzer`.
@ -37,9 +38,7 @@ Note that we only support the latest version of VS Code.
==== Updates
The extension will be updated automatically as new versions become available.
The server update functionality is in progress.
For the time being, the workaround is to remove the binary from `globalStorage` and to restart the extension.
The extension will be updated automatically as new versions become available. It will ask your permission to download the matching language server version binary if needed.
==== Building From Source

View file

@ -6,7 +6,7 @@
"private": true,
"icon": "icon.png",
"//": "The real version is in release.yaml, this one just needs to be bigger",
"version": "0.2.0-dev",
"version": "0.2.20200211-dev",
"publisher": "matklad",
"repository": {
"url": "https://github.com/rust-analyzer/rust-analyzer.git",

View file

@ -11,7 +11,7 @@ export async function createClient(config: Config): Promise<null | lc.LanguageCl
// It might be a good idea to test if the uri points to a file.
const workspaceFolderPath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '.';
const serverPath = await ensureServerBinary(config.serverBinarySource);
const serverPath = await ensureServerBinary(config.serverSource);
if (!serverPath) return null;
const run: lc.Executable = {

View file

@ -24,6 +24,19 @@ export class Config {
]
.map(opt => `${Config.rootSection}.${opt}`);
private static readonly extensionVersion: string = (() => {
const packageJsonVersion = vscode
.extensions
.getExtension("matklad.rust-analyzer")!
.packageJSON
.version as string; // n.n.YYYYMMDD
const realVersionRegexp = /^\d+\.\d+\.(\d{4})(\d{2})(\d{2})/;
const [, yyyy, mm, dd] = packageJsonVersion.match(realVersionRegexp)!;
return `${yyyy}-${mm}-${dd}`;
})();
private cfg!: vscode.WorkspaceConfiguration;
constructor(private readonly ctx: vscode.ExtensionContext) {
@ -98,7 +111,7 @@ export class Config {
}
}
get serverBinarySource(): null | BinarySource {
get serverSource(): null | BinarySource {
const serverPath = RA_LSP_DEBUG ?? this.cfg.get<null | string>("raLspServerPath");
if (serverPath) {
@ -116,6 +129,8 @@ export class Config {
type: BinarySource.Type.GithubRelease,
dir: this.ctx.globalStoragePath,
file: prebuiltBinaryName,
storage: this.ctx.globalState,
version: Config.extensionVersion,
repo: {
name: "rust-analyzer",
owner: "rust-analyzer",

View file

@ -60,6 +60,10 @@ export class Ctx {
this.pushCleanup(d);
}
get globalState(): vscode.Memento {
return this.extCtx.globalState;
}
get subscriptions(): Disposable[] {
return this.extCtx.subscriptions;
}

View file

@ -0,0 +1,58 @@
import * as vscode from "vscode";
import * as path from "path";
import { promises as fs } from "fs";
import { strict as assert } from "assert";
import { ArtifactReleaseInfo } from "./interfaces";
import { downloadFile } from "./download_file";
import { throttle } from "throttle-debounce";
/**
* Downloads artifact from given `downloadUrl`.
* Creates `installationDir` if it is not yet created and put the artifact under
* `artifactFileName`.
* Displays info about the download progress in an info message printing the name
* of the artifact as `displayName`.
*/
export async function downloadArtifact(
{downloadUrl, releaseName}: ArtifactReleaseInfo,
artifactFileName: string,
installationDir: string,
displayName: string,
) {
await fs.mkdir(installationDir).catch(err => assert.strictEqual(
err?.code,
"EEXIST",
`Couldn't create directory "${installationDir}" to download `+
`${artifactFileName} artifact: ${err.message}`
));
const installationPath = path.join(installationDir, artifactFileName);
console.time(`Downloading ${artifactFileName}`);
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
cancellable: false, // FIXME: add support for canceling download?
title: `Downloading ${displayName} (${releaseName})`
},
async (progress, _cancellationToken) => {
let lastPrecentage = 0;
const filePermissions = 0o755; // (rwx, r_x, r_x)
await downloadFile(downloadUrl, installationPath, filePermissions, throttle(
200,
/* noTrailing: */ true,
(readBytes, totalBytes) => {
const newPercentage = (readBytes / totalBytes) * 100;
progress.report({
message: newPercentage.toFixed(0) + "%",
increment: newPercentage - lastPrecentage
});
lastPrecentage = newPercentage;
})
);
}
);
console.timeEnd(`Downloading ${artifactFileName}`);
}

View file

@ -3,24 +3,30 @@ import { GithubRepo, ArtifactReleaseInfo } from "./interfaces";
const GITHUB_API_ENDPOINT_URL = "https://api.github.com";
/**
* Fetches the latest release from GitHub `repo` and returns metadata about
* `artifactFileName` shipped with this release or `null` if no such artifact was published.
* Fetches the release with `releaseTag` (or just latest release when not specified)
* from GitHub `repo` and returns metadata about `artifactFileName` shipped with
* this release or `null` if no such artifact was published.
*/
export async function fetchLatestArtifactReleaseInfo(
repo: GithubRepo, artifactFileName: string
export async function fetchArtifactReleaseInfo(
repo: GithubRepo, artifactFileName: string, releaseTag?: string
): Promise<null | ArtifactReleaseInfo> {
const repoOwner = encodeURIComponent(repo.owner);
const repoName = encodeURIComponent(repo.name);
const apiEndpointPath = `/repos/${repoOwner}/${repoName}/releases/latest`;
const apiEndpointPath = releaseTag
? `/repos/${repoOwner}/${repoName}/releases/tags/${releaseTag}`
: `/repos/${repoOwner}/${repoName}/releases/latest`;
const requestUrl = GITHUB_API_ENDPOINT_URL + apiEndpointPath;
// We skip runtime type checks for simplicity (here we cast from `any` to `GithubRelease`)
console.log("Issuing request for released artifacts metadata to", requestUrl);
// FIXME: handle non-ok response
const response: GithubRelease = await fetch(requestUrl, {
headers: { Accept: "application/vnd.github.v3+json" }
})

View file

@ -1,3 +1,5 @@
import * as vscode from "vscode";
export interface GithubRepo {
name: string;
owner: string;
@ -50,6 +52,17 @@ export namespace BinarySource {
* and in local `.dir`.
*/
file: string;
/**
* Tag of github release that denotes a version required by this extension.
*/
version: string;
/**
* Object that provides `get()/update()` operations to store metadata
* about the actual binary, e.g. its actual version.
*/
storage: vscode.Memento;
}
}

View file

@ -1,63 +1,15 @@
import * as vscode from "vscode";
import * as path from "path";
import { strict as assert } from "assert";
import { promises as fs } from "fs";
import { promises as dns } from "dns";
import { spawnSync } from "child_process";
import { throttle } from "throttle-debounce";
import { BinarySource } from "./interfaces";
import { fetchLatestArtifactReleaseInfo } from "./fetch_latest_artifact_release_info";
import { downloadFile } from "./download_file";
import { fetchArtifactReleaseInfo } from "./fetch_artifact_release_info";
import { downloadArtifact } from "./download_artifact";
export async function downloadLatestServer(
{file: artifactFileName, dir: installationDir, repo}: BinarySource.GithubRelease
) {
const { releaseName, downloadUrl } = (await fetchLatestArtifactReleaseInfo(
repo, artifactFileName
))!;
await fs.mkdir(installationDir).catch(err => assert.strictEqual(
err?.code,
"EEXIST",
`Couldn't create directory "${installationDir}" to download `+
`language server binary: ${err.message}`
));
const installationPath = path.join(installationDir, artifactFileName);
console.time("Downloading ra_lsp_server");
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
cancellable: false, // FIXME: add support for canceling download?
title: `Downloading language server (${releaseName})`
},
async (progress, _cancellationToken) => {
let lastPrecentage = 0;
const filePermissions = 0o755; // (rwx, r_x, r_x)
await downloadFile(downloadUrl, installationPath, filePermissions, throttle(
200,
/* noTrailing: */ true,
(readBytes, totalBytes) => {
const newPercentage = (readBytes / totalBytes) * 100;
progress.report({
message: newPercentage.toFixed(0) + "%",
increment: newPercentage - lastPrecentage
});
lastPrecentage = newPercentage;
})
);
}
);
console.timeEnd("Downloading ra_lsp_server");
}
export async function ensureServerBinary(
serverSource: null | BinarySource
): Promise<null | string> {
if (!serverSource) {
export async function ensureServerBinary(source: null | BinarySource): Promise<null | string> {
if (!source) {
vscode.window.showErrorMessage(
"Unfortunately we don't ship binaries for your platform yet. " +
"You need to manually clone rust-analyzer repository and " +
@ -69,80 +21,104 @@ export async function ensureServerBinary(
return null;
}
switch (serverSource.type) {
switch (source.type) {
case BinarySource.Type.ExplicitPath: {
if (isBinaryAvailable(serverSource.path)) {
return serverSource.path;
if (isBinaryAvailable(source.path)) {
return source.path;
}
vscode.window.showErrorMessage(
`Unable to run ${serverSource.path} binary. ` +
`Unable to run ${source.path} binary. ` +
`To use the pre-built language server, set "rust-analyzer.raLspServerPath" ` +
"value to `null` or remove it from the settings to use it by default."
);
return null;
}
case BinarySource.Type.GithubRelease: {
const prebuiltBinaryPath = path.join(serverSource.dir, serverSource.file);
const prebuiltBinaryPath = path.join(source.dir, source.file);
if (isBinaryAvailable(prebuiltBinaryPath)) {
const installedVersion: null | string = getServerVersion(source.storage);
const requiredVersion: string = source.version;
console.log("Installed version:", installedVersion, "required:", requiredVersion);
if (isBinaryAvailable(prebuiltBinaryPath) && installedVersion == requiredVersion) {
// FIXME: check for new releases and notify the user to update if possible
return prebuiltBinaryPath;
}
const userResponse = await vscode.window.showInformationMessage(
"Language server binary for rust-analyzer was not found. " +
`Language server version ${source.version} for rust-analyzer is not installed. ` +
"Do you want to download it now?",
"Download now", "Cancel"
);
if (userResponse !== "Download now") return null;
try {
await downloadLatestServer(serverSource);
} catch (err) {
vscode.window.showErrorMessage(
`Failed to download language server from ${serverSource.repo.name} ` +
`GitHub repository: ${err.message}`
);
console.error(err);
dns.resolve('example.com').then(
addrs => console.log("DNS resolution for example.com was successful", addrs),
err => {
console.error(
"DNS resolution for example.com failed, " +
"there might be an issue with Internet availability"
);
console.error(err);
}
);
return null;
}
if (!isBinaryAvailable(prebuiltBinaryPath)) assert(false,
`Downloaded language server binary is not functional.` +
`Downloaded from: ${JSON.stringify(serverSource)}`
);
vscode.window.showInformationMessage(
"Rust analyzer language server was successfully installed 🦀"
);
if (!await downloadServer(source)) return null;
return prebuiltBinaryPath;
}
}
function isBinaryAvailable(binaryPath: string) {
const res = spawnSync(binaryPath, ["--version"]);
// ACHTUNG! `res` type declaration is inherently wrong, see
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/42221
console.log("Checked binary availablity via --version", res);
console.log(binaryPath, "--version output:", res.output?.map(String));
return res.status === 0;
}
}
async function downloadServer(source: BinarySource.GithubRelease): Promise<boolean> {
try {
const releaseInfo = (await fetchArtifactReleaseInfo(source.repo, source.file, source.version))!;
await downloadArtifact(releaseInfo, source.file, source.dir, "language server");
await setServerVersion(source.storage, releaseInfo.releaseName);
} catch (err) {
vscode.window.showErrorMessage(
`Failed to download language server from ${source.repo.name} ` +
`GitHub repository: ${err.message}`
);
console.error(err);
dns.resolve('example.com').then(
addrs => console.log("DNS resolution for example.com was successful", addrs),
err => {
console.error(
"DNS resolution for example.com failed, " +
"there might be an issue with Internet availability"
);
console.error(err);
}
);
return false;
}
if (!isBinaryAvailable(path.join(source.dir, source.file))) assert(false,
`Downloaded language server binary is not functional.` +
`Downloaded from: ${JSON.stringify(source, null, 4)}`
);
vscode.window.showInformationMessage(
"Rust analyzer language server was successfully installed 🦀"
);
return true;
}
function isBinaryAvailable(binaryPath: string): boolean {
const res = spawnSync(binaryPath, ["--version"]);
// ACHTUNG! `res` type declaration is inherently wrong, see
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/42221
console.log("Checked binary availablity via --version", res);
console.log(binaryPath, "--version output:", res.output?.map(String));
return res.status === 0;
}
function getServerVersion(storage: vscode.Memento): null | string {
const version = storage.get<null | string>("server-version", null);
console.log("Get server-version:", version);
return version;
}
async function setServerVersion(storage: vscode.Memento, version: string): Promise<void> {
console.log("Set server-version:", version);
await storage.update("server-version", version.toString());
}