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/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 ------------------ 5 files changed, 514 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 (limited to 'editors/code/src/installation') 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; -} -- cgit v1.2.3