History

Merge pull request #9686 from humbletim/21114-part3

CR 21114 -- Implement modules/require support into hifi

Chris Collins committed 9 years ago

Commit 308c1342 parents 1723549 87d62ad29 files · +1538 −138Browse files

Showing the changes to libraries/script-engine/src/ScriptEngine.cpp (1 of 29 files). Show all

Unified · Split

libraries/script-engine/src/ScriptEngine.cpp+482−36
@@ -19,6 +19,9 @@ · 18 unmodified lines
1919 #include <QtCore/QThread>
2020 #include <QtCore/QRegularExpression>
2121
22+#include <QtCore/QFuture>
23+#include <QtConcurrent/QtConcurrentRun>
24+
2225 #include <QtWidgets/QMainWindow>
2326 #include <QtWidgets/QApplication>
2427
@@ -72,13 +75,18 @@ · 47 unmodified lines
7275
7376 #include "MIDIEvent.h"
7477
78+const QString ScriptEngine::_SETTINGS_ENABLE_EXTENDED_EXCEPTIONS {
79+ "com.highfidelity.experimental.enableExtendedJSExceptions"
80+};
81+
82+static const int MAX_MODULE_ID_LENGTH { 4096 };
83+static const int MAX_DEBUG_VALUE_LENGTH { 80 };
84+
7585 static const QScriptEngine::QObjectWrapOptions DEFAULT_QOBJECT_WRAP_OPTIONS =
7686 QScriptEngine::ExcludeDeleteLater | QScriptEngine::ExcludeChildObjects;
7787 static const QScriptValue::PropertyFlags READONLY_PROP_FLAGS { QScriptValue::ReadOnly | QScriptValue::Undeletable };
7888 static const QScriptValue::PropertyFlags READONLY_HIDDEN_PROP_FLAGS { READONLY_PROP_FLAGS | QScriptValue::SkipInEnumeration };
7989
80−
81−
8290 static const bool HIFI_AUTOREFRESH_FILE_SCRIPTS { true };
8391
8492 Q_DECLARE_METATYPE(QScriptEngine::FunctionSignature)
@@ -86,7 +94,7 @@ int functionSignatureMetaID = qRegisterMetaType<QScriptEngine::FunctionSignature · 1 unmodified line
8694
8795 Q_LOGGING_CATEGORY(scriptengineScript, "hifi.scriptengine.script")
8896
89−static QScriptValue debugPrint(QScriptContext* context, QScriptEngine* engine){
97+static QScriptValue debugPrint(QScriptContext* context, QScriptEngine* engine) {
9098 QString message = "";
9199 for (int i = 0; i < context->argumentCount(); i++) {
92100 if (i > 0) {
@@ -143,7 +151,7 @@ QString encodeEntityIdIntoEntityUrl(const QString& url, const QString& entityID) · 50 unmodified lines
143151 }
144152
145153 QString ScriptEngine::logException(const QScriptValue& exception) {
146− auto message = formatException(exception);
154+ auto message = formatException(exception, _enableExtendedJSExceptions.get());
147155 scriptErrorMessage(message);
148156 return message;
149157 }
@@ -335,7 +343,7 @@ void ScriptEngine::runInThread() { · 185 unmodified lines
335343 // The thread interface cannot live on itself, and we want to move this into the thread, so
336344 // the thread cannot have this as a parent.
337345 QThread* workerThread = new QThread();
338− workerThread->setObjectName(QString("Script Thread:") + getFilename());
346+ workerThread->setObjectName(QString("js:") + getFilename().replace("about:",""));
339347 moveToThread(workerThread);
340348
341349 // NOTE: If you connect any essential signals for proper shutdown or cleanup of
@@ -534,6 +542,40 @@ static QScriptValue createScriptableResourcePrototype(QScriptEngine* engine) { · 192 unmodified lines
534542 return prototype;
535543 }
536544
545+void ScriptEngine::resetModuleCache(bool deleteScriptCache) {
546+ if (QThread::currentThread() != thread()) {
547+ executeOnScriptThread([=]() { resetModuleCache(deleteScriptCache); });
548+ return;
549+ }
550+ auto jsRequire = globalObject().property("Script").property("require");
551+ auto cache = jsRequire.property("cache");
552+ auto cacheMeta = jsRequire.data();
553+
554+ if (deleteScriptCache) {
555+ QScriptValueIterator it(cache);
556+ while (it.hasNext()) {
557+ it.next();
558+ if (it.flags() & QScriptValue::SkipInEnumeration) {
559+ continue;
560+ }
561+ qCDebug(scriptengine) << "resetModuleCache(true) -- staging " << it.name() << " for cache reset at next require";
562+ cacheMeta.setProperty(it.name(), true);
563+ }
564+ }
565+ cache = newObject();
566+ if (!cacheMeta.isObject()) {
567+ cacheMeta = newObject();
568+ cacheMeta.setProperty("id", "Script.require.cacheMeta");
569+ cacheMeta.setProperty("type", "cacheMeta");
570+ jsRequire.setData(cacheMeta);
571+ }
572+ cache.setProperty("__created__", (double)QDateTime::currentMSecsSinceEpoch(), QScriptValue::SkipInEnumeration);
573+#if DEBUG_JS_MODULES
574+ cache.setProperty("__meta__", cacheMeta, READONLY_HIDDEN_PROP_FLAGS);
575+#endif
576+ jsRequire.setProperty("cache", cache, READONLY_PROP_FLAGS);
577+}
578+
537579 void ScriptEngine::init() {
538580 if (_isInitialized) {
539581 return; // only initialize once
@@ -587,6 +629,15 @@ void ScriptEngine::init() { · 47 unmodified lines
587629
588630 registerGlobalObject("Script", this);
589631
632+ {
633+ // set up Script.require.resolve and Script.require.cache
634+ auto Script = globalObject().property("Script");
635+ auto require = Script.property("require");
636+ auto resolve = Script.property("_requireResolve");
637+ require.setProperty("resolve", resolve, READONLY_PROP_FLAGS);
638+ resetModuleCache();
639+ }
640+
590641 registerGlobalObject("Audio", &AudioScriptingInterface::getInstance());
591642 registerGlobalObject("Entities", entityScriptingInterface.data());
592643 registerGlobalObject("Quat", &_quatLibrary);
@@ -859,6 +910,11 @@ void ScriptEngine::addEventHandler(const EntityItemID& entityID, const QString& · 266 unmodified lines
859910 handlersForEvent << handlerData; // Note that the same handler can be added many times. See removeEntityEventHandler().
860911 }
861912
913+// this is not redundant -- the version in BaseScriptEngine is specifically not Q_INVOKABLE
914+QScriptValue ScriptEngine::evaluateInClosure(const QScriptValue& closure, const QScriptProgram& program) {
915+ return BaseScriptEngine::evaluateInClosure(closure, program);
916+}
917+
862918 QScriptValue ScriptEngine::evaluate(const QString& sourceCode, const QString& fileName, int lineNumber) {
863919 if (DependencyManager::get<ScriptEngines>()->isStopped()) {
864920 return QScriptValue(); // bail early
@@ -881,29 +937,26 @@ QScriptValue ScriptEngine::evaluate(const QString& sourceCode, const QString& fi · 16 unmodified lines
881937 // Check syntax
882938 auto syntaxError = lintScript(sourceCode, fileName);
883939 if (syntaxError.isError()) {
884− if (isEvaluating()) {
885− currentContext()->throwValue(syntaxError);
886− } else {
940+ if (!isEvaluating()) {
887941 syntaxError.setProperty("detail", "evaluate");
888− emit unhandledException(syntaxError);
889942 }
943+ raiseException(syntaxError);
944+ maybeEmitUncaughtException("lint");
890945 return syntaxError;
891946 }
892947 QScriptProgram program { sourceCode, fileName, lineNumber };
893948 if (program.isNull()) {
894949 // can this happen?
895950 auto err = makeError("could not create QScriptProgram for " + fileName);
896− emit unhandledException(err);
951+ raiseException(err);
952+ maybeEmitUncaughtException("compile");
897953 return err;
898954 }
899955
900956 QScriptValue result;
901957 {
902958 result = BaseScriptEngine::evaluate(program);
903− if (!isEvaluating() && hasUncaughtException()) {
904− emit unhandledException(cloneUncaughtException(__FUNCTION__));
905− clearExceptions();
906− }
959+ maybeEmitUncaughtException("evaluate");
907960 }
908961 return result;
909962 }
@@ -926,10 +979,7 @@ void ScriptEngine::run() { · 16 unmodified lines
926979
927980 {
928981 evaluate(_scriptContents, _fileNameString);
929− if (!isEvaluating() && hasUncaughtException()) {
930− emit unhandledException(cloneUncaughtException(__FUNCTION__));
931− clearExceptions();
932− }
982+ maybeEmitUncaughtException(__FUNCTION__);
933983 }
934984 #ifdef _WIN32
935985 // VS13 does not sleep_until unless it uses the system_clock, see:
@@ -1300,11 +1350,361 @@ void ScriptEngine::print(const QString& message) { · 364 unmodified lines
13001350 emit printedMessage(message);
13011351 }
13021352
1353+// Script.require.resolve -- like resolvePath, but performs more validation and throws exceptions on invalid module identifiers (for consistency with Node.js)
1354+QString ScriptEngine::_requireResolve(const QString& moduleId, const QString& relativeTo) {
1355+ if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) {
1356+ return QString();
1357+ }
1358+ QUrl defaultScriptsLoc = defaultScriptsLocation();
1359+ QUrl url(moduleId);
1360+
1361+ auto displayId = moduleId;
1362+ if (displayId.length() > MAX_DEBUG_VALUE_LENGTH) {
1363+ displayId = displayId.mid(0, MAX_DEBUG_VALUE_LENGTH) + "...";
1364+ }
1365+ auto message = QString("Cannot find module '%1' (%2)").arg(displayId);
1366+
1367+ auto throwResolveError = [&](const QScriptValue& error) -> QString {
1368+ raiseException(error);
1369+ maybeEmitUncaughtException("require.resolve");
1370+ return QString();
1371+ };
1372+
1373+ // de-fuzz the input a little by restricting to rational sizes
1374+ auto idLength = url.toString().length();
1375+ if (idLength < 1 || idLength > MAX_MODULE_ID_LENGTH) {
1376+ auto details = QString("rejecting invalid module id size (%1 chars [1,%2])")
1377+ .arg(idLength).arg(MAX_MODULE_ID_LENGTH);
1378+ return throwResolveError(makeError(message.arg(details), "RangeError"));
1379+ }
1380+
1381+ // this regex matches: absolute, dotted or path-like URLs
1382+ // (ie: the kind of stuff ScriptEngine::resolvePath already handles)
1383+ QRegularExpression qualified ("^\\w+:|^/|^[.]{1,2}(/|$)");
1384+
1385+ // this is for module.require (which is a bound version of require that's always relative to the module path)
1386+ if (!relativeTo.isEmpty()) {
1387+ url = QUrl(relativeTo).resolved(moduleId);
1388+ url = resolvePath(url.toString());
1389+ } else if (qualified.match(moduleId).hasMatch()) {
1390+ url = resolvePath(moduleId);
1391+ } else {
1392+ // check if the moduleId refers to a "system" module
1393+ QString systemPath = defaultScriptsLoc.path();
1394+ QString systemModulePath = QString("%1/modules/%2.js").arg(systemPath).arg(moduleId);
1395+ url = defaultScriptsLoc;
1396+ url.setPath(systemModulePath);
1397+ if (!QFileInfo(url.toLocalFile()).isFile()) {
1398+ if (!moduleId.contains("./")) {
1399+ // the user might be trying to refer to a relative file without anchoring it
1400+ // let's do them a favor and test for that case -- offering specific advice if detected
1401+ auto unanchoredUrl = resolvePath("./" + moduleId);
1402+ if (QFileInfo(unanchoredUrl.toLocalFile()).isFile()) {
1403+ auto msg = QString("relative module ids must be anchored; use './%1' instead")
1404+ .arg(moduleId);
1405+ return throwResolveError(makeError(message.arg(msg)));
1406+ }
1407+ }
1408+ return throwResolveError(makeError(message.arg("system module not found")));
1409+ }
1410+ }
1411+
1412+ if (url.isRelative()) {
1413+ return throwResolveError(makeError(message.arg("could not resolve module id")));
1414+ }
1415+
1416+ // if it looks like a local file, verify that it's an allowed path and really a file
1417+ if (url.isLocalFile()) {
1418+ QFileInfo file(url.toLocalFile());
1419+ QUrl canonical = url;
1420+ if (file.exists()) {
1421+ canonical.setPath(file.canonicalFilePath());
1422+ }
1423+
1424+ bool disallowOutsideFiles = !defaultScriptsLocation().isParentOf(canonical) && !currentSandboxURL.isLocalFile();
1425+ if (disallowOutsideFiles && !PathUtils::isDescendantOf(canonical, currentSandboxURL)) {
1426+ return throwResolveError(makeError(message.arg(
1427+ QString("path '%1' outside of origin script '%2' '%3'")
1428+ .arg(PathUtils::stripFilename(url))
1429+ .arg(PathUtils::stripFilename(currentSandboxURL))
1430+ .arg(canonical.toString())
1431+ )));
1432+ }
1433+ if (!file.exists()) {
1434+ return throwResolveError(makeError(message.arg("path does not exist: " + url.toLocalFile())));
1435+ }
1436+ if (!file.isFile()) {
1437+ return throwResolveError(makeError(message.arg("path is not a file: " + url.toLocalFile())));
1438+ }
1439+ }
1440+
1441+ maybeEmitUncaughtException(__FUNCTION__);
1442+ return url.toString();
1443+}
1444+
1445+// retrieves the current parent module from the JS scope chain
1446+QScriptValue ScriptEngine::currentModule() {
1447+ if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) {
1448+ return unboundNullValue();
1449+ }
1450+ auto jsRequire = globalObject().property("Script").property("require");
1451+ auto cache = jsRequire.property("cache");
1452+ auto candidate = QScriptValue();
1453+ for (auto c = currentContext(); c && !candidate.isObject(); c = c->parentContext()) {
1454+ QScriptContextInfo contextInfo { c };
1455+ candidate = cache.property(contextInfo.fileName());
1456+ }
1457+ if (!candidate.isObject()) {
1458+ return QScriptValue();
1459+ }
1460+ return candidate;
1461+}
1462+
1463+// replaces or adds "module" to "parent.children[]" array
1464+// (for consistency with Node.js and userscript cache invalidation without "cache busters")
1465+bool ScriptEngine::registerModuleWithParent(const QScriptValue& module, const QScriptValue& parent) {
1466+ auto children = parent.property("children");
1467+ if (children.isArray()) {
1468+ auto key = module.property("id");
1469+ auto length = children.property("length").toInt32();
1470+ for (int i = 0; i < length; i++) {
1471+ if (children.property(i).property("id").strictlyEquals(key)) {
1472+ qCDebug(scriptengine_module) << key.toString() << " updating parent.children[" << i << "] = module";
1473+ children.setProperty(i, module);
1474+ return true;
1475+ }
1476+ }
1477+ qCDebug(scriptengine_module) << key.toString() << " appending parent.children[" << length << "] = module";
1478+ children.setProperty(length, module);
1479+ return true;
1480+ } else if (parent.isValid()) {
1481+ qCDebug(scriptengine_module) << "registerModuleWithParent -- unrecognized parent" << parent.toVariant().toString();
1482+ }
1483+ return false;
1484+}
1485+
1486+// creates a new JS "module" Object with default metadata properties
1487+QScriptValue ScriptEngine::newModule(const QString& modulePath, const QScriptValue& parent) {
1488+ auto closure = newObject();
1489+ auto exports = newObject();
1490+ auto module = newObject();
1491+ qCDebug(scriptengine_module) << "newModule" << modulePath << parent.property("filename").toString();
1492+
1493+ closure.setProperty("module", module, READONLY_PROP_FLAGS);
1494+
1495+ // note: this becomes the "exports" free variable, so should not be set read only
1496+ closure.setProperty("exports", exports);
1497+
1498+ // make the closure available to module instantiation
1499+ module.setProperty("__closure__", closure, READONLY_HIDDEN_PROP_FLAGS);
1500+
1501+ // for consistency with Node.js Module
1502+ module.setProperty("id", modulePath, READONLY_PROP_FLAGS);
1503+ module.setProperty("filename", modulePath, READONLY_PROP_FLAGS);
1504+ module.setProperty("exports", exports); // not readonly
1505+ module.setProperty("loaded", false, READONLY_PROP_FLAGS);
1506+ module.setProperty("parent", parent, READONLY_PROP_FLAGS);
1507+ module.setProperty("children", newArray(), READONLY_PROP_FLAGS);
1508+
1509+ // module.require is a bound version of require that always resolves relative to that module's path
1510+ auto boundRequire = QScriptEngine::evaluate("(function(id) { return Script.require(Script.require.resolve(id, this.filename)); })", "(boundRequire)");
1511+ module.setProperty("require", boundRequire, READONLY_PROP_FLAGS);
1512+
1513+ return module;
1514+}
1515+
1516+// synchronously fetch a module's source code using BatchLoader
1517+QVariantMap ScriptEngine::fetchModuleSource(const QString& modulePath, const bool forceDownload) {
1518+ using UrlMap = QMap<QUrl, QString>;
1519+ auto scriptCache = DependencyManager::get<ScriptCache>();
1520+ QVariantMap req;
1521+ qCDebug(scriptengine_module) << "require.fetchModuleSource: " << QUrl(modulePath).fileName() << QThread::currentThread();
1522+
1523+ auto onload = [=, &req](const UrlMap& data, const UrlMap& _status) {
1524+ auto url = modulePath;
1525+ auto status = _status[url];
1526+ auto contents = data[url];
1527+ qCDebug(scriptengine_module) << "require.fetchModuleSource.onload: " << QUrl(url).fileName() << status << QThread::currentThread();
1528+ if (isStopping()) {
1529+ req["status"] = "Stopped";
1530+ req["success"] = false;
1531+ } else {
1532+ req["url"] = url;
1533+ req["status"] = status;
1534+ req["success"] = ScriptCache::isSuccessStatus(status);
1535+ req["contents"] = contents;
1536+ }
1537+ };
1538+
1539+ if (forceDownload) {
1540+ qCDebug(scriptengine_module) << "require.requestScript -- clearing cache for" << modulePath;
1541+ scriptCache->deleteScript(modulePath);
1542+ }
1543+ BatchLoader* loader = new BatchLoader(QList<QUrl>({ modulePath }));
1544+ connect(loader, &BatchLoader::finished, this, onload);
1545+ connect(this, &QObject::destroyed, loader, &QObject::deleteLater);
1546+ // fail faster? (since require() blocks the engine thread while resolving dependencies)
1547+ const int MAX_RETRIES = 1;
1548+
1549+ loader->start(MAX_RETRIES);
1550+
1551+ if (!loader->isFinished()) {
1552+ QTimer monitor;
1553+ QEventLoop loop;
1554+ QObject::connect(loader, &BatchLoader::finished, this, [this, &monitor, &loop]{
1555+ monitor.stop();
1556+ loop.quit();
1557+ });
1558+
1559+ // this helps detect the case where stop() is invoked during the download
1560+ // but not seen in time to abort processing in onload()...
1561+ connect(&monitor, &QTimer::timeout, this, [this, &loop, &loader]{
1562+ if (isStopping()) {
1563+ loop.exit(-1);
1564+ }
1565+ });
1566+ monitor.start(500);
1567+ loop.exec();
1568+ }
1569+ loader->deleteLater();
1570+ return req;
1571+}
1572+
1573+// evaluate a pending module object using the fetched source code
1574+QScriptValue ScriptEngine::instantiateModule(const QScriptValue& module, const QString& sourceCode) {
1575+ QScriptValue result;
1576+ auto modulePath = module.property("filename").toString();
1577+ auto closure = module.property("__closure__");
1578+
1579+ qCDebug(scriptengine_module) << QString("require.instantiateModule: %1 / %2 bytes")
1580+ .arg(QUrl(modulePath).fileName()).arg(sourceCode.length());
1581+
1582+ if (module.property("content-type").toString() == "application/json") {
1583+ qCDebug(scriptengine_module) << "... parsing as JSON";
1584+ closure.setProperty("__json", sourceCode);
1585+ result = evaluateInClosure(closure, { "module.exports = JSON.parse(__json)", modulePath });
1586+ } else {
1587+ // scoped vars for consistency with Node.js
1588+ closure.setProperty("require", module.property("require"));
1589+ closure.setProperty("__filename", modulePath, READONLY_HIDDEN_PROP_FLAGS);
1590+ closure.setProperty("__dirname", QString(modulePath).replace(QRegExp("/[^/]*$"), ""), READONLY_HIDDEN_PROP_FLAGS);
1591+ result = evaluateInClosure(closure, { sourceCode, modulePath });
1592+ }
1593+ maybeEmitUncaughtException(__FUNCTION__);
1594+ return result;
1595+}
1596+
1597+// CommonJS/Node.js like require/module support
1598+QScriptValue ScriptEngine::require(const QString& moduleId) {
1599+ qCDebug(scriptengine_module) << "ScriptEngine::require(" << moduleId.left(MAX_DEBUG_VALUE_LENGTH) << ")";
1600+ if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) {
1601+ return unboundNullValue();
1602+ }
1603+
1604+ auto jsRequire = globalObject().property("Script").property("require");
1605+ auto cacheMeta = jsRequire.data();
1606+ auto cache = jsRequire.property("cache");
1607+ auto parent = currentModule();
1608+
1609+ auto throwModuleError = [&](const QString& modulePath, const QScriptValue& error) {
1610+ cache.setProperty(modulePath, nullValue());
1611+ if (!error.isNull()) {
1612+#ifdef DEBUG_JS_MODULES
1613+ qCWarning(scriptengine_module) << "throwing module error:" << error.toString() << modulePath << error.property("stack").toString();
1614+#endif
1615+ raiseException(error);
1616+ }
1617+ maybeEmitUncaughtException("module");
1618+ return unboundNullValue();
1619+ };
1620+
1621+ // start by resolving the moduleId into a fully-qualified path/URL
1622+ QString modulePath = _requireResolve(moduleId);
1623+ if (modulePath.isNull() || hasUncaughtException()) {
1624+ // the resolver already threw an exception -- bail early
1625+ maybeEmitUncaughtException(__FUNCTION__);
1626+ return unboundNullValue();
1627+ }
1628+
1629+ // check the resolved path against the cache
1630+ auto module = cache.property(modulePath);
1631+
1632+ // modules get cached in `Script.require.cache` and (similar to Node.js) users can access it
1633+ // to inspect particular entries and invalidate them by deleting the key:
1634+ // `delete Script.require.cache[Script.require.resolve(moduleId)];`
1635+
1636+ // cacheMeta is just used right now to tell deleted keys apart from undefined ones
1637+ bool invalidateCache = module.isUndefined() && cacheMeta.property(moduleId).isValid();
1638+
1639+ // reset the cacheMeta record so invalidation won't apply next time, even if the module fails to load
1640+ cacheMeta.setProperty(modulePath, QScriptValue());
1641+
1642+ auto exports = module.property("exports");
1643+ if (!invalidateCache && exports.isObject()) {
1644+ // we have found a cached module -- just need to possibly register it with current parent
1645+ qCDebug(scriptengine_module) << QString("require - using cached module '%1' for '%2' (loaded: %3)")
1646+ .arg(modulePath).arg(moduleId).arg(module.property("loaded").toString());
1647+ registerModuleWithParent(module, parent);
1648+ maybeEmitUncaughtException("cached module");
1649+ return exports;
1650+ }
1651+
1652+ // bootstrap / register new empty module
1653+ module = newModule(modulePath, parent);
1654+ registerModuleWithParent(module, parent);
1655+
1656+ // add it to the cache (this is done early so any cyclic dependencies pick up)
1657+ cache.setProperty(modulePath, module);
1658+
1659+ // download the module source
1660+ auto req = fetchModuleSource(modulePath, invalidateCache);
1661+
1662+ if (!req.contains("success") || !req["success"].toBool()) {
1663+ auto error = QString("error retrieving script (%1)").arg(req["status"].toString());
1664+ return throwModuleError(modulePath, error);
1665+ }
1666+
1667+#if DEBUG_JS_MODULES
1668+ qCDebug(scriptengine_module) << "require.loaded: " <<
1669+ QUrl(req["url"].toString()).fileName() << req["status"].toString();
1670+#endif
1671+
1672+ auto sourceCode = req["contents"].toString();
1673+
1674+ if (QUrl(modulePath).fileName().endsWith(".json", Qt::CaseInsensitive)) {
1675+ module.setProperty("content-type", "application/json");
1676+ } else {
1677+ module.setProperty("content-type", "application/javascript");
1678+ }
1679+
1680+ // evaluate the module
1681+ auto result = instantiateModule(module, sourceCode);
1682+
1683+ if (result.isError() && !result.strictlyEquals(module.property("exports"))) {
1684+ qCWarning(scriptengine_module) << "-- result.isError --" << result.toString();
1685+ return throwModuleError(modulePath, result);
1686+ }
1687+
1688+ // mark as fully-loaded
1689+ module.setProperty("loaded", true, READONLY_PROP_FLAGS);
1690+
1691+ // set up a new reference point for detecting cache key deletion
1692+ cacheMeta.setProperty(modulePath, module);
1693+
1694+ qCDebug(scriptengine_module) << "//ScriptEngine::require(" << moduleId << ")";
1695+
1696+ maybeEmitUncaughtException(__FUNCTION__);
1697+ return module.property("exports");
1698+}
1699+
13031700 // If a callback is specified, the included files will be loaded asynchronously and the callback will be called
13041701 // when all of the files have finished loading.
13051702 // If no callback is specified, the included files will be loaded synchronously and will block execution until
13061703 // all of the files have finished loading.
13071704 void ScriptEngine::include(const QStringList& includeFiles, QScriptValue callback) {
1705+ if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) {
1706+ return;
1707+ }
13081708 if (DependencyManager::get<ScriptEngines>()->isStopped()) {
13091709 scriptWarningMessage("Script.include() while shutting down is ignored... includeFiles:"
13101710 + includeFiles.join(",") + "parent script:" + getFilename());
@@ -1367,7 +1767,7 @@ void ScriptEngine::include(const QStringList& includeFiles, QScriptValue callbac · 56 unmodified lines
13671767
13681768 doWithEnvironment(capturedEntityIdentifier, capturedSandboxURL, operation);
13691769 if (hasUncaughtException()) {
1370− emit unhandledException(cloneUncaughtException(__FUNCTION__));
1770+ emit unhandledException(cloneUncaughtException("evaluateInclude"));
13711771 clearExceptions();
13721772 }
13731773 } else {
@@ -1414,6 +1814,9 @@ void ScriptEngine::include(const QString& includeFile, QScriptValue callback) { · 40 unmodified lines
14141814 // as a stand-alone script. To accomplish this, the ScriptEngine class just emits a signal which
14151815 // the Application or other context will connect to in order to know to actually load the script
14161816 void ScriptEngine::load(const QString& loadFile) {
1817+ if (!IS_THREADSAFE_INVOCATION(thread(), __FUNCTION__)) {
1818+ return;
1819+ }
14171820 if (DependencyManager::get<ScriptEngines>()->isStopped()) {
14181821 scriptWarningMessage("Script.load() while shutting down is ignored... loadFile:"
14191822 + loadFile + "parent script:" + getFilename());
@@ -1483,6 +1886,52 @@ void ScriptEngine::updateEntityScriptStatus(const EntityItemID& entityID, const · 63 unmodified lines
14831886 emit entityScriptDetailsUpdated();
14841887 }
14851888
1889+QVariant ScriptEngine::cloneEntityScriptDetails(const EntityItemID& entityID) {
1890+ static const QVariant NULL_VARIANT { qVariantFromValue((QObject*)nullptr) };
1891+ QVariantMap map;
1892+ if (entityID.isNull()) {
1893+ // TODO: find better way to report JS Error across thread/process boundaries
1894+ map["isError"] = true;
1895+ map["errorInfo"] = "Error: getEntityScriptDetails -- invalid entityID";
1896+ } else {
1897+#ifdef DEBUG_ENTITY_STATES
1898+ qDebug() << "cloneEntityScriptDetails" << entityID << QThread::currentThread();
1899+#endif
1900+ EntityScriptDetails scriptDetails;
1901+ if (getEntityScriptDetails(entityID, scriptDetails)) {
1902+#ifdef DEBUG_ENTITY_STATES
1903+ qDebug() << "gotEntityScriptDetails" << scriptDetails.status << QThread::currentThread();
1904+#endif
1905+ map["isRunning"] = isEntityScriptRunning(entityID);
1906+ map["status"] = EntityScriptStatus_::valueToKey(scriptDetails.status).toLower();
1907+ map["errorInfo"] = scriptDetails.errorInfo;
1908+ map["entityID"] = entityID.toString();
1909+#ifdef DEBUG_ENTITY_STATES
1910+ {
1911+ auto debug = QVariantMap();
1912+ debug["script"] = scriptDetails.scriptText;
1913+ debug["scriptObject"] = scriptDetails.scriptObject.toVariant();
1914+ debug["lastModified"] = (qlonglong)scriptDetails.lastModified;
1915+ debug["sandboxURL"] = scriptDetails.definingSandboxURL;
1916+ map["debug"] = debug;
1917+ }
1918+#endif
1919+ } else {
1920+#ifdef DEBUG_ENTITY_STATES
1921+ qDebug() << "!gotEntityScriptDetails" << QThread::currentThread();
1922+#endif
1923+ map["isError"] = true;
1924+ map["errorInfo"] = "Entity script details unavailable";
1925+ map["entityID"] = entityID.toString();
1926+ }
1927+ }
1928+ return map;
1929+}
1930+
1931+QFuture<QVariant> ScriptEngine::getLocalEntityScriptDetails(const EntityItemID& entityID) {
1932+ return QtConcurrent::run(this, &ScriptEngine::cloneEntityScriptDetails, entityID);
1933+}
1934+
14861935 bool ScriptEngine::getEntityScriptDetails(const EntityItemID& entityID, EntityScriptDetails &details) const {
14871936 auto it = _entityScripts.constFind(entityID);
14881937 if (it == _entityScripts.constEnd()) {
@@ -1621,10 +2070,10 @@ void ScriptEngine::loadEntityScript(const EntityItemID& entityID, const QString& · 132 unmodified lines
16212070
16222071 auto scriptCache = DependencyManager::get<ScriptCache>();
16232072 // note: see EntityTreeRenderer.cpp for shared pointer lifecycle management
1624− QWeakPointer<ScriptEngine> weakRef(sharedFromThis());
2073+ QWeakPointer<BaseScriptEngine> weakRef(sharedFromThis());
16252074 scriptCache->getScriptContents(entityScript,
16262075 [this, weakRef, entityScript, entityID](const QString& url, const QString& contents, bool isURL, bool success, const QString& status) {
1627− QSharedPointer<ScriptEngine> strongRef(weakRef);
2076+ QSharedPointer<BaseScriptEngine> strongRef(weakRef);
16282077 if (!strongRef) {
16292078 qCWarning(scriptengine) << "loadEntityScript.contentAvailable -- ScriptEngine was deleted during getScriptContents!!";
16302079 return;
@@ -1743,13 +2192,12 @@ void ScriptEngine::entityScriptContentAvailable(const EntityItemID& entityID, co · 112 unmodified lines
17432192 timeout.setSingleShot(true);
17442193 timeout.start(SANDBOX_TIMEOUT);
17452194 connect(&timeout, &QTimer::timeout, [&sandbox, SANDBOX_TIMEOUT, scriptOrURL]{
1746− auto context = sandbox.currentContext();
1747− if (context) {
17482195 qCDebug(scriptengine) << "ScriptEngine::entityScriptContentAvailable timeout(" << scriptOrURL << ")";
17492196
17502197 // Guard against infinite loops and non-performant code
1751− context->throwError(QString("Timed out (entity constructors are limited to %1ms)").arg(SANDBOX_TIMEOUT));
1752− }
2198+ sandbox.raiseException(
2199+ sandbox.makeError(QString("Timed out (entity constructors are limited to %1ms)").arg(SANDBOX_TIMEOUT))
2200+ );
17532201 });
17542202
17552203 testConstructor = sandbox.evaluate(program);
@@ -1765,7 +2213,7 @@ void ScriptEngine::entityScriptContentAvailable(const EntityItemID& entityID, co · 9 unmodified lines
17652213 if (exception.isError()) {
17662214 // create a local copy using makeError to decouple from the sandbox engine
17672215 exception = makeError(exception);
1768− setError(formatException(exception), EntityScriptStatus::ERROR_RUNNING_SCRIPT);
2216+ setError(formatException(exception, _enableExtendedJSExceptions.get()), EntityScriptStatus::ERROR_RUNNING_SCRIPT);
17692217 emit unhandledException(exception);
17702218 return;
17712219 }
@@ -1777,9 +2225,8 @@ void ScriptEngine::entityScriptContentAvailable(const EntityItemID& entityID, co · 5 unmodified lines
17772225 testConstructorType = "empty";
17782226 }
17792227 QString testConstructorValue = testConstructor.toString();
1780− const int maxTestConstructorValueSize = 80;
1781− if (testConstructorValue.size() > maxTestConstructorValueSize) {
1782− testConstructorValue = testConstructorValue.mid(0, maxTestConstructorValueSize) + "...";
2228+ if (testConstructorValue.size() > MAX_DEBUG_VALUE_LENGTH) {
2229+ testConstructorValue = testConstructorValue.mid(0, MAX_DEBUG_VALUE_LENGTH) + "...";
17832230 }
17842231 auto message = QString("failed to load entity script -- expected a function, got %1, %2")
17852232 .arg(testConstructorType).arg(testConstructorValue);
@@ -1817,7 +2264,7 @@ void ScriptEngine::entityScriptContentAvailable(const EntityItemID& entityID, co · 31 unmodified lines
18172264
18182265 if (entityScriptObject.isError()) {
18192266 auto exception = entityScriptObject;
1820− setError(formatException(exception), EntityScriptStatus::ERROR_RUNNING_SCRIPT);
2267+ setError(formatException(exception, _enableExtendedJSExceptions.get()), EntityScriptStatus::ERROR_RUNNING_SCRIPT);
18212268 emit unhandledException(exception);
18222269 return;
18232270 }
@@ -1861,10 +2308,12 @@ void ScriptEngine::unloadEntityScript(const EntityItemID& entityID, bool shouldR · 37 unmodified lines
18612308 const EntityScriptDetails &oldDetails = _entityScripts[entityID];
18622309 if (isEntityScriptRunning(entityID)) {
18632310 callEntityScriptMethod(entityID, "unload");
1864− } else {
2311+ }
2312+#ifdef DEBUG_ENTITY_STATES
2313+ else {
18652314 qCDebug(scriptengine) << "unload called while !running" << entityID << oldDetails.status;
18662315 }
1867−
2316+#endif
18682317 if (shouldRemoveFromMap) {
18692318 // this was a deleted entity, we've been asked to remove it from the map
18702319 _entityScripts.remove(entityID);
@@ -1956,10 +2405,7 @@ void ScriptEngine::doWithEnvironment(const EntityItemID& entityID, const QUrl& s · 85 unmodified lines
19562405 #else
19572406 operation();
19582407 #endif
1959− if (!isEvaluating() && hasUncaughtException()) {
1960− emit unhandledException(cloneUncaughtException(!entityID.isNull() ? entityID.toString() : __FUNCTION__));
1961− clearExceptions();
1962− }
2408+ maybeEmitUncaughtException(!entityID.isNull() ? entityID.toString() : __FUNCTION__);
19632409 currentEntityIdentifier = oldIdentifier;
19642410 currentSandboxURL = oldSandboxURL;
19652411 }