Merge pull request #2060 from kchadha/load-core-extension

Load core extensions synchronously
This commit is contained in:
Karishma Chadha 2019-03-28 14:05:16 -04:00 committed by GitHub
commit e89f5d0361
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 193 additions and 39 deletions

View file

@ -0,0 +1,45 @@
const BlockType = require('../extension-support/block-type');
/**
* An example core block implemented using the extension spec.
* This is not loaded as part of the core blocks in the VM but it is provided
* and used as part of tests.
*/
class Scratch3CoreExample {
constructor (runtime) {
/**
* The runtime instantiating this block package.
* @type {Runtime}
*/
this.runtime = runtime;
}
/**
* @returns {object} metadata for this extension and its blocks.
*/
getInfo () {
return {
id: 'coreExample',
name: 'CoreEx', // This string does not need to be translated as this extension is only used as an example.
blocks: [
{
opcode: 'exampleOpcode',
blockType: BlockType.REPORTER,
text: 'example block'
}
]
};
}
/**
* Example opcode just returns the name of the stage target.
* @returns {string} The name of the first target in the project.
*/
exampleOpcode () {
const stage = this.runtime.getTargetForStage();
return stage ? stage.getName() : 'no stage yet';
}
}
module.exports = Scratch3CoreExample;

View file

@ -35,6 +35,39 @@ class CentralDispatch extends SharedDispatch {
this.workers = []; this.workers = [];
} }
/**
* Synchronously call a particular method on a particular service provided locally.
* Calling this function on a remote service will fail.
* @param {string} service - the name of the service.
* @param {string} method - the name of the method.
* @param {*} [args] - the arguments to be copied to the method, if any.
* @returns {*} - the return value of the service method.
*/
callSync (service, method, ...args) {
const {provider, isRemote} = this._getServiceProvider(service);
if (provider) {
if (isRemote) {
throw new Error(`Cannot use 'callSync' on remote provider for service ${service}.`);
}
return provider[method].apply(provider, args);
}
throw new Error(`Provider not found for service: ${service}`);
}
/**
* Synchronously set a local object as the global provider of the specified service.
* WARNING: Any method on the provider can be called from any worker within the dispatch system.
* @param {string} service - a globally unique string identifying this service. Examples: 'vm', 'gui', 'extension9'.
* @param {object} provider - a local object which provides this service.
*/
setServiceSync (service, provider) {
if (this.services.hasOwnProperty(service)) {
log.warn(`Central dispatch replacing existing service provider for ${service}`);
}
this.services[service] = provider;
}
/** /**
* Set a local object as the global provider of the specified service. * Set a local object as the global provider of the specified service.
* WARNING: Any method on the provider can be called from any worker within the dispatch system. * WARNING: Any method on the provider can be called from any worker within the dispatch system.
@ -45,10 +78,7 @@ class CentralDispatch extends SharedDispatch {
setService (service, provider) { setService (service, provider) {
/** Return a promise for consistency with {@link WorkerDispatch#setService} */ /** Return a promise for consistency with {@link WorkerDispatch#setService} */
try { try {
if (this.services.hasOwnProperty(service)) { this.setServiceSync(service, provider);
log.warn(`Central dispatch replacing existing service provider for ${service}`);
}
this.services[service] = provider;
return Promise.resolve(); return Promise.resolve();
} catch (e) { } catch (e) {
return Promise.reject(e); return Promise.reject(e);

View file

@ -9,6 +9,10 @@ const BlockType = require('./block-type');
// TODO: change extension spec so that library info, including extension ID, can be collected through static methods // TODO: change extension spec so that library info, including extension ID, can be collected through static methods
const builtinExtensions = { const builtinExtensions = {
// This is an example that isn't loaded with the other core blocks,
// but serves as a reference for loading core blocks as extensions.
coreExample: () => require('../blocks/scratch3_core_example'),
// These are the non-core built-in extensions.
pen: () => require('../extensions/scratch3_pen'), pen: () => require('../extensions/scratch3_pen'),
wedo2: () => require('../extensions/scratch3_wedo2'), wedo2: () => require('../extensions/scratch3_wedo2'),
music: () => require('../extensions/scratch3_music'), music: () => require('../extensions/scratch3_music'),
@ -106,6 +110,30 @@ class ExtensionManager {
return this._loadedExtensions.has(extensionID); return this._loadedExtensions.has(extensionID);
} }
/**
* Synchronously load an internal extension (core or non-core) by ID. This call will
* fail if the provided id is not does not match an internal extension.
* @param {string} extensionId - the ID of an internal extension
*/
loadExtensionIdSync (extensionId) {
if (!builtinExtensions.hasOwnProperty(extensionId)) {
log.warn(`Could not find extension ${extensionId} in the built in extensions.`);
return;
}
/** @TODO dupe handling for non-builtin extensions. See commit 670e51d33580e8a2e852b3b038bb3afc282f81b9 */
if (this.isExtensionLoaded(extensionId)) {
const message = `Rejecting attempt to load a second extension with ID ${extensionId}`;
log.warn(message);
return;
}
const extension = builtinExtensions[extensionId]();
const extensionInstance = new extension(this.runtime);
const serviceName = this._registerInternalExtension(extensionInstance);
this._loadedExtensions.set(extensionId, serviceName);
}
/** /**
* Load an extension by URL or internal extension ID * Load an extension by URL or internal extension ID
* @param {string} extensionURL - the URL for the extension to load OR the ID of an internal extension * @param {string} extensionURL - the URL for the extension to load OR the ID of an internal extension
@ -117,14 +145,14 @@ class ExtensionManager {
if (this.isExtensionLoaded(extensionURL)) { if (this.isExtensionLoaded(extensionURL)) {
const message = `Rejecting attempt to load a second extension with ID ${extensionURL}`; const message = `Rejecting attempt to load a second extension with ID ${extensionURL}`;
log.warn(message); log.warn(message);
return Promise.reject(new Error(message)); return Promise.resolve();
} }
const extension = builtinExtensions[extensionURL](); const extension = builtinExtensions[extensionURL]();
const extensionInstance = new extension(this.runtime); const extensionInstance = new extension(this.runtime);
return this._registerInternalExtension(extensionInstance).then(serviceName => { const serviceName = this._registerInternalExtension(extensionInstance);
this._loadedExtensions.set(extensionURL, serviceName); this._loadedExtensions.set(extensionURL, serviceName);
}); return Promise.resolve();
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -148,7 +176,7 @@ class ExtensionManager {
dispatch.call('runtime', '_refreshExtensionPrimitives', info); dispatch.call('runtime', '_refreshExtensionPrimitives', info);
}) })
.catch(e => { .catch(e => {
log.error(`Failed to refresh buildtin extension primitives: ${JSON.stringify(e)}`); log.error(`Failed to refresh built-in extension primitives: ${JSON.stringify(e)}`);
}) })
); );
return Promise.all(allPromises); return Promise.all(allPromises);
@ -161,6 +189,15 @@ class ExtensionManager {
return [id, workerInfo.extensionURL]; return [id, workerInfo.extensionURL];
} }
/**
* Synchronously collect extension metadata from the specified service and begin the extension registration process.
* @param {string} serviceName - the name of the service hosting the extension.
*/
registerExtensionServiceSync (serviceName) {
const info = dispatch.callSync(serviceName, 'getInfo');
this._registerExtensionInfo(serviceName, info);
}
/** /**
* Collect extension metadata from the specified service and begin the extension registration process. * Collect extension metadata from the specified service and begin the extension registration process.
* @param {string} serviceName - the name of the service hosting the extension. * @param {string} serviceName - the name of the service hosting the extension.
@ -189,17 +226,15 @@ class ExtensionManager {
/** /**
* Register an internal (non-Worker) extension object * Register an internal (non-Worker) extension object
* @param {object} extensionObject - the extension object to register * @param {object} extensionObject - the extension object to register
* @returns {Promise} resolved once the extension is fully registered or rejected on failure * @returns {string} The name of the registered extension service
*/ */
_registerInternalExtension (extensionObject) { _registerInternalExtension (extensionObject) {
const extensionInfo = extensionObject.getInfo(); const extensionInfo = extensionObject.getInfo();
const fakeWorkerId = this.nextExtensionWorker++; const fakeWorkerId = this.nextExtensionWorker++;
const serviceName = `extension_${fakeWorkerId}_${extensionInfo.id}`; const serviceName = `extension_${fakeWorkerId}_${extensionInfo.id}`;
return dispatch.setService(serviceName, extensionObject) dispatch.setServiceSync(serviceName, extensionObject);
.then(() => { dispatch.callSync('extensions', 'registerExtensionServiceSync', serviceName);
dispatch.call('extensions', 'registerExtensionService', serviceName); return serviceName;
return serviceName;
});
} }
/** /**

View file

@ -151,6 +151,11 @@ class VirtualMachine extends EventEmitter {
this.extensionManager = new ExtensionManager(this.runtime); this.extensionManager = new ExtensionManager(this.runtime);
// Load core extensions
for (const id of CORE_EXTENSIONS) {
this.extensionManager.loadExtensionIdSync(id);
}
this.blockListener = this.blockListener.bind(this); this.blockListener = this.blockListener.bind(this);
this.flyoutBlockListener = this.flyoutBlockListener.bind(this); this.flyoutBlockListener = this.flyoutBlockListener.bind(this);
this.monitorBlockListener = this.monitorBlockListener.bind(this); this.monitorBlockListener = this.monitorBlockListener.bind(this);
@ -493,14 +498,6 @@ class VirtualMachine extends EventEmitter {
installTargets (targets, extensions, wholeProject) { installTargets (targets, extensions, wholeProject) {
const extensionPromises = []; const extensionPromises = [];
if (wholeProject) {
CORE_EXTENSIONS.forEach(extensionID => {
if (!this.extensionManager.isExtensionLoaded(extensionID)) {
extensionPromises.push(this.extensionManager.loadExtensionURL(extensionID));
}
});
}
extensions.extensionIDs.forEach(extensionID => { extensions.extensionIDs.forEach(extensionID => {
if (!this.extensionManager.isExtensionLoaded(extensionID)) { if (!this.extensionManager.isExtensionLoaded(extensionID)) {
const extensionURL = extensions.extensionURLs.get(extensionID) || extensionID; const extensionURL = extensions.extensionURLs.get(extensionID) || extensionID;

View file

@ -4,6 +4,9 @@ const Worker = require('tiny-worker');
const dispatch = require('../../src/dispatch/central-dispatch'); const dispatch = require('../../src/dispatch/central-dispatch');
const VirtualMachine = require('../../src/virtual-machine'); const VirtualMachine = require('../../src/virtual-machine');
const Sprite = require('../../src/sprites/sprite');
const RenderedTarget = require('../../src/sprites/rendered-target');
// By default Central Dispatch works with the Worker class built into the browser. Tell it to use TinyWorker instead. // By default Central Dispatch works with the Worker class built into the browser. Tell it to use TinyWorker instead.
dispatch.workerClass = Worker; dispatch.workerClass = Worker;
@ -52,23 +55,49 @@ test('internal extension', t => {
t.ok(extension.status.constructorCalled); t.ok(extension.status.constructorCalled);
t.notOk(extension.status.getInfoCalled); t.notOk(extension.status.getInfoCalled);
return vm.extensionManager._registerInternalExtension(extension).then(() => { vm.extensionManager._registerInternalExtension(extension);
t.ok(extension.status.getInfoCalled); t.ok(extension.status.getInfoCalled);
const func = vm.runtime.getOpcodeFunction('testInternalExtension_go'); const func = vm.runtime.getOpcodeFunction('testInternalExtension_go');
t.type(func, 'function'); t.type(func, 'function');
t.notOk(extension.status.goCalled); t.notOk(extension.status.goCalled);
func(); func();
t.ok(extension.status.goCalled); t.ok(extension.status.goCalled);
// There should be 2 menus - one is an array, one is the function to call. // There should be 2 menus - one is an array, one is the function to call.
t.equal(vm.runtime._blockInfo[0].menus.length, 2); t.equal(vm.runtime._blockInfo[0].menus.length, 2);
// First menu has 3 items. // First menu has 3 items.
t.equal( t.equal(
vm.runtime._blockInfo[0].menus[0].json.args0[0].options.length, 3); vm.runtime._blockInfo[0].menus[0].json.args0[0].options.length, 3);
// Second menu is a dynamic menu and therefore should be a function. // Second menu is a dynamic menu and therefore should be a function.
t.type( t.type(
vm.runtime._blockInfo[0].menus[1].json.args0[0].options, 'function'); vm.runtime._blockInfo[0].menus[1].json.args0[0].options, 'function');
});
t.end();
});
test('load sync', t => {
const vm = new VirtualMachine();
vm.extensionManager.loadExtensionIdSync('coreExample');
t.ok(vm.extensionManager.isExtensionLoaded('coreExample'));
t.equal(vm.runtime._blockInfo.length, 1);
t.equal(vm.runtime._blockInfo[0].blocks.length, 1);
t.type(vm.runtime._blockInfo[0].blocks[0].info, 'object');
t.equal(vm.runtime._blockInfo[0].blocks[0].info.opcode, 'exampleOpcode');
t.equal(vm.runtime._blockInfo[0].blocks[0].info.blockType, 'reporter');
// Test the opcode function
t.equal(vm.runtime._blockInfo[0].blocks[0].info.func(), 'no stage yet');
const sprite = new Sprite(null, vm.runtime);
sprite.name = 'Stage';
const stage = new RenderedTarget(sprite, vm.runtime);
stage.isStage = true;
vm.runtime.targets = [stage];
t.equal(vm.runtime._blockInfo[0].blocks[0].info.func(), 'Stage');
t.end();
}); });

View file

@ -62,3 +62,21 @@ test('remote', t => {
.then(() => runServiceTest('RemoteDispatchTest', t), e => t.fail(e)) .then(() => runServiceTest('RemoteDispatchTest', t), e => t.fail(e))
.then(() => dispatch._remoteCall(worker, 'dispatch', 'terminate'), e => t.fail(e)); .then(() => dispatch._remoteCall(worker, 'dispatch', 'terminate'), e => t.fail(e));
}); });
test('local, sync', t => {
dispatch.setServiceSync('SyncDispatchTest', new DispatchTestService());
const a = dispatch.callSync('SyncDispatchTest', 'returnFortyTwo');
t.equal(a, 42);
const b = dispatch.callSync('SyncDispatchTest', 'doubleArgument', 9);
t.equal(b, 18);
const c = dispatch.callSync('SyncDispatchTest', 'doubleArgument', 123);
t.equal(c, 246);
t.throws(() => dispatch.callSync('SyncDispatchTest', 'throwException'),
new Error('This is a test exception thrown by DispatchTest'));
t.end();
});