Skip to content

support old hls versions compatible with the requested ghc version #506

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Nov 24, 2021
7 changes: 6 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ jobs:
strategy:
matrix:
os: [macos-11, ubuntu-latest, windows-latest]
ghc: [9.0.1]
include:
# To test a ghc version deprecated in newer hls versions
- os: ubuntu-latest
ghc: 8.10.4
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
Expand All @@ -22,7 +27,7 @@ jobs:
- name: Ensure there is a supported ghc versions
uses: haskell/actions/setup@v1
with:
ghc-version: 9.0.1
ghc-version: ${{ matrix.ghc }}
- run: npm ci
- run: npm run webpack
- run: xvfb-run -s '-screen 0 640x480x16' -a npm test
Expand Down
74 changes: 53 additions & 21 deletions src/hlsBinaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const releaseValidator: validate.Validator<IRelease> = validate.object({

const githubReleaseApiValidator: validate.Validator<IRelease[]> = validate.array(releaseValidator);

const cachedReleaseValidator: validate.Validator<IRelease | null> = validate.optional(releaseValidator);
const cachedReleaseValidator: validate.Validator<IRelease[] | null> = validate.optional(githubReleaseApiValidator);

// On Windows the executable needs to be stored somewhere with an .exe extension
const exeExt = process.platform === 'win32' ? '.exe' : '';
Expand Down Expand Up @@ -85,7 +85,7 @@ class NoBinariesError extends Error {
const supportedReleasesLink =
'[See the list of supported versions here](https://github.com/haskell/vscode-haskell#supported-ghc-versions)';
if (ghcVersion) {
super(`haskell-language-server ${hlsVersion} for GHC ${ghcVersion} is not available on ${os.type()}.
super(`haskell-language-server ${hlsVersion} or earlier for GHC ${ghcVersion} is not available on ${os.type()}.
${supportedReleasesLink}`);
} else {
super(`haskell-language-server ${hlsVersion} is not available on ${os.type()}.
Expand Down Expand Up @@ -205,7 +205,11 @@ async function getProjectGhcVersion(
return callWrapper(downloadedWrapper);
}

async function getLatestReleaseMetadata(context: ExtensionContext, storagePath: string): Promise<IRelease | null> {
async function getReleaseMetadata(
context: ExtensionContext,
storagePath: string,
logger: Logger
): Promise<IRelease[] | null> {
const releasesUrl = workspace.getConfiguration('haskell').releasesURL
? url.parse(workspace.getConfiguration('haskell').releasesURL)
: undefined;
Expand All @@ -219,9 +223,28 @@ async function getLatestReleaseMetadata(context: ExtensionContext, storagePath:
path: '/repos/haskell/haskell-language-server/releases',
};

const offlineCache = path.join(storagePath, 'latestApprovedRelease.cache.json');
const offlineCache = path.join(storagePath, 'approvedReleases.cache.json');
const offlineCacheOldFormat = path.join(storagePath, 'latestApprovedRelease.cache.json');

async function readCachedReleaseData(): Promise<IRelease | null> {
// Migrate existing old cache file latestApprovedRelease.cache.json to the new cache file
// approvedReleases.cache.json if no such file exists yet.
if (!fs.existsSync(offlineCache)) {
try {
const oldCachedInfo = await promisify(fs.readFile)(offlineCacheOldFormat, { encoding: 'utf-8' });
const oldCachedInfoParsed = validate.parseAndValidate(oldCachedInfo, validate.optional(releaseValidator));
if (oldCachedInfoParsed !== null) {
await promisify(fs.writeFile)(offlineCache, JSON.stringify([oldCachedInfoParsed]), { encoding: 'utf-8' });
}
logger.info(`Successfully migrated ${offlineCacheOldFormat} to ${offlineCache}`);
} catch (err: any) {
// Ignore if old cache file does not exist
if (err.code !== 'ENOENT') {
logger.error(`Failed to migrate ${offlineCacheOldFormat} to ${offlineCache}: ${err}`);
}
}
}

async function readCachedReleaseData(): Promise<IRelease[] | null> {
try {
const cachedInfo = await promisify(fs.readFile)(offlineCache, { encoding: 'utf-8' });
return validate.parseAndValidate(cachedInfo, cachedReleaseValidator);
Expand All @@ -242,15 +265,16 @@ async function getLatestReleaseMetadata(context: ExtensionContext, storagePath:

try {
const releaseInfo = await httpsGetSilently(opts);
const latestInfoParsed =
validate.parseAndValidate(releaseInfo, githubReleaseApiValidator).find((x) => !x.prerelease) || null;
const releaseInfoParsed =
validate.parseAndValidate(releaseInfo, githubReleaseApiValidator).filter((x) => !x.prerelease) || null;

if (updateBehaviour === 'prompt') {
const cachedInfoParsed = await readCachedReleaseData();

if (
latestInfoParsed !== null &&
(cachedInfoParsed === null || latestInfoParsed.tag_name !== cachedInfoParsed.tag_name)
releaseInfoParsed !== null && releaseInfoParsed.length > 0 &&
(cachedInfoParsed === null || cachedInfoParsed.length === 0
|| releaseInfoParsed[0].tag_name !== cachedInfoParsed[0].tag_name)
) {
const promptMessage =
cachedInfoParsed === null
Expand All @@ -266,8 +290,8 @@ async function getLatestReleaseMetadata(context: ExtensionContext, storagePath:
}

// Cache the latest successfully fetched release information
await promisify(fs.writeFile)(offlineCache, JSON.stringify(latestInfoParsed), { encoding: 'utf-8' });
return latestInfoParsed;
await promisify(fs.writeFile)(offlineCache, JSON.stringify(releaseInfoParsed), { encoding: 'utf-8' });
return releaseInfoParsed;
} catch (githubError: any) {
// Attempt to read from the latest cached file
try {
Expand Down Expand Up @@ -316,8 +340,8 @@ export async function downloadHaskellLanguageServer(
}

logger.info('Fetching the latest release from GitHub or from cache');
const release = await getLatestReleaseMetadata(context, storagePath);
if (!release) {
const releases = await getReleaseMetadata(context, storagePath, logger);
if (!releases) {
let message = "Couldn't find any pre-built haskell-language-server binaries";
const updateBehaviour = workspace.getConfiguration('haskell').get('updateBehavior') as UpdateBehaviour;
if (updateBehaviour === 'never-check') {
Expand All @@ -326,12 +350,12 @@ export async function downloadHaskellLanguageServer(
window.showErrorMessage(message);
return null;
}
logger.info(`The latest release is ${release.tag_name}`);
logger.info(`The latest release is ${releases[0].tag_name}`);
logger.info('Figure out the ghc version to use or advertise an installation link for missing components');
const dir: string = folder?.uri?.fsPath ?? path.dirname(resource.fsPath);
let ghcVersion: string;
try {
ghcVersion = await getProjectGhcVersion(context, logger, dir, release, storagePath);
ghcVersion = await getProjectGhcVersion(context, logger, dir, releases[0], storagePath);
} catch (error) {
if (error instanceof MissingToolError) {
const link = error.installLink();
Expand All @@ -354,29 +378,37 @@ export async function downloadHaskellLanguageServer(
// When searching for binaries, use startsWith because the compression may differ
// between .zip and .gz
const assetName = `haskell-language-server-${githubOS}-${ghcVersion}${exeExt}`;
logger.info(`Search for binary ${assetName} in release assests`);
logger.info(`Search for binary ${assetName} in release assets`);
const release = releases?.find(r => r.assets.find((x) => x.name.startsWith(assetName)));
const asset = release?.assets.find((x) => x.name.startsWith(assetName));
if (!asset) {
logger.error(
`No binary ${assetName} found in the release assets: ${release?.assets.map((value) => value.name).join(',')}`
`No binary ${assetName} found in the release assets`
);
window.showInformationMessage(new NoBinariesError(release.tag_name, ghcVersion).message);
window.showInformationMessage(new NoBinariesError(releases[0].tag_name, ghcVersion).message);
return null;
}

const serverName = `haskell-language-server-${release.tag_name}-${process.platform}-${ghcVersion}${exeExt}`;
const serverName = `haskell-language-server-${release?.tag_name}-${process.platform}-${ghcVersion}${exeExt}`;
const binaryDest = path.join(storagePath, serverName);

const title = `Downloading haskell-language-server ${release.tag_name} for GHC ${ghcVersion}`;
const title = `Downloading haskell-language-server ${release?.tag_name} for GHC ${ghcVersion}`;
logger.info(title);
await downloadFile(title, asset.browser_download_url, binaryDest);
const downloaded = await downloadFile(title, asset.browser_download_url, binaryDest);
if (ghcVersion.startsWith('9.')) {
const warning =
'Currently, HLS supports GHC 9 only partially. ' +
'See [issue #297](https://github.com/haskell/haskell-language-server/issues/297) for more detail.';
logger.warn(warning);
window.showWarningMessage(warning);
}
if (release?.tag_name !== releases[0].tag_name) {
const warning = `haskell-language-server ${releases[0].tag_name} for GHC ${ghcVersion} is not available on ${os.type()}. Falling back to haskell-language-server ${release?.tag_name}`;
logger.warn(warning);
if (downloaded) {
window.showInformationMessage(warning);
}
}
return binaryDest;
}

Expand Down
8 changes: 4 additions & 4 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ const userAgentHeader = { 'User-Agent': 'vscode-haskell' };
* equality is by reference, not value in Map. And we are using a tuple of
* [src, dest] as the key.
*/
const inFlightDownloads = new Map<string, Map<string, Thenable<void>>>();
const inFlightDownloads = new Map<string, Map<string, Thenable<boolean>>>();

export async function httpsGetSilently(options: https.RequestOptions): Promise<string> {
const opts: https.RequestOptions = {
Expand Down Expand Up @@ -141,7 +141,7 @@ async function ignoreFileNotExists(err: NodeJS.ErrnoException): Promise<void> {
throw err;
}

export async function downloadFile(titleMsg: string, src: string, dest: string): Promise<void> {
export async function downloadFile(titleMsg: string, src: string, dest: string): Promise<boolean> {
// Check to see if we're already in the process of downloading the same thing
const inFlightDownload = inFlightDownloads.get(src)?.get(dest);
if (inFlightDownload) {
Expand All @@ -150,7 +150,7 @@ export async function downloadFile(titleMsg: string, src: string, dest: string):

// If it already is downloaded just use that
if (fs.existsSync(dest)) {
return;
return false;
}

// Download it to a .tmp location first, then rename it!
Expand Down Expand Up @@ -241,7 +241,7 @@ export async function downloadFile(titleMsg: string, src: string, dest: string):
inFlightDownloads.get(src)?.delete(dest);
}
}
);
).then(_ => true);

try {
if (inFlightDownloads.has(src)) {
Expand Down