Skip to content

Change CDN files to download locally #11286

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 13 commits into from
Apr 21, 2020
1 change: 1 addition & 0 deletions news/2 Fixes/11274.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix issue where downloading ipywidgets from the CDN might be busy.
1 change: 1 addition & 0 deletions src/client/datascience/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@ export namespace Identifiers {
export const InteractiveWindowIdentity = 'history://EC155B3B-DC18-49DC-9E99-9A948AA2F27B';
export const InteractiveWindowIdentityScheme = 'history';
export const DefaultCodeCellMarker = '# %%';
export const DefaultCommTarget = 'jupyter.widget';
}

export namespace CodeSnippits {
Expand Down
210 changes: 183 additions & 27 deletions src/client/datascience/ipywidgets/cdnWidgetScriptSourceProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,25 @@

'use strict';

import { traceWarning } from '../../common/logger';
import * as fs from 'fs-extra';
import { sha256 } from 'hash.js';
import * as path from 'path';
import request from 'request';
import { Uri } from 'vscode';
import { traceError, traceInfo } from '../../common/logger';
import { IFileSystem, TemporaryFile } from '../../common/platform/types';
import { IConfigurationService, IHttpClient, WidgetCDNs } from '../../common/types';
import { StopWatch } from '../../common/utils/stopWatch';
import { sendTelemetryEvent } from '../../telemetry';
import { Telemetry } from '../constants';
import { createDeferred, sleep } from '../../common/utils/async';
import { ILocalResourceUriConverter } from '../types';
import { IWidgetScriptSourceProvider, WidgetScriptSource } from './types';

// Source borrowed from https://github.com/jupyter-widgets/ipywidgets/blob/54941b7a4b54036d089652d91b39f937bde6b6cd/packages/html-manager/src/libembed-amd.ts#L33
const unpgkUrl = 'https://unpkg.com/';
const jsdelivrUrl = 'https://cdn.jsdelivr.net/npm/';

// tslint:disable: no-var-requires no-require-imports
const sanitize = require('sanitize-filename');

function moduleNameToCDNUrl(cdn: string, moduleName: string, moduleVersion: string) {
let packageName = moduleName;
let fileName = 'index'; // default filename
Expand All @@ -29,6 +38,16 @@ function moduleNameToCDNUrl(cdn: string, moduleName: string, moduleVersion: stri
fileName = moduleName.substr(index + 1);
packageName = moduleName.substr(0, index);
}
if (cdn === jsdelivrUrl) {
// Js Delivr doesn't support ^ in the version. It needs an exact version
if (moduleVersion.startsWith('^')) {
moduleVersion = moduleVersion.slice(1);
}
// Js Delivr also needs the .js file on the end.
if (!fileName.endsWith('.js')) {
fileName = fileName.concat('.js');
}
}
return `${cdn}${packageName}@${moduleVersion}/dist/${fileName}`;
}

Expand All @@ -52,40 +71,177 @@ export class CDNWidgetScriptSourceProvider implements IWidgetScriptSourceProvide
const settings = this.configurationSettings.getSettings(undefined);
return settings.datascience.widgetScriptSources;
}
public static validUrls = new Map<string, boolean>();
private cache = new Map<string, WidgetScriptSource>();
constructor(
private readonly configurationSettings: IConfigurationService,
private readonly httpClient: IHttpClient
private readonly httpClient: IHttpClient,
private readonly localResourceUriConverter: ILocalResourceUriConverter,
private readonly fileSystem: IFileSystem
) {}
public dispose() {
// Noop.
this.cache.clear();
}
public async getWidgetScriptSource(moduleName: string, moduleVersion: string): Promise<WidgetScriptSource> {
const cdns = [...this.cdnProviders];
while (cdns.length) {
const cdn = cdns.shift();
const cdnBaseUrl = getCDNPrefix(cdn);
if (!cdnBaseUrl || !cdn) {
continue;
// First see if we already have it downloaded.
const key = this.getModuleKey(moduleName, moduleVersion);
const diskPath = path.join(this.localResourceUriConverter.rootScriptFolder.fsPath, key, 'index.js');
let cached = this.cache.get(key);
let tempFile: TemporaryFile | undefined;

// Might be on disk, try there first.
if (!cached) {
if (diskPath && (await this.fileSystem.fileExists(diskPath))) {
const scriptUri = (await this.localResourceUriConverter.asWebviewUri(Uri.file(diskPath))).toString();
cached = { moduleName, scriptUri, source: 'cdn' };
this.cache.set(key, cached);
}
const scriptUri = moduleNameToCDNUrl(cdnBaseUrl, moduleName, moduleVersion);
const exists = await this.getUrlForWidget(cdn, scriptUri);
if (exists) {
return { moduleName, scriptUri, source: 'cdn' };
}

// If still not found, download it.
if (!cached) {
try {
// Make sure the disk path directory exists. We'll be downloading it to there.
await this.fileSystem.createDirectory(path.dirname(diskPath));

// Then get the first one that returns.
tempFile = await this.downloadFastestCDN(moduleName, moduleVersion);
if (tempFile) {
// Need to copy from the temporary file to our real file (note: VSC filesystem fails to copy so just use straight file system)
await fs.copyFile(tempFile.filePath, diskPath);

// Now we can generate the script URI so the local converter doesn't try to copy it.
const scriptUri = (
await this.localResourceUriConverter.asWebviewUri(Uri.file(diskPath))
).toString();
cached = { moduleName, scriptUri, source: 'cdn' };
} else {
cached = { moduleName };
}
} catch (exc) {
traceError('Error downloading from CDN: ', exc);
cached = { moduleName };
} finally {
if (tempFile) {
tempFile.dispose();
}
}
this.cache.set(key, cached);
}

return cached;
}

private async downloadFastestCDN(moduleName: string, moduleVersion: string) {
const deferred = createDeferred<TemporaryFile | undefined>();
Promise.all(
// For each CDN, try to download it.
this.cdnProviders.map((cdn) =>
this.downloadFromCDN(moduleName, moduleVersion, cdn).then((t) => {
// First one to get here wins. Meaning the first one that
// returns a valid temporary file. If a request doesn't download it will
// return undefined.
if (!deferred.resolved && t) {
deferred.resolve(t);
}
})
)
)
.then((_a) => {
// If after running all requests, we're still not resolved, then return empty.
// This would happen if both unpkg.com and jsdelivr failed.
if (!deferred.resolved) {
deferred.resolve(undefined);
}
})
.ignoreErrors();

// Note, we only wait until one download finishes. We don't need to wait
// for everybody (hence the use of the deferred)
return deferred.promise;
}

private async downloadFromCDN(
moduleName: string,
moduleVersion: string,
cdn: WidgetCDNs
): Promise<TemporaryFile | undefined> {
// First validate CDN
const downloadUrl = await this.generateDownloadUri(moduleName, moduleVersion, cdn);
if (downloadUrl) {
// Then see if we can download the file.
try {
return await this.downloadFile(downloadUrl);
} catch (exc) {
// Something goes wrong, just fail
}
}
traceWarning(`Widget Script not found for ${moduleName}@${moduleVersion}`);
return { moduleName };
}
private async getUrlForWidget(cdn: string, url: string): Promise<boolean> {
if (CDNWidgetScriptSourceProvider.validUrls.has(url)) {
return CDNWidgetScriptSourceProvider.validUrls.get(url)!;

private async generateDownloadUri(
moduleName: string,
moduleVersion: string,
cdn: WidgetCDNs
): Promise<string | undefined> {
const cdnBaseUrl = getCDNPrefix(cdn);
if (cdnBaseUrl) {
return moduleNameToCDNUrl(cdnBaseUrl, moduleName, moduleVersion);
}
return undefined;
}

private getModuleKey(moduleName: string, moduleVersion: string) {
return sanitize(sha256().update(`${moduleName}${moduleVersion}`).digest('hex'));
}

const stopWatch = new StopWatch();
const exists = await this.httpClient.exists(url);
sendTelemetryEvent(Telemetry.DiscoverIPyWidgetNamesCDNPerf, stopWatch.elapsedTime, { cdn, exists });
CDNWidgetScriptSourceProvider.validUrls.set(url, exists);
return exists;
private handleResponse(req: request.Request, filePath: string): Promise<boolean> {
const deferred = createDeferred<boolean>();
// tslint:disable-next-line: no-any
const errorHandler = (e: any) => {
traceError('Error downloading from CDN', e);
deferred.resolve(false);
};
req.on('response', (r) => {
if (r.statusCode === 200) {
const ws = this.fileSystem.createWriteStream(filePath);
r.on('error', errorHandler)
.pipe(ws)
.on('close', () => deferred.resolve(true));
} else if (r.statusCode === 429) {
// Special case busy. Sleep for 500 milliseconds
sleep(500)
.then(() => deferred.resolve(false))
.ignoreErrors();
} else {
deferred.resolve(false);
}
}).on('error', errorHandler);
return deferred.promise;
}

private async downloadFile(downloadUrl: string): Promise<TemporaryFile | undefined> {
// Create a temp file to download the results to
const tempFile = await this.fileSystem.createTemporaryFile('.js');

// Otherwise do an http get on the url. Retry at least 5 times
let retryCount = 5;
let success = false;
while (retryCount > 0 && !success) {
let req: request.Request;
try {
req = await this.httpClient.downloadFile(downloadUrl);
success = await this.handleResponse(req, tempFile.filePath);
} catch (exc) {
traceInfo(`Error downloading from ${downloadUrl}: `, exc);
} finally {
retryCount -= 1;
}
}

// Once we make it out, return result
if (success) {
return tempFile;
} else {
tempFile.dispose();
}
}
}
11 changes: 9 additions & 2 deletions src/client/datascience/ipywidgets/ipyWidgetMessageDispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { createDeferred, Deferred } from '../../common/utils/async';
import { noop } from '../../common/utils/misc';
import { deserializeDataViews, serializeDataViews } from '../../common/utils/serializers';
import { sendTelemetryEvent } from '../../telemetry';
import { Telemetry } from '../constants';
import { Identifiers, Telemetry } from '../constants';
import { IInteractiveWindowMapping, IPyWidgetMessages } from '../interactive-common/interactiveWindowTypes';
import { INotebook, INotebookProvider, KernelSocketInformation } from '../types';
import { IIPyWidgetMessageDispatcher, IPyWidgetMessage } from './types';
Expand Down Expand Up @@ -280,9 +280,16 @@ export class IPyWidgetMessageDispatcher implements IIPyWidgetMessageDispatcher {
return;
}

traceInfo(`Registering commtarget ${targetName}`);
this.commTargetsRegistered.add(targetName);
this.pendingTargetNames.delete(targetName);
notebook.registerCommTarget(targetName, noop);

// Skip the predefined target. It should have been registered
// inside the kernel on startup. However we
// still need to track it here.
if (targetName !== Identifiers.DefaultCommTarget) {
notebook.registerCommTarget(targetName, noop);
}
}
}

Expand Down
54 changes: 32 additions & 22 deletions src/client/datascience/ipywidgets/ipyWidgetScriptSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export class IPyWidgetScriptSource implements IInteractiveWindowListener, ILocal
private pendingModuleRequests = new Map<string, string | undefined>();
private readonly uriConversionPromises = new Map<string, Deferred<Uri>>();
private readonly targetWidgetScriptsFolder: string;
private readonly _rootScriptFolder: string;
private readonly createTargetWidgetScriptsFolder: Promise<string>;
constructor(
@inject(IDisposableRegistry) disposables: IDisposableRegistry,
Expand All @@ -79,7 +80,8 @@ export class IPyWidgetScriptSource implements IInteractiveWindowListener, ILocal
@inject(IPersistentStateFactory) private readonly stateFactory: IPersistentStateFactory,
@inject(IExtensionContext) extensionContext: IExtensionContext
) {
this.targetWidgetScriptsFolder = path.join(extensionContext.extensionPath, 'tmp', 'nbextensions');
this._rootScriptFolder = path.join(extensionContext.extensionPath, 'tmp', 'scripts');
this.targetWidgetScriptsFolder = path.join(this._rootScriptFolder, 'nbextensions');
this.createTargetWidgetScriptsFolder = this.fs
.directoryExists(this.targetWidgetScriptsFolder)
.then(async (exists) => {
Expand Down Expand Up @@ -108,30 +110,34 @@ export class IPyWidgetScriptSource implements IInteractiveWindowListener, ILocal
* Copying into global workspace folder would also work, but over time this folder size could grow (in an unmanaged way).
*/
public async asWebviewUri(localResource: Uri): Promise<Uri> {
if (this.notebookIdentity && !this.resourcesMappedToExtensionFolder.has(localResource.fsPath)) {
const deferred = createDeferred<Uri>();
this.resourcesMappedToExtensionFolder.set(localResource.fsPath, deferred.promise);
try {
// Create a file name such that it will be unique and consistent across VSC reloads.
// Only if original file has been modified should we create a new copy of the sam file.
const fileHash: string = await this.fs.getFileHash(localResource.fsPath);
const uniqueFileName = sanitize(sha256().update(`${localResource.fsPath}${fileHash}`).digest('hex'));
const targetFolder = await this.createTargetWidgetScriptsFolder;
const mappedResource = Uri.file(
path.join(targetFolder, `${uniqueFileName}${path.basename(localResource.fsPath)}`)
);
if (!(await this.fs.fileExists(mappedResource.fsPath))) {
await this.fs.copyFile(localResource.fsPath, mappedResource.fsPath);
// Make a copy of the local file if not already in the correct location
if (!localResource.fsPath.startsWith(this._rootScriptFolder)) {
if (this.notebookIdentity && !this.resourcesMappedToExtensionFolder.has(localResource.fsPath)) {
const deferred = createDeferred<Uri>();
this.resourcesMappedToExtensionFolder.set(localResource.fsPath, deferred.promise);
try {
// Create a file name such that it will be unique and consistent across VSC reloads.
// Only if original file has been modified should we create a new copy of the sam file.
const fileHash: string = await this.fs.getFileHash(localResource.fsPath);
const uniqueFileName = sanitize(
sha256().update(`${localResource.fsPath}${fileHash}`).digest('hex')
);
const targetFolder = await this.createTargetWidgetScriptsFolder;
const mappedResource = Uri.file(
path.join(targetFolder, `${uniqueFileName}${path.basename(localResource.fsPath)}`)
);
if (!(await this.fs.fileExists(mappedResource.fsPath))) {
await this.fs.copyFile(localResource.fsPath, mappedResource.fsPath);
}
traceInfo(`Widget Script file ${localResource.fsPath} mapped to ${mappedResource.fsPath}`);
deferred.resolve(mappedResource);
} catch (ex) {
traceError(`Failed to map widget Script file ${localResource.fsPath}`);
deferred.reject(ex);
}
traceInfo(`Widget Script file ${localResource.fsPath} mapped to ${mappedResource.fsPath}`);
deferred.resolve(mappedResource);
} catch (ex) {
traceError(`Failed to map widget Script file ${localResource.fsPath}`);
deferred.reject(ex);
}
localResource = await this.resourcesMappedToExtensionFolder.get(localResource.fsPath)!;
}
localResource = await this.resourcesMappedToExtensionFolder.get(localResource.fsPath)!;

const key = localResource.toString();
if (!this.uriConversionPromises.has(key)) {
this.uriConversionPromises.set(key, createDeferred<Uri>());
Expand All @@ -144,6 +150,10 @@ export class IPyWidgetScriptSource implements IInteractiveWindowListener, ILocal
return this.uriConversionPromises.get(key)!.promise;
}

public get rootScriptFolder(): Uri {
return Uri.file(this._rootScriptFolder);
}

public dispose() {
while (this.disposables.length) {
this.disposables.shift()?.dispose(); // NOSONAR
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,14 @@ export class IPyWidgetScriptSourceProvider implements IWidgetScriptSourceProvide

// If we're allowed to use CDN providers, then use them, and use in order of preference.
if (this.configuredScriptSources.length > 0) {
scriptProviders.push(new CDNWidgetScriptSourceProvider(this.configurationSettings, this.httpClient));
scriptProviders.push(
new CDNWidgetScriptSourceProvider(
this.configurationSettings,
this.httpClient,
this.localResourceUriConverter,
this.fs
)
);
}
if (this.notebook.connection && this.notebook.connection.localLaunch) {
scriptProviders.push(
Expand Down
Loading