Skip to content

Make Manifold and CrossSection safe for concurrent const access#1636

Merged
pca006132 merged 1 commit into
elalish:masterfrom
zmerlynn:fix/pnode-thread-safety
Apr 13, 2026
Merged

Make Manifold and CrossSection safe for concurrent const access#1636
pca006132 merged 1 commit into
elalish:masterfrom
zmerlynn:fix/pnode-thread-safety

Conversation

@zmerlynn

@zmerlynn zmerlynn commented Apr 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Add a per-instance shared_ptr<mutex> to both Manifold and CrossSection, protecting the mutable pNode_/paths_ fields from data races when multiple threads call const methods on the same object.

All reads of pNode_ in const methods go through either LoadPNode() (builder methods like Translate, Boolean) or GetCsgLeafNode() (query methods like NumVert, GetMeshGL), both of which lock the mutex. CrossSection::GetPaths() and Transform() similarly lock pathsMutex_.

The mutex is wrapped in shared_ptr so it works with defaulted move constructors. Copy constructor and operator= lock the source's mutex to read its fields, and operator= creates a fresh mutex for the destination.

Design

Cost is one shared_ptr (16 bytes) per object plus an uncontended mutex lock (~20ns) per method call — negligible compared to actual mesh operations. We considered std::call_once (protects queries but not builders) and C++17 std::atomic_load/std::atomic_store on shared_ptr, but the per-object mutex is clearer — all pNode_ access goes through two accessors (LoadPNode and GetCsgLeafNode), making it hard to accidentally add an unprotected read.

When the project moves to C++20, this can be simplified to std::atomic<std::shared_ptr<CsgNode>> with no mutex needed (see #1153).

Fixes #1635

Test plan

  • All 326 existing tests pass
  • No behavioral changes for single-threaded use

🤖 Generated with Claude Code

@codecov

codecov Bot commented Apr 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.00000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 95.34%. Comparing base (842479e) to head (39f10e5).
⚠️ Report is 3 commits behind head on master.

Files with missing lines Patch % Lines
src/cross_section/cross_section.cpp 75.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1636      +/-   ##
==========================================
- Coverage   95.34%   95.34%   -0.01%     
==========================================
  Files          34       34              
  Lines        7696     7708      +12     
==========================================
+ Hits         7338     7349      +11     
- Misses        358      359       +1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@pca006132

Copy link
Copy Markdown
Collaborator

I'm thinking about whether to use this or mutex, since I doubt if we can move to c++20 in a short period of time, since we probably want to wait until the major users move to c++20.

I am not worried about the overhead introduced by mutex (probably a std::shared_ptr<std::mutex> so it is cloneable), since the overhead is minuscule compared to constructing a MeshGL object.

@zmerlynn

Copy link
Copy Markdown
Contributor Author

Good point about the C++20 timeline. I'm happy to switch to a shared_ptr<mutex> — same size cost as the once_flag approach and it closes the builder method gap entirely.

One question on the semantics: should copies share the mutex or get independent ones? Currently the copy constructor copies pNode_ (so both copies start with the same shared_ptr<CsgNode> value) but each has its own member that gets independently written during evaluation. So I think each copy needs its own mutex — sharing would be wrong since the mutex protects the per-instance pNode_ member, not the underlying CsgNode. Does that match your thinking?

Concretely, the approach would be:

  • mutable shared_ptr<recursive_mutex> pNodeMutex_ (fresh per instance, same pattern as ConcurrentSharedPtr in utils.h)
  • All pNode_ reads go through a GetPNode() accessor that locks + returns a copy of the shared_ptr
  • GetCsgLeafNode() locks for the read-check-write
  • Copy constructor / operator= get a fresh mutex (and lock the source's mutex to read pNode_)
  • Move stays defaulted

@zmerlynn zmerlynn force-pushed the fix/pnode-thread-safety branch from a6e7d50 to 0870683 Compare April 11, 2026 13:17
@zmerlynn

Copy link
Copy Markdown
Contributor Author

Updated the PR to use shared_ptr<mutex> as you suggested. All pNode_ reads now go through two locked accessors (LoadPNode for builder methods, GetCsgLeafNode for query methods), so both query+query and query+builder concurrent access is safe.

Approach matches what I outlined in the earlier comment — per-instance mutex (non-recursive is sufficient), fresh mutex on copy/assign, source locked during copy. CrossSection gets the same treatment for paths_/transform_.

transform_ = other.transform_;
}

CrossSection& CrossSection::operator=(const CrossSection& other) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think for this to be thread safe, we need another mutex for the CrossSection object itself?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — updated operator= for both classes to lock this as well, using std::scoped_lock for deadlock avoidance. Left the copy constructors locking only the source since this is under construction and can't be accessed concurrently.

Comment thread src/manifold.cpp Outdated

Manifold& Manifold::operator=(const Manifold& other) {
if (this != &other) {
std::lock_guard<std::mutex> lock(*other.pNodeMutex_);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as the CrossSection copy constructor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix applied here — std::scoped_lock on both mutexes to avoid deadlock from concurrent a = b / b = a.

Add a per-instance shared_ptr<mutex> to both Manifold and CrossSection,
protecting the mutable pNode_/paths_ fields from concurrent access.

All reads of pNode_ in const methods go through either LoadPNode()
(builder methods) or GetCsgLeafNode() (query methods), both of which
lock the mutex. CrossSection::GetPaths() and Transform() similarly lock
pathsMutex_.

The mutex is wrapped in shared_ptr so it works with defaulted move
constructors. Copy constructor and operator= lock the source's mutex
to read its fields. operator= uses std::scoped_lock on both self and
source mutexes for deadlock avoidance.

Fixes elalish#1635

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@zmerlynn zmerlynn force-pushed the fix/pnode-thread-safety branch from d317c84 to 39f10e5 Compare April 12, 2026 12:07
@pca006132 pca006132 merged commit 79524cf into elalish:master Apr 13, 2026
36 checks passed
@zmerlynn zmerlynn deleted the fix/pnode-thread-safety branch April 19, 2026 23:40
@elalish elalish mentioned this pull request May 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Manifold::pNode_ is not thread-safe for concurrent reads

2 participants