From 14d165c1e51121680c3df5751855cbd4f461fc81 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Fri, 16 Oct 2026 01:39:26 +1300 Subject: [PATCH 1/6] New version. --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index f800d458e..cac73a68b 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.20261016.0 +1.20261016.1 From 295bf21ef628becbeaa554db5bcba63eaa60d336 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Fri, 16 Oct 2026 01:55:55 +1300 Subject: [PATCH 2/6] Centralise Emscripten threading flags in JS bindings Remove the compile/link flags (-matomics, -mbulk-memory, -pthread, -sSHARED_MEMORY=1, -sALLOW_TABLE_GROWTH) that were duplicated in src/CMakeLists.txt. They are now supplied by src/bindings/javascript/CMakeLists.txt, which also passes them to the third-party library build. Note that -sALLOW_TABLE_GROWTH is part of the linker flags set there. -pthread stays in the link options. Update the explanatory comments in CMakeLists.txt and the note in file.cpp. Threading and shared memory are now supported, so the file.cpp comment only mentions ALLOW_MEMORY_GROWTH as a risk. --- src/CMakeLists.txt | 41 +++++++++++--------------- src/bindings/javascript/CMakeLists.txt | 5 +++- src/bindings/javascript/file.cpp | 5 ++-- 3 files changed, 24 insertions(+), 27 deletions(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8efff70a6..d92e8084d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -364,47 +364,42 @@ if(EMSCRIPTEN) ${HEADER_FILES}) # Set the flags for our generated .js and .wasm files. - # Note #1: for compiling, we use -O3 to get the best performance, -matomics to enable atomic operations, - # -mbulk-memory to enable bulk memory operations, -pthread to enable native threading support, - # -sSHARED_MEMORY=1 to enable shared memory support, and -DNDEBUG to disable debugging information (it is - # not needed in production). - # Note #2: for linking, we use -O2 (rather than -O3) since our compressed .wasm file is then smaller with no loss of + # Note #1: our other C/C++ and linker flags (e.g., to enable threading, shared memory, SIMD, and WebAssembly + # exception handling) are those that src/bindings/javascript/CMakeLists.txt passes on to the Emscripten + # build that it kicks off (see the note above about building our third-party libraries). + # Note #2: for compiling, we use -O3 to get the best performance and -DNDEBUG to disable assertions (they are not + # needed in production). + # Note #3: for linking, we use -O2 (rather than -O3) since our compressed .wasm file is then smaller with no loss of # performance (and it links faster), -flto to use the LTO version of Emscripten's system libraries and to # optimise LLVM, which we build as LLVM bitcode (see src/3rdparty/LLVM/CMakeLists.txt; our other object - # files are not LTO ones), -pthread to enable native threading support, -sALLOW_TABLE_GROWTH to allow the - # WebAssembly table to grow, -sASSERTIONS=0 to disable assertions (they are not needed in production), - # -sEXPORT_ES6=1 to export ES6 modules, -sEXPORTED_FUNCTIONS to export the functions we need (i.e. those - # needed by the WebAssembly modules that we generate for our models, see initialiseWorkerWasmJS()), - # -sEXPORTED_RUNTIME_METHODS to export the runtime methods we need, -sINITIAL_MEMORY=2GB to have enough - # memory for "big" models, -sMODULARIZE=1 to generate a module factory function, -sSHARED_MEMORY=1 to - # enable shared memory support, -sSTACK_SIZE=8MB to reserve a reasonable amount of memory for the stack at - # startup (which is needed for "big" models), --bind to generate the necessary Embind bindings, and - # --emit-tsd to generate a TypeScript type definitions file. - # Note #3: for linking, we used to have -sALLOW_MEMORY_GROWTH=1, -sINITIAL_MEMORY=256MB, and -sMAXIMUM_MEMORY=2GB, - # but we now support threading and to allow memory growth might non-WASM code to run slowly. So, now, we - # use -sINITIAL_MEMORY=2GB and don't allow memory growth. + # files are not LTO ones), -pthread to enable native threading support, -sASSERTIONS=0 to disable + # assertions (they are not needed in production), -sEXPORT_ES6=1 to export ES6 modules, + # -sEXPORTED_FUNCTIONS to export the functions we need (i.e. those needed by the WebAssembly modules that + # we generate for our models, see initialiseWorkerWasmJS()), -sEXPORTED_RUNTIME_METHODS to export the + # runtime methods we need, -sINITIAL_MEMORY=2GB to have enough memory for "big" models, -sMODULARIZE=1 to + # generate a module factory function, -sSTACK_SIZE=8MB to reserve a reasonable amount of memory for the + # stack of each thread (which is needed for "big" models), --bind to generate the necessary Embind + # bindings, and --emit-tsd to generate a TypeScript type definitions file. + # Note #4: for linking, we used to have -sALLOW_MEMORY_GROWTH=1, -sINITIAL_MEMORY=256MB, and -sMAXIMUM_MEMORY=2GB, + # but we now support threading, which means that our memory is shared, and to allow shared memory to grow + # would make JavaScript's accesses to it (e.g., through HEAPU8) slower. So, now, we use + # -sINITIAL_MEMORY=2GB and don't allow memory growth. set(DTS_FILE ${CMAKE_PROJECT_NAME}.d.ts) target_compile_options(${CMAKE_PROJECT_NAME} PRIVATE -O3 - -matomics - -mbulk-memory - -pthread - -sSHARED_MEMORY=1 -DNDEBUG) target_link_options(${CMAKE_PROJECT_NAME} PRIVATE -O2 -flto -pthread - -sALLOW_TABLE_GROWTH -sASSERTIONS=0 -sEXPORT_ES6=1 -sEXPORTED_FUNCTIONS=[_free,_malloc,_memset,_nlaSolverAddress,_wasmNlaSolve,_pow,_sqrt,_fabs,_exp,_exp2,_log,_log10,_ceil,_floor,_fmin,_fmax,_fmod,_sin,_cos,_tan,_sinh,_cosh,_tanh,_asin,_acos,_atan,_asinh,_acosh,_atanh] -sEXPORTED_RUNTIME_METHODS=HEAPU8 -sINITIAL_MEMORY=2GB -sMODULARIZE=1 - -sSHARED_MEMORY=1 -sSTACK_SIZE=8MB --bind --emit-tsd ${DTS_FILE}) diff --git a/src/bindings/javascript/CMakeLists.txt b/src/bindings/javascript/CMakeLists.txt index b077edc5a..16fb49bdd 100644 --- a/src/bindings/javascript/CMakeLists.txt +++ b/src/bindings/javascript/CMakeLists.txt @@ -71,7 +71,10 @@ if(EMSCRIPTEN OR LIBOPENCOR_JAVASCRIPT_BINDINGS) # catching the std::out_of_range exception thrown by std::stoi()). Unlike Emscripten's # JavaScript-based exception handling, WebAssembly exception handling has no runtime cost when no # exception is thrown. - # Note #3: those flags are also used to build our third-party libraries (see packages.cmake), so that they are + # Note #3: the linker flags also allow the WebAssembly table to grow (-sALLOW_TABLE_GROWTH), which we need to + # install the functions of the WebAssembly modules that we generate for our models (see + # initialiseWorkerWasmJS()). + # Note #4: those flags are also used to build our third-party libraries (see packages.cmake), so that they are # all built with exception handling too. set(EMSCRIPTEN_COMPILER_FLAGS "-fwasm-exceptions -matomics -mbulk-memory -msimd128 -pthread -sSHARED_MEMORY=1") diff --git a/src/bindings/javascript/file.cpp b/src/bindings/javascript/file.cpp index de7a94597..325ff4763 100644 --- a/src/bindings/javascript/file.cpp +++ b/src/bindings/javascript/file.cpp @@ -47,9 +47,8 @@ void fileApi() // Note: we avoid using emscripten::typed_memory_view() as an intermediate step since it creates a // JavaScript's TypedArray view directly backed by wasmMemory.buffer. This is fragile and would break - // if ALLOW_MEMORY_GROWTH was to be enabled or if pthreads/SharedArrayBuffer support were to be added. - // By performing the copy entirely within a single EM_ASM() block, HEAPU8 is fetched and used - // atomically. + // if ALLOW_MEMORY_GROWTH was to be enabled. By performing the copy entirely within a single EM_ASM() + // block, HEAPU8 is fetched and used atomically. return emscripten::val::take_ownership(static_cast(EM_ASM_PTR({ let jsArray = new Uint8Array(HEAPU8.subarray($0, $0 + $1)); From 1c202a9c0b30fa5c6df37392c33e589d67a5f377 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Fri, 16 Oct 2026 02:06:41 +1300 Subject: [PATCH 3/6] Throw clear error for startRun() without threads. --- src/api/libopencor/sedinstance.h | 8 ++++ src/bindings/javascript/main.cpp | 7 ++-- src/bindings/javascript/sed.cpp | 18 ++++++++- src/bindings/javascript/stringconverter.h | 12 +++++- tests/bindings/javascript/README.md | 2 +- tests/bindings/javascript/res/index.html | 2 - .../bindings/javascript/sed.instance.test.js | 40 +++++++++++++++++++ 7 files changed, 80 insertions(+), 9 deletions(-) diff --git a/src/api/libopencor/sedinstance.h b/src/api/libopencor/sedinstance.h index f5c6a52d6..357f6056b 100644 --- a/src/api/libopencor/sedinstance.h +++ b/src/api/libopencor/sedinstance.h @@ -88,6 +88,10 @@ class LIBOPENCOR_EXPORT SedInstance: public Logger * plot them progressively). The simulation settings and model changes used are those in effect when this method is * called. * + * Note: in a Web browser, this method can only be used if the page is cross-origin isolated (i.e. served with the + * "Cross-Origin-Opener-Policy: same-origin" and "Cross-Origin-Embedder-Policy: require-corp" HTTP headers) since + * threads are otherwise not supported, in which case an error is thrown. + * * @return @c true if a new run was started, @c false if a run is already in progress. */ @@ -98,6 +102,10 @@ class LIBOPENCOR_EXPORT SedInstance: public Logger * * Wait for any currently-running instance to complete. * + * Note: in a Web browser, calling this method on the main thread straight after @ref startRun() blocks forever + * since the background thread cannot start until the main thread returns to its event loop, so poll @ref status() + * instead. + * * @return The elapsed time in milliseconds for the last completed instance run. */ diff --git a/src/bindings/javascript/main.cpp b/src/bindings/javascript/main.cpp index 070ceffcb..84616b815 100644 --- a/src/bindings/javascript/main.cpp +++ b/src/bindings/javascript/main.cpp @@ -35,8 +35,9 @@ namespace { void restoreStackOnThrow() { // Make sure that the WebAssembly stack pointer gets restored when a binding throws a JavaScript exception (see Note - // #4 in stringconverter.h), i.e. a binding that takes a string (see fromJsString()) or that may reject a value (see - // throwRangeError()), as Embind itself does for a binding that allocates its arguments on the WebAssembly stack. + // #4 in stringconverter.h), i.e. a binding that takes a string (see fromJsString()), that may reject a value (see + // throwRangeError()), or that may fail in the current JavaScript environment (see throwError()), as Embind itself + // does for a binding that allocates its arguments on the WebAssembly stack. // Note #1: a binding is listed as ":||...", where a member name is that of a // method, a static method, a property (in which case its setter is wrapped), or "constructor" (in which // case the class is wrapped using a Proxy, so that it otherwise behaves as before, e.g., for instanceof). @@ -55,7 +56,7 @@ void restoreStackOnThrow() stackRestore(sp); } }; - let bindings = 'File:constructor|childFileFromFileName FileManager:fileFromFileNameOrUrl SedBase:id|isDuplicateId|isValidId SedChangeAttribute:constructor|componentName|variableName|newValue SedDocument:serialise Strings:push_back|resize|set'.split(' '); + let bindings = 'File:constructor|childFileFromFileName FileManager:fileFromFileNameOrUrl SedBase:id|isDuplicateId|isValidId SedChangeAttribute:constructor|componentName|variableName|newValue SedDocument:serialise SedInstance:startRun Strings:push_back|resize|set'.split(' '); addOnPostCtor(() => bindings.forEach((binding) => { let parts = binding.split(':'); diff --git a/src/bindings/javascript/sed.cpp b/src/bindings/javascript/sed.cpp index 65e5fc739..e3af1f5d8 100644 --- a/src/bindings/javascript/sed.cpp +++ b/src/bindings/javascript/sed.cpp @@ -18,6 +18,8 @@ limitations under the License. #include +#include + namespace { constexpr std::string_view INVALID_ID_ERROR {"The id is not a valid SED-ML identifier."}; @@ -26,6 +28,7 @@ constexpr std::string_view COMPONENT_NAME_ERROR {"The component name is not a va constexpr std::string_view VARIABLE_NAME_ERROR {"The variable name is not a valid CellML identifier."}; constexpr std::string_view NEW_VALUE_ERROR {"The new value is not a valid double value."}; constexpr std::string_view CHANGE_ATTRIBUTE_ERROR {"The component and variable names must be valid CellML identifiers and the new value must be a valid double value."}; +constexpr std::string_view START_RUN_ERROR {"The instance cannot be run in a background thread since threads are not supported (in a Web browser, the page must be cross-origin isolated)."}; void setId(libOpenCOR::SedBase &pThis, const JsString &pId) { @@ -153,7 +156,20 @@ void sedApi() .smart_ptr("SedInstance") .property("status", &libOpenCOR::SedInstance::status) .function("run", &libOpenCOR::SedInstance::run) - .function("startRun", &libOpenCOR::SedInstance::startRun) + // clang-format off + .function("startRun", emscripten::optional_override([](libOpenCOR::SedInstance &pThis) { + // Note: threads require SharedArrayBuffer, which a Web browser only provides to a page that is cross-origin + // isolated (i.e. served with the "Cross-Origin-Opener-Policy: same-origin" and + // "Cross-Origin-Embedder-Policy: require-corp" HTTP headers). Without it, startRun() would fail to + // create its thread and throw a C++ exception, which would reach JavaScript as an opaque + // WebAssembly.Exception, so we throw an Error with a meaningful message instead. + + if (!emscripten_has_threading_support()) { + throwError(START_RUN_ERROR); + } + + return pThis.startRun(); + })) // clang-format on .function("waitForRun", &libOpenCOR::SedInstance::waitForRun) .function("pauseRun", &libOpenCOR::SedInstance::pauseRun) .function("resumeRun", &libOpenCOR::SedInstance::resumeRun) diff --git a/src/bindings/javascript/stringconverter.h b/src/bindings/javascript/stringconverter.h index 976404aca..bd776520e 100644 --- a/src/bindings/javascript/stringconverter.h +++ b/src/bindings/javascript/stringconverter.h @@ -39,8 +39,8 @@ limitations under the License. // we throw using emscripten::val::throw_() rather than from within an EM_ASM() block since the latter is // declared as not throwing. Also, a JavaScript exception that leaves WebAssembly doesn't restore the // WebAssembly stack pointer, meaning that some of the WebAssembly stack would be leaked every time a binding -// throws. So, every binding that may throw (i.e. that uses fromJsString() or throwRangeError()) must be listed -// in restoreStackOnThrow() (see main.cpp). +// throws. So, every binding that may throw (i.e. that uses fromJsString(), throwError(), or throwRangeError()) +// must be listed in restoreStackOnThrow() (see main.cpp). EMSCRIPTEN_DECLARE_VAL_TYPE(JsString); @@ -188,6 +188,14 @@ inline std::string fromJsString(const JsString &pString) // clang-format on } +[[noreturn]] inline void throwError(std::string_view pMessage) +{ + // Throw a JavaScript Error with the given message (see Note #4), e.g., when something cannot be done in the current + // JavaScript environment. + + emscripten::val::global("Error").new_(std::string(pMessage)).throw_(); +} + [[noreturn]] inline void throwRangeError(std::string_view pMessage) { // Throw a JavaScript RangeError with the given message (see Note #4), e.g., when a value is rejected by libOpenCOR. diff --git a/tests/bindings/javascript/README.md b/tests/bindings/javascript/README.md index 31c7d2f5f..666af359c 100644 --- a/tests/bindings/javascript/README.md +++ b/tests/bindings/javascript/README.md @@ -7,4 +7,4 @@ cd [libOpenCOR] emrun --browser chrome build/tests/bindings/javascript/index.html ``` -Note: libOpenCOR's JavaScript bindings require `SharedArrayBuffer` for pthread support, which in turn requires the `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp` HTTP headers (or their `` equivalents, which are already present in the HTML). If the demo page fails to load the WebAssembly module, verify that these headers are being served. +Note: libOpenCOR's JavaScript bindings require `SharedArrayBuffer` to run a simulation in a background thread (i.e. using `startRun()`), which in turn requires the page to be served with the `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp` HTTP headers. `emrun` serves them, but if you use another Web server, you must make sure that it serves them too (Web browsers ignore their `` equivalents). Otherwise, `startRun()` throws an error. diff --git a/tests/bindings/javascript/res/index.html b/tests/bindings/javascript/res/index.html index e70c5767d..f22569bba 100644 --- a/tests/bindings/javascript/res/index.html +++ b/tests/bindings/javascript/res/index.html @@ -5,8 +5,6 @@ - - diff --git a/tests/bindings/javascript/sed.instance.test.js b/tests/bindings/javascript/sed.instance.test.js index fb46e81b6..2bfb6b487 100644 --- a/tests/bindings/javascript/sed.instance.test.js +++ b/tests/bindings/javascript/sed.instance.test.js @@ -209,6 +209,46 @@ test.describe('Sed instance tests', () => { assert.strictEqual(instance.hasIssues, false); }); + test('Asynchronous run without threading support', () => { + // Note: this has no C++ or Python equivalent since threads are always supported in C++ and Python while, in a Web + // browser, they are only supported if the page is cross-origin isolated, i.e. if SharedArrayBuffer is + // available, hence we simulate this by hiding SharedArrayBuffer. + + const file = new loc.File(utils.resourcePath('cellml_2.cellml')); + + file.setContents(utils.fileContents(file.path)); + + const document = new loc.SedDocument(file); + const instance = document.instantiate(); + const sharedArrayBuffer = globalThis.SharedArrayBuffer; + + delete globalThis.SharedArrayBuffer; + + try { + assert.throws( + () => { + instance.startRun(); + }, + { + name: 'Error', + message: + 'The instance cannot be run in a background thread since threads are not supported (in a Web browser, the page must be cross-origin isolated).' + } + ); + } finally { + globalThis.SharedArrayBuffer = sharedArrayBuffer; + } + + assert.strictEqual(instance.status, loc.SedInstance.Status.IDLE); + + // The instance can still be run, be it synchronously or, once threads are supported again, asynchronously. + + assert.ok(instance.run() > 0.0); + assert.strictEqual(instance.startRun(), true); + assert.ok(instance.waitForRun() > 0.0); + assert.strictEqual(instance.hasIssues, false); + }); + test('Progress before any run', () => { const file = new loc.File(utils.resourcePath('cellml_2.cellml')); From eea8b5ccc3ecaf810fd8f488b9dfbb01153de803 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Fri, 16 Oct 2026 02:23:02 +1300 Subject: [PATCH 4/6] Added -sCROSS_ORIGIN=1 to our Emscripten build. --- src/CMakeLists.txt | 13 ++++++++++++- src/api/libopencor/sedinstance.h | 4 +++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d92e8084d..b2ad64ef4 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -373,7 +373,8 @@ if(EMSCRIPTEN) # performance (and it links faster), -flto to use the LTO version of Emscripten's system libraries and to # optimise LLVM, which we build as LLVM bitcode (see src/3rdparty/LLVM/CMakeLists.txt; our other object # files are not LTO ones), -pthread to enable native threading support, -sASSERTIONS=0 to disable - # assertions (they are not needed in production), -sEXPORT_ES6=1 to export ES6 modules, + # assertions (they are not needed in production), -sCROSS_ORIGIN=1 to support our .js file being loaded + # from another origin than that of the Web page (see Note #5), -sEXPORT_ES6=1 to export ES6 modules, # -sEXPORTED_FUNCTIONS to export the functions we need (i.e. those needed by the WebAssembly modules that # we generate for our models, see initialiseWorkerWasmJS()), -sEXPORTED_RUNTIME_METHODS to export the # runtime methods we need, -sINITIAL_MEMORY=2GB to have enough memory for "big" models, -sMODULARIZE=1 to @@ -384,6 +385,15 @@ if(EMSCRIPTEN) # but we now support threading, which means that our memory is shared, and to allow shared memory to grow # would make JavaScript's accesses to it (e.g., through HEAPU8) slower. So, now, we use # -sINITIAL_MEMORY=2GB and don't allow memory growth. + # Note #5: a Web browser only creates a worker (and therefore a thread) from a script that has the same origin as + # the Web page, so without -sCROSS_ORIGIN=1, a thread couldn't be created if our .js file was loaded from + # another origin (e.g., a CDN), i.e. startRun() would throw an error (Chrome) or never complete (Firefox). + # With -sCROSS_ORIGIN=1, a worker is instead created from a Blob URL (which has the same origin as the Web + # page) that imports our .js file, whatever the origin of our .js file. This means that our .js file, if + # loaded from another origin, must be served with CORS headers and that a Web page that has a Content + # Security Policy must allow blob: workers (i.e. worker-src blob:), even if our .js file has the same + # origin as the Web page. Otherwise, a run started using startRun() would never actually start (both in + # Chrome and Firefox). set(DTS_FILE ${CMAKE_PROJECT_NAME}.d.ts) @@ -395,6 +405,7 @@ if(EMSCRIPTEN) -flto -pthread -sASSERTIONS=0 + -sCROSS_ORIGIN=1 -sEXPORT_ES6=1 -sEXPORTED_FUNCTIONS=[_free,_malloc,_memset,_nlaSolverAddress,_wasmNlaSolve,_pow,_sqrt,_fabs,_exp,_exp2,_log,_log10,_ceil,_floor,_fmin,_fmax,_fmod,_sin,_cos,_tan,_sinh,_cosh,_tanh,_asin,_acos,_atan,_asinh,_acosh,_atanh] -sEXPORTED_RUNTIME_METHODS=HEAPU8 diff --git a/src/api/libopencor/sedinstance.h b/src/api/libopencor/sedinstance.h index 357f6056b..e630b4006 100644 --- a/src/api/libopencor/sedinstance.h +++ b/src/api/libopencor/sedinstance.h @@ -90,7 +90,9 @@ class LIBOPENCOR_EXPORT SedInstance: public Logger * * Note: in a Web browser, this method can only be used if the page is cross-origin isolated (i.e. served with the * "Cross-Origin-Opener-Policy: same-origin" and "Cross-Origin-Embedder-Policy: require-corp" HTTP headers) since - * threads are otherwise not supported, in which case an error is thrown. + * threads are otherwise not supported, in which case an error is thrown. Also, if the page has a Content Security + * Policy, it must allow blob: workers (i.e. worker-src blob:) since the run would otherwise never start, i.e. the + * status of the instance would remain RUNNING. * * @return @c true if a new run was started, @c false if a run is already in progress. */ From e3b0102922b8172e63cf51d52a82f977d6e87e83 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Fri, 16 Oct 2026 02:30:02 +1300 Subject: [PATCH 5/6] Clarify browser blocking caveats in the SedInstance documentation. --- src/api/libopencor/sedinstance.h | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/api/libopencor/sedinstance.h b/src/api/libopencor/sedinstance.h index e630b4006..b46ad1357 100644 --- a/src/api/libopencor/sedinstance.h +++ b/src/api/libopencor/sedinstance.h @@ -75,6 +75,10 @@ class LIBOPENCOR_EXPORT SedInstance: public Logger * Run all the tasks associated with this instance. If a run is already in progress (see @ref startRun), then wait * for it to complete before running all the tasks associated with this instance. * + * Note: in a Web browser, calling this method on the main thread straight after @ref startRun() blocks forever + * since the background thread cannot start until the main thread returns to its event loop, so poll @ref status() + * until the instance is idle first. + * * @return The elapsed time in milliseconds. */ @@ -92,7 +96,10 @@ class LIBOPENCOR_EXPORT SedInstance: public Logger * "Cross-Origin-Opener-Policy: same-origin" and "Cross-Origin-Embedder-Policy: require-corp" HTTP headers) since * threads are otherwise not supported, in which case an error is thrown. Also, if the page has a Content Security * Policy, it must allow blob: workers (i.e. worker-src blob:) since the run would otherwise never start, i.e. the - * status of the instance would remain RUNNING. + * status of the instance would remain RUNNING. Finally, deleting the instance on the main thread before its run has + * actually started (e.g., straight after calling this method) blocks forever since the instance stops its run and + * waits for it to complete before being deleted, but the background thread cannot start until the main thread + * returns to its event loop. * * @return @c true if a new run was started, @c false if a run is already in progress. */ @@ -106,7 +113,7 @@ class LIBOPENCOR_EXPORT SedInstance: public Logger * * Note: in a Web browser, calling this method on the main thread straight after @ref startRun() blocks forever * since the background thread cannot start until the main thread returns to its event loop, so poll @ref status() - * instead. + * until the instance is idle first. * * @return The elapsed time in milliseconds for the last completed instance run. */ From 6ce4d2d8c6239bc2945736e43031a9cdd2f60ccd Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Fri, 16 Oct 2026 02:56:05 +1300 Subject: [PATCH 6/6] Join our run thread to release our WASM's thread resources. --- src/sed/sedinstance.cpp | 50 +++++++++++++------ src/sed/sedinstance_p.h | 3 ++ tests/api/sed/instancetests.cpp | 25 ++++++++++ .../bindings/javascript/sed.instance.test.js | 27 ++++++++++ tests/bindings/python/test_sed_instance.py | 23 +++++++++ 5 files changed, 114 insertions(+), 14 deletions(-) diff --git a/src/sed/sedinstance.cpp b/src/sed/sedinstance.cpp index 4a4d23930..778a8ef5b 100644 --- a/src/sed/sedinstance.cpp +++ b/src/sed/sedinstance.cpp @@ -265,6 +265,20 @@ double SedInstance::Impl::executeRun() return res; } +double SedInstance::Impl::joinRun() +{ + // Wait for our asynchronous run to complete and return its elapsed time (or rethrow the exception it threw). + // Note: we join the thread of our asynchronous run rather than only wait for its future to become ready, so that + // the thread's resources get released straight away. Indeed, our WASM version would otherwise only release + // them (i.e. free the thread's stack and make its Web worker available for another thread) once the main + // thread returns to its event loop, meaning that asynchronous runs started and waited for from a loop (e.g., + // a parameter sweep in Node.js) would eventually exhaust our WASM memory. + + mRunThread.join(); + + return mRunFuture.get(); +} + double SedInstance::Impl::run() { const std::scoped_lock runLock(mRunMutex); @@ -272,7 +286,7 @@ double SedInstance::Impl::run() // Wait for any asynchronous run to complete since our tasks cannot be run while they are already being run. if (mRunFuture.valid()) { - mLastRunElapsedTime.store(mRunFuture.get(), std::memory_order_relaxed); + mLastRunElapsedTime.store(joinRun(), std::memory_order_relaxed); } // Reset our control flags (see the note in prepareRun()). @@ -310,7 +324,7 @@ bool SedInstance::Impl::startRun() return false; } - mLastRunElapsedTime.store(mRunFuture.get(), std::memory_order_relaxed); + mLastRunElapsedTime.store(joinRun(), std::memory_order_relaxed); } // Reset our control flags (see the note in prepareRun()). @@ -329,21 +343,29 @@ bool SedInstance::Impl::startRun() // Note #1: we must be flagged as not running anymore once our run is done, even if executeRun() throws an exception // (it reports a failure as an issue, but it might still throw, e.g., std::bad_alloc when adding an issue), // hence we use a guard to do so. Otherwise, we would be stuck in RUNNING. - // Note #2: std::async() may throw an exception (e.g., std::system_error if no thread could be created), in which - // case we must also be flagged as not running anymore. We have no way to trigger such an exception in our - // tests, hence we ignore our try...catch statement during code coverage. + // Note #2: we use a joinable thread rather than std::async() since the latter may use a detached thread (e.g., with + // libc++), which we couldn't join (see joinRun()). We still use a future (through a packaged task) so that + // we can retrieve the elapsed time of our run or the exception that it threw. + // Note #3: creating our thread may throw an exception (e.g., std::system_error if no thread could be created), in + // which case we must also be flagged as not running anymore and must not have a future (since it would + // never become ready). We have no way to trigger such an exception in our tests, hence we ignore our + // try...catch statement during code coverage. + + std::packaged_task runTask([this]() { + auto resetRunning = [](std::atomic *pRunning) { + pRunning->store(false, std::memory_order_release); + }; + const std::unique_ptr, decltype(resetRunning)> runningGuard {&mRunning, resetRunning}; + + return executeRun(); + }); + auto runFuture {runTask.get_future()}; #ifndef CODE_COVERAGE_ENABLED try { #endif - mRunFuture = std::async(std::launch::async, [this]() { - auto resetRunning = [](std::atomic *pRunning) { - pRunning->store(false, std::memory_order_release); - }; - const std::unique_ptr, decltype(resetRunning)> runningGuard {&mRunning, resetRunning}; - - return executeRun(); - }); + mRunThread = std::thread(std::move(runTask)); + mRunFuture = std::move(runFuture); #ifndef CODE_COVERAGE_ENABLED } catch (...) { mRunning.store(false, std::memory_order_release); @@ -363,7 +385,7 @@ double SedInstance::Impl::waitForRun() return mLastRunElapsedTime.load(std::memory_order_relaxed); } - mLastRunElapsedTime.store(mRunFuture.get(), std::memory_order_relaxed); + mLastRunElapsedTime.store(joinRun(), std::memory_order_relaxed); mRunning.store(false, std::memory_order_release); return mLastRunElapsedTime.load(std::memory_order_relaxed); diff --git a/src/sed/sedinstance_p.h b/src/sed/sedinstance_p.h index d0f146619..1eb247971 100644 --- a/src/sed/sedinstance_p.h +++ b/src/sed/sedinstance_p.h @@ -24,6 +24,7 @@ limitations under the License. #include #include #include +#include namespace libOpenCOR { @@ -39,6 +40,7 @@ class SedInstance::Impl: public Logger::Impl mutable std::atomic mRunning {false}; mutable std::mutex mRunMutex; + mutable std::thread mRunThread; mutable std::future mRunFuture; mutable std::atomic mLastRunElapsedTime {0.0}; @@ -55,6 +57,7 @@ class SedInstance::Impl: public Logger::Impl void prepareRun(); double executeRun(); + double joinRun(); double run(); bool startRun(); diff --git a/tests/api/sed/instancetests.cpp b/tests/api/sed/instancetests.cpp index 966dc25f9..302031829 100644 --- a/tests/api/sed/instancetests.cpp +++ b/tests/api/sed/instancetests.cpp @@ -177,6 +177,31 @@ TEST(InstanceSedTest, asynchronousRunCanBeRestarted) EXPECT_FALSE(instance->hasIssues()); } +TEST(InstanceSedTest, manyAsynchronousRuns) +{ + // Note: each asynchronous run is done in its own thread, the resources of which must be released once the run has + // been waited for. Otherwise, our WASM version would run out of memory after a few hundred runs. + + static const auto RUN_COUNT = 300; + static const auto SIMULATION_PROPERTY = 10; + + auto file {libOpenCOR::File::create(libOpenCOR::resourcePath("cellml_2.cellml"))}; + auto document {libOpenCOR::SedDocument::create(file)}; + const auto &simulation {std::dynamic_pointer_cast(document->simulations()[0])}; + + simulation->setNumberOfSteps(SIMULATION_PROPERTY); + simulation->setOutputEndTime(static_cast(SIMULATION_PROPERTY)); + + auto instance {document->instantiate()}; + + for (size_t i {0}; i < RUN_COUNT; ++i) { + EXPECT_TRUE(instance->startRun()); + EXPECT_GT(instance->waitForRun(), 0.0); + } + + EXPECT_FALSE(instance->hasIssues()); +} + TEST(InstanceSedTest, progressBeforeAnyRun) { auto file {libOpenCOR::File::create(libOpenCOR::resourcePath("cellml_2.cellml"))}; diff --git a/tests/bindings/javascript/sed.instance.test.js b/tests/bindings/javascript/sed.instance.test.js index 2bfb6b487..b47a5c854 100644 --- a/tests/bindings/javascript/sed.instance.test.js +++ b/tests/bindings/javascript/sed.instance.test.js @@ -209,6 +209,33 @@ test.describe('Sed instance tests', () => { assert.strictEqual(instance.hasIssues, false); }); + test('Many asynchronous runs', () => { + // Note: each asynchronous run is done in its own thread, the resources of which must be released once the run has + // been waited for. Otherwise, our WASM version would run out of memory after a few hundred runs. + + const RUN_COUNT = 300; + const SIMULATION_PROPERTY = 10; + + const file = new loc.File(utils.resourcePath('cellml_2.cellml')); + + file.setContents(utils.fileContents(file.path)); + + const document = new loc.SedDocument(file); + const simulation = document.simulations.get(0); + + simulation.numberOfSteps = SIMULATION_PROPERTY; + simulation.outputEndTime = SIMULATION_PROPERTY; + + const instance = document.instantiate(); + + for (let i = 0; i < RUN_COUNT; ++i) { + assert.strictEqual(instance.startRun(), true); + assert.ok(instance.waitForRun() > 0.0); + } + + assert.strictEqual(instance.hasIssues, false); + }); + test('Asynchronous run without threading support', () => { // Note: this has no C++ or Python equivalent since threads are always supported in C++ and Python while, in a Web // browser, they are only supported if the page is cross-origin isolated, i.e. if SharedArrayBuffer is diff --git a/tests/bindings/python/test_sed_instance.py b/tests/bindings/python/test_sed_instance.py index 9bcd40289..f189871cf 100644 --- a/tests/bindings/python/test_sed_instance.py +++ b/tests/bindings/python/test_sed_instance.py @@ -199,6 +199,29 @@ def test_asynchronous_run_can_be_restarted(): assert not instance.has_issues +def test_many_asynchronous_runs(): + # Note: each asynchronous run is done in its own thread, the resources of which must be released once the run has + # been waited for. Otherwise, our WASM version would run out of memory after a few hundred runs. + + RUN_COUNT = 300 + SIMULATION_PROPERTY = 10 + + file = loc.File(utils.resource_path("cellml_2.cellml")) + document = loc.SedDocument(file) + simulation = document.simulations[0] + + simulation.number_of_steps = SIMULATION_PROPERTY + simulation.output_end_time = float(SIMULATION_PROPERTY) + + instance = document.instantiate() + + for _ in range(RUN_COUNT): + assert instance.start_run() is True + assert instance.wait_for_run() > 0.0 + + assert not instance.has_issues + + def test_progress_before_any_run(): file = loc.File(utils.resource_path("cellml_2.cellml")) document = loc.SedDocument(file)