Skip to content

Commit 3106c54

Browse files
committed
Change CDN files to download locally (#11286)
* Working downloader * Tests passing * Add more tests * Add retry test * Fix build errors. Not sure why not happening locally. * Fix unit tests * Fix azure ml on windows Rework error checking to use status * Fix sonar error * Add some more descriptive comments * More comments * Refactor some code * Make sure to not use exists as we're downloading anyway
1 parent 69d2691 commit 3106c54

13 files changed

+412
-137
lines changed

news/2 Fixes/11274.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix issue where downloading ipywidgets from the CDN might be busy.

src/client/datascience/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,7 @@ export namespace Identifiers {
404404
export const InteractiveWindowIdentity = 'history://EC155B3B-DC18-49DC-9E99-9A948AA2F27B';
405405
export const InteractiveWindowIdentityScheme = 'history';
406406
export const DefaultCodeCellMarker = '# %%';
407+
export const DefaultCommTarget = 'jupyter.widget';
407408
}
408409

409410
export namespace CodeSnippits {

src/client/datascience/ipywidgets/cdnWidgetScriptSourceProvider.ts

Lines changed: 183 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,25 @@
33

44
'use strict';
55

6-
import { traceWarning } from '../../common/logger';
6+
import * as fs from 'fs-extra';
7+
import { sha256 } from 'hash.js';
8+
import * as path from 'path';
9+
import request from 'request';
10+
import { Uri } from 'vscode';
11+
import { traceError, traceInfo } from '../../common/logger';
12+
import { IFileSystem, TemporaryFile } from '../../common/platform/types';
713
import { IConfigurationService, IHttpClient, WidgetCDNs } from '../../common/types';
8-
import { StopWatch } from '../../common/utils/stopWatch';
9-
import { sendTelemetryEvent } from '../../telemetry';
10-
import { Telemetry } from '../constants';
14+
import { createDeferred, sleep } from '../../common/utils/async';
15+
import { ILocalResourceUriConverter } from '../types';
1116
import { IWidgetScriptSourceProvider, WidgetScriptSource } from './types';
1217

1318
// Source borrowed from https://github.com/jupyter-widgets/ipywidgets/blob/54941b7a4b54036d089652d91b39f937bde6b6cd/packages/html-manager/src/libembed-amd.ts#L33
1419
const unpgkUrl = 'https://unpkg.com/';
1520
const jsdelivrUrl = 'https://cdn.jsdelivr.net/npm/';
21+
22+
// tslint:disable: no-var-requires no-require-imports
23+
const sanitize = require('sanitize-filename');
24+
1625
function moduleNameToCDNUrl(cdn: string, moduleName: string, moduleVersion: string) {
1726
let packageName = moduleName;
1827
let fileName = 'index'; // default filename
@@ -29,6 +38,16 @@ function moduleNameToCDNUrl(cdn: string, moduleName: string, moduleVersion: stri
2938
fileName = moduleName.substr(index + 1);
3039
packageName = moduleName.substr(0, index);
3140
}
41+
if (cdn === jsdelivrUrl) {
42+
// Js Delivr doesn't support ^ in the version. It needs an exact version
43+
if (moduleVersion.startsWith('^')) {
44+
moduleVersion = moduleVersion.slice(1);
45+
}
46+
// Js Delivr also needs the .js file on the end.
47+
if (!fileName.endsWith('.js')) {
48+
fileName = fileName.concat('.js');
49+
}
50+
}
3251
return `${cdn}${packageName}@${moduleVersion}/dist/${fileName}`;
3352
}
3453

@@ -52,40 +71,177 @@ export class CDNWidgetScriptSourceProvider implements IWidgetScriptSourceProvide
5271
const settings = this.configurationSettings.getSettings(undefined);
5372
return settings.datascience.widgetScriptSources;
5473
}
55-
public static validUrls = new Map<string, boolean>();
74+
private cache = new Map<string, WidgetScriptSource>();
5675
constructor(
5776
private readonly configurationSettings: IConfigurationService,
58-
private readonly httpClient: IHttpClient
77+
private readonly httpClient: IHttpClient,
78+
private readonly localResourceUriConverter: ILocalResourceUriConverter,
79+
private readonly fileSystem: IFileSystem
5980
) {}
6081
public dispose() {
61-
// Noop.
82+
this.cache.clear();
6283
}
6384
public async getWidgetScriptSource(moduleName: string, moduleVersion: string): Promise<WidgetScriptSource> {
64-
const cdns = [...this.cdnProviders];
65-
while (cdns.length) {
66-
const cdn = cdns.shift();
67-
const cdnBaseUrl = getCDNPrefix(cdn);
68-
if (!cdnBaseUrl || !cdn) {
69-
continue;
85+
// First see if we already have it downloaded.
86+
const key = this.getModuleKey(moduleName, moduleVersion);
87+
const diskPath = path.join(this.localResourceUriConverter.rootScriptFolder.fsPath, key, 'index.js');
88+
let cached = this.cache.get(key);
89+
let tempFile: TemporaryFile | undefined;
90+
91+
// Might be on disk, try there first.
92+
if (!cached) {
93+
if (diskPath && (await this.fileSystem.fileExists(diskPath))) {
94+
const scriptUri = (await this.localResourceUriConverter.asWebviewUri(Uri.file(diskPath))).toString();
95+
cached = { moduleName, scriptUri, source: 'cdn' };
96+
this.cache.set(key, cached);
7097
}
71-
const scriptUri = moduleNameToCDNUrl(cdnBaseUrl, moduleName, moduleVersion);
72-
const exists = await this.getUrlForWidget(cdn, scriptUri);
73-
if (exists) {
74-
return { moduleName, scriptUri, source: 'cdn' };
98+
}
99+
100+
// If still not found, download it.
101+
if (!cached) {
102+
try {
103+
// Make sure the disk path directory exists. We'll be downloading it to there.
104+
await this.fileSystem.createDirectory(path.dirname(diskPath));
105+
106+
// Then get the first one that returns.
107+
tempFile = await this.downloadFastestCDN(moduleName, moduleVersion);
108+
if (tempFile) {
109+
// Need to copy from the temporary file to our real file (note: VSC filesystem fails to copy so just use straight file system)
110+
await fs.copyFile(tempFile.filePath, diskPath);
111+
112+
// Now we can generate the script URI so the local converter doesn't try to copy it.
113+
const scriptUri = (
114+
await this.localResourceUriConverter.asWebviewUri(Uri.file(diskPath))
115+
).toString();
116+
cached = { moduleName, scriptUri, source: 'cdn' };
117+
} else {
118+
cached = { moduleName };
119+
}
120+
} catch (exc) {
121+
traceError('Error downloading from CDN: ', exc);
122+
cached = { moduleName };
123+
} finally {
124+
if (tempFile) {
125+
tempFile.dispose();
126+
}
127+
}
128+
this.cache.set(key, cached);
129+
}
130+
131+
return cached;
132+
}
133+
134+
private async downloadFastestCDN(moduleName: string, moduleVersion: string) {
135+
const deferred = createDeferred<TemporaryFile | undefined>();
136+
Promise.all(
137+
// For each CDN, try to download it.
138+
this.cdnProviders.map((cdn) =>
139+
this.downloadFromCDN(moduleName, moduleVersion, cdn).then((t) => {
140+
// First one to get here wins. Meaning the first one that
141+
// returns a valid temporary file. If a request doesn't download it will
142+
// return undefined.
143+
if (!deferred.resolved && t) {
144+
deferred.resolve(t);
145+
}
146+
})
147+
)
148+
)
149+
.then((_a) => {
150+
// If after running all requests, we're still not resolved, then return empty.
151+
// This would happen if both unpkg.com and jsdelivr failed.
152+
if (!deferred.resolved) {
153+
deferred.resolve(undefined);
154+
}
155+
})
156+
.ignoreErrors();
157+
158+
// Note, we only wait until one download finishes. We don't need to wait
159+
// for everybody (hence the use of the deferred)
160+
return deferred.promise;
161+
}
162+
163+
private async downloadFromCDN(
164+
moduleName: string,
165+
moduleVersion: string,
166+
cdn: WidgetCDNs
167+
): Promise<TemporaryFile | undefined> {
168+
// First validate CDN
169+
const downloadUrl = await this.generateDownloadUri(moduleName, moduleVersion, cdn);
170+
if (downloadUrl) {
171+
// Then see if we can download the file.
172+
try {
173+
return await this.downloadFile(downloadUrl);
174+
} catch (exc) {
175+
// Something goes wrong, just fail
75176
}
76177
}
77-
traceWarning(`Widget Script not found for ${moduleName}@${moduleVersion}`);
78-
return { moduleName };
79178
}
80-
private async getUrlForWidget(cdn: string, url: string): Promise<boolean> {
81-
if (CDNWidgetScriptSourceProvider.validUrls.has(url)) {
82-
return CDNWidgetScriptSourceProvider.validUrls.get(url)!;
179+
180+
private async generateDownloadUri(
181+
moduleName: string,
182+
moduleVersion: string,
183+
cdn: WidgetCDNs
184+
): Promise<string | undefined> {
185+
const cdnBaseUrl = getCDNPrefix(cdn);
186+
if (cdnBaseUrl) {
187+
return moduleNameToCDNUrl(cdnBaseUrl, moduleName, moduleVersion);
83188
}
189+
return undefined;
190+
}
191+
192+
private getModuleKey(moduleName: string, moduleVersion: string) {
193+
return sanitize(sha256().update(`${moduleName}${moduleVersion}`).digest('hex'));
194+
}
84195

85-
const stopWatch = new StopWatch();
86-
const exists = await this.httpClient.exists(url);
87-
sendTelemetryEvent(Telemetry.DiscoverIPyWidgetNamesCDNPerf, stopWatch.elapsedTime, { cdn, exists });
88-
CDNWidgetScriptSourceProvider.validUrls.set(url, exists);
89-
return exists;
196+
private handleResponse(req: request.Request, filePath: string): Promise<boolean> {
197+
const deferred = createDeferred<boolean>();
198+
// tslint:disable-next-line: no-any
199+
const errorHandler = (e: any) => {
200+
traceError('Error downloading from CDN', e);
201+
deferred.resolve(false);
202+
};
203+
req.on('response', (r) => {
204+
if (r.statusCode === 200) {
205+
const ws = this.fileSystem.createWriteStream(filePath);
206+
r.on('error', errorHandler)
207+
.pipe(ws)
208+
.on('close', () => deferred.resolve(true));
209+
} else if (r.statusCode === 429) {
210+
// Special case busy. Sleep for 500 milliseconds
211+
sleep(500)
212+
.then(() => deferred.resolve(false))
213+
.ignoreErrors();
214+
} else {
215+
deferred.resolve(false);
216+
}
217+
}).on('error', errorHandler);
218+
return deferred.promise;
219+
}
220+
221+
private async downloadFile(downloadUrl: string): Promise<TemporaryFile | undefined> {
222+
// Create a temp file to download the results to
223+
const tempFile = await this.fileSystem.createTemporaryFile('.js');
224+
225+
// Otherwise do an http get on the url. Retry at least 5 times
226+
let retryCount = 5;
227+
let success = false;
228+
while (retryCount > 0 && !success) {
229+
let req: request.Request;
230+
try {
231+
req = await this.httpClient.downloadFile(downloadUrl);
232+
success = await this.handleResponse(req, tempFile.filePath);
233+
} catch (exc) {
234+
traceInfo(`Error downloading from ${downloadUrl}: `, exc);
235+
} finally {
236+
retryCount -= 1;
237+
}
238+
}
239+
240+
// Once we make it out, return result
241+
if (success) {
242+
return tempFile;
243+
} else {
244+
tempFile.dispose();
245+
}
90246
}
91247
}

src/client/datascience/ipywidgets/ipyWidgetMessageDispatcher.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { createDeferred, Deferred } from '../../common/utils/async';
1414
import { noop } from '../../common/utils/misc';
1515
import { deserializeDataViews, serializeDataViews } from '../../common/utils/serializers';
1616
import { sendTelemetryEvent } from '../../telemetry';
17-
import { Telemetry } from '../constants';
17+
import { Identifiers, Telemetry } from '../constants';
1818
import { IInteractiveWindowMapping, IPyWidgetMessages } from '../interactive-common/interactiveWindowTypes';
1919
import { INotebook, INotebookProvider, KernelSocketInformation } from '../types';
2020
import { IIPyWidgetMessageDispatcher, IPyWidgetMessage } from './types';
@@ -280,9 +280,16 @@ export class IPyWidgetMessageDispatcher implements IIPyWidgetMessageDispatcher {
280280
return;
281281
}
282282

283+
traceInfo(`Registering commtarget ${targetName}`);
283284
this.commTargetsRegistered.add(targetName);
284285
this.pendingTargetNames.delete(targetName);
285-
notebook.registerCommTarget(targetName, noop);
286+
287+
// Skip the predefined target. It should have been registered
288+
// inside the kernel on startup. However we
289+
// still need to track it here.
290+
if (targetName !== Identifiers.DefaultCommTarget) {
291+
notebook.registerCommTarget(targetName, noop);
292+
}
286293
}
287294
}
288295

src/client/datascience/ipywidgets/ipyWidgetScriptSource.ts

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export class IPyWidgetScriptSource implements IInteractiveWindowListener, ILocal
6666
private pendingModuleRequests = new Map<string, string | undefined>();
6767
private readonly uriConversionPromises = new Map<string, Deferred<Uri>>();
6868
private readonly targetWidgetScriptsFolder: string;
69+
private readonly _rootScriptFolder: string;
6970
private readonly createTargetWidgetScriptsFolder: Promise<string>;
7071
constructor(
7172
@inject(IDisposableRegistry) disposables: IDisposableRegistry,
@@ -79,7 +80,8 @@ export class IPyWidgetScriptSource implements IInteractiveWindowListener, ILocal
7980
@inject(IPersistentStateFactory) private readonly stateFactory: IPersistentStateFactory,
8081
@inject(IExtensionContext) extensionContext: IExtensionContext
8182
) {
82-
this.targetWidgetScriptsFolder = path.join(extensionContext.extensionPath, 'tmp', 'nbextensions');
83+
this._rootScriptFolder = path.join(extensionContext.extensionPath, 'tmp', 'scripts');
84+
this.targetWidgetScriptsFolder = path.join(this._rootScriptFolder, 'nbextensions');
8385
this.createTargetWidgetScriptsFolder = this.fs
8486
.directoryExists(this.targetWidgetScriptsFolder)
8587
.then(async (exists) => {
@@ -108,30 +110,34 @@ export class IPyWidgetScriptSource implements IInteractiveWindowListener, ILocal
108110
* Copying into global workspace folder would also work, but over time this folder size could grow (in an unmanaged way).
109111
*/
110112
public async asWebviewUri(localResource: Uri): Promise<Uri> {
111-
if (this.notebookIdentity && !this.resourcesMappedToExtensionFolder.has(localResource.fsPath)) {
112-
const deferred = createDeferred<Uri>();
113-
this.resourcesMappedToExtensionFolder.set(localResource.fsPath, deferred.promise);
114-
try {
115-
// Create a file name such that it will be unique and consistent across VSC reloads.
116-
// Only if original file has been modified should we create a new copy of the sam file.
117-
const fileHash: string = await this.fs.getFileHash(localResource.fsPath);
118-
const uniqueFileName = sanitize(sha256().update(`${localResource.fsPath}${fileHash}`).digest('hex'));
119-
const targetFolder = await this.createTargetWidgetScriptsFolder;
120-
const mappedResource = Uri.file(
121-
path.join(targetFolder, `${uniqueFileName}${path.basename(localResource.fsPath)}`)
122-
);
123-
if (!(await this.fs.fileExists(mappedResource.fsPath))) {
124-
await this.fs.copyFile(localResource.fsPath, mappedResource.fsPath);
113+
// Make a copy of the local file if not already in the correct location
114+
if (!localResource.fsPath.startsWith(this._rootScriptFolder)) {
115+
if (this.notebookIdentity && !this.resourcesMappedToExtensionFolder.has(localResource.fsPath)) {
116+
const deferred = createDeferred<Uri>();
117+
this.resourcesMappedToExtensionFolder.set(localResource.fsPath, deferred.promise);
118+
try {
119+
// Create a file name such that it will be unique and consistent across VSC reloads.
120+
// Only if original file has been modified should we create a new copy of the sam file.
121+
const fileHash: string = await this.fs.getFileHash(localResource.fsPath);
122+
const uniqueFileName = sanitize(
123+
sha256().update(`${localResource.fsPath}${fileHash}`).digest('hex')
124+
);
125+
const targetFolder = await this.createTargetWidgetScriptsFolder;
126+
const mappedResource = Uri.file(
127+
path.join(targetFolder, `${uniqueFileName}${path.basename(localResource.fsPath)}`)
128+
);
129+
if (!(await this.fs.fileExists(mappedResource.fsPath))) {
130+
await this.fs.copyFile(localResource.fsPath, mappedResource.fsPath);
131+
}
132+
traceInfo(`Widget Script file ${localResource.fsPath} mapped to ${mappedResource.fsPath}`);
133+
deferred.resolve(mappedResource);
134+
} catch (ex) {
135+
traceError(`Failed to map widget Script file ${localResource.fsPath}`);
136+
deferred.reject(ex);
125137
}
126-
traceInfo(`Widget Script file ${localResource.fsPath} mapped to ${mappedResource.fsPath}`);
127-
deferred.resolve(mappedResource);
128-
} catch (ex) {
129-
traceError(`Failed to map widget Script file ${localResource.fsPath}`);
130-
deferred.reject(ex);
131138
}
139+
localResource = await this.resourcesMappedToExtensionFolder.get(localResource.fsPath)!;
132140
}
133-
localResource = await this.resourcesMappedToExtensionFolder.get(localResource.fsPath)!;
134-
135141
const key = localResource.toString();
136142
if (!this.uriConversionPromises.has(key)) {
137143
this.uriConversionPromises.set(key, createDeferred<Uri>());
@@ -144,6 +150,10 @@ export class IPyWidgetScriptSource implements IInteractiveWindowListener, ILocal
144150
return this.uriConversionPromises.get(key)!.promise;
145151
}
146152

153+
public get rootScriptFolder(): Uri {
154+
return Uri.file(this._rootScriptFolder);
155+
}
156+
147157
public dispose() {
148158
while (this.disposables.length) {
149159
this.disposables.shift()?.dispose(); // NOSONAR

src/client/datascience/ipywidgets/ipyWidgetScriptSourceProvider.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,14 @@ export class IPyWidgetScriptSourceProvider implements IWidgetScriptSourceProvide
161161

162162
// If we're allowed to use CDN providers, then use them, and use in order of preference.
163163
if (this.configuredScriptSources.length > 0) {
164-
scriptProviders.push(new CDNWidgetScriptSourceProvider(this.configurationSettings, this.httpClient));
164+
scriptProviders.push(
165+
new CDNWidgetScriptSourceProvider(
166+
this.configurationSettings,
167+
this.httpClient,
168+
this.localResourceUriConverter,
169+
this.fs
170+
)
171+
);
165172
}
166173
if (this.notebook.connection.localLaunch) {
167174
scriptProviders.push(

0 commit comments

Comments
 (0)