Thread Safety in OCCTSwift
TL;DR
OCCT is not thread-safe for concurrent access to shared geometry. Use OCCTSerial.withLock { } to serialize multi-step workflows, or shape.copy(copyGeometry: true) / Shape.deepCopy(shape) to create independent geometry for parallel processing, not the no-argument instance shape.deepCopy(), which only clones topology; see below (#831).
The Problem
OCCT has several thread-unsafe patterns:
-
BSpline evaluation caches:
GeomAdaptor_CurveandGeomAdaptor_Surfacehave mutableBSplCLib_Cache/BSplSLib_Cachethat are written duringconstevaluation methods without synchronization. Two threads evaluating the same adaptor will race. -
Topology flag mutations:
TopoDS_TShape::myStateuses non-atomicuint16_twith bitwise operations. Concurrent flag modification on shared TShapes is a data race. -
Various algorithms:
BRepBuilderAPI_Transform,BRepClass3d_SolidClassifier,GeomAPI_ProjectPointOnSurf, and others have internal mutable state. Surveyed and confirmed clean (issue #1155): this item as originally written was a hypothesis, not a finding, unlike items 1/2/4 above. All eight candidate classes named in #1155 (this item’s three plusBRepBuilderAPI_MakeEdge/MakeWire/MakeFace,BRepOffsetAPI_MakePipeShell/MakeThickSolid,BRepFilletAPI_MakeFillet/MakeChamfer,ShapeFix_Face/Wire/Shape,BRepCheck_Analyzer) hold only instance state; see “Algorithm instance-state survey” below for the one near-miss (a live but currently-unreachable file-scope-static cluster in the legacy fillet-reconstruction engine), fixed as #1371. -
Shared geometry after booleans: Boolean operations can produce result shapes that share edge/face geometry with input shapes via the same
TopoDS_TShapehandles. Subsequent operations on both the original and result can race on shared adaptors. -
Non-reentrant statics in the fillet solid-reconstruction (issue #298), fixed in v1.12.3.
BRepFilletAPI_MakeFilletreconstructs its result solid through OCCT’s legacyTopOpeBRepBuildboolean engine (ChFi3d_Builder::Compute→TopOpeBRepBuild_HBuilder::MergeSolid→TopOpeBRepBuild_Builder::SplitSolid), which passed state between methods through file-scopestaticvariables. The functional culprit isSTATIC_SOLIDINDEX:SplitSolidsets it to 1/2 to tellFillSolidwhich operand it is splitting, andFillSolidreads it back to pick the operand shape. That made the whole fillet/chamfer path non-reentrant: two builds on independent shapes on separate threads clobbered each other’s flag, soFillSolidmis-classified faces and returned a wrong-but-plausible solid (one solid, positive volume, but failsBRepCheck), silent bad geometry, not a crash. Distinct from items 1–4: no geometry is shared at the Swift level, yet the operation raced on a process-global. ThreadSanitizer on a concurrent fuse+fillet stress pinpointed it (the blend solver’s scratch caches,BlendFunc_ConstRad/EvolRad,ChFi3d_Buildercheckcurve, also raced, but benignly:STATIC_SOLIDINDEXalone accounts for the corruption). Fixed by converting the fillet-path statics tothread_local(carried asScripts/patches/0003, upstreamed as Open-Cascade-SAS/OCCT#1374); the pinned xcframework now includes the fix, so fillet/chamfer are genuinely reentrant and the interimocctFilletMutexserialization shipped in v1.12.1 has been removed, concurrent fillet/chamfer builds run in parallel again.
What IS Thread-Safe
- Handle reference counting (
occ::handle<T>), atomicstd::atomic_intrefcount - Reading shape topology: immutable once built
- Completely independent shapes: shapes with no shared TShapes or geometry handles. This now includes 3D fillet and chamfer: the process-global statics that made them unsafe (item 5) were fixed in v1.12.3, so concurrent fillet/chamfer builds on independent shapes are safe again, and parallel (no longer serialised).
- OCCT’s internal parallel algorithms:
BOPAlgo_*withSetRunParallel(true),BRepCheck_AnalyzerwithSetParallel(true),BRepMesh_IncrementalMesh
The Solution
OCCTSerial. Global Recursive Mutex
OCCTSwift provides a global recursive mutex (OCCTSerial) backed by std::recursive_mutex in the C bridge. Use it to serialize access:
// Protect a multi-step workflow
let result = OCCTSerial.withLock {
let box = Shape.box(width: 10, height: 10, depth: 10)!
let filleted = box.filleted(radius: 1)!
return filleted.drilled(at: .zero, direction: SIMD3(0, 0, -1), radius: 3)
}
The lock is recursive, nested calls are safe:
OCCTSerial.withLock {
// This won't deadlock even though the inner call also acquires the lock
OCCTSerial.withLock {
let box = Shape.box(width: 5, height: 5, depth: 5)
}
}
Independent Geometry for Parallelism, three copy APIs, only two actually copy geometry (#831)
Shape has three “deep copy” entry points, and this section previously conflated them, it claimed the no-argument shape.deepCopy() used BRepBuilderAPI_Copy with independent geometry, which is wrong on both counts (verified by reading the OCCT source each one actually calls, not just its header comment):
| API | OCCT mechanism | Geometry / mesh independence |
|---|---|---|
shape.copy(copyGeometry:copyMesh:) (instance) | BRepBuilderAPI_Copy | Yes, when copyGeometry/copyMesh are true (the defaults are true/false), clones Geom_Surface/Geom_Curve/Poly_Triangulation via their own ->Copy(). |
Shape.deepCopy(_:copyGeometry:copyMesh:) (static) | BRepTools_CopyModification | Yes, same clone mechanism as copy(), BRepBuilderAPI_Copy is a thin convenience wrapper around exactly this class, so the two are the same operation reached two ways (defaults true/true, note the different copyMesh default from copy()). |
shape.deepCopy() (instance, no parameters) | TNaming_CopyShape::CopyTool | No. Builds new TopoDS_TShapes (independent topology), but TNaming_TranslateTool::UpdateFace/UpdateEdge assign the same Handle(Geom_Surface)/Handle(Geom_Curve)/Handle(Poly_Triangulation) to the copy, no geometry or mesh cloning at all. |
For parallel geometry workflows, use copy(copyGeometry: true) or the static deepCopy(_:), not the no-argument instance deepCopy(), which does not protect against the shared-geometry races (items 1–4 above), since the two shapes still point at the same Geom_Surface/Geom_Curve objects those races live on:
let original = Shape.box(width: 10, height: 10, depth: 10)!
// Create 4 independent copies for parallel processing, copyGeometry: true clones the
// actual Geom_Surface/Geom_Curve handles, not just the topology.
let copies = (0..<4).map { _ in original.copy(copyGeometry: true)! }
// Process each copy on a different thread. Independence protects against the
// shared-geometry races (items 1–4); the `filleted` call is safe because the
// fillet path is reentrant as of v1.12.3 (item 5, issue #298).
DispatchQueue.concurrentPerform(iterations: 4) { i in
let result = copies[i].filleted(radius: Double(i + 1))
// Use result...
}
A real, already-shipped call site used to rely on the weaker guarantee, now fixed (#1160): Shape.isSelfIntersecting(hardTimeout:) (Shape.swift) builds a probe shape for a detached background thread, on the reasoning that an orphaned computation past the deadline can keep running without racing the caller. It originally did so via the no-argument instance deepCopy(), which per the table above shares Geom_Surface/Geom_Curve handles (and the mutable evaluation caches on them, item 1) with self, a well-evidenced latent risk (read from the OCCT source chain, not reproduced under ThreadSanitizer) tracked in #831 as a candidate follow-up. It now builds the probe via Shape.deepCopy(_:copyGeometry:copyMesh:) (BRepTools_CopyModification), which does clone geometry, so the orphaned background computation and the caller’s continued use of self no longer share anything item 1’s race lives on.
Manual Lock/Unlock
For advanced use cases:
OCCTSerial.lock()
defer { OCCTSerial.unlock() }
// Multiple OCCT operations that must be atomic
3D fillet/chamfer thread safety (issue #298)
Fillet and chamfer are safe to call concurrently on independent shapes, with no lock on your side and no deepCopy() required, Shape.filleted, Shape.chamfered, the fillet/chamfer builders, and SheetMetal.Builder.build (which fillets internally) are all reentrant.
This required a kernel fix, not just caller discipline: the racing state (item 5) lived in OCCT’s own file-scope statics, not in any shape the caller could copy. The history:
- v1.12.1 shipped an interim mitigation, a dedicated bridge mutex (
occtFilletMutex) serialised every 3D fillet/chamfer build. Correct, but it meant fillet/chamfer could not run in parallel. - v1.12.3 ships the real fix in the pinned kernel (
Scripts/patches/0003, upstreamed as OCCT#1374): the fillet-path statics arethread_local, so the operation is genuinely reentrant. TheocctFilletMutexserialization was removed, concurrent fillet/chamfer builds now run fully in parallel, like every other operation.
2D fillets/chamfers (BRepFilletAPI_MakeFillet2d) were never affected, they use the separate analytic ChFi2d toolkit with no such statics.
Algorithm instance-state survey (issue #1155)
Item 3 above named eight candidate classes as a hypothesis, not a finding: BRepBuilderAPI_Transform, BRepClass3d_SolidClassifier, GeomAPI_ProjectPointOnSurf, BRepBuilderAPI_MakeEdge/MakeWire/MakeFace, BRepOffsetAPI_MakePipeShell/MakeThickSolid, BRepFilletAPI_MakeFillet/MakeChamfer, ShapeFix_Face/Wire/Shape, BRepCheck_Analyzer. Each candidate’s full call chain (not just its own .cxx) was read for file-scope/static mutable state, then checked against a real TSan reproducer. All eight confirmed clean: every candidate either has no static mutable state in its reachable call chain, or the state that exists is dead code (an #ifdef this project’s Release build never defines), already thread_local/mutex-protected, or a function-local static that is read-only after one-time construction. Full per-class characterization, the repro, and the TSan transcripts are in Scripts/repro/1155-thread-safety-survey/.
One near-miss: BRepFilletAPI_MakeFillet/MakeChamfer’s underlying legacy TopOpeBRepBuild_Builder engine had a second cluster of unsynchronized file-scope statics (in TopOpeBRepBuild_ffsfs.cxx/GridSS.cxx/GridFF.cxx) that #298’s fix (above) did not reach, the same failure shape in the same toolkit. Confirmed unreachable from this bridge’s own call surface by two independent methods (a static call-graph read and an empirical reachability probe), so it does not race in practice today. Fixed here initially (Scripts/patches/0032) on this project’s precedent that a live unsynchronized global is worth fixing before something starts reaching it, not after, then retired 2026-09-02 once OCCT’s own upstream master shipped a strictly better fix for the identical globals (per-instance member fields, not thread_local) in OCCT#1505/#1509; carrying an inferior fix for an already-unreachable defect someone else was actively fixing better was not worth it. See the #1371 row in okf/references/known-occt-bugs.md, and Scripts/patches/README.md’s retired 0032 entry for the full account.
Document creation thread safety (issues #341, #344)
Document.create(), Document.loadOBJ/loadSTEP/loadGLTF/etc., and every other document-producing API are safe to call concurrently as of v1.15.6, no lock needed on your side.
Until v1.15.17 every one of these calls went through a single process-wide XCAFApp_Application singleton (XCAFApp_Application::GetApplication()), which is why the two kernel fixes below were needed, both in OCCT itself rather than the bridge. It no longer does: each OCCTDocument now constructs its own private TDocStd_Application, and XCAFApp_Application is not constructed anywhere in Sources/OCCTBridge. See “Private TDocStd_Application per document, not a shared singleton (issue #371)” below for what that changed and what it did not.
- v1.15.5 (
Scripts/patches/0011, issue #341):XCAFDoc_ShapeTool::theAutoNaming, a process-global flag mutated by every document-tree build, raced across concurrent OBJ/glTF import. Fixed viaXCAFDoc_ShapeTool::AutoNamingScope(a mutex-backed RAII scope) plus making the flag itselfstd::atomic<bool>. Revised in v1.15.15 (issue #363) after upstream review: the mutex only serialized the three known override call sites against each other, not every other read of the flag elsewhere in the file, so an unrelated unscoped caller could still observe another thread’s temporary override.OwnAutoNamingScopereplaces it, saving/restoring a per-instance override onXCAFDoc_ShapeTool(already one instance per document) instead of a shared flag, no locking needed at all, since independent documents never touch anything shared. See the#341row inokf/references/known-occt-bugs.mdandScripts/repro/341-meshcaf/for the full writeup, including why the naive “set on entry, unset on exit” version of this fix would have brokenXCAFDoc_Editor::Expand()’s self-recursion. - v1.15.6 (
Scripts/patches/0012, issue #344): an uncatchable SIGSEGV survived the v1.15.5 fix, a genuinely different pair of races, inGetApplication()’s lazy singleton init (two threads could each construct their own instance) andCDF_Directory::Add(the singleton’s document registry, mutated with no locking at all). Fixed via a thread-safe static initializer and a private mutex onCDF_Directory. Fixing the singleton init meant every caller genuinely shares oneTDocStd_Applicationinstance for the first time (as intended), which surfaced more races on that instance’s format-registration state,TDocStd_Application::Resources()(same lazy-init bug asGetApplication()),Resource_Manager’s internal maps, andCDF_Application::myReaders/myWriters, all fixed in the same patch.
An interim bridge-side mutex (meshCafMutex(), serializing every OBJ/glTF/PLY bridge call) shipped in v1.15.4 between these two kernel fixes and was removed once v1.15.5 made the underlying OCCT calls safe on their own, the same “bridge mitigation, then kernel fix” pattern as #298 above.
Concurrent Document.saveOCAF/saveOCAFInPlace/loadOCAF of the same target format was a separate issue (#349): CDF_Application::WriterFromFormat/ReaderFromFormat cache one storage/retrieval driver instance per format and reuse it for every call, but the driver’s own Write()/Read() isn’t reentrant, its instance-level scratch state (e.g. BinLDrivers_DocumentStorageDriver’s myRelocTable) corrupts under two concurrent callers. v1.15.6 shipped an interim bridge-side mitigation (ocafStoreMutex() in OCCTBridge_Document.mm), the same “bridge mitigation, then kernel fix” pattern as #298/#341 above, and v1.15.9 (Scripts/patches/0014) fixed the underlying kernel non-reentrancy: a mutex on PCDM_StorageDriver/PCDM_Reader held at the call sites that invoke a cached, possibly-shared driver. ocafStoreMutex() stayed in place afterward as defense-in-depth (same pattern again), and turned out to still be load-bearing for a different reason once v1.15.17 (#371) moved documents off the shared singleton, see below. Plain shape-format I/O (STEP/IGES/BREP/OBJ/glTF) is unaffected, this only ever covered the OCAF (.bcaf/.xcaf-style binary/XML document) persistence entry points.
STEP/IGES data-exchange thread safety (issues #181, #359)
Every STEP and IGES import/export call is safe to call concurrently as of v1.15.12, no lock needed on your side. STEPControl/STEPCAFControl/IGESControl readers and writers all read and write OCCT’s process-global Interface_Static parameter table (Open-Cascade-SAS/OCCT#1179), so the bridge serializes every data-exchange (DE) call on a single shared mutex (igesMutex() in OCCTBridge_IO_StepFormat.mm/OCCTBridge_IO_IgesFormat.mm). This is a wrapper-level fix, not an OCCT kernel patch. OCCT’s own DE readers/writers aren’t thread-safe by design, same as issue #298’s original framing.
- #181-B (fixed via PR #184) found this for concurrent
writeSTEP: two STEP writes on different threads SIGSEGV’d insideSTEPCAFControl_Writer/STEPControl_Writerat once. The fix serialized the STEP/IGES writer entry points that existed at the time. - #359 found the same lock never covered STEP import at all (the #181-B report was specifically about writes), and 3 writer entry points added after PR #184 shipped (
OCCTExportSTEPWithName,OCCTExportSTEPWithModeProgress,OCCTDocumentWriteSTEPWithModes) never picked up the lock either, 18 functions total. Fixed by extendingigesMutex()coverage to all 18, matching the existing convention. - #1157 investigated whether
Interface_Staticcould be made thread-safe at the kernel level soigesMutex()could be narrowed or removed. Partial answer:Interface_Static’s own backing store (a process-wideNCollection_DataMap,MoniTool_TypedValue::Stats()) is fixed kernel-side (Scripts/patches/0033-*, override-link validated, TSan-confirmed to close exactly that race and nothing more), butigesMutex()stays, unchanged. Two independent reasons, both measured rather than assumed: (1) even withInterface_Staticitself perfectly locked, two concurrent operations setting different values for the same named parameter still cross-talk 100% of the time (it is a shared implicit parameter-passing channel, not merely an unprotected container), and (2) the widerXSControl_Controller/IFSelect_WorkSessionmachinery everySTEPControl_Writer/Readerconstruction goes through has its own, structural, always-live races (a single process-wideSTEPControl_Controllersingleton shares oneSTEPControl_ActorWriteactor across every concurrentTransfer()call, andIFSelect_WorkSession’s constructor races on a global namederrhand), independent ofInterface_Staticand not reachable by a patch scoped to it. SeeScripts/repro/1157-interface-static-thread-safety/andScripts/repro/1157-interface-static-thread-safety/for the full measurement.
Distinct from issue #280 (constructing a STEPCAFControl_Reader poisons subsequent STEP writes), that’s a different, already-fixed mechanism confirmed not Interface_Static-related, resolved via an upstream kernel patch in v1.10.1.
Naming-scope validation and font enumeration thread safety (issues #361, #363)
Two more process-global bridge singletons, found scoping #342. Both are safe to call concurrently, bridge-only fixes, no OCCT kernel change needed since the shared state lives in bridge-owned globals, not inside OCCT’s own classes, but they took different fixes, worth contrasting:
Document.namingScopeValid/namingScopeIsValid/namingScopeValidChildren/namingScopeUnvalid/namingScopeClear/namingScopeValidCountoriginally went through one process-wideTNaming_Scopeinstance shared across everyDocument. v1.15.13 (#361) added a mutex (docNamingScopeMutex()) around every access, which fixed the underlying race,TNaming_Scope’s ownNCollection_Map<TDF_Label> myValidhas no internal synchronization, but left a design bug in place: everyDocumentstill shared the same map, so one document’s valid-label set could leak into another’s regardless of locking. Upstream reviewer feedback on #341’s analogousAutoNamingScopefix (OCCT#1388, “a mutex is not the right tool here… usage remains unprotected”) prompted a second look: v1.15.14 (#363) moves naming scope onto aTNaming_Scopefield onOCCTDocumentitself: no lock needed at all, since two threads working on two differentDocumentinstances no longer touch anything shared.docNamingScopeMutex()was removed. The general lesson: a mutex is the right tool only when the state is genuinely meant to be one shared resource; when it was wrongly made global/shared in the first place, the fix is relocating ownership to whatever object actually owns the data, not locking access to the wrong owner.FontManager(fontCount,fontName,fontPath,fontHasAspect,initDatabase) shares a process-global font-list cache with an unsynchronized check-then-act lazy-init, plusinitDatabase()could reassign the cache at any time, racing an in-progress read. Fixed viafontListMutex()inOCCTBridge_Visualization.mm, held for every access (population and read).
Shape.fuseAll(_:) internal parallelism (issue #367)
Shape.fuseAll(_:) is safe to call concurrently as of v1.15.16. It previously set builder.SetRunParallel(true) on its BRepAlgoAPI_BuilderAlgo, internal OCCT parallelism for a single call, not multiple independent calls. Under concurrent load this was actively unsafe: two threads’ top-level Build() calls, each requesting internal parallelism, submit work to the same process-wide OSD_ThreadPool::DefaultPool(), and worker threads from one caller’s dispatch can end up processing another caller’s data. Confirmed via TSan stress (Scripts/repro/342-boolean-ops/, fuse_multi_parallel scenario): 100% of concurrent runs produced wrong results (27 faces instead of the correct 13), plus 237 race reports across foundational topology code (TopoDS_Builder::Add, TopExp_Explorer, BRep_Tool::Range, BOPTools_AlgoTools::MakeSplitEdge), not a rare interleaving, a reliably reproducible one.
Fixed by dropping SetRunParallel(true) entirely, Shape.fuseAll(_:) now runs on OCCT’s safe serial default, same as Shape.union(with:)/.subtracting(_:)/.intersecting(_:) (which never set it and were already confirmed clean under the same stress: 2000 concurrent mixed operations, zero errors, zero wrong results, zero races). This is the only bridge call site that ever set SetRunParallel(true): grepped exhaustively across Sources/OCCTBridge/src/*.mm.
This is a distinct, more severe finding than a missing lock on bridge-owned state (#359/#361/#363): it points at OSD_ThreadPool/BOPTools_Parallel, OCCT’s own shared-pool parallel-dispatch infrastructure, potentially not being safe for concurrent independent top-level callers at all, not just this one call site. Root-causing that properly is tracked as a dedicated follow-up investigation in #369, out of scope for this release; removing the trigger was the correct immediate fix regardless of what the eventual root cause turns out to be.
#369 status: OSD_ThreadPool itself is exonerated, a synthetic stress test using OSD_ThreadPool::Launcher directly (no BOPAlgo, no OCCT geometry) ran 3000 concurrent operations clean, and reading OSD_ThreadPool.cxx’s Lock/Free/WakeUp/WaitIdle protocol found no flaw. The bug is narrowed to BOPTools_Parallel/BOPAlgo_PaveFiller’s specific use of the pool, not yet root-caused. Full investigation trail, ruled-out hypotheses, and concrete next steps: Scripts/repro/342-boolean-ops/README.md.
Private TDocStd_Application per document, not a shared singleton (issue #371)
v1.15.17 stops routing every OCCTDocument through the shared XCAFApp_Application::GetApplication() singleton described in the #341/#344 section above. Per upstream maintainer feedback on OCCT#1396 (our #353 repro issue), GetApplication() “exists solely for compatibility reasons”; OCCT’s own guidance since 7.1 is a private TDocStd_Application per caller, OCCTDocument’s constructor now does app = new TDocStd_Application() instead. Ground-truth C++ testing confirmed this behaves identically to the singleton for our usage (create, attach XCAF tools, add shape, set color, retarget storage format, save, reload with a separate private instance), and header inspection confirmed the state #344/#349/#353 fixed (CDF_Directory::myDocuments, CDF_Application::myReaders/myWriters, CDM_Application::myMetaDataLookUpTable) is per-instance, not static, a private app per document makes that state exclusive to one document by construction, so our own bridge can no longer trip over those specific mechanisms.
This does not eliminate the need for ocafStoreMutex(). A dedicated confirmation harness (Scripts/repro/371-getapplication-singleton-elimination/occt_371_private_app.cpp), private app per thread/round, zero shared state, no serialization, found two previously-uncharacterized races when run unguarded against the real TSan-instrumented kernel: Resource_Manager:: Resource_Manager() writes an unsynchronized file-scope global (Debug) on every construction, and the current-data handle then reached through Storage_Schema’s private ICurrentData() static was a process-wide mutable Handle every constructor nullified and every (de)serialization call read, also unsynchronized (the static no longer exists, see the #374 section below). Neither had ever been caught by this project’s prior TSan gates, because every prior investigation (and all of production, until this change) shared one application instance, Resources()’s own per-instance lazy-init mutex (from the #344 fix) accidentally serialized Resource_Manager/Storage_Schema usage down to “runs once, ever, for the whole process.” Moving to a private instance per caller is what first makes them concurrent. Filed upstream as OCCT#1398; fixed in the kernel in v1.15.18, see the #374 section below.
ocafStoreMutex()’s coverage was expanded, not removed: it now also wraps the six OCCTDocumentDefineFormatBin/BinL/Xml/XmlL/BinXCAF/XmlXCAF functions and OCCTDocumentCreateWithFormat (previously outside the lock, safe only because every document shared one app instance, so Resources()’s per-instance guard covered them for free). Confirmed by adding an equivalent mutex to a copy of the confirmation harness: 8×50 threads/rounds, zero TSan warnings, matching the real bridge’s coverage. Plain shape-format I/O (STEP/IGES/BREP/OBJ/glTF/PLY) is unaffected, none of those paths touch Resources()/Storage_Schema.
The upstream kernel PRs for #344/#349/#353 (OCCT#1390, #1394, #1397) remain open and are not withdrawn by this change, they fix real bugs in the singleton pattern OCCT’s own header still documents as “the only valid method” to get an XCAFApp_Application, which every other OCCT consumer still following that guidance is exposed to. Moving our own bridge off the singleton sidesteps our exposure to those specific mechanisms; it doesn’t make the bugs stop existing for anyone still using the pattern.
Resource_Manager::Debug / Storage_Schema’s current-data races, fixed (issue #374)
The two races #371’s confirmation harness found (previous section) are fixed in v1.15.18, filed upstream as OCCT#1398. Resource_Manager:: Debug (a file-scope static bool written on every construction) becomes std::atomic<bool>, a plain process-wide flag, not per-instance intent, so atomic is sufficient (unlike #341’s theAutoNaming, which needed a deeper per-instance redesign). The shared current-data handle (reached through the ICurrentData()/ISetCurrentData() statics, a function-local static Handle every constructor’s Clear() nulled and Write()/BindType()/TypeBinding()/AddPersistent()/ PersistentToAdd()/HasTypeBinding() read or wrote) is removed rather than locked: patch 0016 deletes both statics and replaces them with a mutable occ::handle<Storage_Data> myCurrentData member on Storage_Schema itself, so each schema instance owns the data it is writing and there is no shared state left to guard. Both statics were private with no callers outside the class, so nothing outside the kernel could observe the change.
This supersedes the first version of the fix, an ICurrentDataMutex() recursive mutex around every touch point, which is what this section described until #1400. That version was revised on upstream review, and the per-instance field is what Scripts/patches/0016 actually carries and what the pinned headers declare: ICurrentData is not a member of Storage_Schema in this build, and a census that looks for it will correctly report it as undeclared. No public API changes; only the pinned OCCT.xcframework kernel binary changed (Scripts/patches/0016), OCCTBridge.xcframework was not rebuilt. Confirmed via a dedicated TSan reproducer (Scripts/repro/374-resource-manager-storage-schema-race/occt_374_stress.cpp, the “unguarded” variant of #371’s own confirmation harness): 13 races + SIGABRT before the fix, 0/4 clean runs after (8×30, 8×50, 10×60, 8×40).
Swift @unchecked Sendable audit (issue #1162)
Issue #1162 flagged 27+ Swift classes marked @unchecked Sendable without thread-safety verification, citing #1153/#1154/#1155/#1156/#1158/#1159 (all now closed) as evidence. Re-verifying those citations against what’s actually shipped, rather than trusting them, found several stale or outright wrong: #1153/#1154’s kernel fixes are override-link-validated but not in the pinned kernel (so their races are still live), #1155’s survey answered a different question (independent instances per thread, not concurrent calls on one shared instance), and #1156/#1158/#1159 are duplicates with no independent evidence of their own. Full audit table, per-class mechanism, and disposition: Scripts/repro/1162-sendable-audit/.
The project-wide convention, stated explicitly rather than left implicit: @unchecked Sendable on a bridge-handle wrapper means the handle is safe to move across a concurrency-domain boundary, not that concurrent method calls on the same instance from multiple threads are safe. That distinction was first written down for ThruSectionsBuilder (PR #912, predating #1162), and #1162 generalized it: every class in this package keeps @unchecked Sendable unless its unsafe surface is masked as safe (every method reads like a pure query and none is) rather than merely undocumented, in which case the conformance is genuinely misleading and was removed.
Two classes met that bar: EdgeCurve and WireCurve are no longer Sendable. Their bridge structs each hold a persistent BRepAdaptor_Curve/BRepAdaptor_CompCurve, built once at init and reused by every subsequent call, so every accessor (point, tangent, length, …) mutates the adaptor’s BSpline evaluation cache (item 1 above) with zero synchronization, despite looking like a const query. Every other flagged class’s unsafe surface is an ordinary, API-visible mutator (a setter, a re-callable perform()/build()) and kept its conformance with a corrected or strengthened doc comment instead, matching every other builder-style wrapper in this package.
ThreadSanitizer gate for concurrency-touching changes
Every thread-safety kernel bug this project has found and fixed (#298, #341, #344, #349, #353, #374), a chain where #371’s move to a private TDocStd_Application per document first surfaced #374’s pair of races, was pinned down by the same protocol: a minimal-module ThreadSanitizer build of the pinned OCCT with all carried patches applied, plus a small standalone C++ stress harness for the suspect usage pattern. Scripts/tsan-stress.sh formalizes that protocol as a routine gate, because upstream OCCT runs no sanitizers in its CI at all: races we do not catch here are caught by nobody.
When running it is required
Run Scripts/tsan-stress.sh run (plus swift) before merging any change that:
- adds or widens a concurrent path through the bridge (a new operation callable in parallel, a new async/worker entry point);
- wraps a new OCCT subsystem that callers are expected to use from multiple threads;
- removes or relaxes a serialization mutex (
OCCTSerial,meshCafMutex,ocafStoreMutex, or any successor); or - adds or updates a carried kernel patch that touches shared state.
If the change introduces a genuinely new concurrent usage pattern, also add a gate scenario: either a new mode in an existing harness under Scripts/repro/ or a new standalone harness, and register it in the SCENARIOS matrix at the top of Scripts/tsan-stress.sh. The existing harnesses (341-meshcaf, 344-cdf-directory, 349-ocaf-driver-reentrancy, 353-cdm-metadata-lookup-table, 363-own-autonaming, 371-getapplication-singleton-elimination, 374-resource-manager-storage-schema-race) are the templates.
Writing the harness is not registering it. 363-own-autonaming existed from the day patch 0011’s redesign landed and was absent from the matrix until the v2.0.0 release check, so the one scenario that tests the property the earlier mutex fix could not guarantee ran in no gate at all. A harness under Scripts/repro/ that is not in SCENARIOS is a file, not a gate.
Commands
Scripts/tsan-stress.sh build # one-time: TSan-instrumented OCCT into Libraries/occt-install-tsan
Scripts/tsan-stress.sh run # compile + run every gate scenario; fails on unsuppressed races
Scripts/tsan-stress.sh swift # swift test --sanitize=thread on the concurrency-focused suites
Scripts/tsan-stress.sh all # build if the instrumented kernel does not match, then run + swift
build wipes occt-build-tsan and occt-install-tsan before configuring, and refuses to run at all unless Libraries/occt-src is at the tag build-occt.sh names. all decides whether to rebuild by comparing a stamp (occt-install-tsan/.tsan-stamp: the OCCT tag plus a digest of every carried patch) against the current tree, not by asking whether an install directory exists.
All three of those are scar tissue from one release check. all used to accept any existing install as current, and the one on the machine was from 3 August, predating four carried patches; build had no tag check, unlike build-occt.sh; and because nothing was wiped, an incremental build over that tree finished in 1m26s and installed 48 libraries wearing that day’s timestamps. A clean rebuild of the same thing takes about 15 minutes. Nothing in the fast result said which libraries had actually been recompiled, and a race that fails to reproduce against the wrong kernel looks exactly like a race that is fixed.
Coverage model
runis the kernel gate: the harnesses link the instrumented OCCT directly, so races wholly inside kernel code are visible. This is the mode that foundSTATIC_SOLIDINDEX(#298),theAutoNaming(#341), the CDF singleton family (#344), the storage-driver scratch state (#349), andResource_Manager/Storage_Schema’s construction-time races (#374).swiftinstruments the Swift and OCCTBridge sources only; the prebuiltOCCT.xcframeworkis not instrumented, so kernel-internal races are invisible there. It exists to catch wrapper-level races (bridge caches, Swift concurrency misuse), not kernel ones.
Suppressions
Scripts/tsan.supp may contain only (a) confirmed-benign races reviewed and documented, or (b) already-filed open kernel findings, each with an issue link and a removal condition (current example: the TopoDS_TShape::myState flag-mutation race, #1154, suppressed until a rebuilt xcframework carries patch 0030 so the gate stays green for new code; the CDM_Application metadata-map suppression this sentence used to name was removed in v1.15.11 once patch 0015 landed). An unsuppressed race is a gate failure: fix it or file it first. When a suppressed finding’s fix lands, remove the suppression; the gate then verifies the fix.
Performance
The mutex overhead is ~1µs per lock/unlock. Typical OCCT operations take 0.1ms-10s. The serialization cost is negligible for all practical workflows.
What FreeCAD and CadQuery Do
- FreeCAD: Runs all OCCT operations on the main thread. Recomputes are sequential.
- CadQuery: Relies on Python’s GIL for implicit serialization. Multi-processing (separate processes) works but multi-threading doesn’t.
OCCTSwift follows the same model with an explicit opt-in lock rather than implicit serialization.
RC5 Thread Safety Improvements
OCCT 8.0.0-rc5 improved thread safety in several areas:
BRepCheck_*result classes now have mutex protection- Foundation globals made thread-safe via
std::atomic - TKBool globals converted to
thread_local
These reduce the risk of data races in validation and boolean operations but do not fix the fundamental BSpline adaptor cache issue.