From fb6e655de8a44c65275ad45a27bf5bd684670ba0 Mon Sep 17 00:00:00 2001 From: Aleksey Kladov Date: Tue, 17 Mar 2020 12:44:31 +0100 Subject: 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). --- editors/code/package.json | 2 +- editors/code/src/commands/server_version.ts | 16 +-- editors/code/src/config.ts | 103 +------------- editors/code/src/ctx.ts | 9 +- editors/code/src/installation/downloads.ts | 97 ------------- editors/code/src/installation/extension.ts | 146 ------------------- .../installation/fetch_artifact_release_info.ts | 77 ---------- editors/code/src/installation/interfaces.ts | 63 -------- editors/code/src/installation/server.ts | 131 ----------------- editors/code/src/main.ts | 158 ++++++++++++++++++--- editors/code/src/net.ts | 131 +++++++++++++++++ editors/code/src/persistent_state.ts | 64 ++++----- editors/code/src/util.ts | 54 ------- 13 files changed, 312 insertions(+), 739 deletions(-) delete mode 100644 editors/code/src/installation/downloads.ts delete mode 100644 editors/code/src/installation/extension.ts delete mode 100644 editors/code/src/installation/fetch_artifact_release_info.ts delete mode 100644 editors/code/src/installation/interfaces.ts delete mode 100644 editors/code/src/installation/server.ts create mode 100644 editors/code/src/net.ts (limited to 'editors') 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 @@ "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" }, 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 @@ -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); }; } 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 @@ -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; } 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'; 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 { + static async create(config: Config, extCtx: vscode.ExtensionContext, serverPath: string): Promise { 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; diff --git a/editors/code/src/installation/downloads.ts b/editors/code/src/installation/downloads.ts deleted file mode 100644 index 7ce2e2960..000000000 --- a/editors/code/src/installation/downloads.ts +++ /dev/null @@ -1,97 +0,0 @@ -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"; - -const pipeline = util.promisify(stream.pipeline); - -/** - * 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( - url: string, - destFilePath: fs.PathLike, - destFilePermissions: number, - onProgress: (readBytes: number, totalBytes: number) => void -): Promise { - const res = await fetch(url); - - if (!res.ok) { - log.error("Error", res.status, "while downloading file from", url); - log.error({ body: await res.text(), headers: res.headers }); - - throw new Error(`Got response ${res.status} when trying to download a file.`); - } - - const totalBytes = Number(res.headers.get('content-length')); - assert(!Number.isNaN(totalBytes), "Sanity check of content-length protocol"); - - log.debug("Downloading file of", totalBytes, "bytes size from", url, "to", destFilePath); - - let readBytes = 0; - res.body.on("data", (chunk: Buffer) => { - readBytes += chunk.length; - onProgress(readBytes, totalBytes); - }); - - const destFileStream = fs.createWriteStream(destFilePath, { mode: destFilePermissions }); - - await pipeline(res.body, destFileStream); - return new Promise(resolve => { - destFileStream.on("close", resolve); - destFileStream.destroy(); - - // Details on workaround: https://github.com/rust-analyzer/rust-analyzer/pull/3092#discussion_r378191131 - // 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; - }); - } - ); -} 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 @@ -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 { - // 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 => { - 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); -} 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 @@ -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 { - - 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; - }>; - } -} 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 @@ -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; - } -} 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 @@ -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 { - 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 => { - 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; -} 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 @@ 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/editors/code/src/net.ts b/editors/code/src/net.ts new file mode 100644 index 000000000..492213937 --- /dev/null +++ b/editors/code/src/net.ts @@ -0,0 +1,131 @@ +import fetch from "node-fetch"; +import * as vscode from "vscode"; +import * as fs from "fs"; +import * as stream from "stream"; +import * as util from "util"; +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 { + + 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. + */ +async function downloadFile( + url: string, + destFilePath: fs.PathLike, + mode: number | undefined, + onProgress: (readBytes: number, totalBytes: number) => void +): Promise { + const res = await fetch(url); + + if (!res.ok) { + log.error("Error", res.status, "while downloading file from", url); + log.error({ body: await res.text(), headers: res.headers }); + + throw new Error(`Got response ${res.status} when trying to download a file.`); + } + + const totalBytes = Number(res.headers.get('content-length')); + assert(!Number.isNaN(totalBytes), "Sanity check of content-length protocol"); + + log.debug("Downloading file of", totalBytes, "bytes size from", url, "to", destFilePath); + + let readBytes = 0; + res.body.on("data", (chunk: Buffer) => { + readBytes += chunk.length; + onProgress(readBytes, totalBytes); + }); + + const destFileStream = fs.createWriteStream(destFilePath, { mode }); + + await pipeline(res.body, destFileStream); + return new Promise(resolve => { + destFileStream.on("close", resolve); + destFileStream.destroy(); + + // Details on workaround: https://github.com/rust-analyzer/rust-analyzer/pull/3092#discussion_r378191131 + // Issue at nodejs repo: https://github.com/nodejs/node/issues/31776 + }); +} 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 @@ 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("server-release-tag", this.ctx.globalState, null); -} - - -export class Storage { - 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); + async updateLastCheck(value: number) { + await this.globalState.update("lastCheck", value); } -} -export class DateStorage { - inner: Storage; - constructor(key: string, storage: vscode.Memento) { - this.inner = new Storage(key, storage, null); + /** + * Release id of the *nightly* extension. + * Used to check if we should update. + */ + get releaseId(): number | undefined { + return this.globalState.get("releaseId"); } - - get(): null | Date { - const dateStr = this.inner.get(); - return dateStr ? new Date(dateStr) : null; + async updateReleaseId(value: number) { + await this.globalState.update("releaseId", value); } - async set(date: null | Date) { - await this.inner.set(date ? date.toString() : null); + /** + * 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); } } 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 @@ 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( @@ -86,17 +69,6 @@ function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } -export function notReentrant( - fn: (this: TThis, ...params: TParams) => Promise -): 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 { - 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) - ); -} -- cgit v1.2.3