From 4baecb7017f96136a1b4b803eb05899bccacabf0 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Mon, 5 Oct 2026 03:41:36 +0200 Subject: [PATCH] fix(wasm): reject reentrant native calls --- CHANGELOG.md | 3 +++ wasm/AGENTS.md | 2 ++ wasm/src/odr_wasm.cpp | 11 +++++++++++ wasm/src/odr_wasm.hpp | 10 ++++++++++ wasm/tests/lifetime.test.mjs | 28 ++++++++++++++++++++++++++++ 5 files changed, 54 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd2bcc71d..85af10f3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- WebAssembly rejects reentrant calls from callbacks instead of allowing a + callback to close or mutate a document during an active native operation. + - WebAssembly rejects fractional, nonfinite and out-of-range element IDs, sheet coordinates, rendering limits and view indices before conversion. diff --git a/wasm/AGENTS.md b/wasm/AGENTS.md index 1c9921748..565efca84 100644 --- a/wasm/AGENTS.md +++ b/wasm/AGENTS.md @@ -60,6 +60,8 @@ structured-cloned. Three rules follow. is called during a render, and one that needs the main thread deadlocks behind a `postMessage` round trip. The same holds for a future resource locator. +- `guarded` rejects reentrant calls: callbacks must not invalidate an active + operation's session, document or view. `CallScope` resets during unwinding. - The package is plain JavaScript with a hand-written `js/index.d.ts`. Keep it in step with `js/index.js` by hand. - Test inputs are built in memory. `tests/helper.mjs` has a zip writer. diff --git a/wasm/src/odr_wasm.cpp b/wasm/src/odr_wasm.cpp index a60c3b23e..477b70255 100644 --- a/wasm/src/odr_wasm.cpp +++ b/wasm/src/odr_wasm.cpp @@ -13,6 +13,8 @@ namespace odr::wasm { namespace { +bool active_call{false}; + std::unordered_map &sessions() { static std::unordered_map instance; return instance; @@ -30,6 +32,15 @@ emscripten::val error_for(const std::exception &e) { } // namespace +CallScope::CallScope() { + if (active_call) { + throw std::logic_error("reentrant calls into odr are not supported"); + } + active_call = true; +} + +CallScope::~CallScope() { active_call = false; } + Session &session(const Handle handle) { const auto it = sessions().find(handle); if (it == sessions().end()) { diff --git a/wasm/src/odr_wasm.hpp b/wasm/src/odr_wasm.hpp index 5dad4a4d8..5f0239e9d 100644 --- a/wasm/src/odr_wasm.hpp +++ b/wasm/src/odr_wasm.hpp @@ -71,8 +71,18 @@ emscripten::val error(ErrorCode code, const std::string &message); /// The envelope for the exception being handled. Call from a `catch` block. emscripten::val current_exception_error(); +/// Prevents callbacks from invalidating a session used by an active call. +class CallScope final { +public: + CallScope(); + ~CallScope(); + CallScope(const CallScope &) = delete; + CallScope &operator=(const CallScope &) = delete; +}; + template emscripten::val guarded(F &&f) { try { + const CallScope scope; return std::forward(f)(); } catch (...) { return current_exception_error(); diff --git a/wasm/tests/lifetime.test.mjs b/wasm/tests/lifetime.test.mjs index 48c8c1ed1..36731445d 100644 --- a/wasm/tests/lifetime.test.mjs +++ b/wasm/tests/lifetime.test.mjs @@ -15,6 +15,34 @@ describe('lifetimes', () => { }); after(() => odr.closeAll()); + it('refuses reentrant callbacks and recovers after JavaScript exceptions', () => { + const doc = odr.open(minimalOdt(), { editable: true }); + try { + const id = Number(doc.render().html.match(/]*data-odr-id="(\d+)"/)[1]); + let called = false; + doc.setTextStyle(id, { + toJSON() { + called = true; + for (const close of [() => doc.close(), () => odr.closeAll()]) { + assert.throws(close, (error) => + error instanceof OdrError && /reentrant/.test(error.message)); + } + return { bold: true }; + }, + }); + assert.equal(called, true); + assert.match(doc.render().html, /font-weight:bold/); + + const failure = new Error('style serialization failed'); + assert.throws(() => doc.setTextStyle(id, { + toJSON() { throw failure; }, + }), (error) => error === failure); + assert.equal(doc.meta().fileType, 'odt'); + } finally { + doc.close(); + } + }); + it('refuses a handle that has been closed', () => { const doc = odr.open(minimalOdt()); doc.render(0);