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).
This commit is contained in:
Aleksey Kladov 2020-03-17 12:44:31 +01:00
parent f0a1b64d7e
commit fb6e655de8
13 changed files with 270 additions and 696 deletions

1
.vscode/launch.json vendored
View file

@ -16,6 +16,7 @@
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
// "--user-data-dir=${workspaceFolder}/target/code",
"--disable-extensions",
"--extensionDevelopmentPath=${workspaceFolder}/editors/code"
],

View file

@ -228,7 +228,7 @@
"default": "stable",
"markdownEnumDescriptions": [
"`\"stable\"` updates are shipped weekly, they don't contain cutting-edge features from VSCode proposed APIs but have less bugs in general",
"`\"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**"
"`\"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**"
],
"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"
},

View file

@ -1,20 +1,10 @@
import * as vscode from 'vscode';
import { ensureServerBinary } from '../installation/server';
import * as vscode from "vscode";
import { spawnSync } from "child_process";
import { Ctx, Cmd } from '../ctx';
import { spawnSync } from 'child_process';
export function serverVersion(ctx: Ctx): Cmd {
return async () => {
const binaryPath = await ensureServerBinary(ctx.config, ctx.state);
if (binaryPath == null) {
throw new Error(
"Rust Analyzer Language Server is not available. " +
"Please, ensure its [proper installation](https://rust-analyzer.github.io/manual.html#installation)."
);
}
const version = spawnSync(binaryPath, ["--version"], { encoding: "utf8" }).stdout;
const version = spawnSync(ctx.serverPath, ["--version"], { encoding: "utf8" }).stdout;
vscode.window.showInformationMessage('rust-analyzer version : ' + version);
};
}

View file

@ -1,9 +1,5 @@
import * as os from "os";
import * as vscode from 'vscode';
import { ArtifactSource } from "./installation/interfaces";
import { log, vscodeReloadWindow } from "./util";
const RA_LSP_DEBUG = process.env.__RA_LSP_SERVER_DEBUG;
import { log } from "./util";
export interface InlayHintOptions {
typeHints: boolean;
@ -25,10 +21,7 @@ export interface CargoFeatures {
loadOutDirsFromCheck: boolean;
}
export const enum UpdatesChannel {
Stable = "stable",
Nightly = "nightly"
}
export type UpdatesChannel = "stable" | "nightly";
export const NIGHTLY_TAG = "nightly";
export class Config {
@ -41,6 +34,7 @@ export class Config {
"cargo-watch",
"highlighting.semanticTokens",
"inlayHints",
"updates.channel",
]
.map(opt => `${this.rootSection}.${opt}`);
@ -94,100 +88,17 @@ export class Config {
);
if (userResponse === "Reload now") {
await vscodeReloadWindow();
await vscode.commands.executeCommand("workbench.action.reloadWindow");
}
}
private static replaceTildeWithHomeDir(path: string) {
if (path.startsWith("~/")) {
return os.homedir() + path.slice("~".length);
}
return path;
}
/**
* Name of the binary artifact for `rust-analyzer` that is published for
* `platform` on GitHub releases. (It is also stored under the same name when
* downloaded by the extension).
*/
get prebuiltServerFileName(): null | string {
// See possible `arch` values here:
// https://nodejs.org/api/process.html#process_process_arch
switch (process.platform) {
case "linux": {
switch (process.arch) {
case "arm":
case "arm64": return null;
default: return "rust-analyzer-linux";
}
}
case "darwin": return "rust-analyzer-mac";
case "win32": return "rust-analyzer-windows.exe";
// Users on these platforms yet need to manually build from sources
case "aix":
case "android":
case "freebsd":
case "openbsd":
case "sunos":
case "cygwin":
case "netbsd": return null;
// The list of platforms is exhaustive (see `NodeJS.Platform` type definition)
}
}
get installedExtensionUpdateChannel(): UpdatesChannel {
return this.extensionReleaseTag === NIGHTLY_TAG
? UpdatesChannel.Nightly
: UpdatesChannel.Stable;
}
get serverSource(): null | ArtifactSource {
const serverPath = RA_LSP_DEBUG ?? this.serverPath;
if (serverPath) {
return {
type: ArtifactSource.Type.ExplicitPath,
path: Config.replaceTildeWithHomeDir(serverPath)
};
}
const prebuiltBinaryName = this.prebuiltServerFileName;
if (!prebuiltBinaryName) return null;
return this.createGithubReleaseSource(
prebuiltBinaryName,
this.extensionReleaseTag
);
}
private createGithubReleaseSource(file: string, tag: string): ArtifactSource.GithubRelease {
return {
type: ArtifactSource.Type.GithubRelease,
file,
tag,
dir: this.ctx.globalStoragePath,
repo: {
name: "rust-analyzer",
owner: "rust-analyzer",
}
};
}
get nightlyVsixSource(): ArtifactSource.GithubRelease {
return this.createGithubReleaseSource("rust-analyzer.vsix", NIGHTLY_TAG);
}
get globalStoragePath(): string { return this.ctx.globalStoragePath; }
// We don't do runtime config validation here for simplicity. More on stackoverflow:
// https://stackoverflow.com/questions/60135780/what-is-the-best-way-to-type-check-the-configuration-for-vscode-extension
private get serverPath() { return this.cfg.get("serverPath") as null | string; }
get updatesChannel() { return this.cfg.get("updates.channel") as UpdatesChannel; }
get serverPath() { return this.cfg.get("serverPath") as null | string; }
get channel() { return this.cfg.get<"stable" | "nightly">("updates.channel")!; }
get askBeforeDownload() { return this.cfg.get("updates.askBeforeDownload") as boolean; }
get highlightingSemanticTokens() { return this.cfg.get("highlighting.semanticTokens") as boolean; }
get highlightingOn() { return this.cfg.get("highlightingOn") as boolean; }

View file

@ -4,21 +4,20 @@ import * as lc from 'vscode-languageclient';
import { Config } from './config';
import { createClient } from './client';
import { isRustEditor, RustEditor } from './util';
import { PersistentState } from './persistent_state';
export class Ctx {
private constructor(
readonly config: Config,
readonly state: PersistentState,
private readonly extCtx: vscode.ExtensionContext,
readonly client: lc.LanguageClient
readonly client: lc.LanguageClient,
readonly serverPath: string,
) {
}
static async create(config: Config, state: PersistentState, extCtx: vscode.ExtensionContext, serverPath: string): Promise<Ctx> {
static async create(config: Config, extCtx: vscode.ExtensionContext, serverPath: string): Promise<Ctx> {
const client = await createClient(config, serverPath);
const res = new Ctx(config, state, extCtx, client);
const res = new Ctx(config, extCtx, client, serverPath);
res.pushCleanup(client.start());
await client.onReady();
return res;

View file

@ -1,146 +0,0 @@
import * as vscode from "vscode";
import * as path from "path";
import { promises as fs } from 'fs';
import { vscodeReinstallExtension, vscodeReloadWindow, log, vscodeInstallExtensionFromVsix, assert, notReentrant } from "../util";
import { Config, UpdatesChannel } from "../config";
import { ArtifactReleaseInfo, ArtifactSource } from "./interfaces";
import { downloadArtifactWithProgressUi } from "./downloads";
import { fetchArtifactReleaseInfo } from "./fetch_artifact_release_info";
import { PersistentState } from "../persistent_state";
const HEURISTIC_NIGHTLY_RELEASE_PERIOD_IN_HOURS = 25;
/**
* Installs `stable` or latest `nightly` version or does nothing if the current
* extension version is what's needed according to `desiredUpdateChannel`.
*/
export async function ensureProperExtensionVersion(config: Config, state: PersistentState): Promise<never | void> {
// User has built lsp server from sources, she should manage updates manually
if (config.serverSource?.type === ArtifactSource.Type.ExplicitPath) return;
const currentUpdChannel = config.installedExtensionUpdateChannel;
const desiredUpdChannel = config.updatesChannel;
if (currentUpdChannel === UpdatesChannel.Stable) {
// Release date is present only when we are on nightly
await state.installedNightlyExtensionReleaseDate.set(null);
}
if (desiredUpdChannel === UpdatesChannel.Stable) {
// VSCode should handle updates for stable channel
if (currentUpdChannel === UpdatesChannel.Stable) return;
if (!await askToDownloadProperExtensionVersion(config)) return;
await vscodeReinstallExtension(config.extensionId);
await vscodeReloadWindow(); // never returns
}
if (currentUpdChannel === UpdatesChannel.Stable) {
if (!await askToDownloadProperExtensionVersion(config)) return;
return await tryDownloadNightlyExtension(config, state);
}
const currentExtReleaseDate = state.installedNightlyExtensionReleaseDate.get();
if (currentExtReleaseDate === null) {
void vscode.window.showErrorMessage(
"Nightly release date must've been set during the installation. " +
"Did you download and install the nightly .vsix package manually?"
);
throw new Error("Nightly release date was not set in globalStorage");
}
const dateNow = new Date;
const hoursSinceLastUpdate = diffInHours(currentExtReleaseDate, dateNow);
log.debug(
"Current rust-analyzer nightly was downloaded", hoursSinceLastUpdate,
"hours ago, namely:", currentExtReleaseDate, "and now is", dateNow
);
if (hoursSinceLastUpdate < HEURISTIC_NIGHTLY_RELEASE_PERIOD_IN_HOURS) {
return;
}
if (!await askToDownloadProperExtensionVersion(config, "The installed nightly version is most likely outdated. ")) {
return;
}
await tryDownloadNightlyExtension(config, state, releaseInfo => {
assert(
currentExtReleaseDate.getTime() === state.installedNightlyExtensionReleaseDate.get()?.getTime(),
"Other active VSCode instance has reinstalled the extension"
);
if (releaseInfo.releaseDate.getTime() === currentExtReleaseDate.getTime()) {
vscode.window.showInformationMessage(
"Whoops, it appears that your nightly version is up-to-date. " +
"There might be some problems with the upcomming nightly release " +
"or you traveled too far into the future. Sorry for that 😅! "
);
return false;
}
return true;
});
}
async function askToDownloadProperExtensionVersion(config: Config, reason = "") {
if (!config.askBeforeDownload) return true;
const stableOrNightly = config.updatesChannel === UpdatesChannel.Stable ? "stable" : "latest nightly";
// In case of reentering this function and showing the same info message
// (e.g. after we had shown this message, the user changed the config)
// vscode will dismiss the already shown one (i.e. return undefined).
// This behaviour is what we want, but likely it is not documented
const userResponse = await vscode.window.showInformationMessage(
reason + `Do you want to download the ${stableOrNightly} rust-analyzer extension ` +
`version and reload the window now?`,
"Download now", "Cancel"
);
return userResponse === "Download now";
}
/**
* Shutdowns the process in case of success (i.e. reloads the window) or throws an error.
*
* ACHTUNG!: this function has a crazy amount of state transitions, handling errors during
* each of them would result in a ton of code (especially accounting for cross-process
* shared mutable `globalState` access). Enforcing no reentrancy for this is best-effort.
*/
const tryDownloadNightlyExtension = notReentrant(async (
config: Config,
state: PersistentState,
shouldDownload: (releaseInfo: ArtifactReleaseInfo) => boolean = () => true
): Promise<never | void> => {
const vsixSource = config.nightlyVsixSource;
try {
const releaseInfo = await fetchArtifactReleaseInfo(vsixSource.repo, vsixSource.file, vsixSource.tag);
if (!shouldDownload(releaseInfo)) return;
await downloadArtifactWithProgressUi(releaseInfo, vsixSource.file, vsixSource.dir, "nightly extension");
const vsixPath = path.join(vsixSource.dir, vsixSource.file);
await vscodeInstallExtensionFromVsix(vsixPath);
await state.installedNightlyExtensionReleaseDate.set(releaseInfo.releaseDate);
await fs.unlink(vsixPath);
await vscodeReloadWindow(); // never returns
} catch (err) {
log.downloadError(err, "nightly extension", vsixSource.repo.name);
}
});
function diffInHours(a: Date, b: Date): number {
// Discard the time and time-zone information (to abstract from daylight saving time bugs)
// https://stackoverflow.com/a/15289883/9259330
const utcA = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
const utcB = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
return (utcA - utcB) / (1000 * 60 * 60);
}

View file

@ -1,77 +0,0 @@
import fetch from "node-fetch";
import { GithubRepo, ArtifactReleaseInfo } from "./interfaces";
import { log } from "../util";
const GITHUB_API_ENDPOINT_URL = "https://api.github.com";
/**
* Fetches the release with `releaseTag` from GitHub `repo` and
* returns metadata about `artifactFileName` shipped with
* this release.
*
* @throws Error upon network failure or if no such repository, release, or artifact exists.
*/
export async function fetchArtifactReleaseInfo(
repo: GithubRepo,
artifactFileName: string,
releaseTag: string
): Promise<ArtifactReleaseInfo> {
const repoOwner = encodeURIComponent(repo.owner);
const repoName = encodeURIComponent(repo.name);
const apiEndpointPath = `/repos/${repoOwner}/${repoName}/releases/tags/${releaseTag}`;
const requestUrl = GITHUB_API_ENDPOINT_URL + apiEndpointPath;
log.debug("Issuing request for released artifacts metadata to", requestUrl);
const response = await fetch(requestUrl, { headers: { Accept: "application/vnd.github.v3+json" } });
if (!response.ok) {
log.error("Error fetching artifact release info", {
requestUrl,
releaseTag,
artifactFileName,
response: {
headers: response.headers,
status: response.status,
body: await response.text(),
}
});
throw new Error(
`Got response ${response.status} when trying to fetch ` +
`"${artifactFileName}" artifact release info for ${releaseTag} release`
);
}
// We skip runtime type checks for simplicity (here we cast from `any` to `GithubRelease`)
const release: GithubRelease = await response.json();
const artifact = release.assets.find(artifact => artifact.name === artifactFileName);
if (!artifact) {
throw new Error(
`Artifact ${artifactFileName} was not found in ${release.name} release!`
);
}
return {
releaseName: release.name,
releaseDate: new Date(release.published_at),
downloadUrl: artifact.browser_download_url
};
// We omit declaration of tremendous amount of fields that we are not using here
interface GithubRelease {
name: string;
// eslint-disable-next-line camelcase
published_at: string;
assets: Array<{
name: string;
// eslint-disable-next-line camelcase
browser_download_url: string;
}>;
}
}

View file

@ -1,63 +0,0 @@
export interface GithubRepo {
name: string;
owner: string;
}
/**
* Metadata about particular artifact retrieved from GitHub releases.
*/
export interface ArtifactReleaseInfo {
releaseDate: Date;
releaseName: string;
downloadUrl: string;
}
/**
* Represents the source of a an artifact which is either specified by the user
* explicitly, or bundled by this extension from GitHub releases.
*/
export type ArtifactSource = ArtifactSource.ExplicitPath | ArtifactSource.GithubRelease;
export namespace ArtifactSource {
/**
* Type tag for `ArtifactSource` discriminated union.
*/
export const enum Type { ExplicitPath, GithubRelease }
export interface ExplicitPath {
type: Type.ExplicitPath;
/**
* Filesystem path to the binary specified by the user explicitly.
*/
path: string;
}
export interface GithubRelease {
type: Type.GithubRelease;
/**
* Repository where the binary is stored.
*/
repo: GithubRepo;
// FIXME: add installationPath: string;
/**
* Directory on the filesystem where the bundled binary is stored.
*/
dir: string;
/**
* Name of the binary file. It is stored under the same name on GitHub releases
* and in local `.dir`.
*/
file: string;
/**
* Tag of github release that denotes a version required by this extension.
*/
tag: string;
}
}

View file

@ -1,131 +0,0 @@
import * as vscode from "vscode";
import * as path from "path";
import { spawnSync } from "child_process";
import { ArtifactSource } from "./interfaces";
import { fetchArtifactReleaseInfo } from "./fetch_artifact_release_info";
import { downloadArtifactWithProgressUi } from "./downloads";
import { log, assert, notReentrant } from "../util";
import { Config, NIGHTLY_TAG } from "../config";
import { PersistentState } from "../persistent_state";
export async function ensureServerBinary(config: Config, state: PersistentState): Promise<null | string> {
const source = config.serverSource;
if (!source) {
vscode.window.showErrorMessage(
"Unfortunately we don't ship binaries for your platform yet. " +
"You need to manually clone rust-analyzer repository and " +
"run `cargo xtask install --server` to build the language server from sources. " +
"If you feel that your platform should be supported, please create an issue " +
"about that [here](https://github.com/rust-analyzer/rust-analyzer/issues) and we " +
"will consider it."
);
return null;
}
switch (source.type) {
case ArtifactSource.Type.ExplicitPath: {
if (isBinaryAvailable(source.path)) {
return source.path;
}
vscode.window.showErrorMessage(
`Unable to run ${source.path} binary. ` +
`To use the pre-built language server, set "rust-analyzer.serverPath" ` +
"value to `null` or remove it from the settings to use it by default."
);
return null;
}
case ArtifactSource.Type.GithubRelease: {
if (!shouldDownloadServer(state, source)) {
return path.join(source.dir, source.file);
}
if (config.askBeforeDownload) {
const userResponse = await vscode.window.showInformationMessage(
`Language server version ${source.tag} for rust-analyzer is not installed. ` +
"Do you want to download it now?",
"Download now", "Cancel"
);
if (userResponse !== "Download now") return null;
}
return await downloadServer(state, source);
}
}
}
function shouldDownloadServer(
state: PersistentState,
source: ArtifactSource.GithubRelease,
): boolean {
if (!isBinaryAvailable(path.join(source.dir, source.file))) return true;
const installed = {
tag: state.serverReleaseTag.get(),
date: state.serverReleaseDate.get()
};
const required = {
tag: source.tag,
date: state.installedNightlyExtensionReleaseDate.get()
};
log.debug("Installed server:", installed, "required:", required);
if (required.tag !== NIGHTLY_TAG || installed.tag !== NIGHTLY_TAG) {
return required.tag !== installed.tag;
}
assert(required.date !== null, "Extension release date should have been saved during its installation");
assert(installed.date !== null, "Server release date should have been saved during its installation");
return installed.date.getTime() !== required.date.getTime();
}
/**
* Enforcing no reentrancy for this is best-effort.
*/
const downloadServer = notReentrant(async (
state: PersistentState,
source: ArtifactSource.GithubRelease,
): Promise<null | string> => {
try {
const releaseInfo = await fetchArtifactReleaseInfo(source.repo, source.file, source.tag);
await downloadArtifactWithProgressUi(releaseInfo, source.file, source.dir, "language server");
await Promise.all([
state.serverReleaseTag.set(releaseInfo.releaseName),
state.serverReleaseDate.set(releaseInfo.releaseDate)
]);
} catch (err) {
log.downloadError(err, "language server", source.repo.name);
return null;
}
const binaryPath = path.join(source.dir, source.file);
assert(isBinaryAvailable(binaryPath),
`Downloaded language server binary is not functional.` +
`Downloaded from GitHub repo ${source.repo.owner}/${source.repo.name} ` +
`to ${binaryPath}`
);
vscode.window.showInformationMessage(
"Rust analyzer language server was successfully installed 🦀"
);
return binaryPath;
});
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
log.debug("Checked binary availablity via --version", res);
log.debug(binaryPath, "--version output:", res.output?.map(String));
return res.status === 0;
}

View file

@ -1,15 +1,18 @@
import * as vscode from 'vscode';
import * as path from "path";
import * as os from "os";
import { promises as fs } from "fs";
import * as commands from './commands';
import { activateInlayHints } from './inlay_hints';
import { activateStatusDisplay } from './status_display';
import { Ctx } from './ctx';
import { activateHighlighting } from './highlighting';
import { ensureServerBinary } from './installation/server';
import { Config } from './config';
import { log } from './util';
import { ensureProperExtensionVersion } from './installation/extension';
import { Config, NIGHTLY_TAG } from './config';
import { log, assert } from './util';
import { PersistentState } from './persistent_state';
import { fetchRelease, download } from './net';
import { spawnSync } from 'child_process';
let ctx: Ctx | undefined;
@ -35,27 +38,14 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(defaultOnEnter);
const config = new Config(context);
const state = new PersistentState(context);
vscode.workspace.onDidChangeConfiguration(() => ensureProperExtensionVersion(config, state).catch(log.error));
// Don't await the user response here, otherwise we will block the lsp server bootstrap
void ensureProperExtensionVersion(config, state).catch(log.error);
const serverPath = await ensureServerBinary(config, state);
if (serverPath == null) {
throw new Error(
"Rust Analyzer Language Server is not available. " +
"Please, ensure its [proper installation](https://rust-analyzer.github.io/manual.html#installation)."
);
}
const state = new PersistentState(context.globalState);
const serverPath = await bootstrap(config, state);
// Note: we try to start the server before we activate type hints so that it
// registers its `onDidChangeDocument` handler before us.
//
// This a horribly, horribly wrong way to deal with this problem.
ctx = await Ctx.create(config, state, context, serverPath);
ctx = await Ctx.create(config, context, serverPath);
// Commands which invokes manually via command palette, shortcut, etc.
ctx.registerCommand('reload', (ctx) => {
@ -109,3 +99,131 @@ export async function deactivate() {
await ctx?.client?.stop();
ctx = undefined;
}
async function bootstrap(config: Config, state: PersistentState): Promise<string> {
await fs.mkdir(config.globalStoragePath, { recursive: true });
await bootstrapExtension(config, state);
const path = await bootstrapServer(config, state);
return path;
}
async function bootstrapExtension(config: Config, state: PersistentState): Promise<void> {
if (config.channel === "stable") {
if (config.extensionReleaseTag === NIGHTLY_TAG) {
vscode.window.showWarningMessage(`You are running a nightly version of rust-analyzer extension.
To switch to stable, uninstall the extension and re-install it from the marketplace`);
}
return;
};
const lastCheck = state.lastCheck;
const now = Date.now();
const anHour = 60 * 60 * 1000;
const shouldDownloadNightly = state.releaseId === undefined || (now - (lastCheck ?? 0)) > anHour;
if (!shouldDownloadNightly) return;
const release = await fetchRelease("nightly").catch((e) => {
log.error(e);
if (state.releaseId === undefined) { // Show error only for the initial download
vscode.window.showErrorMessage(`Failed to download rust-analyzer nightly ${e}`);
}
return undefined;
});
if (release === undefined || release.id === state.releaseId) return;
const userResponse = await vscode.window.showInformationMessage(
"New version of rust-analyzer (nightly) is available (requires reload).",
"Update"
);
if (userResponse !== "Update") return;
const artifact = release.assets.find(artifact => artifact.name === "rust-analyzer.vsix");
assert(!!artifact, `Bad release: ${JSON.stringify(release)}`);
const dest = path.join(config.globalStoragePath, "rust-analyzer.vsix");
await download(artifact.browser_download_url, dest, "Downloading rust-analyzer extension");
await vscode.commands.executeCommand("workbench.extensions.installExtension", vscode.Uri.file(dest));
await fs.unlink(dest);
await state.updateReleaseId(release.id);
await state.updateLastCheck(now);
await vscode.commands.executeCommand("workbench.action.reloadWindow");
}
async function bootstrapServer(config: Config, state: PersistentState): Promise<string> {
const path = await getServer(config, state);
if (!path) {
throw new Error(
"Rust Analyzer Language Server is not available. " +
"Please, ensure its [proper installation](https://rust-analyzer.github.io/manual.html#installation)."
);
}
const res = spawnSync(path, ["--version"], { encoding: 'utf8' });
log.debug("Checked binary availability via --version", res);
log.debug(res, "--version output:", res.output);
if (res.status !== 0) {
throw new Error(
`Failed to execute ${path} --version`
);
}
return path;
}
async function getServer(config: Config, state: PersistentState): Promise<string | undefined> {
const explicitPath = process.env.__RA_LSP_SERVER_DEBUG ?? config.serverPath;
if (explicitPath) {
if (explicitPath.startsWith("~/")) {
return os.homedir() + explicitPath.slice("~".length);
}
return explicitPath;
};
let binaryName: string | undefined = undefined;
if (process.arch === "x64" || process.arch === "x32") {
if (process.platform === "linux") binaryName = "rust-analyzer-linux";
if (process.platform === "darwin") binaryName = "rust-analyzer-mac";
if (process.platform === "win32") binaryName = "rust-analyzer-windows.exe";
}
if (binaryName === undefined) {
vscode.window.showErrorMessage(
"Unfortunately we don't ship binaries for your platform yet. " +
"You need to manually clone rust-analyzer repository and " +
"run `cargo xtask install --server` to build the language server from sources. " +
"If you feel that your platform should be supported, please create an issue " +
"about that [here](https://github.com/rust-analyzer/rust-analyzer/issues) and we " +
"will consider it."
);
return undefined;
}
const dest = path.join(config.globalStoragePath, binaryName);
const exists = await fs.stat(dest).then(() => true, () => false);
if (!exists) {
await state.updateServerVersion(undefined);
}
if (state.serverVersion === config.packageJsonVersion) return dest;
if (config.askBeforeDownload) {
const userResponse = await vscode.window.showInformationMessage(
`Language server version ${config.packageJsonVersion} for rust-analyzer is not installed.`,
"Download now"
);
if (userResponse !== "Download now") return dest;
}
const release = await fetchRelease(config.extensionReleaseTag);
const artifact = release.assets.find(artifact => artifact.name === binaryName);
assert(!!artifact, `Bad release: ${JSON.stringify(release)}`);
await download(artifact.browser_download_url, dest, "Downloading rust-analyzer server", { mode: 0o755 });
await state.updateServerVersion(config.packageJsonVersion);
return dest;
}

View file

@ -1,24 +1,101 @@
import fetch from "node-fetch";
import * as vscode from "vscode";
import * as path from "path";
import * as fs from "fs";
import * as stream from "stream";
import * as util from "util";
import { log, assert } from "../util";
import { ArtifactReleaseInfo } from "./interfaces";
import { log, assert } from "./util";
const pipeline = util.promisify(stream.pipeline);
const GITHUB_API_ENDPOINT_URL = "https://api.github.com";
const OWNER = "rust-analyzer";
const REPO = "rust-analyzer";
export async function fetchRelease(
releaseTag: string
): Promise<GithubRelease> {
const apiEndpointPath = `/repos/${OWNER}/${REPO}/releases/tags/${releaseTag}`;
const requestUrl = GITHUB_API_ENDPOINT_URL + apiEndpointPath;
log.debug("Issuing request for released artifacts metadata to", requestUrl);
const response = await fetch(requestUrl, { headers: { Accept: "application/vnd.github.v3+json" } });
if (!response.ok) {
log.error("Error fetching artifact release info", {
requestUrl,
releaseTag,
response: {
headers: response.headers,
status: response.status,
body: await response.text(),
}
});
throw new Error(
`Got response ${response.status} when trying to fetch ` +
`release info for ${releaseTag} release`
);
}
// We skip runtime type checks for simplicity (here we cast from `any` to `GithubRelease`)
const release: GithubRelease = await response.json();
return release;
}
// We omit declaration of tremendous amount of fields that we are not using here
export interface GithubRelease {
name: string;
id: number;
// eslint-disable-next-line camelcase
published_at: string;
assets: Array<{
name: string;
// eslint-disable-next-line camelcase
browser_download_url: string;
}>;
}
export async function download(
downloadUrl: string,
destinationPath: string,
progressTitle: string,
{ mode }: { mode?: number } = {},
) {
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
cancellable: false,
title: progressTitle
},
async (progress, _cancellationToken) => {
let lastPercentage = 0;
await downloadFile(downloadUrl, destinationPath, mode, (readBytes, totalBytes) => {
const newPercentage = (readBytes / totalBytes) * 100;
progress.report({
message: newPercentage.toFixed(0) + "%",
increment: newPercentage - lastPercentage
});
lastPercentage = newPercentage;
});
}
);
}
/**
* Downloads file from `url` and stores it at `destFilePath` with `destFilePermissions`.
* `onProgress` callback is called on recieveing each chunk of bytes
* to track the progress of downloading, it gets the already read and total
* amount of bytes to read as its parameters.
*/
export async function downloadFile(
async function downloadFile(
url: string,
destFilePath: fs.PathLike,
destFilePermissions: number,
mode: number | undefined,
onProgress: (readBytes: number, totalBytes: number) => void
): Promise<void> {
const res = await fetch(url);
@ -41,7 +118,7 @@ export async function downloadFile(
onProgress(readBytes, totalBytes);
});
const destFileStream = fs.createWriteStream(destFilePath, { mode: destFilePermissions });
const destFileStream = fs.createWriteStream(destFilePath, { mode });
await pipeline(res.body, destFileStream);
return new Promise<void>(resolve => {
@ -52,46 +129,3 @@ export async function downloadFile(
// Issue at nodejs repo: https://github.com/nodejs/node/issues/31776
});
}
/**
* Downloads artifact from given `downloadUrl`.
* Creates `installationDir` if it is not yet created and puts 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 downloadArtifactWithProgressUi(
{ downloadUrl, releaseName }: ArtifactReleaseInfo,
artifactFileName: string,
installationDir: string,
displayName: string,
) {
await fs.promises.mkdir(installationDir).catch(err => assert(
err?.code === "EEXIST",
`Couldn't create directory "${installationDir}" to download ` +
`${artifactFileName} artifact: ${err?.message}`
));
const installationPath = path.join(installationDir, artifactFileName);
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
cancellable: false, // FIXME: add support for canceling download?
title: `Downloading rust-analyzer ${displayName} (${releaseName})`
},
async (progress, _cancellationToken) => {
let lastPrecentage = 0;
const filePermissions = 0o755; // (rwx, r_x, r_x)
await downloadFile(downloadUrl, installationPath, filePermissions, (readBytes, totalBytes) => {
const newPercentage = (readBytes / totalBytes) * 100;
progress.report({
message: newPercentage.toFixed(0) + "%",
increment: newPercentage - lastPrecentage
});
lastPrecentage = newPercentage;
});
}
);
}

View file

@ -1,49 +1,41 @@
import * as vscode from 'vscode';
import { log } from "./util";
import { log } from './util';
export class PersistentState {
constructor(private readonly ctx: vscode.ExtensionContext) {
constructor(private readonly globalState: vscode.Memento) {
const { lastCheck, releaseId, serverVersion } = this;
log.debug("PersistentState: ", { lastCheck, releaseId, serverVersion });
}
readonly installedNightlyExtensionReleaseDate = new DateStorage(
"installed-nightly-extension-release-date",
this.ctx.globalState
);
readonly serverReleaseDate = new DateStorage("server-release-date", this.ctx.globalState);
readonly serverReleaseTag = new Storage<null | string>("server-release-tag", this.ctx.globalState, null);
}
export class Storage<T> {
constructor(
private readonly key: string,
private readonly storage: vscode.Memento,
private readonly defaultVal: T
) { }
get(): T {
const val = this.storage.get(this.key, this.defaultVal);
log.debug(this.key, "==", val);
return val;
/**
* Used to check for *nightly* updates once an hour.
*/
get lastCheck(): number | undefined {
return this.globalState.get("lastCheck");
}
async set(val: T) {
log.debug(this.key, "=", val);
await this.storage.update(this.key, val);
}
}
export class DateStorage {
inner: Storage<null | string>;
constructor(key: string, storage: vscode.Memento) {
this.inner = new Storage(key, storage, null);
}
get(): null | Date {
const dateStr = this.inner.get();
return dateStr ? new Date(dateStr) : null;
}
async set(date: null | Date) {
await this.inner.set(date ? date.toString() : null);
async updateLastCheck(value: number) {
await this.globalState.update("lastCheck", value);
}
/**
* Release id of the *nightly* extension.
* Used to check if we should update.
*/
get releaseId(): number | undefined {
return this.globalState.get("releaseId");
}
async updateReleaseId(value: number) {
await this.globalState.update("releaseId", value);
}
/**
* Version of the extension that installed the server.
* Used to check if we need to update the server.
*/
get serverVersion(): string | undefined {
return this.globalState.get("serverVersion");
}
async updateServerVersion(value: string | undefined) {
await this.globalState.update("serverVersion", value);
}
}

View file

@ -1,6 +1,5 @@
import * as lc from "vscode-languageclient";
import * as vscode from "vscode";
import { promises as dns } from "dns";
import { strict as nativeAssert } from "assert";
export function assert(condition: boolean, explanation: string): asserts condition {
@ -31,22 +30,6 @@ export const log = new class {
// eslint-disable-next-line no-console
console.error(message, ...optionalParams);
}
downloadError(err: Error, artifactName: string, repoName: string) {
vscode.window.showErrorMessage(
`Failed to download the rust-analyzer ${artifactName} from ${repoName} ` +
`GitHub repository: ${err.message}`
);
log.error(err);
dns.resolve('example.com').then(
addrs => log.debug("DNS resolution for example.com was successful", addrs),
err => log.error(
"DNS resolution for example.com failed, " +
"there might be an issue with Internet availability",
err
)
);
}
};
export async function sendRequestWithRetry<TParam, TRet>(
@ -86,17 +69,6 @@ function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export function notReentrant<TThis, TParams extends any[], TRet>(
fn: (this: TThis, ...params: TParams) => Promise<TRet>
): typeof fn {
let entered = false;
return function(...params) {
assert(!entered, `Reentrancy invariant for ${fn.name} is violated`);
entered = true;
return fn.apply(this, params).finally(() => entered = false);
};
}
export type RustDocument = vscode.TextDocument & { languageId: "rust" };
export type RustEditor = vscode.TextEditor & { document: RustDocument; id: string };
@ -110,29 +82,3 @@ export function isRustDocument(document: vscode.TextDocument): document is RustD
export function isRustEditor(editor: vscode.TextEditor): editor is RustEditor {
return isRustDocument(editor.document);
}
/**
* @param extensionId The canonical extension identifier in the form of: `publisher.name`
*/
export async function vscodeReinstallExtension(extensionId: string) {
// Unfortunately there is no straightforward way as of now, these commands
// were found in vscode source code.
log.debug("Uninstalling extension", extensionId);
await vscode.commands.executeCommand("workbench.extensions.uninstallExtension", extensionId);
log.debug("Installing extension", extensionId);
await vscode.commands.executeCommand("workbench.extensions.installExtension", extensionId);
}
export async function vscodeReloadWindow(): Promise<never> {
await vscode.commands.executeCommand("workbench.action.reloadWindow");
assert(false, "unreachable");
}
export async function vscodeInstallExtensionFromVsix(vsixPath: string) {
await vscode.commands.executeCommand(
"workbench.extensions.installExtension",
vscode.Uri.file(vsixPath)
);
}