Index: binaries/data/mods/_test.scriptinterface/promises/simple.js =================================================================== --- /dev/null +++ binaries/data/mods/_test.scriptinterface/promises/simple.js @@ -0,0 +1,33 @@ +var test = 0; + +function incrementTest() +{ + test += 1; +} + +async function waitAndIncrement(promise) +{ + await promise; + incrementTest(); +} + +function runTest() +{ + var rsv; + let prom = new Promise((resolve, reject) => { + incrementTest(); + rsv = resolve; + }); + waitAndIncrement(prom); + TS_ASSERT_EQUALS(test, 1); + rsv(); + // At this point, waitAndIncrement is still not run, but is now free to run. + TS_ASSERT_EQUALS(test, 1); +} + +runTest(); + +function endTest() +{ + TS_ASSERT_EQUALS(test, 2); +} Index: binaries/data/mods/_test.scriptinterface/promises/threaded.js =================================================================== --- /dev/null +++ binaries/data/mods/_test.scriptinterface/promises/threaded.js @@ -0,0 +1,13 @@ +var res = 0; + +async function runSquare() { + res = await Engine.AsyncSquare(5); +} + +runSquare(); +// At this point the function is not run. +TS_ASSERT_EQUALS(res, 0); + +function endTest() { + TS_ASSERT_EQUALS(res, 5 * 5); +} Index: source/scriptinterface/Promises.h =================================================================== --- /dev/null +++ source/scriptinterface/Promises.h @@ -0,0 +1,143 @@ +/* Copyright (C) 2022 Wildfire Games. + * This file is part of 0 A.D. + * + * 0 A.D. is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * 0 A.D. is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with 0 A.D. If not, see . + */ + +#ifndef INCLUDED_SCRIPTINTERFACE_JOBQUEUE +#define INCLUDED_SCRIPTINTERFACE_JOBQUEUE + +#include "js/Promise.h" + +#include "scriptinterface/FunctionWrapper.h" +#include "scriptinterface/ScriptContext.h" +#include "scriptinterface/ScriptInterface.h" +#include "scriptinterface/ScriptRequest.h" +#include "scriptinterface/StructuredClone.h" + +#include "ps/Filesystem.h" +#include "ps/TaskManager.h" + +class ScriptInterface; + +namespace Script +{ +/** + * Spidermonkey has to handle debugger interruptions to the job queue, which is a rather complex topic (see header). + * We aren't going to care about that, only queuing jobs & running them. + */ +class JobQueue : public JS::JobQueue { +public: + JobQueue() {}; + + virtual ~JobQueue() = default; + + JSObject* getIncumbentGlobal(JSContext* cx) + { + return JS::CurrentGlobalOrNull(cx); + } + + bool enqueuePromiseJob(JSContext* cx, JS::HandleObject UNUSED(promise), JS::HandleObject job, JS::HandleObject UNUSED(allocationSite), JS::HandleObject UNUSED(incumbentGlobal)) + { + ScriptRequest rq(cx); + const_cast(rq.GetScriptInterface()).AddJob(job); + return true; + } + + virtual void runJobs(JSContext*) {}; + + virtual bool empty() const { return true; }; + +protected: + // This is used by the debugger-interruptible queue. + virtual js::UniquePtr saveJobQueue(JSContext*) { return nullptr; }; + +private: +}; + +/** + * Run a job in the thread pool. This returns a promise right away, and once the future completes will resolve the promise. + */ +template +JS::HandleValue RunAsPromise(const ScriptInterface& scriptInterface, Args... args) +{ + ScriptRequest rq(scriptInterface); + JS::RootedObject prom(rq.cx, JS::NewPromiseObject(rq.cx, nullptr)); + + // Return a function to type-erase the return type of the callable. + Future test = Threading::TaskManager::Instance().PushTask([args...]() -> std::function{ + auto ret = callable(args...); + return [ret] (const ScriptRequest& rq2) -> JS::HandleValue { + JS::RootedValue val(rq2.cx); + Script::ToJSVal(rq2, &val, ret); + return val; + }; + }); + JS::Heap promObject(prom.get()); + const_cast(scriptInterface).AddPromise(promObject, std::move(test)); + + JS::RootedValue promVal(rq.cx, JS::ObjectValue(*prom)); + // For some reason, this is needed of the returned object isn't interpreted as a promise + // despite there apparently being no pending exception anyways? + ScriptException::CatchPending(rq); + + return promVal; +} + +/** + * Run a job in the thread pool. This returns a promise right away, and once the future completes will resolve the promise. + * This takes a VfsPath to a javascript file & some input data, and runs the job in a separate JS Context. + * TODO: this is obviously massively slow compared to an efficient implementation. + */ +inline JS::HandleValue RunThreaded(const ScriptInterface& scriptInterface, std::wstring workerFile, JS::HandleValue callable) +{ + ScriptRequest rq(scriptInterface); + JS::RootedObject prom(rq.cx, JS::NewPromiseObject(rq.cx, nullptr)); + + Script::StructuredClone clone = Script::WriteStructuredClone(rq, callable); + // Return a function to type-erase the return type of the callable. + Future test = Threading::TaskManager::Instance().PushTask([workerFile, clone]() -> std::function{ + // TODO: this is absurdly inefficient & only used for demonstration purposes. + std::shared_ptr context = ScriptContext::CreateContext(); + ScriptInterface workerInterface("Engine", "worker context", context); + ScriptRequest rqWorker(workerInterface); + JS::RootedValue val(rqWorker.cx); + Script::ReadStructuredClone(rqWorker, clone, &val); + workerInterface.LoadGlobalScriptFile(VfsPath(workerFile)); + + JS::RootedValue global(rqWorker.cx, rqWorker.globalValue()); + JS::RootedValue runResult(rqWorker.cx); + ScriptFunction::Call(rqWorker, global, "run", &runResult, val); + Script::StructuredClone resultClone = Script::WriteStructuredClone(rqWorker, runResult); + + return [resultClone] (const ScriptRequest& rq2) -> JS::HandleValue { + JS::RootedValue resultVal(rq2.cx); + Script::ReadStructuredClone(rq2, resultClone, &resultVal); + return resultVal; + }; + }); + JS::Heap promObject(prom.get()); + const_cast(scriptInterface).AddPromise(promObject, std::move(test)); + + JS::RootedValue promVal(rq.cx, JS::ObjectValue(*prom)); + // For some reason, this is needed of the returned object isn't interpreted as a promise + // despite there apparently being no pending exception anyways? + ScriptException::CatchPending(rq); + + return promVal; +} + +} + +#endif // INCLUDED_SCRIPTINTERFACE_JOBQUEUE Index: source/scriptinterface/ScriptContext.h =================================================================== --- source/scriptinterface/ScriptContext.h +++ source/scriptinterface/ScriptContext.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2021 Wildfire Games. +/* Copyright (C) 2022 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -27,6 +27,10 @@ constexpr int DEFAULT_CONTEXT_SIZE = 16 * 1024 * 1024; constexpr int DEFAULT_HEAP_GROWTH_BYTES_GCTRIGGER = 2 * 1024 * 1024; +namespace Script { +class JobQueue; +} + /** * Abstraction around a SpiderMonkey JSContext. * @@ -86,6 +90,7 @@ private: JSContext* m_cx; + std::unique_ptr m_jobqueue; void PrepareZonesForIncrementalGC() const; std::list m_Realms; Index: source/scriptinterface/ScriptContext.cpp =================================================================== --- source/scriptinterface/ScriptContext.cpp +++ source/scriptinterface/ScriptContext.cpp @@ -1,4 +1,4 @@ -/* Copyright (C) 2021 Wildfire Games. +/* Copyright (C) 2022 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -25,6 +25,7 @@ #include "scriptinterface/ScriptExtraHeaders.h" #include "scriptinterface/ScriptEngine.h" #include "scriptinterface/ScriptInterface.h" +#include "scriptinterface/Promises.h" void GCSliceCallbackHook(JSContext* UNUSED(cx), JS::GCProgress progress, const JS::GCDescription& UNUSED(desc)) { @@ -122,6 +123,9 @@ JS::ContextOptionsRef(m_cx).setStrictMode(true); ScriptEngine::GetSingleton().RegisterContext(m_cx); + + m_jobqueue = std::make_unique(); + JS::SetJobQueue(m_cx, m_jobqueue.get()); } ScriptContext::~ScriptContext() Index: source/scriptinterface/ScriptInterface.h =================================================================== --- source/scriptinterface/ScriptInterface.h +++ source/scriptinterface/ScriptInterface.h @@ -24,6 +24,8 @@ #include "scriptinterface/ScriptRequest.h" #include "scriptinterface/ScriptTypes.h" +#include "ps/FutureForward.h" + #include ERROR_GROUP(Scripting); @@ -211,6 +213,11 @@ bool Eval(const char* code, JS::MutableHandleValue out) const; template bool Eval(const char* code, T& out) const; + void AddJob(JS::HandleObject job); + void RunJobs(); + void AddPromise(JS::Heap& promise, Future>&& future); + void WaitOnPendingPromises(); + /** * Calls the random number generator assigned to this ScriptInterface instance and returns the generated number. */ Index: source/scriptinterface/ScriptInterface.cpp =================================================================== --- source/scriptinterface/ScriptInterface.cpp +++ source/scriptinterface/ScriptInterface.cpp @@ -28,6 +28,7 @@ #include "lib/utf8.h" #include "ps/CLogger.h" #include "ps/Filesystem.h" +#include "ps/Future.h" #include "ps/Profile.h" #include @@ -54,6 +55,13 @@ ScriptInterface_impl(const char* nativeScopeName, const std::shared_ptr& context, JS::Compartment* compartment); ~ScriptInterface_impl(); + static void Trace(JSTracer* trc, void* data) + { + ScriptInterface_impl* m = reinterpret_cast(data); + for (auto& t : m->m_PromiseCompletions) + JS::TraceEdge(trc, &t.first, "Promise object"); + } + // Take care to keep this declaration before heap rooted members. Destructors of heap rooted // members have to be called before the context destructor. std::shared_ptr m_context; @@ -64,6 +72,9 @@ JS::PersistentRootedObject m_glob; // global scope object public: + std::vector, Future>>> m_PromiseCompletions; + std::vector m_jobs; + boost::rand48* m_rng; JS::PersistentRootedObject m_nativeScope; // native function scope object }; @@ -339,10 +350,17 @@ ScriptFunction::Register<&ProfileAttribute>(m_cx, m_nativeScope, "ProfileAttribute"); m_context->RegisterRealm(JS::GetObjectRealmOrNull(m_glob)); + + JS_AddExtraGCRootsTracer(m_cx, &Trace, this); } ScriptInterface_impl::~ScriptInterface_impl() { + JS_RemoveExtraGCRootsTracer(m_cx, &Trace, this); + + for (auto& t : m_PromiseCompletions) + t.second.Wait(); + m_context->UnRegisterRealm(JS::GetObjectRealmOrNull(m_glob)); } @@ -684,6 +702,50 @@ return LoadGlobalScript(path, code); } +void ScriptInterface::AddJob(JS::HandleObject job) +{ + ScriptRequest rq(this); + m->m_jobs.emplace_back(JS::PersistentRootedObject(rq.cx, job)); +} + +void ScriptInterface::AddPromise(JS::Heap& promise, Future>&& future) +{ + m->m_PromiseCompletions.emplace_back(promise, std::move(future)); +} + +#include "js/Promise.h" + +void ScriptInterface::RunJobs() +{ + ScriptRequest rq(this); + + for (auto& t : m->m_PromiseCompletions) + if (t.second.IsReady()) + { + JS::RootedValue ret(rq.cx, t.second.Get()(rq)); + JS::RootedObject promise(rq.cx, t.first); + // This actually enqueues a job below. + JS::ResolvePromise(rq.cx, promise, ret); + } + + JS::HandleValueArray args(JS::HandleValueArray::empty()); + JS::RootedValue rval(rq.cx); + JS::RootedValue globV(rq.cx, rq.globalValue()); + size_t i = 0; + for (i = 0; i < m->m_jobs.size(); ++i) + JS::Call(rq.cx, globV, m->m_jobs[i], args, &rval); + m->m_jobs.clear(); +} + +void ScriptInterface::WaitOnPendingPromises() +{ + ScriptRequest rq(this); + + for (auto& t : m->m_PromiseCompletions) + t.second.Wait(); + RunJobs(); +} + bool ScriptInterface::Eval(const char* code) const { ScriptRequest rq(this); Index: source/scriptinterface/tests/test_Promises.h =================================================================== --- /dev/null +++ source/scriptinterface/tests/test_Promises.h @@ -0,0 +1,84 @@ +/* Copyright (C) 2022 Wildfire Games. + * This file is part of 0 A.D. + * + * 0 A.D. is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * 0 A.D. is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with 0 A.D. If not, see . + */ + +#include "lib/self_test.h" + +#include "scriptinterface/FunctionWrapper.h" +#include "scriptinterface/JSON.h" +#include "scriptinterface/Object.h" +#include "scriptinterface/Promises.h" +#include "scriptinterface/ScriptInterface.h" +#include "scriptinterface/StructuredClone.h" + +#include "ps/CLogger.h" + +class TestPromises : public CxxTest::TestSuite +{ +public: + void test_simple_promises() + { + ScriptInterface script("Engine", "Test", g_ScriptContext); + ScriptTestSetup(script); + TS_ASSERT(script.LoadGlobalScriptFile(L"promises/simple.js")); + script.WaitOnPendingPromises(); + + ScriptRequest rq(script); + JS::RootedValue global(rq.cx, rq.globalValue()); + ScriptFunction::CallVoid(rq, global, "endTest"); + } + + static int AsyncSquare(int input) { + return input * input; + } + + void test_threaded_promises() + { + ScriptInterface script("Engine", "Test", g_ScriptContext); + ScriptRequest rq(script); + ScriptTestSetup(script); + ScriptFunction::Register>(rq, "AsyncSquare"); + TS_ASSERT(script.LoadGlobalScriptFile(L"promises/threaded.js")); + script.WaitOnPendingPromises(); + + JS::RootedValue global(rq.cx, rq.globalValue()); + ScriptFunction::CallVoid(rq, global, "endTest"); + } + + void test_worked_promises() + { + ScriptInterface script("Engine", "Test", g_ScriptContext); + ScriptRequest rq(script); + ScriptTestSetup(script); + ScriptFunction::Register(rq, "RunThreaded"); + TS_ASSERT(script.LoadGlobalScriptFile(L"promises/workerMain.js")); + script.WaitOnPendingPromises(); + + JS::RootedValue global(rq.cx, rq.globalValue()); + ScriptFunction::CallVoid(rq, global, "endTest"); + } + + void setUp() + { + g_VFS = CreateVfs(); + TS_ASSERT_OK(g_VFS->Mount(L"", DataDir() / "mods" / "_test.scriptinterface" / "", VFS_MOUNT_MUST_EXIST)); + } + + void tearDown() + { + g_VFS.reset(); + } +};